第十三课 k8s源码学习和二次开发原理篇-准入控制器
tags:
- k8s
- 源码学习
categories:
- 源码学习
- 二次开发
- 准入控制器
文章目录
第一节 Admission Webhook介绍
1.1 准入控制器介绍
- Kubernetes 提供了需要扩展其内置功能的方法,最常用的可能是自定义资源类型和自定义控制器了,除此之外,Kubernetes 还有一些其他非常有趣的功能,比如
admission webhooks
就可以用于扩展 API,用于修改某些 Kubernetes 资源的基本行为。 - 准入控制器是在对象持久化之前用于对 Kubernetes API Server 的请求进行拦截的代码段,在请求经过身份验证和授权之后放行通过。
- 准入控制器可能正在
validating
、mutating
或者都在执行,Mutating
控制器可以修改他们处理的资源对象,Validating
控制器不会,如果任何一个阶段中的任何控制器拒绝了请求,则会立即拒绝整个请求,并将错误返回给最终的用户。 - 这意味着有一些特殊的控制器可以拦截 Kubernetes API 请求,并根据自定义的逻辑修改或者拒绝它们。Kubernetes 有自己实现的一个控制器列表:https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#what-does-each-admission-controller-do,当然你也可以编写自己的控制器,虽然这些控制器听起来功能比较强大,但是这些控制器需要被编译进 kube-apiserver,并且只能在 apiserver 启动时启动。
- 由于上面的控制器的限制,我们就需要用到动态的概念了,而不是和 apiserver 耦合在一起,
Admission webhooks
就通过一种动态配置方法解决了这个限制问题。 - 在 Kubernetes apiserver 中包含两个特殊的准入控制器:
MutatingAdmissionWebhook
和ValidatingAdmissionWebhook
,这两个控制器将发送准入请求到外部的 HTTP 回调服务并接收一个准入响应。如果启用了这两个准入控制器,Kubernetes 管理员可以在集群中创建和配置一个 admission webhook。
- 整体的步骤如下所示:
- 检查集群中是否启用了 admission webhook 控制器,并根据需要进行配置。
- 编写处理准入请求的 HTTP 回调,回调可以是一个部署在集群中的简单 HTTP 服务,甚至也可以是一个
serverless
函数,例如 https://github.com/kelseyhightower/denyenv-validating-admission-webhook 这个项目。 - 通过
MutatingWebhookConfiguration
和ValidatingWebhookConfiguration
资源配置 admission webhook。
- 这两种类型的 admission webhook 之间的区别是非常明显的:
validating webhooks
可以拒绝请求,但是它们却不能修改准入请求中获取的对象,而mutating webhooks
可以在返回准入响应之前通过创建补丁来修改对象,如果 webhook 拒绝了一个请求,则会向最终用户返回错误。 - 现在非常火热的 Service Mesh 应用
istio
就是通过 mutating webhooks 来自动将Envoy
这个 sidecar 容器注入到 Pod 中去的:https://istio.io/docs/setup/kubernetes/sidecar-injection/。
1.2 创建配置一个 Admission Webhook
- 上面我们介绍了 Admission Webhook 的理论知识,接下来我们在一个真实的 Kubernetes 集群中来实际测试使用下,我们将创建一个 webhook 的 webserver,将其部署到集群中,然后创建 webhook 配置查看是否生效。
- 首先确保在 apiserver 中启用了
MutatingAdmissionWebhook
和ValidatingAdmissionWebhook
这两个控制器,由于我这里集群使用的是 kubeadm 搭建的,可以通过查看 apiserver Pod 的配置:
# kubectl get pods kube-apiserver-ydzs-master -n kube-system -o yaml
apiVersion: v1
kind: Pod
metadata:
labels:
component: kube-apiserver
tier: control-plane
name: kube-apiserver-ydzs-master
namespace: kube-system
......
spec:
containers:
- command:
- kube-apiserver
- --advertise-address=10.151.30.11
- --allow-privileged=true
- --authorization-mode=Node,RBAC
- --client-ca-file=/etc/kubernetes/pki/ca.crt
- --enable-admission-plugins=NodeRestriction,MutatingAdmissionWebhook,ValidatingAdmissionWebhook
......
# 或者通过cat /etc/kubernetes/manifests/kube-apiserver.yaml --enable-admission-plugins选项查看 如果没开启MutatingAdmissionWebhook,ValidatingAdmissionWebhook 需要手动开启
- 上面的
enable-admission-plugins
参数中带上了MutatingAdmissionWebhook
和ValidatingAdmissionWebhook
两个准入控制插件,如果没有的(当前 v1.19.x 版本是默认开启的),需要添加上这两个参数,然后重启 apiserver。 - 然后通过运行下面的命令检查集群中是否启用了准入注册 API:
kubectl api-versions |grep admission
admissionregistration.k8s.io/v1
admissionregistration.k8s.io/v1beta1
- 接下来我们就去实现下我们自己的准入控制器。
第二节 ValidatingAdmissionWebhook 实现
2.1 准入控制器示例
- 通过有两个特殊的“动态”控制器 -
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。
2.2 准入控制器逻辑实现
- 这里我们要实现的是一个简单的带 TLS 认证的 HTTP 服务,用 Deployment 方式部署在我们的集群中。webhook实际上是一个http的服务端。
- 首先新建项目:
mkdir admission-registry
cd admission-registry
export GOPROXY=https://goproxy.cn
go mod init gitee.com/k8sdev/admission-registry
- 然后在根目录下面新建一个** main.go 的入口文件**,在该文件中定义 webhook server 的入口点,代码如下所示:
package main
import (
"context"
"crypto/tls"
"flag"
"fmt"
"gitee.com/k8sdev/admission-registry/pkg"
"k8s.io/klog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
)
func main(){
var param pkg.WhSvrParam
// webhook http server (tls)
// 命令行参数传递证书
flag.IntVar(¶m.Port, "port", 443, "Webhook Server")
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()
// 先获取k8s中 日志模块 go get k8s.io/klog
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
}
// 实例化一个webhook server
whsvr := pkg.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)
}
}
// admission-registry/pkg/webhook.go
package pkg
import "net/http"
type WhSvrParam struct {
Port int
CertFile string
KeyFile string
}
type WebhookServer struct {
Server *http.Server // http server
WhiteListRegistries []string //白名单镜像仓库列表
}
func (s *WebhookServer) Serve(writer http.ResponseWriter, request *http.Request){
}
- 通过 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 函数中会根据传入的 PATH 来决定调用的逻辑,这里我们主要是实现校验的功能,所以主要是实现 validate 函数的逻辑:
// admission-registry/pkg/webhook.go
package pkg
import (
"encoding/json"
"fmt"
"io/ioutil"
admissionV1 "k8s.io/api/admission/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/klog"
"net/http"
"strings"
)
var (
// 反序列化 go get k8s.io/apimachinery
runtimeScheme = runtime.NewScheme()
codeFactory = serializer.NewCodecFactory(runtimeScheme)
deserializer = codeFactory.UniversalDeserializer()
)
type WhSvrParam struct {
Port int
CertFile string
KeyFile string
}
type WebhookServer struct {
Server *http.Server // http server
WhiteListRegistries []string //白名单镜像仓库列表
}
func (s *WebhookServer) Serve(writer http.ResponseWriter, request *http.Request){
var body []byte
if request.Body != nil {
if data, err := ioutil.ReadAll(request.Body); err == nil {
body = data
}
}
if len(body) == 0 {
klog.Error("empty body")
http.Error(writer, "empty body", http.StatusBadRequest)
return
}
// verify the content type is accurate 校验Content-Type
contentType := request.Header.Get("Content-Type")
if contentType != "application/json" {
klog.Errorf("Content-Type=%s, expect application/json", contentType)
http.Error(writer, "invalid Content-Type, expect `application/json`", http.StatusUnsupportedMediaType)
return
}
// 数据序列化(validate、mutate)请求的数据都是 AdmissionReview
// go get k8s.io/api
var admissionResponse *admissionV1.AdmissionResponse
requestedAdmissionReview := admissionV1.AdmissionReview{}
if _, _, err := deserializer.Decode(body, nil, &requestedAdmissionReview); err != nil {
klog.Errorf("Can't decode body: %v", err)
admissionResponse = &admissionV1.AdmissionResponse{
Result: &metav1.Status{
Message: err.Error(),
},
}
} else {
// 序列化成功,也就是说获取到了请求的 AdmissionReview的数据
if request.URL.Path == "/mutate" {
// TODO
} else if request.URL.Path == "/validate" {
admissionResponse = s.validate(&requestedAdmissionReview)
}
}
// 构造返回的 AdmissionReview 结构
responseAdmissionReview := admissionV1.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))
// send response
respBytes, err := json.Marshal(responseAdmissionReview)
if err != nil {
klog.Errorf("Can't encode response: %v", err)
http.Error(writer, fmt.Sprintf("could not encode response: %v", err), http.StatusInternalServerError)
}
klog.Infof("Ready to write response ...")
if _, err := writer.Write(respBytes); err != nil {
klog.Errorf("Can't write response: %v", err)
http.Error(writer, fmt.Sprintf("could not write response: %v", err), http.StatusInternalServerError)
}
}
func (s *WebhookServer) validate(ar *admissionV1.AdmissionReview) *admissionV1.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 &admissionV1.AdmissionResponse{
Allowed: allowed,
Result: &metav1.Status{
Code: int32(code),
Message: err.Error(),
},
}
}
// 处理真正的业务逻辑
for _, container := range pod.Spec.Containers {
var whitelisted = false
for _, reg := range s.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, s.WhiteListRegistries)
break
}
}
return &admissionV1.AdmissionResponse{
Allowed: allowed,
Result: &metav1.Status{
Code: int32(code),
Message: message,
},
}
}
- 代码实现逻辑也很简单的,就是拿着传入的对象 Pod,循环里面的镜像,判断这些镜像是否都是白名单列表中的镜像,如果是则校验通过,否则校验失败,返回
allowed=false
。
2.3 证书准备
- 上面我们实现了最基本的业务逻辑,由于 webhook 要求是通过 HTTPS 暴露服务,所以我们还需要为其生成相关的证书。为了方便这里我们可以使用 cfssl 来生成相关证书。
- 安装 cfssl:
# mac
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
- 在项目目录下创建certs文件夹,然后创建 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
ls -la *.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
ls -la *.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 使用即可。
2.4 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 pkg/ pkg/
# 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 qnhyn/admission-registry:v0.0.1 .
docker push qnhyn/admission-registry:v0.0.1
2.5 部署webhook
- 现在 webhook 的镜像已经准备好了,接下来我们就需要将其部署到 Kubernetes 集群中,这里我们使用 Deployment + Service 来提供服务即可,在 Pod 的规范中配置环境变量
WHITELIST_REGISTRIES
来定义白名单镜像仓库地址,然后将证书通过 Secret 的 Volumes 形式挂载到 Pod 容器中,对应的资源清单文件如下所示:
# webhook.yaml
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: qnhyn/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
kubectl get svc -l app=admission-registry
2.6 注册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 # 把这个值覆盖上面CA_BUNDLE变量
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUR2akNDQXFhZ0F3SUJBZ0lVRXV6ZE9LTld2cDJZK3FRU2tyay9yRkhNMXFrd0RRWUpLb1pJaHZjTkFRRUwKQlFBd1pURUxNQWtHQTFVRUJoTUNRMDR4RURBT0JnTlZCQWdUQjBKbGFVcHBibWN4RURBT0JnTlZCQWNUQjBKbAphVXBwYm1jeEREQUtCZ05WQkFvVEEyczRjekVQTUEwR0ExVUVDeE1HVTNsemRHVnRNUk13RVFZRFZRUURFd3ByCmRXSmxjbTVsZEdWek1CNFhEVEl4TVRBd05URTBOVEV3TUZvWERUSTJNVEF3TkRFME5URXdNRm93WlRFTE1Ba0cKQTFVRUJoTUNRMDR4RURBT0JnTlZCQWdUQjBKbGFVcHBibWN4RURBT0JnTlZCQWNUQjBKbGFVcHBibWN4RERBSwpCZ05WQkFvVEEyczRjekVQTUEwR0ExVUVDeE1HVTNsemRHVnRNUk13RVFZRFZRUURFd3ByZFdKbGNtNWxkR1Z6Ck1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBUThBTUlJQkNnS0NBUUVBN08xSWhMMkI1WEVaR25USXVXZDkKMEhCR2QwNFcvQzJ4d3JBY21RYmc0Um5nK3JGMGc1Qkw5MEliS3NIdk5Ed1FQbkluclgrcmhVSXZiSGNmYnlpVwoyY1NuY055MTJwbWlVR21pMHlsU2dueXhtT21hcVM5cmNkcDVudm5sWGdSa1g3bHpmS0RsMGlzYWVkWGhKVUV0CkI1N2tKckY0czlKakdUbkxjZ1BrRFFFS3lncFVPRlZvRXNQMHY0VnpkeU5FRUpWWFN5dm9Zems0d2lUcE9RNzMKdk1QZDh6aHI3bzhnRFVNQUN3MmhQMFJVRVo5ZGNWb01jWjlxWXpjZHFPek1DV2dSaXlaUW14aytQRUVObStoZApPVFl6LzdOTUZSVnpUNkd6UGZBY0RKbVZObDVOZEs5c1FMNUV6Ym42UU5tWW95a1lGTWFWSG1xMEVnYkw1Rm5kCi9RSURBUUFCbzJZd1pEQU9CZ05WSFE4QkFmOEVCQU1DQVFZd0VnWURWUjBUQVFIL0JBZ3dCZ0VCL3dJQkFqQWQKQmdOVkhRNEVGZ1FVeGdxa3BKRFNyUVVKVnI4TDNhREx2bVJ6OHNZd0h3WURWUjBqQkJnd0ZvQVV4Z3FrcEpEUwpyUVVKVnI4TDNhREx2bVJ6OHNZd0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQkFFZEp6ZVlwQWQ2N241QWltU0tKCmg1Z0NrdnYxZDdOeUJseklxOWdtNU00aEpXZGlNaHJUYXRTK2hKUjFtQkFNbVhtTzcxbTY5empKV2RKZ0JFN28KZ28xdGVndVIvdU9aS0ZTV3NiaTZZWDFmSkFVeG95SnBpSHg0dVlvUkIybERNNkNub3l1L2N1TXhtV0JzelN4eQo1NGZPYVdTUnBxd2wrSFAwYkltbUJUMlhzK0VyNmdldWRiTE1nZjRCcVZ3Q1pNbnd5T2EyVG5ZM25uWklDUnMxCjltN2JDSUh6U09xWlR6N1NrNHUyM0RhdW82S0tnVDZxeE8xTmNSQVV1K2RwNkdZWjBRZ2ZVWUVZeGx2ZDAvSXMKaTYzN21HbkZCWmZPUzlDa1hTaU5KYnZxZzNvNmJGSFFUT2xyK2lYcnhRUGp0aFBCbGVVQ3FqeW5VKzVEYUgyYQo0Wk09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
- 然后将得到的值替换掉
validatingwebhook.yaml
文件中的CA_BUNDLE
,然后就可以直接部署到集群中:
kubectl apply -f validatingwebhook.yaml
kubectl get validatingwebhookconfiguration
- 到这里我们的镜像白名单校验的 webhook 就部署完成了。
2.7 测试功能
- 接下来我们来测试下上面我们 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,这次我们使用一个 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 部署失败了,因为不在镜像白名单中,证明我们的校验准入控制器逻辑是正确的。
2.8 清理环境
- 要移除这个校验准入控制器比较简单,只需要移除上面的几个资源对象即可:
kubectl delete -f validatingwebhook.yaml
kubectl delete -f webhook.yaml
- 这里我们只是通过一个简单的示例来说明我们应该如何去开发一个校验的准入控制器,对于 Mutate 类型的控制器实现方式也基本一致。当然如果我们只是简单的想现在下镜像仓库,我们也可以不需要自己去编写代码来实现,毕竟这样效率并不是很高,我们可以通过
Open Policy Agent Gatekeeper
项目来实现,它提供了一种通过策略配置而不是编写代码来实现类似用例的方法。
第三节 MutatingAdmissionWebhook实现
3.1 MutatingAdmissionWebhook的需求
- 上面我们已经实现了一个校验的准入控制器,我们来尝试开发一个用于 Mutate 的准入控制器,这两个控制器其实都是我们自己通过 Webhook 去实现,而且他们接收和返回的响应数据结构都是 AdmissionReview,唯一不同的是对于 Mutating 的 Webhook 在处理了资源对象后返回的时候需要我们拼接一个 JSONPatch 的数据。
2. 接下来我们在前面的镜像白名单的 Webhook 基础之上新增 mutate 的支持,该项目中我们也预留了 mutate 的入口,通过 /mutate 路径的请求进行 mutate 操作。
3. 比如我们的需求是当我们的资源对象(Deployment 或 Service)中包含一个需要 mutate 的 annotation 注解后,通过这个 Webhook 后我们就给这个对象添加上一个执行了 mutate 操作的注解。
3.2 MutatingAdmissionWebhook逻辑实现
- 首先在 WebhookServ 的 Serve 函数中新增 mutate 的逻辑入口函数:
// 序列化成功,也就是说获取到了请求的 AdmissionReview 的数据
if request.URL.Path == "/mutate" {
admissionResponse = s.mutate(&requestedAdmissionReview)
} else if request.URL.Path == "/validate" {
admissionResponse = s.validate(&requestedAdmissionReview)
}
- 然后我们的 mutate 逻辑就在下面的函数中去实现了:
func (s *WebhookServer) mutate(ar *admissionV1.AdmissionReview) *admissionV1.AdmissionResponse {
//Deployment、Service - annotations: AnnotationMutateKey,AnnotationStatusKey
req := ar.Request
var (
objectMeta *metav1.ObjectMeta
resourceNamespace, resourceName string
)
klog.Infof("AdmissionReview for Kind=%v, Namespace=%v Name=%v UID=%v Operation=%v",
req.Kind.Kind, req.Namespace, req.Name, req.UID, req.Operation)
switch req.Kind.Kind {
case "Deployment":
var deployment appsv1.Deployment
if err := json.Unmarshal(req.Object.Raw, &deployment); err != nil {
klog.Errorf("Could not unmarshal raw object: %v", err)
return &admissionV1.AdmissionResponse{
Result: &metav1.Status{
Code: http.StatusBadRequest,
Message: err.Error(),
},
}
}
resourceName, resourceNamespace, objectMeta = deployment.Name, deployment.Namespace, &deployment.ObjectMeta
case "Service":
var service corev1.Service
if err := json.Unmarshal(req.Object.Raw, &service); err != nil {
klog.Errorf("Could not unmarshal raw object: %v", err)
return &admissionV1.AdmissionResponse{
Result: &metav1.Status{
Code: http.StatusBadRequest,
Message: err.Error(),
},
}
}
resourceName, resourceNamespace, objectMeta = service.Name, service.Namespace, &service.ObjectMeta
default:
return &admissionV1.AdmissionResponse{
Result: &metav1.Status{
Code: http.StatusBadRequest,
Message: fmt.Sprintf("Can't handle this kind(%s) object", req.Kind.Kind),
},
}
}
if !mutationRequired(objectMeta) {
klog.Infof("Skipping validation for %s/%s due to policy check", resourceNamespace, resourceName)
return &admissionV1.AdmissionResponse{
Allowed: true,
}
}
annotations := map[string]string{AnnotationStatusKey: "mutated"}
var patch []patchOperation
patch = append(patch, mutateAnnotation(objectMeta.GetAnnotations(), annotations)...)
patchBytes, err := json.Marshal(patch)
if err != nil {
return &admissionV1.AdmissionResponse{
Result: &metav1.Status{
Message: err.Error(),
},
}
}
klog.Infof("AdmissionResponse: patch=%v\n", string(patchBytes))
return &admissionV1.AdmissionResponse{
Allowed: true,
Patch: patchBytes,
PatchType: func() *admissionV1.PatchType {
pt := admissionV1.PatchTypeJSONPatch
return &pt
}(),
}
}
- 在这个函数中我们针对 Deployment 和 Service 两种资源类型进行处理,首先通过
mutationRequired
函数来判断当前资源对象是否需要执行 mutate 操作。
func mutationRequired(metadata *metav1.ObjectMeta) bool {
annotations := metadata.GetAnnotations()
if annotations == nil {
annotations = map[string]string{}
}
var required bool
switch strings.ToLower(annotations[AnnotationMutateKey]) {
default:
required = true
case "n", "no", "false", "off":
required = false
}
status := annotations[AnnotationStatusKey]
if strings.ToLower(status) == "mutated" {
required = false
}
klog.Infof("Mutation policy for %v/%v: required:%v", metadata.Namespace, metadata.Name, required)
return required
}
- 如果资源对象中包含的
AnnotationMutateKey
这个 annotation 对应的值为 “n”、“no”、“false”、“off” 中的任何一个则不需要执行 mutate 操作,或者AnnotationStatusKey
这个 annotation 对应的值已经是 mutated 了则也不需要,否则就需要执行 mutate 操作。 - 如果需要执行 mutate 操作,则需要我们自己创建 Patch 操作,将 {AnnotationStatusKey: “mutated”} 这个 annotation Patch 到资源中去:
annotations := map[string]string{AnnotationStatusKey: "mutated"}
var patch []patchOperation
patch = append(patch, updateAnnotation(objectMeta.GetAnnotations(), annotations)...)
- 这里要执行 Patch 操作,需要定义一个如下所示的 patchOperation 的结构体:
type patchOperation struct {
Op string `json:"op"`
Path string `json:"path"`
Value interface{} `json:"value,omitempty"`
}
- 然后在
updateAnnotation
函数中来创建更新 annotation 的 Patch:
func mutateAnnotation(target map[string]string, added map[string]string) (patch []patchOperation) {
for key, value := range added {
if target == nil || target[key] == "" {
target = map[string]string{}
patch = append(patch, patchOperation{
Op: "add",
Path: "/metadata/annotations",
Value: map[string]string{
key: value,
},
})
} else {
patch = append(patch, patchOperation{
Op: "replace",
Path: "/metadata/annotations/" + key,
Value: value,
})
}
}
return patch
}
- 最后需要将创建的 Patch 进行序列化,通过
AdmissionResponse
返回给 APIServer 即可:
patchBytes, err := json.Marshal(patch)
if err != nil {
return &admissionv1.AdmissionResponse{
Result: &metav1.Status{
Message: err.Error(),
},
}
}
klog.Infof("AdmissionResponse: patch=%v\n", string(patchBytes))
return &admissionv1.AdmissionResponse{
Allowed: true,
Patch: patchBytes,
PatchType: func() *admissionv1.PatchType {
pt := admissionv1.PatchTypeJSONPatch
return &pt
}(),
}
- 业务逻辑实现完成后,当然同样还是重新构建打包镜像,重新部署 Webhook 服务,然后同样还需要将这个 Webhook 进行注册,创建一个如下所示的资源对象:
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: admission-registry-mutate
webhooks:
- name: io.ydzs.admission-registry-mutate
clientConfig:
service:
namespace: default
name: admission-registry
path: "/mutate"
caBundle: CA_BUNDLE
rules:
- operations: [ "CREATE" ]
apiGroups: ["apps", ""]
apiVersions: ["v1"]
resources: ["deployments","services"]
admissionReviewVersions: [ "v1" ]
sideEffects: None
3.3 MutatingAdmissionWebhook功能测试
- 接下来我们首先创建如下所示的两个资源对象:
# test-mutating-deploy1.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: test-deploy1
spec:
selector:
matchLabels:
app: test1-mutate
template:
metadata:
labels:
app: test1-mutate
spec:
containers:
- name: mutate
image: docker.io/nginx:1.7.9
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: test-svc1
spec:
selector:
app: test1-mutate
ports:
- port: 80
targetPort: 80
type: ClusterIP
- 由于上面的 Deployment 和 Service 没有添加任何的 annotation,所以正常通过上面我们的 mutate 这个准入控制器过后会被添加上一个 annotion:
kubectl apply -f test-deploy1.yaml
kubectl get deploy test-deploy1 -o yaml
############
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
deployment.kubernetes.io/revision: "1"
io.ydzs.admission-registry/status: mutated
......
kubectl get svc test-svc1 -o yaml
apiVersion: v1
kind: Service
metadata:
annotations:
io.ydzs.admission-registry/status: mutated
......
- 可以看到创建的 Deployment 和 Service 都被添加了一个
io.ydzs.admission-registry/status: mutated
的 annotation。接下来再创建一个如下所示的资源对象:
apiVersion: apps/v1
kind: Deployment
metadata:
name: test-deploy2
annotations:
io.ydzs.admission-registry/mutate: "no"
spec:
selector:
matchLabels:
app: test2-mutate
template:
metadata:
labels:
app: test2-mutate
spec:
containers:
- name: mutate
image: docker.io/nginx:1.7.9
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
- 由于该资源对象中本身就包含一个
io.ydzs.admission-registry/mutate: "no"
的 annotation,所以正常创建后不会被添加新的 annotation 了:
kubectl apply -f test-deploy2.yaml
kubectl get deploy test-deploy2 -o yaml
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
deployment.kubernetes.io/revision: "1"
io.ydzs.admission-registry/mutate: "no"
kubectl.kubernetes.io/last-applied-configuration: |
{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"annotations":{"io.ydzs.admission-registry/mutate":"no"},"name":"test-deploy2","namespace":"default"},"spec":{"selector":{"matchLabels":{"app":"test2-mutate"}},"template":{"metadata":{"labels":{"app":"test2-mutate"}},"spec":{"containers":[{"image":"docker.io/nginx:1.7.9","imagePullPolicy":"IfNotPresent","name":"mutate","ports":[{"containerPort":80}]}]}}}}
......
- 到这里就成功验证了 Mutate 这个准入控制器,很多时候可能不只是单纯的添加一个 annotation,很有可能是添加一个容器,添加一个环境变量,或者 volumes,这些实现方式都是一样的了。
第四节 管理 Admission Webhook 的 TLS 证书
4.1 Admission Webhook的TLS证书管理需求
- 我们开发自己的准入控制器 Webhook,这些准入 Webhook 控制器调用自定义配置的 HTTP 回调服务来进行其他检查。但是,APIServer 仅通过 HTTPS 与 Webhook 服务进行通信,并且需要 TLS 证书的 CA 信息。所以对于如何处理该 Webhook 服务证书以及如何将 CA 信息自动传递给 APIServer 带来了一些麻烦。
- 前面我们是通过 openssl(cfssl)来手动生成的相关证书,然后手动配置给 Webhook 服务的,除此之外,我们也可以使用cert-manager 来处理这些 TLS 证书和 CA。但是,cert-manager 本身是一个比较大的应用程序,由许多 CRD 组成来处理其操作。仅安装 cert-manager 来处理准入 webhook TLS 证书和 CA 不是一个很好的做法。
- 另外一种做法就是我们可以使用自签名证书,然后通过使用 Init 容器来自行处理 CA,这就消除了对其他应用程序(如 cert-manager)的依赖。接下来我们就来重点介绍下如何使用这种方式来管理相关证书。
4.2 初始化容器
- 这个初始化容器的主要功能是创建一个自签名的 Webhook 服务证书,并通过 mutate/验证配置将 caBundle 提供给 APIServer。Webhook 服务如何使用该证书(通过 Secret Volumes 或 emptyDir),取决于实际情况。这里我们这个初始化容器将运行一个简单的 Go 二进制文件来执行这些功能。核心代码如下所示:
package main
import (
"bytes"
cryptorand "crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
log "github.com/sirupsen/logrus"
"math/big"
"os"
"time"
)
func main() {
var caPEM, serverCertPEM, serverPrivKeyPEM *bytes.Buffer
// CA config ca 配置
ca := &x509.Certificate{
SerialNumber: big.NewInt(2021),
Subject: pkix.Name{
Country: []string{"CN"},
Province: []string{"Beijing"},
Locality: []string{"Beijing"},
Organization: []string{"ydzs.io"},
OrganizationalUnit: []string{"ydzs.io"},
},
NotBefore: time.Now(), // 有效期
NotAfter: time.Now().AddDate(10, 0, 0),
IsCA: true, // 根证书
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
}
// CA private key 生成ca私钥
caPrivKey, err := rsa.GenerateKey(cryptorand.Reader, 4096)
if err != nil {
fmt.Println(err)
}
// Self signed CA certificate 创建自签名的CA证书
caBytes, err := x509.CreateCertificate(cryptorand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey)
if err != nil {
fmt.Println(err)
}
// PEM encode CA cert 编码证书文件
caPEM = new(bytes.Buffer)
_ = pem.Encode(caPEM, &pem.Block{
Type: "CERTIFICATE",
Bytes: caBytes,
})
// 对哪些域名签名
dnsNames := []string{"admission-registry",
"admission-registry.default", "admission-registry.default.svc",
"admission-registry.default.svc.cluster.local"}
commonName := "admission-registry.default.svc"
// server cert config 服务端证书配置
cert := &x509.Certificate{
DNSNames: dnsNames,
SerialNumber: big.NewInt(1658),
Subject: pkix.Name{
CommonName: commonName,
Country: []string{"CN"},
Province: []string{"Beijing"},
Locality: []string{"Beijing"},
Organization: []string{"ydzs.io"},
OrganizationalUnit: []string{"ydzs.io"},
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1, 0, 0),
SubjectKeyId: []byte{1, 2, 3, 4, 6},
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature,
}
// server private key 生成服务器私钥
serverPrivKey, err := rsa.GenerateKey(cryptorand.Reader, 4096)
if err != nil {
fmt.Println(err)
}
// sign the server cert 服务端私钥签名
serverCertBytes, err := x509.CreateCertificate(cryptorand.Reader, cert, ca, &serverPrivKey.PublicKey, caPrivKey)
if err != nil {
fmt.Println(err)
}
// PEM encode the server cert and key 服务端编码证书文件
serverCertPEM = new(bytes.Buffer)
if err := pem.Encode(serverCertPEM, &pem.Block{
Type: "CERTIFICATE",
Bytes: serverCertBytes,
}); err != nil{
log.Panic(err)
}
serverPrivKeyPEM = new(bytes.Buffer)
if err := pem.Encode(serverPrivKeyPEM, &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(serverPrivKey),
}); err != nil{
log.Panic(err)
}
// 已经生成了CA server.pem server-key. pem
if err := os.MkdirAll("/etc/webhook/certs/", 0666); err != nil {
log.Panic(err)
}
if err := WriteFile("/etc/webhook/certs/tls.crt", serverCertPEM); err != nil{
log.Panic(err)
}
if err := WriteFile("/etc/webhook/certs/tls.key", serverPrivKeyPEM); err != nil{
log.Panic(err)
}
}
// WriteFile writes data in the file at the given path
func WriteFile(filepath string, sCert *bytes.Buffer) error {
f, err := os.Create(filepath)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(sCert.Bytes())
if err != nil {
return err
}
return nil
}
- 的代码中我们通过生成自签名的 CA 并签署 Webhook 服务证书来提供服务:
- 首先为 CA 创建一个配置 ca
- 为该 CA 创建一个 RSA 私钥 caPrivKey
- 生成一个自签名的 CA、caByte 和 caPEM,在这里,caPEM 是 PEM 编码的 caBytes,将是提供给 APIServer 的 CA_BUNDLE 数据
- 创建 webhook 服务证书的配置,即上面代码中的 cert。该配置中的重要属性是 DNSNames 和 commonName,要注意的是该名称必须是到达 Webhook 服务的完整地址名称
- 然后为 Webhook 服务创建一个 RS 私钥 serverPrivKey
- 使用上面代码中的 ca 和 caPrivKey 创建服务端证书 serverCertBytes
- 然后用 PEM 对 serverPrivKey 和 serverCertBytes 进行编码,这个 serverPrivKeyPEM 和 serverCertPEM 就是 TLS 证书和密钥了,将由 Webhook 服务使用。
- 我们就可以生成所需的证书,密钥和 CA_BUNDLE 数据了。然后我们将与同一 Pod 中的实际 Webhook 服务容器共享该服务器证书和密钥。
- 一种方法是事先创建一个空的 Secret 资源,通过将该 Secret 作为环境变量传递来创建 Webhook 服务,初始化容器将生成服务器证书和密钥,并用证书和密钥信息来填充该 Secret。此 Secret 将安装到 Webhook 服务容器上,以使用 TLS 来启动 HTTP 服务器。
- 第二种方法(在上面的代码中使用)是使用 Kubernete 的本地 Pod 特定的 emptyDir 卷。该数据卷将在两个容器之间共享,在上面的代码中,我们可以看到 init 容器将这些证书和密钥信息写入特定路径的文件中,该路径就是其中的一个 emptyDir 卷,并且 Webhook 服务容器将从该路径读取用于 TLS 配置的证书和密钥,并启动 HTTP Webhook 服务器。请参考下图:
- hook 的 Pod 规范如下所示:
spec:
initContainers:
- image: <webhook init-image name>
imagePullPolicy: IfNotPresent
name: webhook-init
volumeMounts:
- mountPath: /etc/webhook/certs
name: webhook-certs
containers:
- image: <webhook server image name>
imagePullPolicy: IfNotPresent
name: webhook-server
volumeMounts:
- mountPath: /etc/webhook/certs
name: webhook-certs
readOnly: true
volumes:
- name: webhook-certs
emptyDir: {}
4.3 处理 CA Bundle
- 然后剩下的就只有使用 mutate/验证配置将 CA_BUNDLE 信息提供给 APIServer,这可以通过两种方式完成:
- 使用 init 容器中的 client-go 在现有
MutatingWebhookConfiguration
或ValidatingWebhookConfiguration
中来修补 CA_BUNDLE 数据。 - 另一种方式使用配置中的 CA_BUNDLE 数据在 init 容器本身中直接创建
MutatingWebhookConfiguration
或ValidatingWebhookConfiguration
即可。
- 使用 init 容器中的 client-go 在现有
- 在这里,我们将通过 init 容器来创建配置,通过动态获取某些参数,例如 mutate 配置名称,Webhook 服务名称和 Webhook 命名空间,我们都可以直接从 init 容器的环境变量中来获取这些值:
initContainers:
- image: <webhook init-image name>
imagePullPolicy: IfNotPresent
name: webhook-init
volumeMounts:
- mountPath: /etc/webhook/certs
name: webhook-certs
env:
- name: MUTATE_CONFIG
value: admission-registry-mutate
- name: VALIDATE_CONFIG
value: admission-registry
- name: WEBHOOK_SERVICE
value: admission-registry
- name: WEBHOOK_NAMESPACE
value: default
- 为了创建
MutatingWebhookConfiguration
或者ValidatingWebhookConfiguration
资源对象,我们将以下代码添加到上面的 init 容器代码中。
package main
import (
"bytes"
"context"
"os"
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
func initKubeClient() (*kubernetes.Clientset, error) {
var (
err error
config *rest.Config
)
if config, err = rest.InClusterConfig(); err != nil {
return nil, err
}
// 创建 Clientset 对象 go get k8s.io/client-go v0.22.2
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
return nil, err
}
return clientset, nil
}
func CreateAdmissionConfig(caCert *bytes.Buffer) error {
var (
webhookNamespace, _ = os.LookupEnv("WEBHOOK_NAMESPACE")
mutationCfgName, _ = os.LookupEnv("MUTATE_CONFIG")
validateCfgName, _ = os.LookupEnv("VALIDATE_CONFIG")
webhookService, _ = os.LookupEnv("WEBHOOK_SERVICE")
validatePath, _ = os.LookupEnv("VALIDATE_PATH")
mutationPath, _ = os.LookupEnv("MUTATE_PATH")
)
clientset, err := initKubeClient()
if err != nil {
return err
}
ctx := context.Background()
if validateCfgName != "" {
validateConfig := &admissionregistrationv1.ValidatingWebhookConfiguration{
ObjectMeta: metav1.ObjectMeta{
Name: validateCfgName,
},
Webhooks: []admissionregistrationv1.ValidatingWebhook{
{
Name: "io.ydzs.admission-registry",
ClientConfig: admissionregistrationv1.WebhookClientConfig{
CABundle: caCert.Bytes(),
Service: &admissionregistrationv1.ServiceReference{
Name: webhookService,
Namespace: webhookNamespace,
Path: &validatePath,
},
},
Rules: []admissionregistrationv1.RuleWithOperations{
{
Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.Create},
Rule: admissionregistrationv1.Rule{
APIGroups: []string{""},
APIVersions: []string{"v1"},
Resources: []string{"pods"},
},
},
},
FailurePolicy: func() *admissionregistrationv1.FailurePolicyType{
pt := admissionregistrationv1.Fail
return &pt
}(),
AdmissionReviewVersions: []string{"v1"},
SideEffects: func() *admissionregistrationv1.SideEffectClass {
se := admissionregistrationv1.SideEffectClassNone
return &se
}(),
},
},
}
validateAdmissionClient := clientset.AdmissionregistrationV1().ValidatingWebhookConfigurations()
_, err := validateAdmissionClient.Get(ctx, validateCfgName, metav1.GetOptions{})
if err != nil {
if errors.IsNotFound(err) {
if _, err = validateAdmissionClient.Create(ctx, validateConfig, metav1.CreateOptions{}); err != nil {
return err
}
} else {
return err
}
} else {
if _, err = validateAdmissionClient.Update(ctx, validateConfig, metav1.UpdateOptions{}); err != nil {
return err
}
}
}
if mutationCfgName != "" {
mutateConfig := &admissionregistrationv1.MutatingWebhookConfiguration{
ObjectMeta: metav1.ObjectMeta{
Name: mutationCfgName,
},
Webhooks: []admissionregistrationv1.MutatingWebhook{{
Name: "io.ydzs.admission-registry-mutate",
ClientConfig: admissionregistrationv1.WebhookClientConfig{
CABundle: caCert.Bytes(), // CA bundle created earlier
Service: &admissionregistrationv1.ServiceReference{
Name: webhookService,
Namespace: webhookNamespace,
Path: &mutationPath,
},
},
Rules: []admissionregistrationv1.RuleWithOperations{{Operations: []admissionregistrationv1.OperationType{
admissionregistrationv1.Create},
Rule: admissionregistrationv1.Rule{
APIGroups: []string{"apps", ""},
APIVersions: []string{"v1"},
Resources: []string{"deployments", "services"},
},
}},
FailurePolicy: func() *admissionregistrationv1.FailurePolicyType{
pt := admissionregistrationv1.Fail
return &pt
}(),
AdmissionReviewVersions: []string{"v1"},
SideEffects: func() *admissionregistrationv1.SideEffectClass {
se := admissionregistrationv1.SideEffectClassNone
return &se
}(),
}},
}
mutateAdmissionClient := clientset.AdmissionregistrationV1().MutatingWebhookConfigurations()
_, err := mutateAdmissionClient.Get(ctx, mutationCfgName, metav1.GetOptions{})
if err != nil {
if errors.IsNotFound(err) {
if _, err = mutateAdmissionClient.Create(ctx, mutateConfig, metav1.CreateOptions{}); err != nil {
return err
}
} else {
return err
}
} else {
if _, err = mutateAdmissionClient.Update(ctx, mutateConfig, metav1.UpdateOptions{}); err != nil {
return err
}
}
}
return nil
}
- 这里首先我们读取环境变量,例如 webhookNamespace,接下来,我们将使用 CA bundle 信息(先前创建)和其他必需信息来定义配置的资源对象结构。最后,我们使用 client-go 来创建配置资源对象。对于 Pod 重新启动或删除的情况,我们可以在 init 容器中添加额外的逻辑,例如首先删除现有配置,然后再仅在创建或更新 CA bundle(如果配置已存在)之前删除它们。
- 对于证书轮换的情况,对于向服务器容器提供此证书所采用的每种方法,方法将有所不同:
- 如果我们使用的是 emptyDir 卷,则方法将是仅重新启动 Webhook Pod。由于 emptyDir 卷是临时的,并且绑定到 Pod 的生命周期,因此在重新启动时,将生成一个新证书并将其提供给服务器容器。如果已经存在配置,则将在配置中添加新的 CA bundle。
- 如果我们正在使用 Secret 卷,则在重新启动 Webhook Pod 时,可以检查 Secret 中现有证书的有效期,以决定是将现有证书用于服务器还是创建新证书。
- 在这两种情况下,都需要重新启动 Webhook Pod 才能触发证书轮换/续订过程。何时需要重新启动 Webhook 容器以及如何重新启动 Webhook 容器,将取决于实际情况。可能的几种方法可以使用Cronjob、controller 等来实现。
- 到这里我们的自定义 Webhook 已注册,APIServer 可以通过 config 读取到 CA bundle 信息,并且Webhook 服务已准备好按照 configs 中定义的规则处理 mutate/验证请求。
4.4 部署
- 最后将上面的证书生成应用打包成一个 Docker 镜像,将之前部署的 Webhook 服务删除,重新使用如下所示的资源对象进行部署即可:
apiVersion: v1
kind: ServiceAccount
metadata:
name: admission-registry-sa
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: admission-registry-role
rules:
- verbs: ["*"]
resources: ["validatingwebhookconfigurations", "mutatingwebhookconfigurations"]
apiGroups: ["admissionregistration.k8s.io"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: admission-registry-rolebinding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: admission-registry-role
subjects:
- kind: ServiceAccount
name: admission-registry-sa
namespace: default
---
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:
serviceAccountName: admission-registry-sa
initContainers:
- image: qnhyn/admission-registry-tls:v0.0.3
imagePullPolicy: IfNotPresent
name: webhook-init
env:
- name: WEBHOOK_NAMESPACE
value: default
- name: MUTATE_CONFIG
value: admission-registry-mutate
- name: VALIDATE_CONFIG
value: admission-registry
- name: WEBHOOK_SERVICE
value: admission-registry
- name: VALIDATE_PATH
value: /validate
- name: MUTATE_PATH
value: /mutate
volumeMounts:
- mountPath: /etc/webhook/certs
name: webhook-certs
containers:
- name: webhook
image: qnhyn/admission-registry:v0.1.4
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
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: admission-registry
labels:
app: admission-registry
spec:
ports:
- port: 443
targetPort: 443
selector:
app: admission-registry
- 现在我们就不需要自己手动去创建包含证书的 Secret 资源对象了,也不需要手动去替换准入控制器配置对象中的 CA bundle 信息了,这些都将通过 Init 初始化容器来帮我们自动完成。
- 由于初始化容器需要访问
MutatingWebhookConfiguration
和ValidatingWebhookConfiguration
这两个资源对象,所以我们需要声明对应的 RBAC 权限。创建完成后的资源对象如下所示:
kubectl get pods -l app=admission-registry
kubectl exec -it admission-registry-64f6b46cdc-vqbrl -- ls
kubectl get validatingwebhookconfiguration
kubectl get mutatingwebhookconfigurations
- 然后同样再去测试一次即可,到这里我们就完成了使用初始化容器来管理 Admission Webhook 的 TLS 证书的功能。