




















如果说前面几篇 Operator 文章我们都在"用 Operator",那这一篇我们要"造 Operator"。
我们要把脚下这条 Kubernetes sample-controller 源码路径(staging/src/k8s.io/sample-controller)拆开、读透、然后照着它的样子从零搭起一个属于我们自己的 application-operator 新项目。
很多新手一上来就被 kubebuilder 生成的目录结构吓住:cmd、pkg/apis、pkg/generated、Makefile、hack/update-codegen.sh……一堆目录、几十个自动生成的文件、还有几段根本看不懂的 shell 脚本。
本文要做的事,就是把这些目录和文件一个一个地剖开给你看:它们从哪儿来、为什么必须存在、彼此怎么协作。
本文结尾你将拥有一份"照着敲就能跑起来"的 application-operator 项目骨架,以及 20 个从初学到生产都会遇到的真问题解答。
Kubernetes 1.36.1 Go 1.26 Operator / CRD controller-gen client-gen deepcopy-gen
🔓 学习重点提示 — 建议先通读全文,再重点回顾标注内容
★ 重点掌握(必须)
• 项目结构:弄清 sample-controller 的 cmd / pkg/apis / pkg/generated / hack 四件套的职责
• API 怎么来:手写 types.go,再由代码生成器补齐 deepcopy / client / register
• DeploymentStatus 字段:ObservedGeneration / Replicas / ReadyReplicas / AvailableReplicas / Conditions
• ServiceStatus 字段:LoadBalancer.Ingress 与 Conditions 的语义
• UpdateStatus 原理:用 status 子资源隔离用户声明与系统观测
☆ 次重点(了解即可)
• update-codegen.sh 内部到底跑了哪些代码生成器
• OwnerReference / BlockOwnerDeletion / Controller 字段的含义
• 控制器 DeploymentSpec 复制与 status 回填的完整流程
我们 Kubernetes 主仓库下面有一个 staging/src/k8s.io/sample-controller 目录,这个目录是官方用来"教别人写 Controller"的样板工程。kubebuilder、operator-sdk 这些工具生成出来的工程,几乎都是 sample-controller 结构的"超集"——多了一个 webhooks 目录、一套 kustomize 配置,核心骨架一模一样。我们把 sample-controller 摸透,等于把 kubebuilder 摸透。
来看一眼它的顶层文件清单:
D:\worker-go\kubernetes-1.36.1\staging\src\k8s.io\sample-controller ├── artifacts\examples\ │ ├── crd.yaml // CRD 定义(无 status 子资源版本) │ ├── crd-status-subresource.yaml // CRD 定义(启用 status 子资源) │ └── example-foo.yaml // 一个 Foo 资源实例 ├── hack\ │ ├── boilerplate.go.txt // 自动生成文件的版权头 │ ├── tools.go // 强制 go.mod 引入 code-generator │ ├── update-codegen.sh // 一键跑代码生成的脚本 │ └── verify-codegen.sh // 校验生成代码是否最新 ├── pkg\ │ ├── apis\samplecontroller\v1alpha1\ │ │ ├── doc.go // +k8s:deepcopy-gen / +groupName 标记 │ │ ├── types.go // 手写的 Foo / FooSpec / FooStatus │ │ ├── zz_generated.deepcopy.go // 生成的 DeepCopy 方法 │ │ └── zz_generated.register.go // 生成的 Scheme 注册代码 │ ├── generated\ // 全部由代码生成器产出 │ │ ├── applyconfiguration\ // SSA(Server-Side Apply) 配置类型 │ │ ├── clientset\versioned\ // typed / fake 两套 clientset │ │ ├── informers\externalversions\ // SharedInformerFactory │ │ ├── listers\ // 本地缓存 Lister │ │ └── openapi\ // OpenAPI 定义 │ └── signals\signal.go // 优雅停机信号处理 ├── controller.go // Controller 业务实现(本文暂不展开) ├── main.go // 入口:装配 client、informer、controller ├── go.mod / go.sum └── README.md
整个项目就这几个模块:手写的 API、生成的 client/informer/lister、业务 controller、main 装配、CRD YAML 清单、代码生成脚本。这六块拼起来就是一个完整的 Operator。下面我们一步一步搭。
我们要搭的项目叫 application-operator。命名上参照官方"sample-controller"的风格——项目名 = 资源类型 + 角色(controller / operator)。注意 Kubernetes 的 CRD Group 不能落在 *.k8s.io / *.kubernetes.io 之下,所以我们的 GroupName 用 mycompany.io。
提前把整个项目目录树画出来:
application-operator/ ├── Makefile // 顶层构建入口:make / make manifests / make test ├── go.mod // module mycompany.io/application-operator ├── go.sum ├── PROJECT // kubebuilder 工程元数据(可选) ├── cmd\ │ └── main.go // 入口:装配 clientset、informer、controller ├── pkg\ │ ├── apis\application\v1alpha1\ │ │ ├── doc.go // +groupName=application.mycompany.io │ │ ├── types.go // 手写 Application / ApplicationSpec / ApplicationStatus │ │ ├── zz_generated.deepcopy.go // 由 deepcopy-gen 生成 │ │ └── zz_generated.register.go // 由 register-gen 生成 │ ├── generated\ // 由 client-gen / informer-gen / lister-gen 生成 │ │ ├── applyconfiguration\application\v1alpha1\ │ │ ├── clientset\versioned\... │ │ ├── informers\externalversions\... │ │ ├── listers\application\v1alpha1\... │ │ └── openapi\ │ └── controller\application\ │ ├── application_controller.go // 下一篇讲:Reconcile 循环 │ └── application_controller_test.go ├── hack\ │ ├── boilerplate.go.txt // 版权头 │ ├── tools.go // 强制引入 k8s.io/code-generator │ └── update-codegen.sh // 一键生成 client/informer/lister/deepcopy ├── artifacts\ │ ├── crd.yaml // CRD 定义 YAML │ ├── crd-status-subresource.yaml // 启用 status 子资源的 CRD │ └── example-application.yaml // 资源实例 ├── config\ // 可选:kustomize 部署配置 │ ├── default\ │ │ ├── kustomization.yaml │ │ ├── manager_auth_proxy_patch.yaml │ │ └── manager_config_patch.yaml │ ├── rbac\role_binding.yaml │ ├── manager\manager.yaml // Deployment 描述 operator 自身 │ └── namespace\namespace.yaml └── README.md
可能你看到这里要问:config/ 那一堆 YAML 是哪里冒出来的?答案是 kubebuilder 在 sample-controller 之上又叠加了一层"标准部署配置",把 operator 自身的 Deployment、ServiceAccount、RBAC、Namespace 都用 kustomize 管起来。本篇我们聚焦在 API 和状态建模,部署配置只需要 cmd/main.go 能本地跑起来即可,config/ 留到下一篇讲 controller 时再展开。
💡 小贴士
第一次接触 Operator 的同学,建议先把 config/ 整个目录删掉——它会干扰你理解 API 本身。等你把 main.go 跑通、CRD 部署好、kubectl get applications 拿到结果之后,再回头看 config/ 的意义就豁然开朗了。
先开一个空目录,初始化 Go module:
$ mkdir application-operator && cd application-operator
$ go mod init mycompany.io/application-operator
go: creating new go.mod: module mycompany.io/application-operator
go: adding go line to go.mod
module mycompany.io/application-operator
go 1.26
然后建好所有的子目录。一次性创建,复制粘贴即可:
$ mkdir -p cmd \
pkg/apis/application/v1alpha1 \
pkg/controller/application \
pkg/generated \
hack \
artifacts/examples
对照 sample-controller 的目录一一比对,你就知道这些目录每个对应什么角色。cmd 放二进制入口;pkg/apis 放手写的 API;pkg/controller 放业务逻辑;pkg/generated 放代码生成器产物;hack 放构建工具脚本;artifacts 放 CRD 与示例 YAML。
在 Kubernetes 主仓库里,sample-controller 的 go.mod 用 replace 把 api、apimachinery、client-go、code-generator 这些都指向了本地 staging/src 路径。我们 application-operator 是独立项目,需要通过真实版本号引入它们:
// application-operator/go.mod(建议版本)
module mycompany.io/application-operator
go 1.26.0
require (
github.com/golang/groupcache v0.0.0-20241129210710-39d4d5f0e3f0 // 间接
k8s.io/api v0.36.1
k8s.io/apimachinery v0.36.1
k8s.io/client-go v0.36.1
k8s.io/code-generator v0.36.1
k8s.io/klog/v2 v2.130.1
k8s.io/sample-apiserver v0.36.1 // 可选
)
写好之后 go mod tidy 一次。这一步会下载几百兆依赖,需要稳定网络。新手常踩的第一个坑:k8s.io/api 与 k8s.io/apimachinery 版本必须严格一致,否则会出现一批 go vet / build 失败的诡异错误——因为内部数据结构是从 apimachinery 导入的,版本错位会导致类型不兼容。
⚠️ 警告
不要在独立 Operator 项目里"模仿 sample-controller 用 replace 指向 staging/src 路径"。sample-controller 在主仓库内可以这么做是因为它本来就是 staging 的一部分。你在外面建项目,replace 指向主仓库的相对路径在 CI / 同事 clone 之后会全部失效。
sample-controller 的 Foo 类型是这么写的:
// staging/src/k8s.io/sample-controller/pkg/apis/samplecontroller/v1alpha1/types.go(行 23-44)
// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type Foo struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec FooSpec `json:"spec"`
Status FooStatus `json:"status"`
}
type FooSpec struct {
DeploymentName string `json:"deploymentName"`
Replicas *int32 `json:"replicas"`
}
type FooStatus struct {
AvailableReplicas int32 `json:"availableReplicas"`
}
我们 application-operator 照着它的形状写一个 Application。Application 的设计目标:用户提交一份"应用清单"(镜像、副本数、端口、暴露方式),operator 自动创建/同步一个 Deployment + Service,并把它们的状态回填到 Application.Status 里。
// application-operator/pkg/apis/application/v1alpha1/types.go
package v1alpha1
import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// +kubebuilder:subresource:status
// Application 是用户提交的应用清单:镜像、副本数、Service 暴露方式
type Application struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ApplicationSpec `json:"spec,omitempty"`
Status ApplicationStatus `json:"status,omitempty"`
}
// ApplicationSpec 描述期望的应用形态
type ApplicationSpec struct {
// Image 必填,部署用容器镜像
Image string `json:"image"`
// Replicas 副本数;nil 表示不指定,operator 用 1
Replicas *int32 `json:"replicas,omitempty"`
// Port 容器监听端口,operator 用来生成 Service spec.ports
Port int32 `json:"port"`
// Expose 暴露方式:ClusterIP / NodePort / LoadBalancer
// +optional
Expose corev1.ServiceType `json:"expose,omitempty"`
}
// ApplicationStatus 描述 operator 观测到的真实状态
type ApplicationStatus struct {
// DeploymentStatus 原样回填 deployment.status,便于用户直接看容器层
DeploymentStatus appsv1.DeploymentStatus `json:"deploymentStatus,omitempty"`
// ServiceStatus 原样回填 service.status,便于用户直接看流量层
ServiceStatus corev1.ServiceStatus `json:"serviceStatus,omitempty"`
// Phase 顶层应用级状态:Pending / Running / Failed
Phase ApplicationPhase `json:"phase,omitempty"`
// Message 人类可读的解释
Message string `json:"message,omitempty"`
}
type ApplicationPhase string
const (
ApplicationPhasePending ApplicationPhase = "Pending"
ApplicationPhaseRunning ApplicationPhase = "Running"
ApplicationPhaseFailed ApplicationPhase = "Failed"
)
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// ApplicationList 是 Application 资源列表
type ApplicationList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Application `json:"items"`
}
逐行解释这堆代码:
🌟 实用技巧
Replicas 为什么用 *int32 而不是 int32?看 sample-controller 的 FooSpec.Replicas 也是 *int32。原因在于序列化语义:int32 的零值是 0,*int32 的零值是 nil。如果用户不写 replicas,int32 反序列化得到 0——operator 会以为"用户要 0 个副本"并把所有 Pod 干掉;*int32 反序列化得到 nil——operator 知道"用户没指定",用默认值 1。这是非常容易踩的坑。
doc.go 是包级别的"代码生成标记文件"。它没任何运行时作用,全部内容都是给代码生成器看的:
// application-operator/pkg/apis/application/v1alpha1/doc.go
// +k8s:deepcopy-gen=package // +k8s:openapi-gen=true // +groupName=application.mycompany.io // Package v1alpha1 contains the v1alpha1 version of the application API. package v1alpha1
三个 magic comment 的含义:
sample-controller 的 hack/tools.go 写法很巧妙——它只是空 import k8s.io/code-generator,但因为这个文件加了 +build tools tag,正常 go build 不会编译它,但 go mod tidy 会把这个包当作依赖拉下来。
// application-operator/hack/tools.go
//go:build tools // 强制 go mod 引入 code-generator,但正常编译时排除掉 package tools import _ "k8s.io/code-generator"
代码生成器会在每个生成文件头部插入 boilerplate 内容。sample-controller 用的是 Apache 2.0 头:
// application-operator/hack/boilerplate.go.txt
/*
Copyright 2026 The MyCompany Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
照着 sample-controller 的 main.go 写。本篇只跑通最小骨架:建好 clientset、informer factory,controller 业务逻辑留到下一篇。完整 main.go 看 sample-controller 的源码:
// staging/src/k8s.io/sample-controller/main.go(行 19-87)
import (
"flag"
"time"
kubeinformers "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/klog/v2"
"k8s.io/sample-controller/pkg/signals"
clientset "k8s.io/sample-controller/pkg/generated/clientset/versioned"
informers "k8s.io/sample-controller/pkg/generated/informers/externalversions"
)
func main() {
klog.InitFlags(nil)
flag.Parse()
ctx := signals.SetupSignalHandler()
logger := klog.FromContext(ctx)
cfg, err := clientcmd.BuildConfigFromFlags(masterURL, kubeconfig)
if err != nil {
logger.Error(err, "Error building kubeconfig")
klog.FlushAndExit(klog.ExitFlushTimeout, 1)
}
kubeClient, err := kubernetes.NewForConfig(cfg)
if err != nil {
logger.Error(err, "Error building kubernetes clientset")
klog.FlushAndExit(klog.ExitFlushTimeout, 1)
}
exampleClient, err := clientset.NewForConfig(cfg)
if err != nil {
logger.Error(err, "Error building kubernetes clientset")
klog.FlushAndExit(klog.ExitFlushTimeout, 1)
}
kubeInformerFactory := kubeinformers.NewSharedInformerFactory(kubeClient, time.Second*30)
exampleInformerFactory := informers.NewSharedInformerFactory(exampleClient, time.Second*30)
controller := NewController(ctx, kubeClient, exampleClient,
kubeInformerFactory.Apps().V1().Deployments(),
exampleInformerFactory.Samplecontroller().V1alpha1().Foos())
kubeInformerFactory.Start(ctx.Done())
exampleInformerFactory.Start(ctx.Done())
if err = controller.Run(ctx, 2); err != nil {
logger.Error(err, "Error running controller")
klog.FlushAndExit(klog.ExitFlushTimeout, 1)
}
}
我们的 application-operator 改为这样(仅 import 路径变化、业务函数名变化):
// application-operator/cmd/main.go(部分)
import (
"flag"
"time"
kubeinformers "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/klog/v2"
"mycompany.io/application-operator/pkg/controller/application"
"mycompany.io/application-operator/pkg/signals"
clientset "mycompany.io/application-operator/pkg/generated/clientset/versioned"
informers "mycompany.io/application-operator/pkg/generated/informers/externalversions"
)
func main() {
klog.InitFlags(nil)
flag.Parse()
ctx := signals.SetupSignalHandler()
cfg, _ := clientcmd.BuildConfigFromFlags(masterURL, kubeconfig)
kubeClient, _ := kubernetes.NewForConfig(cfg)
exampleClient, _ := clientset.NewForConfig(cfg)
kubeInformerFactory := kubeinformers.NewSharedInformerFactory(kubeClient, 30*time.Second)
appInformerFactory := informers.NewSharedInformerFactory(exampleClient, 30*time.Second)
controller := application.NewController(ctx, kubeClient, exampleClient,
kubeInformerFactory.Apps().V1().Deployments(),
kubeInformerFactory.Core().V1().Services(),
appInformerFactory.Application().V1alpha1().Applications())
kubeInformerFactory.Start(ctx.Done())
appInformerFactory.Start(ctx.Done())
if err := controller.Run(ctx, 2); err != nil {
klog.FlushAndExit(klog.ExitFlushTimeout, 1)
}
}
注意:现在 main.go 引入了 pkg/generated 下的 clientset 和 informers——这两个目录我们还没生成!编译必然失败。下一步我们要让 Makefile 和 hack/update-codegen.sh 把它们补齐。
Makefile 是整个 Operator 项目的"一键化"门户。所有重复劳动——跑代码生成器、跑测试、构建二进制、打镜像——都用 make 触发。sample-controller 没 Makefile(它只用 hack/update-codegen.sh),但 kubebuilder 生成的项目一定有。我们直接按 kubebuilder v3 的 Makefile 模板写:
// application-operator/Makefile
# IMAGE_REGISTRY ?= myregistry.io # IMAGE_NAME ?= application-operator # IMAGE_TAG ?= v0.0.1 # ---- 代码生成参数 ---- API_PKG := mycompany.io/application-operator/pkg/apis GENERATED_PKG := mycompany.io/application-operator/pkg/generated BOILERPLATE := hack/boilerplate.go.txt .PHONY: all generate manifests fmt vet test build clean update-codegen all: build # 跑代码生成器:deepcopy / client / lister / informer / applyconfig update-codegen: @echo ">>> running code generators" bash hack/update-codegen.sh # 跑 deepcopy / clientset / crd / applyconfig 生成 generate: @echo ">>> generating deepcopy & client" $(MAKE) update-codegen @echo ">>> generating CRD manifests" controller-gen object paths=./pkg/apis/... \ crd:allowDangerousTypes=true \ output:crd:dir=artifacts \ output:none @echo ">>> done" # 编译二进制 build: go build -o bin/application-operator ./cmd # 跑测试 test: go test ./... -count=1 # 格式化 fmt: gofmt -s -w . # 静态检查 vet: go vet ./... clean: rm -rf bin/
Makefile 几个关键点:
sample-controller 的 update-codegen.sh 是这样的:
// staging/src/k8s.io/sample-controller/hack/update-codegen.sh(行 21-59)
SCRIPT_ROOT=$(dirname "${BASH_SOURCE[0]}")/..
CODEGEN_PKG=${CODEGEN_PKG:-$(cd "${SCRIPT_ROOT}"; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null || echo ../code-generator)}
source "${CODEGEN_PKG}/kube_codegen.sh"
THIS_PKG="k8s.io/sample-controller"
kube::codegen::gen_helpers \
--boilerplate "${SCRIPT_ROOT}/hack/boilerplate.go.txt" \
"${SCRIPT_ROOT}/pkg/apis"
kube::codegen::gen_openapi \
--output-dir "${SCRIPT_ROOT}/pkg/generated/openapi" \
--output-pkg "k8s.io/${THIS_PKG}/pkg/generated/openapi" \
--report-filename "/dev/null" \
--output-model-name-file "zz_generated.model_name.go" \
--boilerplate "${SCRIPT_ROOT}/hack/boilerplate.go.txt" \
"${SCRIPT_ROOT}/pkg/apis"
kube::codegen::gen_client \
--with-watch \
--with-applyconfig \
--applyconfig-openapi-schema <(go run k8s.io/sample-controller/pkg/generated/openapi/cmd/models-schema) \
--output-dir "${SCRIPT_ROOT}/pkg/generated" \
--output-pkg "${THIS_PKG}/pkg/generated" \
--boilerplate "${SCRIPT_ROOT}/hack/boilerplate.go.txt" \
"${SCRIPT_ROOT}/pkg/apis"
kube::codegen::gen_register \
--boilerplate "${SCRIPT_ROOT}/hack/boilerplate.go.txt" \
"${SCRIPT_ROOT}/pkg/apis"
四个 kube::codegen::* 函数分别调用了四个代码生成器:
| 函数 | 内部调用 | 产出 |
|---|---|---|
| gen_helpers | deepcopy-gen | zz_generated.deepcopy.go(每个类型 DeepCopy / DeepCopyInto / DeepCopyObject) |
| gen_openapi | openapi-gen | pkg/generated/openapi/zz_generated.openapi.go(OpenAPI 文档) |
| gen_client | client-gen + lister-gen + informer-gen + applyconfiguration-gen | pkg/generated/{clientset,listers,informers,applyconfiguration} |
| gen_register | register-gen | zz_generated.register.go(SchemeBuilder / AddToScheme) |
我们 application-operator 改一下 source 路径和包名即可:
// application-operator/hack/update-codegen.sh
#!/usr/bin/env bash
set -o errexit
set -o nounset
set -o pipefail
SCRIPT_ROOT=$(dirname "${BASH_SOURCE[0]}")/..
CODEGEN_PKG=${CODEGEN_PKG:-$(go env GOPATH)/pkg/mod/k8s.io/code-generator@v0.36.1}
source "${CODEGEN_PKG}/kube_codegen.sh"
THIS_PKG="mycompany.io/application-operator"
kube::codegen::gen_helpers \
--boilerplate "${SCRIPT_ROOT}/hack/boilerplate.go.txt" \
"${SCRIPT_ROOT}/pkg/apis"
kube::codegen::gen_client \
--with-watch \
--with-applyconfig \
--output-dir "${SCRIPT_ROOT}/pkg/generated" \
--output-pkg "${THIS_PKG}/pkg/generated" \
--boilerplate "${SCRIPT_ROOT}/hack/boilerplate.go.txt" \
"${SCRIPT_ROOT}/pkg/apis"
kube::codegen::gen_register \
--boilerplate "${SCRIPT_ROOT}/hack/boilerplate.go.txt" \
"${SCRIPT_ROOT}/pkg/apis"
执行:
$ make generate >>> generating deepcopy & client >>> running code generators API group version: [application.mycompany.io/v1alpha1] Generating deepcopy for application.mycompany.io/v1alpha1 Generating clientset for application.mycompany.io/v1alpha1 Generating listers for application.mycompany.io/v1alpha1 Generating informers for application.mycompany.io/v1alpha1 Generating applyconfiguration for application.mycompany.io/v1alpha1 >>> generating CRD manifests >>> done
之后我们看 pkg/ 目录,会发现多了一大堆文件:applyconfiguration/internal、applyconfiguration/utils.go、clientset/versioned/clientset.go、clientset/versioned/typed/...、clientset/versioned/scheme/register.go、informers/externalversions/...、listers/.../lister.go、openapi/...。这些全部是机器生成的,不要手改。我们看 sample-controller 的生成结果就明白:
// staging/src/k8s.io/sample-controller/pkg/generated/clientset/versioned/clientset.go(行 17-53)
// Code generated by client-gen. DO NOT EDIT.
package versioned
type Interface interface {
Discovery() discovery.DiscoveryInterface
SamplecontrollerV1alpha1() samplecontrollerv1alpha1.SamplecontrollerV1alpha1Interface
}
type Clientset struct {
*discovery.DiscoveryClient
samplecontrollerV1alpha1 *samplecontrollerv1alpha1.SamplecontrollerV1alpha1Client
}
func (c *Clientset) SamplecontrollerV1alpha1() samplecontrollerv1alpha1.SamplecontrollerV1alpha1Interface {
return c.samplecontrollerV1alpha1
}
文件首行那句 Code generated by client-gen. DO NOT EDIT. 就是规则约定——以后你看到以 zz_generated. 开头的文件,或者首行是 DO NOT EDIT 的文件,永远不要直接修改,否则下次 make generate 会覆盖掉。
🌟 实用技巧
建议把 pkg/generated/ 加入 .gitignore 之外,配合 verify-codegen.sh 在 CI 里检查"生成代码是否最新"——一旦手改了生成的代码或者忘了重新跑 make generate,CI 就会失败。
CRD 是 CustomResourceDefinition 的缩写——它告诉 Kubernetes API Server"我要新增一种资源,名字叫 Application,挂在 application.mycompany.io 这个 group 下"。我们看 sample-controller 的 CRD 怎么写:
// staging/src/k8s.io/sample-controller/artifacts/examples/crd.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: foos.samplecontroller.k8s.io
spec:
group: samplecontroller.k8s.io
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
deploymentName:
type: string
replicas:
type: integer
minimum: 1
maximum: 10
status:
type: object
properties:
availableReplicas:
type: integer
names:
kind: Foo
plural: foos
scope: Namespaced
这份 YAML 写得很规整,但有两个关键点新手容易漏:
<plural>.<group>,所以这里 foos.samplecontroller.k8s.io我们 application-operator 的 CRD(启用 status 子资源版本):
// application-operator/artifacts/crd-status-subresource.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: applications.application.mycompany.io
spec:
group: application.mycompany.io
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {} # ← 关键:开启 status 子资源
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [image, port]
properties:
image:
type: string
replicas:
type: integer
minimum: 0
maximum: 100
port:
type: integer
expose:
type: string
enum: [ClusterIP, NodePort, LoadBalancer]
status:
type: object
properties:
phase:
type: string
enum: [Pending, Running, Failed]
message:
type: string
deploymentStatus:
type: object
x-kubernetes-preserve-unknown-fields: true
serviceStatus:
type: object
x-kubernetes-preserve-unknown-fields: true
names:
kind: Application
plural: applications
singular: application
shortNames: [app]
scope: Namespaced
注意 status 块里我们用了 x-kubernetes-preserve-unknown-fields: true——因为 DeploymentStatus / ServiceStatus 字段太多,全写出来 schema 会有一两百行;这个注解告诉 API Server"原样保留未知字段、不做裁剪"。但这样会失去 OpenAPI 校验,生产环境建议把字段都列出来。
$ kubectl apply -f artifacts/crd-status-subresource.yaml customresourcedefinition.apiextensions.k8s.io/applications.application.mycompany.io created $ kubectl get crd | grep application applications.application.mycompany.io 2026-06-14T14:50:00Z
提交第一个 Application 实例:
// application-operator/artifacts/examples/example-application.yaml
apiVersion: application.mycompany.io/v1alpha1 kind: Application metadata: name: hello-app namespace: default spec: image: nginx:1.27 replicas: 3 port: 80 expose: ClusterIP
$ kubectl apply -f artifacts/examples/example-application.yaml application.application.mycompany.io/hello-app created $ kubectl get applications -n default NAME AGE hello-app 5s
到这里:CRD 已注册、Application 实例已提交、kubectl get 能看到、shortName 也生效(kubectl get app 也能用)。Status 字段此刻是空的——因为我们的 controller 还没跑起来。下一篇我们会写 application_controller.go 把 Deployment 创建出来、把 status 回填。
Kubernetes 的 API 对象一律遵循"声明式"设计哲学——Spec 是用户写的期望状态,Status 是系统观测的真实状态。这条规则在 Deployment、Pod、Service 上如此,在我们自定义的 Application 上也如此。
| 对比项 | Spec | Status |
|---|---|---|
| 谁写 | 用户(kubectl apply / kubectl edit) | operator 写(业务代码),用户只读 |
| 谁改 | 用户改完触发 Reconcile | operator 写完后通过 status 子资源提交 |
| 冲突处理 | 如果用户改、operator 改同一个字段,operator 的写入会被拒(因为 resourceVersion 变了) | 通过 status 子资源隔离,用户改 spec 不会误改 status |
| 读权限 | get / list / watch | get / list / watch / update(仅 operator) |
假设业务变了,用户要给 Application 加个"环境标签"(env: prod / staging)。新增字段的流程是:
① 改 types.go → ② make generate → ③ kubectl apply CRD → ④ 业务代码读取
具体操作:
// 步骤①:在 types.go 的 ApplicationSpec 里加字段
type ApplicationSpec struct {
Image string `json:"image"`
Replicas *int32 `json:"replicas,omitempty"`
Port int32 `json:"port"`
Expose corev1.ServiceType `json:"expose,omitempty"`
// 新增字段
Env string `json:"env,omitempty"` // 生产环境:prod / staging
Version string `json:"version,omitempty"` // 业务版本号,写到 deployment label
}
$ make generate >>> generating deepcopy & client ... >>> done $ kubectl apply -f artifacts/crd.yaml customresourcedefinition.apiextensions.k8s.io/applications.application.mycompany.io configured
OpenAPI schema 在 CRD apply 时会做合并校验:你可以新增字段、但不能改字段类型、不能删字段。如果用户提交了旧版本 YAML 漏了新字段,CRD 也不会拒绝(因为都是 omitempty)。这叫"前向兼容"——CRD 演进的核心约束。
⚠️ 警告
新增字段后,老客户端仍然能用(omitempty 不会校验缺失),但反过来——如果新客户端写了一个字段、然后回滚到老版本代码,老代码 DeepCopy 时该字段会丢失。Kubernetes 1.16+ 引入了 structural schema 模式可以保留未知字段,但更稳的做法是把"会写"和"会读"都设计成"渐进增强"。
除了"加个 string 字段",真实业务里你经常会需要这些更复杂的 API 设计:
举个例子——一个生产可用的 ApplicationSpec 长这样:
// 复杂业务场景
type ApplicationSpec struct {
Image string `json:"image"`
Replicas *int32 `json:"replicas,omitempty"`
Port int32 `json:"port"`
Expose corev1.ServiceType `json:"expose,omitempty"`
Env []corev1.EnvVar `json:"env,omitempty"`
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
Ingress *IngressConfig `json:"ingress,omitempty"`
Volumes []corev1.Volume `json:"volumes,omitempty"`
}
type IngressConfig struct {
Host string `json:"host"`
Path string `json:"path"`
SecretName string `json:"secretName,omitempty"`
}
注意 Resources 字段直接复用了 corev1.ResourceRequirements——用户写 cpu: 100m / memory: 256Mi 时和 Pod 一模一样。这种"复用 Kubernetes 原生类型"的实践比自己重新定义 ResourceList 强一百倍。
回到我们的 application-operator 核心设计:直接复用 Kubernetes 原生 DeploymentStatus 作为 Application.Status 的子结构。这是个非常聪明的设计——所有用户能想到的"容器层运行指标"都已经被 DeploymentStatus 包含了,operator 不用自己重新设计一遍。看 DeploymentStatus 全部字段:
// staging/src/k8s.io/api/apps/v1/types.go(行 487-533)
type DeploymentStatus struct {
// 控制器观察到的 generation:spec 改一次 generation +1
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// 当前 spec selector 命中的所有非终止 Pod 数(含所有 RS)
// +optional
Replicas int32 `json:"replicas,omitempty"`
// 拥有最新 template spec 的 Pod 数
// +optional
UpdatedReplicas int32 `json:"updatedReplicas,omitempty"`
// 就绪 Pod 数(ReadinessProbe 通过)
// +optional
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
// 可用 Pod 数(Ready 持续了 minReadySeconds)
// +optional
AvailableReplicas int32 `json:"availableReplicas,omitempty"`
// 不可用 Pod 数(Ready 不达标)
// +optional
UnavailableReplicas int32 `json:"unavailableReplicas,omitempty"`
// 正在终止的 Pod 数(1.33+ Beta,默认开启)
// +optional
TerminatingReplicas *int32 `json:"terminatingReplicas,omitempty"`
// 各种 Conditions:Available / Progressing / ReplicaFailure
Conditions []DeploymentCondition `json:"conditions,omitempty"`
// 哈希冲突计数(ControllerRevision 命名冲突时+1)
// +optional
CollisionCount *int32 `json:"collisionCount,omitempty"`
}
逐字段解释:
// staging/src/k8s.io/api/apps/v1/types.go(行 535-566)
type DeploymentConditionType string
const (
DeploymentAvailable DeploymentConditionType = "Available"
DeploymentProgressing DeploymentConditionType = "Progressing"
DeploymentReplicaFailure DeploymentConditionType = "ReplicaFailure"
)
type DeploymentCondition struct {
Type DeploymentConditionType `json:"type"`
Status v1.ConditionStatus `json:"status"`
LastUpdateTime metav1.Time `json:"lastUpdateTime,omitempty"`
LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
}
三个 Condition 的语义:
| Condition | 取值语义 | operator 关注点 |
|---|---|---|
| Available=True | 至少 minReadySeconds 内有 Required Replicas 个可用 Pod | Application.Phase=Running 的判定条件 |
| Progressing=True | 正在滚动更新(new RS scale up / old RS scale down) | Phase 保持 Running 但加 message 提示 |
| Progressing=False+Reason=ProgressDeadlineExceeded | 超过 progressDeadlineSeconds 还没滚完 | Application.Phase=Failed |
| ReplicaFailure=True | Pod 创建或删除失败 | 在 Application.Message 里写"查看 Pod 事件" |
这就是为什么我们要直接复用 DeploymentStatus——它把"容器层全部观测指标"用 ObservedGeneration + 五个 Replicas 计数器 + Conditions 完整表达了出来,operator 不用重新设计观测维度。
ServiceStatus 是流量层(4 层负载均衡)的状态模型,比 DeploymentStatus 简单很多:
// staging/src/k8s.io/api/core/v1/types.go(行 5883-5933)
type ServiceStatus struct {
// LoadBalancer 包含负载均衡器的当前状态
// +optional
LoadBalancer LoadBalancerStatus `json:"loadBalancer,omitempty"`
// 服务的运行时 Conditions(1.30+ GA)
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
type LoadBalancerStatus struct {
Ingress []LoadBalancerIngress `json:"ingress,omitempty"`
}
type LoadBalancerIngress struct {
IP string `json:"ip,omitempty"`
Hostname string `json:"hostname,omitempty"`
IPMode *LoadBalancerIPMode `json:"ipMode,omitempty"`
Ports []PortStatus `json:"ports,omitempty"`
}
两个核心字段:
LB 入口什么时候才出现?以 LoadBalancer 类型的 Service 为例,submit YAML 之后 kubectl describe service 会看到 Pending 大约 30-60s,等云厂商 controller 把 LB 创建完、IP 分配好,才回填到 Status.Ingress[0].IP。这块对 application-operator 的价值在于:用户提交 Application.Spec.Expose=LoadBalancer 之后,operator 创建 Service 就会自动等待 IP 出现,然后回填到 Application.Status.ServiceStatus.LoadBalancer.Ingress。
把第七、八节的两套原生状态做一次"翻译",就可以得到我们 application-operator 的状态机:
+----------------------+ user submit Application YAML
| Pending | operator 正在拉镜像 / 等待 Pod 启动
+----------+-----------+
| 全部 Pod Ready
v
+----------------------+ Deployment.Available=True & Replicas==Spec.Replicas
| Running | Service 已创建(如果 expose != ClusterIP 则等 LB IP)
+----------+-----------+
| Deployment.Progressing=False
| Reason=ProgressDeadlineExceeded
v
+----------------------+
| Failed | operator 不再 reconcile,停在错误状态
+----------------------+
落到代码上,sample-controller 的 updateFooStatus 给我们做了示范——把 Deployment.Status 复制到 Foo.Status:
// staging/src/k8s.io/sample-controller/controller.go(行 314-326)
func (c *Controller) updateFooStatus(ctx context.Context, foo *samplev1alpha1.Foo, deployment *appsv1.Deployment) error {
// NEVER modify objects from the store. It's a read-only, local cache.
// You can use DeepCopy() to make a deep copy of original object and modify this copy
// Or create a copy manually for better performance
fooCopy := foo.DeepCopy()
fooCopy.Status.AvailableReplicas = deployment.Status.AvailableReplicas
// If the CustomResourceSubresources feature gate is not enabled,
// we must use Update instead of UpdateStatus to update the Status block of the Foo resource.
// UpdateStatus will not allow changes to the Spec of the resource,
// which is ideal for ensuring nothing other than resource status has been updated.
_, err := c.sampleclientset.SamplecontrollerV1alpha1().Foos(foo.Namespace).UpdateStatus(ctx, fooCopy, metav1.UpdateOptions{FieldManager: FieldManager})
return err
}
注释里那句 NEVER modify objects from the store 是金科玉律——Informer 的本地缓存是只读的,修改它会让下一次 List 看到错误的数据。所以第一行 foo.DeepCopy() 必须有。
我们的 application-operator 复用 sample-controller 的写法,但要加上 Phase 字段。我们看 UpdateStatus 的接口是怎么生成的:
// staging/src/k8s.io/sample-controller/pkg/generated/clientset/versioned/typed/samplecontroller/v1alpha1/foo.go(行 39-55)
type FooInterface interface {
Create(ctx context.Context, foo *samplecontrollerv1alpha1.Foo, opts v1.CreateOptions) (*samplecontrollerv1alpha1.Foo, error)
Update(ctx context.Context, foo *samplecontrollerv1alpha1.Foo, opts v1.UpdateOptions) (*samplecontrollerv1alpha1.Foo, error)
// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus().
UpdateStatus(ctx context.Context, foo *samplecontrollerv1alpha1.Foo, opts v1.UpdateOptions) (*samplecontrollerv1alpha1.Foo, error)
Delete(ctx context.Context, name string, opts v1.DeleteOptions) error
DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error
Get(ctx context.Context, name string, opts v1.GetOptions) (*samplecontrollerv1alpha1.Foo, error)
List(ctx context.Context, opts v1.ListOptions) (*samplecontrollerv1alpha1.FooList, error)
Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error)
Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *samplecontrollerv1alpha1.Foo, err error)
Apply(ctx context.Context, foo *applyconfigurationsamplecontrollerv1alpha1.FooApplyConfiguration, opts v1.ApplyOptions) (result *samplecontrollerv1alpha1.Foo, err error)
ApplyStatus(ctx context.Context, foo *applyconfigurationsamplecontrollerv1alpha1.FooApplyConfiguration, opts v1.ApplyOptions) (result *samplecontrollerv1alpha1.Foo, err error)
FooExpansion
}
看 UpdateStatus 与 Update 的区别:UpdateStatus 只允许修改 status 字段,改 spec 会被服务端拒。这是 status 子资源给我们的"安全网"——operator 即使写错了,也只会动 status,不会把用户的 spec 覆盖掉。
我们 application-operator 的 updateApplicationStatus 写起来会是这样(本篇不展开 Reconcile 循环,状态回填代码示意):
// application-operator/pkg/controller/application/application_controller.go(示意)
func (c *Controller) updateApplicationStatus(
ctx context.Context,
app *applicationv1alpha1.Application,
deployment *appsv1.Deployment,
service *corev1.Service,
) error {
appCopy := app.DeepCopy() // 永远深拷贝
appCopy.Status.DeploymentStatus = deployment.Status // 原样复制 Deployment 状态
if service != nil {
appCopy.Status.ServiceStatus = service.Status // 原样复制 Service 状态
}
// 推导顶层 Phase
switch {
case isDeploymentFailed(deployment):
appCopy.Status.Phase = applicationv1alpha1.ApplicationPhaseFailed
appCopy.Status.Message = "Deployment 滚动更新超时"
case isDeploymentAvailable(deployment):
appCopy.Status.Phase = applicationv1alpha1.ApplicationPhaseRunning
appCopy.Status.Message = fmt.Sprintf("%d/%d 副本可用", deployment.Status.AvailableReplicas, *deployment.Spec.Replicas)
default:
appCopy.Status.Phase = applicationv1alpha1.ApplicationPhasePending
appCopy.Status.Message = "正在启动"
}
_, err := c.appClient.ApplicationV1alpha1().Applications(app.Namespace).
UpdateStatus(ctx, appCopy, metav1.UpdateOptions{FieldManager: controllerAgentName})
return err
}
部署完之后用 kubectl 看:
$ kubectl get application hello-app -n default -o yaml
apiVersion: application.mycompany.io/v1alpha1
kind: Application
metadata:
name: hello-app
generation: 1
spec:
image: nginx:1.27
replicas: 3
port: 80
expose: ClusterIP
status:
phase: Running
message: "3/3 副本可用"
deploymentStatus:
observedGeneration: 1
replicas: 3
readyReplicas: 3
availableReplicas: 3
conditions:
- type: Available
status: "True"
lastTransitionTime: "2026-06-14T14:55:00Z"
reason: MinimumReplicasAvailable
- type: Progressing
status: "True"
reason: NewReplicaSetAvailable
serviceStatus:
conditions: []
loadBalancer: {}
看到了吧——我们不用自己造任何字段,DeploymentStatus 的所有指标直接复用。这就是"用好 Kubernetes 原生类型"的力量。
下面这 20 个 Q&A 是 Operator 新手最高频踩的坑,覆盖基础概念、源码原理、生产实践三大类。
▼ Q1: Operator 和 Controller 到底有什么区别?什么时候该用 Operator 模式?
A: 在 Kubernetes 语境下,Controller 是泛指——任何"观察集群状态、对比期望状态、调用 API 收敛差异"的循环都叫 controller,Deployment Controller、ReplicaSet Controller 都属于此类。Operator 是 CRD + Controller 的组合模式:先通过 CustomResourceDefinition 扩展 API,再用 controller 监听这种新资源。我们这一篇搭的 application-operator 正是典型:先有 Application CRD、再有 application_controller。如果你只是想管好现有资源(Pod/Service),写裸 controller 就够;如果要管"业务语义"(Application、RedisCluster、EtcdCluster),用 Operator。Redis Operator、Postgres Operator、Prometheus Operator 都是这条路的成功案例。
▼ Q2: 为什么 CRD 的 group 不能落在 *.k8s.io 或 *.kubernetes.io 之下?
A: 这是 Kubernetes 社区的硬性规范——KEP-2337 把这两个域保留给 Kubernetes 项目自身用,避免命名冲突和信任边界混乱。所以 sample-controller 的 group 是 samplecontroller.k8s.io 但加了 "api-approved.kubernetes.io: unapproved, experimental-only" 的注解,提醒大家这是示例代码。我们 application-operator 用 application.mycompany.io 就完全合规。强行用 *.k8s.io 提交 CRD 时,API Server 会在 validation 阶段直接拒掉,错误信息类似 "metadata.annotations[api-approved.kubernetes.io]: Invalid value"。
▼ Q3: Spec 和 Status 是不是必须分开?为什么不能写在一个 struct 里?
A: 不是"语法强制"——你可以写在一个 struct 里,比如像 Container 那样只有一个 struct。但 Spec / Status 分离是 Kubernetes 生态的惯例,背后的设计哲学是"声明式":用户写 Spec 表达期望,operator 写 Status 表达实际。如果合在一起,operator 改 Status 时容易误碰用户输入的字段;用户改 Spec 时容易覆盖 operator 维护的状态。开启 status 子资源后(subresources.status: {}),UpdateStatus 还会做服务端校验,禁止动 spec——这相当于多了一道安全网。所以推荐 Spec / Status 分开,并开 status 子资源。
▼ Q4: zz_generated.deepcopy.go 这种文件我能改吗?为什么叫 zz_ 前缀?
A: 永远不要手改——文件首行 "Code generated by ... DO NOT EDIT" 就是契约。zz_ 前缀是 Go 社区的小技巧:排序时这些文件会排在包内所有手写文件之后(z 是字母表末尾),这样你 import 这个包时,"手写代码 + 生成的 helper 函数"这种顺序就是确定的;如果手写的 Foo struct 比生成的 DeepCopy 函数先出现但又需要互相引用,Go 编译器会报"undefined"。每次改完 types.go 都要跑 make generate 重新生成,否则编译会失败(因为 DeepCopy 是手写代码用得到的)。
▼ Q5: pkg/generated/ 目录里的 clientset、informers、listers、applyconfiguration 各是什么关系?
A: 四个东西构成一个完整的"对 Kubernetes API 编程的工具栈":
• clientset:强类型的 RESTful 客户端,client.Applications(ns).Get(name) 这种链式调用,背后是 HTTP 请求
• listers:本地缓存读接口,lister.Applications(ns).Get(name) 走的是 informer 的内存索引,不打网络
• informers:事件源,把 API Server 的 watch 流转换成本地缓存 + Indexer,并触发 Add/Update/Delete 事件
• applyconfiguration:SSA(Server-Side Apply)用的补丁结构,专门为 Apply() 方法设计
写作模式:informers 监听变化 → workqueue 排队 → 业务函数从 listers 读最新状态 → 用 clientset 写入修改 → 必要时用 applyconfiguration 做声明式更新。sample-controller 完整地演示了这个模式,我们下一篇会逐行拆 controller.go。
▼ Q6: hack/update-codegen.sh 里的 kube_codegen.sh 是什么?哪来的?
A: kube_codegen.sh 是 k8s.io/code-generator 项目里的"上层封装脚本",把"按特定参数组合调用 deepcopy-gen / client-gen / lister-gen / informer-gen / applyconfiguration-gen / openapi-gen"这件事抽象成四个函数:gen_helpers / gen_openapi / gen_client / gen_register。Kubernetes 主仓库内的所有项目(sample-controller、kube-controller-manager、cluster-autoscaler……)都 source 它来跑代码生成。独立 Operator 项目里没有 vendor 这一说,所以更新-codegen.sh 用 go env GOPATH)/pkg/mod/k8s.io/code-generator@vX.Y.Z" 的方式定位本地缓存里的 kube_codegen.sh 即可。也可以完全跳过它,直接调用六个底层 binary 工具,效果一样只是更繁琐。
▼ Q7: Replicas 字段为什么用 *int32 而不是 int32?这是过度设计吗?
A: 不是过度设计,是必要的。Go 序列化时 int32 零值是 0,*int32 零值是 nil。考虑一个场景:用户提交 Application 时没写 replicas,根据默认值应该用 1。但如果 Spec.Replicas 是 int32,operator 拿到的是 0——分不清"用户没写"还是"用户写了 0"。改成 *int32 之后,nil=未设置,&1=显式 1,&0=显式 0(虽然这种语义奇怪但合法)。DeploymentSpec.Replicas 也是 *int32,PodSpec.Containers[].Resources.Limits[cpu] 是 *resource.Quantity,理由完全一样。这种"指针表示可选"的模式在 Kubernetes API 里随处可见。
▼ Q8: controller-gen 跟 code-generator 是什么关系?能只用其中一个吗?
A: code-generator 是 Kubernetes 项目自带的代码生成器集合,包括 deepcopy-gen / client-gen / lister-gen / informer-gen / applyconfiguration-gen / openapi-gen / register-gen,主要在 k8s.io/kubernetes 主仓库和 staging/src 路径下用,sample-controller 就是用它们。controller-gen 是 kubebuilder 项目里的工具,除了能做 deepcopy-gen 的事,还专门擅长生成 CRD YAML、生成 webhook 配置。生产上推荐 controller-gen 为主:它能直接生成 CRD YAML、deepcopy、webhook RBAC 一站式搞定,code-generator 退化为生成 client/lister/informer 的工具。kubebuilder/operator-sdk 默认就用 controller-gen。
▼ Q9: DeploymentSpec.Selector 字段是必填的吗?忘了写会怎样?
A: 是的,DeploymentSpec.Selector 是强制必填字段,没有 omitempty。忘了写的话 kubectl apply 会被 API Server 拒掉,错误信息 "Selector is required"。Deployment 用 Selector 决定哪些 Pod 归它管——这是滚动更新的基础(拿 old Pod list、跟 new Pod list 对比)。我们 application-operator 创建 Deployment 时必须显式给 Selector,sample-controller 的 newDeployment 函数就是这么做的:
// staging/src/k8s.io/sample-controller/controller.go(行 388-421)节选
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: foo.Spec.DeploymentName,
Namespace: foo.Namespace,
OwnerReferences: []metav1.OwnerReference{
*metav1.NewControllerRef(foo, samplev1alpha1.SchemeGroupVersion.WithKind("Foo")),
},
},
Spec: appsv1.DeploymentSpec{
Replicas: foo.Spec.Replicas,
Selector: &metav1.LabelSelector{
MatchLabels: labels,
},
// ... template 省略
},
}
注意 OwnerReferences 也设了——这让 Deployment 自动随 Foo 删除而清理。OwnerReference 是 Kubernetes "级联删除"的标准机制,比 Finalizer 简单,但前提是 Deployment 跟 Foo 在同一个 namespace。
▼ Q10: ObservedGeneration 字段到底有什么用?必须检查它吗?
A: 必须检查。Generation 是 metadata.generation,由 API Server 在 Spec 变化时自动 +1;ObservedGeneration 是 operator 写到 status 里的"我已经处理到第几代"。它解决的是分布式系统的经典问题:"我是不是已经处理完了最新一次 spec 变更?"。考虑这个时序:用户改 spec(generation=2)→ controller 入队 → controller 处理中 → 用户又改 spec(generation=3)→ controller 把第 2 代的处理结果回填到 status(ObservedGeneration=2)。此时 generation=3 但 ObservedGeneration=2,表示"还有变更没处理完"。如果 controller 不检查这个,可能在 status 看似"最新"时其实漏处理了用户最新的变更。最佳实践:operator 在每次 reconcile 末尾判断 status.ObservedGeneration == metadata.generation,否则把 status 标记为 "Stale: WaitingForReconcile"。
▼ Q11: DeploymentStatus.AvailableReplicas 和 ReadyReplicas 有什么不一样?
A: 两者都要求 Pod 已经 Ready(ReadinessProbe 通过)。区别在于"持续时间":
• ReadyReplicas:ReadinessProbe 通过的瞬间就算"就绪"
• AvailableReplicas:Ready 状态持续了 Spec.MinReadySeconds 这么久才计入"可用"
引入 MinReadySeconds 是为了应对一种场景:Pod 刚启动时通过 ReadinessProbe,但接下来 5 秒内突然崩溃,流量已经打过来了。所以 Available 比 Ready 更严格——它要求 Pod "经得起时间检验"。DeploymentAvailable Condition 触发条件是 AvailableReplicas >= Required Replicas(Required = max(Spec.Replicas - MaxUnavailable, 0))。operator 判定 Application.Phase=Running 时应该用 AvailableReplicas,不是 ReadyReplicas。
▼ Q12: 状态字段这么多,operator 应该把哪些回填到自己的 Application.Status?
A: 推荐"原样复用 + 顶层抽象"两段式:
• 原样复用:把整个 DeploymentStatus / ServiceStatus 当一个 sub-struct 嵌进 Application.Status(就像我们 types.go 那样)。用户写 kubectl get application -o yaml 能直接看到底层 Deployment 的 Replicas / Conditions
• 顶层抽象:再加一个简化的 Phase 字段(Pending / Running / Failed),让用户 5 秒内看出"应用跑没跑起来"
反模式:把 DeploymentStatus 的 12 个字段全展开成 Application.Status 的同级字段。这样会让 API 表面积过大、用户迷惑("availableReplicas 到底是 Deployment 的还是 ReplicaSet 的?"),还把"用户写什么字段"和"系统观测什么字段"的边界搞模糊了。
▼ Q13: sample-controller 的 controller.go 第 145 行那个 ResourceVersion 判断是干什么的?
A: 那是处理 informer 的"周期性 resync"机制。Informer 默认每 10 分钟会触发一次 Update 事件,把所有缓存里的对象当成"被更新"了——目的是兜底,防止 watch 漏事件时 controller 永远不知道某个对象的存在。代码里那段是过滤掉"对象其实没变"的事件:
// staging/src/k8s.io/sample-controller/controller.go(行 142-153)
UpdateFunc: func(old, new interface{}) {
newDepl := new.(*appsv1.Deployment)
oldDepl := old.(*appsv1.Deployment)
if newDepl.ResourceVersion == oldDepl.ResourceVersion {
// Periodic resync will send update events for all known Deployments.
// Two different versions of the same Deployment will always have different RVs.
return
}
controller.handleObject(new)
},
ResourceVersion 是 Kubernetes 对象的"逻辑时钟",每次写操作都会变(+1 或者用新值)。同一个对象真正发生变化时 new.ResourceVersion 一定不等于 old.ResourceVersion;periodic resync 触发的"伪更新"则两者相等。这样过滤之后,事件处理函数不会被 resync 噪声淹没。这是写 controller 的标准模式。
▼ Q14: OwnerReferences 和 Finalizer 是什么关系?什么时候该用哪个?
A: 两者都是"清理资源"的机制,但粒度不同:
• OwnerReferences:声明式级联删除。在 A.metadata.ownerReferences 里写 B 的 GVK + UID,删 A 时 B 会自动删(除非 B 也有 owner)。简单场景够用:sample-controller 创建的 Deployment 自动随 Foo 删除
• Finalizers:优雅关闭。给对象加 finalizer 之后,删除请求会被"挂起",直到对象的所有 finalizer 都被移除。在 finalizer 存在期间,operator 有机会执行清理(删 LB、备份数据、调外部 API)。
使用决策:
① 创建的子资源都在 K8s 内(Deployment、Service)→ 用 OwnerReferences 就够,代码最少
② 还要清理 K8s 外部资源(云厂商 LB、数据库实例、DNS 记录)→ 用 Finalizer
③ 两者都要 → OwnerReferences 管 K8s 内部,Finalizer 管外部
注意:metav1.NewControllerRef() 会把 BlockOwnerDeletion 设为 true,这意味着删 Foo 之前 Deployment 会先删完,但 Deployment 卡在 Terminating 状态时(Pod 删不完)Foo 也删不掉。需要异步清理的场景要用 Finalizer 配合。
▼ Q15: UpdateStatus 写失败了,operator 该怎么办?重试还是放弃?
A: 分两类错误处理:
• Conflict(HTTP 409):resourceVersion 过期,说明用户或别的 operator 已经改了对象。重新 Get → DeepCopy → 修改 status → 再 UpdateStatus。这种情况大概率不是错误,所以 sample-controller 的 updateFooStatus 把 conflict 当作重试目标
• Validation(HTTP 422):status 字段不符合 schema。这种一般是程序 bug,应该把整个 error 打 log、配合 metrics alert
• 5xx:API Server 暂时故障。直接 requeue,等下次 reconcile
总之:不要放弃。Status 回填失败意味着用户看不到 operator 的观测,必须重试。sample-controller 的做法是 updateFooStatus 返回 error → processNextWorkItem 把它 AddRateLimited 重新入队 → 下次 reconcile 再来。
▼ Q16: 我直接用 DeploymentStatus 整段嵌入,会不会让生成的 CRD 太大?
A: 不会显著变大。整段嵌入后 OpenAPI v3 schema 会被原样生成,DeploymentStatus 大约 200 行,ServiceStatus 大约 50 行,CRD 文件总共 1MB 左右。etcd 存储一份 CRD definition 才几十 KB,100 个 Application 实例也只增加几百 KB 的 metadata,整体可控。真正会让 CRD 变臃肿的是"在 spec 里塞完整 PodTemplate",那个才会有几千行——我们 Spec 只放 Image / Replicas / Port / Expose 是合理的精简设计。性能敏感场景可以把 status 字段用 x-kubernetes-preserve-unknown-fields: true 跳过 schema 校验,CRD 会小 70%,代价是失去 OpenAPI 文档自动生成。
▼ Q17: 我把 sample-controller 跑起来了,但 foo 创建之后 deployment 没出现,怎么排查?
A: 按四步排查:
① operator 进程在不在? kubectl get pods -n kube-system 找 controller,kubectl logs 看有没有 panic
② CRD 注册成功了吗? kubectl get crd 看 foos.samplecontroller.k8s.io;kubectl describe crd 看 acceptedNames / conditions
③ controller 收到事件了吗? 在 controller.go 的 enqueueFoo 里加 klog.Info(),重新跑一遍,看 operator log 有没有 "enqueue Foo"。如果没事件,多半是 RBAC 权限——operator 没有 list/watch foos 的权限,加 ClusterRole
④ Create Deployment 失败? 在 controller.go 的 syncHandler 里 err 返回前加 klog.Error(err, ...),常见错误:ImagePullBackOff(镜像拉不到)、Insufficient cpu(资源不够)、Invalid value(YAML schema 校验失败)。kubectl describe deployment 看 Events 是最直接的诊断方式
▼ Q18: 多个 controller 同时 reconcile 同一个 Application 怎么办?会重复创建 Deployment 吗?
A: 不会重复创建,但需要做幂等保护。机制分两层:
• Informer 缓存层:每个 controller 进程独立维护本地缓存,Deployment 创建后 informer 会 watch 到,所有 controller 看到的状态一致
• API Server 排他性:创建同名 Deployment 时,第二个会得到 AlreadyExists 错误(HTTP 409)。sample-controller 的 syncHandler 处理逻辑是 "Get → 找不到则 Create → 找到了就 Update",这是标准的 read-then-write 幂等模式
• Election:生产环境通常多个 controller 副本同时跑,但通过 Lease 锁选主,只有主才真正 reconcile(kube-controller-manager 的 --leader-elect=true)。从库 idle 等待。这不是 application-operator 必须的,但能省 CPU
注意:workqueue 已经去重——同一个对象在同一时刻只会被一个 worker goroutine 处理。但不同对象可能触发同一个对象的 reconcile(例:A 是 B 的 owner,A 变了 → A 和 B 都入队 → 两次 reconcile B)。要小心 business logic 内部状态,必要时用 sync.Map 或 mutex。
▼ Q19: sample-controller 跑在自己的 namespace 里,集群中所有 namespace 的 Foo 都能管吗?
A: 能——前提是 RBAC 给对了。CRD 是集群级别的(CRD 本身 cluster-scoped),Application 是 Namespaced 资源(spec.scope: Namespaced),所以任何 namespace 都能 submit application。关键在于 operator ServiceAccount 的 ClusterRole 授权:必须有 list/watch/create/update/patch/delete applications 的权限(针对所有 namespace),还要有 list/watch/create/update/patch/delete deployments 和 services 的权限。sample-controller 用的 ServiceAccount 默认绑定 system:controller:sample-controller 这个 ClusterRole。如果 RBAC 没配对,operator log 会反复出现 "forbidden" 错误。
▼ Q20: 我照着本文搭完 application-operator,提交 application 之后 service 字段一直是空,为什么?
A: 这篇文章里我们的 controller 还没写实现(下一篇才讲),所以 operator 不会创建 Service。ServiceStatus 一直是空属正常。等下一篇 Reconcile 循环写完,operator 会在 syncHandler 里 Get Service(找不到就 Create),最后 updateApplicationStatus 里把 service.Status 整段赋给 appCopy.Status.ServiceStatus,那时再 kubectl get application 就能看到 status.serviceStatus 里的 LoadBalancer.Ingress(如果 expose=LoadBalancer)或者 Conditions 信息。如果只想快速验证 ServiceStatus 的字段在 CRD 里生效,可以直接 kubectl apply 一个手写的 Service 让 controller watch 到,但这不符合 Operator 模式的最佳实践——Operator 应该是声明式的、自管的。
下一篇我们正式进入 application-operator 的"大脑"——application_controller.go。我们会逐行精讲:
读完下一篇你将能够:独立写出一个能 reconcile 任意 Application 资源的 controller,理解每行代码背后的设计权衡,并在生产环境部署一个稳定运行的 Operator 进程。
本文源码参考:
• sample-controller main.go(行 19-87)
• sample-controller controller.go(行 68-156, 314-326, 388-421)
• Foo 类型定义(行 23-54)
• 生成的 clientset(行 39-74)
• DeploymentStatus 定义(行 487-566)
• ServiceStatus 定义(行 5883-5933)
• NewControllerRef 源码(行 59-68)
• update-codegen.sh(行 21-59)
Kubernetes 编程 / Operator 专题【左扬精讲】—— 从零搭建 application-operator 新项目 · 来源:Kubernetes 1.36.1 源码 + sample-controller 实战
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。