TL;DR
Наши поды ASP.NET Core на Kubernetes убивались по OOM каждые несколько часов во время пилотного запуска. Управляемая куча была чистой — 95% памяти занимала нативная память, невидимая ни одному инструменту диагностики .NET. Причина: нестабильные мобильные соединения создавали тысячи зомби-соединений SignalR, каждое из которых несло нативные накладные расходы на уровне рантайма — состояние соединения, нативные структуры взаимодействия (interop) и множество мелких вызовов malloc(), которые рантайм делает на соединение. Эти выделения памяти попадали ниже порога 128 КБ mmap в glibc и фрагментировали внутреннюю кучу аллокатора по десяткам арен потоков (thread arenas). Освобождённая память никогда не возвращалась ОС. Буферы отправки TCP ядра для мёртвых сокетов добавляли ещё один невидимый слой. Исправление потребовало четырёх изменений на четырёх уровнях: агрессивные тайм-ауты SignalR (с 5 мин до 10 сек), уменьшение idle-тайм-аута ALB, дедупликация потоков и замена аллокатора glibc на jemalloc через LD\_PRELOAD. Поды стабилизировались ниже 1 ГБ. Ни одного OOM-kill с тех пор.
День ноль
Всё началось с алерта в середине утра. Один из наших производственных подов был убит по OOM. Само по себе — ничего необычного для нагруженной системы, кроме одного: это продолжалось снова и снова. В рабочие часы — примерно с 9 утра до 9 вечера, пока активны пользователи — поды раздувались до 4 ГБ и падали. Kubernetes перезапускал их, мобильные клиенты автоматически переподключались за три секунды, и цикл начинался заново.
Я основатель и технический директор BasicHomeLoan. Система, о которой идёт речь, управляет обработкой лидов в режиме реального времени: каждое взаимодействие с клиентом, запись звонка, сообщение в WhatsApp и обновление статуса заявки на ипотеку транслируются в прямом эфире назначенному сотруднику отдела выполнения и их управленческой иерархии через SignalR. Цель — чтобы, когда клиент звонит, все, кому нужно знать, уже знали. Видимость в реальном времени по всей организации ускоряет ответ и повышает удовлетворённость клиентов. Это был пилот перед запуском. Около 200 подключённых устройств — наша команда по выполнению тестировала систему перед полным развёртыванием. Поскольку пилот был контролируемым, мы могли отследить IP клиентов и сетевые вызовы вплоть до конкретных устройств. Именно так мы обнаружили иронию ситуации: примерно половина нестабильных соединений шла не из какого-то отдалённого места с плохим сигналом — они исходили из подвала и первого этажа нашего собственного офиса, где мобильный приём был ужасным. Когда паттерн стал ясен, мы специально расставили тестировщиков и разработчиков в подвале с телефонами, чтобы намеренно воспроизводить обрывы соединений, наблюдая за серверными метриками в реальном времени.
Архитектура такова: когда заявка на ипотеку меняется — приходит новое сообщение в WhatsApp, обновляется статус, появляется запись звонка — мы читаем полную сущность лида и связанные таблицы, а затем отправляем её каждому клиенту, которому нужно это увидеть. Поскольку мы работаем на нескольких подах Kubernetes, SignalR использует бэкплейн Redis — каждая рассылка идёт через Redis, чтобы все поды могли доставлять сообщения своим подключённым клиентам вне зависимости от того, на какой под попал исходный запрос. Позже я расскажу, почему это важно.
История с памятью подов была простой: что-то поглощало 4 ГБ в рабочие часы. Выяснить, что именно, заняло три недели — а запустить систему мы не могли, пока не исправили это.
Неделя 1: Очевидный подозреваемый
Первый инстинкт любого .NET-разработчика при проблемах с памятью — посмотреть на управляемую кучу. Я взял полный дамп памяти с пода, нагруженного до 3,7 ГБ, и открыл его в dotnet-dump.
# Разбивка управляемой кучи
eeheap -gc
# Результат:
GC Heap Size: ~207 MB
207 МБ. Из 3,7 ГБ. Управляемая куча занимала пять процентов общей памяти.
Я всё равно запустил dumpheap -stat и нашёл реальную проблему: наш перехватчик кэша второго уровня (second-level cache interceptor) в EF Core был настроен кэшировать все запросы по умолчанию, включая потоковые запросы, которые срабатывают каждые две секунды. 172 000 объектов EFTableRow. 31 миллион строк. Я добавил .NotCached() ко всем запросам в нашем потоковом нотификаторе, переразвернул и стал наблюдать за метриками.
Управляемая куча уменьшилась. RSS почти не изменился.
Это стало паттерном, который сделал расследование таким изматывающим. Мы работали ночами, разворачивали исправления и запускали нагрузочные тесты — бессонные ночи одна за другой. Каждый раз ночные тесты проходили успешно. Память держалась стабильно, поды работали нормально. Мы наконец ложились спать с мыслью, что решили проблему. Потом наступало 9 утра, 200 реальных пользователей из команды выполнения выходили онлайн со своими телефонами на нестабильных мобильных сетях — и через несколько часов поды снова начинали падать. Проблема проявлялась только под реальным мобильным трафиком с реальных устройств в нестабильных сетях — что не мог воспроизвести ни один синтетический ночной тест.
Я принудительно запустил полную сборку мусора. Ничего. Вызвал GC.Collect(2, GCCollectionMode.Aggressive, true, true). Ничего. Память никуда не делась, продолжала стабильно расти, совершенно безразличная к сборщику мусора.
Осознание
Наш эндпоинт проверки состояния подтвердил это. Нативная память составляла 2577 МБ при бюджете в 656 МБ — почти в 4 раза больше нормы. Управляемая куча — 145 МБ. Сборщику мусора нечего было собирать, потому что 95% памяти вообще не была управляемой.
Краткое отступление: что такое «нативная» память?
Если вы всю карьеру пишете на C#, возможно, вам никогда не приходилось об этом думать. В .NET ваши объекты живут в управляемой куче (managed heap) — области памяти под контролем сборщика мусора (GC). Когда вы создаёте объект, GC выделяет под него место. Когда на него больше никто не ссылается, GC освобождает память. Это мир dumpheap, gcroot и GC.Collect().
Но ваше .NET-приложение работает не в вакууме. Оно работает поверх рантайма, который работает поверх операционной системы. И ОС, рантайм, и многие библиотеки, от которых зависит приложение, выделяют память за пределами управляемой кучи. Это нативная память (native memory) — выделяемая напрямую из операционной системы с помощью C-функций вроде malloc() и free(). Сборщик мусора .NET её не видит, не отслеживает и не собирает. Примеры: буферы подключения к базе данных (MySQL читает данные из сети в нативные буферы), внутренние структуры рантайма (состояние JIT-компилятора, стеки потоков) — и, что критично для нас, накладные расходы рантайма на каждое соединение. Каждое WebSocket-соединение инициирует нативные выделения: рантайм .NET управляет его жизненным циклом через нативные interop-структуры, состояние отслеживания соединения и множество мелких внутренних вызовов malloc(), которые накапливаются по сотням одновременных соединений. По отдельности — небольшие, но в масштабе они суммируются, и ни одно из них не видно сборщику мусора.
В Linux есть метрика под названием RSS (Resident Set Size, размер резидентного набора) — это общий объём физической памяти, который ваш процесс реально использует, по данным операционной системы. Она включает управляемую кучу, все нативные выделения, стеки потоков и файлы, отображённые в память. Но Kubernetes смотрит не только на RSS — он использует учёт памяти cgroup, который отслеживает RSS плюс память ядра, занятую от вашего имени, например буферы TCP-сокетов. Когда Kubernetes решает, убить ли под по OOM, он смотрит на эту совокупную цифру, а не на размер управляемой кучи. Когда ваша панель мониторинга показывает под в 4 ГБ — это память cgroup.
Разрыв между управляемой кучей и RSS — это ваш нативный отпечаток памяти. В здоровом .NET-приложении нативная память может составлять 200–400 МБ — накладные расходы рантайма, пулы соединений, состояние JIT-компилятора. В нашем случае это было 3,5 ГБ.
Это момент, который делит .NET-отладку на два мира. Всё, что я делал до сих пор — dumpheap, gcroot, принудительный GC — работает только с управляемой кучей. Память, пожиравшая наши поды, была нативной и существовала ниже рантайма .NET, на уровне, о котором большинство C#-разработчиков никогда не задумываются.
Я переключился на LLDB — единственный отладчик, способный видеть и управляемую, и нативную память в Linux-дампе. И начал читать о том, как память устроена в Linux. Конкретно — о чём-то под названием glibc.
Неделя 2: Проблема зомби
Анализ дампа никуда не привёл. Ни одно нативное выделение не было достаточно большим, чтобы объяснить 3,5 ГБ. Это не была классическая утечка — забытый malloc без соответствующего free. У каждого выделения было соответствующее освобождение. Память освобождалась. Просто она никогда не возвращалась.
Чтобы понять почему, мне нужно было разобраться, что реально происходило на уровне сети. Вот наша архитектура:
Каждое изменение лида вызывало полное чтение из базы данных и рассылку подключённым клиентам. Клиенты использовали SignalR только для получения данных — все мутации шли через REST API. И вот здесь жила проблема: в нашей конфигурации SignalR параметр ClientTimeoutInterval был установлен в пять минут.
Пять минут. На мобильных устройствах с нестабильным мобильным соединением. С автоматическим переподключением с повтором каждые три секунды.
Представьте, что происходит, когда мобильное устройство въезжает в тоннель, переключается с Wi-Fi на мобильную сеть или просто попадает в мёртвую зону. Сетевое соединение умирает тихо — без вежливого прощания, без уведомления сервера, ничего. Сервер не знает, что клиент ушёл. Он сохраняет соединение живым, продолжает отправлять обновления сущностей в пустоту — до пяти минут, пока не сработает тайм-аут keepalive и не убьёт зомби.
Тем временем клиент уже переподключился. Через три секунды после потери сигнала он устанавливает новое соединение. Теперь сервер отправляет данные в оба — в новое живое соединение и в старое зомби. Ещё через три секунды, если сеть снова моргнёт, появится ещё одно зомби. Каждое живёт до пяти минут.
Математика зомби
# При 100 нестабильных клиентах:
Тайм-аут keepalive: 300 секунд (5 минут)
Повтор клиента: 3 секунды (линейный)
Доля тихих разрывов: ~20-30% от всех отключений
# Худший случай для одного нестабильного клиента:
300с тайм-аут / 3с повтор = до 100 зомби, накапливающихся
до того, как первый вообще истечёт
# Консервативная оценка по всему парку:
100 клиентов × ~20-30 зомби в пике = 2000-3000 зомби-соединений
Каждое зомби удерживало нативную память, недоступную GC: структуры рантайма на уровне соединения (нативное состояние interop, метаданные отслеживания соединений) и буферы отправки TCP ядра, заполняющиеся обновлениями сущностей, которые никогда не будут подтверждены. Нативный отпечаток на соединение был небольшим — но каждое зомби жило до пяти минут, и за это время его выделения действовали как булавки в куче аллокатора, не давая возвращать окружающую память ОС. Буферы транспортного уровня на стороне управляемого кода (System.IO.Pipelines у Kestrel) были пулированными и переиспользуемыми, но нативные накладные расходы на соединение — нет. И ничего из этого не отображалось в dotnet-dump.
Множитель бэкплейна Redis
Здесь работа в Kubernetes добавила дополнительный поворот. Поскольку у нас несколько подов, SignalR использует бэкплейн Redis для синхронизации сообщений между ними. Когда под делает рассылку обновления сущности, он публикует в Redis, и каждый под получает это сообщение и доставляет его своим локально подключённым клиентам — включая зомби-соединения.
Это значит, что одно изменение сущности попадало к зомби не только на одном поде. Оно проходило через Redis и доставалось зомби на каждом поде. Каждый под поддерживал собственное множество зомби-соединений, каждое с собственными нативными накладными расходами. Сам бэкплейн Redis добавлял нагрузку — StackExchange.Redis использует управляемые буферы для операций чтения/записи, но возросшая нагрузка pub/sub от рассылки зомби вызывала больше аллокационного перемешивания через нативный аллокатор при обработке каждого сообщения рантаймом.
При трёх работающих подах мы управляли не одной армией зомби — а тремя, и все питались из одного потока Redis.
Усугубляющий фактор
Каждое изменение сущности загружало больше данных в буферы отправки TCP ядра каждого зомби и удерживало нативное состояние соединения зафиксированным в куче аллокатора. Зомби, проживший пять минут, накапливал каждую рассылку за это время в буфере отправки ядра. Поскольку сущности менялись каждые несколько секунд, память ядра на одно зомби росла непрерывно — а чем дольше зомби жил, тем больше обычного аллокационного перемешивания переплеталось вокруг его зафиксированных нативных структур.
Ловушка фрагментации
Обнаружение зомби объяснило, куда уходила память. Но не объяснило, почему она никогда не возвращалась — даже в тихие периоды, когда все зомби были уже зачищены. Чтобы понять это, мне пришлось спуститься ещё на один уровень ниже — ниже .NET, ниже Kestrel, в сам аллокатор памяти Linux.
Что такое glibc и почему C#-разработчику стоит о нём знать?
Когда ваше .NET-приложение работает на Linux, оно не общается с операционной системой напрямую. Между рантаймом .NET и ядром Linux стоит базовая библиотека glibc (GNU C Library). Вы, вероятно, никогда о ней не думали, но она присутствует в каждом стандартном Docker-образе .NET и тихо занимается низкоуровневой сантехникой: файловый ввод-вывод, потоки, сеть — и, что самое важное в нашем случае, выделение памяти.
Каждый раз, когда что-то в вашем процессе требует памяти, неуправляемой сборщиком мусора .NET, в конечном счёте вызывается malloc(), за который отвечает glibc. Когда память больше не нужна, free() возвращает её glibc. Драйвер базы данных, внутренние структуры рантайма .NET, нативный interop-слой Kestrel — все они обращаются к glibc для нативной памяти. Думайте о glibc как о домовладельце памяти для всего, что не находится в управляемой куче.
Конкретный аллокатор памяти внутри glibc называется ptmalloc2. Он был разработан в начале 2000-х как аллокатор общего назначения и строится на определённых предположениях о том, как приложения используют память — предположениях, разумных для короткоживущих программ, но разваливающихся для современного сервера ASP.NET Core, обрабатывающего сотни WebSocket-соединений в контейнере, который никогда не перезапускается.
Порог, который нас погубил
Каждое нативное выделение в процессе — interop рантайма, временные объекты JSON-сериализации, операции драйвера базы данных, управление жизненным циклом соединений — идёт через malloc(). Все они небольшие, индивидуально хорошо меньше 128 КБ. В этом и проблема.
У ptmalloc2 есть внутренний порог — по умолчанию 128 КБ — который определяет как выделяется память. Это самая важная деталь во всей этой истории:
Выделения выше 128 КБ получают собственный выделенный блок памяти от ОС (через механизм mmap()). Это как арендовать отдельный склад. Когда закончите — возвращаете ключи, ОС немедленно освобождает память. Чисто, просто, без следов.
Выделения ниже 128 КБ попадают во внутреннюю кучу аллокатора — растущие области памяти, которыми аллокатор управляет изнутри (основная арена расширяется через brk(); дополнительные арены потоков выделяют собственные области через mmap()). Это как занять место на общем складе. Когда вы освобождаете эту память через free(), аллокатор помечает её как доступную для повторного использования. Но вот что критично: ОС эту память не получает обратно. С точки зрения операционной системы ваш процесс по-прежнему её использует. Она всё ещё отображается в RSS. Kubernetes её видит.
Вот ключевое понимание: выделения зомби сами по себе не были достаточно велики, чтобы заполнить 3,5 ГБ. Несколько КБ нативных накладных расходов на соединение, умноженных на 3000 зомби — примерно 10–20 МБ живых данных. Но эти выделения жили по пять минут каждое, разбросанные по всей куче. За это время все остальные нативные выделения в процессе — JSON-сериализация, чтение из базы данных, установка и разрыв соединений для здоровых клиентов — попадали в те же арены, переплетаясь с памятью зомби. Когда зомби наконец умирало, освобождённые слоты оставляли дыры, фрагментирующие кучу. Зомби не были содержимым 3,5 ГБ. Они были булавками, не дававшими куче когда-либо сократиться.
Замечание о том, что мы могли и не могли наблюдать: во время исходного инцидента у нас не было включённой трассировки malloc — вы не инструментируете внутренности libc во время живого производственного пожара. Точный профиль нативных выделений восстановлен из того, что мы могли измерить (RSS, размер управляемой кучи, количество соединений, память cgroup), и из того, что мы смогли подтвердить (jemalloc устранил накопление памяти, доказав, что механизмом была фрагментация ptmalloc2). Конкретное взаимодействие между внутренними вызовами malloc() рантайма и жизненным циклом зомби — наша лучшая модель почему это работало, построенная из симптомов, исправлений и видимых частей стека. Когда вы отлаживаете через слои, которыми не владеете — рантайм .NET, Kestrel, glibc, ядро — такова реальность системной работы.
Арены: почему многопоточность усугубляет проблему
У ptmalloc2 есть ещё одно проектное решение, усугубившее нашу проблему. Чтобы снизить конкуренцию, когда несколько потоков одновременно запрашивают память, он даёт каждому потоку собственный отдельный пул памяти — называемый ареной (arena). На системе с 8 ядрами CPU он может создать до 64 арен. Идея звучит разумно: потоки не блокируют друг друга при выделении памяти.
Проблема: арены не делятся свободным пространством друг с другом. Думайте о них как об отдельных складах. Если склад A полон пустых полок, а склад B переполнен — B не может использовать пространство A, он должен расшириться сам. Kestrel использует пул потоков, и соединения обслуживаются разными потоками в течение своей жизни. Нативные структуры зомби-соединения могут быть выделены в арене 1, позже оно обслуживается потоком из арены 3, и когда умирает — оставляет дыры в обеих аренах, которые не могут поделиться ими друг с другом.
Паттерн выделений выглядел примерно так:
# Поток пула потоков 1 → арена 1
Обрабатывает соединение A, нативные выделения: interop + состояние + метаданные
Обрабатывает соединение B, нативные выделения: interop + состояние + метаданные
# Соединение A становится зомби — живёт минутами, фиксируя свои выделения
# Тем временем обычное перемешивание (сериализация, БД, HTTP) заполняет пространство вокруг
Арена 1: [перемеш.] [A: зафиксировано] [перемеш.] [B: живое] [перемеш.]
# Соединение A наконец истекает и освобождается
Арена 1: [перемеш.] [дыра] [перемеш.] [B] [перемеш.]
# Повторяется тысячи раз по десяткам арен
# → Куча только растёт. RSS никогда не уменьшается.
Жизненный цикл зомби-соединения был худшим возможным паттерном для этого аллокатора. Долгоживущие зомби (до пяти минут) означали, что их память глубоко переплеталась с памятью здоровых соединений. Когда зомби наконец умирало, освобождённая память превращалась в швейцарский сыр — разрозненные дыры по нескольким аренам, повторно используемые для запросов того же или меньшего размера, но никогда не возвращаемые ОС.
И есть ещё одна финальная деталь, которая окончательно захлопывает ловушку. Куча может уменьшаться только сверху. Представьте стопку коробок — снимать можно только сверху. Если верхняя коробка всё ещё используется, все нижние застряли, даже если все они пустые. При сотнях переплетённых времён жизни соединений по десяткам арен всегда было что-то живое у верхушки кучи каждой арены. Вся область ниже — потенциально сотни мегабайт свободных дыр — оставалась заблокированной, учитываясь ОС и Kubernetes как «используемая память».
Именно поэтому ситуация выглядела как утечка памяти, хотя технически у каждого malloc() был соответствующий free(). С точки зрения приложения память была освобождена. Но операционная система её так и не получила обратно.
Слой ещё ниже
Фрагментация аллокатора объясняла, почему освобождённая память не возвращалась ОС. Но был ещё один, ещё более невидимый слой накопления нативной памяти: буферы отправки TCP, управляемые ядром Linux.
Когда приложение отправляет данные через сетевой сокет, они не идут напрямую в провод. По пути они проходят через несколько буферов. В нашем случае: сериализованные данные сущности шли из .NET в буферы I/O-пайпов Kestrel, а затем вниз в буфер, которым ядро Linux управляет для каждого TCP-сокета. Ядро отвечает за фактическую отправку байт в сеть, за повторную передачу при потере пакетов и за ожидание подтверждений от другой стороны.
Когда мы отправляли обновления сущностей зомби-соединению, ядро добросовестно пыталось их передать. Подтверждений не приходило — клиент ушёл. TCP не сдаётся легко. Он повторяет передачу с экспоненциальной выдержкой, удваивая паузу между каждой попыткой. За всё это время ядро удерживает данные, потому что может понадобиться их переслать.
Эти буферы живут в пространстве ядра — памяти, управляемой самой ОС, полностью вне адресного пространства вашего процесса. Ни один инструмент диагностики уровня приложения их не видит. dotnet-dump не видит. LLDB не видит. pmap не видит — их вообще нет в виртуальном адресном пространстве процесса. Но учёт памяти cgroup ядра их отслеживает. Kubernetes их видит. OOM-killer их видит.
При пятиминутном тайм-ауте keepalive ядро удерживало буфер отправки каждого зомби на всё это время. Такие буферы обычно занимают от 64 КБ до 4 МБ на сокет в зависимости от объёма поставленных в очередь данных и конфигурации системы.
# Оценка памяти ядра от зомби:
2000 зомби-соединений × ~1 МБ буфера отправки TCP = ~2 ГБ в пространстве ядра
# Полностью невидимо для любой диагностики уровня приложения
Почему Kubernetes усугублял ситуацию
В Kubernetes каждый под работает внутри изоляционного контейнера (cgroup), который ядро Linux использует для отслеживания и ограничения потребления ресурсов на контейнер. Метрика памяти, за которой следит Kubernetes, включает всё: управляемую кучу, фрагментированную нативную кучу, которую аллокатор удерживал, и все буферы TCP ядра для зомби-сокетов. Он не различает «память, которую ваш код активно использует» и «память, которую аллокатор держит, но не использует».
Мы применяем горизонтальное автомасштабирование подов (HPA, Horizontal Pod Autoscaler) — функцию Kubernetes, автоматически добавляющую или убирающую реплики подов на основе метрик ресурсов. При высокой памяти подов HPA масштабировал вверх: больше подов, больше ёмкости, больше затрат. Но вот в чём подвох: каждый новый под, подключавшийся к кластеру, немедленно начинал получать рассылки из бэкплейна Redis и принимать переподключения от мобильных клиентов. Через несколько минут у нового пода была собственная армия зомби. HPA масштабировался вверх, чтобы справиться с проблемой памяти, — и каждый новый запущенный им под делал эту проблему хуже.
Но когда нагрузка успокаивалась, RSS подов не снижался. Управляемая куча сжималась — сборщик мусора делал свою работу. Буферы TCP ядра освобождались по мере закрытия зомби-сокетов. Но фрагментированная нативная куча, всё ещё удерживаемая аллокатором, держала RSS высоким. Память, реально используемая в пиковую нагрузку, оставляла после себя ландшафт пустых дыр, которые ОС не могла вернуть. HPA видел, что поды потребляют гораздо больше необходимых 600 МБ, и никогда не уменьшал их масштаб.
Мы платили за призрачную память — пустые фрагменты арен, которые ОС не могла вернуть, GC не мог трогать, а Kubernetes не мог за ними увидеть реальности. Поды, которые должны были работать при 600 МБ, раздулись от фрагментированной нативной кучи, и HPA добросовестно держал их все живыми.
Полный каскад
Нестабильные мобильные соединения (наш собственный офисный подвал) → зомби-соединения SignalR, живущие минутами → бэкплейн Redis, рассылающий обновления лидов к зомби на каждом поде → нативные выделения на соединение, попадающие во внутреннюю кучу вместо получения выделенной памяти → фрагментация аллокатора по потокам пула → буферы TCP ядра для мёртвых сокетов → память cgroup достигает 4 ГБ → OOM-kill → перезапуск пода → клиенты автоматически переподключаются за 3 секунды → HPA масштабирует вверх → новые поды наследуют ту же армию зомби → повтор.
Неделя 3: Исправление каждого уровня
Единственного исправления не существовало. Проблема существовала на каждом уровне, и каждый уровень требовал собственного решения.
Уровень 1: Убивать зомби быстрее
Самым действенным изменением стало уменьшение тайм-аута SignalR с пяти минут до десяти секунд. Это единственное изменение сократило максимальное время жизни зомби на 97%.
var hubConfig = services.AddSignalR(options =>
{
options.ClientTimeoutInterval = TimeSpan.FromSeconds(10);
options.KeepAliveInterval = TimeSpan.FromSeconds(7);
options.MaximumReceiveMessageSize = 32 * 1024;
options.HandshakeTimeout = TimeSpan.FromSeconds(15);
options.StreamBufferCapacity = 10;
options.MaximumParallelInvocationsPerClient = 1;
options.StatefulReconnectBufferSize = 0;
});
Наши исходные значения были не случайными. Мы установили ClientTimeoutInterval в пять минут, желая проявить толерантность к мобильным клиентам на ненадёжных мобильных сетях — документация Microsoft рекомендует давать время на приход пингов через высоколатентные соединения, и мы перестраховались в сторону терпимости. MaximumReceiveMessageSize был увеличен до 128 КБ как запас для команд от клиента к хабу (JoinGroup, GetData и аналогичные pull-запросы) — щедро для нагрузки, редко превышающей несколько сотен байт. Оба выбора казались разумными на бумаге. На нестабильных мобильных сетях с тихими разрывами тайм-аут оказался катастрофическим — а увеличенный буфер приёма означал, что входящий пайп каждого зомби резервировал больше памяти, чем когда-либо было нужно.
StatefulReconnectBufferSize заслуживает отдельного замечания. Эта функция .NET 8 удерживает буфер на соединение, чтобы клиенты могли возобновить работу после кратких разрывов без потери сообщений. Звучит идеально для нестабильных соединений — за исключением того, что 60% наших разрывов были переключениями сети, создающими совершенно новые TCP-соединения. Буфер не мог им помочь. Мы тратили ~10–17 МБ на функцию, не дававшую никакой пользы в нашей нагрузке.
Уровень 2: Уменьшить радиус поражения
Наш ALB (балансировщик нагрузки приложений) имел тайм-аут простоя 3600 секунд — один час, поднятый со значения AWS по умолчанию в 60 секунд, чтобы балансировщик не закрывал долгоживущие WebSocket-соединения. Простаивающие HTTP-соединения оставались открытыми час, каждое удерживая нативную память. Мы снизили его обратно до 60 секунд и синхронизировали тайм-аут keepalive Kestrel до 70 секунд (всегда немного выше ALB, чтобы избежать ошибок 502).
# Kestrel (по умолчанию: KeepAliveTimeout=130с, MaxConcurrentConnections=без ограничений,
# MaxConcurrentUpgradedConnections=без ограничений)
options.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(70);
options.Limits.MaxConcurrentConnections = 550;
options.Limits.MaxConcurrentUpgradedConnections = 350;
# ALB (тайм-аут простоя по умолчанию 60с; у нас было 3600с для долгих WebSocket)
Connection idle timeout: 60
HTTP client keepalive: 65
Уровень 3: Исправить конвейер потоковой передачи
Наш SystemWaStreamNotifier обрабатывал изменения сущностей каждые две секунды через очередь на основе таймера. Но без дедупликации одна и та же сущность могла быть поставлена в очередь и обработана несколько раз при быстрых обновлениях. Больше обработки означало больше чтений из базы данных, больше сериализации, больше данных в буферах зомби.
Мы добавили глобальную дедупликацию через ConcurrentDictionary. Если сущность App123 уже была в ожидании, последующие обновления просто обновляли отметку времени — без нового элемента в очереди. Количество обработок упало на 60–80% при всплесках трафика.
Уровень 4: Заменить аллокатор
Все вышеперечисленные исправления снижали скорость накопления нативной памяти. Но фрагментация аллокатора glibc структурна — она заложена в принципах работы ptmalloc2. Даже при более коротких временах жизни зомби куча всё равно фрагментировалась бы за дни и недели. Просто медленнее.
Окончательным исправлением на уровне фрагментации стала полная замена аллокатора glibc. Одна строка в нашем Dockerfile:
FROM mcr.microsoft.com/dotnet/aspnet:8.0
RUN apt-get update && \
apt-get install -y libjemalloc2 && \
rm -rf /var/lib/apt/lists/*
ENV LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2
LD_PRELOAD — переменная окружения Linux, указывающая системе загрузить разделяемую библиотеку раньше всех остальных. Указав её на библиотеку jemalloc, мы направляем каждый вызов malloc() и free() во всём процессе — от рантайма .NET до драйвера MySQL и каждой нативной библиотеки — через jemalloc вместо аллокатора glibc по умолчанию. Без изменений кода, без перекомпиляции. Приложение даже не знает, что использует другой аллокатор.
jemalloc был создан именно для такой нагрузки — долгоработающие серверы с параллельными выделениями разных размеров по множеству потоков. Именно его используют в production Redis, Meta и Firefox. Вместо арен потоков с фрагментированными списками свободной памяти jemalloc организует память в бины по размерным классам (size-class bins, как заранее рассортированные полки для предметов разного размера) и агрессивно сообщает ОС о необходимости вернуть неиспользуемые страницы. Он делает это через системный вызов ядра (madvise), который по сути говорит: «Я закончил с этой страницей памяти — забери её обратно, но сохрани адресное резервирование на случай, если она снова понадобится». Результат: освобождённая память действительно снижает RSS.
Замечание о компромиссах jemalloc
jemalloc — не волшебная пыль. Его агрессивное освобождение страниц потребляет чуть больше CPU, чем аллокатор glibc — ядро должно делать больше работы при освобождении и повторном отображении страниц. Некоторые инструменты мониторинга могут давать запутывающие профили памяти, потому что внутренняя бухгалтерия jemalloc отличается от ожиданий инструментов. И это дополнительная зависимость: нужно установить её в Docker-образе и обновлять. Стоит также отметить, что темп развития jemalloc в upstream замедлился после того, как Meta сократила инвестиции, хотя стабильные ветки продолжают получать исправления, и он остаётся аллокатором по умолчанию во FreeBSD. Для нашей нагрузки компромисс был очевиден — мы сжигали гигабайты RAM, чтобы сэкономить несколько тактов CPU на управлении страницами — но вы всегда должны проводить бенчмарки на своём трафике перед тем, как принять решение.
Почему не другой аллокатор?
jemalloc был не единственным вариантом. Мы рассмотрели две альтернативы, но не пошли ни по одному из этих путей:
gperftools tcmalloc (Google) — оптимизирован для быстрых выделений малых объектов с кэшами на поток. Хорош при умеренном числе потоков, но его центральные списки свободной памяти и куча страниц нешардированы, что может стать узким местом при интенсивных межпоточных паттернах выделений. Не тот профиль, который нам нужен для ASP.NET Core с тяжёлым пулом потоков и высокой ротацией соединений.
mimalloc (Microsoft Research) — компактный высокопроизводительный аллокатор с отличными показателями в бенчмарках. Однако его дизайн v2 сохраняет пулы памяти на поток и намеренно не стремится делиться памятью между потоками — память, освобождённая одним потоком, остаётся зарезервированной для него. При переменных паттернах нагрузки пула потоков это известный путь к накоплению памяти. Отличен для чистой скорости выделений; неправильный профиль для наших контейнеров с ограниченной памятью.
jemalloc победил благодаря происхождению и соответствию задаче. Он появился как аллокатор libc FreeBSD в 2005 году — что делает его старейшей проверенным в боях альтернативой среди всех — и прошёл закалку под экстремальной производственной нагрузкой в Redis, Meta и Firefox. Конкретное проектное решение его предрешило: несколько арен с межпоточным обменом, бины по размерным классам минимизирующие фрагментацию, и агрессивный возврат неиспользуемых страниц ОС через madvise. Для долгоработающего сервера с высокой ротацией соединений, переменными размерами сообщений и жёсткими ограничениями памяти контейнера — именно то, что нужно.
После
Поды стабилизировались ниже 1 ГБ. Никаких OOM-kill. HPA впервые начал масштабировать вниз. Полуденные перезапуски прекратились.
Напомним — это был пилот перед запуском. 200 устройств, половина из них — наша собственная команда выполнения, борющаяся с сигналом в офисном подвале. Три недели мы не могли запуститься, пока эта проблема пожирала каждый под. Через неделю после появления исправления мы перешли к полному развёртыванию в production. Система, которая не могла выдержать 200 пилотных пользователей, теперь работает в масштабе без единого OOM-kill.
Три недели — разворачивать исправления ночью и наблюдать, как они падают днём — ради ошибки, которую ни один инструмент диагностики .NET не мог увидеть. Но эти три недели научили меня о том, как программное обеспечение реально работает на Linux, больше, чем предыдущие несколько лет написания C# вместе взятых.
Почему production так и не лёг
Три недели поды падали каждые несколько часов — и ни одного сбоя, видимого клиентам. Это не везение — это три уровня защиты, которые мы выстроили ещё до понимания корневой причины: проба готовности (readiness probe), снимавшая поды с балансировщика при 80% памяти (при этом сохраняя их для существующих соединений), проба живости (liveness probe), форсировавшая контролируемый перезапуск, если память оставалась выше 93% слишком долго, и SmartMemoryTrimService, агрессивно запускавший GC.Collect с уплотнением LOH для контроля управляемой кучи. Ключевое понимание: к моменту, когда под приближался к OOM, он уже был снят с production-трафика и обслуживал только зомби-соединения. OOM-kill, когда он происходил, был невидим клиентам.
Побочный эффект — стоимость. Поды, живые, но не готовые, держали HPA на максимальном числе реплик. Мы платили за стабильность инфраструктурными расходами — именно тот компромисс, который нужен, пока ещё ищешь корневую причину.
Что дали мне три недели без сна
Большинство .NET-разработчиков никогда не задумывались о нативной памяти. Управляемая куча, сборщик мусора, IDisposable — эти абстракции настолько хороши, что можно построить всю карьеру, не зная, как ОС выделяет память под рантаймом. До тех пор, пока не оказываешься на Linux-контейнерах с WebSocket-соединениями к мобильным устройствам в нестабильных сетях — и вдруг абстракция даёт течь.
dotnet-dump видит только половину картины. На Linux, если RSS растёт, а управляемая куча стабильна, утечка — в нативной памяти, а dotnet-dump её слеп. Для анализа дампов нужен LLDB, для просмотра регионов памяти живого процесса — pmap. Быстрая диагностика: запустите dotnet-counters и следите за двумя числами — GC Heap Size и Working Set. Если Working Set продолжает расти, а GC Heap стабилен — у вас проблема с нативной памятью. Инструменты .NET скажут вам, что всё в порядке, пока Kubernetes готовится убить ваш под.
Аллокатор памяти Linux по умолчанию не был спроектирован для вашей нагрузки. ptmalloc2 glibc — аллокатор общего назначения из эпохи до контейнеров, до WebSocket, до пулов потоков, обрабатывающих сотни параллельных соединений в долгоработающих процессах. Он исходит из того, что большинство приложений короткоживущи, паттерны использования памяти предсказуемы, а куча может свободно расти, потому что виртуальная память в изобилии. В контейнере с лимитом в 4 ГБ, запускающем сервер, который никогда не перезапускается, каждое из этих предположений не выполняется. Если вы запускаете долгоработающий ASP.NET Core-сервис на Linux, добавление LD_PRELOAD=libjemalloc.so в ваш Dockerfile должно быть таким же рутинным, как установка DOTNET_gcServer=1. И это не специфично для .NET — Rust, Node.js, Python и любой язык, использующий malloc под капотом, может столкнуться с той же стеной на glibc, в той или иной мере.
Тайм-ауты SignalR по умолчанию рассчитаны на стабильные сети — и их повышение ради мобильной толерантности делает хуже. Мы установили ClientTimeoutInterval в пять минут, думая, что это поможет ненадёжным клиентам. Вместо этого мы создали армию зомби. Если ваши клиенты на мобильных устройствах — идите ниже значений по умолчанию, а не выше. Устанавливайте ClientTimeoutInterval агрессивно. Ваши клиенты уже умеют переподключаться.
Граница в 128 КБ важна. На Linux аллокатор по умолчанию обрабатывает выделения ниже 128 КБ принципиально иначе, чем выше. В долгоработающем сервере каждое нативное выделение — interop рантайма, сериализация, управление соединениями — попадает ниже этого порога на общую кучу, которая никогда не сжимается. Долгоживущие соединения действуют как якоря фрагментации, предотвращающие обрезку кучи даже после их закр