Saltar al contenido

Capítulo 3 de 13

Procesos, señales y recursos

Entender por qué termina un contenedor, cómo detenerlo sin perder trabajo y cómo poner límites medibles.

#El contenedor vive mientras vive su proceso principal

Docker no decide si tu programa "todavía tiene cosas que hacer". Supervisa un proceso principal. Si termina, el contenedor pasa a Exited, aunque haya archivos guardados o esperases que siguiera atendiendo peticiones. Para un servidor, el programa se ejecuta en primer plano dentro del contenedor. Para una tarea de migración, terminar con código cero es el resultado correcto.

-d cambia cómo se conecta tu terminal, no convierte la aplicación en un demonio interno. Si arrancas un servidor con un comando que se manda a segundo plano y deja terminar al shell, Docker puede considerar que el contenedor ha acabado.

#El proceso número 1

Dentro del aislamiento de procesos, el proceso principal tiene identificador 1, o PID 1. Recibe las señales de Docker y tiene responsabilidades particulares respecto a procesos hijos. Cuando una aplicación genera subprocesos, alguno debe recoger sus estados al terminar; de lo contrario pueden quedar procesos zombis.

--init en docker run, o init: true en Compose, coloca un pequeño proceso inicial que reenvía señales y recoge hijos terminados. Ayuda con programas que lanzan subprocesos; no añade reintentos, no guarda trabajo y no sustituye a gestionar el apagado en la aplicación.

docker run --rm --init python:3.13-slim \
  python -c 'print("una tarea que termina")'

#Una parada ordenada tiene un presupuesto de tiempo

Por defecto, docker stop envía una señal de terminación al proceso principal. Docker espera un tiempo y, si sigue vivo, lo fuerza a terminar con SIGKILL. La imagen puede declarar otra señal mediante STOPSIGNAL; por eso conviene conocer el servidor que estás usando.

Una señal es una notificación del sistema a un proceso. SIGTERM permite reaccionar; SIGKILL no puede capturarse para guardar trabajo. Si necesitas terminar una petición o confirmar un archivo, debes hacerlo dentro de la ventana de apagado.

Para verlo, guarda worker.py:

import signal
import time

stopping = False

def request_stop(signum, frame):
    global stopping
    stopping = True

signal.signal(signal.SIGTERM, request_stop)
print("Preparado", flush=True)
while not stopping:
    print("Procesando una unidad corta", flush=True)
    time.sleep(1)
print("Dejo de aceptar trabajo; cierro recursos", flush=True)

signal.signal registra una función que se ejecuta al recibir la señal. Solo cambia una bandera; el bucle termina en un punto controlado. flush=True hace visible cada mensaje inmediatamente. Este patrón puede terminar una unidad corta; si un trabajo dura diez minutos necesitarás puntos de control o poder repetirlo, no esperar que Docker sepa reconstruirlo.

Ejecuta desde la carpeta del archivo:

docker run -d --name parada-demo --init \
  --mount type=bind,src="$(pwd)/worker.py",dst=/worker.py,readonly \
  python:3.13-slim python /worker.py
docker logs -f parada-demo

Espera a ver Preparado: confirma que el programa ya instaló el manejador de señales. En otra terminal ejecuta la parada; hacerlo inmediatamente tras run -d podría interrumpir el arranque antes de que el programa se preparase.

docker stop --time 5 parada-demo
docker logs parada-demo
docker inspect parada-demo --format '{{.State.ExitCode}}'

El montaje enlaza este archivo del host con una ruta del contenedor y lo hace de solo lectura; se desarrolla en Datos y recuperación. Espera el mensaje de cierre y código cero. La aplicación controla el punto de salida; Docker controla cuánto tiempo concede.

En un servidor con peticiones de máximo diez segundos, podrías permitir veinte segundos al servidor para drenar y treinta al contenedor. Es una relación que hay que medir. Si el límite de Docker es menor que el del servidor, el servidor puede ser matado antes de completar su propio apagado.

#Reintentar el proceso no recupera una operación de negocio

Un código de salida expresa cómo terminó el proceso: cero suele indicar éxito, distinto de cero un fallo. Docker puede aplicar una política de reinicio:

Política Conducta Cuándo encaja
no No vuelve a arrancar automáticamente Una tarea puntual cuyo resultado revisas
on-failure:3 Reintenta salidas no exitosas hasta tres veces Una tarea idempotente con fallos transitorios
unless-stopped Mantiene un servicio activo salvo parada explícita Una aplicación que debe volver tras reiniciar el motor
always Reinicia, con el comportamiento documentado para paradas manuales y reinicios del motor Servicios con esa política operativa deliberada

No combines una dependencia caída con un bucle que arranca y consume CPU sin pausa. Mira logs, corrige la causa y usa reintentos limitados con espera dentro de la aplicación. Si se cobró un pago antes de caer el proceso, arrancarlo de nuevo no revela si hay que cobrarlo otra vez. La persistencia e idempotencia pertenecen al diseño de la aplicación.

Un healthcheck puede marcar un contenedor unhealthy mientras el proceso sigue vivo. La política de reinicio de Docker no reinicia un contenedor solo por estar unhealthy. En Compose aprenderás cómo esa señal controla el arranque de dependencias y qué debes supervisar después.

#Compartir una máquina exige presupuestos

Un contenedor no recibe automáticamente una porción justa y segura de todos los recursos. Sin límites puede consumir gran parte de la memoria o CPU disponibles. Separar nombres y procesos no equivale a reservar capacidad.

docker run -d --name limitado \
  --memory 256m --cpus 0.5 --pids-limit 64 \
  python:3.13-slim python -c 'import time; time.sleep(300)'
docker stats --no-stream limitado

--memory 256m establece un límite de memoria del contenedor. Si sus procesos exceden lo que pueden usar, el kernel puede terminar alguno por falta de memoria. --cpus 0.5 limita su cuota de CPU aproximadamente a la mitad de un núcleo durante los intervalos de planificación; no selecciona "medio núcleo físico" ni garantiza latencia. --pids-limit limita procesos/hilos contabilizados para evitar crecimiento descontrolado.

Un límite bajo provoca fallos tan reales como uno ausente. Comienza midiendo consumo en reposo, con concurrencia esperada y durante arranque/migración. Reserva memoria para base de datos, sistema operativo, cachés y picos. Un host con 2 GB no puede atender varios servicios a los que has prometido 2 GB cada uno solo porque estén en contenedores.

#Diagnosticar una salida 137

docker inspect limitado --format \
  'exit={{.State.ExitCode}} oom={{.State.OOMKilled}} error={{.State.Error}}'

Un código 137 suele corresponder a SIGKILL, pero no demuestra por sí mismo falta de memoria: también puede ser una parada forzada. Comprueba OOMKilled, eventos, consumo y logs. Si hubo OOM, determina si creció la carga, hay una fuga, sobran workers o el límite es irreal. Duplicar RAM sin entender la tendencia puede retrasar el próximo incidente.

CPU saturada y memoria agotada se manifiestan de forma diferente. El límite de CPU suele producir lentitud; un límite de memoria puede matar un proceso. Una aplicación con muchos workers multiplica parte de su memoria y sus conexiones a la base de datos. El número de workers debe corresponder a la cuota del contenedor y al tipo de trabajo, no al número total de núcleos del host sin restricciones.

#Diseñar una tarea que pueda interrumpirse

Considera un importador que lee 10 000 documentos. Mantener todo el progreso en memoria es frágil: una actualización rutinaria interrumpe el proceso. Una estrategia profesional y manejable es trabajar por lotes pequeños, confirmar cada lote en almacenamiento y guardar hasta dónde has llegado.

  1. Lee un lote con identificadores estables.
  2. Calcula el resultado sin producir efectos duplicados.
  3. Confirma resultado y progreso juntos cuando comparten base transaccional.
  4. Al recibir SIGTERM, deja de aceptar lotes y termina el actual si cabe en el presupuesto.
  5. Al reiniciar, lee el progreso y continúa; si repite un lote, reconoce que ya lo había aplicado.

Esto no requiere un orquestador gigante. Requiere que la unidad de trabajo y su confirmación estén definidas. Docker aporta ejecución y señales; la aplicación aporta significado y recuperación.

#Comprobar que lo has entendido

Una API tarda 25 segundos en completar una petición y Docker permite diez segundos al detenerla. ¿Añadir restart: always arregla la petición interrumpida?

Ver el razonamiento

No. La política arranca otro proceso, pero no reconstruye la respuesta ni confirma qué efectos quedaron aplicados. Debes alinear el tiempo de drenaje con el tiempo de las operaciones, limitar operaciones largas o moverlas a trabajos persistentes. Después comprueba apagado normal y caída brusca por separado: solo el primero permite ejecutar limpieza.

Elimina los dos ejemplos con docker rm parada-demo y, si sigue corriendo, docker stop limitado seguido de docker rm limitado.

#Referencias para profundizar

    ↑ ↓ para recorrer · Intro para abrir · Ctrl K para buscar