有一次我在集群里部署一套三节点 etcd,图省事,直接把之前项目里的 Deployment YAML 拿来改。去掉 replicas 里的随机后缀逻辑、加了 serviceName 字段,然后 kubectl apply。结果 API Server 直接把我的配置弹了回来:ValidationError(StatefulSet.spec): missing required field "serviceName"。当时我以为补个名字就行,随手填了 etcd,继续提交。Pod 是建出来了,但没一个能起来,日志里反复刷同一行:dial tcp: lookup etcd-0.etcd on 10.96.0.10:53: no such host。那一刻我脑子里全是问号:我一个 etcd 容器,启动时为什么要解析 etcd-0.etcd 这个域名?我连 Service 都还没建,它解析个什么?后来把 Headless Service 补上,集群瞬间就健康了。
这个问题看起来简单,背后其实是 StatefulSet 和有状态应用之间最核心的一套机制——初始化阶段的稳定网络身份。很多同学被这个问题卡住,不是不懂 YAML,而是没理解“StatefulSet 的 Pod 在启动那一刻,必须知道自己在集群里的 DNS 全名,而这个名字的拼装,绕不开 serviceName”。
这篇文章就把这条链路拆开讲透。
1. 一次真实的启动失败:从 ValidationError 到 “no such host”
1.1 场景还原:为什么按 Deployment 的思路改 YAML 会翻车
我见过太多人(包括我自己)第一次写 StatefulSet 时,是拿 Deployment 的模板改的。Deployment 的 YAML 里根本没有 serviceName 这个字段,Pod 模板写好后,kubectl apply 就完事了。但 StatefulSet 的 spec 里,serviceName 是必填字段,缺了它 API Server 直接拒绝。这是 Kubernetes 在 API 层的强校验,绕不过去。
那为什么 Kubernetes 非要逼你填这个字段?因为 StatefulSet 在创建每个 Pod 时,需要把 serviceName 写进 Pod 的 subdomain 字段。Pod 的主机名是它自己的名字,子域名是这个 Service 的名字,两者一拼,才是这个 Pod 在集群内部的完整 DNS 身份。没有 serviceName,Pod 连“户口本”都上不了,后续的有序启动、DNS 解析、集群成员发现全都没法做。
1.2 “no such host”到底说明什么
我补上 serviceName: etcd 之后,StatefulSet 的 Pod 能创建了,但依然起不来,日志是 lookup etcd-0.etcd ... no such host。这个报错信息足够说明问题:Pod 里的 etcd 进程在启动时,尝试把自己注册到 etcd-0.etcd 这个地址上,同时还要去解析 etcd-1.etcd、etcd-2.etcd,用来组成集群的 initial-cluster。
我当时连 Headless Service 都没建,CoreDNS 里根本不存在 etcd-0.etcd 这条 DNS 记录,所以解析直接失败,进程退出,容器不断重启。注意,这里不是 Pod 本身起不来,而是应用进程在初始化阶段发现无法确认自己的网络身份,主动放弃了。
1.3 先分清两个层面的 service name
这个坑里其实混着两个不同的“service name”,很多人会搞混:
- 第一层是 StatefulSet 的 spec.serviceName 字段,它必填,填的是 Service 的名字,没有 API Server 不让创建。
- 第二层是 同命名空间下真实存在的 Headless Service 对象,StatefulSet 启动 Pod 时,会拿
serviceName去关联这个 Service。如果 Service 不存在,Pod 虽然能创建,但应用启动时的 DNS 解析会失败。
所以标准的创建顺序是:先建 Headless Service,再建 StatefulSet。后面我会给完整 YAML。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 稳定网络标识:StatefulSet 的 Pod 和 Deployment 的 Pod 到底差在哪
2.1 无状态 Pod 是“临时工”,有状态 Pod 是“固定工号”
先想一个问题:普通 Deployment 扩容出来的 Pod,名字长什么样?比如 web-7d9d8cf7b9-abcde,每次重建都会变化。这种 Pod 是无状态的,它不关心自己叫什么,谁访问它都可以,挂了就换一个新的,流量由 Service 做负载均衡转发到任意副本上。
但 StatefulSet 里的 Pod 不一样。以 etcd、ZooKeeper、Kafka、MySQL 主从这类应用为例,每个节点是有身份区分的:谁是主节点、谁是副本,集群里都有记录。节点通过自己的主机名参与集群协商,重启后如果名字变了,老成员就会把它当成一个新节点,数据分片和选举逻辑全乱。
所以 StatefulSet 给每个 Pod 一个固定编号:etcd-0、etcd-1、etcd-2。无论 Pod 被删除重建、漂移到哪台机器,这个名字都不变。这就是“固定工号”。
2.2 稳定网络标识 = 固定 Pod 名 + 可解析 DNS 全名
光有固定 Pod 名还不够。应用启动时需要把这个名字解析成 IP,才能与其他节点通信。这个“名字到 IP”的翻译过程,靠的就是 DNS。
在 Kubernetes 中,一个 StatefulSet 的 Pod 完整的 DNS 名称是:
code复制$(pod-name).$(serviceName).$(namespace).svc.cluster.local
比如 etcd-0.etcd.default.svc.cluster.local。其中:
etcd-0是 Pod 名,由 StatefulSet 名称加序号自动生成;etcd就是spec.serviceName指向的 Service 名;default是命名空间;svc.cluster.local是集群默认 DNS 后缀。
所以 serviceName 不只是给 Service 用的,它直接参与了每个 Pod 的 DNS 名称拼接。没有它,Pod 的“工号”就少了一个关键部分,整个名字不完整。
2.3 数据库和中间件必须回答的两个问题:“我是谁”和“同伴在哪”
有状态应用在初始化阶段,一般会做两件事:
第一,确认自己的身份。比如 etcd 的 --name、--advertise-client-urls,通常会用 Pod 名和 serviceName 拼出自己的地址,告诉集群“我是 etcd-0,你们可以通过 etcd-0.etcd 访问我”。
第二,发现集群里的其他成员。--initial-cluster 里会列出一串地址,全是这种 名字.服务名:端口 的格式。应用拿这些名字去做 DNS 查询,拿到对方的 IP,才能建立连接,组成集群。
这个逻辑放在 Kubernetes 环境里,就演变成了:没有 Service Name,应用无法拼出自己和同伴的完整 DNS 名,初始化必然失败。这就是标题里“初始化需要 service name”的本质原因。
3. serviceName 如何参与初始化:从 Pod 创建到应用启动的完整 DNS 链路
3.1 FQDN 的拼装规则:一件容易被忽略的小事
StatefulSet controller 在创建每个 Pod 时,会往 Pod 的 spec 里自动填两个字段:hostname 和 subdomain。我建议你实际去看一眼正在运行的 Pod 定义:
bash复制kubectl get pod etcd-0 -o yaml | grep -A2 -E "hostname|subdomain"
正常情况下你会看到:
yaml复制spec:
hostname: etcd-0
subdomain: etcd
hostname 等于 Pod 名,subdomain 等于 serviceName。这两个字段组合起来,再加上命名空间和 svc.cluster.local 后缀,最终构成 Pod 的完整域名(FQDN)。
一些老手可能会问:我自己写的 Pod YAML 里也能指定 hostname 和 subdomain,StatefulSet 又何必通过 serviceName 来设置?区别在于,StatefulSet 是自动且强制把 serviceName 作为每个 Pod 的 subdomain,这样所有副本的网络身份格式就完全一致,有序启动、集群发现、重新调度后才能保证身份可预期。
3.2 Headless Service:只发“身份凭证”,不做负载均衡
到这里就不得不提 Headless Service。它的定义非常特殊,clusterIP 设置为 None:
yaml复制apiVersion: v1
kind: Service
metadata:
name: etcd
spec:
clusterIP: None
selector:
app: etcd
ports:
- name: client
port: 2379
targetPort: 2379
- name: peer
port: 2380
targetPort: 2380
普通 Service 会分配一个 ClusterIP,客户端的请求会通过这个虚拟 IP 做负载均衡。而 Headless Service 不分配 ClusterIP,它的作用是“把符合条件的 Pod 直接暴露给 DNS”。
查询无头服务的域名时,CoreDNS 返回的不是一个虚拟 IP,而是所有就绪 Pod 的 IP 列表;查询某个具体 Pod 的域名时,返回的是这个 Pod 自己的 IP。每个 Pod 都有独立的 A 记录,这个机制才是 StatefulSet 稳定网络标识的真正落地点。
3.3 初始化时间线:每个环节都在向 DNS 要答案
把整个过程串起来看,才能理解“初始化”这几个字的份量。一个 StatefulSet 的 Pod 从创建到应用启动成功,大致会经历以下几步:
- StatefulSet controller 按序号创建 Pod。先创建
etcd-0,等它就绪后再创建etcd-1,依次往后。这就是有序部署。 - kubelet 创建并启动 Pod 内容器。此时 Pod 的
hostname和subdomain已经由 StatefulSet 写入。 - 容器内进程读取环境变量或命令参数。比如 etcd 通过 Downward API 拿到
POD_NAME,再通过serviceName拼出自己的完整 FQDN。 - 进程发起 DNS 解析。容器内的
/etc/resolv.conf默认配置了集群 DNS 地址和搜索域,应用访问etcd-0.etcd时,会自动补全成etcd-0.etcd.default.svc.cluster.local去查询。 - CoreDNS 返回 Pod IP。这个响应能成立的前提,是
etcd这个 Service 存在、是 Headless、并且 selector 选中了当前 Pod。少一个条件,解析都会失败。 - 应用确认自己是集群成员,开始监听端口,等待其他节点接入。
所以你在日志里看到的 no such host,往往就是第 4 步或第 5 步挂了。排查的时候别一上来就怀疑 CoreDNS,先回头看看 Headless Service 建了没有。
3.4 如果这里用了普通 ClusterIP Service,会发生什么
还有一个容易踩的变体:Service 建了,但不是 Headless,而是普通的带 ClusterIP 的 Service。这种情况 Kubernetes 不会拦你,StatefulSet 也能创建成功,但行为会变得非常诡异。
普通 Service 的 DNS 记录,解析结果指向 ClusterIP。比如查询 etcd.default.svc.cluster.local,返回的可能是 10.96.0.50 这个虚拟 IP。但查询 etcd-0.etcd.default.svc.cluster.local 这种 Pod 级别的记录时,CoreDNS 不会为普通 Service 生成 Pod 维度的 A 记录,结果依然是解析失败。
就算应用解析的是 Service 自己的域名(不带 Pod 名),拿到 ClusterIP 后,它也没法确定自己到底是哪一台节点。etcd 这类应用会把 ClusterIP 当成某个成员的地址,导致成员间地址错乱、选举失败、日志里全是连接超时。这类问题比“Service 不存在”更隐蔽,因为从 kubectl get svc 看,Service 是正常的,但集群就是组不起来。
3.5 initContainer 的等待循环同样依赖 serviceName
很多有状态应用会在 StatefulSet 里加 Init Container,用来做启动前的等待。比如:
yaml复制initContainers:
- name: wait-for-peer
image: busybox:1.36
command: ['sh', '-c', 'until nslookup etcd-1.etcd; do echo "waiting for etcd-1"; sleep 2; done']
这段逻辑想表达的是:主容器启动前,先等 etcd-1 可以被解析。如果 Headless Service 没建,nslookup etcd-1.etcd 会一直失败,Init Container 就会一直循环,Pod 永远停在 Init:0/1 状态。
这其实是最好的复现场景。你不需要真的部署一个 etcd 集群,只要拿一个 busybox 容器配上这样的 Init Container 和一个没有 Service 的 StatefulSet,就能完整看到“初始化阶段卡死”的全过程。
4. 故障清单与排查路径:serviceName 惹出的麻烦怎么定位
4.1 三类典型故障和根因对照
我在实际维护和答疑过程中,最常遇到的 StatefulSet 初始化失败可以归纳成三类:
| 故障现象 | 常见根因 | 特征日志 |
|---|---|---|
| Pod 创建直接被拒 | 缺少 serviceName 字段 |
missing required field "serviceName" |
| Pod 反复 CrashLoopBackOff | 没有创建对应的 Headless Service | no such host、server not found |
| Pod 起来了但集群组不起来 | Service 存在但是普通 ClusterIP 类型 | 连接超时、成员列表不稳定、解析到 ClusterIP |
故障现象 1 最好排查,YAML 补上字段就行。故障现象 2 需要确认 Service 是否创建、selector 是否匹配、是否是无头类型。故障现象 3 最坑,因为表面上 Service、Pod 都正常,但日志里的内容和预期完全对不上。
4.2 一次完整排查过程:从 Pod 日志查到 CoreDNS
我拿故障现象 2 举例,完整的排查链路是这样的。
第一步,看 Pod 状态:
bash复制kubectl get pod -o wide
发现 etcd-2 一直在 CrashLoopBackOff,etcd-0 和 etcd-1 已经就绪。
第二步,看日志:
bash复制kubectl logs etcd-2 --tail=50
日志里能看到类似:
code复制etcd-2.etcd: Name or service not known
关键信息是 etcd-2.etcd 这个域名解析不了。这里注意,它解析的是短域名,容器内会通过 search domain 自动补全成 etcd-2.etcd.default.svc.cluster.local。
第三步,看 Service 是否存在:
bash复制kubectl get svc
如果列表里根本没有 etcd 这个 Service,那根因基本确定了。如果 Service 存在,接着看它的类型和 Endpoints:
bash复制kubectl get svc etcd -o yaml
kubectl get endpoints etcd
无头 Service 的 clusterIP 必须是 None,Endpoints 里必须能看到所有 Pod 的 IP。如果 Endpoints 是空的,说明 selector 没匹配上 Pod,比如 Pod 标签写成了 name: etcd,Service 选的是 app: etcd。
第四步,进入容器亲自解析一下试试:
bash复制kubectl run -it --rm dnsutils \
--image=busybox:1.36 \
--restart=Never \
-- nslookup etcd-2.etcd
这一步能直接验证 CoreDNS 是否返回了正确 IP。拿到结果后,再对照 kubectl get pod -o wide 里的 Pod IP,确认返回的就是目标 Pod 的 IP。
4.3 排查命令清单与检查顺序
如果你也遇到类似问题,可以按下面这个顺序查,不用来回跳:
bash复制# 1. 看 StatefulSet 是否正常编排
kubectl get statefulset
kubectl describe statefulset etcd
# 2. 看 Pod 状态和事件
kubectl get pod -o wide
kubectl describe pod etcd-2
# 3. 看日志(如果是 CrashLoopBackOff)
kubectl logs etcd-2 --tail=50
# 4. 看 Service 类型和 Endpoints
kubectl get svc
kubectl get svc etcd -o yaml
kubectl get endpoints etcd
# 5. 进容器实测 DNS
kubectl run -it --rm dnsutils \
--image=busybox:1.36 \
--restart=Never \
-- nslookup etcd-0.etcd
这个顺序的核心逻辑是:先确认编排有没有生效,再确认应用为什么失败,最后确认 DNS 链路本身通不通。大部分 StatefulSet 初始化问题,走到第 4 步就能定位到根因。
5. 一套可直接复制的配置:无头 Service + StatefulSet 完整示例
5.1 etcd 三节点完整 YAML
下面这套配置我实际验证过,可以直接拿去做实验。先创建 Headless Service,再创建 StatefulSet。
yaml复制# headless-service.yaml
apiVersion: v1
kind: Service
metadata:
name: etcd
labels:
app: etcd
spec:
clusterIP: None
selector:
app: etcd
ports:
- name: client
port: 2379
targetPort: 2379
- name: peer
port: 2380
targetPort: 2380
yaml复制# statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: etcd
spec:
serviceName: etcd
replicas: 3
selector:
matchLabels:
app: etcd
template:
metadata:
labels:
app: etcd
spec:
terminationGracePeriodSeconds: 10
containers:
- name: etcd
image: quay.io/coreos/etcd:v3.5.0
ports:
- containerPort: 2379
name: client
- containerPort: 2380
name: peer
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
command:
- /usr/local/bin/etcd
- --name=$(POD_NAME)
- --data-dir=/var/lib/etcd
- --initial-advertise-peer-urls=http://$(POD_NAME).etcd:2380
- --listen-peer-urls=http://0.0.0.0:2380
- --advertise-client-urls=http://$(POD_NAME).etcd:2379
- --listen-client-urls=http://0.0.0.0:2379
- --initial-cluster=etcd-0=http://etcd-0.etcd:2380,etcd-1=http://etcd-1.etcd:2380,etcd-2=http://etcd-2.etcd:2380
- --initial-cluster-state=new
关键点在于:
- Service 的
clusterIP必须是None,这是无头服务最核心的标识; - Service 的 selector 必须能选中 StatefulSet 的 Pod,否则 Endpoints 为空;
- StatefulSet 的
serviceName必须和 Service 名称一致; - etcd 启动参数里,把
$(POD_NAME).etcd拼进地址,其中etcd就是 serviceName。拼接逻辑和 3.1 节里的 FQDN 规则完全对应。
5.2 部署后的三条验证路径
部署完别急着走,建议做三个验证,确认自己真的理解了这个机制。
第一,验证 Pod 的域名解析:
bash复制kubectl run -it --rm dnsutils \
--image=busybox:1.36 \
--restart=Never \
-- nslookup etcd-0.etcd
如果返回的 IP 和 kubectl get pod etcd-0 -o wide 里的 IP 一致,说明无头服务的 DNS 链路是通的。
第二,验证无头服务本身的查询结果:
bash复制kubectl run -it --rm dnsutils \
--image=busybox:1.36 \
--restart=Never \
-- nslookup etcd
正常会返回三个等等 Pod 的 IP,而不是一个虚拟 IP。这能直观展示 Headless Service 和普通 Service 在 DNS 行为上的区别。
第三,验证有序启动:
bash复制kubectl get pod -w
正常会看到 etcd-0 先进入 Running 和 Ready,之后 etcd-1 才开始创建,最后才是 etcd-2。如果顺序乱了,或者某个 Pod 一直卡在 Init,回头检查 Service 的配置。
5.3 单副本场景的经验:serviceName 不是可选项
还会有人问:我只部署一个副本,不需要集群发现,是不是可以不建 Service?
我的建议是不要省这一步。即使在单副本场景,应用启动时依然会尝试解析自己的域名,比如 mysql-0.mysql 这个地址。没有无头 Service,解析失败,Pod 一样起不来。我自己踩过这个坑,当时以为单节点不需要那么多复杂逻辑,结果一个 MySQL 单实例在容器里反复重启,查了半天才发现是漏了 Service。
另外,单副本 StatefulSet 通常也会配 PVC,重建后 Pod 名和 DNS 名保持不变,才能正确挂载同一份数据。这种情况下,serviceName 和 Headless Service 更是整个身份机制里不可分割的一部分。
根据我个人经验,部署 StatefulSet 的固定动作就是:先检查要关联的无头 Service 是否已经存在,再写 StatefulSet 的 YAML。你可以把这个检查当成上线前的一个习惯动作,能省下大量排查时间。等你在生产环境里遇到过几次因为 serviceName 引发的诡异故障后,就会明白这个字段不只是“必填”两个字那么简单,它是有状态应用在 Kubernetes 里活下去的根基。
