Appearance
Leader Election 与高可用
本篇讲解 Operator 的高可用机制——Leader Election(选主)。前面章节我们的 Operator 都是单副本运行的,一旦 Pod 所在节点宕机,Operator 就停了,直到 K8s 重新调度它。对于关键业务,这种中断不可接受。Leader Election 让 Operator 多副本部署,但同一时刻只有一个副本真正干活,其余待命;Leader 挂了,待命副本立即接管。本篇会讲清楚它的原理、controller-runtime 的配置、切换流程、问题排查,并给出高可用 Deployment 的完整示例。
一、为什么需要 Leader Election
1. 多副本的问题
直觉上,高可用就是「多跑几个副本」。但对 Operator 来说,简单多副本会出问题:
- 重复操作:两个 Controller 同时看到「Deployment 不存在」,都去 Create,一个成功一个报
AlreadyExists。 - 状态冲突:两个 Controller 同时 Update 同一个 Deployment,互相覆盖,最后状态不可预测。
- Finalizer 竞争:删 CR 时两个 Controller 都试图清理外部资源,可能重复删除或互相干扰。
Controller 本质是有状态的控制循环,不能像无状态 Web 服务那样简单水平扩展。它需要的是「主备」——多个副本部署,但只有一个活跃。
2. Leader Election 的思路
Leader Election(选主)就是解决这个问题的标准方案:
- 部署 N 个 Operator 副本。
- 启动时它们竞争一个「锁」(K8s 里通常是 Lease 资源)。
- 抢到锁的成为 Leader,开始运行 Controller。
- 其余副本待命,持续监视锁。
- Leader 挂了(不再续约),待命副本中最快的一个抢到锁,成为新 Leader。
这样任何时刻只有一个 Controller 在工作,避免了重复操作;同时 Leader 故障时能在秒级切换,实现高可用。
二、Leader Election 原理
1. Lease 资源
controller-runtime 的 Leader Election 基于 K8s 的 coordination.k8s.io/v1 Lease 资源。Lease 本质是一个带过期时间的「锁」:
yaml
apiVersion: coordination.k8s.io/v1
kind: Lease
metadata:
name: redis-operator.example.com
namespace: operator-system
spec:
holderIdentity: "redis-operator-pod-1" # 当前持有者
leaseDurationSeconds: 15 # 租约有效期
renewTime: "2026-08-01T10:00:10Z" # 上次续约时间
acquireTime: "2026-08-01T09:50:00Z" # 获取时间
leaseTransitions: 3 # 切换次数关键字段:
- holderIdentity:当前 Leader 的标识(通常是 Pod 名)。
- leaseDurationSeconds:租约有效期。Leader 必须在这个时间内续约,否则视为失联。
- renewTime:上次续约时间。待命副本比较
now - renewTime是否超过leaseDurationSeconds来判断 Leader 是否失联。
2. 选主流程
副本 A、B、C 启动
│
▼
尝试创建/更新 Lease
│
┌────┴────┐
│ A 抢到 │ B、C 失败,进入待命
└────┬────┘
│ A 成为 Leader,启动 Controller
│ A 每隔 renewDeadline(如 10s)续约 Lease
│
│ B、C 每隔 retryPeriod(如 2s)尝试抢锁
│ 但 renewTime 还新鲜,抢不到
│
┌────┴────────────┐
│ A 宕机,停止续约 │
└────┬────────────┘
│ Lease 的 renewTime 不再更新
│
│ 超过 leaseDurationSeconds(15s)后
│ B 或 C 的下一次尝试成功抢到锁
│
▼
新 Leader 启动 Controller,接管工作3. 三个关键时间参数
controller-runtime 的 Leader Election 有三个核心参数,理解它们的关系至关重要:
| 参数 | 含义 | 典型值 |
|---|---|---|
LeaseDuration | 租约有效期,超过则认为 Leader 失联 | 15s |
RenewDeadline | Leader 续约间隔,必须 < LeaseDuration | 10s |
RetryPeriod | 待命副本尝试抢锁的间隔 | 2s |
它们的关系:
- Leader 每
RenewDeadline(10s)续约一次,保证renewTime在LeaseDuration(15s)内始终新鲜。 - 待命副本每
RetryPeriod(2s)尝试一次抢锁,但只要now - renewTime < LeaseDuration,API Server 会拒绝(lease 还有效)。 - Leader 挂了,续约停止。等
now - renewTime > LeaseDuration(最多 15s)后,待命副本的下一次尝试才能成功。
所以故障切换的最大延迟 ≈ LeaseDuration + RetryPeriod(15s + 2s = 17s)。想缩短切换时间就调小 LeaseDuration,但太小容易误判(网络抖动就切)。
三、controller-runtime 的 Leader Election
1. Manager 选项
controller-runtime 在 Manager 层面内置了 Leader Election,开启非常简单:
go
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: "8080"},
HealthProbeBindAddress: "8081",
// 开启 Leader Election
LeaderElection: true,
// Lease 资源名(必须全局唯一,建议用 <operator>.<domain> 格式)
LeaderElectionID: "redis-operator.example.com",
// 锁类型,默认 leases(推荐)
LeaderElectionResourceLock: "leases",
// 部署的 namespace(Lease 创建在这里)
LeaderElectionNamespace: "operator-system",
})开启后,Manager.Start() 会先尝试获取 Lease,成功后才启动 Controller;待命副本的 Controller 不会启动。
2. Lease 配置(时间参数)
默认时间参数是 LeaseDuration=15s, RenewDeadline=10s, RetryPeriod=2s,对大多数场景够用。如需调整,用 ctrl.Options 里的字段(controller-runtime 较新版本)或通过 manager.Options 的 LeaseDuration 等:
go
import (
"time"
"k8s.io/apimachinery/pkg/util/wait"
"sigs.k8s.io/controller-runtime/pkg/manager"
)
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
LeaderElection: true,
LeaderElectionID: "redis-operator.example.com",
LeaderElectionResourceLock: "leases",
LeaderElectionNamespace: "operator-system",
// 自定义时间参数
LeaderElectionLeaseDuration: 15 * time.Second,
LeaderElectionRenewDeadline: 10 * time.Second,
LeaderElectionRetryPeriod: 2 * time.Second,
})调整建议:
- 低延迟要求:LeaseDuration=10s, RenewDeadline=6s, RetryPeriod=2s。切换延迟约 12s。
- 稳定优先(避免误切):LeaseDuration=30s, RenewDeadline=20s, RetryPeriod=5s。切换延迟约 35s,但抗网络抖动。
- 生产推荐:保持默认 15/10/2,平衡切换速度和稳定性。
3. 命令行参数化
通常把 Leader Election 做成命令行 flag,方便部署时控制:
go
func main() {
var enableLeaderElection bool
flag.BoolVar(&enableLeaderElection, "leader-elect", false,
"Enable leader election for controller manager. "+
"Enabling this will ensure there is only one active controller manager.")
var leaderElectionID string
flag.StringVar(&leaderElectionID, "leader-election-id",
"redis-operator.example.com", "Leader election lease name")
flag.Parse()
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
LeaderElection: enableLeaderElection,
LeaderElectionID: leaderElectionID,
LeaderElectionResourceLock: "leases",
})
// ...
}四、Leader Election 工作流程
1. 获取 Lease
Manager 启动时,调用 leaderelection.RunOrDie(client-go 提供):
- 尝试
CreateLease(如果不存在)或Update(把 holderIdentity 改成自己)。 - 成功 → 成为 Leader,启动所有 Controller。
- 失败(lease 被别人持有且未过期)→ 进入待命,每
RetryPeriod重试。
2. 续约
Leader 启动后,后台 goroutine 每 RenewDeadline 更新 Lease 的 renewTime。续约用的是乐观并发(带 resourceVersion 的 Update),如果续约时发现 resourceVersion 变了(被别人抢了),Leader 失去身份,停止 Controller。
3. 切换
当 Leader 进程被杀/节点宕机:
- 续约停止,
renewTime不再更新。 - 待命副本的下次尝试:发现
now - renewTime > LeaseDuration,更新 Lease 把 holderIdentity 改成自己。 - 新 Leader 启动 Controller,从 API Server 重新获取所有 CR 的当前状态,继续 Reconcile。
因为 Reconcile 是幂等的(Level-Triggered),切换后新 Leader 重新评估所有状态,不会出错。这就是为什么前几章反复强调「Reconcile 不要依赖内存状态」——切换后内存状态丢失,必须从 API Server 重建。
五、优雅切换:旧的 Leader 释放资源
1. 默认行为:非优雅
默认情况下,Leader 被杀时是「硬切」——Lease 自然过期,待命副本抢锁。期间有一个 LeaseDuration 的窗口期没有 Controller 工作。对于有 Finalizer 的 CR,如果删 CR 时正好 Leader 挂了,清理会推迟到新 Leader 上任。
2. 优雅退出
controller-runtime 通过 ctrl.SetupSignalHandler() 监听 SIGTERM/SIGINT。收到信号时,Manager 会:
- 停止接收新的 Reconcile。
- 等待正在进行的 Reconcile 完成(有 grace period)。
- 主动释放 Lease(删除或清空 holderIdentity)。
主动释放后,待命副本能立即抢锁(不用等 LeaseDuration),切换延迟降到 RetryPeriod(2s)级别。
go
// SetupSignalHandler 已经处理了优雅退出
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
setupLog.Error(err, "problem running manager")
os.Exit(1)
}3. 配合 Pod 优雅终止
要让优雅切换真正生效,Operator 的 Deployment 要配合 terminationGracePeriodSeconds:
yaml
spec:
template:
spec:
terminationGracePeriodSeconds: 30 # 给 Manager 足够时间释放 Lease
containers:
- name: manager
# 确保收到 SIGTERM 时能处理
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"] # 等 endpoint 摘除K8s 删 Pod 时先发 SIGTERM,等 terminationGracePeriodSeconds(默认 30s)后发 SIGKILL。Manager 在 SIGTERM 后释放 Lease,待命副本立即接管。如果 30s 不够(比如有慢 Reconcile),调大这个值。
4. OnStoppedLeading 回调
controller-runtime 的 leaderelection 有个 OnStoppedLeading 回调,Leader 失去身份时触发。默认实现是让进程退出(这样 K8s 会重启它重新竞争):
go
// 如果失去 Leader 身份,进程退出,由 K8s 重启
OnStoppedLeading: func() {
setupLog.Info("leader election lost, exiting")
os.Exit(0)
},退出比「待命空跑」更安全——避免一个进程既不是 Leader 又在跑 Controller 的歧义状态。
六、排查 Leader Election 问题
1. 谁是当前 Leader
查 Lease 资源:
bash
$ kubectl get lease redis-operator.example.com -n operator-system -o yaml
apiVersion: coordination.k8s.io/v1
kind: Lease
metadata:
name: redis-operator.example.com
namespace: operator-system
spec:
holderIdentity: redis-operator-pod-1
leaseDurationSeconds: 15
renewTime: "2026-08-01T10:00:10Z"
acquireTime: "2026-08-01T09:50:00Z"
leaseTransitions: 3holderIdentity 就是当前 Leader 的 Pod 名。
2. Controller 不工作(待命副本没接管)
症状:Leader Pod 挂了,但 CR 状态不更新。可能原因:
- Lease 没过期:检查
renewTime和now的差值是否 >leaseDurationSeconds。如果 Leader Pod 还在但卡住(续约 goroutine 阻塞),Lease 不会过期,待命副本抢不到。这种情况要手动删 Pod 强制重启。 - 待命副本没启动:检查 Deployment 副本数和 Pod 状态。
- Lease 被删了:如果有人误删 Lease,所有副本都会重新竞争,期间无 Leader。
3. 频繁切换(flapping)
症状:leaseTransitions 数字增长很快。原因:
- LeaseDuration 太小:网络抖动就过期。调大到 30s。
- Leader Pod 不健康:CPU/内存限得太低,续约 goroutine 调度不上来。检查 Pod 的资源 limit。
- API Server 慢:续约请求超时。检查 API Server 延迟。
4. 双 Leader(脑裂)
理论上 Lease 机制能防脑裂(乐观并发保证只有一个 holderIdentity)。但如果出现:
- 不同 Operator 用了相同的
LeaderElectionID→ 互相抢锁。确保 ID 全局唯一。 - 多个集群共用一个 kubeconfig → 选主跨集群了。确保每个 Operator 连的是自己的集群。
5. 日志特征
Leader 选举相关日志:
# 成为 Leader
attempting to acquire leader lease redis-operator.example.com...
successfully acquired lease redis-operator.example.com
# 待命
failed to acquire lease redis-operator.example.com
# 失去 Leader 身份
leader election lost七、部署多副本 Operator
1. 带健康检查的 Deployment
开启 Leader Election 后,待命副本的 Manager 是「阻塞在选主」状态,此时它的 readyz 会失败(Controller 没启动)。这会导致待命 Pod 不被 Service 转发流量——但这正是我们想要的,因为待命副本不该接活。
但 readinessProbe 失败会让 Pod 处于 NotReady,Deployment 的 available 副本数不达标。如果集群要求 available >= 1,可能误判。controller-runtime 提供了解法:用 readinessProbe 区分「进程就绪」和「是 Leader」。
Kubebuilder 生成的 Deployment 模板已经处理了这点:
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis-operator
namespace: operator-system
spec:
replicas: 2 # 两副本
selector:
matchLabels:
control-plane: controller-manager
template:
metadata:
labels:
control-plane: controller-manager
spec:
containers:
- name: manager
image: redis-operator:latest
args:
- --leader-elect # 开启选主
ports:
- containerPort: 8081
name: healthz
livenessProbe:
httpGet:
path: /healthz
port: healthz
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /readyz
port: healthz
initialDelaySeconds: 5
periodSeconds: 10
resources:
limits:
cpu: "1"
memory: "512Mi"
requests:
cpu: "100m"
memory: "128Mi"
terminationGracePeriodSeconds: 302. readyz 的特殊行为
controller-runtime 的 /readyz 端点在开启 Leader Election 后有个巧妙设计:
- 是 Leader:返回 200(就绪)。
- 不是 Leader:返回 200(也就绪!)。
等等,这不是矛盾吗?为什么不待命副本也返回就绪?
这是为了让 Deployment 的 available 副本数达标(两个 Pod 都 Available),避免被误判为不健康。同时因为 Operator 不通过 Service 对外提供服务(它是 in-cluster controller,不接外部流量),readinessProbe 实际不影响功能。真正的「是否在干活」由 Leader Election 保证。
如果你想让待命副本 NotReady(更严格),可以自定义 readyz 检查:在 Manager 启动后注册一个 readyz check,只有是 Leader 时才返回成功。但通常没必要。
3. 副本数选择
- 2 副本:最常见。一个 Leader 一个待命,能扛单点故障。
- 3 副本:更高可用,能扛同时挂两个。但待命副本也占资源,且切换时多个待命竞争可能短暂反复。
- 1 副本 + Leader Election:没意义(没有待命可切),不如不开。
生产推荐 2 副本。
八、完整示例:高可用 Operator Deployment
下面给出完整的高可用部署清单,整合 Leader Election、健康检查、RBAC、优雅退出。
1. Namespace
yaml
apiVersion: v1
kind: Namespace
metadata:
name: operator-system
labels:
control-plane: controller-manager2. RBAC
yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: redis-operator-leader-election-role
namespace: operator-system
rules:
# Leader Election 需要操作 Lease
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# 健康检查需要操作 configmap(部分实现用 configmap lock)
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: redis-operator-leader-election-rolebinding
namespace: operator-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: redis-operator-leader-election-role
subjects:
- kind: ServiceAccount
name: redis-operator
namespace: operator-system3. Operator Deployment
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis-operator
namespace: operator-system
labels:
control-plane: controller-manager
spec:
replicas: 2
selector:
matchLabels:
control-plane: controller-manager
template:
metadata:
labels:
control-plane: controller-manager
spec:
serviceAccountName: redis-operator
securityContext:
runAsNonRoot: true
runAsUser: 65532
containers:
- name: manager
image: example.com/redis-operator:v0.1.0
imagePullPolicy: IfNotPresent
args:
- --leader-elect
- --leader-election-id=redis-operator.example.com
- --metrics-bind-address=:8080
- --health-probe-bind-address=:8081
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
ports:
- name: metrics
containerPort: 8080
- name: healthz
containerPort: 8081
livenessProbe:
httpGet:
path: /healthz
port: healthz
initialDelaySeconds: 15
periodSeconds: 20
failureThreshold: 3
readinessProbe:
httpGet:
path: /readyz
port: healthz
initialDelaySeconds: 5
periodSeconds: 10
resources:
limits:
cpu: "1"
memory: "512Mi"
requests:
cpu: "100m"
memory: "128Mi"
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
terminationGracePeriodSeconds: 304. 验证高可用
部署后:
bash
kubectl apply -f deploy/
kubectl get pods -n operator-system
# NAME READY STATUS
# redis-operator-xxx-aaa 1/1 Running # Leader
# redis-operator-xxx-bbb 1/1 Running # 待命查看谁是 Leader:
bash
$ kubectl get lease redis-operator.example.com -n operator-system -o jsonpath='{.spec.holderIdentity}'
redis-operator-xxx-aaa两个 Pod 的日志对比:
bash
# Leader Pod
$ kubectl logs redis-operator-xxx-aaa -n operator-system | grep -i leader
attempting to acquire leader lease redis-operator.example.com
successfully acquired lease redis-operator.example.com
Starting Controller
# 待命 Pod
$ kubectl logs redis-operator-xxx-bbb -n operator-system | grep -i leader
attempting to acquire leader lease redis-operator.example.com
failed to acquire lease redis-operator.example.com
# 没有 "Starting Controller"——Controller 没启动5. 模拟故障切换
删掉 Leader Pod:
bash
kubectl delete pod redis-operator-xxx-aaa -n operator-system观察:
- 原 Leader Pod 被删,Deployment 补一个新的 Pod(待命)。
- 原 待命 Pod
xxx-bbb在 ~15s 内抢到 Lease,成为新 Leader。 - Controller 在
xxx-bbb上启动。
bash
$ kubectl get lease redis-operator.example.com -n operator-system -o jsonpath='{.spec.holderIdentity}'
redis-operator-xxx-bbb # 切换到原待命 Pod
$ kubectl logs redis-operator-xxx-bbb -n operator-system | tail -3
successfully acquired lease redis-operator.example.com
Starting Controller
Starting workers切换期间(约 15s)CR 的 Reconcile 暂停,但已存在的 Deployment/Service 继续由 K8s 自身维持,业务不受影响。新 Leader 上任后继续推进未完成的 Reconcile。
九、Leader Election 的代价与权衡
1. 切换延迟
最坏情况下切换延迟 ≈ LeaseDuration(15s)。这期间无 Controller 工作。对大多数 Operator 可接受(K8s 内置 Controller 也是这个量级)。如果你需要亚秒级切换,Operator 模式可能不合适,考虑用「主动-主动」+ 分片(每个副本负责不同 namespace 的 CR)。
2. 待命副本的资源浪费
待命副本虽然不干活,但要占内存(Cache、Watch 连接)。2 副本意味着双倍内存。对于 Watch 大量资源的 Operator,这是个成本。优化方向:
- 限制 Watch 的 namespace(
Cache.DefaultNamespaces),减少缓存大小。 - 待命副本其实也在建 Cache(为了抢到 Leader 后能立即工作),所以省不掉。
3. 是否需要 Leader Election
不是所有 Operator 都需要。判断标准:
- 需要:管理有状态资源、写外部系统、对重复操作敏感。绝大多数生产 Operator 都该开。
- 不需要:只读监控类 Operator、或单副本足够(如开发环境)。关掉省资源。
十、小结
本篇讲解了 Operator 高可用的核心机制——Leader Election。要点回顾:
- 为什么需要:Controller 是有状态控制循环,不能简单多副本。Leader Election 实现「多副本部署、单副本活跃」,避免重复操作。
- 原理:基于 K8s Lease 资源。Leader 持有 Lease 并续约,待命副本监视 Lease,Leader 失联后待命副本抢锁接管。
- 三个时间参数:
LeaseDuration(租约期,默认 15s)、RenewDeadline(续约间隔,10s)、RetryPeriod(抢锁间隔,2s)。切换延迟 ≈ LeaseDuration + RetryPeriod。 - controller-runtime 配置:
LeaderElection: true+LeaderElectionID一行开启。LeaderElectionID必须全局唯一。 - 优雅切换:
SetupSignalHandler监听 SIGTERM,收到后主动释放 Lease,切换延迟降到 RetryPeriod 级别。配合terminationGracePeriodSeconds。 - 幂等是切换安全的前提:新 Leader 从 API Server 重建状态,不依赖内存。这就是前几章强调「Reconcile 不维护跨调用内存状态」的原因。
- 排查:查 Lease 的
holderIdentity知道谁是 Leader;频繁切换调大 LeaseDuration;双 Leader 检查 ID 是否撞车。 - 部署:2 副本 +
--leader-elect+ 健康检查 + RBAC(Lease 权限)。生产推荐 2 副本。
下一篇是本系列最后一篇,讲 Webhook(准入控制)、单元测试、envtest 集成测试、e2e 测试,以及 Operator 的镜像构建与发布。