Aurora Libros funciona en tu máquina, y funciona bien. Producción es otra cosa: allí nadie reinicia el contenedor a mano cuando se cuelga, la configuración cambia entre entornos y una petición cortada a mitad es un pedido perdido. Esta lección convierte tu imagen 1.3.0 en la 2.0.0: la que un orquestador puede arrancar, matar y volver a arrancar cien veces al día sin que un cliente se entere.
Contenido
- Qué distingue una imagen de producción
- Los doce factores aplicados a la configuración
- Validar la configuración al arrancar y fallar rápido
- PID 1 y el problema de los zombis
- Un proceso por contenedor
- El apagado ordenado completo
- El periodo de gracia y la petición más larga
- Las tres sondas: liveness, readiness y startup
/salud/vivoy/salud/listoenaurora-api- Reproducibilidad: digests, lockfile y etiquetas OCI
- Elegir límites de recursos con datos reales
- El límite de memoria y el heap de Node
- El
Dockerfiledefinitivo de Aurora Libros - El
compose.prod.yamlde la versión 2.0.0 - Checklist de quince puntos
Advertencia. Los valores de límites, plazos y sondas de esta lección son puntos de partida razonados con datos de Aurora Libros, no una política. Los umbrales de recursos, las ventanas de apagado y el tratamiento de secretos en producción deben validarse con el responsable de infraestructura, seguridad o compliance de tu organización.
- Qué distingue una imagen de producción
No es una diferencia de tecnología: es una diferencia de supuestos. En desarrollo supones que hay alguien mirando; en producción supones que no.
| Aspecto | Imagen de desarrollo | Imagen de producción |
|---|---|---|
| Base | node:22 (~1,1 GB), con git, compiladores |
node:22-alpine fijada por digest |
| Dependencias | npm install, incluye devDependencies |
npm ci --omit=dev con lockfile |
| Código | Bind mount desde el host | Copiado dentro de la imagen |
| Recarga | nodemon, --watch |
Sin recarga: el proceso se reemplaza |
| Configuración | .env en el repositorio |
Variables de entorno del orquestador |
| Secretos | Texto plano cómodo | Ficheros en /run/secrets/, patrón _FILE |
| Usuario | root (da igual) |
node (UID 1000), sin privilegios |
| Sistema de ficheros | Escribible | read_only con tmpfs mínimos |
| Logs | Coloreados, nivel debug |
JSON en una línea, nivel info |
| Errores | Stack trace al cliente | Mensaje genérico; el detalle, al log |
| Apagado | Ctrl+C y ya |
SIGTERM capturado, drenaje ordenado |
| Salud | Ninguna | Tres sondas separadas |
| Arranque con config inválida | Falla a la primera petición | Falla al arrancar, en el segundo 0 |
| Reproducibilidad | "Funciona hoy" | El mismo digest hoy y en seis meses |
Las cuatro últimas filas separan una imagen que funciona de una que se puede operar; las tres primeras ya las resolviste en el módulo 5.
- Los doce factores aplicados a la configuración
La regla del tercer factor de la metodología twelve-factor es tajante: la configuración vive en el entorno, nunca en la imagen. La prueba práctica es la pregunta del código abierto: ¿podrías publicar esta imagen ahora mismo en un registro público sin filtrar nada? Si la respuesta es no, tienes configuración dentro.
De ahí se deriva la propiedad más valiosa de todo el módulo: una imagen, muchos entornos. El mismo digest que pasó las pruebas en CI es el que corre en staging y el que corre en producción; lo único que cambia son las variables que le inyectas. Si reconstruyeras la imagen para producción, estarías desplegando un artefacto que nadie ha probado. Lo que sigue es cómo se reparte cada cosa.
| Tipo de dato | Dónde va | Ejemplo en Aurora Libros |
|---|---|---|
| Constante de la aplicación | En la imagen | Rutas de las vistas, versión de la API |
| Configuración por entorno | Variable de entorno | DB_HOST, PORT, LOG_NIVEL |
| Secreto | Fichero montado + patrón _FILE |
DB_PASSWORD_FILE=/run/secrets/db_password |
| Estado | Volumen o servicio externo | aurora-datos, aurora-cache |
- Validar la configuración al arrancar y fallar rápido
El peor fallo de configuración es el silencioso: el contenedor arranca, se declara sano, recibe tráfico y entonces descubre que DB_PASSWORD estaba vacía. Para cuando lo sabes, el balanceador ya le ha mandado clientes.
La solución es fail fast: validar todo al arrancar y salir con código distinto de cero si algo falta.
// api/src/config.js — única puerta de entrada a la configuración
const fs = require('node:fs');
function leer(nombre, { obligatorio = false, defecto } = {}) {
const ruta = process.env[`${nombre}_FILE`]; // patrón _FILE: gana sobre la variable directa
if (ruta) {
try { return fs.readFileSync(ruta, 'utf8').trim(); }
catch (e) { throw new Error(`${nombre}_FILE apunta a ${ruta}, ilegible: ${e.code}`); }
}
const valor = process.env[nombre];
if (valor) return valor;
if (defecto !== undefined) return defecto;
if (obligatorio) throw new Error(`Falta la variable obligatoria ${nombre} (o su variante _FILE)`);
}
function entero(nombre, defecto, { min = 1, max = 65535 } = {}) {
const bruto = leer(nombre, { defecto: String(defecto) });
const n = Number(bruto);
if (!Number.isInteger(n) || n < min || n > max) throw new Error(`${nombre}="${bruto}" no es un entero entre ${min} y ${max}`);
return n;
}
let config;
try {
config = {
puerto: entero('PORT', 3000),
version: leer('APP_VERSION', { defecto: '0.0.0-dev' }),
gracia: entero('PLAZO_APAGADO_MS', 15000, { min: 1000, max: 120000 }),
db: {
host: leer('DB_HOST', { obligatorio: true }),
usuario: leer('DB_USER', { obligatorio: true }),
password: leer('DB_PASSWORD', { obligatorio: true }),
base: leer('DB_NAME', { obligatorio: true }),
maxPool: entero('DB_POOL_MAX', 10, { min: 1, max: 200 }),
},
redis: { host: leer('REDIS_HOST', { obligatorio: true }), ttl: entero('CACHE_TTL', 60, { min: 1, max: 86400 }) },
};
} catch (e) {
process.stderr.write(JSON.stringify({ ts: new Date().toISOString(), nivel: 'error',
servicio: 'aurora-api', mensaje: 'configuracion invalida', detalle: e.message }) + '\n');
process.exit(78); // EX_CONFIG de sysexits.h: "error de configuración"
}
module.exports = config;Tres decisiones importantes. La primera: nada del resto del código lee process.env; todos importan config.js, así que existe un único sitio donde saber qué configura la aplicación. La segunda: el _FILE gana sobre la variable directa, de modo que el mismo código sirve para desarrollo (variable) y para producción (secreto montado). La tercera: el código de salida 78 no es decorativo; distinguirlo del 1 genérico permite que el orquestador —y tú, leyendo docker inspect— sepan que no es un fallo transitorio y que reiniciar mil veces no lo va a arreglar. Lo comprobarás en el ejercicio 1: el fallo llega en el segundo 0, con el nombre exacto de la variable que falta, en vez de un 502 a las tres de la madrugada.
- PID 1 y el problema de los zombis
En 03-02 viste que el proceso principal del contenedor es el PID 1. Producción añade un matiz que en desarrollo no molesta: en Linux, el PID 1 tiene dos responsabilidades especiales del proceso init.
- Adoptar huérfanos. Cuando un proceso muere dejando hijos, esos hijos pasan a colgar del PID 1.
- Recoger zombis. Un proceso terminado permanece en estado
Zhasta que su padre llama await()para leer su código de salida. Si nadie lo hace, la entrada nunca se libera.
Node.js no hace lo segundo: no es un init. Si tu API lanza subprocesos —una conversión de imagen, un pg_dump—, cada uno deja un zombi que ocupa una entrada de la tabla de procesos, y los cuentas con docker compose exec aurora-api ps -eo stat | grep -c Z. Con pids_limit: 200, doscientos zombis y el contenedor no puede crear un proceso más. La solución es un init mínimo delante:
| Opción | Cómo | Cuándo |
|---|---|---|
--init / init: true |
Docker inyecta docker-init (tini) como PID 1 |
Por defecto: cero cambios en la imagen |
tini en la imagen |
ENTRYPOINT ["/sbin/tini","--"] |
Cuando no controlas el runtime (Kubernetes) |
dumb-init |
Igual, alternativa histórica | Equivalente |
| Ninguno | El proceso es PID 1 | Si nunca lanza subprocesos y captura señales |
Un detalle que cuesta caro: el PID 1 tiene desactivadas las acciones por defecto de las señales. Si tu proceso no instala un manejador de SIGTERM, la señal se ignora, docker stop espera diez segundos y te lo mata con SIGKILL. Ese es el origen real de la mayoría de los "mi contenedor tarda diez segundos en parar".
- Un proceso por contenedor
La tentación de meter supervisord, systemd o un cron dentro del contenedor aparece en cuanto necesitas una segunda cosa. Resístete, por razones concretas:
- El estado se vuelve opaco. Docker solo ve el gestor. Si Node muere y
supervisordsigue vivo, el contenedor está "sano" con la aplicación caída. - Los reinicios dejan de funcionar.
restart: alwaysy el orquestador reaccionan a la muerte del PID 1, que ya no ocurre nunca. - Los logs se mezclan y pierden su origen;
docker logsdeja de ser útil. - El escalado se acopla. Si la API necesita tres réplicas y el
cronuna, no puedes. - La imagen engorda y su superficie de ataque crece.
Las tareas periódicas de Aurora Libros —la copia de la base de datos de 05-02— son un servicio aparte con su propio ciclo de vida, no un cron escondido. La única excepción legítima es el patrón sidecar: dos contenedores distintos que comparten red o volumen, cada uno con su PID 1.
- El apagado ordenado completo
Cuando el orquestador retira una réplica manda SIGTERM y arranca un cronómetro. Lo que hagas en esos segundos decide si los clientes que estaban comprando terminan su compra o ven un error.
// api/src/server.js — apagado ordenado (fragmento final)
const servidor = app.listen(config.puerto, () =>
log.info('escuchando', { puerto: config.puerto, version: config.version }));
servidor.keepAliveTimeout = 5000; // sin esto, una conexión ociosa lo mantiene abierto
servidor.headersTimeout = 6000;
let apagando = false;
estado.listo = true; // a partir de aquí, /salud/listo responde 200
async function apagar(senal) {
if (apagando) return; // idempotente: dos SIGTERM no rompen nada
apagando = true;
estado.listo = false; // 1) fallar readiness YA: no llega tráfico nuevo
log.info('apagado iniciado', { senal, plazo_ms: config.gracia });
await new Promise(r => setTimeout(r, 5000)); // 2) margen para que el balanceador se entere
await new Promise((resolve) => { // 3) cerrar listener y drenar lo que hay en curso
servidor.close(resolve);
setTimeout(() => { log.warn('conexiones en curso forzadas'); resolve(); }, config.gracia - 7000);
});
// 4) cerrar dependencias en orden inverso al de apertura
try { await redis.quit(); } catch (e) { log.warn('redis.quit fallo', { detalle: e.message }); }
try { await pool.end(); } catch (e) { log.warn('pool.end fallo', { detalle: e.message }); }
log.info('apagado completo');
process.exit(0);
}
process.on('SIGTERM', () => apagar('SIGTERM'));
process.on('SIGINT', () => apagar('SIGINT'));
process.on('unhandledRejection', (e) => { log.error('promesa sin capturar', { detalle: String(e) }); apagar('rechazo'); });El paso 2 es el que casi todo el mundo se salta y el que más errores evita. Entre que tu Pod deja de estar listo y que el balanceador deja de mandarle tráfico pasan unos segundos: la actualización de las tablas de enrutado no es instantánea. Si cierras el listener inmediatamente, las peticiones que ya iban de camino se estrellan contra un puerto cerrado. Esperar unos segundos con el listener abierto pero la readiness en rojo elimina esa ventana.
El orden del paso 4 también importa: primero Redis y después PostgreSQL, al revés de como se abrieron, porque una petición en curso puede necesitar la base de datos después de fallar en la caché.
- El periodo de gracia y la petición más larga
La regla es aritmética:
Para Aurora Libros, con la petición más lenta medida en 4 s (el listado completo sin caché), 5 s de espera de readiness y ~1 s de cierre de conexiones: 5 + 4 + 1 = 10 s, y se configuran 15 s para tener margen.
| Sitio | Clave | Valor en Aurora Libros |
|---|---|---|
| Aplicación | PLAZO_APAGADO_MS |
15000 |
| Compose | stop_grace_period |
20s |
| Dockerfile | STOPSIGNAL |
SIGTERM (por defecto) |
| Kubernetes | terminationGracePeriodSeconds |
30 (06-05) |
Fíjate en que el plazo de la plataforma es siempre mayor que el de la aplicación: quien debe decidir terminar es tu código, no el SIGKILL. Si Docker mata el proceso antes de que acabe, el apagado ordenado no ha servido de nada.
- Las tres sondas: liveness, readiness y startup
El HEALTHCHECK de 02-04 respondía a una sola pregunta. En producción hay tres, y confundirlas provoca caídas espectaculares.
| Sonda | Pregunta | Si falla | Debe comprobar | NO debe comprobar |
|---|---|---|---|---|
| Liveness | ¿El proceso sigue vivo y sano? | Se reinicia el contenedor | Que el bucle de eventos responde | Dependencias externas |
| Readiness | ¿Puede atender peticiones ahora? | Se le quita el tráfico, sin reiniciar | La BD, la caché, el calentamiento | Nada caro ni lento |
| Startup | ¿Ha terminado de arrancar? | Se reinicia (tras muchos intentos) | Lo mismo que liveness | — |
El error clásico es usar el mismo endpoint para las tres, y su consecuencia es una caída total en cascada. Imagina que /salud comprueba PostgreSQL, como el tuyo del módulo 5, y que la base de datos se cae treinta segundos por un failover:
- La readiness falla en las tres réplicas: correcto, no tiene sentido mandarles tráfico.
- La liveness también falla, porque es el mismo endpoint.
- El orquestador reinicia las tres réplicas de la API.
- La base de datos vuelve, pero las réplicas están arrancando desde cero.
- Las tres se reconectan a la vez, saturan el pool y la liveness vuelve a fallar.
Has convertido una incidencia de 30 segundos en un bucle de reinicios. La regla que lo evita es simple: la liveness no comprueba dependencias. Reiniciar tu proceso no arregla una base de datos caída; lo único que hace es empeorarlo.
La sonda de startup resuelve un problema distinto: si arrancar tarda 40 s (migraciones, calentamiento de caché) y la liveness tiene un umbral de 10 s, el contenedor se reinicia eternamente sin llegar nunca a arrancar. La startup suspende a las otras dos hasta que pasa por primera vez.
/salud/vivo y /salud/listo en aurora-api
/salud/vivo y /salud/listo en aurora-api// api/src/salud.js — tres endpoints, tres semánticas
const estado = { listo: false, arrancadoEn: Date.now() };
function montar(app, { pool, redis, config }) {
// LIVENESS: no toca nada externo. Si Node responde, Node está vivo.
app.get('/salud/vivo', (req, res) =>
res.json({ estado: 'vivo', version: config.version, uptime_s: Math.round(process.uptime()) }));
// READINESS: sí comprueba dependencias, con timeout corto y sin efectos
app.get('/salud/listo', async (req, res) => {
if (!estado.listo) return res.status(503).json({ estado: 'apagando' });
const limite = (p, ms) => Promise.race([p, new Promise((_, k) => setTimeout(() => k(new Error('timeout')), ms))]);
const control = {};
try { await limite(pool.query('SELECT 1'), 2000); control.db = 'ok'; } catch (e) { control.db = `error: ${e.message}`; }
try { await limite(redis.ping(), 1000); control.cache = 'ok'; } catch (e) { control.cache = `error: ${e.message}`; }
// La caché degradada NO impide servir: se sirve desde la BD, más lento pero correcto
const sano = control.db === 'ok';
res.status(sano ? 200 : 503).json({ estado: sano ? 'listo' : 'degradado', control });
});
// STARTUP: listo cuando el arranque terminó; el orquestador deja de esperar
app.get('/salud/arrancado', (req, res) =>
res.status(estado.listo ? 200 : 503).json({ estado: estado.listo ? 'arrancado' : 'arrancando' }));
}
module.exports = { estado, montar };La decisión de negocio está en la penúltima línea: Redis caído no quita a la réplica del balanceo, porque el cache-aside de Aurora Libros degrada a origen: db y sigue vendiendo libros, más lento. PostgreSQL caído sí, porque sin catálogo no hay nada que servir. Esa distinción entre dependencia dura y blanda es tuya, no de Docker, y conviene escribirla en el código junto a la sonda.
- Reproducibilidad: digests, lockfile y etiquetas OCI
Que la imagen de hoy y la de dentro de seis meses sean la misma no es purismo: es la única forma de que un rollback sirva de algo.
| Fuente de deriva | Solución | Verificación |
|---|---|---|
| La base cambia | FROM node:22-alpine@sha256:... |
docker buildx imagetools inspect |
| Un paquete npm sube de versión | npm ci (nunca npm install) |
package-lock.json en el repositorio |
| Paquetes del sistema | Versión fijada en apk add |
Reconstruir y comparar digest |
| No saber qué commit corre | Etiquetas OCI con el SHA | docker inspect --format '{{json .Config.Labels}}' |
| Marcas de tiempo | SOURCE_DATE_EPOCH |
Dos builds, mismo digest |
docker buildx imagetools inspect node:22-alpine --format '{{.Manifest.Digest}}' # se fija en el FROM
docker inspect auroralibros/aurora-api:2.0.0 \
--format '{{index .Config.Labels "org.opencontainers.image.revision"}}' # qué commit corre
- Elegir límites de recursos con datos reales
Poner memory: 2G "por si acaso" tiene dos costes: el orquestador reserva memoria que nadie usa y cabe menos en cada nodo. Poner poco tiene otro: OOM kill con código 137 en hora punta. Se mide con quince minutos de muestreo bajo carga representativa, volcando docker stats --no-stream --format '{{.Name}};{{.MemUsage}};{{.CPUPerc}}' en bucle a un CSV.
| Servicio | Memoria reposo | Memoria pico | CPU media | CPU pico | Límite elegido |
|---|---|---|---|---|---|
aurora-api |
78 MiB | 214 MiB | 4 % | 96 % | 512M / 1.0 CPU |
aurora-db |
96 MiB | 380 MiB | 6 % | 140 % | 1G / 2.0 CPU |
aurora-cache |
12 MiB | 208 MiB | 1 % | 15 % | 256M / 0.5 CPU |
aurora-web |
6 MiB | 22 MiB | 1 % | 30 % | 64M / 0.5 CPU |
Las reglas que aplican esos números:
- Memoria: pico observado × 2 aproximadamente, redondeando a una potencia cómoda. La memoria no es comprimible: pasarse del límite significa muerte, no lentitud.
- CPU: no ahogues los picos. La CPU sí es comprimible; un límite bajo solo produce latencia.
aurora-dbrecibe 2 CPU porque unVACUUMo unpg_dumpdeben poder correr. aurora-cachecon--maxmemory 200mbnecesita un límite de contenedor por encima de esos 200 MB: Redis usa memoria además de la de los datos.256Mes el mínimo defendible.- Deja siempre las
requestspor debajo de loslimitsen Kubernetes (06-05): lasrequestsreservan y deciden dónde cabe el Pod; loslimitscortan.
- El límite de memoria y el heap de Node
Aquí hay una trampa concreta. El heap de V8 tiene su propio máximo, independiente del cgroup. Si Node cree que puede usar 4 GB y el contenedor corta en 512 MB, el proceso muere por OOM sin lanzar ninguna excepción: el kernel lo mata antes de que el recolector de basura decida que hay presión. Compruébalo con docker run --rm --memory 512m node:22-alpine node -p 'v8.getHeapStatistics().heap_size_limit/1048576'.
Node 22 lee el cgroup y ajusta el límite razonablemente, pero conviene ser explícito, sobre todo porque el heap no es toda la memoria del proceso: los buffers, el código nativo y la pila viven fuera de él. La regla práctica es dejar al heap el 75 % del límite del contenedor, que es de dónde sale el ENV NODE_OPTIONS="--max-old-space-size=384" del Dockerfile. Con esa configuración, cuando la aplicación se acerque al techo, V8 recolectará agresivamente y, si de verdad no cabe, lanzará un heap out of memory que puedes registrar en vez de un 137 mudo.
- El
Dockerfile definitivo de Aurora Libros
Dockerfile definitivo de Aurora Libros# syntax=docker/dockerfile:1.7
# api/Dockerfile — Aurora Libros 2.0.0
ARG NODE_DIGEST=sha256:9c8f1b1e0c9d2a3e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c
FROM node:22-alpine@${NODE_DIGEST} AS deps # 1. solo dependencias de producción
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked npm ci --omit=dev
FROM node:22-alpine@${NODE_DIGEST} AS deps-dev # 2. dependencias completas
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked npm ci
FROM deps-dev AS pruebas # 3. etapa objetivo del pipeline (06-02)
COPY . .
RUN npm run lint && npm test -- --run
FROM node:22-alpine@${NODE_DIGEST} AS runtime # 4. imagen final
ARG VERSION=2.0.0
ARG REVISION=desconocida
ARG FECHA_BUILD
LABEL org.opencontainers.image.title="aurora-api" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.created="${FECHA_BUILD}" \
org.opencontainers.image.source="https://github.com/auroralibros/aurora-libros" \
org.opencontainers.image.base.name="docker.io/library/node:22-alpine"
ENV NODE_ENV=production APP_VERSION=${VERSION} PORT=3000 \
NODE_OPTIONS="--max-old-space-size=384"
WORKDIR /app
# --chown en el COPY evita una capa extra de RUN chown que duplicaría los ficheros
COPY --chown=node:node --from=deps /app/node_modules ./node_modules
COPY --chown=node:node package.json ./
COPY --chown=node:node src ./src
USER node
EXPOSE 3000
STOPSIGNAL SIGTERM
# La liveness también dentro de la imagen: sirve fuera de un orquestador
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
CMD node -e "require('http').get('http://127.0.0.1:3000/salud/vivo',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
# Sin shell: node es PID 1 y recibe SIGTERM directamente
CMD ["node", "src/server.js"]docker build -t auroralibros/aurora-api:2.0.0 --build-arg REVISION=$(git rev-parse --short HEAD) \
--build-arg FECHA_BUILD=$(date -u +%Y-%m-%dT%H:%M:%SZ) api/
docker image inspect auroralibros/aurora-api:2.0.0 --format '{{.Size}}' | numfmt --to=iec
# 104M (2 MB más que 1.3.0: el precio de las tres sondas y la validación)
- El
compose.prod.yaml de la versión 2.0.0
compose.prod.yaml de la versión 2.0.0# compose.prod.yaml — solo el servicio de la API; el resto sigue como en 05-03
services:
aurora-api:
image: auroralibros/aurora-api:2.0.0
init: true # tini como PID 1: recoge zombis
restart: unless-stopped
stop_grace_period: 20s # > PLAZO_APAGADO_MS (15 s)
read_only: true
tmpfs: [/tmp:size=32m,noexec,nosuid]
user: "1000:1000"
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
pids_limit: 200
environment:
DB_HOST: aurora-db
DB_USER: aurora
DB_NAME: aurora_libros
DB_PASSWORD_FILE: /run/secrets/db_password
DB_POOL_MAX: "10"
REDIS_HOST: aurora-cache
CACHE_TTL: "60"
PLAZO_APAGADO_MS: "15000"
LOG_NIVEL: info
secrets: [db_password]
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/salud/listo',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
interval: 10s
timeout: 3s
retries: 3
start_period: 20s
deploy:
resources:
limits: { memory: 512M, cpus: "1.0" }
reservations: { memory: 128M, cpus: "0.1" }
networks: [frontal, trasera]
depends_on:
aurora-db: { condition: service_healthy }
aurora-cache: { condition: service_healthy }El healthcheck de Compose usa /salud/listo y el HEALTHCHECK de la imagen usa /salud/vivo, a propósito: aquí la salud gobierna depends_on, es decir, "¿puedo mandarte tráfico?", que es exactamente la readiness.
- Checklist de quince puntos
| # | Control | Comprobación |
|---|---|---|
| 1 | Base fijada por digest | grep 'FROM.*@sha256' api/Dockerfile |
| 2 | npm ci con lockfile en el repositorio |
git ls-files package-lock.json |
| 3 | Sin devDependencies en la final |
docker run --rm IMG ls node_modules | wc -l |
| 4 | Corre como usuario sin privilegios | docker run --rm IMG id -u → 1000 |
| 5 | Cero configuración dentro de la imagen | docker history --no-trunc IMG | grep -i pass |
| 6 | Secretos por fichero con patrón _FILE |
DB_PASSWORD_FILE en el entorno |
| 7 | Falla al arrancar si falta configuración | Arrancar sin DB_PASSWORD → código 78 |
| 8 | PID 1 correcto (init: true o tini) |
docker exec IMG ps -eo pid,comm | head -2 |
| 9 | Un solo proceso principal | Sin supervisord ni cron en la imagen |
| 10 | SIGTERM capturado y apagado ordenado | time docker stop C → menos de 15 s |
| 11 | Tres sondas separadas y liveness sin dependencias | curl /salud/vivo con la BD parada → 200 |
| 12 | stop_grace_period > plazo de la aplicación |
20 s frente a 15 s |
| 13 | Límites medidos, no inventados | Tabla de docker stats documentada |
| 14 | Heap de Node coherente con el límite | NODE_OPTIONS al 75 % de memory |
| 15 | Etiquetas OCI con versión y commit | docker inspect --format '{{json .Config.Labels}}' |
Errores Comunes y Consejos
- Reconstruir la imagen para cada entorno. Si
stagingy producción tienen imágenes distintas, no has probado lo que despliegas. Un artefacto, un digest, muchos entornos. - La liveness comprueba la base de datos. Es el error más caro de esta lección: convierte una caída de la BD en un bucle de reinicios de toda la flota. La liveness solo responde "el proceso responde".
- Cerrar el listener nada más recibir SIGTERM. Sin la espera previa con la readiness en rojo, las peticiones ya enrutadas se pierden. Cinco segundos de margen eliminan casi todos los
502de un despliegue. CMD npm start.npmse convierte en PID 1, no reenvíaSIGTERMa Node y añade una capa inútil. Usa siempreCMD ["node", "src/server.js"].- Confiar en los valores por defecto de la configuración. Un
DB_HOSTcon defectolocalhostno falla: se conecta al sitio equivocado. Lo obligatorio no tiene defecto. stop_grace_periodmenor que el plazo interno. Docker mandaSIGKILLa mitad del drenaje y todo el trabajo de apagado ordenado se tira a la basura. Ylatesten producción impide saber qué corre y hace imposible el rollback: etiqueta SemVer y, mejor, despliega por digest.- Consejo: prueba el apagado.
docker stopconheymandando carga y cuenta cuántas peticiones fallan. Si no es cero, tu apagado ordenado no lo es. - Consejo: registra la configuración efectiva al arrancar, con los secretos ocultos. Un log de arranque con
db_host,pool_maxyversionahorra horas de diagnóstico.
Ejercicios
Ejercicio 1. Demuestra el fallo rápido: arranca aurora-api:2.0.0 sin DB_PASSWORD, comprueba que sale con código 78 en menos de un segundo con un mensaje que nombra la variable, y verifica que con DB_PASSWORD_FILE apuntando a un secreto sí arranca.
Ejercicio 2. Comprueba que el apagado ordenado funciona: genera carga contra /libros, ejecuta docker stop en mitad de la carga y mide cuántas peticiones fallaron. Después desactiva el manejador de SIGTERM y repite, comparando el tiempo de parada y los errores.
Ejercicio 3. Demuestra por qué la liveness no debe tocar la base de datos: para aurora-db y comprueba qué responden /salud/vivo y /salud/listo. Explica qué haría un orquestador con cada respuesta.
Soluciones
Solución 1.
time docker run --rm -e DB_HOST=aurora-db -e DB_USER=aurora -e DB_NAME=aurora_libros \
-e REDIS_HOST=aurora-cache auroralibros/aurora-api:2.0.0
echo "codigo: $?"{"ts":"2026-08-05T09:12:44.118Z","nivel":"error","servicio":"aurora-api",
"mensaje":"configuracion invalida",
"detalle":"Falta la variable obligatoria DB_PASSWORD (o su variante _FILE)"}
real 0m0.421s
codigo: 78printf 'clave-ficticia-aurora' > /tmp/db_password
docker run --rm -d --name prueba-cfg -v /tmp/db_password:/run/secrets/db_password:ro \
-e DB_HOST=aurora-db -e DB_USER=aurora -e DB_NAME=aurora_libros -e REDIS_HOST=aurora-cache \
-e DB_PASSWORD_FILE=/run/secrets/db_password --network aurora-libros_trasera \
auroralibros/aurora-api:2.0.0 && docker logs prueba-cfg | head -1
# {"ts":"...","nivel":"info","mensaje":"escuchando","puerto":3000,"version":"2.0.0"}Tres cosas quedan demostradas. El fallo tarda 0,4 segundos: el contenedor nunca llega a existir como servicio, así que ningún balanceador puede mandarle tráfico. El mensaje nombra la variable exacta, con lo que el diagnóstico es inmediato incluso para quien no conoce el código. Y el código 78 es distinguible: en un orquestador con reinicio automático, ver exit 78 repetido dice "no insistas, arregla la configuración", mientras que un exit 1 podría ser cualquier cosa.
En el segundo comando el secreto entra como fichero de solo lectura: nunca aparece en docker inspect ni en el historial del shell, y el código lo lee una sola vez al arrancar.
Solución 2.
docker run --rm --network aurora-libros_frontal ghcr.io/rakyll/hey \
-z 30s -c 20 http://aurora-api:3000/libros > /tmp/con-manejador.txt &
sleep 8; time docker compose -f compose.prod.yaml stop aurora-api
wait; grep -E 'responses|error' /tmp/con-manejador.txt
# real 0m6.104s
# Status code distribution:
# [200] 4127 responsesAhora el escenario contrario, sobrescribiendo el arranque para que el manejador no exista:
docker run -d --name sin-manejador --network aurora-libros_frontal \
auroralibros/aurora-api:2.0.0 \
node -e "require('http').createServer((q,s)=>setTimeout(()=>s.end('ok'),300)).listen(3000)"
# ... misma carga con hey ...
time docker stop sin-manejador| Escenario | Tiempo de stop |
Peticiones fallidas |
|---|---|---|
| Con apagado ordenado | ~6 s | 0 |
| Sin manejador de SIGTERM | 10,2 s | 152 |
Los dos números cuentan la misma historia. 10,2 segundos es la firma inconfundible del problema: el proceso ignoró SIGTERM —recuerda, el PID 1 no tiene acción por defecto—, Docker esperó los 10 segundos completos de --time y lo mató con SIGKILL. Un SIGKILL no se puede capturar: las 152 conexiones abiertas se cortaron a mitad, y cada una es un cliente viendo un error.
Con el manejador, la parada tarda menos (6 s: los 5 de margen de readiness más el drenaje real) y ninguna petición falla. La lección operativa es que un despliegue de veinte réplicas sin apagado ordenado son miles de errores por versión publicada, y ninguno aparecerá en tus logs de aplicación, porque el proceso ya estaba muerto cuando ocurrieron.
Solución 3.
docker compose -f compose.prod.yaml stop aurora-db
sleep 3
curl -s -o /dev/null -w 'vivo: %{http_code}\n' http://localhost:8080/salud/vivo
curl -s -w '\nlisto: %{http_code}\n' http://localhost:8080/salud/listo| Sonda | Respuesta | Qué haría el orquestador | ¿Correcto? |
|---|---|---|---|
/salud/vivo |
200 | Nada: la réplica sigue viva | Sí |
/salud/listo |
503 | Le quita el tráfico, sin reiniciar | Sí |
Ese par de respuestas es exactamente el comportamiento deseado. El proceso de Node está perfectamente sano —su bucle de eventos responde en milisegundos—, así que reiniciarlo no arreglaría nada; lo que ocurre es que no puede servir, y por eso deja de recibir tráfico y nada más.
Si /salud fuera a la vez liveness y readiness, como en la versión 1.3.0, ese mismo 503 habría producido: reinicio de las tres réplicas → 40 s sin servicio por el arranque → reconexión simultánea de tres pools contra una base de datos que acaba de volver → posible nuevo fallo → segundo reinicio. Una incidencia de 30 segundos de la base de datos convertida en varios minutos de caída total, causada íntegramente por la sonda mal diseñada.
Y fíjate en la tercera línea de la respuesta: cache: ok con db: error devuelve 503, pero el caso inverso —db: ok con cache: error— devuelve 200, porque el cache-aside degrada a origen: db. Esa asimetría es una decisión de producto codificada en la sonda: Aurora Libros prefiere vender libros despacio a no venderlos.
Conclusión
Has convertido una imagen que funciona en una imagen que se puede operar. La configuración salió por completo de la imagen y entró por el entorno, con un único config.js que la valida al arrancar y falla en 0,4 segundos con código 78 si falta algo obligatorio, en vez de reventar en la primera petición real; el patrón _FILE deja el mismo código sirviendo para tu portátil y para un clúster con secretos montados. Has resuelto el PID 1 con init: true para que los zombis no agoten tu pids_limit, y has entendido por qué meter un supervisord dentro rompe los reinicios, los logs y el escalado a la vez.
El apagado ordenado ya es completo y, sobre todo, está medido: cero peticiones perdidas frente a 152, con la espera de cinco segundos con la readiness en rojo antes de cerrar el listener —el paso que casi nadie implementa— y el cierre del cliente de Redis y del pool de PostgreSQL en orden inverso al de apertura. Sabes calcular el periodo de gracia a partir de la petición más larga y por qué stop_grace_period debe ser mayor que el plazo interno. Has separado las tres sondas con sus tres semánticas y has visto en vivo la razón: con PostgreSQL parado, /salud/vivo responde 200 y /salud/listo responde 503, así que la réplica pierde el tráfico pero no se reinicia; el endpoint único habría convertido treinta segundos de incidencia en varios minutos de caída total. Y has fijado la base por digest, npm ci con lockfile, etiquetas OCI con el commit, límites elegidos con quince minutos de docker stats en vez de a ojo, y un heap de V8 al 75 % del límite del cgroup para que un desbordamiento sea una excepción registrable y no un 137 mudo. Todo ello cristalizado en el Dockerfile y el compose.prod.yaml de aurora-api:2.0.0 y en una checklist de quince controles verificables uno a uno.
Ahora tienes el artefacto correcto, pero lo sigues construyendo tú, a mano, en tu portátil. En la siguiente lección, CI/CD con Docker, ese trabajo pasa a hacerlo una máquina: montarás el pipeline que, en cada git push, ejecuta el lint y las pruebas dentro de contenedores, construye la imagen multiarquitectura reutilizando la caché remota de BuildKit, la escanea con Trivy rompiendo la build ante vulnerabilidades críticas, la firma con Cosign, genera su SBOM y la publica en ghcr.io etiquetada automáticamente desde tu etiqueta de Git.
Docker: De Principiante a Avanzado
Módulo 1: Introducción a Docker
- ¿Qué es Docker?
- Instalando Docker
- Arquitectura de Docker
- Comandos Básicos de Docker
- Entendiendo las Imágenes de Docker
- Creando tu Primer Contenedor Docker
- El Proyecto del Curso: la Plataforma Aurora Libros
Módulo 2: Trabajando con Imágenes Docker
- Docker Hub y Repositorios
- Construyendo Imágenes Docker
- Conceptos Básicos de Dockerfile
- Instrucciones Avanzadas del Dockerfile
- Gestionando Imágenes Docker
- Etiquetado y Publicación de Imágenes
Módulo 3: Contenedores Docker
- Ejecutando Contenedores
- Ciclo de Vida del Contenedor
- Gestionando Contenedores
- Inspección y Depuración de Contenedores
- Redes en Docker
- Persistencia de Datos con Volúmenes
- Límites de Recursos y Políticas de Reinicio
Módulo 4: Docker Compose
- Introducción a Docker Compose
- Definiendo Servicios en Docker Compose
- Comandos de Docker Compose
- Aplicaciones Multi-Contenedor
- Variables de Entorno en Docker Compose
- Perfiles, Overrides y Múltiples Entornos
- Desarrollo Local con Docker Compose
Módulo 5: Conceptos Avanzados de Docker
- Profundización en Redes Docker
- Opciones de Almacenamiento Docker
- Mejores Prácticas de Seguridad en Docker
- Optimizando Imágenes Docker
- Builds Avanzadas con BuildKit y Buildx
- Registro y Monitoreo en Docker
- El Runtime por Dentro: Namespaces, Cgroups y Capas
Módulo 6: Docker en Producción
- Preparar una Imagen para Producción
- CI/CD con Docker
- Orquestando Contenedores con Docker Swarm
- Introducción a Kubernetes
- Desplegando Contenedores Docker en Kubernetes
- Escalado y Balanceo de Carga
- Estrategias de Despliegue y Rollback
