Storage persistente con Kubernetes e AWS

di Andrea Zani, in Docker,

Qui avevo scritto alcune mie esperienze con Kubernetes. In quel post avevo affrontato alcune problematiche per l'avvio di una architettura semplice composta da tre istanze di MongoDb in ReplicaSet e di una web application che permetteva il loro accesso. Il tutto era stato presentato in un ambiente locale grazie a Minikube. E' arrivato il momento di fare le cose sul serio cercando di pubblicare il tutto su uno dei vari cloud per rendere il tutto usufruibile pubblicamente su Internet.

La mia scelta cade su AWS - il semplice motivo è perché ci lavoro spesso con questo cloud. AWS mette a disposizione un ambiente completo per la costruzione di una infrastruttura basata su Kubernetes in pochi passaggi e con una interfaccia web completa: Amazon Elastic Kubernetes Service (EKS). La prima premessa da fare, se si vuole utilizzare per imparare a padroneggiare Kubernetes, è meglio cominciare con Docker Desktop, Minikube o costruendosi in casa una piccola rete con qualche macchina virtuale seguendo le guide più o meno ufficiali per la configurazione. Questo aiuterà a comprendere meglio il funzionamento di tutto il sistema, e solo quando si vorrà creare qualcosa di più complesso si potrà passare a EKS (o altri cloud) che facilitano enormemente la creazione di tutto, dai nodi master che reggeranno il tutto, ai worker node dove gireranno i nostri pod.

Dopo questa rapida premessa ecco l'obbiettivo di questo post - per complicare un po' le cose abbandonerà l'esempio precedente, che avevo utilizzato anche per scoprire le differenze tra Docker Swarm e Kubernetes - e creerò una web application in ASP.NET Core 3.1 che avrà come base dati un cluster composto da tre istanze di Mysql in replica. All'interno della web application anche la gestione dell'Identity ho creato delle mie classi custom che useranno proprio quel database per salvare le informazioni - ho preferito non usare librerie apposite come Pomelo.EntityFrameworkCore.MySql o la versione ufficialmente supportata perché ho riscontrato stranezze sulle versioni delle librerie incluse. L'identity gestirà per me l'autenticazione e permetterà l'accesso a determinate pagine con il controllo della role degli utenti. Infatti ho inserito una semplice pagina dove sono visualizzati degli articoli a 'mo di wikipedia che utilizza tre livelli di autenticazione:

  • Accesso anonimo: è solo possibile visualizzare la lista degli articoli presenti.
  • Accesso con role users: è possibile vedere anche il contenuto dell'articolo.
  • Accesso con role admin: è possibile modificare il contenuto dell'articolo e crearne di nuovi.

Nulla di complicato, il codice del progetto è possibile trovarlo qui. Dovendo utilizzare questa webapplication all'interno di un container Docker ho fatto delle piccole aggiunte per permettere il passaggio di parametri grazie alle variabili di ambiente; nel mio caso ho aggiunto:

  • MySqlServerName: ip o nome del server.
  • MySqlDatabaseName: nome del database da utilizzare.
  • MySqlUsername: username da utilizzare per l'autenticazione.
  • MySqlPassword: password.

Utilizzando tre istanze in Replica di Mysql, dove un'istanza - è solo una! - è utilizzata per la scrittura e le altre due solo in lettura, sono presenti le stesse voci qui sopra ma per l'accesso in sola modalità lettura: MySqlServerNameReader, MySqlDatabaseNameReader e così via. Inoltre queste informazioni sono inseribili anche come contenuto di file, nel caso tutte alle voci viste finora è sufficiente aggiungere "File" al nome stesso; per esempio, se l'username fosse inserito nel file "/etc/secret-information/username", per dare questa informazioni alla web application, potrò utilizzare la variabile d'ambiente MySqlUsernameFile il cui contenuto sarà il nome del file compreso di path.

E' arrivato ora il momento di creare i vari file per la creazione di tutte le risorse all'interno di Kurbenetes. Come nel post precedente inizio con la definizione dei secret, dove inserirò le credenziali che voglio utilizzare per MySql.

apiVersion: v1
kind: Secret
metadata:
  name: mysecret
type: Opaque
stringData:
  databaseName: "Test1"
  rootPassword: "1234567"
  username: "a1"
  password: "b1"
  mysqlReadinessFile.sh: |
    mysql -uroot -p1234567 --protocol tcp -h 127.0.0.1 -e "SELECT 1"

rootPassword è la password che userò come root del database MySql e l'utente a1 con relativa password che sarà utilizzata dalla web application per l'accesso al database. mysqlReadinessFile.sh merita una spiegazione: ho inserito qui questo banale script perché sarà utilizzato da Kurbenetes per il controllo dell'attività dell'istanza di MySql: dovendo questo codice utilizzare le credenziali ho preferito NON inserirlo come parametro in chiaro nella definizione dei pod del database e non ho potuto trovare un altro modo per poter passare queste credenziali alla funzione readiness di Kubernetes.

I file necessari per la creazione delle tre istanze di MySql in Kubernetes li ho presi dalla documentazione ufficiale a questo link. Io ho solo aggiunto le varie modifiche per l'utilizzo di credenziali e per un minimo di sicurezza del tutto. Incluso in questi file l'utilizzo del ConfigMap. Questo oggetto può contenere configurazioni delle applicazioni come i file: ha la stessa funzionalità dei secret ma senza l'assillo della sicurezza (e infatti qui non inserirò password o altro che potrebbero compromettere la sicurezza). Così come nell'esempio nella documentazione ufficiale:

apiVersion: v1
kind: ConfigMap
metadata:
    name: mysql
    labels:
    app: mysql
data:
  master.cnf: |
    # Apply this config only on the master.
    [mysqld]
    log-bin
  slave.cnf: |
    # Apply this config only on slaves.
    [mysqld]
    super-read-only
  Sql1-StructureUsers.sql: |
    CREATE TABLE IF NOT EXISTS `AspNetUsers` (
    `Id` varchar(255) NOT NULL,
    `UserName` varchar(256) DEFAULT NULL,
    `NormalizedUserName` varchar(256) DEFAULT NULL,
    `Email` varchar(256) DEFAULT NULL,
    `NormalizedEmail` varchar(256) DEFAULT NULL,
    `PasswordHash` longtext,
    PRIMARY KEY (`Id`),
    UNIQUE KEY `UserNameIndex` (`NormalizedUserName`),
    KEY `EmailIndex` (`NormalizedEmail`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    ...

Vengono definiti vari file. Il primo - master.cnf - è per la singola istanza Master di MySql, il secondo per le istanze in sola lettura. Di mio ho aggiunto quattro file contenenti codice SQL per la creazione delle tabelle e dati da inserire (user e altro) che saranno eseguiti nel pod dove è presente il container di MySql. Ulteriori info rimando alla pagina del progetto iniziale inserito prima, anche perché preferisco soffermarmi su altro. Infatti, nel post precedente dedicato a Kubernetes, avevo solo sfiorato l'argomento della gestione dello storage dedicandogli una riga o poco più, quando in precedenza avevo usato una miriade di parole per la gestione dei volume a cui attingevo per i secret e lo scambio di file tra i vari container del pod. Così come in Docker, i volume servono a collegare uno storage esterno al container in cui il software può salvare dati che non devono essere persi - il classico esempio è la directory dove un qualsiasi database salva i dati, se quel container fosse riavviato per un qualsiasi motivo, questi dati sarebbero persi. E' nella natura effimera dei container di Docker e, di conseguenza, dei container all'interno dei pod di Kubernetes, non potere essere utilizzati per la memorizzazione dei dati che non si vuole perdere. In Docker la soluzione più semplice è dichiarare una directory sull'Host dove gira Docker perché questa venga utilizzata dal software all'interno del container per salvare i dati: in questo modo possiamo riavviare il tutto quante volte vogliamo senza rischi di perdita di file. Con Kubernetes questa cosa è ancora presente ma non consigliata: il salvataggio dei dati in una directory dell'host preclude lo spostamento di un pod (e del container al suo interno) su altre macchine in caso di necessità (riavvio dell'host per esempio). Minikube offre questo servizio ma girando su un'unica macchina, per sua natura, non crea alcun problema per eventuali test. Nei vari servizi di cloud dove si possono creare decine di macchine non è una saggia idea utilizzare lo storage diretto sull'host, ma per fortuna, nei cloud dove è automatizzata la creazione dell'infrastruttura di Kubernetes - Aws, Azure, Linode, ecc... - sono disponibili feature apposite per la creazione automatizzata di dischi da collegare ai container - come cercherò di mostrare tra poco per AWS. In un ambiente di produzione dove si ospita in casa tutta l'infrastruttura di macchine per Kubernetes, si possono usare altri strumenti come Glusterfs o simili.

Per la generazione del mio progetto - web application e istanze di MySql - ho creato sei file:

  • mysql-secret.yaml: dove ho inserito le credenziali, come ho scritto sopra.
  • mysql-configmap.yaml: come sopra, contiene la configurazione per i database.
  • mysql-services.yaml: crea i service su cui verranno esposte le istanze di MySql in modo che possano essere chiamate dalla web application.
  • mysql-statefulset.yaml: contiene la configurazione per la creazione dello StatefulSet per le tre istanze di MySql; all'interno sono presenti anche gli InitContainer per la configurazione e per il backup/restore automatico dei database.
  • webapp.yaml: contiene il deployment della web application e il suo service.
  • ingress.yaml: permette la pubblicazione del servizio della web application su internet.

Dei primi due ne ho già parlato. Per la creazione dei service:

# Headless service for stable DNS entries of StatefulSet members.
apiVersion: v1
kind: Service
metadata:
    name: mysql
    labels:
      app: mysql
spec:
    ports:
    - name: mysql
      port: 3306
    clusterIP: None # <- Headless
    selector:
      app: mysql
---
# Client service for connecting to any MySQL instance for reads.
# For writes, you must instead connect to the master: mysql-0.mysql.
apiVersion: v1
kind: Service
metadata:
    name: mysql-read
    labels:
      app: mysql
spec:
    ports:
    - name: mysql
      port: 3306
    selector:
      app: mysql

Il primo servizio viene definito come Headless grazie all'attributo ClusterIp impostato su None, perché di questo lo si può trovare spiegato nel mio post precedente e nella documentazione ufficiale. Il secondo servizio, invece, usato in una connessione, permette l'utilizzo casuale di uno dei server sottostanti, comodo per suddividere le richieste tra tutte le istanze di MySql attive.

Passando ora alla creazione di MySql in replica su tre istanze, nel file yaml in questione nella parte finale si trova:

volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      #storageClassName: fast
      accessModes: 
        - ReadWriteOnce
      resources:
        requests:
          storage: 10Gi

Questo informa Kubernetess che vogliamo usare come StorageClass il servizio di AWS di default (notare anche i parametri successivi dove si può scegliere il tipo di storage e il tipo di file system e che il parametro storageClassName può essere omesso). Se avessi voluto usare Linode al posto di AWS non dovevo cambiare nulla, perché anche Linode offre un suo StorageClass di default. Fin qui tutto è bello e semplice, ma cos'è uno storage class?

Si deve fare un passo indietro. Kubernetes permette la gestione dei volume e mette a disposizione numerosi tipi, molti creati esternamente. Nel mio post precedente avevo usato emptyDir che è per sua natura non dev'essere usato per la persistenza dei dati. Ma ecco una semplice lista dei volume gestibili presenti di default:

  • emptyDir: è uno storage interno che vive e muove insieme al suo pod.
  • secret: anche i secret sono un tipo di storage in grado di mappare i secret come file in una directory accessibile dai container nei pod.
  • hostPath: condivide una directory dell'host con i container; questo permette il salvataggio sicuro dei dati nell'host dove viene istanziato il pod, ma preclude la possibilità di spostarlo al di fuori di quell'host.
  • gitRepo: monta uno storage con il contenuto di un repository di git.
  • NFS: permette l'accesso dai pod al file system condiviso NFS.

Oltre a questi sono disponibili quelli delle società di cloud, come l'awsElasticBlockStore (per AWS), l'azureDisk per Azure, gcePersistentDisk per Google e così via. In pratica possiamo creare un volume nel nostro cloud e utilizzarlo all'interno di Kubernetes dando la possibilità altresì di salvaguardare i dati salvati. Ecco un esempio veloce. In AWS, nella sezione EBS (Elastic Block System) creo un disco da 1GB:

Mi segno il Volume ID: vol-03e506cbf3d033647. Ora creo un pod e monto una directory in modo che questo disco sia accessibile:

    apiVersion: v1
    kind: Pod
    metadata:
      name: test1
    spec:
      volumes:
      - name: test-data
        awsElasticBlockStore:
          volumeID: vol-03e506cbf3d033647
          fsType: ext4
      containers:
      - image: nginx
        name: testnginx
        volumeMounts:
        - name: test-data
          mountPath: /mnt/data

Nella sezione volumes ho inserito il nome e il tipo di storage: awsElasticBlockStore con il tipo di file system e il volume id creato in AWS. Nella sezione volumeMounts monto il disco EBS alla directory /mnt/data. Ora in EKS di AWS (si vedrà poi come avviare il tutto in modo semplice), istanzio questo pod:

# kubectl apply -f other.yaml
pod/test1 created
# kubectl get po
NAME READY STATUS RESTARTS AGE
test1 1/1 Running 0 34s

Il container avvia NGINX, ma per la verità non mi interessa nulla di lui, ora voglio accedere per poter controllare la directory che mi interessa:

# kubectl exec -it test1 -- /bin/bash
root@test1:/# cd /mnt/data/
root@test1:/mnt/data# echo "Hello-world!" > msg.txt
root@test1:/mnt/data# cat msg.txt
Hello-world!

Ho creato il file msg.txt nel disco esterno, ora controllo che mantenga i dati anche in caso di riavvio del pod:

# kubectl delete -f other.yaml
pod "test1" deleted
# kubectl apply -f other.yaml
pod/test1 created
# kubectl exec -it test1 -- /bin/bash
root@test1:/# cd /mnt/data/
root@test1:/mnt/data# cat msg.txt
Hello-world!

Questo conferma che i dati sono al sicuro dal riavvio o eventi inattesi. Questa operazione si è svolta con due azioni: creazione preventiva di un disco, quindi, la sua assegnazione al pod. Sono normalmente azioni eseguite da due figure separate all'interno di un'azienda. Quindi si avrebbe l'ipotetica situazione di una persona che crea la struttura del pod e le sue risorse e richiede all'amministratore dei server (AWS o meno) per avere il volume id di un disco, questo per ogni nuovo pod creato. Per risolvere questo prima problema si è pensato di creare una strato supplementare tra il pod e lo storage vero e proprio, grazie al PersistentVolumeClaim. Questo permette, alla persona che crea la struttura del pod, di inserire la richiesta dello storage all'interno della sua definizione. Ecco un esempio come quello precedente a fatto all'interno di Minikube:

    kind: PersistentVolumeClaim
    apiVersion: v1
    metadata:
      name: my-pv-claim
    spec:
      storageClassName: ""
      accessModes:
        - ReadWriteOnce
      resources:
        requests:
          storage: 1Mi
    ---
    kind: Pod
    apiVersion: v1
    metadata:
      name: task-pv-pod
    spec:
      volumes:
        - name: my-pv-storage
          persistentVolumeClaim:
           claimName: my-pv-claim
      containers:
        - name: task-pv-container
          image: nginx
          volumeMounts:
            - mountPath: /mnt/data
              name: my-pv-storage

Nella prima parte ho definito il nuovo tipo di oggetto PersistentVolumeClaim. In esso specifico le mie esigenze per lo storage - in questo caso voglio un disco che possa essere letto e scritto solo da un pod (ReaderWriteOnce) con la dimensione di un megabyte. Nella definizione del pod, invece, nella sezione volumes, specifico che il disco da agganciare venga preso dal PersistentVolumeClaim in cui specifico il nome (il mount successivo è già stato spiegato più volte). Ma cosa succede ora se inserisco in Minikube questo template?

# kubectl apply -f nginx.yaml 
persistentvolumeclaim/my-pv-claim created
pod/task-pv-pod created
# kubectl get po
NAME          READY   STATUS    RESTARTS   AGE
task-pv-pod   0/1     Pending   0          12s
# kubectl get pvc
NAME          STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   AGE
my-pv-claim   Pending                                                     2m53s

E il pod rimane Pending così come il PersistentVolumeClaim (get pvc), perché effettivamente hanno fatto la richiesta dello storage, ma nessuno gli ha assegnato quanto richiesto. Per accertarci di questo:

# kubectl describe po task-pv-pod
... 
Events:
Type     Reason            Age                 From               Message
----     ------            ----                ----               -------
Warning  FailedScheduling  57s (x3 over 117s)  default-scheduler  error while running "VolumeBinding" filter plugin for pod "task-pv-pod": pod has unbound immediate PersistentVolumeClaims

Per creare lo storage posso scrivere per Minikube:

    apiVersion: v1
    kind: PersistentVolume
    metadata:
      name: pv0001
    spec:
      accessModes:
        - ReadWriteOnce
      capacity:
        storage: 1Mi
      hostPath:
        path: /data/pv0001/

Che creerà uno storage con l'accessMode e la capacity richiesta dal claim visto prima:

# kubectl apply -f vol.yaml 
persistentvolume/pv0001 created
# kubectl get pv
NAME     CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                 STORAGECLASS   REASON   AGE
pv0001   1Mi        RWO            Retain           Bound    default/my-pv-claim                           56s

Il persistent volume è stato creato e nella colonna STATUS e CLAIM si può capire come è già stato assegnato, infatti se controllo gli oggetti creati precedentemente:

# kubectl get pvc
NAME          STATUS   VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   AGE
my-pv-claim   Bound    pv0001   1Mi        RWO                           6m29s
# kubectl get po
NAME          READY   STATUS    RESTARTS   AGE
task-pv-pod   1/1     Running   0          6m35s

Anche il pod si è avviato con l'assegnazione del disco. In linea teorica come si presenta la situazione ora? Il developer, o chi per lui, può creare tutti i template che vuole e i pod di suo interesse specificando i volume di suo interesse, dall'altro lato, chi amministra i server, può mettere a disposizione vari tipi di storage già pronti che Kubernetes assegnerà automaticamente ai pod richiedenti (ovviamente la richiesta e l'offerta deve essere compatibile). Anche se questo facilitava notevolmente il tutto si apriva un altro problema, cioè come prevedere il numero dei volume e la loro capacità in un ambiente dove i pod sono centinaia se non migliaia? La creazione e la gestione degli stessi avrebbe gravato sull'amministratore del cluster. Così si è aperta un'altra possibile soluzione: la creazione automatica dei dischi richiesti con il PersistentVolumeClaim.

Nella definizione vista prima è presente una voce che è stata lasciata vuota appositamente, storageClassName:

    kind: PersistentVolumeClaim
    apiVersion: v1
    metadata:
      name: my-pv-claim
    spec:
      storageClassName: ""
      accessModes:
        - ReadWriteOnce
      resources:
        requests:
          storage: 1Mi

Definendo in questo attributo la class name, daremo il compito a lui di creare il disco per le nostre esigenze. Per sapere se nel nostro cluster Kubernetes è presente, basta scrivere (con Minikube attivo):

# kubectl get sc
NAME                 PROVISIONER                RECLAIMPOLICY   VOLUMEBINDINGMODE   ALLOWVOLUMEEXPANSION   AGE
fast (default)       k8s.io/minikube-hostpath   Delete          Immediate           false                  5d15h
standard (default)   k8s.io/minikube-hostpath   Delete          Immediate           false                  7d23h

Con AWS:

# kubectl get sc
NAME            PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
gp2 (default)   kubernetes.io/aws-ebs   Delete          WaitForFirstConsumer   false                  5m57s

Con Docker Desktop:

# kubectl get sc
NAME                 PROVISIONER         RECLAIMPOLICY   VOLUMEBINDINGMODE   ALLOWVOLUMEEXPANSION   AGE
hostpath (default)   docker.io/hostpath  Delete          Immediate           false                  31d

Per ora prendo in considerazione solo Minikube, dove sono presenti ben due classi: "fast" e "standard", entrambe sono un valore di default quindi è obbligatorio specificarne il nome nell'attributo storageClassName. Nel caso di AWS o Docker Desktop, dove è presente solo una voce, infatti, avrei potuto eliminare la riga per la definizione dello storageClassName:

    kind: PersistentVolumeClaim
    apiVersion: v1
    metadata:
      name: my-pv-claim
    spec:
      accessModes:
        - ReadWriteOnce
      resources:
        requests:
          storage: 1Mi

E avrebbe funzionato perfettamente, mentre con Minikube ora devo scrivere:

    kind: PersistentVolumeClaim
    apiVersion: v1
    metadata:
      name: my-pv-claim
    spec:
      storageClassName: fast # o standard
      accessModes:
        - ReadWriteOnce
      resources:
        requests:
          storage: 1Mi

Cancellato quanto fatto finora ed eseguito nuovamente ora avrò:

# kubectl get pv
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                 STORAGECLASS   REASON   AGE
pvc-b122eb76-7f7d-470d-ad27-aefe1cfce48e   1Mi        RWO            Delete           Bound    default/my-pv-claim   fast                    16s
# kubectl get pvc
NAME          STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
my-pv-claim   Bound    pvc-b122eb76-7f7d-470d-ad27-aefe1cfce48e   1Mi        RWO            fast           18s
# kubectl get po
NAME          READY   STATUS    RESTARTS   AGE
task-pv-pod   1/1     Running   0          21s

Ora senza dover creare appositamente lo storage, Minikube l'ha creato con le specifiche volute e assegnato al pod, problema risolto. Ed ora di ritornare al progetto iniziale dove tre istanze di MySql aspettano il proprio storage. L'implementazione della storage class è abbastanza semplice ora, prima uno sguardo alla sezione volumeMounts:

    volumeMounts:
    - name: data
      mountPath: /var/lib/mysql
      subPath: mysql
    - name: conf
      mountPath: /etc/mysql/conf.d
    - name: secretvolume
      readOnly: true
      mountPath: "/etc/secret-volume"

A parte la definizione dei path per la configurazione e per i secret (dove ho inserito le credenziali come ho spiegato nel post precedente), ho definito anche il mounthPath per i data, che è la directory dove MySql salverà i dati. In StatefulSet è presente la sezione volumeClaimTemplates:

    volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        #storageClassName: fast
        accessModes: 
          - ReadWriteOnce
        resources:
          requests:
            storage: 10Gi

In questo caso, essendo l'esempio pubblicato su AWS, ho specificato che ho bisogno di uno storage di 10GB, ho commentato la riga della definizione dello storageClassName perché con AWS non serve - avrei potuto inserire la riga: storageClassName: gp2. Anzi, non inserire lo storageClassName, mi permette di trasferire il tutto in altri cloud senza modificare nulla nel codice. In ogni caso, per esigenze particolari, è possibile creare anche storage più performanti per usi particolari, e questo ci obbliga a mettere mano al codice del template.

E' arrivato il momento di provare il tutto. Innanzitutto si deve creare il cluster EKS in AWS. Ci sono vari metodi, il più semplice è direttamente dall'interfaccia web anche se poi si dovranno creare role a altro, e importare la configurazione per poter poi accedere da console con il comando kubectl. Dopo vari suggerimenti ho imparato che il metodo più semplice è grazie al comando eksctl. Da terminale bash posso creare il tutto con l'unico comando:

eksctl create cluster --name test-cluster --version 1.18 --region eu-central-1 --nodegroup-name my-nodes --node-type t2.medium --nodes 3

In cui definisco il nome che avrà il cluster in AWS, la versione di Kubernetes che voglio utilizzare, in quale region (in questo caso per abitudine uso il Datacenter a Francoforte ma è disponibile anche a Milano), e infine quanti worker node voglio creare, quale tipo di macchine devono essere utilizzate e il nome del gruppo alla quale saranno assegnate. Tutto qua. Lanciato il comando dopo un tempo variabile tra i dieci e i quindici minuti, viene creato tutto:

Il comando eksctl dietro le quinte crea due template per Cloudfront che poi vengono eseguiti per la creazione di tutta l'infrastruttura. Questo permette, alla fine dei test, di poter cancellare il tutto con un solo comando. Inoltre questo comando crea la configurazione corretta in locale per il comando kubectl e ci permette si lavorare fin da subito con AWS:

...
[?]  kubectl command should work with "/home/az/.kube/config", try 'kubectl get nodes'
...

E' ora di lanciare in AWS quanto creato finora. Inizio con la ConfigMap, i secret e infine i service:

# kubectl apply -f mysql-configmap.yaml
# kubectl apply -f mysql-secret.yaml
# kubectl apply -f mysql-services.yaml

Ora creo le tre istanze di MySql:

# kubectl apply -f mysql-statefulset.yaml

Se tutto è andato bene ora si dovrebbero poter vedere i dischi creati automaticamente da AWS:

volumues ebs in aws

E controllo che ci siano tutti i pod per MySql in replica:

# kubectl get po
NAME      READY   STATUS    RESTARTS   AGE
mysql-0   2/2     Running   0          3m44s
mysql-1   2/2     Running   0          2m45s
mysql-2   2/2     Running   0          97s

E' il momento della web application. Creo un pod e un service per lui:

  apiVersion: v1
  kind: Service
  metadata:
    name: webapp
  spec:
    selector:
      app: webapp
    ports:
    - protocol: TCP
      port: 80
      targetPort: 5000
    type: LoadBalancer
  ---
  apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: webapp
    labels:
      app: webapp
  spec:
    replicas: 3
    selector:
      matchLabels:
        app: webapp
    template:
      metadata:
        labels:
          app: webapp
      spec:
        containers:
        - name: webapp
          image: sbraer/mysqlidentity:v1
          imagePullPolicy: IfNotPresent
          env:
          - name: MySqlServerName
            value: mysql-0.mysql
          - name: MySqlDatabaseName
            value: Test1
          - name: MySqlUsername
            value: a1
          - name: MySqlPassword
            value: b1
          - name: MySqlServerNameReader
            value: mysql-read
          - name: MySqlDatabaseNameReader
            value: Test1
          - name: MySqlUsernameReader
            value: a1
          - name: MySqlPasswordReader
            value: b1

Ora controllo che tutto sia stato creato correttamente:

#kubectl get po
NAME                      READY   STATUS    RESTARTS   AGE
mysql-0                   2/2     Running   0          7m10s
mysql-1                   2/2     Running   0          6m11s
mysql-2                   2/2     Running   0          5m3s
webapp-745bdf47b9-2jhpp   1/1     Running   0          67s
webapp-745bdf47b9-2r5h7   1/1     Running   0          83s
webapp-745bdf47b9-vt8fr   1/1     Running   0          75s

Ho creato un LoadBalancer tra i service:

#kubectl get svc
NAME         TYPE           CLUSTER-IP     EXTERNAL-IP                                                                 PORT(S)          AGE
kubernetes   ClusterIP      10.100.0.1     <none>                                                                      443/TCP          19m
mysql        ClusterIP      None           <none>                                                                      3306/TCP         8m24s
mysql-read   ClusterIP      10.100.97.86   <none>                                                                      3306/TCP         8m24s
webapp       LoadBalancer   10.100.84.34   aa2e3011816b74f4d83c178407ab3373-667334319.eu-central-1.elb.amazonaws.com   80:32305/TCP   2m19s

Il LoadBancler, nella colonna EXTERNAL-IP contiene un url. Richiamandolo nel browser:

AWS ha creato, grazie alla dichiarazione del service come LoadBalancer, un suo Load Balancer ed ha reso immediatamente pubblica la web application:

La cosa è positiva, perché nella configurazione di Kubernetes mi è sufficiente inserire un LoadBalancer come service perché venga creato un Application Load Balancer in AWS e i miei pod siano esposti su Internet. Ma c'è il rovescio della medaglia: oltre che è uno spreco creare un Applicatoin Load Balancer per ogni servizio, ogni singola istanza ha un costo. Per fortuna c'è una soluzione alternativa conosciuta e utilizzata, che risolve questo problema collegando un singolo Load Balancer di AWS a uno o più service in Kubernetes. Inizio cancellando la webapp e il suo servizio:

kubectl delete -f webapp.yaml

Che cancellerà anche il LoadBalancer di AWS. Quindi modifico il service della web app:

apiVersion: v1
  kind: Service
  metadata:
    name: webapp
  spec:
    selector:
      app: webapp
    ports:
    - protocol: TCP
      port: 5000
      targetPort: 5000

Ora è diventato un servizio interno:

#kubectl get svc
NAME         TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
kubernetes   ClusterIP   10.100.0.1      <none>        443/TCP    28m
mysql        ClusterIP   None            <none>        3306/TCP   17m
mysql-read   ClusterIP   10.100.97.86    <none>        3306/TCP   17m
webapp       ClusterIP   10.100.61.159   <none>        5000/TCP   3s

Per pubblicare la web application su Internet la soluzione è utilizzare NGINX Ingress Controller (sono presenti altri progetti analoghi ma specifi per AWS, Azure e altri). Disponibile per i maggiori cloud (altrimenti si può usare HELM), permette di creare un'unica porta tra il L'Application Load Balancer del cloud (nel mio caso AWS) e il cluster in Kubernetes. La sua installazione è semplice:

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v0.41.2/deploy/static/provider/aws/deploy.yaml

Guardando in AWS ora troverò un Application Load Balancer. E ora con un ingress service collegherò qualsiasi servizio a questa istanza di nginx :

apiVersion: extensions/v1beta1
  kind: Ingress
  metadata:
    name: api-ingresse-test
    annotations:
      kubernetes.io/ingress.class: "nginx"
  spec:
    rules:
    - host: a5776edeb49d94da09accdec22690dd8-76a165cfec2847c5.elb.eu-central-1.amazonaws.com #dns name preso da AWS-> Load balancer
      http:
        paths:
        - backend:
            serviceName: webapp
            servicePort: 5000
          path: /

In host ho messo il DNS del LoadBalacer, e nella sezione http il service name (webapp) e la porta a cui risponde (5000).

#kubectl get ingress
NAME                CLASS    HOSTS                                                                              ADDRESS   PORTS   AGE
api-ingresse-test   <none>   a5776edeb49d94da09accdec22690dd8-76a165cfec2847c5.elb.eu-central-1.amazonaws.com             80      6s

E controllo che la pagina web risponda:

Quando si definisce un Ingress è importante inserire l'host il nome a cui risponde l'nginx ingress controller; nel mio esempio ho inserito il DNS Name del load balancer, ma nel mondo reale sarà necessario far puntare il nome del dominio all'IP del load balancer. Ipotizzando di avere il dominio www.site.com e avendolo configurato tutto correttamente, avrei dovuto inserire il nome del dominio nell'attributo host visto qui sopra. Ma se volessi utilizzare tale dominio perché rispondano più web application?

apiVersion: extensions/v1beta1
  kind: Ingress
  metadata:
    name: api-ingresse-test
    annotations:
      kubernetes.io/ingress.class: "nginx"
  spec:
    rules:
    - host: www.site.com
      http:
        paths:
        - backend:
            serviceName: webapp
            servicePort: 5000
          path: /
        - backend:
            serviceName: webapp2
            servicePort: 5001
          path: /other

Con l'url www.site.com sarà avviata ancora la mia webapp, con l'url www.site.com/other sarà richiamata la webapp2. Se webapp2 fosse una web application in asp net core si avrà una sorpresa una volta eseguita questa configurazione. Richiamando l'url www.site.com/other sarà visualizzata una pagina bianca. Questo problema è noto e la soluzione la si può trovare qui (nella mia web application ho inserito il supporto del cambio del PathBase grazie alla variabile d'ambiente API_PATH_BASE).

Inoltre, avendo configurato i nomi di dominio www.example.com e qa.example.com in modo che puntino all'ip nel mio load balancer, posso scrivere:

apiVersion: extensions/v1beta1
  kind: Ingress
  metadata:
    name: api-ingresse-test
    annotations:
      kubernetes.io/ingress.class: "nginx"
  spec:
    rules:
    - host: www.site.com
      http:
        paths:
        - pathType: Prefix
          path: "/"
          backend:
            service:
              name: webapp
              port:
                number: 5000
    - host: qa.site.com
      http:
        paths:
        - pathType: Prefix
          path: "/"
          backend:
            service:
              name: qawebapp
              port:
                number: 5000

In modo da avere la versione di produzione al dominio www.site.com, mentre la versione in test sarà visibile in qa.site.com.

Sono quasi alla fine. In alcuni post dedicati a Docker Swarm avevo dato enfasi alla possibilità di assegnare alcuni container docker a determinate macchine. L'esempio che facevo era dedicato alle diverse tipologie di macchine che potevano fare parte del cluster per potenza di cpu, memoria o capacità e velocità dei dischi. Anche nel caso di cloud (AWS o altri) è possibile decidere la tipologia di macchine che potrebbero essere utilizzate per determinati processi e non per scopi poco importanti. Nel mio esempio ho creato tre istanze di MySql, e se pubblicato come visto finora potrebbe capitare che due istanze vengano assegnate alla stessa macchina, l'altra istanza assegnata ad una seconda macchina mentre la terza lasciata vuota. L'ideale, anche per la sicurezza per malfunzionamenti, riavvii o altro, sarebbe quella di avere un'istanza di MySql per ogni macchina.

Con Docker Swarm si poteva assegnare una label ad una macchina, e nalla fase di avvio di un container in Docker si poteva decidere di assegnarlo proprio a lei. Anche con Kubernetes è possibile l'assegnazione di pod a determinate macchine e altre interessanti funzioni che combinate permettono l'assegnazione di pod in modo puntuale. A livello di singole macchine possiamo avere il node affinity che avvisa Kubernetes che determinati pod possono essere lì avviati; al contrario, i taint, vietano a certi pod di essere eseguiti su quella macchina. Personalmente trovo i taint molto interessanti e utili, perché permettono di assegnare dei pod a determinate macchine (che tollerano questi taint) e proibiscono tutti gli altri. Questo permette di creare ristretti cluster di macchine assegnate a determinati compiti. Nell'esempio del replica di tre macchine per MySql posso creare tre macchine quindi aggiungo il taint in questo modo:

kubectl taint node nome_macchina_1 node-type=database:NoSchedule
kubectl taint node nome_macchina_2 node-type=database:NoSchedule
kubectl taint node nome_macchina_3 node-type=database:NoSchedule

Nelle tre macchine ho inserito il custom taint con la key node-type e il valore database, NoSchedule specifica che queste macchine NON possono accettare la schedulazione di nuovi pod. Se per la key e il suo valore posso inserire quello che voglio, il terzo valore, definito nella documentazione come effect, accetta tre valori:

  • NoSchedule, dal momento dell'inserimento di questo custom taint nessun altro pod, se non specificato come si vedrà dopo, non potrà essere su questa macchina (quelli già presenti non saranno toccati).
  • PreferNoSchedule, è la versione soft di NoSchedule: come sopra ma se non ci sono altre macchine che possono accettare i nuovi pod, viene permessa la loro esecuzione.
  • NoExecute: è la versione più cattiva: nessun pod può girare su questo server, anche quelli che sono già presenti.

Se volessi controllare i taint presenti su una macchina, è sufficiente controllare l'output di questo comando:

#kubectl describe node nomemacchina
...
CreationTimestamp:  Tue, 27 Oct 2020 13:37:20 +0100
Taints:             node-type=database:NoSchedule
Unschedulable:      false
...

Ora che le tre macchine qui sopra sono tutte dedicate alle tre istanze di MySql, si deve specificare nella definizione dei pod la toleration che permetterà ai pod di poter essere avviati:

...
  serviceName: mysql
  replicas: 3
  template:
    metadata:
      labels:
        app: mysql
    spec:
      tolerations:
      - key: node-type
        operator: Equal
        value: database
        effect: NoSchedule
...

In tolerations specifico che i pod potranno girare sulle macchine dove ho specificato il custom taint con quella key, value e effect - ma questo non preclude che quel pod non possa essere avviato su altre macchine e non preclude il problema principale dell'avvio di due istanze di MySql sulla stessa macchina. Per evitare questo è presente l'affinity per i pod: PodAffinity con il quale si può specificare che un pod debba girare sulla stessa macchina dove è presente un altro specifico pod, ed è presente il PodAntiAffinity che funziona al contrario del precedente: si può evitare che un pod possa girare sulla stessa macchina dov'è presente un determinato pod. E' quello che fa per il mio caso. Ecco la configurazione che mi permette di avviare le tre istanze di MySql sulle tre macchine scelte e ognuna su una singola macchina:

...
  serviceName: mysql
  replicas: 3
  template:
    metadata:
      labels:
        app: mysql
    spec:
      tolerations:
      - key: node-type
        operator: Equal
        value: production
        effect: NoSchedule
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - topologyKey: kubernetes.io/hostname
            labelSelector:
              matchLabels:
                app: mysql
...

In affinity ho specificato il podAntiAffinity, e con requiredDuringSchedulingIgnoredDuringExecution (che si scrive sbagliato quattro volte su cinque) specifico che il pod può essere avviato (required...) se quella macchina (topologyKey: kubernetes.io/hostname) non ha un pod con la label app:mysql. In topologyKey posso specificare altre variabili presente sulla macchina, come la region, in modo che quel pod non debba essere avviato nella stessa region, oppure si possono specificare determinate macchine per armadi, stanze o piani del datacenter. Alternativo a requiredDuringSchedulingIgnoredDuringExecution, in cui specifico che quella regola dev'essere rispettata, c'è preferredDuringSchedulingIgnoredDuringExecution, che rende opzionale la regola.

Come scritto sopra preferisco nella mia limitata esperienza l'uso delle custom taints su tutte le macchine. In questo modo in un cluster formato da molte macchine nessun pod può avviarsi se non specificando la toleration. Altra alternativa è specificare la node affinity dove si specifica su quali macchine i pod devono essere installati, e in questo caso si possono specificare più regole dando ad ognuna un peso, in modo che Kubernetes scelga l'assegnazione valutando innanzitutto la possibilità dell'avvio di quei pod, e quindi il peso di tale regola. Le possibilità che offre Kubernetes per questo scopo sono innumerevoli, e meriterebbero un lungo post dedicato, ma non ne ho voglia e poi la documentazione a riguardo e chiara (se l'ho capita pure io...), si può partire da qui.

E ora di chiudere tutto. Quello che ho attivato con eksctl può essere distrutto con esso:

eksctl delete cluster --name test-cluster

Dopo qualche minuto tutte le risorse create in AWS saranno distrutte. Tutte tranne i dischi creati automaticamente grazie alla storage class, in questo modo vengono salvaguardati eventuali dati inseriti - se non più necessari sarà necessario cancellarli manualmente. Per controllare:

#kubectl get pvc
#kubectl get pv

E si può decidere di cancellarle (questo è comodo anche per ripristinare una situazione precedente che potrebbe bloccare la cancellazione e la creazione di pod che utilizzano quegli storage):

#kubectl delete pvc --all
#kubectl delete pv --all

Ci sono molte altre feature interessanti con l'utilizzo del cloud, come la possibilità dell'autoscaling orizzontale dei worker node: in caso di aumento di richieste e di numero dei pod che i worker node non possono più avviare, AWS può avviare altre macchine, così come le può eliminare in caso di consumo inferiore di risorse - ma questo obbligherebbe l'approfondimento dell'uso delle risorse dei pod, con la configurazione delle richieste minime e massime di memoria e cpu, e così via. Inoltre, interessante anche il supporto all'autoscaling nativo di Kubernetes, ma è roba che allungherebbe questo post ulteriormente e non era lo scopo iniziale. Forse in futuro? No. Inoltre AWS permette l'uso di Kubernetes in modalità Fargate: senza nodi worker ma istanziando i pod di volta in volta a seconda delle esigenze.

Prima di chiudere qualche parola su HELM che è un package manager di Kubernetes: qui si possono trovare già pronti e configurati parecchi software anche complessi da configurare. Per esempio, l'esempio qui mostrato con l'avvio in replica di tre istanze di MySql è presente e attivabile con una riga da console con il comando HELM. Perché io non l'ho fatto? Perché io ero curioso di verificare e personalizzare alcuni punti di mio interesse. Quanto avevo fatto il post precedente per MongoDb in replicaset? Anche quello script è già presente e pronto all'uso.

Infine due parole sull'utilizzo del cloud per testare Kubernetes. Come scritto all'inizio è meglio utilizzarlo solo per progetti reali o di una certa complessità con oggetti non disponibili in locale, anche perché il suo utilizzo non è economico visto che qualsiasi risorsa in AWS dev'essere pagata; personalmente per altri miei progetti personali da qualche tempo affianco Minikube/Aws ad un altro cloud: Linode. Innanzitutto l'avvio di un cluster Kubernetes non è a pagamento orario come AWS (si pagano solo l'uso dei worker node), e soprattutto il suo avvio avviene in pochissimi minuti contro i dieci/quindici minuti di AWS, e lo spegnimento è quasi immediato mentre con AWS ci vogliono circa cinque minuti. Mancano feature come l'autoscaling o la modalità serverless di AWS Fargate, ma quelle sono esigenze particolari. Consiglio di dargli un'occhiata. Fine.

PS nel link del progetto della web application è presente una directory di nome Kubernetes dove sono presenti i file yaml usati.

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