一、引子:一个实习生的困惑

在学习Kubernetes的过程中,我遇到了一个很有意思的问题:

"通过StatefulSet + Headless Service,明明可以部署MySQL主从复制,实现固定的Pod名称和稳定的网络标识。那为什么在生产环境中,大家还是倾向于把数据库放在K8s外面呢?"

这个问题困扰了我很久。带着这个疑问,我深入研究了StatefulSet的原理、有状态服务的特性,以及生产环境中的实际考量。本文将从一个实习生的视角,分享我对这个问题的理解和思考。

二、StatefulSet解决了什么问题?

在理解"为什么不用"之前,我们首先要理解StatefulSet"能做什么"。

2.1 无状态服务的天然缺陷

在K8s中,最常用的Workload是Deployment。它设计用来管理无状态应用

yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
      - name: nginx
        image: nginx

这个Deployment会创建三个Pod,它们的名字是随机的:

  • web-app-7d9f5d5789-abcde

  • web-app-7d9f5d5789-fghij

  • web-app-7d9f5d5789-klmno

如果某个Pod挂了,K8s会重新创建一个全新的Pod,名字会变成另一个随机字符串。对于无状态应用来说,这完全没问题——反正所有实例都是一样的,谁提供服务都一样。

但是,对于有状态应用来说,这就有问题了:

  • MySQL主从架构中,主库和从库的角色是不同的

  • 每个实例都有自己的身份和数据

  • 实例之间需要稳定的网络标识来相互识别

2.2 StatefulSet的核心特性

StatefulSet正是为了解决这些问题而生的。它带来了三个关键特性:

特性一:稳定的网络标识

yaml

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
spec:
  serviceName: mysql-headless
  replicas: 3
  selector:
    matchLabels:
      app: mysql
  template:
    metadata:
      labels:
        app: mysql
    spec:
      containers:
      - name: mysql
        image: mysql:5.7

当这个StatefulSet运行时,创建的Pod会有固定的命名规则:

  • mysql-0(索引从0开始)

  • mysql-1

  • mysql-2

无论这些Pod如何重启、重新调度,它们的名字始终保持不变。这就是"有状态"的含义——每个实例都有自己固定的身份

特性二:稳定的存储

StatefulSet可以配合PersistentVolumeClaim模板使用:

yaml

spec:
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: [ "ReadWriteOnce" ]
      resources:
        requests:
          storage: 10Gi

每个Pod都会绑定一个独立的PVC:

  • data-mysql-0

  • data-mysql-1

  • data-mysql-2

即使Pod被删除重建,新Pod也会自动绑定到原来的PVC上,数据不会丢失。

特性三:有序的部署和伸缩

StatefulSet对Pod的操作是顺序执行的:

  • 创建时:mysql-0 → mysql-1 → mysql-2

  • 删除时:mysql-2 → mysql-1 → mysql-0

  • 滚动更新:从最后一个到第一个

这种顺序性对于有状态应用非常重要。比如在MySQL主从架构中,我们需要确保主库(通常是mysql-0)先启动,然后从库才能连接上来进行复制。

2.3 Headless Service的角色

StatefulSet通常与Headless Service配合使用:

yaml

apiVersion: v1
kind: Service
metadata:
  name: mysql-headless
spec:
  clusterIP: None  # 这就是Headless的关键
  selector:
    app: mysql
  ports:
  - port: 3306
    name: mysql

普通的Service提供负载均衡,访问服务名时会随机转发到某个Pod。而Headless Service(clusterIP: None)不做负载均衡,它直接返回所有Pod的IP列表。

更重要的是,它为每个Pod提供了独立的DNS域名:

  • mysql-0.mysql-headless.default.svc.cluster.local

  • mysql-1.mysql-headless.default.svc.cluster.local

  • mysql-2.mysql-headless.default.svc.cluster.local

这对于MySQL主从复制来说简直是完美的设计:

  • 从库可以永远指向mysql-0.mysql-headless这个固定的域名作为主库

  • 即使主库Pod被重新调度到了新的节点,这个域名依然能解析到新的Pod IP

三、用StatefulSet部署MySQL主从:技术可行性验证

既然StatefulSet这么强大,我们不妨试试用它来部署一套MySQL主从复制。

3.1 理论上的完美方案

第一步:创建Headless Service

yaml

apiVersion: v1
kind: Service
metadata:
  name: mysql
spec:
  clusterIP: None
  selector:
    app: mysql
  ports:
  - port: 3306

第二步:创建ConfigMap配置

yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: mysql-config
data:
  master.cnf: |
    [mysqld]
    log-bin
    server-id=1
  slave.cnf: |
    [mysqld]
    relay-log
    server-id=2

第三步:创建StatefulSet

yaml

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
spec:
  serviceName: mysql
  replicas: 3
  selector:
    matchLabels:
      app: mysql
  template:
    metadata:
      labels:
        app: mysql
    spec:
      containers:
      - name: mysql
        image: mysql:5.7
        env:
        - name: MYSQL_ROOT_PASSWORD
          value: "password"
        ports:
        - containerPort: 3306
          name: mysql
        volumeMounts:
        - name: data
          mountPath: /var/lib/mysql
        - name: conf
          mountPath: /etc/mysql/conf.d
      volumes:
      - name: conf
        configMap:
          name: mysql-config
          items:
          - key: master.cnf
            path: master.cnf
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 10Gi

第四步:初始化主从复制

mysql-0上执行:

sql

CREATE USER 'repl'@'%' IDENTIFIED BY 'replpassword';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
SHOW MASTER STATUS;

在每个从库上执行:

sql

CHANGE MASTER TO
  MASTER_HOST='mysql-0.mysql.default.svc.cluster.local',
  MASTER_USER='repl',
  MASTER_PASSWORD='replpassword',
  MASTER_LOG_FILE='mysql-bin.000001',
  MASTER_LOG_POS=XXX;
START SLAVE;

从理论上看,这个方案是可行的。我们利用了:

  • StatefulSet的固定Pod名称

  • Headless Service的稳定DNS域名

  • PVC模板的持久化存储

3.2 理想很丰满,现实很骨感

然而,当我们把目光投向生产环境时,会发现事情远没有那么简单。

四、为什么生产环境不这么干?复杂性的深度剖析

让我们换个视角,把自己想象成一个生产环境的运维工程师,需要考虑的是7x24小时的稳定性凌晨3点能不能睡个安稳觉

4.1 故障自愈:自动化是把双刃剑

K8s的核心哲学是"保持期望状态"——如果Pod挂了,就重启一个新的。

对于无状态服务,这是福音:

  • Pod挂了 → 新Pod拉起 → 流量照旧

  • 完美!

对于MySQL主库,这可能是噩梦:

场景一:主库OOM被杀

  • mysql-0因为负载过高导致OOM,被内核杀死

  • K8s检测到Pod异常,立即在某个节点上重启一个新的mysql-0

  • 问题来了:新启动的Pod会挂载原来的PVC,但MySQL进程异常退出可能导致数据文件损坏。InnoDB可能需要恢复,如果恢复失败呢?

  • 更糟的是,如果原来的mysql-0所在的节点也出问题了,新的mysql-0被调度到了其他节点,但底层存储是网络存储(如Ceph),网络抖动可能导致I/O hang,数据库响应变慢,引发雪崩效应

场景二:网络分区(最危险的脑裂场景)

  • 假设K8s的控制节点和mysql-0所在的worker节点之间网络不通(网络分区)

  • 控制平面无法收到mysql-0的心跳,认为它挂了

  • 控制平面在另一台健康的worker节点上启动了新的mysql-0

  • 但原来的mysql-0实际上还在运行,还在接受写入

  • 脑裂发生了:两个MySQL实例同时尝试写入同一份数据(如果底层是共享存储)或者分别写入不同的存储

  • 数据一致性被彻底破坏,恢复极其困难

关键思考:对于无状态服务,"Pod挂了重启"是自愈。对于有状态服务,这可能是在制造更大的问题。

4.2 运维操作:精细化管理 vs 粗放式自动化

数据库的日常运维有很多精细操作,K8s原生机制很难满足这些需求。

升级场景的对比:

传统DBA的升级流程:

  1. 在测试环境验证新版本兼容性

  2. 制定详细的升级计划

  3. 备份全库数据(几个小时)

  4. 设置维护窗口,通知业务停写

  5. 先升级从库,验证

  6. 主从切换,升级原主库

  7. 验证数据一致性

  8. 恢复业务写入

  9. 整个过程有回滚预案

K8s的滚动升级策略:

bash

# 一行命令
kubectl set image statefulset/mysql mysql=mysql:8.0

StatefulSet默认的滚动升级策略是逐次删除重建

  • 删除mysql-2,重建为新版本

  • 删除mysql-1,重建为新版本

  • 删除mysql-0,重建为新版本

问题出在哪里?

  • 如果mysql-0是主库,它会是最后一个被升级的

  • 但升级到mysql-0时,它会被删除重建

  • 新启动的mysql-0如何知道自己是主库?binlog位置和GTID集合能无缝衔接吗?

  • 如果新版本的数据格式不兼容,回滚几乎不可能

  • 整个过程中没有"主从切换"的明确步骤,完全依赖MySQL自身的恢复能力

备份恢复的复杂性:

数据库备份有很多种方式:

  • 逻辑备份(mysqldump)

  • 物理备份(Percona XtraBackup)

  • 快照备份(云盘快照)

每种方式都有各自的工具链和恢复流程。在容器环境下:

  • 如何确保备份时表是被锁定的?

  • 如何保证备份文件的一致性和完整性?

  • 如何从备份文件恢复到新的Pod?

  • 如何验证备份是可用的?

这些都不是K8s原生能解决的问题。

4.3 性能的可预测性

数据库对性能的要求极其苛刻:

存储性能:

  • MySQL对磁盘I/O延迟非常敏感,10ms的延迟可能导致业务超时

  • K8s中的存储(特别是网络存储)很难保证稳定的低延迟

  • 即使使用了本地SSD,也要处理"Pod被调度到没有SSD的节点"的问题

网络性能:

  • 容器网络(Overlay)相比主机网络有额外的封装开销

  • 对于高吞吐的场景,这种开销可能达到10-20%

  • 网络抖动会影响主从复制的延迟

资源隔离:

  • K8s集群中通常混跑各种业务,有"吵闹的邻居"问题

  • 即使设置了Guaranteed QoS(CPU/内存固定分配),底层的缓存争抢、NUMA亲和性等问题依然存在

  • 数据库需要的是确定性的性能,而不是"尽力而为"

4.4 基础设施耦合风险

把数据库放在K8s里,意味着数据库的可用性依赖于K8s集群的可用性:

依赖链:

text

业务可用性 → 数据库可用性 → K8s API Server可用性 → etcd可用性

如果K8s控制平面出问题(比如API Server过载),虽然现有Pod可能还在运行,但你无法:

  • 查询数据库状态(kubectl exec进不去)

  • 处理故障(无法删除卡住的Pod)

  • 扩容只读从库

  • 甚至可能因为kubelet故障导致Pod被误杀

单点故障变成了多点故障: 本来只需要担心数据库本身,现在还要担心容器运行时、K8s控制平面、网络插件等一系列组件。

五、有没有更好的方案?Operator模式的出现

看到这里,你可能会想:"难道业界就没有在K8s上跑数据库的吗?"

当然有。Google、Uber、Shopify等公司都在K8s上大规模运行数据库。但他们用的不是裸的StatefulSet,而是Operator

5.1 什么是Operator?

Operator可以理解为"有数据库专业知识的机器人"。它用代码把DBA的运维经验固化下来,通过不断监听K8s资源的变化,自动执行相应的运维操作。

一个典型的MySQL Operator能做什么:

  • 自动化高可用:当检测到主库故障时,自动将从库提升为新主库,而不是盲目重启原主库

  • 优雅升级:执行升级前先备份,按照"从库→主库"的顺序,每一步都验证状态

  • 自动备份:定时执行备份,支持恢复到任意时间点

  • 故障转移:检测到节点故障时,将Pod调度到健康节点,并确保数据一致性

  • 扩缩容:添加新的从库时,自动配置复制关系

5.2 Operator vs StatefulSet
能力 裸StatefulSet MySQL Operator
故障恢复 重启Pod 主从切换+重建
升级策略 滚动重启 备份→升级→验证→切换
备份机制 定时备份+binlog
监控集成 导出Metrics到Prometheus
存储管理 PVC模板 自动扩容、快照备份
5.3 常见的数据库Operator
  • MySQL Operator:Presslabs、Oracle MySQL Operator

  • PostgreSQL Operator:Zalando、Crunchy Data

  • MongoDB Operator:MongoDB Enterprise Operator

  • Redis Operator:Redis Operator(Spotahome)

  • Elasticsearch Operator:ECK(Elastic Cloud on K8s)

六、部署决策的权衡框架

通过以上的分析,我们可以总结出一个决策框架,帮助判断一个服务是否适合用StatefulSet部署:

6.1 三类服务的判断标准
类别 判断标准 代表组件 推荐方案
云原生友好型 有成熟的Operator,组件自身有集群自愈能力 Elasticsearch、Redis Cluster、ZooKeeper、etcd StatefulSet + Operator
谨慎使用型 数据一致性要求极高,社区Operator尚不成熟 MySQL、PostgreSQL、MongoDB 外部独立部署
性能敏感型 I/O要求苛刻,需要硬件级优化 Kafka、HDFS 物理机裸部署
6.2 问自己三个问题

当你考虑是否用StatefulSet部署某个服务时,可以问自己三个问题:

问题一:组件自己会"找人"吗?

当节点故障导致Pod丢失时,组件内部能自动完成角色切换吗?还是需要人工介入?

  • 好的例子:Elasticsearch集群检测到节点下线,会自动将分片rebalance到其他节点

  • 差的例子:MySQL主库挂了,从库不会自动变成主库,需要外部工具介入

问题二:组件有自己的"管家"吗?

社区有没有成熟的Operator?这个Operator是否经过生产验证?

  • 好的例子:ECK(Elastic Cloud on Kubernetes)已经非常成熟,很多公司在用

  • 差的例子:MySQL的Operator相对还比较年轻,生产案例不如ECK多

问题三:我能接受它"失联"几分钟吗?

如果K8s集群升级或网络抖动,导致数据库Pod重启,业务能容忍几分钟的不可用吗?

  • 如果容忍度高(如离线分析系统),可以考虑用Operator

  • 如果容忍度低(如交易系统),建议还是放在外面

七、不同规模公司的选择

在实际工作中,不同规模的公司对这个问题的答案也不同:

7.1 初创公司
  • 现状:服务器数量少,运维团队小(可能就1-2人)

  • 选择:数据库放在云RDS上,或者用Operator在K8s里跑(如果预算紧张)

  • 理由:没有精力维护复杂的数据库基础设施,优先保证业务快速发展

7.2 中型公司
  • 现状:有专门的DBA团队,但对成本开始敏感

  • 选择:核心交易数据库独立部署,非核心业务(如日志、报表)用Operator跑在K8s

  • 理由:在成本和控制之间找平衡,逐步积累容器化数据库的经验

7.3 大型互联网公司
  • 现状:技术储备深厚,有自研的数据库平台

  • 选择:基于Operator自研数据库PaaS平台,统一管理物理机和容器环境

  • 理由:追求极致的资源利用率和运维标准化,有足够的人力解决复杂问题

八、个人思考与总结

回到最初的问题:StatefulSet能部署有状态服务,但为什么不用?

经过这一番探索,我的理解是:

StatefulSet提供的是"基础能力",不是"完整方案"。

它解决了三个底层问题:

  1. 稳定的网络标识

  2. 持久的存储绑定

  3. 有序的启停顺序

但它没有解决上层问题:

  1. 如何优雅地处理故障转移

  2. 如何安全地进行版本升级

  3. 如何可靠地备份和恢复

  4. 如何确保数据一致性

这就像给你砖头、水泥和钢筋,不等于给了你一栋房子。

数据库这类核心有状态服务,需要的是一整套完善的运维体系,而不仅仅是容器的编排能力。StatefulSet只是这套体系中的一个组件,而不是全部。

对运维新手的建议

如果你像我一样,是一个正在学习K8s的运维新人,我的建议是:

  1. 一定要亲手实践StatefulSet:在测试环境用StatefulSet部署MySQL/Redis,然后模拟各种故障(节点宕机、网络分区、Pod删除),观察系统的行为和局限。这是理解"为什么复杂"的最好方式。

  2. 深入研究一个Operator:选一个成熟的Operator(比如ECK),看看它都做了什么,解决了哪些问题。这能帮你理解"自动化"的上限在哪里。

  3. 保持批判性思维:遇到技术方案时,多问几个"为什么"。技术可行不等于生产可行,别人能用不等于自己能用。理解背后的权衡,比记住结论更重要。

  4. 敬畏数据:无论技术如何发展,对数据始终要保持敬畏。在生产环境动数据库之前,永远问自己:如果出了问题,我能恢复吗?

九、写在最后

这篇文章从一个实习生的困惑出发,梳理了StatefulSet的原理、MySQL主从部署的技术可行性、生产环境的复杂性考量,以及Operator模式的演进。

写这篇文章的过程中,我最深刻的体会是:技术方案没有绝对的对错,只有适合不适合。 把数据库放在K8s外面,不是因为K8s做不到,而是因为数据太重要,我们不敢用不确定的方式去承载。

但"不敢"不代表"不会"。随着Operator生态的成熟,越来越多的公司开始在K8s上运行有状态服务。也许有一天,在K8s上运行所有服务会成为标配。到那时,我们今天讨论的这些复杂性,可能会被更好的工具和平台所消化。

但无论技术如何演进,理解背后的原理和权衡,永远是运维工程师的核心竞争力。

希望这篇文章对你有帮助。如果你也有类似的困惑或见解,欢迎留言讨论。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐