Сервис Directum RX «съел всю память»: сборщик мусора .NET, лимиты контейнера и как отличить утечку от настройки
Почему сервисы Directum RX на .NET занимают столько памяти и не отдают её после нагрузки. Серверный и рабочий режимы сборщика мусора, число куч по ядрам, что меняется в контейнере с лимитом, переменные DOTNET_GC* и их шестнадцатеричные значения, dotnet-counters, и как за сутки понять, утечка это или настройка.
Коротко. Серверный сборщик .NET заводит кучу на каждое ядро и не спешит отдавать память. Без лимита контейнера он ориентируется на память всего сервера. Задайте лимиты, при необходимости ограничьте кучу и число куч переменными
DOTNET_GC*(значения шестнадцатеричные), а утечку отличайте двумя замерами с разницей в сутки и счётчикамиdotnet-counters.
На сервере Directum RX кончается память. top показывает, что больше всего занимают процессы dotnet, каждый
по несколько гигабайт, и после окончания рабочего дня потребление почти не падает. Первая мысль об утечке,
и с ней идут к вендору. Чаще причина в том, как настроен сборщик мусора .NET и знает ли он, сколько памяти ему
на самом деле положено. Разбираем, как это проверить до обращения в поддержку.
Симптом
- Процессы
dotnetзанимают гигабайты и не отдают память после снижения нагрузки. - На сервере с большим числом ядер сервисы занимают заметно больше памяти, чем на тестовом с четырьмя ядрами, при той же нагрузке.
- В контейнере без лимита памяти сервис растёт, пока не упрётся в память сервера, и ядро убивает процесс: Out of memory: Killed process (dotnet).
- В Kubernetes под перезапускается с
OOMKilled, код выхода 137: разбор в справочнике. freeпоказывает почти ноль свободной памяти, хотя ничего не падает.
Почему так
Сначала про «свободную память»
Linux занимает свободную память под кэш файлов и отдаёт её по первому требованию. Смотреть нужно на колонку
available в free -h, а не на free. На сервере с PostgreSQL или Elasticsearch почти нулевое free это
нормальная работа: кэш СУБД и поиска. Проблема есть, если мала именно available или если в журнале ядра
появляются убийства процессов.
Сборщик мусора .NET
Сервисы RX работают на .NET, а память в .NET освобождает сборщик мусора. У него два основных режима.
| Рабочий (Workstation) | Серверный (Server) | |
|---|---|---|
| Число куч | одна | по одной на каждое доступное процессу ядро |
| Сборка | реже останавливает приложение надолго, но проходит чаще | реже, но обрабатывает больше за раз |
| Память | экономнее | заметно больше: у каждой кучи свой запас |
| По умолчанию | у настольных и консольных приложений | у веб-приложений ASP.NET Core |
Серверный режим рассчитан на пропускную способность: сборщик не торопится возвращать память операционной системе, потому что она скоро снова понадобится. На сервере с 32 ядрами это 32 кучи, у каждой свой бюджет молодого поколения. Отсюда эффект «на большом сервере ест больше»: процесс видит больше ядер и заводит больше куч.
Что ещё влияет:
- Лимит контейнера. Современный .NET (начиная с .NET Core 3.0, с cgroup v2 с .NET 5) читает лимиты cgroup. Если у контейнера задан лимит памяти, сборщик по умолчанию ограничивает кучу 75 % от него. Если лимита нет, ориентиром становится память всего сервера, и каждый сервис считает, что может занять почти всё.
- Лимит процессора. Число куч в серверном режиме считается от доступных процессу ядер, в контейнере с ограничением CPU их меньше.
- Неуправляемая память. Сборщик управляет только объектами .NET. Память, которую выделяют нативные библиотеки (обработка изображений, конвертация документов, драйверы), в размер кучи не входит и в счётчиках сборщика не видна.
- Адаптивный режим. В .NET 8 появился режим DATAS, в котором серверный сборщик подстраивает число куч под нагрузку. В .NET 8 он включается настройкой, в .NET 9 включён по умолчанию для серверного режима. Какая версия .NET в вашей версии RX, смотрите в документации вендора.
Диагностика
Сколько и где
# Процессы dotnet по занятой памяти (RSS в МБ)
ps -eo pid,rss,args --sort=-rss | awk '/dotnet/ {printf "%s %d МБ %s\n", $1, $2/1024, $3" "$4}' | head
# Контейнеры: память к лимиту
docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}'
kubectl top pods -n rx --sort-by=memory
Если у контейнера в MEM USAGE / LIMIT справа стоит объём всего сервера, лимита нет.
В каком режиме сборщик
Режим задаётся в *.runtimeconfig.json рядом с приложением (System.GC.Server) или переменными окружения,
которые перекрывают файл.
# Внутри контейнера или для процесса на хосте
cat /proc/<pid>/environ | tr '\0' '\n' | grep -i -E '^DOTNET_|^COMPlus_'
grep -h -i '"System.GC' /путь/к/сервису/*.runtimeconfig.json
Что происходит внутри
dotnet-counters из открытых инструментов диагностики .NET показывает размер кучи, число сборок по поколениям
и долю времени в сборке.
dotnet-counters monitor -p <pid> --counters System.Runtime
На что смотреть:
| Счётчик | Что означает |
|---|---|
| GC Heap Size | сколько занимают живые объекты .NET |
| Working Set | сколько процесс занимает в памяти целиком |
| Gen 2 GC Count | полные сборки; частые полные сборки говорят о нехватке памяти под нагрузку |
| % Time in GC | доля времени в сборке; десятки процентов означают, что сервис больше убирает, чем работает |
Если Working Set намного больше GC Heap Size, память уходит вне кучи: в нативные библиотеки или в запас, который сборщик держит, но не использует. Если растёт сама куча, растёт число живых объектов.
Инструменты ставятся в контейнер временно или запускаются из соседнего контейнера с общим пространством
процессов и общим /tmp: через него идёт канал диагностики .NET.
Утечка или настройка
Снимите потребление сервиса дважды, с разницей в сутки, в одно и то же время при похожей нагрузке.
- Растёт от замера к замеру и не падает ночью: похоже на утечку. Нужен снимок кучи (
dotnet-gcdump collect -p <pid>) или полный дамп (dotnet-dump collect), и это материал для разработчиков: вендора или своей прикладной разработки. - Выходит на плато и держится: это рабочий объём плюс запас сборщика. Утечки нет, есть настройка.
Что сделать
- Задать лимиты памяти контейнерам. Лимит по измеренному потреблению на плато плюс 20–30 % запаса. Сборщик увидит лимит и будет держать кучу в его пределах. В Kubernetes одновременно задаётся и запрос памяти, близкий к реальному потреблению: заниженный запрос позволяет планировщику положить на узел больше подов, чем помещается в память.
- Ограничить кучу явно, если лимита контейнера нет. Переменная
DOTNET_GCHeapHardLimitзадаёт предел в байтах,DOTNET_GCHeapHardLimitPercentв процентах от доступной памяти. Значения этих переменных читаются в шестнадцатеричном виде:DOTNET_GCHeapHardLimitPercent=50означает 0x50, то есть 80 %, а не 50. Для 50 % пишется0x32, для 2 ГБ0x80000000. - Уменьшить число куч.
DOTNET_GCHeapCount(тоже шестнадцатеричное) ограничивает число куч серверного режима. На сервере с десятками ядер и несколькими сервисами RX это заметно снижает память при небольшой потере пропускной способности. - Переключить второстепенные сервисы в рабочий режим.
DOTNET_gcServer=0для фоновых сервисов с низкой нагрузкой. Для веб-сервера с большим числом пользователей так делать не стоит, сначала проверяется на нагрузочном тесте. - Попросить сборщик быть экономнее.
DOTNET_GCConserveMemory(от 0 до 9, с .NET 6) заставляет сборщик чаще уплотнять кучу и отдавать память. Цена: больше времени в сборке.
Переменные окружения задаются в описании контейнера: в значениях Helm-чарта для Kubernetes или в настройках запуска сервисов для установки на Docker, если ваша схема установки позволяет их передать. Любую из этих настроек сначала проверяйте на тестовом стенде под нагрузкой: экономия памяти почти всегда покупается временем ответа. Как измерить, что время ответа не ухудшилось, описано в статье про перцентили.
- Наблюдать. Алерт на долю памяти контейнера от лимита выше 90 % дольше 15 минут и на любой
перезапуск с
OOMKilled. Счётчики .NET можно отдавать в Prometheus, тогда рост кучи виден на графике за недели, до жалоб пользователей.
Чего не делать
- Не судить о нехватке по колонке
free. Смотритеavailableи журнал ядра. - Не запускать сервисы .NET в контейнерах без лимита памяти. Каждый будет считать своей всю память сервера.
- Не писать значения переменных
DOTNET_GC*в десятичном виде. Предел окажется другим, чем вы думаете. - Не ставить лимит контейнера впритык к рабочему потреблению. Первый пик нагрузки закончится
OOMKilled. - Не объявлять утечку по одному замеру. Нужно два замера с разницей в сутки при похожей нагрузке.
- Не перезапускать сервисы по расписанию вместо разбора. Это скрывает и настоящую утечку, и тесный лимит.
Похожая картина у вас?
Разберём ваш контур за 30 минут бесплатного созвона: версия RX, состав сервисов, что болит сильнее всего.
Написать