前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Istio Sidecar 注入原理及其实现

Istio Sidecar 注入原理及其实现

作者头像
gopher云原生
发布2022-11-22 15:00:35
6980
发布2022-11-22 15:00:35
举报
文章被收录于专栏:gopher云原生gopher云原生

这是「 Istio 系列 」的第三篇文章。

在上一篇 Istio 系列篇二 | Istio 的安装以及入门使用 中,我们部署了一个微服务示例项目。

代码语言:javascript
复制
$ 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 可执行文件,并且只能由集群管理员配置。在这些控制器中有两个特殊的控制器:MutatingAdmissionWebhookValidatingAdmissionWebhook ,它们可以根据相关配置,调用对应的 Webhook 服务,触发 HTTP 回调机制。

准入控制器阶段[1]

如图所示,资源请求在经过身份认证和授权后就会来到这两个特殊的控制器阶段,其中:

  • Mutating 阶段用于修改请求内容
  • Validating 阶段用于校验请求内容

如果我们利用 MutatingAdmissionWebhook 来拦截 Pod 资源创建的请求,并往请求内容的 spec 中增加新的容器配置,就实现了所谓的 Sidecar 自动注入了。

很巧,Istio 就是这么做的。

源码

既然知道了 Istio 是利用 MutatingAdmissionWebhook 来实现 Sidecar 自动注入,那我们就先来看看在 Istio 安装过程中所创建的资源的具体配置:

代码语言:javascript
复制
$ 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 证书:

代码语言:javascript
复制
// 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

代码语言:javascript
复制
// 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 资源:

代码语言:javascript
复制
// 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

代码语言:javascript
复制
// 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/ 路由并启动服务:

代码语言:javascript
复制
// 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

代码语言:javascript
复制
// 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 (用于容器内访问宿主机),这一点可能需要大家结合自身环境进行更改。

最后直接启动程序:

代码语言:javascript
复制
$ go run *.go

进行验证,创建 sidecar-test 命名空间并添加 sidecar-injector=enabled 标签 :

代码语言:javascript
复制
$ 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 资源:

代码语言:javascript
复制
$ 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 已成功注入,程序对应的日志:

代码语言:javascript
复制
$ 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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-09-26,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 gopher云原生 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 原理
  • 源码
  • 实现
  • 参考资料
相关产品与服务
服务网格
服务网格(Tencent Cloud Mesh, TCM),一致、可靠、透明的云原生应用通信网络管控基础平台。全面兼容 Istio,集成腾讯云基础设施,提供全托管服务化的支撑能力保障网格生命周期管理。IaaS 组网与监控组件开箱即用,跨集群、异构应用一致发现管理加速云原生迁移。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档