Kubernetes Operator开发实战:用Operator-sdk构建自定义控制器
结论先行: 用Operator-sdk开发自定义控制器,关键在于理解“期望状态”和“实际状态”的调和循环(Reconcile Loop)。本文会从一个真实例子出发:构建一个能自动扩缩容的Web应用Operator,记录从初始化到部署的所有踩坑点,最后你会得到一个能生产使用的控制器骨架。
1. 为什么不用Helm而写Operator?
如果你只想把一组YAML包起来,用Helm就够了。但当你的应用需要根据集群状态自动做出反应(比如:数据库主从切换、自动恢复、无状态服务灰度扩缩),就需要Operator。Operator本质是一个自定义控制器,它Watch自定义资源(CRD)的变化,然后调用K8s API去调整状态。
我踩的第一个坑:不要把Operator写成“定时脚本”。Operator的Reconcile函数会被CRD创建/更新/删除事件触发,也会周期性地被队列重试触发。不要在Reconcile里写for { select {} },那样会阻塞控制器。
2. 环境准备
你需要:
- Go 1.20+
- Docker
- Kind 或 Minikube(本地集群)
- Operator-sdk v1.30+
# 安装operator-sdk
export ARCH=$(case $(uname -m) in x86_64) echo -n amd64 ;; aarch64) echo -n arm64 ;; *) echo -n $(uname -m) ;; esac)
export OS=$(uname | awk '{print tolower($0)}')
export OPERATOR_SDK_DL_URL=https://github.com/operator-framework/operator-sdk/releases/download/v1.32.0
curl -LO ${OPERATOR_SDK_DL_URL}/operator-sdk_${OS}_${ARCH}
chmod +x operator-sdk_${OS}_${ARCH} && sudo mv operator-sdk_${OS}_${ARCH} /usr/local/bin/operator-sdk
注意: 不要用go install operator-sdk,否则版本可能不对,后续脚手架会报错。
3. 初始化项目
我打算创建一个叫webapp-operator的项目,管理的自定义资源叫WebApp,它有一个规格Replicas(期望副本数)和一个状态字段AvailableReplicas(可用副本数)。
mkdir -p webapp-operator && cd webapp-operator
operator-sdk init --domain mycompany.io --repo github.com/mycompany/webapp-operator
这时会生成一堆文件,核心是main.go和controllers/。接着创建API和控制器:
operator-sdk create api --group app --version v1 --kind WebApp --resource --controller
这会创建:
- api/v1/webapp_types.go:定义CRD的Spec和Status
- controllers/webapp_controller.go:Reconcile逻辑
4. 定义自定义资源(CRD)
编辑api/v1/webapp_types.go,定义期望状态和状态字段:
package v1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// WebAppSpec 定义期望状态
type WebAppSpec struct {
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=100
Replicas int32 `json:"replicas"`
Image string `json:"image,omitempty"`
}
// WebAppStatus 定义实际状态
type WebAppStatus struct {
AvailableReplicas int32 `json:"availableReplicas"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Replicas",type="integer",JSONPath=".spec.replicas"
// +kubebuilder:printcolumn:name="Available",type="integer",JSONPath=".status.availableReplicas"
type WebApp struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec WebAppSpec `json:"spec,omitempty"`
Status WebAppStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
type WebAppList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []WebApp `json:"items"`
}
func init() {
SchemeBuilder.Register(&WebApp{}, &WebAppList{})
}
关键点: +kubebuilder:subresource:status 必须加,否则无法更新Status字段。
然后执行make generate和make manifests来生成CRD的YAML。
踩坑:忘记加
+kubebuilder:subresource:status,导致在Reconcile里调用Status().Update()时报错“the server could not find the requested resource”。
5. 实现Reconcile逻辑
现在打开controllers/webapp_controller.go,核心逻辑如下:
func (r *WebAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := log.FromContext(ctx)
// 1. 获取WebApp实例
var webapp appv1.WebApp
if err := r.Get(ctx, req.NamespacedName, &webapp); err != nil {
if k8serrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
// 2. 定义期望的Deployment
deploy := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: webapp.Name,
Namespace: webapp.Namespace,
},
Spec: appsv1.DeploymentSpec{
Replicas: ptr.To(webapp.Spec.Replicas),
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": webapp.Name},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": webapp.Name},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "web",
Image: webapp.Spec.Image,
},
},
},
},
},
}
// Set OwnerReference
if err := ctrl.SetControllerReference(&webapp, deploy, r.Scheme); err != nil {
return ctrl.Result{}, err
}
// 3. 创建或更新Deployment
found := &appsv1.Deployment{}
err := r.Get(ctx, types.NamespacedName{Name: deploy.Name, Namespace: deploy.Namespace}, found)
if err != nil && k8serrors.IsNotFound(err) {
log.Info("Creating Deployment", "name", deploy.Name)
if err := r.Create(ctx, deploy); err != nil {
return ctrl.Result{}, err
}
} else if err != nil {
return ctrl.Result{}, err
} else {
// 更新副本数
if found.Spec.Replicas != deploy.Spec.Replicas {
found.Spec.Replicas = deploy.Spec.Replicas
if err := r.Update(ctx, found); err != nil {
return ctrl.Result{}, err
}
}
}
// 4. 获取实际可用副本数
var podList corev1.PodList
if err := r.List(ctx, &podList, client.MatchingLabels{"app": webapp.Name}); err != nil {
return ctrl.Result{}, err
}
var available int32
for _, pod := range podList.Items {
for _, cond := range pod.Status.Conditions {
if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue {
available++
}
}
}
// 5. 更新Status
if webapp.Status.AvailableReplicas != available {
webapp.Status.AvailableReplicas = available
if err := r.Status().Update(ctx, &webapp); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil // 每30秒重新调和,保证状态一致
}
踩坑记录:
- OwnerReference一定要设置,否则删除WebApp时Deployment不会被级联删除。
- 用ptr.To() 而不是直接取地址,因为Replicas是*int32类型。
- Status更新必须用r.Status().Update(),不能用r.Update(),后者会更新整个对象导致冲突。
6. 本地运行与测试
先创建CRD:
make install
在另一个终端运行控制器(不部署到集群,便于调试):
make run
然后创建示例WebApp资源(保存为config/samples/app_v1_webapp.yaml):
apiVersion: app.mycompany.io/v1
kind: WebApp
metadata:
name: webapp-sample
spec:
replicas: 3
image: nginx:alpine
应用:
kubectl apply -f config/samples/app_v1_webapp.yaml
你会看到控制器日志输出“Creating Deployment”,然后可用副本数逐渐变为3。
测试更新replicas:
kubectl patch webapp webapp-sample --type='merge' -p '{"spec":{"replicas":5}}'
控制器会检测到变化并更新Deployment。然后kubectl get webapp会看到AvailableReplicas变为5。
7. 容器化与部署到集群
用make docker-build IMG=myregistry/webapp-operator:latest构建镜像,然后推送到仓库。接着用make deploy IMG=myregistry/webapp-operator:latest部署到集群。
注意:如果你本地用Kind,需要先把镜像load到集群:
kind load docker-image myregistry/webapp-operator:latest
我在这里卡了半小时:直接make deploy后Pod一直CrashLoopBackOff,原因是Role权限不足。默认生成的config/rbac/role.yaml只给了基本的get/list/watch权限,但我的Reconcile里需要create deployment和update status,需要手动在controllers/webapp_controller.go文件头增加注解:
//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch
//+kubebuilder:rbac:groups=app.mycompany.io,resources=webapps/status,verbs=update
然后重新make manifests生成RBAC YAML,再make deploy。
8. 踩坑总结与改进方向
| 坑 | 原因 | 解决方案 |
|---|---|---|
| Status更新报错 | 缺少subresource注解 | 加+kubebuilder:subresource:status |
| 删除WebApp后Deployment残留 | 未设置OwnerReference | 使用ctrl.SetControllerReference |
| 权限不足 | RBAC注解遗漏 | 检查所有涉及资源的verbs |
| 控制器无限重启 | Reconcile里发生错误且没有重试逻辑 | 返回ctrl.Result{}加error,或设置RequeueAfter |
| 镜像拉取失败 | 未load到Kind集群 | 用kind load docker-image |
扩展思考:
- 如果WebApp的Spec里增加了“环境变量”或“ingress”,如何优雅扩展?
- 如何为Operator编写单元测试?operator-sdk提供了envtest可以启动本地apiserver。
- 生产环境建议用make release打包为OLM格式,方便在OperatorHub上分发。
最后,Operator不是万能的——如果你的应用不需要自主决策,一个简单的Helm Chart加ArgoCD足矣。但当自动修复、自动扩缩、复杂状态机出现时,Operator就是最锋利的刀。现在你可以把这篇手记当作脚手架,去构建你的第一个生产级Operator了。

评论已关闭!