k8s 作为当下最受欢迎的容器编排系统除了拥有强大的调度能力外还提供了非常多的扩展机制极大地提高了 k8s 的能力上限,也促就了 k8s 丰富的生态环境。接下来我们一起探索最常用的 动态准入控制 机制,最后实现一个简单的动态准入控制器来解决两个常见的问题:

  • 编写配置时使用了 latest tag 镜像导致 Pod 下一次启动时拉取到了新版本的镜像
  • 使用境外仓库的镜像时由于网络原因拉取失败,例如:registry.k8s.io、gcr.io 等仓库

通常情况下这两个问题都可以通过修改配置解决,例如:latest tag 的问题可以使用 Linter 工具检查;境外仓库的问题可以通过修改镜像仓库地址为国内镜像仓库地址解决。但这两种解决方式都需要人工介入,下面来看看如何使用动态准入控制机制来自动化地解决这两个问题。

准入控制

当一个请求到达 api-server 通过鉴权后就进入到准入控制流程,api-server 会调用内置的 准入控制器 对请求进行准入控制,准入控制器可以分为两类:

  • 验证性(Validating)准入控制器:验证请求是否合法,拒绝不合法的请求。
  • 变更性(Mutating)准入控制器:对请求进行修改,同时也兼具验证性准入控制器的能力。

api-server 会依次调用已启用的准入控制器,顺序是先调用变更性准入控制器后调用验证性准入控制器,如果有一个准入控制器拒绝了请求 api-server 就会终止请求并返回,就像业务开发过程中经常使用到的中间件模式。部分准入控制器是默认启用的,其余的则需要通过设置 api-server 启动参数启用。下面通过一张流程图来更直观的了解创 建 Pod 时的准入控制流程:

流程图
流程图

流程图中特别列出了两个以 Webhook 结尾的准入控制器:

  • MutatingAdmissionWebhook 此准入控制器调用任何与请求匹配的变更(Mutating) Webhook。匹配的 Webhook 将被顺序调用。 每一个 Webhook 都可以自由修改对象
  • ValidatingAdmissionWebhook 此准入控制器调用与请求匹配的所有验证性 Webhook。 匹配的 Webhook 将被并行调用。如果其中任何一个拒绝请求,则整个请求将失败。

可以看出这两个准入控制器都是使用 Webhook 形式调用外部的准入控制器来完成准入控制,这也是动态准入机制得以实现的重要前提。只有被匹配的请求才会调用动态准入控制器,匹配的规则由 ValidatingWebhookConfigurationMutatingWebhookConfiguration 配置决定,例如下面的配置:

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
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: image-webhook
namespace: image-webhook
webhooks:
- admissionReviewVersions:
- v1
clientConfig:
service:
name: webhook
namespace: image-webhook
path: /validating
port: 443
rules:
- operations:
- CREATE
apiGroups:
- ''
apiVersions:
- '*'
resources:
- pods
sideEffects: None
failurePolicy: Fail

这份配置会匹配所有创建 Pod 的请求,匹配的请求会被发送到 webhook.image-webhook.svc:443/validating 这个地址进行验证,这个地址上运行的就是动态准入控制器。在下面的章节中会更详细的讲解配置这里只需要有个概念,下面来看看如何实现一个动态准入控制器。

动态准入控制器

api-server 使用 HTTPS 协议和 JSON 数据格式与动态准入控制器交互,文档中对请求和响应格式都进行了 详细介绍 这里就不过多赘述。下面开始实现一个简单的准入控制器来解决文章开头提到的两个问题,完整的代码放在 这个仓库

首先创建一个 HTTPS 服务并注册两个路由作为请求入口:

1
2
3
4
5
6
7
8
9
func main() {
// 验证准入控制入口
http.HandleFunc("/validating", noLatestTagHandler)
// 变更准入控制入口
http.Handle("/mutating", tagMutatingHandler())

// 启动 HTTPS 服务
log.Fatal(http.ListenAndServeTLS(*serverAddr, *tlsCertFile, *tlsKeyFile, nil))
}

接下来编写 /validating 路由对应的 noLatestTagHandler 函数,这个函数执行验证性准入控制,拒绝使用 latest tag 镜像的请求:

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
func noLatestTagHandler(rw http.ResponseWriter, r *http.Request) {
// 解析请求数据
admissionReview, pod, err := decodeAdmissionReview(r.Body)
if err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
log.Println(err.Error())
return
}

// 构建响应数据
admissionResponse := &admissionv1.AdmissionResponse{
UID: admissionReview.Request.UID,
Allowed: true,
}

for _, container := range pod.Spec.Containers {
if isLatestTag(container.Image) {
admissionResponse.Allowed = false
admissionResponse.Warnings = []string{"use `latest` tag is not allowed"}
break
}
}

err = json.NewEncoder(rw).Encode(admissionv1.AdmissionReview{
Response: admissionResponse,
TypeMeta: admissionReview.TypeMeta,
})

if err != nil {
log.Printf("failed to encode admissionReview: %s", err)
}
}

这个函数的逻辑非常简单,首先从请求中解析出 admissionReview 和 Pod 配置,接着检查 Pod 中是否有容器使用了 latest tag,如果有则将响应的 Allowed 字段设置为 false 并返回一个警告信息,api-server 会拒绝这个请求并将这个警告信息返回给用户。

接下来编写 /mutating 路由对应的 tagMutatingHandler 函数,这个函数执行变更性准入控制,替换镜像仓库地址为国内镜像仓库地址:

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
func tagMutatingHandler(mirrors map[string]string) http.Handler {
jsonPatch := admissionv1.PatchTypeJSONPatch

return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
admissionReview, pod, err := decodeAdmissionReview(r.Body)
if err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
log.Println(err.Error())
return
}

admissionResponse := &admissionv1.AdmissionResponse{
UID: admissionReview.Request.UID,
Allowed: true,
}

defer func() {
err := json.NewEncoder(rw).Encode(admissionv1.AdmissionReview{
Response: admissionResponse,
TypeMeta: admissionReview.TypeMeta,
})

if err != nil {
log.Printf("failed to encode admissionReview: %s", err)
}
}()

if len(mirrors) == 0 {
return
}

var patches []map[string]string

for i, container := range pod.Spec.Containers {
image, replaced := replaceImage(container.Image, mirrors)

if replaced {
// 构建 JSON Patch 替换指定字段值
patches = append(patches, map[string]string{
"op": "replace", // 替换操作
"path": fmt.Sprintf("/spec/containers/%d/image", i), // 需要替换的字段路径
"value": image, // 替换值
})
}
}

if len(patches) > 0 {
admissionResponse.PatchType = &jsonPatch // 目前仅支持 JSON Patch
admissionResponse.Patch, _ = json.Marshal(patches)
}
})
}

整体上两个函数的逻辑非常相似,区别在于返回的数据,当镜像地址需要替换时 tagMutatingHandler 函数返回 JSON Patch 数组来指示 api-server 修改请求,关于 JSON Patch 的用法可以看我另外一篇 文章 的介绍。需要注意的是,即使 tagMutatingHandler 执行的是变更性准入控制,仍需将响应的 Allowed 字段为 true 否则 api-server 会拒绝请求,这是因为变更性准入控制器也兼具验证性准入控制器的能力。基于这个特性我们也可以将两个函数合为一体。

安装准入控制器

准入控制器的安装分为两步:

  1. 将准入控制器部署到集群中运行
  2. 将准入控制器注册到 api-server 中

完整的部署文件放置在 deploy/ 目录中,下面根据文件名编号来介绍动态准入控制器的安装流程:

第一步创建命名空间:

1
$ kubectl apply -f 01-namespace.yaml

前面提到 api-server 与动态准入控制器使用 HTTPS 协议交互,这也是强制性的要求,所以在部署 Webhook 前我们需要先创建好 TLS 证书,由于创建 TLS 证书流程比较繁琐这里借助 kube-webhook-certgen 来完成:

1
2
3
4
5
6
7
8
9
10
11
12
# 生成证书所需的 RBAC 权限
$ kubectl apply -f 02-rbac.yaml
serviceaccount/certgen created
role.rbac.authorization.k8s.io/certgen created
rolebinding.rbac.authorization.k8s.io/certgen created
clusterrole.rbac.authorization.k8s.io/image-webhook-certgen created
clusterrolebinding.rbac.authorization.k8s.io/image-webhook-certgen created

# 生成证书
$ kubectl create -f 03-certgen.yaml && kubectl wait --for=condition=complete job -l 'job=certgen' -n image-webhook
job.batch/certgen-9f8gh created
job.batch/certgen-9f8gh condition met

Job 执行完毕后我们可以看到生成的证书:

1
2
3
4
5
6
7
8
9
10
$ kubectl get secret -n image-webhook webhook-tls -o yaml
apiVersion: v1
data:
ca: ...
cert: ...
key: ...
kind: Secret
metadata:
name: webhook-tls
namespace: image-webhook

如果使用其它方式生成证书需要特别注意一点:证书必须对..svc 域名有效。也就是说证书的 SANs 必须包含..svc 域名,否则会导致访问错误。在 certgen 中是通过 --host 参数指定的:

1
2
3
4
5
6
# 03-certgen.yaml
args:
- create
- --host=webhook,webhook.image-webhook.svc # 指定证书的 SANs
- --namespace=image-webhook # 指定证书 Secret 的命名空间
- --secret-name=webhook-tls # 指定证书 Secret 的名称

接下来部署准入控制器:

1
2
3
$ kubectl apply -f 04-webhook.yaml
deployment.apps/webhook created
service/webhook created

启动 Webhook 时可以通过 --registry-mirrors 参数传入镜像仓库替换规则,例如:

1
2
3
4
5
# 04-webhook.yaml
args:
# docker.io/library/nginx:1.25 -> docker.mirror.lin2ur.com/library/nginx:1.25
# registry.k8s.io/pause:3.2 -> k8s.mirror.lin2ur.com/pause:3.2
- --registry-mirrors=docker.mirror.lin2ur.com,registry.k8s.io:k8s.mirror.lin2ur.com

这里只设置了两个镜像仓库作为示例,大家可以根据自己的需求添加其它镜像仓库,推荐使用 cloudflare-docker-proxy 搭建镜像仓库。

Webhook 绑定的 Service 名称是 webhook,证书包含了 webhook.image-webhook.svc 域名,满足证书必须对..svc 域名有效的要求。

Webhook 正常运行后我们就完成了部署流程的第一个步骤,接下来将 Webhook 注册到 api-server 中,在此之前先来了解一下 ValidatingWebhookConfigurationMutatingWebhookConfiguration 的配置,以 ValidatingWebhookConfiguration 为例介绍:

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
# 05-webhookconfigure.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: image-webhook
namespace: image-webhook
webhooks:
- admissionReviewVersions:
- v1 # admissionReview 的版本
clientConfig:
service:
name: webhook # Webhook Service 名称
namespace: image-webhook # Webhook Service 命名空间
path: /validating # Webhook 路由路径
port: 443 # Webhook Service 端口
caBundle: '' # Webhook 的 CA 证书,如果为空则使用集群的 CA 证书
name: validating.image-webhook.lin2ur.cn # Webhook 名称
rules: # 请求匹配规则
- operations:
- CREATE
apiGroups:
- ''
apiVersions:
- '*'
resources:
- pods
namespaceSelector: # 命名空间选择器
matchExpressions:
- key: kubernetes.io/metadata.name
operator: NotIn
values:
- image-webhook
timeoutSeconds: 10 # 超时时间
sideEffects: None
failurePolicy: Fail # 失败策略

需要注意以下几点:

  • caBundle 字段为 Webhook 的 CA 证书,Webhook 用的服务器证书必须由这个 CA 证书签发,api-server 会使用这个 CA 证书来验证 Webhook 的身份。如果这个字段证书为空会默认使用集群的 CA 证书。但配置中留空并不是使用集群 CA 证书,接下来会用 certgen 将上一步生成的证书填充到这里。

  • rules 字段用于匹配请求,匹配规则可以参考 官方文档

  • namespaceSelector 字段用于选择命名空间,被选择到的命名空间的请求才会进入 rules 字段的匹配,这里的配置表示不对 image-webhook 命名空间的请求进行准入控制,这是因为 image-webhook 命名空间中运行着 Webhook 服务,如果对这个命名空间的请求进行准入控制会导致死循环。除此之外,还可以使用 objectSelector 来选择具体的作用对象。不设置该字段表示匹配所有命名空间。

  • timeoutSeconds 字段用于设置超时时间,如果 Webhook 服务在这个时间内没有响应 api-server 会将请求视为失败。最大不能超过 30 秒。

  • failurePolicy 字段用于设置失败策略,有两个选项:IgnoreFail。当 Webhook 发生错误时,Ignore 表示忽略 Webhook 的错误继续处理请求;Fail 表示拒绝请求并返回错误信息。失败策略需要酌情设置,避免 Webhook 不可用时导致请求被拒绝。

了解完配置后继续部署流程:

1
2
3
4
5
6
7
8
9
10
11
12
$ kubectl apply -f 05-webhookconfigure.yaml
validatingwebhookconfiguration.admissionregistration.k8s.io/image-webhook created
mutatingwebhookconfiguration.admissionregistration.k8s.io/image-webhook created

# 将 CA 证书填充到 WebhookConfiguration 中
$ kubectl create -f 06-patch-webhookconfigure.yaml && kubectl wait --for=condition=complete job -l 'job=patch-webhookconfigure' -n image-webhook

# 检查是否正确填充
$ kubectl get validatingwebhookconfiguration/image-webhook -o jsonpath='{.webhooks[0].clientConfig.caBundle}' | base64 -d
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----

到这里我们就完成了动态准入控制器的安装,接下来测试一下效果。

测试

先来看看使用 latest tag 的 Pod 是否能被创建:

1
2
3
$ kubectl run "image-webhook-test-$(date +%s)" --image=busybox:latest -n default
Warning: use `latest` tag is not allowed
Error from server: admission webhook "validating.image-webhook.lin2ur.cn" denied the request without explanation

可以看到 api-server 拒绝了创建请求并且输出了 Warning 信息。

接下来测试一下镜像仓库替换功能:

1
2
3
$ kubectl run image-webhook-test-nginx --image=nginx:1.25 -n default 
$ kubectl get po/image-webhook-test-nginx -n default -o=jsonpath='{.spec.containers[0].image}'
docker.mirror.lin2ur.com/library/nginx:1.25

可以看到默认镜像仓库地址 docker.io 被替换成了 docker.mirror.lin2ur.com,从此实现镜像拉取自由!