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

  1. Qué distingue una imagen de producción
  2. Los doce factores aplicados a la configuración
  3. Validar la configuración al arrancar y fallar rápido
  4. PID 1 y el problema de los zombis
  5. Un proceso por contenedor
  6. El apagado ordenado completo
  7. El periodo de gracia y la petición más larga
  8. Las tres sondas: liveness, readiness y startup
  9. /salud/vivo y /salud/listo en aurora-api
  10. Reproducibilidad: digests, lockfile y etiquetas OCI
  11. Elegir límites de recursos con datos reales
  12. El límite de memoria y el heap de Node
  13. El Dockerfile definitivo de Aurora Libros
  14. El compose.prod.yaml de la versión 2.0.0
  15. 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.

  1. 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.

  1. 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

  1. 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.

  1. 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.

  1. Adoptar huérfanos. Cuando un proceso muere dejando hijos, esos hijos pasan a colgar del PID 1.
  2. Recoger zombis. Un proceso terminado permanece en estado Z hasta que su padre llama a wait() 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".

  1. 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 supervisord sigue vivo, el contenedor está "sano" con la aplicación caída.
  • Los reinicios dejan de funcionar. restart: always y el orquestador reaccionan a la muerte del PID 1, que ya no ocurre nunca.
  • Los logs se mezclan y pierden su origen; docker logs deja de ser útil.
  • El escalado se acopla. Si la API necesita tres réplicas y el cron una, 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.

  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é.

  1. El periodo de gracia y la petición más larga

La regla es aritmética:

periodo_de_gracia  >  espera_de_readiness + petición_más_larga + cierre_de_dependencias

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.

  1. 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:

  1. La readiness falla en las tres réplicas: correcto, no tiene sentido mandarles tráfico.
  2. La liveness también falla, porque es el mismo endpoint.
  3. El orquestador reinicia las tres réplicas de la API.
  4. La base de datos vuelve, pero las réplicas están arrancando desde cero.
  5. 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.

  1. /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.

  1. 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

  1. 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-db recibe 2 CPU porque un VACUUM o un pg_dump deben poder correr.
  • aurora-cache con --maxmemory 200mb necesita un límite de contenedor por encima de esos 200 MB: Redis usa memoria además de la de los datos. 256M es el mínimo defendible.
  • Deja siempre las requests por debajo de los limits en Kubernetes (06-05): las requests reservan y deciden dónde cabe el Pod; los limits cortan.

  1. 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.

  1. El 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)

  1. El 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.

  1. 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 -u1000
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 staging y 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 502 de un despliegue.
  • CMD npm start. npm se convierte en PID 1, no reenvía SIGTERM a Node y añade una capa inútil. Usa siempre CMD ["node", "src/server.js"].
  • Confiar en los valores por defecto de la configuración. Un DB_HOST con defecto localhost no falla: se conecta al sitio equivocado. Lo obligatorio no tiene defecto.
  • stop_grace_period menor que el plazo interno. Docker manda SIGKILL a mitad del drenaje y todo el trabajo de apagado ordenado se tira a la basura. Y latest en producción impide saber qué corre y hace imposible el rollback: etiqueta SemVer y, mejor, despliega por digest.
  • Consejo: prueba el apagado. docker stop con hey mandando 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_max y version ahorra 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: 78
printf '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 responses

Ahora 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
real  0m10.213s
Status code distribution:
  [200] 3811 responses
  [error] 152 connection reset by peer
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
vivo:  200
{"estado":"degradado","control":{"db":"error: timeout","cache":"ok"}}
listo: 503
Sonda Respuesta Qué haría el orquestador ¿Correcto?
/salud/vivo 200 Nada: la réplica sigue viva
/salud/listo 503 Le quita el tráfico, sin reiniciar

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

Módulo 2: Trabajando con Imágenes Docker

Módulo 3: Contenedores Docker

Módulo 4: Docker Compose

Módulo 5: Conceptos Avanzados de Docker

Módulo 6: Docker en Producción

Módulo 7: Ecosistema y Herramientas de Docker

© Copyright 2026. Todos los derechos reservados