如何构建 kubernetes mutating admission webhook

本文介绍了从零开始构建 Kubernetes Mutating Admission Webhook 的详细步骤。通过使用 Mutating Admission Webhook,实现将文件注入容器的功能。

作者 Adil H 译者 梁斌 发表于 2020年10月17日

本文译自 Building a Kubernetes Mutating Admission Webhook

当你在 Kubernetes 中创建 Pod 的时候,是否注意到在容器的 /var/run/secrets/kubernetes.io/serviceaccount/token 路径上存放了一个用于认证的 token 文件?你可以通过如下命令,在 Kubernetes 集群中验证下:

$ kubectl run busybox --image=busybox --restart=Never -it --rm -- ls -l /var/run/secrets/kubernetes.io/serviceaccount/token
# output
/var/run/secrets/kubernetes.io/serviceaccount/token

注:在 Kubernetes 1.6 以上的版本,你可以 取消 这一个自动注入的功能。

现在假设有这样一个场景,需要添加一个 “hello.txt” 文件到所有(或某组)pod 的容器文件系统内,不能通过在 pod spec 中显式指定 volumeMount 的方式,我们有没有什么方法达到目的呢?

为了让实验更具趣味性,我们用一个 ASCII “小作品”(用这个工具生成的)来作为我们的 “hello.txt” 文件:

hello.txt 文件内容

何为 Admission Webhook

实现该注入的功能的方式之一,就是我们上文中提到的,使用 Kubernetes Admission Webhooks。这是何方神圣?来看下官方文档给的定义:

Admission webhook 是一种用于接收准入请求并对其进行处理的 HTTP 回调机制。 可以定义两种类型的 admission webhook,即 validating admission webhookmutating admission webhook。 Mutating admission webhook 会先被调用。它们可以更改发送到 API 服务器的对象以执行自定义的设置默认值操作。

下文这张借用自 Kubernetes.io blog post 的流程图可以帮助我们理解 Admission Webhook 的概念。

准入控制器处理流程

接下来,本文将采用 Kubernetes 提供的 Mutating Admission Webhook 这一机制,来实现注入 “hello.txt” 文件到 Pod 容器中,我们每次发送请求调用 API 创建 Pod 的时候,Pod 的 spec 信息会被先修改,再存储。如此一来,工作节点上的 Kublet 创建 Pod 的时候,将会预置 “hello.txt” 文件。文件的创建流程是全自动的。一起来试试!

创建 Admission Webhook

笔者已经把下文提到的代码和命令都上传到了 github 仓库。读者可以跟着边看边操作。

首先需要有一个正常运行的 Kubernetes 集群。读者可以通过 Kind 快速起一个集群。

接着,定义一个包含了 “hello.txt” 文件内容的 ConfigMap

apiVersion: v1
kind: ConfigMap
metadata:
  name: hello-configmap
data:
  hello.txt: "\n /$$$$$$$$ /$$   /$$ /$$$$$$  /$$$$$$        /$$$$$$  /$$$$$$        /$$
    \  /$$  /$$$$$$   /$$$$$$ \n|__  $$__/| $$  | $$|_  $$_/ /$$__  $$      |_  $$_/
    /$$__  $$      | $$  /$$/ /$$__  $$ /$$__  $$\n   | $$   | $$  | $$  | $$  | $$
    \ \\__/        | $$  | $$  \\__/      | $$ /$$/ | $$  \\ $$| $$  \\__/\n   | $$
    \  | $$$$$$$$  | $$  |  $$$$$$         | $$  |  $$$$$$       | $$$$$/  |  $$$$$$/|
    \ $$$$$$ \n   | $$   | $$__  $$  | $$   \\____  $$        | $$   \\____  $$      |
    $$  $$   >$$__  $$ \\____  $$\n   | $$   | $$  | $$  | $$   /$$  \\ $$        |
    $$   /$$  \\ $$      | $$\\  $$ | $$  \\ $$ /$$  \\ $$\n   | $$   | $$  | $$ /$$$$$$|
    \ $$$$$$/       /$$$$$$|  $$$$$$/      | $$ \\  $$|  $$$$$$/|  $$$$$$/\n   |__/
    \  |__/  |__/|______/ \\______/       |______/ \\______/       |__/  \\__/ \\______/
    \ \\______/ \n                                                                                                  \n
    \                                                                                                 \n
    \                                                                                                 \n"

为了构建 webhook,我们写一个简洁的 Go API 服务端。http handler 是实现 webhook 代码的最重要部分:

func (app *App) HandleMutate(w http.ResponseWriter, r *http.Request) {
	admissionReview := &admissionv1.AdmissionReview{}

	// read the AdmissionReview from the request json body
	err := readJSON(r, admissionReview)
	if err != nil {
		app.HandleError(w, r, err)
		return
	}

	// unmarshal the pod from the AdmissionRequest
	pod := &corev1.Pod{}
	if err := json.Unmarshal(admissionReview.Request.Object.Raw, pod); err != nil {
		app.HandleError(w, r, fmt.Errorf("unmarshal to pod: %v", err))
		return
	}

	// add the volume to the pod
	pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{
		Name: "hello-volume",
		VolumeSource: corev1.VolumeSource{
			ConfigMap: &corev1.ConfigMapVolumeSource{
				LocalObjectReference: corev1.LocalObjectReference{
					Name: "hello-configmap",
				},
			},
		},
	})

	// add volume mount to all containers in the pod
	for i := 0; i < len(pod.Spec.Containers); i++ {
		pod.Spec.Containers[i].VolumeMounts = append(pod.Spec.Containers[i].VolumeMounts, corev1.VolumeMount{
			Name:      "hello-volume",
			MountPath: "/etc/config",
		})
	}

	containersBytes, err := json.Marshal(&pod.Spec.Containers)
	if err != nil {
		app.HandleError(w, r, fmt.Errorf("marshall containers: %v", err))
		return
	}

	volumesBytes, err := json.Marshal(&pod.Spec.Volumes)
	if err != nil {
		app.HandleError(w, r, fmt.Errorf("marshall volumes: %v", err))
		return
	}

	// build json patch
	patch := []JSONPatchEntry{
		JSONPatchEntry{
			OP:    "add",
			Path:  "/metadata/labels/hello-added",
			Value: []byte(`"OK"`),
		},
		JSONPatchEntry{
			OP:    "replace",
			Path:  "/spec/containers",
			Value: containersBytes,
		},
		JSONPatchEntry{
			OP:    "replace",
			Path:  "/spec/volumes",
			Value: volumesBytes,
		},
	}

	patchBytes, err := json.Marshal(&patch)
	if err != nil {
		app.HandleError(w, r, fmt.Errorf("marshall jsonpatch: %v", err))
		return
	}

	patchType := admissionv1.PatchTypeJSONPatch

	// build admission response
	admissionResponse := &admissionv1.AdmissionResponse{
		UID:       admissionReview.Request.UID,
		Allowed:   true,
		Patch:     patchBytes,
		PatchType: &patchType,
	}

	respAdmissionReview := &admissionv1.AdmissionReview{
		TypeMeta: metav1.TypeMeta{
			Kind:       "AdmissionReview",
			APIVersion: "admission.k8s.io/v1",
		},
		Response: admissionResponse,
	}

	jsonOk(w, &respAdmissionReview)
}

上面这部分代码,和 Kubernetes 内部代码有诸多类似,都使用了 源自 https://github.com/kubernetes/apihttps://github.com/kubernetes/apimachinery 的 schema 类型。上述代码主要做了如下事情:

  • 将来自 Http 请求中的 AdmissionReview json 输入反序列化。
  • 读取 Pod 的 spec 信息。
  • 将 hello-configmap 作为数据源,添加 hello-volume 卷到 Pod。
  • 挂载卷至 Pod 容器中。
  • JSON PATCH 的形式记录变更信息,包括卷的变更,卷挂载信息的变更。顺道为容器添加一个 “hello-added=true” 的标签。
  • 构建 json 格式的响应结果,结果中包含了这次请求中的被修改的部分。

笔者 此处 还为这个 handler 编写了单元/功能测试,以确保它的功能实现符合我们的预期。

加点改进:TLS

Webhook API 服务器需要通过 TLS 方式通信。如果想将其部署至 Kubernetes 集群内,我们还需要证书。笔者通过 New Relic 这个软件来生成 Webhook 证书。笔者创建了一个 个人分支,对代码做了点改动,以确保其可以 Job 方式部署:

apiVersion: batch/v1
kind: Job
metadata:
  name: webhook-cert-setup
spec:
  template:
    spec:
      serviceAccountName: webhook-cert-sa
      containers:
      - name: webhook-cert-setup
        # This is a minimal kubectl image based on Alpine Linux that signs certificates using the k8s extension api server
        image: quay.io/didil/k8s-webhook-cert-manager:0.13.19-1-a
        command: ["./generate_certificate.sh"]
        args:
          - "--service"
          - "hello-webhook-service"
          - "--webhook"
          - "hello-webhook.leclouddev.com"
          - "--secret"
          - "hello-tls-secret"
          - "--namespace"
          - "default"
      restartPolicy: OnFailure
  backoffLimit: 3

其他 YAML

完成 webhook API 服务端的镜像构建后,将其推送至镜像仓库,并将其作为一个 Deployment 部署到集群。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-webhook-deployment
  labels:
    app: hello-webhook
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hello-webhook
  template:
    metadata:
      labels:
        app: hello-webhook
    spec:
      containers:
      - name: hello-webhook
        image: CONTAINER_IMAGE
        ports:
        - containerPort: 8000
        volumeMounts:
        - name: hello-tls-secret
          mountPath: "/tls"
          readOnly: true        
        resources:
          limits:
            memory: "128Mi"
            cpu: "500m"           
      volumes:
      - name: hello-tls-secret
        secret:
          secretName: hello-tls-secret

然后是一个 ClusterIP 类型的 Service:

apiVersion: v1
kind: Service
metadata:
  name: hello-webhook-service
spec:
  type: ClusterIP
  selector:
    app: hello-webhook
  ports:
  - protocol: TCP
    port: 443
    targetPort: 8000

接着,创建一个 MutatingWebhookConfiguration 将我们创建的 webhook 信息注册到 Kubernetes API server:

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: "hello-webhook.leclouddev.com"
webhooks:
- name: "hello-webhook.leclouddev.com"
  objectSelector:
    matchLabels:    
      hello: "true"
  rules:
  - apiGroups:   [""]
    apiVersions: ["v1"]
    operations:  ["CREATE"]
    resources:   ["pods"]
    scope:       "Namespaced"
  clientConfig:
    service:
      namespace: "default"
      name: "hello-webhook-service"  
      path: /mutate
  admissionReviewVersions: ["v1", "v1beta1"]
  sideEffects: None
  timeoutSeconds: 10

如上述的清单信息所示,我们要求 Kubernetes 把(部署了 MutatingWebhookConfiguration )命名空间中所有的 Pod 创建请求,只要匹配上 “hello=true”标签的,就将其转发到 hello-webhook-service 的 “/mutate”路径下,交给其处理。标签是可选的。此处笔者通过标签匹配来说明,如果标签不匹配或者不具备标签的请求,就可以绕开 Mutating Webhook 的预处理。

这篇文章 中提到了 “caBundle”,然而我们上面文件中 “clientConfig” 中却不存在 “caBundle” key 字段。不必感到奇怪,那是因为 webhook-cert-setup Job 会为我们自动创建这个 key。

部署 Webhook

项目差不多就绪可以部署了。我们用 Makefile 和 Kustomize 来部署。

$ make k8s-deploy
# output
kustomize build k8s/other | kubectl apply -f -
configmap/hello-configmap created
service/hello-webhook-service created
mutatingwebhookconfiguration.admissionregistration.k8s.io/hello-webhook.leclouddev.com created
kustomize build k8s/csr | kubectl apply -f -
serviceaccount/webhook-cert-sa created
clusterrole.rbac.authorization.k8s.io/webhook-cert-cluster-role created
clusterrolebinding.rbac.authorization.k8s.io/webhook-cert-cluster-role-binding created
job.batch/webhook-cert-setup created
Waiting for cert creation ...
kubectl certificate approve hello-webhook-service.default
certificatesigningrequest.certificates.k8s.io/hello-webhook-service.default approved
kustomize build k8s/csr | kubectl apply -f -
serviceaccount/webhook-cert-sa unchanged
clusterrole.rbac.authorization.k8s.io/webhook-cert-cluster-role unchanged
clusterrolebinding.rbac.authorization.k8s.io/webhook-cert-cluster-role-binding unchanged
job.batch/webhook-cert-setup unchanged
Waiting for cert creation ...
kubectl certificate approve hello-webhook-service.default
certificatesigningrequest.certificates.k8s.io/hello-webhook-service.default approved
(cd k8s/deployment && \
        kustomize edit set image CONTAINER_IMAGE=quay.io/didil/hello-webhook:0.1.8)
kustomize build k8s/deployment | kubectl apply -f -
deployment.apps/hello-webhook-deployment created

运行一个带有 “hello=true” 标签的 busybox 容器,检查看我们的 mutating webhook 是否在正常运行。

$ kubectl run busybox-1 --image=busybox  --restart=Never -l=app=busybox,hello=true -- sleep 3600

看看容器内的文件系统是否有 hello.txt:

$ kubectl exec busybox-1 -it -- sh -c "ls /etc/config/hello.txt"
# output
/etc/config/hello.txt

再检查下文件内容:

$ kubectl exec busybox-1 -it -- sh -c "cat /etc/config/hello.txt"

The file is in the pod container !

接下来再创建第二个容器,不带 “hello=true” 标签的:

$ kubectl run busybox-2 --image=busybox --restart=Never -l=app=busybox -- sleep 3600
# output
pod/busybox-2 created
$ kubectl exec busybox-2 -it -- sh -c "ls /etc/config/hello.txt"
# output
ls: /etc/config/hello.txt: No such file or directory

和我们预期的一致,第一次创建的 busybox 容器,匹配上了 webhook 的标签,注入了文件。第二次创建的 busybox 容器则没有。

再来检查下是否只有 buxybox-1 容器具备 “hello-added” 标签:

$ kubectl get pod -l=app=busybox -L=hello-added
# output
NAME        READY   STATUS    RESTARTS   AGE    HELLO-ADDED
busybox-1   1/1     Running   0          3m7s   OK
busybox-2   1/1     Running   0          53s

Mutating Webhook 生效了!

总结

我们尝试用 Mutating Admission Webhooks 对 Kubernetes 进行了初次拓展。文中没有提及 Validating Admission Webhooks,如果你需要对 OpenAPI schemas 之外的资源进行校验,你可以进一步深入了解。

希望本文对你有所帮助,如果你有任何问题和评论,可以联系我。下篇文章,我们将讨论另一种拓展 Kubernetes 的方式:实现一个 Kubernetes Operator。