Привет, Хабр! В двух предыдущих статьях здесь и тут мы рассказывали историю создания одного из компонентов платформы экспериментов в компании 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.

Сравнение SyncWorker и MeinheldWorker
Сравнение SyncWorker и MeinheldWorker

На графике видео, что мы стали отвечать быстрее и 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)

И да, мы в курсе, что использовать его “в бою” не рекомендуется из соображений безопасности и производительности.

SyncWorker vs MeinheldWorker vs Flask-server
SyncWorker vs MeinheldWorker vs Flask-server

На удивление, Flask-server работает быстрее, чем мы ожидали, но всё ещё медленнее конкурентов. Вердикт ясен, Flask-server нам не подходит, поэтому использовать его в production мы не стали. Что ж, не очень-то и хотелось, если честно.

uWSGI

Далее мы решили попробовать uWSGI, так как он очень популярен и обладает большим множеством различных настроек. Для теста этого веб-сервера мы использовали самые обычные настройки, примерно такие:

uwsgi --http 0.0.0.0:8000 --master -p 2 -w api:app
SyncWorker vs MeinheldWorker vs FlaskServer vs uWSGI
SyncWorker vs MeinheldWorker vs FlaskServer vs uWSGI

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, поэтому ожидания от него были серьёзными.

Запуск выглядит следующим образом

Запуск bjoern
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)

SyncWorker vs MeinheldWorker vs FlaskDebugServer vs uWSGI vs Bjoern
SyncWorker vs MeinheldWorker vs FlaskDebugServer vs uWSGI vs Bjoern

В итоге мы поняли — да, именно этот инструмент мы и искали. К сожалению, возможности конфигурации у этого веб-сервера минимальны, если не сказать, что отсутствуют. Потому пришлось написать свою обёртку для удобной конфигурации количества 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 мс, это не решит хаотичные переключения потоков, но уменьшит разброс метрики времени ответа. Увы, это только предположение, в реальности получилось так, что каждую миллисекунду происходило переключение, и треть времени работал главный поток, а остальное время работали остальные. Увеличение интервала тоже не решало проблему. Отсюда следовало, что надо избавиться от влияния GIL вообще. Не слать данные в Kafka нельзя. Обмениваться данными по сети во время обработки входящего запроса — слишком долго. Делать это сразу после обработки — рискованно, так как есть шанс не успеть до прихода следующего запроса.

Получается выход только один — убрать сетевые операции из процесса, обрабатывающего запросы, и совершать их в соседних процессах.

Мы поступили следующим образом. Данные, предназначенные для Kafka, мы стали записывать в multiprocessing.Queue, использующую под капотом Pipe, сразу после того, как рассчитали их. С другой стороны мы создали процесс, который периодически опрашивает эту очередь, вычитывает появившиеся данные и отправляет в Kafka.

Обновлённая процессная схема
Обновлённая процессная схема

Это решение дало существенный результат. Мы довольно сильно уменьшили время ответа, и, что немаловажно, уменьшили дисперсию ответов с более чем 10% до примерно 3%. Таким образом, до клиента наш ответ стал доходить за 7.5 мс с весьма маленьким разбросом.

После вынесения записи в Kafka в отдельный процесс
После вынесения записи в Kafka в отдельный процесс

Nice

И теперь, когда все важные для бизнеса действия разложены по самостоятельным процессам, для более качественной утилизации ресурсов, решили ещё и поменять приоритеты процессов для планировщика операционной системы, воспользовавшись функцией nice из модуля os. Worker’ам оставили максимальный приоритет, процессу с отправкой результатов в Kafka средний, а приоритет главного процесса был опущен до минимума. Выставляя такие настройки, мы рассчитывали на серьёзное увеличение процессорного времени для процессов, принимающих запросы, и, как следствие, существенное снижение времени ответа. Но этого, к сожалению, не случилось. Лишь немного уменьшилась дисперсия. Примерно до 1%.

После изменения приоритетов процессов
После изменения приоритетов процессов

GC

Как и положено после того, как нивелировали негативные стороны GIL, надо взглянуть на garbage collector. Мы попробовали поиграться с параметрами threshold, но спойлерить не будем, так как максимально подробно об этом расскажем в следующей статье.

Подведение итогов

Какой вывод, из всего описанного выше, мы можем сделать? В этой статье не шло речи о сути сплитования трафика. Мы не говорили о слоях, сегментах пользователей, обладающих общим набором характеристик, различных эвристиках решаемой задачи. Вместо этого мы упомянули особенности Python, некоторые библиотеки, возможности HTTP и операционной системы. Порой оказывается, для того, чтобы добиться существенных улучшений производительности не всегда обязательно досконально разбираться в предметной области. Иногда для достижения впечатляющих результатов можно воспользоваться только знаниями об используемых инструментах и на их основе провести несколько экспериментов.

Комментарии (3)


  1. Andrey_Solomatin
    03.08.2024 21:36
    +2

    Данные, предназначенные для Kafka, мы стали записывать в multiprocessing.Queue,

    А какой объём данных?

    При мягкой перезагрузке сервера данные успевают отправится?

    Если процесс убьют, то получается пользователь получит ответ, а данные в кавку не уйдут.


    1. KazakovDenis
      03.08.2024 21:36

      Тоже интересно как решали, при 5000 RPS выглядит как потенциально большая проблема. Кроме того, что будет с пишущим процессом, если читающий умер? Он блокируется полностью?


  1. KazakovDenis
    03.08.2024 21:36

    то это +5 мс ко времени ответа

    Это не совсем так. Интервал переключения по умолчанию действительно 5 мс, но активный поток может отпустить GIL и раньше. Та же `kafka-python` использует epoll/kqueue, поэтому не будет долго держать GIL.

    При этом верно и обратное, если CPU-bound задача долгая, то GIL может удерживаться дольше 5 мс, потому что эта проверка происходит перед выполнением очередной инструкции байт-кода. В kafka-python (да и в любой другой клиентской библиотеке) такой задачей будет только сериализация данных перед отправкой.