Engineering Blog - Prexy: superschnelle Regeln zum Suchen und Ersetzen
Willkommen zu unserem ersten Technik-Blog. Er mag etwas technischer sein, als Sie es von den anderen Blogs gewohnt sind, aber wir haben unser Bestes getan, um ihn für jeden verständlich zu machen. In diesem Artikel werden wir über Prexy sprechen, eine neue Software innerhalb von Clonable, die für die Anwendung von Substitutionsregeln verwendet wird.
Hintergrund
Als Benutzer von Clonable sind Sie vielleicht schon mit der Funktionalität der Ersetzungsregeln vertraut. Mit diesen Such- und Ersetzungsregeln können Sie Text- oder Codestücke durch eine selbst gewählte Variante ersetzen. Damit können Sie zum Beispiel einen API-Schlüssel oder eine Analyse-ID ersetzen, um unterschiedliche Analysen für Ihre ursprüngliche Webseite und Ihre übersetzten Klone zu erstellen. Intern werden dieselben Regeln auch für das Ersetzen Ihres ursprünglichen Domainnamens durch den Domainnamen Ihres Klons und eine Reihe anderer Dinge verwendet.
Seit der allerersten Version von Clonable wurde diese Funktionalität nie aktualisiert, was an sich nicht verwunderlich ist, da sie ihre Aufgabe sehr gut erfüllte. Dennoch waren Verbesserungen möglich, sowohl in Bezug auf die Leistung als auch auf die Benutzerfreundlichkeit. Von Zeit zu Zeit nehmen wir Clonable einen Teil unseres Produkts, der lange Zeit nicht benutzt wurde, um ihn zu verbessern. Letztes Jahr haben wir zum Beispiel unsere gesamte Dateninfrastruktur überarbeitet, um sie schneller, skalierbarer und fehlertoleranter zu mache und davor haben wir auch die URL-Übersetzungsfunktion von Grund auf neu entwickelt. In diesem Quartal waren dann die Substitutionsregeln an der Reihe.
Alte Situation und Einschränkungen
Substitutionsregeln werden in Clonable erst in der allerletzten Phase angewendet, kurz bevor die Antwort an den Client zurückgeschickt wird. Die erste Version von Clonable entschied sich daher für eine Implementierung innerhalb des Webservers NGINX unter Verwendung des replace-filter-nginx-module, einem von OpenResty entwickelten Modul. Das Modul verwendet sregex als Streaming-Regex-Engine, die ebenfalls von OpenResty entwickelt wurde. Der Streaming-Aspekt ist in diesem Fall wichtig, weil wir nicht jede Antwort vollständig in den Speicher laden wollen. Wenn wir das täten, könnte eine Reihe von großen Antworten dazu führen, dass NGINX abstürzt oder nicht mehr funktioniert, weil nicht genügend Speicher verfügbar ist. Eine zweite wichtige Eigenschaft von sregex ist, dass es mehrere Zeilen parallel verarbeiten kann. Das liegt daran, dass jeder Klon bereits einige Standardregeln hat und darüber hinaus manchmal einige Regeln, die speziell für den Klon hinzugefügt wurden. Könnten diese nicht parallel verarbeitet werden, müssten wir immer noch die gesamte Antwort zwischenspeichern, weil wir in der Lage sein müssten, sie nach der ersten Regel für die zweite Regel erneut abzugleichen.
Im letzten Jahr sind wir jedoch zunehmend auf seltsame Leistungsprobleme gestoßen. Diese Probleme waren oft nur von kurzer Dauer, konnten aber im Extremfall die Antwortzeit einer Seite um Sekunden verlängern. Nach einer solchen Spitze wurde die Seite oft wieder normal geladen, so dass das Problem schwer zu reproduzieren war. Zur weiteren Fehlersuche fügten wir allen Antworten den Header "Clonable-Timings" hinzu, der es uns ermöglichte, grob zu bestimmen, welcher Schritt des Übersetzungsprozesses so viel Zeit benötigte. Es zeigte sich, dass der Upstream in fast allen Fällen schnell reagiert und auch die Übersetzung verlief relativ reibungslos. Allerdings gab es eine Lücke zwischen der Fertigstellung der Übersetzung und der vollständigen Fertigstellung der Anfrage, was uns einen Hinweis darauf gab, dass es mit den Ersetzungsregeln zu tun haben könnte.
Das Gleichzeitigkeitsmodell von NGINX
Damit hatten wir aber noch keine Antwort auf die Frage, warum diese Verzögerungen nur sporadisch auftraten und warum dies geschah, wenn die Server nicht einmal zur Hälfte ausgelastet waren. Um dieses Phänomen besser zu verstehen, müssen wir uns etwas genauer ansehen, wie NGINX mit Arbeitslasten umgeht.
NGINX hat eine Worker-Architektur mit einem Master-Prozess und mehreren Worker-Prozessen. Die Verbindungen werden auf die Worker verteilt, um mehrere Anfragen gleichzeitig zu bearbeiten. Dieser Aufbau hat jedoch auch einen Nachteil: Wenn ein Worker mit der Bearbeitung einer Anfrage beschäftigt ist, müssen die anderen Anfragen, die demselben Worker zugewiesen sind, warten. Dies kann dazu führen, dass eine große Anfrage zu Verzögerungen bei mehreren Anfragen führt, selbst wenn die anderen Arbeiter nichts zu tun haben. Dieser Effekt ist in der folgenden Abbildung gut zu sehen. Während der Erstellung dieses Bildes wird ein Stresstest über 10 verschiedene Verbindungen durchgeführt. Das Bild zeigt deutlich, dass drei Worker beschäftigt sind und ein vierter fast nichts tut.

Zeit für Prexy
Nachdem wir den Engpass klar erkannt hatten, erstellten wir einen Plan, um ihn zu verbessern. Dieses Projekt wurde Prexy genannt, eine Verschmelzung von RegEx und Proxy. Es gab 3 Hauptanforderungen für das Endergebnis:
Es sollte ein Drop-in-Ersatz für das aktuelle Setup sein. Die Ersetzungsregeln sollten auf die gleiche Weise angewandt werden, um zu vermeiden, dass Dinge in bestehenden Setups kaputt gehen.
Ersetzungsregeln müssen gestreamt werden, damit die Lösung nicht zu viel Speicherplatz verbraucht.
Die neue Lösung sollte schneller sein als die aktuelle Lösung.
Zunächst mussten wir nach einer leistungsfähigen Multi-Thread-Laufzeit suchen, die Anfragen effizient verarbeiten kann. Nachdem wir mehrere Optionen in Betracht gezogen hatten, fiel die Wahl auf die Tokio-Laufzeitumgebung für die Programmiersprache Rust. Rust ist dafür bekannt, dass man damit schnelle Programme schreiben kann, ohne die Sicherheitsrisiken anderer Low-Level-Sprachen wie C++. Die Tokio-Laufzeitumgebung ist eine flexible asynchrone Laufzeitumgebung für vernetzte Anwendungen. Eine der wichtigsten Eigenschaften für uns ist die Tatsache, dass sie work-stealing ist. Das bedeutet, dass im Gegensatz zu NGINX eine Anfrage nicht an einen Worker gebunden ist, sondern ein Worker, der nichts zu tun hat, einem anderen Worker Arbeit "stehlen" kann. Auf diese Weise können die Ressourcen unserer Server besser genutzt werden.
Erster Prototyp
Nach der Auswahl der zugrundeliegenden Technologien beschlossen wir, einen ersten Prototyp zu erstellen, um ungefähr abschätzen zu können, wie viel Geschwindigkeitsgewinn uns dieses Projekt bringen würde und ob es sich überhaupt lohnt. Als erste Implementierung für die Regex-Engine (der Teil, der die Regeln tatsächlich verarbeitet und anwendet) haben wir die Standard-Regex-Bibliothek (oder Crate, wie sie in der Rust-Terminologie genannt wird) verwendet. Diese Engine ist, wie sregex, nicht rückverfolgbar, was ein geringeres Risiko eines ReDoS-Problems bedeutet. Bei einem ReDoS wird ein regulärer Ausdruck mit einer solchen Eingabe konfrontiert, dass die Zeit, die er zur Auswertung der Eingabe benötigt, exponentiell ansteigt. Dies kann sich katastrophal auf die Leistung auswirken und führte bei Cloudflare zu einem größeren Ausfall. Für uns ist es daher wichtig, dass die von uns verwendete Engine nicht rückverfolgbar ist.
Mit dieser Regex-Engine haben wir einen ersten Prototyp gebaut. Dieser Prototyp verwendete fest kodierte Regeln und die Regex-Engine pufferte die gesamte Antwort, anstatt sie zu streamen, aber das reichte für einen ersten Leistungstest aus.
Mit dieser Regex-Engine haben wir einen ersten Prototyp gebaut. Dieser Prototyp verwendete fest kodierte Regeln und die Regex-Engine pufferte die gesamte Antwort, anstatt sie zu streamen, aber das reichte für einen ersten Leistungstest aus. Unser Testsetup bestand aus zwei VMs, wobei auf einer VM ein Setup lief, auf dem sowohl die alte als auch die neue Lösung ausgeführt werden konnte. Auf diese Weise konnten wir leicht Vergleiche zwischen den beiden Methoden anstellen. Auf dem anderen Server lief NGINX, das als Ursprungsserver diente und eine Testdatei auslieferte. Auf demselben Server lief auch das Tool wrk, mit dem wir die Last erzeugten. Dies ist nicht ganz optimal, da man für reine Daten den Lastgenerator lieber auf einer separaten VM laufen lässt, aber diese VM hatte genügend Ressourcen, so dass NGINX und wrk sich nicht gegenseitig behinderten.
Die ersten Testergebnisse waren überdeutlich: Prexy war bei der Bearbeitung einer einzigen Anfrage etwa 22 Mal schneller (siehe Bild unten). Das gab dem Projekt endgültig grünes Licht, denn bei einem so großen Unterschied gab es genug Spielraum, um einige Leistungseinbußen aufzufangen, die bei der Vervollständigung der Funktionalität auftreten könnten.

Nach ersten Tests haben wir einige offensichtliche Optimierungen vorgenommen, wie z. B. das Zwischenspeichern von kompilierten regulären Ausdrücken. Außerdem haben wir eine effizientere Methode zur Wiederverwendung von Verbindungen zum Upstream implementiert. Damit erreichten wir einen Geschwindigkeitszuwachs von etwa 20 %. Dann haben wir beide Lösungen auch unter Spitzenlast getestet, indem wir so viele Anfragen wie möglich an den Server geschickt haben. Auch hier war der Unterschied deutlich sichtbar und Prexy erreichte einen etwa 25-fach höheren Durchsatz als die alte Lösung.

Streaming-Regex-Engine
Eine der Anforderungen bei diesem Projekt war, dass die verwendete Regex-Engine streamingfähig sein sollte. Die Regex-Erstellung des Prototyps ist dies nicht, so dass in diesem Bereich zusätzliche Arbeit erforderlich war. Die Regex-Kiste erwies sich jedoch als hochgradig optimiert, so dass wir beschlossen, diese Implementierung als Grundlage für unsere Streaming-Engine zu verwenden. Beim Streaming von Daten durch eine Regex-Engine gibt es einige wichtige Dinge zu beachten. Erstens weiß man nicht, welche Daten noch kommen werden. Daher sollten Sie zunächst mit partiellen Übereinstimmungen arbeiten: Übereinstimmungen, die noch nicht vollständig sind, aber bereits 1 oder mehr Zeichen enthalten. Bei der Suche nach einer vollständigen Übereinstimmung müssen Sie dann prüfen, ob es keine überlappenden Teilübereinstimmungen gibt, da diese auch zu Übereinstimmungen werden können (und eine längere Übereinstimmung gewinnt im Falle einer Überlappung gegenüber einer kürzeren). Sie können also nur dann mit der Verarbeitung einer Übereinstimmung beginnen, wenn sich herausstellt, dass alle aktuellen Teilübereinstimmungen keine vollständige Übereinstimmung sind.
Weitere Optimierungen
Wie erwartet hatte der Umbau der Regex-Engine einen negativen Einfluss auf die Leistung von Prexy. Im Vergleich zur alten Lösung gab es jedoch immer noch viel Spielrau und durch das Hinzufügen weiterer Optimierungen konnten wir die Leistung sogar noch weiter steigern als vor dem Umbau der Regex-Engine. Eine der Optimierungen, die wir vorgenommen haben, bestand darin, sich zu merken, ob eine Regel auf eine bestimmte Datei anzuwenden ist. Statische Assets wie CSS- und JS-Dateien ändern sich fast ni und deshalb ist es sinnlos, eine Regel jedes Mal auszuführen, wenn sie nie auf eine bestimmte Datei passt. Eine Alternative dazu wäre es, das Ergebnis der Ersetzungen zwischenzuspeichern, aber der Nachteil dieser Strategie ist, dass man viel Speicherplatz benötigt, um alle Dateien darin zu speichern. Wenn wir uns merken, welche Regeln anzuwenden sind, benötigen wir nur wenige Bytes pro Datei, da die Informationen als Bitmap gespeichert werden.
Darüber hinaus nutzen wir die Optimierungen innerhalb der Regex-Engine voll aus, indem wir zum Beispiel keine Capturing-Gruppen aufbewahren, wenn sie bei der Ersetzung nicht verwendet werden. Dadurch muss sich die Regex-Engine nur den Anfang und das Ende der gesamten Übereinstimmung merken, was Arbeit spart. Wir haben auch die benannten Erfassungsgruppen deaktiviert, da dies in der Streaming-Variante recht komplex wa und da die alte Lösung dies auch nicht unterstützte, war es nicht unbedingt notwendig. All diese Optimierungen führten letztendlich zu folgender Leistung:

Das Endergebnis
Nachdem wir Prexy ausgiebig getestet hatten, um sicherzustellen, dass es sich tatsächlich genauso verhält wie die alte Lösung, begannen wir, es schrittweise einzuführen. Über einen Zeitraum von etwa einer Woche haben wir Prexy für jeden Klon aktiviert. Bei unserer Überwachung war der Zeitpunkt der Aktivierung oft leicht zu erkennen. Eine Verringerung der Antwortzeiten um etwa 50 % war nicht selten zu beobachten. Geringfügige Unterschiede von etwa 10 % wurden auch bei anderen Kunden festgestellt, die nur sehr wenige Substitutionsregeln hatten. Unsere eigene Webseite wurde um etwa 30% schneller (~50ms).
In unserer gesamten Infrastruktur sehen wir nach der Einführung von Prexy die folgenden Unterschiede.
33% weniger RAM-Verbrauch
20% weniger CPU-Verbrauch
10-50 % schnellere Antworten
Alles in allem können wir also sagen, dass Prexy ein erfolgreiches Projekt war. Durch den Ersatz eines veralteten NGINX-Moduls können wir nun neuere und effizientere Techniken einsetzen, die für alle unsere Kunden einen deutlich messbaren Unterschied machen. In Zukunft werden wir Clonable weiter verbessern, sowohl in Bezug auf neue Features als auch auf die Erweiterung bestehender Funktionen.
Vielen Dank für die Lektüre dieses Technik-Blogs. Lassen Sie es uns wissen, wenn Sie diese Art von technischen Einblicken in unser Produkt öfter erhalten möchten. Sind Sie von diesem Projekt begeistert? Dann werfen Sie einen Blick auf unsere Stellenausschreibungen :)