先讲一个我翻车的事。
那是一个内部系统的上线日。前端服务要调用后端的新版本接口,代码里写的还是后端老Pod的IP。后端发版后Deployment滚动重建,新Pod被调度到了另一台工作节点,IP自然变了。前端服务配置里写死的地址彻底失效,整个系统登不进去。排查了大半天,最后才发现问题不是代码逻辑,不是网络策略,就是那个被我当成“固定不变”的Pod IP。
后来我把项目里的服务间调用全部改成了K8s Service域名,问题才算根除。这个经历让我意识到:理解K8s的Service发现机制,不是K8s面试题里的冷知识,而是部署任何多服务应用都绕不开的生存技能。
这篇文章,我围绕K8s Service发现这件事,把原理、实践和排障经验完整摊开讲一遍。内容覆盖Service对象的运作机制、CoreDNS的解析细节、Headless Service的适用场景,以及我在实际集群里遇到过的五类故障案例。适合正在学习K8s的开发者,也适合集群里服务间调用总出问题、但一直没找到根因的运维和平台工程师。
1. 为什么K8s里不能直接用Pod IP做服务调用
1.1 Pod IP的宿命:谁在动它
新手最容易产生的误解,就是把Pod IP当成一台虚拟机或物理机的固定IP。K8s里完全不是这个逻辑。
Pod是K8s调度的最小单元,而调度器的工作原则是“哪里有资源,Pod就去哪”。节点故障、资源紧张触发的驱逐、Deployment滚动升级、副本数扩缩容,甚至有人手滑删了一个Pod,这些操作都会导致Pod被销毁重建。每次重建,Pod都会从节点所在的网段里重新申请IP,新IP和老IP大概率不相等。
就算没有任何操作,Pod也未必安全。节点上的kubelet定期对Pod做健康检查,如果容器探针失败,会杀掉容器重启;如果整个节点NotReady,控制平面会把Pod重新调度到其他节点。也就是说,K8s的Pod默认就不可信,它是“短暂且可替换的”。
1.2 动态IP时代,稳定的入口才是一切
用Pod IP做服务间调用,会面临三个具体问题。
第一是配置无法收敛。微服务架构里面少则十几个服务,多则上百个。每个服务可能有好几个副本,你不可能把十几个甚至上百个Pod IP一个一个写进其他服务的配置文件。就算用配置中心管理,Pod一变你就得跟着改,配置变更频繁到让人崩溃。
第二是客户端侧的负载均衡没人管。一个Service后端有5个Pod,客户端总得有个办法把请求分散到这5个Pod上去。如果直接用Pod IP,客户端就得自己实现一套负载均衡逻辑。每个语言一套实现,每个团队维护一套,完全是重复造轮子。
第三是健康检查机制缺失。Pod IP列表里如果有一个Pod已经挂了,调用方根本不知道。没有一层抽象来感知后端的存活状态,请求就会持续打到故障Pod上,造成大量超时和报错。
1.3 K8s给出的解法:Service作为抽象层
K8s引入了Service资源,就是为了解决这一整类问题。
Service给一组Pod提供一个稳定的虚拟IP(ClusterIP)和稳定的DNS域名。客户端只需要记住这个域名或虚拟IP,后端Pod怎么漂移、怎么扩缩容、怎么重建,都是平台层面自动处理的。Service永远不变,后端的Pod列表则通过标签选择器动态维护。服务间的调用关系从“点对点Pod IP直连”变成了“面对稳定入口,后端自动切换”。
这套机制就是“服务发现”在K8s里的落地形态。它解决的核心问题,就是让调用方不需要预先知道服务实例在哪里、去哪找。
2. Service三件套:Selector、Endpoint、ClusterIP的协同链路
2.1 一个最常见的Service定义
看一个最简单的Service配置:
apiVersion: v1 kind: Service metadata: name: nginx-svc namespace: default spec: selector: app: nginx ports: - name: http port: 80 targetPort: 80 protocol: TCP type: ClusterIP这里有三处关键配置,很多人初次使用容易搞混。
selector是Service找Pod的规则。它根据Pod上的标签(labels)来匹配,比如app: nginx就会匹配所有带这个标签的Pod。port是Service对外暴露的端口,访问方用的是这个端口;targetPort是后端Pod容器实际监听的端口。也就是说,请求打到Service的80端口,最终会被转给Pod的80端口。如果后端容器监听的是8080,这里targetPort就要写成8080。
2.2 Endpoint是怎么被自动维护的
创建Service之后,K8s控制面里有一个名为Endpoint Controller的组件,会持续监听Service和Pod的变化。一旦发现Service的selector匹配到新的Pod(无论Pod新建、删除、IP变化),它就会自动更新Endpoint对象。
Endpoint对象长这样:
NAME ENDPOINTS AGE nginx-svc 10.244.1.23:80 5m每个符合selector条件的Pod IP都会出现在这里,端口对应targetPort。
这里要特别注意:Endpoint里没有IP,就说明Service没有匹配到任何Pod。这是服务间调用不通最常见的原因之一。检查排障顺序时,第一步永远是kubectl get endpoints。
较新版本的K8s已经把Endpoint升级为EndpointSlice,格式和API不同,但逻辑完全一致:动态维护后端Pod IP列表。
2.3 流量转发的最后一公里:kube-proxy
Service创建好了,Endpoint也维护了,接下来谁来真正把流量从Service的ClusterIP转发到后端Pod IP?
答案是每台工作节点上的kube-proxy组件。
kube-proxy通过API Server监听Service和Endpoint的变化,然后把转发规则写入当前节点的网络规则。具体有两种实现模式:iptables和ipvs。
以iptables模式为例,客户端访问10.96.1.10:80时,数据包进入内核网络栈,命中kube-proxy写入的KUBE-SVC-XXX链,再经过KUBE-SEP-XXX链,最终完成DNAT转换,目标地址变成某个Pod的IP和端口。整个转发过程全部在Linux内核里完成,不需要用户态进程介入,效率很高。
2.4 iptables和ipvs怎么选
我曾经在测试环境直接用了iptables的默认模式,后来服务多了才发现规则数量增长很快,性能开始不稳定。改成ipvs之后问题解决了。
两者对比如下:
| 维度 | iptables | ipvs |
|---|---|---|
| 规则匹配方式 | 链式遍历,规则数越多耗时越长 | 哈希表查找,规则规模对性能影响小 |
| 负载均衡策略 | 默认随机,策略有限 | 支持rr、wrr、lc、wlc、sh等 |
| 调试方式 | iptables-save直接看规则 | 需要ipvsadm查看 |
| 适用场景 | 小规模集群、简单拓扑 | 中大规模生产集群 |
如果集群规模不大,iptables够用。生产环境如果担心持久连接和负载均衡能力,优先切到ipvs。在kube-proxy启动参数里加--proxy-mode=ipvs即可,或者通过ConfigMap配置。
3. 用DNS做服务发现:CoreDNS与解析细节全解
3.1 先说说环境变量这种“老办法”
K8s早期的服务发现机制,是通过环境变量注入实现的。
新创建的Pod,会被自动注入当前namespace下所有Service的地址信息。比如你在default命名空间创建了一个名为nginx-svc的Service,那么之后创建的Pod里会自动多出这些环境变量:
NGINX_SVC_SERVICE_HOST=10.96.1.10 NGINX_SVC_SERVICE_PORT=80应用代码直接读环境变量,就能拿到Service地址。
这个机制看起来简单,但有一个很严重的坑:环境变量只在Pod创建的那一刻注入。如果Service在Pod创建之后才创建,或者Service被删除重建,Pod里的环境变量不会自动更新。应用只能拿到创建时的旧地址,一旦Service变化就只能重启Pod。
所以现在绝大多数集群都已经放弃环境变量方式,改用DNS。DNS的好处是实时解析,Service的IP变化都由CoreDNS自动维护,调用方完全感知不到。
3.2 CoreDNS与服务名的解析规则
K8s集群默认部署CoreDNS作为DNS解析服务,它通常运行在kube-system命名空间下,对应的Service叫kube-dns。每个Pod的/etc/resolv.conf都会指向这个DNS服务的ClusterIP。
Service创建后,CoreDNS会自动注册一条A记录,完整域名规则是:
<service名称>.<namespace>.svc.<cluster域名>默认集群域名是cluster.local,所以default命名空间下的nginx-svc对应完整域名是:
nginx-svc.default.svc.cluster.local在同一个namespace内部,可以直接用Service名访问:
curl http://nginx-svc:80但在跨 namespace 访问时,必须写完整域名:
curl http://nginx-svc.prod.svc.cluster.local:80在容器内部执行nslookup或者dig,可以直观看到解析过程:
$ nslookup nginx-svc.default.svc.cluster.local Server: 10.96.0.10 Name: nginx-svc.default.svc.cluster.local Address: 10.96.1.10解析出来的地址就是Service的ClusterIP。之后客户端连接这个ClusterIP,再走上一章的kube-proxy转发链路。
除了A记录,CoreDNS还会为带端口的Service生成SRV记录,格式为:
_<端口名>._<协议>.<service>.<namespace>.svc.<cluster域名>比如:
_http._tcp.nginx-svc.default.svc.cluster.localSRV记录配合工具如Consul等做服务发现时很有用,但在K8s原生的调用链路上,用到的人不多。大多数客户端只需要A记录就够了。
3.3 Headless Service:把“发现”交给调用方
普通Service用ClusterIP做稳定入口,转发逻辑全部由kube-proxy承担。但有些场景下,客户端希望直接拿到后端Pod的真实IP列表,自己决定连哪一台。
比如:
- 有状态应用(如MongoDB、Kafka、Elasticsearch),每个Pod都有自己的身份和状态,不能随意通过负载均衡转发。
- 需要自行实现灰度或流量策略的中间件。
这时候就用Headless Service。配置很简单:
apiVersion: v1 kind: Service metadata: name: mongo-svc namespace: default spec: clusterIP: None selector: app: mongo ports: - port: 27017 targetPort: 27017关键在于clusterIP: None。设置了None之后,K8s会创建一个不分配ClusterIP的Service。DNS解析会直接返回所有后端Pod的IP列表:
$ nslookup mongo-svc.default.svc.cluster.local Name: mongo-svc.default.svc.cluster.local Address: 10.244.1.23 Address: 10.244.2.15 Address: 10.244.3.31配合StatefulSet使用时,还会自动生成每个Pod独立的DNS域名,格式为:
<pod名称>.<service名称>.<namespace>.svc.<cluster域名>比如:
mongo-0.mongo-svc.default.svc.cluster.local客户端可以直接通过mongo-0.mongo-svc...连到指定的Pod。这对数据库集群搭建特别重要,因为每个节点需要知道其他节点的确切地址。
3.4 ExternalName:把集群外的服务也纳入发现
有些服务不在K8s集群里,比如遗留系统里的数据库,或者第三方API。K8s提供一个叫ExternalName的Service类型,可以把一个外部域名包装成集群内的Service。
apiVersion: v1 kind: Service metadata: name: legacy-db namespace: default spec: type: ExternalName externalName: db.legacy.internal创建之后,集群内的Pod可以直接通过legacy-db.default.svc.cluster.local访问外部数据库域名。CoreDNS返回的是外部域名的CNAME记录,不会经过kube-proxy转发,流量直接走外部域名解析。
这样做的好处是,应用代码不需要关心目标服务到底在集群内还是集群外,统一用Service名。哪天外部数据库迁到了集群里,只需要把这个Service改成普通ClusterIP类型,应用代码一行不用改。
3.5 ndots与搜索域:DNS解析性能的隐形杀手
这是我在实际项目里吃过大亏的地方。
K8s生成的/etc/resolv.conf默认长这样:
nameserver 10.96.0.10 search default.svc.cluster.local svc.cluster.local cluster.local options ndots:5ndots:5的意思是,域名中包含的点数少于5个时,解析器会先用search列表里的域名轮番拼接后查询,最后才查询原始域名。
访问集群内部Service名,这个机制没问题。但访问外部域名时问题就来了,比如访问api.example.com,它只有2个点,小于5,解析器就会先依次去拼:
- api.example.com.default.svc.cluster.local
- api.example.com.svc.cluster.local
- api.example.com.cluster.local
每次拼接都是一次DNS查询,这些查询大概率会失败超时。如果DNS响应慢,一个简单的外部域名解析可能拖到好几秒,应用表现就是“偶尔卡顿,过一会儿又恢复”。
解决办法有两个。第一是给Pod单独设置dnsConfig,把ndots改成2:
spec: dnsConfig: options: - name: ndots value: "2"第二是调整search列表,减少不必要的搜索域。我个人建议所有需要大量访问集群外部域名的服务,都显式配置ndots:2。集群内服务调用不受影响,外部域名解析也能快很多。
4. 服务发现故障排查:从解析失败到流量转发异常的实战链路
4.1 现象一:服务名根本解析不出来
这是服务间调用最常见的问题之一。表现为应用日志里报错,类似could not resolve host: nginx-svc。
排查链路如下:
先确认Service是否存在:
kubectl get svc -A | grep nginx-svc如果Service不存在,回查是不是部署文件没apply成功,或者Service被删了。
Service存在,解析还是失败,就要看Pod的DNS配置和CoreDNS状态。
确认CoreDNS Pod是否正常:
kubectl get pods -n kube-system -l k8s-app=kube-dns然后进入一个Pod,手动用nslookup测试:
kubectl exec -it <pod名称> -- nslookup nginx-svc.default.svc.cluster.local如果测试报错,看CoreDNS日志:
kubectl logs -n kube-system -l k8s-app=kube-dns --tail=100还有一种情况是Pod的dnsPolicy被改过。默认是ClusterFirst,表示集群内解析走CoreDNS;如果被改成Default,则直接使用宿主机DNS,解析不到集群内的Service名。配置回ClusterFirst即可。
4.2 现象二:解析成功但Endpoints为空
Service存在,域名也能解析出ClusterIP,但请求就是超时。这时候重点检查Endpoint列表:
kubectl get endpoints nginx-svc如果显示ENDPOINTS列为空,说明selector没有匹配到任何Pod。
常见原因有两个。
一个是标签没对齐。Service的selector是app: nginx,但Pod的labels写的却是app: nginx-web。逐个检查Pod标签:
kubectl get pods --show-labels另一个是Pod没有Ready。K8s只把处于Ready状态的Pod加入Endpoint列表。查看Pod状态:
kubectl get pods | grep nginx如果是ImagePullBackOff或者CrashLoopBackOff,先把Pod本身的问题解决了,Endpoint自然就有值了。
4.3 现象三:转发规则或网络策略导致不通
Endpoints有IP,但从应用里访问Service就是不通。这时候就需要分两层排查。
先绕过Service直接访问Pod IP,确认Pod本身及网络插件是否正常:
curl http://<某个Pod的IP>:80Pod IP能通而Service不亮,问题大概率出在kube-proxy或网络策略层面。
确认kube-proxy运行状态:
kubectl get pods -n kube-system | grep kube-proxy查看iptables模式下的规则是否生成:
iptables-save | grep KUBE-SVCipvs模式下则用:
ipvsadm -Ln | grep 10.96.1.10如果规则正常,再看是否有NetworkPolicy拦截了流量。K8s默认允许所有Pod互访,但一旦有NetworkPolicy定义,未放行的流量会被丢弃。检查命名空间下是否有生效策略:
kubectl get netpol -A曾经在一个生产环境里,服务端Pod的namespace下有一条NetworkPolicy只放行了来自特定标签Pod的流量,新上线的服务没打对应标签,结果所有请求全被丢弃,排查了一整天才发现是策略问题。
4.4 现象四:环境变量注入带来的历史遗留问题
之前遇到过一个问题:某个服务明明ConfigMap里已经改了新的Service地址,但应用运行一段时间后突然连不上数据库。查了应用日志,发现它一直连的是旧地址。
追根溯源,是因为这个服务通过环境变量方式使用Service地址,而环境变量在Pod创建的时候就固化了。Service重建之后IP变了,环境变量却还指向旧IP,直到Pod重启才会刷新。
这类问题在DNS机制普及后很少出现,但老集群里还有存量应用在跑。排查时看应用日志里报错地址,和当前Service的ClusterIP做对比,如果对不上,多半就是环境变量污染。
这种场景下没有太好的实时修复手段,只能重启Pod让它重新注入。根治方案还是迁到DNS解析。
5. 服务发现的集群边界:从NodePort到Ingress再到服务网格
5.1 南北向流量也需要“服务发现”
以上讲的服务发现解决的是集群内服务如何互访,也就是“东西向流量”。但业务系统都免不了要接收外部访问,比如浏览器打开页面、App调用接口。这部分流量叫“南北向流量”。
K8s对南北向流量的处理,同样是围绕Service展开的。Service的type字段可以配置成NodePort或LoadBalancer,把ClusterIP映射到节点端口或云厂商负载均衡器上,外部流量就能进入集群访问到Service。
NodePort方式最直接:每个节点开放一个高位端口(默认30000-32767),外部访问节点IP:端口进入集群,然后被转发到对应的Service上。但这种方式在云环境下很不优雅,因为需要知道节点IP,节点变化后入口也会变。
LoadBalancer则更适合云环境。云厂商会在你创建LoadBalancer类型Service时自动创建一个负载均衡器,把外部流量引到集群内的Service上。对使用者来说,入口是一个固定的负载均衡器地址,背后Service的变化完全透明。
5.2 Ingress如何衔接内部Service
如果外部访问的域名和路径很多,每个服务都单独搞一个LoadBalancer成本太高。此时一般用Ingress。
Ingress是K8s里的一个API对象,定义了外部HTTP/S请求如何路由到集群内的Service。它本身不承载流量,真正干活的是Ingress Controller,比如nginx-ingress、traefik。Controller根据Ingress规则做请求转发:
请求 api.example.com -> Ingress Controller -> backend-service:8080Ingress规则示例:
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: example-ingress spec: rules: - host: api.example.com http: paths: - path: /v1 pathType: Prefix backend: service: name: backend-svc port: number: 8080Ingress Controller部署在集群里,本身也是一个或一组Pod,对外暴露为一个LoadBalancer类型的Service。整个链路是:
外部流量 -> 云负载均衡器 -> Ingress Controller Pod -> 集群内Service -> 后端Pod
5.3 服务发现的下一个形态:服务网格转发决策
服务网格(比如Istio)进一步把服务发现和流量管理做深了一层。
在服务网格架构里,每个应用Pod会多一个Sidecar代理容器。应用发出的请求不再直接走kube-proxy的iptables规则,而是被劫持到Sidecar。Sidecar从控制平面拿到全量服务注册信息和转发规则,自己决定请求该发给哪个Pod。
这样做的好处是,转发决策从“网络层规则”上升到了“应用层路由”。你可以做精细的按权重灰度、按header分流、故障自动重试等操作,这些用原生Service是很难实现的。
但对大多数中小型集群来说,K8s原生的Service发现已经完全够用。服务网格引入之后,运维复杂度和资源开销都会明显上升,属于“能力过剩”的选择。如果只是需要稳定的服务间互访,原生机制反而是最优解。
从Endpoints到EndpointSlice、从iptables到ipvs再到eBPF,K8s的服务发现机制一直在演进,但底层的设计思想没有变:让应用之间不关心彼此在哪里,只关心稳定入口,后端的变化交给平台消化。
整个链路跑通之后,我在实际项目中得到的最大体会是:服务间的互访配置,一定要用K8s DNS域名,千万不要图省事去写IP或环境变量。一旦规模上来,维修成本会指数级上升。排障顺序也慢慢形成了一套肌肉记忆:先看Service是否存在,再看Endpoint有没有IP,紧接着验证DNS解析,最后查kube-proxy规则和NetworkPolicy。
另外一个小技巧,新建Pod后习惯性先去看一眼/etc/resolv.conf,确认nameserver指向正确、search和ndots符合预期。很多间歇性超时问题,根源都藏在这个文件里。K8s服务发现这东西,出了故障不可怕,怕的是没有一套固定的排查顺序。把这套链路记熟了,出了问题基本都能快速定位。