**
原链接:https://www.yuque.com/docs/share/db4e0802-1d0f-49e9-8bec-a179c65656a5?# 《OAM 深入解读:OAM 框架赋予任意 Workload 版本化与灰度发布能力》
组件版本与灰度能力
众所周知,对于实际的生产环境而言,系统的稳定性是一切的前提。无论是一个完整应用,还是一个服务组件,每一次升级发布都需要保证整体系统的稳定,而灰度发布正是这样一种保证升级过程平滑稳定的必要方式。它可以帮助用户尽早发现升级引入的问题,同时将影响面缩到最小,最终保证系统升级的过程是健康平稳的。而“版本化”则是一个系统可以进行发布的必要条件,灰度能力本质上就是对系统中不同组件版本的管控能力。
举个例子,Kubernetes 的 Deployment 工作负载,自带有 Pod 粒度的 Rolling Update 策略,它可以实现当 Deployment 需要更新时,逐次关停一定数量旧版本 Pod 的同时创建对应数量的新版本,最终使得新版本的 Pod 完全取代旧版本的 Pod 完成发布。不过,在实际使用的过程中,社区普遍认为 Deployment 的这个 Rolling Update 局限性还是很大的,其中一个最主要的问题是无法同集群中的流量管理能力(比如 Service Mesh)集成和配合。这就导致应用平台没办法细粒度的控制升级过程中的流量分配,用户请求既可能被发到旧 Pod 也可能被发到新 Pod。
在生产环境中,这种缺少细粒度流量控制能力的灰度发布系统是行不通的。仅以阿里巴巴为例,在阿里的 Kubernetes 应用平台中,任何一个应用的发布过程中的绝大多数监控和健康数据,都是建立在细粒度流量管理能力的基础上。发布过程中,运维人员关心的吞吐量、CPU资源消耗、延迟等性能指标,产品人员关心的点击率、PV、UV等业务指标,都依赖与系统对流量“标签”的感知和操作。此外,流量的去向往往也意味着风险的去向,假如发布过程中的流量不能随着实例的变化随时进行细粒度的控制,那升级过程中的风险就会实质上进入“失控”状态
这也是为什么,在实际的生产场景中,绝大多数企业都不会使用 Deployment 自带的 Rolling Update 能力,而是选择自研另外一个控制器,去同时控制两个 Deployment 对应“新”、“旧”两个版本,然后对每个 Deployment 分别进行实例数和流量的控制,说白了就是把 Deployment 当做 ReplicaSet 使用。而在更复杂的情况下,应用的工作负载并非只有新旧两个版本存在,还会有多个版本同时存在,版本切换的粒度不只局限在全量推进或者回滚,流量路由也不能只在两个版本间分配,而应该是根据实际情况,在多个版本之间按需灵活切换或分配。这种情况下,就需要操纵多个工作负载对象来完成精细化的发布流程了。
而在开源社区中,随着Service Mesh 技术的逐渐普及,像 Istio 等项目已经能提供生产环境需要的强大的流量治理能力,比如精准的流量路由、帮助系统进行自动化升级与回滚,甚至进行跨集群和设置自动告警等等。这也是为什么,在今天的开源社区中,大多数的自动发布项目都是跟阿里一样采用同时操纵多个版本的 Deployment 而不是依靠它自己的 Rollout 策略来进行发布的。比如 Weaveworks 的 Flagger 项目。
不难理解,像这样的发布与流量系统联动的工具要能够正确工作,就必须能够处理“版本”这个概念。比如,当一个变更发生时,应用平台的 Rollout Controller 必须能够知道变更发生前工作负载的版本(v1)和它对应的工作负载对象,以及变更发生后的版本(v2)和它对应的工作负载对象,这样,Rollout Controller 才能够操作 Istio 等工具对这些版本直接进行流量分配和管理。
这里的问题就来了:在 Kubernetes 中,应用工作负载的所有变更,都会产生历史版本吗?它们被记录在哪里?我的发布控制器能不能通过这些记录,找到每个版本对应的工作负载,然后对它们进行操纵呢?
实际上,Kubernetes 自身并不会为你提供工作负载基本的版本管理能力。而像这种自动化的工作负载的版本管理与透出,正是 Open Application Model(OAM)为 Kubernetes 带来的一个核心功能。**
OAM 中的组件版本机制
作为 Kubernetes 原生的应用模型,OAM 默认就提供自动化的组件版本记录与透出机制,从而帮助平台开发者轻松的构建发布系统。需要注意的是,OAM 本身不提供灰度或者发布功能,它的职责是自动化的记录下组件任何一次变更的版本,并且通过标准化的方式把这些版本暴露给发布系统,确保发布系统能够根据版本号直接找到对应的实际工作负载对象。
核心设计
组件(Component)作为OAM的核心概念,承载着描述工作负载(Workload)信息的重要职能。而 OAM 组件版本管理机制的核心设计,也正是利用了组件与工作负载之间的这层映射关系来实现的。具体来说,组件的每一次创建和变更,都会自动触发系统生成一个新的“版本”,而真正的工作负载对象,则是同这些“版本”一一对应的。在实现上,OAM Kubernetes 插件会把这个版本记录在 K8s 内置的 ControllerRevision 对象中,每个 ControllerRevision 的 .metadata.name
由运行时根据组件名称自动生成。在实际系统当中,这个 Name 字段就是一个组件版本的唯一标识,我们将它称为 RevisionName。
上面这个图需要做下修改:
- 这个图得修改一下,Component 与 revision 对象之间的线不能省略, revision 对象和 workload 对象一一对应的关系也要画出来。
我们通过实际的例子来说明一下这个机制。假如有如下组件:
# sample_component.yaml
apiVersion: core.oam.dev/v1alpha2
kind: Component
metadata:
name: foo
spec:
workload:
apiVersion: apps/v1
kind: Deployment
spec:
containers:
- name: my-workload
image: my/foo:1.0
当我们通过 kubectl 创建它时,系统就会自动生成它的第一个版本对象(用 ControllerRevision 来承载),此处的 “foo-c8bb659c5” 就是该版本的名字,也是版本对象(ControllerRevision 对象)的名字。
$ kubectl apply -f sample_component.yaml
$ kubectl get controllerrevisions
NAMESPACE NAME CONTROLLER REVISION AGE
default foo-c8bb659c5 core.oam.dev/component 1 2d15h
所以,这时候如果我们查看名为 foo-c8bb659c5 的 ControllerRevision 对象,就会看到这个版本对应的版本号(revision)与相应的组件信息(data):
$ kubectl get controllerrevisions foo-c8bb659c5 -o yaml
apiVersion: apps/v1
kind: ControllerRevision
metadata:
name: foo-c8bb659c5 # 以组件名作为前缀的名称标识
revision: 1 # 版本号
data: # 该版本组件的定义信息
workload:
apiVersion: core.oam.dev/v1alpha2
kind: ContainerizedWorkload
spec:
containers:
- name: my-workload
image: my/foo:1.0
接下来,如果研发人员修改了组件描述文件,就会产生一个新的版本:
# sample_component.yaml
apiVersion: core.oam.dev/v1alpha2
kind: Component
metadata:
name: foo
spec:
workload:
apiVersion: core.oam.dev/v1alpha2
kind: ContainerizedWorkload
spec:
containers:
- name: my-workload
image: my/foo:2.0 # changed from 1.0 to 2.0
$ kubectl apply -f sample_component.yaml
$ kubectl get controllerrevisions
NAMESPACE NAME CONTROLLER REVISION AGE
default foo-c8bb659c5 core.oam.dev/component 1 2d15h
default foo-a75588698 core.oam.dev/component 2 2d14h
这个新版本的信息,依然会由名叫 foo-a75588698 的 ControllerRevision 对象承载:
$ kubectl get controllerrevisions foo-a75588698 -o yaml
apiVersion: apps/v1
kind: ControllerRevision
metadata:
name: foo-a75588698 # 以组件名作为前缀的名称标识
revision: 2 # 版本号
data: # 该版本组件的定义信息
workload:
apiVersion: core.oam.dev/v1alpha2
kind: ContainerizedWorkload
spec:
containers:
- name: my-workload
image: my/foo:2.0
我们后面会专门演示如何在一个运维能力(Trait)的实现中使用这个“版本”进行各种运维操作。
示例二:在运维侧进行“版本锁定”
通过上面的例子,我们可以看到当一个 Component(组件)变更时,Kubernetes 会:
- 生成版本对象;
- 根据版本对象生成对应的 Workload(工作负载)对象,比如最新版本的 Deployment 对象。
但是在实际的生产环境中,我们的应用平台很可能并不允许变更发生时第二步被无条件的触发,而是需要由运维人员或者审批人员审批通过之后,才执行第二步,生成对应的工作负载对象,进而触发系统执行指定的发布策略。这种强管控的发布机制很多时候被称作“人工审批”。OAM 中对于这种机制是原生支持的,具体功能被称作“版本锁定”。
具体来说,OAM 的 ApplicationConfiguration 中有一个字段 revisionName
,该字段与 componentName
字段互斥的且必须填写其中的一个。其具体区别如下:
componentName
:当 Component 发生变更时,系统即会立刻生成最新版本的工作负载对象;revisionName
:这时候即使 Component 发生变更,系统依然会锁定在该revisionName
指定的版本上,直到运维人员手动修改revisionName
的值为最新的版本名称,新版本的工作负载对象才会被创建。
// TODO:我改到这里了,后面的你们来改吧,改动比较大。
// TODO: 下面这个示例需要重新组织。这里明显需要对比着来说(先创建第一个,看结果,再创建第二个看结果,对比有啥不同)。而不是一下部署两个 AppConfig,然后看到两个 Workload,这起不到任何对比的效果。
// TODO: 使用 Deployment 做例子,不要用 ContainerizedWorkload,见文章最后面的 Comment。_
接下来,我们通过2个示例看一下2种模式的不同之处
模式1:锁定最新版本
我们在 ApplicationConfiguration 中添加一个组件,并将 componentName
字段填写为组件名称 foo
# sample_appconfig.yaml
apiVersion: core.oam.dev/v1alpha2
kind: ApplicationConfiguration
metadata:
name: example-appconfig-1
spec:
components:
- componentName: foo # always use latest version
部署之后会得到 Deployments 的工作负载对象,我们查看对象中的 .spec.containters.image
字段,可以发现它对应 revision 2 版本的 image 值,也就是当前的最新版本
$ kubectl apply -f sample_appconfig.yaml
------------------------------------------
$ kubectl get deployments
NAME AGE
foo 20s
------------------------------------------
$ kubectl get deployments foo -o json | jq '.spec.template.spec.containers|.[0].image'
"my/foo:2.0" # image of revision 2
我们再次给组件升级,将 image 改为 my/foo:3.0
,部署生效后,再次查看工作负载对象中的 image 字段,可以发现此时该值变为了 my/foo:3.0
,依然是当前的最新版本
$ kubectl get deployments foo -o json | jq '.spec.template.spec.containers|.[0].image'
"my/foo:3.0" # image of revision 3 (latest version)
模式2:锁定指定版本
我们在 ApplicationConfiguration 中添加一个组件,并将 revisionName
字段填写为 foo-c8bb659c5
,这是该组件 revision 1 版本的 revisionName, 可以通过 $ kubectl get controllerrevisions
查看到
# sample_appconfig.yaml
apiVersion: core.oam.dev/v1alpha2
kind: ApplicationConfiguration
metadata:
name: example-appconfig-1
spec:
components:
- revisionName: foo-c8bb659c5 # always use this version
部署之后会得到 Deployments 的工作负载对象,我们查看对象中的 .spec.containters.image
字段,可以发现该值为 my/foo:1.0
,也就是指定的 foo-c8bb659c5
版本中该字段的值;另外值得注意的是,此时 deployments 的名称不再是组件名称 foo,而是 revisionName foo-c8bb659c5
, 也就是说,在锁定指定版本的情况下,工作负载对象的名称是 revisionName
。
$ kubectl apply -f sample_appconfig.yaml
------------------------------------------
$ kubectl get deployments
NAME AGE
foo-c8bb659c5 20s
------------------------------------------
$ kubectl get deployments foo-c8bb659c5 -o json | jq '.spec.template.spec.containers|.[0].image'
"my/foo:1.0" # image of revision 1
现在我们再次给组件升级,将 image 改为 my/foo:4.0
,部署生效后,再次查看工作负载对象中的 image 字段,可以发现此时该值依然为 my/foo:1.0
,即锁定在指定版本。
$ kubectl get deployments
NAME AGE
foo-c8bb659c5 30s
------------------------------------------
$ kubectl get deployments foo-c8bb659c5 -o json | jq '.spec.template.spec.containers|.[0].image'
"my/foo:1.0" # image of revision 1
通过对比我们可以发现:第一种模式下,每次升级组件时,组件的工作负载对象都会自动切换到最新版本;第二种模式下,每次升级组件时,组件的工作负载对象会一直锁定在运维侧指定的版本。
3.3 Trait 如何与组件版本化进行交互
当我们要求工作负载对象始终使用最新版本的组件时,应该考虑这样一个问题:工作负载对象是以何种形式实现版本切换的?OAM运行时提供了两种方式,一种是创建新工作负载,另一种是直接更新旧工作负载。区别在于,前者允许新旧不同版本的多个工作负载对象同时存在,而后者将始终只有一个版本的工作负载存在(即不具备灰度能力)。采用哪一种方式将取决于添加给组件的 Trait 的情况。
根据设计,TraitDefinition 中引入了一个新的字段 .spec.revisionEnabled (type:bool)
,作用如下:
- 在 ApplicationConfiguration 的组件配置中,假如组件添加了任意一个
.spec.revisionEnabled: true
的 trait,那么OAM运行时将对该组件产生的工作负载对象采用“创建新负载对象”的方式进行版本切换,否则采用“更新旧负载对象”的方式。
借助新字段,apiVersion: core.oam.dev/v1alpha2 kind: TraitDefinition metadata: name: simplerollouttraits.extend.oam.dev spec: revisionEnabled: true # determine how update workload appliesToWorkloads: - core.oam.dev/v1alpha2.ContainerizedWorkload - deployments.apps definitionRef: name: simplerollouttraits.extend.oam.dev
revisionEnabled:true
的Trait 就可以“激活”组件“创建新工作负载对象”的升级策略,我们也可以理解为,Trait 向 OAM运行时传递了这样的信息:“我将要提供和组件版本化有关的功能,请在组件升级时创建新的工作负载对象(供我使用),并且,把回收旧负载对象的工作留给我处理。”接下来,当组件发生更新时,OAM运行时就会先创建一个 ControllerRevision 来保存升级信息(包括 RevisionName、版本序号以及新版本组件的信息),然后再创建新的工作负载对象。值得注意的是,OAM 运行时虽然创建了新的工作负载对象,但是不会负责回收旧的对象,必须由运维侧提供相关 Trait 来回收。
在这个过程中,OAM运行时有两个操作非常重要:
- 使用新创建的 ControllerRevision 的
.metadata.name
(即 RevisionName)作为新的工作负载对象的.metadata.name
的值,参考源代码 - 创建完新的工作负载对象后,将该组件的所有 Trait “指向”新的工作负载对象,也就是将 Trait 的
.spec.workloadRef.Name
等于新负载对象的metadata.name
(即 RevisionName)。这个过程就会触发相关 Trait 控制器的调谐操作,假如是和发布功能有关的 Trait ,就会开始执行包括回收旧负载对象在内的各项功能任务
整个过程中的各种要素间的关系如下图所示。
至此,组件、ControllerRevision、工作负载对象以及 Trait 就完美的结合在了一起,共同组成了一套可以搭建灰度系统的框架,基于这套框架,平台开发者可以创建或接入各种 Trait,从而为应用管理平台添加丰富多样的发布功能。接下来我们通过一个实例,进一步了解 Trait 是如何结合这套框架为平台提供发布功能。**
4. 实例:Simple Rollout Trait (灰度发布)
4.1 功能描述
SimpleRolloutTrait 将为组件提供灰度发布能力,参数 batch
表示创建新实例时每个灰度轮次增加的副本数,参数 maxUnavailable
表示移除旧工作负载对象时每轮逐次关停的副本数。为简化实现,SimpleRolloutTrait 直接增加了副本数 replica 的设置,实际上这个字段可以直接通过 Workload 获取。
apiVersion: core.oam.dev/v1alpha2
kind: ApplicationConfiguration
metadata:
name: example-appconfig-rollout
spec:
components:
- componentName: example-component
traits:
- trait:
apiVersion: extend.oam.dev/v1alpha2
kind: SimpleRolloutTrait
metadata:
name: example-rollout-trait
spec:
replica: 6
maxUnavailable: 2
batch: 2
当组件升级时,SimpleRolloutTrait 获取新旧两个版本的工作负载对象,并逐次增加新实例底层资源的副本数 .spec.replica
,同时逐次减少旧实例底层资源的副本数;当新实例的副本数增加到期望值,并且旧实例的副本数缩减为0时,彻底移除旧实例,灰度发布完成。
4.2 实现细节
4.2.1 CRD定义与TraitDefinition
除了上述 .spec
的3个参数的字段外,我们还需要 .spec.workloadRef
字段,它是OAM trait 与框架结合的字段,由 OAM运行时负责填写该 trait 指向的工作负载对象信息,也是 trait 与组件建立联系的关键。
在 SimpleRolloutTraitStatus 中,我们定义 .status.currentWorkloadRef
字段用来记录灰度发布的过程中当前实际工作负载的实例信息,当灰度发布完成时,该字段将与 .spec.workloadRef
的值一致,表示旧实例已被新实例取代。为了提供一定的可观测性,定义 .status.rolloutHistory
用来保存灰度发布的历史记录。
最后,我们在 TraitDefinition 中将 .spec.revisionEnabled
字段设置为 true,这样运行时就会为添加了 SimpleRolloutTrait 的组件开启“始终创建新实例”模式。
4.2.2 控制器逻辑
SimpleRolloutTrait 控制器调谐操作的触发起于组件升级操作,组件升级由 OAM运行时负责,如前文所述,运行时会将该组件的所有 Trait 指向新创建的工作负载对象,也就是更新了 Trait 的 .spec.workloadRef
字段,SimpleRolloutTrait 控制器会监测到这个更新操作,从而触发调谐操作。
当进行组件升级时,SimpleRolloutTrait通过 .spec.workloadRef
获取新的工作负载对象,通过 .status.currentWorkloadRef
获取旧的工作负载对象,然后执行以下操作:
- 获取新旧工作负载底层资源,逐次增加/减少新旧实例资源的副本数
- 当新实例副本数达到期望值,并且旧实例副本数缩减为0时,删除旧实例
- 旧实例删除完成后,将
.status.currentWorkloadRef
由旧实例改写为新实例 - 获取新旧版本组件的 ControllerRevision,取出版本序号与组件定义信息,存入 SimpleRolloutTrait的
.status.rolloutHistory。
调谐完成。
这里着重提一下如何 Trait 控制器获取新旧版本的 ControllerRevision。如前文所述,组件升级时,运行时会创建一个 ControllerRevision,它的 .metadata.name
就是 RevisionName,而每个工作负载对象的 .metadata.name
也被运行时设置为了 RevisionName,所以 SimpleRolloutTrait 控制器只要用新旧实例的 .metadata.name
就可以获取到对应的 ControllerRevision 。
4.3 SimpleRolloutTrait 的可拓展性
得益于 OAM 的高可拓展性设计,任意 Trait 都有能力去兼容不同类型的工作负载,目前 SimpleRolloutTrait 支持的工作负载有 containerizedworkloads.core.oam.dev 与 deployments.apps。在SimpleRolloutTrait 控制器的调谐逻辑中,只要能够获取到更多类型的工作负载及其底层资源,并通过控制其 .spec.replicas
实现副本数的增减,就可以为更多类型的工作负载的组件提供灰度发布能力。SimpleRolloutTrait 源代码及更详细的示例,请访问 oam-dev/catalog/traits/simplerollouttrait。
5. 总结
全新的组件版本管理机制是 OAM 的又一重要特性,进一步增强了 OAM 的应用层抽象能力。不同于 Kubernetes 简易的原生灰度能力,OAM 的组件版本机制是在应用抽象层提供了高度可拓展的灰度能力。它的强大之处在于,用户可以通过创建各种各样的 Trait 为平台接入不同的发布功能,既可以从零开始打造新功能,也可以直接引入 Istio、Flagger 等项目已有的丰富功能,而 Trait 本身还具有兼容不同类型 Workload 的能力,所以,OAM 框架可以赋予任意 Workload 版本化与灰度发布能力!
在OAM版本更迭的过程中,新的组件版本机制最大限度的减小了对已有OAM规范与运行时的影响,oam-kubernetes-runtime 已经遵循新机制给出了相关的运行时实现。基于此,Trait 开发者们可以非常方便的实现各种发布功能。除了简单的灰度发布,OAM社区未来将推出更多更强大的 Trait,比如 Canary Rollout 等,以满足云原生应用复杂的交付场景需求,敬请期待。