k8s 有状态服务
前面我们讲解了无状态服务的部署。但是,现实中大部分的服务都是有状态的,例如数据库,需要持久化的消息队列,以及需要持久化的缓存等等。这些服务的部署和管理都有一些特殊的要求。
在 k8s 中,可以使用有状态组件 StatefulSet 来部署有状态服务。StatefulSet 与 Deployment 类似,但是有一些不同之处。
但是,实际上,在生产环境中一般会把有状态服务部署在专门的数据库集群中,而不是直接部署在 k8s 中。这样可以更好的管理数据库的备份,恢复,以及数据的持久化。两种方法各有优劣,具体情况具体分析,我们都会讲解。
有状态对象
k8s 中有状态对象主要有以下几个。
StatefulSet
StatefulSet 是有状态服务的部署对象。StatefulSet 与 Deployment 类似,但是有一些不同之处。
- StatefulSet 会为每个 Pod 分配一个唯一的标识符,这个标识符会随着 Pod 的重启而保持不变。
- StatefulSet 会按照顺序启动和关闭 Pod,这样可以保证 Pod 的启动和关闭顺序。
这两条特性,使得每一个 Pod 都有一个唯一的标识符,可以用来做一些特殊的操作,例如数据迁移,数据备份等等,因此可以支援有状态服务的部署。
要创建一个 StatefulSet,通常需要一个 Headless Service 作为网络入口,以及一个 PersistentVolume 作为数据持久化,并给 Pod 带上对应的 PersistentVolumeClaim。
PersistentVolume 与 PersistentVolumeClaim
前面我们讲过,StatefulSet 自己本身只是给每个 Pod 分配一个唯一的标识符,但是并不会保证数据的持久化。数据的持久化需要使用 PersistentVolume 与 PersistentVolumeClaim。
PersistentVolume 是一个独立于 Pod 的存储卷,可以用来存储数据。PersistentVolumeClaim 是一个声明,用来请求 PersistentVolume。如果每一个 Pod 都有唯一的 PersistentVolumeClaim,那么每一个 Pod 都有自己的独立的存储卷。这样,就可以保证每一个 Pod 的数据是独立的,实现了有状态服务的部署。
例如,如果一个集群中有两个数据库,而我们希望向外提供一个数据库集群服务,并使用分片技术增加并发数(分片是指将数据分散到多个数据库中,类似于磁盘阵列的 RAID 0),那么我们可以使用 StatefulSet 部署两个数据库,每一个数据库都有自己的 PersistentVolumeClaim,然后使用一个网关将请求分发到这两个数据库中。
为了进一步提高并发数,我们可以部署一个 Redis 来缓存某条数据在哪一个数据库中。但是,一旦任何一个数据库发生故障,如果使用 Deployment 模式,即使使用了正确的持久化技术,重启的 Pod 也会有不同的标识符,这样 Redis 就会失效。因此,在这个例子中,我们需要使用 StatefulSet,来确保每个 Pod 在重启前后有相同的网络标识与数据。
消息队列也是同样的道理,例如工作在扇出模式的消息队列,每一个 Pod 都有自己的队列。此时需要一个独立的持久化存储,以及一个独立的网络标识,来保证重启前后数据的一致性。
Headless Service
Headless 是一种特殊的 Service。前面我们讲过,Service 有四种模式,而 Headless Service 是一种特殊的 ClusterIP Service,它的 ClusterIP 是 None。Headless Service 会为每一个 Pod 分配一个 DNS 记录,这样可以通过 DNS 记录访问每一个 Pod。这样,我们可以通过 DNS 记录访问每一个 Pod,而不需要通过 Service 访问。
有状态服务 k8s 部署
要在 k8s 集群中部署有状态服务,这里我们简单地使用两个 nginx,它们提供的网页将会不同。我们将使用 StatefulSet 来部署这两个 nginx,以及一个 Headless Service 来访问这两个 nginx。
创建 PersistentVolume
首先,我们需要创建若干 PersistentVolume。我们只会部署两个 PostgresQL,因此我们只需要两个 PersistentVolume。
此外,前面我们没有提过,一个文件中可以通过---
分隔符分隔多个对象。
apiVersion: v1
kind: PersistentVolume
metadata:
name: html-pv-0
spec:
storageClassName: html
capacity:
storage: 1Mi
accessModes:
- ReadWriteOnce
hostPath:
path: /mnt/data/html-0
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: html-pv-1
spec:
storageClassName: html
capacity:
storage: 1Mi
accessModes:
- ReadWriteOnce
hostPath:
path: /mnt/data/html-1
这里的 Capacity 即容量,而 AccessModes 是访问模式。这里我们使用 ReadWriteOnce,又称 RWO,即可读可写,但只能被一个 Pod 挂载。其它模式有 ReadOnlyMany(ROX),只读但可以被多个 Pod 挂载,以及 ReadWriteMany(RWX),可读可写,可以被多个 Pod 挂载。RWX 需要手动保持数据一致性。
hostPath 是指将这个 volume 存储在本机的某个路径下,这是在使用 manual 存储类时的一种方式。但是在生产环境中,要么将这个 hostPath 映射到一个网络存储中,要么使用网络存储类。
这里的 storageClassName 是后面将要创建的 PersistentVolumeClaim 用来匹配的字段。
如果你使用了 helm,还可以使用模版来执行循环,来创建多个 PersistentVolume,这里不再赘述。
创建 PersistentVolumeClaim
然后需要创建 PersistentClaim,PersistentClaim 像是 Persistent Volume 的钥匙,用来请求 Persistent Volume。
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc
spec:
storageClassName: html
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 16Ki
这里的 storageClassName 是 PersistentVolume 的 storageClassName,accessModes 是访问模式,resources 是资源请求,这里我们请求 16Ki 的存储。
这里我们创建了一个 storageClassName 为 html 的 PersistentVolumeClaim,这个 PersistentVolumeClaim 会请求一个 storageClassName 为 html 的 PersistentVolume。
如果我们现在把它绑到一个 Pod 上,这个 Pod 就会申请一个同样 storageClassName 为 html 的 PersistentVolume,这个 PersistentVolume 会被绑定到这个 Pod 上。
但是,注意,现在的 Pod 如果重启,或有新的 Pod 在水平扩展中产生,这个 PersistentVolume 会被重新绑定到新的 Pod 上,这样数据就会丢失,同一网络地址的数据一致性丢失。因此需要使用 StatefulSet 来保证数据的一致性。
创建 StatefulSet
现在我们创建 StatefulSet。如果你不熟悉 nginx 的 docker container,这里是一个友善的提醒:nginx 的默认网页是/usr/share/nginx/html
,因此我们需要将 PersistentVolume 挂载到这个路径下。
Stateful 和 Deployment 相似处我们不再赘述。
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: html-stateful-set
spec:
serviceName: "html-service"
replicas: 2
selector:
matchLabels:
app: html-pod
template:
metadata:
labels:
app: html-pod
spec:
containers:
- name: nginx
image: nginx
volumeMounts:
- name: html-pvc
mountPath: /usr/share/nginx/html
volumeClaimTemplates:
- metadata:
name: html-pvc
spec:
accessModes: [ "ReadWriteOnce" ]
storageClassName: html
resources:
requests:
storage: 16Ki
这里我们创建了一个 StatefulSet,名字为 html-statefulset,这个 StatefulSet 会创建两个 Pod,每一个 Pod 都会挂载一个 PersistentVolumeClaim,这个 PersistentVolumeClaim 会请求一个 PersistentVolume。
注意,因为 StatefulSet 需要逐个 Pod 访问,因此必须使用无头服务来访问这两个 Pod,因此需要指定服务名 serviceName 为 html-service。
挂载 volume 时,name 和 volumeClaimTemplates 的 name 需要匹配即可。
创建 Headless Service
最后,我们创建一个 Headless Service,用来访问这两个 Pod。Headless Service 会为每一个 Pod 分配一个 DNS 记录,这样我们可以通过 DNS 记录访问每一个 Pod。
apiVersion: v1
kind: Service
metadata:
name: html-service
spec:
clusterIP: None
selector:
app: html-pod
这里我们创建了一个 Headless Service,名字为 html-headless,这个 Headless Service 会为每一个 Pod 分配一个 DNS 记录,这样我们可以通过 DNS 记录访问每一个 Pod。
外部访问
在启动服务前,我们先进入minikube ssh
,然后在前面指定的 hostPath 创建两个内容不同的index.html
。注意,这时候容器内没有文本编辑器,使用 echo 命令来创建文件。如果你之前已经启动了,使用kubectl delete all --all
删除所有资源。此外,你还需要用 sudo 提权。
echo "<h1>html-0</h1>" | sudo tee ./index.html
然后,我们启动服务。
kubectl apply -f .
现在我们需要单独访问 Pod。当处于 Headless 模式时,Service 会在 DNS 记录每个 Pod 的 IP 地址。我们可以使用kubectl get pods -o wide
来查看 Pod 的 IP 地址。
kubectl get pods -o wide
现在我们可以进入集群,直接访问 Pod 的 IP 地址。可以发现它们正确地返回了不同的网页。
但是,如果是在写后端服务,我们不可能使用 IP 地址来访问。之前我们可以直接使用 Service 名称作为 DNS 记录来访问,现在也一样,可以用特殊的网络地址来访问这两个 Pod,即{stateful-set-name}-{ordinal}.{service-name}
。在这个例子中,就是html-stateful-set-0.html-service
和html-stateful-set-1.html-service
。
curl http://html-stateful-set-0.html-service
注意,要进入任意一个在集群中的容器,可以使用kubectl exec -it {pod-name} -- /bin/bash
。这样才会通过集群的 DNS 记录来访问。
有状态服务外部部署
在上面的例子中,我们介绍了如何用 k8s 部署有状态服务。但是,实际上,大部分有状态服务都是部署在专门的数据库集群中,而不是直接部署在 k8s 中。
使用 k8s 部署有状态服务的优点是,可以使用 k8s 的自动化部署,自动化扩展,以及自动化恢复。但是,缺点是,k8s 并不是一个专门的数据库集群,因此在数据的备份,恢复,以及数据的持久化上,可能会有一些问题。此外,k8s 的容器化也会带来 IO 性能的损失,而有状态服务大多是 IO 密集型的服务。
因此,还有一种解决方案,在 k8s 外部部署有状态服务,然后 k8s 集群内容器去访问这个有状态服务。这样,就可以充分利用 k8s 的自动化部署,自动化扩展,以及自动化恢复,同时又可以使用专门的数据库集群来管理数据。k8s 内部的服务就是纯粹的无状态服务,只需要访问外部的有状态服务。
有状态服务外部部署
首先我们在外部部署一个 nginx,然后在 k8s 集群中访问这个 nginx。
首先,我们在外部部署一个 nginx。
docker run -d --name nginx -p 8080:80 nginx
注意,这里的外部是相对集群的外部,即minikube ssh
进入的虚拟机。k8s 集群和 minikube 的虚拟机是在一个网络中的,但宿主机和虚拟机在 MacOS 和 Windows 上是不同的网络,因此不能直接访问。在生产环境中,使用 linux 系统,没有虚拟机,k8s 可以直连宿主机。
然后,我们在 k8s 集群中访问这个 nginx。我们之前没有讲过的 ExternalName 就能用上了。它能把 k8s 集群外的服务映射到 k8s 集群内的服务。因为我们的 nginx 是运行在localhost:8080
,我们可以使用 ExternalName 来映射这个服务。
apiVersion: v1
kind: Service
metadata:
name: ext
spec:
type: ExternalName
externalName: localhost
ports:
- port: 80
现在,使用curl http://ext
就可以访问到外部的 nginx 了。如果要访问其它的服务,也是同理。
但是,有时候我们的外部服务是 IP,这种时候要使用 Endpoints。Endpoints 其实是 Service 的后端,Service 的 Selector 其实是基于选择的 Pod 创建了一个 Endpoints,然后再创建 Service。
要手动创建 Endpoints,使用下面的配置文件,
apiVersion: v1
kind: Endpoints
metadata:
name: ext
subsets:
- addresses:
- ip: 172.0.0.1
ports:
- port: 80
然后是 Headless,注意我们不要写 selector,Service 的名字要与 Endpoint 的名字一致。
apiVersion: v1
kind: Service
metadata:
name: ext
spec:
ports:
- protocol: TCP
port: 80
targetPort: 80
然后直接访问服务即可,效果是一样的。