- Fly.io hat die Vorgehensweise geändert, um die Zustandslast der WireGuard-Gateways zu reduzieren, während die direkte Kommunikation zwischen
flyctlund Fly Machines erhalten bleibt: Peers werden nicht mehr vorab installiert, sondern erst beim Verbindungsaufbau dem Kernel hinzugefügt. - Im bisherigen Ablauf übergab die GraphQL-API die Peer-Konfiguration per NATS-RPC,
wggwdregistrierte sie in SQLite und im Linux-Kernel-WireGuard, danach stellteflyctldie Verbindung her. - Verlorene NATS-Nachrichten und einmalig erzeugte Peers in CI-Jobs führten zusammen dazu, dass sich auf den Gateways Hunderttausende nicht wiederverwendeter Peers ansammelten und Kernel-Operationen sowie das Laden beim Neustart langsam wurden.
- Der neue Ansatz fängt
handshake initiation-Pakete über einen BPF-Filter oder den WebSockets-Empfangspfad ab, entschlüsselt einen Teil des Noise-Handshakes, um den öffentlichen Schlüssel zu identifizieren, und holt dann nur die benötigten Peers über eine interne HTTP-API. - Nach einigen Wochen im Betrieb war die Zahl alter Peers nahezu verschwunden; die Gateways können nun mit weniger Zustand schnellere Peer-Einrichtung und Neustarts bewältigen.
Wie Fly.io WireGuard nutzt
- Fly.io führt Container als Firecracker-basierte VMs aus und nutzt WireGuard an mehreren Stellen wie einen Teil der Kunden-API.
flyctlerstellt beim Start einen TCP/IP-Stack mit eigener IPv6-Adresse und kommuniziert direkt mit Fly Machines im Fly.io-Netzwerk.- Dieser Ansatz macht es einfacher, Funktionen wie Remote-Docker-Builder so darzustellen, als befänden sie sich im selben LAN; sie dauerhaft zuverlässig zu betreiben, ist jedoch schwieriger.
- Fly.io stellte den Standardpfad schließlich auf WireGuard-over-WebSockets um.
Bisheriger Provisioning-Ablauf der Gateways
- Fly.io verbindet eingehende WireGuard-Verbindungen über mehrere Gateway-Server weltweit mit dem passenden privaten Netzwerk.
- Wenn
flyctlfür Container-Builds, SSH-Konsolen, Dateikopien oder Service-Proxys mit einer Fly Machine kommunizieren muss, startet oder verbindet es einen Agent-Prozess im Hintergrund. - Beim ersten Start erzeugt der Agent über die GraphQL-API eine neue WireGuard-Peer-Konfiguration.
- Die Peer-Konfiguration besteht aus einem öffentlichen Schlüssel und der zu verbindenden Adresse.
- Die API übermittelt diese Konfiguration per RPC im NATS-Messaging-System an das passende Gateway.
wggwdauf dem Gateway nimmt die Konfiguration entgegen, speichert sie in SQLite, fügt sie über die WireGuard-Go-Bibliothek dem Kernel hinzu und meldet der API die abgeschlossene Installation.- Wenn die API die Konfiguration in der GraphQL-Anfrage zurückgibt, verbindet sich
flyctlmit dem bereits auf dem Gateway installierten WireGuard-Peer.
Warum die bisherige Architektur langsam wurde
- NATS ist schnell, garantiert aber keine Zustellung, wodurch es als Basis für eine zuverlässige API schwierig zu nutzen war.
- Fly.io reduzierte intern die Nutzung von NATS; so wurde etwa die interne
flyd-API von NATS-basiert auf HTTP-basiert umgestellt. - Die Reduzierung der NATS-Nutzung verbesserte die WireGuard-Gateways, reichte aber nicht aus.
- Fly.io reduzierte intern die Nutzung von NATS; so wurde etwa die interne
- Nach dem Beenden von
flyctlblieben die erzeugten WireGuard-Peers auf dem Gateway erhalten, und es gab keinen Prozess zum Aufräumen alter Peers.- Die Entscheidung, Peers nicht zu entfernen, beruhte auf der Möglichkeit, am nächsten Tag erneut zu deployen oder per
fly ssh consolezu debuggen. - Die meisten Peers entstanden jedoch in CI-Jobs ohne persistenten Storage; beim nächsten Lauf konnten sie sich nicht mit demselben Peer erneut verbinden, sodass jedes Mal ein neuer Peer erzeugt wurde.
- Die Entscheidung, Peers nicht zu entfernen, beruhte auf der Möglichkeit, am nächsten Tag erneut zu deployen oder per
- In der Folge hielten Gateways Hunderttausende Peers vor, die möglicherweise nie wiederverwendet wurden.
- Mit der wachsenden Zahl alter Peers wurden Kernel-WireGuard-Operationen sehr langsam.
- Besonders langsam war es, nach einem Neustart eines Gateway-Servers alle Peers wieder in den Kernel zu laden.
- Es traten auch einige Kernel Panics auf.
Design: Peers nur bei Bedarf im Kernel installieren
- Die gesamte Historie aller WireGuard-Peers in einer einzelnen SQLite-Datenbank zu speichern, ist nicht besonders schwierig; alle Peers im Linux-Kernel vorzuhalten, wird jedoch zum Flaschenhals.
- Statt Konfigurationen an Gateways zu pushen, entschied sich Fly.io dafür, dass Gateways die benötigten Peers on demand aus der API abrufen.
- Wenn Peers erst dann dem Kernel hinzugefügt werden, wenn ein Client eine Verbindung aufbauen will, können alte Peers jederzeit aus dem Kernel entfernt werden.
- Entfernte Peers können beim nächsten Verbindungsversuch wieder abgerufen und installiert werden, sodass Gateways weniger langfristigen Zustand halten müssen.
- Allerdings bietet Linux-Kernel-WireGuard keine API, um ein Ereignis wie „incoming connection attempt“ zu abonnieren.
Umsetzung von JIT-WireGuard-Peers
- Die Konfigurationsschnittstelle von WireGuard im Linux-Kernel ist Netlink, und die Go-Steuerbibliothek für WireGuard verwendet wgctrl-go.
- Fly.io nutzt aus, dass WireGuard-Verbindungsanfragen als erkennbare Pakete vorliegen, und erzeugt mit BPF-Filtern und einem packet socket direkt eigene Events.
- Beim WebSockets-WireGuard-Pfad lassen sich rohe WireGuard-Pakete einfacher erhalten.
- Dieser Pfad tauscht als UDP-Pakete geframte Daten über eine nicht authentifizierte WebSockets-Verbindung mit der Gateway-Schnittstelle aus.
- Da Fly.io den Code dieses Daemons besitzt, kann es einen Hook in die Paketempfangsfunktion einbauen.
- WireGuard kennt keine Konzepte von „Client“ und „Server“, sondern ist ein Point-to-Point-Protokoll, bei dem Peers miteinander verbinden, sobald Traffic gesendet wird.
- Die Seite, die zuerst verbindet, ist der Initiator, die Gegenseite der Responder.
- Bei Fly.io ist normalerweise
flyctlder Initiator und das Gateway der Responder.
- Das erste UDP-Paket ist gemäß dem WireGuard-Paper eine
handshake initiation; der Pakettyp steht in einem Klartext-Byte.- Fly.io erfasst eingehende Verbindungen mit dem BPF-Filter
udp and dst port 51820 and udp[8] = 1.
- Fly.io erfasst eingehende Verbindungen mit dem BPF-Filter
Peers im Noise-Handshake identifizieren
- WireGuard basiert auf dem Noise Protocol Framework, und Noise verbirgt Identifikatoren während des Handshakes für identity hiding.
- Daher kann man nicht einfach einen Wert wie einen Benutzernamen aus dem Paket lesen und damit direkt die Konfiguration finden.
- Fly.io führt einen Teil der Noise-Verschlüsselung aus, um die Identität zu entschlüsseln und eingehende Anfragen zu identifizieren.
- Dieser Code ist anspruchsvoll, umfasst aber nur rund 200 Zeilen.
- Die Kernel-Netlink-Schnittstelle kann einem privilegierten Prozess den privaten Schlüssel der Schnittstelle bereitstellen, sodass die nötigen geheimen Werte verfügbar sind.
- Der zugehörige Code ist als Gist veröffentlicht.
- Nach diesem Schritt erhält man einen Event-Feed der öffentlichen Schlüssel von Nutzern, die versuchen, eine WireGuard-Verbindung zum Gateway aufzubauen.
Optimierungen für Installation, Cache und Retries
- Das Gateway hält in SQLite einen Rate-Limit-Cache vor und ruft bei Entdeckung eines neuen Peers die zugehörigen Peer-Informationen per interner HTTP-API ab, um sie zu installieren.
- Diese Logik passte gut in den kleinen Daemon, der auf dem Gateway bereits WireGuard verwaltete.
- Alte Peers können nun per
cron-Job aktiv entfernt werden. - API-Lookups für neue Peers sind möglicherweise nicht schnell genug, um sofort auf die erste
handshake initiation-Nachricht zu reagieren.- Da WireGuard schnell erneut versucht, ist das für die Funktion an sich kein Problem.
- Mithilfe einer von Jason Donenfeld erläuterten Linux-WireGuard-Netlink-Funktion kann die Verbindung schneller hergestellt werden.
- Aus der eingehenden Initiation-Nachricht wird die 4-Tupel-Adresse einschließlich des temporären Source-Ports von
flyctlgewonnen. - Das Gateway installiert den Peer so, als wäre es selbst der Initiator und
flyctlder Responder. - Der Linux-Kernel startet die WireGuard-Verbindung in Richtung
flyctl; das Protokoll hängt nicht stark von Server- und Client-Rollen ab. - Neue Verbindungen werden nahezu mit der Geschwindigkeit aufgebaut, mit der sie installiert werden können.
- Aus der eingehenden Initiation-Nachricht wird die 4-Tupel-Adresse einschließlich des temporären Source-Ports von
Ergebnisse im Betrieb
- Dieser Ansatz läuft seit einigen Wochen in Produktion.
- Die Zahl alter WireGuard-Peers, die je nach Gateway von Tausenden bis Hunderttausenden reichte, ist auf nahezu null gefallen.
- Gateways müssen weniger Zustand vorhalten.
- Peer-Konfigurationen erfolgen schneller.
- Beim Neustart müssen weniger ungenutzte Peers erneut in den Kernel geladen werden.
1 Kommentare
Hacker-News-Kommentare
Ich verstehe nicht ganz die Aussage, dass WireGuard im Linux-Kernel keine Funktion hat, Peers bei Bedarf zu installieren. Es scheint möglich zu sein, Peers auch zur Laufzeit hinzuzufügen: https://serverfault.com/questions/1101002/wireguard-client-a...
Wenn ich das richtig verstehe, ist dieser Schritt bereits zu spät, und man möchte offenbar authentifizieren, bevor man den Peer hinzufügt, damit keine veralteten Einträge im Interface verbleiben.
Daher sieht die Architektur so aus, dass vor das Interface ein eBPF-Filter gesetzt wird, der auf Basis von Cryptokey-Routing prüft, ob die Gegenstelle autorisiert ist, dann bei Erfolg den Peer dem Interface hinzufügt und ihn nach einem Timeout wieder entfernt.
Die Peers lägen alle etwa in SQLite und würden erst bei Bedarf installiert, wenn ein Client versucht, eine Verbindung aufzubauen.
Aus Sicht eines VPN-Anbieters ist die aktuelle API etwas grob. Zwar ist in der Praxis zu jedem Zeitpunkt nur ein Teil der Peers aktiv, aber wenn die Zahl der Peers von Hunderttausenden auf Zehnmillionen wächst, wird es unmöglich, sie alle in einer einzelnen Kernel-Instanz zu speichern.
Wenn Peers vorab installiert werden müssen, bindet man sie letztlich an eine bestimmte Servermaschine.
Wie der Artikel sagt, kann man schon heute mit einfachem Packet Capture etwas Ähnliches wie das benötigte Interface bauen, und weil Jason die API gut entworfen hat, lässt sich die Initiator-Richtung zwischen Server und Client sehr leicht umdrehen. Selbst wenn der Kernel die erste Initiator-Nachricht verworfen hat, fühlt es sich für den Nutzer wie eine nahtlose Verbindung an.
Jann Horn meinte, man könne noch einen Schritt weitergehen und das erfasste Initiator-Paket aufbewahren und nach der Peer-Installation wieder in den Kernel injizieren; auch das ist eine ziemlich gute Idee.
Ich würde nicht sagen, dass dieser Artikel lebensverändernd ist, eher eine Sammlung sauberer Tricks, die Leute kennen sollten.
Der nächste Schritt wäre, darauf aufbauend floating peers zu bauen und Peers vollständig von Regionen zu entkoppeln. Dann müssten Nutzer sich nicht darum kümmern, in welcher Region ein Peer konfiguriert wurde, und das hätte wohl echte Produktvorteile, über reinen Nerd-Spaß hinaus.
Der Ausdruck JIT WireGuard fühlt sich etwas seltsam an. Mein erster Gedanke war: „Warum? Der Performance-Flaschenhals ist die Verschlüsselung, und ein client-spezifisches JIT hilft dort nicht.“
Ich wäre wohl einfach in den Userspace gegangen. Mit tokio-uring oder glommio ließe sich Performance herausholen.
Wenn man weiter alles im Kernel erzwingt, wird man ständig an Grenzen stoßen, weil Linux nicht dafür gebaut wurde, Millionen aktiver Tunnel zu verarbeiten. Schon Millionen TCP-Verbindungen in einem Kernel können mitunter knifflig sein.
Für jede Grenze braucht man einen Hack, und jeder Hack bringt Systemeinstellungen mit sich, die angewendet und verwaltet werden müssen. Die Toolchain für die Provisionierung physischer Linux-Server liegt weit hinter Werkzeugen für App- und Service-Entwicklung sowie Konfigurationsmanagement zurück.
Oder bin ich einfach dumm und habe etwas falsch verstanden?
Wenn man in einer Go-App einen Userspace-WireGuard-Peer bauen möchte, lohnt sich ein Blick auf mein aktuelles Experimentierprojekt https://github.com/dpeckett/noisysockets
Es basiert auf der hervorragenden Arbeit von wireguard-go, soll aber einfacher als Library nutzbar und idiomatischer für Go sein.
Damit ein Service Mesh zu bauen, wäre interessant. Mehrere Sprachen zu unterstützen wäre schwierig, aber man könnte vielleicht eine Socket-API implementieren.
Allerdings habe ich für WireGuard-Verschlüsselung noch keine Hardwarebeschleunigung gesehen, daher könnte es bei der Performance schwer werden, mit mTLS zu konkurrieren.
Nebenbei: Ich suche derzeit Freelance-Arbeit; wenn jemand einen Golang-Freelancer für schnelles, sicheres Networking braucht, kann er sich gern melden.
So etwas wie Magic Wormhole für beliebige Tunnel; ich hoffe, dass es auch das Problem deutlich verbessert, dass Dateiübertragungen über lange High-Bandwidth-Netzwerke bei 20–30 MB/s einbrechen.
0 - https://github.com/slackhq/nebula
Ich stimme grundsätzlich zu, dass für einzelne Punkt-zu-Punkt-Nachrichten direkte HTTP-Requests zuverlässiger sein können als der Weg über eine Message Queue, aber ich bin etwas überrascht, dass bei NATS so viele Nachrichten verloren gegangen sind, dass es den Dienst stark beeinträchtigt hat.
Wenn Nachrichten verloren gehen, sendet NATS sie dann nicht erneut, bis es erfolgreich ist? Weiß jemand, warum eine spürbare Instabilität aufgetreten sein könnte?
Die Architektur von NATS ist intuitiv und attraktiv, daher würde mich interessieren, wo es schiefgelaufen ist. JetStream hat viele einstellbare Parameter.
Zum Beispiel sind Memory-Streams mit zeitbasiertem Deduplizierungsfenster, Push/Pull-Modi sowie Einstellungen für Redelivery- und Ack-Policies möglich.
Allerdings könnte es nicht gut zu einmaligen Verbindungen mit einzelnen Nachrichten passen. So oder so wären konkretere Details sehr hilfreich.
Am Ende brauchten wir es aber nicht. Die Messaging-Schicht hat uns nicht mehr Ausdruckskraft gegeben, sondern Tests und Monitoring nur schwieriger gemacht.
Der Teil „wir richten den Peer so ein, als wären wir der Initiator, und lassen flyctl der Responder sein. Der Linux-Kernel startet die WireGuard-Verbindung zu flyctl neu“ fügt dem Handshake im Grunde eine halbe Round-Trip-Latenz hinzu?
Ich frage mich zum Beispiel, ob der Ablauf so ist: 1) flyctl sendet eine Initiation, 2) per netlink wird ein Peer hinzugefügt und eine neue Initiation gesendet, 3) flyctl sendet eine Response
Also gibt es Schritt 3 entweder nicht oder man muss nicht darauf warten; und wenn man die neue Initiation aus Schritt 2 verhindert, wäre das wohl definitiv nicht der Fall
1.a) Bob nimmt den Anruf nicht an, fügt aber die Nummer aus der Anrufer-ID seinem Adressbuch hinzu
Ich verstehe nicht, was mit „jedes Mal, wenn man flyctl ausführt, erschafft unsere liebenswerte, riesige CLI aus dem Nichts einen TCP/IP-Stack, hat ihre eigene IPv6-Adresse und spricht direkt mit den in unserem Netzwerk laufenden Fly Machines“ gemeint ist
Die Formulierung „erschafft aus dem Nichts einen TCP/IP-Stack“ kommt daher, dass normalerweise das Betriebssystem den TCP/IP-Stack als Teil des Kernels bereitstellt
Bei wireguard-go läuft der TCP/IP-Stack im Userspace, daher kann man ihn innerhalb eines normalen Userspace-Prozesses wie dem flyctl-Kommandozeileninterface erzeugen
Für Leute, die schon lange mit Systemen arbeiten, kann das ziemlich magisch wirken. Wirklich brauchbare prozessinterne TCP/IP-Stacks im Userspace sind relativ neu und originell
Ich frage mich, was einen daran hindert, das erste Handshake-Paket wieder in den Netzwerk-Stack einzuspeisen. Dann gäbe es anscheinend keinen Paketverlust
Außerdem frage ich mich, wozu im eBPF-Filter
udp[8] = 1geprüft wirdWie im Nachbarkommentar gesagt, fischt der BPF-Filter nur Initiation-Pakete heraus, und genau das ist das gewünschte Verhalten. Es ist die WireGuard-Version davon, bei TCP nach SYN zu sniffen, um den Verbindungsaufbau zu sehen
udp[8] = 1filtert nur Handshake-Pakete. Ohne das würden auch Datenpakete an den Userspace-Daemon geschicktIch bin nicht sicher, ob man den ersten Handshake wieder abspielen kann, aber da WireGuard unbekannte Clients ignoriert, könnte es möglich sein
Interessant, dass WireGuard standardmäßig über WebSocket getunnelt wird. Für die Performance ist das nicht gut, aber für DevOps-artige Aufgaben, bei denen flyctl genutzt wird, dürfte es in Ordnung sein
Daran musste ich auch denken, wenn es um die Zukunft von QUIC/HTTP3 geht. Die Wahrscheinlichkeit ist nicht null, dass Netzwerkbetreiber UDP auf Port 443 eher komplett blockieren, statt ihn korrekt zu behandeln
flyctlgibt es dafür ebenfalls eine KonfigurationsoptionWenn UDP nicht funktioniert, funktioniert es gar nicht, und das Debugging ist schwierig; deshalb haben wir den Default auf das gesetzt, von dem wir wissen, dass es zuverlässig funktioniert
Dass ich die Debatte darüber, welchen Default man wählen sollte, verloren habe, ist bitter
Mein Startup hat Fly fast ein Jahr lang genutzt. Die Kernfunktion, Code in unter einer Minute zu deployed Code zu machen, ist wirklich schön
Neue Nodes für Backfills hoch- und wieder herunterzufahren dauerte ebenfalls nur Sekunden
Aber das Unternehmen selbst wirkte ein wenig unausgereift. Einmal war unser API-Server auf Fly 48 Stunden lang nicht erreichbar, und ich war mir nicht sicher, ob es ein Konfigurationsfehler von mir war oder ein weiterer „stiller“ Ausfall
Es gibt ein „db“-Produkt, aber es heißt dann etwa „kein Managed Postgres“, und auch dort kam es regelmäßig zu Unterbrechungen
Es fühlte sich seltsam an, Postgres in der CLI als Top-Level-Nomen aufzunehmen und dann den Umfang der unterstützten Funktionen einzuschränken
Auch der Zugriff auf die zentrale Service-API fiel häufig aus, sodass man warten musste, bevor man Änderungen an neuen Services deployen konnte
Die Deployment-Erfahrung vermisse ich, aber ehrlich gesagt bin ich inzwischen mit GCPs Cloud Run zufriedener. Es gibt deutlich weniger „Überraschungen“, und die Dokumentation ist wesentlich ausgereifter
Ich finde es erstaunlich, dass VPS-Anbieter kaum Unterstützung dafür bieten, die Latenz von Backend-Services für Nutzer zu verringern. Niemand unterstützt Anycast, und es gibt nur sehr wenige GeoDNS-Optionen
GeoDNS bringt allerdings zusätzliche Komplexität mit sich
Ich wünschte, die Datenübertragungskosten bei Fly.io wären niedriger. Im Moment muss ich in einem ngrok-ähnlichen Dienst, an dem ich arbeite, einen großen Teil der Fly.io-Funktionalität unbeholfen neu implementieren
[0]: https://lastlogin.io
[1]: Der Fly-spezifische Code, der nötig ist, um LastLogin weltweit verteilt auszuführen, ist ungefähr so viel: https://github.com/lastlogin-io/obligator/blob/37f75cc861f1b...
Wenn es gut funktionierte, war es wirklich reibungslos, aber das passierte nicht oft genug
Ich möchte die Gelegenheit nutzen, Netmaker[0] vorzustellen.
Ich habe nichts damit zu tun, sondern nutze es nur zufrieden, weil ich privaten Zugriff auf AWS-VPCs über mehrere Accounts hinweg brauche. Ich würde mich freuen, wenn es breiter genutzt würde.
[0] https://www.netmaker.io/
In meinem früheren Job habe ich mit Ansible wg auf einigen Windows- und Linux-Rechnern eingerichtet und verwaltet; das war zwar okay, wurde am Ende aber etwas unübersichtlich.
Die Website ist zu vage.
Der Teil mit „Gateways mit Hunderttausenden Peers, von denen viele nie wieder verwendet werden“ war genau der Gedanke, der mir beim Lesen der ersten Absätze kam.
Auch die Idee „Es gibt keinen API-Aufruf, um Events für eingehende Verbindungsversuche zu abonnieren. Schon gut. Dann erzeugen wir die Events eben selbst. WireGuard-Verbindungsanfragen sind Pakete und leicht zu identifizieren, sodass man sie mit einem BPF-Filter und Packet Sockets effizient abfangen kann“ ist gut.
Wenn eine eingehende Initiierungsnachricht eintrifft, bekommt man die 4-Tupel-Adresse der gewünschten Verbindung, einschließlich des temporären Source-Ports, den flyctl verwendet; dann wird der Peer so installiert, als wären wir der Initiator und flyctl der Responder. Ich frage mich, ob das auch hinter NAT funktioniert.
{wggwd.fly.io, 12345, clientIP, 23456}.Ob es ein neues „Initiator“-UDP-Paket ist oder eine Antwort auf eine ausgehende Initiierungsnachricht: Für das UDP-NAT auf dem Pfad sieht es exakt gleich aus.
Die Entscheidungsgrundlage ist nur das 4-Tupel, und dieses 4-Tupel ist identisch.