容器 entrypoint 与信号处理:做对 PID 1 该做的事

讲解容器中 PID 1 的特殊语义、exec 与信号转发、SIGTERM 优雅退出、配置渲染与首次初始化,以及 tini/dumb-init 与健康检查脚本的正确用法。

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 的信号,且需回收孤儿进程
execexec "$@" 让业务进程成为 PID 1,零成本解决信号问题
转发必须清理时用 trap + wait 循环,注意 wait 被信号打断
优雅退出grace period 要覆盖最慢收尾,先摘流量再停机
配置渲染envsubst 必须限定变量白名单,防止误清空 $host
初始化首次初始化靠标记文件保证幂等,依赖等待用 /dev/tcp
inittini/dumb-init 或 docker run --init 提供信号与回收
健康检查退出码表达状态,--timeout 必须小于 --interval

容器 entrypoint 是「脚本能力」与「进程语义」的交汇点:理解 PID 1 的特殊性、把信号转发做对、让健康检查真实反映可服务状态,容器才能在编排系统里被安全地滚动、缩容与重启。下一个主题我们转向另一个高频场景——把海量日志从「一堆文本」变成「可统计、可告警的结构数据」。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「shell」更多文章

  1. 任务编排与 Makefile 实战
  2. 文件监控与事件驱动流水线实战
  3. 结构化数据清洗与报表生成实战