前言:一个让你周末加班的"小"问题
上周五下午,准备准点下班的你,心情愉快地执行了 docker stop:
docker stop my-app
然后...然后你就看着终端卡住了。
10秒过去... 30秒过去... 1分钟过去...
最后容器终于停了,但你的周末计划已经泡汤了一半。
你可能在想:不就是停个容器吗?怎么就这么难?
今天咱们就来聊聊这个让你加班的罪魁祸首——容器里的 PID 1 和信号处理。
一、先搞清楚:什么是 PID 1?
在 Linux 系统中,PID 1 是 init 进程,它是所有进程的"老祖宗"。
它的两个关键职责:
- 回收孤儿进程(父进程挂了,它来收尸)
- 处理系统信号(比如
SIGTERM信号)
问题来了:容器里的 PID 1 是谁?
# Dockerfile
FROM node:18
COPY . /app
WORKDIR /app
CMD ["node", "server.js"]
这个容器启动后,PID 1 就是 node server.js 进程。
但如果是这样写呢?
CMD node server.js
或者用 shell 脚本启动?
CMD ["sh", "-c", "node server.js"]
这时候 PID 1 就是 sh,而你的 node 进程是它的子进程!
这有啥区别? 关键区别大了。
二、信号处理的坑:为什么 docker stop 不听使唤?
当你在终端执行 docker stop 时,Docker 会做两件事:
- 给容器里的 PID 1 发送
SIGTERM信号(让程序优雅退出) - 等待 10 秒
- 如果还没停,就发送
SIGKILL强制杀死
理想情况:
docker stop my-app
→ PID 1 收到 SIGTERM
→ 保存状态、关闭连接、清理资源
→ 优雅退出
→ 容器停止(耗时 1 秒)
实际情况(你可能遇到过的):
docker stop my-app
→ PID 1 收到 SIGTERM
→ 但 PID 1 是 sh,它不处理信号!
→ 信号丢失
→ 等待 10 秒超时
→ 被强制杀死
→ 容器停止(耗时 10 秒)
为什么会这样?
因为 Linux 的信号机制有个规则:信号只会发给指定进程,不会自动转发给子进程。
所以当 PID 1 是 sh 时:
sh收到了SIGTERM,但它不知道怎么处理(或者压根不处理)- 你的
node进程什么都没收到,继续运行 - 直到 10 秒后被强制杀死
三、踩坑现场:我遇到的真实案例
我们有个 Java 应用,用 Docker 部署,启动脚本长这样:
#!/bin/bash
java -jar app.jar
Dockerfile:
CMD ["./start.sh"]
问题来了:
每次 kubectl delete pod 都要等 30 秒(GracePeriod),然后被强制杀死。
后果:
- 正在处理的请求被中断,用户看到 500 错误
- 数据库连接没关闭,连接池爆满
- 消息队列里的消息没确认,被重复消费
我们以为是网络问题、JVM 问题、甚至是 Kubernetes 的 bug...
结果最后发现,就是因为 PID 1 是 bash,它不转发信号! ( ̄へ ̄)
四、解决方案:三种姿势任你选
方案 1:用 JSON 数组形式的 CMD(推荐)
✅ 正确写法:
CMD ["node", "server.js"]
这样 PID 1 就是 node,它能直接处理信号。
❌ 错误写法:
CMD node server.js
这会被解析成 /bin/sh -c "node server.js",PID 1 是 sh。
方案 2:使用专门的 init 系统(如 tini)
Docker 有个 --init 参数,会用一个轻量级的 init 进程作为 PID 1:
docker run --init my-image
或者在 Dockerfile 里:
ENTRYPOINT ["tini", "--"]
CMD ["node", "server.js"]
tini 做了什么?
- 作为 PID 1,负责信号转发和僵尸进程回收
- 收到信号后,转发给子进程
- 等子进程退出后,自己也退出
很多官方镜像都内置了 tini:
- WordPress 镜像
- MySQL 镜像
- 自己加也很简单:
# Debian/Ubuntu
RUN apt-get install tini
# Alpine
RUN apk add tini
方案 3:自己写信号处理(适用于复杂场景)
如果你必须用 shell 脚本启动(比如需要做环境变量替换),那就自己处理信号:
#!/bin/bash
# 定义信号处理函数
cleanup() {
echo "收到退出信号,正在清理..."
# 这里做清理工作:杀子进程、关闭连接等
kill -TERM "$child" 2>/dev/null
wait "$child"
echo "清理完成,退出"
exit 0
}
# 捕获 SIGTERM 和 SIGINT
trap cleanup SIGTERM SIGINT
# 启动主进程
java -jar app.jar &
# 记录子进程 PID
child=$!
# 等待子进程
wait "$child"
原理:
trap命令捕获信号- 收到信号后执行
cleanup函数 cleanup里手动杀掉子进程- 等待子进程完全退出
五、怎么验证我的容器有问题?
测试方法:
# 启动容器
docker run -d --name test my-image
# 发送 SIGTERM 信号
docker stop test
# 观察停止时间
如果容器立即停止(1 秒内),说明信号处理正常。
如果卡了 10 秒才停,说明有问题。
进阶测试:
# 启动容器
docker run -it --rm my-image /bin/bash
# 在容器里查看 PID 1
ps aux
# 找到 PID 为 1 的进程是谁
# 手动发送信号测试
kill -TERM 1
# 看看容器会不会退出
六、最佳实践总结
记住这三条黄金法则:
-
永远用 JSON 数组形式的 CMD/ENTRYPOINT
CMD ["executable", "arg1", "arg2"] -
如果必须用 shell,用 tini 或自己处理信号
ENTRYPOINT ["tini", "--"] CMD ["sh", "-c", "my-start-script.sh"] -
测试你的容器能否优雅退出
docker stop your-container # 应该立即停止,不是卡 10 秒
特殊情况:
- 如果你的程序本身能处理信号(Node.js、Java、Go 都可以),让它做 PID 1
- 如果你有多个进程需要管理,用 supervisord 或 s6-overlay
- 如果是微服务架构,一个容器一个进程,不要搞多个
七、延伸思考:云原生时代的信号处理
搞懂了 PID 1,你就能理解:
-
为什么 Kubernetes 的 pod 删除这么慢?
- 可能是你的容器不处理 SIGTERM
- 设置
terminationGracePeriodSeconds给足时间
-
为什么滚动更新时会有 5xx 错误?
- 老容器被强制杀死,没来得及优雅下线
- 需要配合 readiness probe 和 preStop hook
-
为什么日志截断了?
- 程序被强制杀死,flush buffer 的机会都没有
- 信号处理 + 日志驱动要配合好
总结
PID 1 不是简单的"第一个进程",它是容器的"管家"。
- 用对 PID 1,容器能优雅退出,数据不丢失
- 用错 PID 1,你会收获 10 秒的强制等待和可能的数据损坏
下次写 Dockerfile 时,记住一句话:
你的容器谁做 PID 1,决定了你的周末准不准点下班。 ( ̄▽ ̄)ノ
你在项目里遇到过 PID 1 的坑吗?
评论区聊聊,看看谁的故事更惨(顺便学学别人的解决方法)。
相关阅读: