Nelle pagine di questo blog si è parlato molto spesso di Containers e Pod, riferendoci in particolare a OpenShift e Kubernetes, il servizio di orchestrazione su cui OpenShift è sviluppato. In questo articolo studieremo, tramite l’implementazione e il deploy di un’applicazione dedicata, gli Init Containers di Kubernetes.
Crediti dell’immagine: OpenShift Blog, Kubernetes: A Pod’s Life
Pods
Cosa è un Pod? Possiamo definirlo come l’unita più piccola di esecuzione in Kubernetes consistente in uno o più container che condividono i medesimi Linux namespace. E’ importante ricordare a tal proposito che un Linux container è una forma di isolamento di processi che vengono limitati a specifici namespace.
NOTA: Non si confondano i Linux namespace, che sono una feature del kernel che permette di isolare specifiche risorse (IPC, Pid, UTS, Mount, User, Network, CGroup) per un processo, dai namespace di Kubernetes, che hanno invece il ruolo di isolare delle risorse all’interno di uno scope limitato nel cluster.
Comprendere a fondo i Linux namespace è importantissimo per capire come funziona l’isolamento dei processi all’interno di un container.
Per maggiori dettagli consultare la man page relativa:
$ man 7 namespaces
Per maggiori approfoindimenti sul namespace Kubernetes: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/
Ma torniamo ai Pod. Come conseguenza di quanto detto si può affermare che in un Pod possiamo eseguire più processi containerizzati che andranno a condividere tra loro risorse comuni come il network stack o le IPC. Questo permette di generare un’unità di servizio coesa che viene integralmente schedulata sullo stesso nodo.
Per approfondire andiamo a studiarci la relativa pagina nella ricchissima sezione dei concepts di Kubernetes: https://kubernetes.io/docs/concepts/workloads/pods/pod/
Formalmente, ogni qualvolta si crea un pod in Kubernetes, viene creato per primo un container infrastrutturale che contiene un processo dummy il cui unico scopo è quello di inizializzare i namespace che verranno poi condivisi dagli altri container. In Kubernetes questo processo è un semplice processo scritto in C che rimane in attesa di segnali (https://github.com/kubernetes/kubernetes/tree/master/build/pause/pause.c) mentre in OpenShift l’equivalente processo è un binario Go abbastanza simile (https://github.com/openshift/origin/blob/master/images/pod/pod.go). Questi tipi di container vengono detti infra container.
Questo container non è visibile tramite chiamate API e il suo unico scopo si esaurisce in quanto abbiamo detto. E’ visualizzabile con un docker ps dai singoli nodi che eseguono i pod.
Il container principale (o i container) viene eseguito subito dopo e il suo stato viene monitorato tramite le Readiness e Liveness probes.
Init Containers
Come fare nel caso in cui sia necessario compiere delle azioni preliminari prima di eseguire il processo principale? Ad esempio creare delle strutture di directory, file o magari inizializzare un database? Per questo tipo di operazioni ci vengono in aiuto gli Init Container. Pensiamo ad essi proprio come ad una sorta di init dei Pod: gli Init container verranno eseguiti per primi e solo successivamente verranno creati gli altri container.
Allo scopo di fornire una dimostrazione il più possibile esaustiva abbiamo creato un’applicazione minimale che utilizza una semplice tabella su un database MariaDB (eseguito in un altro pod separato) per produrre stringhe random tratte dalla famosa Magic 8 Ball, gioco apparso negli anni 50 che consisteva in una sfera a forma di palla da biliardo numero 8 che, se agitata, produceva messaggi casuali che potevano interpretati come risposte di un oracolo alle domande poste dal giocatore.
Quindi la nostra applicazione leggerà queste risposte dal database e le esporrà in risposta a delle semplicissime chiamate HTTP GET.
Non vogliamo però dover inizializzare il database manualmente tramite del codice sql iniettato. Vogliamo invece che l’applicazione, alla sua prima esecuzione crei la tabella “answers” e la popoli di record con le varie frasi. Questo dovrà inoltre avvenire in modo idempotente. Esecuzioni successive dovranno semplicemente constatare che il db è già inizializzato.
Vogliamo inoltre separare la logica di inizializzazione dalla logica di erogazione del servizio vero e proprio.
A tal fine abbiamo scritto una piccola applicazione divisa in due binari distinti:
- magicball-init, che si occuperà di inizializzare il database in modo idempotente.
- magicball-server, che esporrà un servizio http sulla porta 8080 in attesa ti chiamate GET e restituirà le risposte casuali.
Il codice dell’applicazione è stato scritto tutto in Go.
Perché Go? Anzitutto perché ci piace tantissimo in quanto incarna caratteristiche fondamentali di semplicità ed eleganze che lo rendono un linguaggio perfetto per scrivere infrastrutture complesse (vedasi ad esempio Kubernetes o OpenShift, scritti appunto in Go) ed ottimo per implementare architetture server che scalano in contesti Cloud.
Inoltre, quando si builda un binario Go viene prodotto un unico file che contiente tutti i package linkati staticamente. Questa caratteristica, che fino a pochi anni fa veniva anzi evitata in favore dell’approccio a shared libraries, oggi rende molto semplice il processo di containerizzazione delle applicazioni.
Tutti i sorgenti dell’applicazione sono disponibili sul mio personale repo GitHub https://github.com/giannisalinetti/magicball assieme ai manifest per configurare i deployment e i servizi Kubernetes che vedremo dopo.
Per il deployment su Kubernetes abbiamo optato per l’opzione più semplice e immediata, ovvero MiniKube: un tool che scarica, configura ed esegue una vm Kubernetes all-in-one.
Installare MiniKube è facillissimo e per questo rimandiamo alla documentazione ufficiale:
https://kubernetes.io/docs/tasks/tools/install-minikube/
https://kubernetes.io/docs/getting-started-guides/minikube/
Una volta installato MiniKube è sufficiente avviare l’ambiente con il comando
$ minikube start
Il codice di magicball-init
Iniziamo dunque clonando il repository:
$ git clone https://github.com/giannisalinetti/magicball $ cd magiball/magicball-init
Il repo contiene tre sottodirectory:
- k8s
- magicball-init
- magicball-server
Nella directory magicball-init troveremo il file main.go, che conterrà il codice del nostro Init Container.
package main import ( "database/sql" "fmt" "log" "os" "regexp" _ "github.com/go-sql-driver/mysql" ) const ( driver = "mysql" ) type answers struct { id int sentence string } type dbConnection struct { user string password string protocol string address string port string database string } var ( createTableStatement = "CREATE TABLE answers(id INT AUTO_INCREMENT, sentence VARCHAR(255), PRIMARY KEY(id));" insertDataStatements = []answers{ {1, "INSERT INTO answers (id, sentence) VALUES (?, 'As I see it, yes');"}, {2, "INSERT INTO answers (id, sentence) VALUES (?, 'It is certain');"}, {3, "INSERT INTO answers (id, sentence) VALUES (?, 'It is decidedly so');"}, {4, "INSERT INTO answers (id, sentence) VALUES (?, 'Most likely');"}, {5, "INSERT INTO answers (id, sentence) VALUES (?, 'Yes');"}, {6, "INSERT INTO answers (id, sentence) VALUES (?, 'Ask again later');"}, {7, "INSERT INTO answers (id, sentence) VALUES (?, 'Better not tell you now');"}, {8, "INSERT INTO answers (id, sentence) VALUES (?, 'Cannot predict now');"}, {9, "INSERT INTO answers (id, sentence) VALUES (?, 'Reply hazy, try again');"}, {10, "INSERT INTO answers (id, sentence) VALUES (?, 'Concentrate and ask again');"}, {11, "INSERT INTO answers (id, sentence) VALUES (?, 'Do not count on it');"}, {12, "INSERT INTO answers (id, sentence) VALUES (?, 'My reply is no');"}, {13, "INSERT INTO answers (id, sentence) VALUES (?, 'My sources say no');"}, {14, "INSERT INTO answers (id, sentence) VALUES (?, 'Outlook not so good');"}, {15, "INSERT INTO answers (id, sentence) VALUES (?, 'Very doubtful');"}, } ) func main() { myConn := &dbConnection{ os.Getenv("APPDB_USER"), os.Getenv("APPDB_PASS"), os.Getenv("APPDB_PORT_3306_TCP_PROTO"), os.Getenv("APPDB_SERVICE_HOST"), os.Getenv("APPDB_SERVICE_PORT"), os.Getenv("APPDB_NAME"), } ds := fmt.Sprintf("%s:%s@%s(%s:%s)/%s", myConn.user, myConn.password, myConn.protocol, myConn.address, myConn.port, myConn.database) db, err := sql.Open(driver, ds) if err != nil { log.Fatal(err) } defer db.Close() err = db.Ping() if err != nil { log.Fatal(err) } else { log.Print("Connection successful.") } // Create the table _, err = db.Exec(createTableStatement) if err != nil { errMessage := err.Error() tableExistsCode := "1050" re := regexp.MustCompile(tableExistsCode) if re.FindString(errMessage) == tableExistsCode { log.Print("Table already exists, exiting.") os.Exit(0) // If the table exists, we're done } else { log.Fatal(err) } } // Insert rows, this need more checks for _, statement := range insertDataStatements { res, err := db.Exec(statement.sentence, statement.id) if err != nil { log.Fatal(err) } else { rows, _ := res.RowsAffected() log.Printf("Last insert id: %v, Rows affected: %v", statement.id, rows) } }
Cerchiamo di analizzare un po’ più nel dettaglio cosa fa questo codice.
package main
Con questo campo dichiariamo il package a cui apparterrà questo file. Il package main in Go è speciale poiché istruisce il compilatore di attivare il linker e di produrre un file binario eseguibile.
import ( "database/sql" "fmt" "log" "os" "regexp" _ "github.com/go-sql-driver/mysql" )
La sezione di import serve appunto ad importare tutti i package utilizzati dall’applicazione.
Il package database/sql tra questi è certamente il più importante poiché è quello che si occupa di gestire non solo le connessioni ai database (tramite meccanismi di connection pooling) ma anche query e transazioni. Lo fa inoltre mantenendo un livello di astrazione elevato rispetto al driver utlizzato. Questo permette di cambiare RDMBS sotto e di modificare molto poco del codice.
Poiché nel nostro caso utilizziamo MariaDB il nostro driver sarà implementato nel package github.com/go-sql-driver/mysql.
Il simbolo “_” all’inizio della riga sta ad indicare che non verranno invocati metodi o tipi di questo driver ma che il package sarà comunque inizializzato e pertanto utlizzabile dal più astratto database/sql.
Il package fmt è il più comune in Go: ricorda l’header file stdio.h del C e si occupa di fornire metodi e tipi per la gestione dell’output e non solo.
Il package log è abbastanza parlante, si occupa di logging.
Il package os permette di gestire le interazioni a basso livello con il sistema operativo. Useremo da questo package due funzioni: os.Getenv() per leggere il contenuto di specifiche variabili d’ambiente, e os.Exit() per generare un exit code per il programma in uscita.
Infine il package regexp permette di applicare pattern di espressioni regolari che utilizzeremo nell’identificazione dei codici d’errore Mysql.
Definiamo ora la costante driver che rappesenterà il tipo di driver utilizzato:
const ( driver = "mysql" )
Un modo ottimale per gestire dati organizzati in modo relazionale è trattare quei dati con un approccio strutturale. Nell’esempio proposto il db è composto da una sola tabella e pertanto viene creata una struttura omologa, dove troviamo due campi: id, per l’indice della tabella, sentence, contenente le frasi che la Magic Ball restituirrà come risposta.
type answers struct { id int sentence string }
Tra poco avremo bisogno di inizializzare un Data Source per gestire le connessioni al db. Una buona pratica è organizzare le variabili inerenti le stesse funzionalità in un tipo di dato struct:
type dbConnection struct { user string password string protocol string address string port string database string }
In questo modo possiamo creare anche più istanze della stessa struttura e gestire contemporaneamente connessioni diverse.
Una nota in particolare: il campo port potrebbe essere stato tranquillamente di tipo int o unit ma poiché gestiremo queste informaizioni tramite variabili d’ambiente generate da Kubernetes si è preferito utilizzare direttamente il formato string senza fare nessun type casting.
Passiamo ora alla definizione di due variabili contenenti i nostri statement SQL.
var ( createTableStatement = "CREATE TABLE answers(id INT AUTO_INCREMENT, sentence VARCHAR(255), PRIMARY KEY(id));" insertDataStatements = []answers{ {1, "INSERT INTO answers (id, sentence) VALUES (?, 'As I see it, yes');"}, {2, "INSERT INTO answers (id, sentence) VALUES (?, 'It is certain');"}, {3, "INSERT INTO answers (id, sentence) VALUES (?, 'It is decidedly so');"}, {4, "INSERT INTO answers (id, sentence) VALUES (?, 'Most likely');"}, {5, "INSERT INTO answers (id, sentence) VALUES (?, 'Yes');"}, {6, "INSERT INTO answers (id, sentence) VALUES (?, 'Ask again later');"}, {7, "INSERT INTO answers (id, sentence) VALUES (?, 'Better not tell you now');"}, {8, "INSERT INTO answers (id, sentence) VALUES (?, 'Cannot predict now');"}, {9, "INSERT INTO answers (id, sentence) VALUES (?, 'Reply hazy, try again');"}, {10, "INSERT INTO answers (id, sentence) VALUES (?, 'Concentrate and ask again');"}, {11, "INSERT INTO answers (id, sentence) VALUES (?, 'Do not count on it');"}, {12, "INSERT INTO answers (id, sentence) VALUES (?, 'My reply is no');"}, {13, "INSERT INTO answers (id, sentence) VALUES (?, 'My sources say no');"}, {14, "INSERT INTO answers (id, sentence) VALUES (?, 'Outlook not so good');"}, {15, "INSERT INTO answers (id, sentence) VALUES (?, 'Very doubtful');"}, } )
La prima variabile, createTableStatement, rappresenta una semplice CREATE TABLE. Come possiamo vedere siamo ai limiti estremi del minimalismo: una sola tabella con due campi: un id di tipo INT che sarà chiave primaria e un campo sentence di tipo VARCHAR(255) per le nostre frasi.
La variabile insertDataStatements è invece uno slice di strutture di tipo answers, rappresentato come []answers. Uno slice in Go è simile a un array ma di dimensioni non vincolate da un limite specifico. Può essere dunque esteso con facilità e soprattutto, come vedremo tra poco, è iterabile.
Nel nostro caso, avere la possibilità di gestire un array di strutture è un’opzione molto utile nell’iterazione che faremo tra poco.
Notare il “?” nelle varie INSERT: tale simbolo rappresenta in Mysql un placeholder che deve essere sostituito con un argomento passato nella query dinamica. Lo rimpiazzeremo con l’id definito nella struttura stessa.
Passiamo alla nostra funzione main(), detta anche main goroutine:
func main() { myConn := &dbConnection{ os.Getenv("APPDB_USER"), os.Getenv("APPDB_PASS"), os.Getenv("APPDB_PORT_3306_TCP_PROTO"), os.Getenv("APPDB_SERVICE_HOST"), os.Getenv("APPDB_SERVICE_PORT"), os.Getenv("APPDB_NAME"), } ds := fmt.Sprintf("%s:%s@%s(%s:%s)/%s", myConn.user, myConn.password, myConn.protocol, myConn.address, myConn.port, myConn.database)
La variabile myConn è un’istanza della struttura dbConnection. I campi vengono valorizzati utilizzando os.Getenv() e variabili d’ambiente dai nomi molto specifici. Cosa sono?
Per capirlo dobbiamo porci una domanda: come fanno i pod all’interno di Kubernetes a parlarsi tra loro? La risposta è semplice: tramite i servizi. Un servizio Kubernetes è una forma di astrazione che espone un set di pod tramite un unico VIP. Questa sorta di bilanciamento viene gestito, a basso livello, tramite regole iptables generate dinamicamente. Quando un pod viene eseguito ed è in uno stato Ready viene incluso in un servizio specifico. Come? Tramite un campo Selector che definisce una serie di label.
Il nostro servizio bilancerà dunque tutti i pod che hanno nei loro metadati le stesse label.
Questo permette di creare astrazione e di garantire consistenza nel caso in cui il pod dovesse andare giù ed essere rischedulato. Oppure nel caso in cui si scali il numero dei pod.
Fatta questa importante premessa arriviamo alla parte più bella: all’interno del medesimo namespace un pod di nuova creazione avrà iniettate automaticamente delle variabili d’ambiente che faranno riferimento ai servizi già attivi degli altri pod. Pertanto, dato un servizio di nome appdb, i pod nel medesimo namespace vedranno variabili d’ambiente del tipo:
- APPDB_SERVICE_PORT, che definisce la porta esposta dal servizio.
- APPDB_SERVICE_HOST, che definisce l’IP del servizio.
- APPDB_PORT_<PORTNUMBER>_<PROTOCOL>_PROTO, che definisce il protocollo utilizzato per una specifica porta.
Possiamo sfruttare queste variabili d’ambiente per connetterci al nostro servizio che esporrà, tramite un VIP visibile solo nel cluster Kubernetes detto ClusterIP, il nostro database.
Queste variabili vengono quindi create dinamicamente quando creiamo il nostro pod.
Assieme a queste ne abbiamo definite anche delle altre, mantenendo per consistenza lo stesso pattern:
- APPDB_USER, per il nome utente usate nella connessione al db.
- APPDB_PASS, per la password.
- APPDB_NAME, per il nome del database.
Queste ultime verranno create successivamente in fase di deploy del pod nel nostro manifest di Deployment Kubernetes.
La variabile ds contiene la stringa di connessione al database, generata tramite la funzione fmt.Sprintf() che genera una stringa formattata.
Siamo giunti all’inizializzazione del nostro data source:
db, err := sql.Open(driver, ds) if err != nil { log.Fatal(err) } defer db.Close()
La funzione sql.Open() inizializza il nostro data source e restituisce un oggetto *sql.DB che viene puntato dalla variabile db, seppur non creando alcuna connessione effettiva per il momento. Poiché non vogliamo lasciare connessioni appese aggiungiamo ache subito una chiamata a db.Close() preceduta dalla keyword defer che permette di posticipare l’esecuzione della chiamata all’uscita della funzione.
Testiamo la nostra connessione tramite il metodo db.Ping():
err = db.Ping() if err != nil { log.Fatal(err) } else { log.Print("Connection successful.") }
Passiamo ora alla creazione della tabella che ospiterà le risposte:
// Create the table _, err = db.Exec(createTableStatement) if err != nil { errMessage := err.Error() tableExistsCode := "1050" re := regexp.MustCompile(tableExistsCode) if re.FindString(errMessage) == tableExistsCode { log.Print("Table already exists, exiting.") os.Exit(0) // If the table exists, we're done } else { log.Fatal(err) } }
Qui c’è una parte molto importante, quella che effettivamente rende il nostro init container idempotente. La funzione db.Exec() si occupa di eseguire degli statement che non ritornano alcun record, come appunto una CREATE. Il meccanismo di controllo d’errore (una delle cose che preferisco in Go) permette di identificare subito l’esito. Se la variabile d’appoggio err istanza dell’interfaccia error non è nulla, vuol dire che la nostra creazione non è andata a buon fine e che la tabella già esiste. Sicuramente se ci limitassimo a verificare che ci sia un generico errore saremmo troppo sbrigativi: potremmo sempre incappare in qualche altro errore e scambiarlo per una tabella esistente. Per questo motivo ci faremo dare una mano dalle espressioni regolari del package regexp.
Ci interessa soltato avere nell’output d’errore il codice 1050 che per Mysql corrisponde a “Table already exists”. Compileremo questo pattern iperminimale con la funzione regexp.MustCompile(). Questa restituirà un oggetto di tipo *regexp.RegExp su cui eseguiremo il metodo regexp.FindString(). Quest’ultimo restituisce il primo match del pattern a partire da sinistra. Dopo aver verificato che questo match sia equivalente al nostro codice potremo uscire serenamente con exit code 0. In caso contrario usciremo comunque con exit code 1 per evitare ulteriori scritture di campi sulla tabella.
Dopo che la tabella è stata appena creata è possibile procedere al popolamento:
// Insert rows for _, statement := range insertDataStatements { res, err := db.Exec(statement.sentence, statement.id) if err != nil { log.Fatal(err) } else { rows, _ := res.RowsAffected() log.Printf("Last insert id: %v, Rows affected: %v", statement.id, rows) } }
La slice definita all’inizio, insertDataStatements viene iterata e per ogni statement viene eseguito db.Exec() per effettuare la INSERT. Da notare che db.Exec() accetta come argomenti la stringa della query, statement.sentence, e una serie di argomenti aggiuntivi da passare in sostituzione nella query, in questo caso statement.id.
Oltre all’errore stavolta cattureremo anche un output di tipo Result, che è un’interfaccia che supporta due metodi: LastInserId() e RowsAffected(). Di questi useremo RowsAffected() per scrivere un log un po’ più ricco sui record che sono stati creati.
Chiudiamo con un messaggio finale che dichiara il completamento dell’inizializzazione.
Il codice di magicball-server
Passiamo all’analisi del codice del servizio http, anche questo molto semplice. Nella sottodirectory magicball-server troveremo un altro file main.go:
package main import ( "database/sql" "encoding/json" "flag" "fmt" "io" "log" "math/rand" "net/http" "os" "time" _ "github.com/go-sql-driver/mysql" ) const ( driver = "mysql" ) type dbConnection struct { user string password string protocol string address string port string database string } func randomInt(min, max int) int { return rand.Intn(max-min) + min } func getSentence(db *sql.DB) (string, error) { var sentence string var count int err := db.QueryRow("SELECT COUNT(*) FROM answers").Scan(&count) if err != nil { return "", err } randomId := randomInt(1, count) err = db.QueryRow("SELECT sentence FROM answers WHERE id = ?", randomId).Scan(&sentence) if err != nil { return "", err } return sentence, nil } func toJson(s string) (string, error) { m := make(map[string]string) m["answer"] = s payload, err := json.Marshal(m) if err != nil { return "", err } return string(payload), nil } func main() { listenPort := flag.String("p", "8080", "Service listening port") flag.Parse() myConn := &dbConnection{ os.Getenv("APPDB_USER"), os.Getenv("APPDB_PASS"), os.Getenv("APPDB_PORT_3306_TCP_PROTO"), os.Getenv("APPDB_SERVICE_HOST"), os.Getenv("APPDB_SERVICE_PORT"), os.Getenv("APPDB_NAME"), } ds := fmt.Sprintf("%s:%s@%s(%s:%s)/%s", myConn.user, myConn.password, myConn.protocol, myConn.address, myConn.port, myConn.database) db, err := sql.Open(driver, ds) if err != nil { log.Fatal(err) } defer db.Close() defaultSocket := fmt.Sprintf("%s:%s", os.Getenv("POD_IP"), *listenPort) rand.Seed(time.Now().UnixNano()) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { answer, err := getSentence(db) if err != nil { log.Fatal(err) } fullResponse := fmt.Sprintf("Magic 8 Ball said: %s.\n", answer) io.WriteString(w, fullResponse) }) http.HandleFunc("/json", func(w http.ResponseWriter, r *http.Request) { answer, err := getSentence(db) if err != nil { log.Fatal(err) } jsonAnswer, err := toJson(answer) if err != nil { log.Fatal(err) } fullResponse := fmt.Sprintf("%s\n", jsonAnswer) io.WriteString(w, fullResponse) }) log.Fatal(http.ListenAndServe(defaultSocket, nil)) }
Come al solito si parte dalla definizione del package main e dagli import, simili a prima ma con alcune differenze:
package main import ( "database/sql" "encoding/json" "flag" "fmt" "io" "log" "math/rand" "net/http" "os" "time" _ "github.com/go-sql-driver/mysql" )
In questo caso si definiscono anche dei nuovi package:
- encoding/json, per gestire la manipolazione di oggetti json.
- flag, per gestire flag da linea di comando. Lo useremo per permettere di impostare una porta d’ascolto alternativa.
- io, che espone metodi per l’input/output e che noi useremo per scriver su un’interfaccia di tipo io.Writer.
- math/rand, package per la generazione di numeri pseudo-casuali.
- net/http, che espone metodi e tipi per gestire il protollo http e relative connessioni
- time, che useremo per creare un seed per il generatore di numeri.
Dichiariamo poi allo stesso modo di prima driver di connessione e struttura per gestire i parametri di connessione:
const ( driver = "mysql" ) type dbConnection struct { user string password string protocol string address string port string database string }
Per produrre risposte casuali ci limiteremo a randomizzare un intero nell’intervallo tra 1 e il l’indice più alto del database. A tal fine ci costruiamo questa piccola helper function:
func randomInt(min, max int) int { return rand.Intn(max-min) + min }
Al suo interno viene invocata la funzione rand.Intn() che restituisce un intero casuale tra zero e il valore passato come argomento.
Poiché si vogliono definire più endpoint per diversi tipi di chiamate, è preferibile racchiudere il codice della query in una specifica funzione e poi limitarsi a richiamare quest’ultima. La funzione getSentence() si occupa di effettuare la query al db e di restituire una stringa con la frase casuale. La prima query, tramite una SELECT COUNT, ci permette di ottenere il numero di rows nella nostra tabella.
La seconda effettua la SELECT passando un randomId generato dalla funzione randomInt() prima definita. Da notare che l’unico argomento passato alla funzione è un puntatore ad un struttura *sql.DB, generata dalla sql.Open().
func getSentence(db *sql.DB) (string, error) { var sentence string var count int err := db.QueryRow("SELECT COUNT(*) FROM answers").Scan(&count) if err != nil { return "", err } randomId := randomInt(1, count) err = db.QueryRow("SELECT sentence FROM answers WHERE id = ?", randomId).Scan(&sentence) if err != nil { return "", err } return sentence, nil }
Poichè vorremo poter stampare l’output anche in formato json creeremo una piccola helper function che si occuperà di fare il Marshaling di una semplice mappa [string]string in cui il valore sarà proprio la nostra frase. Qui la conversione da mappa a json restituito formato []byte (byte slice) è svolta dalla funzione json.Marshal().
func toJson(s string) (string, error) { m := make(map[string]string) m["answer"] = s payload, err := json.Marshal(m) if err != nil { return "", err } return string(payload), nil }
Passiamo ora alla main goroutine. La prima parte definisce, tramite il package flag, un’opzione per modificare la porta di ascolto del socket. Notare che questa ha un suo valore di default, che è 8080:
func main() { listenPort := flag.String("p", "8080", "Service listening port") flag.Parse()
Successivamente passiamo all’inizializzazione dellla connessione, similmente a come abbiamo fatto prima. Questo passaggio merita un’importante considerazione. La connessione al db viene inizializzata una sola volta. Questo ottimizzerà al meglio le prestazioni della nostra applicazione, seppur minimale.
myConn := &dbConnection{ os.Getenv("APPDB_USER"), os.Getenv("APPDB_PASS"), os.Getenv("APPDB_PORT_3306_TCP_PROTO"), os.Getenv("APPDB_SERVICE_HOST"), os.Getenv("APPDB_SERVICE_PORT"), os.Getenv("APPDB_NAME"), } ds := fmt.Sprintf("%s:%s@%s(%s:%s)/%s", myConn.user, myConn.password, myConn.protocol, myConn.address, myConn.port, myConn.database) db, err := sql.Open(driver, ds) if err != nil { log.Fatal(err) } defer db.Close()
Definiamo una stringa che rappresenterà il nostro socket del server http. La passeremo tra poco alla handler function:
defaultSocket := fmt.Sprintf("%s:%s", os.Getenv("POD_IP"), *listenPort)
La variabile POD_IP rappresenterà l’indirizzo IPv4 del pod. Vedremo poi, nel Deploment Kubernetes, come viene estratta.
Dobbiamo generare risposte casuali, e il modo più facile per farlo è pescare un indice a caso all’interno del range del database. Per utilizzare il generatore pseudo-casuale dobbiamo prima definire un random seed. Nel nostro caso ci baseremo sul valore restituito dallo Unix epoch in nanosecondi:
rand.Seed(time.Now().UnixNano())
Anche questa operazione è ottimizzata per esser svolta una volta soltanto all’avvio del container.
Siamo arrivati alla parte più interessante, ovvero la funzione http.HandleFunc() che gestisce le richieste su una data url. La documentazione di Go la definisce così:
“func HandleFunc(pattern string, handler func(ResponseWriter, *Request))
HandleFunc registers the handler function for the given pattern in the
DefaultServeMux. The documentation for ServeMux explains how patterns
are matched.”
Si tratta quindi di una funzione che accetta un pattern per la url (qui usereremo semplicemente “/”) e una handler function che deve avere per argomenti su un’interfaccia http.ResponseWriter e una struttura *http.Request. Questa funzione può essere definita esternamenete al main e poi invocata oppure passata direttamente in foma di funzione anonima. Il vantaggio? In Go una funzione anonima vede le variabili definite esternamente senza la necessita di passargliele come argomenti. Possiamo quindi far si che essa acceda direttamente al nostro oggetto db inizalizzato prima.
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { answer, err := getSentence(db) if err != nil { log.Fatal(err) } fullResponse := fmt.Sprintf("Magic 8 Ball said: %s.\n", answer) io.WriteString(w, fullResponse) })
All’interno della nostra funzione anonima, passata a http.HandleFunc, viene invocata la funzione getSentence(db) che abbiamo definito prima.
Infine il testo della risposta viene formattato in una stringa e scritto sull’output tramite la funzione io.WriteString().
Vogliamo definire anche un secondo endpoint per produrre un output più minimale in formato json, che può essere più facilmente processato da altri componenti che vorremo aggiungere come un’interfaccia UI. Per fare ciò chiamiamo nuovamente http.HandleFunc() ma questa volta passeremo come pattern l’endpoint “/json”.
Similmente a prima, viene invocata la funzione getSentence(), successivamente viene invocata la nostra altra funzione toJson() per produrre una stringa in formato json che viene poi formattata tramite fmt.Sprintf().
http.HandleFunc("/json", func(w http.ResponseWriter, r *http.Request) { answer, err := getSentence(db) if err != nil { log.Fatal(err) } jsonAnswer, err := toJson(answer) if err != nil { log.Fatal(err) } fullResponse := fmt.Sprintf("%s\n", jsonAnswer) io.WriteString(w, fullResponse) })
Abbiamo tutto, ci manca solo un pezzo fondamentale: l’avvio del servizio in ascolto sul socket che abbiamo definito tramite la funzione http.ListenAndServe():
log.Fatal(http.ListenAndServe(defaultSocket, nil)) }
E con questo si conclude l’analisi del codice di magicball-server. Possiamo passare all build delle immiagini.
Build delle immagini
Per buildare le immagini si può utilizzare il comando docker build:
$ cd magicball-init $ docker build -t magicball-init . $ docker tag magicball-init docker.io/<NAMESPACE>/magicball-init:latest $ docker push docker.io/<NAMESPACE>/magicball-init:latest $ cd .. $ cd magicball-server $ docker build -t magicball-server . $ docker tag magicball-server docker.io/<NAMESPACE>/magicball-server:latest $ docker push docker.io/<NAMESPACE>/magicball-server:latest $ cd ..
Quest’ultima operazione richiede che si abbia definito un account su Docker Hub e che siano stati già creati a monte due repository magicball-init e macigball-server.
Per semplificare le cose nel nostro esempio utilizzeremo quelli già creati:
- docker.io/gbsal/magicball-init:latest
- docker.io/gbsal/magicball-server:latest
Deploy in Kubernetes
Nella directory k8s abbiamo già a disposizione tutti i manifest necessari per creare l’applicazione.
Iniziamo con la creazione del Deployment per il database e osserviamo anche il contenuto del manifest:
apiVersion: apps/v1 kind: Deployment metadata: name: appdb labels: app: appdb spec: replicas: 1 selector: matchLabels: app: appdb template: metadata: labels: app: appdb spec: containers: - env: - name: MYSQL_USER value: testuser - name: MYSQL_PASSWORD value: mypa55 - name: MYSQL_ROOT_PASSWORD value: r00tpa55 - name: MYSQL_DATABASE value: magicball image: docker.io/mariadb:latest imagePullPolicy: Always name: appdb ports: - containerPort: 3306 protocol: TCP restartPolicy: Always
In questo Deployment notiamo le variabili d’ambiente passate al pod MariaDB per definire utente, password utente e root, e nome del db. Queste variabili, definite nel Dockerfile del database sono:
- MYSQL_USER: definisce un utente che avrà privilegi rw sul db
- MYSQL_PASSWORD: password relativa all’utente sopra citato
- MYSQL_ROOT_PASSWORD: password per l’utente root di MariaDB
- MYSQL_DATABASE: nome del db che verrà creato in fase di inizializzazione.
Notiamo anche che il pod generato da questo Deployment avrà la label app: appdb. La vedremo tornare tra poco, quando creeremo il relativo servizio e parleremo del metodo utilizzato per esporre i pod.
Iniziamo quindi con la creazione del namespace in cui eseguiremo i nostri pod tramite il seguente comando:
$ kubectl create namespace magic8
Per poi creare il deployment usando il manifest mostrato:
$ kubectl apply -f appdb-deployment.yml -n magic8
Il Deployment scatenerà la creazione di un ReplicaSet che a sua volta gestirà il deploy del Pod vero e proprio. Al primo avvio, se l’immagine docker.io/mariadb non è già presente, sarà necessario pazientare il tempo del download.
Dopo aver creato il Pod, possiamo creare il servizio appdb. Iniziamo con l’analisi del manifest:
kind: Service apiVersion: v1 metadata: name: appdb spec: selector: app: appdb ports: - name: mysql protocol: TCP port: 3306
Notiamo in particolare il campo selector: questo ci dice che il servizio bilancerà soltanto i Pod in stato Ready che hanno la label app: appdb, ovvero il nostro database.
Usiamo sempre kubectl per creare il servizio:
$ kubectl apply -f appdb-svc.yml -n magic8
Se il nostro db è già up and running dovremmo essere in grado di vedere il servizio bilanciare un endpoint corrispondente all’indirizzo IPv4 del pod:
$ kubectl describe svc appdb -n magic8
L’output corretto dovrebbe apparire simile al seguente:
Name: appdb Namespace: magic8 Labels: <none> Annotations: kubectl.kubernetes.io/last-applied-configuration={"apiVersion":"v1","kind":"Service","metadata":{"annotations":{},"name":"appdb","namespace":"magic8"},"spec":{"ports":[{"name":"mysql","port":3306,"pro... Selector: app=appdb Type: ClusterIP IP: 10.104.172.133 Port: mysql 3306/TCP TargetPort: 3306/TCP Endpoints: 172.17.0.3:3306 Session Affinity: None Events: <none>
Se il campo Endpoint dovesse apparire vuoto cerchiamo di indagare su cosa impedisce al pod di raggiungere lo stato Ready e di essere esposto dal servizio.
Siamo giunti alla creazione del Deployment relativo alla nostra applicazione. Il file in questo caso sarà leggermente più complesso in quanto conterrà nel template sia la definizione dell’Init container che del container principale. Di seguito il contenuto:
apiVersion: apps/v1 kind: Deployment metadata: name: magicball labels: app: magicball spec: replicas: 1 selector: matchLabels: app: magicball template: metadata: labels: app: magicball spec: initContainers: - name: magicball-init image: docker.io/gbsal/magicball-init env: - name: APPDB_USER value: "testuser" - name: APPDB_PASS value: "mypa55" - name: APPDB_NAME value: "magicball" containers: - name: magicball-server image: docker.io/gbsal/magicball-server env: - name: POD_IP valueFrom: fieldRef: fieldPath: status.podIP - name: APPDB_USER value: "testuser" - name: APPDB_PASS value: "mypa55" - name: APPDB_NAME value: "magicball" ports: - containerPort: 8080 protocol: TCP restartPolicy: Always
Si può notare che nel template di configurazione dei containers nel deployment ci sono due campi distinti:
- metadata: conterrà tutti i metadati, label comprese, da applicare ai pod creati
- spec: conterrà le definizioni dei containers.
Negli spec troviamo le definizioni per i containers e per gli initContainers. Al loro interno possono essere definiti, sotto forma di lista (ogni container sarà un blocco di un “-“) più containers. La sintassi per gli Init Containers è stata modificata da Kubernetes 1.6 ed è ora molto più comoda, precedentemente andavano definiti tra le annotations dei metadati in modo molto meno intuitivo.
Abbiamo dunque un Init containers, la cui immagine è docker.io/gbsal/magicball-init:latest e un container standard, la cui immagine è docker.io/gbsal/magicball-server:latest.
All’interno delle definizioni dei container troviamo la chiave env dove si definiscono le variabili d’ambiente.
Nell’Init container magicball-init le variabili definite sono:
- APPDB_USER: L’utente con cui collegarsi al database
- APPDB_PASS: La password con cui autenticarsi
- APPDB_NAME: Il nome del db a cui collegarsi
Nel container magicball-server definiamo le stesse variabili oltre ad una in più:
- POD_IP: L’indirizzo IPv4 su cui mettere in ascolto il server http. Questo indirizzo viene assegnato in fase di deployment da Kubernetes in un subnet definita nella configurazione del plugin CNI di Kubernetes e non ci è noto in anticipo. Pertanto utilizzeremo un campo status.podIP generato nell’oggetto API Pod in fase di avvio. Fortunatamente questo indirizzo è disponibile già prima dell’avvio del processo poichè, come ricorderemo, all’avvio del pod viene eseguito un infra container preparatorio che inizializza i namespace (e quindi anche il network namespace).
NOTA IMPORTANTE: In questo contesto puramente dimostrativo ci siamo limitati a “scolpire” le password nei deployment per manterenere un approccio semplice e minimalista ma in un deployment di produzione questo sarebbe un errore gravissimo.
Se vogliamo poter versionare i nostri deployment all’interno di un SCM come Git non è accettabile in alcun modo andare a storare dati sensibili come credenziali o chiavi, che andranno gestiti separatamente.Per svincolare credenziali dalle configurazioni nei nostri deployment sarebbe possibile definire le passwrod del database in un Secret e poi utilizzare un valueFrom per valorizzare dinamicamente le variabili d’ambiente dei container in fase di avvio.
Nondimeno, anche le variabili che non contengono dati sensibili possono essere gestite in modo più flessibile tramite delle ConfigMaps.
Deployamo la nostra applicazione:
$ kubectl apply -f magicball-deploymnt.yml -n magic8
Subito dopo averla creato il Deployment monitoriamo subito lo stato dei pod. Dovremmo aspettarci un output simile a questo:
$ kubectl get pods -n magic8
NAME READY STATUS RESTARTS AGE
appdb-696878b6b7-m8xw4 1/1 Running 0 3m
magicball-656dcc854f-kvzc5 0/1 Init:0/1 0 12s
Il messaggio Init:0/1 ci dice che l’init container è in fase di avvio. Una volta completato verrà eseguito il container magicbal-server e potremo vedere il pod in stato Running e l’indicatore di Readiness valorizzato a 1/1.
Una volta deployato il pod nel cluster Kubernetes possiamo creare il servizio per esporlo usando il quarto e ultimo manifest:
kind: Service apiVersion: v1 metadata: name: magicball spec: type: LoadBalancer selector: app: magicball ports: - name: http protocol: TCP port: 8080
In Kuberentes è possibile creare diversi tipi di servizi. In questo caso, e diversamente da quanto fatto per il servizio del database, abbiamo bisogno di esporre tale servizio esternamente e di renderlo raggiungibile fuori dal cluster.
In Kubernetes questo è possibile creando un servizio di tipo LoadBalancer. In questo modo viene definita una risorsa Ingress che permette di esporre un IP raggiungibile dall’esterno attraverso un Load Balancer erogato dal cloud provider.
Per approfondire ulteriormente l’argomento troviamo sulla documentazione Kubernetes il task Create an External Load Balancer.
Nel nostro caso, poiché non stiamo eseguendo Kubernetes su GCE o AWS o OpenStack ma in locale, il nostro load balancer sarà il processo MiniKube stesso.
Per esporre il servizio esternamente tramite Minikube utilizziamo la sua CLI:
$ minikube service magicball -n magic8 --url http://192.168.39.163:30280
L’opzione –url restituisce in standard output la url del servizio esposto. Omettendola, MiniKube tenta di aprirla con il default browser della macchina.
Possiamo finalmente testare la nostra applicazione e verificarne il funzionamento. Poiché si tratta di un semplice metodo HTTP GET non abbiamo bisogno di altro che di eseguire una curl senza opzioni aggiuntive. Proviamo alcune esecuzioni consecutive:
$ curl http://192.168.39.163:30280 Magic 8 Ball said: Ask again later. $ curl http://192.168.39.163:30280 Magic 8 Ball said: My sources say no. $ curl http://192.168.39.163:30280 Magic 8 Ball said: It is decidedly so. $ curl http://192.168.39.163:30280 Magic 8 Ball said: Concentrate and ask again.
Se vogliamo produrre un output in formato json invocando il secondo handler dobbiamo soltanto modificare la nostra url:
$ curl http://192.168.39.163:30280/json {"answer":"Most likely"} $ curl http://192.168.39.163:30280/json {"answer":"Concentrate and ask again"} $ curl http://192.168.39.163:30280/json {"answer":"Do not count on it"} $ curl http://192.168.39.163:30280/json {"answer":"Yes"}
Ricapitolando, il nostro Init container magicball-init ha inizializzato il database creando la tabella answers e popolandola con le risposte attese. Successivamente il container magicball-server è stato eseguito. Quest’ultimo genera risposte dinamiche pescando in un range casuale tra 1 e il numero massimo di rows della tabella e stampando l’output di cui sopra come una semplice stringa o in formato JSON.
Viene da porsi una domanda: cosa succede se scaliamo il numero dei Pod dell’applicazione? Non resta che provare:
$ kubectl scale deployment/magicball --replicas=5 -n magic8
Vedremo partire i Pod scalati del deployment:
$ kubectl get pods -n magic8 NAME READY STATUS RESTARTS AGE appdb-696878b6b7-xpxt7 1/1 Running 2 1d magicball-656dcc854f-2n7kv 0/1 Init:0/1 0 2s magicball-656dcc854f-c7hxc 0/1 Init:0/1 0 2s magicball-656dcc854f-d9f6k 1/1 Running 2 21h magicball-656dcc854f-sp6b4 0/1 Init:0/1 0 2s magicball-656dcc854f-z9p6t 0/1 Init:0/1 0 2s
Dopo pochissimo l’Init container uscirà senza applicare nessuna modifica ulteriore al database grazie all’approccio idempotente che abbiamo seguito. Il pod attraverserà dunque lo stadio PodInitializing e verranno avviati i container magicball-server:
$ kubectl get pods -n magic8 NAME READY STATUS RESTARTS AGE appdb-696878b6b7-xpxt7 1/1 Running 2 1d magicball-656dcc854f-2n7kv 1/1 Running 0 21s magicball-656dcc854f-c7hxc 1/1 Running 0 21s magicball-656dcc854f-d9f6k 1/1 Running 2 21h magicball-656dcc854f-sp6b4 1/1 Running 0 21s magicball-656dcc854f-z9p6t 1/1 Running 0 21s
Ovviamente il servizio relativo bilancerà ora tutti i pod appena scalati.
Siamo giunti alla conclusione di questo laboratorio sugli Init Containers. E’ stata una bella occasione non solo per capire meglio questa interessante feature ma anche per approfondire in modo più dettagliato alcuni aspetti di sviluppo in Go e best practice Operation in Kubernetes. I sorgenti, come già detto prima, sono disponibili e liberamente utilizzabili sotto Apache Licenze v2, pertanto sono ben gradite eventuali Pull Request e/o Issues.
Buon divertimento!