1. 容器里的 PID 1 到底特殊在哪
一句话总结: 容器的第一个进程是 PID 1,内核会对它做两件特殊处理:忽略没有安装处理函数的信号,并且要求它负责回收孤儿进程。
1.1 默认信号处置被忽略
普通进程收到 SIGTERM 且没有 handler 时会终止;但 PID 1 是个例外——内核会静默忽略它没有显式注册 handler 的信号。
# 一个「永远不会响应 docker stop」的容器
docker run -d --name bad alpine sh -c 'while true; do sleep 1; done'
docker stop bad # 会一直等到超时(默认 10s)
docker inspect bad --format '{{.State.ExitCode}}'
# 137 —— 被 SIGKILL 强杀,而不是优雅退出
docker exec bad ps -o pid,ppid,comm # PID 1 就是那个 sh,它没装 handler
这就是「docker stop 总要等 10 秒」的根本原因:shell 收不到信号,只能等 docker 兜底发 SIGKILL。
一句话总结: 把脚本当 PID 1 跑,默认情况下它不会因
SIGTERM退出,docker stop只能靠超时强杀。
1.2 谁负责回收僵尸进程
PID 1 必须 wait() 掉那些「父进程已死」的孤儿进程,否则它们会以僵尸状态堆积。
# shell 作为 PID 1 时孤儿进程的父进程会变成它,但普通 shell 不会主动 wait
docker run -d --name zombie alpine sh -c 'sleep 1 & sleep 100000'
docker exec zombie ps -o pid,ppid,stat,comm # 状态为 Z 就是没人回收
三种解法:让业务进程自己当 PID 1(推荐)、用 tini/dumb-init 当 PID 1、或在脚本里写回收循环。
2. exec 与信号转发
一句话总结:
exec "$@"用业务进程替换掉 shell 自己,是最简单的「让业务进程成为 PID 1」的办法,代价是 shell 之后无法再做清理。
2.1 exec 替换进程映像
#!/bin/sh
# 最简 entrypoint:准备完环境后把控制权交给业务进程
set -e
echo "starting with config: $APP_ENV"
exec "$@"
FROM alpine:3.19
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"] # 无 exec 形式,保证脚本是 PID 1
CMD ["/usr/bin/myapp", "--port", "8080"]
此时容器里 PID 1 就是 myapp,docker stop 发来的 SIGTERM 直接送达业务进程,无需任何转发。
一句话总结:
exec是「零成本」方案:不引入额外进程、不改变信号语义,只要脚本在 exec 之前不需要做收尾。
2.2 需要清理时的转发写法
当脚本必须「启动子进程 → 等待 → 收到信号后清理 → 退出」时,就得自己转发信号。
#!/usr/bin/env bash
set -euo pipefail
/usr/bin/myapp --port 8080 &
child=$!
# 转发信号:收到 TERM/INT 就转给子进程
trap 'kill -TERM "$child" 2>/dev/null' TERM
trap 'kill -INT "$child" 2>/dev/null' INT
# wait 会被信号打断并提前返回,所以要循环等它真正结束
while kill -0 "$child" 2>/dev/null; do
wait "$child" && break || true
done
wait "$child"
status=$?
echo "child exited with $status, running cleanup"
rm -f /tmp/myapp.sock
exit "$status"
关键细节:wait 在收到被 trap 的信号后会提前返回,所以必须用 kill -0 循环确认子进程真的结束,否则会把「被打断」误判成「已退出」而提前清理。
3. SIGTERM 优雅退出
一句话总结: 优雅退出的本质是「收到 SIGTERM 后停止接受新请求、处理完在途请求、再退出」,而给多少时间由
docker stop -t与编排系统的terminationGracePeriodSeconds决定。
3.1 退出时序与超时
# docker stop 的时序:发 SIGTERM → 等待 -t 秒 → 发 SIGKILL
docker stop -t 30 myapp
# Kubernetes 的等价配置(默认 30 秒)
# spec.template.spec.terminationGracePeriodSeconds: 30
| 阶段 | 发生的事 | 脚本要做的 |
|---|---|---|
| t0 | 收到 SIGTERM | 停止接受新连接 |
| t0~t1 | 在途请求处理完 | 等待 worker 收尾 |
| t1 | 主动 exit 0 | 清理临时文件、释放锁 |
| t0+timeout | 收到 SIGKILL | 已无机会清理 |
grace period 必须大于脚本最坏情况下的收尾时间,否则 SIGKILL 会直接砍掉清理流程,留下半截的临时文件或未提交的事务。
一句话总结: 超时设置要覆盖「最慢一次收尾」,宁可多给几秒,也不要让 SIGKILL 打断清理。
3.2 脚本侧的优雅停机
#!/usr/bin/env bash
set -euo pipefail
DRAIN_FILE=/tmp/draining
rm -f "$DRAIN_FILE"
# 供健康检查读取:一旦开始排空就报告不健康,让流量先切走
on_term() {
echo "draining..."
touch "$DRAIN_FILE"
sleep 5 # 给负载均衡器摘除本实例的时间
kill -TERM "$child" 2>/dev/null || true
}
trap 'on_term' TERM
/usr/bin/myapp --port 8080 &
child=$!
while kill -0 "$child" 2>/dev/null; do
wait "$child" && break || true
done
# 健康检查脚本读这个标记,实现「先摘流量再停机」
[ -f /tmp/draining ] && exit 1
curl -fsS http://127.0.0.1:8080/health >/dev/null || exit 1
这是「优雅」的真正含义:不是自己多活几秒,而是让上游先停止向自己发流量。
4. 配置渲染与初始化
一句话总结: entrypoint 的经典职责是把环境变量渲染进配置文件,并做一次性的初始化;渲染要幂等,初始化要能重复执行而不出错。
4.1 用 envsubst 渲染模板
#!/bin/sh
set -e
# config/nginx.conf.template 中写 ${PORT} ${UPSTREAM} 等占位符
envsubst '${PORT} ${UPSTREAM} ${LOG_LEVEL}' \
< /etc/app/app.conf.template \
> /etc/app/app.conf
exec /usr/bin/myapp --config /etc/app/app.conf
envsubst 的参数限定只替换列出的变量,避免误替换配置里本来就有的 $ 符号(例如 Nginx 的 $host)。需要默认值时用 shell 参数扩展兜底:
: "${PORT:=8080}"; : "${LOG_LEVEL:=info}"; export PORT LOG_LEVEL
一句话总结: 一定要限定
envsubst的变量白名单,否则模板里的$host、$remote_addr会被静默清空。
4.2 首次初始化与幂等
#!/usr/bin/env bash
set -euo pipefail
# 只在数据目录为空时做初始化(幂等)
if [ ! -f /var/lib/app/.initialized ]; then
echo "first run: initializing"
/usr/bin/myapp init --data-dir /var/lib/app
touch /var/lib/app/.initialized
fi
exec /usr/bin/myapp serve
# 等待依赖就绪(容器编排里常见的启动竞态)
wait_for() {
local host="$1" port="$2" tries="${3:-30}"
for _ in $(seq "$tries"); do
(echo >"/dev/tcp/$host/$port") 2>/dev/null && return 0
sleep 1
done
echo "timeout waiting for $host:$port" >&2
return 1
}
wait_for db 5432 60
注意:/dev/tcp 是 bash 特性,sh(dash)里不可用,所以这个函数必须配 #!/usr/bin/env bash。
5. tini 与 dumb-init
一句话总结: 当业务进程无法改造、又必须有人转发信号和回收僵尸时,用 tini 或 dumb-init 这类「最小 init」顶在 PID 1 的位置。
5.1 为什么需要 init 进程
它们只做两件事:转发信号与回收僵尸,几 KB 大小、几乎零开销。
apt-get install -y tini # 或 dumb-init,二者职责相同
5.2 接入方式
# 方式一:显式用 tini 作为 ENTRYPOINT
FROM debian:12-slim
RUN apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
COPY myapp /usr/bin/myapp
ENTRYPOINT ["/usr/sbin/tini", "--", "/usr/bin/myapp"]
# 方式二:dumb-init
FROM debian:12-slim
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["/usr/bin/myapp"]
# 方式三:不改镜像,运行时注入 init(compose 里对应 init: true)
docker run --init -d myimage
tini 的 -- 之后才是被托管的命令;如果省略 --,tini 会把后面的参数当成自己的选项解析。另外,tini -g 会把信号发给整个进程组,适合有子进程树的场景。
# docker compose
services:
app:
image: myimage
init: true # 等价于 docker run --init
stop_grace_period: 30s
一句话总结: 能用
exec就用exec,需要额外 init 时优先选docker run --init,把复杂度留在运行时而不是镜像里。
6. 健康检查脚本
一句话总结: 健康检查靠退出码表达状态:0 表示健康、1 表示不健康;探针要检查「能否真正服务」而不是「进程是否存在」。
6.1 HEALTHCHECK 与退出码
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD /usr/local/bin/healthcheck.sh || exit 1
#!/bin/sh
# /usr/local/bin/healthcheck.sh
set -e
[ -f /tmp/draining ] && exit 1 # 排空标记优先
curl -fsS --max-time 2 http://127.0.0.1:8080/health >/dev/null || exit 1
[ -w /var/lib/app ] || exit 1 # 关键磁盘可写
exit 0
| 参数 | 含义 | 调优建议 |
|---|---|---|
--interval | 检查间隔 | 30s 起步 |
--timeout | 单次超时 | 必须小于 interval |
--start-period | 启动宽限期 | 覆盖最慢冷启动 |
--retries | 连续失败几次才标记不健康 | 3 次避免抖动 |
一句话总结:
--timeout一定要小于--interval,否则探针会互相堆积,反而拖垮容器。
6.2 探针要检查什么
反面教材是 nc -z 127.0.0.1 8080——进程卡死时端口照样在监听。正确做法是走一次真实业务路径,能返回正确结果才算健康:
#!/bin/sh
set -e
body=$(curl -fsS --max-time 2 http://127.0.0.1:8080/health)
case "$body" in
*'"status":"ok"'*) exit 0 ;;
*) echo "unexpected health body: $body" >&2; exit 1 ;;
esac
探针应当区分 liveness(活着吗,失败就重启)与 readiness(能接流量吗,失败就摘流量)。排空标记这种「暂时不健康但不应重启」的状态,只能影响 readiness。
7. 实战:一个完整的 entrypoint
一句话总结: 把渲染、等待依赖、初始化、信号转发、exec 组合起来,就是一个生产可用的 entrypoint;关键是每一步都幂等且可观测。
7.1 脚本实现
#!/usr/bin/env bash
set -euo pipefail
log() { printf '[entrypoint] %s\n' "$*" >&2; }
# 1. 参数默认值
: "${APP_PORT:=8080}"
: "${LOG_LEVEL:=info}"
: "${WAIT_FOR:=}"
# 2. 等待依赖(WAIT_FOR 形如 db:5432,redis:6379)
if [ -n "$WAIT_FOR" ]; then
IFS=, read -ra deps <<<"$WAIT_FOR"
for d in "${deps[@]}"; do
host="${d%%:*}"; port="${d##*:}"
log "waiting for $host:$port"
for _ in $(seq 60); do
(echo >"/dev/tcp/$host/$port") 2>/dev/null && break
sleep 1
done
done
fi
# 3. 渲染配置(幂等) 4. 交给业务进程,让它成为 PID 1
export APP_PORT LOG_LEVEL
envsubst '${APP_PORT} ${LOG_LEVEL}' < /etc/app/app.conf.template > /etc/app/app.conf
log "exec: $*"
exec "$@"
FROM debian:12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
gettext-base curl tini ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY app.conf.template /etc/app/app.conf.template
COPY healthcheck.sh /usr/local/bin/healthcheck.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/healthcheck.sh
ENV APP_PORT=8080 LOG_LEVEL=info
EXPOSE 8080
STOPSIGNAL SIGTERM
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD /usr/local/bin/healthcheck.sh
ENTRYPOINT ["/usr/sbin/tini", "--", "/usr/local/bin/entrypoint.sh"]
CMD ["/usr/bin/myapp", "serve"]
STOPSIGNAL SIGTERM 是显式声明,让编排系统知道该用哪个信号停机;如果业务进程期望的是 SIGQUIT(例如 Nginx 的优雅退出),就在这里改掉。
7.2 验证清单
docker run -d --name t myimage
docker exec t ps -o pid,ppid,comm # 1. PID 1 是预期进程
time docker stop -t 30 t # 2. 优雅退出,耗时小于超时
docker inspect t --format 'exit={{.State.ExitCode}}'
docker exec t ps -o pid,stat,comm | grep -c ' Z ' || true # 3. 无僵尸
docker inspect t --format '{{.State.Health.Status}}' # 4. 健康检查生效
退出码 0(或 143)表示优雅退出,137 表示被强杀——出现 137 就应该回头检查信号转发链路。
8. 总结
| 环节 | 要点 |
|---|---|
| PID 1 | 内核忽略其未注册 handler 的信号,且需回收孤儿进程 |
| exec | exec "$@" 让业务进程成为 PID 1,零成本解决信号问题 |
| 转发 | 必须清理时用 trap + wait 循环,注意 wait 被信号打断 |
| 优雅退出 | grace period 要覆盖最慢收尾,先摘流量再停机 |
| 配置渲染 | envsubst 必须限定变量白名单,防止误清空 $host |
| 初始化 | 首次初始化靠标记文件保证幂等,依赖等待用 /dev/tcp |
| init | tini/dumb-init 或 docker run --init 提供信号与回收 |
| 健康检查 | 退出码表达状态,--timeout 必须小于 --interval |
容器 entrypoint 是「脚本能力」与「进程语义」的交汇点:理解 PID 1 的特殊性、把信号转发做对、让健康检查真实反映可服务状态,容器才能在编排系统里被安全地滚动、缩容与重启。下一个主题我们转向另一个高频场景——把海量日志从「一堆文本」变成「可统计、可告警的结构数据」。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。