Ein Server, mehrere TYPO3-Portale, für jedes Portal ein eigener Redis-Cache in einem Docker-Container, dazu ein Bündel Site-to-Site-Tunnel mit strongSwan zu den Standorten der Kunden. Alles läuft seit Jahren. Dann ein Wartungsabend an den Tunneln, ein systemctl restart strongswan, und am nächsten Morgen ist das Postfach voll:
Subject: Cron <…> scheduler:run
[ RedisException ]
read error on connection to 127.0.0.1:6385
Der TYPO3-Scheduler eines Portals kommt nicht mehr an seinen Redis. Minütlich. Die ganze Nacht. Was folgt, ist ein Debugging-Protokoll mit einer Ursache, die ich so noch nicht gesehen hatte, und ein paar Werkzeugen, die den Unterschied gemacht haben.
Die ersten Reflexe laufen ins Leere
docker ps: der Container läuft seit vier Monaten. docker logs: nichts Auffälliges, stündliche RDB-Snapshots wie immer. docker exec redis-… redis-cli -a … PING: PONG. Speicher bei 5 MB von 256 MB, keine abgewiesenen Verbindungen, keine Evictions.
Der Container ist gesund. Ein docker restart (den ein Kollege am Vorabend schon zweimal probiert hatte) ändert erwartungsgemäß nichts. Wer hier weiter am Container dreht, verliert Zeit.
Eingrenzen: vom Host aus ist es kaputt
Der entscheidende Perspektivwechsel: nicht im Container testen, sondern von dort, wo TYPO3 sitzt, nämlich auf dem Host. Dafür braucht es nicht einmal redis-cli, Bash reicht:
Ein gesunder Redis mit Passwort antwortet hier sofort mit -NOAUTH Authentication required. Ohne Antwort bis zum Timeout ist die Strecke tot. Und sie war tot, auf allen fünf Redis-Ports des Servers, nicht nur auf dem einen, der Mails produzierte. Die anderen Portale hatten es schlicht noch nicht gemerkt.
Zwei weitere Beobachtungen grenzen weiter ein:
- Auch die Container-IP direkt (
172.17.0.x:6379, am Docker-Proxy vorbei) antwortet nicht. Nicht einmalping. ss -tanzeigt am Listener127.0.0.1:6385eine Verbindung mit 37 Bytes in der Receive-Queue: derdocker-proxynimmt die Verbindung an, liest aber nicht, weil er selbst nicht zum Container durchkommt.
Dann tcpdump -ni docker0, während ich vom Host aus einen Verbindungsversuch starte. Zu sehen ist nur eines: der Container fragt per ARP nach dem Host (who-has 172.17.0.1) und bekommt keine Antwort. Vom Host geht in Richtung Container gar nichts über docker0, kein ARP, kein SYN. Der Host schickt die Pakete also woandershin.
Die Spur: ip route get
Nicht docker0, sondern ipsec0, und nicht die Haupttabelle, sondern Tabelle 220. Das ist die Routingtabelle von strongSwan, und ip rule zeigt, dass sie vor der Haupttabelle abgefragt wird:
0: from all lookup local
220: from all lookup 220
32766: from all lookup main
In Tabelle 220 stehen die Routen zu den Tunnel-Netzen, wie erwartet, und dazwischen ein Eintrag, der dort nichts verloren hat:
172.17.0.0/16 dev ipsec0 proto static src 172.17.0.1
Das gesamte Docker-Netz wird auf das IPsec-Interface geleitet und verschwindet dort. ip route get ist deshalb das Werkzeug der Wahl: es berücksichtigt Policy-Routing, ip route allein zeigt nur die Haupttabelle und sieht harmlos aus.
Die Ursache: zwei Plugins, die keiner bestellt hat
Wer hat die Route gesetzt? Das Journal von charon zum Zeitpunkt des Neustarts:
uninstalling bypass policy for 172.17.0.0/16
installed bypass policy for 172.17.0.0/16
Zwei strongSwan-Plugins arbeiten hier zusammen:
- bypass-lan legt beim Start für jedes lokal angeschlossene Netz eine Bypass-Policy an, damit LAN-Verkehr nie versehentlich in einen Tunnel mit weitem Selektor gerät. Das Docker-Bridge-Netz
172.17.0.0/16ist aus Sicht des Plugins ein LAN wie jedes andere. - kernel-libipsec ist die Userland-IPsec-Implementierung mit dem TUN-Interface
ipsec0. Sie installiert für Policies Routen über dieses Interface, und in dieser Version auch für die Bypass-Policy von bypass-lan. Ergebnis: die Route ins Nichts.
Warum das jahrelang unauffällig blieb: beim Systemstart existiert docker0 noch nicht, wenn charon startet. Die Falle schnappt nur bei einem Neustart von charon im laufenden Betrieb zu, während Docker läuft. Und warum die Plugins überhaupt geladen waren: die /etc/strongswan.conf dieses Servers band das Verzeichnis strongswan.d/ nicht ein. Die dort liegenden load = no für genau diese beiden Plugins waren wirkungslos, charon lud einfach alles.
Der Fix: eine Zeile
Im selben Moment antworten alle fünf Redis-Ports mit -NOAUTH, der Scheduler läuft beim nächsten Cron-Lauf sauber durch.
Dauerhaft absichern
Ein manuell gelöschter Eintrag ist keine Lösung, der nächste Neustart bringt ihn zurück. Drei Maßnahmen, die sich ergänzen:
1. bypass-lan von der Docker-Bridge fernhalten. In der effektiven strongswan.conf (also der, die charon tatsächlich liest):
charon {
plugins {
bypass-lan {
interfaces_ignore = docker0
}
}
}
2. Ein systemd-Drop-in als zweites Netz. Falls doch einmal etwas eine solche Route setzt, wird sie nach jedem Start wieder entfernt:
# /etc/systemd/system/strongswan.service.d/docker-route.conf
[Service]
ExecStartPost=/bin/sh -c "sleep 8; /sbin/ip route del 172.17.0.0/16 table 220 2>/dev/null; exit 0"
Die acht Sekunden braucht es, weil charon die Route erst kurz nach dem Start installiert. systemctl daemon-reload nicht vergessen.
3. Nur laden, was gebraucht wird. ipsec statusall | grep "loaded plugins" zeigt, was wirklich aktiv ist. Wer kernel-netlink nutzt, braucht kernel-libipsec nicht, und bypass-lan ist nur sinnvoll, wenn Tunnel mit 0.0.0.0/0 im Spiel sind. Entweder strongswan.d/*.conf in der strongswan.conf einbinden, wie es die Distribution vorsieht, oder die Plugins mit charon { load = ... } explizit aufzählen. Beides braucht einen charon-Neustart, der mit den Maßnahmen 1 und 2 jetzt gefahrlos ist.
Die Checkliste nach jedem Eingriff an strongSwan
Der eigentliche Fehler war nicht die Route, sondern dass nach dem Neustart nur die Tunnel geprüft wurden. Seitdem gehört auf diesen Servern nach jeder Änderung an strongSwan dazu:
ip route show table 220 | grep -E "172\.(1[6-9]|2[0-9]|3[01])\." # muss leer sein
for p in 6380 6381 6383 6384 6385; do
printf "%s: " $p
timeout 3 bash -c "exec 3<>/dev/tcp/127.0.0.1/$p; printf 'PING\r\n' >&3; head -c 30 <&3"
echo
done
Fünfmal -NOAUTH, und der Abend ist wirklich vorbei.
Was hängen bleibt
- Ein gesunder Container sagt nichts über den Weg zum Container. Immer von dort testen, wo der Client sitzt.
ip route get <ziel>stattip route: nur so sieht man Policy-Routing und fremde Tabellen.ssmit Blick auf die Receive-Queue verrät, ob ein Proxy-Prozess hängt.tcpdumpaufdocker0beantwortet in Sekunden, ob überhaupt Pakete fließen und in welche Richtung.- Konfigurationsdateien, die nicht eingebunden sind, sind Dekoration.
load = nogilt nur, wenn die Datei gelesen wird.


