前几天在群里看到有人问:K8s里部署了一套三节点的etcd,客户端配置的时候地址怎么写?写Pod IP吧,一旦Pod重建IP就变了,写Service的ClusterIP吧,又只能做负载均衡,没法固定到某一个节点。这个问题其实很典型,有状态应用在Kubernetes里最难处理的就是网络标识的稳定性,而StatefulSet加Headless Service的组合,恰恰就是为了解决这个问题而生的。
说白了,StatefulSet创建的Pod,和Deployment创建的Pod,最大区别在于“身份”。Deployment的Pod是无状态的,挂了重建一个,名字和IP都换了,谁都能替代谁;StatefulSet的Pod则是有“名分”的,每个Pod都有一个从0开始的序号,比如etcd-0、etcd-1、etcd-2,主机名固定,而且只要你配好了对应的Headless Service,每一个Pod都能拿到一个稳定的、可以通过DNS解析的域名。这篇文章就围绕这个机制,把StatefulSet的Pod域名访问从原理到踩坑完整过一遍,顺便聊聊这套能力在生产里的实用场景。
1. 为什么StatefulSet的Pod需要域名访问
1.1 有状态应用对网络标识的硬性要求
先聊个生活化的例子。Deployment管理的Pod就像便利店里的临时工,今天张三来上班,明天李四来顶替,顾客根本不需要关心是谁在收银,只要店开着就行。StatefulSet管理的Pod则像医院里的主治医生,病历上清清楚楚写着谁负责这个病人,你不能随便换人,换了就得重新交接。
对应到技术层面,数据库、消息队列、协调服务这类有状态应用,节点之间是需要互相识别身份的。比如etcd集群的member列表里记录的是每个节点的地址,如果某个节点的IP变了,整个集群可能就要重新选举甚至脑裂。再比如MySQL主从复制,从库需要知道主库是谁,这个关系一旦建立就不能轻易变动。
所以Kubernetes给StatefulSet设计了一套“稳定网络身份”机制:每个Pod有一个固定序号,Pod的主机名是podName-序号,并且通过一个特殊的Service(Headless Service)把这些Pod的域名固定下来。无论Pod怎么重建,只要序号不变,域名就不变,IP变了也能通过域名找到它。
1.2 普通Service和Headless Service的区别
Service大家应该很熟,它是一组Pod的访问入口,有一个虚拟IP(ClusterIP),请求打过来由kube-proxy转发到后端的某个Pod。这里面有个关键点:普通Service做的是流量分发,你访问Service的IP,实际打到后端哪个Pod是随机的,而且是轮询或者按策略来的。
Headless Service则完全不一样,它的ClusterIP被显式设置为None,也就是说它不创建一个虚拟IP,不参与负载均衡。它的作用变成了纯“服务发现”——通过这个Service的DNS解析,你能拿到后端所有Pod的真实IP列表。
我整理了一个对比表格,两者区别看得更清楚:
| 对比项 | 普通Service | Headless Service |
|---|---|---|
| clusterIP | 分配虚拟IP | 设置为None |
| DNS解析结果 | 返回虚拟IP | 返回全部Pod的IP列表 |
| 负载均衡 | kube-proxy自动转发 | 不做负载均衡 |
| 适用场景 | 无状态应用流量入口 | 有状态应用节点寻址 |
| StatefulSet关联 | 可选 | 必须(配合稳定域名) |
实际用的时候,一套StatefulSet往往同时挂两个Service:一个Headless Service用于Pod级别的域名解析和节点间通信,一个普通Service用于对外暴露统一访问入口,对外只看到一个稳定的ClusterIP,对内节点间通过Headless Service的域名互访。
2. 搭建一套可域名访问的StatefulSet
2.1 完整YAML示例:三节点etcd集群
纸上谈兵没什么意思,直接上一个可以跑起来的例子。我这边用etcd来演示,因为etcd集群对节点身份识别的要求非常典型,而且很多K8s集群本身就用etcd存储数据,大家比较熟悉。
下面是完整的StatefulSet加Headless Service的YAML,我拆开讲:
# Headless Service,负责给每个Pod提供稳定的DNS记录 apiVersion: v1 kind: Service metadata: name: etcd-svc namespace: default labels: app: etcd spec: clusterIP: None publishNotReadyAddresses: true selector: app: etcd ports: - name: client port: 2379 targetPort: 2379 --- # StatefulSet,创建3个etcd节点 apiVersion: apps/v1 kind: StatefulSet metadata: name: etcd namespace: default spec: serviceName: etcd-svc replicas: 3 selector: matchLabels: app: etcd template: metadata: labels: app: etcd spec: containers: - name: etcd image: quay.io/coreos/etcd:v3.5.4 ports: - name: client containerPort: 2379 - name: peer containerPort: 2380 env: - name: NODE_NAME valueFrom: fieldRef: fieldPath: metadata.name - name: NODE_IP valueFrom: fieldRef: fieldPath: status.podIP command: - /usr/local/bin/etcd args: - --name=$(NODE_NAME) - --data-dir=/var/lib/etcd - --initial-advertise-peer-urls=http://$(NODE_NAME).etcd-svc.default.svc.cluster.local:2380 - --listen-peer-urls=http://0.0.0.0:2380 - --listen-client-urls=http://0.0.0.0:2379 - --advertise-client-urls=http://$(NODE_NAME).etcd-svc.default.svc.cluster.local:2379 - --initial-cluster-token=etcd-cluster-1 - --initial-cluster=etcd-0=http://etcd-0.etcd-svc.default.svc.cluster.local:2380,etcd-1=http://etcd-1.etcd-svc.default.svc.cluster.local:2380,etcd-2=http://etcd-2.etcd-svc.default.svc.cluster.local:2380 - --initial-cluster-state=new这段配置有个地方值得特别注意,就是Headless Service里的publishNotReadyAddresses: true这个字段。默认情况下,Pod只有变成Ready状态才会出现在Endpoints里,DNS才有记录。但etcd这类应用启动时就需要和其他节点通信来完成集群初始化,如果等Ready才发布DNS,那第一个节点根本找不到其他节点,形成死锁。所以把这个字段设为true,意思是Pod只要存在,哪怕还没Ready,也会被写入DNS记录。
2.2 关键字段逐个拆解
StatefulSet里有个容易忽略但极其重要的字段:spec.serviceName。这个字段必须填一个已经存在的Headless Service的名字,它的作用就是告诉Kubernetes:“我这个StatefulSet的Pod,要用哪个Headless Service来生成DNS记录。”
如果你忘了填serviceName,或者填了一个不存在的Service,那么Pod虽然能正常启动,但你用域名解析Pod名的时候会失败,因为系统根本没有为该StatefulSet的Pod生成对应的SRV记录和A记录。
publishNotReadyAddresses刚才说了,对于需要自组集群的应用是必备的。不过要注意,这个字段在K8s 1.12之前的版本叫publishNotReadyAddresses,再早版本叫service.alpha.kubernetes.io/tolerate-unready-endpoints注解,新版本已经把注解方式废弃了,直接用字段就行。
还有一个相关字段是podManagementPolicy。默认值是OrderedReady,也就是创建和销毁Pod时按序号依次进行,0号先起,等Ready再起1号。对于etcd这类需要严格初始顺序的应用,保持默认就好。如果是RabbitMQ这类比较随意、不依赖启动顺序的应用,可以改成Parallel,加快批量启停速度。
StatefulSet的Pod还有一个特点:删除重建后,虽然IP会变,但主机名不变,存储卷不变。主机名的规则是StatefulSet名称-序号,比如etcd-0、etcd-1、etcd-2。这个规律在后面理解Pod域名格式时很关键。
2.3 部署和验证集群状态
配置写好后,一条命令就能部署:
# 部署Headless Service和StatefulSet kubectl apply -f etcd.yaml # 查看Pod创建状态 kubectl get pods -o wide # 等一段时间后,确认所有Pod都是Running且Ready正常的话,你会看到三个名字带序号的Pod依次创建出来,它们的IP是各自独立的。这时候查看Service,会发现etcd-svc这个Service的CLUSTER-IP一栏显示的是None:
kubectl get svc etcd-svc输出类似这样:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE etcd-svc ClusterIP None <none> 2379/TCP 5m看到CLUSTER-IP为None,就说明Headless Service生效了。接下来就可以开始验证域名解析了。
3. 深入Pod域名解析规则
3.1 必须先搞明白的DNS命名格式
StatefulSet里每个Pod的域名,遵循一套固定的格式,这条规则是整个机制的核心:
<pod-name>.<service-name>.<namespace>.svc.<cluster-domain>拆开来看:
pod-name:Pod的名字,也就是StatefulSet名称-序号,比如etcd-0service-name:你在StatefulSet里指定的serviceName,也就是Headless Service的名字namespace:Pod所在的命名空间svc:固定不变cluster-domain:集群的DNS域名,默认是cluster.local
所以上一节那个etcd例子,三个Pod的完全限定域名(FQDN)分别是:
| Pod名称 | 完整域名 |
|---|---|
| etcd-0 | etcd-0.etcd-svc.default.svc.cluster.local |
| etcd-1 | etcd-1.etcd-svc.default.svc.cluster.local |
| etcd-2 | etcd-2.etcd-svc.default.svc.cluster.local |
在同一个命名空间内部,可以省略namespace.svc.cluster.local这一大串,直接用短域名:
etcd-0.etcd-svc甚至有些场景下,部分K8s版本还支持只写Pod名就能解析到本命名空间下的Service,但我不建议依赖这种简写,跨命名空间或者集群环境一变就容易出问题。我自己的习惯是:同命名空间内用etcd-0.etcd-svc,跨命名空间一律写完整FQDN,宁可多打几个字,也别在生产环境赌这个。
3.2 在集群内部实际验证DNS解析
部署完成后,我们用调试Pod来验证解析。创建一个标准的busybox调试容器:
kubectl run -it --rm debug --image=busybox:1.36 --restart=Never -- sh进入容器后,先测试短域名的解析:
nslookup etcd-0.etcd-svc正常输出类似:
Server: 10.96.0.10 Address 1: 10.96.0.10 Name: etcd-0.etcd-svc Address 1: 10.244.1.15注意这里解析出来的20.244.1.15,是etcd-0这个Pod的真实IP,而不是ClusterIP。再看完整FQDN:
nslookup etcd-0.etcd-svc.default.svc.cluster.local结果会指向同一个IP。这就是Headless Service和普通Service在DNS层面的核心区别:普通Service解析出来是一个虚拟IP,Headless Service解析出来直接就是Pod IP。
再测试一下搜索Headless Service本身的域名,也就是不带Pod名前缀的:
nslookup etcd-svc对于Headless Service,这个查询返回的是该Service选择器匹配到的所有Pod的IP列表,比如三个etcd节点的IP都会列出来。这个特性在做服务发现时很有用,后面我会展开讲。
3.3 跨命名空间访问的完整域名写法
跨命名空间时,必须使用完整的FQDN。比如Pod在namespace为prod的环境中,Service名字是etcd-svc,那某个Pod的域名就是:
etcd-0.etcd-svc.prod.svc.cluster.local这里容易犯的错是漏掉svc这一层,写成了etcd-0.etcd-svc.prod.cluster.local,这会导致DNS解析失败。我见过不止一次这种事故,排查到最后发现就是少写了svc三个字母。
另外要注意,Kubernetes集群的cluster-domain是可以改的。大多数默认安装的集群是cluster.local,但有些私有化部署可能改成corp.local之类的,这时候所有FQDN里的cluster.local都要跟着变。可以通过查看CoreDNS的ConfigMap来确认:
kubectl get configmap coredns -n kube-system -o yaml里面kubernetes插件那行会有类似kubernetes cluster.local in-addr.arpa ip6.arpa的配置,那就是集群的DNS域。
4. 实操:从Pod内部访问StatefulSet服务
4.1 简单场景:访问etcd服务
验证完DNS解析,我们直接演示从另一个Pod访问etcd集群。先看同一个命名空间下的情况。在debug容器里,直接用域名配置etcd客户端:
# 使用完整域名 etcdctl --endpoints=http://etcd-0.etcd-svc.default.svc.cluster.local:2379,http://etcd-1.etcd-svc.default.svc.cluster.local:2379,http://etcd-2.etcd-svc.default.svc.cluster.local:2379 endpoint health --cluster如果嫌地址太长,在同命名空间下可以简化成:
etcdctl --endpoints=http://etcd-0.etcd-svc:2379 member list这里有一个知识点,就是etcd的client端口2379在Service里已经声明了targetPort,但是访问Pod域名时,域名解析只负责解析IP,端口还是要自己指定。所以你看到我写的是etcd-0.etcd-svc:2379,冒号后面的端口就是Pod容器实际监听的端口。
4.2 验证Pod重建后域名依然可用
StatefulSet的域名机制,最有价值的地方在于Pod故障重建后的稳定性。我们来实际演示一下。先记录当前etcd-1的IP:
kubectl get pod etcd-1 -o wide然后把etcd-1删掉,让StatefulSet控制器重建:
kubectl delete pod etcd-1Pod会重新创建,名字还是etcd-1,但IP很可能会变。这时候再解析域名:
nslookup etcd-1.etcd-svc.default.svc.cluster.local解析出来的IP已经不是原来的那个了,但域名完全没变。对客户端来说,只要它配置的是域名,Pod重建后它感知不到任何变化,不需要修改任何配置。
这就是StatefulSet和Deployment在网络层面最大的区别:Deployment的Pod重建后,名称变了IP也变了,配置里写死Pod IP那就等着完蛋;StatefulSet的Pod重建后,名称不变,域名不变,哪怕IP变了客户端也无感。
4.3 结合VIP Service做统一访问入口
刚才提到,StatefulSet的Pod域名适合节点间互访和集群自组网,但如果想对集群外部提供一个统一的访问入口,更方便的做法是再创建一个普通Service。
apiVersion: v1 kind: Service metadata: name: etcd-client namespace: default spec: type: ClusterIP selector: app: etcd ports: - name: client port: 2379 targetPort: 2379这个Service有一个ClusterIP,客户端只需要配置这个IP或者对应的域名etcd-client.default.svc.cluster.local,请求会被kube-proxy均匀分发到三个etcd节点上。对于不需要区分具体节点的业务场景,这种模式更省心,你只需要保证后端三个节点数据一致,前端怎么轮询都行。
所以实际生产里,有状态服务通常都是双Service架构:
- 一个Headless Service,管Pod稳定域名,给集群节点之间互相发现用的
- 一个普通Service,管统一入口,给上游业务访问用的
5. 常见问题排查与避坑指南
5.1 DNS解析不到Pod域名
这个问题出现频率最高。现象是在debug容器里nslookup etcd-0.etcd-svc返回错误,比如** server can't find etcd-0.etcd-svc: NXDOMAIN。
排查思路我按顺序列一下:
确认StatefulSet里serviceName有没有填对。这是最常见的原因,如果serviceName为空或者和实际Service名不一致,系统不会为Pod生成DNS记录。
确认Headless Service的clusterIP是None。如果不小心把Headless Service写成了普通Service,那Pod域名解析会走到ClusterIP的逻辑,行为完全不一样。
确认Service的selector匹配到了Pod标签。如果selector写的标签和Pod模板里的labels对不上,Endpoints会是空的,DNS记录自然也不会有。查看命令:
kubectl get endpoints etcd-svc如果Endpoints下面没有任何IP,说明selector没匹配上。
- 确认CoreDNS工作正常。如果其他Service的DNS解析也失效,那就不是StatefulSet的问题,而是整个集群DNS组件出了问题。
kubectl get pods -n kube-system -l k8s-app=kube-dns5.2 Pod域名能解析出来,但连通不了
解析成功只能说明DNS没问题,接下来要验证网络链路。我遇到过一种情况:域名解析出来的IP是Pod IP,但从另一个命名空间的Pod去访问时,网络策略(NetworkPolicy)拦住了跨命名空间的流量。这种时候无论域名怎么配,连接都是超时。
排查步骤:
- 直接ping一下解析出来的Pod IP,确认IP本身通不通
- 从源Pod里用
nc或telnet测试端口:
nc -vz etcd-0.etcd-svc.default.svc.cluster.local 2379- 如果IP通但域名不通,检查NetworkPolicy
- 如果IP都不通,检查CNI网络插件和节点路由
5.3 访问某个指定Pod,但被别的Pod抢答了
这其实不是一个问题,而是对机制理解不透彻。很多人以为配置了StatefulSet的所有Pod域名,客户端就能精确访问特定的那个Pod。实际上,如果你创建的是普通Service,那么访问这个Service的域名永远是通过ClusterIP做负载均衡,请求会轮询到任意一个Pod上。
如果你要精确访问某一个Pod,最常见的做法是给这个Pod单独创建一个Service,用statefulset.kubernetes.io/pod-name这个标签来精确匹配:
apiVersion: v1 kind: Service metadata: name: etcd-0-external spec: selector: app: etcd statefulset.kubernetes.io/pod-name: etcd-0 ports: - port: 2379 targetPort: 2379Kubernetes会自动给StatefulSet的每个Pod打上statefulset.kubernetes.io/pod-name标签,值就是Pod名,用这个做精确匹配,就能做到只暴露etcd-0这一个Pod给特定客户端。
5.4 CoreDNS缓存导致解析到旧IP
Pod删除重建后,IP变化了,但客户端可能在一段时间内仍然解析到旧IP。这是因为CoreDNS对DNS解析结果有缓存,缓存时间由CoreDNS的配置和上游DNS的TTL决定。
解决办法几个:
- 客户端侧尽量使用域名,不要缓存结果,每次调用时实时解析
- 如果需要固定IP,可以给Pod设置静态IP(依赖CNI插件能力),不过这样会牺牲StatefulSet的灵活性
- 如果使用Headless Service,解析结果的TTL是30秒,等缓存过期后自然就能解析到新IP
这里顺带说一下StatefulSet的优雅停机问题。如果Pod被手动删除,域名解析会在TTL过期后指向新Pod。但如果你直接delete整个StatefulSet,那域名直接就没了,客户端就会报找不到主机。所以生产环境运维时,清理StatefulSet之前要确认没有客户端依赖它的域名。
6. 延伸:用DNS做集群自组网
6.1 为什么数据库集群需要DNS自发现
写到这里,其实已经不只是“通过域名访问Pod”这么简单了。这套机制真正的威力,在于让有状态应用可以利用DNS做集群自组网。
拿etcd或Cassandra这类分布式系统来说,它们在启动时需要一个“种子成员列表”。过去没容器化的时候,这个列表通常是写在配置文件里的IP地址;容器化之后,Pod IP会变动,再写固定IP就不现实了。而StatefulSet的Pod域名刚好解决了这个问题:启动时通过DNS解析固定域名,就能找到集群里的其他节点。
以我之前部署的etcd为例,启动参数中直接用了域名格式:
--initial-cluster=etcd-0=http://etcd-0.etcd-svc.default.svc.cluster.local:2380,etcd-1=http://etcd-1.etcd-svc.default.svc.cluster.local:2380,etcd-2=http://etcd-2.etcd-svc.default.svc.cluster.local:2380这样三个节点都通过域名互相识别,无论Pod被调度到哪个节点、IP变成什么,集群成员关系都不会乱。
6.2 用Headless Service做SRV记录服务发现
除了Pod级别的A记录,Headless Service还会为定义了命名的端口生成SRV记录。SRV记录在微服务架构里的服务发现中很有用。
比如Service里定义了这样两个端口:
ports: - name: client port: 2379 - name: peer port: 2380那么通过CoreDNS可以查询到这些SRV记录:
nslookup -type=srv _client._tcp.etcd-svc.default.svc.cluster.localSRV记录会返回后端Pod的域名和端口号。对于使用Consul、etcd或者自研服务发现框架的应用来说,直接通过SRV记录获取可用节点列表,可以免去自己轮询Pod IP列表的麻烦。特别是StatefulSet结合Headless Service使用时,SRV记录返回的内容就相当于一份实时的节点成员名单。
6.3 结合ExternalName做不同集群互通
还有个进阶玩法。如果两个K8s集群之间需要互通,其中一个集群的应用要访问另一个集群的StatefulSet服务,可以通过ExternalName类型的Service把跨集群的FQDN映射过来:
apiVersion: v1 kind: Service metadata: name: remote-etcd namespace: default spec: type: ExternalName externalName: etcd-0.etcd-svc.prod.svc.cluster.local这样本集群的Pod访问remote-etcd时,DNS解析会直接返回目标集群那个Pod的FQDN。前提是两个集群的网络互通,而且DNS能解析到对方的集群域。
6.4 自定义集群域名的注意事项
最后提一个容易被忽坑的点:有些团队为了图方便,把自定义域名直接映射到StatefulSet的Pod域名上,比如做一个内网DNS A记录,把etcd.internal.company.com解析到某个固定的Pod IP。这种做法在短期内能跑通,但一旦Pod重建换了IP,这条静态DNS记录就会失效,到时候排查起来相当痛苦。
我的建议是,凡是在K8s集群内部访问StatefulSet服务,一律使用K8s原生DNS域名。外部系统如果需要访问,优先用NodePort或Ingress接入层,再由接入层转发到K8s内部的Service域名,这样链路清晰又稳定。
写在最后的一点经验
StatefulSet通过域名访问Pod这套机制,我前前后后在生产环境摸爬滚打用了快三年,最大的体会是:域名机制本身很简单,核心就那几个字段、一条命名规则,但真正用好的关键在于对应用自身的理解。像etcd这种对节点身份极度敏感的应用,必须配Headless Service并且开启publishNotReadyAddresses;而像Redis Cluster这种对顺序不敏感的,可以适当放宽podManagementPolicy以加快部署速度。
如果真的在生产踩了域名解析的坑,永远记得从这几个方向排查:serviceName写没写对、Headless Service的selector匹配没匹配、Pod的Ready状态如何、CoreDNS有没有病。按照这个顺序查,基本十分钟内能定位问题。
另外还有一个我踩过的坑想提醒大家:千万别把StatefulSet Pod的IP拿去写白名单或者配额,IP随手就换了,域名才是真正稳定的标识。该用域名的地方,老老实实用域名,别贪图省事写IP。如果你在本地实验环境想快速测试,完全可以只部署一个单副本的StatefulSet加上Headless Service,用busybox解析一下域名,整套机制十分钟就能跑通,强烈建议亲手玩一遍。