Istio Sidecar 注入原理及其實現

今天本文就從 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 可執行文件,並且只能由集羣管理員配置。在這些控制器中有兩個特殊的控制器:MutatingAdmissionWebhookValidatingAdmissionWebhook ,它們可以根據相關配置,調用對應的 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/togettoyou/sidecar-injector

[4]

https://github.com/togettoyou/sidecar-go: https://github.com/togettoyou/sidecar-go

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/XABVBH29GQX2ABdfIlKrnw