Vi invito a leggere la trascrizione della presentazione di Aleksandr Sigachev di Inventos "Il processo di sviluppo e testing con Docker + Gitlab CI"
Chi inizia a implementare il processo di sviluppo e testing basato su Docker + Gitlab CI spesso si pone domande fondamentali. Da dove cominciare? Come organizzare? Come testare?
Questa presentazione è utile perché spiega in modo strutturato il processo di sviluppo e testing utilizzando Docker e Gitlab CI. La presentazione risale al 2017. Penso che da questo documento si possano trarre le basi, la metodologia, l'idea e l’esperienza d'uso.

Chi è interessato, prosegua oltre.
Mi chiamo Aleksandr Sigachev. Lavoro per la società Inventos. Condividerò la mia esperienza nell'utilizzo di Docker e come lo stiamo implementando gradualmente nei progetti dell'azienda.
Tema della presentazione: Il processo di sviluppo utilizzando Docker e Gitlab CI.

Questa è la mia seconda presentazione su Docker. Al momento della mia prima presentazione, utilizzavamo Docker solo in fase di sviluppo sui computer degli sviluppatori. Il numero di dipendenti che utilizzavano Docker era di circa 2-3 persone. Gradualmente abbiamo accumulato esperienza e abbiamo fatto qualche passo avanti. Ecco il link al nostro .
Cosa ci sarà in questa presentazione? Condivideremo la nostra esperienza su quali ostacoli abbiamo incontrato e come abbiamo risolto diversi problemi. Non è stato sempre facile, ma ci ha permesso di andare avanti.
Il nostro motto: containerizza tutto ciò che ci passa per le mani.

Quali problemi risolviamo?
Quando ci sono più team in azienda, il programmatore diventa una risorsa condivisa. Ci sono momenti in cui il programmatore viene strappato da un progetto e assegnato per un certo tempo a un altro progetto.
Per permettere al programmatore di integrarsi rapidamente, è necessario che scarichi il codice sorgente del progetto e avvii il prima possibile un ambiente che gli permetta di procedere affrontando le attività di quel progetto.
Di solito, se si inizia da zero, la documentazione del progetto è scarsa. Le informazioni su come configurare sono disponibili solo da chi è già presente da tempo. I collaboratori generalmente configurano il proprio ambiente di lavoro in uno o due giorni. Per accelerare questo processo, abbiamo utilizzato Docker.
Il motivo successivo è la standardizzazione delle impostazioni in Development. Dalla mia esperienza, gli sviluppatori tendono sempre a prendere l'iniziativa. In un caso su cinque si introduce un dominio personalizzato, ad esempio vasya.dev. Vicino c'è il vicino Petya, che ha il dominio petya.dev. Stanno sviluppando un sito o un componente del sistema utilizzando questo nome di dominio.
Quando il sistema si espande e questi nomi di dominio iniziano a comparire nelle configurazioni, si verifica un conflitto negli ambienti di Development e viene sovrascritto il percorso del sito.
Lo stesso accade con le impostazioni del database. Qualcuno non si preoccupa della sicurezza e lavora con la password vuota di root. Qualcun altro, durante l'installazione, ha trovato che MySQL richiedeva una password e la password era semplice, come 123. Spesso accade che la configurazione del database cambi in continuazione a seconda del commit dello sviluppatore. Qualcuno ha corretto, qualcun altro no. Ci sono stati stratagemmi in cui portavamo qualche configurazione di test in .gitignore ogni sviluppatore doveva installare un database. Questo complicava il processo di avvio. Oltre a tutto ciò, è importante tenere a mente il database. Il database deve essere inizializzato, è necessario specificare una password, un nome utente, creare una tabella e così via.
Un'altra problematica riguarda le diverse versioni delle librerie. Spesso gli sviluppatori lavorano su progetti diversi. C'è un progetto legacy che è stato avviato cinque anni fa (dal 2017 - nota del curatore). All'inizio si partì con MySQL 5.5. Ci sono anche progetti moderni, dove cerchiamo di implementare versioni più recenti di MySQL, come 5.7 o superiore (nel 2017 - nota del curatore).
Chi lavora con MySQL sa che queste librerie portano con sé delle dipendenze. È piuttosto complicato avviare due database insieme. Almeno, è problematico connettere i vecchi client a un nuovo database. Questo, a sua volta, crea diverse problematiche.
Il problema successivo si verifica quando uno sviluppatore lavora su una macchina locale, utilizzando risorse locali, file locali e RAM locale. Tutta l'interazione durante lo sviluppo delle soluzioni avviene all'interno di un unico sistema. Un esempio è rappresentato dalla situazione in cui abbiamo 3 server backend in produzione, mentre lo sviluppatore salva i file nella directory radice, da cui nginx preleva i file per rispondere alle richieste. Quando questo codice viene trasferito in produzione, si scopre che il file è presente su uno dei 3 server.
Attualmente si sta sviluppando la direzione dei microservizi. Quando suddividiamo le nostre grandi applicazioni in piccoli componenti che interagiscono tra loro. Questo consente di scegliere tecnologie specifiche per il particolare stack di compiti. Inoltre, permette di separare il lavoro e le aree di responsabilità tra gli sviluppatori.
Uno sviluppatore frontend, lavorando in JS, ha un'influenza minima sul backend. Lo sviluppatore backend, a sua volta, sta sviluppando, nel nostro caso, su Ruby on Rails e non interferisce con il frontend. L'interazione avviene tramite API.
Come bonus, grazie a Docker siamo riusciti a gestire le risorse su Staging. Ogni progetto, a causa della sua specificità, richiedeva configurazioni particolari. Fisicamente, era necessario dedicare un server virtuale per ciascun progetto e configurarli separatamente, oppure condividere un ambiente variabile, potendo influenzare reciprocamente i progetti in base alle versioni delle librerie.

Strumenti. Cosa utilizziamo?
- Il Docker stesso. Nel Dockerfile vengono descritte le dipendenze di un'applicazione.
- Docker-compose è il collante che unisce alcune delle nostre applicazioni Docker.
- Utilizziamo GitLab per l'archiviazione del codice sorgente.
- Utilizziamo GitLab-CI per l'integrazione continua.

La presentazione è composta da due parti.
La prima parte parlerà di come abbiamo avviato Docker sulle macchine degli sviluppatori.
La seconda parte tratterà di come interagiamo con GitLab, come eseguiamo i test e come distribuiamo su Staging.

Docker è una tecnologia che consente di descrivere i componenti necessari utilizzando un approccio dichiarativo. Ecco un esempio di Dockerfile. Qui dichiariamo di ereditare dall'immagine Docker ufficiale Ruby:2.3.0, che contiene Ruby versione 2.3. Installiamo le librerie di compilazione necessarie e NodeJS. Descriviamo che creiamo una directory. /appAssegniamo alla directory app la directory di lavoro. In questa directory posizioniamo il Gemfile e il Gemfile.lock minimi necessari. Poi eseguiamo la build dei progetti, che installano le dipendenze di questo'immagine. Indichiamo che il contenitore sarà pronto ad ascoltare sulla porta esterna 3000. L'ultima riga è il comando che avvia effettivamente la nostra applicazione. Se eseguiamo il comando di avvio del progetto, l'app tenterà di eseguire e lancerà il comando specificato.

Questo è un esempio minimo di file docker-compose. In questo caso mostriamo come si connettono due contenitori. Si tratta direttamente del servizio del database e del servizio web. Le nostre applicazioni web richiedono spesso come backend un database per memorizzare i dati. Poiché utilizziamo MySQL, il nostro esempio è con MySQL, ma non c'è nulla che ci impedisca di utilizzare un altro database (PostgreSQL, Redis).
Prendiamo l'immagine MySQL 5.7.14 senza modifiche da una fonte ufficiale di Docker Hub. L'immagine che serve alla nostra applicazione web la creiamo dalla directory corrente. Durante il primo avvio, genera l'immagine per noi. Dopo di che, avvia il comando che eseguiamo qui. Se torniamo indietro, vediamo che è stato definito un comando di avvio tramite Puma. Puma è un servizio scritto in Ruby. Nel secondo caso, lo sovrascriviamo. Questo comando può essere arbitrario in base alle nostre esigenze o compiti.
Definiamo anche il port forwarding dalla macchina host dello sviluppatore alla porta 3000 del contenitore. Questo avviene automaticamente tramite iptables e il meccanismo incorporato in Docker.
Lo sviluppatore può continuare a utilizzare qualsiasi indirizzo IP disponibile, ad esempio, 127.0.0.1 come IP locale o l'indirizzo IP esterno della macchina.
L'ultima riga indica che il container web dipende dal container db. Quando avviamo il container web, prima docker-compose avvierà il nostro database. Solo dopo l'avvio del database (in realtà, dopo l'avvio del container! La prontezza del DB non è garantita) verrà avviata la nostra applicazione, il backend.
Questo consente di evitare errori quando il database non è attivo e permette di risparmiare risorse quando interrompiamo il container del database, liberando così risorse per altri progetti.

Cosa comporta l'uso della containerizzazione del database nel progetto. Fissiamo la versione di MySQL per tutti gli sviluppatori. Questo aiuta a evitare alcuni errori che possono sorgere quando ci sono discrepanze tra le versioni, quando cambia la sintassi, la configurazione, le impostazioni predefinite. Permette di indicare hostname comuni per il database, login e password. Ci allontaniamo così dal caos di nomi e conflitti nei file di configurazione che esistevano precedentemente.
Abbiamo la possibilità di utilizzare una configurazione più ottimale per l'ambiente di sviluppo, che sarà diversa da quella predefinita. MySQL è impostato di default per macchine meno potenti e le sue prestazioni, così, sono molto basse fin dall'inizio.

Docker consente di utilizzare l'interprete Python, Ruby, NodeJS, PHP con la versione necessaria. Sgombriamo il campo dalla necessità di utilizzare un gestore di versioni. In passato, per Ruby, utilizzavamo un pacchetto rpm che permetteva di cambiare versione a seconda del progetto. Inoltre, grazie al container Docker, possiamo migrare il codice e versionarlo insieme alle sue dipendenze senza problemi di compatibilità. Non ci saranno dubbi riguardo alla versione sia dell'interprete che del codice. Per aggiornare la versione, è sufficiente abbattere il vecchio container e avviarne uno nuovo. Se qualcosa va storto, possiamo fermare il nuovo container e riavviare quello vecchio.
Dopo la costruzione dell'immagine, i container sia in sviluppo che in produzione saranno identici. Questo è particolarmente rilevante per installazioni di grandi dimensioni.
Per il frontend utilizziamo JavaScript e NodeJS.
Attualmente, l'ultimo progetto è su ReactJS. Gli sviluppatori hanno avviato tutti i container e lavorato utilizzando l'hot-reload.
Successivamente, viene avviato un task per la compilazione di JavaScript e il codice generato in statico viene servito tramite nginx, risparmiando risorse.

Qui ho presentato lo schema del nostro ultimo progetto.
Quali problemi abbiamo risolto? Abbiamo avuto la necessità di costruire un sistema con cui interagirei dispositivi mobili. Ricevono dati. Una delle possibilità è inviare notifiche push a questo dispositivo.
Cosa abbiamo fatto per questo?
Abbiamo suddiviso l'applicazione in componenti come: parte admin in JS, backend che opera tramite un'interfaccia REST con Ruby on Rails. Il backend interagisce con il database. I risultati generati vengono restituiti al cliente. L'admin interagisce con il backend e il database attraverso l'interfaccia REST.
Avevamo anche la necessità di inviare notifiche push. In precedenza, avevamo un progetto in cui era stato realizzato un meccanismo per la consegna di notifiche su piattaforme mobili.
Abbiamo sviluppato questo schema: l'operatore dal browser interagisce con l'admin, l'admin interagisce con il backend, e viene impostato un task per inviare notifiche push.
Le notifiche push interagiscono con un altro componente, implementato su NodeJS.
Le code delle notifiche viene costruito attraverso delle code, che poi vengono inviate secondo il loro meccanismo.
Qui sono illustrate due basi di dati. Attualmente, utilizzando Docker, abbiamo due basi di dati indipendenti, che non sono collegate tra loro. A parte il fatto che condividono una rete virtuale, i dati fisici sono memorizzati in diverse directory sulla macchina dello sviluppatore.

Lo stesso vale in numeri. Qui è importante il riutilizzo del codice.
Se prima parlavamo del riutilizzo del codice sotto forma di librerie, in questo esempio il nostro servizio per le notifiche push viene riutilizzato come server completo. Fornisce un'API. E la nostra nuova applicazione interagisce con essa.
A quel tempo utilizzavamo la versione 4 di NodeJS. Attualmente (nel 2017 — nota della redazione) nei nuovi progetti utilizziamo la versione 7 di NodeJS. Non ci sono problemi ad incorporare nuove versioni delle librerie nei nuovi componenti.
Se necessario, si può procedere a un refactoring e aggiornare la versione di NodeJS per il servizio delle notifiche push.
Se possiamo mantenere la compatibilità con l'API, potremo sostituirlo in altri progetti utilizzati in precedenza.

Cosa serve per aggiungere Docker? Aggiungiamo nel nostro repository un Dockerfile che descrive le dipendenze necessarie. In questo esempio, i componenti sono suddivisi logicamente. Questa è la configurazione minima per uno sviluppatore backend.
Quando creiamo un nuovo progetto, creiamo un Dockerfile e descriviamo l'ecosistema necessario (Python, Ruby, NodeJS). In docker-compose descriviamo le dipendenze necessarie, come il database. Specifichiamo di quale versione di database abbiamo bisogno e dove conservare i dati.
Utilizziamo un terzo contenitore separato con nginx per servire i file statici. È prevista la possibilità di caricare immagini. Il backend le colloca in un volume preparato in anticipo, che è anche montato nel contenitore con nginx, il quale serve i file statici.
Per archiviare la configurazione di nginx e mysql, abbiamo aggiunto una cartella Docker in cui conserviamo i file di configurazione necessari. Quando lo sviluppatore esegue il git clone del repository sulla propria macchina, ottiene già un progetto pronto per lo sviluppo locale. Non ci si pone la domanda su quale porta o quali impostazioni applicare.

Successivamente, abbiamo diversi componenti: admin, inform-API, push-notifiche.
Per avviare tutto ciò, abbiamo creato un altro repository chiamato dockerized-app. Al momento utilizziamo diversi repository per ciascun componente. Questi si differenziano logicamente: in GitLab appare come una cartella, mentre sulla macchina dello sviluppatore si trova una cartella specifica per il progetto. A un livello inferiore ci sono i componenti che verranno uniti.

Questo è un esempio del contenuto di dockerized-app. Qui includiamo anche la cartella Docker, in cui inseriamo le configurazioni necessarie per le interazioni tra i vari componenti. È presente un README.md, che descrive brevemente come avviare il progetto.
Abbiamo applicato due file docker-compose. Questo è stato fatto per avere la possibilità di avviare in modo graduale. Quando uno sviluppatore lavora sul core, non ha bisogno delle notifiche push, quindi avvia semplicemente il file docker-compose e di conseguenza si risparmiano le risorse.
Se è necessario integrare le notifiche push, si avvia docker-compose.yaml e docker-compose-push.yaml.
Poiché docker-compose.yaml e docker-compose-push.yaml si trovano nella stessa cartella, viene automaticamente creata una rete virtuale unica.

Descrizione dei componenti. Questo è un file più esteso che è responsabile della raccolta dei componenti. Cosa c'è di notevole qui? Qui introduciamo il componente bilanciatore.
Questo è un'immagine Docker pronta, in cui viene eseguito nginx e un'applicazione che ascolta il socket Docker. Genera dinamicamente la configurazione di nginx man mano che i contenitori vengono attivati e disattivati. La gestione dei componenti viene distribuita su nomi di dominio di terzo livello.
Per l'ambiente di sviluppo utilizziamo il dominio .dev — api.informer.dev. Le applicazioni con dominio .dev sono accessibili sulla macchina locale dello sviluppatore.
In seguito, le configurazioni vengono trasferite a ciascun progetto e tutti i progetti vengono avviati simultaneamente.

Se lo rappresentassimo graficamente, il client sarebbe il nostro browser o uno strumento tramite il quale inviamo richieste al bilanciatore.
Il bilanciatore determina, in base al nome di dominio, a quale contenitore rivolgersi.
Questo potrebbe essere nginx, che fornisce le JS della dashboard. Oppure nginx, che offre l'API o file statici, che vengono serviti da nginx come caricamento delle immagini.
Nello schema si vede che i contenitori sono connessi in una rete virtuale e nascosti dietro un proxy.
Su una macchina di sviluppo, è possibile accedere al contenitore conoscendo l'IP, ma in generale non lo utilizziamo. Non sorge praticamente mai la necessità di un accesso diretto.

Quale esempio posso vedere per dockerizzare la mia applicazione? A mio parere, un buon esempio è l'immagine ufficiale di Docker per MySQL.
È piuttosto complesso. Ci sono molte versioni. Tuttavia, le sue funzionalità coprono molte esigenze che possono sorgere durante lo sviluppo. Se dedichi del tempo a capire come interagiscono tutte queste parti, penso che nell'implementazione autonoma non dovresti avere problemi.
Su hub.docker.com di solito ci sono collegamenti a github.com, dove sono forniti i dati grezzi da cui puoi creare autonomamente l'immagine.
All'interno di questo repository c'è uno script docker-endpoint.sh, che si occupa dell'inizializzazione iniziale e della successiva gestione dell'avvio dell'applicazione.
In questo esempio è anche presente la possibilità di configurare utilizzando variabili d'ambiente. Definendo un ambiente variabile al momento dell'avvio di un singolo container o tramite docker-compose, possiamo specificare che abbiamo bisogno di impostare una password vuota per docker per root su MySQL, oppure una password a nostra scelta.
C'è la possibilità di creare una password casuale. Indichiamo che ci serve un utente, dobbiamo impostare una password per l'utente e dobbiamo creare un database.
Nei nostri progetti abbiamo unificato un po' il Dockerfile, che si occupa dell'inizializzazione. Abbiamo adattato le necessità per semplicemente estendere i diritti dell'utente utilizzato dall'applicazione. Questo ha permesso di creare facilmente un database dalla console dell'applicazione. Nelle applicazioni Ruby c'è un comando per creare, modificare e eliminare database.

Questo è un esempio di come appare una versione specifica di MySQL su github.com. È possibile aprire il Dockerfile e vedere come avviene l'installazione.
Lo script docker-endpoint.sh è responsabile del punto d'ingresso. Durante la prima inizializzazione, sono necessarie alcune preparazioni e tutte queste azioni sono riportate nello script di inizializzazione.

Passiamo alla seconda parte.
Per la memorizzazione del codice sorgente abbiamo scelto gitlab. È un sistema piuttosto potente che offre un'interfaccia visiva.
Uno dei componenti di Gitlab è Gitlab CI. Questo consente di descrivere una serie di comandi che saranno utilizzati successivamente per organizzare un sistema di distribuzione del codice o per avviare test automatici.
Relazione su Gitlab CI 2 — una presentazione del Ruby Russia club — piuttosto dettagliata e potrebbe interessarvi.

Ora vediamo cosa è necessario per attivare Gitlab CI. Per avviare Gitlab CI, è sufficiente posizionare il file .gitlab-ci.yml nella radice del progetto.
Qui descriviamo le sequenze di stati che vogliamo eseguire, come i test o il deploy.
Eseguiamo script che richiamano direttamente la costruzione dell'applicazione usando docker-compose. Questo è un esempio di backend.
Successivamente, ci assicuriamo di eseguire le migrazioni per le modifiche al database e di eseguire i test.
Se gli script vengono eseguiti correttamente e non restituiscono codice di errore, il sistema passa quindi alla seconda fase del deployment.
La fase di deployment è attualmente implementata per lo staging. Non abbiamo organizzato un riavvio senza downtime.
Spegniamo forzatamente tutti i container e poi riavviamo tutti i container, assemblati nella prima fase durante il testing.
Eseguiamo le migrazioni del database già per l'ambiente variabile corrente, scritte dai sviluppatori.
C'è una nota che indica di applicare questo solo per il branch master.
Non viene eseguito quando si modificano altri branch.
C'è la possibilità di organizzare i deployment per branch.

Per procedere con ciò, dobbiamo installare Gitlab Runner.
Questo strumento è scritto in Golang. È un file singolo, come è consuetudine nel mondo Golang, senza dipendenze necessarie.
Durante l'esecuzione registriamo Gitlab Runner.
Otteniamo dalla web interfaccia di Gitlab una chiave.
Poi chiamiamo il comando di inizializzazione nel terminale.
Configuriamo Gitlab Runner in modalità dialogo (Shell, Docker, VirtualBox, SSH).
Il codice su Gitlab Runner verrà eseguito a ogni commit in base alla configurazione di .gitlab-ci.yml.

Ecco come appare visivamente in GitLab nell'interfaccia web. Dopo aver collegato GitLab CI, appare una bandiera che mostra in quale stato si trova attualmente il build.
Possiamo vedere che 4 minuti fa è stato effettuato un commit che ha superato tutti i test senza alcun problema.

Possiamo esaminare più dettagliatamente i build. Qui vediamo che sono già passati due stati: lo stato di test e lo stato di deploy su staging.
Se clicchiamo su un build specifico, ci sarà l'output della console dei comandi eseguiti durante il processo secondo il file .gitlab-ci.yml.

Ecco come appare la cronologia del nostro prodotto. Possiamo vedere i tentativi riusciti. Quando i test falliscono, non si passa al passo successivo e il codice su staging non viene aggiornato.

Quali problemi abbiamo risolto su staging quando abbiamo implementato Docker? La nostra sistema è composta da diversi componenti e abbiamo dovuto riavviare solo una parte dei componenti che erano stati aggiornati nel repository, piuttosto che l'intero sistema.
Per questo motivo, abbiamo dovuto separare tutto in cartelle distinte.
Dopo aver completato l'operazione, abbiamo riscontrato un problema con Docker-compose, che crea uno spazio di rete separato per ogni cartella e non riesce a vedere i componenti vicini.
Per aggirare questo, abbiamo creato manualmente una rete in Docker. In Docker-compose, abbiamo specificato di utilizzare questa rete per il progetto.
In questo modo, ogni componente che viene avviato con questa rete può vedere i componenti in altre parti del sistema.
Il problema successivo riguarda la separazione degli ambienti di staging tra più progetti.
Per far sì che tutto appaia attraente e il più vicino possibile alla produzione, è consigliabile utilizzare le porte 80 o 443, largamente utilizzate nel WEB.

Come abbiamo risolto questo problema? Abbiamo assegnato un unico Gitlab Runner a tutti i grandi progetti.
Gitlab consente di avviare più Gitlab Runner distribuiti, i quali prenderanno gli incarichi in modo casuale e sequenziale.
Per evitare confusione, abbiamo limitato il gruppo dei nostri progetti a un solo Gitlab Runner, che gestisce senza problemi i nostri volumi di lavoro.
Abbiamo estratto nginx-proxy in uno script di avvio separato e in esso abbiamo specificato le reti di tutti i progetti.
Il nostro progetto ha una rete, mentre il bilanciatore ne ha diverse con i nomi dei progetti. Può fare proxy in base ai nomi di dominio.
Riceviamo richieste sul dominio tramite la porta 80, che vengono gestite da un gruppo di container che serve quel dominio.

Quali altri problemi ci sono stati? Di default, tutti i container vengono eseguiti con l'utente root. Questo root non è lo stesso del root del sistema host.
Tuttavia, se si accede al container, si avrà root e il file che creiamo in quel container ottiene i diritti di root.
Se uno sviluppatore è entrato nel container e ha eseguito alcuni comandi che generano file, poi esce dal container, nella propria directory di lavoro avrà un file a cui non ha accesso.
Come possiamo risolvere questo problema? Possiamo aggiungere utenti che saranno nel container.
Quali problemi sono sorti quando abbiamo aggiunto un utente?
Creando un utente, spesso non coincidono l'ID del gruppo (UID) e l'ID dell'utente (GID).
Per risolvere questo problema, nel container utilizziamo utenti con ID 1000.
Nel nostro caso, questo coincide con il fatto che praticamente tutti gli sviluppatori utilizzano il sistema operativo Ubuntu. E in Ubuntu il primo utente ha ID 1000.

Quali sono i nostri piani?
Rileggere la documentazione su Docker. Il progetto è in continua evoluzione e la documentazione cambia. I dati raccolti due o tre mesi fa stanno già lentamente diventando obsoleti.
Alcuni dei problemi che abbiamo risolto sono probabilmente già risolti con mezzi standard.
Vorrei tanto andare oltre e passare direttamente all'orchestrazione.
Uno degli esempi è il meccanismo integrato in Docker chiamato Docker Swarm, che viene fornito di default. Voglio avviare qualcosa in produzione basato sulla tecnologia Docker Swarm.
La generazione di contenitori rende scomoda la gestione dei log. Attualmente i log sono isolati. Sono sparsi nei vari contenitori. Uno degli obiettivi è creare un accesso comodo ai log tramite interfaccia web.

Fonte: habr.com
