Docker Compose for network simulations
I use Docker Compose to simulate mobile networks. Here is a tutorial about how to use this tool to simulate networks.
Docker Compose version
There are 2 main versions of Docker Compose:
- Docker Compose v1, written in Python
- Docker Compose v2, written in Go
The Go version is farly better than the Python one, at least for network engineering.
It allows to specify usefull options in docker-compose.yaml
,
like dns
, healthcheck
and profiles
. Additionnaly, this version is packaged for debian.
In this tutorial, I use the Go version (v2.6.0).
Docker image creation
To instanciate a container, you must have a Docker image. It is a good practice to use only Docker Official Images, and images build by yourself (i.e. not using images you randomly found on DockerHub, since they may contain malwares). For network engineering purpose, I recommend to base your images on debian:stable-slim.
Personally, I create Git repositories on Github, and automate the creation of images with Github Actions once a week, so I can have an up-to-date image ready in the DockerHub. Of course, if you do this, make sure to not put secrets in your image (even if you delete them in the next layer). I create images that do just one specific thing, with only minimum packages installed and no debug tools.
If possible I use debian packages (I build some of them myself),
but if there is no package it is also possible to build a software inside a builder
image,
and import the binary using COPY --from=builder …
to reduce the size of final image.
I will show in this tutorial how to use debug tools from another image created specifically for this.
Lets create some images for demonstration purpose:
FROM debian:bullseye-slim as router
RUN apt-get update -q && DEBIAN_FRONTEND=non-interactive apt-get install -qy iproute2 && rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /usr/local/sbin/entrypoint.sh
ENV VOL_FILE=""
ENV ROUTE6_NET=""
ENV ROUTE6_GW=""
ENV ROUTE4_NET=""
ENV ROUTE4_GW=""
ENTRYPOINT ["entrypoint.sh"]
CMD ["--help"]
FROM debian:bullseye-slim as router-debug
RUN apt-get update -q && DEBIAN_FRONTEND=non-interactive apt-get install -qy iperf3 iputils-ping iproute2 tshark && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["sleep"]
CMD ["infinity"]
The first image router
will contains only the iproute2
package.
In a real simulation, it could contains a specific software that will use iproute2
, iptables
,
and other network packages for example to create tunnels according to instructions given by the control plane.
The second image router-debug
contains various tools for testing your network behaviour.
The file entrypoint.sh
copied in router
image is an executable file containing the following:
#!/usr/bin/env bash
set -e
if [ "$1" == "--help" ]; then
echo "Example entrypoint, use --start to run it" > /dev/stderr
exit 0
fi
# Demonstration of volumes
if [ -n "$VOL_FILE" ]; then
cat "$VOL_FILE" > /dev/stderr
fi
if [ "$1" == "--start" ]; then
echo "[$(date --iso-8601=s)] Starting router with arguments \"$@\"" > /dev/stderr
# Demonstration of routes creation at runtime
if [[ -n "$ROUTE6_GW" && -n "$ROUTE6_NET" ]]; then
ip -6 route add "$ROUTE6_NET" via "$ROUTE6_GW" proto static
fi
if [[ -n "$ROUTE4_GW" && -n "$ROUTE4_NET" ]]; then
ip -4 route add "$ROUTE4_NET" via "$ROUTE4_GW" proto static
fi
# Remove default route
ip -6 route delete default
ip -4 route delete default
# Display IP Addresses
ip address list > /dev/stderr
# Keep the container up
exec sleep infinity
fi
exit 1
Implementing the architecture
For this demonstration, I will define the following setup: three containers R1
, R2
, and R3
.
R1
and R2
are connected on the same switch S1
; R2
and R3
are connected on the same switch S2
.
Lets create a docker-compose.yaml
file:
# Debug containers template
x-router-debug: &router-debug
restart: always
image: router-debug
build:
context: .
target: router-debug
# This capability will allow us to use iproute2
cap_add:
- NET_ADMIN
# Using different profile allow to
# not start unrequired debug container
profiles:
- debug
# Routers container template
x-router: &router
restart: always
image: router
build:
context: .
target: router
cap_add:
- NET_ADMIN
command: --start
# Required to enable routing
# It is not possible to change these values at runtime
sysctls:
- net.ipv6.conf.all.disable_ipv6=0
- net.ipv6.conf.all.forwarding=1
- net.ipv4.ip_forward=1
services:
# Routers containers
R1:
<<: *router
container_name: R1
hostname: R1
dns:
# Change dns for this container
- 192.0.2.1
- 2001:db8::1
volumes:
# Our entrypoint will display this file
- ./Dockerfile:/Dockerfile:ro
environment:
# Indicate to the entrypoint which file to display
- VOL_FILE=/Dockerfile
- ROUTE6_NET=fd83:b442:5c7e::/80
- ROUTE6_GW=fd32:f7ff:393f::8000:0:2
- ROUTE4_NET=10.0.200.0/24
- ROUTE4_GW=10.0.100.130
networks:
# IP Address is assigned automatically from the pool
S1:
R2:
<<: *router
container_name: R2
hostname: R2
networks:
# This container use manually assigned IP Addresses
S1:
ipv4_address: 10.0.100.130
ipv6_address: fd32:f7ff:393f::8000:0:2
S2:
ipv4_address: 10.0.200.130
ipv6_address: fd83:b442:5c7e::8000:0:2
R3:
<<: *router
container_name: R3
hostname: R3
environment:
- ROUTE6_NET=fd32:f7ff:393f::/80
- ROUTE6_GW=fd83:b442:5c7e::8000:0:2
- ROUTE4_NET=10.0.100.0/24
- ROUTE4_GW=10.0.200.130
networks:
S2:
# Debug containers
R1-debug:
<<: *router-debug
container_name: R1-debug
network_mode: service:R1
R2-debug:
<<: *router-debug
container_name: R2-debug
network_mode: service:R2
R3-debug:
<<: *router-debug
container_name: R3-debug
network_mode: service:R3
networks:
S1:
name: s1
enable_ipv6: true
driver: bridge
driver_opts:
com.docker.network.bridge.name: br-s1
com.docker.network.container_iface_prefix: s1-
ipam:
driver: default
config:
- subnet: 10.0.100.0/24
# ip_range is used for automatic ip allocation,
# I reserve half of the subnet for this.
# The other half is for manual allocation,
# I will use it for R2 and for the host (gateway)
# to be sure their IP addresses are available, even
# if R1 and R3 are started first
ip_range: 10.0.100.0/25
gateway: 10.0.100.129
# I use a /80 instead of a /48 for my ULA because
# of this issue: https://github.com/moby/moby/issues/40275
- subnet: fd32:f7ff:393f::/80
ip_range: fd32:f7ff:393f::/81
gateway: fd32:f7ff:393f::8000:0:1
S2:
name: s2
enable_ipv6: true
driver: bridge
driver_opts:
com.docker.network.bridge.name: br-s2
com.docker.network.container_iface_prefix: s2-
ipam:
driver: default
config:
- subnet: 10.0.200.0/24
ip_range: 10.0.200.0/25
gateway: 10.0.200.129
- subnet: fd83:b442:5c7e::/80
ip_range: fd83:b442:5c7e::/81
gateway: fd83:b442:5c7e::8000:0:1
Now, we can run docker compose --profile debug build
to build images (if you host images on DockerHub, you can run docker compose --profile debug pull
instead).
The option --profile debug
allows us to build images associated to the debug
profile in addition to the default
one.
We can then launch Docker Compose:
$ docker compose up -d
[+] Running 5/5
⠿ Network s2 Created 0.1s
⠿ Network s1 Created 0.1s
⠿ Container R3 Started 1.2s
⠿ Container R1 Started 1.2s
⠿ Container R2 Started 1.5s
$ docker compose logs
R3 | [2022-06-25T22:48:33+00:00] Starting router with arguments "--start"
R3 | 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
R3 | link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
R3 | inet 127.0.0.1/8 scope host lo
R3 | valid_lft forever preferred_lft forever
R3 | inet6 ::1/128 scope host
R3 | valid_lft forever preferred_lft forever
R3 | 432: s2-0@if433: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
R3 | link/ether 02:42:0a:00:c8:01 brd ff:ff:ff:ff:ff:ff link-netnsid 0
R3 | inet 10.0.200.1/24 brd 10.0.200.255 scope global s2-0
R3 | valid_lft forever preferred_lft forever
R3 | inet6 fd83:b442:5c7e::1/80 scope global nodad
R3 | valid_lft forever preferred_lft forever
R3 | inet6 fe80::42:aff:fe00:c801/64 scope link tentative
R3 | valid_lft forever preferred_lft forever
R1 | FROM debian:bullseye-slim as router
R1 | RUN apt-get update -q && DEBIAN_FRONTEND=non-interactive apt-get install -qy iproute2 && rm -rf /var/lib/apt/lists/*
R1 | COPY entrypoint.sh /usr/local/sbin/entrypoint.sh
R1 | ENV VOL_FILE=""
R1 | ENV ROUTE6_NET=""
R1 | ENV ROUTE6_GW=""
R1 | ENV ROUTE4_NET=""
R1 | ENV ROUTE4_GW=""
R1 | ENTRYPOINT ["entrypoint.sh"]
R1 | CMD ["--help"]
R1 |
R1 | FROM debian:bullseye-slim as router-debug
R1 | RUN apt-get update -q && DEBIAN_FRONTEND=non-interactive apt-get install -qy iperf3 iputils-ping iproute2 tshark && rm -rf /var/lib/apt/lists/*
R1 | ENTRYPOINT ["sleep"]
R1 | CMD ["infinity"]
R1 | [2022-06-25T22:48:33+00:00] Starting router with arguments "--start"
R1 | 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
R1 | link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
R1 | inet 127.0.0.1/8 scope host lo
R2 | [2022-06-25T22:48:33+00:00] Starting router with arguments "--start"
R2 | 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
R2 | link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
R2 | inet 127.0.0.1/8 scope host lo
R2 | valid_lft forever preferred_lft forever
R2 | inet6 ::1/128 scope host
R2 | valid_lft forever preferred_lft forever
R2 | 430: s1-0@if431: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
R2 | link/ether 02:42:0a:00:64:82 brd ff:ff:ff:ff:ff:ff link-netnsid 0
R2 | inet 10.0.100.130/24 brd 10.0.100.255 scope global s1-0
R2 | valid_lft forever preferred_lft forever
R2 | inet6 fd32:f7ff:393f::8000:0:2/80 scope global nodad
R2 | valid_lft forever preferred_lft forever
R2 | inet6 fe80::42:aff:fe00:6482/64 scope link tentative
R2 | valid_lft forever preferred_lft forever
R2 | 436: s2-0@if437: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
R2 | link/ether 02:42:0a:00:c8:82 brd ff:ff:ff:ff:ff:ff link-netnsid 0
R2 | inet 10.0.200.130/24 brd 10.0.200.255 scope global s2-0
R2 | valid_lft forever preferred_lft forever
R2 | inet6 fd83:b442:5c7e::8000:0:2/80 scope global nodad
R2 | valid_lft forever preferred_lft forever
R2 | inet6 fe80::42:aff:fe00:c882/64 scope link tentative
R2 | valid_lft forever preferred_lft forever
R1 | valid_lft forever preferred_lft forever
R1 | inet6 ::1/128 scope host
R1 | valid_lft forever preferred_lft forever
R1 | 434: s1-0@if435: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
R1 | link/ether 02:42:0a:00:64:01 brd ff:ff:ff:ff:ff:ff link-netnsid 0
R1 | inet 10.0.100.1/24 brd 10.0.100.255 scope global s1-0
R1 | valid_lft forever preferred_lft forever
R1 | inet6 fd32:f7ff:393f::1/80 scope global nodad
R1 | valid_lft forever preferred_lft forever
R1 | inet6 fe80::42:aff:fe00:6401/64 scope link tentative
R1 | valid_lft forever preferred_lft forever
To test our routing is right, lets start R2-debug
and R3-debug
:
$ docker compose up -d R2-debug R3-debug
[+] Running 2/2
⠿ Container R3 Running 0.0s
⠿ Container R3-debug Started 0.4s
⠿ Container R2 Running 0.0s
⠿ Container R2-debug Started 0.4s
So, we will start tshark
on R2
, and run a ping
between R3
and R1
:
$ docker exec -it R3-debug ping fd32:f7ff:393f::1 -c 1
PING fd32:f7ff:393f::1(fd32:f7ff:393f::1) 56 data bytes
64 bytes from fd32:f7ff:393f::1: icmp_seq=1 ttl=63 time=0.083 ms
--- fd32:f7ff:393f::1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.083/0.083/0.083/0.000 ms
Be sure to not forget the -it
in docker exec
command, or you won’t be able to kill it using ^C
and might start multiple tshark
instances (then you will have strange capture behaviours).
To avoid that, you can also run tshark
from the host using tshark -i br-s1 -i br-s2 -Y "icmpv6"
.
$ docker exec -it R2-debug tshark -i s1-0 -i s2-0 -Y "icmpv6"
Running as user "root" and group "root". This could be dangerous.
Capturing on 's1-0' and 's2-0'
1 0.000000000 fd83:b442:5c7e::1 ? fd32:f7ff:393f::1 ICMPv6 118 Echo (ping) request id=0x0043, seq=1, hop limit=63
2 0.000023245 fd32:f7ff:393f::1 ? fd83:b442:5c7e::1 ICMPv6 118 Echo (ping) reply id=0x0043, seq=1, hop limit=64 (request in 1)
3 -0.000008826 fd83:b442:5c7e::1 ? fd32:f7ff:393f::1 ICMPv6 118 Echo (ping) request id=0x0043, seq=1, hop limit=64
4 0.000026059 fd32:f7ff:393f::1 ? fd83:b442:5c7e::1 ICMPv6 118 Echo (ping) reply id=0x0043, seq=1, hop limit=63 (request in 3)
We can see our routes are used because the R3-debug
container is using the same network stack than R3
.
Note: packets are not displayed in the right order, but using timestamps we can reorder as follow:
3 -0.000008826 fd83:b442:5c7e::1 ? fd32:f7ff:393f::1 ICMPv6 118 Echo (ping) request id=0x0043, seq=1, hop limit=64
1 0.000000000 fd83:b442:5c7e::1 ? fd32:f7ff:393f::1 ICMPv6 118 Echo (ping) request id=0x0043, seq=1, hop limit=63
2 0.000023245 fd32:f7ff:393f::1 ? fd83:b442:5c7e::1 ICMPv6 118 Echo (ping) reply id=0x0043, seq=1, hop limit=64 (request in 1)
4 0.000026059 fd32:f7ff:393f::1 ? fd83:b442:5c7e::1 ICMPv6 118 Echo (ping) reply id=0x0043, seq=1, hop limit=63 (request in 3)
We can also run iperf3
:
$ docker up R1-debug -d
[+] Running 2/2
⠿ Container R1 Running 0.0s
⠿ Container R1-debug Started 0.2s
$ docker exec -it R1-debug iperf3 -s
-----------------------------------------------------------
Server listening on 5201
-----------------------------------------------------------
Accepted connection from fd83:b442:5c7e::1, port 46730
[ 5] local fd32:f7ff:393f::1 port 5201 connected to fd83:b442:5c7e::1 port 46732
[ ID] Interval Transfer Bitrate
[ 5] 0.00-1.00 sec 3.40 GBytes 29.2 Gbits/sec
[ 5] 1.00-2.00 sec 3.39 GBytes 29.1 Gbits/sec
[ 5] 2.00-3.00 sec 3.22 GBytes 27.7 Gbits/sec
[ 5] 3.00-4.00 sec 3.39 GBytes 29.1 Gbits/sec
[ 5] 4.00-5.00 sec 3.38 GBytes 29.0 Gbits/sec
[ 5] 5.00-6.00 sec 3.41 GBytes 29.3 Gbits/sec
[ 5] 6.00-7.00 sec 3.41 GBytes 29.3 Gbits/sec
[ 5] 7.00-8.00 sec 3.40 GBytes 29.2 Gbits/sec
[ 5] 8.00-9.00 sec 3.42 GBytes 29.4 Gbits/sec
[ 5] 9.00-10.00 sec 3.41 GBytes 29.3 Gbits/sec
[ 5] 10.00-10.00 sec 512 KBytes 13.8 Gbits/sec
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bitrate
[ 5] 0.00-10.00 sec 33.8 GBytes 29.1 Gbits/sec receiver
-----------------------------------------------------------
Server listening on 5201
-----------------------------------------------------------
^Ciperf3: interrupt - the server has terminated
$ docker -it exec R3-debug iperf3 -c fd32:f7ff:393f::1
Connecting to host fd32:f7ff:393f::1, port 5201
[ 5] local fd83:b442:5c7e::1 port 46732 connected to fd32:f7ff:393f::1 port 5201
[ ID] Interval Transfer Bitrate Retr Cwnd
[ 5] 0.00-1.00 sec 3.40 GBytes 29.2 Gbits/sec 0 897 KBytes
[ 5] 1.00-2.00 sec 3.39 GBytes 29.1 Gbits/sec 0 1.05 MBytes
[ 5] 2.00-3.00 sec 3.22 GBytes 27.7 Gbits/sec 270 1.70 MBytes
[ 5] 3.00-4.00 sec 3.39 GBytes 29.1 Gbits/sec 0 1.70 MBytes
[ 5] 4.00-5.00 sec 3.38 GBytes 29.0 Gbits/sec 0 1.70 MBytes
[ 5] 5.00-6.00 sec 3.41 GBytes 29.3 Gbits/sec 948 1.19 MBytes
[ 5] 6.00-7.00 sec 3.41 GBytes 29.3 Gbits/sec 0 1.19 MBytes
[ 5] 7.00-8.00 sec 3.40 GBytes 29.2 Gbits/sec 245 1.19 MBytes
[ 5] 8.00-9.00 sec 3.42 GBytes 29.4 Gbits/sec 282 855 KBytes
[ 5] 9.00-10.00 sec 3.41 GBytes 29.3 Gbits/sec 0 855 KBytes
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bitrate Retr
[ 5] 0.00-10.00 sec 33.8 GBytes 29.1 Gbits/sec 1745 sender
[ 5] 0.00-10.00 sec 33.8 GBytes 29.1 Gbits/sec receiver
iperf Done.
For comparison, here is the output of the same test on the same machine but without using Docker:
$ iperf3 -c localhost
Connecting to host localhost, port 5201
[ 5] local ::1 port 58788 connected to ::1 port 5201
[ ID] Interval Transfer Bitrate Retr Cwnd
[ 5] 0.00-1.00 sec 5.41 GBytes 46.5 Gbits/sec 0 3.25 MBytes
[ 5] 1.00-2.00 sec 5.87 GBytes 50.4 Gbits/sec 0 3.25 MBytes
[ 5] 2.00-3.00 sec 5.87 GBytes 50.5 Gbits/sec 0 3.25 MBytes
[ 5] 3.00-4.00 sec 5.85 GBytes 50.3 Gbits/sec 0 3.25 MBytes
[ 5] 4.00-5.00 sec 5.87 GBytes 50.5 Gbits/sec 0 3.25 MBytes
[ 5] 5.00-6.00 sec 5.89 GBytes 50.6 Gbits/sec 0 3.25 MBytes
[ 5] 6.00-7.00 sec 5.90 GBytes 50.7 Gbits/sec 0 3.25 MBytes
[ 5] 7.00-8.00 sec 5.90 GBytes 50.7 Gbits/sec 0 3.25 MBytes
[ 5] 8.00-9.00 sec 5.78 GBytes 49.7 Gbits/sec 0 3.25 MBytes
[ 5] 9.00-10.00 sec 5.76 GBytes 49.5 Gbits/sec 0 3.25 MBytes
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bitrate Retr
[ 5] 0.00-10.00 sec 58.1 GBytes 49.9 Gbits/sec 0 sender
[ 5] 0.00-10.00 sec 58.1 GBytes 49.9 Gbits/sec receiver
iperf Done.
Setting a DNS Resolver
As we have already seen, network stack is shared when network_mode:container
is used (network_mode: service:R3
for example).
This obviously include network interfaces and routing tables, but it also includes some network related files like /etc/resolv.conf
.
By default, Docker Compose will populate this file with the following:
$ docker exec -it R2 cat /etc/resolv.conf
nameserver 127.0.0.11
nameserver 2001:4860:4860::8888
nameserver 2001:4860:4860::8844
options edns0 trust-ad ndots:0
The IPv4 nameserver is a local resolver, managed by Docker,
that will resolve names using nameserver configured in host /etc/resolv.conf
,
and if dns
option is used in docker-compose.yaml
with an IPv4, this value will be used for resolution.
IPv6 nameservers are Google nameservers by default. Specifing a dns
option in docker-compose.yaml
will replace these lines.
For example, here is the content of this file on R1
(and R1-debug
), where we have configured dns
in docker-compose.yaml
:
$ docker exec -it R1 cat /etc/resolv.conf
nameserver 127.0.0.11
nameserver 2001:db8::1
options edns0 trust-ad ndots:0
We can also modify this file manually:
$ docker exec -it R3 bash -c "echo nameserver 192.0.2.53 > /etc/resolv.conf"
$ docker exec -it R3-debug cat /etc/resolv.conf
nameserver 192.0.2.53
The file is shared with R3-debug
, even when modified at runtime.
Modify container MAC addresses
Sometimes you may want to change MAC addresses of a container.
If you use mac_address
in docker-compose.yaml
, it is only possible to change it for the first interface
(priority
can be used to be sure this is always the same interface).
This configuration option become useless if you want to modify more than one MAC address on a container.
It is possible to mount in a volume a script which will edit MAC address using ip link set
then exec the original entrypoint.
You can run this kind of script by adding an entrypoint
option in docker-compose.yaml
.
Conclusion
I hope this tutorial will help you with your usage of Docker Compose. Docker Compose is a great tool for network engineering, even if it might be difficult to use the first times.
Code used in this blog article is available on Github with the repository louisroyer/blog-docker-compose-for-network-simulations.