10. 准备部署
从本地到生产
上一章 make run 把 Controller 跑在本地——你 Ctrl+C 它就停。生产环境不能这样,得跑成集群里的 Pod,挂了自动重启,多副本做高可用。
这一章解决三个问题:
- 怎么打包:Controller 变成镜像,跑成 Pod
- 怎么高可用:多副本时多个 Controller 抢着调谐怎么办
- 怎么观测:跑起来后怎么知道它健康不健康、性能怎么样
镜像构建
Kubebuilder 生成的项目自带 Dockerfile:
# 多阶段构建:builder 阶段编译,runner 阶段只跑二进制
FROM golang:1.22 AS builder
WORKDIR /workspace
COPY go.mod go.sum ./
RUN go mod download
COPY api/ api/
COPY cmd/ cmd/
COPY internal/ internal/
RUN CGO_ENABLED=0 GOOS=linux go build -o manager cmd/main/main.go
FROM gcr.io/distroless/static:nonroot
WORKDIR /
COPY --from=builder /workspace/manager .
USER nonroot:nonroot
ENTRYPOINT ["/manager"]
几个关键点:
| 决策 | 原因 |
|---|---|
| 多阶段构建 | builder 阶段有 Go 工具链几百 MB,runner 阶段只拷二进制,镜像从 800MB 砍到 20MB |
CGO_ENABLED=0 | 纯静态二进制,不依赖 glibc,能在 distroless 里跑 |
distroless/static | 没有 shell、没有包管理器,攻击面小 |
nonroot | 用非 root 用户跑,符合 Pod Security Standards |
构建+推送:
# 用 Makefile 封装好的
make docker-build docker-push IMG=yourrepo/mysql-operator:v0.1.0
# 或手动
docker build -t yourrepo/mysql-operator:v0.1.0 .
docker push yourrepo/mysql-operator:v0.1.0
部署 manifest
Kubebuilder 生成的 config/manager/manager.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysql-operator
namespace: mysql-operator-system
spec:
replicas: 1 # 见后文 Leader Election
selector:
matchLabels:
control-plane: controller-manager
template:
metadata:
labels:
control-plane: controller-manager
spec:
serviceAccountName: mysql-operator # RBAC 绑的账号
containers:
- name: manager
image: yourrepo/mysql-operator:v0.1.0
args:
- --metrics-bind-address=:8080
- --health-probe-bind-address=:8081
- --leader-elect # 启用领导选举
ports:
- containerPort: 8080
name: metrics
- containerPort: 8081
name: health
livenessProbe:
httpGet:
path: /healthz
port: 8081
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /readyz
port: 8081
initialDelaySeconds: 5
periodSeconds: 10
resources:
limits:
cpu: 500m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
terminationGracePeriodSeconds: 10
逐项看为什么这么配。
serviceAccountName
Controller 用这个 ServiceAccount 调 API Server。上一章生成的 RBAC ClusterRole 绑到这个账号上——Controller 才有权限读写 MySQL、StatefulSet 这些资源。
# RBAC 绑定
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: mysql-operator-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: mysql-operator-role # 上一章 make manifests 生成的
subjects:
- kind: ServiceAccount
name: mysql-operator
namespace: mysql-operator-system
不打这个绑定,Controller 启动后第一个 API 调用就 403。
健康检查
两种 probe 用途不同:
| probe | 用途 | 失败了干嘛 |
|---|---|---|
| liveness | "Controller 还活着吗" | 重启容器 |
| readiness | "能接活了吗" | 暂时从 Service endpoints 摘掉(不影响 Controller,但影响被 Service 暴露的场景) |
/healthz 和 /readyz 是 controller-runtime 自动注册的路径——你的 Manager 启动时就有,不用自己写。
/readyz 返回 ok 表示缓存同步好了、可以处理事件了。/healthz 返回 ok 表示主循环没死。
resources
Controller 不吃资源,但必须给 limit。原因:
- Controller 内存泄漏(Informer 缓存失控、goroutine 泄漏)时,limit 是兜底
- 没有 limit 的 Pod 可能吃光节点内存,触发 OOM 杀别的 Pod
给个小 limit 就行——100m CPU + 128Mi 内存对大部分 Controller 够用。管大规模集群(几万资源)时再调大。
terminationGracePeriodSeconds
terminationGracePeriodSeconds: 10
Pod 被删时,kubelet 先发 SIGTERM,等 10 秒还没退就 SIGKILL。controller-runtime 的 SetupSignalHandler() 监听 SIGTERM,收到后:
- 停止接收新事件
- 等队列里的 key 处理完
- 退出
10 秒对大部分场景够。如果你的 Reconcile 调外部系统(慢),可能要调大。
Leader Election:多副本高可用
为什么不能多副本直接跑
假设 replicas: 3,三个 Controller 同时跑。一个 MySQL 资源变了,三个 Controller 都收到事件,都去 reconcile——同时创建 StatefulSet,要么冲突要么重复。
像三个厨师看到同一张订单,都去炒同一盘菜——端上来三份,客人傻眼。
Leader Election 怎么解决
多副本还是跑,但只有一个干活,其他待命。干活的叫 leader,挂了待命的顶上。
┌────────────────────────────────────────┐
│ Lease 对象(etcd 里) │
│ holder: mysql-operator-pod-1 │
│ renewTime: 2026-07-01T10:00:05Z │
└────────────────────────────────────────┘
▲
│ 每 2 秒续约
│
┌───────────────┼────────────────────────┐
│ pod-1 (leader) ──→ 干活 │
│ pod-2 ──→ 待命,每 1 秒检查 lease │
│ pod-3 ──→ 待命 │
└────────────────────────────────────────┘
机制:
- 三个 Pod 启动,都尝试创建/更新一个 Lease 对象
- 抢到的成为 leader,开始 Reconcile
- leader 每隔几秒续约(更新
renewTime) - 其他 Pod 周期性查 lease——
renewTime还新鲜,继续待命 - leader 挂了,续约停止,待命的发现 lease 过期,抢着接任
启用
main.go 里:
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
LeaderElection: true,
LeaderElectionID: "mysql-operator.example.com", # Lease 名字
LeaderElectionNamespace: "mysql-operator-system", # Lease 在哪个 namespace
})
或命令行:
args:
- --leader-elect
Lease 的几个参数
LeaderElectionReleaseOnCancel: true, # leader 退出时主动释放 lease
LeaseDuration: 15 * time.Second, # lease 有效期
RenewDeadline: 10 * time.Second, # 续约超时
RetryPeriod: 2 * time.Second, # 重试间隔
- LeaseDuration:lease 多久没续约算过期。设太短——网络抖动就换主;设太长——leader 真挂了要等很久才切换
- RenewDeadline:leader 续约失败的重试窗口
- RetryPeriod:非 leader 检查 lease 的频率
默认值(15s/10s/2s)对大部分场景合适。网络不稳的集群适当调大。
replicas 给多少
spec:
replicas: 2 # 或 3
给 1——单点,挂了就停。 给 2——挂一个还有一个顶。够用。 给 3——更稳,但浪费资源。除非真的极高可用要求。
不要给 5 或 10——leader 选举的性能跟副本数有关,副本越多选举越慢,收益递减。
指标暴露
controller-runtime 自带 Prometheus 指标,默认在 :8080/metrics:
controller_runtime_reconcile_total{controller="mysql"} 42
controller_runtime_reconcile_errors_total{controller="mysql"} 3
controller_runtime_reconcile_time_seconds{controller="mysql"} 0.15
workqueue_depth{controller="mysql"} 0
workqueue_adds_total{controller="mysql"} 42
workqueue_retries_total{controller="mysql"} 5
| 指标 | 含义 | 看什么 |
|---|---|---|
reconcile_total | reconcile 调用次数 | 趋势,看是不是疯狂调谐 |
reconcile_errors_total | 失败次数 | 不为 0 就查日志 |
reconcile_time_seconds | 单次耗时 | p99 太高就要优化 |
workqueue_depth | 队列积压 | 持续 > 0 说明处理跟不上 |
workqueue_retries_total | 重试次数 | 高说明有持续失败的资源 |
ServiceMonitor 暴露给 Prometheus
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: mysql-operator
namespace: mysql-operator-system
spec:
selector:
matchLabels:
control-plane: controller-manager
endpoints:
- port: metrics
path: /metrics
interval: 30s
配好后在 Grafana 建面板:
- Reconcile 速率(QPS)
- 错误率
- 队列深度
- Reconcile 延迟 p50/p95/p99
自定义指标
controller-runtime 默认指标偏运维视角(reconcile 次数、队列长度)。业务指标——比如"当前管着几个 MySQL 实例"——要自己加:
import (
"github.com/prometheus/client_golang/prometheus"
"sigs.k8s.io/controller-runtime/pkg/metrics"
)
var mysqlCount = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "mysql_operator_managed_instances",
Help: "当前管理的 MySQL 实例数",
},
)
func init() {
metrics.Registry.MustRegister(mysqlCount)
}
func (r *MySQLReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// ...
mysqls, _ := r.List(ctx, &mygroupv1.MySQLList{})
mysqlCount.Set(float64(len(mysqls.Items)))
// ...
}
metrics.Registry 是 controller-runtime 暴露给 /metrics 的注册表——你注册的指标自动出现在端点里。
部署到集群
# 1. 建 namespace
kubectl create namespace mysql-operator-system
# 2. 装 CRD
make install
# 3. 装 RBAC
kubectl apply -f config/rbac/
# 4. 部署 Operator
make deploy IMG=yourrepo/mysql-operator:v0.1.0
# 5. 看 Pod
kubectl get pods -n mysql-operator-system
# NAME READY STATUS RESTARTS AGE
# mysql-operator-xxx-yyy 1/1 Running 0 1m
完整部署清单
把这一章的组件拼起来:
┌─ Namespace: mysql-operator-system ──────────────────────┐
│ │
│ ┌─ Deployment ─────────────────────────┐ │
│ │ replicas: 2 │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ Pod 1 (leader) │ │ │
│ │ │ - manager 容器 │ │ │
│ │ │ 健康检查: /healthz │ │ │
│ │ │ 指标: :8080/metrics │ │ │
│ │ │ args: --leader-elect │ │ │
│ │ └──────────────────────────┘ │ │
│ │ ┌──────────────────────────┐ │ │
│ │ │ Pod 2 (standby) │ │ │
│ │ └──────────────────────────┘ │ │
│ └───────────────────────────────────────┘ │
│ │
│ ┌─ ServiceAccount ──┐ ┌─ ClusterRole ──────┐ │
│ │ mysql-operator │←──│ mysql-operator-role │ │
│ └───────────────────┘ └────────────────────┘ │
│ │
│ ┌─ Lease ───────────────────────────────┐ │
│ │ mysql-operator.example.com │ │
│ │ holder: mysql-operator-pod-1 │ │
│ └────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
前置阅读
- 使用Kubebuilder — 本章前置,部署的是上一章写的 Controller
- 工作负载管理 — Starter 第 5 章,Deployment 是 Controller 部署的基础
- 安全 — Starter 第 8 章,ServiceAccount/RBAC 是权限基础
- 超卖与QoS — Starter 第 10 章,resources 配置影响 QoS 等级
- 弹性伸缩 — Starter 第 9 章,HPA 也用 Leader Election 做高可用
思考题
问题 1:Leader Election 的 LeaseDuration 设成 1 秒会怎样?设成 1 小时呢?
问题 2:Deployment 的 replicas 给 2,但忘了开 --leader-elect,会发生什么?
问题 3:Controller Pod 被 OOM 杀了,正在处理的 Reconcile 会不会丢?
解答
问题 1:1 秒——网络抖动一次就换主,频繁切换 leader,每个 Pod 都在抢 lease 不干活。1 小时——leader 真挂了,要等 1 小时待命的才发现,期间没人 reconcile。合理值是 15-60 秒,跟网络稳定性挂钩。
问题 2:两个 Pod 同时跑,同时 Watch 同一个 MySQL 资源,同时 reconcile——重复创建 StatefulSet 导致冲突,或重复调谐外部系统造成副作用。必须开 leader-elect 保证只有一个干活。
问题 3:不会丢。Workqueue 是基于 etcd 的——key 在处理中时被标记为"processing",Pod 挂了,Done() 没被调用,key 在 lease 过期后被其他 Pod 重新取出处理。controller-runtime 的 Reconcile 失败 requeue 机制保证最终一致。
下一章
Controller 写完也部署了。最后一章我们跳出 K8S——看看 Controller 这种模式在 K8S 之外的应用。