Piccolo riassunto del post precedente: per poter accedere al cluster di Kubernetes e poter operare sulle risorse presenti, ho bisogno di un Service Account al quale saranno collegati Role apposite che abiliteranno determinate azioni.
Risultato finale che voglio in questo post? Creare un operator abbastanza semplice, di utilità quasi nulla, ma che potrebbe essere utile a me in futuro quando vorrò, o dovrò riprendere in mano l'argomento. Lo scopo degli Operator in Kubernetes è quello di creare e gestire gli oggetti all'interno di Kubernetes. Solitamente si parte dalla definizione di una Custom Resource sulla quale un nostro controller eseguirà i compiti di controllo ed eventualmente, di creazione, modifica e cancellazione di oggetti all'interno nel nostro cluster. Sempre da questo controller possiamo il corretto funzionamento del tutto ed agire di conseguenza.
Ma il primo passaggio non obbligatorio in Kubernetes è creare le Custom Resource usate per la gestione delle risorse personalizzate che saranno poi gestite internamente. La loro definizione ci permette di inserire questa configurazione che userò per il mio Operator, per esempio:
apiVersion: "example.com/v1" kind: RefreshConfigMap metadata: name: my-refresh-config-map spec: configName: "example-config" deploymentName: "nginx-deployment"
Il kind di questo oggetto non esiste, e infatti se provassi ad eseguire questa configurazione in Kubernetes otterrei un errore. Innanzitutto si deve configurare questa nuova Resource, lo si fa con questo script:
apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: # name must match the spec fields below, and be in the form: <plural>.<group> name: refreshconfigmap.example.com spec: # group name to use for REST API: /apis/<group>/<version> group: example.com # list of versions supported by this CustomResourceDefinition versions: - name: v1 # Each version can be enabled/disabled by Served flag. served: true # One and only one version must be marked as the storage version. storage: true schema: openAPIV3Schema: type: object properties: spec: type: object properties: configName: type: string deploymentName: type: string # either Namespaced or Cluster scope: Namespaced names: # plural name to be used in the URL: /apis/<group>/<version>/<plural> plural: refreshconfigmap # singular name to be used as an alias on the CLI and for display singular: refreshconfigmap # kind is normally the CamelCased singular type. Your resource manifests use this. kind: RefreshConfigMap # shortNames allow shorter string to match your resource on the CLI shortNames: - rcm
Come da documentazione, qui ho definito, a fine script, il kind come RefreshConfigMap, quindi nella parte iniziale il nome con tanto di namespace: refreshconfigmap.example.com. Viene consigliato l'uso di un dominio completo per permettere una migliore classificazione dei propri custom resource, e per semplicità ho lasciato, come da esempio, example.com. In spec ho definito quali parametri è possibile definire in questa nuova resource - come visto precedente, solo due voci, configName e deploymentName (se viene definita qualche altro parametro non conosciuto sarà visualizzato un errore). Infine in names ho inserito la definizione singolare, plurale e lo shortname per le richieste API future (come da kubectl).
Lanciato il file qui sopra con il classico:
kubectl create -f custom-crd.yaml
E' possibile ora inserire la custom resource:
kubectl create -f refreshconfigmap_test.yaml
Ora la Resource sarà creata e sarà possibile listarle (notare l'uso dello short-name rcm), averne i dettagli e cancellarla:
>kubectl get rcm NAME AGE my-refresh-config-map 11s >kubectl describe rcm my-refresh-config-map Name: my-refresh-config-map Namespace: default Labels: <none> Annotations: <none> API Version: example.com/v1 Kind: RefreshConfigMap Metadata: Creation Timestamp: 2021-11-22T19:12:35Z Generation: 1 Managed Fields: API Version: example.com/v1 Fields Type: FieldsV1 fieldsV1: f:spec: .: f:configName: f:deploymentName: Manager: kubectl-create Operation: Update Time: 2021-11-22T19:12:35Z Resource Version: 733 UID: 3d00dc54-8d7f-47ed-97ce-49905d3dd44b Spec: Config Name: message-config Deployment Name: busybox-deployment Events: <none>
Sono solo a metà strada perché le informazioni sono sì salvate, ma Kubernetes non saprà che altro farsene. Per utilizzarle mi vengono in aiuto i custom controller, secondo tassello necessario per la creazione di un Operator. Su questo due piccoli appunti personali: il primo è la quasi totalità degli esempi disponibili in Internet e nella documentazione ufficiale utilizzano il linguaggio Go; per chi come me è solo andato poco al di là di Hello World! con questo linguaggio non è proprio d'aiuto, ma per fortuna è presente la classe ben supportata scritta in Net Core, vista nel post precedente, che semplifica l'accesso alle API di Kubernetes. Il secondo appunto è l'impossibilità di testare quanto scritto senza dover mettere mano ad un cluster reale (anche locale va bene). Questo comporta che, una vola scritto il tutto anche con tutto il codice di testing necessario, sarà necessario creare un'immagine Docker e avviarla sul cluster per poterne controllare a fondo il funzionamento - ho trovato in rete alcuni suggerimenti per velocizzare la cosa, ma sono stati tutti fallimentari e scomodi: alla fine mi sono creato un esempio che loggava qualsiasi messaggio ricevuto dalle API e che riutilizzavo per la scrittura del codice del mio controller definitivo per la gestione dei messaggi. In cantiere mi sono scritto qualcosa di non risolutivo, non incluso nel progetto finale pubblico.
Ma qual è lo scopo di tutto questo? Sto creando un Operator che leggendo le informazioni dal mio custom resource si prende carico di riavviare un Deploy specifico in caso di modifica di un ConfigMap. Per chi conosce Kubernetes e l'utilizzo dei ConfigMap all'interno dei Pod, sa che le informazioni di un ConfigMap possono essere passate al Pod come environment variable o file; il problema c'è quando una applicazione non è in grado di vedere i cambiamenti (solitamente una applicazione si legge i dati di configurazione e se li tiene in memoria senza più controllare eventuali aggiornamenti) e l'unico modo per aggiornare questi dati e riavviare l'applicazione - lo so, potevo scegliermi un esempio migliore.
Per il mio test ho creato un Deployment che istanzia un Pod:
apiVersion: apps/v1 kind: Deployment metadata: name: busybox-deployment spec: selector: matchLabels: app: busybox replicas: 1 template: metadata: labels: app: busybox spec: containers: - name: busybox image: busybox:1.34 command: ["/bin/sh"] args: ["-c", "printenv; sleep infinity"] env: - name: messageconfig valueFrom: configMapKeyRef: name: message-config key: my-message
Il Pod contiene l'immagine minimale Busybox e visualizza con printenv le environment variable. Una di esse, messageconfig, è presa dal ConfigMap:
apiVersion: v1 kind: ConfigMap metadata: name: message-config namespace: default data: my-message: "Hello World!"
Una volta avviato all'interno del cluster Kubernetes, con il comando log sul Pod, potrò vedere il contenuto:
>kubectl logs busybox-deployment-7f6dc75958-x8wwc KUBERNETES_PORT=tcp://10.96.0.1:443 KUBERNETES_SERVICE_PORT=443 HOSTNAME=busybox-deployment-7f6dc75958-x8wwc SHLVL=1 HOME=/root KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin KUBERNETES_PORT_443_TCP_PORT=443 KUBERNETES_PORT_443_TCP_PROTO=tcp KUBERNETES_SERVICE_PORT_HTTPS=443 KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443 KUBERNETES_SERVICE_HOST=10.96.0.1 PWD=/ messageconfig=Hello World!
Lo scopo del mio Controller di esempio è proprio questo: controllare eventuali configurazioni inserite nel Custom Resource e verificare, aggiornare e cancellare gli oggetti all'interno del cluster di Kubernetes. Per ottenere questo mi sono creato una console application in Net Core che, con la classe KubernetesClient, rimane in ascolto di notifiche appropriate:
var config = KubernetesClientConfiguration.InClusterConfig(); // Use the config object to create a client. var client = new Kubernetes(config); var result = await client.ListNamespacedCustomObjectWithHttpMessagesAsync( group: _appConfiguation.Group, version: _appConfiguation.Version, namespaceParameter: _appConfiguation.NamespaceParameter, plural: _appConfiguation.Plural, watch: true, timeoutSeconds: (int)TimeSpan.FromMinutes(60).TotalSeconds, cancellationToken: _cancellationToken.Token ) .ConfigureAwait(false);
Al metodo ListNamespacedCustomObjectWithHttpMessagesAsync passo i parametri in modo che mi notifichi la creazione/modifica/cancellazione della mia Custom Resource. Prendendo la configurazione presente nel file visto prima - custom-crd.yaml - in group inserisco la stringa example.com (come nel group in spec, così come apiVersion (version -> name in spec). Namespace e plural sono presi anch'essi dalla definizione del custom-crd.yaml file. Fine. Al primo avvio a questo metodo saranno passati tutte le notifiche su questo oggetto, sia quelle già eseguite sia quelle future. Questa funzione ritorna un oggetto al quale è possibile collegare una funzione al watch che riceverà effettivamente tutte le notifiche - è possibile deserializzare la risposta in un oggetto specifico, ma per le Custom Resource ho sempre avuto problemi ed ho preferito trattare la risposta in json nativa. Due sono i parametri ritornati: il metodo utilizzato e un json con i dettagli della richiesta fatta. I metodi utilizzati sono solo tre: added, modified e deleted che specificano se una risorsa è stata creata, modificata o cancellata. Nel mio codice tratto tutti questi metodi per archiviare in un oggetto condiviso le coppie di valori configName e deplymentName. E' tutto per questo primo passo. E che cosa ne farò di queste informazioni? In parallelo richiamo anche il metodo ListConfigMapForAllNamespacesWithHttpMessagesAsync per avere la lista delle ConfigMap e tutte le operazioni fatte su di esse, le stesse viste prima: added, modified, deleted. Ora, collegando sempre il watch ad un mio metodo, controllo se è stata fatta una operazione ad un ConfigMap nella lista salvata prima. Se sì, posso prendere il deploymentname ed operare su di esso.
Ecco un esempio di notifica della mia custom resource:
{ "apiVersion": "v1", "binaryData": null, "data": { "my-message": "Hello World2!" }, "immutable": null, "kind": "ConfigMap", "metadata": { "annotations": { "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"data\":{\"my-message\":\"Hello World2!\"},\"kind\":\"ConfigMap\",\"metadata\":{\"annotations\":{},\"name\":\"message-config\",\"namespace\":\"default\"}}\n" }, "clusterName": null, "creationTimestamp": "2021-11-23T16:36:45Z", "deletionGracePeriodSeconds": null, "deletionTimestamp": null, "finalizers": null, "generateName": null, "generation": null, "labels": null, "managedFields": [ { "apiVersion": "v1", "fieldsType": "FieldsV1", "fieldsV1": { "f:data": { ".": {}, "f:my-message": {} }, "f:metadata": { "f:annotations": { ".": {}, "f:kubectl.kubernetes.io/last-applied-configuration": {} } } }, "manager": "kubectl-client-side-apply", "operation": "Update", "subresource": null, "time": "2021-11-23T16:36:45Z" } ], "name": "message-config", "namespace": "default", "ownerReferences": null, "resourceVersion": "6923", "selfLink": null, "uid": "11dbdc82-ffc5-4f0d-954e-1bc8e2ed3eca" } }
Ora ho tutte le informazioni che servono: ho ricevuto la notifica che un ConfigMap è stato modificato e ora posso riavviare i Pod nel Deploy interessato per vedere le modifiche. Qui sono possibili più scelte, la prima è prendere a uno a uno i Pod e cancellarli in modo che Kubernetes, in automatico, li ricrei. Altrimenti, come ho fatto nel codice in esempio, prendo il numero definito per il ReplicaSet, lo azzero e lo imposto al suo valore precedente. In questo modo i Pod vengono cancellati e ricreati con le informazioni aggiornate:
private async Task RefreshDeploymentAsync(Kubernetes client, string deployment, string nameSpace) { try { _log.Info($"RefreshDeployment '{deployment}' [{nameSpace}]"); var patchStr = @" { ""spec"": { ""replicas"": {n} } }"; var reply = await client.ReadNamespacedDeploymentAsync(deployment, nameSpace); if (reply is null) { _log.Info($"RefreshDeployment {deployment} not found"); return; } int numberOfPods = reply.Spec.Replicas ?? 0; _log.Info($"Num of pods: {numberOfPods}"); if (numberOfPods == 0) { _log.Info($"RefreshDeployment '{deployment}' replicas is zero"); return; } await client.PatchNamespacedDeploymentAsync( new V1Patch(patchStr.Replace("{n}", "0"), V1Patch.PatchType.MergePatch), deployment, nameSpace); await client.PatchNamespacedDeploymentAsync( new V1Patch(patchStr.Replace("{n}", numberOfPods.ToString()), V1Patch.PatchType.MergePatch), deployment, nameSpace); _log.Info($"RefreshDeployment ALL OK"); } catch (Exception ex) { _log.Error($"RefreshDeployment {ex.Message}", ex); } }
Ma prima di mettere in pratica il tutto ho dimenticato che, come detto nel post precedente, è necessario creare delle Role apposite per poter accedere dai Pod alla risorse di Kubernetes. Inizio creando il Service Account:
apiVersion: v1 kind: ServiceAccount metadata: name: crd-service-account namespace: default
Ora devo creare tre Role. La prima è per poter controllare (con il watch) i ConfigMap, quindi per il Deployment devo poter richiedere la struttura attuale (get) e poterla modificare (patch), e infine per la Custom Resource devo controllare eventuali inserimenti e modifiche della loro configurazione (watch). Ecco le Role:
--- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: crd-configmap-role rules: - apiGroups: [""] resources: ["configmaps"] verbs: ["watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: crd-deployment-role rules: - apiGroups: ["", "apps"] resources: ["deployments"] verbs: ["get", "patch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: crd-refreshconfigmap-role rules: - apiGroups: ["example.com"] resources: ["refreshconfigmaps"] verbs: ["watch"]
Le quali devono essere legate al Service Account:
--- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: allow-configmap subjects: - kind: ServiceAccount name: crd-service-account namespace: default roleRef: kind: ClusterRole name: crd-configmap-role apiGroup: rbac.authorization.k8s.io --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: allow-deployment subjects: - kind: ServiceAccount name: crd-service-account namespace: default roleRef: kind: ClusterRole name: crd-deployment-role apiGroup: rbac.authorization.k8s.io --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: allow-refreshconfigmap subjects: - kind: ServiceAccount name: crd-service-account namespace: default roleRef: kind: ClusterRole name: crd-refreshconfigmap-role apiGroup: rbac.authorization.k8s.io
Fine. Ora posso controllare che funzioni il tutto. Nel codice in allegato sono presente gli script per creare il tutto. Primo passo, creare il Custom Resource:
kubectl create -f custom-crd.yaml
Secondo passo: creare il Service Account e tutte le Role necessarie:
kubectl create -f crd.yaml
E' il momento di avviare il Controller, ma prima si deve creare l'immagine Docker (nel progetto contenente il codice sorgente e i file yaml per la creazione di tutto il necessario in Kubernetes, è presente anche il Dockerfile per la sua creazione):
kubectl create -f controller.yaml
Questo file contiene la configurazione di un Deployment, eccolo:
apiVersion: apps/v1 kind: Deployment metadata: name: dotnet-controller labels: app: dotnet-controller spec: replicas: 1 selector: matchLabels: app: dotnet-controller template: metadata: labels: app: dotnet-controller spec: serviceAccountName: crd-service-account containers: - name: main image: sbraer/kubernetescontroller1:v1 env: - name: Group value: "example.com" - name: Version value: v1 - name: NamespaceParameter value: "" - name: Plural value: refreshconfigmaps - name: FieldSelector value: ""
Nelle environment variable passo le informazioni sul Custom Resource creato prima.
Controllo nel log del Pod del Controller se tutto viene eseguito correttamente:
>kubectl get po NAME READY STATUS RESTARTS AGE dotnet-controller-6cc69cb799-dbqk2 1/1 Running 0 15s >kubectl logs dotnet-controller-6cc69cb799-dbqk2 Start check controller CRD Wait new CRD configuration... Wait ConfigMap... Old CRD Messages
Fin qui tutto ok. Ora posso finalmente inserire la configurazione che il Controller dovrà monitorare:
apiVersion: "example.com/v1" kind: RefreshConfigMap metadata: name: my-refresh-config-map spec: configName: message-config deploymentName: busybox-deployment
Con il comando:
kubectl create -f refreshconfigmap_test.yaml
Controllo il log del Controller:
>kubectl logs dotnet-controller-6cc69cb799-dbqk2 Start check controller CRD Wait new CRD configuration... Wait ConfigMap... CRD deploymentName = 'busybox-deployment' configName = 'message-config' uid = 'f0e0a89b-c73e-4854-8d7e-2f943b5d0a9f' CRD Added
E l'ultima riga mostra che è stato letto e inserito nella proprio lista interna (CRD Added). Ora è il momento di avviare il Deployment di test visto prima:
>kubectl apply -f env_test_2.yaml configmap/message-config created >kubectl create -f env_test.yaml deployment.apps/busybox-deployment created >kubectl get po NAME READY STATUS RESTARTS AGE busybox-deployment-7f6dc75958-8drp2 1/1 Running 0 13s dotnet-controller-6cc69cb799-dbqk2 1/1 Running 0 2m59s >kubectl logs busybox-deployment-7f6dc75958-8drp2 KUBERNETES_SERVICE_PORT=443 KUBERNETES_PORT=tcp://10.96.0.1:443 HOSTNAME=busybox-deployment-7f6dc75958-8drp2 SHLVL=1 HOME=/root KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin KUBERNETES_PORT_443_TCP_PORT=443 KUBERNETES_PORT_443_TCP_PROTO=tcp KUBERNETES_SERVICE_PORT_HTTPS=443 KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443 KUBERNETES_SERVICE_HOST=10.96.0.1 PWD=/ messageconfig=Hello World!
Ora aggiorno il file env_test_2.yaml cambiando il messaggio a Hello World 2! e inserisco la modifica:
>kubectl apply -f env_test_2.yaml configmap/message-config configured >kubectl get po NAME READY STATUS RESTARTS AGE busybox-deployment-7f6dc75958-8drp2 1/1 Terminating 0 2m49s busybox-deployment-7f6dc75958-hbfqh 1/1 Running 0 7s dotnet-controller-6cc69cb799-dbqk2 1/1 Running 0 5m35s >kubectl logs busybox-deployment-7f6dc75958-hbfqh KUBERNETES_SERVICE_PORT=443 KUBERNETES_PORT=tcp://10.96.0.1:443 HOSTNAME=busybox-deployment-7f6dc75958-hbfqh SHLVL=1 HOME=/root KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin KUBERNETES_PORT_443_TCP_PORT=443 KUBERNETES_PORT_443_TCP_PROTO=tcp KUBERNETES_SERVICE_PORT_HTTPS=443 KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443 KUBERNETES_SERVICE_HOST=10.96.0.1 PWD=/ messageconfig=Hello World 2!
Dopo l'aggiornamento il Pod vecchio è stato cancellato e un altro è stato avviato, e conterrà il corretto messaggio. Il log del Controller mostrerà l'avvenuto refresh del Deployment (che è stato eseguito azzerando il numero di Pod e poi ripristinando il valore):
>kubectl logs dotnet-controller-6cc69cb799-dbqk2 Start check controller CRD Wait new CRD configuration... Wait ConfigMap... CRD deploymentName = 'busybox-deployment' configName = 'message-config' uid = 'f0e0a89b-c73e-4854-8d7e-2f943b5d0a9f' CRD Added ConfigMap Search message-config ConfigMap Refresh busybox-deployment default RefreshDeployment 'busybox-deployment' [default] Num of pods: 1 RefreshDeployment ALL OK
Questo banale esempio mostra solo una parte delle potenzialità degli Operator in Kubernetes. Grazie alle API interne è possibile controllare ogni cosa, dal crearsi un proprio gestore di log globale che controlla tutti i Pod, dal misuratore di risorse occupate nei Node che compongono il cluster, alla creazione e gestione di servizi complessi e strutturati...
Giunti alla fine qualche piccola nota. Confrontando i controller creati in Net Core vs Go è sotto gli occhi di tutti una grande differenza: la dimensione delle immagini Docker. Il linguaggio Go compila il tutto direttamente in un eseguibile senza le dipendenze delle applicazioni in Net Core, e l'immagine Docker sta tranquillamente sotto i 10MB. Nel caso della mia applicazione in Net Core 6, se usassi la classica build multi stage, la dimensione sarà in locale di circa 88MB e 36MB compressa sull'hub di Docker. E' possibile utilizzare qualche trucco per spingere la dimensione a 27.1MB e 14.69MB compresso (anche se rimarrebbe il problema del consumo di memoria dell'eseguibile a favore sempre di Go) - questo è un problema alla fine?
Piuttosto ecco qui il link al codice sorgente di quanto esposto (Controller in Net Core e file per la creazione delle risorse in Kubernetes).
Per inserire un commento, devi avere un account.
Fai il login e torna a questa pagina, oppure registrati alla nostra community.
- C# e Net 6 in Kubernetes con Prometheus e Grafana, il 12 gennaio 2022 alle 21:58
- Snaturare Kubernetes evitando i custom container Docker, il 6 gennaio 2022 alle 19:40
- Provando Kaniko in Kubernetes come alternativa a Docker per la creazione di immagini, il 18 dicembre 2021 alle 20:11
- Divertissement con l'OpenID e Access Token, il 6 dicembre 2021 alle 20:05
- RBAC in Kubernetes verso gli operator, il 21 novembre 2021 alle 20:52