DockOne微信分享(二六五):如何基于 OAM 编写一个扩展 Trait?

【编者的话】本文将首次为大家带来 OAM 体系中的实战演练介绍,先将讲解 OAM Workload 和 Trait 相关知识及它们的交互逻辑,接下来手把手教大家如何通过实现自定义 CRD 和 Controller 实现一个 OAM 的扩展 Trait。以及通过展示这个 Trait 的部署使用来更深入的理解 OAM 的运行机制。

背景

众所周知,在 OAM 中,一个应用描述可能会包含三个核心概念:

  • 第一个核心概念是组成应用程序的组件(Component),它可能是一个微服务、一个容器、一个虚拟机、一个数据库实例或者一个 Function 等各种各样的工作负载(Workload)的描述。
  • 第二个核心概念是每个组件所需要的运维特征(Trait)。例如:弹性伸缩、负载均衡、灰度发布、证书鉴权、监控等一系列运维能力的描述。它们对应用程序的运行至关重要,但在不同环境中其实现方式各不相同;
  • 第三个核心概念其实就是将前两个概念按照一定规则组合到一起,就会得到一个自包含的、具体的应用程序描述了(Application Configuration)。

你可以在《 深度解读!阿里统一应用管理架构升级的教训与实践 》一文中了解更多 OAM 的由来以及相关背景。

为什么需要 Trait?

实际上,在 Kubernetes 这样一个“一切皆对象”的项目当中,只要你稍微留心,就会发现 Kubernetes 提供的很多能力或者说 API 对象其实都属于 Trait(运维特征)的范畴。比如一个应用运行所需要的 HPA(HorizontalPodAutoScaler),暴露端口和访问信息所需要的 Service 和 Ingress,网络配置策略 NetworkPolicy 等等。此外,得益于 CRD 和 Operator 机制的成熟,Kubernetes 社区中也有越来越丰富的“应用运维能力”涌现出来,如具备多种发布策略的 Flagger 项目,具备强大的流量管理能力的 Istio 项目,这些 Kubernetes 插件提供的“能力”也都属于 OAM 中的 Traits。

所以说,OAM 中将 Kubernetes 对象划分为“组件”和“运维特征”的思想其实是非常自然的一件事情。简而言之,Kubernetes 中的对象,要么是用来描述“我要运行什么”,要么就是用来描述“怎么运维这个东西”。后者在 Kubernetes 中就是一个运维能力对象,也就是 OAM 规范中的 Trait。而更重要的是,在一个企业级应用管理系统当中,凡是对于属于“运维特征”的 API 对象,往往是需要做特殊的管理和编排的,这个 Trait 管理的细节我们会在后面的系列文章中做详细介绍。

另外一个问题是,为什么在 OAM 作为一个 Kubernetes 的应用定义规范,一定要求把这些“运维特征”独立定义在一个单独的对象(Trait)当中,而不是像有些项目那样直接定义为工作负载的 spec 的一部分呢?

这里的原因,一方面是出于对“关注点分离”的考虑,即系统本身对于运维侧的能力能够通过一个单独的 API 暴露出来而不是同业务研发侧关心的工作负载 spec 耦合在一起。而另一方面则是出于实际运维场景和稳定性的需要。举一个简单的例子,假设我们在工作负载的 spec 中同时定义了这个组件的自动扩缩容策略,即“最多 10 个实例,最少 3 个实例”,如下所示:

那么假设后续系统容量发生了变化,我需要把扩容策略的上限减小到 5 个实例,该怎么做呢?当然是把 maxScale 修改为 5 了。然而这时候问题出现了:这个 maxScale 的信息本身是属于 MyWorkload 的一部分的,所以这个对扩容策略的修改,就会被系统识别为一次“变更”。而这次变更,就会被系统捕捉到产生一条新的变更记录,甚至如果你的系统是自动发布流水线的话,它还有可能触发一次线上发布。在生产环境中,这种原因不明的变更和发布可能会引发很多不可预知的故障,比如:业务容器被无缘无故的重启甚至带来预期外的服务中断。

所以说,OAM 规范建议将对运维能力的配置定义在一个单独的 API 对象中(Trait)而不是作为工作负载的一部分(无论是 spec 还是 annotation),从而避免我们对运维能力的配置被系统识别为一次变更。更重要的是,在这种工作负载与运维能力解耦情况下,运维能力的 API 对象还可以锁定工作负载的某个固定版本(Revision)上,相当于在运维侧引入了人工审批环节,从根本上避免错误的变更引发不可预知的故障。

到这里,相信你已经对 Trait 背后的设计思想有了更加深入的了解了。那么我们现在来看下一些实际的、通过 OAM 规范定义应用的案例吧。

案例 1:一个由 Deployment + Flagger Canary 组成的应用

我们首先来定义一个应用,这个应用的工作负载是 Deployment,需要的运维能力是 Flagger 项目提供的金丝雀发布策略(Canary)。

这个应用通过 OAM 规范的定义方法非常简单,我们直接把 Flagger Canary 的 CR 定义为一个 OAM Trait ,然后把它绑定给一个 Deployment 工作负载即可。如下所示:

apiVersion: core.oam.dev/v1alpha2

kind: Component

metadata:

name: frontend

spec:

workload:

apiVersion: apps/v1

kind: Deployment

spec:

  containers:

    - name: wordpress

      image: wordpress:4.6.1-apache          

      ports:

        - containerPort: 80

          name: wordpress

---

apiVersion: core.oam.dev/v1alpha2

kind: ApplicationConfiguration

metadata:

name: example-appconfig

spec:

components:

- componentName: frontend

  traits:

    - trait:

       apiVersion: flagger.app/v1beta1

       kind: Canary

       spec:

         targetRef:

           apiVersion: apps/v1

           kind: Deployment

           name: wordpress

         progressDeadlineSeconds: 60

         analysis:

           interval: 1m

           threshold: 5

           ...

这样,这个 ApplicationConfiguration 就成为了一个由“Deployment(工作负载) + Canary (运维能力)”组成的标准的、符合 OAM 规范的应用定义对象了,你可以把它交给任何一个支持 OAM 的 Kubernetes 集群部署起来。

案例 2: 一个由 Deployment + 自定义 Ingress 组成的应用

平时我们创建一个 Kubernetes 应用,如果需要外部访问,通常会创建一个 Ingress。但是,Kubernetes 对 Ingress 的设计是比较“奇葩”的,你光有一个 Ingress 不够,必须创建一个 Kubernetes Service 才能够让这个 Ingress 生效。

所以在这里,大家一定会有一个自然而然的想法,我能不能自己在 Kubernetes 里做一个 Ingress 能力,只要用户提交一个 Ingress YAML,系统就自动创建出来一个 Ingress 和对应的 Service 对象呢?

答案当然是可以的!而且显然你不需要去改写 Kubernetes Ingress Controller,只需要使用 CRD 基于现有 Ingress 和 Service 对象做一个封装,根据用户的提交自动生成这些 API 资源即可。这种基于现有 Kubernetes API 对象封装成一个更加用户友好的上层 API 对象的过程,就叫做“构建上层抽象”。

我们来看一下这个自定义 Ingress (我们把它取名叫做 IngressTrait)的实际使用效果:

apiVersion: core.oam.dev/v1alpha2

kind: Component

metadata:

name: example-deploy

spec:

workload:

apiVersion: apps/v1

kind: Deployment

metadata:

  name: web

spec:

  selector:

    matchLabels:

      app: test

  template:

    metadata:

      labels:

        app: test

    spec:

      containers:

        - name: nginx

          image: nginx:1.17

          ports:

            - containerPort: 80

              name: web

---                  

apiVersion: core.oam.dev/v1alpha2

kind: ApplicationConfiguration

metadata:

name: example-appconfig

spec:

components:

- componentName: example-deploy

  traits:

    - trait:

        apiVersion: extended.oam.dev/v1beta2

        kind: IngressTrait

        metadata:

          name: example-ingress-trait

        spec:

          rules:

            - host: nginx.oam.com

              paths:

                - path: /

                  backend:

                    serviceName: deploy-test

                    servicePort: 8080

上面这个 ApplicationConfiguration YAML 文件,就是一个由 Deployment 工作负载和自定义 Ingress 运维特征组成的、符合 OAM 规范的应用定义了。只要你把它提交给任何一个支持 OAM 的 Kubernetes 集群,它就能够自动创建出来你这个应用运行所需要的 Deployment,Ingress,还有 Service 对象了。更重要的是,用户再也不需要纠结为什么还需要给 Ingress 创建 Service 的问题:只要这个 Service 不存在,那么 IngressTrait 会自动把这个 Service 创建出来。

不知道你有没有发现,上面这个 YAML 文件中,其实并没有显示的给出这个 Service 的详细信息,那么这个 IngressTrait CRD 对应的 Controller 是如何生成 Service 对象的呢?

实际上,当你提交一个 ApplicationConfiguration 对象给 Kubernetes 之后,OAM 插件(OAM Runtime)就会为你自动创建这个对象里定义的 Workload(工作负载)和 Trait(运维特征)对应的 API 对象。但与此同时,OAM Runtime 在生成 Trait 对象的时候会做一个小小的处理,它会根据具体情况来在 Trait 对象上加上一个字段叫做 workloadRef ,所以上例中的 IngressTrait 实际的 CR 会如下所示:

apiVersion: extended.oam.dev/v1alpha2

kind: IngressTrait

metadata:

name: example-ingress-trait

spec:

workloadRef:

apiVersion: apps/v1

kind: Deployment

name: web

rules:

- host: nginx.oam.com

  paths:

    - path: /

      backend:

      serviceName: deploy-test

      servicePort: 8080 

这个小秘密,就是为什么你自己编写的 IngressTrait Controller 可以在用户不显式定义 Service 信息(比如: Label、Port 等)的情况下,生成出该 Service 对象的关键所在:上述这些信息实际上都是 OAM Runtime 通过 workloadRef 字段反查 Component 中的 spec.workload 字段获取、拼装得到的。当然,这也意味着这个 Service 对象的类型是固定的,都是 ClusterIP 类型(因为这完全是一个内部使用的 Service)。

从这个角度来说,OAM 不仅仅是 Kubernetes 上的应用定义规范,也为你提供了一个构建上层抽象的标准化框架。

IngressTrait 的实现细节

综上所述,IngressTrait 的实现是一个标准的 Kubernetes CRD + Controller 的编写过程,我是使用 kubebuilder 来做的,大家也可以使用自己喜欢的框架。这个项目的地址在: https://github.com/oam-dev/cat … trait

构建项目

我们使用 kubebuilder init 命令初始化一个新项目,并用 kubebuilder create api 命令创建一个新的API,注意我们不需要创建 webhook。由此两步我们便可成功得到 CRD 和 Controller 的模板:

详细步骤参考文档: https://book.kubebuilder.io/quick-start.html

编写 Controller 代码

kubebuilder 已经为我们生成了较为完整的框架,之后我们主要编辑 ingresstrait_types.go 和 ingresstrait_controller.go 两个文件,来自定义 CRD 和 Controller 逻辑。

类型定义

具体代码可以查看 ingresstrait_types.go 文件,其中最核心的是 IngressTraitSpec 结构体中定义,其中WorkloadReference就是上文提到 workloadRef 辅助字段。

控制器逻辑

Trait 的控制逻辑都在 Controller 的 Reconcile 函数中实现,具体代码可查看 ingresstrait_controller.go 文件,主要分为 4 个步骤。

  1. 获取 Trait 对象,通过传入的 req.NamespacedName 获取需要调谐(Reconcile)的 Trait 对象。
  2. 根据获取到的 Trait 对象,去获取其引用的 Workload 对象。
  3. 获取目标资源对象,首先需要确定 Workload 对象的类型,若是自定义 API 类型(比如用户自己在 Deployment 上又做了自己的封装),则其子资源才是我们需要的目标资源对象;若是 Kubernetes 内置的 API 类型(比如 Deployment),则 Workload 对象就是我们需要的目标资源对象。具体 DetermineWorkloadType 函数:此处示例是根据 APIVersion 来做判断,若是自定义的 OAM workload 则用 util.FetchWorkloadDefinition 去获取 workload 的子资源并返回;若是 Kubernetes CR 则直接将 workload 作为返回值即可。
  4. 执行 Trait 逻辑,IngressTrait 的逻辑是:若目标资源对象没有 Service,则为其 同时创建 Kubernetes Ingress 和 Service 对象,其中 Service 对象的信息根据 Workload 对象和 IngressTrait 对象的 spec 生成出来;若目标资源对象已有 Service,则只为其创建一个 Ingress 对象即可。

作用于其他类型的工作负载

需要注意的是,我们编写的这个 IngressTrait 还是非常通用的,它不仅可以作用于 Deployment,也可以作用于 StatefulSet,还可以作用于你自己通过 CRD + Operator 定义的各种 Workload。这里面也有很多有意思的细节,比如当作用于 StatefulSet 的时候,IngressTrait Controller 会自动获取 StatefulSet 中的 spec.template.spec.container.ports 字段和 spec.serviceName 字段为其创建 Service 和 Ingress。 关于上述 IngressTrait 详细的使用方法,大家可以参考这个样例库: https://github.com/oam-dev/cat … mples

总结

在 OAM 规范下,定义一个应用运行所需的运维特征是非常简单的,而基于 Kubernetes 开发一个运维能力的上层抽象,也变得有章可循,并且可以立即被整个 OAM 生态复用起来。社区近期还在规划的标准化 Trait 包括:TrafficManagement(基于 Istio 的流量治理),LogCollector(用户友好的日志收集 Spec)、MetricCollector( 用户友好的 Metric 收集 Spec)、Rollout(Kubernetes 原生的灰度发布能力)、Let’sEncrypt(证书管理)等等,具体细节大家可以持续关注 OAM Workload/Trait 仓库 中的 Issue 和 PR。

Q&A

Q:webhook 什么情况下使用?

A:在实现层面需要做拦截修改(注入)、验证的时候推荐使用。

Q:kubebuilder 的 webhook 最佳实践有吗?

A:kubebuilder 自带的 webhook 比较难用,可以考虑直接编写 admission webhook controller。

Q:在自定义控制器的情况下,容器原地重启核心原理是什么?

A:这个可以参考下 OpenKruise 项目 https://github.com/openkruise/kruise

以上内容根据2020年6月23日晚微信群分享内容整理。 分享人 钱王骞,浙江大学软件学院研究生,杭州谐云科技实习生,参与 OAM 社区相关工作 。DockOne每周都会组织定向的技术分享,欢迎感兴趣的同学加微信:liyingjiesf,进群参与,您有想听的话题或者想分享的话题都可以给我们留言。