Best practice per l'utilizzo dei container

Last reviewed 2023-02-28 UTC

Questo articolo descrive una serie di best practice per semplificare l'utilizzo dei container. Queste pratiche coprono un'ampia gamma di argomenti, dalla sicurezza al monitoraggio e al logging. Il loro obiettivo è semplificare l'esecuzione delle applicazioni in Google Kubernetes Engine e nei container in generale. Molte delle pratiche qui discusse si ispirano alla metodologia a dodici fattori, che è un'ottima risorsa per la creazione di applicazioni cloud-native.

Queste best practice non hanno la stessa importanza. Ad esempio, potresti eseguire correttamente un carico di lavoro di produzione senza alcuni di questi, ma altri sono fondamentali. In particolare, l'importanza delle best practice relative alla sicurezza è soggettiva. La loro implementazione dipende dall'ambiente e dai vincoli.

Per ottenere il massimo da questo articolo, è necessaria una conoscenza di Docker e Kubernetes. Alcune best practice illustrate qui si applicano anche ai container Windows, ma la maggior parte presuppone che tu stia lavorando con i container Linux. Per consigli sulla creazione di container, consulta Best practice per la creazione di container.

Utilizza i meccanismi di logging nativi dei container

Importanza: ALTA

Come parte integrante della gestione delle applicazioni, i log contengono informazioni preziose sugli eventi che si verificano nell'applicazione. Docker e Kubernetes cercano di semplificare la gestione dei log.

Su un server classico, probabilmente dovrai scrivere i log su un file specifico e gestire la rotazione dei log per evitare di riempire i dischi. Se disponi di un sistema di logging avanzato, potresti inoltrare questi log a un server remoto per centralizzarli.

I container offrono un modo semplice e standardizzato per gestire i log perché è possibile scriverli in stdout e stderr. Docker acquisisce queste righe di log e ti consente di accedervi utilizzando il comando docker logs. Come sviluppatore di applicazioni, non è necessario implementare meccanismi di logging avanzati. Utilizza invece i meccanismi di logging nativi.

L'operatore della piattaforma deve fornire un sistema per centralizzare i log e renderli disponibili per la ricerca. In GKE, questo servizio è fornito da Fluent Bit e Cloud Logging. A seconda della versione del master del tuo cluster GKE, per raccogliere i log viene utilizzato Fluentd o Fluent Bit. A partire da GKE 1.17, i log vengono raccolti utilizzando un agente basato su Fluentbit. I cluster GKE che utilizzano versioni precedenti a GKE 1.17 utilizzano un agente basato su Fluentd. In altre distribuzioni Kubernetes, i metodi comuni includono l'utilizzo di uno stack EFK (Elasticsearch, Fluentd, Kibana).

Diagramma di un sistema di gestione dei log classico in Kubernetes.
Figura 1. Diagramma di un tipico sistema di gestione dei log in Kubernetes

Log JSON

La maggior parte dei sistemi di gestione dei log è in realtà database delle serie temporali che archivia i documenti indicizzati. Questi documenti possono in genere essere forniti in formato JSON. In Cloud Logging e in EFK, una singola riga di log viene archiviata come documento, insieme ad alcuni metadati (informazioni su pod, container, nodo e così via).

Puoi sfruttare questo comportamento registrando direttamente in formato JSON con campi diversi. Puoi quindi cercare nei log in modo più efficace in base a questi campi.

Ad esempio, valuta la possibilità di trasformare il seguente log in formato JSON:

[2018-01-01 01:01:01] foo - WARNING - foo.bar - There is something wrong.

Ecco il log trasformato:

{
  "date": "2018-01-01 01:01:01",
  "component": "foo",
  "subcomponent": "foo.bar",
  "level": "WARNING",
  "message": "There is something wrong."
}

Questa trasformazione consente di cercare facilmente nei log tutti i log a livello di WARNING o tutti i log del sottocomponente foo.bar.

Se decidi di scrivere log in formato JSON, tieni presente che devi scrivere ogni evento su una singola riga affinché venga analizzato correttamente. In realtà, la pagina ha il seguente aspetto:

{"date":"2018-01-01 01:01:01","component":"foo","subcomponent":"foo.bar","level": "WARNING","message": "There is something wrong."}

Come puoi vedere, il risultato è molto meno leggibile di una normale riga di un log. Se decidi di utilizzare questo metodo, assicurati che i tuoi team non facciano molto affidamento sull'ispezione manuale dei log.

Pattern sidecar dell'aggregatore di log

Alcune applicazioni (come Tomcat) non possono essere facilmente configurate per la scrittura di log su stdout e stderr. Poiché queste applicazioni scrivono in diversi file di log su disco, il modo migliore per gestirle in Kubernetes è utilizzare il pattern sidecar per il logging. Un sidecar è un piccolo container che viene eseguito nello stesso pod della tua applicazione. Per un'analisi più dettagliata dei file collaterali, consulta la documentazione ufficiale di Kubernetes.

In questa soluzione, aggiungerai un agente Logging in un container sidecar alla tua applicazione (nello stesso pod) e condividerai un volume emptyDir tra i due container, come mostrato in questo esempio YAML su GitHub. Successivamente, configurerai l'applicazione in modo che scriva i suoi log nel volume condiviso e configurerai l'agente Logging per leggerli e inoltrarli dove necessario.

Poiché non utilizzi i meccanismi di logging nativi o di Kubernetes, in questo pattern devi occuparti della rotazione dei log. Se l'agente Logging non gestisce la rotazione dei log, la rotazione può essere gestita da un altro container sidecar nello stesso pod.

Pattern sidecar per la gestione dei log.
Figura 2. Pattern sidecar per la gestione dei log

Assicurati che i container siano stateless e immutabili

Importanza: ALTA

Se stai provando i container per la prima volta, non considerarli come server tradizionali. Ad esempio, potresti avere la tentazione di aggiornare l'applicazione all'interno di un container in esecuzione o di applicare patch a un container in esecuzione quando sorgono vulnerabilità.

Fondamentalmente, i container non sono progettati per funzionare in questo modo. Sono progettati per essere stateless e immutabili.

Condizione stateless

Stateless significa che qualsiasi stato (dati permanenti di qualsiasi tipo) viene archiviato al di fuori di un container. Le opzioni di archiviazione esterna possono assumere diverse forme, a seconda delle esigenze:

  • Per archiviare i file, consigliamo di utilizzare un archivio di oggetti come Cloud Storage.
  • Per archiviare informazioni come le sessioni utente, consigliamo di utilizzare un archivio chiave-valore esterno a bassa latenza, come Redis o Memcached.
  • Se hai bisogno di archiviazione a livello di blocco (ad esempio per i database), puoi utilizzare un disco esterno collegato al container. Nel caso di GKE, consigliamo di utilizzare i dischi permanenti.

Utilizzando queste opzioni, rimuovi i dati dal container stesso, il che significa che il container può essere arrestato ed eliminato in modo pulito in qualsiasi momento, senza timore di perdere i dati. Se viene creato un nuovo container per sostituire quello precedente, è sufficiente collegare il nuovo container allo stesso datastore o associarlo allo stesso disco.

Immutabilità

Immutabile significa che un container non verrà modificato nel corso della sua vita: nessun aggiornamento, nessuna patch o modifica della configurazione. Se devi aggiornare il codice dell'applicazione o applicare una patch, crei una nuova immagine ed esegui nuovamente il deployment. L'immutabilità rende i deployment più sicuri e ripetibili. Se devi eseguire il rollback, devi semplicemente eseguire nuovamente il deployment dell'immagine precedente. Questo approccio consente di eseguire il deployment della stessa immagine container in tutti i tuoi ambienti, rendendoli il più identici possibile.

Per utilizzare la stessa immagine container in ambienti diversi, ti consigliamo di esternalizzare la configurazione del container (porta di ascolto, opzioni di runtime e così via). In genere, i container sono configurati con variabili di ambiente o file di configurazione montati su un percorso specifico. In Kubernetes, puoi utilizzare sia Secret sia ConfigMaps per inserire le configurazioni nei container come variabili di ambiente o file.

Se devi aggiornare una configurazione, esegui il deployment di un nuovo container (basato sulla stessa immagine) con la configurazione aggiornata.

Esempio di come aggiornare la configurazione di un deployment utilizzando un ConfigMap montato come file di configurazione nei pod.
Figura 3. Esempio di come aggiornare la configurazione di un deployment utilizzando un ConfigMap montato come file di configurazione nei pod

La combinazione di stateless e immutabilità è uno dei punti di forza delle infrastrutture basate su container. Questa combinazione consente di automatizzare i deployment e aumentarne la frequenza e l'affidabilità.

Evita container con privilegi

Importanza: ALTA

In una macchina virtuale o in un server bare-metal, eviti di eseguire le tue applicazioni con l'utente root per un semplice motivo: se l'applicazione viene compromessa, un malintenzionato avrà accesso completo al server. Per lo stesso motivo, evita di utilizzare container con privilegi. Un container con privilegi è un container che ha accesso a tutti i dispositivi della macchina host, aggirando quasi tutte le funzionalità di sicurezza dei container.

Se ritieni di dover utilizzare container con privilegi, prendi in considerazione le seguenti alternative:

  • Fornisci funzionalità specifiche al container tramite l'opzione securityContext di Kubernetes o il flag --cap-add di Docker. Nella documentazione di Docker sono elencate sia le funzionalità abilitate per impostazione predefinita sia quelle che devi abilitare esplicitamente.
  • Se l'applicazione deve modificare le impostazioni dell'host per poter essere eseguita, modifica tali impostazioni in un container sidecar o in un container init. A differenza dell'applicazione, questi container non hanno bisogno di essere esposti al traffico interno o esterno, il che li rende più isolati.
  • Se devi modificare i valori di sistema in Kubernetes, utilizza l'annotazione dedicata.

Puoi vietare i container con privilegi in Kubernetes utilizzando Policy Controller. Nel cluster Kubernetes, non puoi creare pod che violano i criteri configurati utilizzando Policy Controller.

Semplifica il monitoraggio della tua applicazione

Importanza: ALTA

Come il logging, il monitoraggio è parte integrante della gestione delle applicazioni. In molti modi, il monitoraggio delle applicazioni containerizzate segue gli stessi principi che si applicano al monitoraggio delle applicazioni non containerizzate. Tuttavia, poiché le infrastrutture containerizzate tendono a essere altamente dinamiche, con container che vengono creati o eliminati di frequente, non puoi permetterti di riconfigurare il sistema di monitoraggio ogni volta che succede.

Puoi distinguere due principali classi di monitoraggio: black-box e white-box. Il monitoraggio black-box consiste nell'esaminare l'applicazione dall'esterno, come se fossi un utente finale. Il monitoraggio black-box è utile se il servizio finale che vuoi offrire è disponibile e funzionante. Poiché è esterno all'infrastruttura, il monitoraggio black-box non fa differenze tra le infrastrutture tradizionali e quelle containerizzate.

Per monitoraggio white-box si intende l'esame dell'applicazione con un qualche tipo di accesso privilegiato e la raccolta di metriche sul suo comportamento che un utente finale non può visualizzare. Poiché il monitoraggio white-box deve esaminare i livelli più profondi dell'infrastruttura, differisce in modo significativo per le infrastrutture tradizionali e containerizzate.

Un'opzione popolare nella community Kubernetes per il monitoraggio white-box è Prometheus, un sistema in grado di rilevare automaticamente i pod da monitorare. Prometheus esegue lo scraping dei pod per le metriche, si aspetta un formato specifico per loro. Google Cloud offre Google Cloud Managed Service per Prometheus, un servizio che consente di monitorare e creare avvisi sui carichi di lavoro a livello globale senza dover gestire e utilizzare Prometheus manualmente su larga scala. Per impostazione predefinita, Google Cloud Managed Service per Prometheus è configurato in modo da raccogliere le metriche di sistema dai cluster GKE e inviarle a Cloud Monitoring. Per ulteriori informazioni, consulta Osservabilità per GKE.

Per trarre vantaggio da Prometheus o Monitoring, le tue applicazioni devono esporre le metriche. I due metodi seguenti mostrano come fare.

Endpoint HTTP delle metriche

L'endpoint HTTP delle metriche funziona in modo simile agli endpoint menzionati più tardi nella sezione Esporre l'integrità della tua applicazione. Espone le metriche interne dell'applicazione, di solito su un URI /metrics. Una risposta si presenta così:

http_requests_total{method="post",code="200"} 1027
http_requests_total{method="post",code="400"}    3
http_requests_total{method="get",code="200"} 10892
http_requests_total{method="get",code="400"}    97

In questo esempio, http_requests_total è la metrica, method e code sono etichette e il numero più a destra è il valore di questa metrica per queste etichette. Qui, dal suo avvio, l'applicazione ha risposto a una richiesta HTTP GET 97 volte con un codice di errore 400.

La generazione di questo endpoint HTTP è semplificata dalle librerie client di Prometheus esistenti per molti linguaggi. OpenCensus può esportare le metriche anche utilizzando questo formato (tra le tante altre funzionalità). Non esporre questo endpoint alla rete internet pubblica.

La documentazione ufficiale di Prometheus fornisce dettagli più approfonditi sull'argomento. Puoi anche leggere il Capitolo 6 di Site Reliability Engineering per saperne di più sul monitoraggio non consentito.

Pattern sidecar per il monitoraggio

Non tutte le applicazioni possono essere instrumentate con un endpoint HTTP /metrics. Per mantenere il monitoraggio standardizzato, consigliamo di utilizzare il pattern collaterale per esportare le metriche nel formato corretto.

La sezione Pattern sidecar aggregatore di log ha spiegato come utilizzare un container sidecar per gestire i log delle applicazioni. Puoi utilizzare lo stesso pattern per il monitoraggio: il container collaterale ospita un agente di monitoraggio che traduce le metriche così come sono esposte dall'applicazione in un formato e un protocollo riconosciuti dal sistema di monitoraggio globale.

Vediamo un esempio concreto: applicazioni Java ed estensioni di gestione Java (JMX). Molte applicazioni Java espongono le metriche utilizzando JMX. Anziché riscrivere un'applicazione per esporre le metriche nel formato Prometheus, puoi sfruttare jmx_exporter. jmx_exporter raccoglie le metriche da un'applicazione tramite JMX e le espone attraverso un endpoint /metrics che Prometheus può leggere. Questo approccio presenta anche il vantaggio di limitare l'esposizione dell'endpoint JMX, che può essere utilizzato per modificare le impostazioni dell'applicazione.

Pattern sidecar per il monitoraggio.
Figura 4. Pattern sidecar per il monitoraggio

Esponi l'integrità dell'applicazione

Importanza: MEDIA

Per facilitarne la gestione in produzione, un'applicazione deve comunicare il proprio stato al sistema generale: è in esecuzione? È salutare? È pronto a ricevere traffico? Come si comporta?

Kubernetes prevede due tipi di controlli di integrità: probe di attività e probe di idoneità. Ognuno ha un uso specifico, come descritto in questa sezione. Puoi implementare entrambe le opzioni in vari modi (tra cui l'esecuzione di un comando all'interno del container o il controllo di una porta TCP), ma il metodo migliore è utilizzare gli endpoint HTTP descritti in questa best practice. Per ulteriori informazioni su questo argomento, consulta la documentazione di Kubernetes.

Probe di attività

Il modo consigliato per implementare il probe di attività è che la tua applicazione esponga un endpoint HTTP /healthz. Dopo aver ricevuto una richiesta su questo endpoint, l'applicazione deve inviare una risposta "200 OK" se è considerata in stato integro. In Kubernetes, lo stato integro significa che il container non deve essere interrotto o riavviato. Ciò che costituisce uno stato integro varia da un'applicazione all'altra, ma di solito significa quanto segue:

  • L'applicazione è in esecuzione.
  • Le sue dipendenze principali sono soddisfatte (ad esempio può accedere al suo database).

Probe di idoneità

Il modo consigliato per implementare il probe di idoneità è che la tua applicazione esponga un endpoint HTTP /ready. Quando riceve una richiesta su questo endpoint, l'applicazione deve inviare una risposta "200 OK" se è pronta a ricevere traffico. Per "Pronto per ricevere traffico" si intende quanto segue:

  • L'applicazione è integro.
  • Tutti i potenziali passaggi di inizializzazione sono stati completati.
  • Qualsiasi richiesta valida inviata all'applicazione non restituisce un errore.

Kubernetes utilizza il probe di idoneità per orchestrare il deployment della tua applicazione. Se aggiorni un deployment, Kubernetes esegue un aggiornamento in sequenza dei pod appartenenti a tale deployment. Il criterio di aggiornamento predefinito prevede l'aggiornamento di un pod alla volta: Kubernetes attende che il nuovo pod sia pronto (come indicato dal probe di idoneità) prima di aggiornare il successivo.

Evita di essere eseguito come root

Importanza: MEDIA

I container forniscono l'isolamento: con le impostazioni predefinite, un processo all'interno di un container Docker non può accedere alle informazioni dalla macchina host o da altri container collocati. Tuttavia, poiché i container condividono il kernel della macchina host, l'isolamento non è così completo come lo è con le macchine virtuali, come spiega questo post del blog. Un utente malintenzionato potrebbe trovare vulnerabilità ancora sconosciute (in Docker o nello stesso kernel Linux) che potrebbero consentire all'utente malintenzionato di scappare da un container. Se l'utente malintenzionato rileva una vulnerabilità e il tuo processo è in esecuzione come root all'interno del container, otterrà l'accesso root alla macchina host.

A sinistra: le macchine virtuali utilizzano hardware virtualizzato.
A destra: le applicazioni nei container utilizzano il kernel host.
Figura 5. A sinistra, le macchine virtuali utilizzano hardware virtualizzato. A destra, le applicazioni nei container utilizzano il kernel host.

Per evitare questa possibilità, la best practice prevede di non eseguire i processi come root all'interno dei container. Puoi applicare questo comportamento in Kubernetes utilizzando Policy Controller. Quando crei un pod in Kubernetes, utilizza l'opzione runAsUser per specificare l'utente Linux che esegue il processo. Questo approccio sostituisce l'istruzione USER del Dockerfile.

In realtà, ci sono delle sfide. Il processo principale di molti pacchetti software molto noti viene eseguito come root. Se vuoi evitare l'esecuzione come root, progetta il container in modo che possa essere eseguito con un utente sconosciuto e senza privilegi. Questa pratica spesso significa che devi modificare le autorizzazioni per varie cartelle. In un container, se segui la best practice relativa alla singola applicazione per container e esegui una singola applicazione con un singolo utente, preferibilmente non root, puoi concedere a tutti gli utenti le autorizzazioni di scrittura per le cartelle e i file che devono essere scritti e rendere tutte le altre cartelle e file scrivibili solo per root.

Un modo semplice per verificare se il container è conforme a questa best practice è eseguirlo localmente con un utente casuale e verificare se funziona correttamente. Sostituisci [YOUR_CONTAINER] con il nome del container.

docker run --user $((RANDOM+1)) [YOUR_CONTAINER]

Se il container necessita di un volume esterno, puoi configurare l'opzione fsGroup Kubernetes per assegnare la proprietà di questo volume a un gruppo Linux specifico. Questa configurazione risolve il problema della proprietà dei file esterni.

Se il processo viene eseguito da un utente senza privilegi, non potrà essere associato a porte inferiori alla 1024. Di solito non è un problema, perché puoi configurare Kubernetes Services per instradare il traffico da una porta a un'altra. Ad esempio, puoi configurare un server HTTP per l'associazione alla porta 8080 e reindirizzare il traffico dalla porta 80 con un servizio Kubernetes.

Scegli attentamente la versione dell'immagine

Importanza: MEDIA

Quando utilizzi un'immagine Docker, sia come immagine di base in un Dockerfile sia come immagine di cui è stato eseguito il deployment in Kubernetes, devi scegliere il tag dell'immagine che stai utilizzando.

La maggior parte delle immagini pubbliche e private segue un sistema di tagging simile a quello descritto in Best practice per la creazione di container. Se l'immagine utilizza un sistema simile al controllo delle versioni semantico, devi prendere in considerazione alcune specifiche di tagging.

Soprattutto, il tag "più recente" può essere spostato spesso da un'immagine all'altra. La conseguenza è che non puoi fare affidamento su questo tag per build prevedibili o riproducibili. Ad esempio, prendi il seguente Dockerfile:

FROM debian:latest
RUN apt-get -y update && \ apt-get -y install nginx

Se crei un'immagine da questo Dockerfile due volte, in momenti diversi, potresti avere due versioni diverse di Debian e NGINX. Considera invece questa versione riveduta:

FROM debian:11.6
RUN apt-get -y update && \ apt-get -y install nginx

Utilizzando un tag più preciso, ti assicuri che l'immagine risultante sia sempre basata su una versione secondaria specifica di Debian. Poiché una specifica versione Debian fornisce anche una specifica versione NGINX, hai un controllo molto maggiore sull'immagine che viene creata.

Questo risultato non è vero solo in fase di build, ma anche in fase di esecuzione. Se fai riferimento al tag "più recente" in un manifest Kubernetes, non hai alcuna garanzia della versione che verrà utilizzata da Kubernetes. Nodi diversi del cluster potrebbero estrarre lo stesso tag "più recente" in momenti diversi. Se il tag è stato aggiornato in un determinato punto tra i pull, puoi ottenere nodi diversi che eseguono immagini diverse (tutte taggate con "più recente" in un determinato momento).

Idealmente, dovresti sempre utilizzare un tag immutabile nella riga FROM. Questo tag consente di avere build riproducibili. Tuttavia, esistono alcuni compromessi in termini di sicurezza: più blocchi la versione da utilizzare, meno saranno automatizzate le patch di sicurezza nelle tue immagini. Se l'immagine che stai usando utilizza un corretto controllo delle versioni semantico, la versione della patch (ovvero la "Z" in "X.Y.Z") non deve avere modifiche incompatibili con le versioni precedenti: puoi utilizzare il tag "X.Y" e ricevere automaticamente le correzioni di bug.

Immagina un software chiamato "SuperSoft". Supponiamo che il processo di sicurezza di SuperSoft sia correggere le vulnerabilità tramite una nuova versione di patch. Vuoi personalizzare SuperSoft e hai scritto il seguente Dockerfile:

FROM supersoft:1.2.3
RUN a-command

Dopo un po', il fornitore scopre una vulnerabilità e rilascia la versione 1.2.4 di SuperSoft per risolvere il problema. In questo caso, spetta a te rimanere al corrente sulle patch di SuperSoft e aggiornare il Dockerfile di conseguenza. Se invece utilizzi FROM supersoft:1.2 nel Dockerfile, la nuova versione viene estratta automaticamente.

Alla fine, devi esaminare attentamente il sistema di tagging di ogni immagine esterna che utilizzi, decidere quanto ti fidi delle persone che creano quelle immagini e decidere quale tag utilizzare.

Passaggi successivi

Esplora le architetture di riferimento, i diagrammi e le best practice su Google Cloud. Dai un'occhiata al nostro Cloud Architecture Center.