Das Herzstück von Kubernetes ist seine API sowie die Erweiterbarkeit dieser. Heute möchten wir die Kubernetes API um eine eigene Komponente erweitern: einen Admission Controller.
Admission Controller sind Code. Sie fangen Requests an die Kubernetes API ab und modifizieren oder validieren diese, bevor die Inhalte dieser Requests persistiert werden. Admission Controller haben drei Operation Modes: Sie können Requests validieren, verändern oder beide vorherigen Operationen durchführen. Das unten stehende Schaubild erläutert den (zum Verständnis nicht vollständigen) Flow eines Requests von der Entgegennahme des API-Servers bis hin zur Persistierung.
Kubernetes liefert von Haus aus eine ganze Menge Admission Controller mit und viele davon sind bereits per default aktiviert. Eine aktuelle Liste aller ab Werk aktivierten Admission Controller findet sich hier: kube-apiserver options unter: -enable-admission-plugins
Nicht verwirren lassen: In der offiziellen Dokumentation werden die Begriffe Admission Controller und Admission Plugin synonym verwendet. Im weiteren Verlauf des Blog Posts nutzen wir den Begriff Admission Controller oder die Begriffe Validating Webhook und Mutating Webhook zur genaueren Abgrenzung.
Warum sollte ich Admission Controller nutzen?
Möglicherweise möchte man gewisse Best Practices in seinen Clustern enforcen. Man könnte hier u. a. auch Kyverno (darüber hat mein Kollege Simon Dreher hier ausführlicher berichtet) benutzen, aber auch ein eigener Admission Controller ist gar nicht so schwer. Prinzipiell stehen einem damit viele Möglichkeiten zur Verfügung. Einige Beispiele für Use Cases wären:
- Man möchte sicherstellen, dass Deployments oder Pods einen securityContext verwenden
- oder man möchte als Cluster Admin bei jedem Deployment den Timestamp als eigenes Label oder Annotation setzen
- oder es soll sichergestellt werden, dass eine bestimmte Namenskonvention für Deployments und Services eingehalten wird.
Sollten wir also sicherstellen wollen, dass Pods in unserem Cluster einen securityContext gesetzt haben, habe ich zwei Möglichkeiten:
- Ich kann die Erstellung des Pods mittels eines Validating Controllers ablehnen und (optional) dem/der Anwender:in / Entwickler:in eine Fehlermeldung ausgeben oder
- ich kann mittels eines Mutating Controllers einen default securityContext setzen, sollte das übermittelte Deployment Manifest diesen nicht definiert haben.
Grundlegende Architektur eines Admission Controllers
Bevor wir uns an die Entwicklung eines eigenen Admission Controllers machen, schauen wir uns noch kurz an, wo ein eigen entwickelter Admission Controller in die Gesamtarchitektur von Kubernetes passt. Grundlegend bestehen Admission Controller aus zwei Komponenten:
Aus einem Kubernetes API Objekt sowie einem Webhook Server, der Anfragen in Form eines AdmissionReview Objekts in JSON entgegennimmt, verarbeitet und mit einem AdmissionReview Objekt in JSON antwortet.
Beim ersten Lesen mag das verwirrend klingen, jedoch unterscheiden sich Anfrage und Antwort im Inhalt des AdmissionReview Objektes.
Um festzulegen, welche Objekte bei welcher Operation zur Validierung oder Modifikation an welche Controller gesendet werden, müssen ValidatingWebhookConfiguration– oder MutatingWebhookConfiguration-Objekte angelegt werden. Diese Objekte stellen gemeinsam mit dem Webhook Server die drei Bausteine eines Admission Controllers dar.
Entwicklung eines eigenen Admission Controllers
Um das neu Erlernte zu vertiefen, entwickeln wir unseren eigenen Admission Controller und werden diesen lokal auf einem kind Cluster provisionieren und testen. Wir möchten beide zuvor genannten Möglichkeiten (Validating and Mutating) implementieren. Zum einen wollen wir Deployments verhindern, denen das Feld runAsNonRoot innerhalb des SecurityContext fehlt (Validating Webhook) und zum anderen möchten wir Deployments immer mit einer Timestamp Annotation versehen (Mutating Webhook).
Realisieren werden wir das ganze in Go und damit wir direkt mit der Implementierung starten können, findet sich unter https://github.com/inovex/blog-dynamicadmissioncontrol_template ein Template Repository. Der Code dieses Repositories basiert zu großen Teilen auf der Referenzimplementierung des Admission Webhook Servers.
Einziger Unterschied: In der Referenzimplementierung werden sowohl v1beta1 als auch v1 Version der admissionregistration.k8s.io API verwendet. Ab Kubernetes Version 1.22 ist v1beta1 jedoch deprecated, daher setzen wir hier in unserem Beispiel ausschließlich auf die stabile und auch längerfristig verfügbare Version v1.
Das Template Repository enthält bereits einen grundlegenden funktionierenden Webhook Server, jedoch fehlt diesem die Logik für das validieren oder modifizieren von Requests. Die Logik für die beiden Funktionen befindet sich in der Datei webhook.go, genauer in den beiden selbst beschreibenden Funktionen validate und mutate.
Validating Webhook implementieren
Unter Webhook Request finden wir das Format des AdmissionReview-Objektes, das der API Server an unseren Webhook Server senden wird. Unserer validate-Funktion wird das AdmissionReview-Objekt übergeben. Um nun zu überprüfen, ob der SecurityContext gesetzt wurde, greifen wir auf das eigentlich zu erstellende Objekt innerhalb des AdmissionReview-Objektes zu.
Da wir wissen, dass wir unseren Webhook Server lediglich für die Validierung von Deployments verwenden wollen und dies auch später in unserer ValidatingWebhookConfiguration konfigurieren werden, können wir das JSON-Objekt einfach mittels json.Unmarshal in unser appsv1.Deployment struct packen.
Mittels reflect.ValueOf(deploy.Spec.Template.Spec.SecurityContext.RunAsNonRoot).IsNil() überprüfen wir nun, ob das Feld gesetzt ist. (Wir gehen davon aus, dass unsere Entwickler:innen wissen, was Sie tun, und erwarten hier kein true als Wert des Feldes.)
Nach unserer Prüfung geben wir das AdmissionReview-Objekt wieder an unsere serve-Funktion zurück und sind damit schon mit unserem ValidatingWebhook fertig. Die Funktion sieht dann am Ende in etwa so aus:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
func validate(ar admissionv1.AdmissionReview) *admissionv1.AdmissionResponse { klog.Info("entering the validate func") // Preparing our review response reviewResponse := admissionv1.AdmissionResponse{} reviewResponse.Allowed = true raw := ar.Request.Object.Raw var deploy appsv1.Deployment // Unmarshalling the data in the deployment struct if err := json.Unmarshal(raw, &deploy); err != nil { klog.Errorf("Could not unmarshal raw object: %v", err) return toAdmissionResponse(err) } // Check if RunAsNonRoot is set // It may be set to false in edge cases, but it needs to be set if reflect.ValueOf(deploy.Spec.Template.Spec.SecurityContext.RunAsNonRoot).IsNil() { err := errors.New("need to set RunAsNonRoot") return toAdmissionResponse(err) } return &reviewResponse } |
Mutating Webhook implementieren
Wie initial erwähnt, möchten wir einen Timestamp als Annotation setzen. Dafür müssen wir das zu erstellende Objekt anpassen, dies geschieht über ein Verfahren namens JSON Patch.
Wir holen uns mittels Go Standardlibrary und time.Now() einen aktuellen Zeitstempel und speichern diesen in der Variable date. Anschließend bauen wir uns aus diesem Zeitstempel und unserem JSON Patch das passende Objekt zusammen, das wir dem APIServer mitgeben möchten.
Man könnte möglicherweise erwarten, das Objekt der Begierde direkt im Code anzupassen und an den API Server zurückzusenden, um den gewünschten Effekt zu erzielen. Das AdmissionResponse Objekt erwartet JSON Patches jedoch in einem eigens dafür vorgesehenen Feld. Den Patch selbst nimmt dann der API Server vor.
In Code sieht das ganze dann so aus:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
func mutate(ar admissionv1.AdmissionReview) *admissionv1.AdmissionResponse { klog.Infoln("entering the mutate func") // Preparing our review Response reviewResponse := admissionv1.AdmissionResponse{} // As we are mutating here and are just adding an annotation, we will allow this operation reviewResponse.Allowed = true // Getting the current date for the timestamp annotation date := time.Now() // Creating our Patch Operation (This is just for demonstration purposes) - see also: https://tools.ietf.org/html/rfc6902 addTimeStampAnnotation := `[{ "op": "add", "path": "/metadata/annotations/deployment_timestamp", "value": "` + date.String() + `" }]` // Adding the Timestamp to the Object reviewResponse.Patch = []byte(addTimeStampAnnotation) pt := admissionv1.PatchTypeJSONPatch reviewResponse.PatchType = &pt return &reviewResponse } |
Damit haben wir auch unseren MutatingWebhook fertig gestellt und können uns nun an das Deployment in unserem Test-Setup mit einen lokalen kind Cluster machen.
Aufsetzen der lokalen Testumgebung
Für das Aufsetzen einer lokalen Testumgebung in Form eines kind Clusters verweisen wir hier an der Stelle an die offizielle Dokumentation.
Ein beliebiges Kubernetes Cluster mit von dir erreichbarer Registry funktioniert natürlich ebenfalls.
Docker Image bauen und verfügbar machen
Das Template-Repository enthält ein Makefile, welches alle Kommandos zum Bauen, Deployen und Testen unseres Webhook Servers bündelt. Um unsere Anwendung über das mitgelieferte Dockerfile zu bauen und in unserem kind Cluster bereitzustellen, nutzen wir die beiden Befehle:
1 2 |
make build make pushimage |
Nun steht unser Webhook Server Image für ein Deployment in unserem kind Cluster zur Verfügung. Welche Kommandos hinter den beiden make Kommandos stecken, kann man über das Betrachten des Makefiles herausfinden.
Zertifikate
Bevor wir nun unseren Admission Controller deployen und nutzen können, müssen wir noch valide Zertifikate erstellen und in unserem kind Cluster verfügbar machen. Hierfür nutzen wir cfssl und das bereitgestellten Zertifikatsrequest in der Datei certs/csr.json, mit dem wir ein self-signed Zertifikat für unseren Webhook Server erstellen können.
Achtung: In einem produktiven Setup keine self-signed Zertifikate verwenden. 🙂
Mittels der nachfolgenden Befehle erstellen wir ein selbst signiertes Zertifikat und legen dieses als Secret mit dem Namen inovex-webhook-certs in unserem kind Cluster an:
1 2 |
cfssl selfsign inovex-webhook.default.svc csr.json | cfssljson -bare selfsigned kubectl create secret tls --key selfsigned-key.pem --cert selfsigned.pem inovex-webhook-certs |
Bevor wir nun mit dem Deployment starten können, müssen wir der WebhookConfiguration, die wir vorbereitet in der deployment.yml finden, noch das caBundle unseres Zertifikats mitgeben. Denn, laut Definition:
caBundle is a PEM encoded CA bundle which will be used to validate the webhook’s server certificate.
Da wir ein self-signed Zertifikat nutzen, setzen wir den Output des folgenden Kommandos in unserer deployment.yaml als Value für den Key caBundle:
1 2 3 4 5 |
# On Linux: base64 -w0 selfsigned.pem # On Darwin: base64 selfsigned.pem |
Fast auf der Zielgeraden angekommen, sollten wir uns aber noch einmal kurz die MutatingWebhookConfiguration bzw. ValidatingWebhookConfiguration in der deployment.yml unseres Template Repositories genauer ansehen.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
--- apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingWebhookConfiguration metadata: name: inovex-webhook-validate webhooks: - name: webhook-validate.inovex.io clientConfig: service: name: inovex-webhook namespace: default path: "/validate" port: 8443 caBundle: "$CA_BUNDLE" rules: - operations: ["CREATE","UPDATE"] apiGroups: ["apps"] apiVersions: ["v1"] resources: ["deployments"] scope: "*" failurePolicy: Fail admissionReviewVersions: ["v1"] sideEffects: None timeoutSeconds: 10 [...] |
Was machen wir hier? Grob gesagt definieren wir Regeln, unter denen unser Webhook Server kontaktiert werden soll und wo dieser denn zu finden ist. Diese Regeln definieren wir unterhalb des „rules” keys und im Falle einer CREATE oder einer UPDATE Operation eines Deployments wird unser Webhook Server mit den Werten der clientConfig auf Port 8443 und unter dem Pfad /validate aufgerufen. Die MutatingWebhookConfiguration ist unterscheidet sich lediglich in der Pfadangabe und den Metadaten. Für nähere Informationen zur ValidatingWebhookConfiguration oder MutatingWebhookConfiguration sowie der weiteren Optionen empfehlen wir die Kubernetes API Referenz.
Deployment und Tests
Nun haben wir alle Voraussetzungen erfüllt, die wir für das Deployment unseres Admission Controllers benötigen. Mithilfe des Kommandos
1 |
make deploy |
können wir unseren selbst entwickelten Admission Controller deployen. Im Optimalfall erhalten wir dann nach einem
1 |
kubectl get pods -n default |
folgendes Ergebnis:
1 2 3 |
$ kubectl get pods -n default NAME READY STATUS RESTARTS AGE inovex-webhook-bf69cf847-c22zq 1/1 Running 0 24h |
Unser Webhook Server steht also nun bereit.
Die soeben erstellte ValidatingWebhookConfiguration/MutatingWebhookConfiguration können wir uns mit:
1 |
$ kubectl get validatingwebhookconfigurations.admissionregistration.k8s.io |
beziehungsweise:
1 |
$ kubectl get mutatingwebhookconfigurations.admissionregistration.k8s.io |
anzeigen lassen.
Da wir in unseren WebhookConfigurations den Service unseres Webhook Servers als Ziel angeben, werfen wir auch noch schnell einen Blick auf unseren Service um sicherzustellen, dass das Anlegen auch hier erfolgreich war:
1 2 3 |
$ kubectl get svc -n default NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE inovex-webhook ClusterIP 10.96.198.140 <none> 8443/TCP 24h |
In unserem Template Repository haben wir für den Test unseres Setups eine test_deployment.yml Datei vorbereitet. In dieser befinden sich zwei Deployments.
Das erste Deployment hat den Namen testapp-failed und sollte sich aufgrund des fehlenden SecurityContexts nicht ausrollen lassen. Das zweite Deployment hat den Namen testapp und besitzt eine gültige Konfiguration. Hier ist runAsNonRoot unterhalb des securityContext gesetzt und bei einem Deployment sollte unser Validating Webhook das Deployment erlauben. Der von uns entwickelte MutatingWebhook sollte vor der Persistierung noch einen aktuellen Timestamp in die Annotationen des Deployments packen.
Über
1 2 3 4 5 |
make test oder kubectl apply -f test_deployment.yml |
können wir unseren Test starten:
1 2 |
deployment.apps/testapp created Error from server: error when creating "test_deployment.yml": admission webhook "webhook-validate.inovex.io" denied the request: need to set RunAsNonRoot |
Wir sehen nun, dass unser Deployment names testapp erfolgreich ausgerollt wurde. Das Deployment testapp-failed wurde mit unserer oben definierten Fehlermeldung abgewiesen. Mission erfolgreich.