Skip to main content

IPv6 und LXC

Erster Urlaubstag. Die letzten drei Tage auf Arbeit hatte ich mir auf ein Thema vorgenommen, das mich schon seit geraumer Zeit stört: Unsere selbst betriebenen Dienste waren nicht per IPv6 erreichbar. Dazu muss man wissen, dass wir eine eigene Herangehensweise haben. Heutzutage wird ja viel über Docker und OCI-Images gemacht, aber wir betreiben unsere Dienste in Linux Containern, LXC. Die Dienste selbst sind auch per IPv4 nicht direkt erreichbar, sondern werden per Port Forwarding auf einen LXC mit einem nginx Reverse Proxy erreichbar gemacht. Zumindest die Webdienste. Im Falle anderer Dienste werden die Ports ggf. auf die jeweiligen Container durchgeleitet.

Im Default-Setup liefert Docker schon eine Menge Automatismen mit, um Ports von Containern zu exponieren, aber ich habe nicht viel Erfahrung mit Docker, das muss ich offen zugeben. Mein Eindruck aus den Installationsanleitungen diverser per Docker verteilter Webdienste ist aber, dass alle davon ausgehen, dass sie auf dem Host der einzige Webdienst seien und sich die Ports 80 und 443 krallen könnten. Zumindest der Caddy- oder nginx-Docker-Container müsste also manuell konfiguriert werden.

Aber es soll hier nicht um Docker gehen, sondern konkret um LXC, wobei vieles auch auf Systemcontainer, die auf systemd-nspawn basieren, und virtuelle Maschinen, die mit libvirt, kvm und qemu erstellt wurden, anzuwenden ist. Vielleicht auch VirtualBox, wobei mir das auf dem Server suspekt ist 😃. Grundsätzlich ist das anwendbar auf alle Lösungen, die sich einfach nur ohne Weiteres an ein Bridge-Interface hängen lassen.

Das Szenario

Ich habe mir für meine Versuche eine Cloud-VM bei Hetzner erstellt, das kleinste Modell für 2,99€/Monat. Als Betriebssystem kommt ein Debian 13 (Trixie) zum Einsatz.

Da ich mich auf IPv6 konzentrieren wollte, habe ich auf eine IPv4-Adresse verzichtet. Wenn ich mich recht erinnere, hieß es im Setup, dass IPv6 kostenlos sei, IPv4 aber mit 0,8ct/Stunde berechnet würde. Leider erstreckt sich das kostenlose IPv6-Angebot nur auf /64-Prefixe (ich bleibe bei der englischen Schreibweise, da Websuchen eher darauf ansprechen). Für einen kürzeren Prefix müsse man bezahlen, 15 Euro Bearbeitungsgebühr.

Als Grundlage der Netzwerkkonfiguration wollte ich systemd-networkd verwenden. systemd-networkd bringt meines Wissens alle Komponenten mit, um IPv6 auf dem Host und Downstream zu konfigurieren. Wer systemd meiden möchte, wird hier nicht glücklich. Ich denke aber, dass sich vieles auch auf ifupdown und radvd übertragen lässt. Ich habe keine Ahnung, welche DHCP-, DHCPv4- und DHCPv6-Komponente aktuell angesagt ist, wenn man systemd-networkd meiden will – ISC DHCP, Kea, dnsmasq? Und ob eine DHCPv6-Komponente überhaupt benötigt wird.

Privat nutze ich gerne systemd-nspawn anstelle von LXC, womit aber mein Arbeitskollege bereits vertraut war, als er das Thema Containerisierung bei uns bekannt machte. Ich finde es spannend, die Gemeinsamkeiten und Unterschiede zwischen den beiden letztlich auf demselben Fundament (Namespaces und Control Groups) aufsetzenden Ansätzen auszuloten. Beide bringen eine Menge Automatismen mit, manchmal für dasselbe Problem bei beiden, manchmal aber auch nur bei dem einen oder dem anderen. Im großen und ganzen sehe ich systemd-nspawn aber bei den Automatismen vorn (für mich besonders wichtig: Portfreigaben!). Das soll nicht heißen, dass man sie nicht nachvollziehen kann. Aber hier kommt LXC zum Einsatz.

Das Setup

Netzwerk

Hetzner konfiguriert die VMs per Cloud-Init. Dazu wird aus der Cloud-Init eine Netzwerkkonfiguration /etc/network/interfaces.d/50-cloud-init erstellt und der Dienst networking.service gestartet. Diese Konfiguration wird erst einmal auf systemd-networkd umgestellt.

# /etc/systemd/network/eth0.network
[Match]
Name=eth0

[Network]
DHCP=no
LinkLocalAddressing=ipv6
Address=2001:db8:900d:1dea::1/64
Gateway=fe80::1
DNS=2a01:4ff:ff00::add:1
DNS=2a01:4ff:ff00::add:2
IPv6AcceptRA=true

Die frisch erstelle Konfiguration bringt noch gar nichts, weil systemd-networkd.service noch nicht läuft:

systemctl enable --now systemd-networkd.service systemd-resolved.service

Das funktioniert und macht auch nichts kaputt. networkctl status zeigt dann, dass die Schnittstelle eth0 erfolgreich konfiguriert wurde. Jetzt muss nur noch ifupdown ausgeschaltet werden:

systemctl disable --now networking.service

Und so fand ich heraus, dass der Dienst nicht einfach gestoppt und deaktiviert wird, sondern auch die konfigurierten Schnittstellen in den down-Zustand bringen will. Gut, dass ich ein root-Passwort gesetzt hatte.

In der ausgelieferten Config gibt es einen Hinweis, wie man Cloud-Init mitteilt, keine Netzwerkkonfiguration mehr zu erstellen, ansonsten wird das immer wieder gemacht, und ich nehme an, dass auch networking.service immer wieder gestartet wird.

# This file is generated from information provided by the datasource.  Changes
# to it will not persist across an instance reboot.  To disable cloud-init's
# network configuration capabilities, write a file
# /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg with the following:
# network: {config: disabled}

LXC

Eines der Quality-of-Life-Features von LXC ist die Möglichkeit, ein fertiges Image aus einem Repository zu laden:

lxc-create -n ipv6-client -t download -- -d debian -r trixie -a amd64

Das download-Template wird im Paket lxc-templates bereitgestellt. Ich habe weder die /etc/defaults/lxc noch die /etc/lxc/default.conf angepasst. Am Ende werde ich das eine oder andere erwähnen, was vielleicht noch sinnvoll wäre, aber ich reproduziere das hier eh schon aus dem Gedächtnis, solange es noch halbwegs frisch ist, und kann nicht gewährleisten, dass alles 100 Prozent korrekt ist.

Eine der Voreinstellungen von LXC ist, dass neu erstellte Container eine virtuelle Schnittstelle vom Typ veth bekommen, die an der Bridge lxcbr0 hängt. Um die Erstellung kümmert sich ein Skript, das über den Dienst lxc-net.service gestartet wird und in /usr/libexec/lxc/lxc-net liegt. Schaue ich mir das an, wird viel von der Schnittstellenkonfiguration direkt über ip-Befehle erledigt. Ich habe keine Lust, das alles zu replizieren, also fasse ich diese Schnittstelle für meine Experimente besser nicht an. Außerdem ist es vielleicht ganz sinnvoll, wenn die LXC untereinander in einem privaten Subnetz reden können und nur selektiv exponiert werden.

Ich ändere also die Konfiguration /var/lib/lxc/ipv6-client/config vom LXC ipv6-client und ergänze ein paar Zeilen:

# /var/lib/lxc/ipv6-client/config
# lxc.net.0 ist die vordefinierte Schnittstelle aus der Default-Config,
# deswegen hier lxc.net.1
lxc.net.1.type = veth
lxc.net.1.link = br0
lxc.net.1.flags = up

Damit das funktioniert, brauche ich eine Schnittstelle br0 auf dem Host.

Bridge

Es braucht ein Network-Device in systemd-networkd:

# /etc/systemd/network/br0.netdev
[NetDev]
# Der Name kann beliebig gewählt werden.
Name=br0
Kind=bridge

Und für dieses Device auch eine passende Network-Definition:

# /etc/systemd/network/br0.network
[Match]
# Der Name aus dem NetDev oben muss hier gematcht werden.
Name=br0

[Network]
IPMasquerade=no
LLDP=yes
EmitLLDP=customer-bridge

Nach einem networkctl reload ist die Bridge br0 sofort da. Wird der LXC gestartet, lxc-start ipv6-client, dann wird die zweite Schnittstelle eth1 an br0 gehängt.

Wie kriegt eth1 jetzt aber eine IPv6?

Irrwege

Weiß man, wie es richtig geht, könnte der Beitrag in den nächsten Absätzen enden. IPv6 ist aber für mich und uns im Team immer noch ein neues Thema, Neuland sozusagen. Der Leidensdruck, von IPv4 wegzukommen, war bislang nicht sonderlich groß. IPv6 ist ja letzten Monat auch erst 30 Jahre alt geworden (wir haben Teammitglieder, die jünger sind 😀).

IPv6 erfordert Umdenken. Manche Konzepte sind gänzlich neu. Auch nach dieser Erfahrung ist mir das Zusammenspiel zwischen RA, DHCPv6 und SLAAC noch nicht abschließend klar.

Meine Vorstellung war: Auf dem Host ist ein Prefix auf der Uplink-Schnittstelle eth0 konfiguriert. Ich sage der Schnittstelle: »Hey, annonciere doch mal, dass wir dieses Prefix haben, und zwar auf folgenden Schnittstellen: …«

Okay, so geht das nicht, aber ich kann doch br0 sagen: »Hey, frag doch bei eth0 an, welchen Prefix wir haben!«

Zumindest mein erster Versuch dahingehend war nicht erfolgreich, vielleicht geht das auch nicht. Ich habe die Konfiguration von eth0 dann weitgehend auf br0 übertragen und eth0 dann als Mitglied bei br0 eingehängt. Das hat … Wirkung gezeigt. Nur leider nichts Robustes. Immerhin konnte die eth1 im LXC dann eine IPv6 beziehen, aber der Verkehr war merkwürdig gestört: Beim Ping kam das erste Paket durch, dann 12 Pakete verloren, 1 Antwort, 12 Pakete verloren, und irgendwie hat sich das dann nach einer halben bis zwei Minuten stabilisiert.

Das war unbefriedigend. Manche Sachen wollte sich auch überhaupt nicht pingen lassen.

Und das war alles, nachdem ich bereits anderthalb Tage darauf verwendet hatte, IPv6-Prefix-Delegation zu konfigurieren. Ich will nicht ausschließen, dass es mit einem /64-Prefix nicht doch irgendwie gehen könnte, aber es ist nicht vom Protokoll vorgesehen, Prefixe größer als /64 zu delegieren. Ich denke auch, dass gewisse Unterschiede im Begriff "Delegation" zwischen deutsch und englisch hier hineinspielen. Tatsächlich muss man das so verstehen, dass die komplette Verantwortung für einen Prefix von einer Schnittstelle auf eine andere abgetreten wird. Es ist nicht so zu verstehen, dass nur die Kenntnis von einem Prefix einer anderen Schnittstelle mitgeteilt wird.

Die Prefix-Delegation dient wirklich nur dazu einen Prefix kleiner als /64 in größere Prefixe aufzuteilen, also z.B. ein /48 in mehrere /56 oder ein /56 in mehrere /64. Inwiefern die Länge immer ein Vielfaches von 8 (oder 4?) sein muss, kann ich nicht sagen. Ich glaube im Web auch Beiträge gesehen zu haben, wie ein /60 in mehrere /64 aufgeteilt wurde.

Hetzner, wie oben erwähnt, gewährt einem kostenlos nur /64er-Prefixe. Für meine Tests wollte ich jetzt nicht einen kleineren Prefix buchen. Für viele, die es irgendwann hierher verschlägt, dürfte /64 der Normalfall sein. Ich hoffe, ich kann jemanden hiermit die Zeit ersparen, es mit Prefix-Delegation zu probieren. Und ich hoffe, dass ich eines Tages doch noch praktische Verwendung für Prefix-Delegation finden kann.

Die Lösung, …

… erster Teil

Auf dem Host werden eth0 und br0 erstmal als separate Schnittstellen konfiguriert. br0 erhält zwei neue Abschnitte, [IPv6Prefix] und [IPv6SendRA]:

# /etc/systemd/network/br0.network
…
[IPv6Prefix]
Prefix=2001:db8:900d:1dea::/64
Assign=true
# ::1 ist eth0
Token=static:::2/64

[IPv6SendRA]
UplinkInterface=eth0

Und im Abschnitt [Network] werden auch noch ein paar Zeilen ergänzt:

# /etc/systemd/network/br0.network
[Network]
…
IPv6AcceptRA=true
IPv6SendRA=true
IPv6Forwarding=true

Die Zeile IPv6SendRA=true bewirkt, dass Router Advertisements auf der Schnittstelle erzeugt werden, mutmaßlich mit den Angaben aus dem Abschnitt [IPv6Prefix]. Die Zeile IPv6AcceptRA=true bewirkt, dass die Schnittstelle die Router Advertisements auf der Schnittstelle akzeptiert

Die drei Doppelpunkte nach static sind wichtig. Der erste Doppelpunkt trennt static von der IPv6-Adresse, die anderen beiden Doppelpunkte sind Teil der IPv6-Adresse. Sieht komisch aus, ist aber so.

Mit den Änderungen haben auf dem Host eth0 und br0 Adressen mit dem Prefix und der LXC ipv6-client hat sich ebenfalls eine Adresse mit dem Prefix konfiguriert.

Ich kann den Host anpingen, sowohl mit der IPv6-Adresse von eth0 wie auch der von br0. Das ganze Prefix wird auf diesen Host geroutet, wie man es auch erwarten mag. Aber die IPv6-Adresse vom LXC ist nicht erreichbar.

… zweiter Teil

Zwischendurch war mir zwar schon aufgefallen, dass ich auf dem Host zwei Routen habe für das /64-Netz, aber ich habe mir nichts weiter dabei gedacht. Mein Kollege kam dann auf die Idee, die Adresse auf eth0 doch einfach mit /128 zu konfigurieren. Ich griff den Vorschlag sofort auf:

# /etc/systemd/network/eth0.network
…
[Network]
Address=2001:db8:900d:1dea::1/128

Und tatsächlich funktioniert das so. Auf dem Host wird nun eine Route zur /128er-Adresse gesetzt, für die eth0 zuständig ist, ansonsten ist br0 für das /64 zuständig. Die Adressen sind alle anpingbar, der LXC ist exponiert, auch ssh auf den LXC funktioniert, wie ich es erwarte.

Ist das eine saubere Lösung? Ich habe keine Ahnung.

Zusammenfassung

Das Network-Device für br0 hat sich nicht geändert. Die Network-Konfiguration sieht ungefähr so aus:

# /etc/systemd/network/br0.network
[Match]
Name=br0

[Network]
Description=bridged uplink interface
LinkLocalAddressing=ipv6

# See section [IPv6Prefix]
IPv6SendRA=true
IPv6AcceptRA=true

IPMasquerade=no
LLDP=yes
EmitLLDP=customer-bridge
IPv6Forwarding=true

# See section [Network]
[IPv6Prefix]
Prefix=2001:db8:900d:1dea::/64
Assign=true
Token=static:::2

[IPv6SendRA]
UplinkInterface=eth0

Für eth0 auf dem Host gibt es das hier:

# /etc/systemd/network/uplink.network
[Match]
Name=eth0

[Network]
Description=physical uplink interface
LinkLocalAddressing=ipv6

Address=2001:db8:900d:1dea::1/128
Gateway=fe80::1
DNS=2a01:4ff:ff00::add:2
DNS=2a01:4ff:ff00::add:1

LLDP=yes
EmitLLDP=customer-bridge
IPv6Forwarding=true

Unerwähnt blieb bislang die Netzwerkkonfiguration von eth1 im LXC, aber sie ist eigentlich nicht besonders:

# /etc/systemd/network/eth1.network
[Match]
Name=eth1

[Network]
DHCP=ipv6
IPv6AcceptRA=true

[DHCP]
ClientIdentifier=mac

[DHCPv6]
UseDelegatedPrefix=true

Ich nehme an, da kann noch einiges rausgeworfen werden.

Mögliche Verbesserungen

Ganz oben auf der Liste steht bei mir, der eth1 des Containers eine MAC-Adresse dauerhaft zuzuordnen. Während des Gebastels ist mir mehrmals aufgefallen, dass sich die IPv6-Adresse von Iteration zu Iteration verändert hat, was sicher daran lag, dass ich zwischendurch immer auch Änderungen an der Konfiguration des LXC vorgenommen hatte. Das gab eine neue MAC-Adresse und basierend darauf dann eine neue IPv6-Adresse.

Wobei ich vorher untersuchen sollte, ob das auch jedes Mal bei einem Neustart des LXC schon passiert. Das wäre nur ein weiterer Grund, den passenden Konfigurationsparameter lxc.net.1.hwaddr in der config des LXC zu setzen. Glücklicherweise kann man in der /etc/lxc/default.conf einen Parameter setzen, der beim Erstellen von LXC auch zufällige MAC-Adressen generiert und eine solche in die config schreibt.

Firewall-Regeln! Jetzt, wo ein LXC exponiert ist, sollte er auch entsprechend geschützt werden. In unserem Fall dürften alle Ports außer 80 und 443 abgelehnt werden. Was bin ich froh, dass sowohl LXC wie auch systemd-nspawn dafür mittlerweile auf nftables setzen

Offene Fragen

Was passiert, wenn ein Container jetzt die ::1 anspricht? Es gibt ja die die /64-Route. Wenn erste Hinweise stimmen, dann interessiert sich das System nicht wirklich dafür, über welche Schnittstelle ein Paket für eine Adresse hereinkommt, die ihm zugeordnet ist – br0 war ja auch von außen anpingbar, obwohl eth0 und br0 nicht verbunden waren.

Hat sich der LXC jetzt per DHCPv6 konfiguriert oder per SLAAC? Ich könnte vielleicht schauen, ob die IPv6-Adresse nach EUI-64 gebildet wurde. Momentan ist mir noch ein wenig rätselhaft, wann DHCPv6 zum Einsatz kommt, zumal ich keine expliziten Konfigurationsoptionen zum Starten eines DHCPv6-Servers identifizieren konnte.

Brauche ich DHCPv6 überhaupt? Prefix und Routen werden ja offenbar per Router Advertisement vermittelt. Wie würde ich per DHCPv6 überhaupt weitergehenden Einfluss auf die Konfigurationen eines DHCPv6-Clients ausüben?

Manche der Fragen werde ich bestimmt nach meinem Urlaub angehen. Alles in allem war das eine sehr lehrreiche Erfahrung und wieder mal eine Erinnerung, warum ich so gerne mit meinem Kollegen zusammenarbeite.

Comments

With an account on the Fediverse or Mastodon, you can respond to this post. Since Mastodon is decentralized, you can use your existing account hosted by another Mastodon server or compatible platform if you don't have an account on this one. Known non-private replies are displayed below.