**

原链接: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。
2020-07-07_113238.png
上面这个图需要做下修改:

  1. 这个图得修改一下,Component 与 revision 对象之间的线不能省略, revision 对象和 workload 对象一一对应的关系也要画出来。
    1. 注意:新的设计中 Component 其实还是 workload 对象的模板。V1alpha1 的问题是没有 revision 对象,所以你必须创建多个 Component 来跟 workload 对象一一对应。

      示例一:组件变更自动生成“版本”

我们通过实际的例子来说明一下这个机制。假如有如下组件:

  1. # sample_component.yaml
  2. apiVersion: core.oam.dev/v1alpha2
  3. kind: Component
  4. metadata:
  5. name: foo
  6. spec:
  7. workload:
  8. apiVersion: apps/v1
  9. kind: Deployment
  10. spec:
  11. containers:
  12. - name: my-workload
  13. 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 会:

  1. 生成版本对象;
  2. 根据版本对象生成对应的 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

通过对比我们可以发现:第一种模式下,每次升级组件时,组件的工作负载对象都会自动切换到最新版本;第二种模式下,每次升级组件时,组件的工作负载对象会一直锁定在运维侧指定的版本。
2020-07-07_113305.png

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 ,就会开始执行包括回收旧负载对象在内的各项功能任务

整个过程中的各种要素间的关系如下图所示。
2020-07-07_113316.png
至此,组件、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 为平台接入不同的发布功能,既可以从零开始打造新功能,也可以直接引入 IstioFlagger 等项目已有的丰富功能,而 Trait 本身还具有兼容不同类型 Workload 的能力,所以,OAM 框架可以赋予任意 Workload 版本化与灰度发布能力!

在OAM版本更迭的过程中,新的组件版本机制最大限度的减小了对已有OAM规范与运行时的影响,oam-kubernetes-runtime 已经遵循新机制给出了相关的运行时实现。基于此,Trait 开发者们可以非常方便的实现各种发布功能。除了简单的灰度发布,OAM社区未来将推出更多更强大的 Trait,比如 Canary Rollout 等,以满足云原生应用复杂的交付场景需求,敬请期待。