golang

Разбираем подводные камни, ошибки и лучшие практики при разработке Kubernetes-операторов. Часть 3

  • вторник, 31 марта 2026 г. в 00:00:13
https://habr.com/ru/companies/sberbank/articles/1015936/

Привет! Это снова Стас Иванкевич, техлид в команде разработки управляющего слоя Platform V DropApp в СберТехе. Наши операторы продолжают бороздить просторы K8s, а инсталляторы — разворачивать новые кластеры, и мы готовы поделиться с вами новыми полезностями.

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

Обработка ошибки и повторы

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

Что на самом деле означает возврат error из Reconcile

При работе с ошибками в Go мы привыкли, что чаще всего их можно просто вернуть. И где-то там, далеко-далеко, их кто-то обработает, если это кому-то будет нужно. А если нет — не беда, приложение просто упадет. И это вполне считается вариантом нормы.

С точки зрения контроллера оператора, так тоже можно. Однако, если наш контроллер вернет ошибку из Reconcile, то обрабатывать ее будет controller-runtime, и запущенный оператор не упадёт.

И тут важно знать несколько простых правил работы с возвращаемыми контроллером значениями.

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

func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    return ctrl.Result{RequeueAfter: MyTime}, ErrMyError
}

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

Warning: Reconciler returned both a result with either RequeueAfter or Requeue set and a non-nil error. RequeueAfter and Requeue will always be ignored if the error is non-nil. For more details, see: https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/reconcile#Reconciler

 При этом значение, указанное в Result, будет проигнорировано, а очередная реконсиляция произойдёт, когда этого пожелает controller-runtime. В целом, столкнуться с этой проблемой сложно, если специально не пытаться. Однако, в сложных операторах, когда результат и ошибка могут формироваться в разных местах, это возможно.

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

Например, если нам нужно уйти на реконсиляцию при получении ошибки, мы можем сделать так:

func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    if err := r.myService.Do(ctx); err != nil {
        return ctrl.Result{RequeueAfter: 1}, nil
    }
}

Но обратите внимание: это очень ненадежный способ. По сути, мы заглушаем ошибку и сразу уходим на повторную реконсиляцию. Помимо того, что администратор не получает информации о возможной проблеме (что, в целом, может быть вполне нормальной ситуацией), мы получаем реконсиляцию без какого-либо таймаута. Моментальную, насколько позволит текущая нагрузка на оператора.

В крайнем случае, если ошибка будет возникать всегда, это приведет к DoS собственного кластера. Это не есть хорошо. Поэтому стоит всегда использоватьRequeueAfter с временем, позволяющим контроллеру и окружающему миру восстановиться после повторного получения потенциальной ошибки. Это хоть как-то спасает ситуацию.

Тем не менее, во всех возможных ситуациях стоит полагаться на controller-runtime. Просто потому, что обработка ошибок у него уже «из коробки» строится с учетом экспоненциальных ретраев. Так что первые попытки будут быстрыми, а последующие — со все возрастающим временным лагом.

Кстати, ответ на вопрос, почему в примере используется RequeueAfter с единичкой, а не Requeue: true, будет в одном из следующих разделов. 

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

Не все ошибки, которые мы возвращаем из Reconcile, приведут к повторным попыткам реконсиляции. Точнее, мы можем сказать, на какие именно ошибки мы не хотим реконсилироваться повторно. Для этого нужно лишь воспользоваться враппером для ошибок reconcile.TerminalError. При этом вообще не важно, где в цепочке ошибок будет использован этот враппер.

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

func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    if err := r.myService.Do(ctx); err != nil {
        return ctrl.Result{}, reconcile.TerminalError(err)
    }
}

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

func (s *MyService) Do(ctx context.Context) error {
    ...
    if result := s.someAction.Do(ctx); result == 400 {
        return reconcile.TerminalError(ErrInvalidResult)
    }
    ...
}

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

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

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

Очередь и лимитер: workqueue и rate-limiter

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

Сперва разберемся с workqueue. Что это вообще такое? 

Как нетрудно понять из названия, это очередь. Есть очередь в кубовой либе client-go и обертка для нее в controller-runtime. Все это довольно сложная и низкоуровневая штука, о чем недвусмысленно намекает комментарий:

// NOTE: LOW LEVEL PRIMITIVE!
    // Only use a custom NewQueue if you know what you are doing.
    NewQueue func(controllerName string, rateLimiter workqueue.TypedRateLimiter[request]) workqueue.TypedRateLimitingInterface[request]

Влезать в нее имеет смысл, только если вам действительно нужно поменять поведение очереди. Например, вас почему-то не устраивает очередь с приоритетом или вы хотите убрать дедупликацию элементов. Тем не менее в большинстве случаев не стоит задумываться о том, чтобы менять очередь. Как минимум, до тех пор, пока вы чётко не осознаете железобетонную необходимость.

Изменить очередь можно весьма просто в любом вашем контроллере:

return ctrl.NewControllerManagedBy(mgr).
   WithOptions(
      controller.Options{
         NewQueue: func(controllerName string, rateLimiter workqueue.TypedRateLimiter[reconcile.Request]) workqueue.TypedRateLimitingInterface[reconcile.Request] {
            return workqueue.NewTypedRateLimitingQueueWithConfig(rateLimiter, workqueue.TypedRateLimitingQueueConfig[reconcile.Request]{
               Name: controllerName,
            })
         },
      },
   )
...

Теперь разберемся с rate-limiter

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

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

Изменить rate-limiter можно аналогично через опции контроллера:

return ctrl.NewControllerManagedBy(mgr).
   WithOptions(
      controller.Options{
         RateLimiter: workqueue.NewTypedItemExponentialFailureRateLimiter[reconcile.Request](time.Millisecond, 1000*time.Second),
      },
   )
...

Поведение очереди и взаимодействие с ней

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

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

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

For(&webappv1.Guestbook{},
    builder.WithPredicates(
       predicate.Or[client.Object](
          predicate.GenerationChangedPredicate{},
          mypredicate.FinalizerChanged{},
       ),
    ),
).

Второе. Как мы уже упоминали, дефолтная очередь дедуплицирует события. Это значит, что пока в очереди или на исполнении в одном из воркеров контроллера находится объект, он не попадёт в очередь повторно.

Но тут можно попасть в небольшую ловушку. Скажем, что будет, если ваш контроллер будет всё время возвращать ошибку на объект? Правильно, он каждый раз будет добавляться в очередь, а rate-limiter — каждый раз увеличивать время между повторными попытками реконсиляции.

Но что, если между очередными попытками реконсиляции вручную изменить объект в кластере? Сработает дедупликация, объект не добавится в очередь, и контроллер получит его, только когда выйдет время, которое указал rate-limiter?

На самом деле нет. Всё чуточку сложнее.

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

Тем не менее пойдём дальше. Представим: вот мы передёрнули объект, сработал контроллер и… снова ошибка. По идее объект должен добавиться в эту «другую» очередь. Но этого не произойдёт, потому что он там уже есть. Однако, rate-limiter будет считать, что очередная «попытка» прошла неуспешно, и повысит у себя счётчик для этого объекта.

Проведём небольшой эксперимент. Создадим контроллер, который всё время падает, и укажем rate-limiter с начальным промежутком в одну секунду:

func (r *GuestbookReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    return ctrl.Result{}, ErrSome
...
func (r *GuestbookReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
       WithOptions(
          controller.Options{
             RateLimiter: workqueue.NewTypedItemExponentialFailureRateLimiter[reconcile.Request](time.Second, 100*time.Second),
          },
       ).
...

Запустим его, обновим в какой-то момент реконсилируемый объект простым kubectl apply -f и посмотрим на получившийся лог:

 2026-01-22T09:42:45+03:00  ERROR  Reconciler error
 2026-01-22T09:42:46+03:00  ERROR  Reconciler error
 2026-01-22T09:42:48+03:00  ERROR  Reconciler error
 2026-01-22T09:42:52+03:00  ERROR  Reconciler error
 2026-01-22T09:43:00+03:00  ERROR  Reconciler error
*2026-01-22T09:43:04+03:00  ERROR  Reconciler error
 2026-01-22T09:43:16+03:00  ERROR  Reconciler error
 2026-01-22T09:44:20+03:00  ERROR  Reconciler error

Как видим, время между логами стабильно растёт вплоть до помеченного звёздочкой. А на нём тайминги перестают сходиться. Этот лог возник, когда мы вручную изменили объект через kubectl.

Обратите внимание: следующий шаг запустился ровно тогда, когда и должен — через 16 секунд после предыдущего, но всего через 12 секунд после внеочередного. И следующий за ним должен был появиться через 32 секунды, как и положено при экспоненциальном росте. Но он появился только через 64! Наш внеочередной запуск реконсиляции привёл к увеличению числа попыток.

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

При такой ситуации в логах вы будете видеть, что объект пытался реконсилироваться пару десятков или даже сотен раз в течение нескольких секунд. А все последующие попытки происходили лишь спустя минут пятнадцать после предыдущей. Это как раз происходит из-за того, что лимитер быстро использовал все попытки в самом начале и упёрся в потолок по времени.

Третье — скорее небольшое дополнение ко второму пункту.

Ровно такое же поведение, как при возврате ошибки, вы получите, если вёрнете результат с Requeue. Вот так:

return ctrl.Result{Requeue: true}, nil

Точно так же, как с ошибкой, будет триггериться rate-limiter, и время между попытками будет увеличиваться. Поэтому не рекомендуем использовать Requeue: true. Тем более что относительно недавно он и вовсе был помечен как устаревший: нет ситуаций, когда он реально может быть полезен. 

Действительно, rate-limiter необходим, чтобы контролировать число и частоту попыток при ошибке. Если вам нужно дождаться какого-то события, скорее всего, вы не хотите, чтобы между попытками у вас был экспоненциально растущий лаг. Используйте в такой ситуации RequeueAfter. Если же вам нужна максимально быстрая повторная попытка — вы просто возвращаете RequeueAfter с минимально возможным временем.

Поэтому рекомендую просто забыть про существование этого флага.

Четвёртое — уже упоминавшийся выше RequeueAfter. Здесь все просто. Пожалуйста, не воспринимайте это поле как указание на то, что ваш контроллер снова обработает объект строго спустя указанное время. И да, и нет. Например:

return ctrl.Result{RequeueAfter: time.Second}, nil

Такая запись вовсе не значит, что ровно через секунду ваш контроллер снова получит реквест с тем же объектом. Через секунду или около того реквест лишь будет снова добавлен в очередь. Не более. А уж когда он попадет на исполнение к контроллеру — зависит от такого числа переменных, что даже страшно подумать. Да, очередь банально может быть забита другими объектами. И пока они не будут обработаны, ваш очередник в работу не поступит.

Тонкая настройка работы с ошибками

Последний момент — работа с типовыми ошибками из клиента controller-runtime

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

Начнём с уже знакомого reconcile.TerminalError. Как говорили ранее, этот враппер позволяет сказать рантайму, что ошибку не нужно повторять. Лог будет записан, но на этом всё. 

Как использовать:

import “sigs.k8s.io/controller-runtime/pkg/reconcile”
...
return ctrl.Result{}, reconcile.TerminalError(err)

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

А вот следующую функцию использует практически каждый контроллер. Это client.IgnoreNotFound(), которая уже фигурировала в примерах выше. Даже минимальный контроллер, который просто получает объект в реквесте, будет её использовать.

import “sigs.k8s.io/controller-runtime/pkg/client”
...
if err := r.Get(ctx, req.NamespacedName, book); err != nil {
    	return ctrl.Result{}, client.IgnoreNotFound(err)
}

Из названия очевидно, для чего она нужна: чтобы не логировать ошибку ненайденного объекта и не реконсилироваться повторно. Здесь может возникнуть вопрос: а как такая ситуация вообще может возникнуть?

Ответ прост. Могут быть сильно разнесены во времени два момента: когда объект попадает в очередь на реконсиляцию и когда реквест на реконсиляцию достигает реконсайла вашего контроллера. И в этот временной лаг может произойти что угодно. Вплоть до удаления поставленного в очередь объекта.

В бизнес-логике вы, скорее всего, хотели бы использовать немного другую функцию. А именно apierrors.IsNotFound, которая показывает, что ошибка перед нами — это именно NotFound, а не что-то другое. Например:

import apierrors "k8s.io/apimachinery/pkg/api/errors"
...
if err := r.Get(ctx, req.NamespacedName, secret); err != nil {
    	if apierrors.IsNotFound(err) {
    	    	r.createSecret(ctx)
    	}
...

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

Теперь рассмотрим функцию client.IgnoreAlreadyExists. Она проверяет,  сигнализирует ли ошибка о том, что создаваемый объект уже существует. И, если это так, вернет вместо ошибки nil. Например, в примере выше была функция createSecret, которая могла бы содержать что-то подобное:

err := r.Create(ctx, req.NamespacedName, secret)
if err != nil {
    	return ctrl.Result{}, client.IgnoreAlreadyExists(err)
}

Такое встречается реже, чем ситуация, когда запрашиваемого объекта не существует. Но, уверен, вы бы не хотели видеть в ваших логах ошибки с содержимым AlreadyExists. А конечного пользователя вашего оператора это может и вовсе дезориентировать. Из-за чего он может удалить созданный вашим объектом секрет, думая, что решает проблему.

А если вам нужно не просто заглушить, а реагировать на эту ошибку, вы можете использовать функцию apierrors.IsAlreadyExists. Подобно функции проверки ошибки NotFound эта вернет нам true, только если полученная ею ошибка — AlreadyExists:

err := r.Create(ctx, req.NamespacedName, secret)
if err != nil {
    	if apierrors.IsAlreadyExists(err) {
    	    	r.updateSecret(ctx)
...

Причин, по которым такая ошибка вообще может возникнуть, множество. Самые банальные — временной лаг и кеширование данных в самом контроллере, по аналогии с ошибкой NotFound. Да, клиент в controller-runtime кеширует данные и не работает с кластером напрямую. Если последнее утверждение для вас новость — рекомендую восполнить этот пробел, чтобы избежать косяков при создании контроллеров.

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

В большинстве случаев так делать не стоит. Ключевой момент в том, что create всегда будет делать POST-запрос. И это будет задействовать etcd, добавляя на него нагрузку. В то время как использование Get не будет задействовать API и проверит наличие объекта в локальном кэше. Реконсиляции могут быть довольно частыми событиями, поэтому не стоит перегружать кластер бесполезными запросами.

И, пожалуй, последняя из наиболее часто используемых функций — apierrors.IsConflict. Функция позволяет проверить, является ли проверяемое ошибкой конфликта. Ранее мы обсуждали механизмы повторных попыток и упоминали функцию retry.RetryOnConflict. Эта функция как раз использует apierrors.IsConflict под капотом. Мы также можем использовать её в своей бизнес-логике. Например:

err := r.Update(ctx, req.NamespacedName, secret)
if err != nil {
    	if apierrors.IsConflict(err) {
    	    	return ctrl.Result{RequeueAfter: MyTime}, nil
...

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

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

Заключение

Мы рассмотрели довольно обширный пласт информации о работе контроллеров в целом и многих тонких моментов в частности. Тем не менее, операторы всё ещё не самый распространенный вид разрабатываемого ПО, хотя с каждым годом потребность в них будет только расти. Поэтому, возможно, у статьи будет и продолжение — ведь ошибаться при разработке можно бесконечно :-)

И, конечно, если у вас возникли вопросы — не стесняйтесь задавать их в комментариях!