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.
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.
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úť. .