Sin dalle prime ore di uso di Docker, si sente subito la necessità di salvare in qualche formato, che non sia uno script bash, i mille parametri che si possono passare al comando docker run. Ben presto si fa la conoscenza di Docker Compose: avere un file YAML che descrive i parametri di lancio, ma non solo, di uno o più container ci fa sentire al sicuro perché possiamo metterlo sotto controllo di versione. Appena però abbiamo bisogno di far girare i container su più macchine, ci rendiamo conto che nemmeno questo è lo strumento che stavamo cercando. Abbiamo bisogno di Docker Swarm per fare questo e, da tre anni ormai dalla sua nascita, possiamo considerarlo ormai production-ready.

Se siete qua, probabilmente sapete già cosa sono Compose e Swarm e magari vi state chiedendo se conviene fare un “upgrade”. Cerchiamo insieme di capire se si tratta davvero di un upgrade e cosa comporta.

Docker Compose

Compose è uno strumento per definire ed eseguire applicazioni Docker multi-container. Con Compose, è possibile definire un file YAML per configurare i servizi applicativi.

Nel file YAML infatti, è possibile definire più container Docker sotto il cappello “services“, sia a partire da Dockerfile che da immagini già pronte.

Docker Compose all’opera

Immaginiamo di voler tirare su WordPress, che ha bisogno di MySQL. All’interno di una cartella chiamata wordpress-docker, creiamo un file di nome docker-compose.yml con questo contenuto (presente su GitHub):

version: '3.7'

services:
   db:
     build: ./mysql
     image: poc/mysql-for-wordpress
     volumes:
       - db_data:/var/lib/mysql
     restart: always
     environment:
       MYSQL_ROOT_PASSWORD: somewordpress
       MYSQL_DATABASE: wordpress
       MYSQL_USER: wordpress
       MYSQL_PASSWORD: wordpress

   wordpress:
     depends_on:
       - db
     image: wordpress:latest
     ports:
       - "8000:80"
     restart: always
     environment:
       WORDPRESS_DB_HOST: db:3306
       WORDPRESS_DB_USER: wordpress
       WORDPRESS_DB_PASSWORD: wordpress
       WORDPRESS_DB_NAME: wordpress
volumes:
    db_data: {}

In questo caso abbiamo fatto una personalizzazione (non che ce ne fosse bisogno, è solo a scopo didattico), tramite Dockerfile, del container di MySQL, mentre abbiamo preso quello di default per WordPress. Da notare il valore di WORDPRESS_DB_HOSTdb:3306“: il riferimento al container del database viene risolto con il nome del servizio associato.

Docker Compose però non è nativamente disponibile quando si installa Docker, ma è un tool separato, scritto in Python, che va installato autonomamente e automatizza certe operazioni del demone Docker. Ha una propria CLI, accessibile tramite il comando docker-compose, che semplifica notevolmente la “gestione di gruppo” dei servizi elencati nel file YAML.

Per avviare tutti i servizi, basta eseguire:

$> docker-compose up -d

Dove con -d si evita di rimanere attaccati al system out dei container avviati. Cosa accade adesso? Il controllo della console ci viene restituito solo dopo queste operazioni:

Creating network "wordpress-docker_default" with the default driver
Creating volume "wordpress-docker_db_data" with default driver
Building db
Step 1/2 : FROM mysql:5.7
 ---> 98455b9624a9
Step 2/2 : COPY *.cnf /etc/mysql/conf.d/
 ---> 52440b13db7c

Successfully built 52440b13db7c
Successfully tagged poc/mysql-for-wordpress:latest
WARNING: Image for service db was built because it did not already exist. To rebuild this image you must use `docker-compose build` or `docker-compose up --build`.
Creating wordpress-docker_db_1 ... done
Creating wordpress-docker_wordpress_1 ... done
  • viene creato un network che lega i due container e li isola da tutto il resto. Il nome è curioso: si tratta del nome della cartella dove risiede il docker-compose.yml (chiamata wordpress-docker ricordate?) a cui viene apposto “default”.
  • viene creato il volume “db_data”, sempre con prefisso wordpress-docker.
  • viene creata l’immagine modificata del database, chiamata poc/mysql-for-wordpress, tramite il Dockerfile presente nella cartella mysql.
  • vengono avviati i container dei servizi “db” e “wordpress”, esattamente in quest’ordine, per via delle dipendenze. In questo caso, oltre al consueto prefisso wordpress-docker, notiamo un numero dopo che segue il nome del servizio: si tratta della prima istanza del servizio.

La prima cosa che salta all’occhio è che il nome della cartella che contiene il file YAML viene usata come “namespace” per la nomenclatura delle risorse create: questo approccio è interessante perché permette di rilanciare lo stesso stack di servizi da cartelle diverse senza creare conflitti.

Tutte queste operazioni sono sincrone con la CLI: finché i container non saranno avviati, non potremo riprendere il controllo della console. La CLI di compose è interessante perché permette di controllare in gruppo i container:

$> docker-compose ps

            Name                          Command               State          Ports
--------------------------------------------------------------------------------------------
wordpress-docker_db_1          docker-entrypoint.sh mysqld      Up      3306/tcp, 33060/tcp
wordpress-docker_wordpress_1   docker-entrypoint.sh apach ...   Up      0.0.0.0:8000->80/tcp

visualizza lo stato dei container e le relative porte mappate (se ci sono). E’ possibile anche controllarne uno solo, chiamato con il nome del servizio:

$> docker-compose stop wordpress
Stopping wordpress-docker_wordpress_1 ... done

$> docker-compose start wordpress
Starting wordpress ... done

Se si riesegue il comando di avvio di tutti i servizi (up), Compose controllerà per noi se esistono versioni nuove delle immagini ed eventualmente le avvierà (con le dipendenze in cascata). Modificando l’immagine del database quindi, verrà riavviato anche WordPress:

$> docker-compose up -d
Recreating wordpress-docker_db_1 ... done
Recreating wordpress-docker_wordpress_1 ... done

Tirando le somme

Fin qua abbiamo visto che Compose è perfetto per gestire il ciclo di build e di runtime di più servizi insieme, perché dovremmo avere bisogno di altro?

Per il supporto allo sviluppo software è perfetto: possiamo praticamente creare e tirare su l’infrastruttura di cui abbiamo bisogno sulla nostra macchina di sviluppo! A mio avviso, Compose comincia a stare stretto in due casi:

  • quando si ha bisogno che i servizi girino su più macchine, Compose non è in grado di controllare questa situazione. La rete che viene creata tra i servizi istanziati è una sottorete dello stesso host, puro isolamento basato su iptables: non esiste un concetto di “cluster di nodi”. Non possiamo quindi far girare WordPress su una macchina e il MySQL in un’altra gestiti da Compose, perché non si “vedrebbero” a livello di rete: i Compose delle due macchine non avrebbero niente in comune. L’unico modo per farli comunicare sarebbe esporre le porte sui rispettivi host e far dialogare i servizi tramite gli hostname, ma si perde tutta la dinamicità offerta da Docker.
  • se si prova a scalare un servizio (ovviamente sullo stesso host perché non si può fare altrimenti) che ha il binding di una porta sull’host, lo scaling verrà impedito perché la porta è già occupata dalla prima istanza! Proviamo infatti a scalare WordPress:
    $> docker-compose up -d --scale wordpress=2  
    WARNING: The "wordpress" service specifies a port on the host. If multiple containers for this service are created on a single host, the port will clash.  
    Starting wordpress-docker_wordpress_1 ... done  
    Creating wordpress-docker_wordpress_2 ... error  
    ERROR: for wordpress-docker_wordpress_2  Cannot start service wordpress: driver failed programming external connectivity on endpoint wordpress-docker_wordpress_2 (f19636f06cfcf461c0d175a182fa3b7f53bf0f9c430260e76320f7bfa31f9220): Bind for 0.0.0.0:8000 failed: port is already allocated 
    Volendo scalare il database, invece, questo funzionerebbe perché non ha binding:
    $> docker-compose up -d --scale db=2 
    Starting wordpress-docker_db_1 ... done 
    Creating wordpress-docker_db_2 ... done 
    wordpress-docker_wordpress_1 is up-to-date 
    
    Anche se poi il risultato è che 2 istanze di MySQL sullo stesso volume possono solo fare danni :), quindi meglio evitare!

Quando si ha la necessità di controllare più servizi su più macchine, e magari scalarli su di esse, è necessario qualcosa di più, è necessario almeno Docker Swarm. Prima di continuare, distruggiamo tutti i container, compresi i volumi:

$> docker-compose down -v
Stopping wordpress-docker_wordpress_1 ... done
Stopping wordpress-docker_db_1        ... done
Removing wordpress-docker_wordpress_1 ... done
Removing wordpress-docker_db_1        ... done
Removing network wordpress-docker_default
Removing volume wordpress-docker_db_data

Docker Swarm

Uno swarm (“sciame” in italiano) consiste in un insieme di nodi Docker che si comportano da manager, per la gestione del cluster stesso, e worker, che eseguono servizi.

Swarm si propone quindi come lo strumento di default per la gestione di container in un cluster di nodi Docker.

Un po’ di teoria

Già dalla definizione, emergono una serie di concetti e terminologie che è bene avere chiari prima di andare avanti:

  • nodi: si tratta di istanze di Docker che partecipano al cluster. Solitamente, ogni istanza del motore di Docker risiede su una macchina fisica o virtuale che sia: di conseguenza si può pensare (ma non è obbligatorio) ad un nodo dello swarm come ad una macchina. I nodi possono essere di tipo manager o worker (o entrambi): più nodi possono avere il ruolo di master, ma solo uno è eletto in un certo momento a coordinare i servizi e a mantenere lo stato del cluster. Il deploy di una applicazione passa sempre da un nodo master che delega un task ad un nodo worker (o un altro master, o a se stesso) in modo che esegua il servizio richiesto. I nodi del cluster informano sempre il nodo master sullo stato dei task assegnati, in modo da mantenere sempre lo stato dei servizi come richiesto.
  • task e servizi: abbiamo visto che un nodo master assegna il compito (task appunto) di eseguire un certo servizio su un nodo del cluster (possibilmente worker), ovvero avviare una o più istanze di un container a partire da una immagine. Questo concetto già ci suona più familiare: servizi e container li avevamo visti anche con Compose. Ma questo task invece? Come vedremo a breve, è un concetto abbastanza trasparente, ma fondamentale, nell’uso di Swarm, se non per il fatto che tutta l’interazione tra il nodo master e la CLI è asincrona: da qua si evince che c’è qualcuno che sta facendo qualcosa (task) dopo che abbiamo eseguito un comando.

Docker Swarm all’opera

Swarm non è attivo di default, anche se è già disponibile nell’installazione di Docker (a differenza del Compose). Dobbiamo quindi solo decidere come attivarlo: per lo sviluppo, cioè sulla nostra macchina, possiamo fare un cluster di un nodo semplicemente digitando:

$> docker swarm init
Swarm initialized: current node (keiak51s53xowk57oq24bd5yi) is now a manager.

Docker entra in modalità Swarm e considera se stesso un nodo master. L’output completo del comando (qua omesso) dà anche istruzioni su come aggiungere altri nodi al cluster (sia master che worker). Tramite i comandi della CLI docker swarm e docker node è possibile gestire il cluster.

Ma torniamo a ciò che ci interessa: può Swarm aiutarci a superare i limiti che abbiamo visto con Compose? Essendo al momento su un singolo nodo, non noteremo grandi differenze, se non per il fatto che adesso lo scaling funzionerà!! Ma andiamo per gradi.

Docker stacks

Nel gergo Swarm, l’insieme dei servizi che possiamo deployare si chiama “stack“, cioè pila di servizi. La cosa scaltra di Swarm è che usa i file YAML di Compose, quindi il passaggio a Swarm è molto semplice (anzi, si è quasi invogliati): basta riusare lo stesso identico file di prima e deployare lo stack così:

$> docker stack deploy -c docker-compose.yml wp
Ignoring unsupported options: build, restart

Creating network wp_default
Creating service wp_db
Creating service wp_wordpress

Senza sapere né leggere né scrivere notiamo che:

  • Il comando stack deploy vuole per forza il riferimento al file YAML e il nome dello stack da creare. Il nome è usato come prefisso per le risorse create: il servizio WordPress, il database e la network che li isola. Fossimo un un cluster con più nodi, questa network sarebbe accessibile anche cross-nodo se i due container fossero su nodi diversi.
  • Le direttive build e restart sono state ignorate! Questo significa che non posso eseguire le build delle immagini con Swarm?! Ebbene è proprio così! Come direbbe Doc: “Marty, non stai pensando quadrimensionalmente!” Che senso avrebbe creare immagini su un nodo se poi non è detto che vengano eseguite li? Come portarle sugli altri nodi? Il problema non si pone: Swarm è un ambiente di runtime: qualcun altro deve essere responsabile di fornirgli le immagini già pronte. E restart invece perché viene ignorato? A dirla tutta, Swarm e Compose usano dialetti leggermente diversi del descrittore YAML (in base alla versione): questo discorso lo accenniamo più avanti.

La CLI ha restituito subito il controllo, significa che qualcuno sta facendo qualcosa (ricordate i task?) in background (magari su un altra macchina nel caso multi-nodo). L’interazione è quindi asincrona: come si fa a vedere cosa sta succedendo? Il comando

$> docker stack ps wp 
ID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS
tlobb6t4vnxo wp_wordpress.1 wordpress:latest linuxkit-025000000001 Running Running 2 minutes ago
uvxb7yhbdu1v wp_db.1 poc/mysql-for-wordpress:latest linuxkit-025000000001 Running Running 2 minutes ago

ci dà una panoramica dello stato dei task di wp: abbiamo quindi un task per ogni istanza (per questo il numerino in fondo al nome) di container che implementa il servizio richiesto. Chi li ha istruiti a comportarsi così? Per capire, dobbiamo approfondire il concetto di servizio.

Docker services

In Compose si parla di servizi: ci appaiono come dei proxy sul container (o le sue repliche) gestite dal Compose. In Swarm questo concetto è reso più forte ed assume un ruolo centrale, diventando di fatto l’unità di deployment.

services-diagram

Quando infatti si deploya una applicazione, in realtà si chiede (al master) di creare un servizio, il cui stato desiderato (immagine da usare, porte esposte, overlay di rete…) è definito nel file YAML. Dalla richiesta di servizio si generano (in gergo si “schedulano”) quindi i task che istanziano i container nel cluster, in modo da “implementare” lo stato desiderato. Vediamo allora i servizi generati dal nostro stack wp:

$ docker stack services wp
ID                  NAME                MODE                REPLICAS            IMAGE                            PORTS
bo6cdv2jwnqz        wp_db               replicated          1/1                 poc/mysql-for-wordpress:latest
klsaic86xu39        wp_wordpress        replicated          1/1                 wordpress:latest                 *:8000->80/tcp

Notiamo che abbiamo una replica rispettivamente del database e di WordPress (corrispondenti ai task), nonché il binding della porta 80 di quest’ultimo sulla 8000 dell’host. Adesso siamo in condizioni di poter scalare il container di WordPress a due repliche (anche perché è l’unico che ha senso) senza avere problemi:

$> docker service scale wp_wordpress=2
wp_wordpress scaled to 2
overall progress: 2 out of 2 tasks
1/2: running   [==================================================>]
2/2: running   [==================================================>]
verify: Service converged

Adesso quindi abbiamo due istanze di WordPress! Come ha fatto questa volta a funzionare? La svolta sta nel fatto che il binding della porta è fatto a livello di servizio e non a livello di container! Il servizio quindi rappresenta l’unità non solo “logica”, ma anche fisica, perché è lui responsabile di fare da punto di accesso e da bilanciatore alle funzionalità dei container che “proxa”. Vediamo quindi chi “implementa” questi servizi:

$> docker ps
CONTAINER ID        IMAGE                            COMMAND                  CREATED             STATUS              PORTS                 NAMES
35b70e25a1f3        wordpress:latest                 "docker-entrypoint.s…"   2 minutes ago       Up 2 minutes        80/tcp                wp_wordpress.2.xhpc5jebprigkrhlf742f5272
5aaa08bd0ff9        poc/mysql-for-wordpress:latest   "docker-entrypoint.s…"   22 minutes ago      Up 22 minutes       3306/tcp, 33060/tcp   wp_db.1.uvxb7yhbdu1v36lri90lp2zr8
f0f07649a62e        wordpress:latest                 "docker-entrypoint.s…"   22 minutes ago      Up 22 minutes       80/tcp                wp_wordpress.1.tlobb6t4vnxogbpehwwrr6cyd

Con il classico docker ps possiamo vedere quali container stanno girando sul nodo in cui siamo connessi (occhio a questa cosa!): vediamo infatti che la porta non viene esposta a livello di container; possiamo scalarlo tutte le volte che vogliamo: sarà responsabilità del servizio rendere raggiungibili tutti i container indipendentemente dal nodo in cui sono.

Quest’ultima considerazione fa sorgere una domanda: in un contesto reale, è necessario riuscire a far arrivare il traffico da fuori fin dentro il cluster Swarm, per cui è molto probabile che ci sarà un bilanciatore davanti. Come si fa quindi a sapere su quale nodo è stato fatto il binding della porta 8000 in modo da poter raggiungere WordPress? La soluzione che adotta Swarm (ma non solo, anche Kubernetes si comporta così) è chiama Routing Mesh: il binding della porta viene fatto su tutti i nodi del cluster, anche se fisicamente non sta girando un task in quel nodo.

Avere chiaro questo semplice concetto credo sia la chiave di volta per pensare quadrimensionalmente e comprendere che ogni operazione che facciamo in Swarm deve tener presente che siamo su un ambiente distribuito.

Rolling update

Immaginiamo di dover aggiornare l’immagine del MySQL cambiando il numero di connessioni: abbiamo bisogno di eseguire nuovamente la build dell’immagine.

Su questo Swarm non ci può aiutare: abbiamo infatti detto che è un ambiente di runtime, possibilmente multi-nodo: per lavorare bene dovremmo avere un Docker Registry su cui deployare le nostre immagini (magari generate da Jenkins…) così che ogni task potrà scaricarsi l’ultima versione.

Fatta la legge, trovato l’inganno: nel caso di studio che stiamo affrontando, siamo su un solo nodo, quindi possiamo rieseguire la build con Docker Compose e aggiornare il servizio Swarm “db” con la nuova immagine. Ovviamente questo anti pattern è accettabile solo in ambienti di sviluppo, in altre situazioni è meglio evitare!

Dopo aver aggiornato l’immagine (non voglio sapere come 🙂 ), aggiorniamo anche il servizio: questa volta andrà forzato (non si accorge dell’immagine nuova come Compose):

$> docker service update --force wp_db
wp_db
overall progress: 1 out of 1 tasks
1/1: running   [==================================================>]
verify: Service converged

Controllando i task, ci accorgiamo che è terminato quello precedente e ne è stato avviato uno nuovo subito dopo (questo perché l'”update-order” è di default a “stop-first” – si può verificare con docker service inspect --pretty wp_db). A differenza del Compose però, Swarm non sostituisce il vecchio container con quello nuovo all’aggiornamento del servizio, ma ne crea uno nuovo a fianco, seguendo la policy dell’update-order. Per fare un vero e proprio rolling update senza downtime, è possibile specificare l’order-update a “start-first“: in un dato momento ci saranno così contemporaneamente sia il nuovo task che quello vecchio, prima di essere interrotto.

Tirando le somme

Ricapitolando quindi, in Swarm c’è un cambio di paradigma da tenere sempre presente per capire quello succede: in fase di deploy chiediamo (al nodo master) di creare un servizio che provoca la schedulazione di un task (per replica), su un nodo disponibile, che gestisce il ciclo di vita di un container. Eventuali porte pubblicate dal servizio vengono registrate su tutti i nodi in modo da poter essere raggiunte dall’esterno: sarà cura del servizio di bilanciare il traffico sulle istanze dei nodi che eseguono realmente il task richiesto.

Gli attori principali quindi sono i servizi e i task, non i container, che effettivamente diventano effimeri: possono sparire da un nodo e ricomparire in un altro, per cui non possiamo fare affidamento su eventuali modifiche che vengono fatte internamente al di fuori dei volumi montati (che infatti non andrebbero fatte, ma in sviluppo fa comodo). Inoltre, è vero che non riavviare mai lo stesso container, ma crearne uno nuovo a fianco, permette un rollout senza disservizi (con Compose non era possibile), ma fa proliferare i vecchi container “parcheggiati” (cioè stoppati) che non verranno più riavviati. Non possiamo quindi fermare un servizio e riavviarlo sperando di ritrovare quelle modifiche fatte nel container precedente, perché, anche se ancora lì, non verrà riavviato, ma ne verrà creato uno nuovo.

Inizialmente pensavo fosse “sporcizia” inutile, in realtà, come dicono anche gli sviluppatori stessi, le vecchie istanze possono essere usate per diagnostica (mi sono ritrovato davvero in produzione ad aver bisogno di riavviarle per vedere i log!) e comunque non superano mai le 5 istanze (configurabili). In ambiente di sviluppo però questo non è di nessuno aiuto, anzi crea solo confusione.

Se alla fine Swarm non vi ha soddisfatto, si può sempre disabilitare con:

$> docker swarm leave --force
Node left the swarm.

Conclusioni

Abbiamo quindi visto una panoramica ad ampio spettro (che non vuole essere esaustiva) delle caratteristiche principali di Compose e di Swarm, nonché di pregi e difetti.

Il passaggio quindi da Compose a Swarm è di sola andata? Lo possiamo considerare un upgrade? A mio avviso no: sono due strumenti in parte diversi che rispondono ad esigenze di tipo diverso. Anche se sembra che facciano la stessa cosa, quello che hanno in comune lo fanno in modo diverso, basti pensare alla gestione dei servizi.

Per quanto mi riguarda, continuerò ad usare Compose in sviluppo (e su Jenkins): la gestione dei container è molto semplice e non si ha bisogno di sovrastrutture. Per gli ambienti di quality o produzione invece è un’altra storia: si può trarre vantaggio dal Compose file scritto in sviluppo, tenendo conto delle differenze degli interpreti di Compose e Swarm: sostanzialmente il Compose è compatibile interamente con la versione 2, mentre Swarm con la 3, anche se qua credo ci sia ancora un gran casino e non è così chiaro. La matrice di compatibilità può aiutare a capire.
Swarm credo si comporti bene per piccoli cluster on premise, dove il numero di nodi non cambia dinamicamente e non ci si attende picchi di traffico anomali. In un contesto B2C, dove il target è il pubblico di internet e la variabilità di traffico è alta, probabilmente conviene spostarsi su soluzioni cloud autoscalabili: a quel punto Swarm non è sufficiente e conviene spostarsi su Kubernetes, di gran lunga più complesso ma più potente.

84 Posts

Sono un software engineer focalizzato nella progettazione e sviluppo di applicazioni web in Java. Presso OmniaGroup ricopro il ruolo di Tech Leader sulle tecnologie legate alla piattaforma Java EE 5 (come WebSphere 7.0, EJB3, JPA 1 (EclipseLink), JSF 1.2 (Mojarra) e RichFaces 3) e Java EE 6 con JBoss AS 7, in particolare di CDI, JAX-RS, nonché di EJB 3.1, JPA2, JSF2 e RichFaces 4. Al momento mi occupo di ECM, in particolar modo sulla customizzazione di Alfresco 4 e sulla sua installazione con tecnologie da devops come Vagrant e Chef. In passato ho lavorato con la piattaforma alternativa alla enterprise per lo sviluppo web: Java SE 6, Tomcat 6, Hibernate 3 e Spring 2.5. Nei ritagli di tempo sviluppo siti web in PHP e ASP. Per maggiori informazioni consulta il mio curriculum pubblico. Follow me on Twitter - LinkedIn profile - Google+