Kubernetes Operator 入门

2025-11-07T14:11:02+08:00 | 22分钟阅读 | 更新于 2025-11-07T14:11:02+08:00

@

学习目标

学完本章,你应该能够:

  1. 用自己的话说清 Operator 是什么、解决什么问题——本质是把"运维人员的操作经验"代码化,让复杂有状态应用能自动化部署、升级、备份、恢复。
  2. 讲清 CRD 与 Controller 的分工,以及"声明式控制循环 + 水平触发"为什么是 Operator 的核心心智。
  3. 写出一个带 finalizer、状态更新、错误分级 的完整 Reconcile 函数,理解幂等性和 OwnerReference 的作用。
  4. 看懂并写出一份生产级 CRD:OpenAPI schema 校验、status 子资源、additionalPrinterColumnsobservedGeneration
  5. kubebuilder 从零搭一个 Operator 项目骨架,并把它装到测试集群里跑起来。

前置知识

  • 基本 K8s 概念:Pod、Deployment、StatefulSet、etcd 是什么。
  • 会写一点 Go(struct、interface、方法),不需要很熟。
  • 知道 YAML 的基本格式(缩进、键值对)。
  • 大致知道 controller-runtime / kubebuilder 是干嘛的(不熟也行,本章会带过)。

本章你会动手做的事

  1. kubebuilder init 起一个空的 Operator 项目骨架,感受目录结构。
  2. 给示例 AITask CRD 的 status.phase 加一个新枚举值,重新 make manifests 看生成的 YAML 变化。
  3. 故意在删除逻辑里不加 finalizer,删一个 CR,观察外部资源没被清理的"坑"。

先建立直觉:Operator 是"机器人 SRE"

类比:Operator 就像一个 7×24 小时不睡觉的"机器人 SRE(网站可靠性工程师)"。传统上,部署一套高可用 PostgreSQL 要靠真人 DBA 手动建实例、配主从复制、做备份、故障时手动把从库提升成主库。Operator 把这位 DBA 脑子里的"操作手册"写成代码,跑在 K8s 里——你只声明"我要一个 3 副本的 PG 集群",它自己把活全干了,主库挂了它还自己选从库顶上。一句话:把人肉运维经验沉淀成声明式配置

什么是 Operator

Operator 这玩意儿说白了就是 Kubernetes 的扩展机制,专门把特定领域知识(Domain Knowledge)编码进软件里,让复杂的有状态应用能自动化部署、升级、备份和恢复。CoreOS 在 2016 年提出这个概念,核心思想一句话:

将运维人员的操作经验转化为代码,并以自定义控制器(Controller)的形式运行在 Kubernetes 中。

常见的 Operator 实现有 etcd Operator、Prometheus Operator、MySQL Operator、Redis Operator 这些。

光说概念太空,我拿 PostgreSQL Operator 举个实在例子。你想想,传统方式部署一个高可用 PG 集群要干多少活?建 StatefulSet 管 Pod、配 PVC 存数据、搞主从复制、配 pg_hba.conf、做流复制、定时备份 WAL 日志、故障时手动 failover 把从库提升为主库……这些步骤全是 SRE 手动操作的"领域知识"。

PostgreSQL Operator(比如 CrunchyData 的 PGO)把这些全封装进去了。你只需要声明一个 PostgresCluster CR:

apiVersion: postgres-operator.crunchydata.com/v1beta1
kind: PostgresCluster
metadata:
  name: hippo
spec:
  image: registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-15.4-0
  postgresVersion: 15
  instances:
    - name: instance1
      replicas: 3            # 一个主两个从,PGO 自己管复制关系
      dataVolumeClaimSpec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 1Gi
  backups:
    pgbackrest:
      repos:
        - name: repo1
          schedules:
            full: "0 1 * * 0"           # 每周日凌晨全量备份
            incremental: "0 1 * * 1-6"  # 周一到周六增量

然后 PGO 就自己起 3 个 PG 实例、配好流复制、起 pgBackRest 做备份。主库挂了?Operator 自己挑一个从库 promote 成主库,再把其他从库指过去。你啥都不用管。

这就是 Operator 的价值——把"PG DBA 的脑子"装进了控制器。Prometheus Operator 管 alertmanager 配置、etcd Operator 管集群扩缩容和快照,都是同一个套路:把人肉运维步骤变成 reconcile 里的代码。

Operator 设计原理

Operator 通常由两部分组成:

  1. CRD(Custom Resource Definition):定义自定义资源的数据结构,相当于扩展了 Kubernetes API。
  2. Controller:监听 CR 的变化,执行 reconcile 逻辑,确保实际状态与期望状态一致。

建立直觉:CRD 与 Controller 的分工

类比:把 K8s 想成一家公司。CRD 是 HR 发布的"新岗位说明书"——规定了这个岗位叫什么(Kind)、归哪个部门(Group)、要填哪些简历字段(Spec)、汇报内容长啥样(Status)。Controller 是坐在这个岗位上的"员工"——他盯着所有填了这张表的人(CR 实例),不停核对"简历上写的要求"和"实际执行情况"对不对得上,对不上就动手调整,直到一致。员工随时可能被换(pod 重启),但只要岗位说明书在,换个员工照样干。

工作模式遵循 Kubernetes 的声明式控制循环:

用户声明 CR -> API Server 存储 -> Controller Watch 到变化
    -> Reconcile 调谐 -> 创建/更新/删除关联资源 -> 更新 CR 状态

下面把这条控制循环画成图,建议记住这个闭环——后面所有 reconcile 代码都是在这个环里转:

flowchart LR
    U[用户 kubectl apply CR] --> API[API Server 写入 etcd]
    API --> W[Controller Watch 到变化]
    W --> R[Reconcile 调谐]
    R --> S[创建 更新 删除 关联子资源]
    S --> ST[更新 CR 的 status]
    ST --> W

水平触发 vs 边缘触发:reconcile 的核心心智

这里有个坑我必须重点讲,因为很多人写了几个月 controller 都没真正想明白——为啥 reconcile 要设计成"水平触发"而不是"边缘触发"?

先说边缘触发(edge-triggered),就是"事件来了我才处理一次,处理完就完事"。比如某个 Pod 状态从 Running 变成 Error,你收到事件去处理,然后网络抖了一下你的处理逻辑挂了,这个事件就永远丢了,没人再管这个 Pod。

水平触发(level-triggered)不一样,它看的是"当前状态"而不是"状态变化"。每次 reconcile 进来,Controller 看的是"现在 CR 期望 3 副本,实际是 2 副本,差一个",就补一个。不管你中间崩了几次,只要下次 reconcile 还在跑,最终都会收敛到期望状态。

打个比方:边缘触发像你妈喊你吃饭,你没听见就饿着;水平触发像冰箱上贴了张菜单,你每次路过都对照一下还缺啥。

边缘触发(event-driven)            水平触发(level-triggered)
─────────────────────────────        ─────────────────────────────
事件 -> 处理一次 -> 丢弃              每次 reconcile 看当前状态
丢事件 = 永久失联                    崩了重启继续看状态
逻辑复杂:要自己记"处理到哪了"         逻辑简单:幂等地收敛状态

类比:reconcile 像月底对账。边缘触发是"每来一笔流水就处理一次,处理完就把纸条扔了"——某笔流水处理时系统崩了,这笔账就永远对不上。水平触发是"每次都拿出账本,对照’应有余额’和’实际余额’,差多少补多少"——崩了重启接着对账,最终一定平。这就是 K8s 偏爱水平触发的根本原因:不依赖事件、不怕丢

水平触发为什么"崩了重启还能收敛",看这张图最直观——它就是一个不断拿实际状态去对齐期望状态的循环:

flowchart TD
    A[Controller 被触发 reconcile] --> B[读取 CR 当前实际状态]
    B --> C[读取期望状态 spec]
    C --> D{实际状态 == 期望状态?}
    D -->|是| E[什么都不做 直接返回]
    D -->|否| F[创建 更新 删除 子资源 收敛状态]
    F --> G[更新 CR status]
    G --> A

Kubernetes 整个设计都偏爱水平触发。reconcile 函数你随便 kill -9,重启后从 last reconcile 重新看一遍状态,照样能收敛。写代码时就一个原则:别假设上次跑过啥,每次都从头看当前状态决定干啥

我个人觉得,理解了这点,你写 controller 就不会再去纠结"我这个事件漏处理了怎么办"——根本不需要处理事件,你处理的是状态。

Operator 生命周期

一个 Operator 的完整生命周期大致这几个阶段:

阶段说明
安装通过 YAML、Helm 或 OLM 部署 Operator
部署用户创建 CR,Operator 根据 CR 创建应用实例
升级修改 CR 或更新 Operator 镜像版本
扩缩容调整 CR 中的副本数或资源规格
备份恢复通过自定义逻辑实现数据保护
卸载删除 CR,Operator 清理关联资源

完整状态机

光看表格还是抽象,我把 CR 的状态流转画一下。一个 CR 从创建到销毁,大致经历这么几个 phase:

                    ┌──────────────┐
   kubectl apply ─> │  Pending     │  CR 刚创建,controller 还没 reconcile
                    └──────┬───────┘
                           │ reconcile 开始
                           v
                    ┌──────────────┐
                    │  Creating    │  正在创建子资源(Pod/PVC/Service)
                    └──────┬───────┘
                           │ 子资源就绪
                           v
                    ┌──────────────┐
        ┌──────────│  Running     │ <─────┐  reconcile 自愈
        │           └──────┬───────┘       │
        │                  │ spec 变更     │
        │                  v               │
        │           ┌──────────────┐       │
        │           │  Updating    │ ──────┘
        │           └──────────────┘
        │ 故障/删除
        v
   ┌──────────────┐         ┌──────────────┐
   │  Failed      │ <─────  │  Deleting    │  finalizer 清理外部资源
   └──────┬───────┘         └──────┬───────┘
          │ 重试成功                │ 清理完
          v                         v
   回到 Running              CR 从 etcd 删除

类比:这张图就是 CR 的"一生"。从 Pending(刚出生没人管)到 Creating(正在搭基础设施),到 Running(稳定运行),用户改配置了进 Updating(滚动更新),出故障进 Failed(但 reconcile 会自愈拉回 Running),被删除时进 Deleting(靠 finalizer 先把外部资源擦干净,才真正从 etcd 消失)。每个箭头都是一次 reconcile 的决策结果。

用 Mermaid 把这个状态机再画一遍,方便你对照记忆(注意 Deleting 必须等 finalizer 清理完才能回到起点之外):

stateDiagram-v2
    [*] --> Pending
    Pending --> Creating: reconcile 开始
    Creating --> Running: 子资源就绪
    Running --> Updating: 用户改 spec
    Updating --> Running: 滚动更新完成
    Running --> Failed: 发生故障
    Failed --> Running: 自愈成功
    Running --> Deleting: 用户删除 CR
    Deleting --> [*]: finalizer 清理完外部资源

几个关键点说下:

  • Pending → Creating:controller 第一次拿到 CR,初始化子资源。这步可能因为镜像拉不下来卡住,所以 status 要写清楚原因。
  • Running → Updating:用户改 spec(比如副本数 3 改 5),controller 看到实际跟期望对不上,进入 Updating。这里有个坑:更新过程要保证滚动更新,别一刀切全删。
  • Failed → Running:自愈靠 reconcile 循环,每次进来都检查是否真的 failed,恢复后自动回 Running。
  • Deleting:靠 finalizer 实现。没有 finalizer 的话,你删 CR,etcd 直接删掉,controller 根本来不及清理外部资源(比如 PG 集群在外部 RDS 上的实例)。

CRD 核心开发技术

CRD 通过 YAML 定义自定义资源的 Group、Version、Kind 和 Spec/Status 结构。说白了就是给 Kubernetes API 加一张新表。

建立直觉:CRD 是给 K8s API 加一张新表

类比:K8s 内置资源(Pod、Deployment)本质就是 API Server 后面 etcd 里的一张张"表"。CRD 让你自己再建一张新表——定义表名(Group/Kind)、字段结构(Spec/Status)、字段类型校验(schema 约束)。建好表后,你就能 kubectl apply 往里插"一行行记录"(CR 实例),而 Controller 就是盯着这张表、负责让记录里声明的状态变成现实的后台进程。前面那张 PostgresCluster 表就是 CrunchyData 定义的。

完整 CRD 示例(含 schema 校验、status 子资源、printer columns)

下面这个 CRD 我写得完整点,把实际项目里该有的东西都加上:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: aitasks.aiops.example.com
spec:
  group: aiops.example.com
  scope: Namespaced
  names:
    plural: aitasks
    singular: aitask
    kind: AITask
    shortNames:
      - at
  versions:
    - name: v1
      served: true
      storage: true
      # kubectl get 时显示的额外列,省得每次都要 -o yaml
      additionalPrinterColumns:
        - name: Target
          type: string
          jsonPath: .spec.targetDeployment
        - name: Threshold
          type: number
          jsonPath: .spec.threshold
        - name: Action
          type: string
          jsonPath: .spec.action
        - name: Phase
          type: string
          jsonPath: .status.phase
        - name: Age
          type: date
          jsonPath: .metadata.creationTimestamp
      schema:
        openAPIV3Schema:
          type: object
          # 顶层必填 spec,防止空 CR 进来
          required: ["spec"]
          properties:
            spec:
              type: object
              required: ["targetDeployment", "threshold", "action"]
              properties:
                targetDeployment:
                  type: string
                  # minLength 防止空字符串,踩过坑:空值会让 reconcile 里 Get 报 NotFound
                  minLength: 1
                  maxLength: 253
                namespace:
                  type: string
                  default: default
                threshold:
                  type: number
                  # 业务上阈值不该负数,用 minimum 兜底
                  minimum: 0
                action:
                  type: string
                  # enum 限制取值,挡住乱填的 CR
                  enum:
                    - restart_pod
                    - scale_up
                    - scale_down
                    - cordon_node
                    - noop
                intervalSeconds:
                  type: integer
                  minimum: 10
                  maximum: 3600
                  default: 60
                llmEndpoint:
                  type: string
                  # 用 format 做基本格式校验
                  format: uri
            status:
              type: object
              properties:
                phase:
                  type: string
                  enum: [Pending, Monitoring, Healing, Healed, Failed]
                lastDecision:
                  type: string
                lastExecutionTime:
                  type: string
                  format: date-time
                observedGeneration:
                  type: integer
                  format: int64
                conditions:
                  type: array
                  items:
                    type: object
                    required: ["type", "status"]
                    properties:
                      type:
                        type: string
                      status:
                        type: string
                        enum: ["True", "False", "Unknown"]
                      reason:
                        type: string
                      message:
                        type: string
                      lastTransitionTime:
                        type: string
                        format: date-time
      # status 子资源独立更新,避免和 spec 抢冲突
      subresources:
        status: {}

几个容易忽略的点我说下:

  • subresources.status: {}:这个不写的话,更新 status 要带整个 spec 一起 PUT,冲突概率高得离谱。开了之后用 /status 子路径单独更新,spec 和 status 各走各的。
  • additionalPrinterColumns:不写的话 kubectl get at 只能看到 NAME 和 AGE,啥信息都没有。加上之后直接能看 phase、target,排查问题快很多。
  • observedGeneration:status 里加这个字段,写 metadata.generation 的值。这样能判断"controller 有没有处理过最新一次 spec 变更"——如果 generation 比 observedGeneration 大,说明 controller 还没跟上。
  • enum + minimum:能在 API Server 层挡住一堆垃圾输入,别等到 reconcile 里才发现 spec 是负数。

⚠️ 新手必踩的坑:漏了 observedGeneration 导致"改了没生效"假象。你改了 CR 的 spec(比如把副本数从 3 改成 5),但 Controller 因为某种原因卡住没处理。如果 status 里没记 observedGeneration,你 kubectl get 看到 phase 还是旧值,会以为"改了不生效",其实是 controller 还没跟上。加上这个字段,一眼就能对比 metadata.generationstatus.observedGeneration 是否相等,判断 controller 是否处理过最新变更。

踩坑提示:CRD schema 是 OpenAPI v3 子集,不支持 oneOf 在某些老版本里行为怪异,复杂校验还是放 controller 里做更稳。还有 x-kubernetes-list-type: map 这种高级特性在老集群(1.18 之前)不认,升级集群前别用。

Controller 与 Reconcile

Controller 的核心就是 reconcile 函数。每次资源变化,Controller 把资源的 Namespace/Name 丢进工作队列,worker 慢慢消费,调 reconcile。

建立直觉:reconcile 是"对账"不是"响应事件"

类比:很多人初学会把 reconcile 写成"收到事件→执行一次操作"。这是边缘触发的旧习惯,在 K8s 里是错的。正确的心智是:reconcile 是对账函数——它不关心"刚才发生了什么事件",只关心"现在实际状态和期望状态差在哪",然后让实际对齐期望。因为是对账,所以幂等:查 100 遍账,结果一样;因为是对账,所以不怕崩:崩了重启再查一遍账,照样平。带着这个心智看下面这段代码,所有 return ctrl.Result{...} 都是在说"这一轮先到这,下一轮接着对账"。

reconcile 主流程其实就是上一节那张"实际==期望?“循环的代码化,先上一张流程图帮你建立整体路径感,再读代码就不晕了:

flowchart TD
    Q[收到 Request Namespace/Name] --> G[Get CR]
    G -->|NotFound| X[直接返回 忽略这次事件]
    G -->|存在| D{DeletionTimestamp 非零?}
    D -->|是| DEL[reconcileDelete 清理外部资源 去掉 finalizer]
    D -->|否| F{已有 finalizer?}
    F -->|否| ADD[加上 finalizer 更新 返回 requeue]
    F -->|是| MAIN[主逻辑 检查目标 执行业务]
    MAIN --> UP[更新 status 并同步 observedGeneration]
    UP --> RQ[RequeueAfter 周期监控]

完整 kubebuilder 风格 reconcile

下面这段代码是实际项目里能跑的完整 reconcile,带 finalizer、状态更新、错误重试,我逐段加注释:

package controllers

import (
	"context"
	"fmt"
	"time"

	appsv1 "k8s.io/api/apps/v1"
	"k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
	"sigs.k8s.io/controller-runtime/pkg/log"

	aiopsv1 "github.com/example/aiops-operator/api/v1"
)

const (
	// finalizer 标识,删除时用来触发清理逻辑
	aiTaskFinalizer = "aiops.example.com/finalizer"
	// 默认 requeue 间隔,监控类任务用
	defaultRequeue = 60 * time.Second
)

type AITaskReconciler struct {
	client.Client
	Scheme *runtime.Scheme
}

func (r *AITaskReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	log := log.FromContext(ctx)

	// 1. 拿 CR。NotFound 不报错,可能是被删了,直接返回
	var task aiopsv1.AITask
	if err := r.Get(ctx, req.NamespacedName, &task); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	// 2. 处理删除:DeletionTimestamp 不为零说明在删,走 finalizer 清理
	if !task.DeletionTimestamp.IsZero() {
		return r.reconcileDelete(ctx, &task)
	}

	// 3. 确保 finalizer 存在。没加就加上,这步必须在任何外部副作用之前
	if !controllerutil.ContainsFinalizer(&task, aiTaskFinalizer) {
		controllerutil.AddFinalizer(&task, aiTaskFinalizer)
		if err := r.Update(ctx, &task); err != nil {
			// 冲突就重试,别硬刚
			return ctrl.Result{Requeue: true}, nil
		}
		return ctrl.Result{Requeue: true}, nil
	}

	// 4. 主逻辑:检查目标 Deployment 是否存在
	var dep appsv1.Deployment
	if err := r.Get(ctx, types.NamespacedName{
		Namespace: task.Spec.Namespace,
		Name:      task.Spec.TargetDeployment,
	}, &dep); err != nil {
		if errors.IsNotFound(err) {
			// 目标不存在,标记 Failed,但不重试(用户得自己改 spec)
			_ = r.updateStatus(ctx, &task, "Failed", "target deployment not found", "")
			return ctrl.Result{RequeueAfter: defaultRequeue}, nil
		}
		// 其他错误(API Server 抖动)短重试
		return ctrl.Result{}, err
	}

	// 5. 执行业务逻辑(这里简化为占位,实际项目里调 Prometheus + LLM)
	action, err := r.evaluateAndAct(ctx, &task, &dep)
	if err != nil {
		log.Error(err, "evaluate action failed")
		// 业务错误:更新 status 为 Failed,但短 requeue 重试,避免永久卡死
		_ = r.updateStatus(ctx, &task, "Failed", err.Error(), "")
		return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
	}

	// 6. 更新 status。注意 observedGeneration 要同步
	phase := "Monitoring"
	if action != "" && action != "noop" {
		phase = "Healed"
	}
	if err := r.updateStatus(ctx, &task, phase, "", action); err != nil {
		return ctrl.Result{}, err
	}

	// 7. 周期性 reconcile,持续监控
	interval := time.Duration(task.Spec.IntervalSeconds) * time.Second
	if interval == 0 {
		interval = defaultRequeue
	}
	return ctrl.Result{RequeueAfter: interval}, nil
}

// reconcileDelete 清理外部资源。finalizer 模式的关键:清理完才能去掉 finalizer
func (r *AITaskReconciler) reconcileDelete(ctx context.Context, task *aiopsv1.AITask) (ctrl.Result, error) {
	log := log.FromContext(ctx)

	// 这里清理外部副作用,比如撤销 cordon、删外部 webhook 注册等
	// 示例:如果 action 是 cordon_node,要记得 uncordon
	if task.Spec.Action == "cordon_node" {
		if err := r.cleanupCordon(ctx, task); err != nil {
			log.Error(err, "cleanup cordon failed, will retry")
			// 清理失败绝不能去掉 finalizer,否则资源泄漏
			return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
		}
	}

	controllerutil.RemoveFinalizer(task, aiTaskFinalizer)
	if err := r.Update(ctx, task); err != nil {
		return ctrl.Result{Requeue: true}, nil
	}
	return ctrl.Result{}, nil
}

// updateStatus 统一更新 status,处理冲突
func (r *AITaskReconciler) updateStatus(ctx context.Context, task *aiopsv1.AITask, phase, reason, decision string) error {
	task.Status.Phase = phase
	task.Status.LastDecision = decision
	task.Status.LastExecutionTime = metav1.Now().Format(time.RFC3339)
	task.Status.ObservedGeneration = task.Generation
	// status 更新用 Status().Update 更稳,避免覆盖 spec
	return r.Status().Update(ctx, task)
}

// evaluateAndAct 业务逻辑占位
func (r *AITaskReconciler) evaluateAndAct(ctx context.Context, task *aiopsv1.AITask, dep *appsv1.Deployment) (string, error) {
	// 实际项目:查 Prometheus -> 判断阈值 -> 调 LLM -> 执行
	// 这里只返回 noop,下一篇实战里补全
	return "noop", nil
}

func (r *AITaskReconciler) cleanupCordon(ctx context.Context, task *aiopsv1.AITask) error {
	// 实现略,记得把之前 cordon 过的节点 uncordon
	return nil
}

// SetupWithManager 注册 controller 要 watch 的资源
func (r *AITaskReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&aiopsv1.AITask{}).
		// Owns 表示管理子资源(Deployment 变了也触发 reconcile),需要 OwnerReference
		Owns(&appsv1.Deployment{}).
		Complete(r)
}

// 防止 import 未使用
var _ = fmt.Sprintf

reconcile 函数有几条铁律我反复强调:

  • 幂等性:同一条事件处理 100 遍结果一致。别在里面写"如果没执行过就执行"这种逻辑,靠 status 记录状态判断。
  • 快速返回:别在 reconcile 里 sleep 或调慢接口。慢操作堵 workqueue,反正有 RequeueAfter。
  • 错误分级:可重试错误(API 抖动)返回 Requeue,业务错误(目标不存在)更新 status 后 RequeueAfter,终态错误不重试。
  • OwnerReference:创建子资源时设上,级联删除自动搞定,省得写一堆清理代码。

⚠️ 新手必踩的坑:在 reconcile 里写了"只执行一次"的逻辑。比如"如果这个 Deployment 还没创建过,就创建它”——听起来没问题,但水平触发下 reconcile 会被调用很多次(事件、周期 requeue、controller 重启)。正确做法是永远基于当前状态决定动作:"Get 不到就创建,拿到了就对比 spec 决定更不更新"。靠 status 里记录的 observedGeneration / phase 来判断"该不该做",而不是靠"我刚才做没做"。违反幂等性,轻则重复创建资源报 AlreadyExists,重则状态错乱。

踩坑提示:r.Status().Update 在没开 CRD 的 subresources.status 时会报错"the body of the request was in an unknown state",这个报错信息巨误导,实际就是 CRD 里忘了配 status 子资源。还有个坑:Update 失败(冲突)时别用 Requeue: true 死循环重试,加个退避,不然 API Server 被你打爆。

Operator 开发工具链

常用的 Operator 开发工具就那么几个:

  • kubebuilder:基于 CRD 和 controller-runtime 的项目脚手架,功能完整,Kubernetes 官方在推。
  • operator-sdk:RedHat 主导,支持 Go、Ansible、Helm 三种 Operator 类型。
  • controller-runtime:底层库,kubebuilder 和 operator-sdk 都基于它。

kubebuilder vs operator-sdk 怎么选

这俩我都被坑过,直接上对比:

                  kubebuilder              operator-sdk
─────────────     ───────────────────      ──────────────────────
主导方            Kubernetes SIG           RedHat
语言支持          Go                       Go / Ansible / Helm
脚手架风格        纯 Go,目录清晰          多语言,配置略繁
与 OLM 集成       弱                       强(OLM 是 RedHat 的)
学习曲线          平缓,文档好             稍陡,但 Ansible Operator 对运维友好
生成代码质量      干净                     Go 模式跟 kubebuilder 差不多
社区活跃度        高                       高

我个人觉得:纯 Go 写 controller 直接上 kubebuilder,文档和示例都全;如果团队 SRE 不懂 Go、只会 Ansible,那 operator-sdk 的 Ansible Operator 模式能让他们直接用 playbook 写 controller,门槛低很多。Helm Operator 适合把已有 Helm Chart 包成 Operator,但定制能力弱,复杂场景别用。

kubebuilder init 命令完整流程

从零搭一个项目,命令就这几条:

# 1. 初始化项目骨架。--domain 决定 API group 后缀
kubebuilder init --domain aiops.example.com \
    --repo github.com/example/aiops-operator \
    --license apache2

# 2. 创建一个 API(CRD + Controller 一起生成)
# --group/--version/--kind 决定 CR 的 GVK
kubebuilder create api --group aiops --version v1 --kind AITask

# 3. 交互式会问 "Create Resource" 和 "Create Controller",都选 y
#    选 n 的话只生成 CRD 不生成 controller

# 4. 改 api/v1/aitask_types.go 里的 Spec/Status 结构体
#    然后重新生成 deepcopy 和 CRD YAML
make manifests
make generate

# 5. 本地装 CRD 跑起来(需要 kubeconfig 指向测试集群)
make install
make run

# 6. 构建、推送镜像、部署到集群
make docker-build docker-push IMG=registry.example.com/aiops-operator:v0.1
make deploy IMG=registry.example.com/aiops-operator:v0.1

踩坑提示:kubebuilder create api 之后,如果改了 _types.go 里的字段,必须重新跑 make manifests && make generate,不然 CRD YAML 和 DeepCopy 方法是旧的,运行时会出现"字段明明写了但 controller 拿不到"的玄学问题。还有,make run 默认会用 ~/.kube/config,跑生产集群前确认 kubeconfig 指向对的环境,我见过有人本地 make run 直接连上生产把 CRD 装上去的,差点背锅。

基础 Operator 项目结构

kubebuilder 生成的项目结构大概长这样:

aiops-operator/
├── api/
│   └── v1/
│       ├── aitask_types.go          # CR 类型定义(Spec/Status 结构体)
│       ├── groupversion_info.go     # GroupVersion 注册
│       └── zz_generated.deepcopy.go # 自动生成的 DeepCopy 方法,别手改
├── cmd/
│   └── main.go                      # 入口(新版 kubebuilder 放 cmd 下)
├── config/
│   ├── crd/
│   │   ├── bases/                   # 生成的 CRD YAML
│   │   └── kustomization.yaml
│   ├── default/                     # 默认 kustomize 配置
│   ├── manager/                     # Operator Deployment YAML
│   ├── prometheus/                  # ServiceMonitor(监控用)
│   ├── rbac/                        # ClusterRole/Binding
│   └── samples/                     # 示例 CR
├── controllers/
│   ├── aitask_controller.go         # 控制器逻辑(reconcile 在这)
│   └── suite_test.go                # envtest 集成测试
├── internal/
│   └── llm/                         # 自定义的内部包(LLM 客户端等)
├── Dockerfile
├── Makefile
├── go.mod
├── go.sum
└── PROJECT                          # kubebuilder 元数据

main.go 启动代码

入口文件干的事其实就三件:注册 Scheme、启动 Manager、把 Reconciler 挂上去。我贴一个完整的:

package main

import (
	"flag"
	"os"

	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/runtime"
	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
	_ "k8s.io/client-go/plugin/pkg/client/auth"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/healthz"
	"sigs.k8s.io/controller-runtime/pkg/log/zap"
	metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"

	aiopsv1 "github.com/example/aiops-operator/api/v1"
	"github.com/example/aiops-operator/controllers"
)

var (
	scheme   = runtime.NewScheme()
	setupLog = ctrl.Log.WithName("setup")
)

func init() {
	// 注册所有要操作的内置类型,不然 client.Get 拿不到对象
	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
	utilruntime.Must(appsv1.AddToScheme(scheme))
	utilruntime.Must(corev1.AddToScheme(scheme))
	// 注册自定义 CR 类型
	utilruntime.Must(aiopsv1.AddToScheme(scheme))
}

func main() {
	var metricsAddr string
	var probeAddr string
	var enableLeaderElection bool
	flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "Metrics 地址")
	flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "Probe 地址")
	flag.BoolVar(&enableLeaderElection, "leader-elect", false,
		"多副本时开 leader election,避免重复 reconcile")
	flag.Parse()

	// 日志用 zap,生产环境别用默认的
	opts := zap.Options{
		Development: false,
	}
	ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))

	// 启动 manager,它管着 cache、client、metrics 这些公共组件
	mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
		Scheme:                 scheme,
		Metrics:                metricsserver.Options{BindAddress: metricsAddr},
		HealthProbeBindAddress: probeAddr,
		LeaderElection:         enableLeaderElection,
		LeaderElectionID:       "aiops-operator.example.com",
	})
	if err != nil {
		setupLog.Error(err, "unable to start manager")
		os.Exit(1)
	}

	// 把 Reconciler 注册到 manager,并指定它要 watch 哪些资源
	if err = (&controllers.AITaskReconciler{
		Client: mgr.GetClient(),
		Scheme: mgr.GetScheme(),
	}).SetupWithManager(mgr); err != nil {
		setupLog.Error(err, "unable to create controller", "controller", "AITask")
		os.Exit(1)
	}

	// 健康检查 + 就绪检查,给 kubelet 探针用
	if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
		setupLog.Error(err, "unable to set up health check")
		os.Exit(1)
	}
	if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
		setupLog.Error(err, "unable to set up ready check")
		os.Exit(1)
	}

	setupLog.Info("starting manager")
	if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
		setupLog.Error(err, "problem running manager")
		os.Exit(1)
	}
}

几个地方我特别说下为啥这么写:

  • init() 里注册 Scheme:client-go 的 client 是泛型的,必须告诉它"我认识哪些 GVK"。漏注册某个类型,r.Get 会直接报"no kind is registered for the type"。
  • LeaderElection:Operator 部署多副本时必须开,不然两个 controller 同时 reconcile 同一个 CR,状态乱套。开了之后用 lease 抢锁,同一时刻只有一个生效。
  • SetupSignalHandler:优雅退出,收到 SIGTERM 时先停 controller、flush 缓存再退出,避免 workqueue 里的事件丢失。
  • healthz/readyz:Deployment 的 livenessProbe/readinessProbe 指向这俩,不然 Pod 起不来 controller 还被当健康。

类比:main.go 的启动流程,就像一家分公司开张——先在 init() 里把"公司通讯录"(Scheme)备齐,谁都认识;然后 NewManager 把"办公场地和后勤"(cache/client/metrics)租好;SetupWithManager 把"业务负责人"(Reconciler)安排上岗、告诉他盯哪些业务;AddHealthzCheck 给大楼装好消防和安检;最后 Start 正式开门营业。下面这张图就是这条启动链:

flowchart TD
    I[init 注册 Scheme
内置类型 + 自定义 CR] --> M[NewManager 创建 Manager
管 cache client metrics] M --> S[SetupWithManager 注册 Reconciler
For + Owns] S --> H[AddHealthzCheck / AddReadyzCheck] H --> ST[SetupSignalHandler 启动 Manager]

踩坑提示:metrics-bind-address 默认是 :8080,跟 Prometheus Operator 的 ServiceMonitor 端口对齐。如果你集群里别的服务占了 8080,启动直接挂,记得改。另外 LeaderElectionID 必须全集群唯一,两个 operator 用同一个 ID 会互相抢锁,表现为 controller 隔几秒就重启,日志一堆 leader election 失败。

总结

Operator 是 Kubernetes 平台工程的核心能力。CRD 扩展 API、Controller 跑 reconcile 把状态收敛到期望,这套模式能把任意复杂的运维场景沉淀成声明式配置。

说实话我刚学的时候觉得 reconcile 这套"水平触发"设计特别绕,但写多了就发现它太省心了——你不用操心事件丢了、controller 崩了、网络抖了,每次进来从头看状态决定干啥就行。下一篇笔记会在这基础上接 LLM,做一个能自己判断故障、自己修复的 AIOps Operator。

自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. Operator 的核心思想一句话是什么?它把什么"代码化"了?请举一个 etcd / Prometheus / MySQL Operator 之外的有状态应用例子。
  2. 水平触发(level-triggered)和边缘触发(edge-triggered)的本质区别是什么?为什么 Kubernetes 整个设计都偏爱水平触发?
  3. CRD 里 subresources.status: {} 不写会怎样?observedGeneration 字段是拿来干嘛的?
  4. reconcile 函数有哪几条铁律(至少说出幂等、快速返回、错误分级、OwnerReference 四条)?每条违反分别会有什么后果?
  5. 为什么删除 CR 必须靠 finalizer 才能清理外部资源?如果一个 CR 没有 finalizer,你 kubectl delete 之后会发生什么?

动手练习(建议真做一遍)

  1. kubebuilder init 起一个空项目,再 kubebuilder create api --group aiops --version v1 --kind AITask 生成骨架,跑 make manifests 看生成的 CRD YAML 长什么样。
  2. AITask CRD 的 status.phase 加一个新枚举值(比如 Scaling),改完重新 make manifests,再用 kubectl explain aitask.status.phase 验证枚举生效。
  3. 故意在 reconcileDelete 里注释掉 controllerutil.RemoveFinalizer(...),apply 一个 CR 再 kubectl delete,观察 CR 一直卡在 Terminating、且外部资源没被清理的现象,理解 finalizer 的作用。

本章小结

  • Operator = CRD(扩展 API / 加一张新表)+ Controller(对账进程),核心是把运维领域知识代码化,让复杂有状态应用自动化。
  • 设计灵魂是声明式控制循环 + 水平触发:reconcile 看的是"当前状态",不是"刚才的事件",所以崩了重启照样收敛、逻辑天然幂等。
  • CR 有完整生命周期与状态机(Pending → Creating → Running ↔ Updating / Failed → Deleting),Deleting 必须等 finalizer 把外部资源清理完,才能真正从 etcd 消失。
  • 生产级 CRD 要配齐 schema 校验、status 子资源、additionalPrinterColumns、observedGeneration,把垃圾输入挡在 API Server 层。
  • reconcile 铁律:幂等、快速返回、错误分级、OwnerReference;删除靠 finalizer,更新 status 用 Status().Update
  • kubebuilder 一条龙:initcreate api → 改 _types.gomake manifests && make generatemake installmake run,改了类型定义必须重新生成。

下一篇笔记会在这套 Operator 骨架上接入 LLM,做一个能自己判断故障、自己执行修复动作的 AIOps Operator——把"人看告警再动手"升级成"系统自己诊断自己修"。

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!