Самый быстрый фреймворк на Диком Западе: ускоряем Django-rest-framework вместе с Rust
На этапе запуска тест-менеджмент системы с открытым исходным кодом TestY в качестве фреймворка инженеры выбрали Django, так как он позволяет в максимально короткие сроки реализовать MVP. Однако развивать такой продукт — добавлять фичи, наращивать число пользователей и объем хранимых данных в системе — бывает сложно.
Разработчики действительно быстро запустили MVP, перевезли данные из TestRail с помощью плагинов, и команды тестирования YADRO уже более года пользуются системой. Но есть одно «но»: пользовательские сценарии разных команд сильно отличаются. Так, добавление в систему более полумиллиона тестов привело к просадке скорости работы определенных эндпоинтов, завязанных на древовидных структурах.
Спойлер: камнем преткновения стали CPU-bound задачи с большим количеством данных. Изучив, как можно ускорить выполнение таких задач в Python, Роман Кабаев протестировал несколько решений и нашел оптимальное. Если вы разрабатываете веб-приложение на Django или Python и так же, как автор, хотите ускорить работу сервиса, читайте эту статью.
- Почему упрощение DFR-сериализаторов и сторонняя Python-библиотека не подошли для решения задачи
- По какой логике работает решение, которое разработала команда Романа
- С какими сложностями столкнулись разработчики в процессе интеграции Rust-модуля в Python
Отправная точка — 10,93 секунды
Изначально система отображения данных в виде древовидной структуры нас более чем устраивала. Но из-за неожиданно большого количества данных в TestY стали видны узкие места. Например, интерфейс для создания плана тестирования, который можно увидеть на скриншоте ниже.
Чтобы построить красивое дерево выбора, все тестовые сьюты для выбора и кейсы внутри них отдаются эндпоинтом сразу. А значит, если у вас 10 000 кейсов в одной из сьют, они подгружаются одновременно. Отсюда и такая огромная просадка по скорости отдачи данных.
Проблему можно исправить с помощью хорошего UX-дизайна и небольшого изменения архитектуры, но так как TestY — не единственный наш проект, на это не было ресурсов. Поэтому, чтобы сделать быстрый фикс без изменений UI, нам нужно было ускорить отдачу древовидных структур именно с серверной части приложения.
Первый проект, на котором мы обнаружили проблему, — один из самых важных у команды тестирования, поэтому для оценки производительности возьмем его:
- 16 419 тестовых кейсов.
- 445 тестовых сьют, разных степеней вложенности.
- Размер payload (полезной нагрузки) с 2 МБ данных.
- Для работы с базой данных используем Django ORM.
- Запускаем это все в Docker-контейнере на MacBook Pro (M2) 16 ГБ ОЗУ.
- Версия Python 3.9.13, операционная система контейнера Alpine.
Изначально у нас есть код с использованием Django и Django-rest-framework (DRF) следующего вида:
Производительность не очень впечатляющая, эндпоинт отдал данные за 10,93 секунды. Попробуем ускорить решение, чтобы добиться более высокой скорости работы.
Поиск узкого места
Чтобы решить какую-либо задачу, сначала нужно ее проанализировать и понять корень проблемы. Так как мы говорим про веб-приложение, первое, с чего я начал, — это посмотрел, как быстро работает запрос в базу данных с помощью django-debug-toolbar. Он съедает часть производительности системы, поэтому мы включаем его только единожды, чтобы посмотреть, как много времени у нас занимают запросы в базу данных. Все запросы в БД занимают 371 миллисекунду.
Упрощаем DRF-сериализаторы
Сериализаторы DRF очень тяжеловесны и содержат много скрытой логики и CPU-bound нагрузки. Попробуем их облегчить:
- избавимся от ModelSerializer в пользу Serializer,
- сделаем все поля read_only.
Получаем сериализаторы следующего вида:
Получаем результат в 9,12 секунды. Такой медленный ответ пользователя не устроит, продолжим поиски.
Используем стороннюю библиотеку для перевода Python-объектов в JSON
Так как конвертирование Python-словаря в JSON — не самая «легкая» cpu-bound операция из-за рекурсивного обхода вложенных Python-объектов и манипуляций со строками, ищем альтернативу.
Вспоминаем про библиотеку Pydantic, которая часто используется для сериализации объектов в связке с FastAPI. Нам не нужен весь функционал Pydantic, интересует только сериализация в JSON, а для этого Pydantic использует Orjson — библиотеку, написанную на Rust как раз для ускорения сериализации в JSON.
Используем Orjson для сериализации в JSON и получаем результат в 8,32 секунды. Пользователь в ярости от такой нерасторопности системы: возможно, уже обновил страницу кулаком и не один раз повысил нагрузку на сервер. Результат — плохо всем.
Как и большинство веб-проектов на Django, для запуска в production-окружении мы используем application server gunicorn. Так как мы не можем обрабатывать один запрос более двух минут, чтобы не сломать систему и тем более виртуальную машину, на которой у нас развернуто приложение, мы выставляем timeout. Если запрос исполняется больше двух минут, gunicorn worker ликвидируется и выдает неприятную ошибку в системе мониторинга Sentry.
Стадия принятия
Каким бы прекрасным ни был Python, когда разработчики сталкиваются с ограничением производительности, то обращаются к более высокопроизводительным языкам, а затем делают биндинги в Python. Воспользуемся этим. Orjson использует Rust, и мы решили написать на этом языке свой пакет.
Так как сборка древовидных структур происходит по ID его родителя, вся нагрузка ложится на Python. Но мы можем получить наборы данных в плоской структуре — в виде списков словарей, а затем передать их в пакет, написанный на Rust, и обработать данные там.
Что мы делали раньше:
- Сформировали запрос в базу данных с помощью django-orm.
- Отдали набор данных QuerySet в DRF сериализатор.
- Достали из него Python-словарь.
- Конвертировали Python словарь в JSON (данный шаг скрыт под капотом у класса Response из библиотеки DRF).
- Отдали ответ пользователю.
Как это выглядит теперь:
- Получаем QuerySet всех тестовых кейсов, используем метод values (). Он вернет QuerySet, содержащий словари. Обернем все это в список — так мы получим список словарей, с которым проще всего будет работать в Rust. Таким же образом получаем все тестовые кейсы, в values указываем все нужные нам поля, чтобы не тянуть лишние данные из базы.
- Ищем тестовые сьюты, к которым относятся эти тестовые кейсы, и получаем их предков (родители родителя и так до 0 уровня). Получаем плоский набор данных по принципу, описанному выше.
- Передаем наборы данных в функцию из нашей Rust-библиотеки и производим необходимую обработку.
- Возвращаем список словарей. Словари, в свою очередь, представляют иерархическое дерево.
- Сериализуем полученный список в JSON с помощью Orjson.
- Отдаем ответ пользователю.
Для написания пакета нам нужен сам Rust, с инструкцией по его установке можно ознакомится на официальном сайте. Также понадобится Python-пакет maturin.
Maturin создает проект с необходимым содержимым для того, чтобы мы начали писать свой Python-пакет на Rust:
- Cargo.toml — конфигурационный файл пакетного менеджера Rust Cargo,
- pyproject.toml — конфигурация нашего Python-пакета,
- lib.rs — точка входа Rust-кода.
Сначала напишем функцию, которую будет вызывать Python-код. Это функция с декоратором #[pyfunction]
:
Регистрируем ее в наш модуль в файле lib.rs
:
Не буду вдаваться в детали алгоритма построения дерева и группирования дочерних объектов на основных элементах, так как эта задача достаточно тривиальна. В нашем случае основные элементы — это тестовые сьюты и сценарии, которые в них содержатся.
Поговорим немного о преобразовании Python-объектов в Rust-объекты. Нам нужна какая-то структура, которая помогла бы преобразовывать динамически типизированные Python-объекты в статически типизированные Rust-объекты, поэтому напишем следующие структуры:
Built-in типы PyO3 может преобразовывать и сам. Например, list[int])
преобразуется в Vec
без явного вмешательства с нашей стороны. Однако нам нужно передать определенную метаинформацию, чтобы потенциально переиспользовать функцию из Python-Rust-пакета для обработки других структур, отличных от тестовых сьют с кейсами. А еще мы бы хотели работать с объектами Python как с не строго типизированными, потому что мы можем получить разное количество ключей в словарях или передать словари с другими полями. Поэтому у нас есть Python-объекты (дата-классы), которые несут определенную метаинформацию.
Объекты, которые мы хотим обработать, оставляем в формате PyObject. В процессе формирования иерархической структуры мы достаем из них только ключи, необходимые нам для формирования древовидных структур. Чтобы добиться этого, мы реализуем Rust traits из PyO3, такие как FromPyObject
и IntoPy
.
FromPyObject
конвертирует Python-объект в Rust-структуру, IntoPy
— это обратная операция. Чтобы с модулем было удобнее работать из основного приложения, нужно добавить тайп-хинты и объекты группировки. Последние подскажут пользователю, как работать с нашим модулем.
Так как мы хотим использовать и Python, и Rust в одном проекте, сделаем папку pysrc
. Выкладка нашего модуля будет выглядеть так:
В pyproject.toml
, который сгенерировал пакет maturin
, обозначим, что Python-исходник — это пункт python-source
:
В pysrc/rusty/types
сделаем пару простых дата-классов, которые будут содержать необходимую для форматирования данных метаинформацию. Prefetch
— это объекты, которые мы будем группировать на другие объекты, в нашем случае тестовые кейсы на сьюты. Нам нужен ключ, по которому мы будем складывать наши тестовые кейсы в сьюты. fk_key
— это имя ключа родительского элемента, по нему будем искать нужный объект, instances — это наши тестовые кейсы.
DataSetObject
— это наши cьюты, pk_key
— это ключ первичного ключа, parent_key
— ключ родительского элемента, instances — это сами сьюты:
И чтобы однозначно обозначить, что наши функции принимают, и чтобы IDE подсказывала нам, что эти функции существуют, сделаем .pyi-файл и положим его рядом с кодом на Python в pysrc/rusty
:
Так мы приходим к следующему коду в нашем view поиска тестовых кейсов:
Результатом работы становится ответ сервера в 300 мс — это отлично, если сравнивать с исходным результатом в 10,93 секунды
Но мы пойдем дальше и посмотрим, что будет, если сделать этот же запрос на самый нагруженный проект в системе. В проекте 221 203 тестовых кейса и 101 тестовый сьют. В результате получим ответ от сервера за 722 мс с пейлоадом в 22.3 МБ. Это более чем удовлетворительный результат, подходящий пользователям.
Раз мы удовлетворены перформансом эндпоинта, поговорим о подводных камнях при установке пакета.
Установка Rust/Python-модуля
Так как мы хотим, чтобы наш пакет работал на большом количестве конфигураций систем, было бы хорошо включить в наш Dockerfile Rust и собирать пакет на лету. Но это увеличивает и так не маленькое время установки зависимостей проекта и запуска контейнеров. Примерно на 4−8 минут, так как сначала нужно установить Rust, а затем уже скомпилировать нашу зависимость. Как разработчиков системы это нас очень печалит.
Вспоминаем, что есть предсобранные файлы wheels или же .whl Python-пакеты, но есть одно «но». Даже у разработчиков в нашей команде разные конфигурации систем MacOS (arm/x86), Debian (x86) и Windows (x86), то есть мы не можем собрать .whl-файл для какой-то конкретной системы, даже для нашего внутреннего использования. Также надо помнить, что TestY — это проект с открытым исходным кодом, а значит, он должен собираться на большом количестве разных систем.
Что мы можем сделать, чтобы уменьшить шансы ошибки сборки проекта? Собрать .whl-файлы для самых часто встречающихся конфигураций машин. Поэтому мы решили автоматизировать сборку .whl-файлов и их загрузку на pypi. И когда пользователь будет делать pip-install, pypi сам будет определять необходимую пользователю зависимость. Для этого мы воспользуемся GitHub Workflows, в частности maturin-action. Как только мы запушим новый tag в наш репозиторий на GitHub, maturin-action соберет необходимые нам .whl-файлы и зальет их на pypi, если не найдет идентичные .whl-файлы.
Пример кода пайплана для сборки .whl-файлов для различных дистрибутивов Linux на разных архитектурах:
Часть пайплайна, которая загружает собранные .whl-файлы в pypi, опущена, также как и сборка под другие операционные системы. Теперь наш пакет полностью готов к использованию.
Выводы по задаче и альтернативные решения
В ходе разработки я понял, что интеграция Rust-модуля в Python — не самая тривиальная задача. Хоть на эту тему есть много гайдов от других инженеров, всегда возникают подводные камни, которые никто не описывал. Как по мне, документация Rust-пакетов, в частности pyo3, могла бы быть лучше, но все знают, что наличие документации в целом — уже чудо.
Такая оптимизация — это обходной путь для нехватки человеческого ресурса и времени на разработку. Тем не менее, мы всеми силами стараемся сделать систему лучше, не отказываемся от Django и не хотим переписывать всю систему на Rust, так как это сильно замедлит скорость разработки. Мы используем все возможные средства для улучшения пользовательского опыта, не замыкаемся в стандартных решениях для Django или ограничениях языка.
Если тоже хотите поучаствовать в улучшении тест-менеджмент системы TestY, пишите на почту testy@yadro.com. А скачать репозиторий с версией TestY 1.3, о которой мы недавно писали, можно по ссылке.