Skip to content

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
RenewDeadlineLeader 续约间隔,必须 < LeaseDuration10s
RetryPeriod待命副本尝试抢锁的间隔2s

它们的关系:

  • Leader 每 RenewDeadline(10s)续约一次,保证 renewTimeLeaseDuration(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.OptionsLeaseDuration 等:

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 提供):

  1. 尝试 Create Lease(如果不存在)或 Update(把 holderIdentity 改成自己)。
  2. 成功 → 成为 Leader,启动所有 Controller。
  3. 失败(lease 被别人持有且未过期)→ 进入待命,每 RetryPeriod 重试。

2. 续约

Leader 启动后,后台 goroutine 每 RenewDeadline 更新 Lease 的 renewTime。续约用的是乐观并发(带 resourceVersion 的 Update),如果续约时发现 resourceVersion 变了(被别人抢了),Leader 失去身份,停止 Controller。

3. 切换

当 Leader 进程被杀/节点宕机:

  1. 续约停止,renewTime 不再更新。
  2. 待命副本的下次尝试:发现 now - renewTime > LeaseDuration,更新 Lease 把 holderIdentity 改成自己。
  3. 新 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 会:

  1. 停止接收新的 Reconcile。
  2. 等待正在进行的 Reconcile 完成(有 grace period)。
  3. 主动释放 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: 3

holderIdentity 就是当前 Leader 的 Pod 名。

2. Controller 不工作(待命副本没接管)

症状:Leader Pod 挂了,但 CR 状态不更新。可能原因:

  • Lease 没过期:检查 renewTimenow 的差值是否 > 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: 30

2. 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-manager

2. 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-system

3. 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: 30

4. 验证高可用

部署后:

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

观察:

  1. 原 Leader Pod 被删,Deployment 补一个新的 Pod(待命)。
  2. 原 待命 Pod xxx-bbb 在 ~15s 内抢到 Lease,成为新 Leader。
  3. 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。要点回顾:

  1. 为什么需要:Controller 是有状态控制循环,不能简单多副本。Leader Election 实现「多副本部署、单副本活跃」,避免重复操作。
  2. 原理:基于 K8s Lease 资源。Leader 持有 Lease 并续约,待命副本监视 Lease,Leader 失联后待命副本抢锁接管。
  3. 三个时间参数LeaseDuration(租约期,默认 15s)、RenewDeadline(续约间隔,10s)、RetryPeriod(抢锁间隔,2s)。切换延迟 ≈ LeaseDuration + RetryPeriod。
  4. controller-runtime 配置LeaderElection: true + LeaderElectionID 一行开启。LeaderElectionID 必须全局唯一。
  5. 优雅切换SetupSignalHandler 监听 SIGTERM,收到后主动释放 Lease,切换延迟降到 RetryPeriod 级别。配合 terminationGracePeriodSeconds
  6. 幂等是切换安全的前提:新 Leader 从 API Server 重建状态,不依赖内存。这就是前几章强调「Reconcile 不维护跨调用内存状态」的原因。
  7. 排查:查 Lease 的 holderIdentity 知道谁是 Leader;频繁切换调大 LeaseDuration;双 Leader 检查 ID 是否撞车。
  8. 部署:2 副本 + --leader-elect + 健康检查 + RBAC(Lease 权限)。生产推荐 2 副本。

下一篇是本系列最后一篇,讲 Webhook(准入控制)、单元测试、envtest 集成测试、e2e 测试,以及 Operator 的镜像构建与发布。