Ошибки в разработке неизбежны, но они помогают расти. Я Стас Иванкевич, техлид в команде разработки управляющего слоя Platform V DropApp в СберТехе. Наша команда придерживается простого принципа: не наступать на одни грабли дважды. Из каждой ошибки стараемся извлечь урок и больше её не повторять, а ещё лучше — учиться на ошибках других. Поэтому мы развиваем культуру открытого обсуждения ошибок и не закрываем глаза на возникающие сложности.

На первый взгляд, написание Kubernetes-операторов — технически сложная, но вполне понятная работа. Определил CRD, написал контроллер, настроил реконсиляцию — и вуаля, автоматизация работает. Но на практике ошибки могут быть не в коде, а в архитектуре, подходах и принятых допущениях — даже если основная логика реализована правильно.

Я уже рассказывал о подводных камнях и лучших практиках при разработке операторов Kubernetes: материал в трёх частях можно почитать тут, тут и тут. А в этой статье я собрал ошибки, которые часто встречались мне в реальных проектах при работе с Kubernetes-операторами. Расскажу, как мы их исправляли, какие выводы сделали и что теперь делаем иначе. Надеемся, наш опыт будет полезным для вас и поможет их не повторить.

История 1: воскресшие CR и гонка событий

Первым сигналом стало отзыв от команды тестирования: «Удалённые CR почему-то возвращаются». Это выглядело странно, но позже оказалось, что причина в классической гонке событий между контроллерами.

У нас было два контроллера:

  • ToolController создавал некоторый под на основе своего CR (ToolCR).

  • ComponentController управлял несколькими Tool, описанными в его CR (ComponentCR). Примерно так же, как Deployment управляет подами, разве что Tool в этом случае могут быть разными у одного Component.

В спецификации ComponentCR был флаг enable для каждого Tool. Если флаг выключен — ToolCR должен быть удалён, если включен — должен быть создан.

Логика была примерно такой: когда пользователь удалял ToolCR, ToolController должен был:

  1. удалить под;

  2. обновить Component, установив enable: false, чтобы тот не пытался пересоздать ресурс.

Важно, что работа по обновлению состояния поля enable в ComponentCR была возложена именно на ToolController. Но на практике происходило следующее:

  1.  Пользователь удаляет Tool.

  2. Событие приходит обоим контроллерам ToolController и ComponentController, ведь Component — владелец ресурса Tool.

  3. Если ComponentController обрабатывает событие быстрее, он видит enable: true и решает, что ToolCR должен существовать, а раз его нет — пересоздаёт его.

  4. ToolController приходит, видит живой ресурс, живой под и enable:true — и ничего не делает.

Результат: пользователь видит, что ToolCR вернулся, а под не удалился. И, честно говоря, это выглядело, как магия.

Как исправляли

  • Убрали из ToolController все изменения в ComponentCR.

  •  Перенесли логику управления enable в ComponentController.

  •  Добавили финализаторы на Tool, чтобы гарантировать, что контроллеры успеют отреагировать до удаления.

Мы поняли, что был нарушен фундаментальный принцип: один контроллер — один CR. Контроллер Tool не должен был трогать спецификацию Component. Это нарушение разделения ответственности.

Теперь удаление происходит корректно: ComponentController отключает Tool, тот удаляет под, финализатор снимается — и только тогда ресурс исчезает.

История 2: финализатор, который не спас

Следующая проблема звучала так: «Оператор оставляет мусор в кластере». Как оказалось, helm-релизы, установленные через наш оператор, могли не удалиться после удаления связанной с ним CR, даже если установка прошла неудачно.

Чтобы понять, почему так получилось, рассмотрим упрощённую схему работы контроллера:

  1. Принимает CR.

  2. Запускает helm install.

  3. В случае успеха — добавляет финализатор.

  4. В случае неудачи — повторяет попытку установки.

Кажется, всё логично. Но есть проблема: если пользователь удаляет CR до завершения helm install, то финализатор не добавляется. А значит, контроллер может не обработать событие удаления CR, и релиз остаётся в кластере.

Как исправляли

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

Здесь есть важное правило: если контроллер должен выполнить какие-либо действия после удаления CR, то финализатор ставится в самом начале reconcile, а не после успешного выполнения.

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

  • Модульные: фейковый клиент + моки Helm, чтобы точно воспроизвести сценарии с ошибками, установкой и удалением. Эти тесты сосредоточены на работе одного конкретного контроллера.

  • Интеграционные: TestEnv для написания интеграционных тестов, работающих как бы в мини-кластере. Эти тесты тяжелее и проверяют более сложные сценарии взаимодействия контроллеров.

TestEnv — мощный инструмент, но он не имитирует всё. Например, сборка мусора в нём не работает, поэтому важно понимать все ограничения.

История 3: финализатор, который не уходил

Если в прошлой истории финализатора не хватало и мы добавляли его, чтобы решить проблему, то в этот раз он не уходил вообще.

Разберем упрощённый сценарий — как мы представляли себе удаление helm-релиза:

  1. CR удаляется. Неважно, почему: это может быть команда от другого контроллера или от самого пользователя. Но на этом CR висит финализатор, и он не пропадёт, пока контроллер его не обработает.

  2. Контроллер получает запрос и видит, что CR помечен для удаления. Начинает удалять helm-релиз, вызывает helm uninstall.

  3. Релиз удаляется успешно, и с CR снимается финализатор.

  4. CR без финализатора пропадёт автоматически при очистке мусора.

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

Рассмотрим сценарий, когда всё начинало работать не так, как ожидалось:

  1. CR удаляется так же, как и в рабочем сценарии.

  2. Контроллер начинает удалять helm-релиз.

  3. Случается неожиданное: релиз удаляется с ошибкой. Неважно, почему: сетевая ошибка, таймаут, что-то ещё. Важно, что ошибка удаления может повторяться из раза в раз.

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

  5. Если никто и ничто не вмешивается в работу контроллера и самого Kubernetes, то спустя некоторое время контроллер вернётся к этой задаче. Он попытается снова удалить релиз и, скорее всего, ему это удастся. Но может получиться и так, что удалить релиз стандартными механизмами автоматизации невозможно.

  6. Тогда helm-релиз удаляют вручную. Админ разбирается, что пошло не так, и  исправляет это.

  7. Но в следующий раз при обработке CR контроллер увидит, что релиза нет, и завершит работу над CR, решив, что уже ничего делать и не нужно.

  8. При этом финализатор остаётся висеть на CR и не даёт удалиться. Это некрасиво и захламляет память.

Проблема в нарушении атомарности. Контроллер пытался сделать несколько действий за один запуск reconcile:

  •  Проверить наличие helm-релиза. Одна проверка на один ресурс.

  •  Удалить helm-релиз. Это первое действие, с объектом проверки.

  •  Удалить финализатор. Это второе действие, и в нём проблема, ведь оно никак явно не связано с проверкой, под которой находится.

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

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

Как исправляли

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

  1. Если есть релиз, то удаляем его. Если получили ошибку, то возвращаем её и завершаем текущую реконсиляцию с постановкой задачи в очередь.

  2. Если релиза нет, то переходим к следующей проверке. 

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

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

  4. Если всё чисто, то выходим без добавления задачи в очередь на реконсиляцию.

Теперь каждый reconcile выполняет ровно одно атомарное действие. Это делает поведение предсказуемым и устойчивым к сбоям.

История 4: проверка, которой не было

Однажды оператор начал вести себя, мягко говоря, странно. Задачи не выполняются, CR фейлятся, оператор периодически падает. При этом в журналах всё вроде бы нормально, но некоторые данные странные.

Как, в итоге, оказалось, в одном из CR поле, отвечающее за лимит, имело значение 150. И всё бы ничего, но оператор явно не ожидал такого значения, и даже в самой CRD на этот случай была проверка: max: 100. Тем не менее, в кластере появилась CR в полтора раза больше, чем вообще может переварить оператор.

Как это оказалось возможно?

Да, наша CRD явно запрещала значения больше 100. В ходе расследования мы несколько раз проверяли это в нашем helm-чарте. Магии не случилось, и значение в манифестах наших CRD действительно было ровно 100. Ни больше, ни меньше. Но ведь в проде мы видим CR, у которой это значение равно 150. Более того, попытки воспроизвести это на наших тестовых кластерах ни к чему не привели: везде проверка работала как положено и отклоняла наши CR с большим значением.

Сперва кажется: это невозможно. А потом мы посмотрели на CRD в проде и всё стало ещё запутаннее: проверка была ровно такая же, и ровно такое же ограничение поля. Более того, попытка установить CR с таким же значением ни к чему не привела: проверка работала.

Тогда мы пошли шерстить журналы и восстанавливать последовательность событий. Расследование показало: кто-то временно удалил проверку из CRD в проде, установил CR с нужным значением и вернул проверку. И какое-то время это даже работало! 

Наша ошибка здесь — слепое доверие кластеру. Мы полностью полагались на проверку CRD и не проверяли данные внутри оператора. 

Как исправляли

Отсюда правило: никогда не доверяй внешним данным, даже если они пришли из твоего собственного кластера.

Теперь мы дублируем всю проверку внутри кода оператора. CRD-проверка — это хорошо, но её могут отключить или отредактировать, а вот код всегда под контролем разработчика.

История 5: один CR на двоих — плохая идея 

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

Причина проста: недальновидное решение, призванное «упростить» архитектуру.

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

Чтобы не дублировать конфигурацию и не требовать синхронного обновления CR, мы решили чуть упростить схему. Так у нас появился оператор «один CR на два контроллера».

На первый взгляд, это и просто, и гениально:

  1. Избегаем дублирования конфигурации.

  2. Упрощаем управление.

  3. В какой-то мере следуем парадигме Kubernetes, ведь на те же поды может быть любое количество контроллеров. Так чем наша CR хуже?

И какое-то время всё было хорошо. С периодическими зацикливаниями примерно такого рода:

  1. Контроллер A начинает работу и обновляет статус CR на «Processing».

  2. Это триггерит контроллер B.

  3. Тот видит, что его ресурсы в порядке, и в свою очередь обновляет статус на «Ready».

  4. Контроллер A снова получает событие и снова обновляет статус, и вот вам зацикливание.

Сначала это происходило редко, а потом всё чаще.

Как исправляли

Первое, что мы попытались сделать, — внедрили два статуса в одной CR. Решение быстрое и исправляло значительную часть проблем.

Но ничто не вечно, и в конце концов команда разделилась на две. Каждая взяла на поддержку и сопровождение один из контроллеров. Каждая команда получила свой бэклог, свой пул задач и так далее, а CR осталась одна, общая.

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

Вот так у нас появилось поле в метаданных, которое по-разному стали трактовать в каждом из контроллеров. Один контроллер писал туда lastSyncTime, другой — lastAction. Каждый перезаписывал данные другого и тем самым триггерил его. Здравствуйте, бесконечные обновления — и, в отличие от предыдущих проблем, это зацикливание было уже реально бесконечным.

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

Решение оказалось очень простым и висящим в воздухе: мы просто вернули два отдельных CR. Раз уж контроллеры разъехались и стали всё больше отдаляться, то старые способы упрощения работы и уменьшения когнитивной нагрузки перестали работать. Да, мы получили некоторое дублирование конфигурации, но вместе с тем независимость, явность и стабильность.

Здесь правило: разделяй ответственность. Один CR — один контроллер. Даже если прямо сейчас это кажется избыточным.

Какие выводы мы сделали

Мы прошли путь от «всё работает» до «как это вообще работало?» и поняли важную вещь: ошибки — это не провал, а опыт. Главное — не повторять их дважды. А ещё лучше учиться на чужих.

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

Спасибо всем за внимание, и до встречи!