Come creare un'applicazione Node.js con Docker, Parte 1: Deploy

In questo articolo vedremo come utilizzare Docker per eseguire un'applicazione Node.js senza più temere lunghe e difficoltose configurazioni.

Quante volte ti sei trovato ad affrontare il setup del tuo pc per lo sviluppo di una nuova applicazione che richiedeva non soltanto l'installazione di un linguaggio di programmazione o dipendenze ma anche una la configurazione di uno o più servizi come un database o un debugger?

Lavorare su un'applicazione eseguita in ambienti configurati molto diversamente può dare luogo a spiacevoli esperienze durante lo sviluppo che vanno da un setup difficile e alle volte sconveniente (immagina di avere sullo stesso pc due o più applicativi che funzionano con diverse versioni delle stesse dipendenze) a bug non facilmente riproducibili su altre macchine.

Docker è uno strumento creato per affrontare questo e altri problemi. L'idea alla base è semplice: creare un ambiente isolato dal sistema operativo e facilmente riproducibile con solo le dipendenze strettamente necessarie per permettere l'esecuzione della nostra applicazione.

In questa serie di articoli vedremo passo per passo come usare le varie funzionalità di Docker per gestire tutto il ciclo di vita di un'applicazione web Node.js: sviluppo, testing, debugging, deploy con altri servizi. L'applicazione sarà realizzata con il framework Express ma ovviamente i procedimenti che andremo a illustrare si possono applicare per ogni applicazione di questo genere.

In questo primo articolo ci occuperemo di creare un'immagine per eseguire l'app.

Requisiti

Prima di iniziare accertati che Docker sia installato correttamente sul tuo pc lanciando da terminale

$ docker --version
Docker version 18.06.1-ce, build e69vc7a

Il Dockerfile

Creiamo una nuova cartella per il nostro progetto e al suo interno creiamo il nostro Dockerfile.

$ mkdir nodejs-app
$ cd nodejs-app/
$ touch Dockerfile

Docker utilizza le istruzioni contenute in questo file per creare un' immagine. Pensa a un'immagine come a un sistema operativo completo e portatile con su solo i file e i programmi necessari all'esecuzione della nostra applicazione che può essere utilizzata su ogni macchina dove Docker è installato.

Copia il seguente snippet nel Dockerfile:

# Usa l'immagine ufficiale di Node.js come immagine parente
FROM node:10.15-slim

# Imposta la cartella di lavoro. Se non esiste, verrà creata
WORKDIR /app

# Definisci la variabile d'ambiente `PORT`
ENV PORT 3000

# Esponi la porta 3000
EXPOSE ${PORT}

# Copia il file package.json dalla cartella corrente
# all'interno della nostra immagine nella cartella `app`
COPY ./package.json /app/package.json

# Installa le dipendenze
RUN npm install

# Copia il file package.json dalla cartella corrente
# all'interno della nostra immagine nella cartella `app`
COPY . /app

# Avvia l'applicazione
ENTRYPOINT ["npm", "start"]

Nel corso dell'articolo analizzeremo ciascuna di queste istruzioni.

L'immagine base

Vediamo quindi la prima istruzione

# Usa l'immagine ufficiale di Node.js come immagine parente
FROM node:10.15-slim

Un'immagine viene tipicamente creata partendo da altre immagini a cui semplicemente aggiungiamo file e programmi secondo le nostre esigenze. Con l'istruzione FROM node:10.15-slim iniziamo la build della nostra immagine partendo da una con su già presente Node.js e NPM.

Si consiglia di scegliere immagini con già le dipendenze che ci interessano in modo da mantenere i propri Dockerfile concisi e con poche istruzioni, non solo per una migliore manutenzione e per risparmiare tempo ma anche per poter meglio beneficiare del sistema di caching di Docker (che vedremo in tra poco).

Un'immagine è identificata tramite tag, composto generalmente da un nome (es. node), da un numero di versione (es. 10.15) e da una sigla indicante la variante della stessa (es. -slim). Suggerisco di far riferimento alla sorgente da cui si scarica l'immagine per capire qual'è la versione più adatta al nostro caso. Quando il tag si omette, latest è sottointeso come tag ed indica l'ultima versione disponbile dell'immagine.

Quando Docker avvia un'immagine, crea un container, ossia un'istanza dell'immagine che è eseguita in un contesto isolato rispetto alla macchina.

Impostare la cartella principale

WORKDIR è utilizzata per impostare la cartella di default in cui verrà eseguita ogni successiva istruzione. Possiamo cambiarla più volte all'interno dello stesso Dockerfile.

# Imposta la cartella di lavoro. Se non esiste, verrà creata
WORKDIR /app

Esporre una porta

Un container Docker è un sistema isolato dalla nostra macchina. Se vogliamo fare in modo che la nostra applicazione sia raggiungibile dall'esterno, dobbiamo esporre una porta. Per farlo basterà aggiungere la seguente istruzione

# Esponi la porta 3000
EXPOSE 3000

Possiamo anche definire una variabile d'ambiente per poter riutilizzare più facilmente questo valore all'interno della nostra applicazione.

# Definisci la variabile d'ambiente `PORT`
ENV PORT 3000

# Esponi la porta 3000
EXPOSE ${PORT}

Installare le dipendenze

Ora che abbiamo un ambiente con tutti i programmi necessari, possiamo pensare alle dipendenze richieste dalla nostra applicazione. Questa cartella infatti non conterrà solo i file relativi a Docker ma ogni file di cui è composta la nostra applicazione e su cui dovremo lavorare.

Creiamo nella stessa cartella un file package.json come il seguente:

{
  "name": "nodejs-app",
  "dependencies": {
    "express": "^4.17.0"
  },
  "scripts": {
    "start": "node index.js"
  }
}

Includiamolo nella nostra immagine e installiamo le dipendenze tramite le istruzioni COPY e RUN. La prima serve per copiare un file o una directory dalla nostra cartella in locale in un'immagine Docker mentre la seconda permette di eseguire un comando nella shell dell'immagine.

# Copia il file package.json dalla cartella corrente
# nella cartella /app della nostra immagine
COPY ./package.json /app/package.json

# Installa le dipendenze
RUN npm install

Il sistema di caching di Docker

In diverse guide potreste trovare invece la seguente istruzione per COPY

COPY . /app

RUN npm install

La quale copia l'intero contenuto della directory corrente all'interno dell'immagine in /app e installa le dipendenze. Cosa implica ciò?

Quando Docker esegue un'istruzione dal Dockerfile, come FROM, COPY, RUN ecc., crea un nuovo layer. Alla fine della build tutti i layer vengono uniti per creare l'immagine finale. Prima di creare un nuovo layer, Docker controlla se ne ha una copia in cache e in tal caso usa quella copia invece di crearne uno nuovo. Al contrario quando incontra un'istruzione il cui layer non è in cache, esegue e crea un nuovo layer per questa e per tutte le altre successive istruzioni.

Per ogni istruzione Docker utilizza una diversa strategia per il controllo della cache.

Ad esempio prima di creare un layer dall'istruzione COPY, Docker verifica se ne ha uno in cache comparando tramite checksum i file che deve copiare con quelli già copiati dalla stessa istruzione in un layer precedentemente creato. Se trova una corrispondenza con un layer in cache, Docker utilizzerà questo invece di crearne uno nuovo.

L'istruzione vista poco prima

COPY . /app

RUN npm install

Copia l'intera directory di lavoro che è quindi composta da file che saranno spesso modificati. Durante la build dell'immagine difficilmente sarà possibile trovare un layer già cachato per quest'istruzione costringendo Docker a creare un nuovo layer non solo per quest'istruzione ma anche per le successive le quali potrebbero essere molto lunghe, come RUN npm install che installa tutte le dipendenze della nostra app Javascript.

Utilizzando invece

COPY ./package.json /app/package.json

RUN npm install

possiamo fare in modo che Docker recuperi dalla cache i layer per le due istruzioni se il file package.json non è cambiato. Le dipendenze dell'app sono definite in questo file quindi se esso non cambia allora possiamo recuperarle dalla cache anzichè riscaricarle e reinstallarle.

Per verificare che Docker recuperi dalla cache i layer interessati, possiamo far riferimento ai log in fase di build:

Step 5/8 : COPY ./package.json /app/package.json
 ---> Using cache
 ---> b6bbe99bb004
Step 6/8 : RUN npm install
 ---> Using cache
 ---> 8f3ecd93131b

Aggiungere il codice sorgente

Ora occupiamoci del codice della nostra web app. Creiamo quindi un file index.js e inseriamoci il codice seguente. Quando avvieremo l'app, essà si metterà in ascolto sulla porta che abbiamo usato prima con l'istruzione EXPOSE e mostrerà una semplice pagina con la scritta "Hello World".

var express = require('express');
var app = express();
var port = process.env.PORT || 3000;

app.get('/', function (req, res) {
  res.send('Hello World!');
});

app.listen(port);

Copiamo questo file e l'intera cartella di lavoro nella nostra immagine

# Copia il file package.json dalla cartella corrente
# all'interno della nostra immagine nella cartella `app`
COPY . /app

Ed aggiungiamo il comando che sarà eseguito dal container una volta avviato

# Avvia l'applicazione
ENTRYPOINT ["npm", "start"]

Buildare e lanciare un'immagine

Non ci rimane che avviare la nostra applicazione. Per prima cosa dobbiamo buildare la nostra immagine tramite il comando

$ docker build -t nodejs-app:1.0.0 .

Il parametro -t specifica il nome del tag che daremo alla nostra immagine, in questo caso nodejs-app:1.0.0. Tramite l'argomento . designiamo la cartella corrente come la la cartella di riferimento per le istruzioni contenute nel Dockerfile.

Una volta che l'immagine sarà stata buildata, la ritroveremo nella lista delle immagini salvate sul nostro pc. Per vederne l'elenco basta usare il comando

$ docker image ls
REPOSITORY   TAG     IMAGE ID       CREATED         SIZE
nodejs-app   1.0.0   1182ac9acd7b   2 minutes ago   146MB

A questo punto avviamo la nostra applicazione

$ docker container run -p 4000:3000 -d nodejs-app:1.0.0

Tramite il parametro -p diciamo a Docker di mappare la porta 3000 del container sulla 4000 della macchina host in modo da usare quest'ultima per raggiungere l'app da fuori il container. Il parametro opzionale -d serve invece ad avviare il container come processo in background.

Se ora apri il browser e vai su localhost:4000, vedrai la tua app in esecuzione che mostra la pagina di Hello World.

Se invece ti trovi su Windows e hai installato Docker tramite Docker Toolbox, potresti non riuscire a raggiungere la pagina da localhost. In questo caso devi usare l'ip della Docker Machine, per esempio http://192.168.99.100:4000/. Per scoprirlo, basta usare il comando

$ docker-machine ip
192.168.99.100

Comandi utili

Di seguito ti elenco alcuni comandi che possono tornare utile nell'utilizzo di base di Docker

# Mostra i container in esecuzione
docker ps

# Ferma un container in esecuzione
# L'id del container lo puoi ottenere usando il comando precedente
docker stop <CONTAINER ID>

# Rimuovi un container
docker rm <CONTAINER ID>

# Riavvia un container
docker restart <CONTAINER ID>

# Vedi i log creati al momento dell'esecuzione del container
docker logs <CONTAINER ID>

# Vedi le opzioni disponibili per un comando Docker
docker help
docker image help
docker image ps help

Conclusioni

Ora sappiamo come creare un'immagine ed avviarla per eseguire la nostra applicazione web su qualunque macchina con su Docker installato. Nei prossimi articoli impareremo come gestire anche gli altri aspetti del suo sviluppo.

Come creare un'applicazione Node.js con Docker, Parte 2: Sviluppo

Come creare un'applicazione Node.js con Docker, Parte 2: Sviluppo

Docker è un ottimo strumento non solo per il deploy delle applicazioni ma anche per tutte le altri fasi del ciclo di vita di un software. In questa seconda parte vedremo il suo utilizzo nello sviluppo della nostra applicazione web Node.js.

Come creare un'applicazione Node.js con Docker, Parte 3: Debugging

Come creare un'applicazione Node.js con Docker, Parte 3: Debugging

Quando si sviluppano applicazioni complesse il debugging è fondamentale per implementare nuove funzionalità, ottimizzare il codice e risolvere bug. Vediamo come usare gli strumenti messi a disposizione da Docker e da Node.js per affrontare al meglio questa importante attività.

Come creare un'applicazione Node.js con Docker, Parte 4: Testing

Come creare un'applicazione Node.js con Docker, Parte 4: Testing

Nella quarta parte di questa serie ci concentreremo su come creare facilmente un ambiente di testing completo per la nostra applicazione Node.js utilizzando Docker, Mocha e Chai.