你一定见过这种场面:照着网上的 MySQL 主从 StatefulSet 抄了一份 YAML,自认为看懂了所有字段,顺手把那个"看着就多余"的serviceName删掉,然后kubectl apply直接甩给你一句:
ValidationError(StatefulSet.spec): missing required field "serviceName" in io.k8s.api.apps.v1.StatefulSetSpec更让人懵的是,很多人会追问:"我这个 Pod 初始化阶段不是只要镜像拉下来、存储挂上去就行吗?service name 这种和网络相关的东西,到底卡住了哪一步?"
这个问题几乎每个接触 k8s 的人都会遇到。它不仅是一个必填字段的问题,背后是 StatefulSet 对"稳定网络身份"的整套依赖逻辑——为什么有状态应用必须靠 Service 才能拿到自己的域名,为什么初始化流程里 DNS 解析不上来,整个集群就永远卡在第一个 Pod。这篇文章就把这条链路一次性讲透:先看 API 层的强制要求,再拆 Headless Service 在 DNS 层为每个 Pod 做了什么,最后落到初始化场景里你真正会遇到的报错和排查方法。正在学 k8s、准备面试,或者已经在维护数据库、消息队列这类有状态应用的同学,都值得读完。
1. 先看报错现场:serviceName 为什么被设计成必填字段
1.1 从 Deployment 到 StatefulSet,你突然多出来的"三个稳定"
大多数人对 StatefulSet 的第一印象是:这不就是 Deployment 的兄弟吗?模板结构差不多,都有replicas、都有selector、都有 PodTemplateSpec。于是很多人下意识觉得,把名字从 Deployment 换成 StatefulSet,再把副本数固定下来,是不是就算"有状态"了?
但 Deployment 管的是无状态应用,它创建的 Pod 名字长这样:web-6b9f5f5d7d-abcde,每次滚动更新都会换一批,名字本身没有任何逻辑。这样的 Pod 想互相通信,唯一的办法就是通过 Service 做负载均衡,至于请求落到哪一台,不重要。
StatefulSet 完全不同,它必须给每个副本一个"终身不变的身份证"。这个身份证由三部分组成:
- 稳定的名字:
web-0、web-1、web-2,由 StatefulSet 控制器按序号生成,跟 Pod 重启了多少次无关; - 稳定的网络域名:
web-0.web.default.svc.cluster.local,无论 Pod 的 IP 怎么变,这个域名永远指向同一个 Pod; - 稳定的存储:
>web.default.svc.cluster.local -> 10.96.200.10客户端拿到的永远是 10.96.200.10 这个虚拟 IP,再由 kube-proxy 转发到后端 Pod。这种模式下,
web-0.web.default.svc.cluster.local这种按 Pod 序号区分的域名是不存在的。你想精确访问某个 Pod,普通 Service 给不了。2.2 Headless Service 的 DNS 行为:为每个 Pod 单独生成记录
把 Service 的
clusterIP显式设为None,情况就变了。控制器不会分配 ClusterIP,kube-proxy 也不做转发,CoreDNS 会动态地为 Service 背后每一个匹配的 Pod 生成独立的 A 记录。假设有
web-0、web-1、web-2三个 Pod,DNS 里的记录长这样:web-0.web.default.svc.cluster.local -> 10.244.1.10 web-1.web.default.svc.cluster.local -> 10.244.2.11 web-2.web.default.svc.cluster.local -> 10.244.3.12 web.default.svc.cluster.local -> 10.244.1.10, 10.244.2.11, 10.244.3.12注意最后一行:查询集合名称
web.default.svc.cluster.local时,DNS 返回的是所有 Pod IP 的列表,而不是一个 VIP。客户端拿到这个列表后要自己决定连哪一个。这种"自己选"的模式恰恰是有状态应用需要的——它们本来就清楚自己要连的是哪个序号。2.3 StatefulSet 控制器把 serviceName 写进了 Pod 的两个字段
看到这里你可能会问:serviceName 填了,Controller 到底用它做了什么?答案是它直接改变了每个 Pod 的
hostname和subdomain。StatefulSet 控制器在创建 Pod 时,会执行一段类似这样的逻辑(对应 k8s 源码
pkg/controller/statefulset/stateful_set_control.go):pod.Spec.Hostname = pod.Name // 例如 web-0 pod.Spec.Subdomain = set.Spec.ServiceName // 例如 web设置之后,
web-0这个 Pod 的 FQDN(完全限定域名)就是:web-0.web.default.svc.cluster.local这个 FQDN 是"称呼",而 Headless Service 的 DNS 记录是"电话簿",两者必须配套存在。如果你填了
serviceName: web,但集群里根本没有web这个 Service,或者这个 Service 的 selector 匹配不上 Pod,那么 Pod 虽然可以创建,但它的完整域名在 DNS 里查不到。更隐蔽的是,kubelet 在写/etc/hosts时也会参考 subdomain 对应的 Headless Service 是否存在,Service 不存在时甚至不会把 FQDN 写入 Pod 的 hosts 文件。这也是为什么官方文档反复强调:StatefulSet 的 serviceName 必须与一个真实存在的 Headless Service 同名,并且处在同一个命名空间。名字填错、命名空间填错、Service 被误删,任何一项都会让整个初始化流程不再可靠。
3. "初始化"到底卡在哪:DNS、Ready 与有序创建如何互相牵制
3.1 OrderedReady 策略在等什么
StatefulSet 默认的
podManagementPolicy是OrderedReady。这个策略的意思是:控制器必须等前一个 Pod 变成 Running 且 Ready 之后,才会创建下一个 Pod。请注意:控制器等的是"Ready",而不是"业务初始化完成"。Ready 状态由你的
readinessProbe决定。问题在于,很多有状态应用的探活逻辑本身就依赖域名。比如一个常见的 Redis 集群探针:readinessProbe: exec: command: - sh - -c - "redis-cli -h $(hostname).redis.default.svc.cluster.local ping | grep -q PONG || exit 1"这个探针要先去解析
web-0.web.default.svc.cluster.local,如果 Service 不存在、DNS 记录缺失,第一个 Pod 的探活永远失败,Ready 永远为 False,后面的 Pod 也就永远不会被创建。整个集群的初始化就死在第一步。3.2 initContainer 里最常见的等待模式
再往下一层,很多 StatefulSet 会用 initContainer 做依赖等待。比如 MySQL 从库的初始化脚本:
#!/bin/sh until mysqladmin ping -h mysql-0.mysql.default.svc.cluster.local --silent; do echo "waiting for mysql-0 ..." sleep 2 done这个脚本的执行逻辑是:先通过 DNS 拿到
mysql-0的 IP,再尝试建立 TCP 连接。如果 DNS 层就查不到mysql-0.mysql.default.svc.cluster.local,脚本会一直卡在循环里,Pod 的状态永远停留在Init:0/1。这里有个很容易忽略的点:
mysql-0.mysql.default.svc.cluster.local这个域名只有在mysql是 Headless Service 时才会返回 Pod IP。如果mysql是个普通 Service,DNS 返回的是 ClusterIP,mysqladmin ping连接的是 VIP,VIP 背后可能轮询到任意 MySQL 节点——从库初始化时"等主库"的结果可能是连到了从库自己,这种错乱比单纯的超时更难排查。3.3 集群成员互相发现的场景:配置文件里全是 FQDN
等依赖还只是最基础的一层,真正的有状态中间件在初始化阶段要做的是"成员互相发现"。ZooKeeper 的配置就是最典型的例子,它的
zoo.cfg里必须写清楚每个节点的地址:server.1=zk-0.zk-hs.default.svc.cluster.local:2888:3888 server.2=zk-1.zk-hs.default.svc.cluster.local:2888:3888 server.3=zk-2.zk-hs.default.svc.cluster.local:2888:3888Etcd 也是同样:
initial-cluster: etcd-0=http://etcd-0.etcd.default.svc.cluster.local:2380,etcd-1=http://etcd-1.etcd.default.svc.cluster.local:2380,etcd-2=http://etcd-2.etcd.default.svc.cluster.local:2380Pod 启动后,拿着这份配置去和每个节点建立连接,谁先启动不重要,重要的是每个名字都能解析到具体 Pod。一旦某一台节点的域名查不到,集群初始化就会一直重试,日志里反复出现 connection refused 或者 unknown host。这时候再去查镜像、看资源限制,方向就完全错了。
3.4 重启与扩容时,网络身份比存储还敏感
很多人会以为初始化只在第一次部署时发生,其实 Pod 重启、节点故障、滚动升级都是"重新初始化"的过程。节点重启后 Pod 的 IP 大概率会变,但 FQDN 不变——DNS 记录跟着 Pod 对象走,Pod 重建后会重新绑定新 IP 并更新记录。这就是稳定网络身份的价值:别人不用管你的 IP 变成什么,只要用域名就能找到你。
扩容场景更明显。新 Pod
web-3被创建,它需要用web-3.web.default.svc.cluster.local这个域名向老成员报到。如果 Service 是普通模式,解析结果不是自己的 Pod IP,老成员就会把请求转发到错误的对象。所以 Headless Service 在这个场景下就是集群成员的通讯录,缺了它,新节点根本没有办法融入现有集群。4. 亲手验证一把:对齐 serviceName 的三种典型表现
4.1 先跑一个最简配置
为了把抽象的概念变成实锤,建议你直接复制下面的 YAML 到本地集群跑一遍。这是一个最简单的 nginx StatefulSet,附带对应的 Headless Service:
apiVersion: v1 kind: Service metadata: name: web namespace: default spec: clusterIP: None selector: app: nginx-sts ports: - port: 80 name: http --- apiVersion: apps/v1 kind: StatefulSet metadata: name: web namespace: default spec: serviceName: web replicas: 3 selector: matchLabels: app: nginx-sts template: metadata: labels: app: nginx-sts spec: containers: - name: nginx image: nginx:1.25 ports: - containerPort: 80这里
serviceName: web对应的web就是那个 Headless Service。两者同名不是巧合,StatefulSet 要求你出的事实上就是"Pod 域名的中间段"。4.2 正常情况:hostname、/etc/hosts 和 DNS 全部对齐
应用之后,等三个 Pod 都 Running,进入
web-0查看:kubectl exec -it web-0 -- sh执行:
hostname输出是:
web-0再看
/etc/hosts:cat /etc/hosts能看到类似这样的记录:
10.244.1.10 web-0.web.default.svc.cluster.local web-0然后验证解析
web-1的完整域名:getent hosts web-1.web.default.svc.cluster.local输出应该是
web-1那个 Pod 的独立 IP,例如:10.244.2.11 web-1.web.default.svc.cluster.local最后再试集合域名:
getent hosts web.default.svc.cluster.local正常情况会返回两个或三个 IP,这说明 Headless Service 把所有 Pod 的地址都作为 A 记录暴露出来了。
4.3 错误场景一:serviceName 指向普通 Service
把上面 YAML 里 Service 的
clusterIP: None去掉,让它变成一个普通 Service(会分配一个 ClusterIP),然后重新 apply。此时
web-1.web.default.svc.cluster.local这条记录在 DNS 里直接不存在——普通 Service 不会为每个 Pod 生成独立 A 记录。而web.default.svc.cluster.local解析出来的只有一个 ClusterIP。这种错误的可怕之处在于:单独 curl
web.default.svc.cluster.local是通的,但一旦有状态应用按 FQDN 访问某个具体节点,就会出现间歇性连接失败,或者连到了错误节点。你在业务日志里看到的往往是"连接被拒绝""握手超时"这类模棱两可的信息。4.4 错误场景二:serviceName 指向的名字根本找不到 Service
把
spec.serviceName: web改成spec.serviceName: not-exist,再 apply。这时 Pod 照样会被创建出来,但检查 StatefulSet 状态时能看到类似提示:kubectl describe sts web在 Events 区域会有
Service "not-exist" not found之类的报错。Pod 的
hostname和subdomain已经被设置为web-0和not-exist,但集群里没有任何 Service 为它生成 DNS 记录。进入 Pod 后执行:getent hosts web-0.not-exist.default.svc.cluster.local会得到解析失败。如果你的 Pod 里有 initContainer,它就会一直卡在等待解析的循环里,状态停留在
Init:0/1。逐个场景变化可以整理成下表:
场景 创建结果 初始化现象 解析结果 serviceName 为空 API 拒绝 无法创建 无 serviceName 指向 Headless Service 正常创建 正常初始化 每个 Pod 独立 A 记录 serviceName 指向普通 Service 能创建但身份错乱 依赖具体序号的逻辑失败 只有 ClusterIP,无 Pod 级记录 serviceName 指向不存在的 Service Pod 能创建但网络身份缺失 initContainer 卡住,Pod 不 Ready DNS 查不到任何记录 5. 围绕 serviceName 的三个常见误区和一条排查链路
5.1 误区一:Headless Service 只属于 StatefulSet
Headless Service 不是 StatefulSet 的专属配件,它是 Service 的一种独立工作模式。只要设了
clusterIP: None,任何 Deployment 后面的 Pod 都能被 DNS 按个体记录。很多自研服务发现方案就是基于 Headless Service 加 DNS 实现的。但对 StatefulSet 来说,Headless Service 不是可选优化,而是身份系统的一部分。如果你的集群里有不需要 DNS 个体记录的 StatefulSet,比如只是一个固定副本数的无状态作业,那它可能本身就不该用 StatefulSet。
5.2 误区二:serviceName 和被依赖的 Service 可以"差不多"
Service 的名字必须完全匹配,不能填别名,不能跨命名空间,selector 必须精确匹配到 StatefulSet 的 Pod 标签。selector 匹配不上时,Headless Service 的 Endpoints 为空,CoreDNS 不会生成任何 Pod 记录。这是实践中非常容易踩的坑——Service 是 Headless、名字也一致,但 Pod 就是互相发现不了,最后一看,Service 的 selector 里写了个
app: nginx,而 Pod 的标签是app: nginx-sts。所以排查顺序应该是固定的:
kubectl get svc确认 Service 存在,且CLUSTER-IP列显示为None;kubectl get endpoints <serviceName>确认 Addresses 里有对应 Pod IP,而不是显示<none>;- 进入任意 Pod,
getent hosts web-1.web.default.svc.cluster.local验证解析结果是否为具体 Pod IP; - 查看 initContainer 日志,确认卡住的环节到底是 DNS 解析还是业务连接;
- 回看 StatefulSet 的
kubectl describe事件,确认 controller 有没有报 Service not found。
这一套流程下来,80% 的 StatefulSet 初始化卡死问题都能定位。剩下的 20%,通常出在应用自身把域名写死成 IP、或者在配置里遗漏了端口。
5.3 误区三:只要顺序创建开了,DNS 身份就不重要
有人看到
podManagementPolicy: Parallel,以为并行创建模式下 serviceName 就可以随便填了。实际上Parallel只是取消了"前一个 Ready 才创建下一个"的等待,它并没有改变每个 Pod 需要稳定域名这一事实。并行创建恰恰让集群成员对 DNS 的依赖更迫切——所有节点同时启动,谁都想在第一时间找到别人,如果 Service 没配置对,整批节点都会在初始化阶段打转。另外提醒一句,StatefulSet 的 serviceName 只是集群内 DNS 身份,它不负责外部访问。集群外想访问某个具体的 Pod,通常要靠 NodePort 固定端口、ExternalIP、Ingress,或者直接用
hostNetwork。不要混淆"稳定网络域名"和"外部可达"这两个概念。6. 最后分享一个让我印象深刻的排查经历
前阵子帮朋友看一个 ZooKeeper 集群扩容后新节点初始化永远失败的问题。三台老节点运行正常,新节点
zk-3的日志一直报Cannot open channel to 2 at election address zk-2.zk-hs.default.svc.cluster.local:3888。我按上面那套流程排查,第一眼觉得 DNS 没问题,因为
getent hosts zk-2.zk-hs.default.svc.cluster.local能解析出 IP。但仔细一看,这个 Service 的CLUSTER-IP不是None——某个同事不知道什么时候把它从 Headless 改成了普通 Service。虽然大部分时候访问集合域名是通的,但 ZooKeeper 按序号访问zk-2时拿到的要么是 VIP,要么解析失败,选举流程就一直建立不起来。改回clusterIP: None后,扩容立即成功。说实话,这个坑如果只靠看 YAML 很难发现,因为业务层报错信息和真正的 DNS 问题隔了好几层。但回过头来,
StatefulSet.spec.serviceName这个必填字段本身就在提醒你:有状态应用的网络身份不是可有可无的装饰,而是一套完整机制的基础。先有 Service,后有个体身份,这是 StatefulSet 世界里绕不过去的规则。之后你再看到别人写的 StatefulSet,不妨先看一眼 serviceName 对应的 Service 是不是 Headless、selector 是否匹配,这一步能帮你节省大量排错时间。