从 Pod 生命周期到优雅下线:Kubernetes 服务发现、路由与 SIGTERM

滚动发布时,一个 Pod 已经显示 Terminating,为什么仍然会收到请求?给 preStop 加上 sleep 15,是不是就实现了优雅关闭?同样放在容器里,为什么有的 Go 程序收到 SIGTERM 就退出,而没有处理信号的 Rust 程序可能一直等到被强杀?

这些问题涉及同一条链路:Pod 状态变化 → 后端地址更新 → 服务发现与路由收敛 → 连接排空 → 进程退出。链路上的组件各自异步工作,理解它们之间的时间差,才能解释发布时偶发的连接重置和请求失败。

本文以 Linux 容器、有 selector 的 Service,以及支持 EndpointSlice 终止状态的 Kubernetes 为背景;具体代理模式和特性应以集群版本、配置为准。正确术语是 Headless Service,不是 headerless Service。

1. Pod 的生命周期如何改变 Service 后端

Pod 的 Running 不等于可以接流量

Pod 的 status.phase 有五种取值:

Phase 含义
Pending Pod 已被接收,但调度或容器准备尚未完成
Running 已绑定节点,容器已创建,至少一个容器正在运行、启动或重启
Succeeded 所有容器成功终止,且不会再重启
Failed 所有容器已终止,且至少一个失败
Unknown 无法取得 Pod 状态,通常涉及与节点通信的问题

这不是必须逐项经过的状态机。kubectl get pod 展示的 CrashLoopBackOff、Terminating 也不是新的 phase。前者来自容器重启状态,后者通常表示 Pod 已有 deletionTimestamp。

能不能接业务流量,要继续看 Pod 的 Ready condition。readiness probe 失败通常使 Pod 不再作为正常后端,但不会因此重启容器;liveness probe 失败则可能触发容器重启。容器在原 Pod 内重启,也不等于控制器创建了新 Pod。新 Pod 有新的 UID,IP 也可能变化。Pod 生命周期官方说明

一句话区分 Endpoints 与 EndpointSlice

旧版 Endpoints 把一个 Service 的后端地址集中在一个对象里,EndpointSlice 则将后端拆成多个切片,并提供更完整的地址与状态信息,适合规模化更新和监听。

这里口头说的 endpoint 可以是一条后端地址,API 资源名则是复数 Endpoints;两者不要混淆。旧 Endpoints API 自 Kubernetes v1.33 起弃用,大规模场景还存在超过 1000 个后端时截断的问题,新实现应优先使用 discovery.k8s.io/v1 的 EndpointSlice。Service API 说明

从创建、就绪到终止

Service 的 selector 匹配 Pod 标签,控制器据此维护后端信息。常见过程如下,表中假设 publishNotReadyAddresses: false:

Pod 状态 旧 Endpoints 的典型表现 EndpointSlice 的典型表现
尚未获得 Pod IP 没有可发布的地址 没有可发布的地址
有 IP,但未 Ready 地址可出现在 notReadyAddresses ready: false,serving: false
Ready,未终止 地址进入 addresses ready: true,serving: true,terminating: false
被删除,仍能服务 正常控制器将其从后端集合移除 可暂时保留,ready: false,serving: true,terminating: true
终止且不能继续服务 不作为正常后端 serving: false,随后删除地址

EndpointSlice 的三个 condition 分别回答不同问题:

  • ready:是否应作为正常流量的候选后端。
  • serving:此刻是否仍具备服务能力,对于 Pod 后端对应 Pod Ready。
  • terminating:是否已开始终止,对于 Pod 后端对应设置了删除时间戳。

因此,后端地址仍在切片中,不代表它还应接收正常的新流量。终止中的 Pod 可以继续处理请求,同时退出正常后端集合。

publishNotReadyAddresses: true 是重要例外:控制器生成的 endpoint 会被视为 ready,不能再把 ready 当成真实业务健康状态。它常用于有状态应用的成员发现,不能直接照搬普通 Service 的摘流假设。EndpointSlice 条件定义

旧 Endpoints 的删除过滤逻辑也可直接查看 Endpoints controller 源码。

上述变化经过控制器协调,并非删除 Pod 的 API 调用返回时,各节点路由、DNS 缓存和客户端连接池就已经同时更新。

2. 普通 Service 与 Headless Service:谁选择 Pod

这里的“普通 Service”主要指分配了 ClusterIP 的 Service;ExternalName 是 DNS 别名机制,不属于下面的对比。

维度 有 ClusterIP 的 Service Headless Service
配置 自动分配或指定 ClusterIP clusterIP: None
服务名的 A/AAAA 记录 Service 的 ClusterIP,双栈可有两类地址 符合发布条件的后端地址集合
客户端连接目标 通常为 ClusterIP 客户端选中的 Pod IP
后端选择 Service 数据面完成 客户端或其代理完成
kube-proxy 为 Service VIP 配置转发 不为该 Service 建立 VIP 转发
常见用途 普通业务访问 成员发现、直连、客户端负载均衡

Headless Service 示例:

apiVersion: v1
kind: Service
metadata:
  name: api-headless
spec:
  clusterIP: None
  selector:
    app: api
  ports:
    - name: http
      port: 8080
      targetPort: 8080

普通 Service 的访问可以理解为:

api.default.svc.cluster.local → ClusterIP → 数据面选择 Pod

Headless Service 则是:

api-headless.default.svc.cluster.local → Pod IP 列表
                                      → 客户端选择一个 IP 并连接

这只是免去了该 Service 的 VIP 转发,Pod 之间仍依赖集群网络、路由和 NetworkPolicy。Headless Service 不会自动提供均匀负载均衡:客户端如果总选第一个地址,或者长期复用同一条连接,流量仍可能集中在少数 Pod 上。Service 与 Headless Service

方式一:通过 DNS 获取 Pod IP

对有 selector 的 Headless Service,DNS 按后端发布状态返回 A/AAAA 记录。默认要求后端就绪;启用 publishNotReadyAddresses 会改变这个条件。有合适 hostname/subdomain 配置的 Pod,还可以获得逐 Pod 的域名;StatefulSet 常借此提供稳定的成员身份。DNS 的 A/AAAA 记录只携带地址,命名端口还可以通过 SRV 记录发现。Kubernetes DNS 规则

Pod 很多时,首先遇到的是响应大小问题。

512 字节、EDNS 与 TCP 回退

传统 DNS over UDP 在不使用 EDNS 时,DNS 消息大小上限是 512 字节,不含 IP/UDP 头。它不是“最多 512 个 IP”。响应还要包含 DNS 头、问题区、记录元数据和域名等。

在记录名可压缩为两字节指针的理想条件下,一条 A 记录大约占 16 字节,一条 AAAA 记录大约占 28 字节,再加公共开销,所以 512 字节往往只能容纳几十条地址。准确数量随域名、记录类型及附加内容变化。

EDNS(0) 通过 OPT 伪记录让客户端声明自己能接收的 UDP 消息大小,例如 1232 或 4096 字节;它不会让 DNS 响应无限增大,也不是服务端保证一定能发送的大小。RFC 6891

1232 是常见的保守选择,来源于 IPv6 最小 MTU 1280 减去基本 IPv6 头 40 字节和 UDP 头 8 字节。把缓冲区增大到 4096,可能增加 IP 分片风险;丢掉一个分片就可能导致整个响应不可用,因此“放大 UDP 包”不总是更快。RFC 9715

当响应放不下时,服务器可设置 TC=1,表示响应被截断。支持该流程的 resolver 应通过 TCP 重试,DNS over TCP 用两字节长度字段分帧,单条 DNS 消息最大 65535 字节。TCP 是 DNS 协议的正常组成部分,网络策略应允许到 DNS 服务的 UDP/53 和 TCP/53。RFC 7766

但“UDP 超时”与“收到 TC 标记”不同:如果分片被丢弃,客户端可能只看到超时,不一定立即回退 TCP。旧 resolver 或定制客户端也可能没有正确实现回退。

可以在集群内有 dig 的环境中检查实际响应:

# 不使用 EDNS,并禁止 dig 自动改用 TCP,以观察 UDP 截断标记。
dig api-headless.default.svc.cluster.local. A +noedns +ignore

# 声明 UDP 接收大小;观察 flags、ANSWER 数量和 MSG SIZE。
dig api-headless.default.svc.cluster.local. A +bufsize=1232

# 显式验证 TCP 查询链路。
dig api-headless.default.svc.cluster.local. A +tcp

即使有 TCP,也不宜把一个规模无限增长的 Service 当作每次全量下载的 DNS 地址目录。

TTL 到期,不等于连接到期

CoreDNS kubernetes 插件默认 TTL 为 5 秒,但 Corefile 可以覆盖它,应以实际响应为准。CoreDNS 自己通过 API watch 更新服务信息,DNS 客户端看到的则是某次查询产生的快照。CoreDNS kubernetes 插件

实际链路可能有 CoreDNS cache、NodeLocal DNSCache、操作系统 resolver 和应用缓存。遵守 TTL 的缓存通常传递剩余 TTL,不能机械地把每一层 TTL 相加;最小缓存时间、负缓存和过期数据服务策略又可能改变观测结果。CoreDNS cache 插件

还有一个比 TTL 更关键的问题:TTL 约束 DNS 记录的缓存有效期,不约束已经建立的 TCP 连接。地址已从 DNS 中消失,客户端仍可能用已有 HTTP keep-alive、HTTP/2 或 gRPC 连接继续发送请求。应用若只在启动时解析一次,也不会因为 TTL 到期自动替换连接池。

因此,DNS 方案需要同时设计定期重新解析、连接失败后的刷新、地址选择,以及旧连接的停止复用策略。查询低 TTL 的 DNS,不等于拥有实时服务发现。

方式二:直接 list/watch EndpointSlice

客户端或代理也可以访问 Kubernetes API,使用标签选择器找到某个 Service 的所有切片:

kubectl get endpointslices -n default \
  -l kubernetes.io/service-name=api-headless -o yaml

kubectl get endpointslices -n default \
  -l kubernetes.io/service-name=api-headless --watch

生产客户端不能只“连上 watch 等事件”。它需要先取得完整快照,再从对应 resourceVersion 继续 watch;处理断线重连、对象删除,以及历史版本失效后的 410 Gone 与重新 list。可使用成熟客户端的 informer/cache 机制完成这些工作。Kubernetes API 的 list/watch 语义

更新本地后端池时,还要注意:

  1. 聚合同一 Service 的所有切片,正确处理地址族、端口和协议;切片更新过程中可能出现重复地址,应去重。
  2. 普通新流量选择 ready 且非 terminating 的后端;如果要支持终止阶段排空,应单独处理 serving/terminating。API 中 condition 可为空:ready 未知应按 true 解释,terminating 未知按 false 解释,serving 未知则回退参考 ready,不能把“字段未出现”一律当作 false。参见 EndpointSlice API 字段定义。
  3. 后端消失时停止为它建立新连接,并对已有连接实施排空策略,而不是立即粗暴关闭全部连接。
  4. 配置最小范围的 RBAC get/list/watch 权限,控制每个业务副本直接 watch API server 带来的连接数和事件开销。

这个方案能取得 DNS 不提供的端口、拓扑和终止状态,也绕过了客户端 DNS 缓存等待;但它仍有控制器延迟、watch 传输延迟和本地处理延迟,不是同步通知。EndpointSlice 管理与分布

DNS 与 watch 都是服务发现方式。两者最终都需要把“成员变化”落实到连接池行为上,而且 watch EndpointSlice 并不要求 Service 必须是 headless。

3. Service 的数据包究竟如何到达 Pod

kube-proxy + iptables

在 iptables 模式中,kube-proxy 监听 Service 和 EndpointSlice,再把转发规则写入内核。业务数据包不需要进入 kube-proxy 进程逐个转发。

典型的新 TCP 连接路径是:

客户端发往 ClusterIP:port
  → 节点上的 Service 匹配规则
  → 选择一个后端
  → DNAT 为 PodIP:targetPort
  → 通过集群网络送达 Pod

规则常能看到 KUBE-SERVICES、KUBE-SVC-*、KUBE-SEP-* 等链名,但这些是实现细节。没有亲和性等额外约束时,iptables 模式通常通过概率规则选择后端。

Linux conntrack 为连接记录 NAT 映射,同一 TCP 连接的后续报文沿用映射。这意味着负载均衡主要发生在建立连接时,而不是每个 HTTP 请求独立选择 Pod。后端摘除通常影响后续新连接,并不自动迁移已经建立的 TCP 连接。Service 代理机制

于是,一个客户端即使每秒发几千个请求,只要全部复用同一条 HTTP/2 连接,它们也可能始终进入同一个 Pod。

其他数据面与额外一跳

实现 主要机制
kube-proxy nftables 使用内核 nftables 规则和集合实现 Service 转发
kube-proxy IPVS 使用内核 IPVS 调度,并配合其他网络规则;是否采用取决于版本和部署
eBPF Service 实现 例如 Cilium 在 socket 或网络包处理路径上执行后端选择,可替代 kube-proxy
Ingress / Gateway / Service Mesh 代理可能直接连接 Pod,也可能连接 ClusterIP;可增加请求级路由和连接排空逻辑

前两者参见 Kubernetes 虚拟 IP 文档,eBPF 的具体路径参见 Cilium kube-proxy replacement。

对于外部流量,还可能经过云负载均衡器、NodePort 和 Ingress。排查摘流时间时,应画出实际链路,逐层确认谁维护后端列表、谁复用连接。

另外,“terminating 后端绝不会再接新流量”也不是通用保证。代理在没有可用非终止后端时,可能继续使用仍 serving 的终止后端;例如 externalTrafficPolicy: Local 场景下,kube-proxy 可以向本地仍能服务的终止后端转发,给外部负载均衡器更新健康状态留出时间。终止后端的转发语义

4. 用 preStop 推迟 SIGTERM,给摘流和在途请求留时间

终止过程有两条并行链路

以正常删除 Pod 为例,可以把过程画成下面两条线:

API server 为 Pod 设置 deletionTimestamp
  │
  ├─ kubelet:开始终止宽限期 → 执行 preStop → 发送停止信号 → 等待退出
  │                                                   └→ 超时后 SIGKILL
  │
  └─ 控制面:更新 EndpointSlice → 代理 / CoreDNS / watch 客户端更新
                                      └→ 路由、DNS 缓存、连接池逐渐收敛

两条线没有“所有路由都更新完,才发信号”的全局屏障。preStop 的作用是让对应容器在停止信号发出前继续运行一段时间,同时给另一条链路争取收敛时间。Pod 终止与 EndpointSlice 变化示例

通常停止信号是 SIGTERM,但镜像的 STOPSIGNAL,以及支持相关特性的集群中的容器 stopSignal 配置,可以改变它。这里按 SIGTERM 讨论。

最简单的等待方案

下面是 Pod template 的局部配置,适合合并到已有 Deployment 中:

spec:
  template:
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: api
          # 省略现有 image、ports、probes 等字段。
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 15"]

它要求镜像内存在 /bin/sh 和 sleep;distroless 镜像不能直接照抄。支持 lifecycle sleep action 的集群可以使用 kubelet 执行的等待动作,但应先核对目标集群版本。

这 15 秒里,应用尚未收到 kubelet 发出的 SIGTERM,仍可处理迟到的新连接和已有请求。随着后端更新传播,正常新连接逐步转向其他 Pod;已经开始的短请求有机会自然完成。

60 秒包含 preStop 的 15 秒,不是等完 15 秒再获得 60 秒。hook 返回后,应用大约只剩 45 秒,实际还要扣除处理开销。hook 超时的短暂兜底延长不能作为正常预算。容器生命周期 hook

等待可以改善关闭,但不能保证排空

如果应用收到 SIGTERM 就立刻退出,纯 sleep 方案能保护一部分短请求,但无法可靠覆盖这些情况:

  • 客户端 DNS 缓存尚未刷新,仍向旧 Pod IP 发起连接。
  • 连接池继续在原有连接上发送新请求。
  • HTTP/2 stream、WebSocket 或长任务持续时间超过等待窗口。
  • 终止后端回退策略仍在向这个 Pod 转发。

所以这里实现的是一种“延迟退出,让流量尽量自然排空”的效果。完整的优雅关闭需要应用配合:先减少新流量,再停止接受新工作,等待已接受的工作完成,最后释放资源并退出。

一个常见组合是:preStop 等待路由传播;收到 SIGTERM 后,应用关闭监听或进入协议级 draining;等待在途任务完成;超过应用自己的超时后结束。HTTP/2 可通过框架的 GOAWAY/drain 机制推动客户端重连,WebSocket 和后台任务则需要独立的结束协议。

也可以让 preStop 调用应用的管理接口进入 drain,再等待完成;但不要在路由尚未收敛时立即把所有迟到请求都拒绝掉,否则只是把连接重置换成了业务错误。

为参数留预算时,可以使用:

Pod 宽限期 > preStop 等待 + 应用关闭超时 + 余量

preStop 的等待时间应依据后端更新传播延迟、DNS 使用方式和客户端连接行为测量,而不是固定照抄 5 秒、15 秒。对纯 sleep 方案,若最后一个新请求在第 T 秒到达、请求最多运行 R 秒,至少要允许它运行到 T+R;如果新请求持续从旧连接进入,就不存在仅靠固定 sleep 能保证的排空时间。

OOM、节点突然掉电、强制删除等情况也不提供这条正常关闭链路的保证。

5. SIGTERM 到达进程后:Go、Rust 与 Linux PID 1

Go runtime 默认会处理 SIGTERM 并退出

普通 Go 可执行程序即使没有调用 os/signal,runtime 也已安装信号处理器。默认收到 SIGTERM 会结束进程,不会自动等待 goroutine,也不会自动完成 HTTP 请求或执行正常返回路径中的 defer。Go os/signal 默认行为

要执行应用级清理,需要显式接管信号。例如下面是已有 Go HTTP 服务中的关闭片段,srv 是已启动的 *http.Server,应在服务启动阶段尽早注册信号,并确保 main 等待关闭完成:

ctx, stop := signal.NotifyContext(context.Background(),
    os.Interrupt, syscall.SIGTERM)
defer stop()

<-ctx.Done()
stop() // 恢复信号默认行为,后续信号可再次触发退出。

// 不能用已取消的 ctx,否则 Shutdown 会立即遇到取消。
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
    log.Printf("HTTP shutdown: %v", err)
    _ = srv.Close()
}

Shutdown 会关闭监听器和空闲连接,并等待活跃连接回到空闲;它不会管理被 hijack 的连接,例如某些 WebSocket 实现。超时返回也不等于所有活跃连接已被强制关闭,是否随后 Close 应由应用策略决定。net/http Server.Shutdown

Rust 默认沿用操作系统的 SIGTERM 处置

普通 Rust 程序的标准库不会像 Go runtime 那样自动为 SIGTERM 安装退出处理器。若程序和依赖没有修改信号处置或屏蔽信号,普通 Linux 进程的 SIGTERM 默认动作是终止;这不会进行 Rust 的正常栈展开,也不会逐个执行 Drop。Rust Unix 初始化源码、Linux signal(7)

使用 Tokio 时,需要显式监听 SIGTERM;仅等待 tokio::signal::ctrl_c() 主要处理的是 SIGINT。下面是 Unix 下接入退出流程的片段,需要启用 Tokio 的 signal feature:

use tokio::signal::unix::{signal, SignalKind};

let mut term = signal(SignalKind::terminate())?;
term.recv().await;
// 通知 HTTP server 停止接收新工作,等待任务结束,再让 main 返回。

接收到信号只是开始:还需要把通知接入所用 HTTP 框架、任务追踪器和超时机制。Tokio 注册的信号处理会改变进程的默认行为,丢弃监听对象也不会自动恢复原始处置,因此不能注册后又不实现退出逻辑。Tokio Unix signal

Linux 为什么特殊保护 PID 1

PID namespace 的 PID 1 是该命名空间的 init 进程。Linux 对它有特殊信号规则:对于 SIGTERM 这类信号,如果它没有安装对应处理器,就不能期待内核按普通进程的默认终止动作杀死它。这是防止命名空间的 init 意外退出的保护。

该限制甚至适用于来自祖先 PID namespace 的普通信号;但祖先 namespace 发来的 SIGKILL/SIGSTOP 是例外,会被强制递送。因此,容器运行时仍可在宽限期结束后从外部强制终止容器中的 PID 1。这里讨论的是容器 PID namespace,不能把规则简单套用到宿主机 init。pid_namespaces(7)

结合语言运行时,就得到下面的典型差异,前提是信号确实发给业务进程,没有被屏蔽,也没有额外库改变处置:

程序 普通 PID 作为 PID namespace 的 PID 1
未显式接管信号的 Go 可执行程序 runtime 默认退出 runtime 已安装 handler,通常仍会退出
未安装 SIGTERM handler 的 Rust 程序 按系统默认动作终止 可能因 PID 1 保护而继续运行
已安装 handler 的 Go / Rust 程序 执行应用定义的逻辑 执行应用定义的逻辑

Go 的实现对此还有明确兜底:dieFromSignal 尝试按信号结束进程后,如果进程仍在运行,会调用 exit(128 + sig);源码注释直接提到了 PID 1 对默认终止信号的免疫。因此,“容器中 PID 1 一律忽略 SIGTERM”是错误的,关键在于谁安装了信号处理器,以及处理器做什么。Go runtime 的 dieFromSignal

最后还要确认实际入口进程。若通过 shell 启动业务程序,SIGTERM 可能只发给 shell,而 shell 没有转发给业务子进程。优先使用 exec 形式的入口:

ENTRYPOINT ["/app/server"]

需要启动脚本时,最后使用 exec /app/server 替换 shell;需要管理子进程时可由 init 程序负责转发信号和回收僵尸进程。启用了共享 PID namespace 时,业务进程也未必是 PID 1,应检查实际进程树。

发布时若仍有请求失败,可以沿着这条链路逐层定位:EndpointSlice 何时标记终止,代理何时停止选择该后端,客户端何时停止复用旧连接,SIGTERM 到达了哪个进程,以及应用何时完成最后一个请求。把这些时间点放在一起,才能判断应该调整 preStop、客户端连接策略,还是应用自己的关闭流程。