Технический блог — Prexy: сверхбыстрые правила поиска и замены
Добро пожаловать в наш первый технический блог. Возможно, он будет немного более техническим, чем вы привыкли видеть в других блогах, но мы постарались сделать его понятным для всех. В этой статье мы поговорим о Prexy — новом программном модуле в Clonable, который используется для применения правил подстановки.
Предпосылки
Как пользователь Clonable, вы, возможно, уже знакомы с функцией «правил замены». Эти правила поиска и замены можно использовать для замены фрагментов текста или кода на выбранный вами вариант. Например, это можно использовать для замены ключа API или идентификатора аналитики, чтобы вы могли проводить отдельные анализы для вашего исходного сайта и его переведённых клонов. Внутри системы эти же правила используются для замены вашего исходного доменного имени на доменное имя вашего клона, а также для решения ряда других задач.
С момента выпуска самой первой версии Clonable эта функциональность никогда не изменялась, и в этом нет ничего удивительного, ведь она отлично справлялась со своей задачей. Тем не менее, были возможны улучшения как в плане производительности, так и в плане удобства использования. Время от времени мы выбираем в Clonable ту часть нашего продукта, которой уже давно не уделялось внимания, и приступаем к её совершенствованию. Так, в прошлом году мы полностью пересмотрели всю нашу инфраструктуру обработки данных, чтобы сделать её более быстрой, масштабируемой и отказоустойчивой, и в рамках этой работы мы также полностью переработали функцию преобразования URL-адресов с нуля. В этом квартале настала очередь правил замещения.
Прежняя ситуация и ограничения
Правила замены в Clonable применяются только на самом последнем этапе, непосредственно перед отправкой ответа клиенту. Поэтому в первой версии Clonable было решено реализовать это в рамках веб-сервера NGINX с помощью модуля replace-filter-nginx, разработанного OpenResty. Данный модуль использует sregex в качестве потокового движка регулярных выражений, также разработанного OpenResty. Потоковый характер обработки в данном случае важен, поскольку мы не хотим полностью загружать каждый ответ в память. Если бы мы это делали, несколько объёмных ответов могли бы привести к сбою или зависанию NGINX из-за недостатка доступной памяти. Вторая важная особенность sregex заключается в том, что он может обрабатывать несколько правил параллельно. Дело в том, что каждый клон уже содержит ряд стандартных правил, а также, в некоторых случаях, ряд правил, добавленных специально для данного клона. Если бы их нельзя было обрабатывать одновременно, нам всё равно пришлось бы буферизовать весь ответ, поскольку после первого правила нам необходимо было бы повторно пройти по нему для обработки второго правила.
Однако в прошлом году мы всё чаще сталкивались со странными проблемами производительности. Эти проблемы часто носили кратковременный характер, но в крайних случаях могли увеличить время отклика страницы на несколько секунд. После одного из таких пиков страница часто снова загружалась нормально, что затрудняло воспроизведение проблемы. Для дальнейшей отладки мы добавили заголовок Clonable-Timings ко всем ответам, что позволило нам в общих чертах определить, какой этап процесса перевода занимал столько времени. В результате оказалось, что в подавляющем большинстве случаев исходный сервер отвечал быстро, а сам процесс перевода также проходил плавно. Однако между завершением перевода и полным завершением запроса возникала задержка, что навело нас на мысль о том, что проблема может быть связана с правилами подстановки.
Модель параллелизма NGINX
Однако это ещё не дало нам ответа на вопрос, почему эти задержки возникали лишь эпизодически и почему это происходило, хотя нагрузка на серверы не достигала даже половины их пропускной способности. Чтобы лучше понять это явление, нам необходимо более глубоко изучить, как NGINX обрабатывает рабочие нагрузки.
NGINX имеет архитектуру «рабочих процессов» с одним главным процессом и несколькими рабочими процессами. Соединения распределяются между рабочими процессами, что позволяет одновременно обрабатывать несколько запросов. Однако у такой конфигурации есть и недостаток: когда один рабочий процесс занят обработкой запроса, остальные запросы, также назначенные этому же рабочему процессу, вынуждены ждать. Это может привести к тому, что один ресурсоемкий запрос вызовет задержку в обработке нескольких других запросов, даже если остальные рабочие процессы не загружены. Этот эффект хорошо виден на приведённом ниже изображении. Во время создания данного изображения проводился нагрузочный тест по 10 различным соединениям. На изображении ясно видно, что три рабочих процесса активно заняты, а четвёртый практически ничего не делает.

Время для Prexy
Поняв, в чём заключалось узкое место, мы разработали план по его устранению. Этот проект получил название Prexy — слово, образованное из сочетания RegEx и Proxy. К конечному результату предъявлялись три основных требования:
Решение должно быть готовым заменителем существующей конфигурации, при этом правила замены применяются идентично, чтобы исключить сбои в работе существующих настроек.
Правила подстановки должны применяться в режиме потоковой обработки, чтобы решение не потребляло слишком много памяти.
Новое решение должно работать быстрее, чем существующее.
В первую очередь нам необходимо было найти мощную многопоточную среду выполнения, способную эффективно обрабатывать запросы. Рассмотрев несколько вариантов, мы остановили свой выбор на среде выполнения Tokio для языка программирования Rust. Rust известен тем, что позволяет писать быстрые программы без рисков для безопасности, характерных для других низкоуровневых языков, таких как C++. Среда выполнения Tokio — гибкая асинхронная среда, созданная для сетевых приложений. Одной из важнейших для нас функций является поддержка механизма «work-stealing». Это означает, что, в отличие от NGINX, запрос не привязан к одному рабочему процессу, а рабочий процесс, у которого нет загрузки, может «перехватить» работу у другого рабочего процесса. Таким образом, ресурсы наших серверов используются более эффективно.
Первый прототип
После выбора базовых технологий мы создали первый прототип, чтобы оценить, какого прироста скорости он нам даст и стоит ли проект того. В качестве первой реализации для движка регулярных выражений (части, которая обрабатывает и применяет правила) мы использовали стандартную библиотеку регулярных выражений (или crate, как их называют в терминологии Rust). Этот движок, как и sregex, является неретроспективным (non-backtracking), что означает меньший риск возникновения проблемы ReDoS. При ReDoS регулярное выражение сталкивается с такими входными данными, что время, необходимое для их оценки, растёт экспоненциально. Это может иметь катастрофические последствия для производительности и привело к серьёзному сбою в работе Cloudflare. Поэтому для нас важно, чтобы используемый нами движок был без обратного прослеживания.
С помощью этого движка регулярных выражений мы создали первый прототип. В этом прототипе использовались жестко запрограммированные правила, а движок регулярных выражений буферизовал весь ответ вместо его потоковой передачи, однако этого было достаточно для первоначального тестирования производительности.
С помощью этого движка регулярных выражений мы создали первый прототип. В этом прототипе использовались жестко запрограммированные правила, а движок регулярных выражений буферизовал весь ответ вместо его потоковой передачи, однако этого было достаточно для первоначального тестирования производительности. Наша тестовая конфигурация состояла из двух виртуальных машин. На одной из них работала среда, в которой могли запускаться как старое, так и новое решение. Таким образом, мы могли легко сравнивать оба метода. На другом сервере работал NGINX, который выступал в роли исходного сервера и предоставлял тестовый файл. На том же сервере также работал инструмент wrk, с помощью которого мы генерировали нагрузку. Это не совсем оптимальный вариант, поскольку для получения чистых данных предпочтительно запускать генератор нагрузки на отдельной виртуальной машине, однако данная виртуальная машина обладала достаточными ресурсами, чтобы NGINX и wrk не мешали друг другу.
Первые результаты тестирования были совершенно очевидны: Prexy обрабатывал один запрос примерно в 22 раза быстрее (см. изображение ниже). Это дало проекту окончательный «зелёный свет», поскольку при столь значительном преимуществе оставалось достаточно запаса для компенсации возможного снижения производительности, которое могло возникнуть при доработке функционала до полной готовности.

После первого тестирования мы провели ряд очевидных оптимизаций, таких как кэширование скомпилированных регулярных выражений. Кроме того, мы внедрили более эффективный способ повторного использования соединений с вышестоящим сервером. Благодаря этому мы добились прироста скорости примерно на 20 %. Затем мы протестировали оба решения в условиях пиковой нагрузки, направляя на сервер максимально возможное количество запросов. И здесь разница была хорошо заметна: пропускная способность Prexy оказалась примерно в 25 раз выше, чем у старого решения.

Потоковый движок регулярных выражений
Одним из требований данного проекта было то, чтобы используемый движок регулярных выражений поддерживал потоковую обработку. Регулярное выражение, реализованное в прототипе, не соответствовало этому требованию, поэтому в данном аспекте потребовалась дополнительная работа. Однако реализация регулярного выражения оказалась весьма оптимизированной, поэтому мы решили взять её за основу для нашего потокового движка. При потоковой обработке данных с помощью движка регулярных выражений важно учитывать ряд моментов. Во-первых, вы не знаете, какие данные ещё поступят. Поэтому вам приходится работать с частичными совпадениями: совпадениями, которые ещё не завершены, но уже содержат один или несколько совпавших символов. При обнаружении полного совпадения необходимо затем проверить, нет ли перекрывающихся частичных совпадений, поскольку в конечном итоге они также могут считаться совпадениями (а в случае перекрытия более длинное совпадение имеет приоритет над более коротким). Таким образом, вы можете приступить к обработке совпадения только после того, как все частичные совпадения, имеющиеся на данный момент, окажутся неполными.
Дальнейшие оптимизации
Переработка движка регулярных выражений, как и ожидалось, оказала негативное влияние на производительность Prexy. Однако по сравнению со старым решением у нас по-прежнему оставался достаточный запас производительности, и благодаря дополнительным оптимизациям мы в конечном итоге даже превзошли показатели, достигнутые до переработки движка регулярных выражений. Одной из примененных нами оптимизаций стало запоминание того, следует ли применять то или иное правило к конкретному файлу. Статические ресурсы, такие как файлы CSS и JS, практически никогда не изменяются, и в этом случае бессмысленно каждый раз выполнять правило, которое никогда не будет соответствовать данному конкретному файлу. Альтернативой было кэширование результатов замен, однако недостатком этой стратегии является то, что для хранения всех файлов требуется много памяти. Запомнив, какие правила должны применяться, нам требуется всего несколько байтов на файл, поскольку информация сохраняется в виде растрового изображения.
Кроме того, мы максимально эффективно используем возможности оптимизации в механизме регулярных выражений, например, не отслеживая группы захвата, когда они не используются в процессе замены. Благодаря этому механизму регулярных выражений необходимо запоминать лишь начало и конец всего совпадения, что значительно облегчает работу. Мы также отключили именованные группы захвата, поскольку в потоковой версии это было довольно сложно, а поскольку старое решение также не поддерживало эту функцию, в ней не было особой необходимости. Все эти оптимизации в конечном итоге обеспечили следующие показатели производительности:

Итоговый результат
После тщательного тестирования Prexy, чтобы убедиться, что его поведение действительно соответствует поведению старого решения, мы приступили к поэтапному внедрению. В течение примерно недели мы активировали Prexy для каждого клона. В нашей системе мониторинга момент активации часто был хорошо заметен. Сокращение времени отклика примерно на 50 % наблюдалось довольно часто. У других клиентов, у которых было мало правил замещения, также отмечались менее значительные изменения — примерно на 10 %. Наш собственный веб-сайт стал работать примерно на 30 % быстрее (~50 мс).
Что касается нашей общей инфраструктуры, после внедрения Prexy мы наблюдаем следующие изменения.
На 33 % меньшее потребление оперативной памяти
На 20 % меньшее потребление ресурсов процессора
На 10–50 % более быстрые ответы
Таким образом, в целом можно сделать вывод, что Prexy стал успешным проектом. Благодаря замене устаревшего модуля NGINX мы теперь можем использовать более современные и эффективные технологии, которые обеспечивают явно ощутимую разницу для всех наших клиентов. В будущем мы также продолжим работу над совершенствованием Clonable, как в плане добавления новых функций, так и в плане улучшения существующих возможностей.
Благодарим вас за то, что прочитали этот технический блог. Сообщите нам, если вам хотелось бы чаще получать подобные технические обзоры нашего продукта. Этот проект вас вдохновил? Тогда загляните на нашу страницу с вакансиями :)
Начните использовать Clonable бесплатно уже сегодня
Управляйте международными сайтами централизовано с помощью одной платформы.