Overlay networks and the routing mesh

VXLAN overlays, VIP vs DNS round robin, the ingress mesh, host mode and encryption.

Intermediate18 min · lesson 17 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (3 files, 2 KB): swarmnet.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-hard/swarmnet.tar.gz && tar -xzf swarmnet.tar.gz, which creates ~/lab/swarmnet/. SHA-256: 92796f389f7c58911b63973bc6d723f6d92bdf9282e775d11568389d09f3a5c9
Watch out
Run this only in the SecOpsLog disposable lab VM (secopslog-docker-sec). The lab simulates a three-node swarm with privileged Docker-in-Docker containers and adds and removes an iptables rule on the VM. At the end it changes /etc/docker/daemon.json and restarts dockerd. If the VM doesn't exist yet, create it on your workstation from the lab kit folder with ./setup/create-lab.sh --profile sec. Open a shell with multipass shell secopslog-docker-sec (limactl shell secopslog-docker-sec with Lima), and reset it with ./setup/create-lab.sh --profile sec --recreate. The daemon.json change follows "Configuring the daemon safely": back up, merge, validate, restore. On this VM ubuntu isn't in the docker group, so commands for the VM's own daemon use sudo.

Every node in the swarm reports Ready, and DNS resolves the service name. Yet a request from a container on one node to a task on another still times out. Managers and workers talk over TCP 2377 and gossip over 7946, so the cluster looks healthy. Service traffic between nodes takes a different path, VXLAN over UDP port 4789. A single firewall rule on that port breaks every cross-node connection and leaves the control plane untouched. You'll follow that path here: overlay networks, service discovery, the routing mesh, encrypted overlays, and the firewall requirements that go with them.

The lab is the simulated cluster from "Swarm clusters and services", started from ~/lab/swarmnet where the lesson files unpack. It has three docker:29-dind nodes (mgr1 10.77.0.11, wrk1 10.77.0.12, wrk2 10.77.0.13) on the VM bridge lab-swarm, reached through Docker contexts. swarm-lab.sh builds them, and it pulls any base image the VM lacks before copying it into the nodes. The nodes share the VM's kernel and bridge, so the VM can capture their traffic and filter it. That's how you'll reproduce the timeout further down. Between real hosts the same packets cross a physical network and its firewalls.

ubuntu@secopslog-docker-sec:~/lab/swarmnet · Docker 29.9.2
$ ./swarm-lab.sh up
Successfully created context "lab-mgr1" lab-mgr1 10.77.0.11 29.9.2 ready Successfully created context "lab-wrk1" lab-wrk1 10.77.0.12 29.9.2 ready Successfully created context "lab-wrk2" lab-wrk2 10.77.0.13 29.9.2 ready lab-reg:5000 has: {"repositories":["alpine","postgres"]}
$ docker context use lab-mgr1 docker swarm init --advertise-addr 10.77.0.11 >/dev/null for n in lab-wrk1 lab-wrk2; do docker --context $n swarm join --token "$(docker swarm join-token -q worker)" 10.77.0.11:2377; done docker build -q -t lab-reg:5000/web:1 web/ >/dev/null && docker push -q lab-reg:5000/web:1
lab-mgr1 Current context is now "lab-mgr1" This node joined a swarm as a worker. This node joined a swarm as a worker. lab-reg:5000/web:1

The last command builds the small nginx test image from that lesson and pushes it to the lab registry. It answers with its version, its container hostname and the client address it sees.

An overlay network

An overlay network is a layer-2 network for containers that spans hosts. Each node attached to it gets a Linux bridge for the network inside a dedicated network namespace, plus a VXLAN interface. A frame from a container on one node is wrapped in a UDP packet and sent to the node that hosts the destination container. It's unwrapped there. You create overlays on a manager, and you can make them attachable. --attachable lets standalone containers (docker run) join, which is handy for debugging.

ubuntu@secopslog-docker-sec:~ · Docker 29.9.2
$ docker network create -d overlay --attachable appnet docker network ls --filter driver=overlay
tao2u0y7olvrmhlja6obibzng NETWORK ID NAME DRIVER SCOPE tao2u0y7olvr appnet overlay swarm ptpk7uqrwmf5 ingress overlay swarm
$ docker --context lab-wrk2 network ls --filter driver=overlay
NETWORK ID NAME DRIVER SCOPE ptpk7uqrwmf5 ingress overlay swarm
$ docker service create -q --name api --network appnet --replicas 2 \ --constraint node.role==worker lab-reg:5000/web:1 docker service ps api --format "{{.Name}} {{.Node}} {{.CurrentState}}" docker --context lab-wrk2 network ls --filter driver=overlay
a36b52l2nyre5031h5jcs76lo api.1 wrk2 Running 5 seconds ago api.2 wrk1 Running 5 seconds ago NETWORK ID NAME DRIVER SCOPE tao2u0y7olvr appnet overlay swarm ptpk7uqrwmf5 ingress overlay swarm
$ docker network inspect appnet --format "{{.Driver}} {{json .IPAM.Config}} {{json .Options}}"
overlay [{"Subnet":"10.0.1.0/24","Gateway":"10.0.1.1"}] {"com.docker.network.driver.overlay.vxlanid_list":"4097"}

Before any task used appnet, wrk2 didn't know about it. Managers create overlays in the cluster state and extend them to a worker only when a task on that worker needs the network. ingress is the overlay Swarm creates for the routing mesh. appnet got the subnet 10.0.1.0/24 from Swarm's default pool and VXLAN network identifier 4097. The default pool is 10.0.0.0/8 carved into /24s; change it with docker swarm init --default-addr-pool if it collides with your networks.

Names: a VIP, tasks., or DNS round robin

Docker's embedded DNS server (127.0.0.11 in every container) answers for services on networks the container shares with them. A debug container on appnet asks for api and for tasks.api:

ubuntu@secopslog-docker-sec:~ · Docker 29.9.2
$ docker run -d --name debug --network appnet lab-reg:5000/alpine:3.24 sleep 1d
ef89098213df80425c1538d7cabf42a9ee2dcc431ee2b2e6cc1752b78cc61d17
$ docker exec debug nslookup -type=a api docker exec debug nslookup -type=a tasks.api
Server: 127.0.0.11 Address: 127.0.0.11:53 Non-authoritative answer: Name: api Address: 10.0.1.2 Server: 127.0.0.11 Address: 127.0.0.11:53 Non-authoritative answer: Name: tasks.api Address: 10.0.1.3 Name: tasks.api Address: 10.0.1.4
$ for i in 1 2 3 4; do docker exec debug wget -qO- http://api/; done
web v1 on 5a0790554655, client 10.0.1.8 web v1 on 64d48dcaaa05, client 10.0.1.8 web v1 on 5a0790554655, client 10.0.1.8 web v1 on 64d48dcaaa05, client 10.0.1.8

api resolves to one address, 10.0.1.2, the service's virtual IP (VIP). tasks.api returns the two task addresses. Connections to the VIP are balanced across healthy tasks by IPVS in the kernel of the calling node. That's why four requests alternated between the two containers. The VIP stays the same while tasks are replaced, rescheduled or scaled, so clients that cache DNS answers keep working. Balancing is per connection, not per request: a client that holds one keep-alive connection open talks to one task.

ubuntu@secopslog-docker-sec:~ · Docker 29.9.2
$ docker service create -q --name cache --network appnet --endpoint-mode dnsrr --replicas 2 lab-reg:5000/alpine:3.24 sleep 1d docker exec debug nslookup -type=a cache
jw8vjfrjkanh9zyr8dl9n6lce Server: 127.0.0.11 Address: 127.0.0.11:53 Non-authoritative answer: Name: cache Address: 10.0.1.9 Name: cache Address: 10.0.1.10
$ docker service inspect api --format "{{json .Endpoint.VirtualIPs}}" docker service inspect cache --format "{{json .Endpoint.VirtualIPs}} {{.Spec.EndpointSpec.Mode}}"
[{"NetworkID":"tao2u0y7olvrmhlja6obibzng","Addr":"10.0.1.2/24"}] null dnsrr

With --endpoint-mode dnsrr there's no VIP (VirtualIPs is null), and the service name itself returns every task address. Clients then do their own balancing, and DNS caching becomes your problem. A client that resolved once and kept the answer keeps calling a task that may have moved. Use dnsrr for software that wants to see its peers (clustered databases, some proxies with their own health checking), and the default VIP mode otherwise. A dnsrr service can't publish ports through the routing mesh. It can publish in host mode, shown below.

VXLAN on the wire

Now capture on the VM bridge while the debug container on mgr1 opens a connection to an api task:

ubuntu@secopslog-docker-sec:~ · Docker 29.9.2
$ BR=br-$(sudo docker network inspect -f "{{.Id}}" lab-swarm | cut -c1-12) sudo timeout 10 tcpdump -ni $BR -c 2 "udp port 4789" 2>/dev/null & sleep 1; docker exec debug wget -qO- http://api/ >/dev/null; wait
13:11:04.063286 IP 10.77.0.11.58008 > 10.77.0.12.4789: VXLAN, flags [I] (0x08), vni 4097 IP 10.0.1.8.55792 > 10.0.1.4.80: Flags [S], seq 2999033151, win 64860, options [mss 1410,sackOK,TS val 2767304074 ecr 0,nop,wscale 10], length 0 13:11:04.063522 IP 10.77.0.12.47549 > 10.77.0.11.4789: VXLAN, flags [I] (0x08), vni 4097 IP 10.0.1.4.80 > 10.0.1.8.55792: Flags [S.], seq 3940773109, ack 2999033152, win 64308, options [mss 1410,sackOK,TS val 2540926802 ecr 2767304074,nop,wscale 10], length 0

Each capture line is two packets. The outer one is the UDP packet from node to node: 10.77.0.11 to 10.77.0.12, destination port 4789, VXLAN network identifier 4097 (the ID appnet showed above). Inside it is the container-to-container TCP SYN from the debug container (10.0.1.8) to the api task at 10.0.1.4, port 80, and then the SYN-ACK coming back. The connection went to the VIP, but IPVS on mgr1 had already rewritten the destination to a task address before encapsulation. The inner traffic isn't encrypted. Anyone who can capture on the network between the hosts reads it in plain text. That matters as soon as that network is shared or crosses a data centre you don't control.

Encapsulation costs you 50 bytes per packet. The inner MSS of 1410 above comes from an inner MTU of 1450 on a 1500-byte network. Some VPNs and cloud networks have a smaller underlying MTU. There, set the overlay MTU explicitly with docker network create -d overlay --opt com.docker.network.driver.mtu=<value>, or small requests work and large responses hang.

A firewall that drops 4789/udp

Now the opening symptom. A rule in the VM's DOCKER-USER chain drops UDP port 4789 on the bridge between the nodes. Between real hosts, a host firewall or a security group without that port does the same. Node status and DNS still work, and the TCP connection to the VIP times out:

ubuntu@secopslog-docker-sec:~ · Docker 29.9.2
$ sudo iptables -I DOCKER-USER -p udp --dport 4789 -j DROP docker node ls --format "{{.Hostname}} {{.Status}}" docker exec debug nslookup -type=a api | tail -n 2 docker exec debug wget -T 3 -qO- http://api/ || echo "wget exit $?"
mgr1 Ready wrk1 Ready wrk2 Ready Address: 10.0.1.2 wget: download timed out wget exit 1

Delete the rule and the same request goes through:

ubuntu@secopslog-docker-sec:~ · Docker 29.9.2
$ sudo iptables -D DOCKER-USER -p udp --dport 4789 -j DROP docker exec debug wget -T 3 -qO- http://api/
web v1 on 5a0790554655, client 10.0.1.8

You'll see the same picture when a cloud security group allows 2377 but not 4789/udp. It also appears when 7946 is blocked (node discovery then flaps and overlay peers aren't learned), or when a host firewall on one node filters forwarded traffic. Between all nodes, allow 7946/tcp, 7946/udp and 4789/udp. Towards managers, allow 2377/tcp, and IP protocol 50 (ESP) when you use encrypted overlays. Keep all of them closed to everything outside the cluster. VXLAN has no authentication, so anyone who can send packets to 4789 on a node can inject traffic into its overlays.

The routing mesh

A port published with -p goes on the ingress overlay. Every node in the swarm listens on it, whether or not it runs a task, and forwards the connection to a healthy task on any node:

ubuntu@secopslog-docker-sec:~ · Docker 29.9.2
$ docker service create -q --name web --replicas 1 --constraint node.hostname==wrk1 -p 8080:80 lab-reg:5000/web:1 for ip in 10.77.0.11 10.77.0.12 10.77.0.13; do curl -s http://$ip:8080/; done
twb0hzt5hmsj5g9uet85psxza web v1 on 59e8f32192cc, client 10.0.0.2 web v1 on 59e8f32192cc, client 10.0.0.3 web v1 on 59e8f32192cc, client 10.0.0.4
$ docker network ls --filter name=ingress docker network inspect ingress --format "{{json .IPAM.Config}}"
NETWORK ID NAME DRIVER SCOPE ptpk7uqrwmf5 ingress overlay swarm [{"Subnet":"10.0.0.0/24","Gateway":"10.0.0.1"}]

The single web task runs on wrk1, yet all three node addresses answer, and the reply comes from the same container each time. The client address the application sees is 10.0.0.2, 10.0.0.3 or 10.0.0.4. That's the ingress-network address of the node that received the connection, not the VM's address. The mesh applies source NAT so that replies return through the node that accepted the connection. It's convenient behind an external load balancer that knows nothing about task placement. But published ports are open on every node, and your application loses the real client address. That breaks IP allow-lists, rate limits and access logs.

Host-mode publishing binds the port only on nodes that run a task. It goes directly to the task, without the mesh and without NAT:

ubuntu@secopslog-docker-sec:~ · Docker 29.9.2
$ docker service create -q --name edge --mode global --constraint node.role==worker \ --publish mode=host,published=8081,target=80 lab-reg:5000/web:1 for ip in 10.77.0.11 10.77.0.12 10.77.0.13; do curl -s -m 3 http://$ip:8081/ || echo "$ip: curl exit $?"; done
bqrdg40v9rr8sstx6hkxyyxwa 10.77.0.11: curl exit 7 web v1 on e4f7c71c2887, client 10.77.0.1 web v1 on 95a46c16a43b, client 10.77.0.1
$ docker service ls --format "{{.Name}} {{.Ports}}"
api cache edge web *:8080->80/tcp

mgr1 runs no edge task and refuses the connection (curl exit 7). The two workers answer with the client address 10.77.0.1, the VM's own address on the bridge, which is the real source. docker service ls lists only mesh ports in its PORTS column, so host-mode ports don't show up there; check the service spec instead. Host mode pairs naturally with global services: one task per node, and each node publishes the port. Then a load balancer that health-checks the nodes sends traffic to them, or a proxy such as Traefik or HAProxy runs as that global service. With a replicated service in host mode, two tasks of the same service can't land on one node (the port is taken). And your load balancer has to follow task placement.

Encrypted overlays

--opt encrypted makes Swarm set up IPsec (ESP in transport mode, AES-GCM) between every pair of nodes that share the network. The managers generate the keys and rotate them every 12 hours.

ubuntu@secopslog-docker-sec:~ · Docker 29.9.2
$ docker network create -d overlay --opt encrypted --attachable secnet docker service create -q --name sapi --network secnet --replicas 1 --constraint node.hostname==wrk2 lab-reg:5000/web:1 docker run -d --name sdebug --network secnet lab-reg:5000/alpine:3.24 sleep 1d >/dev/null docker exec sdebug wget -qO- http://sapi/
xysck5potpadyw7l8b1iyxdsr h2q71s0sl70q4ao6i8mg5vsqi web v1 on c4fd756b291f, client 10.0.2.6
$ BR=br-$(sudo docker network inspect -f "{{.Id}}" lab-swarm | cut -c1-12) sudo timeout 10 tcpdump -ni $BR -c 2 "esp or udp port 4789" 2>/dev/null & sleep 1; docker exec sdebug wget -qO- http://sapi/ >/dev/null; wait
13:11:38.142027 IP 10.77.0.11 > 10.77.0.13: ESP(spi=0xea1b8e5f,seq=0x6), length 116 13:11:38.142523 IP 10.77.0.13 > 10.77.0.11: ESP(spi=0xb58dc45f,seq=0x6), length 116

The same kind of request now shows ESP packets between the nodes, with no VXLAN header and no inner addresses visible. Encryption is per network and off by default. It costs CPU and throughput on every packet, so measure it on your hardware before you turn it on for heavy east-west traffic. Firewalls between nodes must pass IP protocol 50, which many security group templates don't include, and the failure looks exactly like the 4789 drop above. Encryption covers overlay traffic only. The routing mesh's ingress network isn't encrypted by this option. Traffic from clients to published ports is whatever the application makes it, so terminate TLS in the application or the proxy as well. On Windows nodes, encrypted overlays aren't supported.

ubuntu@secopslog-docker-sec:~/lab/swarmnet · Docker 29.9.2
$ docker rm -f debug sdebug >/dev/null docker context use default ./swarm-lab.sh down
default Current context is now "default" swarm lab removed

Swarm and the nftables firewall backend

Docker 29 added an experimental nftables firewall backend ("firewall-backend": "nftables" in daemon.json), which "Publishing ports and the packet path" covers. The overlay driver's rules haven't been migrated, so the daemon refuses to enter swarm mode with it. The first command backs up daemon.json (an empty {} when there's none, as here). It merges the key into the backup with jq, validates the result and restarts. The last one restores the backup:

ubuntu@secopslog-docker-sec:~ · Docker 29.9.2
$ sudo cp -a /etc/docker/daemon.json /etc/docker/daemon.json.bak 2>/dev/null || echo '{}' | sudo tee /etc/docker/daemon.json.bak >/dev/null jq '. + {"firewall-backend": "nftables"}' /etc/docker/daemon.json.bak | sudo tee /etc/docker/daemon.json sudo dockerd --validate --config-file /etc/docker/daemon.json sudo systemctl restart docker sudo docker info --format '{{json .FirewallBackend}}'
{ "firewall-backend": "nftables" } configuration OK {"Driver":"nftables","Info":[["EnableUserlandProxy","true"],["UserlandProxyPath","/usr/bin/docker-proxy"]]}
$ sudo docker swarm init --advertise-addr 127.0.0.1
Error response from daemon: --firewall-backend=nftables is incompatible with swarm mode
$ sudo cp /etc/docker/daemon.json.bak /etc/docker/daemon.json sudo systemctl restart docker sudo docker info --format "{{.FirewallBackend.Driver}}"
iptables

If you moved a host to the nftables backend, it can't be a swarm node until you switch back to iptables, the default. The reverse also holds, since a daemon in swarm mode keeps iptables. Plan the backend per host role.

Try this

On your next run, before down, put the 4789 drop back with the same sudo iptables -I DOCKER-USER command and repeat the routing-mesh loop over the three node addresses. The web task runs only on wrk1. Predict which addresses still answer and which time out, then check, and delete the rule with the fw-allow command afterwards.

Takeaway

A swarm can show every node Ready and resolve api to 10.0.1.2 while service traffic between nodes is dead, because the control plane and VXLAN use different ports. When a cross-node request times out, check UDP 4789 (and IP protocol 50 on encrypted overlays) on every host and security group first. Ports 2377 and 7946 being open says nothing about the data path.

Quick check
01A new three-node swarm forms cleanly and every node is Ready. A frontend task on one node cannot reach a backend task on another, although DNS returns the backend's VIP. The security group allows 2377/tcp between the nodes. What is missing?
Correct — 2377 carries cluster management only. Gossip uses 7946 and the VXLAN data plane 4789/udp; the lab dropped 4789 and got exactly this symptom.
Incorrect — Services on a shared overlay reach each other without publishing; publishing is for clients outside the swarm.
Incorrect — --attachable only lets standalone containers join; service tasks are attached regardless.
Incorrect — VIPs work on every node; the lab's debug container on mgr1 reached tasks on both workers through the VIP.
02An nginx service published with -p 80:80 logs every request as coming from 10.0.0.x, so its IP allow-list blocks real customers. What change gives nginx the real client address?
Incorrect — Endpoint mode changes how other services resolve the name; it does not change how external clients reach a published port.
Incorrect — Encryption protects traffic between nodes; the mesh still applies source NAT.
Incorrect — With the mesh, a request can still be forwarded and NATed, whether or not the receiving node has a task.
Correct — Host mode binds the port on the node running the task, without the mesh and its source NAT; the lab showed the real address 10.77.0.1.
03Clients of a dnsrr service keep sending traffic to a task address for minutes after that task was rescheduled to another node. Why?
Incorrect — The embedded DNS answers with the current task addresses; the stale address comes from the client side.
Incorrect — dnsrr services do not use a VIP or the mesh at all.
Correct — In VIP mode the address never changes; with dnsrr the name maps straight to task addresses, so client-side DNS caching serves stale ones.
Incorrect — The problem is the cached name-to-address answer, not ARP.

Related