Kontext

Ich betreibe einen Monitoring-Stack mit Docker Compose, bestehend aus u.a. Grafana, Prometheus, Node Exporter, cAdvisor, InfluxDB, einem WireGuard-Container und einem speziellen Sidecar-Container (grafana-netinit), der im Netzwerk-Namespace von Grafana läuft.

Ziel ist, dass der Grafana-Container über den WireGuard-Container auf mein Heimnetz (z.B. 172.16.46.0/24) zugreifen kann, ohne das offizielle Grafana-Image zu verändern.

Alle Secrets (Tokens, Passwörter, Client IDs, E-Mail-Adressen) sind im Compose-File durch Platzhalter wie <SECRET_...> ersetzt.

Warum der Sidecar nötig ist

Das offizielle Grafana-Image ist ein generisches, fertig gebautes Container-Image, in dem ich selbst keine zusätzlichen Pakete wie iproute2 installieren oder den EntryPoint anpassen möchte (bzw. zum Teil auch gar nicht kann, z.B. wenn es als unverändertes Upstream-Image laufen soll). Im Container laufen die Prozesse zudem typischerweise nicht mit vollen Kernel-Capabilities, sodass Befehle wie ip route add ohne passende CAP_NET_ADMIN-Rechte scheitern würden.

Statt das Grafana-Image zu „verbiegen“ oder Sicherheits-/Upgrade-Probleme zu riskieren, übernimmt der Sidecar (grafana-netinit) die Aufgabe, im gemeinsamen Netzwerk-Namespace die Route zu setzen. Durch network_mode: "service:grafana" teilen sich beide Container denselben Network Namespace (Interfaces, IP-Adressen, Routing-Tabelle), sodass der Sidecar mit CAP_NET_ADMIN die Route setzen kann, während Grafana selbst unverändert und mit minimalen Rechten läuft.

Skizze / Zeichnung (Textform)

                 +-----------------------+
                 |     Heimnetz          |
                 |   172.16.46.0/24      |
                 +-----------+-----------+
                             ^
                             | (WireGuard-Tunnel)
                             v
+-----------------------------+-----------------------------+
|                  Docker Host / monitoring-Netz           |
|                                                           |
|   172.20.0.0/16 (Bridge "monitoring")                     |
|                                                           |
|   +---------------------------+                           |
|   | wireguard                 |   IP: 172.20.0.50         |
|   |  - Tunnel ins Heimnetz    |---------------------------+
|   +---------------------------+                           |
|                                                           |
|   +---------------------------------------------------+   |
|   |        gemeinsamer Network Namespace              |   |
|   |  (Grafana + grafana-netinit via                   |   |
|   |   network_mode: "service:grafana")                |   |
|   |                                                   |   |
|   |   +-------------------+      +-----------------+  |   |
|   |   | Grafana           |      | grafana-netinit |  |   |
|   |   | (App, kein        |      | (Sidecar,       |  |   |
|   |   |  CAP_NET_ADMIN)   |      |  CAP_NET_ADMIN) |  |   |
|   |   +---------+---------+      +---------+-------+  |   |
|   |             |                          |          |   |
|   |        Routing-Tabelle im Namespace    |          |   |
|   |        ----------------------------    |          |   |
|   |        default via 172.20.0.1 dev ethX |          |   |
|   |        172.20.0.0/16 dev ethX         |          |   |
|   |        172.21.0.0/16 dev ethY         |          |   |
|   |        172.16.46.0/24 via 172.20.0.50 |<---------+   |
|   +---------------------------------------------------+   |
+-----------------------------------------------------------+

Kurz erklärt:

  • Grafana und der Sidecar teilen sich denselben Netzwerk-Namespace (gleichen Satz an Interfaces und Routing-Tabelle).
  • Der Sidecar setzt die Route 172.16.46.0/24 via 172.20.0.50 dev <IFACE> in diesem Namespace.
  • Grafana schickt dadurch Traffic ins Heimnetz automatisch über den WireGuard-Container, obwohl es selbst keine zusätzlichen Rechte oder Tools dafür besitzt.

Relevante Services (vereinfacht)

  • Grafana

    • Image: grafana/grafana-oss:latest
    • Auth via Generic OAuth gegen <OIDC_PROVIDER_URL>
    • Hängt an zwei Netzen: monitoring (172.20.0.0/16) und proxy (172.21.0.0/16)
    • Ziel: Zugriff auf Heimnetz 172.16.46.0/24 über WireGuard.
  • WireGuard

    • Image: lscr.io/linuxserver/wireguard:latest
    • Statische IP im monitoring-Netz: 172.20.0.50
    • Baut Tunnel ins Heimnetz auf, das 172.16.46.0/24 routen kann.
  • grafana-netinit (Sidecar)

    • Image: busybox:1.36 (alternativ alpine:3.19 mit iproute2)
    • network_mode: "service:grafana" → teilt sich den Netzwerk-Namespace mit Grafana
    • cap_add: [ NET_ADMIN ]
    • Aufgabe: Statische Route im Grafana-Netznamespace setzen, damit Traffic ins Heimnetz über WireGuard geht.

Problem & Ursache

  • Anfangs funktionierte die Route, später nicht mehr.
  • Im Grafana-Container fehlte der Eintrag 172.16.46.0/24 via 172.20.0.50 dev <iface>.
  • Analyse zeigte:
    • Docker ordnet die NICs je nach Netzwerk-Reihenfolge zu; das monitoring-Netz war früher eth0, später eth1.
    • Der ursprüngliche Befehl nutzte ein hart codiertes Interface (dev eth0), das nicht mehr zum 172.20er Netz passte.
    • Zusätzlich hat Docker Compose/Portainer das $IFACE im command:-Block interpoliert, wodurch der Befehl im Container unvollständig wurde.

finale Lösung: dynamisches Interface + escaped $

Im grafana-netinit-Service wird das Interface dynamisch aus der Routing-Tabelle ermittelt, und die Shell-Variable wird im Compose-File korrekt escaped:

grafana-netinit:
  image: busybox:1.36
  network_mode: "service:grafana"
  cap_add:
    - NET_ADMIN
  depends_on:
    - grafana
    - wireguard
  command: >
    /bin/sh -lc "
      IFACE=$(ip route | awk '/172.20.0.0\\/16/ {print $3; exit}');
      ip route replace 172.16.46.0/24 via 172.20.0.50 dev $$IFACE || true;
      sleep infinity
    "
  restart: unless-stopped

Wichtige Details:

  • network_mode: "service:grafana" sorgt dafür, dass der Sidecar im selben Netznamespace wie Grafana läuft und dort ip route bearbeiten kann, ohne das Grafana-Image anzupassen.
  • IFACE=$(ip route | awk '/172.20.0.0\\/16/ {print $3; exit}') ermittelt das tatsächliche Interface (z.B. eth1) für das 172.20er Netz dynamisch.
  • dev $$IFACE: Das doppelte Dollarzeichen verhindert, dass Compose/Portainer die Variable schon beim Parsen interpoliert. Im Container kommt dev $IFACE an, das dann von /bin/sh korrekt aufgelöst wird.

Optional könnte ein Watchdog eingesetzt werden, der periodisch prüft, ob die Route noch existiert, und sie bei Bedarf nachsetzt, falls ein Grafana-Restart die Routing-Tabelle überschreibt.

Checkliste / How-To

  1. WireGuard-Container mit fixer IP im gleichen Netz wie Grafana (172.20.0.50 im monitoring-Netz).
  2. Grafana und WireGuard im selben Docker-Netzwerk (monitoring).
  3. Sidecar grafana-netinit mit:
    • network_mode: "service:grafana"
    • cap_add: NET_ADMIN
    • dynamischer IFACE-Ermittlung und statischem Route-Command wie oben.
  4. Nach Deployment prüfen:
    docker exec -it <grafana-container> ip route
    # Erwartet:
    # 172.16.46.0/24 via 172.20.0.50 dev ethX
  5. Sicherstellen, dass WireGuard das Heimnetz (172.16.46.0/24) korrekt als AllowedIPs/Route kennt und Antworten zurück ins monitoring-Netz gehen.

Dieses Setup erlaubt Grafana den Zugriff auf Ressourcen im Heimnetz über den WireGuard-Tunnel, ohne das offizielle Grafana-Image anzupassen, und ist robust gegenüber Interface-Änderungen in Docker.