
在深入讨论技术细节前,需要明确有状态(Stateful)与无状态(Stateless)应用在实际业务场景中的本质区别。无状态应用如大多数 Web 服务,其每个服务实例(Pod)均为同质、可互换的,适用于通过 Kubernetes Deployment 进行标准化部署与自动伸缩。
例如,处理用户请求的 stateless 服务无需持久化存储前后状态,系统可在任意节点随意调度及重建实例。
然而,某些场景如数据库(MySQL、PostgreSQL)或分布式协调系统(ZooKeeper、etcd),由于其需保障业务连续性及数据一致性,天然具有“有状态”属性。
此类应用的每个 Pod 都具备唯一的身份标识和专属的持久化数据。在这类场景下,Deployment 无法满足对稳定网络标识、持久存储及有序伸缩的需求。
这时,Kubernetes 提供的 StatefulSet 控制器,针对有状态服务的生命周期管理、持久数据存储及有序部署,提供了标准化和更为严谨的解决方案。
StatefulSet 与 Deployment 同样是 Kubernetes 的控制器,负责应用的部署、扩缩容和自愈。但它之所以特别,是因为它为 Pod 提供了一份独一无二且持之以恒的“身份证明”。这份身份证明包含三个关键部分。
StatefulSet 为每个 Pod 都分配了一个稳定且可预测的名字和网络地址(DNS 主机名)。
这个名字遵循一个简单的格式:<StatefulSet名称>-<序号>。例如,一个名为 mysql 的 StatefulSet,它创建的 Pod 会被依次命名为 mysql-0、mysql-1、mysql-2……这个序号是固定的,即使 Pod 发生故障被重建,新的 Pod 依然会继承同样的名字和序号。
有状态应用最核心的就是数据。StatefulSet 确保每个 Pod 都能绑定到一个专属的持久化存储卷(Persistent Volume)。
更神奇的是,这个绑定关系是“一生一世”的。mysql-0 这个 Pod 永远使用它自己的那个存储卷,mysql-1 也永远用它自己的。就算 mysql-0 所在的服务器节点宕机了,Kubernetes 会在另一个节点上重新创建 mysql-0,并且把之前属于它的那个存储卷重新挂载给它。数据完好无损,就像给咪咪们分配了刻有名字的专属饭碗,无论它跑到哪里,它的饭碗永远跟着它。
与 Deployment 一上来就“群起而攻之”(并行创建所有 Pod)不同,StatefulSet 充满了仪式感,它严格按照 Pod 的序号进行操作,一个一个来。
mysql-0,并耐心等待它完全启动并进入“就绪”(Ready)状态后,才会开始创建 mysql-1。这个过程就像建造一座高塔,必须先建好第一层,才能往上盖第二层,保证了集群的稳定初始化。mysql-3、mysql-4 的顺序依次创建。mysql-4,等它完全关闭后,再处理 mysql-3。这种可预见的逆序销毁对于需要数据同步的集群应用至关重要,避免了“雪崩”式的灾难。StatefulSet 这种有序的操作行为,是防止分布式数据应用(如数据库主从集群)在启动或关闭时发生数据不一致或脑裂问题的关键。它用可预测性换来了系统的稳定性。
理论说完了,让我们亲手部署一个 StatefulSet,来感受它的“魔法”。我们将部署一个简单的 Nginx 应用,但为它配上持久化存储,模拟一个需要保存用户上传内容的网站。 在开始之前,你需要一个正在运行的 Kubernetes 集群。
首先,我们需要告诉 Kubernetes,我们的应用需要什么样的存储。StorageClass 就是用来定义存储类型的对象。比如,我们可以定义一种名为 fast 的、使用 SSD 的高性能存储。
这里我们使用一个适用于 Google Kubernetes Engine (GKE) 的例子。如果你使用其他云平台或自建集群,请参考对应存储插件的文档。
|# gcp-sc.yml apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: flash provisioner: pd.csi.storage.gke.io volumeBindingMode: WaitForFirstConsumer allowVolumeExpansion: true parameters:
请注意,对于整个物理节点(Node)失联的复杂故障,StatefulSet 出于数据安全考虑,不会自动将 Pod 漂移到新节点。因为它无法判断节点是永久宕机还是暂时网络分区。在这种情况下,通常需要管理员手动介入,确认节点无法恢复后,再强制删除旧 Pod,触发重建。
StatefulSet 是 Kubernetes 中管理有状态应用的核心武器。它通过提供三大法宝:稳定的网络标识、稳定的持久化存储和有序的操作,为那些需要“记忆”和“身份”的应用提供了运行基础。
虽然它比 Deployment 更复杂,但当你需要部署数据库、消息队列或任何需要可靠数据存储和可预测性的分布式系统时,StatefulSet 都是你最值得信赖的伙伴。
部署它:
|$ kubectl apply -f gcp-sc.yml storageclass.storage.k8s.io/flash created
接下来,我们需要一个能让 Pod 之间通过稳定域名互相发现的机制。这就是 Headless Service 的作用。
普通的 Service 有一个 ClusterIP,所有流量都先经过这个虚拟 IP 再转发到后端的 Pod。而 Headless Service 没有 ClusterIP(clusterIP: None),它的作用仅仅是为 StatefulSet 管理的每个 Pod 生成一个 DNS A 记录。
|# headless-svc.yml apiVersion: v1 kind: Service metadata: name: dullahan labels: app: web spec: ports: - port: 80 name: web clusterIP: None selector: app: web
部署这个 Service:
|$ kubectl apply -f headless-svc.yml service/dullahan created
这个名为 dullahan 的 Service 将会为我们稍后创建的 Pod 生成类似 <pod-name>.dullahan.default.svc.cluster.local 的域名。
在完成了前面的准备工作之后,我们已经具备了所有必要的条件,现在可以开始定义我们的 StatefulSet 了。接下来,我们将通过一个 YAML 文件来详细定义这个 StatefulSet。
|# sts.yml apiVersion: apps/v1 kind: StatefulSet metadata: name: tkb-sts spec: replicas: 3 selector: matchLabels: app: web serviceName: "dullahan" template: metadata: labels: app: web spec: terminationGracePeriodSeconds: 10 containers: - name: ctr-web image: nginx:latest ports: - containerPort: 80 name: web volumeMounts: - name: webroot mountPath: /usr/share/nginx/html volumeClaimTemplates: - metadata: name: webroot spec: accessModes: [ "ReadWriteOnce" ] storageClassName: "flash" resources: requests: storage: 1Gi
我们来解读一下这个 YAML 文件中的关键部分:
replicas: 3:我们希望有 3 个 Nginx 实例。serviceName: "dullahan":将这个 StatefulSet 与我们刚刚创建的 Headless Service dullahan 关联起来,从而获得稳定的网络标识。volumeClaimTemplates:这是 StatefulSet 的精髓之一。它是一个“持久化存储声明模板”。当 StatefulSet 创建一个新 Pod 时,会使用这个模板为该 Pod 自动创建一个专属的 PersistentVolumeClaim (PVC),并请求一个 1Gi 大小的、类型为 flash 的存储卷。现在,我们要将这个 StatefulSet 部署到 Kubernetes 集群中。这个过程就像是在搭建一个有序的队列,每个 Pod 都会按照顺序被创建。 我们可以通过执行以下命令来应用这个 YAML 文件,并实时观察每个 Pod 的创建状态。
|$ kubectl apply -f sts.yml statefulset.apps/tkb-sts created $ kubectl get pods -w NAME READY STATUS RESTARTS AGE tkb-sts-0 0/1 ContainerCreating 0 3s tkb-sts-0 1/1 Running 0 18s tkb-sts-1 0/1 Pending 0 0s tkb-sts-1 0/1 ContainerCreating 0 1s tkb-sts-1
你会清晰地看到,tkb-sts-0、tkb-sts-1、tkb-sts-2 是严格按顺序创建的。
同时,检查一下 PVCs,你会发现它们也被自动创建并绑定了。
|$ kubectl get pvc NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE webroot-tkb-sts-0 Bound pvc-a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6 1Gi RWO flash 2m webroot-tkb-sts-1 Bound pvc-b2c3d4e5-f6g7-h8i9-j0k1-l2m3n4o5p6q7 1Gi RWO flash 90s webroot-tkb-sts-2 Bound pvc-c3d4e5f6-g7h8-i9j0-k1l2-m3n4o5p6q7r8 1Gi RWO flash 60s
Pod 和 PVC 之间的命名关系就像是身份证和个人的关系一样明确。每个 Pod 都有一个与之对应的 PVC,这种一一对应的关系确保了每个 Pod 都能访问到它专属的存储资源。 通过这种方式,我们可以轻松地识别出哪个 PVC 属于哪个 Pod,从而实现数据的持久化和管理的便利性。
在这个阶段,我们将对 StatefulSet 进行一次缩容操作。具体来说,我们会将当前的副本数量从 3 个减少到 2 个。 这就好比我们在一个三层的蛋糕上,轻轻地移走了最上面的一层。虽然蛋糕的高度变矮了,但底下的两层依然稳稳地存在。
|$ kubectl scale sts tkb-sts --replicas=2 statefulset.apps/tkb-sts scaled
检查 Pod 列表,你会发现 tkb-sts-2 被终止了,因为它的序号最大。
|$ kubectl get pods NAME READY STATUS RESTARTS AGE tkb-sts-0 1/1 Running 0 10m tkb-sts-1 1/1 Running 0 9m
但如果你检查 PVC,webroot-tkb-sts-2 依然存在!StatefulSet 默认不会删除 PVC,这是为了保护你的数据。
现在,让我们再扩容回 3 个副本。
|$ kubectl scale sts tkb-sts --replicas=3 statefulset.apps/tkb-sts scaled
很快,一个新的 tkb-sts-2 Pod 会被创建出来,并且它会自动绑定到之前遗留下来的 webroot-tkb-sts-2 这个 PVC 上,数据得以延续。
让我们手动删除 tkb-sts-0 来模拟一次故障。
|$ kubectl delete pod tkb-sts-0 pod "tkb-sts-0" deleted
几乎在瞬间,StatefulSet 控制器就会发现期望的状态(3个副本)和实际状态(2个副本)不符,并立即开始重建工作。
|$ kubectl get pods NAME READY STATUS RESTARTS AGE tkb-sts-1 1/1 Running 0 15m tkb-sts-2 1/1 Running 0 5m tkb-sts-0 0/1 ContainerCreating 0 3s tkb-sts-0 1/1 Running 0 20s
一个新的 tkb-sts-0 被创建了。我们可以通过 describe 命令确认,它绑定的依然是 webroot-tkb-sts-0 这个 PVC。Pod 换了“身体”,但“灵魂”(身份和数据)永存。