Zum Hauptinhalt springen

WireGuard VPN für Docker Compose Setups

·1008 Wörter·5 min

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.

VPN Setup

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: unter networks: sorgt dafür, dass das Netzwerk wirklich wireguard-net heißt und nicht, wie Compose es sonst täte, mit dem Projektnamen zu wireguard_wireguard-net verunstaltet wird. Nur so können andere Setups es unter einem vorhersagbaren Namen einbinden.

  • Der Container bekommt mit ipv4_address eine 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änkt ip_range den dynamisch vergebenen Bereich auf 192.168.192.16/28 ein – .2 bis .15 bleiben für fix vergebene Adressen reserviert.

  • Der Container bekommt nur NET_ADMIN. Viele Anleitungen (und das Image selbst) hätten gerne zusätzlich SYS_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 mit PersistentKeepalive offen – er muss nichts entgegennehmen. Das ist mehr als Kosmetik: Von Docker veröffentlichte Ports landen in der DOCKER-iptables-Chain und werden damit vor den eigenen Regeln ausgewertet. Ein -p hebelt 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.