问题描述

我们在写 Operator 的时候,可能会在更新资源的时候(即 Update 动作)经常遇到一个这样的问题

1
the object has been modified; please apply your changes to the latest version and try again.

这是为什么呢 ?

为了更好地叙述这个问题,我们可以尝试用最小的代码复现这个问题

这里采用 Kubebuilder bootstrap 起一个极简单的 Operator,有一个名为 Foo 的 CRD,该 CRD 只有一个 String 类型的字段,如下所示:

1
2
3
4
5
6
7
8
// FooSpec defines the desired state of Foo
type FooSpec struct {
	// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
	// Important: Run "make" to regenerate code after modifying this file

	// Foo is an example field of Foo. Edit foo_types.go to remove/update
	Foo string `json:"foo,omitempty"`
}

然后用创建的 Controller 写了这样一个非常简单的 Reconcile 逻辑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func (r *FooReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
	var (
		log = r.Log.WithValues("Foo", req.NamespacedName)
		ctx = context.WithValue(context.Background(), "logger", log)
	)

	log.Info("Reconcile Foo workload")

	foo := new(webappv1.Foo)
	if err := r.Get(ctx, req.NamespacedName, foo); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	return ctrl.Result{}, nil
}

此时我们测试一下:

1
$ kubectl apply -f config/samples/webapp_v1_foo.yaml

可打印出:

1
2
INFO	controllers.Foo	Reconcile Foo workload	{"Foo": "default/foo-sample"}
DEBUG	controller-runtime.controller	Successfully Reconciled	{"controller": "foo", "request": "default/foo-sample"}

似乎一切正常。

我们再添加一下逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func (r *FooReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
	var (
		log = r.Log.WithValues("Foo", req.NamespacedName)
		ctx = context.WithValue(context.Background(), "logger", log)
	)

	log.Info("Reconcile Foo workload")

	foo := new(webappv1.Foo)
	if err := r.Get(ctx, req.NamespacedName, foo); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	// 每次将更新前后的 resource version 打印出来
	log.Info("before update", "resourceVersion", foo.ResourceVersion, "Foo", foo.Spec.Foo)
	foo.Spec.Foo = "Hello"
	if err := r.Update(ctx, foo); err != nil {
		return ctrl.Result{}, err
	}
	log.Info("after update", "resourceVersion", foo.ResourceVersion, "Foo", foo.Spec.Foo)

	return ctrl.Result{}, nil
}

当我们创建 Foo 的时候(记得删除上一次测试的资源),同时将其对应字段进行修改,再次运行,其打印日志为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Reconcile Foo workload	{"Foo": "default/foo-sample"}
before update	{"Foo": "default/foo-sample", "resourceVersion": "19021", "Foo": "bar"}
after update	{"Foo": "default/foo-sample", "resourceVersion": "19022", "Foo": "Hello"}
Successfully Reconciled	{"controller": "foo", "request": "default/foo-sample"}

Reconcile Foo workload	{"Foo": "default/foo-sample"}
{"Foo": "default/foo-sample", "resourceVersion": "19022", "Foo": "Hello"}
{"Foo": "default/foo-sample", "resourceVersion": "19023", "Foo": "Hello"}
Successfully Reconciled	{"controller": "foo", "request": "default/foo-sample"}

Reconcile Foo workload	{"Foo": "default/foo-sample"}
{"Foo": "default/foo-sample", "resourceVersion": "19023", "Foo": "Hello"}
{"Foo": "default/foo-sample", "resourceVersion": "19023", "Foo": "Hello"}
Successfully Reconciled	{"controller": "foo", "request": "default/foo-sample"}

可以看到,Reconcile() 被触发了 3 次:

  1. 第一次进行 Update 操作,Update() 函数会将对应传入的 Foo 也进行修改,即修改了原来的 resourceVersion 字段,因此我们可以看到 resource version 从 19021 增加到 19022
  2. 第一次的 Update 又触发了一次 Reconcile(),触发的原因是 Cache 中的 foo-sample 依然是老的 resource version。在这次 Reconcile 中 Foo 又更新成功,resource version 从 19022 增加到 19023
  3. 第二次的 Update 又触发了一次 Reconcile(),此时更新前后的 resource version 都是 19023,因此不再生成新的事件;

如果我们再做一下修改:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func (r *FooReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
	var (
		log = r.Log.WithValues("Foo", req.NamespacedName)
		ctx = context.WithValue(context.Background(), "logger", log)
	)

	log.Info("Reconcile Foo workload")

	foo := new(webappv1.Foo)
	if err := r.Get(ctx, req.NamespacedName, foo); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	// 每次将更新前后的 resource version 打印出来
	log.Info("before update", "resourceVersion", foo.ResourceVersion, "Foo", foo.Spec.Foo)
	foo.Spec.Foo = "Hello"
	if err := r.Update(ctx, foo); err != nil {
		return ctrl.Result{}, err
	}
	log.Info("after update", "resourceVersion", foo.ResourceVersion, "Foo", foo.Spec.Foo)

	// 在上一次修改之后再,修改一次
	log.Info("before update", "resourceVersion", foo.ResourceVersion, "Foo", foo.Spec.Foo)
	foo.Spec.Foo = "World"
	if err := r.Update(ctx, foo); err != nil {
		return ctrl.Result{}, err
	}
	log.Info("after update", "resourceVersion", foo.ResourceVersion, "Foo", foo.Spec.Foo)

	return ctrl.Result{}, nil
}

此时我们再执行之前的动作,就会发现从日志发现 Reconcile() 被不断被触发,且伴随着 the object has been modified 的错误。

问题的原因

这里其实有两个问题:

  1. 为什么最后一次修改版本的 controller 会不断触发 Reconcile()

    原因还是在于我们在一个 Reconcile 周期中塞了两个 Update 动作。每一次都会触发新的 Reconcile 流程。由于是两次修改动作,那么新触发的 Reconcile 中的 Update 操作其实又是一个新的 Update:资源字段又发生了新的改变,因此循环往返。

  2. 为什么会发生更新失败 ?

    要想解释清楚为什么更新失败,那就必须了解一下 K8s 中更新资源的机制。由于 K8s 底层用的是 etcd,而 etcd 是一个强一致性的 KV,底层实现了 MVCC 机制。MVCC 可以认为是 etcd 底层维护了一个多版本机制,采用 optimistic lock 的方式来实现并发控制。etcd 中的每一个 Key 都拥有一个版本 revision

    基于 etcd,K8s 也沿用了版本和乐观锁的更新逻辑。每一个 K8s Resource 都有一个 resourceVersion(即上文打印出来的字段)。

    当我们发出的更新操作所使用的 resourceVersion 与存储端(即 etcd)所使用的 resourceVersion 不一致时,则说明在更新之前已经有了新的写入,即写者持有的是一个旧数据。此时需要写者再次获取最新版本的资源进行更新。此次更新失败。

    乐观锁的原理就是:全局不加锁,可任意写入。当时每次写入的时候都要检查一下有没有新的更新,如果有,则需要重新获取最新版本再次提交写操作(或者说是事务回滚)。

    很明显,日志中的错误就是我们提交的 Resource 的版本与服务端 Resource 的版本不一致,从而导致更新失败。

    而为什么会导致二者版本不一致呢 ?其实本质原因还是由于我们的不断的 Reconcile 放大了客户端 Cache 和实际存储间的数据的不一致

    由于 client-go 的 Informer 机制,我们实际上 Get 动作是从客户端 Cache 获取,如下代码所示:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    // 代码位于 controller-runtime@v0.5.0/pkg/cache/informer_cache.go
    // Get implements Reader
    func (ip *informerCache) Get(ctx context.Context, key client.ObjectKey, out runtime.Object) error {
            gvk, err := apiutil.GVKForObject(out, ip.Scheme)
            if err != nil {
                    return err
            }
    
            started, cache, err := ip.InformersMap.Get(gvk, out)
            if err != nil {
                    return err
            }
    
            if !started {
                    return &ErrCacheNotStarted{}
            }
            return cache.Reader.Get(ctx, key, out)
    }
    

    而客户端的 Cache 更新与服务端的更新有一定延迟,当二者存在不匹配情况时,此时 Update 就将出现问题。

解决方式

解决方式就如同乐观锁所说的:再次获取并更新

我们增加以下这么一个函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func (r *FooReconciler) UpdateFoo(ctx context.Context, foo *webappv1.Foo, newFoo string) error {
	// 采用 retry 进行默认的 backoff 重试,如果 update 失败则再次获取新版本再更新
	return retry.RetryOnConflict(retry.DefaultBackoff, func() (err error) {
		if err = r.Client.Get(ctx, client.ObjectKey{Name: foo.Name, Namespace: foo.Namespace}, foo); err != nil {
			return
		}

		// 再次更新 foo.Spec.Foo 字段
		foo.Spec.Foo = newFoo
		return r.Update(ctx, foo)
	})
}

当我们再次使用新的 UpdateFoo() 时:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
...
	log.Info("before update", "resourceVersion", foo.ResourceVersion, "Foo", foo.Spec.Foo)
	if err := r.UpdateFoo(ctx, foo, "Hello"); err != nil {
		return ctrl.Result{}, err
	}
	log.Info("after update", "resourceVersion", foo.ResourceVersion, "Foo", foo.Spec.Foo)

	// 在上一次修改之后再,修改一次
	log.Info("before update", "resourceVersion", foo.ResourceVersion, "Foo", foo.Spec.Foo)
	if err := r.UpdateFoo(ctx, foo, "World"); err != nil {
		return ctrl.Result{}, err
	}
	log.Info("after update", "resourceVersion", foo.ResourceVersion, "Foo", foo.Spec.Foo)
...

这样,我们就得到了一个不断触发 Reconcile 但是更新成功的 Controller 逻辑了

在实战中,其实我们并不推荐在 Reconcile 中塞太多的 Update 动作,这样会很容易陷入不幂等的逻辑。不过上述的 Retry Update 逻辑在大多数的 Operator 都很常见,可作为写 Operator 的一个常见范式。

参考文档

  1. Kubernetes operators best practices: understanding conflict errors
  2. Kubernetes API 概念