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:
| 维度 | ConfigMap | CRD |
|---|---|---|
| Schema 校验 | 无,字符串里写错运行时才炸 | OpenAPI v3 schema,apiserver 入口就拒 |
kubectl get | 必须 -o yaml 再 grep | kubectl 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 自动生成的 DeepCopyconfig/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 设计是否够:
- 玩具 nginx:只填
image→ 1 副本 ClusterIP Service - 3 副本 web:填
image + replicas:3 + port:8080→ 不创 Ingress - 对外服务:填全部字段 → 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 / FailedreadyReplicas:给监控 / 告警判断扩缩容是否生效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.go | CRD type 定义(Spec/Status) | ⭐ 必改 |
internal/controller/simpleapp_controller.go | reconcile 业务逻辑 | ⭐ 必改 |
config/crd/bases/*.yaml | CRD manifest,controller-gen 生成 | 不动 |
config/rbac/role.yaml | Role,从代码注释扫描生成 | 不动 |
cmd/main.go | manager 入口 + 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:status | status 走独立 /status endpoint,只能 PATCH | reconcile 和 user 写 spec 互相覆盖,race 不可控 |
printcolumn | kubectl get simpleapp 显示业务列 | 只显示 NAME/AGE,debug 必须 -o yaml |
object:root=true | controller-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 走/statusendpoint,跟用户写 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 步:
GetCR;NotFound 直接 return(级联删除走 OwnerReference)- 顺次 reconcile Deployment / Service / Ingress(后者可选)
- 子资源都用
SetControllerReference挂 OwnerReference - 回写 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:
| Case | Spec | 预期 |
|---|---|---|
| case1-minimal | image only | 1 副本 + Service,无 Ingress |
| case2-multi | image + replicas:3 + port:8080 | 3 副本 + Service@8080,无 Ingress |
| case3-ingress | 全字段 + host | 2 副本 + 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 + FieldOwner | controller 声明它管哪些字段 | 最强,但学习曲线陡 |
选 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 写入策略三层:
resourceVersion是 K8s 的乐观锁(CAS),走Update接口必查- 三种写入:
Update(最严,race 多) →Patch(MergeFrom)(不查 RV,推荐) → Server-Side Apply(字段所有权,最强) - 不是所有 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。
排查顺序:
- controller 还跑着吗?
kubectl logs -n simpleapp-system deploy/controller-manager看 reconcile error - 清理逻辑卡哪一步?日志加 trace 看 cleanupExternal 是 hang 还是抛错
- 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 层防御:
- diff-then-update:
reflect.DeepEqual(existing.Spec, desired.Spec)不变就不写 - Patch(MergeFrom) 替代 Update:只发变化字段
- 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 主资源。
| Owns | Watches | |
|---|---|---|
| 反向解析 | OwnerReference 反查 root | 自定义 EventHandler |
| 适用 | 自己创建 + 想级联管的子资源 | 关心但没自己创建的资源 |
| 跨 namespace | 不支持 | 支持 |
手动 yaml 创建的子资源没 OwnerReference,Owns 不触发;跨 namespace 引用(SimpleApp 引用别 ns 的 ConfigMap)只能用 Watches。
Q4:CRD vs ConfigMap,5 个核心差异
| 维度 | ConfigMap | CRD |
|---|---|---|
| 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 种修法:
controllerutil.CreateOrUpdate:内置 retry,还是基于 Updateclient.Patch(client.MergeFrom(orig))—— JSON Merge Patch,只发变化字段,不依赖 RV 比对整个对象 ⭐ 推荐- 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 看板。