Внутри гиперкуба плохих вайбов: размерность пространства имён

Getting ready to dissect what I like to call: the Kubernetes hypercube of bad vibes.
Готовимся к вскрытию того, что я называю гиперкубом плохих вайбов Kubernetes

Многие команды работают с кластерами Kubernetes побольше нашего. В них больше узлов, больше подов, больше ingress и так далее. По большинству размерностей нас кто-нибудь, да побеждает.

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

Проблема сильно усугубляется, когда daemonset должен выполнять listwatch пространств имён или сетевых политик (netpol), которые мы определяем для каждого пространства имён. Так как daemonset запускают под в каждом узле, каждый из этих подов выполняет listwatch одних и тех же ресурсов, из-за чего объём используемой памяти увеличивается при росте количества узлов.

Хуже того — эти вызовы listwatch серьёзно нагружали apiserver. Если одновременно перезапускалось множество подов daemonset, например, при развёртывании, то они могли перегрузить сервер запросами и вызвать реальный вылет.

Идём по следу памяти

Несколько месяцев назад самыми большими потребителями памяти часто были daemonset. В частности, Calico и Vector, обрабатывающие сетевое конфигурирование и сбор логов.

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

Memory profiling results
Результаты профилирования памяти
Time-series graph of memory usage per pod for calico-node instances
График временных последовательностей используемой памяти в каждом поде для инстансов calico-node

Для удобства этой работы мы настроили staging-кластер с несколькими сотнями тысяч пространств имён. Мы знали, что большую нагрузку на Calico вызывают сетевые политики (netpol) каждого пространства имён, поэтому воссоздали эти условия, чтобы валидировать вносимые нами изменения.

В процессе выполнения этих тестов мы заметили нечто странное. Vector (ещё один daemonset) тоже начал потреблять большие объёмы памяти.

Memory usage per pod graph showing Vector pods
График используемой подами Vector памяти

Этот паттерн выглядел знакомо, поэтому мы поняли, что нам придётся разбираться с ещё одной проблемой. Было очевидно, что Vector не использует netpol, но немного изучив вопрос, мы обнаружили, что он выполняет listwatch пространств имён для каждого узла, чтобы обеспечить возможность ссылок на метки пространств имён каждого пода в источнике логов kubernetes.

Нужны ли нам эти метки?

У нас появилась мысль: что, если Vector вообще не понадобятся пространства имён? Возможно ли это вообще?

Оказалось, что да: они использовались в нашей конфигурации, но только для проверки, принадлежит ли под к пользовательскому пространству имён.

type: filter
    inputs: [add_log_levels]
    condition:
      type: vrl
      source: .kubernetes.namespace_labels.userNS == "true"

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

.kubernetes.namespace_name != null && 
  (
    starts_with(string!(.kubernetes.namespace_name), "user-")
  )

Собираем фикс (и ломаем логи)

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

GitHub comment proposing to make namespace list/watching in Vector an opt-in setting
Комментарий на GitHub, предлагающий сделать list/watching пространств имён Vector включаемой опцией

Дальше оставалось только всё попробовать. Поменять код оказалось очень просто. Мы добавили новую опцию конфигурации и направили её по соответствующим частям кодовой базы.

insert_namespace_fields: {
        description: """
            Specifies whether or not to enrich logs with namespace fields.
            Setting to `false` prevents Vector from pulling in namespaces and thus namespace label fields will not
            be available. This helps reduce load on the `kube-apiserver` and lowers daemonset memory usage in clusters
            with many namespaces.
            """
        required: false
        type: bool: default: true
    }

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

Но потом мы столкнулись препятствием. Никаких логов не выводилось, и мы понятия не имели, почему.

К счастью на помощь пришёл наш друг Claude:

Claude chatbot answer: Looking at the code, I can see the issue. When add_namespace_fields is set to false, the namespace watcher/reflector is not created (lines 722–741). However, there’s still a dependency on the namespace state in the K8sPathsProvider (line 768) and NamespaceMetadataAnnotator (line 774)
Ответ чат-бота Claude: «Просмотрев код, я нашёл, в чём проблема. Когда add_namespace_fields имеет значение false, watcher/reflector пространств имён не создаётся (строки 722–741). Однако в K8sPathsProvider (строка 768) и NamespaceMetadataAnnotator (строка 774) по-прежнему присутствует зависимость от состояния пространства имён»

Я всё пересобрал (на всё ушло где-то 73 часа, потому что это Rust), сгенерировал новый образ, обновил staging и начал нервно наблюдать. На этот раз логи выводились нормально, и…

Memory usage per pod graph
График использования памяти каждым подом

Что-то не сходится

Благодаря этому изменению мы сэкономили 50% памяти. Серьёзная победа! Мы были готовы закончить работу и отправить её в продакшен.

Но потом член нашей команды Hieu задал очень хороший вопрос.

Slack conversation: Hieu: that sounds good. still concerning that it uses 1Gi in staging without namespace data, though. Me: we can profile it more if we want. now that I can build images, I think we can do the Rust profiling the same way we get from Go for free.Hieu: cool! yeah, I think it’d be worth understanding where the RAM is going
Hieu: Выглядит отлично, но у меня всё равно вызывает опасения то, что он использует 1 ГиБ в staging без данных пространств имён. Я: Мы можем продолжить профилирование, если захотим. Теперь, когда я могу собирать образы, наверно, можно выполнять профилирование Rust так же, как мы бесплатно делали это в Go. Hieu: Отлично! Да, думаю, стоит разобраться, куда девается RAM

Он был прав, что-то здесь нечисто.

Спустя несколько часов, побившись головой о стену, я так ничего и не нашёл. Всё равно оставался гибибайт неучтённой памяти. Вся моя теория о том, как это работает, начала трещать по швам.

Я даже спросил в канале, есть ли у кого-нибудь опыт работы с Valgrind:

Я: Кто-нибудь работал с valgrind? Кажется, настроить его довольно просто, но в результате он не взаимодействует с pyroscope. Придётся подключиться к контейнеру и выполнять ручную отладку в gdb.

Такого опыта ни у кого не оказалось.

В последней отчаянной попытке повторного профилирования я наконец-то нашёл ответ. Всё это время он был у меня прямо перед глазами.

На самом деле, у нас было два источника kubernetes_logs для пользовательских узлов. Я установил флаг только для одного из них. Когда я применил его к обоим, объём используемой памяти упал до уровня, который мы наблюдали в staging до добавления дополнительных пространств имён.

sources:
 kubernetes_system_logs:
    type: kubernetes_logs
    glob_minimum_cooldown_ms: 1000
...
  kubernetes_logs:
    type: kubernetes_logs
    glob_minimum_cooldown_ms: 1000
...

Выпускаем пул-реквест

Я создал целый пул-реквест, и спустя какое-то время его выпустили в релиз!

PR merged!
PR смерджен!
Changelog noting new insert_namespace_fields. Click to see it in the Vector docs.
В Changelog говорится о новой опции insert_namespace_fields.

Примерно в то же самое время на дежурстве был наш коллега Марк. Он сотворил свою обычную магию — соединил всё вместе, протестировал развёртывание в staging и выпустил в продакшен.

Результаты говорят сами за себя.

Memory usage per pod dropped from nearly 4 GiB down to just a few tens of MiB not to mention the reduction in CPU and network IO
Объём используемой каждым подом памяти упал с почти 4 ГиБ до всего до десятков МиБ, не говоря уже о снижении операций CPU и сетевого ввода-вывода
The total memory usage across one of our larger clusters dropped by 1TiB.
Общий объём используемой памяти в одном из наших самых больших кластеров снизился на 1 ТиБ.

В нашем самом большом кластере объём занимаемой памяти снизился на 1 ТиБ, а суммарная экономия с учётом всех остальных кластеров составила чуть больше 7 ТиБ.

7 ТиБ спустя

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

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

Огромное спасибо Hieu за прогресс в расследовании, Марку за беспроблемное развёртывание и мейнтейнерам Vector за открытость и готовность к изменениям.

Если вы в больших масштабах работаете с daemonset и наблюдаете необъяснимую нагрузку на память, то стоит задаться вопросом: а действительно ли вам нужны метки пространств имён?

Комментарии (0)