k8s 更新机制

背景

调用put 更新

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
curl -X PUT 'http://ip:8001/apis/serving.kubeflow.org/v1beta1/namespaces/default/inferenceservices/xxx' \
-H 'Content-Type: application/json' \
--data-raw '{
   "apiVersion": "serving.kubeflow.org/v1beta1",
   "kind": "InferenceService",
   "metadata": {
      "name": "xxx"
   },
   "spec": {
      "predictor": {
         "containers": [
            {
               "name": "kserve-container",
               "image": "xxx:8080/library/custom-model:v1",
               "env": [
                  {
                     "name": "MODEL_NAME",
                     "value": "xxx"
                  }
               ]
            }
         ]
      }
   }
}'

调用 K8s api 接口做更新。结果收到报错如下:

1
metadata.resourceVersion: Invalid value: 0x0: must be specified for an update

因为 YAML 文件中没有包含 resourceVersion 字段。对于 update 请求而言,应该取出当前 K8s 中的对象做修改后提交

正确应该

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
curl -X PUT 'http://ip:8001/apis/serving.kubeflow.org/v1beta1/namespaces/default/inferenceservices/xxx' \
-H 'Content-Type: application/json' \
--data-raw '{
   "apiVersion": "serving.kubeflow.org/v1beta1",
   "kind": "InferenceService",
   "metadata": {
      "name": "xxx",
      "resourceVersion": "298219602"
   },
   "spec": {
      "predictor": {
         "containers": [
            {
               "name": "kserve-container",
               "image": "xxx:8080/library/custom-model:v1",
               "env": [
                  {
                     "name": "MODEL_NAME",
                     "value": "xxx"
                  }
               ]
            }
         ]
      }
   }
}'

原理

对一个 Kubernetes 资源对象做更新操作,简单来说就是通知 kube-apiserver 组件我们希望如何修改这个对象。

而 K8s 为这类需求定义了两种“通知”方式,分别是 update 和 patch。

  • 在 update 请求中,我们需要将整个修改后的对象提交给 K8s;K8s 对 update 请求的版本控制机制所限制的。
  • 而对于 patch 请求,我们只需要将对象中某些字段的修改提交给 K8s。

Update 机制

Kubernetes 中的所有资源对象,都有一个全局唯一的版本号(metadata.resourceVersion)。每个资源对象从创建开始就会有一个版本号,而后每次被修改(不管是 update 还是 patch 修改),版本号都会发生变化。

唯一能做的就是通过比较版本号相等来确定对象是否是同一个版本(即是否发生了变化),resourceVersion 一个重要的用处,就是来做 update 请求的版本控制。

K8s 要求用户 update 请求中提交的对象必须带有 resourceVersion,也就是说我们提交 update 的数据必须先来源于 K8s 中已经存在的对象。因此,一次完整的 update 操作流程是:

  1. 首先,从 K8s 中拿到一个已经存在的对象(可以选择直接从 K8s 中查询;如果在客户端做了 list watch,推荐从本地 informer 中获取);
  2. 然后,基于这个取出来的对象做一些修改,比如将 Deployment 中的 replicas 做增减,或是将 image 字段修改为一个新版本的镜像;
  3. 最后,将修改后的对象通过 update 请求提交给 K8s;
  4. 此时,kube-apiserver 会校验用户 update 请求提交对象中的 resourceVersion 一定要和当前 K8s 中这个对象最新的 resourceVersion 一致,才能接受本次 update。否则,K8s 会拒绝请求,并告诉用户发生了版本冲突(Conflict)。

image-20220430150707223

Patch 机制

当用户对某个资源对象提交一个 patch 请求时,kube-apiserver 不会考虑版本问题,而是直接接受用户的合法请求,也就是将 patch 打到对象上、同时更新版本号。

有不同的更新策略

1
2
3
kubectl patch -h
# ...
  --type='strategic': The type of patch being provided; one of [json merge strategic]

Example

如果针对一个已有的 Deployment 对象,假设 template 中已经有了一个名为 app 的容器

  • 如果要在其中新增一个 nginx 容器,如何 patch 更新?

  • 如果要修改 app 容器的镜像,如何 patch 更新?

json patch

新增容器:

1
2
kubectl patch deployment/foo --type='json' -p \
  '[{"op":"add","path":"/spec/template/spec/containers/1","value":{"name":"nginx","image":"nginx:alpine"}}]'

修改已有容器 image:

1
2
kubectl patch deployment/foo --type='json' -p \
  '[{"op":"replace","path":"/spec/template/spec/containers/0/image","value":"app-image:v2"}]'

可以看到,在 json patch 中我们要指定操作类型,比如 add 新增还是 replace 替换,另外在修改 containers 列表时要通过元素序号来指定容器

merge patch

无法单独更新一个列表中的某个元素,因此不管我们是要在 containers 里新增容器、还是修改已有容器的 image、env 等字段,都要用整个 containers 列表来提交 patch

1
2
kubectl patch deployment/foo --type='merge' -p \
  '{"spec":{"template":{"spec":{"containers":[{"name":"app","image":"app-image:v2"},{"name":"nginx","image":"nginx:alpline"}]}}}}'

显然,这个策略并不适合我们对一些列表深层的字段做更新,更适用于大片段的覆盖更新

不过对于 labels/annotations 这些 map 类型的元素更新,merge patch 是可以单独指定 key-value 操作的,相比于 json patch 方便一些,写起来也更加直观:

1
kubectl patch deployment/foo --type='merge' -p '{"metadata":{"labels":{"test-key":"foo"}}}'

strategic merge patch

在我们 patch 更新 containers 不再需要指定下标序号了,而是指定 name 来修改,K8s 会把 name 作为 key 来计算 merge。比如针对以下的 patch 操作

1
2
kubectl patch deployment/foo -p \
  '{"spec":{"template":{"spec":{"containers":[{"name":"nginx","image":"nginx:mainline"}]}}}}'

如果 K8s 发现当前 containers 中已经有名字为 nginx 的容器,则只会把 image 更新上去;而如果当前 containers 中没有 nginx 容器,K8s 会把这个容器插入 containers 列表。

目前 strategic 策略只能用于原生 K8s 资源以及 Aggregated API 方式的自定义资源,对于 CRD 定义的资源对象,是无法使用的。这很好理解,因为 kube-apiserver 无法得知 CRD 资源的结构和 merge 策略。如果用 kubectl patch 命令更新一个 CR,则默认会采用 merge patch 的策略来操作

kubectl的封装

https://github.com/kubernetes/kubectl

其实 kubectl 为了给命令行用户提供良好的交互体感,设计了较为复杂的内部执行逻辑,诸如 apply、edit 这些常用操作其实背后并非对应一次简单的 update 请求。

apply

使用默认参数执行 apply 时,触发的是 client-side apply。kubectl 逻辑如下

首先解析用户提交的数据(YAML/JSON)为一个对象 A;然后调用 Get 接口从 K8s 中查询这个资源对象:

  • 如果查询结果不存在,kubectl 将本次用户提交的数据记录到对象 A 的 annotation 中(key 为 kubectl.kubernetes.io/last-applied-configuration),最后将对象 A提交给 K8s 创建;
  • 如果查询到 K8s 中已有这个资源,假设为对象 B:
    • kubectl 尝试从对象 B 的 annotation 中取出 kubectl.kubernetes.io/last-applied-configuration 的值(对应了上一次 apply 提交的内容)
    • kubectl 根据前一次 apply 的内容和本次 apply 的内容计算出 diff(默认为 strategic merge patch 格式,如果非原生资源则采用 merge patch)
    • 将 diff 中添加本次的 kubectl.kubernetes.io/last-applied-configuration annotation,最后用 patch 请求提交给 K8s 做更新。

edit

当用户修改完成、保存退出时,kubectl 并非直接把修改后的对象提交 update(避免 Conflict,如果用户修改的过程中资源对象又被更新)

而是会把修改后的对象和初始拿到的对象计算 diff,最后将 diff 内容用 patch 请求提交给 K8s。