Коротко. Серверный сборщик .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), и это материал для разработчиков: вендора или своей прикладной разработки.
  • Выходит на плато и держится: это рабочий объём плюс запас сборщика. Утечки нет, есть настройка.

Что сделать

  1. Задать лимиты памяти контейнерам. Лимит по измеренному потреблению на плато плюс 20–30 % запаса. Сборщик увидит лимит и будет держать кучу в его пределах. В Kubernetes одновременно задаётся и запрос памяти, близкий к реальному потреблению: заниженный запрос позволяет планировщику положить на узел больше подов, чем помещается в память.
  2. Ограничить кучу явно, если лимита контейнера нет. Переменная DOTNET_GCHeapHardLimit задаёт предел в байтах, DOTNET_GCHeapHardLimitPercent в процентах от доступной памяти. Значения этих переменных читаются в шестнадцатеричном виде: DOTNET_GCHeapHardLimitPercent=50 означает 0x50, то есть 80 %, а не 50. Для 50 % пишется 0x32, для 2 ГБ 0x80000000.
  3. Уменьшить число куч. DOTNET_GCHeapCount (тоже шестнадцатеричное) ограничивает число куч серверного режима. На сервере с десятками ядер и несколькими сервисами RX это заметно снижает память при небольшой потере пропускной способности.
  4. Переключить второстепенные сервисы в рабочий режим. DOTNET_gcServer=0 для фоновых сервисов с низкой нагрузкой. Для веб-сервера с большим числом пользователей так делать не стоит, сначала проверяется на нагрузочном тесте.
  5. Попросить сборщик быть экономнее. DOTNET_GCConserveMemory (от 0 до 9, с .NET 6) заставляет сборщик чаще уплотнять кучу и отдавать память. Цена: больше времени в сборке.

Переменные окружения задаются в описании контейнера: в значениях Helm-чарта для Kubernetes или в настройках запуска сервисов для установки на Docker, если ваша схема установки позволяет их передать. Любую из этих настроек сначала проверяйте на тестовом стенде под нагрузкой: экономия памяти почти всегда покупается временем ответа. Как измерить, что время ответа не ухудшилось, описано в статье про перцентили.

  1. Наблюдать. Алерт на долю памяти контейнера от лимита выше 90 % дольше 15 минут и на любой перезапуск с OOMKilled. Счётчики .NET можно отдавать в Prometheus, тогда рост кучи виден на графике за недели, до жалоб пользователей.

Чего не делать

  • Не судить о нехватке по колонке free. Смотрите available и журнал ядра.
  • Не запускать сервисы .NET в контейнерах без лимита памяти. Каждый будет считать своей всю память сервера.
  • Не писать значения переменных DOTNET_GC* в десятичном виде. Предел окажется другим, чем вы думаете.
  • Не ставить лимит контейнера впритык к рабочему потреблению. Первый пик нагрузки закончится OOMKilled.
  • Не объявлять утечку по одному замеру. Нужно два замера с разницей в сутки при похожей нагрузке.
  • Не перезапускать сервисы по расписанию вместо разбора. Это скрывает и настоящую утечку, и тесный лимит.