容器里的 PID 1 为什么这么重要?信号处理的坑我替你踩了

62 阅读5分钟

前言:一个让你周末加班的"小"问题

上周五下午,准备准点下班的你,心情愉快地执行了 docker stop

docker stop my-app

然后...然后你就看着终端卡住了。

10秒过去... 30秒过去... 1分钟过去...

最后容器终于停了,但你的周末计划已经泡汤了一半。

你可能在想:不就是停个容器吗?怎么就这么难?

今天咱们就来聊聊这个让你加班的罪魁祸首——容器里的 PID 1 和信号处理


一、先搞清楚:什么是 PID 1?

在 Linux 系统中,PID 1 是 init 进程,它是所有进程的"老祖宗"。

它的两个关键职责:

  1. 回收孤儿进程(父进程挂了,它来收尸)
  2. 处理系统信号(比如 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 会做两件事:

  1. 给容器里的 PID 1 发送 SIGTERM 信号(让程序优雅退出)
  2. 等待 10 秒
  3. 如果还没停,就发送 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
# 看看容器会不会退出

六、最佳实践总结

记住这三条黄金法则:

  1. 永远用 JSON 数组形式的 CMD/ENTRYPOINT

    CMD ["executable", "arg1", "arg2"]
    
  2. 如果必须用 shell,用 tini 或自己处理信号

    ENTRYPOINT ["tini", "--"]
    CMD ["sh", "-c", "my-start-script.sh"]
    
  3. 测试你的容器能否优雅退出

    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 的坑吗?

评论区聊聊,看看谁的故事更惨(顺便学学别人的解决方法)。


相关阅读: