Оперативная память в Linux никогда не была единым котлом, и это не украшение архитектуры, а следствие реальных различий: часть адресов нужна старым устройствам для DMA, часть отдана под горячее подключение и перемещаемые страницы, часть лежит на удалённом NUMA-узле. Эти области называются зонами, и их наполнение не бывает ровным само по себе: одна зона заканчивается, пока соседняя полна, ядро будит переработку, приложение ловит задержку, а в плохой конфигурации и отказ в выделении. Состав зон различается по архитектурам и поколениям платформ, поэтому первый шаг любой диагностики всегда одинаковый: прочитать фактический список зон конкретной машины, а не полагаться на привычную схему. Управление балансом зон, от водяных отметок до NUMA-балансировки и компакции, относится к тем настройкам, которые в спокойной лаборатории выглядят лишними, а в продакшене определяют, переживёт ли узел пик. Нюанс, который легко упустить: часть выделений ядра помечена как атомарные, они не умеют ждать освобождения страниц, и на истощённой зоне такие выделения падают сразу, что на сетевых драйверах оборачивается отброшенными пакетами, а на драйверах хранения сбросами очередей.
Разберём устройство зон, механизмы удержания баланса, настройку на NUMA-системах и порядок практической доводки.
Зоны памяти и роли каждой из них
Каждый NUMA-узел делит свою память на зоны. ZONE_DMA хранит страницы, доступные устройствам с ограниченной адресацией, исторически первые 16 мегабайт адресного пространства. ZONE_DMA32 покрывает первые четыре гигабайта и существенна для контроллеров, не умеющих адресовать выше. ZONE_NORMAL это основная рабочая масса памяти, ZONE_MOVABLE собирает страницы, которые ядро вправе переносить, и служит для горячего подключения памяти и противодействия фрагментации, а ZONE_DEVICE отражает память устройств вроде PMEM через тот же менеджер страниц. Высокая зона HIGHMEM осталась достоянием 32-разрядных систем, и на современных серверах её нет. Отдельная страница истории связана с ZONE_DEVICE: она отображает память устройств, прежде всего энергонезависимые модули PMEM, через обычный менеджер страниц, что позволило приложениям работать с такой памятью стандартными страницами, и по механике баланса она близка к обычным зонам, хоть и живёт вне узлов процессоров.
Картину по зонам выдают стандартные файлы:
grep -E 'Node 0, zone' /proc/zoneinfo
cat /proc/buddyinfo
cat /proc/pagetypeinfo
grep -E 'allocstall|pgalloc' /proc/vmstat
В zoneinfo по каждой зоне видны размеры, свободные страницы и водяные отметки, в buddyinfo распределение свободных блоков по порядкам, в pagetypeinfo то же самое с разрезом по типам миграции страниц, а в vmstat счётчики выделений и stalls по зонам. Последние особенно важны: строки allocstall_dma32 и allocstall_normal показывают, сколько раз ядро засыпало в ожидании освобождения страниц конкретной зоны, и любое их накопление это прямой симптом дисбаланса.
Водяные отметки и пробуждение kswapd
Баланс внутри зоны держат водяные отметки. Отметка min это аварийный уровень, до которого ядро дожимает выделения в ущерб всему, low будит поток kswapd, high снова усыпляет его, и ровная работа системы зависит от того, где эти отметки стоят относительно размера зоны. Отметка min вычисляется из sysctl vm.min_free_kbytes, который ядро пересчитывает само по размерам зон, но администратор может задать и вручную: слишком маленькое значение приводит к сбоям атомарных выделений и драматичным отказам, слишком большое просто крадёт память под пустой резерв.
Отметки low и high растут от min с масштабом, которым управляет sysctl vm.watermark_scale_factor. Значение по умолчанию 10 означает десятую долю процента размера зоны, и на больших узлах с сотнями гигабайт этого мало: kswapd просыпается слишком поздно, дорабатывает рывком и создаёт всплески задержки. Поднятие масштаба до 50-125 растягивает окно работы kswapd и делает переработку незаметной, и это одна из самых дешёвых побед в настройке больших серверов. Числа наглядны: на узле с 512 гигабайтами десятая доля процента это порядка 512 мегабайт, и kswapd обязан закрыть такой разрыв рывком, а при масштабе 125 окно вырастает примерно до шести гигабайт, и переработка растягивается на секунды ровного фона. Отдельно существует vm.watermark_boost_factor, по умолчанию 15000, управляющий временным подъёмом отметок после фрагментационных событий: уменьшение его снижает лишние пробуждения на узлах с интенсивной работой с огромными страницами.
Балансировка страниц между NUMA-узлами
Между узлами баланс держит NUMA-балансировка страниц, включаемая sysctl kernel.numa_balancing. Механизм периодически сканирует адресные пространства, помечает страницы защитой и по отказам страниц выясняет, кто к какой памяти бегает, после чего переносит страницы туда, где их потребитель. Параметры сканирования настраиваются семейством sysctl kernel.numa_balancing_scan_period_min_ms, scan_period_max_ms и scan_size_mb, и агрессивность стоит поднимать осторожно, потому что каждое перемещение это копия страницы и запись в счётчики миграции. Умолчания описывают умеренный темп: задержка между сканированиями порядка ста миллисекунд, период от секунды до минуты по активности процесса, объём скана до сотен мегабайт за заход, и перегиб в любую сторону либо сжигает процессор на сканировании, либо не успевает за горячими страницами.
Качество раскладки показывает numastat:
cat /sys/devices/system/node/node0/numastat
numactl -H
numactl --interleave=all -- ./app
Поля numa_hit и local_node говорят, что память попадает туда, где её потребляют, а растущие numa_miss и numa_foreign сигналят о перекрёстном трафике между узлами. Для нагрузок, которым важна ровная полоса, а не локальность, помогает чередование страниц по узлам через numactl с ключом interleave, и это буквальное выравнивание нагрузки между зонами разных узлов. Отдельный рычаг, vm.zone_reclaim_mode, решает, что ядро делает при исчерпании локальной зоны: ноль разрешает идти в удалённую память, единица включает локальную переработку, двойка добавляет выгрузку на диск, четвёрка разрешает swap. На узлах с большой удалённой памятью переработка по умолчанию вредна, и этот параметр стоит сверять с геометрией конкретной машины.
MOVABLE, CMA и горячее подключение
Зона MOVABLE заслуживает отдельного разговора, потому что её объём задаётся параметрами загрузки kernelcore и movablecore, а на системах с подключаемой памятью параметр movable_node переносит всю горячую память в подвижную зону. Типовые пропорции на узле под горячее подключение выглядят так: параметр kernelcore удерживает ядерную часть объёмом, например, в четверть памяти, остальное уходит в подвижную зону, и оба способа задания согласуются с планами подключения планок без простоя служб. Баланс здесь деликатный: подвижные страницы обязаны уметь съезжать при подключении или отключении, но если ядро положит в подвижную зону что-то неперемещаемое, операция застрянет, поэтому ядро держит типы миграции строго по полкам, и pagetypeinfo показывает, не забилась ли подвижная зона неперемещаемыми гостями.
Смежный механизм, CMA, резервирует область под крупные непрерывные выделения для устройств, но хитрым образом: до востребования область отдаётся подвижным страницам, а в момент запроса их просят съехать. Резерв задаётся параметром cma в мегабайтах, например cma=8G, состояние областей видно в каталоге /sys/kernel/debug/cma, где по каждой области читаются размер и фактическое использование, и на системах с интенсивными DMA-выделениями размер CMA определяет, переживёт ли узел пик запросов без отказа. На баланс зон CMA влияет двояко: она защищает обычные зоны от фрагментации крупными блоками, но сама живёт внутри зон и уменьшает доступный обычным выделениям объём.
Фрагментация и компакция как часть баланса
Ровный баланс зон ломает фрагментация: свободных страниц хватает, но не теми кусками, и запрос на большой порядок падает в отказ при формально свободной памяти. Ядро борется компакцией, физическим переездом страниц ради сборки непрерывных блоков. Проактивная компакция включается sysctl vm.compaction_proactiveness в диапазоне от ноля до сотни, где значение по умолчанию двадцать оставляет работу на совести событий выделения, а повешение заставляет ядро искать и чинить фрагментацию заранее, за счёт процессорного времени. Глубину проблемы показывает отладочный файл extfrag_index в debugfs, где по порядкам и зонам виден коэффициент фрагментации, и он же подсказывает, какой зоной стоит заняться. Ручной запуск компакции возможен записью единицы в /proc/sys/vm/compact_memory, что удобно при разборе инцидента, когда хочется выровнять зоны немедленно, а не ждать проактивной работы. С огромными страницами связана и политика дефрагментации vm.thp_defrag, управляющая тем, насколько рьяно ядро уплотняет память ради транской страницы, и значения от always до madvise меняют и задержки выделений, и фоновую активность компакции. Требования к непрерывности растут с размером страницы, и страницы на гигабайт нагружают компакцию ещё сильнее, чем двухмегабайтные, поэтому на системах с явными пулами огромных страниц их резервы стоит выделить заранее, а не собирать из живой памяти на ходу.
С огромными страницами связан sysctl vm.thp_defrag, управляющий тем, насколько рьяно ядро уплотняет память ради транской страницы, и значения от always до madvise меняют и задержки выделений, и фоновую активность компакции. В связке с watermark_boost_factor настройка этой тройки определяет, станет ли подача огромных страниц ровной, или узел будет жить пилообразными паузами компакции.
MGLRU и современное вытеснение
Свежим пополнением арсенала стала многослойная очередь LRU, MGLRU, вошедшая в ядро 6.1. Механизм меняет способ отбора жертв при переработке, заменяя двухсписковую эвристику поколениями по возрасту обращений, и на больших узлах это заметно снижает работу kswapd и глубину отказов страниц. Управление живёт в /sys/kernel/mm/lru_gen, где файл enabled принимает битовую маску включаемых возможностей, а параметр минимального времени жизни страниц ограждает рабочие наборы от слишком ретивой переработки: страницы, к которым обращались недавно, получают защиту от немедленного вытеснения, что спасает кэши приложений от необдуманного сброса. Для узлов с терабайтами памяти MGLRU одна из немногих настроек, которая реально меняет поведение под пиками, поэтому её стоит проверить в списке возможностей установленного ядра, прежде чем строить планы на старые механизмы.
Практический порядок настройки баланса
Доводка баланса зон сводится к последовательности:
- снять исходную картину через zoneinfo, buddyinfo и vmstat на эталонной нагрузке и записать allocstall по зонам;
- подобрать vm.watermark_scale_factor так, чтобы kswapd работал ровно, ориентируясь на исчезновение всплесков в графиках задержки;
- включить NUMA-балансировку, если нагрузки ходят между узлами, и сверить numastat на предмет роста numa_miss;
- решить вопрос zone_reclaim_mode по геометрии узла, отдав приоритет локальности либо ровной полосе;
- настроить подвижную зону и CMA под горячее подключение и крупные DMA, сверив pagetypeinfo на предмет чужеродных страниц;
- включить компакцию и MGLRU по показаниям, а не по привычке, и закрепить замеры в мониторинге до и после каждой правки.
Главное правило всей этой работы звучит просто: каждая правка проверяется замером на реальной нагрузке, потому что баланс зон это не догма спецификации, а равновесие конкретного железа с конкретным приложением, и оно переезжает вместе с ними. На практике правки удобнее вносить по одной и держать журнал изменений рядом с графиками задержки, поскольку эффекты водяных отметок, балансировки и компакции складываются нелинейно, и поиск виновника по памяти без записей превращается в гадание.
В сумме балансировка зон это управление тремя скоростями одновременно: скоростью пополнения свободных страниц внутри зоны, скоростью переноса страниц между зонами и узлами и скоростью сборки крупных блоков из фрагментов. Ядро даёт на каждую скорость свои ручки, от водяных отметок до MGLRU, и узел, на котором эти ручки выставлены по замерам, держит ровную задержку под пиками там, где соседний с настройками по умолчанию захлёбывается на первых же скачках нагрузки.