前置阅读:建议先阅读 Dockerfile 最佳实践 与 Docker 部署 Node.js。
关键概念:容器内的 PID 1 进程 责任重大——它要回收孤儿/僵尸进程,还要把宿主机发来的 SIGTERM 信号 转达给业务子进程,否则应用无法优雅退出。很多"容器 kill 不掉"“日志里冒一堆 defunct 进程"的问题,根源都是 PID 1 没做好这两件事。
1. PID 1 的特殊职责
1.1 为什么 PID 1 与众不同
Linux 中 PID 1(init)有三个其他进程没有的特殊行为:
1. 成为所有"无父进程"子进程的收养者
- 业务进程 fork 出的孙进程,父进程先死后,会被 PID 1 收养
2. 负责回收僵尸进程(wait() 子进程)
- 子进程退出后未 wait,会残留为 <defunct> 状态
3. 接收并转发信号给子进程
- 宿主机 docker stop 发出 SIGTERM,先到达 PID 1
# 查看容器内的 PID 1 是谁(镜像默认可能就是 bash 或 sh)
docker exec -it myapp ps -ef | head -5
# 看容器内是否存在僵尸进程(Z 状态)
docker exec -it myapp ps -ef | awk '$2=="Z" || $2=="Z+" || $3 ~ /defunct/'
docker exec -it myapp ps aux | grep -c defunct
1.2 默认镜像的典型问题
大多数基础镜像的 PID 1 是 bash 或 sh,而 shell 既不做子进程回收,也不转发信号:
bash 作为 PID 1 时:
- 子进程退出 → 无人 wait → 僵尸堆积(尤其长期运行的容器)
- docker stop → SIGTERM 发给 bash,bash 不转发给 Java/Node 进程
- 应用没收到 SIGTERM → 不会优雅关闭连接、刷盘
- 等到 SIGKILL → 数据丢失 / 连接中断
这就是"容器停了但服务端还在报错"的常见根源。
一句话:让应用进程自己当 PID 1(exec-form + 可执行入口)是最省事且最正确的默认方案;只有应用无法直接收信号或需要多进程时才引入 init 包装器。
2. 信号处理与优雅退出
2.1 信号到容器的完整链路
docker stop myapp
│
▼
dockerd 向容器 PID 1 发送 SIGTERM
│
▼(两种情况)
A. PID 1 = 应用本身(exec) → 应用直接处理 SIGTERM,优雅退出
B. PID 1 = shell/init 包装器 → 需要把 SIGTERM 转发给真正的应用进程
│
▼
超时后(默认 10s)dockerd 发送 SIGKILL 强制杀掉
调整 stop 等待时长:
# 给容器更长的优雅退出时间(单位秒)
docker run -d --stop-timeout 30 myapp
# docker-compose 中配置
# services:
# myapp:
# stop_grace_period: 30s
2.2 应用侧如何正确处理 SIGTERM
// Node.js 优雅退出
const server = require('http').createServer(...);
server.listen(8080);
process.on('SIGTERM', () => {
console.log('收到 SIGTERM,停止接收新连接...');
server.close(() => {
console.log('存量连接处理完毕,退出');
process.exit(0);
});
// 兜底:5 秒后强退
setTimeout(() => process.exit(1), 5000).unref();
});
# Python 优雅退出
import signal, sys
def graceful(signum, frame):
print("收到退出信号,正在清理...")
# 关闭连接池、落盘缓存
sys.exit(0)
signal.signal(signal.SIGTERM, graceful)
signal.signal(signal.SIGINT, graceful)
# 主循环
while True:
time.sleep(1)
// Java 优雅退出(JVM 对 SIGTERM 默认会执行 shutdown hook)
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("JVM 关闭钩子执行...");
// 关闭线程池、释放资源
executor.shutdownNow();
}));
3. tini 与 dumb-init:轻量 init 进程
3.1 为什么需要它们
当应用进程不是 PID 1,或者容器需要运行多个进程时,需要一个小型 init 做"信号转发 + 僵尸回收 + 子进程收养”。这就是 tini 和 dumb-init 的用途。
| 特性 | tini | dumb-init | 裸 shell |
|---|---|---|---|
| 信号转发 | 是 | 是 | 否 |
| 僵尸回收 | 是 | 是 | 否 |
| 孤儿收养 | 是 | 是 | 部分 |
| 单文件大小 | ~30KB | ~100KB | —— |
| 是否被 Docker 内置 | 是(--init 即 tini) | 否 | —— |
| 进程组处理 | 精确到单进程 | 按进程组 | —— |
3.2 tini 的两种用法
用法一:Docker 内置(最简单,推荐)
# 让 Docker 自动注入 tini 作为 PID 1
docker run --init --rm alpine sleep 1000
# 验证:容器内 PID 1 是 /sbin/tini
docker exec -it <cid> ps -p 1 -o pid,comm,args
用法二:Dockerfile 显式安装
# 基于 Alpine(musl)的镜像
RUN apk add --no-cache tini
ENTRYPOINT ["tini", "--", "/entrypoint.sh"]
# 基于 Debian/Ubuntu 的镜像
RUN apt-get update && apt-get install -y tini && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
3.3 dumb-init 用法
RUN wget -O /usr/local/bin/dumb-init \
https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 \
&& chmod +x /usr/local/bin/dumb-init
ENTRYPOINT ["dumb-init", "--"]
CMD ["/app/main"]
一句话:能直接让应用当 PID 1 就不装 init;需要多进程或应用收不到信号时,
--init(tini)是零成本第一选择。
4. ENTRYPOINT 与 CMD 的正确组合
4.1 两种 form 的本质差异
| 写法 | 行为 | 信号/exec 语义 | 典型用途 |
|---|---|---|---|
exec-form(数组) | 直接 exec 启动,PID 1 就是目标进程 | 收到 SIGTERM 直接处理 | 应用入口(推荐) |
shell-form(字符串) | 通过 /bin/sh -c 包一层 | 信号先到 sh,sh 不转发 | 需要变量展开/管道时 |
# exec-form:PID 1 = node
ENTRYPOINT ["node", "/app/server.js"]
# shell-form:PID 1 = sh,sh 再拉 node(信号被 sh 吞掉)
ENTRYPOINT node /app/server.js
4.2 ENTRYPOINT + CMD 组合矩阵
CMD 会被 docker run 末尾参数覆盖,ENTRYPOINT 不会。组合规则:
| 组合 | docker run 追加参数后 | 适用场景 |
|---|---|---|
只有 CMD | CMD 被覆盖 | 纯命令式镜像(如 alpine 工具) |
只有 ENTRYPOINT | ENTRYPOINT 原样执行 | 单一固定入口 |
| ENTRYPOINT 固定 + CMD 作默认参数 | CMD 作为参数追加 | 最推荐的"带默认参数的入口" |
# 推荐模板:ENTRYPOINT 固定、CMD 提供默认参数
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
EXPOSE 8080
ENTRYPOINT ["node", "/app/dist/server.js"]
CMD ["--port", "8080", "--env", "production"]
# 覆盖 CMD 传入新参数(ENTRYPOINT 不变)
docker run myapp --port 9090 --env staging
# 实际执行:node /app/dist/server.js --port 9090 --env staging
4.3 entrypoint.sh 的常见写法
#!/bin/sh
set -e
echo "=== 初始化阶段 ==="
# 1. 等待依赖(数据库就绪)
until nc -z db 5432; do echo "等待 db..."; sleep 2; done
# 2. 执行一次性迁移
if [ "$RUN_MIGRATIONS" = "1" ]; then
/app/bin/migrate --config /app/config.yml
fi
# 3. exec 真正的主进程(关键:必须 exec,让应用成为 PID 1)
exec "$@"
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/app/main"]
一句话:entrypoint 脚本里最后一步必须
exec "$@"——否则脚本成为 PID 1,应用沦为子进程,信号转发又回到老问题。
5. 多进程容器:supervisor 与 s6
5.1 什么时候才需要多进程
容器哲学是"一容器一进程",但有些场景(传统应用、配置下发 + 业务、日志采集边车)确实需要多个进程。两条路:
| 方案 | 特性 | 适合场景 |
|---|---|---|
| s6-overlay | 进程守护 + 服务树 + 日志集成,轻量、信号管理好 | Alpine 小镜像、微服务多进程 |
| supervisor | 经典、配置简单、可定义程序组 | 传统应用、运维熟悉 Python 生态 |
| 多个容器 | 真正的容器化做法 | 新项目首选 |
5.2 supervisor 配置示例
; /etc/supervisor/conf.d/app.conf
[supervisord]
nodaemon=true ; 必须前台运行,否则容器立即退出
logfile=/dev/null
[program:web]
command=/usr/bin/python /app/wsgi.py
autostart=true
autorestart=true
stopsignal=TERM
stopwaitsecs=20
[program:worker]
command=/usr/bin/python /app/celery_worker.py
autostart=true
autorestart=true
RUN pip install supervisor
COPY supervisord.conf /etc/supervisor/conf.d/app.conf
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
5.3 s6-overlay(Alpine 生态常用)
FROM alpine:3.20
ADD https://github.com/just-containers/s6-overlay/releases/download/v3.1.6.2/s6-overlay-noarch.tar.xz /tmp/
RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz \
&& rm -f /tmp/s6-overlay-noarch.tar.xz
# 服务定义写 /etc/s6-overlay/s6-rc.d/ 下的目录结构
ENTRYPOINT ["/init"]
6. 各语言应用的入口封装实践
6.1 Java:JVM 与 SIGTERM
JVM 默认收到 SIGTERM 会触发 shutdown hook 后退出,但要注意 -XX:+UseContainerSupport 读取 cgroup 限制:
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/app.jar .
# exec-form + 显式堆限制(避免 JVM 与 cgroup 误判)
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar"]
6.2 Node.js:node 直接收信号
FROM node:20-alpine
WORKDIR /app
# --init 让 Docker 内置 tini 当 PID 1,弥补 node 对孤儿进程不回收的问题
ENTRYPOINT ["node", "/app/server.js"]
# 运行时加 --init(PID 1 = tini,node 是子进程但信号被正确转发)
docker run --init --stop-timeout 30 mynodeapp
6.3 Python:uWSGI / gunicorn 的 master-worker
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir gunicorn==23.0.0
COPY . .
# gunicorn master 进程可作 PID 1,能回收 worker 僵尸
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "app:app"]
6.4 Go:多阶段 + 静态二进制直接 PID 1
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
FROM scratch
COPY --from=builder /app /app
# scratch 镜像无 shell,PID 1 就是 Go 程序本体,天然正确
ENTRYPOINT ["/app"]
7. 进程健康检查与重启策略
7.1 HEALTHCHECK 与 restart 配合
# Dockerfile 内定义健康检查
HEALTHCHECK --interval=5s --timeout=3s --retries=3 --start-period=10s \
CMD curl -fs http://localhost:8080/health || exit 1
# compose 中的重启策略(与健康检查联动)
services:
web:
image: myapp:1.2
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-fs", "http://localhost:8080/health"]
interval: 10s
timeout: 3s
retries: 3
7.2 PID 1 相关常见问题速查
| 症状 | 根因 | 对策 |
|---|---|---|
docker stop 后服务不优雅退出 | PID 1 是 shell,不转发信号 | exec-form / --init / tini |
容器内 defunct 进程堆积 | 无人 wait() 回收僵尸 | tini/s6/supervisor 或应用当 PID 1 |
| 容器启动即退出 | PID 1 是后台 daemon 化进程 | 去掉 & / 前台运行 / supervisor |
| 脚本初始化后主进程收不到信号 | 脚本最后没用 exec | 脚本结尾 exec "$@" |
| 明明没超限却 OOM | 应用 fork 太多、内存翻倍 | 限制 + 预留 + 排查线程/协程泄漏 |
8. 最佳实践清单
□ 能用 exec-form 就不用 shell-form(PID 1 就是应用本体)
□ 需要初始化时,脚本末尾务必 exec "$@"
□ 多进程场景优先 s6-overlay,其次 supervisor(nodaemon=true)
□ 默认给 docker run 加 --init,或镜像显式装 tini
□ docker stop 超时不够时调 --stop-timeout / stop_grace_period
□ 应用侧正确实现 SIGTERM 的优雅关闭(关连接、刷盘、退线程池)
□ JVM 镜像加 -XX:MaxRAMPercentage 与 cgroup 联动
□ 用 HEALTHCHECK + restart 策略兜住崩溃进程
一句话:容器的"优雅"不在停止命令,而在 PID 1 的职责——回收僵尸 + 转发信号,二者做到,优雅退出就水到渠成。
9. 总结
| 场景 | 正确做法 | PID 1 是谁 |
|---|---|---|
| 单进程应用(推荐) | exec-form ENTRYPOINT + CMD 默认参数 | 应用本体 |
| 需要轻量初始化 | entrypoint.sh 末尾 exec "$@" | 应用本体 |
| 应用收不到信号 / 需要回收僵尸 | docker run --init(内置 tini) | tini |
| 多进程容器 | s6-overlay(轻量)/ supervisor(传统) | init/supervisord |
| 静态二进制(Go/Rust) | scratch + 静态可执行文件 | 应用本体 |
| JVM 应用 | temurin + -XX:MaxRAMPercentage + exec-form | java 进程 |
容器进程管理的核心就两件事:PID 1 必须是"会收信号、会收孤儿"的角色,以及 应用侧要真的处理 SIGTERM。把这两条内化为习惯,再用 tini 和正确的 ENTRYPOINT 组合固化到镜像里,你的容器就既"停得优雅"又"活得干净"——这是从"能跑"走向"可运维"的关键一步。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。