golang

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

  • среда, 18 марта 2026 г. в 00:00:07
https://habr.com/ru/companies/sberbank/articles/1010886/

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

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

Поехали!

Горутины внутри Reconcile: что можно, а что нельзя

Первое, что хочется сказать, — забудьте про горутины.

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

Забудьте про горутины, которые живут дольше, чем породивший их вызов Reconcile. Если вы не можете обеспечить завершение всех горутин, которые порождает очередной запуск Reconcile, то лучше вообще их не использовать. Причина проста: горутины усложняют работу контроллера и повышают вероятность конфликтов при работе с объектами кластера.

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

ctrl.NewControllerManagedBy(mgr).
	…
	WithOptions(controller.Options{MaxConcurrentReconciles: 
10}).

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

И вот тут может возникнуть справедливое замечание: «А если обязательно нужно параллельно обработать задачи в рамках одного Reconcile?» 

Возможных ответов несколько: 

  • Ещё раз подумайте, нужно ли вводить параллельность. Если все задачи идемпотентные и независимые, то они могут и должны быть последовательными. Это значительно упрощает разработку, поддержку, сопровождение, поиск и исправление багов. 

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

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

Update, Patch и конфликты обновлений

Ещё одна неочевидная вещь — конфликты при внесении изменений в ресурсы Kubernetes. Для начала рассмотрим простой пример:

func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	book := &webappv1.Guestbook{}
	err := r.Get(ctx, req.NamespacedName, book)
	if err != nil {
    	return ctrl.Result{}, client.IgnoreNotFound(err)
	}
 
	// Очень долгая операция
 
	book.Labels["new_label"] = "new_value"
	if err := r.Update(ctx, book); err != nil {
    	return ctrl.Result{}, fmt.Errorf("update label: %w", err)
	}
 
	return ctrl.Result{}, nil
}

 Что здесь происходит? 

На самом старте получаем объект гостевой книги, затем выполняем некоторую очень долгую операцию, и в конце добавляем метку на загруженный в начале объект. И вроде бы ничего страшного, но недаром тут упоминается абстрактная очень долгая операция. Действительно, длительная операция, разрывающая загрузку объекта и внесение в него изменений, может привести к проблемам. Если во время этой долгой операции объект будет изменён (неважно, как и кем), мы получим такую ошибку:

Operation cannot be fulfilled on guestbooks.webapp.my.domain "guestbook-sample": the object has been modified; please apply your changes to the latest version and try again

Буквально: «не удалось выполнить операцию, объект был изменён». Если обобщить, то это ошибка конкурентного доступа к данным: несколько акторов пытаются читать и изменять объект одновременно, а Kubernetes, в свою очередь, предотвращает возможные ошибки, не давая завершить такую операцию.

Сразу предостерегаю от попыток проводить прямые параллели с гонками данных из Go. И суть, и общий посыл ошибок, несомненно, схожи. Однако детали делают их принципиально различными. Чтобы возникло состояние гонки (race condition), доступ к данным должен быть строго одновременным. В то время как ошибка в Kubernetes возникает, если пытаться работать с уже устаревшими данными.

Борьба с конфликтами

Теперь поговорим о том, как предотвращать такие ошибки. 

В Go существуют примитивы синхронизации, призванные бороться в том числе с гонками данных. В Kubernetes в целом и в controller-runtime в частности существуют собственные механизмы борьбы с подобными ситуациями.

Но, перед тем как начать, важно понимать, что эта ошибка — не ошибка как таковая. Это нормальная часть цикла согласования (Reconcile). И нам нужно не пытаться её предотвратить, а научиться понять, как её обрабатывать.

Главное — мьютексов и аналогичных систем в Kubernetes нет и принципиально быть не может. Не будем разбирать, почему, иначе потратим много времени на обсуждение eventual consistency и всего, что рядом. Поэтому движемся дальше. Что же есть?

Повторные попытки

Самый распространённый механизм — это банальный RetryOnConflict. Наш первый пример можно было бы выполнить так:

err = retry.RetryOnConflict(retry.DefaultRetry, func() error {
	book := &webappv1.Guestbook{}
	err := r.Get(ctx, req.NamespacedName, book)
	if err != nil {...}
 
	// Очень долгая операция
 
	book.Labels["new_label"] = "new_value"
	if err := r.Update(ctx, book); err != nil {...}
   	return nil
})

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

Какие тут есть проблемы?

  1. Самое очевидное — то, что при конфликте обновления меток мы повторно выполним нашу очень долгую операцию. И хорошо, если она идемпотентна, тогда, теоретически, мы могли бы себе это позволить. А если нет?

    Например, у нас идёт вызов метода отправки нотификации клиенту. Сколько нотификаций мы отправим клиенту? Две? Больше? А если возникнет ситуация взаимосвязанных конфликтов и несколько операторов начнут наматывать объект, пытаясь его изменить под себя? Мы будем безостановочно обстреливать клиента нотификациями. Такой себе пользовательский опыт.

  2. Чуть менее очевидно то, что такое использование функции попросту излишне. С тем же успехом мы могли бы вернуть ctrl.Result{Requeue: true} вместо ошибки. Более того, это был бы гораздо более правильный с точки зрения архитектуры вариант. Но про архитектуру ещё поговорим позже.

Более корректно было бы обернуть только обновление. Вот так:

book := &webappv1.Guestbook{}
err := r.Get(ctx, req.NamespacedName, book)
if err != nil {...}
 
// Очень долгая операция
 
err = retry.RetryOnConflict(retry.DefaultRetry, func() error {
	book := &webappv1.Guestbook{}
	err := r.Get(ctx, req.NamespacedName, book)
	if err != nil {...}
 
	book.Labels["new_label"] = "new_value"
	if err := r.Update(ctx, book); err != nil {...}
   	return nil
})

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

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

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

Патчинг

В свете вышесказанного может возникнуть желание вместо Update использовать Patch. Ведь, в отличие от Update, Patch не упадёт с конфликтом. Например, если бы мы решили заменить обновление на патч, то наш код выглядел бы как-то так:

// Очень долгая операция
patch := client.MergeFrom(book.DeepCopy())
book.Labels["new_label"] = "new_value"
if err := r.Patch(ctx, book, patch); err != nil {...}

При таком раскладе мы всегда будем просто переписывать лейбл (и только его) в целевом объекте. Даже если во время очень долгой операции был изменён объект, лейблы объекта или наш целевой лейбл new_label.

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

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

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

Всё было бы слишком просто, если бы история с патчами на этом закончилась. Часто разработчики запоминают эти простые паттерны и больше к ним не возвращаются. Но патч — это мощный инструмент, поэтому изучим его немного шире.

Рассмотрим пример:

// Очень долгая операция
patch := client.MergeFromWithOptions(book.DeepCopy(), client.MergeFromWithOptimisticLock{})
book.Labels["new_label"] = "new_value"
if err := r.Patch(ctx, book, patch); err != nil {...}

По сравнению с прошлым примером добавилась опция MergeFromWithOptimisticLock при создании объекта патча. Это важная деталь. Благодаря этой опции мы получим поведение, схожее с Update. Если за время очень долгой операции что-то произошло с объектом, появится ошибка 409 (Conflict).

Такой патч очень полезен в ситуациях, когда вы работаете с объектом неизвестного поля. Если бы вы использовали Update — неизвестные поля просто удалились бы. В то же время патч работает только с полями, которые были явно изменены, и не затронет неизвестные.

Надеюсь, в этот момент вы подумали: «Так, это можно использовать для работы с апгрейдом или даунгрейдом оператора». Это действительно так, но мало какие операторы предусматривают поддержку даунгрейда, по понятным причинам.

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

Полезный механизм — использование сырого патча. Например:

patchOps := []map[string]any{
	{
    	"op":	"test",
    	"path":  "/spec/foo",
    	"value": "baz",
	},
	{
    	"op":	"replace",
    	"path":  "/spec/foo",
    	"value": "bar",
	},
}
 
patchBytes, err := json.Marshal(patchOps)
if err != nil {...}
 
if err := r.Patch(ctx, book, client.RawPatch(types.JSONPatchType, patchBytes)); err != nil {...}

В этом случае мы самостоятельно решаем, что и как патчить, а не полагаемся на функции вроде client.MergeFrom. Благодаря этому можно, например, добавить операцию test, чтобы проверить значение поля. Если значение отличается от ожидаемого — патч упадёт.

В таком случае можно безопасно менять значения полей, не загружая целевой объект. Например, вот так:

patchOps := []map[string]any{...}
 
patchBytes, err := json.Marshal(patchOps)
if err != nil {...}
 
res := &webappv1.Guestbook{}
res.Name = req.Name
res.Namespace = req.Namespace

err := r.Patch(ctx, res, client.RawPatch(types.JSONPatchType, patchBytes))
if err != nil {...}

Замечу, что в этом случае в качестве второго значения в метод Patch мы передаём свежесозданный объект, в котором нет никакого содержимого, кроме имени и неймспейса. И тем не менее патч будет работать.

Это только часть того, что можно использовать. Существует ещё как минимум client.StrategicMergeFrom, который, однако, работает только со встроенными объектами и не умеет работать с объектами CR. А также мощный инструмент Apply, реализующий SSA.

Использование всей мощи патчинга позволяет сильно упростить работу оператора.

Заключение

В этой части мы разобрали практические проблемы, с которыми частенько сталкиваются при разработке Kubernetes-операторов: неконтролируемое использование горутин внутри Reconcile, конфликты при обновлении ресурсов и использование Patch для уменьшения их количества. Эти моменты напрямую влияют на стабильность оператора и требуют осознанных архитектурных решений. 

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