jimyag's Blog

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 是:

1
2
3
search service-ops.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5

客户端查询短名 echo-server 时,resolver 会结合 search 列表尝试域名,最终命中同一命名空间下的:

1
echo-server.service-ops.svc.cluster.local

因此这些名称的适用范围不同:

写法典型含义
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 差异的结果:

1
2
3
echo-server -> ['10.96.149.190']
echo-server.service-ops.svc.cluster.local -> gaierror: [Errno -5] Name has no usable address
echo-server.service-ops.svc.cluster.local. -> ['10.96.149.190']

这里的客户端是 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 返回什么

实验部署两个后端:

1
2
10.244.1.5
10.244.2.5

普通 Service echo-server 分配了 ClusterIP 10.96.149.190,Headless Service echo-headless 设置 clusterIP: None。同一客户端的解析结果是:

1
2
echo-server   -> ['10.96.149.190']
echo-headless -> ['10.244.1.5', '10.244.2.5']
  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:

1
2
service/empty-service   ClusterIP   10.96.79.176
endpointslice/empty-service-wbhnz   IPv4   <unset>   <unset>

DNS 仍然成功:

1
empty-service -> ['10.96.79.176']

但 HTTP 请求失败:

1
empty-service: URLError: <urlopen error [Errno 111] Connection refused>

普通 ClusterIP 的 DNS 记录来自 Service 对象,不要求它已有 Endpoint。这个结果把两类故障明确分开:

  • NXDOMAIN、超时或 resolver 错误:先查 DNS;
  • 能得到 ClusterIP,但连接拒绝或超时:继续查 EndpointSlice 和数据面。

Endpoint 存在也不代表端口正确

第二个故障注入 wrong-port 使用正确 selector,却故意把 targetPort 写成没有进程监听的 18080。它的 EndpointSlice 看起来并不空:

1
endpointslice/wrong-port-btdww   IPv4   18080   10.244.1.5,10.244.2.5

请求仍然失败:

1
wrong-port: URLError: <urlopen error [Errno 111] Connection refused>

EndpointSlice 控制器只负责把 Service 端口解析成 Endpoint 端口,不会替你确认容器内真有进程监听。因此排查时必须同时核对:

1
Service port -> targetPort -> EndpointSlice port -> container listen socket

命名端口能减少数字在多个对象间漂移,但名称指向错误或容器未监听时仍会失败。

删除 Pod 时,Endpoint 如何变化

实验 Service 开启 sessionAffinity: ClientIP,确保客户端先稳定命中同一个后端。初始目标是:

1
session-affinity target=echo-server-64cf4cd6c8-7cvp9

服务端支持一个耗时 6 秒的 /slow 请求,并配置:

1
2
3
4
5
6
7
8
9
# 给慢请求和 preStop 留出完成时间。
terminationGracePeriodSeconds: 20
containers:
  - name: server
    lifecycle:
      preStop:
        exec:
          # Endpoint 进入终止状态后,延迟向应用发送 SIGTERM。
          command: ["sh", "-c", "sleep 10"]

启动慢请求 1 秒后删除目标 Pod,EndpointSlice 很快出现:

1
pod=echo-server-64cf4cd6c8-7cvp9 ready=false serving=true terminating=true

三个 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:

1
2
3
4
5
6
echo-server-64cf4cd6c8-lbblr
echo-server-64cf4cd6c8-lbblr
echo-server-64cf4cd6c8-lbblr
echo-server-64cf4cd6c8-lbblr
echo-server-64cf4cd6c8-lbblr
echo-server-64cf4cd6c8-lbblr

已经发给旧 Pod 的慢请求没有被 Endpoint 更新主动切断:

1
2
3
slow-start pod=echo-server-64cf4cd6c8-7cvp9 seconds=6
slow-finished pod=echo-server-64cf4cd6c8-7cvp9 seconds=6
client=10.244.2.4 "GET /slow?seconds=6 HTTP/1.1" 200 -

这说明两个机制要同时成立:

  1. Endpoint 更新让新连接避开旧 Pod;
  2. 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 已经报告:

1
deployment "echo-server" successfully rolled out

但同一时刻旧 Pod 仍在 grace period:

1
2
3
echo-server-64cf4cd6c8-7cvp9   1/1   Terminating   10.244.1.5
echo-server-64cf4cd6c8-b99rt   1/1   Running       10.244.1.6
echo-server-64cf4cd6c8-lbblr   1/1   Running       10.244.2.5

等旧 Pod 真正删除后,Endpoint 才最终收敛为:

1
10.244.2.5,10.244.1.6

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

1
2
3
cat /etc/resolv.conf
getent hosts echo-server
nslookup echo-server.service-ops.svc.cluster.local.

同时检查 CoreDNS:

1
2
3
kubectl -n kube-system get pod -l k8s-app=kube-dns
kubectl -n kube-system logs deployment/coredns --tail=200
kubectl -n kube-system get configmap coredns -o yaml

2. 核对 Service,不要只看 ClusterIP

1
kubectl -n service-ops get service echo-server -o yaml

重点看 selector、port、targetPort、协议、internalTrafficPolicy、externalTrafficPolicy 和 sessionAffinity。

3. 展开 EndpointSlice conditions

1
2
kubectl -n service-ops get endpointslice \
  -l kubernetes.io/service-name=echo-server -o yaml

至少确认地址、端口、协议、ready、serving、terminating 和 nodeName。只运行旧式 kubectl get endpoints 容易丢掉终止状态等细节。

4. 绕过 Service 直连 Pod

1
2
# 从同一个故障客户端测试 Endpoint 地址。
curl -v --connect-timeout 2 http://10.244.1.6:8080/hostname

直连也失败,问题通常在应用监听、CNI、路由或策略;直连成功而 ClusterIP 失败,再进入节点数据面。

5. 按实际数据面选工具

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# kube-proxy 日志先确认真实模式。
kubectl -n kube-system logs daemonset/kube-proxy --tail=200 | \
  grep -E 'Using .* Proxier'

# iptables
iptables-save -t nat | grep -E 'KUBE-SVC|KUBE-SEP'

# nftables
nft list table ip kube-proxy

# IPVS
cat /proc/net/ip_vs

# Cilium
cilium-dbg service list
cilium-dbg bpf lb list

# Calico eBPF
calico-node -bpf nat dump

Kubernetes 官方也维护了一份 Debug Services 清单。它适合作为兜底,但现场仍应保留上述顺序:先证明上一层,再进入下一层。

生产配置要解决的不是一个数字

可靠排空需要把多个时间预算对齐:

1
2
3
4
5
负载均衡器摘除延迟
+ EndpointSlice / 数据面传播延迟
+ 最长可接受请求或连接时间
+ 应用收到 SIGTERM 后的关闭时间
< terminationGracePeriodSeconds

这个不等式只是设计起点。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 与监听 socketwrong-port:18080
发布时偶发失败terminating condition、grace period、应用关闭终止 Endpoint 时间线
新请求正常,旧长连接断开进程 SIGTERM 行为、preStop、客户端连接池慢请求是对照组,成功返回 200
Pod IP 可通,ClusterIP 不通kube-proxy/eBPF、conntrack、策略返回路径进入数据面排查

故障码本身不能唯一定位。例如 Connection refused 可能来自目标节点主动 REJECT,也可能来自 Pod 上无人监听;超时可能是丢包、NetworkPolicy、错误返回路径或应用没有响应。表格给的是调查起点,不是无需证据的结论。

把三篇文章连起来

理解 Service 可以分成三个层次:

  1. API 与发现:Service 提供稳定名称和虚拟地址,EndpointSlice 维护后端;
  2. 节点数据面:iptables、nftables、IPVS 或 eBPF 选择 Endpoint 并执行 NAT;
  3. 生产生命周期:DNS 缓存、readiness、终止 conditions、连接池和 grace period 决定变更期间是否平滑。

排障时也按这三个层次前进。先确认名字解析到了什么,再确认 Service 当前认为哪些 Endpoint 可用,最后用匹配当前数据面的工具检查节点。如果一开始就把所有问题归咎于 CoreDNS、kube-proxy 或 CNI,通常只会得到很多日志,却没有一条能证伪假设的证据链。

#Kubernetes #Service #DNS #EndpointSlice #故障排查