这是「 Istio 系列 」的第三篇文章。
在上一篇 Istio 系列篇二 | Istio 的安装以及入门使用 中,我们部署了一个微服务示例项目。
$ kubectl get pod -n istio-app
NAME READY STATUS RESTARTS AGE
adservice-78c76f67d7-vgc8d 2/2 Running 0 59s
cartservice-7fb7c7bbcf-7xklw 2/2 Running 0 60s
checkoutservice-7dc67d866f-jh9vm 2/2 Running 0 60s
currencyservice-86cbc887cf-29v9n 2/2 Running 0 60s
emailservice-5d4d698877-vgvc8 2/2 Running 0 60s
frontend-78756cdbb9-xzm95 2/2 Running 0 60s
loadgenerator-7ddcddf799-9hrkj 2/2 Running 0 60s
paymentservice-66697f866c-k5qnj 2/2 Running 0 60s
productcatalogservice-78b45fdb9f-t7t8x 2/2 Running 0 60s
recommendationservice-58956f7f99-fxk6s 2/2 Running 0 60s
redis-cart-5b569cd47-7c48x 2/2 Running 0 59s
shippingservice-5cbc5b7c4c-5tb95 2/2 Running 0 59s
由于我们在 istio-app
命名空间添加了 istio-injection=enabled
标签,所以在此命名空间创建的 Pod ,Istio 都会自动为其注入 SideCar 应用,为微服务应用启用 Istio 支持。
今天本文就从 Istio 为 Pod 注入 SideCar 的原理入手,以其源码为辅,用代码从零开始还原一个 SideCar 的注入过程。
作为整个集群管理的 API 入口, Kubernetes API Server 的架构从上到下可以分为四层:
API Server 的架构,图源《Kubernetes权威指南》
当我们使用 kubectl 等客户端工具发起创建 Pod
的请求时,实际就是在调用 API Server 中 API 层的 api/v1
核心接口,接着访问控制层负责对用户身份进行认证和授权,根据配置的各种准入控制器(Admission Control
),判断是否允许访问,最后根据注册表(Registry)中定义的资源对象类型进行格式编码并持久化存储到 etcd 数据库中。
其中的准入控制器(Admission Control
)实际上就是一段代码,它会在请求通过认证和授权之后、对象被持久化之前拦截到达 API 服务器的请求。
Kubernetes 内置了许多这样的准入控制器,这些控制器被编译进 kube-apiserver 可执行文件,并且只能由集群管理员配置。在这些控制器中有两个特殊的控制器:MutatingAdmissionWebhook
和 ValidatingAdmissionWebhook
,它们可以根据相关配置,调用对应的 Webhook 服务,触发 HTTP 回调机制。
准入控制器阶段[1]
如图所示,资源请求在经过身份认证和授权后就会来到这两个特殊的控制器阶段,其中:
如果我们利用 MutatingAdmissionWebhook
来拦截 Pod 资源创建的请求,并往请求内容的 spec
中增加新的容器配置,就实现了所谓的 Sidecar 自动注入了。
很巧,Istio 就是这么做的。
既然知道了 Istio 是利用 MutatingAdmissionWebhook 来实现 Sidecar 自动注入,那我们就先来看看在 Istio 安装过程中所创建的资源的具体配置:
$ istioctl manifest generate --set profile=demo
# 输出 demo 配置文件的各种资源类型配置
我们直接定位到我们所关心的 MutatingAdmissionWebhook 的位置:
其中关键的两个不同监听级别的 webhooks 配置:
监听命名空间级别
监听资源对象级别
这两个 webhooks 配置都是在监听 Pod 资源的创建,然后携带请求内容调用 istio-system
命名空间的 istiod
服务的 /inject
接口,即请求 https://istiod.istio-system.svc:443/inject
。按照我们的原理推测,该接口将会篡改原始请求数据,在 Pod 中额外添加 Sidecar 容器。
对于 istio-system
命名空间的 istiod
容器服务,其对应镜像为 docker.io/istio/pilot:1.xx.x
,进程名为 pilot-discovery
,源码入口位置在 pilot/cmd/pilot-discovery/main.go
:
从源码来看,注入的总体逻辑和原理推测的一样:Api Server 携带 Pod 的原始数据作为 Request Body
来请求 pilot-discovery
的 /inject
接口,该接口将 Request Body
修改为带有 Sidecar 容器的新的 Pod 数据并作为 Response
返回给 Api Server
,所以后续 Api Server
中的 Pod 就是被注入了 Sidecar 容器的 Pod 了。
本文截图源码基于 ea32d26 分支[2]
虽然 Sidecar 的原理很简单,但是要在集成了众多功能模块的 Istio 源码中查看这其中的实现还是略微麻烦了点,所以接下来我们将用最简单的代码,从零开始还原一个 SideCar 的注入过程。
首先创建 main.go
,为 webhook 服务自建 https 证书:
// main.go
package main
const (
// hostname 为 API Server 的请求域名,根据实际情况更改
hostname = "host.docker.internal"
port = 9443
crt = "tls.crt"
key = "tls.key"
)
func main() {
// 1.为 webhook 服务自建 https 证书
caPEM, err := createCert()
if err != nil {
panic(err)
}
}
自建 https 证书的逻辑实现 cert.go
:
// cert.go
package main
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"time"
)
var (
orgs = []string{"sidecar-injector"}
commonName = "sidecar-injector"
dnsNames = []string{hostname}
)
func createCert() (*bytes.Buffer, error) {
ca := &x509.Certificate{
SerialNumber: big.NewInt(2048),
Subject: pkix.Name{Organization: orgs},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1, 0, 0),
IsCA: true,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
}
caPrivateKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, err
}
caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPrivateKey.PublicKey, caPrivateKey)
if err != nil {
return nil, err
}
caPEM := new(bytes.Buffer)
err = pem.Encode(caPEM, &pem.Block{
Type: "CERTIFICATE",
Bytes: caBytes,
})
if err != nil {
return nil, err
}
cert := &x509.Certificate{
DNSNames: dnsNames,
SerialNumber: big.NewInt(1024),
Subject: pkix.Name{
CommonName: commonName,
Organization: orgs,
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1, 0, 0),
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature,
}
serverPrivateKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, err
}
serverCertBytes, err := x509.CreateCertificate(rand.Reader, cert, ca, &serverPrivateKey.PublicKey, caPrivateKey)
if err != nil {
return nil, err
}
serverCertPEM := new(bytes.Buffer)
err = pem.Encode(serverCertPEM, &pem.Block{
Type: "CERTIFICATE",
Bytes: serverCertBytes,
})
if err != nil {
return nil, err
}
serverPrivateKeyPEM := new(bytes.Buffer)
err = pem.Encode(serverPrivateKeyPEM, &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(serverPrivateKey),
})
if err != nil {
return nil, err
}
err = writeFile(crt, serverCertPEM)
if err != nil {
return nil, err
}
err = writeFile(key, serverPrivateKeyPEM)
if err != nil {
return nil, err
}
return caPEM, nil
}
func writeFile(filepath string, content *bytes.Buffer) error {
f, err := os.Create(filepath)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(content.Bytes())
if err != nil {
return err
}
return nil
}
证书生成后,我们将继续使用代码的方式来创建 MutatingWebhookConfiguration 资源:
// main.go
func main() {
// 1.为 webhook 服务自建 https 证书
caPEM, err := createCert()
if err != nil {
panic(err)
}
// 2.创建 MutatingWebhookConfiguration
err = createMutatingWebhookConfiguration(caPEM)
if err != nil {
panic(err)
}
}
创建 MutatingWebhookConfiguration 的逻辑实现在 config.go
:
// config.go
package main
import (
"bytes"
"context"
"flag"
"fmt"
"path/filepath"
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/util/homedir"
)
func createMutatingWebhookConfiguration(caPEM *bytes.Buffer) error {
var kubeconfig *string
if home := homedir.HomeDir(); home != "" {
kubeconfig = flag.String("kubeconfig", filepath.Join(home, ".kube", "config"), "(optional) absolute path to the kubeconfig file")
} else {
kubeconfig = flag.String("kubeconfig", "", "absolute path to the kubeconfig file")
}
flag.Parse()
// use the current context in kubeconfig
config, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)
if err != nil {
panic(err.Error())
}
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
return err
}
mutatingWebhookConfigV1Client := clientset.AdmissionregistrationV1()
metaName := "sidecar-injector-mutating-webhook-configuration"
url := fmt.Sprintf("https://%s:%d/inject", hostname, port)
mutatingWebhookConfig := &admissionregistrationv1.MutatingWebhookConfiguration{
ObjectMeta: metav1.ObjectMeta{
Name: metaName,
},
Webhooks: []admissionregistrationv1.MutatingWebhook{{
Name: "namespace.sidecar-injector.togettoyou.com",
AdmissionReviewVersions: []string{"v1"},
SideEffects: func() *admissionregistrationv1.SideEffectClass {
se := admissionregistrationv1.SideEffectClassNone
return &se
}(),
ClientConfig: admissionregistrationv1.WebhookClientConfig{
CABundle: caPEM.Bytes(),
URL: &url,
},
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
}(),
NamespaceSelector: &metav1.LabelSelector{
MatchExpressions: []metav1.LabelSelectorRequirement{
{
Key: "sidecar-injector",
Operator: metav1.LabelSelectorOpIn,
Values: []string{
"enabled",
},
},
},
},
}},
}
mutatingWebhookConfigV1Client.MutatingWebhookConfigurations().
Delete(context.Background(), metaName, metav1.DeleteOptions{})
_, err = mutatingWebhookConfigV1Client.MutatingWebhookConfigurations().
Create(context.Background(), mutatingWebhookConfig, metav1.CreateOptions{})
return err
}
这里的配置和 Istio 监听命名空间级别的配置几乎一致,区别在于需要为命名空间添加的是 sidecar-injector=enabled
标签了。
最后,为 webhook 服务注册 /inject 和 /inject/ 路由并启动服务:
// main.go
func main() {
// 1.为 webhook 服务自建 https 证书
caPEM, err := createCert()
if err != nil {
panic(err)
}
// 2.创建 MutatingWebhookConfiguration
err = createMutatingWebhookConfiguration(caPEM)
if err != nil {
panic(err)
}
// 3.注册 /inject 和 /inject/ 路由
http.HandleFunc("/inject", inject)
http.HandleFunc("/inject/", inject)
// 4.启动 webhook 服务
panic(http.ListenAndServeTLS(fmt.Sprintf(":%d", port), crt, key, nil))
}
来到最关键的核心注入逻辑,其代码实现在 inject.go
:
// inject.go
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
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"
)
type patchOperation struct {
Op string `json:"op"`
Path string `json:"path"`
Value interface{} `json:"value,omitempty"`
}
// 注入逻辑
func inject(w http.ResponseWriter, r *http.Request) {
log.Println("收到请求")
// 1.获取 body
var body []byte
if r.Body != nil {
if data, err := ioutil.ReadAll(r.Body); err == nil {
body = data
}
}
if len(body) == 0 {
http.Error(w, "no body found", http.StatusBadRequest)
return
}
// 2.校验 content type
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
http.Error(w, "invalid Content-Type, want `application/json`", http.StatusUnsupportedMediaType)
return
}
// 3.解析 body 为 k8s pod 对象
deserializer := serializer.NewCodecFactory(runtime.NewScheme()).UniversalDeserializer()
ar := admissionv1.AdmissionReview{}
if _, _, err := deserializer.Decode(body, nil, &ar); err != nil {
http.Error(w, fmt.Sprintf("could not decode body: %v", err), http.StatusInternalServerError)
return
}
var pod corev1.Pod
if err := json.Unmarshal(ar.Request.Object.Raw, &pod); err != nil {
http.Error(w, fmt.Sprintf("could not decode pod: %v", err), http.StatusInternalServerError)
return
}
// 4.根据 sidecar 模板篡改资源,得到修改后的补丁
sidecarTemp := []corev1.Container{
{
Name: "sidecar",
Image: "busybox:1.28.4",
Command: []string{"/bin/sh", "-c", "echo 'sidecar' && sleep 3600"},
},
}
patch := addContainer(pod.Spec.Containers, sidecarTemp)
patchBytes, err := json.Marshal(patch)
if err != nil {
http.Error(w, fmt.Sprintf("could not encode patch: %v", err), http.StatusInternalServerError)
return
}
// 5.将篡改后的补丁内容写入 response
admissionReview := admissionv1.AdmissionReview{
TypeMeta: metav1.TypeMeta{
APIVersion: "admission.k8s.io/v1",
Kind: "AdmissionReview",
},
Response: &admissionv1.AdmissionResponse{
UID: ar.Request.UID,
Allowed: true,
Patch: patchBytes,
PatchType: func() *admissionv1.PatchType {
pt := admissionv1.PatchTypeJSONPatch
return &pt
}(),
},
}
resp, err := json.Marshal(admissionReview)
if err != nil {
http.Error(w, fmt.Sprintf("could not encode response: %v", err), http.StatusInternalServerError)
return
}
if _, err := w.Write(resp); err != nil {
http.Error(w, fmt.Sprintf("could not write response: %v", err), http.StatusInternalServerError)
}
log.Println("注入成功")
}
func addContainer(target, added []corev1.Container) (patch []patchOperation) {
first := len(target) == 0
var value interface{}
for _, add := range added {
value = add
path := "/spec/containers"
if first {
first = false
value = []corev1.Container{add}
} else {
path = path + "/-"
}
patch = append(patch, patchOperation{
Op: "add",
Path: path,
Value: value,
})
}
return patch
}
到此,四个 go 文件就最简还原了 Sidecar 的注入过程,由于我的环境是 K8s For Docker Desktop ,所以 hostname 配置的是 host.docker.internal
(用于容器内访问宿主机),这一点可能需要大家结合自身环境进行更改。
最后直接启动程序:
$ go run *.go
进行验证,创建 sidecar-test
命名空间并添加 sidecar-injector=enabled
标签 :
$ kubectl get MutatingWebhookConfiguration
NAME WEBHOOKS AGE
sidecar-injector-mutating-webhook-configuration 1 9s
$ kubectl create ns sidecar-test
namespace/sidecar-test created
$ kubectl label ns sidecar-test sidecar-injector=enabled
namespace/sidecar-test labeled
在 sidecar-test
命名空间创建 Pod 资源:
$ cat <<EOF | kubectl create -n sidecar-test -f -
apiVersion: v1
kind: Pod
metadata:
name: nginx
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:latest
ports:
- containerPort: 80
EOF
pod/nginx created
$ kubectl get pod -n sidecar-test -w
NAME READY STATUS RESTARTS AGE
nginx 0/2 Pending 0 0s
nginx 0/2 Pending 0 0s
nginx 0/2 ContainerCreating 0 0s
nginx 2/2 Running 0 5s
$ kubectl logs nginx sidecar -n sidecar-test
sidecar
可以看出,Sidecar 已成功注入,程序对应的日志:
$ go run *.go
2022/09/25 16:49:40 收到请求
2022/09/25 16:49:40 注入成功
本文到这里就结束了,所有的代码已经上传到 https://github.com/togettoyou/sidecar-injector[3] 仓库。 另外对于在实际项目中 webhook 服务的开发,建议使用
operator-sdk
框架直接快速生成代码,例子可以参考 https://github.com/togettoyou/sidecar-go[4] 仓库。
感谢阅读到这里!关注我,下次见。
[1]
准入控制器阶段: https://kubernetes.io/blog/2019/03/21/a-guide-to-kubernetes-admission-controllers/
[2]
ea32d26 分支: https://github.com/istio/istio/tree/ea32d26a26ca9b49f9d0b94f95c57472f752fc63
[3]
https://github.com/togettoyou/sidecar-injector: https://github.com/togettyou/sidecar-injector
[4]
https://github.com/togettoyou/sidecar-go: https://github.com/togettoyou/sidecar-go