Docker容器进程中的僵尸进程问题

这是一个经典问题:Python 作为 PID 1 在容器中运行,当 SSH 超时(特别是使用 ProxyCommand/bastion 时),其子进程会挂起在 PID 1 上,而 PID 1 并未监控它们——于是产生僵尸进程。我通过两层方案解决:

  1. 在镜像内安装并设置 tini 作为 init(保护所有 docker run);
  2. docker-compose.yml 中显式启用 init: true 作为明确信号。
RUN apt-get update \
    && apt-get install -y --no-install-recommends openssh-client \
    && apt-get install -y --no-install-recommends openssh-client tini \
    && rm -rf /var/lib/apt/lists/*

ENTRYPOINT ["python3", "/app/server.py", "--host", "0.0.0.0", "--config", "/config/config.yaml"]
# tini 作为 PID 1:回收孤儿子进程(ProxyCommand/bastion、SSH 超时导致的子 ssh 进程挂起在 init 上)
# 并正确转发信号给 Python 服务。
ENTRYPOINT ["/usr/bin/tini", "-g", "--", "python3", "/app/server.py", "--host", "0.0.0.0", "--config", "/config/config.yaml"]

restart: always
# 防止镜像中 ENTRYPOINT 被覆盖时:docker-init(tini)仍会接管 SSH/ProxyCommand 超时产生的僵尸进程。
init: true
ports:

变更说明及原因:

  • Dockerfile:安装 tini 包,并将命令包装为 tini -g -- python3 ...。现在 PID 1 是 tini,它:

    • 会回收孤儿子进程(僵尸进程的主要来源是使用 ProxyCommand/bastion 的 SSH,因 ConnectTimeout/SSH_TIMEOUT_SEC 被终止后,子 ssh 进程仍挂起在 init 上);
    • 正确转发 SIGTERM/SIGINT 到整个进程组(-g 标志),使 docker stop 不再卡顿 10 秒,也不会残留 SSH 进程。
  • docker-compose.yml:启用 init: true —— 作为额外保障,以防有人在本地覆盖了 entrypoint:(此时镜像中的 tini 会消失,但 docker-init 仍会作为 PID 1 启动)。

如何应用:

docker compose build --no-cache ansible-status
docker compose up -d

检查僵尸进程是否已消除(容器内不应出现 STAT 字段含 Z 的进程):

docker compose exec ansible-status sh -c 'ps -e -o pid,ppid,stat,comm | awk "NR==1 || /Z/"'

如需更进一步优化:

可以在 ssh_check_for_target 中额外添加选项 -o ControlMaster=no -o ControlPath=none,并在 subprocess.run 中设置 start_new_session=True,这样 SSH 就不会创建长期存在的多路复用器,当发生 TimeoutExpired 时,我们就能直接杀死整个进程组,而不仅限于主 SSH 进程。但这属于行为优化,根本原因——缺少 init —— 已经解决。