容器信号传递与 PID 1 进程分析
✒️摘要:在容器里,只有 PID = 1 的进程能收到 K8s 发来的 SIGTERM 信号,且它必须自己主动处理(内核对 PID 1 的信号有特殊对待)。如果你的应用不是 PID 1(比如被 shell 包裹),或者 PID 1 没写信号处理逻辑,那么优雅关停就无从谈起——所有的
@PreDestroy、SmartLifecycle、shutdown hook都不会被触发。
文章目录
一、背景:从"日志有时不打印"说起
在实际生产中遇到过这样的现象:
Pod 更新镜像或服务重启时,
@PreDestroy等JVM销毁前要执行的 hook 里的相关日志没有被打印
第一反应是"是不是 Spring 生命周期出了问题",因为@PreDestroy是一个固定的Spring线程在串行执行,如果总耗时太久,被强制kill,那有部分@PreDestroy没有被执行,倒也可能。但排查完发现 Spring 层面没错——问题出在更底层:容器要销毁时,SIGTERM信号根本没传到 Java 进程。
这就引出了本文的核心话题:容器环境下的信号传递机制。要搞懂这个问题,需要先理解三件事:
- Linux 信号是什么? SIGTERM 和 SIGKILL 的区别
- PID 1 有什么特殊? 为什么内核对它区别对待
- 容器里的 PID 1 到底是谁? 你以为的 PID 1 可能不是真的 PID 1
二、Linux 信号机制回顾
2.1 什么是信号
信号(Signal) 是 Linux 内核提供的进程间异步通信机制。一个进程可以向另一个进程发送信号,接收方进程会被打断当前执行流,转而处理这个信号。
常见发送方式:
kill -TERM <pid>命令行发信号kill(pid, SIGTERM)系统调用- 键盘 Ctrl+C 发送 SIGINT
- 内核在特定事件下发送(如段错误 SIGSEGV)
2.2 常见信号对照
| 信号名 | 编号 | 含义 | 是否可捕获 | 默认行为 |
|---|---|---|---|---|
| SIGTERM | 15 | 优雅终止请求 | ✅ 可捕获 | 终止进程 |
| SIGKILL | 9 | 强制杀死 | ❌ 不可捕获 | 立即终止 |
| SIGINT | 2 | 中断(Ctrl+C) | ✅ 可捕获 | 终止进程 |
| SIGHUP | 1 | 挂断(终端关闭) | ✅ 可捕获 | 终止进程 |
| SIGQUIT | 3 | 退出并 dump | ✅ 可捕获 | 终止 + core dump |
| SIGSTOP | 19 | 暂停 | ❌ 不可捕获 | 暂停进程 |
关键区别:
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()→@PreDestroy→DisposableBean.destroy()
- 执行所有
- SIGKILL → JVM 完全无感知,直接被内核干掉
三、PID 1 的特殊性(内核视角)
3.1 PID 1 是什么
在 Linux 系统中,PID 1 是所有用户态进程的祖先,通常是 init 系统(systemd / SysVinit / OpenRC 等)。它由内核在启动最后阶段创建,其他所有进程都是它的后代。
PID 1 的三大职责:
3.2 内核对 PID 1 的特殊对待
这是本文最关键的一点:
Linux 内核代码里明确规定:对于 PID 1,除非它主动注册了对应信号的 handler,否则内核会忽略所有信号(包括 SIGTERM、SIGINT)。
这是内核层面的保护机制——防止 init 进程被意外杀死导致系统崩溃。
内核源码位置:kernel/signal.c 中的 sig_task_ignored() 函数会判断:
- 如果目标进程是 PID 1
- 且该信号没有注册自定义 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 编号空间。
关键点:
- 宿主机看: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"
结果:
写法 B:Exec 形式(正确)
CMD ["java", "-jar", "app.jar"]
# 或
CMD exec java -jar app.jar
# 或
ENTRYPOINT ["java", "-jar", "app.jar"]
实际执行:Docker 直接 exec 启动 java 进程,不经过 shell
结果:
4.3 判别口诀
数组形式(JSON 数组)= exec,没有 shell 中间人,信号直达。
字符串形式 = shell,有中间人,信号可能丢。
| 写法 | PID 1 | SIGTERM 传递 | 优雅关停 |
|---|---|---|---|
CMD java -jar app.jar | /bin/sh | ❌ 被 sh 吞掉 | ❌ 失败 |
CMD exec java -jar app.jar | java | ✅ 直达 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 完整链路图
5.2 时间预算
默认 terminationGracePeriodSeconds = 30s 的完整时间轴:
5.3 preStop hook 的作用

preStop hook 是 K8s 提供的一种机制,在发送 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 保持不变。
陷阱 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,会有两个问题:
- 僵尸进程:JVM 不擅长处理僵尸子进程回收
- 信号语义微妙:某些场景下 JVM 对信号的处理不够"init 化"
解决方案:在 Java 之前加一层轻量级 init,专门做这两件事。
7.2 常见 init 工具
| 工具 | 大小 | 特点 |
|---|---|---|
| tini | ~10 KB | 最流行,Docker 官方推荐,--init 参数内置的就是它 |
| dumb-init | ~20 KB | Yelp 出品,功能类似 tini |
| s6-overlay | ~1 MB | 更完整的进程管理套件 |
7.3 tini 的工作原理
tini 做的两件事:
- 信号转发:收到的所有信号(包括 SIGTERM)转发给子进程 java
- 僵尸回收:定期调
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 官方文档
- Kubernetes: Container Lifecycle Hooks
- Kubernetes: Pod Lifecycle - Termination
- Docker: Best practices for writing Dockerfiles
9.2 内核与信号
- The TTY demystified — 更深入理解信号
9.3 init 进程
- tini GitHub — 最流行的容器 init
- dumb-init GitHub — Yelp 的实现
- Docker: --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:一图速览
最后总结:
优雅关停不是"应用层的事",而是"从 K8s → 容器 → 内核 → 进程"整条链路的事。
只要链路中任何一环出问题(shell 吞信号、PID 1 屏蔽信号、grace period 不够、init 缺失),应用层再完善的
@PreDestroy和SmartLifecycle都是空谈。优雅关停的第一前提:让 SIGTERM 能到达你的应用进程。
更多推荐
所有评论(0)