Тюнинг производительности для развёртывания

Это руководство описывает настройку производительности и конкурентности при развёртывании production-приложения на Ruby on Rails: выбор сервера приложений, конфигурация Puma, YJIT, аллокаторы памяти и нагрузочное тестирование.

Это руководство описывает настройку производительности и конкурентности при развёртывании вашего production-приложения на Ruby on Rails.

После его прочтения вы будете знать:

  • Стоит ли использовать Puma — сервер приложений по умолчанию.
  • Как настроить важные параметры производительности Puma.
  • Как начать тестировать производительность настроек вашего приложения.

Это руководство сфокусировано на веб-серверах — основном компоненте большинства веб-приложений, влияющем на производительность. Другие компоненты, такие как фоновые задачи и WebSocket'ы, также можно тюнить, но это руководство их не охватывает.

Больше информации о том, как настраивать ваше приложение, можно найти в Руководстве по конфигурированию.


В этом руководстве предполагается, что вы используете MRI — каноническую реализацию Ruby, также известную как CRuby. Если вы используете другую реализацию Ruby, такую как JRuby или TruffleRuby, большая часть этого руководства неприменима. При необходимости обратитесь к источникам, специфичным для вашей реализации Ruby.

Выбор сервера приложений

Puma — это сервер приложений Rails по умолчанию и наиболее часто используемый сервер в сообществе. В большинстве случаев он работает хорошо. В некоторых случаях вы можете захотеть перейти на другой.

Сервер приложений использует определённый метод конкурентности. Например, Unicorn использует процессы, Puma и Passenger используют гибридную конкурентность на основе процессов и потоков, а Falcon использует файберы.

Полное обсуждение методов конкурентности Ruby выходит за рамки этого документа, но ключевые компромиссы между процессами и потоками будут рассмотрены. Если вы хотите использовать метод, отличный от процессов и потоков, вам нужно будет использовать другой сервер приложений.

Это руководство сфокусировано на том, как тюнить Puma.

Что оптимизировать?

По сути, тюнинг веб-сервера на Ruby — это поиск компромисса между несколькими свойствами, такими как использование памяти, пропускная способность (throughput) и задержка (latency).

Пропускная способность — это мера того, сколько запросов в секунду может обработать сервер, а задержка — мера того, как долго выполняются отдельные запросы (также называется временем ответа).

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

Важно понимать, что оптимизация по одному свойству, как правило, ухудшает как минимум одно другое.

Понимание конкурентности и параллелизма в Ruby

В CRuby есть Global Interpreter Lock, часто называемый GVL или GIL. GVL не позволяет нескольким потокам одновременно выполнять Ruby-код в одном процессе. Несколько потоков могут ожидать данных из сети, операций с базой данных или какой-либо другой не-Ruby работы, обычно называемой операциями ввода-вывода (I/O), но только один может активно выполнять Ruby-код в каждый момент времени.

Это означает, что конкурентность на основе потоков позволяет увеличить пропускную способность за счёт параллельной обработки веб-запросов всякий раз, когда они выполняют I/O-операции, но может ухудшать задержку всякий раз, когда I/O-операция завершается. Поток, который её выполнил, может быть вынужден подождать, прежде чем сможет возобновить выполнение Ruby-кода. Аналогично, сборщик мусора Ruby работает по принципу «stop-the-world», поэтому при его срабатывании все потоки должны остановиться.

Это также означает, что независимо от того, сколько потоков содержит Ruby-процесс, он никогда не будет использовать более одного ядра CPU.

Из-за этого, если ваше приложение тратит только 50% времени на I/O-операции, использование более 2 или 3 потоков на процесс может серьёзно ухудшить задержку, а прирост пропускной способности быстро упрётся в убывающую отдачу.

Вообще говоря, хорошо написанное Rails-приложение, не страдающее от медленных SQL-запросов или проблем N+1, тратит не более 50% времени на I/O-операции, и поэтому вряд ли выиграет от более чем 3 потоков. Однако некоторые приложения, которые делают встроенные вызовы сторонних API, могут тратить очень большую долю времени на I/O-операции и могут выиграть от большего количества потоков.

Способ достичь настоящего параллелизма в Ruby — использовать несколько процессов. Пока есть свободное ядро CPU, Ruby-процессам не нужно ждать друг друга, прежде чем возобновить выполнение после завершения I/O-операции. Однако процессы разделяют только часть своей памяти через copy-on-write, поэтому один дополнительный процесс использует больше памяти, чем дополнительный поток.

Обратите внимание, что хотя потоки дешевле процессов, они не бесплатны, и увеличение количества потоков на процесс также увеличивает использование памяти.

Практические последствия

Пользователи, заинтересованные в оптимизации пропускной способности и утилизации сервера, захотят запускать один процесс на ядро CPU и увеличивать количество потоков на процесс до тех пор, пока влияние на задержку не станет слишком значительным.

Пользователи, заинтересованные в оптимизации задержки, захотят держать количество потоков на процесс низким. Чтобы оптимизировать задержку ещё больше, пользователи могут даже установить количество потоков на процесс равным 1 и запускать 1.5 или 1.3 процесса на ядро CPU, чтобы учесть моменты, когда процессы простаивают, ожидая I/O-операций.

Важно отметить, что некоторые хостинг-решения могут предлагать относительно небольшой объём памяти (RAM) на ядро CPU, что не позволит вам запустить столько процессов, сколько нужно для использования всех ядер CPU. Однако у большинства хостинг-решений есть разные тарифы с разными соотношениями памяти и CPU.

Ещё одна вещь, которую стоит учесть — использование памяти Ruby выигрывает от эффекта масштаба благодаря copy-on-write. Так что 2 сервера с 32 Ruby-процессами каждый будут использовать меньше памяти на ядро CPU, чем 16 серверов с 4 Ruby-процессами каждый.

Конфигурации

Puma

Конфигурация Puma находится в файле config/puma.rb. Две наиболее важные настройки Puma — это количество потоков на процесс и количество процессов, которые Puma называет workers.

Количество потоков на процесс настраивается директивой threads. В стандартной сгенерированной конфигурации оно установлено в 3. Вы можете изменить его, либо установив переменную окружения RAILS_MAX_THREADS, либо просто отредактировав файл конфигурации.

Количество процессов настраивается директивой workers. Если вы используете более одного потока на процесс, его следует установить равным количеству ядер CPU, доступных на сервере, или, если на сервере запущено несколько приложений, количеству ядер, которые вы хотите выделить под приложение. Если вы используете только один поток на воркер, вы можете увеличить его выше одного на ядро, чтобы учесть моменты, когда воркеры простаивают, ожидая I/O-операций.

Вы можете настроить количество воркеров Puma, установив переменную окружения WEB_CONCURRENCY. Установка WEB_CONCURRENCY=auto автоматически подберёт количество воркеров Puma в соответствии с количеством доступных CPU. Однако эта настройка может быть неточной на облачных хостах с общими CPU или на платформах, которые сообщают количество CPU некорректно.

YJIT

Недавние версии Ruby поставляются с Just-in-time компилятором под названием YJIT.

Не вдаваясь в подробности, JIT-компиляторы позволяют выполнять код быстрее ценой использования некоторого дополнительного объёма памяти. Если вы действительно не можете позволить себе это дополнительное использование памяти, настоятельно рекомендуется включить YJIT.

Начиная с Rails 7.2, если ваше приложение работает на Ruby 3.3 или выше, YJIT будет автоматически включён Rails по умолчанию. Более старые версии Rails или Ruby должны включать его вручную; пожалуйста, обратитесь к документации YJIT за инструкциями.

Если дополнительное использование памяти является проблемой, прежде чем полностью отключать YJIT, вы можете попробовать настроить его так, чтобы он использовал меньше памяти, через параметр --yjit-mem-size.

Аллокаторы памяти и их конфигурация

Из-за того, как работает аллокатор памяти по умолчанию в большинстве дистрибутивов Linux, запуск Puma с несколькими потоками может привести к неожиданному увеличению использования памяти из-за фрагментации памяти. В свою очередь, это увеличенное использование памяти может помешать вашему приложению полностью утилизировать ядра CPU сервера.

Чтобы смягчить эту проблему, настоятельно рекомендуется настроить Ruby на использование альтернативного аллокатора памяти: jemalloc.

Стандартный Dockerfile, генерируемый Rails, уже преднастроен для установки и использования jemalloc. Но если ваше хостинг-решение не основано на Docker, вам следует разобраться, как установить и включить jemalloc там.

Если по какой-то причине это невозможно, менее эффективной альтернативой является настройка аллокатора по умолчанию способом, уменьшающим фрагментацию памяти, путём установки MALLOC_ARENA_MAX=2 в вашем окружении. Однако учтите, что это может замедлить Ruby, поэтому jemalloc — предпочтительное решение.

Тестирование производительности

Поскольку каждое Rails-приложение уникально и каждый пользователь Rails может захотеть оптимизировать разные свойства, невозможно предложить конфигурацию или рекомендации по умолчанию, которые подойдут всем.

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

Это можно сделать с помощью симулированной production-нагрузки или непосредственно в продакшене с реальным трафиком приложения.

Тестирование производительности — глубокая тема. Это руководство даёт лишь простые рекомендации.

Что измерять

Пропускная способность (throughput) — это количество запросов в секунду, которые ваше приложение успешно обрабатывает. Любая хорошая программа нагрузочного тестирования измеряет её. Пропускная способность обычно выражается одним числом в «запросах в секунду».

Задержка (latency) — это интервал времени от момента отправки запроса до момента успешного получения ответа на него, обычно выражается в миллисекундах. У каждого отдельного запроса своя задержка.

Перцентильная задержка показывает задержку, при которой определённый процент запросов имеет задержку лучше неё. Например, P90 — это задержка 90-го перцентиля. P90 — это задержка для одного нагрузочного теста, при которой только 10% запросов обрабатывались дольше. P50 — это задержка, при которой половина ваших запросов была медленнее; также называется медианной задержкой.

«Хвостовая задержка» (tail latency) относится к задержкам высоких перцентилей. Например, P99 — это задержка, хуже которой были только 1% ваших запросов. P99 — это хвостовая задержка. P50 хвостовой задержкой не является.

Вообще говоря, средняя задержка — не лучший показатель для оптимизации. Лучше сосредоточиться на медианной (P50) и хвостовой (P95 или P99) задержках.

Измерения в продакшене

Если ваше production-окружение включает более одного сервера, может быть хорошей идеей провести там A/B-тестирование. Например, вы могли бы запустить половину серверов с 3 потоками на процесс, а другую половину с 4 потоками на процесс, а затем использовать сервис мониторинга производительности приложений (APM), чтобы сравнить пропускную способность и задержку двух групп.

Сервисов мониторинга производительности приложений много, некоторые из них — self-hosted, некоторые — облачные решения, и многие предлагают бесплатные тарифы. Рекомендация какого-то конкретного выходит за рамки этого руководства.

Инструменты нагрузочного тестирования

Вам понадобится программа для нагрузочного тестирования, чтобы делать запросы к вашему приложению. Это может быть какая-то специализированная программа нагрузочного тестирования, или вы можете написать небольшое приложение, которое будет делать HTTP-запросы и отслеживать, сколько времени они занимают. Обычно не стоит сверяться с временем в файле лога Rails. Это время — только то, сколько Rails потратил на обработку запроса. Оно не включает время, потраченное сервером приложений.

Отправлять много одновременных запросов и засекать их время может быть непросто. Легко внести едва заметные ошибки измерения. Обычно следует использовать программу нагрузочного тестирования, а не писать свою. Многие нагрузочные тестеры просты в использовании, и многие отличные нагрузочные тестеры бесплатны.

Что вы можете менять

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

Более крупным хостам с большим объёмом памяти и количеством ядер CPU потребуется больше процессов для оптимального использования. Вы можете варьировать размер и тип хостов у хостинг-провайдера.

Увеличение количества итераций обычно даёт более точный ответ, но требует больше времени на тестирование.

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

Прогрев (Warmup)

Ваше приложение должно обработать некоторое количество запросов после запуска, которые не включаются в итоговые измерения. Эти запросы называются «прогревочными» (warmup) и обычно гораздо медленнее, чем последующие запросы в «установившемся режиме» (steady-state).

Ваша программа нагрузочного тестирования обычно поддерживает прогревочные запросы. Вы также можете запустить её более одного раза и отбросить первый набор замеров.

У вас достаточно прогревочных запросов, когда увеличение их количества существенно не меняет ваш результат. Теория, стоящая за этим, может быть сложной, но большинство распространённых ситуаций несложны: тестируйте несколько раз с разным количеством прогрева. Посмотрите, сколько прогревочных итераций нужно, прежде чем результаты останутся примерно одинаковыми.

Очень длительный прогрев может быть полезен для тестирования фрагментации памяти и других проблем, которые случаются только после многих запросов.

Какие запросы

Ваше приложение, вероятно, принимает много разных HTTP-запросов. Стоит начать нагрузочное тестирование лишь с нескольких из них. Со временем вы можете добавлять больше видов запросов. Если какой-то конкретный вид запросов слишком медленный в вашем production-приложении, вы можете добавить его в код нагрузочного тестирования.

Синтетическая нагрузка не может идеально совпадать с реальным production-трафиком вашего приложения. Тем не менее она полезна для тестирования конфигураций.

На что обращать внимание

Ваша программа нагрузочного тестирования должна позволять вам смотреть задержки, в том числе перцентильные и хвостовые.

Для разных количеств процессов и потоков, или разных конфигураций в целом, смотрите на пропускную способность и одну или несколько задержек, таких как P50, P90 и P99. Увеличение количества потоков улучшит пропускную способность до определённого предела, но ухудшит задержку.

Выбирайте компромисс между задержкой и пропускной способностью исходя из потребностей вашего приложения.

On this page