在日常工作中我们经常会用到其他开发者编写好的 k8s 资源配置文件来部署应用,这些配置文件可能是放在 Git 仓库也可能是封装成 helm chart,无论是哪种分发形式我们在安装之前或多或少都会对这些配置文件进行一些修改,比如注入数据库连接相关的环境变量,修改镜像版本等。但有时候我们会遇到这样的的问题:

  • 将 Git 仓库拉取下来直接修改配置文件后,上游仓库有更新导致与本地代码冲突
  • 要修改的字段没有在 values.yaml 中提供对应的字段

kustomize 可以非常优雅地解决上述问题,它使用 声明式 的配置来对 k8s 资源配置文件进行自定义,本文只介绍与 修改 相关的内容。kustomize 的安装过程非常简单这里不过多赘述,参照 官方文档 说明安装即可。

下面构建一个简单的 Nginx + PHP + MySQL 应用配置文件用于后续修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# app.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
labels:
app-name: app
spec:
replicas: 1
selector:
matchLabels:
app: app
template:
metadata:
name: app
labels:
app: app
spec:
containers:
- name: php
image: php-fpm:latest
imagePullPolicy: IfNotPresent
- name: nginx
image: nginx:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
name: web
restartPolicy: Always

---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysql
labels:
app-name: app
spec:
replicas: 1
selector:
matchLabels:
app: mysql
template:
metadata:
name: mysql
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:8.0
imagePullPolicy: IfNotPresent
restartPolicy: Always

---
apiVersion: v1
kind: Service
metadata:
name: mysql
labels:
app-name: app
spec:
selector:
app: mysql
ports:
- protocol: TCP
port: 3306
targetPort: 3306
type: ClusterIP

kustomization.yaml

开始使用 kustomize 之前我们需要创建一个配置文件 kustomization.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# kustomization.yaml
resources:
- app.yaml

patches:
- target:
kind: Deployment
name: app
patch: |-
- op: replace
path: /spec/template/spec/containers/0/image
value: php-fpm:7.4
- op: replace
path: /spec/template/spec/containers/1/image
value: nginx:1.25

以上就是一份简单的 kustomize 配置,你可能已经猜到它的作用:将 Deployment/app 中的两个容器的镜像版本分别修改为 php-fpm:7.4nginx:1.25。下面介绍一下各个字段的作用:

  • resources 指定要对哪些资源文件进行修改,可以使用通配符,也可以引用远程配置文件
  • patches 指定修改目标对象以及修改规则
  • patches.[].target 指定要修改的目标对象,kustomize 会在 resources 字段指定的文件中进行匹配。可以使用 GVKNN 来匹配具体的对象,也可以使用 labelSelectorannotationSelector 来匹配多个对象
  • patches.[].patch 指定 Patch 规则,kustomize 支持多种 Patch 语法,例子中用的是 JSON Patch 语法

编写好配置文件后就可以调用 kustomize 生成资源配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
$ ls
app.yaml kustomization.yaml

$ kustomize build .
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
labels:
app: app
spec:
replicas: 1
selector:
matchLabels:
app: app
template:
metadata:
name: app
labels:
app: app
spec:
containers:
- name: php
image: php-fpm:7.4
imagePullPolicy: IfNotPresent
- name: nginx
image: nginx:1.25
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
name: web
restartPolicy: Always
...

如果要将生成的资源配置文件安装到集群可以使用 kustomize build . | kubectl apply -f -kubectl apply -k . 命令

到这里我们已经大概了解了 kustomize 配置文件结构以及 kustomize 的用法,接下来介绍 kustomize 支持的两种 Patch 语法,两种语法可以满足不同的需求。

JSON Patch

JSON Patch 是 kustomize 支持的最简单的 Patch 语法,包含三个字段:

  • op 指定操作类型,在上个例子中使用了 replace 操作,除此之外还支持 addremovemovecopytest 操作
  • path 指定要修改的字段路径,使用 JSON Pointer 规范 来定位字段:
    • 必须以 / 开头,使用 / 分割路径
    • 路径中如果包含 / 需要使用 ~1 转义
    • 数组使用下标定位元素
  • value 指定要修改的值,使用 remove 操作时可以为空

add 操作

add 操作可以添加字段,比如给所有对象添加一个 app.kubernetes.io/name 的标签:

1
2
3
4
5
6
7
8
# kustomization.yaml
patches:
- target:
labelSelector: 'app-name=app'
patch: |-
- op: add
path: /metadata/labels/app.kubernetes.io~1name # 注意对标签名中包含的 `/` 进行转义
value: app

value 字段还可以指定为对象,可以很方便的给容器设置 resources 字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# kustomization.yaml
patches:
- target:
kind: Deployment
name: app
patch: |-
- op: add
path: /spec/template/spec/containers/0/resources
value:
limits:
cpu: 500m
memory: 1Gi
requests:
cpu: 100m
memory: 500Mi

test 操作

test 操作可以对字段进行断言测试,比如在安装不信任的配置文件时检查是否所有 Pod 都使用 nonRoot 身份运行:

1
2
3
4
5
6
7
8
9
# kustomization.yaml
patches:
- target:
kind: Deployment
name: .*
patch: |-
- op: test
path: /spec/template/spec/securityContext/runAsNonRoot
value: true

检查失败时 kustomize 会以非 0 的状态码退出并报错:

1
2
$ kubectl kustomize .
error: testing value /spec/template/spec/securityContext/runAsNonRoot failed: test failed

以上就是 JSON Patch 的用法,JSON Patch 的优点是简单,但缺点也很明显:一次只能操作一个字段,在需要修改多个字段的场景下效率太低;使用数组下标定位元素虽然方便,但如果元素位置发生变化会导致非预期的修改。

patchesStrategicMerge

与 JSON Patch 的逐个字段修改不同 策略性合并 是基于合并的修改方案,下面这段配置将一次性完成 JSON Patch 章节中提到的所有修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# kustomization.yaml
patches:
- target:
kind: Deployment
name: app
patch: |-
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
spec:
template:
labels:
app.kubernetes.io/name: app
spec:
containers:
- name: php
image: php-fpm:7.4
resources:
limits:
cpu: 500m
memory: 1Gi
requests:
cpu: 100m
memory: 500Mi
- name: nginx
image: nginx:1.25

kustomize 会将 patch 字段的内容合并到目标对象中,但需要注意一点:apiVersion kind metadata.name 三个字段即使不修改也要写上,但值可以随意写,kustomize 不会合并这几个字段。

patchesStrategicMerge 修改数组对象的方式看起来非常自然,可能你已经猜到是基于相同的 containers.[].name 字段进行合并,能否使用其它字段呢?答案是否定的,因为对象数组字段都有一个特定的 patch merge key 用于确定如何合并其中的对象,这是策略性合并的「策略」中一个重要的概念。字段的 patch merge key 在源码中通过结构体的 tag 定义的,kustomize 通过 OpenAPI Schema 中 x-kubernetes-patch-merge-key 扩展字段获取,OpenAPI Schema 可以访问 http://${API_SERVER}/openapi/v2 接口或者调用 kustomize openapi fetch 命令查看。但 OpenAPI Schema 的数据看起来眼花缭乱,我们可以在 API 文档 中更方便地找到字段的的 patch merge key:

PodSpec v1 core
PodSpec v1 core

因此对于 containers 字段我们只能用 name 字段作为合并主键,在修改类似的对象数组字段时一定要注意这个问题以免出现非预期的结果。

delete 操作

patchesStrategicMerge 还可以通过特殊的 $patch 字段来使用其它操作,delete 操作可以删除指定的对象以及字段,下面我们将示例配置文件中的 Deployment/mysql 删除并将 Service/mysql 修改为 ExternalName 类型指向外部的 MySQL 数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# kustomization.yaml
patches:
- target:
kind: Deployment
name: mysql
patch: |-
$patch: delete
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysql
- target:
kind: Service
name: mysql
patch: |-
apiVersion: v1
kind: Service
metadata:
name: mysql
spec:
selector:
$patch: delete
ports:
- $patch: delete
externalName: external-mysql.com
type: ExternalName

最终生成的 Service 对象:

1
2
3
4
5
6
7
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
externalName: external-mysql.com
type: ExternalName

需要注意三个 $patch: delete 操作的位置:第一个写在顶层表示删除整个对象;第二个写在 selector 字段下表示删除 selector 字段;第三个也是删除字段操作,但 ports 字段是一个对象数组,$patch: delete 写在数组元素中表示删除元素,但没有指定 patch merge key 因此会删除整个数组字段,下面是一个通过 patch merge key 删除指定元素的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# kustomization.yaml
patches:
- target:
kind: Deployment
name: app
patch: |-
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
spec:
template:
spec:
containers:
# 删除 nginx 容器
- name: nginx
$patch: delete

replace 操作

在修改 Service/mysql 的例子中也可以直接替换掉 spec 字段的内容,这比逐个删除不需要的字段更高效:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# kustomization.yaml
patches:
- target:
kind: Service
name: mysql
patch: |-
apiVersion: v1
kind: Service
metadata:
name: mysql
spec:
$patch: replace
externalName: external-mysql.com
type: ExternalName

patchesStrategicMerge 为我们提供了更强大的 Patch 能力但仍有局限性:对对象数组的修改依赖 patch merge key。虽然 k8s 的内置对象都有指定,但在日常使用中我们还使用到 CRD,不幸的是我使用过的大多数 CRD 都没有指定 patch merge key,即便是「教科书」级别的 Prometheus Operator 的 CRD 也是如此,例如下面的 PrometheusRule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kind: PrometheusRule
apiVersion: monitoring.coreos.com/v1
metadata:
name: node-rules
spec:
groups:
- name: memory
rules:
- expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes * 100 < 30
alert: HostOutOfMemory
for: 2m
labels:
severity: warning
annotations:
description: Instance {{ $labels.instance }} memory is filling up (< {{ $value }}% left)
summary: Host out of memory (instance {{ $labels.instance }})

这是一个 Prometheus 的告警规则,当节点可用内存量 < 30% 并持续 2 分钟后触发 HostOutOfMemory 告警,其中触发时间是 spec.groups.[].rules.[].for 字段控制的,如果要使用 patchesStrategicMerge 将持续时间改为 5m 似乎无从下手,即便 spec.groups.[].namespec.groups.[].rules.[].alert 两个字段看起来是可以用作合并的但这两个并不是 patch merge key。如果要用 JSON Patch 实现字段路径是 /spec/groups/0/rules/0/for,两层数组只要其中一层发生变化修改就会导致非预期修改,而且不看原文件是发现不了的。有没有更好的方案来实现这个修改呢?

replacements

replacements 是 kustomize 提供的另外一种修改能力,它的工作方式是:使用 A 对象的字段替换掉 B 对象的字段。下面来看一个示例:

1
2
3
4
5
6
7
8
#mysql-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: mysql-config
data:
DB_USER: 'root'
DB_PWD: '123456'

创建一个新的 mysql-config.yaml 文件,里面包含一个 ConfigMap 保存了 MySQL 的配置信息,现在要将这个 ConfigMap 的配置信息注入到示例应用中,不使用 envFromvalueFrom 实现而是直接替换 php 容器的 MYSQL_USER MYSQL_PWD 两个环境变量的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# kustomization.yaml
resources:
- app.yaml
- mysql-config.yaml # 注意要将 ConfigMap 加入到 `resources` 中

replacements:
- source:
kind: ConfigMap
name: mysql-config
fieldPath: data.DB_USER
targets:
- select:
kind: Deployment
name: app
fieldPaths:
- spec.template.spec.containers.[name=php].env.[name=MYSQL_USER].value
- source:
kind: ConfigMap
name: mysql-config
fieldPath: data.DB_PWD
targets:
- select:
kind: Deployment
name: app
fieldPaths:
- spec.template.spec.containers.[name=php].env.[name=MYSQL_PWD].value

解释一下这份配置:

  • replacements.[].source 指定替换值的来源,前面说到 replacements 是使用 A 对象的字段值替换 B 对象的字段,指定对象的规则与 patches.target 字段一致,fieldPath 字段指定值来源字段,以 . 分割路径不需要以 . 开头
  • targets.[].select 指定被替换的对象,规则与 patches.target 字段一致
  • targets.[].fieldPaths 指定被替换的字段,这里用的是类似 JSONPath 的语法定位字段

这份配置的重点在 targets.[].fieldPaths 字段选择数组元素的方式:使用元素的字段来匹配元素,并且可以是任意字段不受 patch merge key 的限制。

虽然 replacements 用起来很繁琐但它修改数组字段的能力非常强大,在上面介绍的两种 Patch 都不太适用的场景下 replacements 是非常不错的选择。在需要对多个字段进行修改的情况下可以用 yaml 的锚点语法减少工作量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# kustomization.yaml
replacements:
- source: &source
kind: ConfigMap
name: mysql-config
fieldPath: data.DB_USER
targets:
- select: &select
kind: Deployment
name: app
fieldPaths:
- spec.template.spec.containers.[name=php].env.[name=MYSQL_USER].value
- source:
<<: *source
fieldPath: data.DB_PWD
targets:
- select: *select
fieldPaths:
- spec.template.spec.containers.[name=php].env.[name=MYSQL_PWD].value

接下来完成对 PrometheusRule 的修改,还是需要先创建一个 ConfigMap 对象存放修改值来源:

1
2
3
4
5
6
7
# node-rules-replacements.yaml
kind: ConfigMap
apiVersion: v1
metadata:
name: node-rules-replacements
data:
for: '5m'

接着编写 replacements 配置:

1
2
3
4
5
6
7
8
9
10
11
12
# kustomization.yaml
replacements:
- source:
kind: ConfigMap
name: node-rules-replacements
fieldPath: data.for
targets:
- select:
kind: PrometheusRule
name: node-rules
fieldPaths:
- spec.groups.[name=memory].rules.[alert=HostOutOfMemory].for

replacements 有没有什么「杀手锏」级别的功能呢?为 MutatingWebhookConfiguration 注入 CA 证书必须是其中一个。假设我们已经通过 openssl 或其它方式在本地生成了 TLS 证书,接下来看看如何使用 kustomize 将其注入到 MutatingWebhookConfiguration/my-webhook 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# MutatingWebhookConfiguration.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: my-webhook
webhooks:
- admissionReviewVersions:
- v1
clientConfig:
service:
name: webhook-controller
namespace: default
name: my-webhook.k8s.io
sideEffects: None
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# kustomization.yaml
resources:
- MutatingWebhookConfiguration.yaml

secretGenerator:
- files:
- tls.crt
- tls.key
- ca.crt
type: "kubernetes.io/tls"
name: webhook-tls
options:
disableNameSuffixHash: true # 禁止 kustomize 添加 hash 后缀

replacements:
- source:
kind: Secret
name: webhook-tls
fieldPath: data.ca\.crt # 路径中包含的 `.` 需要转义
targets:
- select:
kind: MutatingWebhookConfiguration
name: my-webhook
fieldPaths:
- webhooks.*.clientConfig.caBundle # 使用 `*` 匹配数组下所有元素
options:
create: true # 当目标字段不存在时创建

secretGenerator 的作用是使用本地文件生成 Secret,这份配置会生成以下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: v1
data:
ca.crt: <base64 encoded ca.crt>
tls.crt: <base64 encoded tls.crt>
tls.key: <base64 encoded tls.key>
kind: Secret
metadata:
name: webhook-tls

---
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: my-webhook
webhooks:
- admissionReviewVersions:
- v1
clientConfig:
service:
name: webhook-controller
namespace: default
name: my-webhook.k8s.io
caBundle: <base64 encoded ca.crt>
sideEffects: None

kustomize x helm

kustomize 还可以与 helm 结合起来使用,比如 Prometheus 官方提供的 kube-prometheus-stack Chart 里面包含了数十条告警规则,包括上面提到的内存用量告警规则,虽然这些规则大多数都是「开箱即用」的,但难免会有一些不太适用的地方,这时候就可以使用 kustomize 配合 helm 的后置渲染功能进行修改。

后置渲染器是在STDIN能够接受渲染后的Kubernetes manifest并能在STDOUT返回有效的Kubernetes manifest, 可以是任意可执行文件。它应该在出现失败事件时返回非0退出码。这是两个组件之间的唯一API。允许在你的后置渲染过程中有很好的灵活性。

用一行非常简单的命令模拟后置渲染功能:

1
helm template my-chart ./ | DO_SOMETHING | kubectl apply -f -

用一句话总结:helm 将渲染好的配置文件使用标准输入传递给后置渲染器(也就是命令的 DO_SOMETHING),后置渲染器对配置进行处理后通过标准输出返回给 helm,helm将其提交到 k8s 中。

逻辑看起来很简单但有两个小问题:后置渲染器需要是一个可执行文件;kustomize 只支持从文件输入不支持从标准输入读取。可以通过一个简单的脚本解决:

1
2
3
4
#!/bin/bash

cat <&0 >all.yaml
kustomize build .

将标准输入的内容重定向到 all.yaml 中就解决了 kustomize 不支持从标准输入读取内容的问题,最后调用 helm 安装即可:

1
2
chmod +x ./render.sh
helm install my-chart ./ --post-renderer ./render.sh