租约(Lease)
Kubernetes 的 Lease 资源是一种轻量级的机制,用于在集群中的各个节点之间进行领导者选举或协调访问共享资源。它属于 coordination.k8s.io
API 组,主要用于在多个客户端之间同步状态,确保只有一个客户端(或节点)在特定时间内承担某个任务或控制特定资源。
官方文档中提及的Lease用途包括
- 节点心跳:对于每个node都有对应的Lease资源,节点心跳定期更新Lease
spec.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的简单示例。基本原理如下图:
- 多实例启动。
- 所有实例尝试获取到租期锁,但只有一个实例会成功获取锁,成为领导者。
- 领导者定时更新租期到期时间,同时其他实例定时轮询租期到期时间,若发现租期已到期则会发生领导权转移。
运行示例
程序的入参包括:
- 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的函数中即可。流程如下:
- 初始化client-go
- 定义核心业务逻辑
- 定义LeaseLock
- 定义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核心流程
- 程序启动,获取领导选举记录
- 根据记录中的内容做不同操作
- 若记录不存在或记录中的领导权已过期,则尝试获取领导权
- 若记录中的领导权归属于自身(通过id判断),则刷新领导权。否则,说明有一个leader持有领导权,在等待RetryPeriod后,重新尝试获取记录。
- 领导权成功转移/续期后
- 领导者:调用OnStart回调,执行业务逻辑,同时按照RetryPeriod的间隔,不间断地续期领导权
- 候选者:按照RetryPeriod的间隔,不断获取领导选举记录,查看领导权是否过期(说明领导者出现了某些意外,无法正常完成需求操作),若领导权过期,则尝试获取,进而成为领导者。
由于Kubernetes对资源有乐观锁的并发控制,如果同时有多个候选者试图获取锁,那么只有一个候选者会成功,其余候选者将返回失败并在等待RetryPeriod后重新获取领导权记录。
服务路由
若通过leader election的方式提高应用的可用性,则服务路由与常规的多实例应用不同,部分或全部请求只能被发送到leader。为了实现这样的流量控制需要一下额外的手段,比如利用Pod 就绪探针(Readiness Probe)、手动修改Service和Endpoint、应用间流量转发等
Pod就绪探针
Kubernetes只会将处于ready状态的pod加入到endpoint列表,并将对service的流量转发到列表中的pod。因此我们可以在程序中配置就绪探针,并且仅当获取领导权后才上报程序处于ready状态。这样流量只会被转发到leader。
- 优点
- 简单,易于实现,只需要提供一个简单http接口
- 缺点
- 无法正常进行deployment的滚动更新
手动修改Service和Endpoint
使用没有选择算符的 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资源对象。
参考
- k8s-await-election:client-go leader election的封装
- kubernetes简单的leader election:提供leader election镜像,以sidecar的形式完成选主逻辑,对业务逻辑侵入小,并且不依赖于主应用语言。