Day 3: CRD + Operator (kubebuilder 从 0 写)
目标: 不是 hello-world,真做一个生产形态的 Operator ——
SimpleApp一键管 Deployment + Service + Ingress + status 上报。 耗时: 3-5 小时 风险: 写错 reconcile 会无限循环 / finalizer 卡死 / RBAC 不够导致 watch 失败 — 都是面试常考的真坑
0. TL;DR
- scaffold (Day 3.A): m1 装 Go + kubebuilder,kubebuilder init / create api SimpleApp
- CRD 设计 (Day 3.B): Spec=
{image,replicas,port,host}+ Status={phase,readyReplicas,url} - Reconcile (Day 3.C): CR → 创建/更新 Deployment + Service + Ingress,Status 回写
- 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?
| 维度 | ConfigMap | CRD |
|---|---|---|
| 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 出哪几个目录"):
api/: 你的 CRD type 定义 → controller-gen 生成 deepcopy + yamlinternal/controller/: 你的 reconcile 逻辑 (4.x 改名,3.x 叫 controllers/)cmd/: manager 入口 (一般不动)config/: 部署 manifest (kustomize 组织)hack/: 代码生成器配置 + boilerplatebin/: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:status | status 单独 endpoint /status,只能 PATCH,不能 spec/status 一起写 | spec/status 互相覆盖,reconcile 和 user 写 race |
// +kubebuilder:printcolumn | kubectl get simpleapp 显示真实业务列 | 只显示 NAME/AGE,debug 必须 -o yaml |
// +kubebuilder:object:root=true | controller-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 走/statusendpoint,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 都是必须的,分清责任):
- leader_election_*: 多副本 controller 选主用的 Lease,只在
kube-system或自己 namespace 生效 - metrics_*: Prometheus 来 scrape
/metrics,要过 token auth - 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 步骤:
GetCR → NotFound 直接 return(级联删除走 OwnerReference)- 顺次 reconcile Deployment / Service / Ingress(Ingress 可选)
- 子资源都用
SetControllerReference挂 OwnerReference(级联删除 + Owns 触发) - 回写 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 区别":
| 项 | Owns | Watches |
|---|---|---|
| 反向解析 | 通过 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:
| Case | Spec | 预期 |
|---|---|---|
| case1-minimal | image only | 1 副本 + Service,无 Ingress |
| case2-multi | image + replicas:3 + port:8080 | 3 副本 + Service@8080,无 Ingress |
| case3-ingress | image + replicas:2 + port:80 + host:case3.bootcamp.local | 2 副本 + 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):
controllerutil.CreateOrUpdate⭐ (最经典)- 内置 Get → 改 mutateFn → Update 一条龙,失败自动 retry
- 缺点: 还是有 update conflict 窗口,只是 retry 包了一层
r.Patch(existing, client.MergeFrom(orig))(推荐)- JSON Merge Patch,只发送变化字段
- apiserver 用 SMP 算 diff,不依赖 resourceVersion 做完整对象比较
- 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 校验,中间有别人写就 conflictPatch(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 (面试可讲三层):
- resourceVersion 是 K8s 的乐观锁机制 (CAS),走 Update 接口必查
- 三种写入策略: Update (最严, race 多) → Patch (推荐,不查 RV) → Server-Side Apply (最强,字段所有权)
- 不是所有 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 的对象,物理删除。
但有些情况物理删除前必须先做事:
- 删 cloud LB: AWS ELB / 阿里云 SLB 是 K8s 外的资源,K8s GC 管不到 → 必须手动调 cloud API 删
- 删 DNS record: 注销 external-dns 给的 A record
- 写审计/合规日志: 删除事件必须落库
- 优雅下线: 通知 Service Mesh drain 流量,再杀 Pod
- 释放配额: 第三方系统(如 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 能反向触发:
SetupWithManager里.Owns(&appsv1.Deployment{})注册了 Deployment 的 informer- Deployment 被删 → informer 收到 DELETE event
- controller-runtime 的 OwnerHandler 看 Deployment.metadata.ownerReferences → 找到 root SimpleApp UID
- enqueue
{namespace: default, name: case2-multi}到 workqueue - 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