Kubernetes Service 生产实践:DNS、连接排空与故障排查
一次 Service 访问会依次经过客户端 resolver、CoreDNS、Service、EndpointSlice、节点数据面和 Pod 监听端口。排障困难往往来自这些层的状态并不同步。
其中任意一层都可能出现一种很有迷惑性的半成功:
- DNS 能解析,但 Service 没有后端;
- EndpointSlice 有 Pod IP,但
targetPort没有进程监听; - 新连接已经绕开终止中的 Pod,旧长连接却仍在里面执行;
- Deployment 显示 rollout 完成,旧 Pod 还处于 Terminating。
本文不从命令清单开始,而是先把 DNS、连接排空和故障定位放回一条完整时间线。实验运行在 m6 的三节点 kind v1.35.0 集群,Service 数据面是 kube-proxy iptables 模式;服务端和客户端都使用 python:3.13.7-alpine3.22。清单和采集脚本已提交到 k8sdev 6da31f8。
一次服务访问经过哪些层
客户端访问 http://echo-server/hostname 时,名称解析和数据包转发是两个阶段:
sequenceDiagram
participant App as Application
participant Resolver as Pod resolver
participant DNS as CoreDNS
participant Proxy as Service data plane
participant Endpoint as Backend Pod
App->>Resolver: resolve echo-server
Resolver->>DNS: DNS query
DNS-->>Resolver: ClusterIP
Resolver-->>App: 10.96.x.x
App->>Proxy: TCP to ClusterIP:80
Proxy->>Endpoint: DNAT to PodIP:8080
Endpoint-->>App: response
DNS 只负责把名字变成地址。拿到 ClusterIP 后,请求是否能到 Pod,取决于 EndpointSlice、端口、数据面、NetworkPolicy 和应用进程。看到 nslookup 成功,只能证明第一阶段成立。
DNS:短名为什么能解析
实验客户端的 /etc/resolv.conf 是:
| |
客户端查询短名 echo-server 时,resolver 会结合 search 列表尝试域名,最终命中同一命名空间下的:
| |
因此这些名称的适用范围不同:
| 写法 | 典型含义 |
|---|---|
echo-server | 当前命名空间的 Service |
echo-server.service-ops | 指定命名空间,仍会受 resolver 搜索行为影响 |
echo-server.service-ops.svc.cluster.local. | 以尾点结束的绝对 DNS 名称 |
集群域不一定永远是 cluster.local,排障时应读取 Pod 的 resolv.conf 和 CoreDNS Corefile,不要只凭经验拼接。
ndots 与尾点不是装饰
实验中出现了一个很适合说明 resolver 差异的结果:
| |
这里的客户端是 Alpine 3.22,使用 musl resolver;ndots:5 会让只有 4 个点且没有尾点的名称先按相对名称搜索。本次组合最终返回 Name has no usable address,加上尾点强制按绝对名称查询后成功。
这不是“Kubernetes FQDN 必须加尾点”的通用结论。glibc、musl、Go resolver、JVM DNS 缓存和应用自己的解析库可能表现不同。生产排障要在故障容器内部使用应用相同的运行时复现,不能只在节点上运行一次 dig。
Kubernetes 官方的 Pod 与 Service DNS 文档 给出了名称格式;search 顺序和实际查询行为仍由 Pod 内 resolver 配置与实现决定。
普通 Service 和 Headless Service 返回什么
实验部署两个后端:
| |
普通 Service echo-server 分配了 ClusterIP 10.96.149.190,Headless Service echo-headless 设置 clusterIP: None。同一客户端的解析结果是:
| |
flowchart LR
C[Client]
D[CoreDNS]
VIP[Normal Service<br/>ClusterIP]
P1[Pod IP 1]
P2[Pod IP 2]
H[Headless Service]
C --> D
D --> VIP
VIP --> P1
VIP --> P2
D --> H
H --> P1
H --> P2
普通 Service 把后端变化藏在稳定 VIP 后面,实际选择后端的是 kube-proxy 或 eBPF 数据面。Headless Service 没有 VIP,DNS 直接暴露 Endpoint 地址,客户端库要面对多地址、缓存和重试策略。
DNS 成功不代表有可用后端
为了把发现层和转发层拆开,实验创建了一个 selector 永远匹配不到 Pod 的 empty-service:
| |
DNS 仍然成功:
| |
但 HTTP 请求失败:
| |
普通 ClusterIP 的 DNS 记录来自 Service 对象,不要求它已有 Endpoint。这个结果把两类故障明确分开:
NXDOMAIN、超时或 resolver 错误:先查 DNS;- 能得到 ClusterIP,但连接拒绝或超时:继续查 EndpointSlice 和数据面。
Endpoint 存在也不代表端口正确
第二个故障注入 wrong-port 使用正确 selector,却故意把 targetPort 写成没有进程监听的 18080。它的 EndpointSlice 看起来并不空:
| |
请求仍然失败:
| |
EndpointSlice 控制器只负责把 Service 端口解析成 Endpoint 端口,不会替你确认容器内真有进程监听。因此排查时必须同时核对:
| |
命名端口能减少数字在多个对象间漂移,但名称指向错误或容器未监听时仍会失败。
删除 Pod 时,Endpoint 如何变化
实验 Service 开启 sessionAffinity: ClientIP,确保客户端先稳定命中同一个后端。初始目标是:
| |
服务端支持一个耗时 6 秒的 /slow 请求,并配置:
| |
启动慢请求 1 秒后删除目标 Pod,EndpointSlice 很快出现:
| |
三个 condition 含义不同:
terminating=true:对应 Pod 正在终止;ready=false:为兼容旧消费者,终止中的 Endpoint 不再被视为普通 ready Endpoint;serving=true:应用当前仍能处理流量,可供理解终止状态的消费者做排空决策。
官方 EndpointSlice conditions 对这三个字段有明确说明。不要只看 kubectl get endpointslice -o wide 的地址列表;wide 输出会同时列出 ready 和 terminating 地址,必须读取 conditions 才知道状态。
排空不是“把 Pod 从 Endpoint 删掉”这么简单
实验时间线如下:
sequenceDiagram
participant Client
participant Service
participant Old as Old Pod
participant Slice as EndpointSlice
participant New as Other Pod
Client->>Service: start /slow?seconds=6
Service->>Old: existing request
Note over Old: deletion requested
Slice-->>Service: ready=false, terminating=true
Client->>Service: six new connections
Service->>New: all new requests
Old-->>Client: slow request finishes with 200
Note over Old: preStop completes, then process exits
Slice-->>Service: old endpoint removed
六条新连接全部去了另一个 Pod:
| |
已经发给旧 Pod 的慢请求没有被 Endpoint 更新主动切断:
| |
这说明两个机制要同时成立:
- Endpoint 更新让新连接避开旧 Pod;
- Pod 在 grace period 内继续存活,让已进入的请求完成。
readiness 变化不会自动帮应用完成排空。若进程收到 SIGTERM 就立即退出,或 preStop 加上应用关闭时间超过 grace period,旧连接仍会失败。Kubernetes 的 Pod termination flow 还指出,termination grace period 的计时在 preStop 之前已经开始;preStop: sleep 10 会消耗 20 秒预算中的 10 秒,不是额外赠送 10 秒。
rollout 完成不等于旧 Pod 已消失
慢请求结束后,Deployment 已经报告:
| |
但同一时刻旧 Pod 仍在 grace period:
| |
等旧 Pod 真正删除后,Endpoint 才最终收敛为:
| |
kubectl rollout status 关注新 ReplicaSet 是否达到 Deployment 的可用条件,不承诺所有旧 Pod 的终止钩子都已完成。发布系统如果把“rollout complete”当作“旧版本连接已全部结束”,就会错误估计数据库连接、消息消费或长请求的重叠窗口。
一套从名字到进程的排障顺序
面对“Service 不通”,先逐层缩小范围,再进入对应的数据面查规则。
flowchart TB
A{名称能解析吗?}
A -->|否| B[检查 resolv.conf<br/>CoreDNS Pod 和日志]
A -->|是| C{解析结果符合 Service 类型吗?}
C -->|否| D[检查 Service 与 Headless 配置]
C -->|是| E{EndpointSlice 有 ready Endpoint 吗?}
E -->|否| F[检查 selector<br/>readiness 和 terminating]
E -->|是| G{端口和协议一致吗?}
G -->|否| H[检查 port targetPort<br/>Endpoint port 和监听 socket]
G -->|是| I{直连 Pod IP 是否成功?}
I -->|否| J[检查应用 CNI 路由<br/>NetworkPolicy 和 MTU]
I -->|是| K[检查 kube-proxy 或 eBPF<br/>conntrack 与返回路径]
1. 在故障 Pod 内查 DNS
| |
同时检查 CoreDNS:
| |
2. 核对 Service,不要只看 ClusterIP
| |
重点看 selector、port、targetPort、协议、internalTrafficPolicy、externalTrafficPolicy 和 sessionAffinity。
3. 展开 EndpointSlice conditions
| |
至少确认地址、端口、协议、ready、serving、terminating 和 nodeName。只运行旧式 kubectl get endpoints 容易丢掉终止状态等细节。
4. 绕过 Service 直连 Pod
| |
直连也失败,问题通常在应用监听、CNI、路由或策略;直连成功而 ClusterIP 失败,再进入节点数据面。
5. 按实际数据面选工具
| |
Kubernetes 官方也维护了一份 Debug Services 清单。它适合作为兜底,但现场仍应保留上述顺序:先证明上一层,再进入下一层。
生产配置要解决的不是一个数字
可靠排空需要把多个时间预算对齐:
| |
这个不等式只是设计起点。HTTP keep-alive、gRPC stream、WebSocket、数据库会话和消息消费者的“完成”含义不同;有些连接不能等待自然结束,需要应用主动停止接收新工作并设置最大存活时间。
上线前至少验证这些场景:
- readiness 失败后,新连接是否还会进入该 Pod;
- 删除 Pod 时,正在执行的最长请求能否完成;
- preStop 与应用 SIGTERM 处理合计是否小于 grace period;
- 客户端连接池是否会持续复用已经摘除后端的旧连接;
- Headless Service 客户端是否会重新解析并移除旧 Pod IP;
- 外部 LoadBalancer、Ingress 或 Gateway 的摘除速度是否慢于集群内 Endpoint 更新;
- rollout 报告完成时,是否仍有旧版本 Pod 或连接存在。
用症状反推最可能的层
| 症状 | 优先检查 | 本文实验对应证据 |
|---|---|---|
NXDOMAIN | 名称、命名空间、search、CoreDNS | 无;短名与绝对名可成功 |
| DNS 有 ClusterIP,连接拒绝 | EndpointSlice 是否为空 | empty-service |
| Endpoint 地址存在,连接拒绝 | targetPort 与监听 socket | wrong-port:18080 |
| 发布时偶发失败 | terminating condition、grace period、应用关闭 | 终止 Endpoint 时间线 |
| 新请求正常,旧长连接断开 | 进程 SIGTERM 行为、preStop、客户端连接池 | 慢请求是对照组,成功返回 200 |
| Pod IP 可通,ClusterIP 不通 | kube-proxy/eBPF、conntrack、策略返回路径 | 进入数据面排查 |
故障码本身不能唯一定位。例如 Connection refused 可能来自目标节点主动 REJECT,也可能来自 Pod 上无人监听;超时可能是丢包、NetworkPolicy、错误返回路径或应用没有响应。表格给的是调查起点,不是无需证据的结论。
把三篇文章连起来
理解 Service 可以分成三个层次:
- API 与发现:Service 提供稳定名称和虚拟地址,EndpointSlice 维护后端;
- 节点数据面:iptables、nftables、IPVS 或 eBPF 选择 Endpoint 并执行 NAT;
- 生产生命周期:DNS 缓存、readiness、终止 conditions、连接池和 grace period 决定变更期间是否平滑。
排障时也按这三个层次前进。先确认名字解析到了什么,再确认 Service 当前认为哪些 Endpoint 可用,最后用匹配当前数据面的工具检查节点。如果一开始就把所有问题归咎于 CoreDNS、kube-proxy 或 CNI,通常只会得到很多日志,却没有一条能证伪假设的证据链。