Три недели в окопах: охота за утечкой 4 ГБ нативной памяти, которую .NET не мог увидеть
Перевод оригинальной статьи Three Weeks in the Trenches: Hunting a 4GB Native Memory Leak That .NET Couldn’t See.
Перевод сделан специально для телеграм-канала Мониторим ИТ. Подписывайтесь! Там еще больше полезных постов о мониторинге.
Кратко (TL;DR): Наши поды ASP.NET Core в Kubernetes завершались по OOM-kill каждые несколько часов во время предпускового пилотного запуска. Heap был практически пустой — 95% памяти занимала нативная память, невидимая для любых средств диагностики .NET. Причиной стали нестабильные мобильные соединения, создававшие тысячи «зомби»-подключений SignalR. Каждое такое подключение содержало собственные накладные расходы в нативной памяти среды выполнения — состояние подключения, структуры нативного взаимодействия (native interop) и многочисленные небольшие выделения памяти черезmalloc(), которые среда выполнения выполняет для каждого подключения. Поскольку размеры этих выделений были меньше порога 128 КБ дляmmap()в glibc, они попадали во внутренний heap аллокатора, вызывая фрагментацию heap в десятках потоках. Освобождённая память никогда не возвращалась операционной системе. Дополнительный невидимый слой памяти составляли буферы отправки TCP ядра для уже несуществующих сокетов. Для устранения проблемы потребовалось внести четыре изменения на четырёх уровнях: сократить тайм-ауты SignalR (с 5 минут до 10 секунд); уменьшить тайм-аут бездействия ALB; устранить дублирование потоков данных; заменить стандартный аллокатор glibc наjemallocс помощьюLD_PRELOAD. После этого использование памяти подами стабилизировалось на уровне менее 1 ГБ. С тех пор не произошло ни одного OOM-kill.
День 0
Всё началось с уведомления поздним утром. Один из наших production-подов был завершён из-за нехватки памяти (OOM). Ничего необычного для системы под нагрузкой — за исключением того, что это продолжало происходить снова и снова. В рабочее время — примерно с 9:00 до 21:00, когда наши пользователи были активны, — поды разрастались до 4 ГБ и завершались. Kubernetes перезапускал их, наши мобильные клиенты автоматически переподключались через три секунды, и цикл начинался снова.
Я являюсь основателем и техническим директором BasicHomeLoan. Система, о которой идёт речь, обрабатывает запросы на управление лидами в режиме реального времени — каждое взаимодействие с клиентом, журнал звонков, сообщение WhatsApp и обновление статуса заявки на ипотечный кредит передаются в режиме реального времени назначенному сотруднику отдела обработки заявок и его руководству через SignalR. Цель заключается в том, чтобы к моменту звонка клиента все сотрудники команды сопровождения, которым необходимо об этом знать, уже владели этой информацией. Возможность видеть происходящее в режиме реального времени во всей организации позволяет быстрее реагировать на обращения клиентов и повышает их удовлетворённость. Это был предпусковой пилотный запуск. Около 200 подключённых устройств, наша команда сопровождения тестировала систему перед полномасштабным внедрением. Поскольку это был контролируемый пилотный запуск, мы могли сопоставить IP-адреса клиентов и сетевые обращения с конкретными устройствами. Именно так мы обнаружили любопытную деталь: примерно половина нестабильных подключений поступала вовсе не из какого-то удалённого сельского района — они поступали из подвала и с первого этажа нашего собственного офиса, где мобильная связь была очень плохой. После того как мы выявили эту закономерность, мы специально разместили тестировщиков и разработчиков в подвале с их телефонами, чтобы намеренно воспроизводить обрывы соединения, одновременно наблюдая за серверными метриками в режиме реального времени.
Архитектура была следующей: когда изменялась заявка на ипотеку — поступало новое сообщение WhatsApp, обновлялся статус или добавлялась запись о звонке, — мы считывали полную сущность заявки и связанные с ней таблицы, а затем отправляли её всем клиентам, которым необходимо было видеть эти данные. Поскольку приложение работало в нескольких подах Kubernetes, SignalR использовал Redis backplane — каждое широковещательное сообщение проходило через Redis, чтобы все поды могли доставить его своим подключённым клиентам независимо от того, какой под обработал исходный запрос. Позже станет понятно, почему это оказалось важным.
Использование памяти пода говорило само за себя: в рабочее время что-то потребляло 4 ГБ памяти. Чтобы выяснить, что именно, понадобилось три недели — и до тех пор, пока проблема не была устранена, мы не могли запустить систему.
Неделя 1: Очевидный подозреваемый
Первое, что приходит в голову любому .NET-разработчику, столкнувшемуся с проблемой нехватки памяти, — это проверить управляемый heap. Я снял полный дамп памяти с пода, который потреблял 3,7 ГБ, и открыл его с помощью dotnet-dump.
# The managed heap breakdown eeheap -gc
# Result: GC Heap Size: ~207 MB
207 МБ из 3,7 ГБ. Управляемый heap использовал пять процентов от общего объема памяти.
Тем не менее я выполнил dumpheap -stat и действительно обнаружил проблему: наш интерцептор кэша второго уровня EF Core был настроен так, чтобы по умолчанию кэшировать все запросы, включая запросы потоковой передачи, которые выполнялись каждые две секунды. 172 000 объектов EFTableRow. 31 миллион строк. Я добавил .NotCached() ко всем запросам в нашем уведомителе потоковой передачи, повторно развернул приложение и начал наблюдать за метриками.
Объём управляемого heap уменьшился. RSS практически не изменился.
Это стало причиной того, что расследование стало таким изнурительным. Мы работали всю ночь, развёртывая исправления и проводя нагрузочные тесты — бессонная ночь за бессонной ночью. И каждый раз ночные тесты проходили успешно. Использование памяти оставалось стабильным, поды работали без сбоев. Мы наконец ложились спать, уверенные, что решили проблему. Но наступало 9 утра, 200 реальных пользователей из нашей команды сопровождения подключались со своих телефонов через нестабильную мобильную сеть, и уже через несколько часов поды снова начинали завершаться. Проблема проявлялась только при реальном мобильном трафике с реальных устройств, работавших в нестабильных сетях, — то, что невозможно было воспроизвести ни одним синтетическим ночным тестом.
Я принудительно запустил полную сборку мусора. Ничего. Вызвал GC.Collect(2, GCCollectionMode.Aggressive, true, true). Ничего. Память по-прежнему оставалась занятой, продолжая неуклонно расти, совершенно не реагируя на сборщик мусора.
Осознание Наша точка проверки работоспособности это подтвердила. Объем нативной памяти составлял 2577 МБ при бюджете в 656 МБ — почти в 4 раза больше, чем должно было быть. Объем управляемого heap составлял 145 МБ. Сборщику мусора было нечего освобождать, потому что 95% памяти вообще не относились к управляемой памяти.
Небольшое отступление: что такое «нативная» память?
Если вы всю свою карьеру писали на C#, возможно, вам никогда не приходилось об этом задумываться. В .NET ваши объекты находятся в управляемом heap — области памяти, которой управляет сборщик мусора (GC). Когда вы создаёте объект, GC выделяет для него память. Когда на объект больше никто не ссылается, GC освобождает эту память. Именно с этим работают dumpheap, gcroot и GC.Collect().
Но приложение .NET не существует само по себе. Оно работает поверх среды выполнения, а та, в свою очередь, работает поверх операционной системы. И операционная система, среда выполнения и многие библиотеки, от которых зависит ваше приложение, выделяют память за пределами управляемого heap. Это и есть нативная память — память, выделяемая непосредственно у операционной системы с помощью функций уровня C, таких как malloc() и free(). Сборщик мусора .NET не может её видеть, отслеживать или освобождать. К ней относятся, например, буферы подключений к базе данных (MySQL считывает данные из сети в нативные буферы), внутренние структуры среды выполнения (состояние JIT-компилятора, стеки потоков) и — что было особенно важно в нашем случае — накладные расходы среды выполнения на каждое подключение. Каждое WebSocket-подключение приводит к выделению нативной памяти, пока среда выполнения .NET управляет его жизненным циклом: создаются структуры взаимодействия с нативным кодом, данные отслеживания подключения и множество небольших внутренних выделений памяти через 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 и не завершает это «зомби»-подключение.
Тем временем клиент уже переподключился. Через три секунды после потери сигнала он устанавливает новое соединение. Сервер теперь отправляет данные обоим соединениям — новому активному и старому «зомби-соединению». Через три секунды, если сеть снова произойдёт сбой, появится ещё одно «зомби-соединение». Каждое из них будет активно до пяти минут.
Математика зомби
# With 100 unstable clients: KeepAlive timeout: 300 seconds (5 minutes) Client retry: 3 seconds (linear) Silent drop rate: ~20-30% of disconnects
# Worst case per unstable client:
300s timeout / 3s retry = up to 100 zombies stacking up
before the first one even times out
# Conservative estimate across fleet:
100 clients × ~20-30 zombies at peak = 2,000-3,000 zombie connectionsКаждое зомби-подключение удерживало нативную память, недоступную сборщику мусора: структуры среды выполнения, связанные с каждым подключением (состояние взаимодействия с нативным кодом, метаданные отслеживания подключения), а также буферы отправки TCP ядра, которые постепенно заполнялись обновлениями сущностей и никогда не получали подтверждения доставки. Объём нативной памяти на одно подключение был небольшим, но каждое «зомби» существовало до пяти минут, и в течение этого времени его выделения памяти действовали как своеобразные «якоря» во внутренней куче аллокатора, не позволяя возвращать окружающую их память операционной системе. Буферы транспорта на стороне управляемой памяти (System.IO.Pipelines в Kestrel) использовали пул и могли повторно использоваться, а вот нативные накладные расходы на каждое подключение — нет. Всё это было невидимо для dotnet-dump.
Множитель Redis backplane
Именно здесь работа в Kubernetes добавила ещё один нюанс. Поскольку мы использовали несколько подов, SignalR применял Redis backplane для синхронизации сообщений между ними. Когда один под выполнял широковещательную отправку обновления сущности, он публиковал сообщение в Redis, после чего каждый под получал его и доставлял своим локально подключённым клиентам — включая свои «зомби»-подключения.
Это означало, что одно изменение сущности затрагивало не только «зомби» на одном поде. Оно проходило через Redis и достигало «зомби» на каждом поде. Каждый под поддерживал собственный набор «зомби»-подключений, каждое из которых имело собственные накладные расходы на нативную память. Кроме того, сам Redis backplane создавал дополнительную нагрузку. StackExchange.Redis использует управляемые буферы для операций чтения и записи, однако увеличение объёма публикаций и подписок (pub/sub), вызванное рассылкой сообщений «зомби»-подключениям, приводило к ещё более интенсивным выделениям памяти через нативный аллокатор при обработке каждого сообщения средой выполнения.
При работе трёх подов мы имели дело уже не с одной армией «зомби», а с тремя, и все они получали одни и те же сообщения из потока Redis.
Усугубляющий фактор Каждое изменение сущности отправляло новые данные в буферы отправки TCP ядра каждого зомби-подключения и продолжало удерживать его нативное состояние подключения во внутренней куче аллокатора. «Зомби», существовавший пять минут, за это время накапливал в своём буфере отправки ядра все широковещательные сообщения. Поскольку сущности изменялись каждые несколько секунд, объём памяти ядра, приходившийся на каждое «зомби», непрерывно рос. И чем дольше существовало «зомби», тем сильнее обычные выделения памяти перемешивались вокруг удерживаемых им нативных структур.
Ловушка фрагментации
Обнаружение «зомби» объяснило, куда уходила память. Но это не объяснило, почему она никогда не возвращалась, даже после периодов затишья, когда все «зомби» были удалены. Чтобы разобраться, мне пришлось спуститься ещё на один уровень — ниже .NET, ниже Kestrel, к самому аллокатору памяти Linux.
Что такое glibc и почему разработчику на C# это должно быть интересно?
Когда ваше .NET-приложение работает под Linux, оно не взаимодействует с операционной системой напрямую. Между средой выполнения .NET и ядром Linux находится базовая библиотека под названием glibc (библиотека GNU C). Вы, вероятно, никогда об этом не задумывались, но она присутствует в каждом стандартном образе контейнера Docker для .NET, незаметно обрабатывая низкоуровневые процессы: файловый ввод-вывод, многопоточность, работу с сетью и — что наиболее важно в данном случае — выделение памяти.
Каждый раз, когда чему-либо внутри процесса требуется память, не управляемая сборщиком мусора .NET, в конечном итоге вызывается malloc(), обработкой которого занимается glibc. Когда эта память больше не нужна, free() возвращает её glibc. Драйвер базы данных, внутренние структуры среды выполнения .NET, слой взаимодействия Kestrel с нативным кодом — все они используют glibc для работы с нативной памятью. Можно представить glibc как арендодателя памяти для всего, что не находится в управляемой куче.
Специализированный аллокатор памяти внутри glibc называется ptmalloc2. Он был разработан в начале 2000-х годов как универсальный аллокатор и основывается на определённых предположениях о том, как приложения используют память. Эти предположения вполне разумны для короткоживущих программ, но перестают работать в случае современного сервера ASP.NET Core, который обслуживает сотни WebSocket-подключений внутри контейнера, никогда не перезапускающегося.
Порог, который нас погубил
Каждое выделение нативной памяти в процессе — взаимодействие среды выполнения с нативным кодом, временные объекты сериализации JSON, операции драйвера базы данных, управление жизненным циклом подключений — проходит через malloc(). Все эти выделения невелики — каждое значительно меньше 128 КБ. И именно в этом заключается проблема.
У функции ptmalloc2 есть внутренний порог — по умолчанию 128 КБ — который определяет способ выделения памяти. Это самая важная деталь во всей этой истории:
Выделения памяти свыше 128 КБ получают от операционной системы собственный выделенный блок памяти (с помощью механизма mmap()). Представьте, что вы арендуете отдельное хранилище. Когда оно больше не нужно, вы возвращаете ключи, и операционная система сразу освобождает эту память. Всё просто и без остатка.
Выделенные объемы памяти менее 128 КБ аллокатора — в растущие области памяти, которыми аллокатор управляет самостоятельно (основная область расширяется через brk(), а дополнительные области потоков выделяют собственные области с помощью mmap()). Представьте себе место на общем хранилище. Когда вы вызываете free(), аллокатор лишь помечает этот участок памяти как доступный для повторного использования. Но здесь есть критически важный момент: ОС не получает эту память обратно. С точки зрения операционной системы процесс по-прежнему её использует. Она продолжает учитываться в RSS. Kubernetes тоже продолжает её учитывать.
Вот ключевая мысль: сами выделения памяти, связанные с «зомби»-подключениями, были недостаточно велики, чтобы заполнить 3,5 ГБ. Несколько килобайт нативных накладных расходов на подключение, умноженные на 3 000 «зомби», — это всего около 10–20 МБ реально используемых данных. Но эти выделения существовали по пять минут каждое и были разбросаны по всей куче. За это время все остальные выделения нативной памяти в процессе — сериализация JSON, чтение из базы данных, создание и завершение подключений исправных клиентов — попадали в те же самые области, чередуясь с памятью «зомби». Когда «зомби» наконец завершался, освободившиеся участки оставляли после себя «дыры», фрагментировавшие кучу. Содержимым этих 3,5 ГБ были вовсе не «зомби». Они были теми самыми «якорями», которые не позволяли куче когда-либо снова уменьшиться.
Несколько слов о том, что мы могли и чего не могли наблюдать: во время первоначального инцидента у нас не было включено трассирование malloc() — в условиях реальной производственной среды невозможно инструментировать внутренние механизмы libc. Точный профиль распределения нативных ресурсов был восстановлен на основе того, что мы смогли измерить (RSS, размер управляемой кучи, количество подключений, память cgroup) и что мы смогли проверить (jemalloc устранил удержание памяти, тем самым доказав, что причиной была фрагментация в ptmalloc2). Конкретное взаимодействие между внутренними вызовами malloc() среды выполнения и жизненным циклом «зомби»-подключений — это наша наиболее вероятная модель того, почему всё происходило именно так. Она была построена на основе наблюдавшихся симптомов, внесённых исправлений и тех частей стека, которые были нам доступны. Когда вы отлаживаете проблему, проходящую через уровни, которыми не управляете, — среду выполнения .NET, Kestrel, glibc, ядро Linux, — именно так и выглядит реальность системной разработки.
Арены: почему потоки ухудшают ситуацию
В ptmalloc2 есть ещё одно архитектурное решение, которое усугубило нашу проблему. Чтобы уменьшить конкуренцию, когда нескольким потокам одновременно требуется память, он выделяет каждому потоку свой собственный отдельный пул памяти — так называемую арену. В системе с 8 ядрами ЦП он может создать до 64 арен. Идея вполне разумна: потоки не блокируют друг друга при выделении памяти.
Проблема заключается в том, что арены не могут совместно использовать свободную память. Представьте их как отдельные склады. Если на складе A есть свободные полки, а склад B переполнен, склад B не может воспользоваться свободным местом склада A — ему приходится расширяться. Kestrel использует пул потоков, а подключения в течение своего жизненного цикла могут обслуживаться разными потоками. «Зомби»-подключение могло получить свои нативные структуры в арене 1, затем обслуживаться потоком, использующим арену 3, а после завершения оставить «дыры» сразу в обеих аренах, которыми ни одна из них не могла поделиться с другой.
Схема выделения памяти выглядела следующим образом:
# Thread pool thread 1 → arena 1 Handles connection A, native allocations: interop + state + metadata Handles connection B, native allocations: interop + state + metadata
# Connection A goes zombie — lives for minutes, pinning its allocations # Meanwhile, normal churn (serialization, DB, HTTP) fills around it Arena 1: [churn] [A: pinned] [churn] [B: alive] [churn] # Connection A finally times out and is freed Arena 1: [churn] [hole] [churn] [B] [churn] # Repeat thousands of times across dozens of arenas # → Heap only grows. RSS never returns.
Жизненный цикл «зомби-подключений» был наихудшим из возможных вариантов для этого аллокатора. Долгое существование «зомби» (до пяти минут) приводило к тому, что их память глубоко перемешивалась с памятью исправных подключений. Когда «зомби» наконец умирал, освобожденная память представляла собой швейцарский сыр — разбросанные дыры по множеству областей, которые можно было повторно использовать для запросов того же или меньшего размера, но которые никогда нельзя было вернуть в операционную систему.
И есть ещё одна деталь, окончательно замыкающая эту ловушку. Куча может уменьшаться только сверху. Представьте стопку коробок — убрать можно только верхние. Если верхняя коробка всё ещё используется, все коробки под ней оказываются заблокированными, даже если они пусты. При сотнях пересекающихся по времени жизненных циклов подключений в десятках арен почти всегда оставалось что-то ещё живое рядом с вершиной каждой кучи арены. Вся область ниже — потенциально сотни мегабайт свободных «дыр» — оставалась заблокированной и продолжала учитываться операционной системой и Kubernetes как «используемая память».
Именно поэтому всё выглядело как утечка памяти, хотя с технической точки зрения каждому вызову malloc() соответствовал вызов free(). С точки зрения приложения память была освобождена. Но операционная система так её обратно и не получила.
Ещё одним уровнем ниже
Фрагментация аллокатора объясняла, почему освобождённая память не возвращалась операционной системе. Но существовал ещё один уровень накопления нативной памяти, который был ещё менее заметен: буферы отправки TCP, управляемые ядром Linux.
Когда приложение отправляет данные через сетевой сокет, они не попадают сразу в сеть. По пути они проходят через несколько буферов. В нашем случае сериализованные данные сущности проходили из .NET в буферы канала ввода-вывода Kestrel, а затем попадали в буфер, которым для каждого TCP-сокета управляет ядро Linux. Именно ядро отвечает за фактическую передачу байтов по сети, повторную отправку потерянных пакетов и ожидание подтверждений от другой стороны.
Когда мы отправляли обновления сущностей «зомби»-подключению, ядро добросовестно пыталось их доставить. Но подтверждения не поступало — клиент уже исчез. Протокол TCP не сдаётся сразу. Он повторно передаёт данные, используя механизм экспоненциальной задержки, каждый раз увеличивая интервал между попытками вдвое. В течение всего этого времени ядро продолжает удерживать данные, поскольку они могут понадобиться для повторной отправки.
Эти буферы находятся в пространстве ядра — памяти, которой управляет сама операционная система и которая полностью находится за пределами адресного пространства процесса. Ни один инструмент диагностики на уровне приложения не может их увидеть. Их не видит dotnet-dump. Их не видит LLDB. Их не видит pmap — их вообще нет в виртуальном адресном пространстве процесса. Но они учитываются системой учёта памяти cgroup ядра. Их видит Kubernetes. Их видит OOM-killer.
При пятиминутном тайм-ауте KeepAlive ядро удерживало буфер отправки каждого «зомби»-подключения на протяжении всего этого времени. Размер таких буферов обычно составляет от 64 КБ до 4 МБ на сокет — в зависимости от объёма данных в очереди и настроек системы.
# Estimated kernel memory from zombies: 2,000 zombie connections × ~1MB TCP send buffer = ~2GB in kernel space # Completely invisible to any application-level diagnostic
Почему Kubernetes усугубил ситуацию
В Kubernetes каждый под работает внутри изолированной среды (cgroup), которую ядро Linux использует для учёта и ограничения потребления ресурсов контейнером. Метрика памяти, за которой следит Kubernetes, включает всё: управляемую кучу, фрагментированную нативную кучу, удерживаемую аллокатором, и все буферы TCP ядра, принадлежащие «зомби»-сокетам. Она не различает «память, которую код действительно использует» и «память, которую аллокатор удерживает, но не использует».
Мы используем Horizontal Pod Autoscaler (HPA) — механизм Kubernetes, который автоматически увеличивает или уменьшает количество реплик подов на основании метрик использования ресурсов. Когда поды начинали потреблять слишком много памяти, HPA масштабировал систему: больше подов, больше вычислительных ресурсов, выше стоимость. Но здесь возникла проблема: каждый новый под, присоединившийся к кластеру, сразу начинал получать широковещательные сообщения через Redis backplane и принимать повторные подключения мобильных клиентов. Уже через несколько минут у нового пода появлялась собственная армия «зомби». HPA масштабировал систему, пытаясь решить проблему памяти, но каждый новый под только усугублял эту проблему.
Но когда нагрузка снижалась, RSS подов так и не уменьшался. Управляемая куча сокращалась — сборщик мусора выполнял свою работу. Буферы TCP ядра освобождались после закрытия «зомби»-сокетов. Но фрагментированная нативная куча, которую продолжал удерживать аллокатор, сохраняла высокий RSS. Память, которая действительно использовалась во время пиковых нагрузок, оставляла после себя множество пустых участков, которые операционная система уже не могла вернуть себе. HPA видел, что поды по-прежнему потребляют значительно больше памяти, чем необходимые им 600 МБ, и больше не уменьшал количество реплик.
Мы платили за призрачную память — пустые фрагменты арен, которые операционная система не могла освободить, сборщик мусора не мог затронуть, а Kubernetes не мог отличить от реально используемой памяти. Поды, которым было достаточно 600 МБ, оставались раздутыми из-за фрагментированной нативной кучи, а HPA исправно продолжал поддерживать их работу.
Полный каскад Нестабильные мобильные соединения (в нашем собственном офисном подвале) → «зомби»-подключения SignalR, существующие несколько минут → Redis backplane рассылает обновления заявок «зомби»-подключениям на каждом поде → нативные выделения памяти для каждого подключения попадают в общую кучу вместо выделения отдельных областей памяти → фрагментация аллокатора между потоками пула → буферы отправки TCP ядра для «мёртвых» сокетов → объём памяти cgroup вырастает до 4 ГБ → завершение пода из-за нехватки памяти (OOM) → перезапуск пода → клиенты автоматически переподключаются через три секунды → 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 рекомендуется оставлять достаточный запас времени для получения ping-пакетов в сетях с высокой задержкой, и мы решили выбрать более безопасный вариант. Параметр MaximumReceiveMessageSize был увеличен до 128 КБ, чтобы иметь запас для команд, отправляемых клиентом в Hub (JoinGroup, GetData и аналогичных запросов на получение данных), хотя их размер редко превышал несколько сотен байт. На первый взгляд оба решения выглядели вполне разумными. Однако в нестабильных мобильных сетях с «тихими» разрывами соединения такой тайм-аут оказался катастрофическим, а увеличенный буфер приёма означал, что входной канал каждого «зомби»-подключения резервировал значительно больше памяти, чем ему когда-либо требовалось.
Параметр StatefulReconnectBufferSize тоже заслуживает отдельного пояснения. Эта возможность появилась в .NET 8 и хранит буфер для каждого подключения, чтобы клиент мог восстановить соединение после кратковременного разрыва, не потеряв сообщения. Звучит идеально для нестабильных соединений. Но 60% наших отключений происходили из-за переключения между сетями, при котором создаётся совершенно новое TCP-соединение. В таких случаях этот буфер никак не помогал. Мы расходовали примерно 10–17 МБ памяти на функцию, которая не приносила никакой пользы в нашей рабочей нагрузке.
Уровень 2: Уменьшить масштаб последствий
В нашем ALB (Application Load Balancer) тайм-аут бездействия составлял 3 600 секунд — один час. Он был увеличен по сравнению со стандартным значением AWS, равным 60 секундам, чтобы балансировщик нагрузки не закрывал долгоживущие WebSocket-подключения. Неактивные HTTP-соединения оставались открытыми в течение часа, удерживая нативную память. Мы вернули тайм-аут к 60 секундам и установили для Kestrel значение KeepAliveTimeout, равное 70 секундам (всегда немного больше тайм-аута ALB, чтобы избежать ошибок 502 Bad Gateway).
# Kestrel (defaults: KeepAliveTimeout=130s, MaxConcurrentConnections=unlimited, # MaxConcurrentUpgradedConnections=unlimited) options.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(70); options.Limits.MaxConcurrentConnections = 550; options.Limits.MaxConcurrentUpgradedConnections = 350;
# ALB (default idle timeout is 60s; ours was set to 3600s for WebSocket longevity) 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.2LD_PRELOAD — это переменная окружения Linux, которая указывает системе загрузить разделяемую библиотеку раньше всех остальных. Если указать в ней библиотеку jemalloc, каждый вызов malloc() и free() во всём процессе — от среды выполнения .NET до драйвера MySQL и любых других нативных библиотек — будет выполняться через jemalloc вместо стандартного аллокатора glibc. Никаких изменений в коде. Никакой повторной компиляции. Само приложение даже не знает, что использует другой аллокатор.
jemalloc был создан именно для такого типа нагрузки — долгоживущих серверов с большим количеством одновременных выделений памяти разного размера, выполняемых из множества потоков. Именно его используют Redis, Meta и Firefox в продакшене. Вместо отдельных арен для каждого потока с фрагментированными списками свободных блоков jemalloc организует память по классам размеров (size-class bins), которые можно представить как заранее отсортированные полки для объектов разных размеров, и активно сообщает операционной системе о возможности освободить неиспользуемые страницы памяти. Для этого используется системный вызов madvise, который фактически говорит ядру: «Эта страница памяти мне больше не нужна — можешь забрать её обратно, но оставь резервирование адресного пространства на случай, если она снова понадобится». В результате освобождённая память действительно приводит к уменьшению RSS.
Несколько слов о компромиссах при использовании jemalloc jemalloc — не волшебная палочка. Его агрессивная очистка страниц памяти требует немного больше процессорного времени, чем аллокатор glibc, — ядру приходится выполнять больше работы по освобождению и повторному отображению страниц памяти. Некоторые средства мониторинга могут показывать непривычную картину использования памяти, поскольку внутренний механизм учёта jemalloc отличается от того, чего ожидают эти инструменты. Кроме того, это ещё одна зависимость: её необходимо установить в Docker-образ и своевременно обновлять. Следует также отметить, что темпы развития jemalloc в последние годы снизились после того, как Meta сократила объём инвестиций в проект. Тем не менее стабильные ветки продолжают получать исправления, а jemalloc по-прежнему остаётся аллокатором по умолчанию в FreeBSD. Для нашей рабочей нагрузки компромисс был очевиден — мы расходовали гигабайты оперативной памяти ради экономии нескольких процессорных циклов на управлении страницами памяти. Но прежде чем принимать такое решение, всегда следует проводить тестирование на собственной рабочей нагрузке.
Почему не другой аллокатор?
jemalloc был не единственным вариантом. Мы рассматривали две альтернативы, но в итоге отказались от них.
gperftools tcmalloc (Google) — оптимизирован для быстрого выделения небольших объектов за счёт использования кэшей потоков. Хорошо показывает себя при умеренном количестве потоков, однако его центральные списки свободных блоков и общий пул страниц не разделены между потоками, что может стать узким местом при интенсивном выделении памяти из разных потоков. Это не соответствовало профилю нашей системы — сервера ASP.NET Core с активным использованием пула потоков и высокой интенсивностью создания и завершения подключений.
mimalloc (Microsoft Research) — компактный высокопроизводительный аллокатор с превосходными результатами в тестах производительности. Однако в архитектуре версии 2 используются отдельные пулы памяти для каждого потока, и память намеренно не передаётся между потоками — память, освобождённая одним потоком, остаётся зарезервированной именно за ним. Для пула потоков с постоянно меняющейся нагрузкой это хорошо известный путь к накоплению памяти. Отличный выбор, если важна максимальная скорость выделения памяти, но неподходящий вариант для наших контейнеров с жёсткими ограничениями по памяти.
jemalloc оказался лучшим выбором благодаря своей зрелости и соответствию нашей задаче. Он появился в 2005 году как аллокатор libc для FreeBSD, что делает его самым старым из рассматриваемых альтернатив, проверенных в реальных условиях эксплуатации. С тех пор он прошёл испытание экстремальными нагрузками в Redis, Meta и Firefox. Окончательно выбор определили особенности его архитектуры: множество арен с возможностью совместного использования памяти между потоками, классы размеров, минимизирующие фрагментацию, и активный возврат неиспользуемых страниц операционной системе с помощью madvise. Для долгоживущего сервера с высокой интенсивностью создания и завершения подключений, сообщениями переменного размера и жёсткими ограничениями памяти контейнеров именно такое сочетание возможностей оказалось тем, что нам было нужно.
После
Использование памяти подами стабилизировалось на уровне менее 1 ГБ. Завершения из-за нехватки памяти (OOM) прекратились. HPA впервые начал уменьшать количество реплик. Перезапуски в середине рабочего дня прекратились.
Напомню — это был предпусковой пилотный запуск. 200 устройств, половина из которых принадлежала нашим собственным специалистам по сопровождению, пытавшимся работать при плохом сигнале в подвале офиса. Три недели мы не могли выйти в production, потому что эта проблема приводила к завершению каждого пода, который мы запускали. Через неделю после внедрения исправлений мы выполнили полноценный запуск системы. Система, которая не могла выдержать нагрузку от 200 пользователей во время пилотного запуска, теперь работает в промышленной эксплуатации без единого завершения из-за нехватки памяти (OOM).
Три недели мы каждую ночь развёртывали исправления, а днём наблюдали, как они не дают результата, — и всё из-за ошибки, которую не мог обнаружить ни один инструмент диагностики .NET. Но за эти три недели я узнал о том, как программное обеспечение на самом деле работает в Linux, больше, чем за несколько предыдущих лет разработки на C#.
Почему production ни разу не остановился
Три недели поды завершались из-за нехватки памяти каждые несколько часов, и при этом не произошло ни одного сбоя, заметного пользователям. Это было не везение, а результат трёх уровней защиты, которые мы реализовали ещё до того, как поняли первопричину проблемы: readiness probe, исключавшей под из балансировщика нагрузки при достижении 80% использования памяти (при этом существующие подключения продолжали обслуживаться), liveness probe, принудительно перезапускавшей под, если использование памяти слишком долго оставалось выше 93%, и SmartMemoryTrimService, который агрессивно запускал GC.Collect() с уплотнением Large Object Heap (LOH), чтобы удерживать управляемую кучу под контролем. Ключевой вывод заключался в следующем: к тому моменту, когда под приближался к завершению из-за нехватки памяти (OOM), он уже был исключён из production-трафика и обслуживал только «зомби»-подключения. Поэтому даже если происходило завершение из-за OOM, пользователи этого не замечали.
Побочным эффектом стали затраты. Поды, которые были работоспособны, но не готовы к работе, поддерживали максимальное количество реплик HPA. Мы платили за стабильность инфраструктурными затратами — именно тот компромисс, который необходим, пока вы ищете первопричину проблемы.
Чему меня научили три недели без сна
Большинству .NET-разработчиков никогда не приходится задумываться о нативной памяти. Управляемая куча, сборщик мусора, IDisposable — все эти абстракции настолько хорошо работают, что можно построить целую карьеру, так и не узнав, как операционная система выделяет память под средой выполнения. До тех пор, пока ваше приложение не начинает работать в контейнерах Linux с WebSocket-подключениями от мобильных устройств, находящихся в нестабильных сетях, — и тогда оказывается, что эта абстракция имеет свои пределы.
dotnet-dump видит только половину картины. Если в Linux растёт RSS, а размер управляемой кучи остаётся неизменным, значит проблема находится в нативной памяти, и dotnet-dump её не увидит. Для анализа дампов потребуется LLDB, а для просмотра областей памяти работающего процесса — pmap. Быстрый способ диагностики — запустить dotnet-counters и следить за двумя показателями: GC Heap Size и Working Set. Если Working Set продолжает расти, а GC Heap Size остаётся стабильным, значит проблема находится в нативной памяти. Инструменты .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 КБ. В долгоживущем сервере все нативные выделения памяти — взаимодействие среды выполнения с нативным кодом, сериализация, управление подключениями — оказываются ниже этого порога и попадают в общую кучу, которая никогда не уменьшается. Долгоживущие подключения становятся источниками фрагментации, не позволяющими уменьшить размер кучи даже после закрытия этих подключений.
Искусственный интеллект — это инструмент, многократно повышающий эффективность работы, а не замена инженерному мышлению. Я активно использовал Claude — как помощника для исследований, а не как непререкаемый источник истины. Он помог мне разобраться в механизме арен ptmalloc2, оценить объём памяти, занимаемой «зомби»-подключениями, проанализировать компромиссы при выборе настроек буферов и обратить внимание на альтернативные аллокаторы, о которых я раньше не задумывался. Но он ошибался не реже, чем был прав. Я тратил столько же времени на проверку его гипотез, сколько и на их использование: уточнял оценки памяти, опровергал предположения о работе буферов и заставлял его согласовывать выводы с тем, что показывали реальные метрики production. Направление задавало понимание системы. Скорость обеспечивал помощник, который в рамках одной беседы мог переключаться от внутреннего устройства ядра Linux к особенностям работы SignalR.
«Самые сложные ошибки находятся не в вашем коде. Они скрываются в предположениях, которые платформа делает о том, как этот код должен работать.»
Хронология
Если вы используете ASP.NET Core в контейнерах Linux с подключениями в режиме реального времени, проверьте объём нативной памяти. Запустите dotnet-counters и сравните GC Heap Size с Working Set. Если Working Set продолжает расти, а GC Heap Size остаётся стабильным, проблема находится в нативной памяти — за пределами области, которую контролирует сборщик мусора. Путь к её устранению начинается с понимания того, где на самом деле находится память вашего приложения.
Подписывайтесь на телеграм-канал Мониторим ИТ, там еще больше полезной информации о мониторинге!