
Ошибки в разработке неизбежны, но они помогают расти. Я Стас Иванкевич, техлид в команде разработки управляющего слоя 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 должен был:
удалить под;
обновить
Component, установивenable: false, чтобы тот не пытался пересоздать ресурс.
Важно, что работа по обновлению состояния поля enable в ComponentCR была возложена именно на ToolController. Но на практике происходило следующее:
Пользователь удаляет
Tool.Событие приходит обоим контроллерам
ToolControllerиComponentController, ведьComponent— владелец ресурсаTool.Если
ComponentControllerобрабатывает событие быстрее, он видитenable: trueи решает, чтоToolCRдолжен существовать, а раз его нет — пересоздаёт его.ToolControllerприходит, видит живой ресурс, живой под иenable:true— и ничего не делает.
Результат: пользователь видит, что ToolCR вернулся, а под не удалился. И, честно говоря, это выглядело, как магия.
Как исправляли
Убрали из
ToolControllerвсе изменения вComponentCR.Перенесли логику управления
enableвComponentController.Добавили финализаторы на
Tool, чтобы гарантировать, что контроллеры успеют отреагировать до удаления.
Мы поняли, что был нарушен фундаментальный принцип: один контроллер — один CR. Контроллер Tool не должен был трогать спецификацию Component. Это нарушение разделения ответственности.

Теперь удаление происходит корректно: ComponentController отключает Tool, тот удаляет под, финализатор снимается — и только тогда ресурс исчезает.
История 2: финализатор, который не спас
Следующая проблема звучала так: «Оператор оставляет мусор в кластере». Как оказалось, helm-релизы, установленные через наш оператор, могли не удалиться после удаления связанной с ним CR, даже если установка прошла неудачно.
Чтобы понять, почему так получилось, рассмотрим упрощённую схему работы контроллера:
Принимает CR.
Запускает
helm install.В случае успеха — добавляет финализатор.
В случае неудачи — повторяет попытку установки.
Кажется, всё логично. Но есть проблема: если пользователь удаляет CR до завершения helm install, то финализатор не добавляется. А значит, контроллер может не обработать событие удаления CR, и релиз остаётся в кластере.

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

Здесь есть важное правило: если контроллер должен выполнить какие-либо действия после удаления CR, то финализатор ставится в самом начале reconcile, а не после успешного выполнения.
Эта ошибка подсветила недостаточное покрытие модульными тестами. Кроме того, оказалось, что такое поведение, разделённое на несколько последовательных, но разнесённых по времени запусков Reconcile, довольно сложно поймать в обычных модульных тестах, если вообще возможно. Поэтому мы используем разные уровни тестов, каждый со своим набором инструментов:
Модульные: фейковый клиент + моки Helm, чтобы точно воспроизвести сценарии с ошибками, установкой и удалением. Эти тесты сосредоточены на работе одного конкретного контроллера.
Интеграционные: TestEnv для написания интеграционных тестов, работающих как бы в мини-кластере. Эти тесты тяжелее и проверяют более сложные сценарии взаимодействия контроллеров.
TestEnv — мощный инструмент, но он не имитирует всё. Например, сборка мусора в нём не работает, поэтому важно понимать все ограничения.
История 3: финализатор, который не уходил
Если в прошлой истории финализатора не хватало и мы добавляли его, чтобы решить проблему, то в этот раз он не уходил вообще.
Разберем упрощённый сценарий — как мы представляли себе удаление helm-релиза:
CR удаляется. Неважно, почему: это может быть команда от другого контроллера или от самого пользователя. Но на этом CR висит финализатор, и он не пропадёт, пока контроллер его не обработает.
Контроллер получает запрос и видит, что CR помечен для удаления. Начинает удалять helm-релиз, вызывает
helm uninstall.Релиз удаляется успешно, и с CR снимается финализатор.
CR без финализатора пропадёт автоматически при очистке мусора.

Пожалуй, звучит даже логичнее, чем в прошлых историях. Но тут что-то не так, иначе она не попала бы сюда.
Рассмотрим сценарий, когда всё начинало работать не так, как ожидалось:
CR удаляется так же, как и в рабочем сценарии.
Контроллер начинает удалять helm-релиз.
Случается неожиданное: релиз удаляется с ошибкой. Неважно, почему: сетевая ошибка, таймаут, что-то ещё. Важно, что ошибка удаления может повторяться из раза в раз.
Контроллер умный, но не каждую ошибку может обработать. К тому же, у него копится очередь из задач. Поэтому текущую он откладывает на некоторое время и приступает к выполнению других.
Если никто и ничто не вмешивается в работу контроллера и самого Kubernetes, то спустя некоторое время контроллер вернётся к этой задаче. Он попытается снова удалить релиз и, скорее всего, ему это удастся. Но может получиться и так, что удалить релиз стандартными механизмами автоматизации невозможно.
Тогда helm-релиз удаляют вручную. Админ разбирается, что пошло не так, и исправляет это.
Но в следующий раз при обработке CR контроллер увидит, что релиза нет, и завершит работу над CR, решив, что уже ничего делать и не нужно.
При этом финализатор остаётся висеть на CR и не даёт удалиться. Это некрасиво и захламляет память.

Проблема в нарушении атомарности. Контроллер пытался сделать несколько действий за один запуск reconcile:
Проверить наличие helm-релиза. Одна проверка на один ресурс.
Удалить helm-релиз. Это первое действие, с объектом проверки.
Удалить финализатор. Это второе действие, и в нём проблема, ведь оно никак явно не связано с проверкой, под которой находится.
Если вдруг что-то пошло не так, если ожидаемая последовательность нарушается хотя бы минимально, то процесс останавливался, а финализатор не снимался.
Чтобы подобные проблемы не возникали повторно, мы ввели ещё одно простое правило: операции должны быть атомарными. Или «одна проверка — одно действие». Конечно, дальше можно рассуждать об эффективности, нагрузке и скорости работы, но, в целом, все эти вопросы касаются понимания атомарности каждой конкретной операции.
Как исправляли
Решение — один шаг за раз. Мы частично переписали логику, чтобы избежать этой и других потенциальных проблем. Новый алгоритм работы стал таким:
Если есть релиз, то удаляем его. Если получили ошибку, то возвращаем её и завершаем текущую реконсиляцию с постановкой задачи в очередь.
Если релиза нет, то переходим к следующей проверке.
Проверяем наличие финализатора. Если он есть, то удаляем его. Если получена ошибка, то возвращаем её и завершаем текущую реконсиляцию с постановкой задачи в очередь.
Да-да, после удаления финализатора всё равно ставим задачу в очередь. Мы можем быть уверены только в том, что удалили именно этот финализатор, но не в том, что можно завершать работу с CR. В конце концов, на CR могут висеть и другие финализаторы.
Если всё чисто, то выходим без добавления задачи в очередь на реконсиляцию.

Теперь каждый 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 на два контроллера».
На первый взгляд, это и просто, и гениально:
Избегаем дублирования конфигурации.
Упрощаем управление.
В какой-то мере следуем парадигме Kubernetes, ведь на те же поды может быть любое количество контроллеров. Так чем наша CR хуже?

И какое-то время всё было хорошо. С периодическими зацикливаниями примерно такого рода:
Контроллер A начинает работу и обновляет статус CR на «Processing».
Это триггерит контроллер B.
Тот видит, что его ресурсы в порядке, и в свою очередь обновляет статус на «Ready».
Контроллер A снова получает событие и снова обновляет статус, и вот вам зацикливание.

Сначала это происходило редко, а потом всё чаще.
Как исправляли
Первое, что мы попытались сделать, — внедрили два статуса в одной CR. Решение быстрое и исправляло значительную часть проблем.
Но ничто не вечно, и в конце концов команда разделилась на две. Каждая взяла на поддержку и сопровождение один из контроллеров. Каждая команда получила свой бэклог, свой пул задач и так далее, а CR осталась одна, общая.
Понятно, что CR не была статичной и активно развивалась. Но коммуникации между командами слабее, чем внутри. Это начало порождать проблемы, о которых мы даже не задумывались, когда были одной, большой, но дружной командой.
Вот так у нас появилось поле в метаданных, которое по-разному стали трактовать в каждом из контроллеров. Один контроллер писал туда lastSyncTime, другой — lastAction. Каждый перезаписывал данные другого и тем самым триггерил его. Здравствуйте, бесконечные обновления — и, в отличие от предыдущих проблем, это зацикливание было уже реально бесконечным.
Очередной ночной релиз. Оператор начинает наматываться на вентилятор: полчаса на диагностику и ещё полчаса на откат. Как говорится, пострадала только наша гордость.
Решение оказалось очень простым и висящим в воздухе: мы просто вернули два отдельных CR. Раз уж контроллеры разъехались и стали всё больше отдаляться, то старые способы упрощения работы и уменьшения когнитивной нагрузки перестали работать. Да, мы получили некоторое дублирование конфигурации, но вместе с тем независимость, явность и стабильность.
Здесь правило: разделяй ответственность. Один CR — один контроллер. Даже если прямо сейчас это кажется избыточным.
Какие выводы мы сделали
Мы прошли путь от «всё работает» до «как это вообще работало?» и поняли важную вещь: ошибки — это не провал, а опыт. Главное — не повторять их дважды. А ещё лучше учиться на чужих.
Конечно, один из ключевых моментов, который позволил нам безболезненно учиться на ошибках, — это великолепное покрытие тестами нашей ключевой логики. Иначе такие, казалось бы, очевидные проблемы могут начать вылезать на проде. Поэтому второй важный вывод, к которому мы пришли, — хороший конвейер тестирования критически необходим.
Спасибо всем за внимание, и до встречи!
