美文网首页
ValidatingAdmissionWebhook 实现

ValidatingAdmissionWebhook 实现

作者: 程序员札记 | 来源:发表于2022-06-20 20:54 被阅读0次

    前面我们已经介绍了准入控制器的含义,了解可以通过有两个特殊的“动态”控制器 –ValidatingAdmissionWebhookMutatingAdmissionWebhook 来让开发者自行实现自己的准入逻辑。这两个控制器没有实现任何固定逻辑,相反,它们使我们能够在每次在集群中创建、更新或删除Kubernetes 资源时通过 webhooks 灵活地实现和执行自定义逻辑。

    image.png

    示例

    接下来我们将构建一个准入控制器示例,只允许使用来自白名单镜像仓库的资源创建 Pod,拒绝使用不受信任的镜像仓库中进行拉取镜像。

    image.png

    比如我们这里之允许使用来自 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(&param.Port, "port", 443, "Webhook server port.")
    flag.StringVar(&param.CertFile, "tlsCertFile", "/etc/webhook/certs/tls.crt", "File containing the x509 Certificate for HTTPS.")
    flag.StringVar(&param.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)
    }
    
    

    所以这里最重要的就是 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": "8760h"
        },
        "profiles": {
          "server": {
            "usages": ["signing", "key encipherment", "server auth", "client auth"],
            "expiry": "8760h"
          }
        }
      }
    }
    EOF
    
    ➜  cat > ca-csr.json <<EOF
    {
        "CN": "kubernetes",
        "key": {
            "algo": "rsa",
            "size": 2048
        },
        "names": [
            {
                "C": "CN",
                "L": "BeiJing",
                "ST": "BeiJing",
                "O": "k8s",
                "OU": "System"
            }
        ]
    }
    EOF
    
    

    然后使用下面的命令生成 CA 证书和私钥:

    ➜  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
    
    

    相关文章

      网友评论

          本文标题:ValidatingAdmissionWebhook 实现

          本文链接:https://www.haomeiwen.com/subject/rxexvrtx.html