The Raspberry Pi 4 is powerful enough to run multiple Docker containers with decent performances. The goal here is to use Docker as much as possible to separate services and responsibilities and ease the maintenance and the updates.
Docker installation
Docker is properly supported on Raspbian Buster and the installation is straight forward
$ sudo su
# curl -sSL https://get.docker.com | sh
# usermod -aG docker [USERNAME]
After the usermod
command, you need to logout/login to get permission to run docker commands.
Let’s check that Docker is running properly
$ docker run hello-world
$ docker system prune -a
Docker Compose
(based on https://www.berthon.eu/2019/revisiting-getting-docker-compose-on-raspberry-pi-arm-the-easy-way/)
Docker Compose is a tool to define and run Docker containers using YAML configuration files. Unfortunately, Compose is not available for ARM architecture as an ELF binary. You can install it using pip install docker-compose
but it requires to install a lot of Python3 dependencies on the Raspberry Pi.
A smart solution is to build compose
inside a container and to retrieve the built ELF executable. This way, you don’t have to install any package on the host.
-- Create a docker base folder
$ sudo mkdir /opt/docker
$ sudo chmod 770 /opt/docker
$ sudo chgrp docker /opt/docker
-- Retrieve latest stable compose sources
$ mkdir /opt/docker/compose-build && cd /opt/docker/compose-build
$ wget -qO- https://github.com/docker/compose/archive/1.24.1.tar.gz | tar xzvf -
$ cd compose-1.24.1/
-- Fix the sources (thanks to J-C Berthon)
$ sed -i -e 's:^VENV=/code/.tox/py36:VENV=/code/.venv; python3 -m venv $VENV:' script/build/linux-entrypoint
$ sed -i -e '/requirements-build.txt/ i $VENV/bin/pip install -q -r requirements.txt' script/build/linux-entrypoint
-- Create the image to build docker-compose (take a coffee)
$ docker build -t docker-compose-build:armhf -f Dockerfile.armhf .
-- Build docker-compose using the created image (finish the coffee pot)
$ docker run --rm --entrypoint="script/build/linux-entrypoint" -v $(pwd)/dist:/code/dist "docker-compose-build:armhf"
-- Retrieve the ELF binary
$ sudo cp dist/docker-compose-Linux-armv7l /usr/local/bin/docker-compose
$ sudo chown root:root /usr/local/bin/docker-compose
$ sudo chmod 0755 /usr/local/bin/docker-compose
-- Test
$ docker-compose version
docker-compose version 1.24.1, build unknown
docker-py version: 3.7.3
CPython version: 3.6.9
OpenSSL version: OpenSSL 1.1.1c 28 May 2019
-- Cleanup
$ cd ~/
$ rm -fR /opt/docker/compose-build
$ docker images docker-compose-build -q | xargs docker rmi
Docker IPv6 support
Docker was not built with IPv6 in mind and it’s not so easy to have a working IPv6 configuration. There are two main options:
-
Expose containers using NDP proxying
-
Enable IPv6 NAT by using docker-ipv6nat
Enabling IPv6
The first step is to enable IPv6 for Docker. You can enable IPv6 on the default bridge (bridge0
) or create a Docker user-defined bridge (or you can do both). I choose the user-defined bridge at the end.
If you plan to use NDP proxying, you have to provide a public range of routable IP and if you plan to use docker-ipv6nat, you need to provide a ULA range (non-routable).
-- Public range for NDP proxying
$ export IPV6_RANGE=2404:e801:XXXX:XXXX:5000::/80
-- Local range for ipv6nat
$ export IPV6_RANGE=fd00:e801:XXXX:XXXX:5000::/80
Activate IPv6 for the default bridge
In /etc/docker/daemon.json
, activate the IPv6 feature on the default bridge and specify a range of available IP for Docker (it has to be in the range assigned by your ISP and managed by your router).
$ sudo tee /etc/docker/daemon.json > /dev/null << EOF
{
"ipv6": true,
"fixed-cidr-v6": "${IPV6_RANGE}"
}
EOF
$ sudo systemctl restart docker
Create a user-defined bridge with IPv6 support
According to the Docker documentation, user-defined bridges are superior compared to the default bridge and I finally choose this way. The following command will create a new bridge with the networks 172.20.0.0/16
and IPV6_RANGE
and the default gateways. The interface will be named docker-br0
for the kernel and br0
for Docker.
$ docker network create --ipv6 --driver=bridge \
--subnet=172.20.0.0/16 \
--subnet=${IPV6_RANGE} \
-o "com.docker.network.bridge.name"="docker-br0" br0
Test
You can run a simple container to check IPv6 is properly supported
-- net=bridge or net=br0 depending of the method you choose
$ docker run -it --rm --net=br0 alpine ash -c "ip -6 addr; ip -6 route; ping -6 -c4 www.google.com"
Reaching the containers
The containers can reach IPv6 hosts on Internet but they cannot be reached from outside the host. The reason is the Raspberry Pi doesn’t manage an IPv6 prefix and is a simple client for the Linksys router. The IPv6 prefix is managed by the router and the containers are not directly accessible (they belongs to another network segment).
There is 2 main options: enabling ND Proxying or using NAT to expose containers ports.
NDP Proxying
Basically, the ND Proxy (Neighbour Discovery Proxy) allows bridging the router network and the docker network. The Raspberry Pi will reply to the Neighbor Solicitation messages sent on the network on the behalf of the containers and they become reachable.
We start by activating the proxy_ndp
option in the kernel
$ echo "net.ipv6.conf.eth0.proxy_ndp=1" | sudo tee -a /etc/sysctl.conf
$ sudo sysctl -p
Once the proxy_ndp
is activated, you need to register the IPv6 addresses you want to expose.
-
One way is to enable proxying per container with a command like
ip -6 neigh add proxy 2404:e801:xxxx:xxxx:5000::xxxx dev eth0
. It’s a manual operation and it requires some maintenance when you add/remove containers. -
A better way is to use
ndppd
, a small daemon that can proxy a whole subnet and doesn’t require any maintenance.$ sudo apt install ndppd $ sudo tee /etc/ndppd.conf > /dev/null << "EOF" route-ttl 5000 proxy eth0 { router yes timeout 500 ttl 30000 # docker bridge rule 2404:e801:xxxx:xxxx:5000::/80 { auto } } EOF
It seems
ndppd
doesn’t properly restart so you can use the following commands to kill and restart the process:$ sudo kill $(cat /var/run/ndppd.pid) $ sudo rm /var/run/ndppd.pid $ sudo systemctl restart ndppd
docker-ipv6nat
NDP Proxying is a nice solution but it has several drawbacks:
- As the containers have a routable address, they are fully exposed (and not only the published ports)
- Sources IP are rewritten so all requests are issued by the host from the container point of view.
The solution is to use docker-ipv6nat. It solves the issues I mentioned by managing ip6tables
rules and it mimics the Docker NAT behavior for IPv6.
$ mkdir /opt/docker/ipv6nat && cd /opt/docker/ipv6nat
$ tee /opt/docker/ipv6nat/docker-compose.yml > /dev/null << "EOF"
version: "3"
services:
ipv6nat:
container_name: ipv6nat
restart: always
image: robbertkl/ipv6nat
privileged: true
network_mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /lib/modules:/lib/modules:ro
EOF
$ docker-compose up -d
That’s it! This container will publish the ports exposed by the other containers on the host.
Bonus - Run MTR in a Docker container
MTR (My traceroute) is a nice network diagnostic tool but if you don’t want to install it with its dependencies on the Raspberry Pi, you can run it through Docker.
-- Create a simple Dockerfile
$ mkdir /opt/docker/mtr && cd /opt/docker/mtr
$ tee Dockerfile > /dev/null << "EOF"
FROM alpine:latest
LABEL maintainer="Gregoire Pailler <gregoire@pailler.fr>"
RUN apk add --no-cache mtr
ENTRYPOINT ["/usr/sbin/mtr"]
EOF
-- Build the image
$ docker build -t mtr .
-- Add an alias to run mtr like any other command
$ tee -a ~/.bash_aliases > /dev/null << "EOF"
alias mtr='docker run --rm -it --net=br0 mtr'
EOF
-- Reload the aliases
$ source ~/.bash_aliases
-- Test !
$ mtr -6 ipv6.google.com
Extra bonus - Run ddclient in a Docker container
DDClient is a small utility used to update DNS entries on dynamic DNS services. It allows accessing the Raspberry Pi using a stable DNS name.
Create a docker-compose.yml
file with the following content
$ mkdir /opt/docker/ddclient && cd /opt/docker/ddclient
$ tee docker-compose.yml > /dev/null << "EOF"
version: "3"
services:
ddclient:
image: linuxserver/ddclient:latest
container_name: ddclient
environment:
- TZ=Asia/Singapore
- PUID=1000
- PGID=1000
volumes:
- ./ddclient.conf:/config/ddclient.conf:ro
# Use custom DNS to ensure the IP is updated even
# when the local DNS service is broken
dns:
- 1.1.1.1
- 8.8.8.8
restart: unless-stopped
networks:
default:
external:
name: br0
EOF
And create a ddclient.conf
file to update a DynDNS entry (the settings are dependent of your provider).
$ export DD_LOGIN=login
$ export DD_PASSWORD=password
$ export DD_SERVER=www.ovh.com
$ export DD_HOST=host.domain.tld
$ tee ddclient.conf > /dev/null << EOF
daemon=300
syslog=yes
protocol=dyndns2
use=web, web=api.ipify.org
server=${DD_SERVER}
login=${DD_LOGIN}
password='${DD_PASSWORD}'
${DD_HOST}
EOF
$ chmod o-r ddclient.conf
Start the container and check the logs to ensure DDClient works properly and updates the DynDNS entry.
$ docker-compose up -d && docker-compose logs -f
Now you can open the port 22 on your router and forward it to the Raspberry Pi to get an SSH access from everywhere. You can check the port is properly open by using a network scanner like http://www.ipv6scanner.com/cgi-bin/main.py