Injektovanie sidecarov v Kubernetes

Dnes vhupnem trochu do Kubernetes sveta. Na jednom meetupe som hovoril o tom, ako service mesh v Kubernetes injektuje sidecar proxy do podov.

Asi to znie ako španielská dedina. Netreba sa však nechať odradiť. Ide o pekný príklad hlavne pre programátorov. Človek si lepšie uvedomi ako pracuje Kubernetes API a získa predstavu, ako je možné Kubernetes rozšírovať o produkty pre pokročilejšiu automatizáciu.

Sidecar a Kubernetes

Najprv si rýchlo povedzme čo je sidecar vzor. Ide o jednoduchý koncept vo svete microservices, kedy určitú spoločnú funkcionalitu pre naše mikroslužby: napr trafik, logovanie alebo monitorovanie, vytrhneme z kódu von, a necháme ich bežať vedľa mikroslužby. Tradične sa k tomu používali rôzne knižnice a frameworky. Výhodou je, že Microslužba tak nieje zatažená rôznymi frameworkami a môže sa sústrediť na biznis logiku. A práve tento vzor používa service mesh. Ku každej bežiacej inštancii služby sa pridá malé proxy. Služba potom príjma požiadavky a komunikuje výhradne skrz toto proxy, ktoré sa stará napríklad o autorizáciu, monitorovanie trafiku, alebo discovery služieb.

sidecar

Kubernetes má tú vlastnosť, že kontajneri ktoré bežia v rámci jedného podu zdieľajú nparíklad aj sieť. A to sa hodí. Čiže do podu s mojou službou potrebujem nejak dostať sidecar proxy. Napríklad Envoy.

To môžem spraviť ručne úpravou deployment manifestu. To je pracné a náchylné na chyby. Ideálne by bolo, aby som sidecar vedel injektnuť automaticky pre každý pod. Aby programátor sa nemusel zapodievať nejakým sidecarom. Ako na to?

Prvý pokus

Mám teda jednoduchú službu bežiacu v Kubernetes. Budem ju volať demoapp. Služba pobeží vo vlastnom namespace demoapp a použijem hashicorp/http-echo kontajner.

kubectl create ns demoapp
kubectl run demoapp \
  --image=hashicorp/http-echo \
  --namespace demoapp \
  -- -text hello

Ako by som vedel injektnúť sidecar? Viem, že Kubernetes API je vlastne RESTovské API. Čiže by som vedel napísať aplikáciu, ktorá by cez toto API získala zoznam podov. Potom by každý pod zmodifikovala a pridala sidecar kontajner.

Vyskúšam teda zmeniť ručne môj pod pomocou kubectl edit.

kubectl edit pod demoapp

Pridám envoy sidecar do spec.containers:

apiVersion: v1
kind: Pod
metadata:
  name: demoapp
spec:
  containers:
    - name: envoy-sidecar
      image: envoyproxy/envoy:v1.20.3            
    ...
    - name: demoapp
      image: hashicorp/http-echo
		  ...

Po potvrdení ma čaká nemilé prekvapenie. Kubernetes mi oznámi, že pod nemôže byť zmodifikovaný.

spec.containers: Fobridden: pod updates may not add or remove containers

V Kubernetes totiž existujú 2 typy objektov. Meniteľné - mutable, a nemeniteľné - immutable. POD je práve immutable objekt. Môžem ho teda len zmazať alebo vytvoriť. Ako teda injektnuť sidecar? Odpoveďou sú tzv. admission webhooky.

Admission webhook

Najprv si musíme povedať niečo o tom, ako Kubernetes API spracováva požiadavky.

Keď dorazí HTTP požiadavka na Kubernetes API, tak prechádza určitými fázami. Najprv ju príjme handler, potom sa realizuje autorizácia a autentifikácia.

k8s_api

Podom nasleduje tzv. mutation admission webhook. Ide o miesto, kde Kubernetes API odošle na zaregistrovanú službu AdmissionReview požiadavku. Táto služba môže zmodifikovať objekt a odpovedať zmenami. Kubernetes API tieto zmeny aplikuje a pokračuje ďalej. Toto je práve miesto, kde môj kód môže zmeniť deployment a injektovať sidecar kontajner.

Ďalej nasleduje fáza validácie schémy. Za touto validáciou ešte nasleduje ďalší typ admission webhooku a to je tzv. audit admission webhook. Opäť Kubernetes API posiela HTTP požiadavku na aplikáciu kde pošle AdmissionReview požiadavku už s modifikovaným objektom. Aplikácia potom odpovedá len či má byť objekt aplikovaný alebo má byť odmietnutý.

Až potom je stav objektu uložený v etcd databáze, kde sa ho pokúsi Kubernetes dostať do požadovaného stavu. Čiže ak ide o nový pod, tak sa ho pokúsi vytvoriť.

Programujem môj prvý webhook

Teraz keď už viem, že existuje niečo ako mutation admission webhook, tak môžem napísať moju injekčnú aplikáciu injector ako klasickú webovú službu v Go. Vytvorím základ aplikácie. Zatiaľ bez akejkoľvek mutácie.

mkdir injector
cd injector
go mod init github.com/sn3d/injector

Ďalej si vytvorím prvý nástrel main.go

func main() {
   mux := http.NewServeMux()
   mux.HandleFunc("/", handleRoot)
   mux.HandleFunc("/mutate", handleMutate)
  
   // Kubernetes API komunikuje s aplikaciou cez TLS,
   // preto potrebujem certifikaty
   certs, err := tls.LoadX509KeyPair("./certs/injector.crt", "./certs/injector.key")
   if err != nil {
      fmt.Printf("error: %s", err)
      os.Exit(1)
   }

   s := &http.Server{
      Addr:      ":8443",
      Handler:   mux,
      TLSConfig: &tls.Config{Certificates: []tls.Certificate{certs}},
   }

   fmt.Printf("Injector is running...\n")
   log.Fatal(s.ListenAndServeTLS("", ""))
}

func handleRoot(w http.ResponseWriter, r *http.Request) {
   fmt.Fprintf(w, "Hello mutator")
}

Moju aplikáciu tvoria 2 endpointy. Koreňový / endpoint je spracovaný funkciou handleRoot(), a vypíše len jednoduchý text. Slúži len na to, aby Kubernetes vedel, že aplikácia žije.

O druhý /mutate endpoint sa stará funkcia handleMutate. Je to presne tá funkcia, ktorá bude do podov dopĺnať sidecar. Zatial len takto stručne si zalogujem čo mi príde:

func handleMutate(w http.ResponseWriter, r *http.Request) {
   reqBody, _ := ioutil.ReadAll(r.Body)
   defer r.Body.Close()
   fmt.Printf("REQUEST:\n%s\n", reqBody)
	
   // TODO ...
}

Ešte si priravím Dockerfile pre môj injector.

FROM golang:1.18 as builder
WORKDIR /app
ADD go.mod ./go.mod
RUN go mod download

ADD main.go ./main.go
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux go build -a -o injector main.go

FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/injector .
ENTRYPOINT ["./injector"]

Moja prvá mutácia

Teraz prichádza tá časť kódu, ktorá bude pridávať sidecar. Kubernetes teda zavolá /mutate a POSTom odošle na tento endpoint AdmissionReview dáta vo formáte JSON. Ten obsahuje rôzné informácie ako je UID, alebo aj presný manifest podu, ktorý sa snaží Kubernetes vytvoriť.

Kubernetes API a jeho dátove štruktúry niesu práve triviálne a programátorsky čisté veci. Aby som si nemusel písať vlastný typ a parser pre tento JSON, tak siahnem po už existujúcich typoch. Naimportujem a použijem teda to, čo mi Kubernetes dáva:

import (
   ...
   admissionv1 "k8s.io/api/admission/v1"
   corev1 "k8s.io/api/core/v1"
   metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

Teraz môžem vo funkcii handleMutate() sparsovať telo požiadavky:

func handleMutate(w http.ResponseWriter, r *http.Request) {
   reqBody, _ := ioutil.ReadAll(r.Body)
   defer r.Body.Close()
   fmt.Printf("REQUEST:\n%s\n", reqBody)
	
   // parse admission review request
   admReviewRequest := admissionv1.AdmissionReview{}
   json.Unmarshal(reqBody, &admReviewRequest)

   // TODO ...
}

Stále som však neurobil mutáciu. Na to je potrebné vytvoriť odpoveď. Tá bude tiež typu AdmissionReview. Nepôjde ale o zmenenu kópiu toho, čo mi Kubernetes poslal. Odpoveď totiž bude obsahovať tzv. JSONPatch. Mutácie sa totiž realizujú pomocou série JSONPatch-ov. Ide štandardizovaný spôsob, ako meniť dáta v JSONe. Konkrétne je to RFC 6902.

Na prácu s JSONPatch budem potrebovať do kódu doplniť takýto jednoduchý typ:

// RFC 6902
type JSONPatch struct {
   Op    string      `json:"op"`
   Path  string      `json:"path"`
   Value interface{} `json:"value,omitempty"`
}

Jeho použitie je veľmi jednoduché. V Op nastavím aký druh operácie chcem robiť, napr. add. V Path zase poviem, akú časť JSONu chcem meniť touto operáciou. No a potom Value je vnorený JSON, ktorý sa aplikuje.

Injektovanie Envoy sidecaru bude teda JSONPatch, ktorý doplním do handleMutate() funkcie:

func handleMutate(w http.ResponseWriter, r *http.Request) {
   reqBody, _ := ioutil.ReadAll(r.Body)
   defer r.Body.Close()
   fmt.Printf("REQUEST:\n%s\n", reqBody)
	
   // parse admission review request
   admReviewRequest := admissionv1.AdmissionReview{}
   json.Unmarshal(reqBody, &admReviewRequest)

   // create injection JSON Patch
   envoyInjection := []JSONPatch{
      JSONPatch{
         Op:   "add",
         Path: "/spec/containers/-",
         Value: corev1.Container{
            Name:  "envoy",
            Image: "envoyproxy/envoy:v1.20.3",
         },
      },
   }
   envoyInjectionData,_ := json.Marshal(envoyInjection)

   // TODO ...
}

Teraz mám všetko potrebné na vytvorenie AdmissionReview odpovede. Zo spracovanej požiadavky budem potrebovať UID a JSONPatch. To zlepím nasledujúcim kódom:

func handleMutate(w http.ResponseWriter, r *http.Request) {
   reqBody, _ := ioutil.ReadAll(r.Body)
   defer r.Body.Close()
   fmt.Printf("REQUEST:\n%s\n", reqBody)
	
   // parse admission review request
   admReviewRequest := admissionv1.AdmissionReview{}
   json.Unmarshal(reqBody, &admReviewRequest)

   // create injection JSON Patch
   envoyInjection := []JSONPatch{
      JSONPatch{
         Op:   "add",
         Path: "/spec/containers/-",
         Value: corev1.Container{
            Name:  "envoy",
            Image: "envoyproxy/envoy:v1.20.3",
         },
      },
   }
   envoyInjectionData,_ := json.Marshal(envoyInjection)

   // create AdmissionReview response
   patchType := admissionv1.PatchTypeJSONPatch
   admReviewResponse := admissionv1.AdmissionReview{
      TypeMeta: metav1.TypeMeta{
         APIVersion: "admission.k8s.io/v1",
         Kind:       "AdmissionReview",
      },
      Response: &v1.AdmissionResponse{
         UID:       request.Request.UID,
         Allowed:   true,
         Patch:     envoyInjectionData,
         PatchType: &patchType,
      },
   }

   // write to response
   respBody, _ := json.Marshal(admReviewResponse)
   w.WriteHeader(http.StatusOK)
   w.Write(respBody)
}

Kód mam teda hotový. Stači zostaviť kontajner môjho injectora.

docker build -t okontajneroch/injector:latest . 

A môžem sa posunúť k nasadeniu do Kubernetes klastra a k samotnej registrácii Admission Webhook.

Vytvorenie certifikátov

Pozorné oko programátora si zrejme všimlo, že aplikácia používa TLS. Kubernetes API s aplikáciou komunikuje výlučne skrz HTTPS. Čiže potrebujem certifikáty.

Z vytvárania certifikátov mam neustále bolehlav. Ak existuje možnosť, ako sa bolehlavu vyhnúť, tak ju využijem. Aj tu si uľahčím život nástrojom self-signed-cert. Ide o Go nástroj a inštalácia je taká klasická Go:

git clone https://github.com/surajssd/self-signed-cert
go install

Teraz môžem vytvoriť certifikáty:

$ export CA_PATH=$(self-signed-cert --namespace default --service-name okontajneroch-injector)

Nástroj na výstup vypíše cestu, kde boli vytvorené certifikáty. Aby sa mi s tym lepšie pracovalo, tak som si výstup uložil do premennej prostredia CA_PATH. Už len overím, čo mi presne vytvoril nástroj:

$ ls $CA_PATH
ca.crt     server.crt server.key

Vytvorené certifikáty si naimportujem do Kubernetesu, ako injector-certs.

kubectl create secret generic injector-certs \
  --from-file=injector.key=$CA_PATH/server.key \
  --from-file=injector.crt=$CA_PATH/server.crt

Deployment a registrácia Webhooku

Čiže certifikáty mám vyvorené a aplikované. Mám už aj zostavený kontajner. Teraz môžem pristúpiť k samotnému deploymentu môjho injektora. Vytvorím si súbor k8s/injector.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: injector
  labels:
    app: injector
spec:
  replicas: 1
  selector:
    matchLabels:
      app: injector
  template:
    metadata:
      name: injector
      labels:
        app: injector
    spec:
      containers:
        - name: injector
          image: okontajneroch/injector:latest
          volumeMounts:
            - name: injector-certs
              mountPath: /app/certs
      volumes:
        - name: injector-certs
          secret:
            secretName: injector-certs
---
apiVersion: v1
kind: Service
metadata:
  name: injector
  labels:
    app: injector
spec:
  ports:
    - port: 443
      targetPort: 8443
  selector:
    app: injector

Je to bežný Deployment pre Kubernetes. Čo tu stojí za zmienku je, že certifikáty ktoré som si vytvoril sa montujú do kontajnera na cestu /app/certs. Odtiaľ ich môj kód načítava. Kedže injektor je webová služba, tak tu nesmie chýbať aj Service. Injektor je dostupný v rámci klastra pod menom injector.default.svc.cluster.local.

Injektor teraz môžem nasadiť do Kubernetesu klasicky:

kubectl apply -f ./k8s/injector.yaml

Výsledkom je bežiaci injektor.

$ kubectl get pods
NAME                                  READY   STATUS    RESTARTS   AGE
injector-588d98577d-7wg2p   1/1     Running   0          2m

Lenže to nestačí. Kubernetes potrebuje nejakým spôsobom vedieť, že kde presne existuje môj nový webhook, a kedy sa má vyvolať. K tomu slúži MutatingWebhookConfiguration. Zaregistrujem si teda webhook vytvorením k8s/webhook.yaml:

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: injector
  labels:
    app: injector
webhooks:
  - name: injector.default.svc.cluster.local
    admissionReviewVersions: ["v1", "v1beta1"]
    clientConfig:
      caBundle: ${CA_BUNDLE}
      service:
        name: injector
        namespace: default
        path: "/mutate"
        port: 443
    rules:
      - operations: ["CREATE"]
        apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["pods"]
    sideEffects: None
    timeoutSeconds: 5
    reinvocationPolicy: Never
    failurePolicy: Ignore
    namespaceSelector:
      matchLabels:
        injection: enabled

Skôr ako dám apply, tak si povedzme niečo o tejto konfigurácii. Parameter service povie klastru, kde presne má posielať Admission požiadavky. V mojom prípade je to endpoint /mutate.

Okrem toho sú tu rules, alebo pravidlá, kedy je webhook vyvolaný. Môj injector bude reagovať na každé vytvorenie nového podu. Ešte je tu namespaceSelector. Vďaka nemu bude webhook volaný len pre tie pod-y, ktoré su v takom namespace, ktorý ma label injection: enabled. Vyzerá to krkolomne. Je to ale dobrý spôsob, ako mať lepšiu kontrolu nad tým, kde bude môj webhook aplikovaný. Ak by som nemal túto kontrolu, webhook by mohol byť aplikovaný na všetky pody v klustry a mohla by nastať dosť slušná kucapaca.

Posledným ale dôležitým parametrom je caBundle. Ten je potrebný na zabezpečenie zabezpečenej komunikácie s injector. Používam tu ${CA_BUNDLE}. Bohužial kubectl nedokáže robiť substitúciu. Tú spravím ručne pomocou sed a nahradím ${CA_BUDLE} base64 zakódovaným certifikátom tzv. certifikačnej autority. Pozmenený YAML potom pošlem do kubectl apply. Následujúci príkaz je teda trošku komplikovanejší:

sed "s|\${CA_BUNDLE}|`cat $CA_PATH/ca.crt|base64`|g" webhook.yaml | kubectl apply -f -

Injector beží, webhook je zaregistrovany. Môžem začať skúšať. Najprv ale pridam label pre demoapp namespace. Dôvod je práve vyžšie zmienovaný namespaceSelector.

kubectl label namespace demoapp injection=enabled --

Opäť nasadím testovaciu aplikáciu.

$ kubectl delete pod demoapp -n demoapp && kubectl run demoapp \
  --image=hashicorp/http-echo \
  --namespace demoapp \
  -- -text hello

Kedže som POD zmazal a znovu vytvoril, došlo ku CREATE a Kubernetes vyvolal webhook. To si môžem overiť v logoch injectora:

$ kubectl get logs injector-588d98577d-7wg2p
Injector is running...
called mutate
REQUEST:
{"kind":"AdmissionReview","apiVersion":"admission.k8s.io/v1","request":{"uid":"6f84fe4b-902a-47ec-bd52-50a3a2bd16f2","kind":...

Injector teda obdržal HTTP požiadavku, ktorej obsah je dosť rozsiahly AdmissionReview JSON.

Následne môj injector odpovedal mutáciou a tá by sa mala prejaviť. Nový pod by mal okrem pôvodného hashicorp/http-echo kontajnera obsahovať aj injectnutý Envoy proxy kontajner:

$ kubectl get pod demoapp -n demoapp -o jsonpath='{.spec.containers[*].image}'
hashicorp/http-echo envoyproxy/envoy:v1.20.3

Záver

Admission webhook je dôležitý koncept Kubernetes a jeho API. Tieto webhooky nepoužívaju len Istio alebo Linkerd service mesh. Su na tom postavené aj rôzne iné nástroje, ktore riešia napríklad rôzne pravidlá a policies. Napríklad OPA Gateway alebo Kyverno. Tie sa zase sústredujú skôr na validačnú časť.

S veľkou mocou prichádza aj veľká zodpovednosť. Treba si uvedomiť, že webhooky vedia ovplyvnovať chod celého klastra. V prípade chybného kódu teda vedia nepriaznivo ovplyvniť všetko, čo v klastri beží. Preto je dobré mať ich pod kontrolou a vedieť ich oklieštiť, resp. vypnúť. .

<