2024-04-03 使用kubernees leases 轻松实现leader election

假如你正在创建一个数据复制服务,该服务只需要一个实例来处理数据以保持正确的顺序。或者考虑开发一个多人在线游戏,在这种游戏中,同步所有参与者之间的游戏状态至关重要。这些类型的服务通常需要单个实例专门处理特定任务,并且还需要备用副本在当前实例失败时能够迅速接管工作负载。 #

image-20240226150259482

解决此类挑战常用的技术之一是 leader election。它通过使组/集群中的节点参与投票或共识过程来选择单个 Leader 实例。然后该Leader 承担需要集中协调的责任。如果选举失败,则其余节点会自动重新运行选举过程以选择新的 Leader,确保系统连续性和容错性。 #

实现的复杂性 #

在一个组中达成所有节点对 Leader 的共识,对于实现 Leader 选举至关重要。 #
基本的实现可以利用服务来确定领导权。 #

image-20240226150312356

在这种情况下,每个实例都尝试获取一个共享锁。锁服务确保只有一个实例在任何给定时间持有该锁,有效地使该实例成为 Leader。其他副本不断尝试获取锁,从而准备好进行无缝故障转移,以防当前的 Leader 变得不可用。 #
然而,在某些情况下会出现挑战,例如当领导节点失效和无响应时,并且持有锁一段时间直到该实例被回收。这种情况可能会导致传入请求因超时而失败,并且由于备用副本无法承担领导职责而增加正在进行任务的延迟。为了缓解这个问题,通常建议锁服务在锁上实施过期机制,通常称为基于 TTL 的租约。 #

问题1:失效的 Leader 实例 #

image-20240226150333561

此外,锁服务必须符合一定的健壮性标准。一方面,它不能作为单个实例运行,因为这将使其成为单点故障。另一方面,如果它使用多个实例以提供高可用性,则必须设计能够抵御网络分区问题。否则可能导致一个棘手的情况发生:在不同网络分区中的两个副本都认为自己已经获得了锁,并因此承担领导者角色。 #

问题2:脑裂 #

image-20240226150349667

基于仲裁的系统成为首选解决方案 #

为了解决网络分区的核心问题,像 ZooKeeper(被 Kafka 使用)和 etcd(被 Kubernetes 利用)这样的基于仲裁的系统通过内置的 TTL 功能应运而生,将自己确立为领导者选举的最佳解决方案。ZooKeeper 原子广播(ZAB)协议和 etcd 采用的 RAFT 协议都确保只有在大多数节点认可时才会考虑任何操作是有效的。在发生网络分区时,只有一个分区可以包含大多数节点,有效地防止了出现多个领导者的可能性。 #
然而,无论是 ZooKeeper 还是 etcd 都不具备成本效益。分布式系统固有复杂性带来了运维挑战。为小规模服务部署这样的集群可能太重,并且额外开销可能超过应用程序本身的运维成本。 #

K8s Leases #

由于成本问题,可靠的领导者选举只适用于大规模系统吗?未必,如果你正在使用 Kubernetes 基础设施,则可以通过使用 Kubernetes Leases 来简单地实现 etcd 级别的领导者选举 —— 无需直接与 Etcd API 交互。 #

为什么很强大 #

Kubernetes Leases 之所以强大,部分原因在于 Kubernetes 本身通常将 etcd 作为其所有 API对象(包括租约)的存储。这意味着 etcd 的高可用性、一致性和容错特性自然地可用于 Kubernetes Leases。但是,Kubernetes API 添加了额外的功能层,比如通过资源版本控制实现乐观并发控制,这也有助于使租约成为在 Kubernetes 环境中进行领导者选举时一个强大的选择。 #

工作原理 #

在 Kubernetes 中,可以使用 Lease 对象来促进领导者选举。竞争领导权的候选人要么创建要么更新 Lease 对象,并将其标识符设置在 holderIdentity 字段中。领导者持续“续订”该 Lease 以保持其角色。如果领导者未能在 Lease 到期前续订,则其他候选人会尝试获取它。首个成功更新 Lease 的候选人将成为新领导者,确保平稳故障转移。所有候选人都会监视 Lease 对象以跟踪领导变更。 #

image-20240226150408146

示例 #

让我们通过一个全面的示例来了解下这是如何工作的。这里我们通过直接对 K8s API 进行 HTTP 调用,该演示与语言无关。 #

需要一个版本大于等于 1.14 的 Kubernetes 集群。需要安装一些基本的工具:kubectlcurljq 等等。

首先我们创建一个如下锁是的 JSON 文件 lease.json,用来代表 Lease 对象: #
{
  "apiVersion": "coordination.k8s.io/v1",
  "kind": "Lease",
  "metadata": {
    "name": "example-lease",
    "namespace": "example-lease"
  },
  "spec": {
    "holderIdentity": "replica-1",
    "leaseDurationSeconds": 15
  }
}
然后我们可以使用下面的 curl 命令来创建该对象: #
curl -k -X POST -H "Content-Type: application/json" --data @lease.json https://$KUBE_PROXY_URL/apis/coordination.k8s.io/v1/namespaces/example-lease/leases
检查 Lease 对象是否创建成功: #
kubectl get lease example-lease -n example-lease -o json

#output:
{
  "apiVersion": "coordination.k8s.io/v1",
  "kind": "Lease",
  "metadata": {
    "name": "example-lease",
    "namespace": "example-lease",
    "resourceVersion": "123"
  },
  "spec": {
    "holderIdentity": "replica-1",
    "leaseDurationSeconds": 15,
    "acquireTime": "2023-09-11T20:30:00Z",
    "renewTime": "2023-09-11T20:35:00Z",
    "leaseTransitions": 2
  }
}
现在 replica-1 已成功获取租约,并将在15秒后到期。 #
接下来创建创建一个名为 standby_replica 的脚本。此任务在每次更新 Lease 对象时检查 renewTime 字段,以确定当前 leader 是否仍处于活动状态。如果 renewTime 比当前时间落后了定义的阈值,则假定 leader 处于非活动状态。然后,该任务尝试通过使用当前 resourceVersion 更新来获取租约。如果成功,运行该任务的 pod 将成为新的 leader,并应继续更新 renewTime 以保持其状态。 #
# standby_replica.sh
#!/bin/bash

LEASE_ENDPOINT="http://$KUBE_PROXY_URL/apis/coordination.k8s.io/v1/namespaces/NAMESPACE/example-lease/LEASE_NAME"
REPLICA_NAME="replica-2"
# implement watch. Please note that there is a watch endpoint, which is more efficeint when writing production code
while true; do
  # Fetch the lease object
  lease=$(curl -s $LEASE_ENDPOINT)

  # Extract renewTime and resourceVersion
  renew_time=$(echo $lease | jq -r '.spec.renewTime')
  resource_version=$(echo $lease | jq -r '.metadata.resourceVersion')
  current_time=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

  if [[ "$renew_time" < "$current_time" ]]; then
    updated_lease=$(echo $lease | jq ".metadata.resourceVersion = \"$resource_version\" | .spec.renewTime = \"$current_time\" | .spec.holderIdentity = \"$REPLICA_NAME\"")

    # Attempt to update the lease
    update_response=$(curl -s -X PUT --header "Content-Type: application/json" -d "$updated_lease" $LEASE_ENDPOINT)

    if [[ $(echo $update_response | jq -r '.metadata.resourceVersion') == "$resource_version" ]]; then
      echo "Successfully acquired the lease."
    else
      echo "Failed to acquire the lease."
    fi
  else
    echo "Lease is still valid."
  fi

  # Wait for 5 seconds before the next iteration
  sleep 5
done
如果我们一直去查看对象,有对象输出,renewTime 会不断更新。因此,没有发生领导者故障转移。应该能够看到输出为: #
Lease is still valid.
Lease is still valid.
Lease is still valid.
Lease is still valid.
Lease is still valid.
当停止更新来自 replica-1 的租约时,到期时间将在15秒内到期,以便 replica-2 能够接管领导权。您应该能够看到输出为: #
Lease is still valid.
Lease is still valid.
Successfully acquired the lease.

# And then fetch the example lease again:
kubectl get lease example-lease -n example-lease -o json

#output:
{
  "apiVersion": "coordination.k8s.io/v1",
  "kind": "Lease",
  "metadata": {
    "name": "example-lease",
    "namespace": "example-lease",
    "resourceVersion": "124"
  },
  "spec": {
    "holderIdentity": "replica-2",
    "leaseDurationSeconds": 15,
    "acquireTime": "2023-09-11T20:36:00Z",
    "renewTime": "2023-09-11T20:51:00Z",
    "leaseTransitions": 3
  }
}

总结 #

在 Kubernetes 中利用 Lease 对象进行领导者选举提供了一种轻量级但强大的替代方案,而不是运行像 ZooKeeper 或 etcd 这样的重型协调服务。通过依赖原生 Kubernetes API 对象,您可以获得相同级别的容错能力,而无需处理维护单独分布式协调服务所带来的运维复杂性和资源开销。 #
在实现方面,每个 Pod 都运行一个后台任务来观察 Lease 对象的变化。通过检查 renewTime 字段,该任务可以确定当前领导者是否仍然可操作。如果此时间已过期,则表明领导权已经开放重新选举。然后 Pod 尝试通过使用自己的 holderIdentity 和当前 resourceVersion 更新 Lease 对象来获取或更新租约。成功更新 Lease 的 Pod 承担新领导者角色。这种方法为在 Kubernetes 环境中实现领导者选举提供了一种简单而有效的方式。 #
这使其成为对于在 Kubernetes 环境中运行的应用程序更易访问和高效选择。 #