Kubernetes 设计模式笔记 —— Stateful Services

有状态的分布式应用通常需要一些特性作为支撑,比如持久化的身份标识、网络、存储和有序性等。Kubernetes 中的 StatefulSet 原语就提供了一系列基础构件,方便管理有状态的应用。

现实世界中,在每一个高扩展性的无状态的服务背后,都会存在一个有状态的服务,通常作为某种形式的数据仓库。
在早期的 Kubernetes 中,由于缺乏对有状态负载的支持,解决方案通常是将无状态的部分放置到 Kubernetes 集群,有状态的部分则保留在集群外部。不管是借助公有云抑或是 on-premises 硬件,都以传统的非云原生的方式进行管理。

有状态应用的挑战

存储

我们可以很轻松地增加 ReplicaSet 中副本的数量,以期得到一个分布式的有状态应用。但是该如何定义对存储的需求呢?
通常一个有状态的分布式应用中的每一个实例都需要专用的持久化存储。ReplicaSet 和 PersistentVolumeClaim 会导致所有的 Pod 都关联到同一个 PersistentVolume。其存储并不是专用的,而是在所有 Pod 间共享的。

我们能想到的一种方式是令应用实例共享同一个存储的同时,通过某种应用内部的设计,将共享的存储划分成多个子文件夹,每个实例都只占用属于自己的 subfolder,从而不会产生任何冲突。
上述方式是可行的,但同时也引入了单点故障,因为存储实际上只绑定了一个。此外,当应用需要扩展,Pod 的数量改变,也可能会存在数据损坏或丢失的风险。

另一种方式是为每一个有状态的应用实例都分别创建一个 ReplicaSet(replicas=1),最终每个实例都能拥有独占的 PVC 和存储。缺点是会耗费更多的人力操作,扩展应用时需要创建一批的新的 ReplicaSet、PVC 等。同时缺少一个能管理所有应用实例的统一抽象。

网络

同存储上的需求类似,有状态的分布式应用需要一个稳定的网络标识。除了要保存特定于应用的数据到存储设备外,有状态的应用还需要存储一些配置细节,比如主机名和对端的连接细节等。这意味着每个实例都必须能够通过一个可预测的地址访问,而不能像 ReplicaSet 中的 Pod 那样,IP 地址会动态地发生变化。主机名也不是稳定的,会在每次重启时改变。

Identity

在有状态的应用中,每一个实例都是唯一的,且知晓其自身的标识。这个身份标识的主要组成部分就是长期存在的专用存储和网络坐标,我们可以把实例的 identity/name 也加入进去,有些应用需要唯一的持久的名称。在 Kubernetes 中,就是 Pod 名称。
对于由 ReplicaSet 创建的 Pod,其名称是随机的且并不会在重启后保留。

Ordinality

除了拥有一个唯一的、长久存活的身份标识外,对于有状态的应用,其实例还必须各自拥有一个固定的位置,即各实例之间是有序的。这种有序性会影响到应用扩展和收缩时的顺序,同时也会影响数据的分布和访问,以及集群内部与位置相关的行为,比如锁、单例或主从等。

其他需求

稳定且长久存活的存储、网络、身份标识以及有序性是有状态的分布式应用的共同需求。此外由于个例具体的应用场景不同,管理有状态应用还需要满足一些特定的需求。比如一些应用具有仲裁的概念,要求可用的实例数量必须大于某个最小值;有些应用对于实例的顺序很敏感,另外一些则允许并行化的部署,不要求遵循特定的顺序;有些应用容忍重复的实例存在,有些则不能。
对于所有这些一次性的案例,统一进行规划并提供一个通用的机制是不现实的。因此 Kubernetes 还允许用户自己创建 CustomResourceDefinitions 和 Operators 来管理有状态的应用。

StatefulSet

从很多方面来看,StatefulSet 用来管理“宠物”,而 ReplicaSet 则用来管理“家畜”。
宠物 vs 家畜是 DevOps 里一个很有名的类比。家畜用来指代完全相同的、可自由替换的服务;而宠物则用来指代独特的、不可替代的、需要区别对待的服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: rg
spec:
serviceName: random-generator
replicas: 2
selector:
matchLabels:
app: random-generator
template:
metadata:
labels:
app: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
ports:
- containerPort: 8080
name: http
volumeMounts:
- name: logs
mountPath: /logs
volumeClaimTemplates:
- metadata:
name: logs
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 10Mi
存储

绝大多数有状态的应用需要对其“状态”进行保存,因而需要专属于实例的持久化存储。
不同于 ReplicaSet 使用 persistentVolumeClaim 指向一个预定义的 PVC,StatefulSet 则是通过 volumeClaimTemplates 在 Pod 创建时动态地创建 PVC。这种机制能够保证不管是在初始创建还是在扩展时,每个 Pod 都能够获得专用的 PVC。

扩展一个 StatefulSet(增加 replicas 的数值)会创建新的 Pod 和关联的 PVC,但其收缩时只会删除 Pod,并不会删除任何关联的 PVC 或 PV。即 PV 并不会被回收,其占用的空间没有被释放。
上述行为是故意这样设计的,其基础在于,通常有状态应用的数据存储是至关重要的,意外的收缩不应该导致数据丢失。

网络

StatefulSet 创建的每一个 Pod 都拥有一个稳定的名称标识,由 StatefulSet 的 name 字段和索引数字(从 0 开始)组成。ReplicaSet 创建的 Pod 的名称后缀则是随机的。
由 ReplicaSet 创建的无状态的 Pod 应该是完全相同的,因此客户端请求无论转发到哪个 Pod 都没有任何区别。但是有状态的 Pod 彼此之间是不相同的,我们有可能希望某些请求只转发给特定的 Pod。

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: Service
metadata:
name: random-generator
spec:
clusterIP: None
selector:
app: random-generator
ports:
- name: http
port: 8080

上述配置定义了一个 headless 服务。在 headless 服务中,clusterIP: None,表明我们不想让 kube-proxy 去处理请求,也不需要 cluster IP 或 负载均衡。
带有 selector 的 headless 服务会在 API Server 上创建 Endpoint 纪录,DNS 返回的 A 记录(地址)直接指向 Pod。即每个 Pod 都会获得一个 DNS 记录,能够被客户端直接访问。
假如我们的 random-generator 服务属于 default 命名空间,若需要访问 Pod rg-0,就可以使用域名 rg-0.random-generator.default.svc.cluster.local

对于 StatefulSet 来说,通过 volumeClaimTemplates 定义专用存储不是必须的,通过 serviceNmae 字段指向服务则是一定要做的步骤。Governing Service 必须在 StatefulSet 创建之前就存在,作为网络标识。分布式有状态应用

Identity

Identity 是 StatefulSet 保证的所有特性的最基础构件。基于 StatefulSet 的 name,我们可以预测其创建的 Pod 的名称和标识。再利用 Pod 的标识命名 PVC,通过 headless 服务访问特定的 Pod 等等。
每一个 Pod 在创建前,其标识是可以预先确定的,从而必要的时候可以在应用内部直接使用此标识。

Ordinality

从定义来看,一个分布式的有状态应用包含多个独特的、不可相互替代的实例。除了独特性以外,各实例之间还可能会有基于顺序/位置的关系。
从 StatefulSet 的视角看,顺序性出现在应用需要扩展时。Pod 扩展时的顺序同创建时的顺序一致,收缩时则相反(从 n - 10)。
当我们创建一个带有多个副本的 ReplicaSet,Pod 是同时被调度和启动的,不需要等待第一个 Pod 启动成功,Pod 之间的启动顺序是没有保证的。应用收缩时也是这样。属于同一个 ReplicaSet 的 Pod 同时被关闭,不关注任何顺序或依赖性。
上述行为在执行时可能更加迅速,但并不是 StatefulSet 所需要的,尤其是涉及到数据分片的时候。

为了保证应用扩展和收缩时数据能够正确地同步,默认情况下 StatefulSet 会执行顺序(串行)的启动和终止。这表示第一个 Pod(索引 0)会先启动,在其启动成功以后,下一个 Pod(索引 1)才会被调度。在应用收缩时,顺序则相反。

其他特性

Partitioned Updates
StatefulSet 在扩展时会保证串行执行和顺序性。对于正在运行的应用的升级操作(修改 .spec.template),则允许阶段性更新(比如 canary release),保证在对一部分实例进行更新的同时,剩余部分中特定数量的实例保持正常。
对于默认的 rolling update 策略,可以指定一个 .spec.updateStrategy.rollingUpdate.partition 数字来对实例进行分片。所有索引值大于或等于 partition 的 Pod 会被更新,剩余的 Pod 则保持不变。若需要继续更新集群中剩余的实例,可以将 partition 值改为 0。

Parallel Deployments
当我们指定 .spec.podManagementPolicyParallel 时,StatefulSet 会并行地同时加载或关闭所有的 Pod,不需要等待前一个 Pod 完毕。
若串行处理不是必须的条件,则可以设置此选项来加速部署流程。

At-Most-One 保证
唯一性是有状态应用实例的基础属性,Kubernetes 会确保属于同一个 StatefulSet 的两个 Pod 不会具有相同的标识,不会绑定给同一个 PV。
另一方面,ReplicaSet 则为其实例提供了 At-Least-X-Guarantee。比如一个配置了两个副本的 ReplicaSet 会始终确保最少有两个实例处于运行状态,即便在某些场景下,Pod 的数量有可能超过两个。
StatefulSet 控制器则会进行任何可能的检查来确保不会出现重复的 Pod。即 At-Most-One Guarantee。在需要替换 Pod 时,只有在确定旧 Pod 已经被完全关闭之后,新 Pod 才会开始启动;当节点故障时,除非 Kubernetes 能够确定 Pod 已经停止运行,否则不会在其他节点上部署新的 Pod。

参考资料

Kubernetes Patterns