AI Infra 训练营
总览
  • 总览
  • 完整安装
  • 核心 K8s
  • Cilium 网络
  • Longhorn 存储
  • 监控日志
  • CI / GitOps
  • 安全准入
  • CI/CD 实战(MySQL+Go+Vue)
  • HPA/Ingress/Hubble 实战
  • 面试速查 + 真实踩坑
  • Day 0 · 新手接管 Runbook
  • Day 1 · 集群起步 + CNI
  • Day 2 · 控制面 + etcd
  • Day 3 · CRD + Operator + Webhook
  • Day 4 · 存储深度
  • Day 5 · 卷扩容 + 安全
  • Day 6 · 调度 + 可观测
  • Day 7 · Harbor + ArgoCD + Mesh
  • Day 8 · AI Infra
  • Day 9 · Triton + GPU
  • Day 10 · MIG + HPA + 量化
  • Day 11 · AI Agent 端到端
  • Day 12 · 灾备
  • Day 13 · Operator + 联邦 + Mesh + RAG
  • Day 14 · CKA / CKS + 总结
  • LLM 训练手册
  • RAG + Agent 手册
  • 推理优化手册
  • 上下文工程手册
  • Agent 开发手册
  • 面试深度复盘
  • 训练 v2 深度手册
  • 心智模型
  • 看懂命令输出
  • 容器网络底层
  • K8s 网络深入
  • DNS 全套
  • 故障排查方法论
  • 心智模型
  • 容器挂载完整指南
  • K8s Volumes 大全
  • PV/PVC/CSI 深入
  • NFS 深入
  • 分布式存储概览
  • 故障排查 runbook
命令手册
HiHuo 主站
GitHub
总览
  • 总览
  • 完整安装
  • 核心 K8s
  • Cilium 网络
  • Longhorn 存储
  • 监控日志
  • CI / GitOps
  • 安全准入
  • CI/CD 实战(MySQL+Go+Vue)
  • HPA/Ingress/Hubble 实战
  • 面试速查 + 真实踩坑
  • Day 0 · 新手接管 Runbook
  • Day 1 · 集群起步 + CNI
  • Day 2 · 控制面 + etcd
  • Day 3 · CRD + Operator + Webhook
  • Day 4 · 存储深度
  • Day 5 · 卷扩容 + 安全
  • Day 6 · 调度 + 可观测
  • Day 7 · Harbor + ArgoCD + Mesh
  • Day 8 · AI Infra
  • Day 9 · Triton + GPU
  • Day 10 · MIG + HPA + 量化
  • Day 11 · AI Agent 端到端
  • Day 12 · 灾备
  • Day 13 · Operator + 联邦 + Mesh + RAG
  • Day 14 · CKA / CKS + 总结
  • LLM 训练手册
  • RAG + Agent 手册
  • 推理优化手册
  • 上下文工程手册
  • Agent 开发手册
  • 面试深度复盘
  • 训练 v2 深度手册
  • 心智模型
  • 看懂命令输出
  • 容器网络底层
  • K8s 网络深入
  • DNS 全套
  • 故障排查方法论
  • 心智模型
  • 容器挂载完整指南
  • K8s Volumes 大全
  • PV/PVC/CSI 深入
  • NFS 深入
  • 分布式存储概览
  • 故障排查 runbook
命令手册
HiHuo 主站
GitHub
  • Day 0 · 环境与硬件

    • Day 0 新手现场接管 Runbook:先看懂,再动手
    • Day 0:5 节点裸 Ubuntu → K8s 装机基线
  • Week 1:K8s 内核 + 周边基础设施

    • Day 1:3 CP HA 集群 + CNI 选型 + DNS 调优
    • Day 2:控制面 deep dive + etcd 内核 + chaos drill
    • Day 3:CRD + Operator (kubebuilder 从 0 写)
    • Day 4:Longhorn 存储 + Cilium 二探(Hubble / NetworkPolicy / L7)
    • Day 5:PVC 在线扩容 + K8s 安全基线(RBAC / PSA / Secret 加密 / Kyverno)
    • Day 6:调度策略 + Prometheus / Loki 观测栈
    • Day 7:Harbor 私有镜像 + ArgoCD GitOps + Cilium WireGuard
  • Week 2:制品 + GitOps + AI Infra + 综合

    • Day 8:k3s 单节点 + NVIDIA Device Plugin + vLLM 跑 Qwen2.5-3B
    • Day 8(attempt 1):跨 WAN GPU 加入主集群(失败复盘)
    • Day 8:AlertManager 真接入 + PrometheusRule 实战
    • Day 8:集群内 CI 闭环 — Gitea + Jenkins + Kaniko
    • Day 9:Triton 多框架推理 + DCGM 跨集群可观测 + vLLM 实测
    • Day 10:MIG 硬切片 + AWQ 量化 + HPA Custom Metrics
    • Day 11:AI 业务端到端 —— chainlit + GitOps + 跨 WAN vLLM
    • Day 12:灾难恢复 + 生产事故注入
    • Day 13:LLM Operator + 多集群联邦 + Ambient Mesh + RAG
    • Day 14:CKA / CKS 真题演练 + 14 天技术栈横向汇总

Day 3:CRD + Operator (kubebuilder 从 0 写)

不是 hello-world 级别的玩具,目标是生产形态的 Operator —— 自定义资源 SimpleApp 一键托管 Deployment + Service + Ingress + Status 上报。从装 Go / kubebuilder 开始,一路走到 finalizer 模拟外部清理。

整篇按 A → D 4 个阶段走,每阶段先给该做什么、命令和代码,再讲遇到的真坑:

  • kubebuilder 4.5 要 Go 1.23+,装机时 Go 1.22 直接 fail
  • make 没装,scaffold post-task 报 executable not found
  • reconcile 写 Update 高并发下乐观锁 conflict,要切 Patch(MergeFrom)
  • finalizer 卡死 Terminating 的诊断和强删
  • Owns vs Watches 的本质:在子资源 informer 上挂 EnqueueHandler 反查 root

原理速通:Operator 到底是什么

K8s 整个系统的核心范式是「声明式 + 控制循环」:用户写 desired state → etcd 持久化 → controller 比对 actual state → 写资源趋近 desired。deployment-controller、kube-scheduler、kube-proxy 都是这个套路。

Operator = Controller + CRD(自定义资源)。你定义自己的 resource(SimpleApp),写自己的 controller 让它「成真」。

为什么是 CRD,不是 ConfigMap

任何「长期托管 + 多副本协同」的东西都该是 CRD,而不是把配置塞进 ConfigMap:

维度ConfigMapCRD
Schema 校验无,字符串里写错运行时才炸OpenAPI v3 schema,apiserver 入口就拒
kubectl get必须 -o yaml 再 grepkubectl get simpleapp,可定义 PrintColumns
RBAC 粒度全集群 ConfigMap 一锅烩单独 verb(get/watch/create on simpleapps)
Status subresource没有,spec / status 一次写独立 /status endpoint,避免和用户写 race
Webhook没有Validating / Mutating Webhook 直接挂

cert-manager 的 Certificate、ArgoCD 的 Application、Istio 的 VirtualService、prometheus-operator 的 ServiceMonitor 全是 CRD,不是某个 ConfigMap 约定。

kubebuilder 提供了什么

kubebuilder 是 scaffold 工具 + 一坨 Makefile。它给你生成:

  • api/v1/xxx_types.go:Spec / Status 的定义(唯一手写处)
  • api/v1/zz_generated.deepcopy.go:controller-gen 自动生成的 DeepCopy
  • config/crd/bases/xxx.yaml:CRD manifest(也是自动生成)
  • config/rbac/role.yaml:RBAC,从代码里 //+kubebuilder:rbac:... 注释扫描生成
  • internal/controller/xxx_controller.go:你写 Reconcile 的地方
  • cmd/main.go:manager 启动入口 + leader election + metrics + healthz

心智模型:types.go 是 source of truth,改完它一键生成 deepcopy + CRD yaml + RBAC,你只关心业务逻辑。


SimpleApp 合约设计

不要直接动手 code,设计 5 分钟省后面 5 小时。

Spec:用户填什么

apiVersion: apps.bootcamp.local/v1
kind: SimpleApp
metadata:
  name: my-blog
spec:
  image: nginx:1.27         # 必填
  replicas: 3               # 默认 1
  port: 80                  # 默认 80
  host: blog.bootcamp.local # 可选,设了才创 Ingress

3 个 litmus test 校验 Spec 设计是否够:

  1. 玩具 nginx:只填 image → 1 副本 ClusterIP Service
  2. 3 副本 web:填 image + replicas:3 + port:8080 → 不创 Ingress
  3. 对外服务:填全部字段 → Deployment + Service + Ingress 都创

覆盖不了这 3 个就重设。

Status:operator 回写什么

status:
  phase: Available     # Pending / Progressing / Available / Failed
  readyReplicas: 3
  url: http://blog.bootcamp.local
  lastUpdated: "2026-05-26T03:14:00Z"
  message: "All 3/3 replicas ready"

3 个核心字段各自的职责:

  • phase:给 kubectl get 一眼看 Available / Failed
  • readyReplicas:给监控 / 告警判断扩缩容是否生效
  • message:debug 用,比如 "ImagePullBackOff: nginx:not-exist"

这一篇明确不做:HPA 集成、init / sidecar 容器、mTLS / NetworkPolicy、Validating Webhook、多 version 转换。划清边界后代码量从 3000 行降到 500 行。


A. 装 Go + kubebuilder + scaffold

A.1 Go 1.23.4(官方 tarball + 软链)

不用 apt 的 golang-go(通常滞后 1-2 个 minor),直接抓官方 tarball,解到 /opt/go-VERSION + 软链 /opt/go,升级时只动软链就能秒回滚:

GO_VER=1.23.4
cd /tmp
wget https://mirrors.aliyun.com/golang/go${GO_VER}.linux-amd64.tar.gz
mkdir -p /opt/go-${GO_VER}
tar -xzf go${GO_VER}.linux-amd64.tar.gz -C /opt/go-${GO_VER} --strip-components=1
ln -sfn /opt/go-${GO_VER} /opt/go

cat > /etc/profile.d/go.sh <<'EOF'
export GOROOT=/opt/go
export GOPATH=/root/goworkspace
export PATH=$PATH:$GOROOT/bin:$GOPATH/bin
export GOPROXY=https://goproxy.cn,direct
EOF
source /etc/profile.d/go.sh
go version  # go version go1.23.4 linux/amd64

为什么 GOPROXY=goproxy.cn:大陆境内不走代理 90% 的 go mod download 会卡在 google.golang.org/grpc,goproxy.cn 是国内镜像。

A.2 kubebuilder 4.5.2

curl -L -o /usr/local/bin/kubebuilder \
  https://github.com/kubernetes-sigs/kubebuilder/releases/download/v4.5.2/kubebuilder_linux_amd64
chmod +x /usr/local/bin/kubebuilder
kubebuilder version

kubebuilder 4.x 跟 3.x 的关键差异:scaffold 出来的 controller 代码放 internal/controller/(3.x 是 controllers/),强制不可外部 import,避免有人把 controller 当 lib 引用造成奇怪耦合。

真坑:kubebuilder 4.5 要 Go 1.23+,装机用 1.22 直接 fail

跟 Day 0 / 1 用的 Go 1.22.10 对齐,跑 kubebuilder init 报:

fatal: failed to initialize project: ... go version 'go1.22.10' is incompatible because
  'plugin requires go1.23 <= version < go2.0alpha1'.

根因:kubebuilder release notes 不会显眼地标 Go 最低版本。1.22 是 2024.2 发布看起来很新,但 kubebuilder v4.5(2025-03 发布)依赖的 controller-runtime 已经用了 1.23 的新 syntax。

修复:留着 /opt/go-1.22.10,软链改指 /opt/go-1.23.4,ln -sfn /opt/go-1.23.4 /opt/go 一条命令秒回滚。

教训:--skip-go-version-check 是逃避标志,可以绕过 pre-scaffold 检查,但 controller-runtime 真的用了 1.23 新 syntax,下一步 go build 还是会炸。直接升 Go 是正解,软链 fallback 模式必备。

真坑:make 没装

Day 0 装 build-essential 时只在部分 worker 装了,m1 上漏了。kubebuilder create api 的 post-task 调 make generate:

fatal: failed to create API: unable to run post-scaffold tasks ...
  exec: "make": executable file not found in $PATH

apt-get install -y make 后补跑 make generate 即可,scaffold 的文件已落盘不用重新做。Day 0 装机清单应把 build-essential make git curl wget 列成基础四件套硬性要求。

A.3 scaffold 项目骨架

mkdir -p /root/operators/simpleapp-operator
cd /root/operators/simpleapp-operator

kubebuilder init \
  --domain=bootcamp.local \
  --repo=bootcamp.local/simpleapp-operator \
  --project-name=simpleapp-operator

yes | kubebuilder create api \
  --group=apps --version=v1 --kind=SimpleApp \
  --resource --controller

scaffold 出来 6 类目录,手改的只有 3 个:

目录干啥的手改?
api/v1/simpleapp_types.goCRD type 定义(Spec/Status)⭐ 必改
internal/controller/simpleapp_controller.goreconcile 业务逻辑⭐ 必改
config/crd/bases/*.yamlCRD manifest,controller-gen 生成不动
config/rbac/role.yamlRole,从代码注释扫描生成不动
cmd/main.gomanager 入口 + leader election + metrics偶改(很少)
Makefile / PROJECT / hack/工具链 + 元数据不动

B. 设计 SimpleApp Types

B.1 手写 api/v1/simpleapp_types.go

替换 scaffold 默认的 Foo string 占位,写出生产形态:

// SimpleAppSpec 用户填的期望状态
type SimpleAppSpec struct {
    // +kubebuilder:validation:MinLength=3
    Image string `json:"image"`

    // +kubebuilder:validation:Minimum=0
    // +kubebuilder:default=1
    Replicas int32 `json:"replicas,omitempty"`

    // +kubebuilder:validation:Minimum=1
    // +kubebuilder:validation:Maximum=65535
    // +kubebuilder:default=80
    Port int32 `json:"port,omitempty"`

    // +optional
    Host string `json:"host,omitempty"`
}

type SimpleAppStatus struct {
    Phase         string       `json:"phase,omitempty"`
    ReadyReplicas int32        `json:"readyReplicas,omitempty"`
    URL           string       `json:"url,omitempty"`
    LastUpdated   *metav1.Time `json:"lastUpdated,omitempty"`
    Message       string       `json:"message,omitempty"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Image",type=string,JSONPath=`.spec.image`
// +kubebuilder:printcolumn:name="Replicas",type=integer,JSONPath=`.spec.replicas`
// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// +kubebuilder:printcolumn:name="URL",type=string,JSONPath=`.status.url`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
type SimpleApp struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`
    Spec   SimpleAppSpec   `json:"spec,omitempty"`
    Status SimpleAppStatus `json:"status,omitempty"`
}

每个 marker 注释的作用 + 不写的后果:

Marker作用漏写会怎样
validation:MinLength=3装到 CRD schema,apiserver 入口拒用户写 image: "" 进 etcd,reconcile 时崩
default=1用户不填 replicas 时 apiserver 自动填 1进 reconcile 才发现是 0,要写丑陋的 if r==0 {r=1}
subresource:statusstatus 走独立 /status endpoint,只能 PATCHreconcile 和 user 写 spec 互相覆盖,race 不可控
printcolumnkubectl get simpleapp 显示业务列只显示 NAME/AGE,debug 必须 -o yaml
object:root=truecontroller-gen 知道这是顶层 K8s 资源不生成 DeepCopyObject(),编译报错

注释位置必须紧贴 struct 声明,中间空一行就不会被识别。

B.2 一键生成 deepcopy + CRD yaml

make generate   # controller-gen object → zz_generated.deepcopy.go
make manifests  # controller-gen crd     → config/crd/bases/*.yaml

make manifests 产物的 openAPIV3Schema 节会出现:

spec:
  properties:
    image:    {type: string,  minLength: 3}
    replicas: {type: integer, minimum: 0,  default: 1}
    port:     {type: integer, minimum: 1,  maximum: 65535, default: 80}
    host:     {type: string}
  required: [image]
subresources:
  status: {}

3 个要点:

  • required: [image] 是从 json:"image"(无 omitempty)推出来的,Go tag 是声明语义源头
  • subresources.status: {} 让 status 走 /status endpoint,跟用户写 spec 不抢资源
  • 完全不靠 webhook 就做到了强 schema 校验,OpenAPI v3 本身就够

DeepCopy 是 K8s 大世界的核心契约:informer cache 给你的 obj 是共享只读引用,绝对不能改,要改先 DeepCopy。controller-gen 自动生成省心 1000 倍。

B.3 RBAC 三层结构

scaffold 出 config/rbac/ 包含 3 类 Role,责任拆得很清:

类别给谁用
leader_election_*多副本 controller 选主用的 Lease 对象
metrics_*Prometheus scrape /metrics,要过 TokenReview
simpleapp_{admin,editor,viewer}_role给集群里的 user 用的,不是 operator 自己用

最后一类挺香 —— cluster admin 装好 operator 后直接 RoleBinding 把 simpleapp:editor 授给业务用户,CRD 自动具备和原生资源一样的权限分层,不用从 0 设计权限模型。

业务 Role(role.yaml)一开始是空的,reconciler 代码里写 //+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete 注释,make manifests 时 controller-gen 扫到自动塞进去。


C. 写 Reconciler

C.1 Reconcile 主流程

核心 4 步:

  1. Get CR;NotFound 直接 return(级联删除走 OwnerReference)
  2. 顺次 reconcile Deployment / Service / Ingress(后者可选)
  3. 子资源都用 SetControllerReference 挂 OwnerReference
  4. 回写 Status(phase / readyReplicas / url / lastUpdated / message)
func (r *SimpleAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var app simplev1.SimpleApp
    if err := r.Get(ctx, req.NamespacedName, &app); err != nil {
        if apierrors.IsNotFound(err) {
            return ctrl.Result{}, nil  // 已删,OwnerReference 级联清理
        }
        return ctrl.Result{}, err
    }
    if err := r.reconcileDeployment(ctx, &app); err != nil { return ctrl.Result{}, err }
    if err := r.reconcileService(ctx, &app);    err != nil { return ctrl.Result{}, err }
    if err := r.reconcileIngress(ctx, &app);    err != nil { return ctrl.Result{}, err }
    return ctrl.Result{RequeueAfter: 30*time.Second}, r.updateStatus(ctx, &app)
}

每个子资源 reconcile 走 diff-then-update 模式:

desired := buildDesired(app)
controllerutil.SetControllerReference(app, desired, r.Scheme)

existing := &SubResource{}
err := r.Get(ctx, key, existing)
if apierrors.IsNotFound(err) {
    return r.Create(ctx, desired)
}
if !reflect.DeepEqual(existing.Spec, desired.Spec) {
    existing.Spec = desired.Spec
    return r.Update(ctx, existing)  // ← 见 C.4 真坑,这里要改 Patch
}

为什么必须 diff 后才 Update:每次无脑 Update 都会触发子资源的 informer event,进而 enqueue 主资源 reconcile,循环触发 → CPU 100% → 无限 reconcile。信息论上「无新信息就不传」。

C.2 SetControllerReference + Owns 的协同

SetControllerReference(app, desired, r.Scheme) 干两件事:写入子资源的 metadata.ownerReferences(含 controller: true、blockOwnerDeletion: true),并让 apiserver 内置 garbage-collector 在 SimpleApp 被删时自动级联清理 Deployment/Service/Ingress。

但要让子资源被外部改时反向触发 SimpleApp 的 reconcile,还要在 SetupWithManager 里 Owns:

func (r *SimpleAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&simplev1.SimpleApp{}).        // 主资源 informer
        Owns(&appsv1.Deployment{}).        // 子资源 informer + 反向 enqueue
        Owns(&corev1.Service{}).
        Owns(&networkingv1.Ingress{}).
        Named("simpleapp").
        Complete(r)
}

Owns vs Watches 的本质差异留到「面试常见题」展开。

C.3 本地起 controller + 3 场景验证

make run = go run ./cmd/main.go,在本地起 controller 通过 ~/.kube/config 连 apiserver。没打镜像、没部署到集群,改一行代码立刻看效果:

make install                                                # CRD → 集群
nohup make run > /var/log/simpleapp-operator/run.log 2>&1 &

启动日志正常会看到 4 个 EventSource(SimpleApp + Deployment + Service + Ingress)并 Starting workers。apply 3 个 sample CR 验证 litmus test:

CaseSpec预期
case1-minimalimage only1 副本 + Service,无 Ingress
case2-multiimage + replicas:3 + port:80803 副本 + Service@8080,无 Ingress
case3-ingress全字段 + host2 副本 + Service + Ingress

25 秒后:

NAME            IMAGE               REPLICAS   READY   PHASE       URL                           AGE
case1-minimal   nginx:1.27-alpine   1          1       Available                                 49s
case2-multi     nginx:1.27-alpine   3          3       Available                                 49s
case3-ingress   nginx:1.27-alpine   2          2       Available   http://case3.bootcamp.local   49s

Status.Phase 从 Pending → Progressing → Available 切换,readyReplicas 实时同步,Ingress 只有 case3 一个(case1/2 没 host,可选语义生效),curl ClusterIP 拿到 nginx 默认页。

kubectl patch simpleapp case1-minimal --type=merge -p '{"spec":{"replicas":4}}' 6 秒内 Deployment 副本到 4。kubectl delete simpleapp case3-ingress 后子资源全消失 —— 完全不用 operator 自己写清理逻辑,apiserver 内置 garbage-collector 反向找子资源挨个删。除非有外部资源(cloud LB / DNS / 第三方配额),才需要 finalizer(见 D)。

C.4 真坑:高频 patch 触发乐观锁 conflict

patch replicas 后 controller 日志开始刷:

ERROR Reconciler error
  error: "reconcile Deployment: Operation cannot be fulfilled on deployments.apps \"case1-minimal\":
         the object has been modified; please apply your changes to the latest version and try again"

排查:单次 patch 后看 Deployment 的 resourceVersion 在短时间内连续跳了 3 次(用户写 spec 一次,deployment-controller 写 status 一次,replicaset-controller 写一次)。

根因 —— K8s 乐观锁(CAS):每个对象有 metadata.resourceVersion(etcd mod_revision)。reconcile 是 Get(existing) → existing.Spec = desired.Spec → Update(existing),Get 和 Update 之间 deployment-controller 写了 status 让 RV 跳,我的 Update 还携带旧 RV,apiserver 拒。

这不是 bug —— controller-runtime 自动 requeue,最终一致。但日志吵会被 Sentry / Prometheus 误报警,生产形态要修。

3 种修法:

方案描述优缺点
controllerutil.CreateOrUpdate内置 Get → mutateFn → Update,retry 包了一层还是有 conflict 窗口,只是 retry 多次
r.Patch(existing, client.MergeFrom(orig))JSON Merge Patch,只发变化字段不依赖 resourceVersion 比较整个对象,推荐
Server-Side Apply client.Apply + FieldOwnercontroller 声明它管哪些字段最强,但学习曲线陡

选 MergeFrom Patch,改动最小:

// before: existing.Spec = desired.Spec; r.Update(ctx, &existing)
patch := client.MergeFrom(existing.DeepCopy())  // 先拍快照
existing.Spec = desired.Spec
return r.Patch(ctx, &existing, patch)

Status 同理用 r.Status().Patch(ctx, app, client.MergeFrom(app.DeepCopy()))。

修复后压测:3 处子资源 + 2 处 status 全改完,10 次连续 patch replicas 后 conflict 0 次、ERROR 0 次。

教训 —— K8s 写入策略三层:

  1. resourceVersion 是 K8s 的乐观锁(CAS),走 Update 接口必查
  2. 三种写入:Update(最严,race 多) → Patch(MergeFrom)(不查 RV,推荐) → Server-Side Apply(字段所有权,最强)
  3. 不是所有 conflict 都是 bug,controller-runtime 会自动 requeue,但生产应当尽量「一次成」,避免日志噪声 + 减少 apiserver 压力

C.5 RBAC:注释扫描生成,写代码同步写注释

每加一种子资源(reconciler 出现新 r.Get/Create/Update/Patch/Delete(&XXX{})),同时加 //+kubebuilder:rbac 注释,跑 make manifests 让 role.yaml 同步。否则部署到生产,ServiceAccount 没权限直接 forbidden。

reconciler 用到的 verb 全部入库:

- apiGroups: ["apps.bootcamp.local"]
  resources: [simpleapps, simpleapps/status, simpleapps/finalizers]
- apiGroups: [apps]
  resources: [deployments]
- apiGroups: [""]
  resources: [services, events]
- apiGroups: [networking.k8s.io]
  resources: [ingresses]

PR review 时这是个 checklist 项:reconciler 改了,role.yaml 必须同一次提交更新,否则下次部署 → forbidden → 又要紧急 hotfix。


D. Finalizer + 外部资源清理

子资源已经被 OwnerReference 级联清理,不需要 finalizer。但为了演示生产形态 + 学习面试要点,加一个模拟外部清理:删 SimpleApp 时写一条审计 ConfigMap(含 deletedAt、image、finalReplicas、namespace),中间 sleep(2s) 模拟调外部 API。

D.1 Finalizer 机制

K8s 默认删除流程:kubectl delete X → apiserver 在 etcd 标 metadata.deletionTimestamp → garbage-collector 扫到没 finalizer 的对象物理 DELETE。

物理删除前必须先做事的真实场景:删 cloud LB(AWS ELB / 阿里云 SLB,GC 管不到 K8s 外的资源)、删 external-dns 给的 A record、写审计 / 合规日志、通知 Service Mesh drain 流量、释放第三方系统配额(Datadog host quota 等)。

Finalizer 机制:往 metadata.finalizers 数组塞一个字符串(域名前缀 + 名字),数组非空时 apiserver 拒绝物理删除,只标 deletionTimestamp。controller 看到 deletionTimestamp 跑清理逻辑,主动从数组移除自己的 finalizer,apiserver 重检查发现空了才真删。

D.2 Reconcile 三段式

const finalizerName = "apps.bootcamp.local/simpleapp-finalizer"

func (r *SimpleAppReconciler) Reconcile(ctx, req) (ctrl.Result, error) {
    var app simplev1.SimpleApp
    if err := r.Get(ctx, req.NamespacedName, &app); err != nil {
        if apierrors.IsNotFound(err) { return ctrl.Result{}, nil }
        return ctrl.Result{}, err
    }

    // 段 1:用户已发起删除
    if !app.DeletionTimestamp.IsZero() {
        if controllerutil.ContainsFinalizer(&app, finalizerName) {
            if err := r.cleanupExternal(ctx, &app); err != nil {
                return ctrl.Result{RequeueAfter: 5*time.Second}, nil  // 失败 retry, finalizer 仍在
            }
            patch := client.MergeFrom(app.DeepCopy())
            controllerutil.RemoveFinalizer(&app, finalizerName)
            if err := r.Patch(ctx, &app, patch); err != nil { return ctrl.Result{}, err }
        }
        return ctrl.Result{}, nil  // 删除路径不做其他 reconcile
    }

    // 段 2:自愈加 finalizer
    if !controllerutil.ContainsFinalizer(&app, finalizerName) {
        patch := client.MergeFrom(app.DeepCopy())
        controllerutil.AddFinalizer(&app, finalizerName)
        if err := r.Patch(ctx, &app, patch); err != nil { return ctrl.Result{}, err }
        return ctrl.Result{Requeue: true}, nil  // 立刻再 reconcile 一次
    }

    // 段 3:正常 reconcile Deployment/Service/Ingress
    // ...
}

三段式的顺序不能换:

  • 段 1 必须最前:用户删了之后不应再创建子资源,否则 reconcile 跟 GC 死循环
  • 段 2 是自愈逻辑:用户 apply 新 CR 时,第一次 reconcile 加完 finalizer 后立刻 Requeue: true 退出,第二次 reconcile 才做业务。防止「先创建子资源 → 才加 finalizer」之间用户删 CR,子资源已建但 finalizer 没加导致清理跳过的 race
  • 段 3 只在前两段都不命中时跑

升级场景下段 2 是关键 —— 你升级 operator 时集群里已有 100 个 CR 都没 finalizer,reconcile 一过自动批量补齐。

加了 r.Create(ctx, &corev1.ConfigMap{}) 后别忘了同步加 RBAC 注释:

// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete

make manifests 后 role.yaml 多出 configmaps 这条,否则部署到集群 forbidden: cannot create configmaps。

D.3 四个 e2e 测试

1. finalizer 自愈。重启 controller 后存量 CR(case1/case2)自动加 finalizer:

kubectl get simpleapp case1-minimal -o jsonpath='{.metadata.finalizers}'
# ["apps.bootcamp.local/simpleapp-finalizer"]

2. 删除流程 + 审计落地:

kubectl delete simpleapp case1-minimal --wait=false
# 立刻看:deletionTimestamp 已写,finalizer 还在,CR 卡 Terminating
# 5s 后:CR NotFound,审计 ConfigMap 出现
kubectl get cm -l audit.bootcamp.local/source=simpleapp-operator
# audit-deleted-case1-minimal-1779768644 ... data: {deletedAt, finalReplicas, image, namespace}

3. Owns 反向触发。手动删 Deployment 模拟运维误操作:

kubectl delete deploy case2-multi && sleep 6
kubectl get deploy case2-multi
# case2-multi   2/2   2   2   7s    ← 已自动重建

反向触发链路:Deployment informer 收到 DELETE event → controller-runtime 的 OwnerHandler 查 metadata.ownerReferences 找到 root SimpleApp UID → enqueue 到 workqueue → Reconcile 跑,Get SimpleApp 发现 Deployment 不在 → Create。

4. rolling update 由 Deployment controller 接管:

kubectl patch simpleapp case2-multi --type=merge -p '{"spec":{"image":"nginx:1.27.1-alpine"}}'
# 8s 后查 Pod image:新旧共存中
# nginx:1.27-alpine nginx:1.27-alpine nginx:1.27.1-alpine

Operator 只用 Patch(MergeFrom) 改 spec.template.spec.containers[0].image,真正的 rolling update 由 Deployment controller 完成。常见反模式是自己 Pod-by-Pod 删除重建把 deployment-controller 的活重写一遍,绝对不要。

D.4 真坑:finalizer 卡死 Terminating 的诊断与强删

清理逻辑写错(外部 API 永久 hang、unrecoverable error),CR 会永远卡 Terminating —— deletionTimestamp 一直在,finalizer 移不掉,apiserver 永远不真删。

症状:kubectl get simpleapp my-app 显示 AGE 10m+ 还在,{.metadata.deletionTimestamp} 已写但 {.metadata.finalizers} 数组还有那条 finalizer。

排查顺序:

  1. controller 还跑着吗?kubectl logs -n simpleapp-system deploy/controller-manager 看 reconcile error
  2. 清理逻辑卡哪一步?日志加 trace 看 cleanupExternal 是 hang 还是抛错
  3. RBAC 缺哪个权限?forbidden: cannot create xxx 是高频根因

逃生强删(运维兜底,跟 Day 1 处理 Calico Installation 卡 Terminating 同款):

kubectl patch simpleapp my-app --type=merge -p '{"metadata":{"finalizers":[]}}'
# 立刻被 GC 真删

生产 finalizer 必须带 timeout + max-retry:清理逻辑包 context.WithTimeout(ctx, 30*time.Second),超过 N 次失败在 status 写明原因 + 触发告警,极端情况让用户决策是否强删。

kubectl delete --wait=true(默认行为)会等 CR 真消失才 return,finalizer 慢时 kubectl 卡几秒是预期。生产脚本调用 delete 时务必想清楚是否要 --wait=false。


面试常见题

Q1:reconcile 为什么容易写出无限循环?怎么防御?

根因:reconcile 里无脑 Update 子资源 → 子资源 informer 触发 event → enqueue 主资源 reconcile → 又 Update → 再触发 event,CPU 立刻 100%。

3 层防御:

  1. diff-then-update:reflect.DeepEqual(existing.Spec, desired.Spec) 不变就不写
  2. Patch(MergeFrom) 替代 Update:只发变化字段
  3. status 走 subresource endpoint:自动不触发 spec watch

幂等是 reconcile 的核心契约 —— 同样的输入跑 100 次和跑 1 次结果一致。

Q2:finalizer 是什么模式?卡 Terminating 怎么救?

metadata.finalizers 数组非空时 apiserver 拒绝物理删除,只标 deletionTimestamp。controller 跑完清理逻辑主动 RemoveFinalizer 才真删。典型场景:删 cloud LB / DNS、写审计、释放第三方配额。

卡 Terminating 排查:controller 还活着吗 → 清理逻辑卡哪一步 → 是不是 RBAC forbidden。兜底强删:kubectl patch X --type=merge -p '{"metadata":{"finalizers":[]}}'。生产 finalizer 必须带 timeout + max-retry。

Q3:Owns 跟 Watches 有什么区别?

Owns(&Deployment{}) 不是 controller 自己 watch,是 controller-runtime 帮你在 Deployment informer 上挂 EnqueueHandler,handler 用 OwnerReference 反查 root UID 后 enqueue 主资源。

OwnsWatches
反向解析OwnerReference 反查 root自定义 EventHandler
适用自己创建 + 想级联管的子资源关心但没自己创建的资源
跨 namespace不支持支持

手动 yaml 创建的子资源没 OwnerReference,Owns 不触发;跨 namespace 引用(SimpleApp 引用别 ns 的 ConfigMap)只能用 Watches。

Q4:CRD vs ConfigMap,5 个核心差异

维度ConfigMapCRD
Schema 校验无OpenAPI v3,apiserver 入口拒
kubectl get必须 -o yaml可定义 PrintColumns
RBAC 粒度全集群一锅烩按 resource 分
Status subresource没有独立 /status endpoint
Webhook没有Validating / Mutating 可挂

cert-manager 的 Certificate、ArgoCD 的 Application、Istio 的 VirtualService 全是 CRD 不是 ConfigMap。

Q5:reconcile 写 Update 总 conflict 怎么办?

K8s 是乐观锁(CAS):metadata.resourceVersion 即 etcd mod_revision,Update 提交时携带,apiserver 比较旧 RV 跟当前不一致就拒。高并发下 deployment-controller / replicaset-controller / 用户 patch 都让 RV 跳,你 Get 完到 Update 之间被改是常态。

3 种修法:

  1. controllerutil.CreateOrUpdate:内置 retry,还是基于 Update
  2. client.Patch(client.MergeFrom(orig)) —— JSON Merge Patch,只发变化字段,不依赖 RV 比对整个对象 ⭐ 推荐
  3. Server-Side Apply:controller 声明 field owner,互不干扰,最强但学习曲线陡

经验法则:Update 用于完整覆盖(写 status 全量),Patch(MergeFrom) 用于增量改 spec(绝大部分场景),SSA 用于多 controller 协同管同一对象。

Q6:Operator 心智模型一句话

Operator = Controller + CRD。CRD 是 etcd 里一坨带 schema 的 JSON;Controller 是「让 CR 成真的循环」:watch CR,比对 desired vs actual,写子资源逼近 desired。K8s 自带的 deployment-controller / replicaset-controller / statefulset-controller 都是同一个模式,operator 只是把这个模式开放给业务领域 —— 把领域知识(怎么部署一个 Kafka 集群 / 怎么 backup 一个 Postgres)codify 成自动化循环。


下一步

Day 3 结束,一个完整的 operator 项目落地:CRD(含 OpenAPI 校验 + 6 printcolumn + status subresource)、reconcile 三件套(Deployment / Service / Ingress + OwnerReference 级联)、Owns 子资源 watch(误删 7s 自愈)、finalizer + 外部清理 + 审计落地、MergeFrom Patch 解决高并发 conflict、kubebuilder RBAC annotation 自动生成 ClusterRole。

Day 4 进入 Operator 生产化:打镜像 + kustomize 部署、Validating Webhook(拒 image:latest、replicas > 100)、conversion webhook 演化 v1 → v2、Prometheus 指标(reconcile_total / reconcile_errors_total / reconcile_duration_seconds)+ Grafana 看板。

在 GitHub 上编辑此页
Prev
Day 2:控制面 deep dive + etcd 内核 + chaos drill
Next
Day 4:Longhorn 存储 + Cilium 二探(Hubble / NetworkPolicy / L7)