Привет, Хабр! В двух предыдущих статьях здесь и тут мы рассказывали историю создания одного из компонентов платформы экспериментов в компании Okko — сервиса сплитования трафика. В тех статьях говорилось о множестве изменений и улучшений, которые претерпел Python-код, чтобы работать достаточно быстро. Но как бы качественно не был написан код, все усилия могут сойти на нет, если он будет запущен в неправильной среде. В этой статье продолжим рассказ об оптимизациях и улучшениях, но сейчас речь будет идти не столько об особенностях предметной области и решаемой бизнес-задачи, сколько о том, как мы архитектурно организовали работу сервиса для получения минимального времени ответа.
Напомним, что сервис сплитования трафика решает только одну задачу. А именно, отвечает на вопрос: "В каких A/B-экспериментах и в каких группах участвует пользователь?” Так же вспомним, что у сервиса серьёзные технические требования:
необходимо держать нагрузку в 5k rps;
время ответа должно быть меньше 10 мс;
uptime — 99.99%.
Однако в предыдущих статьях не говорилось о том, как именно устроен веб-сервер, обрабатывающий входящий трафик. А реализован он был в первой своей итерации довольно привычным для индустрии способом:
Flask как веб-фреймворк;
Gunicorn в качестве WSGI-сервера.
Все данные о пользователе сервис получал в запросе, а данные об экспериментах и других необходимых сущностях хранились и обновлялись в оперативной памяти. После вычисления ответа и перед его отправкой клиенту записывался результат в Kafka с помощью библиотеки kafka-python.
Ниже упрощённый пример того, как мы запускаем веб-сервер на Gunicorn.
Скрытый текст
def run_gunicorn(
num_workers: int,
app_port: int,
timeout: int,
) -> None:
app = create_app(Config)
init_db(app)
gunicorn_options = {
'bind': f'0.0.0.0:{app_port}',
'workers': num_workers,
'timeout': timeout,
}
server = GunicornServer(
app,
gunicorn_options,
worker_class=None,
)
server.run()
Из интересного здесь то, что у нас есть небольшая надстройка над gunicorn.app.base.BaseApplication — GunicornServer, позволяющая нам решать некоторые задачи, суть которых для этой статьи не столько важна.
В предыдущих статьях было описано, как мы смогли достичь высокой скорости выполнения бизнес-логики. Время ответа сервиса замерялось при помощи middleware во Flask-приложении, и это время не включало в себя работу Flask и Gunicorn. Оценить реальное время ответа нашей команде помогли коллеги из отдела QA, проведя нагрузочные тесты. По их итогам выяснилось, что мы отвечаем за неприлично большое время – более чем за 15 миллисекунд в 99.9 процентиле. Мы поняли, что это неприемлемо и начали искать возможные пути уменьшения времени ответа.
Отметим, что исследования, о которых идёт речь в этой статье, во время реальной разработки приложения проводились параллельно с исследованиями, описанными в прошлых двух статьях. Но все схемы и графики представленные ниже специально были сделаны при лучшей из возможных конфигураций, чтобы исключить влияние остальных изменений на метрики.
Keep-alive
При проведении нагрузочного тестирования было замечено, что сервис постоянно закрывает соединения с балансировщиком. У нас используется HTTP версии 1.1. И объемы передаваемых данных были небольшие, около 0.5-1.0 Кб. Один небольшой запрос. Казалось бы, в чем проблема?
Если опуститься на уровень TCP, то окажется, что:
создается TCP-соединение;
передаются данные;
соединение закрывается.
С примерно такими задачами происходит обмен TCP-пакетами для обработки одного пользовательского запроса. Некоторые из пакетов — это полезная нагрузка. А другие пакеты, отвечающие за открытие и закрытие соединения, являются служебными. Именно от таких пакетов хотелось бы избавиться из-за их регулярного дублирования. Как здорово, что управление TCP-соединением можно кастомизировать через HTTP при помощи заголовка keep-alive. И было несколько удивительно видеть, что сервер Gunicorn, использующий SyncWorker, всегда закрывает соединения, и это невозможно контролировать. Вырезка из официальной документации:
Sync worker does not support persistent connections - each connection is closed after response has been sent (even if you manually add Keep-Alive or Connection: keep-alive header in your application).
Быстро решить эту проблему не удалось, и мы взяли её на заметочку. Однако получилось оценить ее влияние на сервис. По логам с балансировщика HAProxy удалось установить, что на инициализацию соединения уходило до 1 миллисекунды. Для нашего сервиса подобные издержки крайне нежелательны. Поэтому мы стали искать разные способы того, как можно получить возможность использовать keep-alive.
Начали с поиска альтернативы SyncWorker. В комплекте к Gunicorn идут thread-worker’ы и несколько асинхронных. Не хотелось бы их использовать только ради потенциальной возможности постоянных соединений, так как основная нагрузка в сервисе приходится не на сеть, а на процессор.
Далее мы искали варианты для worker’ов, которые не поставляются вместе с Gunicorn, но могут работать с ним. И познакомились с Meinheld, который очень похож по своему поведению на SyncWorker - вызывает синхронный код, годится для CPU-bound задач, каждый worker в своем процессе. Всего за полчаса нам удалось внедрить новый класс worker’а, и мы оказались готовы тестировать MeinheldWorker в нашем приложении. Запуск оказался практически идентичным первоначальному, за исключением буквально одной строчки:
worker_class='meinheld.gmeinheld.MeinheldWorker’
На удивление, он дал хорошее улучшение во времени ответа. Но с чего такой прирост, если он во всем похож на SyncWorker? Ответ простой — он написан на C, в качестве бонуса есть возможность использования keep-alive.
На графике видно, что мы стали отвечать быстрее и MeinheldWorker хорошо себя проявил. Однако было проведено ещё одно нагрузочное тестирование и оно показало, что из-за сетевых задержек от клиента до нас, мы всё же не укладываемся в тайминг в 10 мс. Поэтому решили копать дальше и искать замену уже не SyncWorker, а самому Gunicorn.
Flask-server
Сперва было решено попробовать Flask-server, так как он идёт в комплекте с веб-фреймворком в качестве сервера для разработки и его внедрение не требовало серьёзных трудозатрат.
Запуск выглядел примерно так:
# flask не дает запустить flask-server как-либо из приложения, только из файла '__main__'
# так что снимаем flask с предохранителя
os.environ.pop('FLASK_RUN_FROM_CLI')
app.run(host='0.0.0.0', port=app_port, processes=num_workers, load_dotenv=False)
И да, мы в курсе, что использовать его “в бою” не рекомендуется из соображений безопасности и производительности.
На удивление, Flask-server работает быстрее, чем мы ожидали, но всё ещё медленнее конкурентов. Вердикт ясен, Flask-server нам не подходит, поэтому использовать его в production мы не стали. Что ж, не очень-то и хотелось, если честно.
uWSGI
Далее мы решили попробовать uWSGI, так как он очень популярен и обладает большим множеством различных настроек. Для теста этого веб-сервера мы использовали самые обычные настройки, примерно такие:
uwsgi --http 0.0.0.0:8000 --master -p 2 -w api:app
uWSGI показал хорошее время, но не лучшее. Вдобавок к этому нам не особо подошла парадигма этого веб-сервера. Он сам создаёт все процессы и нам оказалось сложно интегрировать свои кастомные решения, о которых, возможно, мы ещё расскажем в будущем. Долго исследовать особенности uWSGI мы не стали, так как уже понимали, что вероятнее всего следующая библиотека нам будет более интересна.
Bjoern
Еще один WSGI-сервер, который был протестирован командой — Bjoern.
В сообществе он имеет репутацию самого быстрого веб-сервера для Python-приложений. Сам же разработчик заявляет:
Bjoern is the fastest, smallest and most lightweight WSGI server out there.
Single-threaded and without coroutines.
Full persistent connection ("keep-alive") support in both HTTP/1.0 and 1.1
Выглядит как то, что нам нужно. Как и Meinheld, Bjoern написан на C, поэтому ожидания от него были серьёзными.
Запуск выглядит следующим образом
Скрытый текст
def run_bjoern(
app: Flask,
num_workers: int,
app_port: int,
) -> None:
worker_pids = []
logger.info('running api with bjoern %s workers on port %s', num_workers, app_port)
bjoern.listen(app, '0.0.0.0', app_port, reuse_port=True)
for _ in range(num_workers):
pid = os.fork()
if pid > 0:
worker_pids.append(pid)
elif pid == 0:
try:
bjoern.run()
except KeyboardInterrupt:
pass
sys.exit()
try:
for _ in range(num_workers):
os.wait()
except KeyboardInterrupt:
for pid in worker_pids:
os.kill(pid, signal.SIGINT)
В итоге мы поняли — да, именно этот инструмент мы и искали. К сожалению, возможности конфигурации у этого веб-сервера минимальны, если не сказать, что отсутствуют. Потому пришлось написать свою обёртку для удобной конфигурации количества worker’ов, фоновых процессов, их рестартов, потоков и других привычных возможностей WSGI-сервера.
Внешние факторы
После внедрения Bjoern, время ответа было весьма неплохим. Около 5 миллисекунд при требовании в 10 миллисекунд. Но стоит учитывать, что это время, полученное из middleware. Между клиентом и нашим приложением есть некоторое физическое расстояние и несколько балансировщиков. На то, чтобы преодолеть эти преграды уходило около 4 миллисекунд. Итого, клиент получал результат наших вычислений примерно за 9 мс. Запас прочности сомнительный, да ещё и очень нестабильный, так как время ответа иногда всё же превышало 10 мс из-за большой дисперсии, которая могла составить более 10% от времени вычислений. И было принято решение попробовать ещё ускориться. Но как именно? Ведь мы уже вооружились самым быстрым веб-сервером из возможных. Изобретать свой велосипед, очевидно, не хочется. Слишком трудозатратно. Благо, у нас ещё было несколько идей, поэтому, спойлер, мы нашли выход.
Было решено взглянуть на процессно-потоковую архитектуру немного под другим углом.
GIL
Напомним, что у нас в Okko имеется некоторая экспертиза в работе с Python, мы знаем про GIL, его плюсы и недостатки. И, кажется, сейчас тот самый случай, когда GIL — это не благо, а проклятье. GIL присутствует во всех процессах, но если в главном процессе он не влияет на время ответа сервиса, так как он не обрабатывает входящие запросы, то в worker’ах это не так. А всё потому что в worker’ах, помимо основного потока имеется ещё один. Этот поток предназначен для отправки данных в Kafka, появляется из библиотеки kafka-python. Его задача - аккумулировать данные в себе, а затем партиями отправлять их. Задумка интересная, ведь отправка в Kafka состоит преимущественно из IO-операций.
Всё это звучит здорово, но есть нюанс… Когда основной поток обрабатывает запросы, поток с Kafka ждёт. При таком сценарии время ответа сервиса не страдает. Однако возможен кейс, что в то время, пока поток с Kafka занят отправкой данных, может прийти запрос. Тогда уже главный поток вынужден ждать своей очереди, что влияет на время ответа.
В Python есть параметр switch interval, с помощью которого можно управлять частотой переключения потоков. По умолчанию, он равен 5 миллисекундам. То есть, если во время обработки запроса происходит смена потоков, то это +5 мс ко времени ответа — значит клиент 100% не дождется ответа. Было предположение, что, если поставить интервал в 1 мс, это не решит хаотичные переключения потоков, но уменьшит разброс метрики времени ответа. Увы, это только предположение, в реальности получилось так, что каждую миллисекунду происходило переключение, и половину времени работал главный поток, а остальное время работал поток с Kafka. Увеличение интервала тоже не решало проблему. Отсюда следовало, что надо избавиться от влияния GIL вообще. Не слать данные в Kafka нельзя. Обмениваться данными по сети во время обработки входящего запроса — слишком долго. Делать это сразу после обработки — рискованно, так как есть шанс не успеть до прихода следующего запроса.
Получается выход только один — убрать сетевые операции из процесса, обрабатывающего запросы, и совершать их в соседних процессах.
Мы поступили следующим образом. Данные, предназначенные для Kafka, мы стали записывать в multiprocessing.Queue, использующую под капотом Pipe, сразу после того, как рассчитали их. С другой стороны мы создали процесс, который периодически опрашивает эту очередь, вычитывает появившиеся данные и отправляет в Kafka.
Это решение дало существенный результат. Мы довольно сильно уменьшили время ответа, и, что немаловажно, уменьшили дисперсию ответов с более чем 10% до примерно 3%. Таким образом, до клиента наш ответ стал доходить за 7.5 мс с весьма маленьким разбросом.
Nice
И теперь, когда все важные для бизнеса действия разложены по самостоятельным процессам, для более качественной утилизации ресурсов, решили ещё и поменять приоритеты процессов для планировщика операционной системы, воспользовавшись функцией nice из модуля os. Worker’ам оставили максимальный приоритет, процессу с отправкой результатов в Kafka — средний, а приоритет главного процесса был опущен до минимума. Выставляя такие настройки, мы рассчитывали на серьёзное увеличение процессорного времени для процессов, принимающих запросы, и, как следствие, существенное снижение времени ответа. Но этого, к сожалению, не случилось. Лишь немного уменьшилась дисперсия. Примерно до 1%.
GC
Как и положено после того, как нивелировали негативные стороны GIL, надо взглянуть на garbage collector. Мы попробовали поиграться с параметрами threshold, но спойлерить не будем, так как максимально подробно об этом расскажем в следующей статье.
Подведение итогов
Какой вывод, из всего описанного выше, мы можем сделать? В этой статье не шло речи о сути сплитования трафика. Мы не говорили о слоях, сегментах пользователей, обладающих общим набором характеристик, различных эвристиках решаемой задачи. Вместо этого мы упомянули особенности Python, некоторые библиотеки, возможности HTTP и операционной системы. Порой оказывается, для того, чтобы добиться существенных улучшений производительности не всегда обязательно досконально разбираться в предметной области. Иногда для достижения впечатляющих результатов можно воспользоваться только знаниями об используемых инструментах и на их основе провести несколько экспериментов.