✒️摘要在容器里,只有 PID = 1 的进程能收到 K8s 发来的 SIGTERM 信号,且它必须自己主动处理(内核对 PID 1 的信号有特殊对待)。如果你的应用不是 PID 1(比如被 shell 包裹),或者 PID 1 没写信号处理逻辑,那么优雅关停就无从谈起——所有的 @PreDestroySmartLifecycleshutdown hook 都不会被触发。



一、背景:从"日志有时不打印"说起

在实际生产中遇到过这样的现象:

Pod 更新镜像或服务重启时,@PreDestroy 等JVM销毁前要执行的 hook 里的相关日志没有被打印

第一反应是"是不是 Spring 生命周期出了问题",因为@PreDestroy是一个固定的Spring线程在串行执行,如果总耗时太久,被强制kill,那有部分@PreDestroy没有被执行,倒也可能。但排查完发现 Spring 层面没错——问题出在更底层:容器要销毁时,SIGTERM信号根本没传到 Java 进程

这就引出了本文的核心话题:容器环境下的信号传递机制。要搞懂这个问题,需要先理解三件事:

  1. Linux 信号是什么? SIGTERM 和 SIGKILL 的区别
  2. PID 1 有什么特殊? 为什么内核对它区别对待
  3. 容器里的 PID 1 到底是谁? 你以为的 PID 1 可能不是真的 PID 1

排查后

排查后

✅ 中招

现象: PreDestroy 日志时有时无

根因分析

Spring 层: 生命周期错乱?

JVM 层: shutdown hook 没触发?

容器层: 信号根本没到进程?

❌ Spring 层正常

❌ JVM 层正常

PID 1 问题
信号被吞 / 进程屏蔽


二、Linux 信号机制回顾

2.1 什么是信号

信号(Signal) 是 Linux 内核提供的进程间异步通信机制。一个进程可以向另一个进程发送信号,接收方进程会被打断当前执行流,转而处理这个信号。

常见发送方式

  • kill -TERM <pid> 命令行发信号
  • kill(pid, SIGTERM) 系统调用
  • 键盘 Ctrl+C 发送 SIGINT
  • 内核在特定事件下发送(如段错误 SIGSEGV)

2.2 常见信号对照

信号名编号含义是否可捕获默认行为
SIGTERM15优雅终止请求✅ 可捕获终止进程
SIGKILL9强制杀死不可捕获立即终止
SIGINT2中断(Ctrl+C)✅ 可捕获终止进程
SIGHUP1挂断(终端关闭)✅ 可捕获终止进程
SIGQUIT3退出并 dump✅ 可捕获终止 + core dump
SIGSTOP19暂停❌ 不可捕获暂停进程

关键区别

SIGTERM

SIGKILL

绕过进程

发送方

进程

是否注册了
信号处理器?

执行自定义逻辑
比如: 关连接、刷日志

执行默认行为: 终止

发送方

内核

直接强制终止
进程完全无感知

2.3 SIGTERM vs SIGKILL

这是优雅关停的核心

维度SIGTERM (15)SIGKILL (9)
是否可拦截✅ 可以注册 handler❌ 内核强制执行
是否可延迟✅ handler 可以慢慢跑❌ 立即终止
应用能感知吗✅ 能,可以清理资源❌ 完全不知道自己要死了
K8s 何时发优雅关停第一步grace period 超时后
数据丢失风险低(可控)高(连接中断、事务未提交)

核心口诀

SIGTERM 是"求你走",SIGKILL 是"直接毙"。
应用能捕获 SIGTERM,做清理;捕获不了 SIGKILL,一枪毙命。

2.4 JVM 对信号的处理

Java 进程默认注册的信号处理器

  • SIGTERM / SIGINT / SIGHUP → 触发 JVM shutdown hook
    • 执行所有 Runtime.getRuntime().addShutdownHook(...) 注册的钩子
    • Spring Boot 的 SpringApplicationShutdownHook 就是通过这个机制触发
    • 依次触发 SmartLifecycle.stop()@PreDestroyDisposableBean.destroy()
  • SIGKILL → JVM 完全无感知,直接被内核干掉
应用代码 Spring 容器 JVM Linux 内核 应用代码 Spring 容器 JVM Linux 内核 ---------- 或者 ---------- 内核直接终止 JVM 和 App 都无感知 SIGTERM 触发 shutdown hook SpringApplicationShutdownHook SmartLifecycle.stop() @PreDestroy DisposableBean.destroy() 关停完成 进程退出 (exit 0) SIGKILL 无任何清理

三、PID 1 的特殊性(内核视角)

3.1 PID 1 是什么

在 Linux 系统中,PID 1 是所有用户态进程的祖先,通常是 init 系统(systemd / SysVinit / OpenRC 等)。它由内核在启动最后阶段创建,其他所有进程都是它的后代。

PID 1 的三大职责

PID 1

信号处理

默认屏蔽所有信号

必须显式注册 handler 才响应

孤儿进程收养

父进程死了的孤儿会被 PID 1 收养

成为其子进程

僵尸进程回收

子进程死后必须调 wait 系统调用,否则变僵尸

PID 1 负责收尸

3.2 内核对 PID 1 的特殊对待

这是本文最关键的一点

Linux 内核代码里明确规定:对于 PID 1,除非它主动注册了对应信号的 handler,否则内核会忽略所有信号(包括 SIGTERM、SIGINT)。

这是内核层面的保护机制——防止 init 进程被意外杀死导致系统崩溃。

内核源码位置kernel/signal.c 中的 sig_task_ignored() 函数会判断:

  • 如果目标进程是 PID 1
  • 且该信号没有注册自定义 handler
  • 那么直接丢弃这个信号

是 PID 1 且未注册 handler

注册了 handler

不是 PID 1

外部发送 SIGTERM 给 PID 1

内核检查
sig_task_ignored

❌ 信号被内核丢弃
进程无感知

✅ 传递信号
触发 handler

✅ 走默认逻辑
终止进程

3.3 这意味着什么

如果你的容器 PID 1 是一个"没写信号处理逻辑"的进程,K8s 发的 SIGTERM 会被内核吞掉,进程根本不会退出,直到 SIGKILL(强制)。

具体到 Java:

  • JVM 本身默认注册了 SIGTERM handler(触发 shutdown hook)
  • ✅ 所以 Java 进程作为 PID 1 时,能收到 SIGTERM 并优雅关停
  • ❌ 但如果 Java 被 sh -c "java ..." 包裹,PID 1 是 sh 而不是 java
  • sh 默认不转发信号给子进程,SIGTERM 到了 sh 就丢了

这是容器里最常见的信号丢失陷阱


四、容器中 PID 1 是谁?

4.1 容器的 PID Namespace

容器本质上是受限的进程:Docker/K8s 通过 Linux Namespace 技术,让容器里的进程看到的是独立的 PID 编号空间

容器内视角

宿主机

同一个进程

PID 1: systemd

PID 5432: dockerd

PID 8765: java 进程

PID 1: java 进程
其实就是宿主机的 8765

关键点

  • 宿主机看:Java 进程 PID 是 8765
  • 容器内看:同一个 Java 进程 PID 是 1
  • 内核对 PID 1 的"特殊对待"规则,在容器 namespace 内同样生效

4.2 Dockerfile 中 CMD/ENTRYPOINT 的两种写法

决定 PID 1 是谁的关键:Dockerfile 的 CMD / ENTRYPOINT 写法。

写法 A:Shell 形式(陷阱)

CMD java -jar app.jar
# 或
ENTRYPOINT java -jar app.jar

实际执行:Docker 会包一层 /bin/sh -c "java -jar app.jar"

结果

❌ sh 不转发信号

容器启动

PID 1: /bin/sh -c 'java -jar app.jar'

PID 7: java -jar app.jar

K8s 发 SIGTERM

java 收不到
继续运行

30s 后 grace period 超时

SIGKILL 发到 PID 1

整个容器被强杀
Java 无优雅关停

写法 B:Exec 形式(正确)

CMD ["java", "-jar", "app.jar"]
# 或
CMD exec java -jar app.jar
# 或
ENTRYPOINT ["java", "-jar", "app.jar"]

实际执行:Docker 直接 exec 启动 java 进程,不经过 shell

结果

✅ JVM 注册了 handler

容器启动

PID 1: java -jar app.jar

K8s 发 SIGTERM

触发 shutdown hook

Spring 优雅关停

进程正常退出 exit 0

4.3 判别口诀

数组形式(JSON 数组)= exec,没有 shell 中间人,信号直达。
字符串形式 = shell,有中间人,信号可能丢。

写法PID 1SIGTERM 传递优雅关停
CMD java -jar app.jar/bin/sh❌ 被 sh 吞掉❌ 失败
CMD exec java -jar app.jarjava✅ 直达 JVM✅ 成功
CMD ["java", "-jar", "app.jar"]java✅ 直达 JVM✅ 成功
CMD ["sh", "-c", "java -jar app.jar"]sh❌ 被 sh 吞掉❌ 失败
ENTRYPOINT ["./entrypoint.sh"] + 脚本内 exec java ...最终是 java✅ 通过 exec 替换✅ 成功

五、K8s SIGTERM 的完整传递链路

5.1 完整链路图

Spring 应用 容器 PID 1 (java) Linux 内核 Runc CRI (containerd / dockerd) Node Kubelet K8s API Server kubectl / Deployment 更新 Spring 应用 容器 PID 1 (java) Linux 内核 Runc CRI (containerd / dockerd) Node Kubelet K8s API Server kubectl / Deployment 更新 若 terminationGracePeriodSeconds 超时 Kubelet 会发 SIGKILL 强制终止 删除 Pod / 滚动更新 1 Pod 状态变为 Terminating 2 执行 preStop hook (若配置) 3 StopContainer 请求 4 停止容器 5 kill -TERM <容器 PID> 6 SIGTERM (通过 PID namespace) 7 JVM shutdown hook 触发 8 SmartLifecycle.stop() 9 @PreDestroy 10 DisposableBean.destroy() 11 关停完成 12 进程退出 (exit 0) 13 容器已停止 14 上报 15 Pod Terminated 16

5.2 时间预算

默认 terminationGracePeriodSeconds = 30s 的完整时间轴:

00 05 10 15 20 25 30 Pod 标记 Terminating preStop hook (可选) SIGTERM 发出 应用优雅关停 grace period 到期 SIGKILL 发出 前置阶段 优雅关停 强制终止 Pod 终止时间预算 (grace=30s)

5.3 preStop hook 的作用

在这里插入图片描述

preStop hookK8s 提供的一种机制,在发送 SIGTERM 之前执行一段自定义逻辑(脚本或 HTTP 请求)。

典型用途

  • 提前从负载均衡器摘流(比如设置 /health 返回 503)
  • Sleep 一段时间,让上游 SDK 缓存刷新
  • 主动通知外部系统"我要下线了"

YAML 示例

lifecycle:
  preStop:
    exec:
      command: ["sh", "-c", "sleep 15"]

前面提到,既然 preStop 是在发送SIGTERM信号之前执行的,那它也是一种“绕开”信号问题的一种思路,因为preStop是K8s提供的机制。前面提到的一些销毁前的hook,因为PID问题收不到信号没有执行的,也可以这样实现:

在这里插入图片描述

关键点

  • preStop 执行不占用 terminationGracePeriodSeconds 的总预算吗?
    → ⚠️ 占用! preStop + SIGTERM 后的应用清理,加起来不能超过 grace period
  • preStop 里的 sleep 是串行阻塞的,会推迟 SIGTERM 的发送时机

六、常见陷阱与解决方案

陷阱 1:shell 包裹 Java,信号被吞

症状:Pod 删除时,Java 应用没有任何"优雅关停"日志,直接被 SIGKILL 干掉。健康的日志时序

14:00:00.000 [SpringApplicationShutdownHook] Starting graceful shutdown
14:00:00.100 [SpringApplicationShutdownHook] SmartLifecycle stop, phase=MAX
14:00:20.150 [SpringApplicationShutdownHook] Deregister delay finished
14:00:20.200 [SpringApplicationShutdownHook] Tomcat graceful shutdown
14:00:20.500 [SpringApplicationShutdownHook] @PreDestroy 执行完成
14:00:20.800 [main] JVM exit 0

病态的日志时序(信号丢失):

14:00:00.000 [http-nio-9080] ...(业务日志)...
(30 秒后被 SIGKILL 强杀,没有任何 shutdown 日志)

诊断kubectl exec pod -- ps -ef 查看 PID 1 是谁:

PID   USER   COMMAND
1     root   /bin/sh -c java -jar app.jar     ← ❌ PID 1 是 sh
7     root   java -jar app.jar                 ← Java 是 PID 7

解决方案:改 Dockerfile 为 exec 形式:

# ❌ Bad
CMD java -jar app.jar

# ✅ Good
CMD ["java", "-jar", "app.jar"]

exec 的作用不 fork 子进程,而是用新程序替换当前进程,PID 保持不变。

使用 exec (Good)

exec 替换

PID 1: shell 脚本

PID 1: java

不用 exec (Bad)

PID 1: shell 脚本

PID 7: java (子进程)

陷阱 2:忘了配 terminationGracePeriodSeconds

症状:优雅关停做了一半就被 SIGKILL。

原因:K8s 默认只给 30 秒。如果你的应用清理逻辑需要更久(比如上游 SDK 缓存刷新等 20 秒),30 秒可能不够。

解决方案:显式配置:

spec:
  terminationGracePeriodSeconds: 60   # 根据实际需要调整

陷阱 3:Java 是 PID 1,僵尸进程堆积

症状:Java 进程作为 PID 1 长期运行后,ps -ef 里出现大量 <defunct> 僵尸进程。

原因:PID 1 有收养孤儿 + 回收僵尸的责任。JVM 的 Runtime.exec() 或调用外部脚本时,若子进程结束但 Java 没调 wait(),僵尸就堆积起来。

解决方案:加一层专门的 init 进程(下一节详述)。


七、init 进程(tini / dumb-init)的作用

7.1 为什么需要 init 进程

如果 Java 作为 PID 1,会有两个问题

  1. 僵尸进程:JVM 不擅长处理僵尸子进程回收
  2. 信号语义微妙:某些场景下 JVM 对信号的处理不够"init 化"

解决方案:在 Java 之前加一层轻量级 init,专门做这两件事。

7.2 常见 init 工具

工具大小特点
tini~10 KB最流行,Docker 官方推荐,--init 参数内置的就是它
dumb-init~20 KBYelp 出品,功能类似 tini
s6-overlay~1 MB更完整的进程管理套件

7.3 tini 的工作原理

有 tini

父进程

PID 1: tini

PID 2: java

自动 wait() 回收僵尸

转发 SIGTERM 给 java

无 tini

PID 1: java

僵尸子进程堆积

tini 做的两件事

  1. 信号转发:收到的所有信号(包括 SIGTERM)转发给子进程 java
  2. 僵尸回收:定期调 waitpid() 回收所有已结束的子进程

7.4 如何启用

方式 A:Docker 命令行(一次性):

docker run --init your-image

方式 B:Dockerfile 显式安装

FROM openjdk:17
RUN apt-get update && apt-get install -y tini
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["java", "-jar", "app.jar"]

方式 C:K8s Pod Spec(如果 CRI 支持):
无直接字段,通常在镜像里内置。

7.5 什么时候需要 init 进程

场景需要 init 吗
纯 Java 应用,不调用外部命令❌ 不太需要(JVM 能收 SIGTERM)
Java 里频繁 Runtime.exec()✅ 需要(避免僵尸)
启动脚本里做初始化再启 java⚠️ 用 exec 替换即可,也可用 init
多进程容器(不推荐)✅ 必须

八、常见误区

误区 1:K8s 会等应用"处理完"才发 SIGKILL

❌ 错误理解:以为 K8s 会"聪明地"等应用真的关停完再决定是否发 SIGKILL。

✅ 正确理解:K8s 只看时间,不看进程状态。

  • T=0s: 发 SIGTERM
  • T=grace_period 秒: 无条件发 SIGKILL

推论terminationGracePeriodSeconds 必须大于应用最坏关停时间。

误区 2:SIGTERM 一定能被应用收到

❌ 错误理解:K8s 发了 SIGTERM,应用就一定能收到。

✅ 正确理解:SIGTERM 发给容器的 PID 1,如果 PID 1 是:

  • shell 脚本 → 大概率吞掉(shell 默认不转发)
  • 应用本身 → ✅ 能收到
  • init 进程(tini) → ✅ 会转发给应用

误区 3:SIGKILL 也能被应用捕获做点什么

❌ 错误理解:以为可以给 SIGKILL 注册处理器。

✅ 正确理解SIGKILL 和 SIGSTOP 是唯二不可捕获、不可屏蔽、不可忽略的信号。它们由内核直接执行,进程完全无感知。

误区 4:“我在代码里加了 shutdown hook 就万无一失”

❌ 错误理解:Spring 有 @PreDestroy,就一定会执行。

✅ 正确理解:需要信号能到 JVM 这个前提。如果 PID 1 是 shell 吞了信号,JVM 根本不知道要关停,shutdown hook 一辈子不会触发。

误区 5:docker stop 和 K8s 发信号一样

❌ 错误理解:以为两者行为完全一致。

✅ 正确理解

  • docker stop 默认发 SIGTERM,等 10 秒 再 SIGKILL(--time 可调)
  • K8s 发 SIGTERM,等 terminationGracePeriodSeconds(默认 30s)再 SIGKILL

数字不同,但机制相同

误区 6:PID 1 就是"第一个启动的进程"

❌ 错误理解:只是启动顺序的问题。

✅ 正确理解:PID 1 有内核层面的特殊语义

  • 信号默认屏蔽(除非注册 handler)
  • 承担孤儿收养和僵尸回收责任
  • 死掉会导致内核 panic(宿主机场景)或容器退出(容器场景)

九、参考资料

9.1 官方文档

9.2 内核与信号

9.3 init 进程


附录 A:完整检查清单

排查"信号丢失"类问题时的自检

  • 通过 kubectl exec -- ps -ef 确认 PID 1 是应用进程本身(不是 sh / bash)
  • Dockerfile 的 CMD / ENTRYPOINT 使用数组形式(exec 形式)
  • 启动脚本内部若有再启动 java,用了 exec java ...(不是 java ...
  • terminationGracePeriodSeconds 大于应用最坏关停时间
  • 应用日志中能看到 SpringApplicationShutdownHook 触发的证据
  • 若有大量子进程调用,考虑用 tini 作为 init(docker run --init 或镜像内置)

附录 B:一图速览

是 shell (sh/bash)

是应用本身 (java)

是 init 进程 (tini)

K8s 发送 SIGTERM

容器 PID 1
是谁?

❌ shell 默认吞信号
Java 收不到

✅ JVM 收到 SIGTERM

tini 转发信号

触发 shutdown hook

Spring 优雅关停

SmartLifecycle.stop()

@PreDestroy

进程正常退出 exit 0

等 grace period 超时

SIGKILL 强杀 exit 137


最后总结

优雅关停不是"应用层的事",而是"从 K8s → 容器 → 内核 → 进程"整条链路的事。

只要链路中任何一环出问题(shell 吞信号、PID 1 屏蔽信号、grace period 不够、init 缺失),应用层再完善的 @PreDestroySmartLifecycle 都是空谈。

优雅关停的第一前提:让 SIGTERM 能到达你的应用进程。

更多推荐