多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

K8s StatefulSet Pod域名访问:Headless Service与DNS解析原理实战

K8s StatefulSet Pod域名访问:Headless Service与DNS解析原理实战 前几天在群里看到有人问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-序号并且通过一个特殊的ServiceHeadless Service把这些Pod的域名固定下来。无论Pod怎么重建只要序号不变域名就不变IP变了也能通过域名找到它。1.2 普通Service和Headless Service的区别Service大家应该很熟它是一组Pod的访问入口有一个虚拟IPClusterIP请求打过来由kube-proxy转发到后端的某个Pod。这里面有个关键点普通Service做的是流量分发你访问Service的IP实际打到后端哪个Pod是随机的而且是轮询或者按策略来的。Headless Service则完全不一样它的ClusterIP被显式设置为None也就是说它不创建一个虚拟IP不参与负载均衡。它的作用变成了纯“服务发现”——通过这个Service的DNS解析你能拿到后端所有Pod的真实IP列表。我整理了一个对比表格两者区别看得更清楚对比项普通ServiceHeadless ServiceclusterIP分配虚拟IP设置为NoneDNS解析结果返回虚拟IP返回全部Pod的IP列表负载均衡kube-proxy自动转发不做负载均衡适用场景无状态应用流量入口有状态应用节点寻址StatefulSet关联可选必须配合稳定域名实际用的时候一套StatefulSet往往同时挂两个Service一个Headless Service用于Pod级别的域名解析和节点间通信一个普通Service用于对外暴露统一访问入口对外只看到一个稳定的ClusterIP对内节点间通过Headless Service的域名互访。2. 搭建一套可域名访问的StatefulSet2.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-urlshttp://$(NODE_NAME).etcd-svc.default.svc.cluster.local:2380 - --listen-peer-urlshttp://0.0.0.0:2380 - --listen-client-urlshttp://0.0.0.0:2379 - --advertise-client-urlshttp://$(NODE_NAME).etcd-svc.default.svc.cluster.local:2379 - --initial-cluster-tokenetcd-cluster-1 - --initial-clusteretcd-0http://etcd-0.etcd-svc.default.svc.cluster.local:2380,etcd-1http://etcd-1.etcd-svc.default.svc.cluster.local:2380,etcd-2http://etcd-2.etcd-svc.default.svc.cluster.local:2380 - --initial-cluster-statenew这段配置有个地方值得特别注意就是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一栏显示的是Nonekubectl 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-namePod的名字也就是StatefulSet名称-序号比如etcd-0service-name你在StatefulSet里指定的serviceName也就是Headless Service的名字namespacePod所在的命名空间svc固定不变cluster-domain集群的DNS域名默认是cluster.local所以上一节那个etcd例子三个Pod的完全限定域名FQDN分别是Pod名称完整域名etcd-0etcd-0.etcd-svc.default.svc.cluster.localetcd-1etcd-1.etcd-svc.default.svc.cluster.localetcd-2etcd-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 --imagebusybox:1.36 --restartNever -- 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。再看完整FQDNnslookup etcd-0.etcd-svc.default.svc.cluster.local结果会指向同一个IP。这就是Headless Service和普通Service在DNS层面的核心区别普通Service解析出来是一个虚拟IPHeadless 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 --endpointshttp://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 --endpointshttp://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的IPkubectl 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 cant 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-appkube-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缓存导致解析到旧IPPod删除重建后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-clusteretcd-0http://etcd-0.etcd-svc.default.svc.cluster.local:2380,etcd-1http://etcd-1.etcd-svc.default.svc.cluster.local:2380,etcd-2http://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 -typesrv _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解析一下域名整套机制十分钟就能跑通强烈建议亲手玩一遍。
返回列表