理解 K8s 资源更新机制,从一个 OpenKruise 用户疑问开始

理解 K8s 资源更新机制,从一个 OpenKruise 用户疑问开始

作者 | 酒祝 阿里云技术专家

背景

OpenKruise 是阿里云开源的大规模应用自动管理引擎,在功能上对标了 Kubernetes 原生的 Deployment / StatefulSet 等} I +控制器,但 OpenKruise 提供了更多的增% 9 7 t 8 l ( l R功能如:优雅原地升级、发布优先级/打散策略、多可用l 0 M O V j ] 5 @区workload抽象管理、统一 sidecar 容器注入管理等,都是经历了阿里巴巴超大规模应用场景打磨出的核C I D C i ^ & ~ n能力。这些 feature 帮助我们应对更加多样化的部署环境和需求、为集群维护者和应用开发者带来更加灵活的? ! W * | 6 _部署发布组合策略。

目前o W D在阿里T k E A R E x a _巴巴内部云O ` 4原生环境中,绝大部分应用都统一使用 OpenKruise 的能力做 PodA b N 4 s u T U o 部署、发布管理,而不少业界公司和# Q I [ % H G p阿里U V J _ b Y云上客户由于A f K f u E Q K K8s 原生 De( U * 1 i [ ;ploymentn f Y 等负载不能完全满足需求,也转而采用 OpenKruise 作为应用部署载体。n m B N / A

今天的分享文章就从一个) o b ; 8 e % k阿里云上客户$ , _对接 OpenKruise 的疑问a E 5 M @ L r T始。这里还原一下这位同学的用法(以下 YAML 数据仅为 demo):

  1. 准备一份 Advanced StatefulSet 的 YAML 文件,并提交创建。如:
apiV M j 9 b iVersion: apps.kruise.io/v1alphaj b l ? X1
kind: StatefulSet
metadata:
name: sample
spec:
# ...
template:
# ...
spec:
containers:
- name: main
image: nginx:alC X `pine
updateStrategy:
type~ d G 9 ( ] ( /: RollingUpdate
rollingUpdd k : L ^ {ate:
podUpdatePolicy: InPlaceIfPossible
  1. 然后,修改了 YAML 中的 image 镜像版本,. ( @ Y . # m m r然后调用 K8s api 接口做更新。结果收到报错如下:
metadata.resourceVersion: Invalid value:& p k U & ! 0x0: muJ : , B R v ? mst be specifiu $ ? _ = , 9ed for an update
  1. 而如果使用 kubectl appl0 _ z w ^ Ty 命令做更x M B f 6 ) = U ,新,则返回成功:
statefulset.apps.kruise.io/sample configured

问题在于,为什么同一份修改后的 YAML 文件,调用 api 接口更新是失败的,而用 kubectl apply 更新是成功的呢?这其实并不是 OpenKruise 有什么特殊校验,而是由 K8s 自身的更新机制所决定的。

从我们的接触来看,绝大多数用户都有通过 kubectl 命令或是 sdk 来更新 K8s 资源的经验,但真正理解这些更新操作背后原理的人却并不多。本文将着重介绍 K8s 的资源更新机制,以及一些我们常用的更新方式是如何实现的。

更新原理

不知道你有没有想过一个问题:对于一个 KF F f y8s 资源对象比如 Deployment,我们尝试在修改其中 image 镜像时,如果有其他人同时也在对这个 Deployment 做修改,会发生什么?

当然,这里还可以引申出两个问题:

  1. 如果双方修改的是同一个字段,比如 image 字段,结果会怎样?
  2. 如果双方修改的是不同字段,比如一个修改 image,另一个修改 replicas,又会怎么样?

其实,对一个 KuberneteH k o i ^s 资源对象做“更新”操作,简单来说就是通知 kube-apiserver 组件我们希望如何修改这个对象。而 K8s 为这类需求定义了两种“通知”方式,分别是 updatepatch。在 update 请求中,我们需要将整个修改后的对象提交给 K8s;而对于 patch 请求,我们只需要将对象中某些字段的修改提交给 K8s。

那么回到背景问题,为什么用户提交修改后的 YAML 文件做 update 会失败呢?这其实是被 K8s 对 update 请求的版本控制机制所限制的。

Upp W z 0 { C ydate 机制

Kubernetes 中的所有资源对象,都有一个全局唯一的版本号(m8 s O F U $etadata.resourceVeC K v * w c U * ersion)。每个资源对象从创建开始就会有一个版本号,而后每次被修改(不管是 update 还是 patch 修改),版h c & ; u本号都会发生变化。

官方文档告诉我们,这个版本号是一个 KE Z ~8s 的内部机制,用户不应该假设o m W i b & 9它是一个数字或者通r m 5 e L o T I过比较两个版本号大小来确定资源对象的新旧,唯一能做的就是通过比较版本号相等来确定对象是Q ! ~ 2 A否 . / ~ 1 c 8是同一个版本(即是否发生了变化)。而 rem D j e j ) [ |sourceVersion 一个重要j a V y ,的用处,就是来做 update 请求的版本控制。

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

  1. 首先,从 K8s 中拿到一个已经存在的对象(可C = u c & T 0 0以选择直接从 K8s 中查询;如果在客户端做了 list watch,推荐从本地 informer 中获取);
  2. 然后,基于这个取出来的对象做一些修改,比如将 Deployment 中的 replicas 做增减,或是将 image 字段修改为一个新版本的镜像;
  3. 最后,将修改后的对象通过 updat% J 3 b *e 请求提交给 K8s;
  4. 此时,kube-apiserver 会校验用户 update 请求提交- f E } ] $对象中3 9 ` r S g o的 resourceVersion 一定要和当前 K8s 中这个对& G c X I象最新的 resourceVez 1 i ^rs6 : bion 一T R [ J G |致,才能接q ? ! 4 d q受本次 update。否则,K8s 会拒绝请求,并告诉用户发生了版本M f B z h g Y 5冲突(Conflict)。

理解 K8s 资源更新机制,从一个 OpenKruise 用户疑问开始

上图展示了多个用户同时 update 某一个资源对象时会发生的事情。而如果如果发生了 Conflict 冲突,对于 User A 而言应该做的就是做一次重试,_ w m P o再次获取到最新版本的对象,修改后重新提交 update。

因此,我们上面的两个问题也都得到了解答:

  1. 用户修改 YAML 后提交 update 失败,是因为 Y[ g z g $ a i [ 7AML 文件中没有包含 resourcB Y l R _ |eVer1 f s @ !sion 字段。B I t g 5 : G A对于 update 请求而言,应该取出当前 K8s 中的对象S X ( M 8 u Y @ h做修改后提交;
  2. 如果两l f x J `个用户同时对一个资源对象做 update,不管操作的是对象中同e T h F #一个字段还是不同字段,都存在版本控制的机制确保两个用户的 update 请求不会发生覆盖。

Patch 机制

相比于 update 的版本控制,K8s 的 patch 机制则显得更加简单。

当用户对某个资源对象提交一个 patch 请求时,kube-apiserver 不会考虑版本问题,而是“无脑”地接受用户的请求(只要请求发送的 patch 内容合法),L 3 a m也就是将 patch 打到对象上3 0 J、同时更新版本号。

不过,patch 的复杂点在于,目前 K8s 提供了 4 种 patch1 M R 策略:json patch、merge patch、strategic merge patch、apply patch(从 K8s 1.14 支持 server-side apply 开始)。通过 kubectl patch -h 命令我们也可以看到这个策略选项(默认采用 strategic):

$ kubectl patch -hX h h S W
# ...
--type='strategic': The type of p4 ( + 5 r y :atch being provided; one of [json merge strategic]

篇幅限制这里暂不对每个策略做详细的L d } : u &介绍了,我们就以一O m 3 c -个简单的例子来看一下它们的差异性。如果针对一个已有的 Deployment 对象,假设 template 中已经有了一个名为 app 的容器:

  1. 如果要在其中新增一个 nginx 容器,如何 patch 更新?
  2. 如果要修改 app 容器的镜像,如何 patch 更新?

json patch([RFC 6902]())

新增容器:

kubectl patch de? M T y U ~ / W sployment/foo --type='json' -p 
'[{"op"; . , + 6:"add","path":"/spec/teC v 8 $ e mmplate/spec/container  X 3s/1","value":{"name":"nginx","image":"nginx:alpine"}}]'

修改已有容器 image:

kubectl patch deplow ] Nyment/foo --type='json' -p 
'[{"op":"replace","path":"/spec/template/spec/containers/0/image","vL k Value":"app-! O 7 _ e G a } Limage:v2"}]'

可以看到,在 jsoC ; % V N ! 0n patch 中我们要指定操作类型,比如 ad( M ^ @ 8 j qdm s g V s u N ? & 新增还是 replace 替换,另外在修改 containers 列时要通过元素序号来指定容器。

这样一来,如果我们 patch 之前这个对象已经被其他人修改了,那么我们的 p^ ! ` J t 8 d -atch` P C G J 有可能产生非预期的后果。比如在执行 app 容器镜像更新时c v `,我们指定的序号是 0,但此时 containers 列中第一个位置被插入了另一个容器,则更新的镜像就被错误地插入到这个非预期的容器中。

merge patch(RFC 7386)

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

kubectl patch deployment/foo --type='merge' -p 
'{"spec":{"template":{"h 1 ! P @spec":{"containers":[{"name":"app","image":"app-im: b i V T Iage:v2"},{"nw # _ ! [ame x @ W w h":E i i U"nginx","image":"nginx:alpline"}]}}}}'

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

不过n E |对于 labels/annotations 这些 map 类型的元素更新,mergez T 0 X B patch 是可以单独指定 key-value 操作的,相比于 json patch 方便一些,写起来( r W也更加直观:

kubectl patcN ? , G ! fhE 5 x 3 M n F 4 deployment/foo --type='merge' -p '{"metadata":{"labels":{"test-key":"foo"}}}'

strategK : ` M { Yic merge patch

这种 patch 策略并没有一C r Y w ` [ !个通用的 RFC 标准,@ # e n - T -而是 K8s 独有的,不过相比前两种而言却更为强大的。

我们先从 K8s 源码看起,在 K8s 原生资源的数据结构定义中额外定义了一些的策略注解。比如以下这个截取了 podSpe] n E / x Oc 中针对 containers 列表的定义,参考 Github:

// ...
// +patchMergeKey=name
// +patchStrategy=merge
Containers []Container `jL 2 _ X W i |son:"containers" patchStrategy:"merge" patchMergeKey:"name" protobuf:"b3 3 M 4 [ytes,2,rep,name=cont2 5 X 3 * y eainers"`

可以1 a p [看到其中有两个关键信息:patchS; x l f 7 a Atrategy:"merge" patch: ` Y CMergeKey:"name" 。这就代表了,containers 列表使用 strategic merge patch 策略更新时,会把下面每个元素中的 name 字段看作 key。

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

kubectl patch deployment/foo -p 
'{"spec":{"template":{"spec":{"containers":[{"name":"nginx"N i ] ) A,"image":"nginx:mainline"}]}}}}'

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

此外还要说明的是,目前 strategic 策略只能用于原生 K8s 资源以及 Aggregated API 方式的自定义资源,对于 CRD 定义的资源对象,是无法{ F m S 0 H O使用的。这很好理解,因为c 0 o h w U kube-apiserver 无法得知 CRD 资源的结构和 merge 策略。如果用 kubectl patch 命令更新` ~ q 1 { $一个 CR,则默认会采用 merge patch 的策略来操作。

kubectl 封装

了解完了 K8s 的基础更新机制,我们再次n 1 e @ f E 8回到最初的问题上。为什么用户修改 YAML 文件后无法直接调用 update 接口更新,却可以通过 kue $ Bbectl apply 命令更新呢?

其实 kubectl 为了给命令行用户提供良好的交互体感,设计了较为复杂的内部执行逻辑,诸如 apply、edit 这些常用操作其实背后并非对应一次简单的 update 请求5 & . z G Q * Q B。毕竟 update 是有版本控制的,如果发生了更新冲突对于普通用户并不友好。以下简略介绍下 kubectl 几种更新操作的逻辑,有兴趣可以看一下 kubectl 封装的源码。

apply

在使用默认参数执行 apply 时,触发的是 client-side aE M u e T App5 V Ply。kubectl 逻辑如下:

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

  • 如果查询结果不存在,kubectl 将本次用户提交的数据记录到对象 A 的 annotation 中(key 为 kubectl.kubernetes.io/last-applied-configuration),最后将对象 A提交给 K8s 创建;

  • 如果查询到 K8s 中已有这个资源,假设为对象 B:1. kubectl 尝试从对象 B 的 annotation 中取出 kubectl.kubernetes.io/last-applied-configuration 的值(对应了上一次 apply 提交的内容);2.kubectl 根据前一次 apply 的内容和本次 appl6 p ] V My 的内容计算出 diff(默认为 strategic merge patch 格式,如果非原生资源则采用 merge patch);3. 将 dm 9 Kiff 中添加本次的 ku| D :bem S *ctl.kube. Y t 0 O ^ grnetes.io/last-applied-configuration annotation,最后用 patch 请求提交给 K8s 做更新。

这里只是一个大致的流程梳理,真实的逻辑会更复杂一些,而从 K8s 1.14 之后也支持 5 9 { * * g g了 server-side apply,有兴趣的同学可以看一下源码实现。

edit

kubectl edit 逻辑上更简单一些。在用户执行命令之后,kubectl 从 K8s 中查到当前的资源对象,并打开一个命令行编辑器(默认用 vi)为用户提供编辑界面。

当用户修改完成、保存退出时,kubectl 并非直接把修改后的对象提交 update(避免 Conflict,如果用户修改的过程中资源对象又被更新)8 1 F ] V v,而是会把修改后的对象和初始拿到的对象计算 diff,最后将 difY | 5 nf 内容用 patch 请求提交给 K8s。

总结

看了上述的介绍,大家应该对 K8s 更新机制有了一个初步的了解了。接下来想一想,既然 K8s 提供了两种更新方式,我们在 r T不同G M U ^ a , W Y q的场景下怎么选择 update 或 patch 来使用呢?这里我们的建议是:

  • 如果要更新的字段只有我们自己会修改(比如我们有一B T Q 2些自定义标签,并写了 operator 来管F t )理),则使用 patch 是最简单的方式;
  • 如果要更新的字段可能会被其p # + Y w = | $他方修V V 0 Y ! ] `改(比如我们修改的 replicas 字段,可能有一些其他组件比如 HPA 也会做修改),则建议使用 update 来更新,避免出现互相覆盖。

最终我们的客户改为基I ( + 2 D于 get 到的对象做修改后提交 update,终于成功触发了 Advanced StatefulSet 的原地升级。此外,我们也欢迎和鼓励更多的同学参与到 OpenKruise 社区中,共同合作打造一款面向规模化场景、高B @ $ F B W P )性能的应用交付解决方案。(欢迎加入钉钉交流群:2D K 7 63330762)

课程推荐

为了更多开发者能够享受到 Serverless 带来的6 l N N N 5 | a红利,% i 9 O , % V W _这一次,我们集结了 10+ 位阿里巴巴 Serverless 领域C O X B & z ^ @技术专家,打造出最适合开发者入门的 Serverless 公开课,让你即学即用,轻松拥抱云计算的新范式o @ + ~ `——Serverless。

点击即可免费观看课程:http8 Q ws://developer.aliyund u q ` :.com/lO ( earning/roadmap/servere V Z J 5 |less

“阿里巴巴云原生关注微服务f t D S l、SerY 6 n :verless、容器、Se M Xervice Mesh 等技术领* / g 域、聚焦云原生流S , E n q e x行技术趋势、云原生大规模的落地实践,做最懂云原生开发者的公众号。”