Kubernetes è un sistema per il deploy di app containerizzate. Come gestisco la scala di un singolo servizio dell'applicazione a multi-containers? Se dovessi avere un traffico sempre crescente per esempio sul worker?
Sarebbe una cosa buona avere la possibilità di "moltiplicare" le istanze dei servizi che eseguono operazioni di calcolo, cosa complicata da fare con Elastic Beanstalk in quanto il Load Balancer di AWS scala utilizzando più VM con la stessa configurazione dei container e con poco controllo nella gestione dei container all'interno delle VM. L'ideale sarebbe avere delle VM con multiple isteanze dei servizi che necessitano di essere scalati.
Kubernetes risolve questo problema.
Kubernetes è composto dai Cluster. Il Cluser è composto da un Master e diversi Node (delle VM) i quali contengono molteplici container. Il Load Balancer gestisce lo smistamento delle richieste sulle VM. Il Master controlla ogni nodo attraverso una serie di programmi ed è il mezzo con cui lo sviluppatore configura il numero di container di determinate immagini da eseguire in ogni nodo.
Kubernetes è quindi per definizione un sistema che esgue molti container differenti su molteplici macchine differenti. Orchestramento e scala di applicazioni multi-container su differenti macchine.
E' utile, per definizione, quando si ha necessità di eseguire diversi container con diverse immagini. Questo implica che in caso di applicazione con un solo container questo strumento non sia necessario.
C'è una forte distinzione tra l'utilizzo in locale per il development e la produzione. In development server utilizzare "minikube" il quale genera un Cluster Kubernetes in locale. In produzione si utilizzano delle "Managed solutions" ossia strumenti come Amazon Elastic Container Service for Kubernetes o Google Cloud Kubernetes Enegine, ma possono essere utilizzate strutture personalizzate e configurate in modo da ospitare i cluster di Kubernetes.
Localmente si utilizza "minikube" il quale crea un cluster nella macchina locale, quindi una virtual machine (un nodo) con diversi container. Per interagire con il nodo occorre un programma chiamato "kubectl
". Esso è incaricato alla gestione dei containers all'interno del "node".
In produzione "minikube" non verrà utilizzato, contrariamente a "kubectl" che verrà comunque utilizzato (per esempio su Google Cloud).
Anche qui, come Docker, viene creata una VM ma, a differenza dei tool di Docker, la cosa è meno automatizzata.
Quindi i passaggi sono:
Kubernetes si aspetta di avere le immagini già pronte e buildate. Non c'è una pipeline di build. Occorre quindi che le immagini siano già buildate e ospitate su Docker Hub. Le indicazioni consistono nella creazione di un file di configurazione per ogni "oggetto" (quale può essere un container).
Il networking va impostato manualmente. Va quindi creato un file per la configurazione del networking (le porte).
I file di configurazione verranno letti da "kubectl" per creare due "oggetti" ossia delle cose che esistono nel cluster di Kubernetes. Gli oggetti possono essere, per esempio:
Questa caratteristica è il "kind" del file di configurazione.
In base alla versione dell'API si può accedere a diversi set di oggetti, nella versione "v1" utilizzo oggetti come: compontentStatus, configMap, Endopoints, Event, Namespace e Pod; nella versione "apps/v1": ControllerRevision e StatefulSet.
Quando si esegue "minikube start" viene creata una VM, ossia un Node che viene usato da Kubernetes per eseguirvi una serie di oggetti di cui quello più basico è il "Pod".
Il Pod contiene uno o più container. Pensando all'istanza di Elastic Beanstalk bisogna fare delle distinzioni: l'obiettivo del Pod è quello di orchestrare dei container che devono essere necessariamente raggruppati per il corretto funzionamento dell'applicazione e sono strettamente dipendenti gli uni dagli altri.
Per esempio un Pod potrebbe servire per eseguire i seguenti container: un container di Postgres, un container incaricato alla scrittura dei logs che necessita di scrivere su db e il container di un backup-manager che, appunto, ha necessità di accedere direttamente al database per funzionare. Senza il db entrambi gli altri container diventano inutili al 100%.
Il "Pod" è quindi la cosa più "piccola" che possiamo deployare. Il file di configurazione del pod ha il seguente nome:
nome-pod.yml
La struttura è la seguente:
apiVersion: v1
kind: Pod
metadata:
name: <nome-servizio>-pod
labels:
component: <nome componente, es. "web">
spec:
containers:
- name: <nome-servizio>
image: <nome immagine su Docker Hub>
ports:
- containerPort: <porta del container, es: 3000. La porta del container che si vuole esporre all'esterno>
name
" sarà il nome del pod
-"labels: component
" è un nome di riferimento per i services.Per vedere i Pods in esecuzione:
kubectl get pods
Quindi verranno elencati con i rispettivi nomi, container in esecuzione su replicas, status, numero di restart ecc… Prendo il nome ed eseguo il seguente comando per vedere i logs:
kubectl logs <nome Pod>
Incaricato all'impostazione del networking nel Cluster di Kubernetes. Ci sono 4 sottotipi:
Il sottotipo viene indicato in "spec: type
".
Un Pod è in esecuzione in un Node (una VM creata da Minikube). Il Service NodePort mette in comunicazione la porta esposta del Pod con "kube-proxy", un programma presente in ogni Node che gestisce il routing all'interno del nodo e riceve le request dall'esterno. Difatti possono esserci diversi Service e diversi Pod.
Con "selector: component
" si indica il nome del component da gestire (viene utilizzato un sistema di "label-selectors" per referenziare i Pod nei Service) . Difatti il "component" non è un nome univoco, ma definisce piuttosto una "classe" di Pod che il servizio prende in considerazione. Il termine "component" può essere sostituito con qualsiasi altro nome, in quanto attributo delle "labels" (l'importante è che ci sia consistenza tra Pod e Service).
Con l'array di "port" si possono gestire diverse porte:
Creo quindi il file "
apiVersion: v1
kind: Service
metadata:
name: <nome-servizio>-node-port
spec:
type: NodePort
ports:
- port: 3050
targetPort: <porta container indicata nel pod>
nodePort: 31515
selector:
component: <nome del componente nel pod, es. “web”>
Il comando è il seguente:
kubectl apply -f <nome del file di configurazione>
Oppure, se voglio caricare molteplici file di configurazione:
kubectl apply -f <directory contenente i file di configurazione>
Usando il comando:
kubectl get <oggetto, es: "pods">
posso elencare gli oggetti in questione che sono in esecuzione. Ora chiaramente la porta non è indirizzata su localhost. Bisogna chiedere a "Minikube" l'indirizzo IP. Per farlo eseguire:
minikube ip
Il comando:
kubectl describe <kind oggetto> <nome oggetto, non obbligatorio>
mosterà la lista di oggetti esistenti nella VM e una lista di Events (report di crush, pull e update di immagini ecc..). All'interno del Pod sono listati i container.
I container in esecuzione nel pod sono visibili nella lista dei container di Docker. Se killo i container di Docker avviati con minikube essi vengono automaticamente riavviati. Ogni nodo è un computer diverso con una copia di Docker per questo in locale è come se avessi due copie di Docker (una è presente nella VM). I nodi sono gestiti dal Master e configurati attraverso un Deployment File.
Il Master è composto da alcuni programmi che gestiscono il Cluster tra cui "kube-apiserver".
Kube-apiserver è il responsabile del monitoraggio dello stato dei Node assicurandosi che facciano le cose correttamente. Il programma legge il Deployment File e stabilisce quante copie/nodi/ecc.. deve mandare in esecuzione e decide come distribuirle sui nodi. In questo modo avviene la scalatura orizzontale di un servizio.
Il motivo per cui in locale il container killato è ripartito è perchè il Master si è "accorto" dell'assenza di un container dal conteggio definito nel file di configurazione. Il Master lavora continumente per cercare di sopperire al mantenimento dello stato indicato dal file di configurazione, ma tutto il controllo avviene "dietro le quinte" senza indicazioni precise da parte dello sviluppatore.
Da una parte abbiamo l'Imperative Deployment, ossia l'indicazione precisa del setup dei container. Sfruttare il Master significa prediligere il Declarative Deployment il quale invece prevede di dare delle indicazioni generiche sull'impostazione dei container desiderata che verrà poi gestita autonomamente da processi automatizzati. Kubernetes permette di utilizzare entrambi gli approcci. Bisogna quindi prestare attenzione ai forum e i blog che si leggono perchè potrebbero dare indicazioni basate su approcci differenti.
Il comando:
kubectl delete -f <nome del file di configurazione utilizzato per creare l'oggetto>
Il match verrà fatto utilizzando il nome dell'oggetto. Questo è un comando che rientra nell'"approccio Imperative".
Approccio Declarative:
sostituisco il nome dell'immagine nel file di configurazione dell'oggetto mantenendo il nome e il kind dell'oggetto;
eseguo l'apply del file su kubctl il quale (vedendo stesso nome e kind) eseguirà l'aggiornamento del pod/service ecc.. senza crearne uno nuovo.
E' consentito modificare solo
spec.initContainers.*
,spec.activeDeadlineSeconds
,spec.tolerations
(solo se in aggiunta a tolerations già esistenti).La modifica di altri parametri, come la containerPort
, causa delle eccezioni sollevate da kubectl.
Essendoci dei campi non modificabili creo, per questo scopo, un altro oggetto chiamato Deployment.
Il Deployment mantiene un set di Pods identici assicurandosi che le loro configurazioni siano corrette e ci sia sempre il numero corretto in esecuzione. Monitora lo stato di ogni Pod eseguendo aggiornamenti se necessario. Viene utilizzato in development ed è il vero oggetto che si utilizza in produzione (raramente si utilizzano i Pods nudi e crudi in produzione). Il Deployment file contiene un "Pod Template", ossia una serie di configurazioni nelle quali sono indicati il numero di container, il nome, la porta e l'immagine. Queste configurazioni consentiranno al Deployment di strutturare o modificare o killare il Pod descritto.
Il file di configurazione si chiama "<nome>-deployment.yaml
":
apiVersion: apps/v1
kind: Deployment
metadata:
name: <nome>-deployment
spec:
replicas: 1
selector:
matchLabels:
component: web
template:
metadata:
labels:
component:
web
spec:
containers:
- name: <nome>
image: <nome immagine>
ports:
- containerPort: <porta>
Eseguo quindi:
kubectl apply -f <nome file deployment>
Se eseguo:
kubectl get deployments
vengono listati i Deployments con alcune informazioni sulle quantità (quantità desiderata, Pod aggiornati all'ultima configurazione, Pod pronti ed in esecuzione).
Quindi utilizzando il comando "minikube ip
" per ottenere l'indirizzo posso raggiungere l'applicazione alla porta indicata nel Service.
Usando il comando:
kubectl get pods -o wide
posso vedere una serie di informazioni aggiuntive tra cui l'indirizzo IP interno alla macchina virtuale su cui il Pod è in ascolto. Ora se il Pod viene aggiornato o ricreato ecc... è possibile che l'indirizzo IP venga cambiato. Questa è la chiave dell'importanza del Service il quale raccoglie gli accessi ai Pods e li rende disponibili su un unico indirizzo IP. Questo ci lascia liberi di eseguire tutte le modifiche necessarie sui Pod senza dover aggiornare continuamente indirizzo IP.
Si modifica il file di configurazione e si esegue il "kubectl apply" nuovamente. Essendo stato modificato il Pod Template, i Pod vengono eliminati e ricreati. Per scalare vado a modificare il numero di "replicas". Dietro le quinte il Master andrà a modificare uno a uno i Pod cancellando quelli non aggiornati e ricreandoli o aggiungendo quelli nuovi se il numero di replicas è aumentato.
Aggiornare un Deployment con l'ultima versione di un'immagine può essere challenging.
Vedi: github.com/kubernetes/kubernetes/issues/336644
Nel Deployment File non vi è alcuna informazione relativa alla versione dell'immagine da utilizzare. Difatti se eseguo l'"apply" senza modifiche, il Master non eseguirà alcun aggiornamento.
Le soluzioni possono essere:
kubectl set image <kind oggetto>/<nome dell'oggetto> <nome del container>=<l'intero tag della nuova immagine da utilizzare>
Per l'aggiornamento dei tag in produzione si andranno a creare degli script appositi (per Elastic Beanstalk o chi per lui) per automatizzare questo passaggio.
Si può configurare il Docker Client di una determinata sessione del terminale per comunicare con il Docker Server interno alla VM (il Node di Kubernetes) piuttosto che con il Docker Server installato nel sistema operativo. Il comando è:
eval $(minukbe docker-env)
Il Docker Client in esecuzione nella finestra del terminale ora punterà alla copia di Docker Server presente nella VM di minikube. Eseguendo solo:
minikube docker-env
si può notare come vengano impostate alcune variabili d'ambiente come il DOCKER_HOST
e il DOCKER_CERT_PATH
su i determinati percorsi di Minikube.
Questo ci permetterà di usare molte tecniche di debug di Docker in quanto molti comandi di debug di Docker CLI sono disponibili attraverso kubectl (per esempio il comando "docker ps
", o "docker exec -it <id container> sh
" o "docker log
"). Per esempio:
kubectl logs <nome del pod>
ci mostra i log dei container di un determinato pod, mentre:
kubectl exec -it <nome deployment> sh
ci permette di navigare ed eseguire comandi shell nei container. E' possibile inoltre cancellare gli elementi rimasti nella cache del Node:
docker system prune -a
Forma un po' più ristretta del NodePort. Espone un set di Pod a tutti gli elementi del Cluster. Questo permette, per esempio, di collegare un Deployment di Pods con i container del server dell'applicazione e di collegarsi al Deployment dei Pods di una database. Tuttavia non è possibile navigare dall'esterno al ClusterIP. Per questo occorre l'Ingress Service. Il traffico arriva sull'Ingress Service che dispiaccia ai rispettivi ClusterIP Service.
Si configura attraverso il file "<nome>-cluster-ip-service.yaml
":
apiVersion: v1
kind: Service
metadata:
name: <nome>-cluster-ip-service
spec:
type: ClusterIP
selector:
<selector del/dei Deployment>
ports:
- port: <porta esposta all'Ingress Service e agli altri oggetti del Cluster>
targetPort: <porta interna da raggiungere esposta dai container>
E' possibile creare un unico file di configurazione che descriva il ClusterIP e il Deployment associato. Per farlo è sufficiente creare un file del tipo "
---
Tuttavia non è buona pratica utilizzare entrambe le tipologie. Occorre usare una “naming convention” e quindi se si decide di usare il file combinato occorre che si utilizzi per tutte le coppie di Deployment/ClusterIP Service del Cluster. Ovviamente quest'ultima opzione rende meno leggibile la struttura dei file.
Il concetto di volume è simile a quello visto in Docker e Docker Compose, ossia uno slot di memoria della macchina fisica su cui il Node andrà a scrivere e recuperare i dati del database. Il Deployment crea il Pod che contiene il Container del DBMS. Il Container contiene uno spazio di memoria interno il quale, però, viene eliminato insieme al Pod in caso di cancellazione.
Il "volume" in Docker è uno spazio file system esterno sulla host machine a cui il container ha accesso, ma è comunque esterno e quindi non viene perso in caso di cancellazione e ricreazione del Pod.
Attenzione che avere due o più database diversi (replicas maggiore di 1) che puntano allo stesso "volume" è una perfetta ricetta per un disastro. Non è quindi buona pratica scalare i Pods di un DBMS attraverso l’incremento del parametro "replicas".
Volume (secondo la terminologia generica dei container): un tipo di meccanismo data-store che consente ad un container di accedere a filesystem esterno.
Volume (secondo Kubernetes): un OGGETTO che consente a un container di registrare dati nel Pod.
Non è la stessa cosa del Volume di Docker. Non serve per scrivere dati che devono perdurare. Quando creiamo un Volume andiamo a creare un oggetto contenuto nel Pod. Se il container all'interno del Pod muore o viene riavviato avrà comunque accesso al Volume e ai dati ivi registrati. Tuttavia se il Pod viene cancellato/ricreato il Volume sparisce con esso.
Lo storage non è strettamente contenuto/legato al Pod, ma separato, e quindi continua a perdurare nonostante la distruzione/riavvio del Pod. Ovviamente i container all'interno del Pod vi avranno sempre accesso.
La configurazione del Pod fa riferimento al PVC ossia a quello che metaforicamente potremmo definire un "annuncio pubblicitario" il quale esibisce le opzioni di storage che possono essere disponibili all'interno del Cluster. Scelta l'opzione, Kubernetes fornisce dei Persistent Volume "statically provisioned" ossia specificatamente costruiti. In alternativa, se l'opzione di storage richiesta non è presente staticamente, viene fornita come Persistent Volume "dynamically provisioned", ossia che viene ricavato dall'Hard Drive della host machine dinamicamente.
Il file di configurazione del PVC è il seguente:
"database-persistent-volume-claim.yaml
"
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: database-persistent-volume-claim
spec:
accessModes:
- <PVC mode>
storageClassName: "" # Empty string must be explicitly set otherwise default StorageClass will be set
volumeName: database-persistent-volume-claim
resources:
requests:
storage: <dimensione dello storage richiesta, es: 2Gi>
Usando
kubectl get storageclass
posso vedere le opzioni possibili di storage. Se vedo indicato come Provisoner "minikube-hostpath
" significa che k8s intende creare il Persistence Volume ritagliando uno spazio di memoria nello storage della macchina hosting.
In ambiente cloud il discorso si complica, lo spazio viene ricavato da un Cloud Provider (Google Cloud Persistent Disk, Azure FIle, Azure Disk, AWS Block Store...) indicando nella configurazione su quale di questi provider si intende andare a ricavare questo volume indicando il Provisoner con il parametro "storageClassName", il che non è necessario in locale essendoci solo l'opzione di default "minikube-hostpath".
Vedi la pagina della documentazione ufficiale di Kubernetes sulle StorageClasses per avere la lista completa dei Provider su cui è possibile andare a creare dei Persistence Volume.
L'opzione di default può essere Google Clouds o AWS Block Store il che andrebbe bene per una configurazione classica del database come può essere Postgres.
Per utilizzare il PVC nella configurazione del Deployment aggiungo a
template:
spec:
volumes:
- name: <nome del volume>
persistentVolumeClaim:
claimName: <nome dato al volume claim>
Aggiungo poi ai container definiti nel Deployment:
volumeMounts:
- name: <nome del volume>
mountPath: <percorso dove il db andrà a reperire i dati, per es. per Postgres saranno in /var/lib/postgresql/data>
subPath: <path dove tutti i dati contenuti nel mountPath devono essere storati nel Volume Claim, per es. per Posgres sarà ‘postgres’>
Vado quindi ad applicare con kubectl.
Con "kubectl get pv
" posso vedere i PV attivi dove lo STATUS "Bound" indica che il volume è montato ed in uso.
Alcune variabili d’ambiente servono a parti dell’applicazione per avere i parametri per connettersi per esempio a Redis o al database, quindi alcuni Deployment dovranno connettersi ad altri Deployment attraverso i ClusterIP Service sfruttando le variabili d'ambiente.
Si tratta quindi di fornire degli URL. Poi ci sono altre variabili che indicano semplicemente dei parametri (come la password e lo user di Postgres). Ovviamente la password va gestita in modo diverso per non doverla committare nel codice (usando l’Encoded Secret).
Nei file di configurazione dei Deployment all'interno dei container definisco:
**env**:
- name: <nome variabile>
value: <valore>
Se devo indicare un host (database o Redis ecc...) indico il nome dato al ClusterIP che gestisce il deploy, si imposta quindi nella forma:
- name: <NOME>_HOST
value: <nome>-cluster-ip-service
- name: <NOME>_PORT
value: '<la porta esposta in "spec">' (non dimenticare gli apici)
Esempio:
containers:
- name: server
image: stephengrider/multi-server
ports:
- containerPort: 5000
env:
- name: REDIS_HOST
value: redis-cluster-ip-service
- name: REDIS_PORT
value: '6379'
- name: PGUSER
value: postgres
- name: PGHOST
value: postgres-cluster-ip-service
- name: PGPORT
value: '5432'
- name: PGDATABASE
value: postgres
Come fare per non scrivere in chiaro la password in una variabile d'ambiente? Si crea un nuovo tipo di oggetto chiamato Secret (dopo aver visto il Pod, il Deployment e i Service).
Lo scopo del Secret è quello di storare in modo sicuro pezzi di informazione nel Cluster, come appunto la password di un database.
Si utilizza un comando "imperative" in quanto occorre fornire direttamente il dato senza però scriverlo in chiaro in un file.
Il comando è:
kubectl create secret <tipo di Secret> <nome_del_secret> --from-literal (ovvero non da file ma direttamente da comando) <nome variabile>=<password>
Si possono inserire multiple coppie di chiavi/valori.
Tipi di Secret:
Con
kubectl get secrets
posso elencare i secrets.
Creo una variabile d'ambiente che possa "puntare" al Secret:
env:
- name: <NOME_VAR_USATA IN APPLICAZIONE>
valueFrom:
secretKeyRef:
name: <nome del Secret>
key: <chiave della coppia chiave/valore storata nel Secret>
Nel file del Deployment del database aggiungo:
env:
- name: <NOME_VAR_DELLA PASSWORD DEL DBMS>
valueFrom:
secretKeyRef:
name: <nome del Secret>
key: <chiave della coppia chiave/valore storata nel Secret>