👋 再见 Helm,你好 CUE

date
Jun 22, 2021
slug
cue-vs-helm.html
status
Published
tags
tech
k8s
back-pressure
helm
summary
长江后浪推前浪
type
Post

什么是 CUE

CUE 是一种开源数据验证语言和推理引擎,其根源在于逻辑编程
说实话,我一开始也没看懂这句描述。我们先来看个小例子:
Data
Schema
CUE
moscow: {
  name:    "Moscow"
  pop:     11.92M
  capital: true
}
municipality: {
  name:    string
  pop:     int
  capital: bool
}
largeCapital: {
  name:    string
  pop:     >5M
  capital: true
}
在定义 JSON 数据时,我们通常会将 Data 和 Schema 分开处理。而 CUE 则将二者结合在一起,既可以指定数据字段类型,也可以直接填写具体值,也就是 CUE 并不会特意地区分 “类型” 和 “值”, string"Moscow" 都会被当作值 ,但是二者之间有包含的先后顺序,在这里 "Moscow" 可以背归纳于 string 的一种,那么 string 在格(lattice)中会优先于 "Moscow"
更多关于 CUE 的介绍,可以通过 官方文档定义 了解过多,这里就不展开了。

使用 CUE 做模版渲染

CUE 有很多很酷的使用场景,而先让我们关注其中的配置文件渲染能力。
 
我们在这里借用 KubeVela 文档中的例子
template: |
  parameter: {
      domain: string
      http: [string]: int
  }

  // trait template can have multiple outputs in one trait
  outputs: service: {
      apiVersion: "v1"
      kind:       "Service"
      spec: {
          selector:
              app: context.name
          ports: [
              for k, v in parameter.http {
                  port:       v
                  targetPort: v
              },
          ]
      }
  }

  outputs: ingress: {
      apiVersion: "networking.k8s.io/v1beta1"
      kind:       "Ingress"
      metadata:
          name: context.name
      spec: {
          rules: [{
              host: parameter.domain
              http: {
                  paths: [
                      for k, v in parameter.http {
                          path: k
                          backend: {
                              serviceName: context.name
                              servicePort: v
                          }
                      },
                  ]
              }
          }]
      }
  }
可以看到,只需要传入 parameter ,就可以得到包含 ServiceIngressoutput
这样的写法立刻让我们想到了一个类似的工具—— Helm,作为较早的 CNCF 毕业项目,Helm 已经慢慢演进成在 k8s 配置定义领域的事实意义上的工业标准。那么相较于 Helm,用 CUE 来写配置文件渲染,又有什么异同呢?

CUE vs Helm

最直观的感受就是,在模版编写上 CUE 比 Helm 流畅太多了。主要的原因二者在最初的设计思路差异,Helm 借助的是 Go 和 Spring 的纯文本渲染能力,而对于 CUE,JSON 才是一等公民。所以在 K8S 这类 yaml(JSON 超集) 文件渲染上,CUE(也是 JSON 超集) 拥有天然的优势。
Talk is cheap, show me the code.
我们一起来看几个常用的场景。

Named Templates

Helm
CUE
# values.yaml
app_name: "example_code"
other_attr1: "foo"
other_attr2: "bar"

# labels.tpl
{{- define "foo.labels" -}}
label1: {{ .Values.app_name | required "app_name is required" }} 
label2: {{ .Values.other_attr2 }}
{{ include "foo.selectorLabels" . }}
{{- end }}

{{- define "foo.selectorLabels" -}}
label3: {{ printf "%s-%s" .Values.app_name .Values.other_attr1 }}
{{- end }}
# labels.cue
app_name: *"example_code" | string
other_attr1: "foo"
other_attr2: "bar"

selector_labels: {
	label3: "\( app_name )-\( other_attr2 )"
}

labels: {
	label1: app_name
  label2: other_attr2
	selector_labels
}
  • Helm 可以在 .tpl 文件中定义可复用的模版,并支持其他模版引用它,同时,也只有定义了的模版才能被复用,在复杂的 Chart 项目里你需要额外定义非常多的基础模版
  • 相对于 Helm 繁琐的写法, 在 CUE 中所有内容均为 JSON 对象,不需要额外的语法去指定模版,任意 JSON 对象都可以互相引用。

空行和缩进

Helm
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "foo.deploymentName" . }}
  labels:
    {{- include "foo.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "foo.selectorLabels" . | nindent 6 }}
  strategy:
    {{- include "foo.updateStrategy" . | nindent 4 }}
  revisionHistoryLimit: {{ .Values.revisionHistoryLimit }}
  template:
    metadata:
    {{- with .Values.podAnnotations }}
      annotations:
        {{- toYaml . | nindent 8 }}
    {{- end }}
      labels:
        {{- include "foo.labels" . | nindent 8 }}
CUE
deployment: {
	apiVersion: "apps/v1"
	kind:       "Deployment"
	metadata: {
		name:   deployment_name
		labels: labels
	}
	spec: {
		replicas: runtime.replicas
		selector: matchLabels: selector_labels
		strategy:             update_strategy
		revisionHistoryLimit: revision_history_limit
  • Helm 中有大量的 {{- include }}nindent 等和实际逻辑无关的标记字符,需要在每一次引用的地方计算空格和缩进。
  • 而在 CUE 中无用编码更少,不需要过多的 {{ * }} 来标记代码块,信息密度更高,而且在缩进和空格方面得到了完全的解放。

values.yaml 自引用

在 Helm 中,一个长久以来的头疼问题就是,无法优雅地实现 values.yaml 引用问题。
我们看下面的例子:
rootDomain: ""

# 我们期待的是通过引用 rootDomain 拼接,例如:"foo.{{ .Values.rootDomain }}"
productDomain: ""
通常的情况下,Chart 的使用者需要针对这两个变量分别填写内容,增加了出错的可能。
虽然我们可以通过定义模版来实现:
# values.yaml
rootDomain: ""
productDomain: "foo.{{ .Values.rootDomain }}"

# helpers.tpl
{{- define "foo.productDomain" -}}
{{ tpl .Values.productDomain $ }}
{{- end }}

# ingress.yaml
...
spec:
  rules:
    - host: {{ include "foo.productDomain" . | quote }}
...
但在实际使用中,所有引用的地方都需要额外 include ,同时定义的维护也非常耗费心力(要时刻保证空行、缩进不出错)。
而在 CUE 中,相互引用显得自然而舒服。
rootDomain: string
productDomain: *"foo.\( rootDomain )" | string

导入 Kubernetes 包

CUE 的另一大杀器,可以针对原生 Kubernetes 源码生成描述 cue 文件,所有 k8s 资源相关的配置文件,都可以天然地拥有 schema 校验。具体教程可以查看 Kubernetes tutorial
package templates

import (
	apps "k8s.io/api/apps/v1"
)

deployment: apps.#Deployment

deployment: {
	apiVersion: "apps/v1"
	kind:       "Deployment"
	metadata: {
		name:   deployment_name
		labels: _labels
	}
...
类似这个例子中,我们定义的 deployment 将会通过 apps.#Deployment 校验,轻松检测出不合法的字段。理论上,你可以针对多个不同版本的 k8s 资源都生成 cue 文件,并且都作为 deployment 的模型定义,这样可以实现资源定义的多版本兼容能力。
 

说了这么多好处,现在就把所有 Helm Chart 都替换成 CUE?

且慢,还没到时候。
因为 Helm 作为 Package Manager ,除了 Chart 渲染,本身还具备一定的应用管理功能,例如 Helm installHelm rollback 等,而 CUE 仅仅是模版。所以在某些我们只使用了 Helm 的模版功能的情况下,可以考虑迁移到 CUE,其他情况,还是用 Helm 吧。

脑洞

做一个开源的 CLI,支持将多个文件聚合为一个应用统一管理:
  • 类似 Helm 的能力,支持安装、更新、回滚
  • 针对配置,支持裸配置和 CUE 文件
好了,先说这么多,我去尝试写一写,下篇文章见 👋 ~
 

© bluesyu 2019 - 2023

powered by nobelium