Сервер с 64 ГБ ушёл в своп из-за одного лимита в systemd
Мониторинг показывает 64 ГБ RAM на борту и только треть занято, а конкретный сервис при этом методично долбит диск свопом и тормозит на каждом запросе. Первая мысль — утечка памяти в приложении, вторая — «монитор врёт». На деле причина обычно куда скучнее и куда быстрее чинится: у сервиса есть собственный, отдельный от общесистемного, лимит памяти в systemd, и он в него упирается независимо от того, сколько RAM свободно на хосте. Разберём инцидент по шагам — от симптома до конкретной строчки в конфиге юнита.
Содержание
Симптом: своп растёт, а free показывает пустоту
Сценарий стандартный для дежурного инженера. Алерт: «высокая задержка на API», клиенты жалуются на подвисания. Первый рефлекс — зайти и посмотреть на память:
free -h
total used free shared buff/cache available
Mem: 62Gi 18Gi 6.2Gi 412Mi 38Gi 43Gi
Swap: 8.0Gi 6.1Gi 1.9Gi
Картина странная сама по себе: used — 18 ГБ из 62, available — 43 ГБ, а своп при этом забит на 6 ГБ из 8 и продолжает расти. Если бы дело было в общей нехватке физической памяти, available было бы близко к нулю, а buff/cache начал бы сжиматься первым — ядро освобождает кэш страниц раньше, чем начинает активно свопить анонимную память процессов. Здесь кэша полно (38 ГБ), свободно ещё 6 с лишним гигабайт чистого free, а своп всё равно активен. Это первый сигнал: проблема не в сервере в целом, а где-то локально — у конкретного процесса или группы процессов.
Дальше смотрим, кто вообще генерирует своп-трафик:
vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
2 3 6291456 6502144 812340 39820112 180 920 210 1440 9800 21000 8 6 70 16 0
si/so (swap-in/swap-out) заметно ненулевые и держатся стабильно — не разовый всплеск, а постоянная фоновая активность. Значит своп используется не эпизодически (как бывает при обычном давлении памяти на старте процесса), а системно, на протяжении всего времени наблюдения.
Первая гипотеза: утечка памяти в приложении
Логичный первый шаг — заподозрить утечку в самом приложении. Рост потребления памяти конкретным процессом со временем — это ранний и надёжный сигнал проблемы, который стоит проверять в первую очередь, когда видно, что памяти не хватает именно локально. Смотрим RSS процесса во времени:
ps -o pid,rss,vsz,cmd -C my-service
PID RSS VSZ CMD
18422 1980412 2210000 /usr/bin/my-service --config=/etc/my-service/config.yaml
RSS около 2 ГБ, и по логам за несколько часов он не растёт монотонно — колеблется в диапазоне 1.8–2.1 ГБ под нагрузкой и откатывается назад после пиков. Классический профиль утечки — плавный неостановимый рост без откатов — тут не наблюдается. Гипотеза утечки не подтверждается графиком, но подозрение остаётся: раз своп активен, память куда-то упирается. Вопрос в том, в какую именно память.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПроверка через cgroups: находим настоящий потолок
Раз общесистемной нехватки нет, а конкретный сервис ведёт себя так, будто памяти мало — логично проверить, не ограничен ли он на уровне cgroups. В современных дистрибутивах systemd управляет ресурсами юнитов именно через cgroups v2, и лимит может быть выставлен прямо в unit-файле, независимо от того, сколько физической памяти есть на сервере.
Первая команда — посмотреть, что видит сам systemd для конкретного юнита:
systemctl show my-service.service | grep -i memory
MemoryAccounting=yes
MemoryMin=0
MemoryLow=0
MemoryHigh=infinity
MemoryMax=2147483648
MemorySwapMax=infinity
MemoryAvailable=41943040
CPUAccounting=yes
Вот и разгадка. MemoryMax=2147483648 — это ровно 2 ГиБ (2 × 1024³ байт). Сервис физически не может занять больше этого объёма анонимной памятью — ядро режет его через cgroup, независимо от того, что на сервере свободно ещё 40+ ГБ. При этом MemorySwapMax=infinity означает, что свопить сервису разрешено сколько угодно — вот почему вместо честного OOM-килла процесс просто уходит в своп при каждом приближении к потолку в 2 ГБ, а не падает и не получает ошибку.
Это же можно увидеть напрямую в файловой системе cgroups v2, без обёртки systemctl:
cat /sys/fs/cgroup/system.slice/my-service.service/memory.max
2147483648
cat /sys/fs/cgroup/system.slice/my-service.service/memory.current
2138931200
cat /sys/fs/cgroup/system.slice/my-service.service/memory.swap.current
6442450944
memory.current практически вплотную к memory.max — сервис реально упирается в потолок. memory.swap.current показывает 6 ГБ — ровно то, что видно было в free -h. Совпадение полное: весь своп на сервере генерирует один этот юнит, потому что ему тесно в его собственном лимите, а не потому что тесно всему серверу.
Разница между уровнями диагностики принципиальна:
| Что смотрим | Что видим | Вывод |
|---|---|---|
free -h на хосте | 43 ГБ available из 62 | Сервер в целом не испытывает нехватки памяти |
ps / RSS процесса | ~2 ГБ, без явного роста | Утечки в приложении не видно |
systemctl show / cgroups | MemoryMax=2Gi, current≈max | Сервис упирается в собственный искусственный лимит |
Именно третий уровень — единственный, который прямо объясняет наблюдаемое поведение. Первые два уровня по отдельности вводят в заблуждение: сервер «в порядке», процесс «не течёт», а своп при этом реально есть.
Почему появился лимит в 2 ГБ
Дальше — вопрос «откуда взялась цифра 2147483648». В истории конфигураций (git-репозиторий с ansible-ролями или просто резервные копии /etc/systemd/system/) находим, что unit-файл my-service.service был создан копированием с другого юнита — вспомогательного воркера, который действительно рассчитан на 2 ГБ и прекрасно себя в этом лимите чувствует:
# /etc/systemd/system/my-service.service
[Unit]
Description=My Service (main worker)
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/my-service --config=/etc/my-service/config.yaml
Restart=on-failure
MemoryAccounting=yes
MemoryMax=2G
MemorySwapMax=infinity
User=myservice
Комментарий # main worker и явный MemoryMax=2G — след того, что при клонировании шаблона (например, при добавлении нового инстанса сервиса или при миграции на новый сервер) числовой лимит скопировался вместе с остальными директивами, а никто не пересчитал его под реальные потребности именно этого, куда более прожорливого сервиса. Тот вспомогательный воркер спокойно работал в 2 ГБ годами, поэтому лимит никого не смущал и не всплывал ни в одном ревью — до тех пор, пока не переехал на «правильный» сервис с другим профилем нагрузки.
Отдельно стоит подчеркнуть механику systemd, которая делает такую ошибку особенно незаметной: MemoryMax без явного MemorySwapMax=0 не убивает процесс при достижении лимита, а позволяет ядру вытеснять его страницы в своп в рамках cgroup. Это отличается от полного исчерпания физической памяти сервера, при котором обычно либо срабатывает OOM-killer, либо всё резко тормозит целиком. Здесь же сервис тихо и постепенно деградирует по производительности, оставаясь при этом «живым» — что и отсрочило обнаружение проблемы.
Как убедиться, что дело именно в лимите, а не в чём-то ещё
Прежде чем менять конфиг, стоит закрыть вопрос до конца — временно поднять лимит и посмотреть, исчезнет ли своп. Это дешёвый и безопасный эксперимент, если делать его через systemctl set-property, не трогая сам unit-файл:
systemctl set-property my-service.service MemoryMax=8G
systemctl show my-service.service | grep MemoryMax
MemoryMax=8589934592
Команда set-property меняет значение в runtime-оверрайде cgroup немедленно, без рестарта сервиса — это удобно именно для диагностики, потому что не нужно обрывать текущие соединения. Через несколько минут наблюдения:
watch -n2 'cat /sys/fs/cgroup/system.slice/my-service.service/memory.swap.current'
Если memory.swap.current перестаёт расти и постепенно (по мере обращений к уже засвопленным страницам) снижается, а memory.current стабилизируется заметно ниже нового потолка — гипотеза подтверждена окончательно. В нашем случае процесс успокоился на уровне около 5.4 ГБ RSS под пиковой нагрузкой, что и стало ориентиром для постоянного значения лимита — с запасом, а не впритык.
Важно: systemctl set-property без флага --runtime меняет конфиг постоянно, записывая drop-in в /etc/systemd/system.control/ или аналогичную директорию. Для чисто диагностической проверки лучше явно указывать --runtime, чтобы изменение пропало после перезагрузки сервера, а постоянное значение потом внести осознанно в основной unit-файл или в отдельный override.
Исправление и постоянный лимит
Как только ориентир по реальному потреблению получен, лимит фиксируется явно и с запасом — не «уберём совсем», а осознанное значение, которое защищает сервер от одного взбесившегося сервиса, но не мешает нормальной работе. Убирать MemoryMax полностью в проде обычно неправильно: без лимита один процесс с настоящей утечкой может утащить в своп или в OOM весь сервер, а не только себя. Задача — не снять ограничение, а поставить его на правильном уровне.
Правим unit через override, не трогая оригинальный файл напрямую (это переживёт обновление пакета, если сервис ставится из репозитория):
systemctl edit my-service.service
В открывшемся drop-in-файле (обычно /etc/systemd/system/my-service.service.d/override.conf):
[Service]
MemoryMax=8G
MemoryHigh=6G
MemorySwapMax=1G
Здесь добавлен ещё один полезный параметр — MemoryHigh. В отличие от MemoryMax (жёсткий потолок с вытеснением в своп), MemoryHigh включает мягкое торможение (throttling) выделения памяти ещё до достижения максимума — это даёт сервису шанс подрегулировать поведение (например, если внутри есть кэши, которые можно сбросить) до того, как дело дойдёт до реального свопа. MemorySwapMax=1G дополнительно ограничивает, сколько именно свопа юнит может выработать даже если упрётся в потолок — так вместо неограниченного роста своп-трафика при следующей аномалии сервис получит явную деградацию с понятным пределом, которую проще заметить на графике.
После правки — применяем и перезапускаем:
systemctl daemon-reload
systemctl restart my-service.service
systemctl show my-service.service | grep -i memory
И обязательно проверяем через /sys/fs/cgroup/..., что новые значения реально применились именно к работающему процессу, а не только видны в выводе systemctl show — иногда drop-in применяется только после полного daemon-reload, а не только restart.
Как не наступить на эти же грабли снова
Разбор одного инцидента ничего не стоит, если урок не превращается в процесс. Несколько конкретных практик, которые снимают именно этот класс проблем:
- Явно проверяйте и документируйте лимиты cgroups/systemd для каждого критичного сервиса. Не полагаться на память о том, «что там вроде настроено» — за полгода-год конфиги меняются, копируются, наследуются от старых версий. Раз в квартал (или при каждом изменении unit-файла) прогоняйте по всем боевым юнитам:
for u in $(systemctl list-units --type=service --state=running --no-legend | awk '{print $1}'); do
echo "== $u =="; systemctl show "$u" | grep -E '^Memory(Max|High|Min|Low|SwapMax)='
done
и сверяйте вывод с тем, что реально нужно сервису по факту нагрузки, а не с тем, что кто-то когда-то вписал.
- При копировании шаблонов systemd между разными сервисами — обязательная проверка и корректировка числовых лимитов. Копия конфигурации — нормальная практика, но каждое число в ней (память,
TasksMax,CPUQuota) должно быть осознанно пересчитано под новый сервис, а не унаследовано автоматически. Хороший минимальный ритуал в code review — явный вопрос «а лимиты в этом unit-файле точно посчитаны под этот сервис, а не скопированы?». - Мониторьте использование памяти на уровне отдельного сервиса, а не только сервера целиком. Общесистемный
free/availableв этом инциденте не показал вообще ничего подозрительного — сервер выглядел здоровым все время, пока сервис страдал. Полезно снимать метрики именно из cgroups по каждому критичному юниту (memory.current,memory.max,memory.swap.current) и заводить алерт не только на «мало общей RAM», но и на «memory.currentприближается кmemory.maxконкретного сервиса» — это ловит проблему за часы, а не когда клиенты уже жалуются на задержки. Отдельно стоит связать это с общим антипаттерном подмены нормальной памяти свопом — активный своп почти всегда сигнал, что где-то стоит лимит меньше реальной потребности, будь то на уровне сервера, контейнера или, как здесь, конкретного systemd-юнита. - Разделяйте диагностику по уровням явно, а не полагаясь на интуицию. Порядок «сервер → процесс → cgroup» экономит часы: сначала
free/vmstatна весь хост, затемps/RSS процесса во времени, и только затем —systemctl showи файлы в/sys/fs/cgroup/. Пропуск последнего шага — типичная причина, по которой подобные инциденты неделями списывают на «непонятную утечку», хотя устройство самих cgroups прямо объясняет наблюдаемое поведение за одну команду.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Почему сервис не падает с OOM, а просто уходит в своп?
Потому что MemorySwapMax по умолчанию не ограничен (infinity), и ядро в рамках cgroup сначала пытается вытеснить страницы в своп, а не убивать процесс. OOM-killer в рамках cgroup сработает, только если и MemoryMax, и доступный своп для этой cgroup будут исчерпаны одновременно — при MemorySwapMax=infinity и наличии свопа на сервере это может не наступить очень долго, а деградация тем временем идёт постоянно.
Можно ли вообще не ставить MemoryMax, чтобы не думать об этом?
Технически можно, но тогда один процесс с реальной утечкой или аномальной нагрузкой способен вытеснить в своп или уронить по OOM весь сервер целиком, включая другие сервисы на нём. Осмысленный лимит с запасом — это не бюрократия, а изоляция сбоя одного сервиса от остальных. Вопрос не «ставить или нет», а «правильное ли выставлено число».
Как быстро понять, что дело в лимите, а не в общей нехватке памяти сервера?
Смотрите на free -h/vmstat: если available заметно больше нуля и buff/cache не сжимается, а своп при этом растёт — общесистемной нехватки нет. Дальше один systemctl show <service> | grep -i memory обычно сразу показывает искусственный потолок, если он есть.
Отличаются ли команды диагностики для контейнеров (Docker/Podman) от systemd-юнитов?
Механизм тот же самый — cgroups v2, только лимит задаётся не в unit-файле, а в параметрах запуска контейнера (--memory, mem_limit в compose). Файлы в /sys/fs/cgroup/ в обоих случаях читаются одинаково, разница только в том, где искать сам источник числа — в systemd unit или в конфигурации контейнера.
Нужно ли перезапускать сервис после изменения MemoryMax через systemctl edit?
Да, daemon-reload подхватывает изменение unit-файла, но само значение cgroup применяется к процессу либо при его следующем запуске, либо сразу, если менять через systemctl set-property без флага --runtime (тогда правится и текущая cgroup, и постоянный конфиг). Для чистоты лучше делать daemon-reload и restart последовательно и затем сверять фактическое значение в /sys/fs/cgroup/.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →