Operator per Kubernetes in C# e Net Core 6.

di Andrea Zani, in .NET,

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).

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Nella stessa categoria
I più letti del mese