Блог за инженерство – Prexy: свръхбързи правила за търсене и замяна

Добре дошли в първия ни инженерен блог. Вероятно е малко по-технически, отколкото сте свикнали от другите блогове, но сме се постарали да го направим разбираем за всички. В тази статия ще говорим за Prexy – нов софтуер в Clonable , който се използва за прилагане на правилата за заместване.

Контекст

Като потребител на Clonable вероятно вече сте запознати с функционалността на правилата за заместване. Можете да използвате тези правила за търсене и заместване, за да замените части от текст или код с вариант по ваш избор. Това може да се използва например за заместване на API ключ или аналитичен идентификатор, така че да можете да правите различни анализи за оригиналния си сайт и преведените му клони. Вътрешно тези същите правила се използват и за замяна на оригиналното Ви домейн име с домейн името на Вашия клон, както и за редица други цели.

Още от най-първата версия на Clonable тази функционалност никога не е била променяна, което само по себе си не е странно, тъй като си вършеше работата отлично. Въпреки това имаше възможност за подобрения, както по отношение на производителността, така и по отношение на удобството при ползване. От време на време вземаме Clonable – част от нашия продукт, на която отдавна не сме отделяли време – за да я подобрим. Например миналата година преразгледахме цялата си инфраструктура за данни, за да я направим по-бърза, по-мащабируема и по-устойчива на грешки, като за целта вече преработихме от нулата функционалността за превод на URL адреси. През това тримесечие дойде редът на правилата за заместване.

Предишна ситуация и ограничения

Правилата за заместване в Clonable се прилагат едва в най-последната фаза, точно преди изпращането на отговора към клиента. Затова в първата версия на Clonable беше решено това да се имплементира в уеб сървъра NGINX с помощта на replace-filter-nginx-module – модул, създаден от OpenResty. Модулът използва sregex като стрийминг regex двигател, също създаден от OpenResty. Стриймингът е важен в този случай, тъй като не искаме да зареждаме всеки отговор изцяло в паметта. Ако го направим, няколко големи отговора биха могли да причинят срив или забиване на NGINX поради недостатъчна памет. Втора важна характеристика на sregex е, че той може да обработва няколко правила едновременно. Всъщност всеки клон вече разполага с няколко стандартни правила, а освен това понякога и с няколко правила, добавени специално за него. Ако те не могат да бъдат обработени едновременно, все пак ще се наложи да буферираме целия отговор, тъй като след първото правило трябва да можем да прегледаме отговора още веднъж за второто правило.

През изминалата година обаче все по-често се сблъсквахме със странни проблеми с производителността. Тези проблеми често бяха краткотрайни, но в крайни случаи можеха да удължат времето за отговор на страницата с няколко секунди. След такъв пик страницата често се зареждаше отново нормално, което правеше проблема труден за възпроизвеждане. За да отстраним по-подробно този проблем, добавихме заглавката „Clonable-Timings“ към всички отговори, което ни позволи да определим в общи линии коя стъпка от процеса на превод отнемаше толкова много време. От това стана ясно, че в почти всички случаи източникът реагираше бързо и че преводът също протичаше доста гладко. Имаше обаче закъснение между завършването на превода и пълното приключване на заявката, което ни подсказа, че проблемът може да е свързан с правилата за заместване.

Моделът на паралелност на NGINX

Това обаче все още не ни даде отговор на въпроса защо тези забавяния се появяваха само спорадично и защо това се случваше, докато сървърите дори не бяха натоварени до половината от капацитета си. За да разберем по-добре този феномен, трябва да разгледаме по-задълбочено как NGINX обработва работните натоварвания.

NGINX разполага с работнически архитектура, състояща се от един главен процес и няколко работни процеса. Връзките се разпределят между работниците, за да могат да се обработват няколко заявки едновременно. Тази конфигурация обаче има и недостатък: когато един работник е зает с обработката на заявка, останалите заявки, които също са разпределени към същия работник, трябва да изчакат. Това може да доведе до ситуация, при която една тежка заявка да причини забавяне на няколко други заявки, дори ако останалите работници нямат работа. Този ефект е добре видимият на изображението по-долу. По време на създаването на това изображение се провежда стрес-тест върху 10 различни връзки. На изображението ясно се вижда, че три работни процеса са заети, а четвъртият почти не прави нищо.

Време е за Prexy

След като препятствието стана ясно видимо, изготвихме план за подобряването му. Този проект получи името Prexy – съчетание от RegEx и Proxy. Към крайния резултат имаше 3 основни изисквания:

  1. Трябва да бъде пряк заместител на настоящата конфигурация. Правилата за заместване трябва да се прилагат по същия начин, за да се избегнат проблеми в съществуващите конфигурации.

  2. Правилата за заместване трябва да се прилагат поточно, за да се избегне прекомерното използване на памет от решението.

  3. Новото решение трябва да бъде по-бързо от настоящото.

Първо трябваше да потърсим мощна многонишкова среда за изпълнение, която да може ефективно да обработва заявките. След като обмислихме няколко варианта, изборът ни падна върху средата за изпълнение Tokio за езика за програмиране Rust. Rust е известен с това, че с него могат да се пишат бързи програми, без рисковете за безопасността, характерни за други нисконивови езици като C++. Runtime-ът Tokio е гъвкава асинхронна среда, създадена за мрежови приложения. Една от най-важните за нас функции е фактът, че тя поддържа „work-stealing“. Това означава, че за разлика от NGINX, едно заявка не е обвързана с един работник, а работник, който няма какво да прави, може да „открадне“ работа от друг работник. По този начин ресурсите на нашите сървъри могат да се използват по-ефективно.

Първи прототип

След като избрахме базовите технологии, решихме да създадем първи прототип, за да преценим колко приблизително ще ни донесе този проект като печалба по отношение на скоростта и дали изобщо си струва усилието. Като първа реализация за regex двигателя (частта, която действително обработва и прилага правилата), използвахме стандартната regex библиотека (или crate, както се нарича в терминологията на Rust). Този двигател, подобно на sregex, е без обратно проследяване (non-backtracking), което означава, че рискът от проблем с ReDoS е по-малък. При ReDoS регексът се сблъсква с такъв вход, че времето, необходимо за оценяването му, нараства експоненциално. Това може да има катастрофални последствия за производителността и е причинило сериозна авария в Cloudflare. Затова за нас е важно двигателят, който използваме, да е без обратно проследяване.

С този регекс двигател създадохме първия прототип. Той използваше твърдо кодирани правила, а двигателят буферираше целия отговор, вместо да го предава на поток, но това беше достатъчно за първоначален тест на производителността. 

С този регекс двигател създадохме първия прототип. Прототипът използваше твърдо кодирани правила, а регекс двигателят буферираше целия отговор, вместо да го предава на поток, но това беше достатъчно за първоначален тест на производителността. Нашата тестова конфигурация се състоеше от две виртуални машини (VM). На едната VM се изпълняваше конфигурация, в която можеха да работят както старото, така и новото решение. По този начин лесно можехме да правим сравнения между двата метода. На другия сървър работеше NGINX, който служеше като сървър на произход и предоставяше тестов файл. На същия сървър работеше и инструментът wrk, с който генерирахме натоварването. Това не е напълно оптимално, тъй като за чисти данни е за предпочитане генераторът на натоварване да работи на отделна виртуална машина, но тази виртуална машина разполагаше с достатъчно ресурси, така че NGINX и wrk да не си пречат взаимно.

Първите резултати от тестовете бяха пределно ясни: Prexy беше около 22 пъти по-бърз при обработката на едно заявка (вижте изображението по-долу). Това даде окончателно зелена светлина на проекта, тъй като при такава голяма разлика имаше достатъчно резерв, за да се поемат евентуални спадове в производителността, които биха могли да възникнат при довършването на функционалността.

След първия тест извършихме няколко очевидни оптимизации, като например кеширане на компилирани регулярни изрази. Освен това внедрихме по-ефективен начин за повторно използване на връзките към upstream. По този начин постигнахме увеличение на скоростта с около 20%. След това тествахме и двете решения при пиково натоварване, като изпратихме колкото се може повече заявки към сървъра. И тук разликата беше ясно видима, като Prexy постигна пропускателна способност, която беше около 25 пъти по-висока от тази на старото решение.

Стриминг на regex двигател

Едно от изискванията на този проект беше, че използваната регекс машина трябва да е стрийминг. Регексът на прототипа не е такъв, така че в това отношение беше необходима допълнителна работа. Регексът обаче се оказа много добре оптимизиран, затова решихме да използваме тази реализация като основа за нашата стрийминг машина. Когато стриймвате данни през регекс машина, има няколко важни неща, които трябва да имате предвид. На първо място, не знаете какви данни предстои да дойдат. Затова трябва да работите с частични съвпадения: съвпадения, които все още не са пълни, но вече са съвпаднали с един или повече символа. При намиране на пълно съвпадение трябва да се провери дали няма припокриващи се частични съвпадения, тъй като те в крайна сметка също могат да се считат за съвпадения (а по-дългото съвпадение има предимство пред по-късото в случай на припокриване). Затова започнете да обработвате съвпадение едва след като всички частични съвпадения към този момент се окажат, че не са пълни съвпадения.

Допълнителни оптимизации

Преработването на regex двигателя, както се очакваше, оказа негативно влияние върху производителността на Prexy. Въпреки това все още имаше достатъчно резерв в сравнение със старото решение и чрез добавянето на допълнителни оптимизации в крайна сметка дори постигнахме по-високи резултати, отколкото преди преработването на regex двигателя. Една от оптимизациите, които приложихме, беше запаметяването дали дадено правило трябва да се приложи към определен файл. Статичните ресурси като CSS и JS файлове почти никога не се променят, затова е безсмислено всеки път да се изпълнява правило, което никога не съвпада с конкретния файл. Алтернатива на това беше да кешираме резултата от заместванията, но недостатъкът на тази стратегия е, че съхранението на всички файлове отнема много памет. Като запомняме кои правила трябва да се прилагат, ни трябват само няколко байта на файл, като съхраняваме информацията като битова карта.

Освен това използваме оптимално оптимизациите в регекс двигателя, като например не запазваме групите за улавяне, когато те не се използват в заместването. По този начин регекс двигателят трябва да запомни само началото и края на цялото съвпадение, което спестява работа. Също така деактивирахме именованите групи за улавяне, тъй като това беше доста сложно в стрийминг варианта, а тъй като старото решение също не го поддържаше, това не беше абсолютно необходимо. Всички тези оптимизации в крайна сметка доведоха до следните резултати:

Крайният резултат

След като изпробвахме Prexy подробно, за да се уверим, че поведението му наистина е същото като на старото решение, започнахме постепенното му внедряване. В рамките на около една седмица активирахме Prexy за всеки клон. В нашата система за мониторинг моментът на активиране често се забелязваше ясно. Намаление от около 50% във времето за отговор не беше рядкост. Наблюдаваха се и по-малки разлики от около 10% при други клиенти, които имаха малко правила за заместване. Нашият собствен уебсайт стана с около 30% по-бърз (~50 ms).

По отношение на цялостната ни инфраструктура наблюдаваме следните разлики след внедряването на Prexy.

  • 33% по-малко потребление на RAM

  • С 20% по-ниска консумация на процесорен ресурс

  • С 10–50 % по-бързи отговори

Като цяло можем да кажем, че Prexy беше успешен проект. Чрез замяната на остарял NGINX модул вече можем да използваме по-нови и по-ефективни техники, които правят ясно измерима разлика за всички наши клиенти. И в бъдеще ще продължим да работим за подобряването на Clonable, както по отношение на нови функции, така и по отношение на подобряването на съществуващата функционалност.


Благодарим Ви, че прочетохте този инженерен блог. Уведомете ни, ако Ви харесва да получавате по-често подобни технически погледи към нашия продукт. Този проект Ви ентусиазира ли напълно? Тогава разгледайте и страницата ни с обяви за работа :)

Започнете безплатно още днес с Clonable

Открийте как да управлявате международни уебсайтове централизирано от една платформа.

Използваме бисквитки, за да подобрим вашето преживяване, да анализираме трафика на уебсайта и да подобрим нашите услуги.

Прочетете нашата политика за бисквитките