Ошибки операторов Kubernetes: как мы их исправляли и чему научились

Ошибки в разработке неизбежны, но они помогают расти. Я Стас Иванкевич, техлид в команде разработки управляющего слоя 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 — один контроллер. Даже если прямо сейчас это кажется избыточным.
Какие выводы мы сделали
Мы прошли путь от «всё работает» до «как это вообще работало?» и поняли важную вещь: ошибки — это не провал, а опыт. Главное — не повторять их дважды. А ещё лучше учиться на чужих.
Конечно, один из ключевых моментов, который позволил нам безболезненно учиться на ошибках, — это великолепное покрытие тестами нашей ключевой логики. Иначе такие, казалось бы, очевидные проблемы могут начать вылезать на проде. Поэтому второй важный вывод, к которому мы пришли, — хороший конвейер тестирования критически необходим.
Спасибо всем за внимание, и до встречи!
Автор: stkevich

