AI Infra 训练营
总览
  • 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 深度手册
HiHuo 主站
GitHub
总览
  • 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 深度手册
HiHuo 主站
GitHub
  • Day 0 · 环境与硬件

    • 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: Storage 主线 + Cilium 二探
    • Day 5: Volume Expansion + 安全主线
    • Day 6: 调度 + 观测主线 + Day 2 遗留修复
    • Day 7: Harbor + ArgoCD + Cilium Service Mesh
  • Week 2:制品 + GitOps + AI Infra + 综合

    • Day 8 主线 — AI Infra: GPU + k3s + vLLM + Qwen2.5
    • Day 8 主线 — AI Infra 尝试 1 (跨 WAN GPU 加入主集群)
    • Day 8 (alt) — AlertManager 真接入 + PrometheusRule 实战
    • Day 8: CI Infrastructure — Gitea + Jenkins + Kaniko
    • Day 9: Triton + GPU Metrics + 推理性能对比
    • Day 10: MIG + 量化 + HPA Custom Metrics
    • Day 11: AI Agent 业务端到端 — 把 Day 1-10 全部串起来
    • Day 12: 灾难恢复 + 生产事故注入
    • Day 13: LLM Operator + 联邦 + Mesh + RAG
    • Day 14: CKA/CKS 真题演练 + 14 天 Bootcamp 终极总结

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

目标: 不是 hello-world,真做一个生产形态的 Operator —— SimpleApp 一键管 Deployment + Service + Ingress + status 上报。 耗时: 3-5 小时 风险: 写错 reconcile 会无限循环 / finalizer 卡死 / RBAC 不够导致 watch 失败 — 都是面试常考的真坑


0. TL;DR

  1. scaffold (Day 3.A): m1 装 Go + kubebuilder,kubebuilder init / create api SimpleApp
  2. CRD 设计 (Day 3.B): Spec={image,replicas,port,host} + Status={phase,readyReplicas,url}
  3. Reconcile (Day 3.C): CR → 创建/更新 Deployment + Service + Ingress,Status 回写
  4. Finalizer + 子资源 watch (Day 3.D): SetupWithManager + Owns + 删 CR 前清理

1. 学习目标 + 闭环输出

能秒答:

  • "kubebuilder 给你 scaffold 出哪几个目录?"
  • "Reconcile 函数返回 ctrl.Result{Requeue: true} 和 RequeueAfter: 10*time.Second 区别?"
  • "Owns vs Watches 区别?"
  • "为什么 reconcile 容易无限循环?"

能动手做:

  • 从 0 起一个 Go module → 写 types.go → 装到集群 → kubectl apply SimpleApp → 自动有 Deployment/Service/Ingress

产出物:

  • 一个完整的 GitHub-ready operator 仓库
  • 可 apply 的 SimpleApp CR 示例
  • 简历可加: "用 kubebuilder 从零实现 SimpleApp Operator (CRD/reconcile/finalizer/webhook)"

2. 原理速通 — Operator 到底是什么

2.1 Controller Pattern (kube 的世界观)

K8s 整个系统的核心范式: 声明式 + 控制循环。

用户写 desired state → etcd 持久化 → controller 比对 actual state → 趋近 desired

deployment-controller 干这件事(管 ReplicaSet → Pod), kube-scheduler 干这件事(挑节点), kube-proxy 干这件事(配 iptables)。所有 controller 都是同一个套路:

for event := range informer.Events {
    desired := getDesired(event.Object)
    actual  := getActual(event.Object.UID)
    if !equal(desired, actual) {
        applyChanges(desired - actual)
    }
}

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

2.2 为什么要 CRD,不直接用 ConfigMap?

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

所以任何"长期托管 + 多副本协同"的东西都该是 CRD: cert-manager 的 Certificate, ArgoCD 的 Application, Istio 的 VirtualService, prometheus-operator 的 ServiceMonitor 都是 CRD。

2.3 kubebuilder 提供了什么

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

  • api/v1/xxx_types.go: 你定义 Spec/Status 的地方(唯一手写)
  • api/v1/zz_generated.deepcopy.go: controller-gen 自动生成 DeepCopy(改 types 后跑 make generate)
  • config/crd/bases/xxx.yaml: 自动生成的 CRD manifest(改 types 后跑 make manifests)
  • config/rbac/role.yaml: RBAC,从 controller 代码里 //+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,你只用关心业务逻辑。


3. 设计先行 — SimpleApp 的合约

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

3.1 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

Litmus test (Working Method #2):

  • (1) 部署一个 nginx 玩具: 只填 image,其他用默认 → 应得 1 副本 ClusterIP Service
  • (2) 部署一个有 3 副本的 web: 填 image + replicas: 3 + port: 8080 → 不创 Ingress
  • (3) 部署一个对外的服务: 填全部字段 → 创 Deployment + Service + Ingress

如果 Spec 设计不能覆盖这 3 个场景,就不是好设计。

3.2 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"

为什么有 phase + readyReplicas + message 三个字段? 因为:

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

3.3 Non-Goals (Working Method #3 — 先声明不做的)

  • ❌ 不做 HPA 集成(Day 6 再说)
  • ❌ 不支持 init container / sidecar(Spec 故意只允许单 image)
  • ❌ 不做 mTLS / network policy(Day 5 再说)
  • ❌ 不做 webhook 校验(Day 3.E 加,本 Day 不一定到)
  • ❌ 不支持多 version 转换(Day 3.F 选做)

划清边界后,代码量会从 3000 行降到 500 行。


10. 实时执行日志(6 维度)

Day 3.A — 装 Go + kubebuilder + scaffold(2026-05-26 上午)

A1. 装 Go 1.22.10 (官方 tarball + 软链)

What:

GO_VER=1.22.10
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 -s /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

Why:

  • 官方 tarball,不用 apt: apt 的 golang-go 通常 1-2 个版本滞后, 而且会跟系统 ca-certs / linker 耦合。tarball 解到 /opt/go-VERSION + 软链 /opt/go 是 12-factor 标准做法 — 升级时只动软链,旧版本保留可回滚。
  • GOPROXY=goproxy.cn: 大陆内不走代理 90% 的 go mod download 会卡 google.golang.org/grpc,goproxy.cn 是国内镜像。
  • PATH 通过 /etc/profile.d/: 不污染 /etc/profile,新装 user 自动继承。

Expected: go version go1.22.10 linux/amd64Actual: ✅ 完全一致 Outcome: Go 装好,可写代码 Lesson: 这是 [[feedback_go_install]] 里的标准模式,新机器复用零摩擦。

A2. 装 kubebuilder 4.5.2

What:

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

Why: kubebuilder 是 SIG 官方 scaffold 工具,二进制独立(纯 Go),不需要装到 GOPATH。

Expected: KubeBuilderVersion:"4.5.2"Actual: ✅ Outcome: scaffold 工具就位 Lesson: 4.x 比 3.x 主要差异 — internal/controller/ 替代旧的 controllers/(避免被外部 import 当 lib 用)。

A3. ⚠️ 真坑 #1: kubebuilder 4.5 要 Go 1.23+,我装的 1.22

What (失败现场):

$ kubebuilder init --domain=bootcamp.local --repo=bootcamp.local/simpleapp-operator
level=fatal msg="failed to initialize project: unable to run pre-scaffold tasks of \
  \"base.go.kubebuilder.io/v4\": go version 'go1.22.10' is incompatible because \
  'plugin requires go1.23 <= version < go2.0alpha1'. \
  You can skip this check using the --skip-go-version-check flag"

Why 会踩: kubebuilder release notes 不会显眼地标 Go 最低版本。我假设 1.22 够新(2024.2 发布),没去 check kubebuilder v4.5 release 时间(2025-03)和它依赖的 controller-runtime 版本。

Fix: 升级 Go 到 1.23.4

GO_VER=1.23.4
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
rm /opt/go && ln -s /opt/go-${GO_VER} /opt/go
source /etc/profile.d/go.sh
go version  # go1.23.4

Expected after fix: kubebuilder init 通过 Actual after fix: ✅ pre-scaffold task pass Outcome: scaffold 可以继续 Lesson (面试可讲):

  • 旧 Go 版本是新 kubebuilder 的常见 gotcha
  • 软链方案救命 —— 升级只换 /opt/go 符号链接,/opt/go-1.22.10 还在,要回滚 1 秒
  • --skip-go-version-check 是逃避标志,可以用但不建议(因为 controller-runtime 真的会用到 1.23 的新 syntax,后面 build 会再炸)

A4. ⚠️ 真坑 #2: make 没装

What (失败现场):

$ kubebuilder create api --group=apps --version=v1 --kind=SimpleApp --resource --controller
level=fatal msg="failed to create API: unable to run post-scaffold tasks of \
  \"base.go.kubebuilder.io/v4\": exec: \"make\": executable file not found in $PATH"

Why 会踩: Day 0 装 build-essential 时, m1 上实际没装(只在某些 worker 上装了,做法不统一)。kubebuilder post-scaffold 要跑 make generate,需要 make。

Fix:

apt-get install -y make
which make  # /usr/bin/make

Lesson:

  • scaffold 文件已经写到磁盘,只是 post-task 失败。再跑 make generate 就 ok。
  • 新机器装 toolchain 要装齐: build-essential + make + git + curl + wget 是基础四件套(Day 0 应改进的清单)。

A5. scaffold 完成

What:

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

Expected: 生成完整目录结构 Actual:

.
├── api/v1/
│   ├── groupversion_info.go     # SchemeBuilder, AddToScheme
│   └── simpleapp_types.go        # ⭐ 你手写 Spec/Status
├── cmd/main.go                   # manager 启动入口
├── config/
│   ├── crd/bases/                # ⭐ CRD yaml (controller-gen 生成)
│   ├── default/                  # kustomize 整体
│   ├── manager/                  # Deployment 模板
│   ├── prometheus/               # ServiceMonitor
│   ├── rbac/                     # ⭐ Role / RoleBinding (从代码注释生成)
│   └── samples/                  # 示例 CR
├── internal/controller/
│   ├── simpleapp_controller.go      # ⭐ 你写 Reconcile
│   ├── simpleapp_controller_test.go # 单测
│   └── suite_test.go                # envtest 套
├── Dockerfile                    # 打镜像
├── Makefile                      # ⭐ 所有命令的入口
├── PROJECT                       # kubebuilder 元数据 (别手改)
├── README.md
├── go.mod / go.sum
├── hack/boilerplate.go.txt       # license header
└── .github/workflows/            # lint + test 模板

Outcome: 一个完整的 operator 项目骨架,已经能 make build (虽然 Reconcile 是空的) Lesson (能秒答 "kubebuilder scaffold 出哪几个目录"):

  1. api/: 你的 CRD type 定义 → controller-gen 生成 deepcopy + yaml
  2. internal/controller/: 你的 reconcile 逻辑 (4.x 改名,3.x 叫 controllers/)
  3. cmd/: manager 入口 (一般不动)
  4. config/: 部署 manifest (kustomize 组织)
  5. hack/: 代码生成器配置 + boilerplate
  6. bin/: make 下的二进制(controller-gen / kustomize / envtest)

Day 3.B — 设计 SimpleApp Types (2026-05-26 上午)

B1. 重写 api/v1/simpleapp_types.go

What: 替换默认 scaffold 的 Foo string,写出生产形态 Spec/Status。

// 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"`
}

Why 每个 marker 注释都重要:

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

Expected: make generate 重新生成 deepcopy 成功 Actual:

/root/operators/simpleapp-operator/bin/controller-gen \
  object:headerFile="hack/boilerplate.go.txt" paths="./..."
# (无输出 = 成功)

Outcome: zz_generated.deepcopy.go 已有 SimpleApp/SimpleAppSpec/SimpleAppStatus 三个 DeepCopy / DeepCopyInto / DeepCopyObject 方法 Lesson:

  • DeepCopy 是 K8s 大世界的核心契约 — informer cache 给你的 obj 是共享的只读引用,你绝对不能改,要改先 DeepCopy。controller-gen 自动生成省心 1000 倍。
  • // +kubebuilder: 注释位置很重要: 必须紧贴 struct/type 声明,中间不能空行。

B2. make manifests 生成 CRD yaml

What:

make manifests
ls config/crd/bases/
cat config/crd/bases/apps.bootcamp.local_simpleapps.yaml

Why: types.go 的 // +kubebuilder:validation:* 注释最终要装到 CRD 的 OpenAPI v3 schema 里, controller-gen 帮你做这件事。这一步不做, apply CR 时就完全没校验。

Expected: 生成 apps.bootcamp.local_simpleapps.yaml,包含 6 个 printcolumn + validation Actual:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: simpleapps.apps.bootcamp.local
spec:
  group: apps.bootcamp.local
  names:
    kind: SimpleApp
    plural: simpleapps
    singular: simpleapp
  scope: Namespaced
  versions:
  - name: v1
    additionalPrinterColumns:
    - {jsonPath: .spec.image, name: Image, type: string}
    - {jsonPath: .spec.replicas, name: Replicas, type: integer}
    - {jsonPath: .status.readyReplicas, name: Ready, type: integer}
    - {jsonPath: .status.phase, name: Phase, type: string}
    - {jsonPath: .status.url, name: URL, type: string}
    - {jsonPath: .metadata.creationTimestamp, name: Age, type: date}
    schema:
      openAPIV3Schema:
        properties:
          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: {}

Outcome: 可直接 kubectl apply -f config/crd/bases/ 装上集群 Lesson (面试可讲):

  • required: [image] 是从 json:"image" (没有 omitempty) 推出来的 — Go tag 是声明语义的源头
  • subresources.status: {} 让 status 走 /status endpoint,reconcile 和用户写 spec 不互相覆盖
  • 没有 webhook 也能做强 schema 校验,这是 OpenAPI v3 的强大之处

B3. scaffold 生成的 RBAC 文件

What (查看):

ls config/rbac/
# kustomization.yaml
# leader_election_role.yaml             ← controller HA 需要 (lease object)
# leader_election_role_binding.yaml
# metrics_auth_role.yaml                ← /metrics endpoint TokenReview
# metrics_auth_role_binding.yaml
# metrics_reader_role.yaml              ← Prometheus 来 scrape 用的
# role.yaml                             ← ⭐ 业务 Role,空的等你 //+kubebuilder:rbac 注释生成
# role_binding.yaml
# service_account.yaml
# simpleapp_admin_role.yaml             ← 给用户分发的 admin 角色
# simpleapp_editor_role.yaml            ← editor 角色
# simpleapp_viewer_role.yaml            ← viewer 角色

Why (3 类 RBAC 都是必须的,分清责任):

  1. leader_election_*: 多副本 controller 选主用的 Lease,只在 kube-system 或自己 namespace 生效
  2. metrics_*: Prometheus 来 scrape /metrics,要过 token auth
  3. simpleapp_{admin,editor,viewer}_role: 这是给集群里的 user用的,不是给 operator 自己用的。装好后 cluster admin 可以 RoleBinding 给业务用户,把 simpleapp 的 admin 权限授出去 — 你的 CRD 自动具备和原生资源一样的权限分层。

Outcome: 后面在 reconciler 里写 //+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete 这种注释, controller-gen 会自动塞进 role.yaml,不用手写 RBAC Lesson: RBAC 是 operator 上线最常踩的坑(forbidden: cannot create deployments)。kubebuilder 强制你用注释声明,可被 audit,可被 review。


Day 3.C — 写 Reconciler(2026-05-26 中午)

C1. Reconcile 三件套 — Deployment + Service + Ingress

What: 替换 scaffold 的空 Reconcile,核心 4 步骤:

  1. Get CR → NotFound 直接 return(级联删除走 OwnerReference)
  2. 顺次 reconcile Deployment / Service / Ingress(Ingress 可选)
  3. 子资源都用 SetControllerReference 挂 OwnerReference(级联删除 + Owns 触发)
  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 { ... }
    if err := r.reconcileService(ctx, &app); err != nil { ... }
    if err := r.reconcileIngress(ctx, &app); err != nil { ... }
    return ctrl.Result{RequeueAfter: 30*time.Second}, r.updateStatus(ctx, &app)
}

每个 reconcile 子方法的模式:

desired := buildDesired(app)
controllerutil.SetControllerReference(app, desired, r.Scheme)
existing := &SubResource{}
err := r.Get(ctx, key, existing)
if IsNotFound(err) { return r.Create(ctx, desired) }
if !reflect.DeepEqual(existing.Spec, desired.Spec) {
    existing.Spec = desired.Spec
    return r.Update(ctx, existing)
}

Why 这种"diff then update"模式:

  • ❌ 不能无脑 Update: 每次 reconcile 都 Update 会触发 informer event → 反复 reconcile → CPU 100% (无限循环)
  • ✅ diff 后只 Update 变化的: reflect.DeepEqual(spec, desired) 不变就不写,信息论上"无新信息就不传"

Why SetControllerReference 是核心契约:

  • 写入子资源的 metadata.ownerReferences 字段,controller: true,blockOwnerDeletion: true
  • 删 SimpleApp 时,apiserver 的 garbage collector 自动级联删 Deployment/Service/Ingress
  • Owns(&appsv1.Deployment{}) 在 SetupWithManager 里注册,使得子资源被外部改时,根据 OwnerReference 反向找到 SimpleApp 触发 reconcile

C2. SetupWithManager — 4 个 informer

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

面试问 "Owns vs Watches 区别":

项OwnsWatches
反向解析通过 OwnerReference 反向找 root通过 EventHandler 自定义函数找 root
适用自己创建 + 想级联管的子资源关心但没自己创建的资源(如 ConfigMap 引用)
写法.Owns(&Deployment{}) 一句话.Watches(&ConfigMap{}, handler.EnqueueRequestsFromMapFunc(...))

C3. make run 起本地 controller

What:

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

make run = go run ./cmd/main.go,在本地起 controller,通过 ~/.kube/config 连 apiserver。没打镜像、没部署到集群,适合开发期。

Expected: 启动日志显示 4 个 EventSource (SimpleApp + Deployment + Service + Ingress) Actual: ✅

Starting EventSource ... source: *v1.SimpleApp
Starting EventSource ... source: *v1.Deployment
Starting EventSource ... source: *v1.Service
Starting EventSource ... source: *v1.Ingress
Starting Controller
Starting workers ... worker count: 1

Outcome: controller 在监听,可以 apply CR 测试 Lesson: make run 模式调试 +100% 体验,改一行代码立刻看效果,不用 docker build → load → kubectl rollout。

C4. 3 场景验证(Litmus Test)

apply 3 个 sample CR:

CaseSpec预期
case1-minimalimage only1 副本 + Service,无 Ingress
case2-multiimage + replicas:3 + port:80803 副本 + Service@8080,无 Ingress
case3-ingressimage + replicas:2 + port:80 + host:case3.bootcamp.local2 副本 + Service + Ingress

Expected: kubectl get simpleapp 显示 6 printcolumn,各 Spec 字段全显示 Actual (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
  • Deployment: 3 个,Pod 全 Running
  • Service: 3 个 ClusterIP (10.103.13.178 / 10.105.16.57 / 10.101.232.222)
  • Ingress: 只有 case3-ingress 一个 ✅(case1/2 没创,符合可选语义)
  • curl http://10.103.13.178 → <title>Welcome to nginx!</title> ✅

Outcome: 端到端链路完全通,Status.Phase Pending → Available 完整切换,readyReplicas 实时同步 Lesson (Working Method #5 — 走完整生命周期):

  • CR → Deployment → ReplicaSet → Pod → 网络可达 → Status 回写 → kubectl 可见,每一环都不能省

C5. 测试 update(改 replicas)

What:

kubectl patch simpleapp case1-minimal -n default --type=merge -p '{"spec":{"replicas":4}}'

Expected: 6 秒内 Deployment 副本数变 4,Status.readyReplicas 同步 Actual:

case1-minimal   nginx:1.27-alpine   4   4   Available
deployment case1-minimal   4/4   4   4

✅ 完全 work

C6. 测试 cascade delete

What:

kubectl delete simpleapp case3-ingress -n default
sleep 3
kubectl get deploy,svc,ingress -n default -l app.kubernetes.io/instance=case3-ingress

Expected: 子资源(Deployment + Service + Ingress)被 GC 自动清空 Actual: No resources found in default namespace. ✅ Lesson (面试问 "怎么实现 cascade delete"):

  • SetControllerReference 写 OwnerReference,把 root UID 写进子资源 metadata
  • 用户 kubectl delete simpleapp X,apiserver 的 garbage-collector controller 接管,根据 OwnerReference 反向找所有子资源,挨个删
  • 完全不用 operator 自己实现清理逻辑(除非有外部资源——见 Day 3.D finalizer)

C7. ⚠️ 真坑 #3: 乐观锁 conflict — resourceVersion mismatch

What (failure): patch replicas 后,controller log:

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"

Why 会踩 (K8s 乐观锁机制):

  • 每个 K8s 对象有 metadata.resourceVersion (etcd 的 mod_revision)
  • 我的 reconcile 流程: Get → existing.Spec = desired.Spec → Update(existing)
  • 在 Get 和 Update 之间, deployment-controller 给 Deployment 写了 status (readyReplicas 等),resourceVersion 跳了
  • 我的 Update 还用旧的 resourceVersion,apiserver 直接拒掉(乐观锁 conflict)

好消息: 这不是 bug,只是"噪声"。controller-runtime 自动 requeue,下次 reconcile 拿到最新版本就过了。最终一致。

坏消息: 日志吵,会被 Sentry/Prometheus 误报告警。生产形态要修。

Fix 方案 (3 选 1):

  1. controllerutil.CreateOrUpdate ⭐ (最经典)
    • 内置 Get → 改 mutateFn → Update 一条龙,失败自动 retry
    • 缺点: 还是有 update conflict 窗口,只是 retry 包了一层
  2. r.Patch(existing, client.MergeFrom(orig)) (推荐)
    • JSON Merge Patch,只发送变化字段
    • apiserver 用 SMP 算 diff,不依赖 resourceVersion 做完整对象比较
  3. Server-Side Apply r.Patch(desired, client.Apply, client.FieldOwner("simpleapp")) (kubebuilder v4 推荐)
    • controller 作为 "field owner" 声明它管哪些字段,其他字段(status / 其他 controller 改的)互不干扰
    • 缺点: 学习曲线陡

我选 方案 2 (MergeFrom Patch) — 改动最小,效果立竿见影。

修法:

// 把 existing.Spec = desired.Spec; r.Update(existing)
// 改为:
patch := client.MergeFrom(existing.DeepCopy())  // 先快照
existing.Spec = desired.Spec
return r.Patch(ctx, existing, patch)

Outcome 待验证: 应再无 conflict 日志,reconcile 一次成


C8. Fix conflict — Update → Patch(MergeFrom)

What (代码改动 3 处子资源 + 2 处 status):

// before
existing.Spec = desired.Spec
return r.Update(ctx, &existing)

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

Status 同理:

// before
app.Status = newStatus
return r.Status().Update(ctx, app)

// after
patch := client.MergeFrom(app.DeepCopy())
app.Status = newStatus
return r.Status().Patch(ctx, app, patch)

Why MergeFrom 比 Update 香:

  • Update 提交整个 obj,带 resourceVersion 校验,中间有别人写就 conflict
  • Patch(MergeFrom(orig)) 自动生成 JSON merge patch(只发 orig→current 的 diff),发送的 PATCH 不带 resourceVersion 约束
  • apiserver 收到 patch 时直接 merge,别的 controller 改的字段不动

Test 压测 (10 次连续 patch replicas):

for i in 1 3 1 4 1 5 2 3 5 1; do
  kubectl patch simpleapp case1-minimal --type=merge -p "{\"spec\":{\"replicas\":${i}}}"
done
sleep 12

Expected: 0 conflict, 0 ERROR Actual:

case1-minimal nginx:1.27-alpine 1 1 Available  (replicas=1 是最后一次 patch 值)
pods: 1
conflict 次数: 0
ERROR 总数: 0

✅ 完美

Outcome: reconciler 在高并发 patch 下 0 错误,最终一致 Lesson (面试可讲三层):

  1. resourceVersion 是 K8s 的乐观锁机制 (CAS),走 Update 接口必查
  2. 三种写入策略: Update (最严, race 多) → Patch (推荐,不查 RV) → Server-Side Apply (最强,字段所有权)
  3. 不是所有 conflict 都是 bug: ctrl-runtime 自动 requeue,最终一致;但生产形态要尽量"一次成",避免日志噪声 & 减少 apiserver 压力

C9. RBAC 自动生成总结

make manifests 把 controller 里的 //+kubebuilder:rbac:* 注释扫描,生成 config/rbac/role.yaml。我们 reconciler 用到的 9 个 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]

Lesson: kubebuilder 强制 RBAC-from-annotation,**杜绝"代码改了忘改 RBAC"**这种 race。生产 operator 应当严守:

  • 每加一种子资源(reconcile 里出现新 r.Get/Create/Update/Patch/Delete(&XXX{}))
  • 同时加 //+kubebuilder:rbac:groups=...,resources=xxx,verbs=...
  • 跑 make manifests 让 role.yaml 同步

PR review 时,这两件事必须同一次提交,否则部署到生产 → ServiceAccount 没权限 → forbidden。


Day 3.D — Finalizer + 外部资源清理(2026-05-26 中午)

D1. Finalizer 是什么 & 为什么必要

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

但有些情况物理删除前必须先做事:

  1. 删 cloud LB: AWS ELB / 阿里云 SLB 是 K8s 外的资源,K8s GC 管不到 → 必须手动调 cloud API 删
  2. 删 DNS record: 注销 external-dns 给的 A record
  3. 写审计/合规日志: 删除事件必须落库
  4. 优雅下线: 通知 Service Mesh drain 流量,再杀 Pod
  5. 释放配额: 第三方系统(如 Datadog 注销 host quota)

Finalizer 机制: 在 metadata.finalizers 数组里塞一个字符串(域名前缀 + 名字),只要数组非空,apiserver 拒绝物理删除,只标 deletionTimestamp。controller 看到 deletionTimestamp,跑完自己的清理逻辑,主动从数组里移除自己的 finalizer,然后 K8s 才真删。

用户 kubectl delete X
   ↓
apiserver: 检查 finalizers
   ├─ 空数组 → 立刻物理删 (DELETE etcd)
   └─ 非空数组 → 仅打 deletionTimestamp,不删
   ↓
controller watch 到 deletionTimestamp != nil
   ↓
跑 cleanup()
   ↓
RemoveFinalizer + Patch
   ↓
apiserver 重新检查 finalizers → 空了 → 真删

D2. SimpleApp 的 Finalizer 设计

我们的子资源(Deployment/Service/Ingress)已经被 OwnerReference 级联清理,不需要 finalizer。但为了演示生产形态 + 学习面试要点,加一个模拟外部清理:

逻辑: 删除 SimpleApp 时,写一条审计 ConfigMap (audit-deleted-<name>-<unix>),包含:

  • 删除时间 deletedAt
  • 镜像 image(快照,删除前记录)
  • 副本数 finalReplicas
  • 命名空间 namespace

模拟"外部 API 调用",中间 sleep(2s)。

D3. 代码改动 — 加 finalizer 三段式

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

func (r *SimpleAppReconciler) Reconcile(ctx, req) (ctrl.Result, error) {
    var app simplev1.SimpleApp
    r.Get(ctx, req.NamespacedName, &app)  // NotFound → 真删了, return

    // === 段 1: 用户已发起删除 ===
    if !app.DeletionTimestamp.IsZero() {
        if controllerutil.ContainsFinalizer(&app, finalizerName) {
            if err := r.cleanupExternal(ctx, &app); err != nil {
                return retry, nil  // 失败 → retry, finalizer 仍在,阻止物理删
            }
            patch := client.MergeFrom(app.DeepCopy())
            controllerutil.RemoveFinalizer(&app, finalizerName)
            r.Patch(ctx, &app, patch)  // ⭐ 移除后才能真删
        }
        return ctrl.Result{}, nil  // ⭐ 删除路径不做其他 reconcile
    }

    // === 段 2: 还没删,确保 finalizer 在 ===
    if !controllerutil.ContainsFinalizer(&app, finalizerName) {
        patch := client.MergeFrom(app.DeepCopy())
        controllerutil.AddFinalizer(&app, finalizerName)
        r.Patch(ctx, &app, patch)
        return ctrl.Result{Requeue: true}, nil  // 立刻再 reconcile 一遍
    }

    // === 段 3: 正常 reconcile ===
    reconcileDeployment / Service / Ingress / updateStatus ...
}

Why 三段式 + 这个顺序:

  • 段 1 必须在最前: 用户删了之后,不应再"创建子资源" — 否则 reconcile 会和 GC 死循环
  • 段 2 自愈式加 finalizer: 用户 apply 新 CR 时, 第一次 reconcile 直接加 finalizer 后 return,第二次 reconcile 才做业务 — 防止 先 reconcile → 创建子资源 → 才加 finalizer 之间用户删 CR 时,子资源已建但 finalizer 没加,导致清理跳过的 race
  • 段 3 才做业务

D4. RBAC 更新(自动)

加了 r.Create(ctx, &corev1.ConfigMap{}) 后必须加注释:

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

make manifests 后 config/rbac/role.yaml 多出 configmaps 这条 — 否则部署到集群会 forbidden: cannot create configmaps。

⚠️ 这是 [[Working Method #12 — Update Bookkeeping Immediately]] 的强制实例。

D5. 测试 1: finalizer 自动加上

What: 重启 controller,观察存量 CR (case1/case2) 是否自动加 finalizer

kill -9 <old-manager-pid>
setsid bin/manager > /var/log/simpleapp-operator/run.log 2>&1 < /dev/null &
sleep 5
kubectl get simpleapp case1-minimal -o jsonpath='{.metadata.finalizers}'

Expected: ["apps.bootcamp.local/simpleapp-finalizer"]Actual: ✅ 完全匹配

2026-05-26T12:10:13+08:00  INFO  finalizer 已添加 ... case1-minimal
2026-05-26T12:10:13+08:00  INFO  finalizer 已添加 ... case2-multi

Outcome: 改造完全向后兼容,旧 CR 自动补 finalizer Lesson: 段 2(自愈加 finalizer)对升级场景至关重要 — 你升级 operator 时,集群里已有 100 个 CR 都没 finalizer,reconcile 一过就批量补齐。

D6. 测试 2: 删除流程 — CR 卡 Terminating,审计 ConfigMap 落地

What:

kubectl delete simpleapp case1-minimal --wait=false
# 立刻看
kubectl get simpleapp case1-minimal -o jsonpath='{.metadata.deletionTimestamp}'
kubectl get simpleapp case1-minimal -o jsonpath='{.metadata.finalizers}'
# 5s 后看
sleep 5
kubectl get simpleapp case1-minimal  # 应 NotFound
kubectl get cm -l audit.bootcamp.local/source=simpleapp-operator

Expected: 删除请求立即 return,但 CR 卡 Terminating(deletionTimestamp 有,finalizer 还在),约 3-5s 后真消失,审计 ConfigMap 出现 Actual:

# 立刻
deletionTimestamp: 2026-05-26T04:10:44Z
finalizers: ["apps.bootcamp.local/simpleapp-finalizer"]
phase: Available (CR 还在!)

# 5s 后
Error from server (NotFound): simpleapps.apps.bootcamp.local "case1-minimal" not found

# 审计 ConfigMap
audit-deleted-case1-minimal-1779768644   5      5s
data: {
  "deletedAt": "2026-05-26T04:10:44Z",
  "finalReplicas": "1",
  "image": "nginx:1.27-alpine",
  "namespace": "default",
  "simpleApp": "case1-minimal"
}

Outcome: 完美演示 finalizer 阻止 GC + 异步清理 + 审计落地 Lesson (面试可讲):

  • kubectl delete --wait=true (默认) 会等 CR 真消失才 return → finalizer 慢的话 kubectl 会卡几秒
  • 生产 finalizer 永远要带 timeout / max-retry,否则坏的清理逻辑会导致 CR 永远 stuck Terminating(运维只能 kubectl patch ... --type=merge -p '{"metadata":{"finalizers":[]}}' 强删,这是 Day 2 处理 Calico Installation 卡 Terminating 的同款逃生术)

D7. 测试 3: Owns 反向触发(子资源 watch)

What: 直接 delete 一个 Deployment(用户/运维误操作),验证 controller 自动 rebuild

kubectl delete deploy case2-multi
sleep 6
kubectl get deploy case2-multi  # 应已重建

Expected: Deployment 被自动重建,Pod 重新跑起来 Actual:

deployment.apps "case2-multi" deleted
(等 6s)
NAME          READY   UP-TO-DATE   AVAILABLE   AGE
case2-multi   2/2     2            2           7s
2 个 Pod Running

✅ 完全 work

Why 能反向触发:

  1. SetupWithManager 里 .Owns(&appsv1.Deployment{}) 注册了 Deployment 的 informer
  2. Deployment 被删 → informer 收到 DELETE event
  3. controller-runtime 的 OwnerHandler 看 Deployment.metadata.ownerReferences → 找到 root SimpleApp UID
  4. enqueue {namespace: default, name: case2-multi} 到 workqueue
  5. Reconcile 跑,Get 到 SimpleApp,build desired,发现 Deployment 不在 → Create

Lesson (面试问 "Owns 怎么实现的?"):

  • 不是 controller 自己 watch — 是controller-runtime 帮你在 Deployment informer 上加 EnqueueHandler,handler 用 OwnerReference 反查 root
  • 跨 namespace 不支持(OwnerReference 必须同 namespace)
  • 如果子资源没 OwnerReference(比如手动 yaml 创建的),Owns 不触发,需要用 .Watches(...) 自定义 EnqueueFunc

D8. 测试 4: image rolling update

What:

kubectl patch simpleapp case2-multi --type=merge -p '{"spec":{"image":"nginx:1.27.1-alpine"}}'
sleep 8
kubectl get pods -l app.kubernetes.io/instance=case2-multi -o jsonpath='{.items[*].spec.containers[*].image}'

Expected: Pod 滚动升级,新旧镜像共存几秒,最终全新 Actual:

nginx:1.27-alpine nginx:1.27-alpine nginx:1.27.1-alpine

正在滚动中 — 旧 ReplicaSet 还有 2 个 Pod,新 ReplicaSet 已起 1 个 ✅

Outcome: reconcileDeployment 用 Patch(MergeFrom) 改 spec.template.spec.containers[0].image → Deployment controller 接管 rolling update → 默认 RollingUpdate strategy 自动 surge + max-unavailable Lesson: Operator 只管 desired state,真正的 rolling update 由 Deployment controller 完成 — 不要把 deployment-controller 的活自己实现一遍(常见反模式: 自己 Pod-by-Pod 删除重建)


11. Day 3 收尾 — 闭环检查

检查项状态证据
CRD 装上集群✅kubectl get crd simpleapps.apps.bootcamp.local
Spec/Status 设计落地✅6 个 printcolumn, status subresource enabled
创建子资源 (Deploy+Svc)✅3 case 全 reconcile 成功
创建 Ingress (可选)✅case3 有, case1/2 没
Status.Phase 状态机✅Pending → Progressing → Available 切换
Status.readyReplicas 实时✅patch replicas 后 1s 内同步
spec.image 改动触发 rolling✅2 个 Pod 用新镜像
OwnerReference 级联删除✅删 CR → 子资源全删
Owns 反向触发 (子资源被删自愈)✅delete Deploy → 7s 重建
Finalizer 阻止物理删 + 清理逻辑✅审计 ConfigMap 落地
高并发 patch 不冲突✅10 次连续 patch, 0 conflict
RBAC 自动生成✅8 个 apiGroup verb 全入库

遗留 / 下一步:

  • [ ] 打 Docker 镜像 → make docker-build IMG=...
  • [ ] kustomize 部署到集群 → make deploy IMG=...(本 Day 跳过, 我们用 make run)
  • [ ] Validating Webhook (拦 image:latest, 拒 replicas>100 等)— Day 3.E 选做
  • [ ] Conversion Webhook (v1→v2 schema 演化)— Day 3.F 选做
  • [ ] Prometheus metrics: reconcile_total / reconcile_errors_total — Day 7 一起做

当前可写进简历的:

用 kubebuilder 4.5 + controller-runtime 0.20 从零实现 SimpleApp Operator:

  • CRD(Spec+Status+6 printcolumn+OpenAPI 校验)
  • Reconcile 三件套 (Deployment/Service/Ingress, OwnerReference 级联)
  • Owns 子资源 watch(误删自愈, 7s rebuild)
  • Finalizer + 外部清理 + 审计落地
  • Patch(MergeFrom) 解决高并发 reconcile 乐观锁 conflict
  • kubebuilder RBAC annotation 自动生成 ClusterRole

99. 当前进度

  • [x] Day 3.A scaffold(踩 2 坑: Go 1.23+ 要求 / make 没装)
  • [x] Day 3.B types.go + generate + manifests + RBAC 理清
  • [x] Day 3.C Reconciler + 踩坑 (乐观锁 conflict → MergeFrom Patch)
  • [x] Day 3.D Finalizer + Owns + e2e 全套通过
  • [ ] Day 3.E (选做) Validating Webhook
  • [ ] Day 3.F (选做) v1→v2 conversion webhook
在 GitHub 上编辑此页
Prev
Day 2: 控制面 deep dive + etcd 内核 + chaos drill
Next
Day 4: Storage 主线 + Cilium 二探