qingtong Blog

从 Docker 到云原生(03)— StatefulSet

May 20, 2024

上一节 主要聊到了 Kubernetes 里的核心组件,包括 Pod, Deployment, Service 和 Ingress。其中 Deployment 的设计是对 Pod 的抽象,以及基于 Deployment 能实现“水平扩缩容”的应用编排能力。

但 Deployment 在面对有状态类(Stateful)应用就有心无力了。问题的根源出自于 Deployment 对应用做了一个简单化的假设:应用的不同 Pod 副本是无差别一致的,Pod 之间没有顺序之分,也无所谓在哪台宿主机上运行。Deployment 可以基于 Pod 模板(Pod template)创新副本,也可以按需结束任意一个 Pod。

但实际场景中,并非所有应用都满足这样的要求,尤其是分布式应用,实例之间往往有依赖关系,比如主从关系,主备关系;还有存储类应用,它的多个实例往往会在本地持久化数据;一旦实例结束,即使重建以后,实例和数据的对应关系已经丢失,从而导致应用失败。

所以,这种实例之间存在不对等关系,以及实例对外部数据有依赖关系的应用,称为有状态应用(Stateful App)。

Kubernetes 使用 StatefulSet 组件实现了对有状态应用的编排。

StatefulSet

StatefulSet 的设计是把应用抽象为了两种情况:

  1. 拓扑(topology)状态。应用的多个实例之间不是完全对等的,应用实例必须按照顺序依次启动。比如应用的主节点 A 必须先于节点 B 启动。而如果删除 A 和 B 两个 Pod,它们再次被创建也必须严格遵循这个顺序运行。并且,新建的 Pod 标识必须和原来一样,这样原先的访问者仍然能用同样的方法继续访问 Pod。
  2. 存储状态。应用的多个实例分别绑定不同的存储数据。即使 Pod 被重新创建过,对于应用实例来说,前后访问到的数据还是同一份。这种情况最典型的例子就是一个数据库应用的多个存储实例。

StatefulSet 的核心功能,就是通过某种方式记录这些状态,然后在 Pod 被重新创建时,能够为新 Pod 恢复这些状态。

在讲 StatefulSet 的核心原理时,要先来介绍一下上一节 提到过的 Headerless Service。

Headless Service

Service 是将 Pod 暴露给外部访问的一种方式,比如,一个 Deployment 实现了 3 个 Pod 副本,那就可以定义一个 Service,用户只要能访问到 Service,就能访问到对应的 Pod。

在有状态应用(Stateful App)中,我们需要保证 Service 能够访问到指定的 Pod,那就需要分配 Pod 一个唯一标识,即使 Pod 被删除重建后依然保持同一个唯一标识,在 Kubernetes 里面就通过 Headless Service 组件来实现。

以下是一个 Headless Service YAML 文件:

apiVersion: v1
kind: Service
metadata:
  name: my-service
spec:
  # Headless Servive
  clusterIP: None
  selector:
    app: my-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 9376

Headless Service 和标准 Service 最大的区别在于 spec.clusterIP 设置为 None,即这个 Service 没有一个 VIP 作为“头”。该 Service 创建以后不会被分配 VIP,而是会以 DNS 记录的方式暴露出它所代理的 Pod。

DNS(Domain Name System,域名系统)在 Kubernetes 里面是用于服务发现和负载均衡的一种实现,它允许 Pod 和 Service 使用节点名(而不是 IP 地址)进行通信,简化了集群内部服务之间的相互访问。

Service 在创建时,Kubernetes 会分配一个 DNS 名称。例如,一个名为 my-service 的 Headless Service 在默认命名空间中,可以通过my-service.default.svc.cluster.local 这样的方式访问。同时该 Service 为每个 Pod 也会创建 DNS 记录,如 pod-name.my-service.default.svc.cluster.local,这个 DNS 地址会直接解析到该 Pod 的 IP 地址。

这个 DNS 记录,正式 Kubernetes 为 Pod 分配的唯一可解析身份(resolvable identity),有了这个可解析身份,只要知道了 Pod 名称及其对应的 Service 名称,就可以非常确定通过这条 DNS 记录访问到 Pod 的 IP 地址。

StatefulSet

基于下面 YAML 我们创建一个 StatefulSet:

apiVersion: v1
kind: Service
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  ports:
  - port: 80
    name: web
  # 定义 Headless Service
  clusterIP: None
  selector:
    app: nginx
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: web
spec:
  selector:
    matchLabels:
      app: nginx # has to match .spec.template.metadata.labels
  # 使用上面定义的 Headless Service
  serviceName: "nginx"
  replicas: 3 # by default is 1
  minReadySeconds: 10 # by default is 0
  template:
    metadata:
      labels:
        app: nginx # has to match .spec.selector.matchLabels
    spec:
      terminationGracePeriodSeconds: 10
      containers:
      - name: nginx
        image: registry.k8s.io/nginx-slim:0.8
        ports:
        - containerPort: 80
          name: web
  # 挂载持久卷
  volumeClaimTemplates:
  - metadata:
      # 对应 pvc 资源的前缀名称
      name: www
    spec:
      accessModes: [ "ReadWriteOnce" ]
      storageClassName: "my-storage-class"
      resources:
        requests:
          storage: 1Gi

相比于 deployment,StatefulSet 区别在于多了一个spec.serviceName 字段,该字段作用就是告诉 StatefulSet controller,在执行控制循环是使用 nginx 这个 Headless Service

通过 kubectl 创建 service 和 statefulSet 之后,我们可以看到两个对象:

$ kubectl apply -f statefulSet.yaml
service/nginx created
statefulset.apps/web created
 
$ kubectl get svc nginx
NAME    TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
nginx   ClusterIP   None         <none>        80/TCP    18s
 
$ kubectl get statefulSet web
NAME   READY   AGE
web    0/3     33s
 
$ kubectl get pod -l name=nginx
NAME    READY   STATUS    RESTARTS   AGE
web-0   1/1     Running   0          2m
web-1   1/1     Running   0          20s

可以看到,StatefulSet 给所有 Pod 命名规则是<statefulSet-name>-[0], <statefulSet-name>-[1] 这样的编号递增规则,编号与 Pod 实例一一对应。

更重要的是,Pod 的创建也是严格按照编号顺序进行的。比如,在 web-0 进入 Running 之前,web-1 会一直处于 Pending 状态。

当两个 Pod 都进入 Running 状态后,就可以查看各自唯一的网络身份了。

我们可以尝试以 DNS 方式访问这个 Headless Service:

# 启动一个临时 Pod,--rm 代表退出就会被删除
$ kubectl run -i --tty --image busybox dns-test --restart=Never --rm /bin/sh
 
# 通过 nslookup 查看 service 对应的网络地址
$ nslookup nginx.default.svc.cluster.local
Server:		10.96.0.10
Address:	10.96.0.10:53
 
 
Name:	nginx.default.svc.cluster.local
Address: 10.244.0.8
Name:	nginx.default.svc.cluster.local
Address: 10.244.0.6
Name:	nginx.default.svc.cluster.local
Address: 10.244.0.7
 
# 查看 pod 对应的网络地址
$ nslookup web-0.nginx.default.svc.cluster.local
Server:		10.96.0.10
Address:	10.96.0.10:53
 
 
Name:	web-0.nginx.default.svc.cluster.local
Address: 10.244.0.6

可以看到,DNS 查询 Service 会返回代理的两个 EndPoint(POD) 对应的 IP,这个 IP 跟通过 DNS 查询 pod 返回是保持一致的。

需要注意的是,尽快 DNS 记录是保持不变的,但它解析到的 IP 地址并不是固定不变,这就意味着,对于有状态应用实例的访问,只能通过 DNS 记录方式访问,而绝不应该直接访问 pod 的 IP 地址。

PV 和 PVC

在聊 StatefulSet 如何解决分布式存储状态的一致性前,要先聊另外一个概念 PVPVC

PV(PersistentVolume,持久卷)是集群中的一块存储,可以由 admin 事先生成。持久卷是集群资源,就像节点也是集群资源一样,它们拥有独立于 Pod 的生命周期。

apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv0003
spec:
  capacity:
    storage: 5Gi
  volumeMode: Filesystem
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Recycle
  storageClassName: slow
  mountOptions:
    - hard
    - nfsvers=4.1
  nfs:
    path: /tmp
    server: 172.17.0.2

作为应用开发者,可能对分布式存储项目(Ceph/HDFS/GFS)不甚了解,自然无法编写对应的 Volume 配置文件,而且有暴露公司基础设施敏感信息(秘钥、管理员密码)等的风险。所以 Kubernetes 又引入了 PVC(PersistentVolumeClaim,持久卷申领)API 对象。

定义一个 PVC,声明想要的 Volumn 属性:

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  # 定义 claim 名称
  name: pv-claim
spec:
  # 可读写权限
  accessModes:
  - ReadWriteOnce
  # 资源占用
  resources:
    requests:
      storage: 1Gi
---
apiVersion: v1
kind: Pod
metadata:
  name: pv-pod
spec:
  containers:
    - name: pv-container
      image: nginx
      ports:
        - containerPort: 80
          name: "http-server"
      volumeMounts:
        - mountPath: "/usr/share/nginx/html"
          name: pv-storage
  volumes:
    - name: pv-storage
      # 申领 volumn 名称
      persistentVolumeClaim:
        claimName: pv-claim

PVC 表达的是Pod对存储的请求。概念上与 Pod 类似。 Pod 会耗用节点资源,而 PVC 申领会耗用 PV 资源。有了 PVC 后,在需要使用持久卷的 Pod 定义里只需要声明使用这个 PVC 即可,这为使用者隐去了很多关于存储的信息。而具体存储的信息就交给 PV 实现即可。

PVC 和 PV 的设计思想可以参考面向对象领域的接口(interface)和实现(implement)。开发者只需知道并会使用接口,即 PVC,具体的接口绑定实现则由运维人员负责,即 PV。

StatefulSet 的 PVC 模板

StatefulSet yaml 配置中,声明了一个 volumeClaimTemplates 字段:

volumeClaimTemplates:
  - metadata:
      name: www
    spec:
      accessModes: [ "ReadWriteOnce" ]
      storageClassName: "my-storage-class"
      resources:
        requests:
          storage: 1Gi

volumeClaimTemplates 和 Deployment 里的 Pod template 类似。也就是说,凡是被 StatefulSet 管理的 Pod,都会声明一个对应的 PVC,而 PVC 的定义,就来自于 volumeClaimTemplates 这个模板字段。更重要的是,这个 PVC 的名字会被分配一个与 Pod 完全一致的编号,相当于 PVC 就与 Pod 绑定了。

StatefulSet创建的这些PVC,都以“PVC名-StatefulSet名-序号”这个格式命名的。

对于上面这个StatefulSet来说,它创建出来的 Pod 和 PVC 的名称如下:

Pod: web-0, web-1
PVC: www-web-0, www-web-1

StatefulSet 架构如下:

image

假设现在 Pod-web-0 被删除,对应的 PVC 和 PV 并不会被删除,volumn 写入的数据依然存储在远程存储服务上。此时 StatefulSet 控制器会重新生成一个名为 Pod-web-0 的 Pod 对象,用来纠正这种不一致的情况。

新生成的 Pod 对象,由于 volumeClaimTemplates 的存在,它声明使用的 PVC 名称还是 www-web-0,由于 PVC 的生命周期是独立于 Pod,这样新 Pod 就接管了以前旧 Pod 留下的数据。通过这种方式,Kubernetes 的 StatefulSet 就完成了对应用存储状态的管理。

总结一下:

  1. 首先 StatefulSet 控制管理的是 Pod。每个 Pod 携带了固定的编号,且不随 Pod 调度而变化。
  2. Kubernetes 通过 Headless Service 为这些有编号的 Pod,在 DNS 服务器中生成带有相同编号的 DNS 记录。只要 StatefulSet 保证 Pod 名字编号不变,那么 Service 类似于 pod-0.my-service.default.svc.cluster.local 的记录就不会变,而这条记录对应的的 IP 地址,会随着 Pod 的删除和重建而自动更新。
  3. 最后 StatefulSet 为每个 Pod 分配并创建一个相同编号的 PVC,这样 Kubernetes 可以通过 PVC 绑定对应的 PV,从而保证每个 Pod 都有一个独立的 Volumn。

参考

  1. 深入理解StatefulSet