这是一个经典问题:Python 作为 PID 1 在容器中运行,当 SSH 超时(特别是使用 ProxyCommand/bastion 时),其子进程会挂起在 PID 1 上,而 PID 1 并未监控它们——于是产生僵尸进程。我通过两层方案解决:
- 在镜像内安装并设置
tini作为 init(保护所有docker run); - 在
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 —— 已经解决。