路漫漫其修远兮
吾将上下而求索

准入控制器:ValidatingAdmissionWebhook 实现

本文介绍如何创建一个用于容器镜像仓库白名单的 Admission Webhook。

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

https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/20210123111237.png

示例

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

https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/admission-test-pod2.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)
}

通过 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 就部署完成了。

代码文件:

admission-registry-main.zip

测试

接下来我们来测试下上面我们 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 项目来实现,它提供了一种通过策略配置而不是编写代码来实现类似用例的方法。

未经允许不得转载:江哥架构师笔记 » 准入控制器:ValidatingAdmissionWebhook 实现

分享到:更多 ()

评论 抢沙发

评论前必须登录!