
一次 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`](https://github.com/jimyag/k8sdev/tree/6da31f8221e048b204208c2d37b060fa3e8c0630/service-production-practice)。

## 一次服务访问经过哪些层

客户端访问 `http://echo-server/hostname` 时，名称解析和数据包转发是两个阶段：

```mermaid
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` 是：

```text
search service-ops.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5
```

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

```text
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 差异的结果：

```text
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 文档](https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/) 给出了名称格式；search 顺序和实际查询行为仍由 Pod 内 resolver 配置与实现决定。

## 普通 Service 和 Headless Service 返回什么

实验部署两个后端：

```text
10.244.1.5
10.244.2.5
```

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

```text
echo-server   -> ['10.96.149.190']
echo-headless -> ['10.244.1.5', '10.244.2.5']
```

```mermaid
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`：

```text
service/empty-service   ClusterIP   10.96.79.176
endpointslice/empty-service-wbhnz   IPv4   <unset>   <unset>
```

DNS 仍然成功：

```text
empty-service -> ['10.96.79.176']
```

但 HTTP 请求失败：

```text
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 看起来并不空：

```text
endpointslice/wrong-port-btdww   IPv4   18080   10.244.1.5,10.244.2.5
```

请求仍然失败：

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

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

```text
Service port -> targetPort -> EndpointSlice port -> container listen socket
```

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

## 删除 Pod 时，Endpoint 如何变化

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

```text
session-affinity target=echo-server-64cf4cd6c8-7cvp9
```

服务端支持一个耗时 6 秒的 `/slow` 请求，并配置：

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

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

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

三个 condition 含义不同：

- `terminating=true`：对应 Pod 正在终止；
- `ready=false`：为兼容旧消费者，终止中的 Endpoint 不再被视为普通 ready Endpoint；
- `serving=true`：应用当前仍能处理流量，可供理解终止状态的消费者做排空决策。

官方 [EndpointSlice conditions](https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#conditions) 对这三个字段有明确说明。不要只看 `kubectl get endpointslice -o wide` 的地址列表；wide 输出会同时列出 ready 和 terminating 地址，必须读取 conditions 才知道状态。

## 排空不是“把 Pod 从 Endpoint 删掉”这么简单

实验时间线如下：

```mermaid
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：

```text
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 更新主动切断：

```text
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](https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination-flow) 还指出，termination grace period 的计时在 preStop 之前已经开始；`preStop: sleep 10` 会消耗 20 秒预算中的 10 秒，不是额外赠送 10 秒。

## rollout 完成不等于旧 Pod 已消失

慢请求结束后，Deployment 已经报告：

```text
deployment "echo-server" successfully rolled out
```

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

```text
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 才最终收敛为：

```text
10.244.2.5,10.244.1.6
```

`kubectl rollout status` 关注新 ReplicaSet 是否达到 Deployment 的可用条件，不承诺所有旧 Pod 的终止钩子都已完成。发布系统如果把“rollout complete”当作“旧版本连接已全部结束”，就会错误估计数据库连接、消息消费或长请求的重叠窗口。

## 一套从名字到进程的排障顺序

面对“Service 不通”，先逐层缩小范围，再进入对应的数据面查规则。

```mermaid
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

```bash
cat /etc/resolv.conf
getent hosts echo-server
nslookup echo-server.service-ops.svc.cluster.local.
```

同时检查 CoreDNS：

```bash
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

```bash
kubectl -n service-ops get service echo-server -o yaml
```

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

### 3. 展开 EndpointSlice conditions

```bash
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

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

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

### 5. 按实际数据面选工具

```bash
# 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](https://kubernetes.io/docs/tasks/debug/debug-application/debug-service/) 清单。它适合作为兜底，但现场仍应保留上述顺序：先证明上一层，再进入下一层。

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

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

```text
负载均衡器摘除延迟
+ 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` 与监听 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 可以分成三个层次：

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

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

