Al cerrar el módulo 4 teníamos servicio-catalogo y servicio-pedidos funcionando con npm run dev en un portátil, con MongoDB, PostgreSQL y RabbitMQ levantados a mano con docker run. Eso sirve para desarrollar, pero no para desplegar: en producción cada servicio se ejecutará en varias réplicas, en máquinas que nadie ha preparado a mano, y tendrá que arrancar, morir y volver a arrancar cientos de veces sin que un humano intervenga. El contenedor es la unidad que hace posible todo eso: empaqueta el servicio con su Node.js exacto y sus dependencias en una imagen inmutable que se ejecuta igual en el portátil de Luis, en el runner de CI y en el clúster de Kubernetes. Esta lección construye la imagen de producción de servicio-catalogo, explica cómo se etiqueta, se ejecuta y se apaga, y levanta por primera vez el sistema completo de TechCorp con Docker Compose, incluida la única prueba E2E que 04-05 dejó pendiente. Kubernetes (05-02) y el pipeline que construirá estas imágenes en cada commit (05-03) se apoyan en lo que aquí queda fijado.

Contenido

  1. Por qué contenedores para microservicios
  2. Conceptos: imagen, capa, contenedor, registro, volumen y red
  3. El Dockerfile de producción de servicio-catalogo, línea a línea
  4. .dockerignore, caché de capas y orden de las instrucciones
  5. Etiquetado de imágenes y registro
  6. Comandos básicos de Docker
  7. Señales, PID 1 y apagado ordenado dentro del contenedor
  8. Docker Compose: el entorno local completo de TechCorp
  9. Operar el entorno: up, down, logs, ps, migraciones y la prueba E2E
  10. Tamaño de imagen y seguridad básica

  1. Por qué contenedores para microservicios

Un contenedor es un proceso aislado que el núcleo de Linux ejecuta con su propio sistema de ficheros, su propia vista de la red y límites de CPU y memoria, a partir de una imagen que contiene todo lo que ese proceso necesita (binario de Node, node_modules, código). No hay un sistema operativo invitado ni un hipervisor: el contenedor comparte el núcleo del anfitrión, por eso arranca en milisegundos y ocupa megabytes.

Aspecto Máquina virtual Contenedor
Qué se virtualiza Hardware completo (CPU, disco, red) con su propio núcleo Solo el espacio de usuario; comparte el núcleo del anfitrión
Tamaño típico Gigabytes Decenas o cientos de megabytes
Arranque Decenas de segundos a minutos Milisegundos a segundos
Densidad por máquina Unidades o decenas Cientos
Aislamiento Fuerte (núcleo propio) Bueno (namespaces y cgroups), no equivalente a una VM
Unidad de despliegue Una imagen de VM (AMI, OVA), pesada de construir Una imagen de contenedor, construida en segundos por CI

Para TechCorp el encaje es directo:

  • Una imagen = una unidad de despliegue. servicio-catalogo es una imagen; desplegar la versión 1.4.2 es ejecutar esa imagen. No hay "instalar Node 20 en el servidor" ni "copiar la carpeta y hacer npm install": todo eso ocurrió una vez, al construir la imagen.
  • Reproducibilidad. La misma imagen que pasó las pruebas de integración en CI es la que se ejecuta en producción, byte a byte. Se acaba el "en mi máquina funciona" y el "en producción hay otra versión de pg".
  • Aislamiento. Seis servicios Node en la misma máquina no comparten node_modules, ni puertos, ni variables de entorno. Cada uno cree tener la máquina para él.
  • Arranque rápido y desechable. El apagado ordenado de 04-02 y la configuración por entorno de 04-03 se escribieron pensando en esto: un contenedor nace, sirve, recibe SIGTERM y muere; otro ocupa su lugar.

  1. Conceptos: imagen, capa, contenedor, registro, volumen y red

Concepto Qué es En TechCorp
Imagen Plantilla inmutable, de solo lectura, con el sistema de ficheros y metadatos (comando de arranque, puertos, usuario) ghcr.io/techcorp/servicio-catalogo:1.4.2
Capa Cada instrucción del Dockerfile que cambia el sistema de ficheros produce una capa; la imagen es la pila de capas. Las capas se cachean y se comparten entre imágenes Las seis imágenes de servicios comparten la capa base de node:20-alpine
Contenedor Una instancia en ejecución de una imagen: las capas de la imagen más una capa de escritura efímera Cada réplica de servicio-catalogo
Registro Almacén de imágenes desde el que se hace push/pull GitHub Container Registry (ghcr.io), junto al código y a @techcorp/comun-http (04-01)
Volumen Almacenamiento persistente fuera de la capa de escritura del contenedor Datos de PostgreSQL, MongoDB y RabbitMQ en local; los servicios de TechCorp no usan volúmenes (son stateless)
Red Red virtual en la que los contenedores se resuelven por nombre En Compose, servicio-pedidos llama a http://servicio-catalogo:3001, el mismo nombre que resolverá el DNS de Kubernetes (03-05)

Que los servicios no tengan estado en disco no es casualidad: es lo que permite matarlos y replicarlos sin pensar. Todo lo que debe sobrevivir vive en la base de datos o en el broker.

  1. El Dockerfile de producción de servicio-catalogo, línea a línea

El Dockerfile vive en la raíz del repositorio techcorp/servicio-catalogo y viene de la plantilla techcorp/plantilla-servicio-node (04-01, ejercicio 2: el Dockerfile se copia y se adapta). Usa multi-stage build: una etapa instala dependencias y otra, limpia, se queda solo con lo necesario para ejecutar.

# syntax=docker/dockerfile:1

# ---------- Etapa 1: dependencias ----------
FROM node:20-alpine AS dependencias
WORKDIR /app
# Solo los ficheros que definen las dependencias: si no cambian, esta capa (y npm ci) se reutilizan de la caché.
COPY package.json package-lock.json ./
# @techcorp/comun-http está en GitHub Packages (04-01): npm necesita un token para descargarla.
# El .npmrc se monta SOLO durante este RUN como secreto de build; no queda en ninguna capa de la imagen.
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --omit=dev

# ---------- Etapa 2: imagen final ----------
FROM node:20-alpine
ENV NODE_ENV=production
WORKDIR /app
# node_modules ya instalado y sin dependencias de desarrollo; propiedad del usuario 'node' que trae la imagen base.
COPY --from=dependencias --chown=node:node /app/node_modules ./node_modules
COPY --chown=node:node package.json ./
COPY --chown=node:node src ./src
COPY --chown=node:node scripts ./scripts
# A partir de aquí el proceso no es root.
USER node
EXPOSE 3001
# Opcional: Docker (no Kubernetes) marca el contenedor como unhealthy si /health/live no responde 200.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget -qO- http://localhost:3001/health/live || exit 1
# Forma exec (JSON): node es PID 1 y recibe SIGTERM directamente (apartado 7).
CMD ["node", "src/servidor.js"]

Instrucción por instrucción:

  • # syntax=docker/dockerfile:1: activa la sintaxis moderna de BuildKit (necesaria para --mount=type=secret).
  • FROM node:20-alpine AS dependencias: imagen base oficial de Node 20 sobre Alpine Linux (unos 50 MB frente a los 350 MB de node:20). AS dependencias le da nombre a la etapa para poder copiar de ella después.
  • WORKDIR /app: crea /app y lo hace directorio de trabajo para las instrucciones siguientes.
  • COPY package.json package-lock.json ./ antes de copiar el código: es la clave de la caché (apartado 4).
  • RUN --mount=type=secret,id=npmrc ... npm ci --omit=dev: npm ci instala exactamente lo que dice el package-lock.json (reproducible; falla si el lock no cuadra con package.json); --omit=dev deja fuera nodemon, jest, supertest, @pact-foundation/pact, testcontainers. El token de GitHub Packages se monta como fichero solo durante este comando; se pasa al construir con --secret id=npmrc,src=$HOME/.npmrc.
  • FROM node:20-alpine (segunda vez): la imagen final empieza de cero; nada de la etapa anterior pasa a ella salvo lo que se copie explícitamente.
  • ENV NODE_ENV=production: Express desactiva mensajes de depuración y algunas librerías optimizan; el config.js de 04-03 lo lee como una variable más.
  • COPY --from=dependencias --chown=node:node /app/node_modules ./node_modules: trae solo node_modules ya instalado. --chown=node:node asigna la propiedad al usuario node (uid 1000) que la imagen base ya define; sin ello los ficheros serían de root y el proceso no root podría no leerlos si los permisos fuesen restrictivos.
  • Tres COPY separados para package.json, src y scripts: scripts/ entra porque semilla.js se ejecuta desde la misma imagen (apartado 8). No se copian pruebas, contratos ni ficheros de configuración de desarrollo.
  • USER node: todo lo que se ejecute a partir de aquí (incluido CMD) corre sin privilegios. Es la medida de seguridad más barata y más eficaz de esta lección.
  • EXPOSE 3001: documenta el puerto (no lo publica; eso se hace con -p o en Compose/Kubernetes). Coincide con PUERTO=3001 de 04-02.
  • HEALTHCHECK: Docker ejecuta wget contra /health/live cada 30 s, con 10 s de gracia al arrancar; tres fallos seguidos marcan el contenedor unhealthy. Alpine trae wget en BusyBox, así que no hace falta instalar curl. Kubernetes ignora HEALTHCHECK y usa sus propias probes (05-02); en Compose sí es útil para depends_on (apartado 8).
  • CMD ["node", "src/servidor.js"]: el comando de arranque, igual que el script start de 04-02, pero sin pasar por npm (apartado 7).

¿Por qué multi-stage si un servicio JavaScript no compila nada? Porque separa dos preocupaciones: en la primera etapa puede haber tokens, caché de npm, herramientas de compilación de módulos nativos; en la segunda solo lo que se ejecuta. Si un día un servicio se escribe en TypeScript, la etapa 1 pasa a ser "instalar y compilar" y la etapa 2 no cambia.

  1. .dockerignore, caché de capas y orden de las instrucciones

COPY src ./src copia lo que hay en el contexto de build (el directorio que se pasa a docker build). Sin filtro, un COPY . . arrastraría node_modules local (con dependencias de desarrollo y binarios de otro sistema operativo), .env con secretos, .git, pactos/, cobertura de pruebas... El .dockerignore los excluye del contexto:

node_modules
npm-debug.log
.env
.env.*
!.env.ejemplo
.git
.github
coverage
pruebas
pactos
*.md

Sobre la caché: Docker construye las capas en orden y, para cada instrucción, reutiliza la capa cacheada si la instrucción y sus entradas no han cambiado; en cuanto una capa cambia, todas las siguientes se reconstruyen. De ahí el orden del Dockerfile:

flowchart LR
    A[FROM node:20-alpine] --> B[COPY package*.json]
    B --> C[RUN npm ci]
    C --> D[COPY src, scripts]
    D --> E[CMD]
    style C fill:#dfe,stroke:#393
    style D fill:#fdd,stroke:#933

Un cambio en src/rutas/productos.js invalida solo la capa roja: el npm ci (la operación lenta, 30-60 s con descarga) se reutiliza de la caché. Si copiásemos primero todo el código y luego instalásemos, cada commit repetiría la instalación. Regla: lo que cambia menos, arriba; lo que cambia más, abajo.

  1. Etiquetado de imágenes y registro

Una imagen se identifica por registro/organización/nombre:etiqueta. TechCorp usa dos etiquetas por build:

Etiqueta Ejemplo Quién la usa
Versión semántica ghcr.io/techcorp/servicio-catalogo:1.4.2 Manifiestos de Kubernetes, notas de versión, humanos
Commit de origen ghcr.io/techcorp/servicio-catalogo:sha-9f3c2ab Trazabilidad exacta: de qué código sale la imagen; el pipeline de 05-03 la crea en cada build

Ambas apuntan al mismo digest (sha256:...), que es el identificador real e inmutable de la imagen. Lo que no se usa en producción es latest:

  • latest no significa "la más reciente", significa "la última a la que alguien puso esa etiqueta". Es una etiqueta mutable: hoy apunta a 1.4.2 y mañana a 1.5.0 sin que ningún manifiesto cambie.
  • Con latest, dos réplicas del mismo Deployment pueden ejecutar versiones distintas según cuándo hicieron pull, y un rollback es imposible porque nadie sabe qué había antes.
  • Los manifiestos de 05-02 llevan siempre una etiqueta concreta; cambiar de versión es cambiar esa línea, y eso queda en git.

Construcción y publicación manual (el pipeline de 05-03 automatiza exactamente esto):

# En la raíz de servicio-catalogo. -t añade una etiqueta; se pueden poner varias.
docker build \
  --secret id=npmrc,src=$HOME/.npmrc \
  -t ghcr.io/techcorp/servicio-catalogo:1.4.2 \
  -t ghcr.io/techcorp/servicio-catalogo:sha-9f3c2ab \
  .

# Autenticación en el registro con un token de GitHub con permiso write:packages
echo "$GITHUB_TOKEN" | docker login ghcr.io -u luis --password-stdin

docker push ghcr.io/techcorp/servicio-catalogo:1.4.2
docker push ghcr.io/techcorp/servicio-catalogo:sha-9f3c2ab

  1. Comandos básicos de Docker

Los que se usan a diario, con la imagen recién construida:

# Ejecutar en segundo plano (-d), con nombre, publicando el puerto 3001 del contenedor en el 3001 del anfitrión (-p host:contenedor)
# y pasando la configuración por variables de entorno (04-03). --rm borra el contenedor al pararse.
docker run -d --rm --name catalogo -p 3001:3001 \
  -e MONGO_URL=mongodb://host.docker.internal:27017 -e MONGO_BD=catalogo -e LOG_NIVEL=debug \
  ghcr.io/techcorp/servicio-catalogo:1.4.2

docker ps                          # contenedores en ejecución: id, imagen, estado (healthy/unhealthy), puertos
docker logs -f catalogo            # stdout/stderr del proceso (pino escribe JSON a stdout: por eso funciona sin ficheros de log)
docker exec -it catalogo sh        # una shell dentro del contenedor (Alpine: sh, no bash) para inspeccionar
docker exec catalogo node scripts/semilla.js   # ejecutar un comando puntual con el mismo entorno del contenedor
docker stop catalogo               # SIGTERM y, si en 10 s no ha terminado, SIGKILL (apartado 7)
docker images                      # imágenes locales y su tamaño

host.docker.internal es el nombre con el que un contenedor llega al anfitrión (Docker Desktop; en Linux, --add-host=host.docker.internal:host-gateway). Solo tiene sentido en este experimento suelto: en Compose y en Kubernetes los servicios se encuentran por nombre dentro de la misma red.

  1. Señales, PID 1 y apagado ordenado dentro del contenedor

En 04-02 escribimos el apagado ordenado: al recibir SIGTERM, /health/ready pasa a 503, servidor.close() termina las peticiones en curso, se cierra Mongo y el proceso sale, con un límite de 10 s. Dentro de un contenedor hay tres detalles que pueden anular ese trabajo:

  1. Quién es PID 1. El proceso arrancado por CMD es el PID 1 del contenedor y es el único que recibe la señal de docker stop o del kubelet. Con la forma exec CMD ["node", "src/servidor.js"], PID 1 es Node y nuestro manejador se ejecuta. Con la forma shell CMD node src/servidor.js, PID 1 es /bin/sh, que recibe SIGTERM y no lo reenvía a Node: el servicio muere 10 s después por SIGKILL, con las peticiones a medias. Y con CMD ["npm", "start"], PID 1 es npm, que tampoco reenvía señales de forma fiable. Por eso el Dockerfile llama a node directamente.
  2. PID 1 no tiene manejadores por defecto. El núcleo trata al PID 1 de forma especial: si no instala manejador para una señal, la ignora. Node sí instala el nuestro (process.on('SIGTERM')), así que estamos cubiertos; un servicio que se olvidase de ello no moriría nunca con docker stop, solo con SIGKILL.
  3. Procesos zombi. PID 1 debe "recoger" procesos hijos terminados. Node no lanza hijos en nuestros servicios, pero si algún día uno ejecuta child_process, conviene un init mínimo: docker run --init (Docker inyecta tini) o, en Kubernetes, shareProcessNamespace o tini en la imagen. Es una línea barata que evita un problema raro de diagnosticar.

Tiempos: docker stop espera 10 s por defecto (-t lo cambia); Compose usa stop_grace_period; Kubernetes, terminationGracePeriodSeconds (30 s por defecto, 05-02). El límite interno de 10 s de 04-02 está pensado para caber dentro de cualquiera de ellos.

  1. Docker Compose: el entorno local completo de TechCorp

Docker Compose describe en un YAML un conjunto de contenedores, redes y volúmenes y los levanta con un comando. Es la herramienta del entorno local y de la E2E (04-01, 04-05); no es un orquestador de producción. El fichero vive en el repositorio techcorp/plataforma, en local/compose.yaml, y asume que los repositorios de servicios están clonados como directorios hermanos (../../servicio-catalogo, etc.).

# techcorp/plataforma/local/compose.yaml
name: techcorp

services:
  # ---------- Dependencias ----------
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: svc_pedidos
      POSTGRES_PASSWORD: dev-pedidos          # solo desarrollo local; en producción, Secret (05-02)
      POSTGRES_DB: pedidos
    volumes:
      - pg-datos:/var/lib/postgresql/data      # los datos sobreviven a docker compose down (sin -v)
    ports:
      - "5432:5432"                            # publicado solo para poder conectar psql/DBeaver desde el portátil
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U svc_pedidos -d pedidos"]
      interval: 5s
      timeout: 3s
      retries: 10

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

  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - "5672:5672"                            # AMQP (los servicios)
      - "15672:15672"                          # consola web http://localhost:15672 (guest/guest)
    volumes:
      - rabbitmq-datos:/var/lib/rabbitmq
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
      interval: 5s
      timeout: 5s
      retries: 12

  # ---------- Tareas de un solo uso ----------
  catalogo-semilla:                            # scripts/semilla.js de 04-02, con la misma imagen del servicio
    image: ghcr.io/techcorp/servicio-catalogo:local
    build:
      context: ../../servicio-catalogo
      secrets: [npmrc]
    command: ["node", "scripts/semilla.js"]
    environment:
      MONGO_URL: mongodb://mongo:27017
      MONGO_BD: catalogo
    depends_on:
      mongo: { condition: service_healthy }
    restart: "no"                              # termina y no se reinicia; es idempotente (upsert)

  pedidos-migraciones:                         # scripts/migrar.js de 04-04, antes de arrancar servicio-pedidos
    image: ghcr.io/techcorp/servicio-pedidos:local
    build:
      context: ../../servicio-pedidos
      secrets: [npmrc]
    command: ["node", "scripts/migrar.js"]
    environment:
      PEDIDOS_DB_URL: postgres://svc_pedidos:dev-pedidos@postgres:5432/pedidos
    depends_on:
      postgres: { condition: service_healthy }
    restart: "no"

  # ---------- Servicios ----------
  servicio-catalogo:
    image: ghcr.io/techcorp/servicio-catalogo:local
    build:
      context: ../../servicio-catalogo
      secrets: [npmrc]
    environment:
      PUERTO: "3001"
      MONGO_URL: mongodb://mongo:27017
      MONGO_BD: catalogo
      LOG_NIVEL: debug
    depends_on:
      mongo: { condition: service_healthy }
      catalogo-semilla: { condition: service_completed_successfully }
    stop_grace_period: 15s                     # margen para el apagado ordenado (10 s internos + holgura)

  servicio-pedidos:
    image: ghcr.io/techcorp/servicio-pedidos:local
    build:
      context: ../../servicio-pedidos
      secrets: [npmrc]
    environment:                               # exactamente la columna "desarrollo" de la tabla de 04-03, con nombres de red de Compose
      PUERTO: "3002"
      NODE_ENV: production
      LOG_NIVEL: debug
      PEDIDOS_DB_URL: postgres://svc_pedidos:dev-pedidos@postgres:5432/pedidos
      RABBITMQ_URL: amqp://rabbitmq:5672
      CATALOGO_URL: http://servicio-catalogo:3001
      CLIENTES_URL: http://servicio-clientes:3004
      TIMEOUT_HTTP_MS: "2000"
      OUTBOX_INTERVALO_MS: "500"
      CATALOGO_REMOTO: "true"
    depends_on:
      postgres: { condition: service_healthy }
      rabbitmq: { condition: service_healthy }
      pedidos-migraciones: { condition: service_completed_successfully }
      servicio-catalogo: { condition: service_started }
    stop_grace_period: 15s

  servicio-clientes:                           # aún no extraído: el stub de 04-04 (scripts/stubClientes.js) responde c-1024
    image: ghcr.io/techcorp/servicio-pedidos:local
    command: ["node", "scripts/stubClientes.js"]
    environment:
      PUERTO: "3004"

  gateway:                                     # el gateway Express de 03-04, con las rutas /api/v1/*
    image: ghcr.io/techcorp/gateway:local
    build:
      context: ../../gateway
      secrets: [npmrc]
    ports:
      - "8080:8080"                            # la ÚNICA puerta pública del sistema
    environment:
      PUERTO: "8080"
      CATALOGO_URL: http://servicio-catalogo:3001
      PEDIDOS_URL: http://servicio-pedidos:3002
      CLIENTES_URL: http://servicio-clientes:3004
      MONOLITO_URL: http://host.docker.internal:3000   # el monolito sigue en el portátil, si hace falta
    depends_on:
      - servicio-catalogo
      - servicio-pedidos
      - servicio-clientes

volumes:
  pg-datos:
  mongo-datos:
  rabbitmq-datos:

secrets:
  npmrc:
    file: ${HOME}/.npmrc                       # token de GitHub Packages para npm ci; nunca entra en la imagen

Puntos que conviene entender bien:

  • Red por defecto. Compose crea una red techcorp_default y conecta todos los servicios; cada uno resuelve a los demás por el nombre del servicio. Por eso CATALOGO_URL=http://servicio-catalogo:3001 y RABBITMQ_URL=amqp://rabbitmq:5672 son idénticos a los que usaremos en Kubernetes: la configuración de 04-03 no cambia entre local y clúster.
  • ports solo donde hace falta. El gateway publica 8080; las bases de datos y RabbitMQ se publican por comodidad de desarrollo. Los servicios servicio-* no publican puertos: solo se llega a ellos por el gateway o desde otros contenedores, como en producción.
  • healthcheck + depends_on: condition. Sin condición, depends_on solo ordena el arranque, no espera a que PostgreSQL acepte conexiones; servicio-pedidos arrancaría, config.js validaría, el pool fallaría y el /health/ready estaría en 503 hasta que se conectase. Con service_healthy Compose espera al healthcheck; con service_completed_successfully espera a que la tarea de un solo uso termine con código 0. Así las migraciones siempre están aplicadas antes de que arranque Pedidos, sin scripts de espera.
  • Tareas de un solo uso con la misma imagen. catalogo-semilla y pedidos-migraciones no necesitan otra imagen: usan la del servicio y cambian command. Es la razón por la que el Dockerfile copia scripts/. En Kubernetes serán Job (05-02).
  • image + build. Con ambos, docker compose build construye y etiqueta la imagen como :local; docker compose up la usa. Si mañana se quiere probar la imagen que publicó CI, basta con cambiar :local por :sha-9f3c2ab y omitir el build.
  • servicio-clientes como stub. Reutiliza scripts/stubClientes.js de 04-04 desde la imagen de Pedidos. Cuando el servicio real exista (orden de extracción de 02-02), se sustituye por su propio build, con CLIENTES_DB_URL y el resto sin tocar.

  1. Operar el entorno: up, down, logs, ps, migraciones y la prueba E2E

cd plataforma/local

docker compose build                    # construye las imágenes :local (usa la caché de capas del apartado 4)
docker compose up -d                    # levanta todo en orden: dependencias → semilla/migraciones → servicios → gateway
docker compose ps                       # estado de cada servicio: running (healthy), exited (0) para las tareas de un solo uso
docker compose logs -f servicio-pedidos # logs de un servicio; sin nombre, de todos, intercalados y con prefijo
docker compose exec servicio-pedidos sh # shell dentro de un servicio
docker compose run --rm pedidos-migraciones   # volver a lanzar las migraciones a mano (p. ej. tras añadir 005-*.sql)
docker compose restart servicio-pedidos # reiniciar uno (recarga variables si se cambió el YAML tras un 'up')
docker compose down                     # para y borra contenedores y red; los volúmenes se conservan
docker compose down -v                  # ...y borra también los volúmenes: base de datos limpia

Comprobación manual del flujo de pedido de todo el curso, esta vez a través del gateway y con todos los servicios en contenedores:

curl -s http://localhost:8080/api/v1/productos?ids=p-501,p-777 | jq .datos[].nombre
curl -s -X POST http://localhost:8080/api/v1/pedidos \
  -H 'Content-Type: application/json' -H 'Idempotency-Key: 7c1e0b3a-e2e-0001' \
  -d '{"clienteId":"c-1024","lineas":[{"productoId":"p-501","cantidad":1},{"productoId":"p-777","cantidad":2}]}'
# → 202 Accepted, {"id":"ped-...","estado":"PENDIENTE"} ; en la consola de RabbitMQ (15672) aparece pedido.creado en techcorp.eventos

Como Inventario, Pagos y Notificaciones aún no están extraídos, en local se simulan sus respuestas publicando los eventos con scripts/publicarEvento.js de 04-04, o añadiendo al Compose stubs consumidores (ejercicio 2). La prueba E2E de 04-05 se ejecuta sobre este mismo entorno desde el repositorio plataforma:

docker compose up -d --wait             # --wait: no devuelve el control hasta que todos los healthchecks estén en verde
GATEWAY_URL=http://localhost:8080 npm run test:e2e   # pruebas/e2e/crearPedido.e2e.test.js: POST /api/v1/pedidos y polling hasta CONFIRMADO (15 s)
docker compose down -v

Ese trío de comandos es literalmente lo que ejecutará el pipeline de 05-03 antes de promocionar a staging: si una cola está mal enlazada o falta una variable, falla aquí y no en producción.

  1. Tamaño de imagen y seguridad básica

Práctica Efecto Estado en TechCorp
Base node:20-alpine (o node:20-slim si un módulo nativo no compila con musl) Imagen final de ~130 MB en lugar de ~450 MB; menos superficie de ataque Alpine por defecto en la plantilla
Multi-stage + npm ci --omit=dev Sin Jest, Pact ni Testcontainers en producción Sí
.dockerignore Contexto pequeño, sin .env ni .git Sí
Usuario no root (USER node) Un fallo del servicio no da root en el contenedor Sí; Kubernetes lo exigirá con runAsNonRoot (05-02)
Sin secretos en la imagen Ni .env, ni .npmrc, ni ARG con contraseñas (los ARG quedan en el historial de la imagen) --mount=type=secret para el token de npm
Fijar la base por digest (node:20-alpine@sha256:...) Builds reproducibles aunque la etiqueta se mueva Lo gestiona el pipeline con Renovate/Dependabot (05-03)
Escaneo de vulnerabilidades (Trivy, Grype) Detectar CVEs en la base y en node_modules Etapa del pipeline; detalle en 07-04
docker history / dive Ver qué capa pesa y por qué Herramienta de diagnóstico

La regla resumida: la imagen contiene código y dependencias; nada más. Configuración y secretos llegan del entorno (04-03), datos viven en volúmenes o servicios externos, y el proceso no es root.

Errores Comunes y Consejos

  • COPY . . al principio del Dockerfile. Cada cambio de código repite npm ci. Primero package*.json, luego instalar, luego el código.
  • Olvidar .dockerignore. El node_modules del portátil (con binarios de macOS) y el .env con la contraseña de PostgreSQL acaban dentro de la imagen publicada en ghcr.io.
  • CMD npm start o forma shell. SIGTERM no llega a Node; cada despliegue corta peticiones. Forma exec y node directamente.
  • latest en cualquier sitio que no sea el portátil. Sin trazabilidad ni rollback.
  • depends_on sin condition. "Funciona" en el portátil rápido y falla en el runner de CI, donde PostgreSQL tarda 8 s en aceptar conexiones. healthcheck en cada dependencia y service_healthy.
  • Publicar puertos de los servicios internos "para probar". Se prueban a través del gateway (o con docker compose exec); publicar 3002 acostumbra a saltarse la única puerta que existirá en producción.
  • Consejo: ejecuta docker compose config para ver el YAML final con variables sustituidas; y docker compose up --build --wait como comando único al empezar el día.

Ejercicios

Ejercicio 1. Escribe el Dockerfile de producción de servicio-pedidos (04-04) partiendo del de Catálogo. Indica qué líneas cambian y por qué, teniendo en cuenta que Pedidos necesita migraciones/ y scripts/migrar.js dentro de la imagen, escucha en 3002 y su HEALTHCHECK no debe usarse en Kubernetes.

Ejercicio 2. El equipo de Pedidos quiere que la E2E pase en local sin Inventario ni Pagos reales. Añade al compose.yaml un servicio saga-simulada que ejecute un script Node (scripts/sagaSimulada.js, ya escrito: consume pedido.creado de una cola propia y publica stock.reservado y pago.confirmado) usando la imagen de Pedidos. ¿Qué variables necesita, de qué depende y por qué no debe publicar puertos?

Ejercicio 3. Un compañero ejecuta docker stop servicio-catalogo y observa que tarda exactamente 10 s y que en los logs no aparece "apagando". Su Dockerfile termina con CMD npm start. Explica la causa, la corrección, y qué otro síntoma tendría en Kubernetes con terminationGracePeriodSeconds: 30.

Soluciones

Solución 1. Cambian: COPY --chown=node:node migraciones ./migraciones añadido junto a src y scripts (el Job de 05-02 y pedidos-migraciones de Compose ejecutan node scripts/migrar.js desde esta imagen y leen migraciones/*.sql); EXPOSE 3002; el HEALTHCHECK apunta a http://localhost:3002/health/live. Como Kubernetes ignora HEALTHCHECK (usa livenessProbe), se puede dejar para Compose o eliminarlo; lo importante es no confundirlo con la probe. Todo lo demás (dos etapas, npm ci --omit=dev con el secreto npmrc, --chown=node:node, USER node, CMD ["node","src/servidor.js"]) es idéntico: es lo que la plantilla de 04-01 da hecho.

Solución 2.

  saga-simulada:
    image: ghcr.io/techcorp/servicio-pedidos:local
    command: ["node", "scripts/sagaSimulada.js"]
    environment:
      RABBITMQ_URL: amqp://rabbitmq:5672
      LOG_NIVEL: debug
    depends_on:
      rabbitmq: { condition: service_healthy }
      servicio-pedidos: { condition: service_started }   # para que la topología (exchange techcorp.eventos) ya esté declarada
    restart: unless-stopped

Solo necesita RABBITMQ_URL (habla únicamente por eventos, como Inventario y Pagos en 03-04: "no expuestos"). No publica puertos porque no expone HTTP y porque nada de fuera de la red de Compose debe hablar con él; si un día lo hiciera, sería por el gateway. Depende de RabbitMQ sano y de que Pedidos haya arrancado (declara la topología al conectar; alternativamente el script puede declararla él mismo con mensajeria/topologia de la librería, y entonces la segunda dependencia sobra).

Solución 3. Con CMD npm start, PID 1 es npm, que arranca node src/servidor.js como hijo. docker stop envía SIGTERM a PID 1 (npm), que no lo reenvía a Node; el manejador process.on('SIGTERM') de 04-02 nunca se ejecuta, así que no hay línea "apagando" ni servidor.close(). Pasados 10 s Docker envía SIGKILL y todo muere en seco: las peticiones en curso reciben connection reset. Corrección: CMD ["node", "src/servidor.js"] (forma exec, Node como PID 1). En Kubernetes el síntoma sería que cada rolling update (05-04) tarda 30 s por pod en lugar de 1-2 s y que, durante esos 30 s, el pod sigue recibiendo tráfico hasta que el readinessProbe falla, porque /health/ready nunca pasó a 503; con la forma exec, el propio servicio se marca no listo en el instante del SIGTERM.

Conclusión

Hemos convertido los procesos Node de los módulos anteriores en unidades desplegables: una imagen por servicio, construida con un Dockerfile multi-stage sobre node:20-alpine (npm ci --omit=dev con el token de npm como secreto de build, COPY --chown=node:node, USER node, EXPOSE, HEALTHCHECK opcional a /health/live, CMD ["node","src/servidor.js"] en forma exec para que SIGTERM llegue al apagado ordenado de 04-02), con .dockerignore y un orden de instrucciones que aprovecha la caché de capas, etiquetada con versión semántica y sha-<commit> en ghcr.io/techcorp/ y nunca con latest. Y hemos levantado por primera vez el sistema completo con Docker Compose (plataforma/local/compose.yaml): PostgreSQL 16, MongoDB 7 y RabbitMQ con healthchecks, tareas de un solo uso para la semilla y las migraciones, servicio-catalogo, servicio-pedidos, el stub de Clientes y el gateway en 8080, con las mismas variables de entorno de 04-03 y los mismos nombres DNS de 03-05, y sobre él la única prueba E2E del curso. Compose es perfecto para un portátil, pero no reinicia un contenedor caído en otra máquina, no reparte réplicas ni gestiona despliegues sin cortes: para eso está Kubernetes, que en la siguiente lección ejecutará estas mismas imágenes con los ConfigMap y Secret que 04-03 dejó nombrados.

Curso de Microservicios

Módulo 1: Introducción a los Microservicios

Módulo 2: Diseño de Microservicios

Módulo 3: Comunicación entre Microservicios

Módulo 4: Implementación de Microservicios

Módulo 5: Despliegue y Orquestación

Módulo 6: Monitoreo y Mantenimiento

Módulo 7: Seguridad en Microservicios

Módulo 8: Casos de Estudio y Ejemplos Prácticos

© Copyright 2026. Todos los derechos reservados