Оглавление
- Почему LLM-сервис тормозит до OOM: два контура памяти
- Сначала бюджет: host RAM, VRAM, offload и запас на всплеск
- PSI: ранний сигнал, который free memory не показывает
- cgroup v2: мягкая граница, жёсткий предел и целостный kill
- Systemd unit для vLLM: application gate поверх инфраструктуры
- systemd-oomd: кого завершать до глобального OOM
- Наблюдаемость: алерт должен отвечать «почему», а не только «сколько»
- Нагрузочный rollout: проверяем границы, а не устраиваем краш-тест в проде
- Аварийный runbook и вывод: сохранить узел, затем вернуть трафик
API отвечает всё медленнее, GPU при этом загружен не полностью, а через несколько минут ядро убивает случайный процесс: это типичная авария LLM-инференса при дефиците host RAM. Причина часто не в объёме VRAM, а в конкуренции model loader, page cache, токенизаторов, CPU offload и соседних сервисов за обычную оперативную память. Если смотреть только на free memory, момент деградации легко пропустить. Ниже разберём production-runbook, который связывает PSI, cgroup v2, systemd-oomd и настройки vLLM в одну проверяемую систему защиты.
Готовы перейти на современную серверную инфраструктуру?
В King Servers мы предлагаем серверы как на AMD EPYC, так и на Intel Xeon, с гибкими конфигурациями под любые задачи — от виртуализации и веб-хостинга до S3-хранилищ и кластеров хранения данных.
- S3-совместимое хранилище для резервных копий
- Панель управления, API, масштабируемость
- Поддержку 24/7 и помощь в выборе конфигурации
Результат регистрации
...
Создайте аккаунт
Быстрая регистрация для доступа к инфраструктуре
Почему LLM-сервис тормозит до OOM: два контура памяти
У GPU-сервера есть как минимум два независимых контура: VRAM ускорителя и host RAM узла. В VRAM лежат веса, активации и KV cache; в оперативной памяти живут Python-процессы, токенизация, memory-mapped файлы, page cache, pinned buffers, CPU offload и служебные процессы. Ошибка начинается, когда команда видит свободные гигабайты на GPU и делает вывод, что памяти достаточно для всего сервиса.
Представьте узел с несколькими GPU, где модель успешно загрузилась и health check зелёный. Затем приходит пакет длинных промптов, растёт очередь, токенизаторы создают больше объектов, а ядро вытесняет файловый кэш. GPU ждёт данные, p99 увеличивается, но метрика VRAM выглядит привычно. Низкая или рваная GPU utilization может быть следствием давления на host RAM, а не проблемой CUDA.
Разводите симптомы по слоям. Для GPU проверяйте свободную VRAM, ошибки аллокатора, KV-cache usage и частоту preemption. Для хоста смотрите PSI, swap in/out, major page faults, reclaim, memory.events и OOM-журнал. Полезный контекст по выбору конфигурации даёт материал о GPU-серверах для машинного обучения, но здесь фокус уже: как не дать работающему inference-сервису разрушить узел.
Infrastructure gate: cgroup v2 смонтирован, PSI доступен, systemd ведёт memory accounting. Application gate: сервис выдерживает целевой контекст и параллелизм без роста host-memory stall. Отрицательная проверка обязательна: временно поднимите конкурентную нагрузку в отдельном slice и убедитесь, что она не вытесняет критичный inference-процесс за его защитную границу.
Сначала бюджет: host RAM, VRAM, offload и запас на всплеск
Лимиты нельзя выбирать по принципу «занято сейчас плюс десять процентов». Снимите профиль по фазам: холодная загрузка модели, прогрев, обычный поток запросов, длинный prefill, пик параллелизма и graceful restart. Для каждой фазы запишите RSS/anon, file cache, pinned memory, swap usage, VRAM и p95/p99. Максимум одной метрики без временной привязки мало полезен: важно увидеть, какая операция создала давление и что происходило с задержкой.
У vLLM параметры --gpu-memory-utilization, --swap-space и --cpu-offload-gb решают разные задачи. Первый ограничивает долю GPU memory для экземпляра. Swap space в аргументах движка относится к CPU memory на GPU, а CPU offload переносит часть весов в host RAM и требует передачи по CPU–GPU interconnect на каждом forward pass. Offload «виртуально» расширяет VRAM, но физически съедает оперативную память и может добавить задержку. Семантику проверяйте по документации вашей версии vLLM: интерфейс развивается.
Практический бюджет складывается из измеренного working set критичного сервиса, памяти на штатный spike, резерва операционной системе и отдельного лимита для batch-задач. Не защищайте memory.min всё подряд: чрезмерная жёсткая защита способна оставить ядру нечего reclaim и приблизить глобальный OOM. Для начала обычно безопаснее MemoryLow для важного slice, MemoryHigh как ранняя зона торможения и MemoryMax как последний барьер.
Спросите себя: что должно случиться при ошибке расчёта? Хороший ответ — «низкоприоритетная очередь замедлится или будет перезапущена, API сохранит управляемую деградацию». Плохой — «OOM killer выберет кого-нибудь на всём хосте». Экономический контекст такого запаса удобно связать со статьёй о стоимости одного LLM-запроса: неиспользуемый резерв стоит денег, но незапланированный простой обычно дороже.
Чтобы превратить профиль в рабочий лимит, заведите простую ведомость по каждому процессу unit: engine workers, API frontend, tokenizer pool, metrics exporter и model loader. Отдельно посчитайте файловый кэш checkpoint, потому что он может выглядеть как «занятая память», но освобождается иначе, чем anonymous RSS. Снимайте не один пик, а минимум три повторения холодного старта: загрузка зависит от состояния page cache и параллельности чтения. Если model loader кратковременно удваивает working set при смене revision, лимит должен выдерживать именно эту фазу либо deployment обязан использовать blue-green узел.
Не забывайте про NUMA. На многосокетном сервере формально свободная RAM может находиться далеко от CPU и PCIe root complex нужного GPU. Это не всегда вызывает OOM, но способно увеличить latency и трафик между сокетами. Сравните memory.numa_stat, размещение GPU и CPU affinity во время прогона. Решение не сводится к жёсткому pinning: сначала докажите, что удалённые обращения влияют на SLO, затем меняйте NUMA policy и повторяйте тот же профиль.

PSI: ранний сигнал, который free memory не показывает
Pressure Stall Information измеряет не количество занятых байтов, а время, когда задачи не могут продуктивно работать из-за дефицита CPU, memory или IO. В /proc/pressure/memory строка some показывает долю времени, когда тормозит хотя бы часть задач, а full — когда одновременно остановлены все non-idle задачи. Средние avg10, avg60 и avg300 помогают отличить короткий всплеск от устойчивого thrashing.
Это особенно полезно для LLM. Сервер может иметь несколько гигабайт available memory, но интенсивный reclaim уже задерживает токенизатор и подачу батчей на GPU. Обратная ситуация тоже возможна: памяти почти не осталось, однако page cache легко освобождается и latency не страдает. Поэтому алерт только по проценту RAM даёт и ложные тревоги, и пропущенные инциденты.
Начните с одновременного чтения system-wide и service-level PSI. В unified cgroup hierarchy у каждого cgroup есть собственный memory.pressure; это позволяет понять, тормозит ли именно inference unit или весь узел. Не копируйте чужой порог как универсальный. Сначала запишите baseline в спокойный период, затем контролируемо увеличьте concurrency и найдите PSI-уровень, на котором нарушается ваш SLO.
set -euo pipefail
test "$(stat -fc %T /sys/fs/cgroup)" = "cgroup2fs"
test -r /proc/pressure/memory
systemctl show llm-inference.service -p ControlGroup -p MemoryCurrent -p MemoryHigh -p MemoryMax
CGROUP="$(systemctl show -p ControlGroup --value llm-inference.service)"
cat /proc/pressure/memory
cat "/sys/fs/cgroup$CGROUP/memory.pressure"
cat "/sys/fs/cgroup$CGROUP/memory.events"
Отрицательная проверка здесь важнее красивого графика: создайте pressure только в тестовом slice и подтвердите, что service-level PSI растёт там, а не в критичном unit. Если растёт global full, эксперимент уже влияет на весь узел — остановите его. PSI не заменяет latency и error rate, но даёт причинный слой между «RAM занята» и «пользователь ждёт».
Интерпретируйте PSI вместе с формой трафика. Короткий рост some avg10 во время холодной загрузки модели может быть приемлем, если инстанс ещё не получает запросы. Тот же рост при стабильном request rate и одновременно увеличившемся time to first token уже указывает на реальную потерю производительности. Устойчивый full требует более срочной реакции: все runnable задачи в области измерения одновременно простаивают из-за памяти, поэтому добавление ещё одного worker обычно ухудшит ситуацию.
Записывайте абсолютный счётчик total и вычисляйте его прирост между scrape, а не полагайтесь только на готовые окна avg10/60/300. Это позволяет сопоставить stall time с вашим интервалом наблюдения и не потерять короткий инцидент. После изменения лимита сбрасывать PSI нельзя, поэтому отмечайте момент конфигурации аннотацией и сравнивайте дельты до и после. Так runbook остаётся воспроизводимым даже при разных scrape intervals.

cgroup v2: мягкая граница, жёсткий предел и целостный kill
В cgroup v2 memory.high — основной throttling boundary: при превышении процессы попадают под тяжёлый reclaim, но OOM killer не вызывается только из-за этой границы. Это полезная жёлтая зона. memory.max — жёсткий предел: если reclaim не помогает, OOM происходит внутри cgroup. Между ними нужен запас, чтобы автоматика или дежурный успели снизить нагрузку.
memory.low даёт best-effort защиту от reclaim, а memory.min — жёсткую. На одном GPU-узле разумно защищать минимальный working set inference API через low, ограничивать batch через high/max и не раздавать min без доказанной необходимости. Иначе сумма обещаний детям окажется больше физической RAM, а защита превратится в источник постоянных OOM.
Включите memory.oom.group, если частичный kill разрушает целостность сервиса. Для engine с несколькими worker-процессами гибель одного процесса может оставить API живым формально, но неспособным обслуживать часть тензорного параллелизма. Групповой kill и чистый restart часто лучше, чем «полуживой» unit. Однако readiness должен снять трафик до перезапуска.
[Service]
MemoryAccounting=yes
MemoryLow=96G
MemoryHigh=176G
MemoryMax=192G
MemorySwapMax=16G
OOMPolicy=stop
Restart=on-failure
RestartSec=15s
Числа выше — только форма конфигурации, не рекомендация для конкретного сервера. Значения зависят от модели, числа GPU, offload, контекста и соседей. После daemon-reload проверьте эффективные свойства через systemctl show и файлы cgroup. Отрицательный тест: процесс в соседнем batch slice должен упереться в свой MemoryHigh и не получить возможность занять резерв inference unit.
Различайте anonymous memory, file cache и swap. Жёсткий запрет swap через MemorySwapMax=0 уменьшает непредсказуемые задержки, но сокращает время на реакцию и может ускорить OOM при всплеске anonymous allocations. Большой swap, наоборот, способен сохранить процесс ценой секундных пауз. Выбирайте политику нагрузочным тестом: наблюдайте swap-in rate и tail latency, а не спорьте о swap абстрактно. Для online inference часто нужен небольшой контролируемый запас, тогда как batch slice может получить больше пространства и меньший приоритет.
Счётчик memory.events:high ожидаемо растёт, когда MemoryHigh используется как рабочий throttle. Ошибкой является не любое событие high, а корреляция с нарушением SLO или отсутствие восстановления после снятия нагрузки. max, oom и oom_kill трактуйте строже: они показывают, что запас между мягкой и жёсткой границей исчерпан. Сохраните предыдущие значения счётчиков до теста, иначе старый инцидент легко принять за новый.

Systemd unit для vLLM: application gate поверх инфраструктуры
Лимит cgroup защищает хост, но сам по себе не гарантирует полезную работу приложения. Поэтому разделяйте два допуска. Infrastructure gate подтверждает unified cgroup, memory controller, PSI, swap policy и эффективные systemd properties. Application gate проверяет загрузку модели, readiness, короткий запрос, длинный prefill, concurrency и graceful termination. Только оба PASS разрешают возвращать инстанс в балансировщик.
Запускайте vLLM с явно зафиксированной версией пакета и документированными аргументами этой версии. Не переносите команду из старого runbook вслепую: набор engine arguments меняется. Уменьшение --gpu-memory-utilization оставляет VRAM headroom, но не исправляет нехватку host RAM. А увеличение --cpu-offload-gb может усилить host pressure.
[Unit]
Description=LLM inference service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=llm
Group=llm
EnvironmentFile=/etc/llm-inference/environment
ExecStart=/opt/vllm/bin/vllm serve /models/current --host 127.0.0.1 --port 8000 --gpu-memory-utilization 0.88 --max-model-len 16384
TimeoutStopSec=120s
KillSignal=SIGTERM
OOMPolicy=stop
Restart=on-failure
RestartSec=15s
[Install]
WantedBy=multi-user.target
Значения 0.88 и 16384 демонстрационные: подставьте результаты профиля, а не воспринимайте их как безопасные defaults. Секреты храните в защищённом environment file или secret manager, не в unit. После старта проверьте отрицательную границу: запрос с контекстом выше разрешённого должен быть отклонён приложением, а не довести процесс до OOM.
В Kubernetes те же принципы переносятся на pod/container limits и node-level eviction, но семантика не идентична systemd unit. Для оркестрационного контекста смотрите материал про Kubernetes для AI inference; не смешивайте его настройки с host-level runbook без проверки, кто реально владеет cgroup.

systemd-oomd: кого завершать до глобального OOM
systemd-oomd — userspace OOM killer, который использует cgroups v2 и PSI, чтобы реагировать раньше глобального kernel OOM. Он может выбирать потомка наблюдаемого slice по memory pressure или высокой доле swap. Цель — не «никогда не убивать процессы», а завершить управляемую жертву до того, как узел потеряет отзывчивость.
Политика должна выражать приоритеты бизнеса. Критичный online inference получает защиту и консервативный предел; batch embeddings, переиндексация RAG, конвертация весов и тестовые notebooks размещаются в отдельных slices и считаются restartable. Не включайте мониторинг на корневом slice без понимания дерева: слишком широкая область выбора может привести к неожиданной жертве.
Начните с oomctl и просмотра кандидатов, затем включайте ManagedOOMMemoryPressure=kill на конкретном slice. Порог pressure и длительность оценивайте по нагрузочным данным, а не по примеру из интернета. Слишком чувствительная настройка создаст flapping; слишком мягкая оставит systemd-oomd без времени на реакцию. Наличие swap обычно даёт сервису и oomd больше времени, но интенсивный swap для latency-sensitive LLM сам становится причиной деградации.
Мини-кейс: ночью batch job распаковывает новый checkpoint, page cache и anonymous memory растут, PSI some держится выше baseline. Правильная политика завершает batch slice, API остаётся доступным, а deploy controller повторяет задачу позже. Неправильная — защищает все workloads одинаково, и сервер доходит до global thrashing. После теста убедитесь через журнал и D-Bus signal, какая cgroup была завершена и почему.
Вводите oomd по этапам. Сначала неделю собирайте PSI, swap и oomctl без политики kill, чтобы увидеть реальных кандидатов. Затем включите действие только на restartable batch slice и проведите контролируемый pressure test. После успешной проверки добавляйте более широкие области наблюдения. У каждого кандидата должен быть владелец, понятный restart и ограничение на частоту повторов; иначе oomd спасёт узел, но создаст бесконечный цикл перезапусков и повторной загрузки checkpoint.
Защита критичного unit не должна означать безусловный иммунитет. Если сам inference-процесс течёт, бесконечно повышать его приоритет опасно для хоста. Задайте жёсткий MemoryMax, целостный kill, backoff и автоматическое снятие трафика. Проверяли ли вы, что балансировщик видит не только живой TCP-порт, но и готовность всех model workers после oomd или kernel OOM? Эта отрицательная проверка отличает восстановление от формального restart.

Наблюдаемость: алерт должен отвечать «почему», а не только «сколько»
Минимальный набор связывает четыре слоя. Пользовательский: request rate, queue time, time to first token, inter-token latency, p95/p99 и ошибки. GPU: utilization, VRAM, KV cache, preemption и ошибки CUDA. Host: available RAM, swap activity, major faults, IO latency и system-wide PSI. Cgroup: memory.current, memory.events, service PSI и число OOM kills.
Алерт по одному memory.current почти ничего не говорит. Намного полезнее условие: p99 растёт, service memory.pressure some устойчиво выше baseline, а счётчик memory.events:high увеличивается. Это указывает на reclaim внутри unit. Если PSI растёт на хосте, но не в inference cgroup, ищите соседа. Если VRAM OOM появляется без host PSI, расследование идёт в сторону KV cache, batch size и длины контекста.
Не прибивайте абсолютные PSI-пороги к статье: Linux даёт измерение времени stall, но допустимый уровень определяется SLO и профилем нагрузки. Запишите burn-rate правила для короткого и длинного окна. Короткое ловит резкий thrashing, длинное — медленную утечку или постепенное увеличение очереди. Добавьте аннотации deploy, model revision и изменение лимитов, иначе график покажет симптом без события.
set -euo pipefail
UNIT=llm-inference.service
CGROUP="$(systemctl show -p ControlGroup --value "$UNIT")"
date --iso-8601=seconds
systemctl show "$UNIT" -p ActiveState -p SubState -p MemoryCurrent -p MemoryPeak -p MemoryHigh -p MemoryMax
cat /proc/pressure/memory
cat "/sys/fs/cgroup$CGROUP/memory.pressure"
cat "/sys/fs/cgroup$CGROUP/memory.events"
cat "/sys/fs/cgroup$CGROUP/memory.stat"
journalctl -u "$UNIT" --since "-15 min" --no-pager
oomctl --no-pager
Такой bundle помогает сравнить инфраструктурную и прикладную картину в один момент. Храните его без секретов и пользовательских промптов. Защитный контур стоит дополнить лимитами дорогих запросов: статья про rate limits и quotas показывает внешний слой, который не даёт одному клиенту превратить memory pressure в постоянный режим.
Разделите сигналы на ticket и page. Медленный рост working set при нормальном PSI — повод для расследования утечки в рабочее время. Одновременный рост full, p99 и queue time — повод будить дежурного. Единичный high во время canary-теста можно оставить аннотацией. Такой routing уменьшает шум и не приучает команду игнорировать memory alerts.
Для каждой страницы добавьте ближайшее безопасное действие прямо в runbook: ограничить concurrency, снять batch slice, вывести canary из балансировки или отменить rollout. Не помещайте в автоматизацию команду, которая увеличивает MemoryMax без верхней границы. Автоматика должна уменьшать нагрузку или изолировать сбой; расширение ресурса требует проверки физического резерва узла. После инцидента сравните, какой сигнал появился первым: PSI, latency, memory.high или swap. Этот порядок подскажет лучший ранний алерт для следующего случая.

Нагрузочный rollout: проверяем границы, а не устраиваем краш-тест в проде
Меняйте по одному классу параметров. Сначала зафиксируйте версию модели и vLLM, затем измерьте baseline без offload. После этого отдельно варьируйте concurrency, длину входа, max-model-len, GPU memory utilization и offload. Если одновременно поменять модель, лимиты cgroup и traffic profile, вы не узнаете, что именно улучшило или ухудшило устойчивость.
Тестовый сценарий должен пройти ступени: idle, обычная нагрузка, целевой пик, перегрузка и восстановление. На каждой ступени проверяйте application gate: readiness, ответы, latency и отсутствие повреждённых workers. Infrastructure gate: PSI, memory.events, swap и состояние oomd. Критерий формулируйте заранее: на целевом пике нет oom_kill, p99 внутри SLO, после снижения трафика PSI возвращается к baseline.
Добавьте отрицательные проверки границ. Batch slice должен замедлиться при своём MemoryHigh. Inference unit не должен читать device или model path, не предназначенный для него. Запрос сверх лимита контекста должен получить контролируемую ошибку. При искусственном pressure low-priority job должен стать кандидатом oomd раньше online API. Если граница обходится расширением привилегий, тест не пройден.
Rollout в production делайте canary-инстансом с небольшим процентом трафика и автоматическим снятием из балансировки при нарушении gate. Жизненный цикл версии, rollback и проверку качества удобно связать с материалом о LLMOps в продакшене. Здесь добавляется инфраструктурное условие: rollback должен учитывать не только качество ответов, но и working set новой версии.
Зафиксируйте acceptance sheet до прогона. Для каждой ступени укажите длительность, request mix, долю длинных контекстов, ожидаемый throughput, допустимый p99, верхнюю границу PSI и разрешённое число событий high. Отдельно запишите stop conditions: рост full pressure, ошибки CUDA, oom_kill, потеря worker readiness или необратимый рост очереди после снятия нагрузки. Без заранее заданных критериев команда легко объявит успешным тест, который просто не успел дойти до точки отказа.
Финальная проверка — восстановление, а не достижение пика. Уменьшите трафик и убедитесь, что очередь очищается, PSI возвращается к baseline, page cache стабилизируется, а latency не остаётся высокой из-за swap-in. Затем выполните один controlled restart и измерьте повторный cold start. Только после этого canary можно расширять. Если восстановление требует ручной очистки процессов или повторного reboot, application gate остаётся FAIL, даже если пиковая пропускная способность выглядела хорошо.

Аварийный runbook и вывод: сохранить узел, затем вернуть трафик
Когда p99 уже растёт и PSI подтверждает memory stall, первая задача — остановить усиление инцидента. Заморозьте rollout, ограничьте входной трафик, снимите batch-задачи и не запускайте параллельный restart всех replicas. Проверьте, где давление: system-wide или внутри unit. Затем сравните memory.events, journal, oomctl и application metrics. Не очищайте page cache «на всякий случай»: это уничтожает полезный кэш и редко лечит причину.
Если memory.events:high растёт только в inference unit, временно уменьшите concurrency или контекст, отключите необязательный CPU offload и дайте reclaim завершиться. Если виноват соседний slice, остановите или ограничьте его. При oom_kill проверяйте целостность группы и readiness перед возвратом трафика. Если узел близок к global full stall, безопаснее вывести его из балансировщика и восстановить сервис на чистом инстансе.
После стабилизации сохраните incident bundle, отметьте версию модели, параметры vLLM, лимиты и форму трафика. Исправление должно менять проверяемую границу: бюджет host RAM, MemoryHigh/Max, приоритет slice, rate limit или rollout gate. Простое увеличение RAM без анализа может лишь отложить повторение утечки.
Главный вывод прост: OOM — поздний сигнал. Надёжный LLM-инференс строится так, чтобы PSI заранее показывал потерю продуктивного времени, cgroup v2 локализовал pressure, systemd-oomd выбирал управляемую жертву, а application gate не возвращал полуживой engine в трафик. Примените runbook сначала на canary GPU-сервере, запишите свой baseline и пороги, затем переносите конфигурацию на остальные узлы. Если инфраструктуру нужно подобрать или изолировать под этот профиль, команда King Servers может помочь сверить RAM, GPU, storage и сеть до production rollout.