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
- «En mi máquina funciona» y qué es un contenedor
- Imagen, contenedor, capas y registro
- El
Dockerfilede Escena Viva, línea a línea .dockerignore: el fichero que nadie escribe y todos necesitanHEALTHCHECKcon/salud/listo- Construir, medir, etiquetar y publicar
docker compose: el entorno completo de desarrollo- Migraciones en contenedor
- Registros, límites de memoria y CPU
- Qué no se mete en la imagen y cómo escanearla
- «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.
- 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
Dockerfileque 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 denode: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.
- El
Dockerfile de Escena Viva, línea a línea
Dockerfile de Escena Viva, línea a líneaEste 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 senalesCon 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:
- 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.
- 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.
.dockerignore: el fichero que nadie escribe y todos necesitan
.dockerignore: el fichero que nadie escribe y todos necesitanAntes 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:
.envacaba dentro de la imagen. ConCOPY . ., tus secretos quedan grabados en una capa, y las capas se publican en un registro: cualquiera con acceso a la imagen los extrae condocker history. Esto ha filtrado credenciales de empresas grandes más de una vez.node_moduleslocal se copia dentro. Además de ser lento, si tu portátil es macOS o Windows, los binarios compilados debcryptson de otra plataforma y no funcionan en Linux. Errores incomprensibles garantizados..gitengorda 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
informesNota 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.
HEALTHCHECK con /salud/listo
HEALTHCHECK con /salud/listoHEALTHCHECK 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.
- 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 # PublicarSobre 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.
docker compose: el entorno completo de desarrollo
docker compose: el entorno completo de desarrolloAquí 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_onconcondition: service_healthy. Eldepends_onsimple 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_onsolo 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_fileseparado. Las variables no secretas van enenvironment(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
Dockerfilepara dos servicios.apiyconsumidorcomparten imagen y solo difieren encommand: 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.
- Migraciones en contenedor
Tentación evidente: poner npm run migrar && node src/servidor.js en el CMD. Es un error, por tres razones.
- 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 TABLEconcurrente producen resultados imprevisibles. - Rompe la forma exec. Ese
&&obliga a una shell, y ya vimos lo que le pasa aSIGTERMcon una shell por medio. - 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.
- 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:
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.
- Qué no se mete en la imagen y cómo escanearla
Nunca en la imagen:
- Secretos. Ni con
ENV, ni conARG, ni copiando un.env. Todo queda en el historial de capas y se recupera condocker 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), usaRUN --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
CMDen forma shell onpm start.SIGTERMno llega a Node,SIGKILLcorta a media respuesta y el apagado ordenado del módulo 6 no sirve de nada. SiempreCMD ["node", "src/servidor.js"].- Olvidar
.dockerignore. Copias.envynode_modulesdentro. Secretos publicados y binarios de otra plataforma. COPY . .antes denpm ci, que hace que cada cambio de una línea reinstale todas las dependencias, oFROM 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-sizecon 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 shy mira dentro: comprueba quenode_modulesno tiene mocha, que no hay.envy que el usuario esnode. 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
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
