Kafka на Kubernetes: cgroup v2 съедает page cache

Недавно мы начали переносить кластеры Kafka с EC2 на EKS с помощью Strimzi. Цель состояла не в погоне за новыми возможностями, а в снижении операционных издержек, связанных с ручным управлением крупными stateful-кластерами. Обновления, изменения конфигурации, замена семейств инстансов и восстановление после сбоев требовали слишком большой ручной координации на EC2.

Мы хотели получить модель, которая даёт:

  • Декларативную конфигурацию.

  • Упрощённые обновления.

  • Более простые изменения инфраструктуры.

  • Улучшенное самовосстановление.

  • Меньше рутинной операционной работы.

С этим всё получилось. Что не получилось — так это производительность после миграции. Как только мы перенесли первый кластер, мы заметили постоянные чтения с диска на всех брокерах и более высокую задержку, чем ожидали на сопоставимом железе.

Почему это было проблемой

Для Kafka дисковые чтения — это не просто деталь хранилища. При нормальной работе, когда потребители (consumers) находятся близко к голове лога и у брокеров достаточно памяти, горячие данные должны обычно отдаваться из кэша страниц (page cache), а не с диска.

Когда чтения начинают проваливаться до хранилища, симптомы становятся трудно игнорируемыми:

  • Задержка растёт.

  • Пропускная способность становится менее предсказуемой.

  • Хранилище выполняет больше работы, чем должно.

  • Кластер ведёт себя иначе, чем предполагает привычная ментальная модель.

Именно поэтому это сразу бросилось в глаза. Мы наблюдали не безобидное расхождение в метриках, а активацию пути обработки данных, который не должен был срабатывать так часто.

Что вы узнаете

В этой статье рассказывается, как мы расследовали проблему и что обнаружили. Хотя именно Kafka обнажила её, первопричина оказалась шире: взаимодействие между Kubernetes, cgroup v2, поведением ядра Linux при освобождении памяти (reclaim) и рабочими нагрузками с активным обращением к диску.

К концу статьи у вас должна сложиться более чёткая картина:

  • Почему некоторые сервисы данных начинают читать с диска чаще ожидаемого на Kubernetes.

  • Какие сигналы помогают отличить проблему Kafka от проблемы ядра или управления памятью.

  • Что стоит проверить, прежде чем менять настройки на уровне приложения.

  • Какие настройки ядра, связанные с освобождением памяти, заслуживают внимания.

  • Как подходить к тюнингу других stateful-сервисов, которые ведут себя странно на Kubernetes.

Проблема: неожиданные чтения с диска

После миграции первого кластера Kafka на Kubernetes с помощью Strimzi мы сразу заметили кое-что необычное: брокеры выполняли постоянные и ощутимые чтения с диска. Для нашей нагрузки это был тревожный сигнал, поскольку эти кластеры обрабатывают очень высокий трафик, и даже небольшие регрессии задержки быстро проявляются в производительности брокеров.

График имел значение, потому что это был не изолированный всплеск и не событие восстановления. Это была стабильная активность чтения при штатной работе на железе, которое должно было вести себя аналогично нашему развёртыванию на EC2.

Почему дисковые чтения имели значение

Kafka в значительной мере полагается на кэш страниц операционной системы, а не реализует собственный буферный слой. В здоровом кластере, когда потребители читают близко к голове лога и на узле достаточно доступной памяти, большинство горячих чтений должны обслуживаться из памяти, а не с диска.

Именно поэтому эти чтения сразу привлекли наше внимание:

  • Обращения к памяти намного быстрее дисковых чтений.

  • Проваливание до хранилища увеличивает задержку.

  • Устойчивые чтения часто означают, что кэш страниц вытесняется слишком агрессивно.

Ключевая мысль была простой: для нашей нагрузки дисковые чтения — это не просто метрика хранилища. Это сигнал задержки.

Первая гипотеза: отставание потребителей

Первым подозреваемым стало отставание потребителей (consumer lag). Это было бы самым простым объяснением: если потребители читают старые смещения (offsets), соответствующие данные могут уже не находиться в кэше страниц, и ядро вынуждено загружать их с диска.

Мы проверили отставание потребителей с помощью наших экспортёров и дашбордов мониторинга и не обнаружили ничего существенного. Потребители читали близко к голове лога, так что одно лишь отставание не могло объяснить постоянные чтения.

Вывод: чтения были реальными, но причиной служило не отставание потребителей.

Что действительно изменилось

Исключив отставание, мы задали следующий очевидный вопрос: что изменилось между старым и новым окружением?

Мы сравнили очевидных кандидатов:

  • Конфигурацию Kafka, включая настройки топиков, сжатие и параметры брокеров.

  • Тюнинг Linux sysctl.

  • Размер инстансов, включая CPU и память.

Всё это фактически не изменилось. Значимые различия находились глубже в стеке:

  • Мы перешли с Ubuntu 20.04 на Amazon Linux 2023.

  • Мы перешли с cgroup v1 на cgroup v2.

Это сузило расследование до поведения памяти на уровне операционной системы, а не самой Kafka.

Измерение поведения ядра

Чтобы понять, что делает ядро, мы использовали writeback.bt — скрипт bpftrace, показывающий, почему страницы записываются обратно на диск (writeback). Это было полезно, поскольку позволяет различать обычный фоновый writeback и writeback, вызванный давлением памяти (reclaim-driven writeback).

На новой машине многие события writeback были помечены как vmscan:

bpftrace ./writeback.bt
Attaching 4 probes…
Tracing writeback… Hit Ctrl-C to end.
TIME DEVICE PAGES REASON ms
13:06:59 259:0 2385 vmscan 0.006
13:06:59 259:0 2385 vmscan 0.000
13:06:59 259:0 26476 periodic 0.000
13:06:59 259:0 38518 vmscan 0.002
13:06:59 259:0 2397 vmscan 0.000
13:06:59 259:0 2397 vmscan 0.000

На старой машине writeback преобладал в режиме фоновых и периодических событий:

bpftrace ./writeback.bt
Attaching 4 probes…
Tracing writeback… Hit Ctrl-C to end.
TIME DEVICE PAGES REASON ms
13:07:59 259:0 2945 periodic 0.006
13:07:59 259:0 25613 periodic 0.000
13:07:59 259:0 26476 background 0.000
13:07:59 259:0 38518 background 0.000
13:07:59 259:0 2107 background 0.000
13:07:59 259:0 2645 periodic 0.000

Это различие стало первым сильным сигналом на уровне ядра. Новая установка выполняла значительно больше writeback, вызванного освобождением памяти, что означало давление на кэш страниц.

Что нам сказал vmscan

vmscan — часть пути освобождения памяти (reclaim path) в ядре. Когда он появляется в трассировках writeback, это обычно означает, что ядро активно освобождает память, а не выполняет плановую фоновую запись.

На практике это означало, что система платила штраф за освобождение памяти, которого мы не ожидали на эквивалентном железе. В этот момент вопрос сместился с «почему Kafka читает с диска?» на «почему ядро так агрессивно вытесняет кэш страниц в этом окружении?»

Первопричина: давление освобождения памяти в cgroup v2

Это привело нас к поведению памяти в cgroup v2, и в частности к memory.high. В cgroup v2, как только рабочая нагрузка пересекает высокое пороговое значение памяти (high memory threshold), ядро может начать применять давление освобождения памяти внутри этой cgroup, даже если на узле ещё есть свободная память.

Это плохо сочетается с Kafka и аналогичными системами, работающими с диском:

  • Им выгодно использовать доступную память под кэш страниц.

  • Высокопроизводительный трафик может быстро привести к давлению на reclaim.

  • Как только освобождение памяти начинается внутри cgroup, горячие страницы вытесняются раньше.

  • Больше чтений проваливается на диск, увеличивая задержку.

Иными словами, проблема была не в том, что у узла недостаточно RAM в абсолютном выражении. Проблема в том, что поведение освобождения памяти изменилось под Kubernetes с cgroup v2.

Почему dirty ratios оказалось недостаточно

Поначалу мы попробовали очевидные Kafka-специфичные ручки тюнинга: vm.dirty_ratio и vm.dirty_background_ratio. Эти настройки влияют на то, сколько «грязных» данных ядро допускает накопить перед принудительным writeback.

Они помогли управлять поведением writeback, но не решили реальную проблему. Writeback, вызванный освобождением памяти, сохранялся, а дисковые чтения оставались выше прежнего базового уровня.

Вывод: тюнинга dirty-страниц было недостаточно, поскольку основная проблема заключалась в давлении reclaim, а не в обычной записи на диск.

Исправление 1: убрать лимиты памяти для подов

Первым значимым исправлением стал отказ от установки лимитов памяти для подов Kafka с переходом к использованию requests и выделенных узлов. Это позволило избежать срабатывания давления освобождения памяти на уровне пода через механизмы управления памятью cgroup, пока хост ещё располагал свободной памятью.

В нашем случае это сработало по следующим причинам:

  • Kafka работала на выделенных узлах.

  • На этих узлах делились ресурсами лишь минимальные системные процессы и DaemonSet-нагрузки.

  • Ёмкость управлялась на уровне узла, а не через жёсткие ограничения памяти подов.

Это изменение дало Kafka больше свободы использовать кэш страниц хоста, вместо того чтобы быть ограниченной искусственной границей освобождения памяти.

Внимание
Не воспринимайте это как универсальную рекомендацию для Kubernetes. Снятие лимитов памяти с подов на общих узлах может привести узел к давлению памяти, что способно вызвать вытеснение подов (pod eviction) и прерывание рабочей нагрузки. Мы применяли этот подход только потому, что Kafka была изолирована на выделенных узлах, а ёмкость контролировалась на уровне узла.

Исправление 2: настройка vm.min_free_kbytes

Второе исправление — настройка vm.min_free_kbytes. Этот параметр влияет на водяные знаки ядра (kernel watermarks), которые определяют, когда kswapd просыпается и начинает освобождать память.

На наших узлах значение по умолчанию составляло:

sysctl -a | grep min_free_kbytes
vm.min_free_kbytes = 67584

Мы постепенно увеличивали его и наблюдали:

  • Более раннее фоновое освобождение памяти со стороны kswapd.

  • Меньше событий прямого освобождения памяти (direct reclaim).

  • Меньше writeback, вызванного vmscan, под той же нагрузкой.

Для нашего железа лучший результат дало:

vm.min_free_kbytes = 2548576

Это значение специфично для конкретной нагрузки, поэтому я не стал бы представлять его как универсальную рекомендацию. Важен сам механизм: повышение водяных знаков помогло ядру освобождать память раньше и плавнее, вместо того чтобы скатываться в более жёсткое поведение reclaim позже.

Результаты

После применения обоих изменений разница была очевидна:

  • Убрать лимиты памяти для подов Kafka.

  • Настроить vm.min_free_kbytes на узле.

При той же нагрузке количество байт, читаемых с диска, упало почти до нуля, а нормализованный средний уровень нагрузки (load average) снизился примерно на 50%.

Практический чеклист

Если вы устраняете подобное поведение в Kafka или другом disk-heavy сервисе на Kubernetes, я бы начал отсюда:

  • Запускайте нагрузку на выделенных узлах.

  • Избегайте лимитов памяти для подов, если сервис сильно зависит от кэша страниц.

  • Сравнивайте поведение cgroup, а не только конфиги Kafka.

  • Используйте bpftrace или аналогичные инструменты, чтобы отличить обычный writeback от вызванного освобождением памяти.

  • Исследуйте настройки, связанные с reclaim, такие как vm.min_free_kbytes, а не только соотношения dirty-страниц.

Дополнительные материалы

Если хотите глубже разобраться в идеях, лежащих в основе этого расследования, следующие ресурсы заслуживают внимания:

Kafka и файловая система

Память Linux, кэш страниц и освобождение памяти

cgroup v2 и инструменты трассировки

Продолжим обсуждение

Если вы сталкивались с похожим поведением кэша страниц, освобождения памяти или cgroup v2 в Kafka или других stateful-нагрузках на Kubernetes — буду рад сравнить наблюдения.

Сообщество DEV
© 2026 meganuke