Featured image of post Kubernetes leader election 使用

Kubernetes leader election 使用

介绍了client-go提供的leader election的使用及其原理

租约(Lease)

Kubernetes 的 Lease 资源是一种轻量级的机制,用于在集群中的各个节点之间进行领导者选举或协调访问共享资源。它属于 coordination.k8s.io API 组,主要用于在多个客户端之间同步状态,确保只有一个客户端(或节点)在特定时间内承担某个任务或控制特定资源。

官方文档中提及的Lease用途包括

  • 节点心跳:对于每个node都有对应的Lease资源,节点心跳定期更新Leasespec.renewTime 字段Kubernetes 控制平面使用此字段的时间戳来确定此 node 的可用性。
  • 领导者选举:Lease 资源常用于 Kubernetes 控制平面组件的领导者选举,例如 kube-scheduler 或 kube-controller-manager。这些组件使用 Lease 来确保集群中只有一个活跃的领导者实例在执行任务。通过 Lease 机制,当当前领导者因为故障或其他原因失去活跃状态时,另一个实例可以接管成为新的领导者。
  • API 服务器身份:从 Kubernetes v1.26 开始,每个 kube-apiserver 都使用 Lease API 将其身份发布到系统中的其他位置。
  • 工作负载:开发者可以定义自己使用的Lease,进而使用 Kubernetes API 进行多实例程序的协调。

Kubernetes资源并发控制

当两个客户端使用 Kubernetes API 同时尝试更新同一个资源对象时,Kubernetes API 服务器使用乐观锁机制来处理这种并发更新。这个机制确保了资源更新的一致性和完整性。

资源版本控制

每个 Kubernetes 资源都有一个 resourceVersion 字段,这是一个在每次资源被修改时自动递增的版本号。客户端在发送更新请求时会包括这个版本号。

乐观锁

Kubernetes 使用乐观锁来处理资源的并发更新。当一个客户端尝试更新资源时,它必须提供它所基于的资源的当前版本号。这个版本号随请求一起发送给 API 服务器。

更新处理流程

  • 成功情况:如果提供的版本号与服务器上当前资源的版本号一致,API 服务器接受更新,应用更改,并将资源的版本号递增。
  • 冲突情况:如果提供的版本号与服务器上的版本号不匹配(说明在客户端读取资源后和发送更新请求之间,资源已被另一个客户端更改),API 服务器将拒绝请求并返回一个冲突错误( HTTP 409 Conflict)。这时,客户端通常需要重新获取最新的资源版本,重新应用其更改,并再次尝试更新。

leader election

client-go提供了leader election的简单示例。基本原理如下图:

image-20240508113507357
  1. 多实例启动。
  2. 所有实例尝试获取到租期锁,但只有一个实例会成功获取锁,成为领导者。
  3. 领导者定时更新租期到期时间,同时其他实例定时轮询租期到期时间,若发现租期已到期则会发生领导权转移。

运行示例

程序的入参包括:

  • kubeconfig:集群kubeconfig文件路径
  • lease-lock-name:租赁锁名称
  • lease-lock-namespace:租赁锁所在的命名空间
  • id:程序实例的id,每个运行的程序都需要有唯一的id
go run main.go -kubeconfig=/path/to/kubeconfig -logtostderr=true -lease-lock-name=example -lease-lock-namespace=default -id=1
go run main.go -kubeconfig=/path/to/kubeconfig -logtostderr=true -lease-lock-name=example -lease-lock-namespace=default -id=2
go run main.go -kubeconfig=/path/to/kubeconfig -logtostderr=true -lease-lock-name=example -lease-lock-namespace=default -id=3

go run cmd/lead/main.go -kubeconfig=/Users/vincent/Documents/work/ai-poc.yaml -logtostderr=true -lease-lock-name=example -lease-lock-namespace=default -id=1
go run cmd/lead/main.go -kubeconfig=/Users/vincent/Documents/work/ai-poc.yaml -logtostderr=true -lease-lock-name=example -lease-lock-namespace=default -id=2
go run cmd/lead/main.go -kubeconfig=/Users/vincent/Documents/work/ai-poc.yaml -logtostderr=true -lease-lock-name=example -lease-lock-namespace=default -id=3

启动3个实例

相继执行命令,得到以下输出

# id=1
I0507 09:46:16.357422    3102 leaderelection.go:248] attempting to acquire leader lease default/example...
I0507 09:46:16.425337    3102 leaderelection.go:258] successfully acquired lease default/example
I0507 09:46:16.425443    3102 main.go:71] Controller loop...
# id=2
I0507 09:46:41.761138    3166 leaderelection.go:248] attempting to acquire leader lease default/example...
I0507 09:46:41.802702    3166 main.go:135] new leader elected: 1
# id=3
I0507 09:46:51.877957    3223 leaderelection.go:248] attempting to acquire leader lease default/example...
I0507 09:46:51.913119    3223 main.go:135] new leader elected: 1

可见id=1的实例当选领导者。此时我们在kubeconfig指定的集群里,执行kubectl get leases -n default example -oyaml 可以看到Lease对象被创建,并且在spec中包括了租期获取时间、到期时间、当前保存的id等信息。

apiVersion: coordination.k8s.io/v1
kind: Lease
metadata:
  creationTimestamp: "2024-05-07T01:46:16Z"
  name: example
  namespace: default
  resourceVersion: "2113597"
  uid: baad0d51-7676-4e7a-83fc-44241e7a8185
spec:
  acquireTime: "2024-05-07T01:46:16.357501Z"
  holderIdentity: "1"
  leaseDurationSeconds: 60
  leaseTransitions: 0
  renewTime: "2024-05-07T02:02:27.633970Z"

关闭主程序,触发领导权变更

# id=1
I0507 09:46:16.357422    3102 leaderelection.go:248] attempting to acquire leader lease default/example...
I0507 09:46:16.425337    3102 leaderelection.go:258] successfully acquired lease default/example
I0507 09:46:16.425443    3102 main.go:71] Controller loop...
# ---new---
^CI0507 10:05:03.735993    3102 main.go:88] Received termination, signaling shutdown
I0507 10:05:03.752979    3102 main.go:126] leader lost: 1

# id=2
I0507 09:46:41.761138    3166 leaderelection.go:248] attempting to acquire leader lease default/example...
I0507 09:46:41.802702    3166 main.go:135] new leader elected: 1
# ---new---
I0507 10:05:08.209963    3166 main.go:135] new leader elected: 3

# id=3
I0507 09:46:51.877957    3223 leaderelection.go:248] attempting to acquire leader lease default/example...
I0507 09:46:51.913119    3223 main.go:135] new leader elected: 1
# ---new---
I0507 10:05:07.193674    3223 leaderelection.go:258] successfully acquired lease default/example
I0507 10:05:07.194014    3223 main.go:71] Controller loop...

可以发现id=3的程序被选为了领导者,此时查看leases对象,发现holderIdentity已经变更为3,且leaseTransitions被加一。

apiVersion: coordination.k8s.io/v1
kind: Lease
metadata:
  creationTimestamp: "2024-05-07T01:46:16Z"
  name: example
  namespace: default
  resourceVersion: "2114958"
  uid: baad0d51-7676-4e7a-83fc-44241e7a8185
spec:
  acquireTime: "2024-05-07T02:05:07.154433Z"
  holderIdentity: "3"
  leaseDurationSeconds: 60
  leaseTransitions: 1
  renewTime: "2024-05-07T02:11:14.792591Z"

分析主程序

下面给出了程序的主流程,可以发现核心代码非常短小,对业务流程的侵入性也很小,只需要将业务逻辑放在LeaderCallbacks的函数中即可。流程如下:

  1. 初始化client-go
  2. 定义核心业务逻辑
  3. 定义LeaseLock
  4. 定义LeaderElectionConfig,并进入主流程
func main() {
    // ...折叠初始化
		// 通过kubeconfig获取client-go示例
    client := clientset.NewForConfigOrDie(config)
  	// 定义核心业务逻辑
    run := func(ctx context.Context) {
       klog.Info("Controller loop...")
       select {}
    }

    // 定义租约锁
    lock := &resourcelock.LeaseLock{
       LeaseMeta: metav1.ObjectMeta{
          Name:      leaseLockName,
          Namespace: leaseLockNamespace,
       },
       Client: client.CoordinationV1(),
       LockConfig: resourcelock.ResourceLockConfig{
          Identity: id,
       },
    }

    // 开始主循环
    leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{
       Lock: lock,
       ReleaseOnCancel: true,
       LeaseDuration:   60 * time.Second,
       RenewDeadline:   15 * time.Second,
       RetryPeriod:     5 * time.Second,
       Callbacks: leaderelection.LeaderCallbacks{
          OnStartedLeading: func(ctx context.Context) {
             run(ctx)
          },
          OnStoppedLeading: func() {
             klog.Infof("leader lost: %s", id)
             os.Exit(0)
          },
          OnNewLeader: func(identity string) {
             if identity == id {
                return
             }
             klog.Infof("new leader elected: %s", identity)
          },
       },
    })
}

LeaseLock中定义了Lease的name和namespace、用于增删改查Lease的Client以及id

lock := &resourcelock.LeaseLock{
   LeaseMeta: metav1.ObjectMeta{
      Name:      leaseLockName,
      Namespace: leaseLockNamespace,
   },
   Client: client.CoordinationV1(),
   LockConfig: resourcelock.ResourceLockConfig{
      Identity: id,
   },
}

LeaderElectionConfig定义了选主配置,包括租赁锁、租期过期时间、回调函数等

leaderelection.LeaderElectionConfig{
	// 传入LeaseLock
   Lock: lock,
  // 是否在context被cancel时释放租赁锁
   ReleaseOnCancel: true,
  // 当候选者观察到领导者未及时更新租期时间时,强制获取领导权需要等待的时间
   LeaseDuration:   60 * time.Second,
  // 领导者重新声明领导权的持续时间
   RenewDeadline:   15 * time.Second,
  // 候选者重试周期
   RetryPeriod:     5 * time.Second,
   Callbacks: leaderelection.LeaderCallbacks{
     // 当选领导者时触发的回调
      OnStartedLeading: func(ctx context.Context) {
         run(ctx)
      },
     // 失去领导权时触发的回调
      OnStoppedLeading: func() {
         klog.Infof("leader lost: %s", id)
         os.Exit(0)
      },
     // 领导权变更时触发的回调
      OnNewLeader: func(identity string) {
         if identity == id {
            return
         }
         klog.Infof("new leader elected: %s", identity)
      },
   },
}

LeaderElector核心流程

image-20240509094601198

  1. 程序启动,获取领导选举记录
  2. 根据记录中的内容做不同操作
    • 若记录不存在或记录中的领导权已过期,则尝试获取领导权
    • 若记录中的领导权归属于自身(通过id判断),则刷新领导权。否则,说明有一个leader持有领导权,在等待RetryPeriod后,重新尝试获取记录。
  3. 领导权成功转移/续期后
    • 领导者:调用OnStart回调,执行业务逻辑,同时按照RetryPeriod的间隔,不间断地续期领导权
    • 候选者:按照RetryPeriod的间隔,不断获取领导选举记录,查看领导权是否过期(说明领导者出现了某些意外,无法正常完成需求操作),若领导权过期,则尝试获取,进而成为领导者。

由于Kubernetes对资源有乐观锁的并发控制,如果同时有多个候选者试图获取锁,那么只有一个候选者会成功,其余候选者将返回失败并在等待RetryPeriod后重新获取领导权记录。

服务路由

若通过leader election的方式提高应用的可用性,则服务路由与常规的多实例应用不同,部分或全部请求只能被发送到leader。为了实现这样的流量控制需要一下额外的手段,比如利用Pod 就绪探针(Readiness Probe)、手动修改Service和Endpoint、应用间流量转发等

image-20240509111026575

Pod就绪探针

就绪探针文档

Kubernetes只会将处于ready状态的pod加入到endpoint列表,并将对service的流量转发到列表中的pod。因此我们可以在程序中配置就绪探针,并且仅当获取领导权后才上报程序处于ready状态。这样流量只会被转发到leader。

  • 优点
    • 简单,易于实现,只需要提供一个简单http接口
  • 缺点
    • 无法正常进行deployment的滚动更新

手动修改Service和Endpoint

没有选择算符的 Service

使用没有选择算符的 Service 我们可以手动操作Service对应的endpoint,进而控制流量只被转发到leader。每当一个候选者获取领导权变为领导者时,需要修改EndpointSlice中的ip地址为自身的地址。

apiVersion: v1
kind: Service
metadata:
  name: my-service
spec:
  type: NodePort
  ports:
  - name: http
    nodePort: 32088
    port: 8000
    protocol: TCP
    targetPort: http
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
  name: my-service-1
  labels:
    kubernetes.io/service-name: my-service
addressType: IPv4
ports:
  - name: 'http' # 应与上面定义的服务端口的名称匹配
    appProtocol: http
    protocol: TCP
    port: 8000
endpoints:
  - addresses:
      - "192.168.127.152"
  • 优点:
    • 灵活,可以同时存在一个普通service和一个没有选择算符的 Service,实现全部实例可读,仅leader可写的效果
  • 缺点:
    • 就绪探针失效,无论pod是否ready,流量都将被转发到EndpointSlice指定的地址

应用间流量转发

对于请求量较小、请求体不大、不需要持久化连接(如websocket)的情况下,可以考虑直接将leader才能处理的请求从其他实例转发到leader。我们可以将pod的ip作为lease中的holderIdentity,其他实例感知到leader变更后,可以直接通过id来确定leader id。或者使用statefulset的形式部署应用,使用statefulsets稳定的dns名称确定leader的地址。

  • 优点:
    • 灵活,同样可以实现全部实例可读,仅leader可写的效果
    • 对客户端来说,无感知应用内部架构
  • 缺点:
    • 对业务代码入侵较大

总结

我们首先介绍了client-go leader election所依赖的Lease资源和Kubernetes乐观锁机制。

  • Lease资源:一种用于领导者选举或协调访问集群共享资源的资源对象
  • Kubernetes乐观锁机制:通过资源版本号来控制并发更新,确保在资源被修改期间数据不会因并发操作而产生冲突。

client-go提供的 leader election使 候选者不断轮询领导权记录,领导者不断续期领导权来维持心跳,进而维持领导者权限归属。领导权记录可以借助lease、configmap或endpoint资源对象。

参考