Ideal application deployment
Kirill YurkovThe First Nine Guide. Блок 4
Итак, наконец-то - мы написали идеальное веб-приложение:
- В нем проработана и снижена комплексность каждого метода и функции под ним, как это мы разбирали тут.
- Мы, конечно, выбрали самый лучший рантайм под нашу задачу, и при этом, вероятно, вдохновлялись информацией из блока 2.
- При написании кода мы понимали внутреннюю архитектуру, от этого наше приложение получилось устойчивым.
И подошли вплотную к моменту, когда нам надо его запустить в продакшн окружении. Ну и конечно в контейнере. Разберемся, что же может пойти не так. Попутно поставим оценки языкам и фреймворкам за то насколько они позволяют пройти этот путь без ловушек и боли.
Как будем разбираться? Всем сделаем схему в ключевых вариантах упаковки в контейнер.
В каждой схеме три контейнера сверху вниз:
1) Наивный - запускаем «как есть», без тюнинга: проявляются типовые проблемы.
2) Подкрученный - снимаем CPU limits (quota=0) и фиксируем базовые ресурсы: убираем CFS‑throttling и непредсказуемость (иногда его скипал, когда поведение рантайма не зависит от наличия лимитов).
3) Идеальный - согласованные ресурсы (requests/параллелизм) и лимиты памяти.
Справа от контейнеров подсвечиваю как работать с тредами, параллелизм и их маппинг на тяжелые потоки операционной системы.
Почему рекомендую снимать CPU limits?
- Максимально упрощенно - ядро начинает искусственно притормаживать процесс (CFS throttling). Время ответа скачет, при коротких пиках не хватает CPU.
- Что тогда оставляем: cpu.requests - это целевая доля CPU для планировщика Kubernetes и обязательно оставляем memory.limits и memory.requests.
(в целом на это будет отдельный пост в канале, но ради холивара - залетайте в комменты уже сейчас)
Когда лимиты нужно оставить? Когда у вас неконтролируемое потребление CPU, например если ваш контейнер такой прожорливый, что хочет потреблять все CPU ноды - лучше его посадить на диету через cpu.limits. Лимиты оправданы в случае использования недоверенного кода или шаренного кластера с абсолютно несвязанными с вами командами. В целом подход без cpu.limits требует в реквестах указывать больше cpu.requests чем реальное потребление, за этим надо следить и это требует особой дисциплины и мониторинга. Тестируйте перед тем как снимать лимиты.
Java Virtual Machine 11+
Ранее я писал уже пост про то как настраивать JVM и не взорваться в проде. Коротко продублирую, чтобы статья была полной.

>Изображение, чтоб поразглядывать<
Наивный деплой
JVM - молодец, рантайм сам определяет параллелизм, в нашем случае для контейнера А это 2. Этот параллелизм используют все пулы: GC, ForkJoinPool, а также пулы потоков веб-серверов вроде Tomcat. Проблема тут будет не всегда, но может выглядеть вот так: упираемся в cpu limit на старте при прогреве памяти, скачет латенси на запросах, периодически проскакивает троттлинг, утечка off-heap.
Снимаем троттлинг
В контейнере B мы пытаемся снять троттлинг и замеделения за счет снятия лимитов, но тут ловушка - JVM, не видя лимита, смотрит на ресурсы всей ноды и решает, что ей доступны все 128 ядер! В результате availableProcessors() возвращает 128, и рантайм создает огромное количество потоков для GC, ForkJoinPool и других компонентов. Это приводит к колоссальным накладным расходам на переключение контекста, борьбе за ресурсы и деградации производительности. Приложение пытается использовать 128 ядер, имея по факту гарантию только на одно.
Идеальный деплой
Чтобы избежать большинства проблем мы убираем лимиты, но даём JVM понять, на какое количество ресурсов ей стоит рассчитывать. Для этого мы выставляем флаг -XX:ActiveProcessorCount=1 (равный реквестам). Теперь JVM будет корректно настраивать внутренний параллелизм, ориентируясь на гарантированные ей ресурсы, а не на все ядра ноды. Это устраняет троттлинг, предотвращает взрыв потоков и делает производительность предсказуемой. Дополнительно мы явно настраиваем память (-XX:MaxRAMPercentage, -XX:MaxMetaspaceSize), чтобы избежать OOM Killer.
Java Virtual Machine 21+ (Virtual Threads)
Чтобы было что-то новенькое, решил добавить про JVM с Virtual threads.

>Изображение, чтоб поразглядывать<
Наивный деплой
Всё как в JVM 11+: выставляем реквесты и лимиты, приложение страдает от CPU throttling. К общим проблемам добавляется то, что планировщик виртуальных потоков (VT Scheduler) также настраивает свой параллелизм (тяжелые треды) на основе лимита CPU.
Снимаем троттлинг
Со снятием лимитов сопровождается еще большая проблема, так как тяжелые треды планировщика виртуальных потоков тоже будут в избытке, от этого будет накопление виртуальных потоков.
Идеальный деплой
Для полного контроля мы убираем лимиты, делаем все как в JVM 11+, но главное что мы помимо ActiveProcessorCount должны использовать второй флаг:
-XX:ActiveProcessorCount=1(равен реквестам) - для управления параллелизмом GC и ForkJoinPool.-Djdk.virtualThreadScheduler.parallelism=1(тоже равен реквестам) - для точной настройки пула тяжелых потоков для виртуальных тредов.
Таким образом, мы согласовываем параллелизм всего рантайма с выделенными ресурсами, получаем предсказуемую производительность без троттлинга и эффективно используем виртуальные потоки.
Golang 1.10+

>Изображение, чтоб поразглядывать<
Наивный деплой
Запускаем приложение с реквестами и лимитами. Рантайм Go достаточно умён, чтобы увидеть лимиты и runtime.NumCPU() вернёт 2. Как и в случае с JVM, приложение может страдать от CPU throttling, так как его постоянно ограничивает планировщик ядра. Еще GC не знает границы контейнера - возможен OOMKill.
Снимаем троттлинг
Убираем лимиты, чтобы победить троттлинг. Ловушка: ну и уже классическая проблема - рантайм Go, не видя ограничений, смотрит на всю машину и runtime.NumCPU() возвращает 128. Планировщик Go немедленно создаёт 128 системных потоков для обработки горутин. Наше приложение начинает порождать огромное количество тяжелых потоков, что ведёт к деградации производительности.
Идеальный деплой
Явно указываем рантайму, сколько ядер ему следует использовать, с помощью переменной окружения GOMAXPROCS=1 (берем из реквестов). Очень рекомендую GOMEMLIMIT устанавливаем в 70-90% от memory limit для правильной работы GC, потому как memory.limit Golang игнорирует (а вообще юзайте automemlimit). В таком варианте рантайм понимает границы контейнера и эффективно управляет ресурсами. На выходе у нас отличная предсказуемость и масштабируемость.
Node.js 18+/20+

>Изображение, чтоб поразглядывать<
Наивный деплой
Устанавливаем реквесты в 1 и лимиты в 2. Основной поток Node.js - однопоточный, поэтому он и не пытается использовать второе ядро. Параллелизм для I/O-операций обеспечивается пулом libuv, размер которого по умолчанию равен 4 и не зависит от числа ядер. Отсюда - проблем минимум, только на этапе масштабирования может быть небольшой сюрприз. Хотя Node.js и не пытается истинно распараллелиться, он всё равно может старадать от CPU throttling из-за установленного лимита, особенно когда выполняет CPU-bound задачки.
Снимаем троттлинг
Я не отрисовал это на схеме, потому что, когда мы пробуем убрать лимиты - ничего особо не случится.
Особенность Node.js: рантайм по умолчанию не особо использует container awareness в плане параллелизма. Он не взорвётся и не попытается использовать все 128 ядер ноды (для большинства библиотек это так). Он продолжит работать в одном потоке. Важно понимать, что избавление от троттлинга, не означает создание эффективного параллелизма, а еще не означает, что CPU гарантируется контейнеру в нужном объеме.
Идеальный деплой
Для Node.js идеальный деплой - это не ограничение рантайма, а его правильное масштабирование. Nodejs поддерживает кластер, и эта кластеризация создает прозрачность. Рекомендую пытаться количество процессов держать равным реквестам. Это позволяет эффективно утилизировать выделенные ядра сохранив прозрачность.
Python 3.8+ (Gunicorn / Uvicorn)

>Изображение, чтоб поразглядывать<
Наивный деплой
В сердце Python живет GIL и код выполняется в одном потоке. Можно уходить в псевдопараллелизм на уровне процесса, но базово Python будет игнорировать лимиты рантаймом. Ну и, конечно, может случится троттлинг, в остальном очевидных проблем у наивного подхода нет.
Снимаем троттлинг
Убираем лимиты. Сам по себе Python-интерпретатор не создаст лишних потоков.
Ловушка: многие библиотеки на C (например, NumPy, Pandas, OpenCV), используемые в Python, для ускорения вычислений смотрят на доступные ядра. Увидев 128 ядер ноды, они попытаются распараллелить свои операции на 128 потоков. Это вызовет огромную борьбу за ресурсы и полностью "подвесит" приложение. Но выдыхайте, скорее всего они их итак видят, потому что лимиты cpu туда не прокидываются без включенного cpuset/CPU affinity, так что в 99,999% случаев все ок.
Идеальный деплой
Мы убираем лимиты, наследуем подход от других однопоточников:
- Советую явно задавать количество процессов Gunicorn равным реквестам. Это обеспечивает основной параллелизм на уровне процессов.
- Для редких кейсов в случае наличия взрывоопасных библиотек, мы устанавливаем переменные окружения, ограничивающие их многопоточность, например:
OPENBLAS_NUM_THREADS=1. - Чтобы вообще минимизировать головную боль обратите внимание на параметры выставляющие max-requests(+jitter), оно может спасти от утечек на должноживущих, нагруженных процессах. Для большого количества воркеров стоит рассмотреть настройку preload.
Таким образом, мы получаем полный контроль над параллелизмом, избегаем троттлинга и непредсказуемого поведения C-расширений.
Ruby 3+

>Изображение, чтоб поразглядывать<
Наивный деплой
И снова однопоточный рантайм, на этот раз в сердце живет MRI. Риски троттлинга все те же, но наивный деплоймент проблем скорее всего не принесет.
Снимаем троттлинг
Снова убираем троттлинг за счет ухода от лимитов, но в данном случае от container awareness зависит параллелизм.
Ловушка: Популярный веб-сервер Puma использует Etc.nprocessors для определения количества ядер, и без лимитов этот вызов вернёт 128 (все ядра хоста). Если настройки воркеров и потоков не заданы явно, Puma может попытаться создать непропорционально большое количество процессов или потоков, что приведёт к борьбе за ресурсы и снижению производительности.
Идеальный деплой
Убираем лимиты и пытаемся сладить с рантаймом. Для это явно задаём количество воркеров и потоков через переменные окружения или конфигурационные файлы, ориентируясь на реквесты CPU.
WEB_CONCURRENCY=1(равно реквестам) - для установки количества процессов-воркеров.RAILS_MAX_THREADS=5(см. картинку) - для настройки пула потоков внутри каждого воркера, в зависимости от типа нагрузки (I/O-bound или CPU-bound).- По аналогии с Python тут есть свой флаг -
preload_app!для большого числа воркеров, а для нагруженных воркеров долгожителей надо смотреть на комбинациюon_worker_bootиworker_timeout.
Казалось бы все ок, но тут надо подсветить, что Ruby требует точечной настройки для большинства библиотек, если лимитов нет, например:
- Puma. CLI: puma --workers N или ENV: WEB_CONCURRENCY=N
- Unicorn: CLI/ENV: worker_processes N
- Sidekiq: CLI: sidekiq -c N или ENV: SIDEKIQ_CONCURRENCY=N
где N берем из cpu.requests.
Только такой подход обеспечивает предсказуемое поведение, устраняет троттлинг и позволяет точно настроить приложение под выделенные ему ресурсы.
PHP‑FPM 7.4+

>Изображение, чтоб поразглядывать<
Наивный деплой
По PHP-FPM работает в режиме dynamic (по умолчанию), создавая и удаляя дочерние процессы по мере необходимости (Process-per-prequest). Прожорливое приложение почти всегда упирается в лимиты и страдает. Кроме того, динамический режим управления процессами (pm=dynamic) может приводить к скачкам потребления памяти, короче непредсказуемость в полный рост.
Снимаем троттлинг
Мы убираем лимиты. Сам по себе PHP-FPM не "взрывается", так как он не настраивает количество воркеров автоматически на основе доступных ядер.
Ловушка: хотя рантайм и умеет читать переменные окружения, он их не использует для автоконфигурации. Если какая-либо из используемых библиотек или скриптов попытается определить количество CPU через системные вызовы (getconf _NPROCESSORS_ONLN), она увидит все 128 ядер, что может привести к непредвиденному поведению.
Идеальный деплой
Для максимальной предсказуемости мы убираем limits и настраиваем PHP-FPM следующим образом:
- Режим управления процессами:
pm = static. Это создаёт фиксированное количество дочерних процессов при старте, что устраняет задержки и делает потребление ресурсов стабильным. - Количество процессов:
pm.max_children = 1(значение равноcpu.requests). Мы явно указываем, сколько воркеров нужно запустить, согласовывая их количество с гарантированными ресурсами CPU.
Этот подход обеспечивает стабильную и предсказуемую производительность без троттлинга. В целом из-за отсутствия постоянного рантайма PHP-FPM имеет ряд сложностей в реализации, например метрик или в профилировании, но есть ряд фреймворков, которые эти пробелмы решают. Например, Roadrunner или FrankenPHP с крайне забавным слонозомби.
.NET 6+

>Изображение, чтоб поразглядывать<
Наивный деплой
Рантайм .NET обладает отличным container awareness: он видит лимит в 2 CPU и автоматически настраивает под это значение размер ThreadPool и поведение сборщика мусора (GC). Сервис всё равно, как и остальные, может сталкивается с троттлингом.
Снимаем троттлинг
Убираем лимиты.
Ловушка: рантайм .NET - container awareness, увидит все 128 ядер хоста. Он адаптирует свой ThreadPool для утилизации всех доступных, как он думает, ядер. Это приводит к созданию большого количества потоков, что неэффективно для приложения, которому гарантирован всего 1 CPU, и вызывает большие проблемы.
Идеальный деплой
Мы убираем лимиты, но используем переменные окружения для тонкой настройки рантайма, чтобы он соответствовал реквестам, а не ресурсам всей ноды. Очень похоже на Golang или на JVM.
DOTNET_PROCESSOR_COUNT=1(по реквестам) - оно наследуется во все пулы, от которых зависит рантайм.DOTNET_GCServer=true- включаем серверный режим GC, который лучше оптимизирован для многоядерных систем и высокой пропускной способности. Иногда в этом нет необходимость, но цель параметра - избавиться от неопределенности и сделать поведение предсказуемым.
Такая конфигурация позволяет избавиться от троттлинга, сохраняя при этом полный контроль над ресурсами и обеспечивая стабильную и высокую производительность.
Ранее я пытался приводить сводную таблицу с субъективной оценкой рантаймов. В итоге, сейчас после анализа каждого я считаю, что объективно ее провести нельзя. Поэтому оставил эту затею в пользу выделения ключевых ловушек и рекомендаций.
Статью буду дополнять, чтобы она служила актуальной шпаргалкой и в будущем. Буду оперативно фиксить найденные ее узкие места или неточности - поэтому велкам в комменты.
В следующем выпуске мы наконец-то поймем кто такой "тяжелый поток" и как с ним вообще поладить
В предыдущей серии - разбирались с тем, как устроен веб-сервер внутри
Подписывайся на канал @r9yo11yp9e - будем искать девятки вместе :)