La lección anterior terminó con una frase incómoda: PM2 resuelve el proceso, no el entorno. Escena Viva vive supervisada, pero sobre una máquina que alguien configuró a mano hace meses, con una versión de Node que puede no ser la que exige engines, con las librerías del sistema que hicieran falta para compilar bcrypt y con las fuentes que se instalaron cuando los PDF de las entradas empezaron a salir mal. Hoy hacemos que el entorno viaje con la aplicación. El resultado será un artefacto único —una imagen— que contiene Node 24, las dependencias de producción ya instaladas, el código y nada más; que se ejecuta idéntico en tu portátil, en integración continua y en producción; y que se despliega copiando un identificador en lugar de repitiendo pasos de instalación. Aviso de alcance: en el catálogo hay un curso de Docker completo. Aquí no vamos a estudiar Docker: vamos a empaquetar bien una aplicación Node, con lo justo de teoría para ser autosuficiente y todo el detalle en las decisiones que afectan a una aplicación como la nuestra.

Contenido

  1. «En mi máquina funciona» y qué es un contenedor
  2. Imagen, contenedor, capas y registro
  3. El Dockerfile de Escena Viva, línea a línea
  4. .dockerignore: el fichero que nadie escribe y todos necesitan
  5. HEALTHCHECK con /salud/listo
  6. Construir, medir, etiquetar y publicar
  7. docker compose: el entorno completo de desarrollo
  8. Migraciones en contenedor
  9. Registros, límites de memoria y CPU
  10. Qué no se mete en la imagen y cómo escanearla

  1. «En mi máquina funciona» y qué es un contenedor

El problema tiene nombre y es viejo: la aplicación depende de mucho más que de su código. Depende de la versión exacta del intérprete, de las librerías compartidas del sistema operativo, de las variables de entorno, de los binarios que estén en el PATH, de las fuentes tipográficas instaladas y del contenido de /etc. Tu portátil, el servidor de CI y el VPS de producción difieren en todo eso, y las diferencias solo se manifiestan en el peor momento. Un contenedor es un proceso normal del sistema operativo anfitrión al que el núcleo le ha mentido sobre el mundo. Mediante namespaces ve su propio sistema de ficheros, su propia lista de procesos (donde él es el PID 1), su propia red y sus propios usuarios; mediante cgroups se le limita cuánta CPU y cuánta memoria puede usar. Pero es un proceso más del anfitrión, ejecutando el mismo núcleo Linux. Si haces ps aux en el servidor, ahí está tu node. Esa es la diferencia clave con una máquina virtual, que emula hardware completo y ejecuta un sistema operativo entero con su propio núcleo.

Contenedor Máquina virtual
Qué aísla Procesos, sistema de ficheros, red Hardware completo
Núcleo El del anfitrión, compartido Propio, uno por máquina
Arranque Milisegundos Decenas de segundos
Tamaño típico 50-300 MB 1-20 GB
Sobrecoste Prácticamente nulo 5-15 % de CPU y memoria
Aislamiento Bueno, pero comparte núcleo Muy fuerte
Sistema operativo Solo Linux sobre núcleo Linux Cualquiera

La consecuencia práctica: puedes ejecutar veinte contenedores en un portátil, arrancarlos en un segundo y tirarlos sin pensarlo. Y la consecuencia de seguridad: como el núcleo es compartido, un contenedor no es una frontera de seguridad tan fuerte como una VM. Por eso importa el usuario no root del apartado 3.

  1. Imagen, contenedor, capas y registro

Cuatro conceptos y seguimos:

  • Imagen: una plantilla inmutable de solo lectura. El sistema de ficheros completo que verá el proceso, más metadatos (qué comando ejecutar, qué puerto expone, qué variables trae). Se construye a partir de un Dockerfile.
  • Contenedor: una instancia en ejecución de una imagen, con una capa de escritura efímera encima. Cuando el contenedor muere, esa capa desaparece. Todo lo que escribas dentro se pierde, y ese detalle tendrá consecuencias enormes en la lección 11-05.
  • Capas: cada instrucción del Dockerfile que modifica el sistema de ficheros crea una capa. Las capas se cachean y se comparten entre imágenes: si dos de tus imágenes parten de node:24-alpine, esa capa se guarda y se transfiere una sola vez. Esto gobierna el tiempo de construcción y el de despliegue.
  • Registro: un almacén de imágenes (Docker Hub, GitHub Container Registry, ECR, Artifact Registry). Publicas ahí y el servidor descarga desde ahí, y eso es lo que convierte «desplegar» en «descargar una imagen y arrancarla». Con eso basta: vamos a lo nuestro.

  1. El Dockerfile de Escena Viva, línea a línea

Este es el fichero completo; después lo desmontamos entero.

# syntax=docker/dockerfile:1

# ---------- ETAPA 1: dependencias de produccion ----------
FROM node:24-alpine AS dependencias
# Herramientas de compilacion para los modulos nativos (bcrypt).
# Se instalan SOLO en esta etapa: no llegan a la imagen final.
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copiamos SOLO los manifiestos: cambian mucho menos que el codigo.
COPY package.json package-lock.json ./
# npm ci: instalacion reproducible desde package-lock.json (M5).
# --omit=dev: sin dependencias de desarrollo (mocha, chai, eslint...).
RUN npm ci --omit=dev && npm cache clean --force

# ---------- ETAPA 2: imagen final ----------
FROM node:24-alpine AS produccion
# tini como PID 1: reenvia senales y entierra procesos zombis.
RUN apk add --no-cache tini
ENV NODE_ENV=production
ENV NODE_OPTIONS="--max-old-space-size=768"
WORKDIR /app

# node_modules ya compilado, con propietario correcto desde el principio.
COPY --from=dependencias --chown=node:node /app/node_modules ./node_modules
# Codigo de la aplicacion. Va al final: es lo que mas cambia.
COPY --chown=node:node package.json ./
COPY --chown=node:node src ./src
COPY --chown=node:node migraciones ./migraciones
COPY --chown=node:node scripts ./scripts

USER node          # Sin privilegios: la imagen node ya trae 'node' (uid 1000).
EXPOSE 3000        # Documental: no publica nada, pero informa a las herramientas.

# Comprobacion de salud apoyada en la sonda de 11-02.
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
  CMD node ./scripts/comprobar-salud.js

# Forma exec (array JSON): SIN shell intermedia, la senal llega al proceso.
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "src/servidor.js"]

FROM: qué imagen base

La primera decisión es la que más se copia sin pensar. Las opciones reales:

Base Tamaño A favor En contra
node:24 (Debian completa) ~1,1 GB Todo compila sin dolor, buen depurado Enorme; mucha superficie de ataque
node:24-slim (Debian mínima) ~250 MB glibc, compatible con casi todo Más grande que Alpine
node:24-alpine ~150 MB Pequeña, pocas vulnerabilidades musl en lugar de glibc
distroless/nodejs24 ~180 MB Sin shell ni gestor de paquetes: superficie mínima Imposible depurar dentro

Aviso importante sobre Alpine y los módulos nativos. Alpine usa musl libc en lugar de glibc. La consecuencia práctica: los binarios precompilados que publican paquetes como bcrypt están hechos para glibc, así que en Alpine hay que compilarlos, y para eso hacen falta python3, make y g++. Por eso nuestra primera etapa los instala. Además, musl tiene un asignador de memoria distinto que, en cargas con mucha concurrencia, puede rendir algo peor que glibc. Escena Viva usa alpine porque el tamaño y la superficie de ataque compensan, y porque la construcción multietapa hace que las herramientas de compilación no lleguen a la imagen final. Si tuvieras problemas raros con bcrypt, sharp o canvas, node:24-slim es una retirada perfectamente honorable — y una alternativa a considerar es sustituir bcrypt por @node-rs/argon2, que no necesita compilación. Y una regla que no se negocia: nunca FROM node:latest. Una construcción que hoy usa Node 24 y mañana Node 25 no es reproducible, y engines dice <25 por algo. Para máxima reproducibilidad se puede fijar el digest: FROM node:24-alpine@sha256:....

Construcción multietapa

FROM ... AS dependencias y luego un segundo FROM crean dos imágenes durante la construcción. Solo la última se conserva; de la primera copiamos lo que nos interesa con COPY --from=dependencias.

El beneficio es enorme: python3, make y g++ ocupan unos 200 MB y no llegan a la imagen final. Tampoco llegan las cabeceras de compilación ni la caché de npm. La imagen final tiene el node_modules ya compilado, sin nada de lo que hizo falta para producirlo.

Por qué package*.json antes que el código

Esto es lo que más ahorra en el día a día, y merece entenderse bien. Docker cachea capas: si los ficheros de entrada de una instrucción no han cambiado, reutiliza la capa y salta al siguiente paso. Pero en cuanto una capa se invalida, todas las siguientes se reconstruyen. Comparemos:

COPY . .                                  # MAL: cualquier cambio reinstala TODO
RUN npm ci --omit=dev

COPY package.json package-lock.json ./    # BIEN: solo si cambian los manifiestos
RUN npm ci --omit=dev
COPY src ./src
Escenario Con COPY . . primero Con manifiestos primero
Primera construcción 95 s 95 s
Cambio en un controlador 92 s 6 s
Cambio en package.json 94 s 94 s

Como el código cambia decenas de veces al día y las dependencias una vez cada quince, el ahorro real ronda el 90 % del tiempo de construcción; en una tubería de CI que se ejecuta en cada push (11-06), eso son horas de espera al mes.

npm ci --omit=dev: el pago del módulo 5

npm ci instala exactamente lo que dice package-lock.json, sin resolver rangos de versiones: es reproducible por definición, y la misma construcción hoy y dentro de seis meses da el mismo árbol de dependencias. npm install, en cambio, puede resolver una versión nueva de una transitiva y meterte un cambio que nadie pidió. Además, npm ci falla si package.json y el lock no son coherentes, lo que atrapa el error de haber editado uno sin el otro.

--omit=dev excluye devDependencies: mocha, chai, sinon, supertest, c8, eslint, prettier, husky, pino-pretty. Son unos 200 MB y decenas de paquetes que en producción no aportan nada y sí superficie de ataque. Aquí se cobra la factura de la disciplina que impusimos en 11-01: si algo que la aplicación necesita en ejecución está en devDependencies, la imagen arranca y revienta con Cannot find module. Y solo en producción.

USER node y por qué

Por defecto, el proceso dentro del contenedor corre como root. Root del contenedor no es root del anfitrión, pero si alguien logra escapar del aislamiento —o si montas un volumen del anfitrión— la diferencia entre root y un usuario normal es la diferencia entre un incidente y una catástrofe. Las imágenes oficiales de Node ya traen un usuario node con uid 1000. Basta con USER node después de copiar los ficheros, y con --chown=node:node en cada COPY para que los permisos sean correctos desde el principio (si haces chown -R después, duplicas el tamaño: crea una capa nueva con todos los ficheros). Efecto secundario que confunde a mucha gente: como usuario no root, el proceso no puede escuchar en puertos por debajo del 1024. Es exactamente la restricción del módulo 4, y la respuesta es la misma: escucha en el 3000 y que el proxy inverso o el mapeo de puertos (-p 80:3000) haga el resto.

WORKDIR, EXPOSE, ENV

WORKDIR /app fija el directorio de trabajo y lo crea si no existe; úsalo siempre en lugar de RUN cd, porque cada RUN es una shell nueva y el cd no persiste. EXPOSE 3000 es puramente documental: no abre ningún puerto —publicar de verdad es -p 3000:3000 al ejecutar—, y su valor está en que docker compose y otras herramientas lo leen. Y ENV NODE_ENV=production activa todo lo que vimos en 11-01: Express en modo producción, librerías sin comprobaciones de desarrollo y nuestro propio configuracion.esProduccion.

CMD en forma de exec: que SIGTERM llegue de verdad

Este es el error clásico que rompe todo el trabajo de apagado ordenado que arrastramos desde el módulo 6.

CMD node src/servidor.js        # MAL: shell. Docker ejecuta /bin/sh -c "..."
CMD ["npm", "start"]            # MAL: npm es un intermedio que no reenvia senales
CMD ["node", "src/servidor.js"] # BIEN: forma exec, node recibe las senales

Con la forma shell, el PID 1 del contenedor es /bin/sh y Node es su hijo. Cuando haces docker stop, Docker envía SIGTERM al PID 1. La shell lo recibe, no lo reenvía a sus hijos y no hace nada. Node nunca se entera de que debe apagarse. Diez segundos después, Docker pierde la paciencia y envía SIGKILL, que mata el proceso al instante: conexiones cortadas a media respuesta, transacciones abiertas, ninguna de las compras en vuelo terminada. Con la forma exec (el array JSON), Node es el PID 1 y recibe SIGTERM directamente. Nuestro manejador de src/servidor.js se dispara, /salud/listo empieza a devolver 503, se cierran las conexiones ociosas y se drena lo que queda. Exactamente lo que construimos hace cinco módulos. Lo mismo aplica a ENTRYPOINT. Y nunca npm start como comando: npm añade un proceso intermedio con el mismo problema, y además reescribe los códigos de salida.

El problema del PID 1 y tini

Ser el PID 1 conlleva responsabilidades que Node no fue escrito para asumir:

  1. Enterrar procesos zombis. Cuando un proceso muere, su padre debe recoger su código de salida. Si el padre no está, el PID 1 hereda al huérfano. Node no lo hace, y los zombis se acumulan en la tabla de procesos.
  2. Manejar señales sin comportamiento por defecto. El PID 1 no tiene los manejadores por defecto del núcleo: si no manejas una señal explícitamente, se ignora.

Escena Viva bifurca procesos de verdad (los worker threads del pool de PDF y, potencialmente, subprocesos), así que el problema es real. La solución es un init mínimo: tini, que ocupa unos pocos kilobytes. Se resuelve con las tres líneas que ya están en el Dockerfile: RUN apk add --no-cache tini y ENTRYPOINT ["/sbin/tini", "--"] delante del CMD. Ahora tini es el PID 1, entierra zombis y reenvía todas las señales a Node. La alternativa sin tocar el Dockerfile es docker run --init, que inyecta un init propio; funciona igual de bien, pero depende de que quien ejecute el contenedor se acuerde de la bandera. Prefiero que la imagen sea correcta por sí sola.

  1. .dockerignore: el fichero que nadie escribe y todos necesitan

Antes de construir, Docker envía el contexto de construcción —el directorio entero— al motor. Sin .dockerignore, eso incluye node_modules local, .git con todo el historial y, atención, tu .env. Los tres desastres, por orden de gravedad:

  1. .env acaba dentro de la imagen. Con COPY . ., tus secretos quedan grabados en una capa, y las capas se publican en un registro: cualquiera con acceso a la imagen los extrae con docker history. Esto ha filtrado credenciales de empresas grandes más de una vez.
  2. node_modules local se copia dentro. Además de ser lento, si tu portátil es macOS o Windows, los binarios compilados de bcrypt son de otra plataforma y no funcionan en Linux. Errores incomprensibles garantizados.
  3. .git engorda el contexto y puede contener ramas, credenciales antiguas y ficheros borrados que siguen en el historial.
# .dockerignore
node_modules
npm-debug.log*
.git
.gitignore
.github
.env
.env.*
!.env.example
test
coverage
.nyc_output
*.md
!README.md
.vscode
.idea
.DS_Store
Dockerfile
docker-compose*.yml
informes

Nota sobre informes: es el directorio donde el módulo 3 escribía los PDF generados. No debe entrar en la imagen ni por asomo, y en la lección siguiente veremos que además no debe existir en producción.

  1. HEALTHCHECK con /salud/listo

HEALTHCHECK le dice a Docker cómo comprobar si el contenedor está sano. Docker marca el contenedor como healthy o unhealthy, y los orquestadores usan ese estado para retirar tráfico o reemplazarlo.

Aquí reutilizamos la sonda de disponibilidad de 11-02. Como la imagen Alpine no trae curl ni wget completo —y no queremos añadirlos solo para esto—, la comprobación se hace con Node, que ya está dentro:

// scripts/comprobar-salud.js — sin dependencias ni config: debe funcionar siempre.
'use strict';

const http = require('node:http');
const port = process.env.PUERTO || 3000;
const opciones = { host: '127.0.0.1', port, path: '/salud/listo', timeout: 2000 };

// Codigo de salida 0 = sano; cualquier otro = enfermo.
const peticion = http.request(opciones, (r) => process.exit(r.statusCode === 200 ? 0 : 1));
peticion.on('error', () => process.exit(1));
peticion.on('timeout', () => { peticion.destroy(); process.exit(1); });
peticion.end();

Los parámetros del HEALTHCHECK merecen atención:

Parámetro Por qué
--interval=30s Suficiente para detectar; no sobrecarga
--timeout=3s Mayor que el límite interno de la sonda (1 s por dependencia)
--start-period=20s Migraciones y conexión a la BD tardan; los fallos aquí no cuentan
--retries=3 Un fallo aislado no debe marcar el contenedor como enfermo

--start-period es el que más se olvida: sin él, un arranque lento cuenta como fallo y el contenedor se marca enfermo antes de haber tenido oportunidad de estar sano.

  1. Construir, medir, etiquetar y publicar

# Dos etiquetas: la version (para humanos) y el hash del commit (la verdad).
docker build -t escena-viva/api:1.4.0 -t escena-viva/api:$(git rev-parse --short HEAD) .
docker images escena-viva/api                                    # Tamaño real
docker run --rm -p 3000:3000 --env-file .env escena-viva/api:1.4.0  # Probar en local
docker push escena-viva/api:1.4.0                                # Publicar

Sobre el etiquetado: latest no es una versión, es un alias mutable que apunta a lo último que publicaste. Desplegar latest significa no saber qué estás desplegando ni poder revertir. Etiqueta siempre con la versión semántica y con el hash corto del commit; la primera es para humanos y la segunda es la verdad.

El tamaño, medido

Estos son los números reales de Escena Viva en cada paso de optimización:

Versión Tamaño Qué cambió
node:24 + COPY . . + npm install 1.410 MB Punto de partida ingenuo
node:24-slim / node:24-alpine 546 / 412 MB Base Debian mínima; base Alpine
+ npm ci --omit=dev 268 MB Sin dependencias de desarrollo
+ multietapa (sin compiladores) 97 MB Sin python3, make, g++ ni caché de npm

De 1,4 GB a menos de 100 MB: catorce veces menos, y no es solo estética. Menos tamaño significa despliegues más rápidos —cada instancia descarga la imagen—, menos coste de almacenamiento y transferencia en el registro, y muchísima menos superficie de ataque: cada binario que no está en la imagen es un binario que no puede tener una vulnerabilidad ni servir a un atacante.

  1. docker compose: el entorno completo de desarrollo

Aquí llega lo que llevas queriendo desde el módulo 7. Escena Viva necesita PostgreSQL, MongoDB y Redis; instalarlos a mano en cada portátil del equipo es una tarde perdida por persona y una fuente inagotable de diferencias de versión.

docker compose levanta todo el entorno con un comando.

# docker-compose.yml
name: escena-viva

# Anclas YAML: la API y el consumidor comparten imagen y conexiones.
x-comun: &comun
  build: { context: ., target: produccion }
  env_file: ['.env.docker']        # Solo los secretos; NO versionado.
  restart: unless-stopped
  environment: &entorno
    NODE_ENV: development
    NIVEL_REGISTRO: debug
    # Los nombres de servicio son nombres DNS dentro de la red de compose.
    URL_POSTGRES: postgres://escena:escena@postgres:5432/escena_viva
    URL_MONGO: mongodb://mongo:27017/escena_viva
    URL_REDIS: redis://redis:6379

x-sano: &sano { condition: service_healthy }

services:
  api:
    <<: *comun
    ports: ['3000:3000']
    init: true                     # Init de Docker; redundante con tini, inofensivo.
    depends_on: { postgres: *sano, mongo: *sano, redis: *sano }

  consumidor:                      # El consumidor de la cola del M10.
    <<: *comun
    command: ['node', 'src/procesos/consumidor-entradas.js']
    environment:
      <<: *entorno
      CONCURRENCIA_COLA: 2
    depends_on: { postgres: *sano, redis: *sano }

  postgres:
    image: postgres:17-alpine
    environment: { POSTGRES_USER: escena, POSTGRES_PASSWORD: escena, POSTGRES_DB: escena_viva }
    ports: ['5432:5432']           # Expuesto solo para un cliente local.
    volumes: ['datos-postgres:/var/lib/postgresql/data']
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U escena -d escena_viva']
      interval: 5s
      retries: 10

  mongo:
    image: mongo:8
    ports: ['27017:27017']
    volumes: ['datos-mongo:/data/db']
    healthcheck:
      test: ['CMD', 'mongosh', '--quiet', '--eval', 'db.adminCommand("ping")']
      interval: 5s
      retries: 10

  redis:
    image: redis:7-alpine
    command: ['redis-server', '--appendonly', 'yes']
    ports: ['6379:6379']
    volumes: ['datos-redis:/data']
    healthcheck: { test: ['CMD', 'redis-cli', 'ping'], interval: 5s, retries: 10 }

volumes: { datos-postgres: , datos-mongo: , datos-redis: }

Lo que hay que entender de este fichero:

  • Red y DNS. Compose crea una red propia donde cada servicio es resoluble por su nombre: por eso la URL es postgres://escena:escena@postgres:5432/..., con el nombre del servicio y el puerto interno (5432), no el mapeado.
  • depends_on con condition: service_healthy. El depends_on simple solo espera a que el contenedor arranque, no a que el servicio esté listo; PostgreSQL tarda unos segundos en aceptar conexiones, y la API arrancaría antes y fallaría. Aun así, la aplicación debe tolerar que la base de datos desaparezca en caliente: depends_on solo cubre el arranque.
  • Volúmenes con nombre. Los datos viven en volúmenes gestionados por Docker y sobreviven a docker compose down. Para empezar de cero, docker compose down -v.
  • env_file separado. Las variables no secretas van en environment (visibles y versionadas); los secretos en .env.docker, que está en .gitignore. Es la misma separación que aplicamos al fichero de ecosistema de PM2 en 11-03.
  • Un solo Dockerfile para dos servicios. api y consumidor comparten imagen y solo difieren en command: los dos procesos que PM2 gestionaba como dos aplicaciones son aquí dos contenedores del mismo artefacto.

Y el flujo de trabajo diario, que es el verdadero regalo de esta lección:

docker compose up -d              # Levanta los cinco servicios
docker compose logs -f api        # Sigue los registros de la API
docker compose exec api sh        # Shell dentro del contenedor
docker compose down               # Para todo, conservando los datos (-v los borra)

Alguien que se incorpore al equipo clona el repositorio, copia .env.example a .env.docker, ejecuta docker compose up y en dos minutos tiene el entorno entero. Sin instalar PostgreSQL, ni MongoDB, ni Redis, ni siquiera Node.

  1. Migraciones en contenedor

Tentación evidente: poner npm run migrar && node src/servidor.js en el CMD. Es un error, por tres razones.

  1. Se ejecutarían N veces en paralelo. Con cuatro instancias arrancando a la vez, cuatro procesos migran el mismo esquema simultáneamente, y las condiciones de carrera en un ALTER TABLE concurrente producen resultados imprevisibles.
  2. Rompe la forma exec. Ese && obliga a una shell, y ya vimos lo que le pasa a SIGTERM con una shell por medio.
  3. Una migración fallida deja el contenedor en bucle de reinicio en lugar de fallar de forma visible y detener el despliegue.

Las migraciones son un paso aparte y único:

# docker-compose.yml, servicio adicional
  migrar:
    <<: *comun
    command: ['npm', 'run', 'migrar']
    depends_on: { postgres: *sano }
    restart: 'no'      # Es una tarea puntual, no un servicio: no se reinicia.

Se invoca aparte, antes de levantar los procesos: docker compose run --rm migrar y después docker compose up -d api consumidor. Fíjate en restart: 'no': es una tarea que termina, no un servicio. Aquí npm run sí es aceptable porque el proceso es efímero y las señales no importan. En producción esto tiene un nombre y una lección propia: es la fase de liberación de la que hablaremos en 11-05, y su orden respecto al despliegue es un tema en sí mismo.

  1. Registros, límites de memoria y CPU

Registros a stdout, ahora obligatorio

En 11-02 dijimos que escribir a stdout es lo recomendable. En contenedores es lo único sensato: el sistema de ficheros del contenedor es efímero, así que un registro escrito a fichero desaparece con el contenedor — justo cuando más lo necesitas, porque el contenedor murió por algo. Docker captura stdout y stderr y los pasa a su controlador de registro, que puede ser el fichero JSON local, journald, o un servicio remoto. La aplicación no sabe ni le importa. Con pino escribiendo JSON a stdout, la cadena está completa.

Conviene además añadir a cada servicio logging: { driver: json-file, options: { max-size: '20m', max-file: '5' } }, que evita el problema clásico de que los registros llenen el disco del anfitrión: por defecto, el controlador json-file no tiene límite.

Límites y su relación con Node

Los contenedores pueden limitarse con cgroups:

    deploy:
      resources:
        limits: { cpus: '2.0', memory: 1G }
        reservations: { memory: 512M }

Y ahora la parte que engaña a muchísima gente. Node no siempre respeta el límite de memoria del contenedor. V8 calcula el tamaño máximo del heap a partir de la memoria que ve, y en versiones y configuraciones donde ve la del anfitrión, un contenedor limitado a 1 GB puede tener un heap objetivo de varios GB. ¿Consecuencia? El proceso crece, el cgroup lo mata sin contemplaciones (OOMKilled, código 137) y el recolector de basura nunca llegó a considerar que hubiera presión de memoria. No hay traza, no hay error: el contenedor simplemente desaparece. La solución es decírselo explícitamente, dejando margen para lo que no es heap (búferes, pila, el propio Node): Por eso el Dockerfile incluye ENV NODE_OPTIONS="--max-old-space-size=768". Con un límite de contenedor de 1 GB, un heap de 768 MB deja unos 256 MB de margen. La regla práctica: --max-old-space-size en torno al 75 % del límite del contenedor. La misma aritmética con la CPU y el módulo 10. Si limitas el contenedor a 2 CPU pero dentro arrancas el cluster con instances: 0 (tantos como núcleos), Node verá los 16 núcleos del anfitrión y arrancará 16 trabajadores que se pelearán por 2 CPU. Peor rendimiento que con uno solo, más memoria y más conexiones a la base de datos. En contenedores la doctrina es distinta a la de PM2: un proceso por contenedor, y el escalado se hace con más contenedores, no con más procesos dentro. Es el orquestador quien reparte. Por eso el Dockerfile apunta a src/servidor.js y no a src/cluster.js.

  1. Qué no se mete en la imagen y cómo escanearla

Nunca en la imagen:

  • Secretos. Ni con ENV, ni con ARG, ni copiando un .env. Todo queda en el historial de capas y se recupera con docker history --no-trunc. Los secretos se inyectan al ejecutar, no al construir. Si necesitas un secreto durante la construcción (por ejemplo, un token para un registro npm privado), usa RUN --mount=type=secret, que no deja rastro en ninguna capa.
  • Datos: bases de datos, ficheros subidos, PDF generados. Van a volúmenes o a almacenamiento externo, porque la imagen es código, no estado.
  • Credenciales de nube, claves SSH, certificados privados y herramientas de desarrollo (compiladores, git, editores): cada binario extra es superficie de ataque.

Y escanear la imagen es un paso barato y muy rentable. Herramientas como Trivy, Grype o docker scout comparan los paquetes instalados con bases de datos públicas de vulnerabilidades: trivy image --severity HIGH,CRITICAL escena-viva/api:1.4.0. Detecta tanto vulnerabilidades del sistema base (OpenSSL, musl) como de las dependencias npm, complementando el npm audit del módulo 5. Este paso entra en la tubería de CI en la lección 11-06, y es una razón más para que la imagen sea pequeña: menos paquetes, menos hallazgos que revisar.

Errores Comunes y Consejos

  • CMD en forma shell o npm start. SIGTERM no llega a Node, SIGKILL corta a media respuesta y el apagado ordenado del módulo 6 no sirve de nada. Siempre CMD ["node", "src/servidor.js"].
  • Olvidar .dockerignore. Copias .env y node_modules dentro. Secretos publicados y binarios de otra plataforma.
  • COPY . . antes de npm ci, que hace que cada cambio de una línea reinstale todas las dependencias, o FROM node:latest, que impide construcciones reproducibles.
  • Ejecutar como root, que es el valor por defecto y hay que cambiarlo a propósito.
  • No fijar --max-old-space-size con límite de memoria. El contenedor muere con código 137 y sin ninguna pista.
  • Arrancar el cluster dentro de un contenedor limitado: Node ve los núcleos del anfitrión y arranca demasiados trabajadores.
  • Migraciones en el CMD, que se ejecutan en paralelo por cada instancia, o registros a fichero dentro del contenedor, que se pierden justo cuando hacen falta.
  • Consejo: ejecuta docker run --rm -it escena-viva/api:1.4.0 sh y mira dentro: comprueba que node_modules no tiene mocha, que no hay .env y que el usuario es node. Se descubren cosas sorprendentes. Y construye siempre la imagen en CI, nunca en el servidor de producción: el servidor solo descarga y ejecuta.

Ejercicios

Ejercicio 1 — Demostrar el problema de la señal

Construye dos variantes de la imagen: una con CMD node src/servidor.js (forma shell) y otra con CMD ["node", "src/servidor.js"]. Arranca cada una, ejecuta docker stop y mide cuánto tarda en parar. Explica el resultado a partir de los registros de apagado.

Ejercicio 2 — Reducir la imagen

Parte de un Dockerfile ingenuo (FROM node:24, COPY . ., npm install) y aplica las cuatro optimizaciones de esta lección una a una, midiendo el tamaño con docker images tras cada paso. Comprueba también el tiempo de reconstrucción tras cambiar una línea de un controlador.

Ejercicio 3 — Entorno completo y semilla

Levanta el entorno con docker compose up -d, ejecuta las migraciones en un contenedor aparte, lanza scripts/semilla.js para cargar las tres salas y los tres eventos, y verifica que /salud/listo devuelve 200 con las tres dependencias en true.

Soluciones

Ejercicio 1.

Construyendo las dos variantes y cronometrando docker stop, la variante shell tarda unos 10 segundos (el tiempo de gracia de Docker antes de SIGKILL) y en docker logs no aparece ninguna línea de apagado: el manejador de SIGTERM nunca se ejecutó. La variante exec para en menos de un segundo y deja los registros esperados:

{"level":30,"msg":"senal SIGTERM recibida, iniciando apagado ordenado"}
{"level":30,"msg":"servidor cerrado; conexiones drenadas"}

Esos diez segundos de diferencia son, en producción, diez segundos de peticiones cortadas en cada despliegue.

Ejercicio 2. Los tamaños esperados son los de la tabla del apartado 6. Sobre el tiempo de reconstrucción, con COPY . . primero cambiar una línea de un controlador cuesta unos 90 segundos; con los manifiestos copiados antes, unos 6, y la salida de Docker lo confirma con un => CACHED [dependencias 4/4] RUN npm ci --omit=dev: la palabra CACHED en el paso de instalación es la prueba de que el orden funciona.

Ejercicio 3.

docker compose up -d postgres mongo redis
docker compose run --rm migrar
docker compose run --rm api node scripts/semilla.js
docker compose up -d api consumidor
curl -s http://localhost:3000/salud/listo
# {"estado":"listo","detalle":{"postgres":true,"mongo":true,"redis":true}}

El orden es deliberado: primero las bases de datos, luego el esquema, luego los datos y por último los procesos de aplicación. Es exactamente el orden que reproducirá la tubería de despliegue de 11-06. Si redis apareciese como false, revisa que la URL use el nombre del servicio (redis://redis:6379) y no localhost: dentro de un contenedor, localhost es el propio contenedor.

Conclusión

Escena Viva ya no es código que se ejecuta sobre una máquina desconocida: es un artefacto de 97 MB que lleva su propio entorno dentro. Un Dockerfile multietapa que compila los módulos nativos en una etapa desechable, instala solo las dependencias de producción con npm ci --omit=dev, copia los manifiestos antes que el código para aprovechar la caché de capas, corre como usuario node sin privilegios y arranca con CMD en forma de exec bajo tini, de modo que SIGTERM llega de verdad al proceso y el apagado ordenado del módulo 6 por fin funciona también en contenedor. Con HEALTHCHECK apoyado en /salud/listo, un .dockerignore que impide que los secretos entren en una capa publicada, y un docker compose que levanta la API, el consumidor, PostgreSQL, MongoDB y Redis en un solo comando: el entorno de desarrollo completo que faltaba desde el módulo 7. Lo que queda es responder dónde se ejecuta ese contenedor cuando alguien de verdad quiere comprar una entrada. En la próxima lección, Desplegando en Heroku y Otras PaaS, ponemos Escena Viva en internet: los modelos de despliegue comparados, el Procfile con sus procesos web y worker, el puerto que asigna la plataforma en process.env.PORT, complementos gestionados de PostgreSQL y Redis con sus límites de conexiones, migraciones en la fase de liberación y cambios de esquema sin parada, el sistema de ficheros efímero y qué hacer entonces con los PDF del módulo 3, HTTPS y trust proxy —la promesa pendiente desde el módulo 6—, escalado, despliegue azul/verde y canario, y cómo revertir un despliegue, que es lo primero que hay que saber hacer.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados