Автор оригинальной статьи, Пьер Мане, рассказывает, как его команда столкнулась с на первый взгляд необъяснимым поведением Cilium и как поиск решения привёл его к конфигурации ядра Linux.

Отладка сродни археологии: ты пробираешься сквозь слои абстракций, пока не доберёшься до коренной породы — ядра. Это история о том, как скрытая в коде Linux логика работы со степенями двойки приводила к случайным и загадочным падениям Cilium, из-за чего мы не могли выкатиться в production.

Для Qonto — лицензированной платежной организации, обслуживающей бизнес по всей Европе, — безопасность крайне важна. Поэтому и кластеры Kubernetes защищены по-максимуму: обнаружение рантайм-угроз, непривилегированные поды, read-only-файловые системы, аутентификация на каждом эндпоинте. Сетевая сегментация должна была стать финальным штрихом нашей эшелонированной обороны. В Kubernetes сеть по умолчанию плоская (mutualized), то есть технически любой под может достучаться до любого пода. Но когда на кону защита денег клиентов и их финансовых данных, важен любой дополнительный слой безопасности. Поэтому мы решили развернуть Cilium — Open Source-проект для реализации сетевых политик прямо на уровне ядра (с помощью eBPF). По сути, это такой регулировщик трафика внутри кластера, который решает, кто с кем может общаться.

Следуя нашему правилу вносить изменения в инфраструктуру максимально осторожно, мы сперва выкатили всё на стейдж. Развёртывание прошло успешно. Но спустя несколько недель начались спорадические сбои: примерно раз в неделю один из агентов Cilium падал так, что приходилось полностью перезагружать весь узел. Никаких явных паттернов или очевидных причин. Баг случался редко, но бил слишком больно, чтобы пускать такое в production.

Первая мысль — скорее всего, намудрили с конфигурацией, ведь Cilium — серьёзный, проверенный в бою проект, который используют тысячи компаний по всему миру. Наверняка мы допустили ошибку в конфигурации или столкнулись с пограничным случаем. Забегая вперед — догадка оказалась и верной, и неверной одновременно, причём весьма неожиданным образом.

Итак, я приступил к расследованию проблемы, опираясь на системные тесты, эмпирические данные и помощь искусственного интеллекта.

Пытаемся воспроизвести проблему

Первым делом нужно было научиться воспроизводить баг. Ждать целую неделю до следующего сбоя — не вариант. Я начал копаться в логах и метриках в поисках хоть какой-нибудь закономерности.

Заметил кое-что интересное: упавшие агенты Cilium потребляли аномально много памяти во время запуска узлов, на которых они размещались. Причём потребление памяти не росло постепенно — на графиках были видны резкие всплески прямо при инициализации узла.

Было ли это причиной бага или простым совпадением? Я не был уверен. Но зацепку стоило проверить.

Появилась гипотеза: возможно, сбои связаны с новыми узлами при массовом планировании подов (burst scheduling). Один из моих коллег попытался проверить это на другом кластере, запустив массовое планирование подов на новых узлах. Ничего. Никаких сбоев.

Через пару дней, в пятницу, я решил повторить тест в staging-кластере. Идея была такой: пусть Karpenter (автоскейлер нашего Kubernetes-кластера) поднимет свежие узлы, а затем мы сразу же запланируем по 60 подов на каждый из них.

Claude Code здорово помог с написанием kubectl-команд для патчинга deployment’ов и масштабирования реплик. Я просто описывал, что хочу протестировать, Claude выдавал команды, а я их одобрял или просил подправить. Помощь ИИ сильно ускорила тестирование.

Тест сработал. Узлы сразу же падали: агенты Cilium завершались с ошибкой OOMKilled. Баг стабильно воспроизводился. Наконец-то прорыв!

Теперь нужно было установить причину. Я начал экспериментировать с различными параметрами Cilium: лимитами памяти, размерами eBPF-карт, различными настройками производительности. Затем попробовал отключить одну специфическую опцию, связанную с производительностью (bpf-distributed-lru: false), и перезапустил тест. Сбои прекратились. Включил её обратно — сбои вернулись. Снова отключил — всё стабильно. Многочисленные перезапуски теста показали: закономерность сохраняется.

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

Но когда в понедельник с утра я решил повторить тесты (с заново включённым distributed LRU), то ничего не произошло. Сбои исчезли. Тот же тест, тот же кластер, та же конфигурация, но совершенно другие результаты. Просто вынос мозга какой-то!

Единственное, что изменилось с пятницы, — это… сами узлы. Karpenter берёт узлы из общего пула (NodePool), подбирая типы инстансов на основе их мощности и цены. В разных тестах мы могли получить разные инстансы.

Может, дело в этом? Что, если баг привязан к конкретному типу инстансов?

От случайности к системе

Я больше не мог полагаться на удачу — требовался системный подход. Решил развернуть узлы на определённых типах инстансов и посмотреть, на каких из них произойдёт сбой.

Настроил Karpenter так, чтобы он заказывал один конкретный тип инстансов за раз и запускал на каждом из них сразу по 60 подов.

Если тип инстанса действительно влиял на проблему, проявилась бы закономерность. Если нет — я бы снова оказался в тупике.

Матрица тестов

Я проверил десятки различных типов EC2-инстансов из нескольких семейств в поисках паттернов — виновата ли архитектура процессора? Объём памяти? Поколение? Тщательно фиксировал результаты каждого теста. Вот пример:

Instance Type

vCPU

RAM

Architecture

Result

c5a.8xlarge

32

64 GB

AMD EPYC

PASS

c5.9xlarge

36

72 GB

Intel Xeon

FAIL

c5a.12xlarge

48

96 GB

AMD EPYC

FAIL

c5a.16xlarge

64

128 GB

AMD EPYC

PASS

c5n.18xlarge

72

192 GB

Intel Xeon

FAIL

c6i.32xlarge

128

256 GB

Intel Xeon

PASS

Закономерность найдена!

Внимательно изучил результаты. Типы инстансов действительно имели значение, но не так, как ожидалось.

Всё упиралось в количество ядер процессора.

Данные выявили абсолютно чёткий паттерн:

  • Степени двойки (32, 64, 128 CPU) → Всё работает.

  • Всё остальное (36, 48, 72 CPU) → Падает.

  • Не зависит от архитектуры: сбоили и AMD, и Intel при конкретном количестве ядер.

  • Не связано с ресурсами: вариант с 48 ядрами и 96 ГБ памяти падал, а с 64 ядрами и 128 ГБ — работал.

В пятницу мне, похоже, дико повезло: Karpenter, видимо, получал узлы исключительно с 36, 48 или 72 ядрами.

Сейчас я понимаю, что изначально тестировать надо было по-другому. Прежде всего следовало проверить, на каких типах инстансов Cilium падал в прошлом, а далее запускать нагрузочные тесты именно на этих типах инстансов. Я же просто разрешил Karpenter’у получать узлы из общего NodePool’а совершенно случайным образом.

Также стало ясно, почему коллега не смог воспроизвести баг в своём кластере: его Karpenter просто получил инстансы с другим количеством ядер CPU.

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

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

map cilium_ct4_global: map create: cannot allocate memory

Сбой случался при выделении памяти для eBPF-карт — структур данных ядра, которые программы eBPF используют для хранения состояния. Их можно считать хеш-таблицами, которые живут в пространстве ядра (kernel space). Cilium широко использует их под разные нужды: cilium_ct4_global отслеживает активные TCP/UDP-соединения, cilium_nat4_global хранит трансляции NAT. Эти карты создаются при запуске Cilium, а их размер зависит от ожидаемой нагрузки. Но каким образом количество CPU влияет на аллокацию памяти для карт?

ИИ: превращаем данные в инсайты

У меня были эмпирические данные, подтверждающие, что баг реален и воспроизводим. Теперь нужно было понять причину. На этом этапе роль Claude Code расширилась от простой генерации команд до глубокого исследования кода.

Добавление исходников Cilium в контекст

На следующий день я склонировал репозиторий Cilium к себе на компьютер и добавил его в рабочее пространство Claude Code. Так Claude получил доступ ко всей кодовой базе — более 2 миллионов строк на Go, более 18 000 файлов. У меня были две надёжные зацепки:

  • Баг был связан с distributed LRU (его отключение устраняло сбои, хотя я до сих пор не понимал, что вообще эта опция делает).

  • Баг проявлялся только там, где количество ядер процессора не было степенью двойки.

Загрузив в контекст кодовую базу Cilium, я попросил Claude отследить, как именно Cilium создаёт eBPF-карты и работает с ними, обращая особое внимание на всё, что связано с distributed LRU и подсчётом количества CPU. Мы проследили весь путь от создания карты до системных вызовов ядра (kernel syscalls) и обнаружили, что при включённом distributed LRU Cilium выставляет флаг BPF_F_NO_COMMON_LRU.

Этот флаг был ключом к разгадке. Что он делает в ядре Linux и как связан с количеством ядер процессора?

Исследование ядра

Поиск по GitHub флага BPF_F_NO_COMMON_LRU привёл нас прямиком в исходники ядра Linux, в файл kernel/bpf/hashtab.c. Когда флаг установлен, ядро идёт по такому пути:

if (percpu_lru) {
    /* ensure each CPU's lru list has >=1 elements.
     * since we are at it, make each lru list has the same
     * number of elements.
     */
    htab->map.max_entries = roundup(attr->max_entries,
                                    num_possible_cpus());

При включённом distributed LRU ядро округляет вверх количество записей в карте до значения, кратного num_possible_cpus().

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

bpf-ct-global-tcp-max: 131072

Это степень двойки (131 072 = 217), как и рекомендуют гайды. И оно отлично работало… пока количество ядер тоже было степенью двойки. Но вот в случае 36, 48 или 72 ядер значение округлялось вверх, создавая несоответствие.

Обнаружение бесконечного цикла

Последовательность событий, которая приводила к проблеме, выглядит следующим образом:

  1. Cilium настраивает карту на 131 072 записи.

  2. Ядро округляет это значение до 131 076 (на узле с 36 ядрами).

  3. Цикл согласования (reconciliation) Cilium проверяет размер карты.

  4. Обнаружено расхождение: в карте 131 076 записей, а должно быть 131 072.

  5. Cilium удаляет карту и пересоздаёт её с размером 131 072 записи.

  6. Ядро округляет значение до 131 076… и так до бесконечности!

При каждой такой итерации выделяется память под 36 ядер CPU. Сам по себе цикл не приводит к мгновенному падению, но если на новый узел разом запланировать 60 подов, эта петля будет раз за разом повторяться для всех карт conntrack, NAT и neighbor каждого пода, стремительно поедая память. Без таких массовых планирований подов система могла бы работать более-менее нормально, выдавая сбои лишь изредка.

Это объясняло сбои в staging-окружении: узлы с некратным двум количеством ядер изредка попадали под массовый запуск подов (во время автомасштабирования, деплоев или ротации узлов) и падали. Такие массовые запуски случались нечасто, поэтому и сбои случались всего раз в неделю.

Выяснение причины: зачем нужен distributed LRU

У меня оставался последний вопрос: зачем вообще разработчики придумали это округление?

Когда куча соединений обрабатывается параллельно на нескольких ядрах, eBPF-карты могут превратиться в узкое место по производительности из-за конкуренции за блокировки (lock contention) — ядрам CPU приходится ждать друг друга, чтобы получить доступ к карте.

Distributed LRU — это оптимизация, которая решает проблему. Вместо общего кеша LRU (Least Recently Used), за который борются все CPU, механизм distributed LRU создаёт отдельный пул памяти для каждого ядра. Это полностью снимает проблему блокировок, позволяя каждому ядру управлять своей памятью независимо.

В Cilium это включается с помощью параметра bpf-distributed-lru: true. Когда он включён, Cilium просит ядро использовать флаг BPF_F_NO_COMMON_LRU при создании карт. Этот флаг заставляет ядро перейти на модель выделения памяти на каждый CPU. Ну и, как мы обнаружили, запускает ту самую логику округления.

Решение

Расследуя баг с LRU и округление, проводимое ядром, я наткнулся на PR #38978 от апреля 2025 года. Мейнтейнер ядра Linux, работающий непосредственно над реализацией distributed LRU в Cilium, уже частично решил эту самую проблему: калькулятор динамического размера стал округлять размеры карт. Но тот патч охватывал только динамически вычисляемые размеры и не учитывал статические конфигурации — а именно их мы и использовали.

При помощи Claude я подготовил комплексный фикс, нормализующий размеры всех LRU-карт независимо от того, откуда эти размеры берутся — из настроек по умолчанию, вычисляются динамически или явно задаются пользователем. Теперь они приводятся в соответствие с тем, что требует ядро. Главная идея оказалась простой: заставить Cilium «говорить на одном языке округления» до создания любых карт.

Для удовлетворения текущих потребностей мы в Qonto просто удалили конфигурацию со статическим размером карт, позволив Cilium использовать значения по умолчанию, которые корректно работают при любом количестве CPU. Полный фикс, включая набор тестов, был предложен в PR #42511. Он поможет всем, кто использует Cilium с кастомными статическими размерами карт.

Извлечённые уроки: связка ИИ и человека

1. ИИ как помощник в командной строке

С самого начала Claude Code помогал мне писать команды kubectl — патчить deployment’ы, масштабировать количество реплик, таргетировать нужные узлы. Я просто говорил, чего хочу (например, «запусти сразу 60 подов на этом узле»), и Claude выдавал готовую команду. Помимо тестирования, с его помощью я писал запросы к метрикам Prometheus и API Kubernetes для анализа логов и паттернов потребления ресурсов. Обычно для этого приходится вспоминать синтаксис запросов и лезть в документацию эндпоинтов API. Больше не нужно запоминать синтаксис или искать в документации флаги, которые нужны пару раз в год. ИИ печатает быстрее меня, а если допускает ошибку — сразу исправляет ее, как только укажешь на недочёт. Так я смог полностью сосредоточиться на поиске бага, не отвлекаясь на рутину с командной строкой.

2. ИИ требует точного контекста, а не догадок

Я не просил ИИ просто «найти ошибку в Cilium». Я закинул в Claude:

  • систематическую методологию тестирования;

  • конкретные данные, демонстрирующие паттерны сбоев (тексты ошибок, типы инстансов);

  • чёткую гипотезу о корреляции с количеством ядер процессора.

Чем меньше ИИ приходится гадать, тем эффективнее он работает. Изначально я позволил Karpenter’у заказывать узлы случайным образом, и из-за этого результаты тестов постоянно скакали.  Но как только я взял переменные под контроль и собрал конкретные данные (количество ядер равно степени двойки — Cilium работает; не равно — падает), ИИ сразу проанализировал код Cilium и нашёл алгоритм округления в ядре, который и расставил всё по местам.

ИИ помог мне понять, почему происходило то, что я наблюдал на практике.

3. ИИ ускоряет разбор кода

Поиск связанного PR от мейнтейнера ядра Linux, понимание кода ядра в функции htab_map_alloc(), отслеживание цикла согласования в Cilium — задачи, которые обычно занимают кучу времени. Мы же выполнили их за считаные минуты.

Навигация по кодовой базе из 2 миллионов строк становится тривиальной, когда ИИ способен практически мгновенно находить нужную информацию и объяснять контекст.

4. Открытые исходники делают возможным глубокий дебаг

Расследование было бы невозможным в случае закрытого ПО. Полный доступ к исходникам Cilium — ко всем 2 миллионам строк — позволил мне пройти весь путь от момента создания карты до системных вызовов (syscalls) ядра. Я мог проверять свои гипотезы, читая реальный код, а не пытаться угадать поведение программы по документации. А раскопав первопричину, я смог подготовить фикс, тем самым принеся пользу всему сообществу. Исходный код ядра Linux сыграл не менее важную роль: чтобы понять алгоритм округления в htab_map_alloc(), нужно было прочитать реальный код на С, а не заниматься реверс-инжинирингом логики по симптомам.

То, что казалось невозможным

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

  • количество ядер процессора, отличное от степени двойки;

  • включённый distributed LRU;

  • статические настройки карт;

  • массовый запуск подов, который съедал память.

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

Правда в том, что кажущиеся «нерешаемыми» проблемы чаще всего оказываются проблемами из категории «мы просто ещё не разобрались». Системное тестирование, помощь ИИ и упорное нежелание верить, что «оно падает само по себе», помогают устранить даже баги на уровне ядра операционной системы.

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

Полезные ссылки

О компании Qonto

Qonto — ведущее в Европе решение для управления финансами, обслуживающее более 600 000 компаний малого и среднего бизнеса и фрилансеров в 8 странах. Запущенный в 2017 году Александром Протом (Alexandre Prot) и Стивом Анави (Steve Anavi) проект объединяет инструменты для корпоративных финансов и мощные системы для ведения бухгалтерии, выставления счетов и управления расходами в единое комплексное решение. С инвестициями свыше 600 миллионов евро и командой из 1600+ человек Qonto трансформирует способы управления финансами для европейского бизнеса, предлагая инновационные продукты, прозрачное ценообразование и круглосуточную поддержку.

P. S.

Читайте также в нашем блоге:

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