笔者就网易传媒在云原生的探索写过一篇文章,感兴趣的同学可以参考这篇文章【】。

1

Serverless介绍

云原生的浪潮伴随着云计算的迅速发展仿佛一夜之间,迅速侵袭了技术的每个角落。每个人都在谈论云原生,谈论云原生对现有技术的变革。Kubernetes已经成为容器编排的事实标准,Servicemesh正在被各大厂商争先恐后的落地实践,Serverless从一个一直以来虚无缥缈的概念,到如今,也被摆在台面,有隐隐约约崛起的势头。

Serverless 翻译成中文是无服务器,所谓的无服务器并非是说不需要依靠服务器等资源,而是说开发者再也不用过多考虑服务器的问题,可以更专注在产品代码上,同时计算资源也开始作为服务出现,而不是作为服务器的概念出现,Serverless 是一种构建和管理基于微服务架构的完整流程,允许用户在服务部署级别而不是服务器部署级别来管理用户的应用部署。

Serverless完全由第三方管理,由事件触发,存在于无状态(Stateless),暂存(可能只存在于一次调用的过程中)在计算容器内,Serverless 部署应用无须涉及更多的基础设施建设,就可以基本实现自动构建、部署和启动服务。

各大厂商都有成熟的Serverless平台供用户使用,开源工具也有很多可以选择,传媒依据自身的业务特点,决定使用开源的工具构建自己的Serverless平台,在对各个开源工具进行比较后,决定基于Knative去构建自己的Serverless平台。(下图展示了目前主流开源Serverless平台的市场占有率情况,从图中可以看到,Knative遥遥领先于其他的Serverless平台)。

在使用Knative的过程中,发现了一些问题,本文就Knative对于Volumes的限制做一些描述,并基于Kubernetes的动态准入控制机制,在不改变Knative的代码的情况下,实现了Volumes的扩展,希望能给读者一些帮助及启示。

2

kubernetes动态准入控制介绍

1. 什么是准入控制

在用户的API请求进入apiserver后,会依次通过鉴权、MutatingAdmissionWebhook、定制化对象校验、ValidatingAdmissionWebhook,最后存入etcd。流程如下图所示:

准入控制器是一段代码,它会在请求通过认证和授权之后、对象被持久化之前拦截到达API服务器的请求。控制器编译进kube-apiserver二进制文件,并且只能由集群管理员配置。有两个特殊的控制器:MutatingAdmissionWebhook和ValidatingAdmissionWebhook。它们根据API中的配置,分别执行变更和验证准入控制Webhook。

准入控制过程分为两个阶段。第一阶段,运行变更准入控制器。第二阶段,运行验证准入控制器。再次提醒,某些控制器既是变更准入控制器又是验证准入控制器。

如果任何一个阶段的任何控制器拒绝了该请求,则整个请求将立即被拒绝,并向终端用户返回一个错误。除了对对象进行变更外,准入控制器还可以有其它作用:将相关资源作为请求处理的一部分进行变更。

2. 动态准入控制Webhook

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

在完成了所有对象修改并且API服务器也验证了所传入的对象之后, 验证性质的Webhook会被调用,并通过拒绝请求的方式来强制实施自定义的策略。

启动的先决条件 :

  • 确保Kubernetes集群版本至少为 v1.16(以便使用admissionregistration.k8s.io/v1 API)或者v1.9(以便使用admissionregistration.k8s.io/v1beta1 API)。

  • 确保启用MutatingAdmissionWebhook和ValidatingAdmissionWebhook控制器。

  • 确保启用了admissionregistration.k8s.io/v1beta1 API。

3. 开发Admission Webhook

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

3.1 创建Admission Webhook

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

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

apiVersion: v1
kind: ConfigMap
metadata:
name: hello-configmap
data:
hello.txt: "$$$$$$$$"

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

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

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

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

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

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

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

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

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

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

patchType := admissionv1.PatchTypeJSONPatch

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

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

jsonOk(w, &respAdmissionReview)
}

上述代码主要做了如下事情:

  • 将来自Http请求中的AdmissionReview json输入反序列化。

  • 读取Pod的spec信息。

  • 将hello-configmap作为数据源,添加hello-volume卷到Pod。

  • 挂载卷至Pod容器中。

  • 以JSON PATCH的形式记录变更信息,包括卷的变更,卷挂载信息的变更。顺道为容器添加一个“hello-added=true”的标签。

  • 构建json格式的响应结果,结果中包含了这次请求中的被修改的部分。


3.2 增加TLS

Webhook API服务器需要通过 TLS 方式通信。如果想将其部署至Kubernetes集群内,我们还需要证书。通过一个脚本可以生成证书:

#!/bin/bash
set -e
while [[ $# -gt 0 ]]; do
case ${1} in
--service)
service="$2"
shift
--secret)
secret="$2"
shift
--namespace)
namespace="$2"
shift
esac
shift
done
[ -z ${service} ] && service=nfaas-webhook
[ -z ${secret} ] && secret=nfaas-webhook-certs
[ -z ${namespace} ] && namespace=default
if [ ! -x "$(command -v openssl)" ]; then
echo "openssl not found"
exit 1
fi
csrName=${service}.${namespace}
tmpdir=$(mktemp -d)
echo "creating certs in tmpdir ${tmpdir} "

cat < >> ${tmpdir}/csr.conf
[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name
[req_distinguished_name]
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = ${service}
DNS.2 = ${service}.${namespace}
DNS.3 = ${service}.${namespace}.svc
EOF

openssl genrsa -out ${tmpdir}/server-key.pem 2048
openssl req -new -key ${tmpdir}/server-key.pem -subj "/CN=${service}.${namespace}.svc" -out ${tmpdir}/server.csr -config ${tmpdir}/csr.conf

kubectl delete csr ${csrName} 2>/dev/null || true

cat < | kubectl create -f -
apiVersion: certificates.k8s.io/v1beta1
kind: CertificateSigningRequest
metadata:
name: ${csrName}
spec:
groups:
- system:authenticated
request: $(cat ${tmpdir}/server.csr | base64 | tr -d '\n')
usages:
- digital signature
- key encipherment
- server auth
EOF

while true; do
kubectl get csr ${csrName}
if [ "$?" -eq 0 ]; then
break
fi
done

kubectl certificate approve ${csrName}
for x in $(seq 10); do
serverCert=$(kubectl get csr ${csrName} -o jsonpath='{.status.certificate}')
if [[ ${serverCert} != '' ]]; then
break
fi
sleep 1
done
if [[ ${serverCert} == '' ]]; then
echo "ERROR: After approving csr ${csrName}, the signed certificate did not appear on the resource. Giving up after 10 attempts." >&2
exit 1
fi
echo ${serverCert} | openssl base64 -d -A -out ${tmpdir}/server-cert.pem

# create the secret with CA cert and server cert/key
kubectl create secret generic ${secret} \
--from-file=key.pem=${tmpdir}/server-key.pem \
--from-file=cert.pem=${tmpdir}/server-cert.pem \
--dry-run -o yaml |
kubectl -n ${namespace} apply -f -

运行./webhool-create-signed-cert.sh就能生成证书。

然后使用下面的脚本将证书内容导出到环境变量:

#!/bin/bash
set -o errexit
set -o nounset
set -o pipefail

ROOT=$(cd $(dirname $0)/../../; pwd)

export CA_BUNDLE=$(kubectl get configmap -n kube-system extension-apiserver-authentication -o=jsonpath='{.data.client-ca-file}' | base64 | tr -d '\n')

if command -v envsubst >/dev/null 2>&1; then
envsubst
else
sed -e "s|\${CA_BUNDLE}|${CA_BUNDLE}|g"
fi

3.3 部署Webhook

先用以下配置部署deployment:

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

然后部署service:

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

接着使用以下配置创建一个MutatingWebhookConfiguration将我们创建的Webhook 信息注册到Kubernetes API server,假设配置文件名为mutating-webhook.yaml,运行命令cat mutating-webhook.yaml | ./create-ca-bundle.sh | kubectl apply -f -:

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

3.4 验证Webhook

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

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

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

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

再检查下文件内容:

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

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

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

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

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

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

Mutating Webhook生效了!

3

针对knatvie的Webhook

1. 目的

由于knative为了稳定和安全,当前只支持secret、configMap、projected、emptyDir这几种volume类型。如果在service中写入了使用其它种类volume,knative Webhook的验证便会失败,想要跳过此验证最容易想到的方式即修改knative Webhook的代码。那是否有更简便无入侵的方式呢?等knative Webhook验证完之后再改不就行了吗?所以可以来实现一个自定义的Webhook。

2. 方法

knatvie创建pod过程中的流程大约是serving->configuration->revision->deployment->replicaSet->pod。因为knative自定义的类型对对象的pod template都有同样的volume校验,所以肯定不能在deployment前修改volume。但是当修改deployment之后,revision的controller会比较revision和deployment的pod template并还原,所以修改目标还得往后移。由于replicaSet只是一个中间类型,未来还有可能被更强大的类型取代,那么就不如对后面一个最小单元pod来着手了。

要让Webhook确定那些volume需要写入pod,需要将volume放入pod的字段中让其读取,最优方案当然是放入annotation。总体流程大致如下:

  1. 创建serving时将所有volume和volumeMount分别json序列化写入pod template的annotation

  2. Webhook监听到pod创建事件,开始修改pod

  3. Webhook将annotation中的volume写入pod

  4. Webhook查找到pod内的user-container容器,将annotation里的volumeMount写入

因为只在pod创建是操作,annotation在使用之后无需删除。

Webhook中的写入volume和volumeMount的关键代码如下:

var userContainer *corev1.Container
var cid int
for i := range pod.Spec.Containers {
container := &pod.Spec.Containers[i]
if container.Name == knconfig.DefaultUserContainerName {
userContainer = container
cid = i
break

p = &jsonpatch.Patch{}
if userContainer == nil {
log.Warnf("can not find user container")
return
}

if value, ok := pod.Annotations[service.AnnotationNodeSelector]; ok {
var nodeSelector map[string]string
err = json.UnmarshalFromString(value, &nodeSelector)
if err != nil {
l.Errorf("wrong format node selector: %s", err.Error())
return
}

p.Add(jsonpatch.PathPodNodeSelecor, nodeSelector)

if value, ok := pod.Annotations[service.AnnotationVolumes]; ok {
var volumes []corev1.Volume
err = json.UnmarshalFromString(value, &volumes)
if err != nil {
l.Errorf("wrong format volumes: %s", err.Error())
return
}

volumes = append(volumes, pod.Spec.Volumes...)
p.Add(jsonpatch.PathPodVolumes, volumes)
}

if value, ok := pod.Annotations[service.AnnotationVolumeMounts]; ok {
var volumeMounts []corev1.VolumeMount
err = json.UnmarshalFromString(value, &volumeMounts)
if err != nil {
l.Errorf("wrong format volumeMounts: %s", err.Error())
return
}
volumeMounts = append(volumeMounts, userContainer.VolumeMounts...)
p.Add(fmt.Sprintf(jsonpatch.FormatPodVolumesMounts, cid), volumeMounts)
}

Webhook使用deployment部署到k8s中,注意将证书挂载进容器:

apiVersion: apps/v1
kind: Deployment
metadata:
name: nfaas-webhook
labels:
app: nfaas-webhook
spec:
replicas: 1
selector:
matchLabels:
app: nfaas-webhook
template:
metadata:
labels:
app: nfaas-webhook
spec:
containers:
- name: nfaas-webhook-container
image: nfaas-webhook:latest
imagePullPolicy: IfNotPresent
volumeMounts:
- name: webhook-certs
mountPath: /etc/webhook/certs
readOnly: true
volumes:
- name: webhook-certs
secret:
secretName: nfaas-webhook-certs

然后将其以service暴露接口:

apiVersion: v1
kind: Service
metadata:
name: nfaas-webhook
labels:
app: nfaas-webhook
spec:
ports:
- port: 443
targetPort: 8088
protocol: TCP
type: ClusterIP
selector:
app: nfaas-webhook

最后需要将Webhook以service形式注册到k8s中,注意只需监听指定namespace下的serving相关的pod创建事件即可:

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: nfaas-webhook
webhooks:
- name: mutating.nfaas.com
admissionReviewVersions: [ "v1" ]
sideEffects: NoneOnDryRun
clientConfig:
service:
name: nfaas-webhook
namespace: default
path: "/pod"
caBundle: ${CA_BUNDLE}
rules:
- operations: [ "CREATE" ]
apiGroups: [ "", ]
apiVersions: [ "v1" ]
resources: [ "pods" ]
scope: "*"
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: nfaas-demo
objectSelector:
matchExpressions:
- key: serving.knative.dev/service
operator: Exists

3. 使用范例

以hostPath volume来举例,目的是将以下volume和volumeMount写入pod:

volumes:
- hostPath:
path: /usr/local/nagent4docker
name: script-vol
volumeMounts:
- mountPath: /usr/local/nagent
name: script-vol

在创建knatvie service时,在template annotation 的keyknative.netease.dev/volumes下写入:

[{"name":"script-vol","hostPath":{"path":"/usr/local/nagent"}}]

在key knative.netease.dev/volumeMounts下写入:

[{"name":"script-vol","mountPath":"/usr/local/nagent4docker"}]

最后会生成类似以下属性的pod:

apiVersion: v1
kind: Pod
metadata:
name: test-pod
annotations:
knative.netease.dev/volumes: '[{"name":"script-vol","hostPath":{"path":"/usr/local/nagent"}}]'
knative.netease.dev/volumeMounts: '[{"name":"script-vol","mountPath":"/usr/local/nagent4docker"}]'
spec:
containers:
- image: test:lastest
name: test-container
volumeMounts:
- mountPath: /usr/local/nagent
name: script-vol
volumes:
- hostPath:
path: /usr/local/nagent4docker
name: script-vol

--End--

欢迎热衷于云原生的攻城狮加入网易传媒,一起打造稳定、可靠的云原生基础架构,有兴趣的同学可以发邮件:chaikebin@corp.netease.com

职位介绍

  • 岗位:Service Mesh开发工程师

  • Base:北京

工作职责

  • 服务网易传媒的 Service Mesh 系统的架构设计与实现

  • 支持 Service Mesh 在业务落地,支撑起微服务的发现、治理、安全、流控、遥测全流程

  • 构建大规模 Service Mesh 集中管控系统,支持海量发布、质量控制、观测诊断

  • 构建跨Kubernets集群的Service Mesh解决方案

职位要求

  • 本科及以上学历,计算机、通信等相关专业

  • 熟练掌握Linux系统及常用操作

  • 精通Go/C/C++中一门或多门语言

  • 有深度参与Istio、Envoy的相关开发经验者优先

  • 深入理解RPC原理,熟悉相关开源框架,如 Thrift/dubbo/gRPC 等

  • 具备扎实的操作系统、数据结构与算法能力,代码风格良好,可扩展性强

职位介绍

  • 岗位:Serverless开发工程师

  • Base:北京

工作职责

  • 服务网易传媒的Serverless系统的架构设计与实现

  • 支持Serverless在业务落地,支撑起业务弹性需求

  • 构建大规模 Serverless 集中管控、弹性、可观测诊断

  • 构建Faas Runtime框架

  • 构建Envoy和Serverless代理的统一

职位要求

  • 本科及以上学历,计算机、通信等相关专业

  • 熟练掌握Linux系统及常用操作

  • 精通Go/C/C++中一门或多门语言

  • 对 Serverless 相关领域有较深了解,有深度参与Knative的相关开发经验者优先,有 Firecracker、KVM、Kubernetes、OpenFaaS、Knative 等开源产品经验

  • 有阿里、腾讯、华为、 AWS Lambda、Google Function、Azure Function相关Faas/Serverless相关平台经验者优先

  • 具备扎实的操作系统、数据结构与算法能力,代码风格良好,可扩展性强