📑 官方文档

Design Idea

对于分布式应用,在它的多个实例之间,往往有依赖关系,比如:主从关系、主备关系。这种实例之间有不对等关系,以及实例外部数据有依赖关系应用,就被称为有状态应用(Stateful Application)。

得益于“控制器模式”的设计思想,Kubernetes项目很早就在Deployment的基础上,扩展出了对“有状态应用”的初步支持,这个编排功能就是:StatefulSet。

StatefulSet把真实世界里的应用状态,抽象为了两种情况:

  1. 拓扑状态:应用的多个实例之间不是完全对等关系,必须按照特定顺序启动。
  2. 存储状态:应用的多个实例分别绑定了不同的存储数据,访问数据有幂等性。

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

Headless Service

Service是Kubernetes中用来将一组Pod暴露给外界访问的一种机制。比如,一个Deployment有3个Pod,那么可以定义一个Service,用户只要能访问到这个Service,它就能访问到某个具体的Pod。

  1. 第一种方式,是以Service的VIP(Virtual IP,即:虚拟IP)方式。比如:访问10.0.23.1这个Service的IP地址时,10.0.23.1其实就是一个VIP,它会把请求转发到该Service所代理的一个Pod上。
  2. 第二种方式,是以Service的DNS方式。比如:访问“my-svc.my-namespace.svc.cluster.local”这条DNS记录,就可以访问到名叫my-svc的Service所代理的某一个Pod。

而第二种Server DNS方式下,具体还可以分为两种处理方法:

  1. 第一种处理方法,是Normal Service。这种情况下,访问”my-svc.my-namespace.svc.cluster.local”解析到的,正是my-svc这个Service的VIP,后面的流程就跟VIP方式一致了;
  2. 第二种处理方法,正是Headless Service。这种情况下,访问“my-svc.my-namespace.svc.cluster.local”解析到的,直接就是my-svc代理的某一个Pod的IP地址。

这里的区别在于,Headless Service不需要分配一个VIP,而是可以直接以DNS记录的方式解析出被代理Pod的IP地址。

Practice1

下面是一个标准的Headless Service对应的YAML文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Service
metadata:
name: nginx
labels:
app: nginx
spec:
ports:
- port: 80
name: web
clusterIP: None
selector:
app: nginx

Headless Service,其实仍然是一个Service的YAML文件。只不过,它的clusterIP字段的值是:None,没有一个VIP作为头,这就是Headless的含义。所以,这个Service被创建后并不会被分配一个VIP,而是会以DNS记录的方式暴露出它所代理的Pod。

按照这样的方式创建了一个Headless Service之后,它所代理的所有Pod的IP地址,都会被绑定一个这样的DNS记录,如下所示:

1
<pod-name>.<svc-name>.<namespace>.svc.cluster.local

这个DNS记录,正是Kubernetes项目为Pod分配的唯一的“可解析身份”(Resolvable Identidy)。有了这个“可解析身份”,只要知道了一个Pod的名字,以及它对应的Service的名字,就可以非常确定地通过这条DNS记录访问到Pod的IP地址。

编写一个StatefulSet的YAML文件,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: web
spec:
serviceName: "nginx"
replicas: 2
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.9.1
ports:
- containerPort: 80
name: web

这个YAML文件中,有一个serviceName=nginx字段,用于告诉StatefulSet控制器:在执行控制循环(Control Loop)的时候,请使用nginx这个Headless Service来保证Pod的“可解析身份”。

创建上面的Service和StatefulSet:

1
2
3
4
5
6
7
8
9
10
11
12
$ kubectl create -f headless-service.yaml 
service/nginx created
$ kubectl get service nginx
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
nginx ClusterIP None <none> 80/TCP 19s

$ kubectl create -f statefulset-demo.yaml
statefulset.apps/web created
$ kubectl get pods -w -l app=nginx
NAME READY STATUS RESTARTS AGE
web-0 1/1 Running 0 10s
web-1 1/1 Running 0 8s

通过上面的Pod创建过程,可以看到StatefulSet给它所管理的所有Pod的名字,进行了编号,编号规则是:<statefulset-name>-<ordinal index>。而且这些编号都是从0开始累加,与Stateful的每个Pod实例一一对应,绝不重复。

更重要的是,这些Pod的创建,也是按照编号顺序进行的。比如,在web-0进入到Running状态、并且细分状态(Conditions)成为Ready之前,web-1会一直处于Pending状态。

使用kubectl exec命令进入到容器中查看它们的hostname:

1
2
3
4
$ kubectl exec web-0 -- sh -c 'hostname'
web-0
$ kubectl exec web-1 -- sh -c 'hostname'
web-1

可以看到,这两个Pod的hostname与Pod名字是一致的,都被分配了对应的编号。

接下来,再试着以DNS的方式,访问一下这个Headless Service:

1
$ kubectl run -i --tty --image busybox:1.28.4 dns-test --restart=Never --rm /bin/sh

这个命令启动了一个一次性的Pod,因为–rm意味着Pod退出后就会被删除掉。然后,在这个Pod的容器里面,尝试用nslookup命令,解析一下Pod对应的Headless Service:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ nslookup web-0.nginx
Server: 10.96.0.10
Address 1: 10.96.0.10 kube-dns.kube-system.svc.cluster.local

Name: web-0.nginx
Address 1: 10.1.0.100 web-0.nginx.default.svc.cluster.local

$ nslookup web-1.nginx
Server: 10.96.0.10
Address 1: 10.96.0.10 kube-dns.kube-system.svc.cluster.local

Name: web-1.nginx
Address 1: 10.1.0.101 web-1.nginx.default.svc.cluster.local

从nslookup命令的输出结果中,可以看到,在访问web-0.nginx的时候,最后解析到的,正是web-0这个Pod的IP地址;而当访问web-1.nginx的时候,解析到的则是web-1的IP地址。

这时候,如果在另一个Terminal里把这两个“有状态应用”的Pod删掉。

1
2
3
$ kubectl delete pod -l app=nginx
pod "web-0" deleted
pod "web-1" deleted

然后,在当前Terminal里Watch一下这两个Pod的状态变化:

1
2
3
4
5
6
7
8
$ kubectl get pod -w -l app=nginx
NAME READY STATUS RESTARTS AGE
web-0 0/1 ContainerCreating 0 0s
NAME READY STATUS RESTARTS AGE
web-0 1/1 Running 0 2s
web-1 0/1 Pending 0 0s
web-1 0/1 ContainerCreating 0 0s
web-1 1/1 Running 0 32s

可以看到,把这两个Pod删除之后,Kubernetes会按照原先编号点顺序,创建出两个新的Pod。并且,Kubernetes依然为它们分配了原来相同的“网络身份”:web-0.nginx和web-1.nginx。

通过这种严格的对应规则,StatefulSet就保证了Pod网络标识的稳定性。

比如,如果 web-0 是一个需要先启动的主节点,web-1 是一个后启动的从节点,那么只要这个 StatefulSet 不被删除,你访问 web-0.nginx 时始终都会落在主节点上,访问 web-1.nginx 时,则始终都会落在从节点上,这个关系绝对不会发生任何变化。

回到最开始的Terminal,再用nslookup命令,查看一下这个新的Pod对应的Headless Service的话:

1
2
3
4
5
6
7
8
9
10
11
12
$ nslookup web-0.nginx
Server: 10.96.0.10
Address 1: 10.96.0.10 kube-dns.kube-system.svc.cluster.local

Name: web-0.nginx
Address 1: 10.1.0.103 web-0.nginx.default.svc.cluster.local
$ nslookup web-1.nginx
Server: 10.96.0.10
Address 1: 10.96.0.10 kube-dns.kube-system.svc.cluster.local

Name: web-1.nginx
Address 1: 10.1.0.104 web-1.nginx.default.svc.cluster.local

可以看到,在这个Stateful中,这两个新的Pod的“网络标识”,再次解析到了正确的IP地址。

通过这种方法,Kubernetes就成功地将Pod的拓扑状态,按照Pod的“名字+编号”的方式固定了下来。此处,Kubernets还为每一个Pod提供了一个固定并且唯一的访问入口,即:这个Pod对应的DNS记录。

这些状态,在StatefulSet的整个生命周期里都保持不变,绝不会因为对应Pod的删除或者重新创建而失效。

尽管web-0.nginx这条记录本身不会变,但它解析到的Pod的IP地址,并不是固定的。这就意味着,对于“有状态应用”实例的访问,必须使用DNS记录或者hostname的方式,而绝不应该直接访问这些Pod的IP地址。

Persistent Volume Claim

Kubernetes引入了一组叫作Persistent Volume Claim(PVC)和Persistent Volume(PV)的API对象,降低了用户声明和使用暴露Volume的门槛。

下面的例子,声明了Ceph RBD类型的Volume的Pod:

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
apiVersion: v1
kind: Pod
metadata:
name: rbd
spec:
containers:
- image: kubernetes/pause
name: rbd-rw
volumeMounts:
- name: rbdpd
mountPath: /mnt/rbd
volumes:
- name: rbdpd
rbd:
monitors:
- '10.16.154.78:6789'
- '10.16.154.82:6789'
- '10.16.154.83:6789'
pool: kube
image: foo
fsType: ext4
readOnly: true
user: admin
keyring: /etc/ceph/keyring
imageformat: "2"
imagefeatures: "layering"

第一步:定义一个PVC,声明想要的Volume属性:

1
2
3
4
5
6
7
8
9
10
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pv-claim
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi

在这个PVC对象里,不需要任何关于Volume细节的字段,只有描述性的属性和定义。

  • Storage: 1Gi,表示需要的Volume大小至少是1GiB;
  • accessModes: ReadWriteOnce,表示这个Volume的挂载方式是可读写,并且只能被挂载在一个节点上而非被多个节点共享。

第二步:在应用的Pod中,声明使用这个PVC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
persistentVolumeClaim:
claimName: pv-claim

在这个Pod的Volumes定义中,只需要声明它的类型是persistentVolumeClaim,然后指定PVC的名字,而完全不必关系Volume本身的定义。

这时候,只要创建这个PVC对象,Kubernetes就会自动为它绑定一个符合条件的Volume,Volume来自于运维人员维护的PV(Persistent Volume)对象。

比如下面一个PV对象的YAML文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
kind: PersistentVolume
apiVersion: v1
metadata:
name: pv-volume
labels:
type: local
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
rbd:
monitors:
# 使用 kubectl get pods -n rook-ceph 查看 rook-ceph-mon- 开头的 POD IP 即可得下面的列表
- '10.16.154.78:6789'
- '10.16.154.82:6789'
- '10.16.154.83:6789'
pool: kube
image: foo
fsType: ext4
readOnly: true
user: admin
keyring: /etc/ceph/keyring

可以看到,这个PV对象的spec.rbd字段,正是CephRBD Volume的详细定义。

所以,Kubernetes中PVC和PV的设计,实际上类似于“接口”和“实现”的思想。开发者只要知道并会使用接口,即:PVC;而运维人员则负责给接口绑定具体的实现,即:PV。这种解耦,就避免了因为向开发者暴露过多的存储系统细节而带来的隐患。此外,这种职责的分离,往往也意味着出现事故时可以更容易定位问题和明确责任,从而避免“扯皮”现象的出现。

PVC、PV的设计,也使得StatefulSet对存储状态的管理成为了可能。

Practice2

如下,在Practice1的StatefulSet的YAML文件的基础上添加一个volumeClaimTemplates字段:

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
33
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: web
spec:
serviceName: "nginx"
replicas: 2
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.9.1
ports:
- containerPort: 80
name: web
volumeMounts:
- name: www
mountPath: /usr/share/nginx/html
volumeClaimTemplates:
- metadata:
name: www
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi

volumeClaimTemplates跟Deployment里Pod模板(PodTemplate)的作用类似。也就是说,凡是被这个Stateful管理的Pod,都会声明一个对应的PVC;而这个PVC的定义,就来自于volumeClaimTemplates这个模板字段。更重要的是,这个PVC的名字,会被分配到一个与这个Pod完全一致的编号。

这个自动创建的PVC,与PV绑定成功后,就会进入到Bound状态,这就意味着这个Pod可以挂载并使用这个PV了。

创建上面的StatefulSet对象,就能看到Kubernetes集群里出现了两个PVC:

1
2
3
4
5
6
$ kubectl create -f stateful-volume.yaml
statefulset.apps/web created
$ kubectl get pvc -l app=nginx
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
www-web-0 Bound pvc-4ac9329b-81da-4ff5-826c-ee318f38b1a9 1Gi RWO hostpath 13s
www-web-1 Bound pvc-7aafd41e-4afa-49fb-9ca9-6ab235c20602 1Gi RWO hostpath 10s

可以看到,这些PVC,都以“<PVC名字>-<Statul名字>-<编号>”的方式命名,并且处于Bound状态。

这个StatefulSet创建出来的所有Pod,都会声明使用编号的PVC。比如,在名叫web-0的Pod的volumes字段,它会声明使用名叫www-web-0的PVC,从而挂载到这个PVC所绑定的PV。

使用如下的指令,在Pod的Volume目录里写入一个文件,来验证上述Volume的分配情况:

1
2
$ for i in 0 1; \
do kubectl exec web-$i -- sh -c 'echo hello $(hostname) > /usr/share/nginx/html/index.html'; done

如上所示,通过kubectl exec指令,在每个Pod的Volume目录里,写入了一个index.html文件。这个文件的内容,正是Pod的hostname。

1
2
3
4
$  for i in 0 1; \
do kubectl exec web-$i -- sh -c 'cat /usr/share/nginx/html/index.html'; done
hello web-0
hello web-1

现在,删除这个两个Pod,看一下Volume中的文件是否会丢失。

1
2
3
$ kubectl delete pod -l app=nginx
pod "web-0" deleted
pod "web-1" deleted

在被删除之后,这两个Pod会按照编号的顺序被重新创建出来,验证一下:

1
2
3
4
$ for i in 0 1; \
do kubectl exec web-$i -- sh -c 'cat /usr/share/nginx/html/index.html'; done
hello web-0
hello web-1

请求结果没有改变,也就是说,原先与名交web-0的Pod绑定的PV,在这个Pod被重新创建之后,依然同新的名交web-0的Pod绑定在了一起。对于Pod web-1来说,也是完全一样的情况。

Storage State

分析一下StatefulSet控制器恢复Pod的过程。

首先,把一个Pod,比如web-0,删除之后,这个Pod对应的PVC和PV,并不会被删除,而这个Volume里已经写入的数据,也依然会保存在远程存储服务里(比如在这个例子里用到的Ceph服务器)。

此时,StatefulSet控制器发现,一个名叫web-0的Pod消失了。所以,控制器就会重新创建一个新的、名字还是叫作web-0的Pod来,纠正这个不一致的情况。

需要注意的是,在这个新的Pod对象的定义里,它声明使用的PVC的名字,还是叫作:www-web-0。这个PVC的定义,还是来自于PVC模板(volumeClaimTemplates),这是StatefulSet创建Pod的标准流程。

所以,在这个新的web-0 Pod被创建出来之后,Kubernetes为它查找名叫www-web-0的PVC时,就会直接找到旧Pod遗留下来的同名的PVC,进而找到跟这个PVC绑定在一起的PV。

这样,新的Pod就可以挂载到旧Pod对应的那个Volume,并且获取在Volume里的数据。

通过这种方式,Kubernetes的StatefulSet就实现了对应用存储状态的管理。

首先,StatefulSet的控制器直接管理的是Pod。这是因为,StatefulSet里的不同Pod实例,不再像ReplicaSet中那样都是完全一样的,而是有了细微区别的。比如,每个Pod的hostname、名字等都是不同的、携带了编号的。而StatefulSet区分这些实例的方式,就是通过在Pod的名字里加上实现约定好的编号。

其次,Kubernetes通过Headless Service,为这些有编号的Pod,在DNS服务器中生成带有同样编号的DNS记录。只要StatefulSet能够保证这些Pod名字里的编号不变,那么Service里类似于web-0.nginx.default.svc.cluster.local这样的DNS记录也就不会变,而这条记录解析出来的Pod的IP地址,则会随着后端Pod的删除和再创建而自动更新。这当然是Service机制本身的能力,不需要StatefulSet操心。

最后,StatefulSet还为每一个Pod分配并创建一个编号的PVC。这样,Kubernetes就可以通过Persistent Volume机制为这个PVC绑定上对应的PV,从而保证了每一个Pod都拥有一个独立的Volume。

在这种情况下,即使Pod被删除,它所对应的PVC和PV依然会保留下来。所以当这个Pod被重新创建出来之后,Kubernetes会为它找到同样编号的PVC,挂载这个PVC对应的Volume,从而获取到以前保存在Volume里的数据。