Open this post in EchoOS →

Chapter 1.11 - Docker Swarm: From One Host to a Cluster

Compose runs an application on one host. When that host needs maintenance, or one machine isn’t enough, you want a cluster: several hosts acting as one, placing containers wherever there’s room and replacing them when a machine dies.

Swarm mode is the orchestrator built into Docker Engine. There’s nothing extra to install, it reads the same Compose file format, and a working cluster takes about three commands. It’s far simpler than Kubernetes; you give up Kubernetes’ enormous ecosystem in exchange. For small and medium setups that’s often the right trade.

The concepts

Nodes: managers and workers

Every Docker host in a swarm is a node.

flowchart TB
  subgraph M["Managers: Raft replicated state"]
    direction LR
    M1["manager-1<br/>(leader)"] <--> M2[manager-2]
    M2 <--> M3[manager-3]
    M1 <--> M3
  end
  M1 -- "schedules tasks" --> W1[worker-1]
  M1 --> W2[worker-2]
  M1 --> W3[worker-3]

Raft needs a majority of managers to agree on any change, which gives the rule for how many to run:

Managers Can lose Notes
1 0 fine for a lab; the cluster can’t change while it’s down
3 1 the usual production choice
5 2 larger clusters

Always use an odd number: 4 managers tolerate no more failures than 3, and give you one more machine that can break. If a majority is lost, running containers keep running, but nothing can be scheduled or changed until quorum returns.

Services and tasks

You don’t start containers in a swarm; you declare a service (“run 3 replicas of nginx:1.27, published on port 80”), and the managers make it true. Each replica is a task, and each task runs one container on some node. If a node dies, its tasks are rescheduled elsewhere to get back to 3.

1. Build a lab cluster

Three VMs with Docker, using the cloud-init file from Chapter 1.10:

for n in manager worker-1 worker-2; do
  multipass launch --name "$n" --cpus 2 --memory 2G --disk 10G --cloud-init docker.yaml
done
multipass list          # note each VM's IPv4 address

Nodes talk to each other on three ports, which must be open between them (not to the internet):

Port Protocol Used for
2377 TCP cluster management (joining, Raft)
7946 TCP + UDP node-to-node gossip, discovering who’s alive
4789 UDP overlay network traffic (VXLAN)

Initialize and join

On the manager:

multipass exec manager -- docker swarm init --advertise-addr 192.168.64.10

The output contains a ready-made join command with a secret token. Run it on each worker:

multipass exec worker-1 -- docker swarm join --token SWMTKN-1-3xqa… 192.168.64.10:2377
multipass exec worker-2 -- docker swarm join --token SWMTKN-1-3xqa… 192.168.64.10:2377

Lost the command? docker swarm join-token worker prints it again (join-token manager for adding managers). Treat these tokens like passwords: anyone holding one can join a machine to your cluster.

From here on, run commands against the manager. A Docker context saves typing multipass exec every time:

docker context create swarm --docker "host=ssh://deploy@192.168.64.10"
docker context use swarm
docker node ls
ID                            HOSTNAME   STATUS   AVAILABILITY   MANAGER STATUS   ENGINE VERSION
p1l3k9…      *                manager    Ready    Active         Leader           27.3.1
a8d2x7…                       worker-1   Ready    Active                          27.3.1
q0m5c1…                       worker-2   Ready    Active                          27.3.1

2. Run a service

docker service create --name web --replicas 3 -p 80:80 nginx:1.27
docker service ls
docker service ps web
ID       NAME    IMAGE        NODE       DESIRED STATE   CURRENT STATE
x1…      web.1   nginx:1.27   worker-1   Running         Running 20 seconds ago
x2…      web.2   nginx:1.27   worker-2   Running         Running 20 seconds ago
x3…      web.3   nginx:1.27   manager    Running         Running 20 seconds ago
flowchart LR
  c([Client]) --> n1[worker-1 :80]
  c --> n2[worker-2 :80]
  c --> n3[manager :80]
  subgraph mesh["Routing mesh (ingress network)"]
    n1 & n2 & n3 --> vip(["service VIP<br/>load-balances"])
  end
  vip --> t1[web.1]
  vip --> t2[web.2]
  vip --> t3[web.3]

Scaling, updating, rolling back

docker service scale web=5

docker service update \
  --image nginx:1.27.2 \
  --update-parallelism 1 \
  --update-delay 10s \
  --update-failure-action rollback \
  web

docker service rollback web
docker service logs -f web

Take a node out for maintenance by draining it: Swarm moves its tasks elsewhere, and active brings it back:

docker node update --availability drain worker-1
docker node update --availability active worker-1

3. Deploy a whole application as a stack

A stack is a Compose file deployed to a swarm. Here’s the proxy/API/Redis project from Chapter 1.9, adapted for a cluster, as stack.yml:

services:
  proxy:
    image: nginx:1.27
    ports:
      - "80:80"
    configs:
      - source: nginx_conf
        target: /etc/nginx/conf.d/default.conf
    deploy:
      replicas: 2

  api:
    image: yourname/shop-api:1.0
    environment:
      REDIS_URL: redis://redis:6379
    deploy:
      replicas: 3
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
      restart_policy:
        condition: on-failure

  redis:
    image: redis:8.0
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redis-data:/data
    deploy:
      placement:
        constraints:
          - node.labels.redis == true

configs:
  nginx_conf:
    file: ./nginx/default.conf

volumes:
  redis-data:

What’s different from the Compose version, and why:

Stateful services need a home

The redis-data volume is created on the node where the task runs; volumes don’t move between nodes. If redis were rescheduled to another node, it would start with an empty volume there. That’s why it’s pinned: label the one node that should hold the data.

docker node update --label-add redis=true worker-2

For real persistence across node failures, either run the database outside the swarm (a managed service, or a dedicated host) or use a volume driver backed by shared or replicated storage.

Deploy it

docker stack deploy -c stack.yml shop
docker stack services shop
docker stack ps shop

4. Secrets

Passwords shouldn’t live in stack files or environment variables, which show up in docker inspect. Swarm secrets are encrypted in the managers’ store and mounted as files, in memory, only into the services that ask for them:

openssl rand -base64 32 | docker secret create redis_password -
services:
  redis:
    image: redis:8.0
    command: ["sh", "-c", "exec redis-server --appendonly yes --requirepass \"$$(cat /run/secrets/redis_password)\""]
    secrets:
      - redis_password

secrets:
  redis_password:
    external: true

The api service then needs the same secret: add secrets: [redis_password] to it and have the app read /run/secrets/redis_password when it builds its Redis URL. Many official images read secrets from files directly through *_FILE variables, for example MYSQL_ROOT_PASSWORD_FILE=/run/secrets/mysql_root.

5. Tearing down

docker stack rm shop              # services, networks and configs
docker volume ls                  # volumes remain, on each node where they were created
docker swarm leave                # on a worker; add --force on the last manager

stack rm deliberately leaves volumes alone. Remove them per node with docker volume rm shop_redis-data once you’re sure.

Troubleshooting

Symptom Check
Tasks stuck in Pending No node satisfies the constraints or has enough resources: docker service ps --no-trunc <service> shows the reason.
Tasks stuck in Preparing / pull errors Workers can’t pull the image: registry login (--with-registry-auth on deploy) or the image doesn’t exist.
Published port unreachable on some nodes Ports 7946 and 4789 blocked between nodes, so the routing mesh can’t forward.
Data “disappeared” after a restart The task moved to another node with its own empty volume; add a placement constraint.
The swarm does not have a leader Majority of managers lost; restore them, or rebuild from one with docker swarm init --force-new-cluster.

Chapter 1.12 closes the chapter by shrinking the images all these nodes pull: multi-stage builds.