Producción
Lo que separa un contenedor que funciona de un servicio explotable.
Despliegue en producción
Una imagen que arranca correctamente en su equipo no es una imagen lista para producción, y la distancia entre ambas no se salva con un comando más. Se salva con una serie de decisiones que nada le obliga a tomar, y esa es precisamente la razón por la que tan a menudo se olvidan.
Los incidentes que se derivan tienen una firma reconocible, porque se repiten en todas partes. Un servidor cuyo disco llega al 100 % en tres semanas: nadie había configurado la rotación de logs, y Docker los conserva sin límite por defecto. Un contenedor con una fuga de memoria que acaba tumbando la base de datos vecina por falta de un tope. Un despliegue que corta el servicio durante cuarenta segundos porque el proceso nunca recibe la señal de parada. Un tráfico redirigido a un contenedor «arrancado» que todavía no acepta conexiones, porque nada distinguía «lanzado» de «listo». Y, de forma más discreta, una imagen que, sin que nadie lo sepa, lleva ocho meses incluyendo una biblioteca vulnerable.
Ninguno de estos problemas es difícil. Todos son invisibles en desarrollo, donde el servicio funciona diez minutos, la memoria sobra y un corte no molesta a nadie. Solo aparecen con el tiempo y la carga, es decir, en el peor momento.
Esta última lección repasa estas decisiones una a una. El objetivo no es convertir un servidor en un clúster: Compose sobre un único host es una arquitectura perfectamente legítima para muchos servicios. El objetivo es que ese servidor aguante seis meses sin intervenciones de urgencia.
Una imagen de producción: mínima y fijada
Una imagen más pequeña se transfiere más rápido, arranca más rápido y ofrece menos código explotable; una imagen fijada se reconstruye de forma idéntica dentro de seis meses. El multi-stage de la lección 3 hace la mayor parte del trabajo; queda por elegir la base final.
| Base | Tamaño orientativo | Contenido | Diagnóstico |
|---|---|---|---|
debian:12 | ~120 MB | Distribución completa | Cómodo |
python:3.12-slim | ~45 MB | Debian reducida, glibc | Shell disponible |
alpine:3.20 | ~8 MB | musl en lugar de glibc | Shell sh |
distroless | ~20 MB | Ni shell ni gestor de paquetes | Sin exec |
scratch | 0 | Absolutamente nada | Ninguno |
La elección no se resume en «gana la más pequeña». Alpine impone musl, que rompe ciertos binarios precompilados y modifica el comportamiento de la resolución DNS; las variantes -slim suelen ofrecer la mejor relación entre peso y tranquilidad. Distroless y scratch dan la menor superficie de ataque a costa del diagnóstico: sin shell, docker exec deja de servir y hay que confiar por completo en los logs y las métricas.
En cuanto a la fijación, el tag no basta en producción: un tag es móvil, un digest no lo es.
# Un tag puede apuntar a un contenido distinto mañana
FROM node:22-slim
# Un digest designa un contenido exacto, para siempre
FROM node:22-slim@sha256:d3e2b17e0f5a9b1d9c8f6e5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c
El digest que hay que fijar se obtiene con docker inspect node:22-slim --format '{{index .RepoDigests 0}}'.
Por último, si sus servidores y sus equipos no tienen la misma arquitectura —caso habitual desde los Mac con Apple Silicon—, construya imágenes multiarquitectura con docker buildx build --platform linux/amd64,linux/arm64 --push. El --push no es opcional: una imagen así es un índice que referencia varios manifiestos, una estructura que la caché local clásica no sabe almacenar.
Healthchecks: distinguir «arrancado» de «listo»
Sin healthcheck, Docker solo conoce un estado útil: el proceso está en marcha o no lo está. Pero un proceso en marcha puede estar bloqueado por un cerrojo, haber perdido su conexión con la base de datos o no haber terminado de cargar su caché.
# --interval : frecuencia de las comprobaciones en régimen normal
# --timeout : pasado este tiempo, la comprobación cuenta como fallo
# --start-period : periodo de gracia al arrancar, los fallos no se cuentan
# --retries : fallos consecutivos antes del estado "unhealthy"
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
La elección del comando de prueba importa. curl -f es el reflejo habitual, pero supone que curl esté instalado —falso en una imagen mínima, e instalarlo solo para el healthcheck amplía la superficie de ataque—. Usar el entorno de ejecución que ya está presente, como arriba con Node, evita este dilema.
El --start-period es el parámetro que más se descuida y el que provoca más falsos positivos. Una aplicación Java, o una que aplica migraciones al arrancar, puede tardar treinta segundos en responder: sin periodo de gracia, se la declara unhealthy, se reinicia y nunca llega a arrancar. Se observa un bucle de reinicios sin causa aparente.
Por el lado de la aplicación, el endpoint debe comprobar algo real. Una ruta que responde 200 sin probar nada no detecta ninguna avería.
app.get('/health', async (req, res) => {
try {
await db.query('SELECT 1'); // ¿responde realmente la base de datos?
await cache.ping(); // ¿y la caché?
res.status(200).json({ status: 'ok' });
} catch (err) {
res.status(503).json({ status: 'degraded', error: err.message });
}
});
docker ps # el estado de salud aparece en la columna STATUS
# Historial de comprobaciones, con la salida de cada intento:
# indispensable para entender POR QUÉ un servicio está unhealthy
docker inspect --format '{{json .State.Health}}' mon-api
Un apunte de honestidad: Docker por sí solo no hace nada con un contenedor unhealthy, no lo reinicia. El estado sirve a Compose (condition: service_healthy), a los orquestadores y a su monitorización, que es quien debe dar la alerta.
Límites de recursos: impedir que un contenedor mate a los demás
Sin límite, un contenedor puede consumir toda la memoria y toda la CPU del host. El kernel acaba invocando a su asesino de procesos (OOM killer), y nada garantiza que elija al culpable: a menudo cae el proceso más voraz, es decir, su base de datos.
docker run -d \
--memory 512m \ # tope de memoria estricto
--memory-swap 512m \ # igual a --memory: prohíbe cualquier swap
--memory-reservation 256m \ # límite flexible, aplicado bajo presión
--cpus 1.5 \ # 1,5 núcleos como máximo
--pids-limit 200 \ # número máximo de procesos (anti fork bomb)
--restart unless-stopped \
mon-api:1.2.3
El ajuste de --memory-swap merece una explicación. Si no se especifica, Docker permite un volumen de swap igual al límite de memoria: un proceso con fuga ralentiza entonces enormemente la máquina en lugar de ser detenido. Fijar ambos al mismo valor prohíbe el swap y hace que el contenedor falle con claridad, algo casi siempre preferible, porque un fallo limpio se detecta, mientras que una lentitud difusa no.
En Compose, estos límites se definen mediante deploy.resources, que Compose v2 respeta incluso fuera de un clúster:
services:
api:
image: registre.exemple.com/equipe/mon-api:1.2.3
deploy:
resources:
limits:
cpus: "1.5"
memory: 512M
reservations:
memory: 256M
pids_limit: 200
restart: unless-stopped
¿Cómo elegir los valores? Observe el consumo con carga normal mediante docker stats y añada después un margen del 50 al 100 %. Y tenga presente la firma de un tope demasiado bajo: el código de salida 137, es decir, 128 + 9, un SIGKILL enviado por el kernel.
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}'
docker inspect mon-api --format '{{.State.ExitCode}} {{.State.OOMKilled}}'
Registro de logs: la primera causa de disco lleno
El driver por defecto, json-file, no aplica ninguna rotación. Una aplicación habladora escribe indefinidamente en /var/lib/docker/containers/<id>/<id>-json.log. En un servidor que lleva seis meses funcionando, ese único archivo puede alcanzar varias decenas de gigabytes y llenar la partición, lo que deja de rodillas a todos los demás servicios de la máquina.
La corrección se puede escribir por contenedor (--log-opt max-size=10m --log-opt max-file=3, es decir, tres archivos de 10 MB como máximo), pero la buena práctica es establecerla de una vez por todas a nivel del daemon, de modo que el valor por defecto sea sano para todo lo que se lance en la máquina.
// /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"live-restore": true
}
Un sudo systemctl reload docker aplica el archivo, y docker info --format '{{.LoggingDriver}}' confirma que se ha tenido en cuenta.
Conviene conocer live-restore: permite que los contenedores sigan funcionando durante un reinicio del daemon, lo que hace las actualizaciones de Docker mucho menos angustiosas. Eso sí, atención: la configuración del daemon solo se aplica a los contenedores creados después de la recarga; los existentes conservan la suya.
Los demás drivers responden a necesidades de centralización: local (más compacto), journald, syslog, fluentd, gelf, awslogs. Algo que hay que saber antes de cambiar: con la mayoría de ellos, docker logs deja de funcionar, porque el motor ya no conserva nada localmente. Solo json-file, local y journald lo permiten.
Dos reglas del lado de la aplicación, de las que depende la utilidad de todo lo demás. Escriba en la salida estándar, nunca en un archivo interno del contenedor. Y escriba en JSON estructurado en lugar de texto libre: con cincuenta contenedores, la diferencia entre unos logs aprovechables y un muro de texto se juega ahí.
Parada limpia: el detalle que causa los cortes de servicio
docker stop envía SIGTERM al proceso PID 1, espera diez segundos y después envía SIGKILL. Si su aplicación no trata SIGTERM, cada despliegue corta bruscamente las peticiones en curso. Tres causas posibles, tres correcciones.
Primera causa: la forma shell de CMD. El PID 1 es entonces /bin/sh, que no retransmite las señales.
# PID 1 = sh. Su aplicación NUNCA recibe SIGTERM.
CMD node server.js
# PID 1 = node. La señal llega.
CMD ["node", "server.js"]
Segunda causa: la aplicación no gestiona la señal.
process.on('SIGTERM', () => {
server.close(async () => { // no más conexiones nuevas
await db.end(); // cierre limpio del pool
process.exit(0);
});
setTimeout(() => process.exit(1), 25_000); // red de seguridad
});
Tercera causa: los procesos zombi. Un verdadero PID 1 debe recoger los procesos huérfanos, algo que la mayoría de las aplicaciones no hacen. La opción --init inserta un gestor mínimo (tini) que se encarga de ello y retransmite las señales.
services:
api:
image: registre.exemple.com/equipe/mon-api:1.2.3
init: true
# Plazo de gracia: debe superar la duración de su parada limpia
stop_grace_period: 30s
# Cronometrar la parada. Cerca de 10 s (o de stop_grace_period) =>
# la señal no se está tratando. Menos de un segundo => correcto.
time docker stop mon-api
Registro privado: subir y descargar sus imágenes
En producción no se construye en el servidor: se descarga una imagen ya construida y probada. Eso supone un registro.
# --password-stdin evita que el secreto aparezca en
# el historial del shell o en la salida de "ps".
echo "$REGISTRY_TOKEN" | docker login registre.exemple.com \
-u ci-bot --password-stdin
docker tag mon-api:1.2.3 registre.exemple.com/equipe/mon-api:1.2.3
docker push registre.exemple.com/equipe/mon-api:1.2.3
docker logout registre.exemple.com
Una convención de etiquetado que conviene adoptar desde el principio: dos tags por construcción, uno legible y otro trazable.
DEPOT="registre.exemple.com/equipe/mon-api"
docker build -t "$DEPOT:1.2.3" -t "$DEPOT:$(git rev-parse --short HEAD)" .
docker push --all-tags "$DEPOT"
El tag del commit responde a la única pregunta que importa a las 3 de la madrugada: ¿qué código exacto se está ejecutando en producción? En el servidor, el despliegue se reduce entonces a tres comandos.
docker compose pull # descargar la nueva imagen
docker compose up -d # recrear los servicios cuya imagen ha cambiado
docker compose ps
# Tocar un solo servicio, sin reiniciar sus dependencias
docker compose up -d --no-deps api
Seamos claros sobre los límites: esta operación recrea el contenedor y, por tanto, corta brevemente el servicio. Un despliegue sin interrupción requiere dos instancias detrás de un balanceador de carga, lo cual queda fuera del alcance de Compose por sí solo. Para la mayoría de los servicios, un corte de uno o dos segundos es aceptable; cuando no lo es, es la señal de que se ha vuelto necesario un orquestador.
Analizar las vulnerabilidades y endurecer la ejecución
Una imagen incluye cientos de paquetes de los que usted solo ha elegido un puñado. Se publican vulnerabilidades continuamente, también sobre una imagen que no ha modificado en seis meses; de ahí la necesidad de reconstruir con regularidad, y no solo cuando cambia el código.
# Resumen: número de vulnerabilidades por nivel de gravedad
docker scout quickview mon-api:1.2.3
# Detalle, filtrado por los niveles que exigen una acción
docker scout cves --only-severity critical,high mon-api:1.2.3
# Sugerencias de actualización de la imagen base
docker scout recommendations mon-api:1.2.3
Trivy, una herramienta libre muy extendida, se usa en contenedor y encaja bien en la integración continua. Una advertencia sobre este comando: montar /var/run/docker.sock da al contenedor el control completo del daemon y, por tanto, de la máquina. Es aceptable para una herramienta elegida, en un agente de build dedicado; nunca es algo trivial.
# --exit-code 1 hace fallar el comando si quedan vulnerabilidades
# del nivel solicitado: así es como se bloquea una cadena de entrega.
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image \
--severity HIGH,CRITICAL --exit-code 1 \
mon-api:1.2.3
El endurecimiento de la ejecución complementa útilmente el análisis: estas opciones reducen lo que un atacante puede hacer incluso en caso de fallo de la aplicación.
docker run -d \
--read-only \ # archivos en solo lectura
--tmpfs /tmp:rw,noexec,nosuid,size=64m \ # salvo /tmp, en memoria
--cap-drop ALL \ # retirar todas las capacidades
--cap-add NET_BIND_SERVICE \ # y devolver solo la útil
--security-opt no-new-privileges \ # sin elevación mediante setuid
--user 1001:1001 \ # nunca root
mon-api:1.2.3
El --read-only es especialmente eficaz: impide que un atacante escriba un binario en el contenedor. Solo exige identificar las pocas rutas en las que la aplicación necesita poder escribir y montarlas por separado.
Una pila de producción completa
Juntemos todo en un archivo que se pueda usar como complemento del compose.yaml de desarrollo.
# compose.prod.yaml
# Uso: docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
services:
api:
# Imagen del registro, fijada: ningún build en el servidor
image: registre.exemple.com/equipe/mon-api:${VERSION:?VERSION est obligatoire}
build: !reset null # neutraliza el build heredado del archivo base
restart: unless-stopped
init: true
stop_grace_period: 30s
read_only: true
tmpfs:
- /tmp:size=64m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
user: "1001:1001"
environment:
NODE_ENV: production
DATABASE_URL: postgres://app:${DB_PASSWORD:?}@db:5432/appdb
ports:
- "127.0.0.1:3000:3000" # un proxy en el frontal se encargará de la exposición pública
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 3s
retries: 3
start_period: 20s
deploy:
resources:
limits:
cpus: "1.5"
memory: 512M
reservations:
memory: 256M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
- frontend
- backend
db:
image: postgres:16-alpine
restart: unless-stopped
stop_grace_period: 1m # dejar que Postgres termine su punto de control
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD:?}
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
deploy:
resources:
limits:
memory: 1G
networks:
- backend # nunca accesible desde el exterior
networks:
frontend:
backend:
internal: true
volumes:
db-data:
# Verificar siempre la fusión antes de desplegar
docker compose -f compose.yaml -f compose.prod.yaml config
VERSION=1.2.3 docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
Fíjese en ${VERSION:?...}: un despliegue sin versión explícita falla de inmediato en lugar de usar silenciosamente latest. Y restart: unless-stopped en vez de always: el matiz es que un contenedor detenido voluntariamente no se reinicia tras un reinicio del servidor, lo que evita reactivar un servicio que se ha puesto fuera de línea a propósito.
Qué vigilar a partir de ahora
El despliegue no es el final. Primero, el espacio en disco, porque es la avería más frecuente. Después, el estado de salud y el contador de reinicios, porque un valor que no deja de crecer señala un problema que quizá ninguna alerta notifique. Por último, el consumo respecto a los límites fijados, para detectar una deriva antes de que ocurra el incidente.
docker system df ; df -h /var/lib/docker
docker ps --format 'table {{.Names}}\t{{.Status}}'
docker inspect mon-api --format '{{.RestartCount}}'
Queda la antigüedad de las imágenes: una imagen que no se ha reconstruido en meses acumula las vulnerabilidades de sus dependencias aunque su código no haya cambiado. Reconstruir y volver a desplegar periódicamente, sin cambios funcionales, es una práctica de mantenimiento, no una pérdida de tiempo.
Por último, la limpieza automatizada exige prudencia. docker system prune -af en un servidor elimina las imágenes no utilizadas, incluida la versión anterior a la que querría volver si surge un problema. Es preferible usar un comando específico como docker image prune -a --filter "until=720h" --force, que solo afecta a las imágenes sin usar desde hace más de treinta días.
Conclusiones
- Una imagen de producción es mínima y fijada: multi-stage, base
-slimo distroless según la necesidad de diagnóstico, y referencia por digest en lugar de por tag móvil. - Un healthcheck con un
--start-periodrealista distingue entre «iniciado» y «listo». El endpoint debe comprobar la base de datos y la caché; de lo contrario, no detecta nada. Docker no reinicia un contenedorunhealthy: es una señal para Compose y para su sistema de supervisión. - Fije siempre
--memoryy--cpus. Sin límite, una fuga de memoria en un contenedor hace caer a sus vecinos. El código de salida137significa «terminado por superar la memoria». - Configure la rotación de logs en
/etc/docker/daemon.json, una vez por todas. El valor por defecto dejson-filesin rotación es la primera causa de disco lleno en un servidor Docker. - Compruebe que
docker stoptarda menos de un segundo. Si no, su aplicación no gestionaSIGTERM—forma shell deCMDo manejador de señales ausente— y cada despliegue corta las peticiones en curso. - No construya en el servidor de producción: publique una imagen etiquetada tanto con su versión como con su commit y, después, ejecute
docker compose pull && docker compose up -d. - Analice sus imágenes (
docker scout cves, Trivy) y reconstrúyalas periódicamente, incluso sin cambios de código. Refuerce la ejecución con--read-only,--cap-drop ALL,no-new-privilegesy un usuario no root.
Para ir más allá: cuando un único host ya no es suficiente —alta disponibilidad, despliegue progresivo sin cortes, escalado automático— entramos en el terreno de los orquestadores. Todo lo que ha aprendido aquí sigue siendo válido: Kubernetes consume las mismas imágenes OCI y espera exactamente los mismos healthchecks, límites de recursos y logs en la salida estándar. La referencia completa de las opciones sigue estando en docs.docker.com.
Comentarios
Los comentarios funcionan con GitHub Discussions
Inicia sesión con GitHub para participar en la discusión