Blog inżynieryjny – Prexy: superszybkie reguły wyszukiwania i zamiany

Witaj na naszym pierwszym blogu inżynieryjnym. Być może jest on nieco bardziej techniczny niż inne blog, do których jesteś przyzwyczajony, ale staraliśmy się, żeby był zrozumiały dla każdego. W tym artykule porozmawiamy o Prexy, nowym module oprogramowania w Clonable , który służy do stosowania reguł substytucji.

Kontekst

Jeśli korzystasz z Clonable , być może znasz już funkcję reguł zamiany. Te reguły wyszukiwania i zamiany pozwalają zastąpić fragmenty tekstu lub kodu wybranymi przez Ciebie wariantami. Możesz to wykorzystać na przykład do zamiany klucza API lub identyfikatora analitycznego, dzięki czemu będziesz mógł przeprowadzać różne analizy dla swojej strony źródłowej i przetłumaczonych klonów. Wewnętrznie te same reguły są też używane do zamiany Twojej oryginalnej nazwy domeny na nazwę domeny klonu oraz do kilku innych celów.

Od pierwszej wersji Clonable ta funkcja nigdy nie była modyfikowana, co w sumie nie jest dziwne, bo świetnie spełniała swoje zadanie. Mimo to dało się wprowadzić pewne ulepszenia, zarówno pod względem wydajności, jak i łatwości obsługi. Od czasu do czasu bierzemy się za jakąś część naszego produktu, której od dawna nie poświęcaliśmy uwagi, żeby ją ulepszyć. Na przykład w zeszłym roku zrewidowaliśmy całą naszą infrastrukturę danych, żeby była szybsza, bardziej skalowalna i odporna na błędy, a w tym celu przebudowaliśmy od podstaw funkcję tłumaczenia adresów URL. W tym kwartale przyszła kolej na reguły zastępowania.

Poprzednia sytuacja i ograniczenia

W Clonable reguły zastępowania są stosowane dopiero na samym końcu, tuż przed wysłaniem odpowiedzi do klienta. Dlatego w pierwszej wersji Clonable zdecydowaliśmy się zaimplementować to w serwerze WWW NGINX za pomocą modułu replace-filter-nginx, stworzonego przez OpenResty. Moduł ten wykorzystuje sregex jako silnik wyrażeń regularnych typu streamingowego, również stworzony przez OpenResty. Aspekt strumieniowania jest w tym przypadku ważny, bo nie chcesz ładować każdej odpowiedzi w całości do pamięci. Gdybyś to zrobił, kilka dużych odpowiedzi mogłoby spowodować awarię lub zawieszenie się NGINX z powodu braku wystarczającej ilości pamięci. Drugą ważną cechą sregex jest to, że potrafi przetwarzać wiele reguł równolegle. Każdy klon ma bowiem już kilka standardowych reguł, a dodatkowo czasem kilka reguł dodanych specjalnie dla tego klonu. Gdyby nie dało się ich przetwarzać jednocześnie, i tak musielibyśmy buforować całą odpowiedź, bo po pierwszej regule musielibyśmy jeszcze raz przejść przez nią, żeby sprawdzić drugą regułę.

W zeszłym roku coraz częściej jednak napotykaliśmy dziwne problemy z wydajnością. Problemy te były często krótkotrwałe, ale w skrajnych przypadkach mogły wydłużyć czas odpowiedzi strony o kilka sekund. Po jednym z takich skoków strona często ładowała się znowu normalnie, co utrudniało odtworzenie problemu. Żeby to dokładniej zbadać, dodaliśmy nagłówek Clonable-Timings do wszystkich odpowiedzi, co pozwoliło nam z grubsza ustalić, który etap procesu tłumaczenia zajmował tyle czasu. Okazało się, że w prawie wszystkich przypadkach serwer źródłowy reagował szybko, a samo tłumaczenie przebiegało dość sprawnie. Była jednak przerwa między zakończeniem tłumaczenia a całkowitym zakończeniem żądania, co dało nam wskazówkę, że może to mieć związek z regułami substytucji.

Model współbieżności NGINX

Nie dało nam to jednak odpowiedzi na pytanie, dlaczego te opóźnienia pojawiały się tylko sporadycznie i dlaczego działo się to, gdy serwery nie były obciążone nawet w połowie swojej pojemności. Żeby lepiej zrozumieć to zjawisko, musimy przyjrzeć się nieco dokładniej temu, jak NGINX radzi sobie z obciążeniami.

NGINX ma architekturę opartą na procesach roboczych, z jednym procesem głównym i wieloma procesami roboczymi. Połączenia są rozdzielane między procesy robocze, co pozwala na jednoczesne przetwarzanie wielu żądań. Ta konfiguracja ma jednak też wadę: gdy jeden proces roboczy jest zajęty przetwarzaniem żądania, pozostałe żądania przypisane do tego samego procesu müssen czekać. Może to sprawić, że jedno obciążające żądanie spowoduje opóźnienia w obsłudze wielu innych żądań, nawet jeśli pozostałe procesy robocze nie mają nic do roboty. Efekt ten dobrze widać na poniższym obrazku. Podczas tworzenia tego obrazka trwa test obciążeniowy na 10 różnych połączeniach. Na obrazku wyraźnie widać, że 3 procesy robocze są mocno obciążone, a czwarty prawie nic nie robi.

Czas na Prexy

Kiedy zidentyfikowaliśmy wąskie gardło, stworzyliśmy plan, jak je usunąć. Projekt nazwaliśmy Prexy – to połączenie słów RegEx i Proxy. Mieliśmy trzy główne wymagania co do efektu końcowego:

  1. Ma to być bezpośredni zamiennik Twojej obecnej konfiguracji. Reguły zastępowania musisz stosować w ten sam sposób, żeby nic nie przestało działać w istniejących konfiguracjach.

  2. Reguły zastępowania musisz stosować w trybie strumieniowym, aby rozwiązanie nie zużywało zbyt dużo pamięci.

  3. Nowe rozwiązanie musi działać szybciej niż obecne.

Najpierw musieliśmy poszukać wydajnego, wielowątkowego środowiska uruchomieniowego, które potrafi efektywnie przetwarzać żądania. Po rozważeniu kilku opcji wybór padł na środowisko Tokio dla języka programowania Rust. Rust jest znany z tego, że pozwala pisać szybkie programy bez ryzyka związanego z bezpieczeństwem, jakie występuje w innych językach niskopoziomowych, takich jak C++. Środowisko Tokio to elastyczne, asynchroniczne środowisko uruchomieniowe stworzone z myślą o aplikacjach sieciowych. Jedną z najważniejszych dla nas funkcji jest to, że obsługuje ono „work-stealing”. Oznacza to, że w przeciwieństwie do NGINX, żądanie nie jest przypisane do jednego pracownika, ale pracownik, który nie ma nic do roboty, może „ukraść” pracę innemu pracownikowi. Dzięki temu możemy lepiej wykorzystać zasoby naszych serwerów.

Pierwszy prototyp

Po wybraniu technologii bazowych postanowiliśmy stworzyć pierwszy prototyp, żeby oszacować, jaki przyrost szybkości da nam ten projekt i czy w ogóle warto się tym zajmować. Jako pierwsze wdrożenie silnika wyrażeń regularnych (czyli części, która faktycznie przetwarza i stosuje reguły) użyliśmy standardowej biblioteki wyrażeń regularnych (czyli „crate”, jak się to nazywa w terminologii Rust). Ten silnik, podobnie jak sregex, działa bez cofania się (non-backtracking), co oznacza mniejsze ryzyko wystąpienia problemu ReDoS. W przypadku ReDoS wyrażenie regularne otrzymuje dane wejściowe takie, że czas potrzebny na ich ocenę rośnie wykładniczo. Może to mieć katastrofalny wpływ na wydajność i spowodowało w Cloudflare poważną awarię. Dlatego dla nas ważne jest, żeby silnik, którego używamy, był typu non-backtracking.

Za pomocą tego silnika wyrażeń regularnych zbudowaliśmy pierwszy prototyp. Prototyp ten korzystał z na stałe zakodowanych reguł, a silnik wyrażeń regularnych buforował całą odpowiedź zamiast ją przesyłać strumieniowo, ale to wystarczyło do pierwszego testu wydajności. 

Za pomocą tego silnika wyrażeń regularnych zbudowaliśmy pierwszy prototyp. Prototyp ten korzystał z zakodowanych na stałe reguł, a silnik buforował całą odpowiedź zamiast ją przesyłać strumieniowo, ale to wystarczyło do pierwszego testu wydajności. Nasza konfiguracja testowa składała się z dwóch maszyn wirtualnych. Na jednej z nich działało środowisko, w którym można było uruchomić zarówno stare, jak i nowe rozwiązanie. Dzięki temu mogliśmy łatwo porównać obie metody. Na drugim serwerze działał NGINX, który pełnił rolę serwera źródłowego i udostępniał plik testowy. Na tym samym serwerze działało też narzędzie wrk, za pomocą którego generowaliśmy obciążenie. Nie jest to całkowicie optymalne rozwiązanie, bo żeby uzyskać czyste dane, najlepiej byłoby uruchomić generator obciążenia na osobnej maszynie wirtualnej, ale ta maszyna miała wystarczająco dużo zasobów, żeby NGINX i wrk nie przeszkadzały sobie nawzajem.

Pierwsze wyniki testów były jednoznaczne: Prexy radził sobie z jednym żądaniem około 22 razy szybciej (zobacz ilustrację poniżej). To dało projektowi ostateczne zielone światło, bo przy tak dużej różnicy było wystarczająco dużo marginesu, by znieść ewentualne spadki wydajności, które mogłyby pojawić się podczas dopracowywania funkcjonalności.

Po pierwszym teście wprowadziliśmy kilka oczywistych optymalizacji, takich jak buforowanie skompilowanych wyrażeń regularnych. Dodatkowo wdrożyliśmy bardziej wydajny sposób ponownego wykorzystywania połączeń z serwerem nadrzędnym. Dzięki temu osiągnęliśmy wzrost prędkości o około 20%. Następnie przetestowaliśmy oba rozwiązania pod obciążeniem szczytowym, wysyłając jak najwięcej żądań do serwera. Również w tym przypadku różnica była wyraźnie widoczna, a Prexy osiągnął przepustowość około 25 razy większą niż stare rozwiązanie.

Silnik wyrażeń regularnych do streamingu

Jednym z wymagań tego projektu było to, żeby silnik wyrażeń regularnych działał w trybie strumieniowym. Silnik wyrażeń regularnych z prototypu tego nie robił, więc w tej kwestii trzeba było włożyć trochę dodatkowej pracy. Okazało się jednak, że ten silnik jest bardzo zoptymalizowany, więc postanowiliśmy wykorzystać tę implementację jako podstawę dla naszego silnika strumieniowego. Kiedy przepuszczasz dane przez silnik wyrażeń regularnych, trzeba pamiętać o kilku ważnych rzeczach. Po pierwsze, nie wiesz, jakie dane jeszcze nadejdą. Musisz więc pracować z częściowymi dopasowaniami: dopasowaniami, które nie są jeszcze kompletne, ale mają już dopasowany co najmniej jeden znak. Po znalezieniu pełnego dopasowania trzeba następnie sprawdzić, czy nie ma nakładających się częściowych dopasowań, bo one też mogą ostatecznie zostać uznane za dopasowania (a w przypadku nakładania się dłuższe dopasowanie ma pierwszeństwo przed krótszym). Możesz więc zacząć przetwarzać dopasowanie dopiero wtedy, gdy żadne z aktualnych dopasowań częściowych nie rozwinie się w dopasowanie pełne.

Dalsze optymalizacje

Przebudowa silnika wyrażeń regularnych, zgodnie z oczekiwaniami, miała negatywny wpływ na wydajność Prexy. Jednak w porównaniu ze starym rozwiązaniem wciąż mieliśmy spory margines, a dzięki wprowadzeniu dalszych optymalizacji ostatecznie osiągnęliśmy nawet lepsze wyniki niż przed przebudową silnika wyrażeń regularnych. Jedną z zastosowanych przez nas optymalizacji było zapamiętywanie, czy dana reguła ma być zastosowana do konkretnego pliku. Pliki statyczne, takie jak CSS i JS, prawie nigdy się nie zmieniają, więc nie ma sensu za każdym razem uruchamiać reguły, skoro nigdy nie pasuje ona do tego konkretnego pliku. Alternatywą było buforowanie wyników zamian, ale wadą tej strategii jest to, że zapisanie wszystkich plików zajmuje dużo pamięci. Zapamiętując, które reguły mają być zastosowane, potrzebujemy tylko kilku bajtów na plik, zapisując te informacje jako mapę bitową.

Dodatkowo optymalnie wykorzystujemy możliwości silnika wyrażeń regularnych, na przykład nie śledząc grup przechwytujących, gdy nie są one używane w procesie zamiany. Dzięki temu silnik wyrażeń regularnych musi zapamiętać tylko początek i koniec całego dopasowania, co oszczędza pracy. Wyłączyliśmy też nazwane grupy przechwytujące, bo w wersji strumieniowej było to dość skomplikowane, a skoro stare rozwiązanie tego nie obsługiwało, nie było to konieczne. Wszystkie te optymalizacje ostatecznie zapewniły następującą wydajność:

Efekt końcowy

Po dokładnym przetestowaniu Prexy, aby upewnić się, że działa tak samo jak stare rozwiązanie, rozpoczęliśmy stopniowe wdrażanie. W ciągu około tygodnia aktywowaliśmy Prexy dla każdego klonu. W naszym systemie monitorowania wyraźnie widać było moment aktywacji. Zwykle obserwowaliśmy skrócenie czasu odpowiedzi o około 50%. U innych klientów, którzy mieli niewiele reguł substytucji, różnice wynosiły około 10%. Nasza własna strona internetowa stała się o około 30% szybsza (~50 ms).

Jeśli chodzi o naszą ogólną infrastrukturę, po wdrożeniu Prexy zauważamy następujące różnice.

  • O 33% mniejsze zużycie pamięci RAM

  • O 20% mniejsze zużycie procesora

  • O 10–50 % szybsze odpowiedzi

Podsumowując, Prexy to udany projekt. Dzięki zastąpieniu przestarzałego modułu NGINX korzystamy teraz z nowszych i wydajniejszych technik, które przynoszą mierzalną różnicę dla wszystkich naszych klientów. Również w przyszłości będziemy nadal pracować nad ulepszaniem Clonable, zarówno pod kątem nowych funkcji, jak i udoskonalania już istniejących.


Dzięki za przeczytanie tego bloga inżynieryjnego. Daj nam znać, jeśli chcesz częściej dostawać takie techniczne wglądy w nasz produkt. Czy ten projekt naprawdę Cię zainspirował? Zajrzyj wtedy na naszą stronę z ofertami pracy :)

Zacznij już dziś za darmo z Clonable

Dowiedz się, jak centralnie zarządzać międzynarodowymi stronami internetowymi z jednej platformy.

Używamy plików cookie, żeby poprawić Twoje wrażenia z korzystania ze strony, analizować ruch na stronie i ulepszać nasze usługi.

Zapoznaj się z naszą polityką plików cookie