容器内进程管理与 Entrypoint 最佳实践

深入解析容器内 PID 1 与僵尸进程、信号处理与优雅退出机制,对比 tini/dumb-init 等 init 进程方案,理清 ENTRYPOINT 与 CMD 的正确组合、exec-form 与 shell-form 的差异,给出多进程容器(supervisor/s6)与 Java/Node/Python 应用的入口封装实践。

前置阅读:建议先阅读 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 的用途。

特性tinidumb-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 追加参数后适用场景
只有 CMDCMD 被覆盖纯命令式镜像(如 alpine 工具)
只有 ENTRYPOINTENTRYPOINT 原样执行单一固定入口
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-formjava 进程

容器进程管理的核心就两件事:PID 1 必须是"会收信号、会收孤儿"的角色,以及 应用侧要真的处理 SIGTERM。把这两条内化为习惯,再用 tini 和正确的 ENTRYPOINT 组合固化到镜像里,你的容器就既"停得优雅"又"活得干净"——这是从"能跑"走向"可运维"的关键一步。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「docker」更多文章

  1. Docker 容器排障与调试实战:退出码、OOM、exec 与调试工具链
  2. Docker 镜像供应链安全:SBOM、签名、扫描与 SLSA 合规
  3. Docker 容器监控与日志实践:cgroups 资源限制、Prometheus 与日志驱动