Aurora Libros ya se construye y se despliega sola, pero sigue viviendo en una máquina. Si esa máquina se apaga, la librería desaparece de internet. Esta lección monta tu primer clúster con Docker Swarm: la orquestación más cercana a lo que ya sabes, porque habla compose.yaml y comandos docker.

Contenido

  1. Qué resuelve un orquestador
  2. Arquitectura de Swarm: managers, workers y Raft
  3. El estado deseado y el bucle de reconciliación
  4. Montar el clúster: init, join y puertos
  5. Gestionar nodos: promover, degradar y drenar
  6. Servicios y tareas
  7. Réplicas frente a modo global
  8. Escalado y restricciones de ubicación
  9. Redes overlay: VXLAN entre nodos
  10. La malla de enrutamiento
  11. Stacks: el compose.yaml que ya tienes
  12. La sección deploy completa
  13. Secretos y configs nativos de Swarm
  14. Aurora Libros en un clúster de tres nodos
  15. Swarm en 2026: cuándo sigue siendo la elección

Advertencia. Un clúster multiplica las decisiones de red, almacenamiento y control de acceso: los puertos entre nodos, el cifrado del tráfico interno y la ubicación de los datos persistentes deben acordarse con el responsable de infraestructura y de seguridad de tu organización antes de llevar nada a producción.

  1. Qué resuelve un orquestador

Problema Compose en una máquina Orquestador
La máquina se apaga Todo cae Las tareas se reprograman en otros nodos
Un contenedor muere restart: lo reinicia ahí mismo Se recrea donde haya sitio
Hace falta más capacidad Solo si cabe en esa máquina Se añade un nodo al clúster
Publicar una versión Recrear: hueco sin servicio Sustitución progresiva, sin corte
¿Dónde colocar cada servicio? No hay elección Planificador según recursos y reglas
Balancear entre réplicas Nginx a mano Balanceo integrado por nombre de servicio
Un despliegue sale mal Vuelves a desplegar a mano Rollback automático (06-07)

Un orquestador aporta tres cosas que Compose en una sola máquina no puede dar por definición: planificación (decidir en qué nodo va cada cosa), reconciliación (mantener el estado deseado pase lo que pase) y red entre máquinas (que los contenedores se hablen aunque estén en anfitriones distintos).

  1. Arquitectura de Swarm: managers, workers y Raft

flowchart TB
    subgraph P["Plano de control — Raft"]
        M1["manager-1 (líder)"] <--> M2[manager-2]
        M2 <--> M3[manager-3]
        M3 <--> M1
    end
    M1 -->|asigna tareas| W1[worker-1]
    M1 -->|asigna tareas| W2[worker-2]
    W1 --- W2
    style M1 fill:#e8f0fe,stroke:#3367d6
Rol Qué hace Cuántos
Manager Guarda el estado, planifica, expone la API, participa en Raft 1, 3, 5 o 7 (impar)
Líder El manager elegido que toma las decisiones de planificación 1, elegido automáticamente
Worker Solo ejecuta tareas; no toma decisiones Los que hagas falta

El estado del clúster —qué servicios existen, cuántas réplicas, qué secretos— se replica entre los managers con el algoritmo de consenso Raft. Para aceptar un cambio hace falta el acuerdo de la mayoría, el quórum, que es (N/2) + 1.

De ahí sale la regla del número impar, que no es superstición sino aritmética:

Managers Quórum Fallos tolerados Comentario
1 1 0 Desarrollo. Si cae, no hay clúster
2 2 0 Peor que uno: cualquier caída bloquea
3 2 1 El mínimo razonable en producción
4 3 1 Igual que 3, con más coste
5 3 2 Clústeres grandes

La fila de los dos managers es la que sorprende: añadir un segundo manager empeora la disponibilidad, porque necesitas los dos vivos para alcanzar el quórum. Y hay una consecuencia importante que conviene tener clara: perder el quórum no para los contenedores en marcha, pero deja el clúster sin capacidad de decidir; no se puede escalar, ni desplegar, ni reprogramar nada hasta que vuelvan suficientes managers.

  1. El estado deseado y el bucle de reconciliación

Aquí está el cambio mental respecto a todo lo anterior. Con docker run das órdenes; con un orquestador declaras cómo quieres que esté el mundo y él se encarga.

El líder ejecuta un bucle permanente: compara el estado deseado (lo que has declarado) con el estado real (lo que hay), y si difieren crea o elimina tareas hasta que coincidan. Cuando coinciden, espera al siguiente cambio y vuelve a comparar.

docker service create --replicas 3 aurora-api no significa "arranca tres contenedores": significa "quiero que siempre haya tres". Mata uno y aparece otro. Apaga un nodo entero y sus tareas renacen en los que quedan. Nadie ha ejecutado un comando de reparación: el bucle simplemente ha notado una diferencia entre lo deseado y lo real y la ha corregido.

  1. Montar el clúster: init, join y puertos

docker swarm init --advertise-addr 10.0.1.10        # en manager-1
# Swarm initialized: current node (k3f9x...) is now a manager.
# To add a worker to this swarm, run the following command:
#     docker swarm join --token SWMTKN-1-49nj1...-8vxv8 10.0.1.10:2377

El --advertise-addr es obligatorio en cuanto la máquina tenga más de una interfaz, y omitirlo es el primer error clásico: Swarm elige una IP cualquiera —a menudo la pública, o una de una VPN— y los demás nodos no consiguen alcanzarla.

docker swarm join-token worker             # los tokens son distintos por rol
docker swarm join-token --rotate manager   # si un token se ha filtrado
docker swarm join --token SWMTKN-1-49nj1...-8vxv8 10.0.1.10:2377   # en cada worker
docker node ls                             # desde cualquier manager
# ID       HOSTNAME   STATUS  AVAILABILITY  MANAGER STATUS  ENGINE
# k3f9x *  manager-1  Ready   Active        Leader          27.3.1
# p8m2q    worker-1   Ready   Active                        27.3.1
# r5t7w    worker-2   Ready   Active                        27.3.1
Puerto Protocolo Para qué Entre
2377 TCP API de gestión del clúster Nodos → managers
7946 TCP y UDP Descubrimiento y gossip entre nodos Todos ↔ todos
4789 UDP Tráfico de datos VXLAN de las redes overlay Todos ↔ todos

Estos tres puertos causan la mitad de los problemas de un clúster nuevo, y siempre por lo mismo: 7946 necesita TCP y UDP, y 4789 es UDP. Un cortafuegos que solo abra TCP produce el síntoma más desconcertante posible: los nodos aparecen Ready, los servicios se despliegan, pero los contenedores de nodos distintos no se ven entre sí. Nunca abras el 2377 a internet: quien lo alcance con un token puede unirse a tu clúster.

  1. Gestionar nodos: promover, degradar y drenar

docker node promote worker-1 worker-2       # de worker a manager
docker node demote manager-3                # de manager a worker
docker node inspect worker-1 --format '{{.Status.State}} {{.Spec.Availability}}'
docker node update --label-add zona=a --label-add disco=ssd worker-1
Availability Tareas nuevas Tareas existentes Cuándo
active Siguen Normal
pause No Siguen Investigar sin que lleguen más
drain No Se mueven a otros nodos Mantenimiento o retirada
docker node update --availability drain worker-1     # vaciar antes de tocar la máquina
# ... mantenimiento, reinicio, actualización del kernel ...
docker node update --availability active worker-1

El ciclo drain → mantenimiento → active es la rutina básica de operación de un clúster: las tareas se recolocan solas antes de que toques nada, y nadie se queda sin servicio. Ojo con un detalle: al volver a active, las tareas no regresan solas al nodo; se quedan donde están hasta el siguiente despliegue.

  1. Servicios y tareas

Swarm introduce dos conceptos nuevos sobre lo que ya conoces:

  • Servicio: la declaración. "Quiero tres réplicas de esta imagen con esta configuración".
  • Tarea: cada unidad de trabajo asignada a un nodo. Una tarea acaba siendo un contenedor, y es inmutable: no se modifica ni se reinicia, se sustituye por otra nueva.
docker service create --name aurora-api --replicas 3 \
  --network aurora-trasera --env DB_HOST=aurora-db --publish published=8080,target=3000 \
  --limit-memory 512M --limit-cpu 1.0 --reserve-memory 128M \
  --health-interval 10s --health-retries 3 ghcr.io/auroralibros/aurora-api:2.0.0
Comando Qué muestra Equivalente que ya conoces
docker service ls Servicios y réplicas listas docker compose ps
docker service ps <svc> Cada tarea, su nodo y su historial docker ps por servicio
docker service logs -f <svc> Logs agregados de todas las réplicas docker compose logs -f
docker service inspect <svc> Especificación completa docker inspect
docker service update <svc> Cambia el estado deseado Editar y up -d
docker service rm <svc> Elimina el servicio y sus tareas docker compose rm

docker service ps es la herramienta de diagnóstico principal, porque muestra también las tareas muertas y por qué murieron:

NAME              NODE       DESIRED STATE  CURRENT STATE           ERROR
aurora-api.1      worker-1   Running        Running 4 minutes ago
aurora-api.2      worker-2   Running        Running 4 minutes ago
aurora-api.3      worker-2   Running        Running 40 seconds ago
 \_ aurora-api.3  worker-1   Shutdown       Failed 45 seconds ago   "task: non-zero exit (78)"

Esa última línea cuenta una historia completa: la tarea 3 murió en worker-1 con el código 78 que programaste en 06-01, es decir, configuración inválida, y el orquestador la recreó en worker-2. Sin la validación de arranque, ahí pondría un genérico exit (1).

  1. Réplicas frente a modo global

Modo Cuántas tareas Al añadir un nodo Para qué
--mode replicated (defecto) Las que pidas No cambia Aplicaciones: aurora-api, aurora-web
--mode global Una por nodo, siempre Aparece una nueva Agentes: Promtail, cAdvisor, node-exporter
--mode replicated-job N ejecuciones y termina Migraciones, tareas puntuales

El modo global es exactamente lo que necesitan los recolectores de métricas y logs del módulo 5: cada nodo necesita su cAdvisor y su Promtail, y quieres que aparezcan solos en cada máquina que añadas al clúster sin acordarte de nada.

  1. Escalado y restricciones de ubicación

docker service scale aurora-api=5 aurora-web=3        # varios a la vez
docker service update --replicas 2 aurora-api         # equivalente

# Restricciones DURAS: si no se cumplen, la tarea no se programa (queda Pending)
docker service update --constraint-add 'node.labels.disco==ssd' aurora-db
# Preferencias BLANDAS: reparten, pero no impiden
docker service update --placement-pref 'spread=node.labels.zona' aurora-api
Expresión Significado
node.role==worker Solo en workers (mantiene los managers descargados)
node.labels.zona==a Solo en nodos con esa etiqueta
node.hostname!=worker-2 En cualquiera menos ese
spread=node.labels.zona Reparto equitativo entre zonas

La diferencia entre restricción y preferencia importa mucho en la práctica: una restricción imposible deja el servicio en Pending para siempre, sin ningún error evidente, mientras que una preferencia solo influye en el reparto. Si un servicio no arranca y docker service ps no muestra ningún error, sospecha de una restricción que ningún nodo cumple.

  1. Redes overlay: VXLAN entre nodos

La red bridge de 03-05 solo conecta contenedores del mismo anfitrión. Una red overlay conecta contenedores de nodos distintos como si compartieran un cable.

docker network create -d overlay --attachable --subnet 10.10.0.0/24 aurora-frontal
docker network create -d overlay --opt encrypted --internal aurora-trasera

Por dentro es un túnel VXLAN: cada paquete Ethernet del contenedor se encapsula dentro de un datagrama UDP hacia el puerto 4789 del nodo destino, que lo desencapsula y lo entrega. Para los contenedores, la existencia de dos máquinas es invisible; siguen resolviendo aurora-db por DNS y hablando por su IP de la red overlay.

Opción Efecto Coste
--attachable Permite conectar contenedores sueltos, no solo servicios Ninguno
--opt encrypted Cifra IPsec el tráfico entre nodos 10-30 % de rendimiento
--internal Sin salida a internet Ninguno
--subnet Rango fijo, evita colisiones con tu red corporativa Ninguno

El --opt encrypted merece una decisión consciente. El tráfico VXLAN viaja en claro por defecto: si tus nodos están en una red privada de un mismo centro de datos puede ser aceptable, pero si cruzan internet o una red compartida, el catálogo, las consultas y las credenciales que pasen por ahí son legibles para cualquiera con acceso al medio. Es exactamente el tipo de decisión que debes validar con tu responsable de seguridad. Ojo también: el cifrado se aplica al plano de datos, y no puede activarse ni desactivarse sin recrear la red.

  1. La malla de enrutamiento

flowchart TB
    C[Cliente] -->|:8080| N2["worker-2<br/>(sin réplica local)"]
    N2 -->|IPVS por la overlay| T1["tarea 1 @ worker-1"]
    N2 --> T2["tarea 2 @ manager-1"]
    style N2 fill:#fff4e5,stroke:#e8a33d

Cuando publicas un puerto en modo ingress (el predeterminado), todos los nodos del clúster escuchan en él, incluso los que no ejecutan ninguna réplica de ese servicio. El nodo que recibe la conexión la reparte por IPVS entre las tareas vivas, estén donde estén.

curl http://worker-2:8080/salud/vivo    # funciona aunque worker-2 no tenga réplicas

La ventaja es enorme para el balanceador de entrada: apunta a cualquier nodo, o a todos, y no necesita saber dónde está nada. Los inconvenientes son dos y conviene conocerlos: hay un salto de red extra cuando el nodo que recibe no tiene la tarea, y la IP de origen que ve tu aplicación es la del nodo, no la del cliente real, porque hay SNAT de por medio.

Modo de publicación Sintaxis Comportamiento
ingress (defecto) --publish 8080:3000 Todos los nodos escuchan; balanceo por IPVS
host --publish mode=host,published=8080,target=3000 Solo los nodos con réplica; IP real del cliente, sin salto extra

El modo host es la salida cuando necesitas la IP del cliente (registro de acceso, límites por IP, geolocalización) o el último gramo de latencia, y tiene un precio: obliga al balanceador externo a saber en qué nodos hay réplicas, y no puedes tener dos réplicas del mismo servicio en un nodo, porque chocarían en el puerto.

  1. Stacks: el compose.yaml que ya tienes

Aquí llega la recompensa de todo el módulo 4: docker stack deploy acepta tu compose.yaml.

docker stack deploy -c compose.swarm.yaml --with-registry-auth aurora
docker stack ls
docker stack services aurora
docker stack ps aurora --no-trunc
docker stack rm aurora

El --with-registry-auth es imprescindible con un registro privado como ghcr.io: sin él, el manager entiende las credenciales pero no las reenvía a los workers, y las tareas fallan con no basic auth credentials en todos los nodos menos donde hiciste docker login.

Clave de Compose En Swarm
image, environment, networks, ports, secrets, configs, healthcheck Funcionan igual
deploy: Solo aquí tiene efecto (Compose la ignoraba salvo resources)
build: Ignorada: hay que publicar la imagen en un registro
depends_on: Ignorada: no hay orden de arranque, hay reintentos
restart:, container_name, develop, profiles Ignoradas
volumes: con ruta relativa Peligrosa: se resuelve en cada nodo

Las dos ignoradas de en medio cambian cómo diseñas la aplicación. Sin build, el flujo obliga a pasar por el registro, lo cual es correcto en producción. Y sin depends_on, tu API tiene que soportar arrancar antes que la base de datos: por eso implementaste el reintento con backoff en 04-04, y por eso las sondas de 06-01 devuelven 503 mientras las dependencias no estén.

  1. La sección deploy completa

services:
  aurora-api:
    image: ghcr.io/auroralibros/aurora-api:2.0.0
    deploy:
      mode: replicated
      replicas: 3
      placement:
        constraints: ["node.role==worker"]
        preferences: [{ spread: node.labels.zona }]
        max_replicas_per_node: 2
      resources:
        limits:       { cpus: "1.0", memory: 512M }
        reservations: { cpus: "0.1", memory: 128M }   # el planificador SÍ las respeta
      restart_policy:
        condition: on-failure      # any | on-failure | none
        delay: 5s
        max_attempts: 3
        window: 120s
      update_config:               # se detalla en 06-07
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
      rollback_config: { parallelism: 2, order: stop-first }
      labels: { com.aurora.componente: api }

Un matiz que separa reservations de limits y que en Compose no existía: en Swarm, las reservas afectan a la planificación. El planificador suma las reservas de las tareas ya ubicadas en cada nodo y solo coloca una nueva donde quepa. Reservar de más deja nodos vacíos con servicios en Pending; reservar de menos amontona tareas hasta que el nodo se queda sin memoria. Los números medidos de 06-01 son justo lo que necesitas aquí.

  1. Secretos y configs nativos de Swarm

printf 'clave-ficticia-aurora' | docker secret create aurora_db_password -
docker config create aurora_init_sql ./db/init.sql
docker secret ls && docker config ls
services:
  aurora-api:
    secrets:
      - source: aurora_db_password
        target: db_password        # queda en /run/secrets/db_password
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password    # el patrón _FILE de 06-01, intacto
secrets:
  aurora_db_password:
    external: true
configs:
  aurora_init_sql:
    external: true

Los secretos viajan a los nodos cifrados por TLS mutuo, se guardan cifrados en el estado Raft y se montan en un tmpfs que nunca toca el disco; el ejercicio 3 lo comprueba dimensión por dimensión. Además, un secreto es inmutable: no se puede modificar. Rotarlo consiste en crear uno nuevo con otro nombre, actualizar el servicio con --secret-rm y --secret-add y eliminar el viejo. Es incómodo a propósito: fuerza que la rotación sea un despliegue trazable y no un cambio silencioso.

Los configs funcionan igual pero sin cifrado en reposo, y son la vía natural para init.sql, nginx.conf o prometheus.yml: ficheros de configuración que deben llegar a todos los nodos sin tener que copiarlos a mano.

  1. Aurora Libros en un clúster de tres nodos

# compose.swarm.yaml — stack aurora (extracto de los cuatro servicios)
services:
  aurora-web:
    image: nginx:alpine
    ports: ["80:80"]
    configs: [{ source: aurora_nginx, target: /etc/nginx/conf.d/default.conf }]
    networks: [frontal]
    deploy:
      mode: global                       # una por nodo: cualquiera atiende
      update_config: { parallelism: 1, order: start-first }

  aurora-api:
    image: ghcr.io/auroralibros/aurora-api:2.0.0
    networks: [frontal, trasera]
    environment:
      DB_HOST: aurora-db
      DB_USER: aurora
      DB_NAME: aurora_libros
      DB_PASSWORD_FILE: /run/secrets/db_password
      REDIS_HOST: aurora-cache
    secrets: [{ source: aurora_db_password, target: db_password }]
    healthcheck:
      test: ["CMD","node","-e","require('http').get('http://127.0.0.1:3000/salud/listo',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
      interval: 10s
      start_period: 20s
    deploy:
      replicas: 3
      placement: { constraints: ["node.role==worker"] }
      resources: { limits: { memory: 512M, cpus: "1.0" }, reservations: { memory: 128M } }
      update_config: { parallelism: 1, delay: 15s, order: start-first, failure_action: rollback }

  aurora-cache:
    image: redis:7-alpine
    command: ["redis-server","--maxmemory","200mb","--maxmemory-policy","allkeys-lru"]
    networks: [trasera]
    deploy: { replicas: 1, resources: { limits: { memory: 256M } } }

  aurora-db:
    image: postgres:16-alpine
    environment: { POSTGRES_USER: aurora, POSTGRES_DB: aurora_libros, POSTGRES_PASSWORD_FILE: /run/secrets/db_password }
    secrets: [{ source: aurora_db_password, target: db_password }]
    volumes: ["aurora-datos:/var/lib/postgresql/data"]
    configs: [{ source: aurora_init_sql, target: /docker-entrypoint-initdb.d/init.sql }]
    networks: [trasera]
    deploy:
      replicas: 1
      placement: { constraints: ["node.labels.datos==si"] }  # anclada al nodo del volumen
      update_config: { order: stop-first }   # nunca dos PostgreSQL sobre los mismos datos

networks:
  frontal: { driver: overlay }
  trasera: { driver: overlay, internal: true, driver_opts: { encrypted: "" } }
volumes: { aurora-datos: {} }
secrets: { aurora_db_password: { external: true } }
configs:
  aurora_nginx:    { external: true }
  aurora_init_sql: { external: true }
docker node update --label-add datos=si worker-1
docker stack deploy -c compose.swarm.yaml --with-registry-auth aurora
docker stack services aurora
NAME                MODE        REPLICAS  IMAGE                                    PORTS
aurora_aurora-api   replicated  3/3       ghcr.io/auroralibros/aurora-api:2.0.0
aurora_aurora-cache replicated  1/1       redis:7-alpine
aurora_aurora-db    replicated  1/1       postgres:16-alpine
aurora_aurora-web   global      3/3       nginx:alpine                             *:80->80/tcp

Advertencia sobre los datos. La constraint de aurora-db es un parche, no una solución. Los volúmenes locales no viajan: si worker-1 muere, Swarm recreará la tarea de PostgreSQL en otro nodo y encontrará un volumen vacío, con lo que el catálogo desaparece. Un clúster de verdad necesita almacenamiento de red (NFS, iSCSI, Ceph, un volumen de bloque del proveedor cloud) o una base de datos gestionada fuera del clúster. Discútelo con tu responsable de infraestructura antes de poner datos reales aquí: no es un detalle de configuración, es una decisión de arquitectura.

  1. Swarm en 2026: cuándo sigue siendo la elección

Swarm sigue incluido en Docker Engine y mantenido, pero es minoritario: la industria estandarizó Kubernetes, y ahí están los proveedores gestionados, las herramientas y la mayor parte de la documentación.

Situación Elección razonable
2-10 nodos, un equipo pequeño, sin plataforma dedicada Swarm
El equipo ya domina Compose y no hay tiempo de formación Swarm
Un clúster en las instalaciones del cliente, mantenido por otros Swarm
Nube gestionada (EKS, GKE, AKS) disponible Kubernetes
Autoescalado, operadores, service mesh, políticas, ecosistema Kubernetes

La comparación detallada la harás en 07-02. Lo que sí conviene retener: casi todo lo de esta lección —estado deseado, reconciliación, servicios y réplicas, red entre nodos, secretos montados como ficheros, actualizaciones progresivas— es exactamente el mismo modelo mental que vas a usar en Kubernetes, con otros nombres. Swarm no ha sido un desvío: ha sido la introducción sencilla al mismo problema.

Errores Comunes y Consejos

  • Dos managers. Empeora la disponibilidad respecto a uno. Siempre 1, 3, 5 o 7.
  • Olvidar --advertise-addr. Con varias interfaces, Swarm elige mal y el clúster no se forma. Ponlo siempre explícito.
  • Abrir solo TCP en el cortafuegos. El 7946 necesita UDP también y el 4789 es UDP puro. El síntoma es nodos Ready con overlay muerta.
  • Esperar que build: funcione. Swarm no construye: publica la imagen antes y referénciala por etiqueta o digest. Y no olvides --with-registry-auth, o las tareas fallarán en todos los nodos menos donde hiciste docker login.
  • Volúmenes locales para datos. La tarea se reprograma y el volumen no la sigue. Almacenamiento de red o base de datos externa.
  • Restricciones que ningún nodo cumple. El servicio se queda en Pending sin mensaje claro. Verifica con docker node inspect.
  • Consejo: usa docker service ps --no-trunc. La columna ERROR completa suele contener la causa exacta del fallo.
  • Consejo: etiqueta los nodos desde el primer día (zona, disco, datos). Reubicar servicios después es cuestión de una restricción.

Ejercicios

Ejercicio 1. Monta un clúster de tres nodos, despliega aurora-api con tres réplicas y demuestra la reconciliación: mata un contenedor a mano y después vacía un nodo entero, comprobando en cada caso dónde reaparecen las tareas.

Ejercicio 2. Demuestra la malla de enrutamiento: publica aurora-web con una sola réplica, comprueba que responde desde los tres nodos y averigua desde cuál se está sirviendo realmente.

Ejercicio 3. Compara un secreto de Swarm con una variable de entorno: intenta extraer ambos valores desde fuera del contenedor con docker inspect y desde dentro con /proc, y explica dónde vive físicamente cada uno.

Soluciones

Solución 1.

docker service ps aurora_aurora-api --format '{{.Name}}\t{{.Node}}\t{{.CurrentState}}'
docker kill "$(docker ps -q --filter name=aurora_aurora-api)"     # ejecutado en worker-1
sleep 8
docker service ps aurora_aurora-api --format '{{.Name}}\t{{.Node}}\t{{.CurrentState}}'
aurora_aurora-api.1   worker-1   Running 6 minutes ago
aurora_aurora-api.2   worker-2   Running 6 minutes ago
aurora_aurora-api.3   worker-2   Running 6 minutes ago
--- tras el kill ---
aurora_aurora-api.1   worker-1   Running 5 seconds ago
 \_ aurora_aurora-api.1  worker-1  Failed 7 seconds ago  "task: non-zero exit (137)"
docker node update --availability drain worker-2 && sleep 15
docker service ps aurora_aurora-api --filter desired-state=running --format '{{.Name}}\t{{.Node}}'
docker service ls --filter name=aurora_aurora-api
# aurora_aurora-api.1   worker-1
# aurora_aurora-api.2   manager-1
# aurora_aurora-api.3   worker-1
# aurora_aurora-api   replicated   3/3

Las dos pruebas muestran la misma maquinaria a dos escalas. Al matar el contenedor, la tarea .1 se recreó en el mismo nodo, porque el nodo seguía sano y era el sitio preferido; el 137 es el SIGKILL de tu docker kill, y el historial con \_ conserva el intento fallido. Al drenar worker-2, sus dos tareas se reubicaron en los otros dos nodos, y el conteo volvió a 3/3 en unos quince segundos.

Lo importante es lo que no hiciste: no ejecutaste ningún comando de reparación. Solo declaraste "quiero tres" una vez, y el bucle de reconciliación mantiene esa afirmación cierta ante un contenedor muerto, un nodo drenado o una máquina apagada de golpe. Es el mismo mecanismo que en Kubernetes gobierna un Deployment, y por eso restart: always deja de tener sentido aquí: la política de reinicio es del servicio, no del contenedor.

Fíjate también en que la tarea .2 fue a parar a manager-1. Con la constraint node.role==worker del compose.swarm.yaml no habría podido: se habría quedado en Pending hasta que worker-2 volviera. Es el compromiso entre proteger los managers y tener sitio donde reprogramar.

Solución 2.

docker service update --replicas 1 aurora_aurora-web
docker service ps aurora_aurora-web --filter desired-state=running --format '{{.Node}}'
for n in manager-1 worker-1 worker-2; do
  printf '%s -> %s\n' "$n" "$(curl -s -o /dev/null -w '%{http_code}' http://$n/salud/vivo)"
done
# worker-2
# manager-1 -> 200
# worker-1  -> 200
# worker-2  -> 200

Los tres nodos responden 200 aunque solo worker-2 ejecuta la réplica. Eso es la malla de enrutamiento: al publicar en modo ingress, cada nodo abre el puerto 80 y reenvía por IPVS a través de la red overlay hasta donde estén las tareas vivas.

docker service logs aurora_aurora-web --tail 3
sudo iptables -t nat -L DOCKER-INGRESS -n | head -4
# 10.0.0.2 - - [05/Aug/2026:11:42:07] "GET /salud/vivo HTTP/1.1" 200
# DNAT  tcp  0.0.0.0/0  0.0.0.0/0  tcp dpt:80 to:172.18.0.2:80

Y ahí aparece el efecto secundario que hay que conocer: la IP registrada es 10.0.0.2, una dirección de la red ingress, no la del cliente real. El DNAT de la tabla nat reescribe el destino y, por el camino, el SNAT sustituye el origen. Si Aurora Libros necesitara la IP real —para limitar peticiones por cliente o para analítica—, tendrías que publicar en mode=host, aceptando que entonces solo responderían los nodos con réplica y que el balanceador externo debe saberlo.

La ventaja compensa en la mayoría de los casos: el balanceador de entrada puede apuntar a los tres nodos sin saber nada de dónde vive cada servicio, y añadir o quitar réplicas no le obliga a cambiar su configuración.

Solución 3.

docker service create --name prueba-env --env CLAVE=ficticia-en-entorno alpine sleep 600
docker service create --name prueba-sec --secret aurora_db_password alpine sleep 600

docker service inspect prueba-env --format '{{.Spec.TaskTemplate.ContainerSpec.Env}}'
docker service inspect prueba-sec --format '{{json .Spec.TaskTemplate.ContainerSpec.Secrets}}' | jq -r '.[0].SecretName'
# [CLAVE=ficticia-en-entorno]
# aurora_db_password

La asimetría ya es total en la primera comprobación: la variable se lee entera en la especificación del servicio, que cualquiera con acceso a la API de Docker puede consultar; del secreto solo se ve el nombre y el punto de montaje.

cid=$(docker ps -q --filter name=prueba-sec)
docker exec "$cid" cat /run/secrets/aurora_db_password; echo
docker exec "$cid" df -h /run/secrets | tail -1
sudo tr '\0' '\n' < /proc/$(docker inspect "$cid" --format '{{.State.Pid}}')/environ | grep -c CLAVE
# clave-ficticia-aurora
# tmpfs   64M  4.0K  64M   1%  /run/secrets
# 0
Dimensión Variable de entorno Secreto de Swarm
En docker service inspect Valor completo Solo el nombre
En /proc/<pid>/environ Legible Ausente
Soporte físico Memoria del proceso tmpfs de 64 MB (RAM)
En el estado del clúster En claro en Raft Cifrado en Raft
Herencia a subprocesos Automática Ninguna
Al parar la tarea Se desmonta y desaparece

El tmpfs de la segunda línea es la clave del diseño: el secreto nunca toca el disco en ningún nodo. Al parar la tarea, el sistema de ficheros se desmonta y el valor deja de existir; no queda en una capa de imagen, ni en un volumen, ni en un fichero de log, ni en un backup del sistema de ficheros del nodo.

La fila de la herencia es la que más incidentes provoca en la práctica. Una variable de entorno la ven todos los subprocesos: si tu API invoca pg_dump, un script de despliegue o cualquier herramienta de terceros, esa contraseña va con ellos, y basta con que una librería registre su entorno al fallar —cosa que hacen muchas— para que la credencial acabe en un servicio externo de gestión de errores. El fichero de /run/secrets/ solo lo lee quien decide leerlo, que es justo lo que hace tu config.js de 06-01, una vez, al arrancar.

Conclusión

Aurora Libros ya no depende de una máquina. Sabes qué aporta un orquestador —planificación, reconciliación y red entre nodos— y has montado un clúster de Swarm con docker swarm init --advertise-addr, tokens distintos por rol y los tres puertos que hay que abrir, con el detalle que cuesta tardes enteras: 7946 en TCP y UDP, 4789 en UDP. Entiendes por qué los managers deben ser impares y por qué dos son peores que uno, y manejas la rutina de drain → mantenimiento → active que permite tocar una máquina sin que nadie deje de comprar libros.

Has cambiado de modelo mental: ya no das órdenes, declaras un estado deseado y un bucle lo mantiene cierto. Lo has comprobado matando un contenedor y drenando un nodo entero, viendo cómo las tareas reaparecen solas sin que ejecutaras ninguna reparación. Distingues servicio de tarea, réplicas de modo global —el que necesitan Promtail y cAdvisor—, y sabes que una restricción imposible deja un servicio en Pending mudo. Has creado redes overlay sobre túneles VXLAN, con la decisión consciente sobre --opt encrypted para el tráfico que cruza redes que no controlas, y has visto la malla de enrutamiento responder desde los tres nodos con una sola réplica viva, junto con su precio: la IP que registra Nginx es la de la red ingress, no la del cliente.

El compose.yaml del módulo 4 se ha convertido en un stack con docker stack deploy, con la tabla de lo que Swarm ignora —build, depends_on, restart— y la sección deploy completa, donde las reservations ya no son decorativas porque gobiernan la planificación. Los secretos nativos han demostrado su ventaja de forma medible: invisibles en inspect, ausentes de /proc/<pid>/environ, montados en un tmpfs que nunca toca el disco y sin heredarse a los subprocesos, mientras el patrón _FILE de 06-01 sigue funcionando sin tocar una línea de código. Y queda anotada en rojo la limitación real: los volúmenes locales no viajan, así que aurora-db está anclada por una etiqueta de nodo y eso es un parche que hay que sustituir por almacenamiento de red o una base de datos gestionada antes de poner datos reales.

En la siguiente lección, Introducción a Kubernetes, cambias de herramienta pero no de ideas. Verás el plano de control con su kube-apiserver, su etcd y su planificador, los objetos fundamentales —Pod, Deployment, Service, Ingress, ConfigMap, Secret, PVC— con su YAML mínimo comentado, el papel central de las etiquetas y los selectores, la tabla de kubectl traducida a los comandos de Docker que ya dominas, y montarás un clúster local con kind para dar tus primeros pasos.

Docker: De Principiante a Avanzado

Módulo 1: Introducción a Docker

Módulo 2: Trabajando con Imágenes Docker

Módulo 3: Contenedores Docker

Módulo 4: Docker Compose

Módulo 5: Conceptos Avanzados de Docker

Módulo 6: Docker en Producción

Módulo 7: Ecosistema y Herramientas de Docker

© Copyright 2026. Todos los derechos reservados