WireGuard VPN für Docker Compose Setups
Lokale Sprachmodelle haben mich veranlasst einen gebrauchten Gaming-PC für
Inference-Aufgaben zu kaufen. Die 1000 EUR sind gut investiert, denn die
Nvidia RTX 3090 mit 24 GiB bringt sehr gute Tokens/Sekunde Werte mit den
aktuellen Sprachmodellen. Das Ding steht bei mir im Büro, hört auf den Namen
gpu-box und hängt dort in einem ganz normalen privaten LAN (192.168.11.0/24).
Nur: Meine Web-Applikationen laufen nicht im Büro, sondern auf einem dedizierten
Server bei Hetzner (Host A). Und die eine, die das
Sprachmodell braucht – ein kleiner, privater Musik-Server, der Playlists per LLM
zusammenstellt – müsste dafür irgendwie in dieses Büro-LAN hineinreichen.
Der Gaming-PC ist immerhin schon als WireGuard-Client mit meinem VPN-Server
verbunden (Host C, ein VPS ebenfalls bei Hetzner, der nichts anderes tut als
Hub zu spielen) und dort unter einer VPN-Adresse aus 10.10.11.0/24
erreichbar. Es geht also gar nicht darum, das Büro-LAN anzuzapfen, sondern nur
darum, Host A in dieses VPN-Netz zu bringen.
Der naheliegende Weg wäre, Host A ebenfalls auf
Betriebssystem-Ebene ins VPN zu hängen. Genau das wollte ich nicht: Auf der
Maschine laufen rund 70 Container in ~30 Compose-Projekten hinter einem einzigen
Traefik, dazu eine gewachsene nftables-Landschaft mit CrowdSec-Bouncer. Da noch
ein wg0 auf Host-Ebene dazwischen zu schieben, das Routing und Firewall-Regeln
für alles verändert, nur damit eine Applikation ein Sprachmodell erreicht –
nein danke.
Ein Docker Container für WireGuard und ein externes Netzwerk #
Der Trick besteht darin, dass neben den anderen Compose-Setups ein weiteres
läuft, das nichts anderes macht als den VPN-Client zu beherbergen. Der
WireGuard-Container hängt dabei in einem externen Docker-Netzwerk
(wireguard-net), das er selbst definiert – und andere Compose-Setups können
dieses Netzwerk zusätzlich verwenden, um über den Tunnel zu kommen.
services:
wireguard:
image: linuxserver/wireguard
container_name: wireguard
cap_add:
- NET_ADMIN
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Vienna
volumes:
- ./wg-config/wg0.conf:/config/wg_confs/wg0.conf:ro
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
restart: unless-stopped
networks:
wireguard-net:
ipv4_address: 192.168.192.2
networks:
wireguard-net:
name: wireguard-net
driver: bridge
ipam:
config:
- subnet: 192.168.192.0/20
gateway: 192.168.192.1
ip_range: 192.168.192.16/28
Vier Details in dieser Datei sind wichtig:
-
Das
name:unternetworks:sorgt dafür, dass das Netzwerk wirklichwireguard-netheißt und nicht, wie Compose es sonst täte, mit dem Projektnamen zuwireguard_wireguard-netverunstaltet wird. Nur so können andere Setups es unter einem vorhersagbaren Namen einbinden. -
Der Container bekommt mit
ipv4_addresseine fixe IP (192.168.192.2). Das ist keine Kosmetik: Diese Adresse ist gleich das Gateway, das andere Container in ihre Routing-Tabelle eintragen müssen. Damit sie ihm nicht versehentlich per DHCP in die Quere kommen, schränktip_rangeden dynamisch vergebenen Bereich auf192.168.192.16/28ein –.2bis.15bleiben für fix vergebene Adressen reserviert. -
Der Container bekommt nur
NET_ADMIN. Viele Anleitungen (und das Image selbst) hätten gerne zusätzlichSYS_MODULE, um das WireGuard-Modul bei Bedarf nachladen zu können. Diese Capability erlaubt aber das Laden beliebiger Kernel-Module und ist damit praktisch Host-Root – wer sie hat, ist aus dem Container heraus. Das würde genau die Trennung aufheben, wegen der das ganze Setup überhaupt gebaut wurde. Seit Kernel 5.6 ist WireGuard ohnehin im Kernel; es reicht, das Modul einmalig am Host zu laden:echo wireguard > /etc/modules-load.d/wireguard.conf modprobe wireguard -
Kein
ports:-Eintrag. Der Container ist reiner WireGuard-Client, baut die Verbindung also selbst auf und hält sie mitPersistentKeepaliveoffen – er muss nichts entgegennehmen. Das ist mehr als Kosmetik: Von Docker veröffentlichte Ports landen in derDOCKER-iptables-Chain und werden damit vor den eigenen Regeln ausgewertet. Ein-phebelt also die Host-Firewall aus, und man wundert sich später, warum ein Port trotz sauberer nftables-Regeln offen ist.
Das Netzwerk in einem anderen Setup verwenden #
Auf der anderen Seite ist es dann erfreulich unspektakulär. Das Compose-Setup
der Applikation deklariert wireguard-net als external und hängt den
Container zusätzlich zum normalen Traefik-Netzwerk hinein:
services:
app:
image: registry.example.com/musik:latest
container_name: musik
networks:
- web
- wireguard-net
environment:
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL}
labels:
- traefik.enable=true
- traefik.http.routers.musik.rule=Host(`musik.example.com`)
- traefik.docker.network=web
networks:
web:
external: true
wireguard-net:
external: true
Wichtig ist hier traefik.docker.network=web: Sobald ein Container in mehreren
Netzwerken hängt, weiß Traefik sonst nicht, über welches es ihn ansprechen soll,
und greift womöglich die falsche IP heraus.
Die Route fehlt noch #
Damit ist der Container zwar im selben Netzwerk wie der VPN-Client, weiß aber
noch nichts vom VPN-Netz 10.10.11.0/24. Pakete an gpu-box würden brav zum
Default-Gateway und damit ins Internet laufen. Es fehlt also noch:
ip route add 10.10.11.0/24 via 192.168.192.2
Und genau hier wurde es bei mir unangenehm: Das Applikations-Image ist ein
distroless-Image. Kein ip, kein sh, nichts – was ja gerade der Sinn der
Übung ist, aber eben auch heißt, dass man dem Container das Kommando nicht
einfach beim Start unterschieben kann. Das Image nur wegen einer einzigen
Routing-Zeile mit einem iproute2 aufzublasen wäre die falsche Antwort
gewesen.
Die Lösung ist ein winziger Sidecar-Container, der sich per
network_mode: "service:app" in den Netzwerk-Namespace der Applikation
einklinkt, dort die Route setzt und sich wieder verabschiedet:
musik-route:
image: alpine:3.24
container_name: musik-route
network_mode: "service:app"
cap_add:
- NET_ADMIN
command: sh -c "ip route add 10.10.11.0/24 via 192.168.192.2 || true"
depends_on:
- app
restart: "no"
network_mode: "service:app" bedeutet, dass dieser Container keinen eigenen
Netzwerk-Stack bekommt, sondern den der Applikation mitbenutzt – die Route
landet also genau dort, wo sie hin soll. NET_ADMIN braucht er, um sie setzen
zu dürfen, restart: "no" weil er nach einer Sekunde fertig ist, und das
|| true verhindert, dass ein docker compose up scheitert, wenn die Route
schon existiert. Das Image ist bewusst auf eine Version festgenagelt und nicht
auf alpine bzw. alpine:latest – ein Container, der mit NET_ADMIN im
Netzwerk-Namespace der Applikation läuft, sollte nicht bei jedem Pull etwas
anderes sein.
Ein bisschen Vorsicht ist geboten: Der Sidecar läuft einmal beim Start. Wird
später nur der App-Container neu erzeugt, ist die Route weg und die
LLM-Funktionen fallen still aus. Ein docker compose up -d über das ganze
Setup zieht sie wieder ein.
Fazit #
Das Setup läuft jetzt seit Monaten ohne Wartung: Die Musik-Applikation liegt
weiter brav hinter Traefik im öffentlichen Netz, spricht aber für ihre LLM-Aufrufe
über den Tunnel mit einer RTX 3090, die in meinem Büro steht. Der Host selbst
weiß von alldem nichts – kein wg0, keine zusätzlichen Firewall-Regeln, keine
veränderten Routen. Für weitere Applikationen sind es genau zwei Zeilen
(wireguard-net in networks:) plus der Sidecar.