Problema de zombis en los procesos del contenedor Docker

Problema clásico: Python se ejecuta como PID 1 dentro del contenedor, y en caso de timeout de SSH (especialmente con ProxyCommand/bastion), los hijos quedan colgados en el PID 1, que ya no los espera — de ahí los zombies. Lo soluciono con dos capas: instalo tini como init dentro de la propia imagen (protege cualquier docker run), y activo init: true en el compose como señal explícita.

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 como PID 1: recoge a los hijos huérfanos (principal origen de zombies — ssh con ProxyCommand/bastion, matado por ConnectTimeout/SSH_TIMEOUT_SEC, deja al ssh hijo colgado en init);
# y transmite correctamente los señales SIGTERM/SIGINT a todo el grupo de procesos (flag -g), para que docker stop no tarde 10 segundos ni deje huellas de ssh.

ENTRYPOINT ["/usr/bin/tini", "-g", "--", "python3", "/app/server.py", "--host", "0.0.0.0", "--config", "/config/config.yaml"]

    restart: always
    # En caso de que el ENTRYPOINT en la imagen haya sido reemplazado: docker-init (tini)
    # capturará los zombies de ssh/ProxyCommand en caso de timeout.

    init: true
    ports:

¿Qué cambió y por qué?

  • Dockerfile: Instalo el paquete tini y envuelvo el comando con tini -g -- python3 …. Ahora el PID 1 es tini, que:

    • reaper a los hijos huérfanos (principal fuente de zombies — ssh con ProxyCommand/bastion, matado por ConnectTimeout/SSH_TIMEOUT_SEC, deja al ssh hijo colgado en init);
    • transmite correctamente SIGTERM/SIGINT a todo el grupo de procesos (flag -g), para que docker stop no tarde 10 segundos ni deje huellas de ssh.
  • docker-compose.yml: init: true — capa adicional de seguridad, en caso de que alguien localmente redefina entrypoint: (entonces tini del contenedor desaparecerá, pero docker-init seguirá siendo el PID 1).

Cómo aplicar:

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

Verificar que ya no se generen más zombies (dentro del contenedor no debe haber líneas con Z en STAT):

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

Si se desea aún más cuidado, se puede añadir en el paso ssh_check_for_target las opciones -o ControlMaster=no -o ControlPath=none y start_new_session=True en subprocess.run, para que ssh no genere multiplexores persistentes, y al TimeoutExpired podamos matar toda la process group, y no solo al proceso padre ssh. Pero esto ya es una mejora del comportamiento, y la causa principal de los zombies — la ausencia de init — ya no existe.