本文介绍如何创建一个用于容器镜像仓库白名单的 Admission Webhook。
前面我们已经介绍了准入控制器的含义,了解可以通过有两个特殊的“动态”控制器 –ValidatingAdmissionWebhook
和 MutatingAdmissionWebhook
来让开发者自行实现自己的准入逻辑。这两个控制器没有实现任何固定逻辑,相反,它们使我们能够在每次在集群中创建、更新或删除Kubernetes 资源时通过 webhooks 灵活地实现和执行自定义逻辑。
示例
接下来我们将构建一个准入控制器示例,只允许使用来自白名单镜像仓库的资源创建 Pod,拒绝使用不受信任的镜像仓库中进行拉取镜像。
比如我们这里之允许使用来自 docker.io 或者 gcr.io 镜像仓库的镜像创建 Pod,其他不受信任的镜像创建的 Pod 将会被拒绝。
要实现这个需求,我们就需要构建一个 ValidatingAdmissionWebhook
,并将其注册到 APIServer。在编写这个 Webhook 之前我们就需要先链接通过注册的 Webhook 从 APIServer 接收到的请求的结构,以及我们对 APIServer 的响应结构。
APIServer 实际上使用的是一个 AdmissionReview
类型的对象来向我们自定义的 Webhook 发送请求和接收响应。
对于每个请求,在 AdmissionReview 结构体内部都有一个 AdmissionRequest 类型的属性,该属性中封装了发送到 APIServer 的原始请求数据,我们主要关心的就是该对象内部包含的正在创建/更新或删除的 Kubernetes 对象(比如 Pod、Deployment 等) JSON payload 数据。下面是用于验证准入控制器的 AdmissionReview 请求对象示例:
{ "apiVersion": "admission.k8s.io/v1", "kind": "AdmissionReview", "request": { # Random uid uniquely identifying this admission call "uid": <random uid>, ... "object": {"apiVersion":"v1","kind":"Pod",...}, ... } }
对于验证准入控制器,我们的应用程序必须接收一个 AdmissionReview
对象,对其进行处理来决定是否允许/不允许该请求,并通过在 AdmissionReview
结构中填充一个类型为 AdmissionResponse
的 response 属性来返回我们的验证结果。在 response 中,我们使用一个名为 allowed 的布尔类型来表示是否允许/不允许,我们还可以选择包含一个 HTTP 状态码和一条 message 消息,将其传递回客户端。下面是用于验证准入控制器的 AdmissionReview
响应对象示例:
{ "apiVersion": "admission.k8s.io/v1", "kind": "AdmissionReview", "response": { "uid": "<value from request.uid>", "allowed": true/false, "status": { "code": <optional http status code, ex: 200/403>, "message": "optional message" } } }
如果我们要构建一个 Mutating 准入控制器,我们将使用一个 JSONPatch
类型的对象作为 AdmissionReview
响应的 response 属性的一部分发送回变更的结果,原始请求将使用此JSON Patch 进行修改。下面是用于 Mutating 准入控制器的 AdmissionReview
响应对象示例:
{ "apiVersion": "admission.k8s.io/v1", "kind": "AdmissionReview", "response": { "uid": "<value from request.uid>", "allowed": true/false, "status": { "code": <optional http status code, ex: 200/403>, "message": "optional message" }, "patchType": "JSONPatch", "patch": <base64 encoded JSON patch> } }
关于 AdmissionReview 的完整结构定义可以查看文档:https://github.com/kubernetes/api/blob/master/admission/v1/types.go。
逻辑实现
这里我们要实现的是一个简单的带 TLS 认证的 HTTP 服务,用 Deployment 方式部署在我们的集群中。
首先新建项目:
➜ mkdir admission-registry ➜ cd admission-registry ➜ export GOPROXY=https://goproxy.cn ➜ go mod init github.com/cnych/admission-registry go: creating new go.mod: module github.com/cnych/admission-registry
然后在根目录下面新建一个 main.go 的入口文件,在该文件中定义 webhook server 的入口点,代码如下所示:
var param server.WhSvrParam // 命令行参数 flag.IntVar(¶m.Port, "port", 443, "Webhook server port.") flag.StringVar(¶m.CertFile, "tlsCertFile", "/etc/webhook/certs/tls.crt", "File containing the x509 Certificate for HTTPS.") flag.StringVar(¶m.KeyFile, "tlsKeyFile", "/etc/webhook/certs/tls.key", "File containing the x509 private key to --tlsCertFile.") flag.Parse() klog.Info(fmt.Sprintf("port=%d, cert-file=%s, key-file=%s", param.Port, param.CertFile, param.KeyFile)) pair, err := tls.LoadX509KeyPair(param.CertFile, param.KeyFile) if err != nil { klog.Errorf("Failed to load key pair: %v", err) return } whsvr := &server.WebhookServer{ Server: &http.Server{ Addr: fmt.Sprintf(":%v", param.Port), TLSConfig: &tls.Config{Certificates: []tls.Certificate{pair}}, }, WhiteListRegistries: strings.Split(os.Getenv("WHITELIST_REGISTRIES"), ","), } // 定义 http server 和 handler mux := http.NewServeMux() mux.HandleFunc("/validate", whsvr.Serve) mux.HandleFunc("/mutate", whsvr.Serve) whsvr.Server.Handler = mux // 在一个新的 goroutine 中启动 webhook server go func() { if err := whsvr.Server.ListenAndServeTLS("", ""); err != nil { klog.Errorf("Failed to listen and serve webhook server: %v", err) } }() klog.Info("Server started") // 监听 OS shutdown 信号 signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) <-signalChan klog.Infof("Got OS shutdown signal, shutting down webhook server gracefully...") if err := whsvr.Server.Shutdown(context.Background()); err != nil { klog.Errorf("HTTP server Shutdown: %v", err) }
通过 flag 来获取传递的命令行参数,比如 TLS 证书,镜像仓库白名单等。然后使用标准库 http 来定义服务,通过一个 WebhookServer
结构体进行了简单的封装,虽然我们这里主要是实现 validate 校验功能,为了扩展支持 muate,这里我们分别定义两个端点来进行支持:
mux.HandleFunc("/validate", whsvr.Serve) mux.HandleFunc("/mutate", whsvr.Serve)
所以这里最重要的就是 serve 函数了,用来处理传入的 mutate 和 validating 函数的 HTTP 请求。该函数从请求中反序列化 AdmissionReview
对象,执行一些基本的内容校验,根据 URL 路径调用相应的 mutate 和 validate 函数,然后序列化 AdmissionReview 对象:
// Serve method for webhook server func (serv *WebhookServer) Serve(w http.ResponseWriter, r *http.Request) { var body []byte if r.Body != nil { if data, err := ioutil.ReadAll(r.Body); err == nil { body = data } } if len(body) == 0 { klog.Error("empty body") http.Error(w, "empty body", http.StatusBadRequest) return } // verify the content type is accurate contentType := r.Header.Get("Content-Type") if contentType != "application/json" { klog.Errorf("Content-Type=%s, expect application/json", contentType) http.Error(w, "invalid Content-Type, expect `application/json`", http.StatusUnsupportedMediaType) return } var admissionResponse *admv1.AdmissionResponse requestedAdmissionReview := admv1.AdmissionReview{} if _, _, err := deserializer.Decode(body, nil, &requestedAdmissionReview); err != nil { klog.Errorf("Can't decode body: %v", err) admissionResponse = &admv1.AdmissionResponse{ Result: &metav1.Status{ Message: err.Error(), }, } } else { if r.URL.Path == "/mutate" { admissionResponse = serv.mutate(&requestedAdmissionReview) } else if r.URL.Path == "/validate" { admissionResponse = serv.validate(&requestedAdmissionReview) } } // 构造返回的 AdmissionReview 结构 responseAdmissionReview := admv1.AdmissionReview{} // admission.k8s.io/v1 版本需要指定对应的 APIVersion responseAdmissionReview.APIVersion = requestedAdmissionReview.APIVersion responseAdmissionReview.Kind = requestedAdmissionReview.Kind if admissionResponse != nil { // 设置 response 属性 responseAdmissionReview.Response = admissionResponse if requestedAdmissionReview.Request != nil { // 返回相同的 UID responseAdmissionReview.Response.UID = requestedAdmissionReview.Request.UID } } klog.Info(fmt.Sprintf("sending response: %v", responseAdmissionReview.Response)) respBytes, err := json.Marshal(responseAdmissionReview) if err != nil { klog.Errorf("Can't encode response: %v", err) http.Error(w, fmt.Sprintf("could not encode response: %v", err), http.StatusInternalServerError) } klog.Infof("Ready to write response ...") if _, err := w.Write(respBytes); err != nil { klog.Errorf("Can't write response: %v", err) http.Error(w, fmt.Sprintf("could not write response: %v", err), http.StatusInternalServerError) } }
在上面的 serve 函数中会根据传入的 PATH 来决定调用的逻辑,这里我们主要是实现校验的功能,所以主要是实现 validate 函数的逻辑:
// validate pod func (serv *WebhookServer) validate(ar *admv1.AdmissionReview) *admv1.AdmissionResponse { req := ar.Request var ( allowed = true code = 200 message = "" ) klog.Infof("AdmissionReview for Kind=%s, Namespace=%s Name=%v UID=%v Operation=%v UserInfo=%v", req.Kind.Kind, req.Namespace, req.Name, req.UID, req.Operation, req.UserInfo) var pod corev1.Pod if err := json.Unmarshal(req.Object.Raw, &pod); err != nil { klog.Errorf("Could not unmarshal raw object: %v", err) allowed = false code = 400 return &admv1.AdmissionResponse{ Allowed: allowed, Result: &metav1.Status{ Code: int32(code), Message: err.Error(), }, } } for _, container := range pod.Spec.Containers { var whitelisted = false for _, reg := range serv.WhiteListRegistries { if strings.HasPrefix(container.Image, reg) { whitelisted = true } } if !whitelisted { allowed = false code = 403 message = fmt.Sprintf("%s image comes from an untrusted registry! Only images from %v are allowed.", container.Image, serv.WhiteListRegistries) break } } return &admv1.AdmissionResponse{ Allowed: allowed, Result: &metav1.Status{ Code: int32(code), Message: message, }, } }
代码实现逻辑也很简单的,就是拿着传入的对象 Pod,循环里面的镜像,判断这些镜像是否都是白名单列表中的镜像,如果是则校验通过,否则校验失败,返回 allowed=false
。
部署
证书
上面我们实现了最基本的业务逻辑,由于 webhook 要求是通过 HTTPS 暴露服务,所以我们还需要为其生成相关的证书。为了方便这里我们可以使用 cfssl 来生成相关证书。
安装 cfssl:
# OS X ➜ brew install cfssl # Linux ➜ wget -q --show-progress --https-only --timestamping \ <https://pkg.cfssl.org/R1.2/cfssl_linux-amd64> \ <https://pkg.cfssl.org/R1.2/cfssljson_linux-amd64> ➜ chmod +x cfssl_linux-amd64 cfssljson_linux-amd64 ➜ sudo mv cfssl_linux-amd64 /usr/local/bin/cfssl ➜ sudo mv cfssljson_linux-amd64 /usr/local/bin/cfssljson
然后创建 CA 证书机构,执行下面的命令:
➜ cat > ca-config.json <<EOF { "signing": { "default": { "expiry": "876000h" }, "profiles": { "server": { "usages": ["signing", "key encipherment", "server auth", "client auth"], "expiry": "876000h" } } } } EOF ➜ cat > ca-csr.json <<EOF { "CA":{"expiry":"876000h"}, "CN": "kubernetes", "key": { "algo": "rsa", "size": 2048 }, "names": [ { "C": "CN", "L": "BeiJing", "ST": "BeiJing", "O": "k8s", "OU": "System" } ] } EOF
然后使用下面的命令生成 CA 证书和私钥(这里的时间范围是100年,默认是5年,需要调整,参考https://blog.csdn.net/zyj_csdn/article/details/121773809):
➜ cfssl gencert -initca ca-csr.json | cfssljson -bare ca 2021/01/23 16:59:51 [INFO] generating a new CA key and certificate from CSR 2021/01/23 16:59:51 [INFO] generate received request 2021/01/23 16:59:51 [INFO] received CSR 2021/01/23 16:59:51 [INFO] generating key: rsa-2048 2021/01/23 16:59:51 [INFO] encoded CSR 2021/01/23 16:59:51 [INFO] signed certificate with serial number 502715407096434913295607470541422244575186494509 ➜ ls -la *.pem -rw------- 1 ych staff 1675 Jan 23 17:05 ca-key.pem -rw-r--r-- 1 ych staff 1310 Jan 23 17:05 ca.pem
然后接下来就可以创建 Server 端证书了:
➜ cat > server-csr.json <<EOF { "CN": "admission", "key": { "algo": "rsa", "size": 2048 }, "names": [ { "C": "CN", "L": "BeiJing", "ST": "BeiJing", "O": "k8s", "OU": "System" } ] } EOF ➜ cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca-config.json \ -hostname=admission-registry.default.svc -profile=server server-csr.json | cfssljson -bare server 2021/01/23 17:08:37 [INFO] generate received request 2021/01/23 17:08:37 [INFO] received CSR 2021/01/23 17:08:37 [INFO] generating key: rsa-2048 2021/01/23 17:08:37 [INFO] encoded CSR 2021/01/23 17:08:37 [INFO] signed certificate with serial number 701199816701013791180179639053450980282079712724 ➜ ls -la *.pem -rw------- 1 ych staff 1675 Jan 23 17:05 ca-key.pem -rw-r--r-- 1 ych staff 1310 Jan 23 17:05 ca.pem -rw------- 1 ych staff 1675 Jan 23 17:08 server-key.pem -rw-r--r-- 1 ych staff 1452 Jan 23 17:08 server.pem
其中最重要的就是 -hostname
的值,格式为 {service-name}.{service-namespace}.svc
,其中 service-name 代表你 webhook 的 Service 名字,service-namespace 代表你 webhook 的命名空间。
然后使用生成的 server 证书和私钥创建一个 Secret 对象:
# 创建Secret ➜ kubectl create secret tls admission-registry-tls \ --key=server-key.pem \ --cert=server.pem secret/admission-registry-tls created
后面我们通过 Volumes 的形式将 Secret 挂载到 webhook 的容器中指定的位置给 webhook 使用即可。
Docker 镜像
然后接下来我们只需要将 webhook 打包成 Docker 镜像,并使用一个 Deployment 来运行这个容器应用即可,对应的 Dockerfile 文件如下所示:
# Build the webhook binary FROM golang:1.13 as builder RUN apt-get -y update && apt-get -y install upx WORKDIR /workspace # Copy the Go Modules manifests COPY go.mod go.mod COPY go.sum go.sum # Copy the go source COPY main.go main.go COPY server/ server/ # Build ENV CGO_ENABLED=0 ENV GOOS=linux ENV GOARCH=amd64 ENV GO111MODULE=on ENV GOPROXY="<https://goproxy.cn>" # cache deps before building and copying source so that we don't need to re-download as much # and so that source changes don't invalidate our downloaded layer RUN go mod download && \ go build -a -o admission-registry main.go && \ upx admission-registry FROM alpine:3.9.2 COPY --from=builder /workspace/admission-registry . ENTRYPOINT ["/admission-registry"]
这里我们使用了 Docker 的多阶段构建功能,先将项目构建打包成二进制文件,然后在 distrolesss 中运行该应用,执行项目的命令构建推送镜像即可:
➜ docker build -t cnych/admission-registry:v0.0.1 . ➜ docker push cnych/admission-registry:v0.0.1
部署 webhook
现在 webhook 的镜像已经准备好了,接下来我们就需要将其部署到 Kubernetes 集群中,这里我们使用 Deployment + Service 来提供服务即可,在 Pod 的规范中配置环境变量 WHITELIST_REGISTRIES
来定义白名单镜像仓库地址,然后将证书通过 Secret 的 Volumes 形式挂载到 Pod 容器中,对应的资源清单文件如下所示:
apiVersion: apps/v1 kind: Deployment metadata: name: admission-registry labels: app: admission-registry spec: selector: matchLabels: app: admission-registry template: metadata: labels: app: admission-registry spec: containers: - name: whitelist image: cnych/admission-registry:v0.0.1 imagePullPolicy: IfNotPresent env: - name: WHITELIST_REGISTRIES value: "docker.io,gcr.io" ports: - containerPort: 443 volumeMounts: - name: webhook-certs mountPath: /etc/webhook/certs readOnly: true volumes: - name: webhook-certs secret: secretName: admission-registry-tls --- apiVersion: v1 kind: Service metadata: name: admission-registry labels: app: admission-registry spec: ports: - port: 443 targetPort: 443 selector: app: admission-registry
直接创建上面的资源清单即可:
➜ kubectl get pods -l app=admission-registry NAME READY STATUS RESTARTS AGE admission-registry-675bc5c575-vqxsn 1/1 Running 0 37s ➜ kubectl get svc -l app=admission-registry NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE admission-registry ClusterIP 10.96.14.219 <none> 443/TCP 61s
注册 webhook
上面我们只是单纯将我们实现的 webhook 部署到了 Kubernetes 集群中,但是还并没有和 ValidatingWebhook
对接起来,要将我们上面实现的服务注册到 ValidatingWebhook
中只需要创建一个类型为 ValidatingWebhookConfiguration
的 Kubernetes 资源对象即可,在这个对象中就可以来配置我们的 webhook 这个服务。
如下所示,我们将 webhook 命名为 io.ydzs.admission-registry
,只需要保证在集群中名称唯一即可。然后在 rules 属性下面就是来指定在什么条件下使用该 webhook 的配置,这里我们只需要在创建 Pod 的时候才调用这个 webhook。此外在 ClientConfig
属性下我们还需要指定 Kubernetes APIServer 如何来找到我们的 webhook 服务,这里我们将通过一个在 default 命名空间下面的名为 admission-registry 的 Service 服务在 /validate
路径下面提供服务,此外还指定了一个 caBundle 的属性,这个属性通过指定一个 PEM 格式的 CA bundle 来表示 APIServer 作为客户端可以使用它来验证我们的 webhook 应用上的服务器证书。对应的注册 webhook 的资源清单如下所示:
# validatingwebhook.yaml apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingWebhookConfiguration metadata: name: admission-registry webhooks: - name: io.ydzs.admission-registry rules: - apiGroups: [""] apiVersions: ["v1"] operations: ["CREATE"] resources: ["pods"] clientConfig: service: namespace: default name: admission-registry path: "/validate" caBundle: CA_BUNDLE admissionReviewVersions: ["v1"] sideEffects: None
上面的 CA_BUNDLE 值使用的是上面生成 ca.crt
文件内容的 base64 值:
➜ cat ca.pem | base64 LS0tLS1CRUdVlNQ......EUtLS0tLQo=
然后将得到的值替换掉 validatingwebhook.yaml
文件中的 CA_BUNDLE
,然后就可以直接部署到集群中:
➜ kubectl apply -f manifests/validatingwebhook.yaml ➜ kubectl get validatingwebhookconfiguration NAME WEBHOOKS AGE admission-registry 1 52s
到这里我们的镜像白名单校验的 webhook 就部署完成了。
代码文件:
测试
接下来我们来测试下上面我们 webhook 是否生效了。首先创建一个如下所示的测试 Pod 清单:
# test-pod1.yaml apiVersion: v1 kind: Pod metadata: name: test-pod1 spec: containers: - name: nginx image: docker.io/nginx:latest
由于 docker.io 是我们的镜像白名单,所以正常上面的应用是可以正常创建的:
➜ kubectl apply -f test-pod1.yaml pod/test-pod1 created
然后创建另外一个 Pod,这次我们使用一个 ydzs.io 的镜像仓库的镜像:
# test-pod2.yaml apiVersion: v1 kind: Pod metadata: name: test-pod2 spec: containers: - name: nginx image: ydzs.io/nginx:latest
由于 ydzs.io 并不在我们的镜像白名单中,所以正常部署后会被拒绝:
➜ kubectl apply -f test-pod2.yaml kubectl apply -f manifests/test-pod2.yaml Error from server: error when creating "manifests/test-pod2.yaml": admission webhook "io.ydzs.admission-registry" denied the request: ydzs.io/nginx:latest image comes from an untrusted registry! Only images from [docker.io gcr.io] are allowed.
可以看到上面的 Pod 部署失败了,因为不在镜像白名单中,证明我们的校验准入控制器逻辑是正确的。
清理
要移除这个校验准入控制器比较简单,只需要移除上面的几个资源对象即可:
➜ kubectl delete -f validatingwebhook.yaml ➜ kubectl delete -f webhook.yaml
总结
这里我们只是通过一个简单的示例来说明我们应该如何去开发一个校验的准入控制器,对于 Mutate 类型的控制器实现方式也基本一致。当然如果我们只是简单的想现在下镜像仓库,我们也可以不需要自己去编写代码来实现,毕竟这样效率并不是很高,我们可以通过 Open Policy Agent Gatekeeper
项目来实现,它提供了一种通过策略配置而不是编写代码来实现类似用例的方法。
评论前必须登录!
注册