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
- Por qué contenedores para microservicios
- Conceptos: imagen, capa, contenedor, registro, volumen y red
- El
Dockerfilede producción deservicio-catalogo, línea a línea .dockerignore, caché de capas y orden de las instrucciones- Etiquetado de imágenes y registro
- Comandos básicos de Docker
- Señales, PID 1 y apagado ordenado dentro del contenedor
- Docker Compose: el entorno local completo de TechCorp
- Operar el entorno:
up,down,logs,ps, migraciones y la prueba E2E - Tamaño de imagen y seguridad básica
- 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-catalogoes 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 hacernpm 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.
- 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.
- El
Dockerfile de producción de servicio-catalogo, línea a línea
Dockerfile de producción de servicio-catalogo, línea a líneaEl 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 denode:20).AS dependenciasle da nombre a la etapa para poder copiar de ella después.WORKDIR /app: crea/appy 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 ciinstala exactamente lo que dice elpackage-lock.json(reproducible; falla si el lock no cuadra conpackage.json);--omit=devdeja fueranodemon,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; elconfig.jsde 04-03 lo lee como una variable más.COPY --from=dependencias --chown=node:node /app/node_modules ./node_modules: trae solonode_modulesya instalado.--chown=node:nodeasigna la propiedad al usuarionode(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
COPYseparados parapackage.json,srcyscripts:scripts/entra porquesemilla.jsse 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í (incluidoCMD) 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-po en Compose/Kubernetes). Coincide conPUERTO=3001de 04-02.HEALTHCHECK: Docker ejecutawgetcontra/health/livecada 30 s, con 10 s de gracia al arrancar; tres fallos seguidos marcan el contenedorunhealthy. Alpine traewgeten BusyBox, así que no hace falta instalarcurl. Kubernetes ignoraHEALTHCHECKy usa sus propias probes (05-02); en Compose sí es útil paradepends_on(apartado 8).CMD ["node", "src/servidor.js"]: el comando de arranque, igual que el scriptstartde 04-02, pero sin pasar pornpm(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.
.dockerignore, caché de capas y orden de las instrucciones
.dockerignore, caché de capas y orden de las instruccionesCOPY 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:
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.
- 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:
latestno 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 mismoDeploymentpueden ejecutar versiones distintas según cuándo hicieron pull, y unrollbackes 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
- 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ñohost.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.
- 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:
- Quién es PID 1. El proceso arrancado por
CMDes el PID 1 del contenedor y es el único que recibe la señal dedocker stopo del kubelet. Con la forma execCMD ["node", "src/servidor.js"], PID 1 es Node y nuestro manejador se ejecuta. Con la forma shellCMD 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 conCMD ["npm", "start"], PID 1 esnpm, que tampoco reenvía señales de forma fiable. Por eso elDockerfilellama anodedirectamente. - 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 condocker stop, solo con SIGKILL. - 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 uninitmínimo:docker run --init(Docker inyectatini) o, en Kubernetes,shareProcessNamespaceotinien 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.
- 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 imagenPuntos que conviene entender bien:
- Red por defecto. Compose crea una red
techcorp_defaulty conecta todos los servicios; cada uno resuelve a los demás por el nombre del servicio. Por esoCATALOGO_URL=http://servicio-catalogo:3001yRABBITMQ_URL=amqp://rabbitmq:5672son idénticos a los que usaremos en Kubernetes: la configuración de 04-03 no cambia entre local y clúster. portssolo donde hace falta. El gateway publica 8080; las bases de datos y RabbitMQ se publican por comodidad de desarrollo. Los serviciosservicio-*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_onsolo ordena el arranque, no espera a que PostgreSQL acepte conexiones;servicio-pedidosarrancaría,config.jsvalidaría, el pool fallaría y el/health/readyestaría en 503 hasta que se conectase. Conservice_healthyCompose espera al healthcheck; conservice_completed_successfullyespera 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-semillaypedidos-migracionesno necesitan otra imagen: usan la del servicio y cambiancommand. Es la razón por la que elDockerfilecopiascripts/. En Kubernetes seránJob(05-02). image+build. Con ambos,docker compose buildconstruye y etiqueta la imagen como:local;docker compose upla usa. Si mañana se quiere probar la imagen que publicó CI, basta con cambiar:localpor:sha-9f3c2aby omitir elbuild.servicio-clientescomo stub. Reutilizascripts/stubClientes.jsde 04-04 desde la imagen de Pedidos. Cuando el servicio real exista (orden de extracción de 02-02), se sustituye por su propiobuild, conCLIENTES_DB_URLy el resto sin tocar.
- Operar el entorno:
up, down, logs, ps, migraciones y la prueba E2E
up, down, logs, ps, migraciones y la prueba E2Ecd 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 limpiaComprobació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.eventosComo 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 -vEse 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.
- 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 delDockerfile. Cada cambio de código repitenpm ci. Primeropackage*.json, luego instalar, luego el código.- Olvidar
.dockerignore. Elnode_modulesdel portátil (con binarios de macOS) y el.envcon la contraseña de PostgreSQL acaban dentro de la imagen publicada enghcr.io. CMD npm starto forma shell. SIGTERM no llega a Node; cada despliegue corta peticiones. Forma exec ynodedirectamente.latesten cualquier sitio que no sea el portátil. Sin trazabilidad ni rollback.depends_onsincondition. "Funciona" en el portátil rápido y falla en el runner de CI, donde PostgreSQL tarda 8 s en aceptar conexiones.healthchecken cada dependencia yservice_healthy.- Publicar puertos de los servicios internos "para probar". Se prueban a través del gateway (o con
docker compose exec); publicar3002acostumbra a saltarse la única puerta que existirá en producción. - Consejo: ejecuta
docker compose configpara ver el YAML final con variables sustituidas; ydocker compose up --build --waitcomo 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-stoppedSolo 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
- Conceptos Básicos de Microservicios
- Ventajas y Desventajas de los Microservicios
- Comparación con la Arquitectura Monolítica
- Cuándo Adoptar Microservicios: Criterios de Decisión
- El Caso Práctico del Curso: la Tienda Online de TechCorp
Módulo 2: Diseño de Microservicios
- Principios de Diseño de Microservicios
- Descomposición de Aplicaciones Monolíticas
- Definición de Bounded Contexts
- Gestión de Datos: una Base de Datos por Servicio
- Consistencia Distribuida: Sagas, CQRS y Event Sourcing
Módulo 3: Comunicación entre Microservicios
- APIs RESTful
- Mensajería Asíncrona
- Protocolos de Comunicación: gRPC, GraphQL
- API Gateway y Backend for Frontend
- Descubrimiento de Servicios y Balanceo de Carga
- Contratos y Versionado de APIs
Módulo 4: Implementación de Microservicios
- Elección de Tecnologías y Herramientas
- Desarrollo de un Microservicio Simple
- Gestión de Configuración
- Integración Práctica: Consumir APIs y Publicar Eventos
- Pruebas en Microservicios: Unitarias, de Integración y de Contrato
Módulo 5: Despliegue y Orquestación
- Contenedores y Docker
- Orquestación con Kubernetes
- CI/CD para Microservicios
- Estrategias de Despliegue: Rolling, Blue-Green y Canary
- Service Mesh: Istio y Linkerd
Módulo 6: Monitoreo y Mantenimiento
- Monitoreo y Logging
- Trazabilidad Distribuida con OpenTelemetry
- Gestión de Errores y Recuperación
- Escalabilidad y Rendimiento
- SLOs, Alertas y Gestión de Incidentes
Módulo 7: Seguridad en Microservicios
- Autenticación y Autorización
- Seguridad en la Comunicación
- Prácticas de Seguridad
- Seguridad en Contenedores y Kubernetes
