单例服务(Singleton Service)模式确保了一个应用的实例一次只有一个是活动的,但又是高度可用的。这种模式可以从应用内部实现,也可以完全委托给 Kubernetes。

问题描述

Kubernetes 提供的主要功能之一是能够轻松地、透明地扩展应用程序。Pod 可以通过单一命令(如 kubectl scale)进行必要的扩展,也可以通过控制器定义(如 ReplicaSet)进行声明式扩展,甚至可以像我们在第 24 章 “弹性扩展” 中描述的那样,根据应用负载进行动态扩展。通过运行同一服务(不是 Kubernetes 服务,而是由 Pod 代表的分布式应用的一个组件)的多个实例,系统通常会增加吞吐量和可用性。可用性增加是因为如果一个服务实例变得不健康,请求调度器会将未来的请求转发到其他健康的实例。在 Kubernetes 中,多个实例是一个 Pod 的副本,服务资源负责请求调度。

但是,在某些情况下,一个服务的实例一次只允许运行一个。例如,如果一个服务中有一个周期性执行的任务,而同一个服务有多个实例,那么每个实例都会在预定的时间间隔触发任务,导致重复,而不是像预期的那样只有一个任务被启动。另一个例子是对特定资源(文件系统或数据库)执行轮询的服务,我们要确保只有一个实例,甚至可能只有一个线程执行轮询和处理。第三种情况发生在我们必须用一个单线程的消费者从一个消息经纪人那里依次消费消息,这个消费者也是一个单人服务。

在所有这些和类似的情况下,我们需要对一个服务的多少个实例(通常只需要一个)同时处于活动状态进行一些控制,不管有多少个实例被启动并保持运行。

解决方案

运行同一 Pod 的多个副本会创建一个主动-主动(active-active)拓扑,其中服务的所有实例都是主动的。我们需要的是一个主动-被动(active-passive 或主-从)拓扑,其中只有一个实例是主动的,其他所有实例都是被动的。从根本上说,这可以在两个可能的层面上实现:应用外锁定和应用内锁定。

应用外锁定(Out-of-Application Locking)

顾名思义,这种机制依赖于应用程序之外的管理进程,以确保应用程序只有一个实例在运行。应用程序实现本身并不知道这个约束,而是作为一个单例运行。从这个角度来看,它类似于有一个 Java 类,它只被管理运行时(如 Spring 框架)实例化一次。类的实现不知道它是作为单例运行的,也不知道它包含任何代码构造来防止实例化多个实例。

图 10-1 显示了如何借助一个具有一个副本的 StatefulSet 或 ReplicaSet 控制器来实现应用外锁定。
image.png
图 10-1 应用外锁定机制

在 Kubernetes 中实现的方法是用一个副本启动一个 Pod。单单这个活动并不能保证单体 Pod 的高可用。我们要做的是还要用一个控制器(比如 ReplicaSet)来支持 Pod,将单体 Pod 变成一个高可用的单体。这种拓扑并不完全是主动-被动(没有被动实例),但它的效果是一样的,因为 Kubernetes 确保 Pod 的一个实例一直在运行。此外,单个 Pod 实例是高度可用的,这要归功于控制器执行健康检查,如第 4 章 “健康探针” 所述,并在 Pod 出现故障时对其进行治疗。

这种方式主要需要注意的是副本数,不要意外增加,因为没有平台级的机制来防止副本数的变化。

始终只有一个实例在运行也不是完全正确的,尤其是在出错的时候。Kubernetes 基本要素(如 ReplicaSet)倾向于可用性而非一致性 — 这是为了实现高可用和可扩展的分布式系统而做出的慎重决定。这意味着 ReplicaSet 对其副本应用“至少”而非“最多”语义。如果我们将 ReplicaSet 配置为一个单例,并且 replicas: 1,控制器会确保至少有一个实例始终在运行,但偶尔也可以是更多的实例。

这里最流行的边界情况发生在控制器管理的 Pod 的节点变得不健康并与 Kubernetes 集群的其他节点断开连接时。在这种情况下,ReplicaSet 控制器会在健康节点上启动另一个 Pod 实例(假设有足够的容量),而不确保断开连接节点上的 Pod 被关闭。同样,当改变副本数量或将 Pod 搬迁到不同节点时,Pod 的数量可以暂时超过所需数量。这种临时增加的目的是为了确保高可用性和避免中断,这是无状态和可扩展应用所需要的。

单例可以具有弹性和恢复能力,但根据定义,它不是高可用的。单例通常倾向于一致性而非可用性。同样偏重一致性而非可用性,并提供所需的严格的单例保证的 Kubernetes 资源是 StatefulSet。如果 ReplicaSet 不能为你的应用提供所需的保证,而你又有严格的单例要求,StatefulSet 可能是答案。StatefulSet 旨在为有状态的应用程序提供许多特性,包括更强的单例保证,但它们也增加了复杂性。我们将讨论有关单例的问题,并在第 11 章 “有状态服务”(Stateful Service)中更详细地介绍 StatefulSet。

通常情况下,在 Kubernetes 上的 Pod 中运行的单例应用程序会打开与消息中间件、关系数据库、文件服务器或其他 Pod 或外部系统上运行的其他系统的传出连接。然而,偶尔,你的单例 Pod 可能需要接受传入连接,在 Kubernetes 上启用的方式是通过服务资源。

我们在第 12 章 “服务发现” 中深入介绍了 Kubernetes 服务,但我们在这里简单讨论一下适用于单例的部分。一个普通的 Service(type: ClusterIP)会创建一个虚拟 IP,并在其选择器匹配的所有 Pod 实例中执行负载均衡。但是通过 StatefulSet 管理的单例 Pod 只有一个 Pod 和一个稳定的网络身份。在这种情况下,最好创建一个无头(Headless)服务(通过设置 type: ClusterIPclusterIP: None)。之所以称为无头,是因为这样的 Service 没有虚拟 IP 地址,kube-proxy 不会处理这些 Service,平台也不会执行任何代理。

然而,这样的 Service 还是很有用的,因为带有选择器的无头 Service 会在 API Server 中创建端点记录,并为匹配的 Pod 生成 DNS A 记录。这样一来,服务的 DNS 查询就不会返回其虚拟 IP,而是返回支持 Pod 的 IP 地址。这使得通过服务 DNS 记录直接访问单例 Pod,而不需要通过服务的虚拟 IP。例如,如果我们用 my-singleton 作为名称创建一个无头服务,我们可以用 my-singleton.default.svc.cluster.local 来直接访问 Pod 的 IP 地址。

综上所述,对于非严格的单例,有一个副本的 ReplicaSet 和一个普通的 Service 就足够了。对于严格的单点和性能更好的服务发现,最好使用 StatefulSet 和无头服务。你可以在第 11 章 “有状态服务” 中找到一个完整的例子,在这里你必须将副本的数量改为一个,使其成为一个单例。

应用内锁定(In-Application Locking)

在分布式环境中,控制服务实例数量的方法之一是通过分布式锁,如图 10-2 所示。每当一个服务实例或实例内部的组件被激活时,它都可以尝试获取一个锁,如果成功了,服务就会成为活动状态。任何后续的服务实例如果未能获取锁,则会等待并不断尝试获取锁,以防当前激活的服务释放锁。
许多现有的分布式框架使用这种机制来实现高可用性和弹性。例如,消息中间件 Apache ActiveMQ 可以运行在一个高可用的主动-被动拓扑中,数据源提供共享锁。第一个启动的中间人实例获得锁并成为主动,随后启动的任何其他实例成为被动,等待锁被释放。这种策略可以确保有一个单一的主动式中间人实例,同时也能抵御故障的发生。
image.png
图 10-2 应用内锁定机制

我们可以将这种策略与面向对象世界中的经典单例进行比较:单例是一个存储在静态类变量中的对象实例。在这个实例中,该类意识到自己是一个单例,而且它的编写方式不允许为同一个进程实例化多个实例。在分布式系统中,这意味着容器化应用程序本身必须以一种不允许同时有多个活动实例的方式来编写,无论启动的 Pod 实例数量有多少。要在分布式环境中实现这一点,首先,我们需要一个分布式锁的实现,比如 Apache ZooKeeper、HashiCorp 的 Consul、Redis 或 etcd 提供的锁。

ZooKeeper 的典型实现使用的是临时(ephemeral)节点,只要有客户端会话就存在,一旦会话结束就会被删除。第一个启动的服务实例在 ZooKeeper 服务器中发起一个会话,并创建一个短暂节点成为活动节点。来自同一集群的所有其他服务实例都会变成被动的,必须等待临时节点被释放。这就是基于 ZooKeeper 的实现如何确保整个集群中只有一个主动服务实例,确保主动/被动的故障转移行为。

在 Kubernetes 的世界里,与其只为锁功能管理 ZooKeeper 集群,不如使用通过 Kubernetes API 暴露的、运行在主节点上的 etcd 功能。etcd 是一个分布式键值存储,它使用 Raft 协议来维护其复制状态。最重要的是,它为实现领导者选举提供了必要的构件,一些客户端库已经实现了这个功能。例如,Apache Camel 有一个 Kubernetes 连接器,也提供了领导者选举和单人能力。这个连接器更进一步,它不是直接访问 etcd API,而是使用 Kubernetes API 来利用 ConfigMap 作为分布式锁。它依靠 Kubernetes 乐观的锁定保证来编辑 ConfigMap 等资源,每次只有一个 Pod 可以更新 ConfigMap。

Camel 的实现使用这个保证来确保只有一个 Camel 路由实例是主动的,任何其他实例都必须等待并获得锁后才能行动。这是对锁的自定义实现,但实现了同样的目标:当有多个 Pod 使用同一个 Camel 应用时,只有其中一个成为主动的单体,其他的则在被动模式下等待。

使用 ZooKeeper、etcd 或任何其他分布式锁实现的实现将与所述的类似:只有一个应用实例成为领导者并激活自己,其他实例被动等待锁。这就保证了即使启动了多个 Pod 副本,并且都是健康的、启动的、运行的,也只有一个服务是主动的,并以单例身份执行业务功能,其他实例都在等待获取锁,以防主控失败或关闭。

Pod 中断预算(Pod Disruption Budget)

当单例服务和领导者选举试图限制一个服务在同一时间运行的最大实例数量时,Kubernetes 的 PodDisruptionBudget 功能提供了一个互补的、有点相反的功能 — 限制同时停机维护的实例数量。

它的核心是,PodDisruptionBudget 确保了一定数量或百分比的 Pod 不会在任何时间点上自愿从一个节点上被驱逐。这里的自愿指的是可以延迟特定时间的驱逐,例如,当它是由维护或升级的节点耗尽(kubectl drain),或集群缩减所触发的,而不是节点变得不健康,这钟无法预测或控制的驱逐。

例 10-1 中的 PodDisruptionBudget 适用于与其选择器匹配的 Pod,并确保两个 Pod 必须一直可用。

  1. # 例 10-1 PodDisruptionBudget 示例
  2. ---
  3. apiVersion: policy/v1beta1
  4. kind: PodDisruptionBudget
  5. metadata:
  6. name: random-generator-pdb
  7. spec:
  8. selector:
  9. # 选择器来计算可用的 Pod
  10. matchLabels:
  11. app: random-generator
  12. # 至少要有 2 个 Pod 可用
  13. # 也可以指定一个百分比,比如 80%,来配置只有 20% 的匹配 Pod 可能被驱逐
  14. minAvailable: 2

除了 .spec.minAvailable,还有一个选项是使用 .spec.maxUnavailable,它指定了在驱逐后可以不可用的那组 Pod 的数量。但是你不能同时指定这两个字段,而且 PodDisruptionBudget 典型地只适用于由控制器管理的 Pod。对于不由控制者管理的 Pod(也被称为裸露或裸露的 Pod),应该考虑围绕 PodDisruptionBudget 的其他限制。

这个功能对于基于法定人数(Quorum)的应用来说是很有用的,这些应用需要最小数量的复制体一直运行以确保法定人数。或者也许当一个应用程序正在服务于关键的流量,永远不应该低于总实例数的某个百分比。这是 Kubernetes 另一个控制和影响运行时实例管理的基元,在本章值得一提。

一些讨论

如果你的用例需要强大的单例保证,你就不能依赖 ReplicaSet 的应用外锁定机制。Kubernetes ReplicaSet 的设计是为了维护其 Pod 的可用性,而不是为了确保 Pod 的最多一个实例的语义。因此,有很多故障场景(例如,当运行单例 Pod 的节点与集群的其他节点分区时,例如用新的 Pod 实例替换删除的 Pod 实例时),一个 Pod 的两个副本在短时间内并发运行。如果不能接受,请使用 StatefulSet 或研究应用内锁定选项,这些选项可以为你提供更多的控制领导者选举过程,并提供更强的保证。后者还可以防止通过改变副本数量来意外扩展 Pod。

在其他场景下,容器化应用中只有一部分应该是单例。例如,可能有一个容器化应用程序提供了一个 HTTP 端点,它可以安全地扩展到多个实例,但也有一个轮询组件必须是一个单体。使用应用外锁定的方法将防止对整个服务进行扩展。此外,作为结果,我们要么必须在其部署单元中拆分单例组件,以保持其为单例(理论上是好的,但并不总是实际的,也不值得开销),要么使用应用内锁定机制,只锁定必须是单例的组件。这将使我们能够透明地扩展整个应用程序,使 HTTP 端点得到扩展,并使其他部分成为主动-被动单体。

参考资料