Un approccio migliore alla distribuzione continua di Google Cloud
Pubblicato: 2022-03-11La distribuzione continua (CD) è la pratica di distribuire automaticamente il nuovo codice in produzione. La maggior parte dei sistemi di distribuzione continua convalida che il codice da distribuire sia fattibile eseguendo test unitari e funzionali e, se tutto sembra a posto, la distribuzione viene implementata. Il rollout stesso di solito avviene in più fasi per poter eseguire il rollback se il codice non si comporta come previsto.
Non mancano i post del blog su come implementare la tua pipeline di CD utilizzando vari strumenti come lo stack AWS, lo stack di Google Cloud, la pipeline Bitbucket, ecc. Ma trovo che la maggior parte di essi non si adatti alla mia idea di cosa sia una buona pipeline di CD dovrebbe essere simile a: uno che compila per primo, e testa e distribuisce solo quel singolo file compilato.
In questo articolo creerò una pipeline di distribuzione continua basata su eventi che prima compila e poi esegue i test sul nostro artefatto finale di distribuzione. Questo non solo rende i risultati dei nostri test più affidabili, ma rende anche la pipeline del CD facilmente estendibile. Sembrerebbe qualcosa del genere:
- Viene effettuato un commit nel nostro repository di origine.
- Ciò attiva una build dell'immagine associata.
- I test vengono eseguiti sull'artefatto costruito.
- Se tutto sembra a posto, l'immagine viene distribuita alla produzione.
Questo articolo presuppone almeno una familiarità passeggera con Kubernetes e la tecnologia dei container, ma se non hai familiarità o potresti utilizzare un aggiornamento, consulta Cos'è Kubernetes? Una guida alla containerizzazione e alla distribuzione.
Il problema con la maggior parte delle configurazioni di CD
Ecco il mio problema con la maggior parte delle pipeline di CD: di solito fanno tutto nel file di build. La maggior parte dei post del blog che ho letto su questo avrà alcune variazioni della seguente sequenza in qualsiasi file di build abbiano ( cloudbuild.yaml
per Google Cloud Build, bitbucket-pipeline.yaml
per Bitbucket).
- Esegui test
- Costruisci immagine
- Invia l'immagine al repository del contenitore
- Aggiorna l'ambiente con una nuova immagine
Non stai eseguendo i test sul tuo manufatto finale.
Facendo le cose in questo ordine, esegui i tuoi test. Se hanno successo, costruisci l'immagine e prosegui con il resto della pipeline. Cosa succede se il processo di compilazione modifica la tua immagine in modo tale che i test non superino più? A mio parere, dovresti iniziare producendo un artefatto (l'immagine del contenitore finale) e questo artefatto non dovrebbe cambiare tra la compilazione e il momento in cui viene distribuito alla produzione. Ciò garantisce che i dati in tuo possesso su detto artefatto (risultati del test, dimensioni, ecc.) siano sempre validi.
Il tuo ambiente di costruzione ha le "chiavi del regno".
Utilizzando l'ambiente di compilazione per distribuire l'immagine nello stack di produzione, gli stai effettivamente consentendo di modificare l'ambiente di produzione. Considero questo come una cosa molto negativa perché chiunque abbia accesso in scrittura al tuo repository di origine ora può fare tutto ciò che vuole nel tuo ambiente di produzione.
È necessario eseguire nuovamente l'intera pipeline se l'ultimo passaggio non è riuscito.
Se l'ultimo passaggio non riesce (ad esempio, a causa di un problema di credenziali) devi eseguire nuovamente l'intera pipeline, occupando tempo e altre risorse che potrebbero essere spese meglio per qualcos'altro.
Questo mi porta al mio ultimo punto:
I tuoi passi non sono indipendenti.
In un senso più generale, avere passaggi indipendenti ti consente di avere più flessibilità nella tua pipeline. Supponiamo che tu voglia aggiungere test funzionali alla tua pipeline. Avendo i tuoi passaggi in un file di build, devi fare in modo che il tuo ambiente di build giri un ambiente di test funzionale ed esegua i test in esso (molto probabilmente in sequenza). Se i tuoi passaggi fossero indipendenti, potresti avviare sia i test unitari che i test funzionali dall'evento "costruzione dell'immagine". Avrebbero quindi eseguito in parallelo nel proprio ambiente.
Il mio CD ideale per la configurazione
A mio avviso, un modo migliore per affrontare questo problema sarebbe avere una serie di passaggi indipendenti tutti collegati tra loro da un meccanismo di eventi.
Questo ha diversi vantaggi rispetto al metodo precedente:
Puoi intraprendere diverse azioni indipendenti su eventi diversi.
Come affermato in precedenza, la creazione di successo di una nuova immagine pubblicherebbe semplicemente un evento di "costruzione riuscita". A nostra volta, possiamo far eseguire diverse cose quando viene attivato questo evento. Nel nostro caso, inizieremmo i test unitari e funzionali. Puoi anche pensare a cose come avvisare lo sviluppatore quando viene attivato un evento di compilazione non riuscita o se i test non vengono superati.
Ogni ambiente ha il proprio insieme di diritti.
Facendo in modo che ogni passaggio avvenga nel proprio ambiente, eliminiamo la necessità che un unico ambiente abbia tutti i diritti. Ora l'ambiente di compilazione può solo compilare, l'ambiente di test può solo testare e l'ambiente di distribuzione può solo eseguire la distribuzione. Questo ti permette di essere sicuro che, una volta che la tua immagine è stata creata, non cambierà. L'artefatto che è stato prodotto è quello che finirà nel tuo stack di produzione. Consente inoltre un controllo più semplice di quale passaggio della pipeline sta facendo cosa poiché è possibile collegare un set di credenziali a un passaggio.
C'è più flessibilità.
Vuoi inviare un'e-mail a qualcuno su ogni build di successo? Basta aggiungere qualcosa che reagisca a quell'evento e invii un'e-mail. È facile: non è necessario modificare il codice di build e non è necessario codificare l'e-mail di qualcuno nel repository di origine.
I tentativi sono più facili.
Avere passaggi indipendenti significa anche che non è necessario riavviare l'intera pipeline se un passaggio non riesce. Se la condizione di errore è temporanea o è stata risolta manualmente, puoi semplicemente ripetere il passaggio non riuscito. Ciò consente una pipeline più efficiente. Quando una fase di compilazione richiede diversi minuti, è bene non dover ricostruire l'immagine solo perché hai dimenticato di concedere all'ambiente di distribuzione l'accesso in scrittura al tuo cluster.
Implementazione della distribuzione continua di Google Cloud
Google Cloud Platform dispone di tutti gli strumenti necessari per realizzare un tale sistema in poco tempo e con pochissimo codice.
La nostra applicazione di prova è una semplice applicazione Flask che serve solo un pezzo di testo statico. Questa applicazione viene distribuita in un cluster Kubernetes che la serve a Internet più ampio.
Implementerò una versione semplificata della pipeline che ho introdotto in precedenza. In pratica ho rimosso i passaggi del test, quindi ora appare così:
- Viene eseguito un nuovo commit nel repository di origine
- Questo attiva una build dell'immagine. Se ha esito positivo, viene inviato al repository del contenitore e un evento viene pubblicato in un argomento Pub/Sub
- Un piccolo script viene sottoscritto su quell'argomento e controlla i parametri dell'immagine: se corrispondono a quanto richiesto, viene distribuito nel cluster Kubernetes.
Ecco una rappresentazione grafica della nostra pipeline.
Il flusso è il seguente:
- Qualcuno si impegna nel nostro repository.
- Ciò attiva una build cloud che crea un'immagine Docker basata sul repository di origine.
- La build cloud invia l'immagine al repository del contenitore e pubblica un messaggio nel cloud pub/sub.
- Questo attiva una funzione cloud che controlla i parametri del messaggio pubblicato (stato della build, nome dell'immagine creata, ecc.).
- Se i parametri sono corretti, la funzione cloud aggiorna una distribuzione Kubernetes con la nuova immagine.
- Kubernetes distribuisce nuovi contenitori con la nuova immagine.
Codice sorgente
Il nostro codice sorgente è un'app Flask molto semplice che serve solo del testo statico. Ecco la struttura del nostro progetto:

├── docker │ ├── Dockerfile │ └── uwsgi.ini ├── k8s │ ├── deployment.yaml │ └── service.yaml ├── LICENSE ├── Pipfile ├── Pipfile.lock └── src └── main.py
La directory Docker contiene tutto il necessario per creare l'immagine Docker. L'immagine si basa sull'immagine uWSGI e Nginx e installa semplicemente le dipendenze e copia l'app nel percorso corretto.
La directory k8s contiene la configurazione di Kubernetes. È costituito da un servizio e una distribuzione. La distribuzione avvia un contenitore basato sull'immagine creata da Dockerfile . Il servizio avvia quindi un servizio di bilanciamento del carico che dispone di un indirizzo IP pubblico e reindirizza ai contenitori dell'app.
Creazione nuvola
La configurazione della build cloud stessa può essere eseguita tramite la console cloud o la riga di comando di Google Cloud. Ho scelto di utilizzare la console cloud.
Qui, costruiamo un'immagine per qualsiasi commit su qualsiasi ramo, ma potresti avere immagini diverse per lo sviluppo e la produzione, ad esempio.
Se la build ha esito positivo, la build cloud pubblicherà l'immagine nel registro contenitori da sola. Pubblicherà quindi un messaggio nell'argomento pub/sub build cloud.
La build cloud pubblica anche messaggi quando una build è in corso e quando una non riesce, quindi potresti anche fare in modo che le cose reagiscano a quei messaggi.
La documentazione per le notifiche pub/sub della build cloud è qui e il formato del messaggio è disponibile qui
Cloud Pub/Sub
Se guardi nella scheda pub/sub cloud nella console cloud, vedrai che la build cloud ha creato un argomento chiamato build cloud. È qui che la build cloud pubblica i suoi aggiornamenti di stato.
Funzione cloud
Quello che faremo ora è creare una funzione cloud che venga attivata su qualsiasi messaggio pubblicato nell'argomento cloud-builds. Ancora una volta, puoi utilizzare la console cloud o l'utilità della riga di comando di Google Cloud. Quello che ho fatto nel mio caso è che utilizzo la build cloud per distribuire la funzione cloud ogni volta che viene modificata.
Il codice sorgente per la funzione cloud è qui.
Diamo prima un'occhiata al codice che distribuisce questa funzione cloud:
steps: - name: 'gcr.io/cloud-builders/gcloud' id: 'test' args: ['functions', 'deploy', 'new-image-trigger', '--runtime=python37', '--trigger-topic=cloud-builds', '--entry-point=onNewImage', '--region=us-east1', '--source=https://source.developers.google.com/projects/$PROJECT_ID/repos/$REPO_NAME']
Qui utilizziamo l'immagine Google Cloud Docker. Ciò consente di eseguire facilmente i comandi GCcloud. Quello che stiamo eseguendo è l'equivalente di eseguire il seguente comando direttamente da un terminale:
gcloud functions deploy new-image-trigger --runtime=python37 --trigger-topic=cloud-builds --entry-point=onNewImage --region=us-east1 --source=https://source.developers.google.com/projects/$PROJECT_ID/repos/$REPO_NAME
Chiediamo a Google Cloud di distribuire una nuova funzione cloud (o di sostituirla se esiste già una funzione con quel nome in quella regione) che utilizzerà il runtime Python 3.7 e verrà attivata da nuovi messaggi nell'argomento cloud-builds. Diciamo anche a Google dove trovare il codice sorgente per quella funzione (qui PROJECT_ID e REPO_NAME sono variabili di ambiente che vengono impostate dal processo di compilazione). Gli diciamo anche quale funzione chiamare come punto di ingresso.
Come nota a margine, affinché funzioni, devi fornire al tuo account di servizio cloudbuild sia lo "sviluppatore di funzioni cloud" che "utente dell'account di servizio" in modo che possa distribuire la funzione cloud.
Ecco alcuni frammenti commentati del codice della funzione cloud
I dati dell'entrypoint conterranno il messaggio ricevuto sull'argomento pub/sub.
def onNewImage(data, context):
Il primo passaggio consiste nell'ottenere le variabili per quella specifica distribuzione dall'ambiente (le abbiamo definite modificando la funzione cloud nella console cloud.
project = os.environ.get('PROJECT') zone = os.environ.get('ZONE') cluster = os.environ.get('CLUSTER') deployment = os.environ.get('DEPLOYMENT') deploy_image = os.environ.get('IMAGE') target_container = os.environ.get('CONTAINER')
Salteremo la parte in cui controlliamo che la struttura del messaggio sia quella che ci aspettiamo e confermiamo che la compilazione ha avuto successo e ha prodotto un artefatto dell'immagine.
Il passaggio successivo consiste nell'assicurarsi che l'immagine creata sia quella che si desidera distribuire.
image = decoded_data['results']['images'][0]['name'] image_basename = image.split('/')[-1].split(':')[0] if image_basename != deploy_image: logging.error(f'{image_basename} is different from {deploy_image}') return
Ora otteniamo un client Kubernetes e recuperiamo la distribuzione che vogliamo modificare
v1 = get_kube_client(project, zone, cluster) dep = v1.read_namespaced_deployment(deployment, 'default') if dep is None: logging.error(f'There was no deployment named {deployment}') return
Infine, correggiamo la distribuzione con la nuova immagine; Kubernetes si occuperà di implementarlo.
for i, container in enumerate(dep.spec.template.spec.containers): if container.name == target_container: dep.spec.template.spec.containers[i].image = image logging.info(f'Updating to {image}') v1.patch_namespaced_deployment(deployment, 'default', dep)
Conclusione
Questo è un esempio molto semplice di come mi piace che le cose siano architettate in una pipeline di CD. Potresti avere più passaggi semplicemente modificando quale evento pub/sub attiva cosa.
Ad esempio, è possibile eseguire un contenitore che esegue i test all'interno dell'immagine e pubblica un evento in caso di esito positivo e un altro in caso di errore e reagire a quelli aggiornando una distribuzione o inviando avvisi a seconda del risultato.
La pipeline che abbiamo creato è piuttosto semplice, ma potresti scrivere altre funzioni cloud per altre parti (ad esempio, una funzione cloud che invierebbe un'e-mail allo sviluppatore che ha eseguito il commit del codice che ha interrotto i tuoi unit test).
Come puoi vedere, il nostro ambiente di compilazione non può modificare nulla nel nostro cluster Kubernetes e il nostro codice di distribuzione (la funzione cloud) non può modificare l'immagine che è stata creata. La nostra separazione dei privilegi sembra buona e possiamo dormire sonni tranquilli sapendo che uno sviluppatore canaglia non farà crollare il nostro cluster di produzione. Inoltre possiamo fornire ai nostri sviluppatori più orientati alle operazioni l'accesso al codice della funzione cloud in modo che possano risolverlo o migliorarlo.
In caso di domande, commenti o miglioramenti, non esitare a contattarci nei commenti qui sotto.