IL BLOG DEGLI RHCE ITALIANI

Gli Init Containers in Kubernetes

Gianni Salinetti

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:

 

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:

 

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:

 

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:

 

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:

 

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:

 

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:

 

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:

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:

 

Nel container magicball-server definiamo le stesse variabili oltre ad una in più:

 

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!

Info about author

Gianni Salinetti

Prenota subito il tuo corso ufficiale Red Hat

GUARDA I CORSI