为了恰当地展示Statefulset的行为,将会创建一个小的集群数据存储。没有太多功能,就像石器时代的一个数据存储。
10.3.1 创建应用和容器镜像
你将使用书中一直使用的kubia应用作为你的基础来扩展它,达到它的每个pod实例都能用来存储和接收一个数据项。 下面列举了你的数据存储的关键代码。
代码清单10.1 一个简单的有状态应用:kubia-pet-image/app.js
const http = require('http');
const os = require('os');
const fs = require('fs');
const dataFile = "/var/data/kubia.txt";
function fileExists(file) {
try {
fs.statSync(file);
return true;
} catch (e) {
return false;
}
}
var handler = function(request, response) {
if (request.method == 'POST') {
var file = fs.createWriteStream(dataFile);
file.on('open', function (fd) {
request.pipe(file); #存储到一个数据文件中
console.log("New data has been received and stored.");
response.writeHead(200);
response.end("Data stored on pod " + os.hostname() + "\n");
});
} else {
var data = fileExists(dataFile) ? fs.readFileSync(dataFile, 'utf8') : "No data posted yet"; #返回主机名和数据文件名称
response.writeHead(200);
response.write("You've hit " + os.hostname() + "\n");
response.end("Data stored on this pod: " + data + "\n");
}
};
var www = http.createServer(handler);
www.listen(8080);
当应用接收到一个POST请求时,它把请求中的body数据内容写入 /var/data/kubia.txt
文件中。而在收到GET请求时,它返回主机名和存储数据(文件中的内容)。是不是很简单呢?这是你的应用的第一版本。它还不是一个集群应用,但它足够让你可以开始工作。在本章的后面,你会来扩展这个应用。
用来构建这个容器镜像的Dockerfile文件与之前的一样,如下面的代码清单所示。
代码清单10.2 有状态应用的Dockerfile:kubia-pet-image/Dockerfile
FROM node:7
ADD app.js /app.js
ENTRYPOINT ["node", "app.js"]
现在来构建容器镜像,或者使用笔者上传的镜像:docker.io/luksa/kubia-pet
。
10.3.2 通过Statefulset部署应用
为了部署你的应用,需要创建两个(或三个)不同类型的对象:
- 存储你数据文件的持久卷(当集群不支持持久卷的动态供应时,需要手动创建)
- Statefulset必需的一个控制Service
- Statefulset本身
对于每一个pod实例,Statefulset都会创建一个绑定到一个持久卷上的持久卷声明。如果你的集群支持动态供应,就不需要手动创建持久卷(可跳过下一节)。如果不支持的话,可以按照下一节所述创建它们。
创建持久化存储卷
因为你会调度Statefulset创建三个副本,所以这里需要三个持久卷。如果你计划调度创建更多副本,那么需要创建更多持久卷。
如果你使用Minikube,请参考本书代码附件中的 Chapter06/persistentvolumes-hostpath.yaml
来部署持久卷。
如果你在使用谷歌的Kubernetes引擎,需要首先创建实际的GCE持久磁盘:
$ gcloud compute disks create --size=1GiB --zone=europe-west1-b pv-a
$ gcloud compute disks create --size=1GiB --zone=europe-west1-b pv-b
$ gcloud compute disks create --size=1GiB --zone=europe-west1-b pv-c
注意 保证创建的持久磁盘和运行的节点在同一区域。
然后通过 persistent-volumes-hostpath.yaml
文件创建需要的持久卷,如下面的代码清单所示。
代码清单10.3 三个持久卷:persistent-volumes-hostpath.yaml
kind: List
apiVersion: v1
items:
- apiVersion: v1
kind: PersistentVolume #持久卷的描述
metadata:
name: pv-a #持久卷的名称
spec:
capacity:
storage: 1Mi #持久卷的大小
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Recycle
hostPath:
path: /tmp/pv-a
- apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-b
spec:
capacity:
storage: 1Mi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Recycle #当卷声明释放后,空间会被回收利用
hostPath:
path: /tmp/pv-b
- apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-c
spec:
capacity:
storage: 1Mi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Recycle
hostPath:
path: /tmp/pv-c
注意 在上一节通过在同一YAML文件中添加三个横杠(---)来区分定义多个资源,这里使用另外一种方法,定义一个List对象,然后把各个资源作为List对象的各个项目。上述两种方法的效果是一样的。
通过上诉文件创建了pv-a、pv-b和pv-c三个持久卷。
创建控制Service
如我们之前所述,在部署一个Statefulset之前,需要创建一个用于在有状态的pod之间提供网络标识的headless Service。下面的代码显示了Service的详细信息。
代码清单10.4 在Statefulset中使用的 kubia-service-headless.yaml
apiVersion: v1
kind: Service
metadata:
name: kubia
spec:
clusterIP: None #Statefulset的控制Service必须是headless模式
selector:
app: kubia #标签选择器
ports:
- name: http
port: 80
上面指定了clusterIP为None,这就标记了它是一个headless Service。它使得你的pod之间可以彼此发现(后续会用到这个功能)。创建完这个Service之后,就可以继续往下创建实际的Statefulset了。
创建Statefulset详单
最后可以创建Statefulset了,下面的代码清单显示了其详细信息。
代码清单10.5 Statefulset详单:kubia-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: kubia
spec:
serviceName: kubia
replicas: 2
selector:
matchLabels:
app: kubia # has to match .spec.template.metadata.labels
template:
metadata:
labels:
app: kubia #定义标签
spec:
containers:
- name: kubia
image: luksa/kubia-pet
ports:
- name: http
containerPort: 8080
volumeMounts:
- name: data
mountPath: /var/data #pvc数据卷嵌入指定目录
volumeClaimTemplates:
- metadata:
name: data
spec:
resources:
requests:
storage: 1Mi
accessModes:
- ReadWriteOnce
这个Statefulset详单与之前创建的ReplicaSet和Deployment的详单没太多区别,这里使用的新组件是volumeClaimTemplates列表。其中仅仅定义了一个名为data的卷声明,会依据这个模板为每个pod都创建一个持久卷声明。如之前在第6章中介绍的,pod通过在其详单中包含一个PersistentVolumeClaim卷来关联一个声明。但在上面的pod模板中并没有这样的卷,这是因为在Statefulset创建指定pod时,会自动将PersistentVolumeClaim卷添加到pod详述中,然后将这个卷关联到一个声明上。
创建Statefulset
现在就要创建Statefulset了:
$ kubectl create -f kubia-statefulset.yaml
现在列出你的pod:
$ kubectl get po
有没有发现不同之处?是否记得一个ReplicaSet会同时创建所有的pod实例?你的Statefulset配置去创建两个副本,但是它仅仅创建了单个pod。
不要担心,这里没有出错。第二个pod会在第一个pod运行并且处于就绪状态后创建。Statefulset这样的行为是因为:状态明确的集群应用对同时有两个集群成员启动引起的竞争情况是非常敏感的。所以依次启动每个成员是比较安全可靠的。特定的有状态应用集群在两个或多个集群成员同时启动时引起的竞态条件是非常敏感的,所以在每个成员完全启动后再启动剩下的会更加安全。
再次列出pod并查看pod的创建过程:
$ kubectl get poNAME READY STATUS RESTARTS AGEkubia-0 1/1 Running 0 2m11skubia-1 1/1 Running 0 81s
可以看到,第一个启动的pod状态是running,第二个pod已经创建并在启动过程中。
检查生成的有状态pod
现在让我们看一下第一个pod的详细参数,看一下Statefulset如何从pod模板和持久卷声明模板来构建pod,如下面的代码清单所示。
代码清单10.6 Statefulset创建的有状态pod
$ k get po kubia-0 -o yamlmetadata: creationTimestamp: "2021-07-16T01:18:16Z" generateName: kubia- labels: app: kubia controller-revision-hash: kubia-c94bcb69b statefulset.kubernetes.io/pod-name: kubia-0 name: kubia-0 namespace: custom........................spec: containers: - image: luksa/kubia-pet imagePullPolicy: Always name: kubia ports: - containerPort: 8080 name: http protocol: TCP resources: {} terminationMessagePath: /dev/termination-log terminationMessagePolicy: File volumeMounts: - mountPath: /var/data #存储挂载点 name: data - mountPath: /var/run/secrets/kubernetes.io/serviceaccount name: kube-api-access-fb4qg readOnly: true ....................... volumes: - name: data persistentVolumeClaim: #Statefulset创建的数据卷 claimName: data-kubia-0 - name: kube-api-access-fb4qg #数据卷相关声明 projected: defaultMode: 420 sources:
通过持久卷声明模板来创建持久卷声明和pod中使用的与持久卷声明相关的数据卷。
检查生成的持久卷声明
现在列出生成的持久卷声明来确定它们被创建了:
$ kubectl get pvc
生成的持久卷声明的名称由在volumeClaimTemplate字段中定义的名称和每个pod的名称组成。可以检查声明的YAML文件来确认它们符合模板的定义。
10.3.3 使用你的pod
现在你的数据存储集群的节点都已经运行,可以开始使用它们了。因为之前创建的Service处于headless模式,所以不能通过它来访问你的pod。需要直接连接每个单独的pod来访问(或者创建一个普通的Service,但是这样还是不允许你访问指定的pod)。
前面已经介绍过如何直接访问pod:借助另一个pod,然后在里面运行curl命令或者使用端口转发。这次来介绍另外一种方法,通过API服务器作为代理。
通过API服务器与pod通信
API服务器的一个很有用的功能就是通过代理直接连接到指定的pod。如果想请求当前的kubia-0 pod,可以通过如下URL:
<apiServerHost>:<port>/api/v1/namespaces/default/pods/kubia-0/proxy/<path>
因为API服务器是有安全保障的,所以通过API服务器发送请求到pod是烦琐的(需要额外在每次请求中添加授权令牌)。幸运的是,在第8章中已经学习了如何使用kubectl proxy来与API服务器通信,而不必使用麻烦的授权和SSL证书。再次运行代理如下:
$ kubectl proxy
现在,因为要通过kubectl代理来与API服务器通信,将使用 localhost:8001
来代替实际的API服务器主机地址和端口。你将发送一个如下所示的请求到kubia-0 pod:
$ curl localhost:8001/api/v1/namespaces/custom/pods/kubia-0/proxy/
返回的消息表明你的请求被正确收到,并在kubia-0 pod的应用中被正确处理。
注意 如果你收到一个空的回应,请确保在URL的最后没有忘记输入/符号(或者用curl的-L选项来允许重定向)
因为你正在使用代理的方式,通过API服务器与pod通信,每个请求都会经过两个代理(第一个是kubectl代理,第二个是把请求代理到pod的API服务器)。详细的描述如图10.10所示。
image
图10.10 通过kubectl代理和API服务器代理来与一个pod通信
上面介绍的是发送一个GET请求到pod,也可以通过API服务器发送POST请求。发送POST请求使用的代理URL与发送GET请求一致。
当你的应用收到一个POST请求时,它把请求的主体内容保存到本地一个文件中。发送一个POST请求到kubia-0 pod的示例:
$ curl -X POST -d "Hey there! This greeting was submitted to kubia-0." localhost:8001/api/v1/namespaces/custom/pods/kubia-0/proxy/
你发送的数据现在已经保存到pod中,那让我们检查一下当你再次发送一个GET请求时,它是否返回存储的数据:
$ curl localhost:8001/api/v1/namespaces/default/pods/kubia-0/proxy/
挺好的,到目前为止都工作正常。现在让我们看看集群其他节点(kubia-1 pod
):
$ curl localhost:8001/api/v1/namespaces/custom/pods/kubia-1/proxy/
与期望的一致,每个节点拥有独自的状态。那这些状态是否是持久的呢?让我们进一步验证。
删除一个有状态pod来检查重新调度的pod是否关联了相同的存储
你将会删除kubia-0 pod,等待它被重新调度,然后就可以检查它是否会返回与之前一致的数据:
$ kubectl delete po kubia-0
如果你列出当前pod,可以看到该pod正在终止运行:
$ kubectl get po
当它一旦成功终止,Statefulset会重新创建一个具有相同名称的新的pod:
$ kubectl get po
请记住,新的pod可能会被调度到集群中的任何一个节点,并不一定保持与旧的pod所在的节点一致。旧的pod的全部标记(名称、主机名和存储)实际上都会转移到新的pod上。如果你在使用Minikube,你将看不到这些,因为它仅仅运行在单个节点上,但是对于多个节点的集群来说,可以看到新的pod会被调度到与之前pod不一样的节点上。
图10.11 一个有状态pod会被重新调度到新的节点,但会保留它的名称、主机名和存储
现在新的pod已经运行了,那让我们检查一下它是否拥有与之前的pod一样的标记。pod的名称是一样的,那它的主机名和持久化数据呢?可以通过访问pod来确认:
$ curl localhost:8001/api/v1/namespaces/custom/pods/kubia-0/proxy/
从pod返回的信息表明它的主机名和持久化数据与之前pod是完全一致的,所以可以确认Statefulset会使用一个完全一致的pod来替换被删除的pod。
扩缩容Statefulset
缩容一个Statefulset,然后在完成后再扩容它,与删除一个pod后让Statefulset立马重新创建它的表现是没有区别的。需要记住的是,缩容一个Statefulset只会删除对应的pod,留下卸载后的持久卷声明。可以尝试缩容一个Statefulset,来进行确认。
需要明确的关键点是,缩容/扩容都是逐步进行的,与Statefulset最初被创建时会创建各自的pod一样。当缩容超过一个实例的时候,会首先删除拥有最高索引值的pod。只有当这个pod被完全终止后,才会开始删除拥有次高索引值的pod。
通过一个普通的非headless的Service暴露Statefulset的pod
在阅读这一章的最后一部分之前,需要为你的pod添加一个适当的非headless Service,这是因为客户端通常不会直接连接pod,而是通过一个服务。
你应该知道了如何创建Service,如果不知道的话,请看下面的代码清单。
代码清单10.7 一个用来访问有状态pod的常规Service:kubia-servicepublic.yaml
apiVersion: v1kind: Servicemetadata: name: kubia-publicspec: selector: app: kubia ports: - port: 80 targetPort: 8080
因为它不是外部暴露的Service(它是一个常规的ClusterIP Service,不是一个NodePort或LoadBalancer-type Service),只能在你的集群内部访问它。那是否需要一个pod来访问它呢?答案是不需要。
通过API服务器访问集群内部的服务
不通过额外的pod来访问集群内部的服务的话,与之前使用访问单独pod的方法一样,可以使用API服务器提供的相同代理属性来访问。
代理请求到Service的URL路径格式如下:
/api/v1/namespaces/<namespace>/services/<service name>/proxy/<path>
因此可以在本地机器上运行curl命令,通过kubectl代理来访问服务(之前启动过kubectl proxy,现在它应该还在运行着):
$ kubectl proxy --address='0.0.0.0' --port=8001 --accept-hosts='.*'
$ curl localhost:8001/api/v1/namespaces/custom/services/kubia-public/proxy/
客户端(集群内部)同样可以通过kubia-public服务来存储或者读取你的集群中的数据。当然,每个请求会随机分配到一个集群节点上,所以每次都会随机获取一个节点上的数据。后面我们会改进它。