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

Knock knock