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.

Nel primo di questa serie di articoli abbiamo imparato come creare ed avviare un container Docker per eseguire un'applicazione web in Node.js riducendo diversi problemi che possono presentarsi nella configurazione dell'ambiente di esecuzione.

Ora creeremo un ambiente di sviluppo per la nostra applicazione che, oltre ai benefici visti prima, ci consenta di modificare agevolmente i file, vederne subito le modifiche tramite live reloading e di utilizzare i programmi o gli script sia all'interno che all'esterno del container.

Prerequisiti

Dal momento che utilizzeremo i file che abbiamo creato nell'articolo precedente, ti consiglio di recuperarli prima di iniziare. Inoltre alcuni termini e concetti che citeremo sono già stati trattati nell'articolo precedente quindi dacci un'occhiata se ancora non conosci Docker.

Volumi

La prima esigenza che abbiamo è quella di poter modificare i file della nostra applicazione, sia all'esterno del container (ad esempio tramite un IDE installato sul nostro pc) sia al suo interno (ad esempio tramite script o task runner). Vogliamo che le modifiche persistano anche dopo la distruzione del container e che siano sincronizzate tra l'host (il sistema operativo) e il container in maniera instantanea ed automatica.

Per fare ciò possiamo usare un particolare tipo di volumi, denominato bind mount, che è uno spazio condiviso tra l'host e il container.

Iniziamo modificando il Dockerfile che abbiamo utilizzato nel precedente articolo per avviare l'applicazione

# Usa l'immagine di Node.js per lo sviluppo
FROM node:10.15

# 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}

Come puoi vedere abbiamo rimosso le istruzioni per installare le dipendenze ed avviare l'applicazione. Quando un volume viene montato in una cartella del container, quest'ultima viene oscurata e sostituita dalla cartella del volume. Dato che come punto di mount utilizzeremo la cartella /app del container, ogni file al suo interno sarà inaccesibile quindi ci conviene lasciarla vuota.

Un'altra modifica, questa volta opzionale, che è stata fatta è stato cambiare l'istruzione FROM node:10.15-slim in FROM node:10.15. Questo perchè vogliamo creare un ambiene di sviluppo usando un'immagine Node.js completa anzichè una sua versione più compatta e minimale meglio adatta ad un ambiente di produzione.

Facciamo una nuova build dell'immagine

$ docker build -t nodejs-app-dev:1.0.0 .

Nel caso volessimo mantenere entrambi i Dockerfile, sia quello usato per il deploy che quello per lo sviluppo, possiamo rinominarli in maniera differente ed eseguire la build di uno dei due speficiandone il nome con il flag --file (o semplicemente -f). C'è un modo migliore per gestire questo caso, ma lo vedremo alla fine.

$ docker build -t nodejs-app-dev:1.0.0 -f ./Dockerfile.dev .

Dal momento che l'ambiente generato da un'immagine è agnostico e indipendente dal sistema operativo, non può conoscere il filesystem in cui il container sarà creato. Perciò possiamo montare il volume solo all'avvio del container tramite

$ docker run -p 4000:3000 -d \
  -it \
  --mount type=bind,source="$(pwd)",target=/app \
  --name myapp \
  nodejs-app-dev:1.0.0

Analizziamo il comando:

  • Abbiamo già visto il comando docker run -p 4000:3000 -d, serve a creare un nuovo container, a mapparne la porta per accedervi dall'host e ad avviarlo come processo in background.
  • Il flag -it permette di utilizzare, dall'host, un terminale eseguito nel container.
  • Il flag --mount serve per montare un volume. Accetta una serie di argomenti coppia-valore separati da virgola per definirne la configurazione. Con type=bind si definisce il tipo di volume da usare, con source=$(pwd) diciamo di utilizzare per il volume la nostra cartella di lavoro corrente e con target=/app indichiamo come punto di mount all'interno del container la cartella /app.
  • Il flag (opzionale) --name serve per dare al container un nome che potremo utilizzare invece del CONTAINER ID per semplificare la sintassi dei comandi che lo richiedono.
  • nodejs-app-dev:1.0.0 è il nome dell'immagine di cui vogliamo creare il container.

Per verificare che il volume sia stato montato correttamente controlliamo la sezione Mounts dell'output restituitoci da

$ docker inspect myapp

Una volta montato il volume, accediamo al terminale del container utilizzando

$ docker exec -it myapp bash
root@13f480d37103:/app#

Installiamo le dipendenze ed avviamo l'applicazione

root@13f480d37103:/app# npm install && npm start

Nota: Se utilizzi Docker su Windows, potresti avere alcuni problemi a causa delle differenze tra il file system del container (Linux) e dell'host (Windows). In quel caso, prova ad utilizzare:

root@13f480d37103:/app# yarn install --no-bin-links && yarn start

Una volta avviata, puoi vedere la tua applicazione direttamente da browser da localhost:4000 (se sei su Windows ricordati di che potresti dover utilizzare l'ip della docker machine anzichè localhost).

Se guardi nella tua directory di lavoro, vedrai che è stata creata la cartella node_modules. Questo ci conferma che le modifiche effettuate sul volume all'interno del container sono state propagate alla cartella sul tuo host. Ora proviamo il contrario. Apriamo il file index.js dall'host e sostituiamo il testo "Hello World!" con "Hello Docker!". Poi, sempre usando la shell del container, killiamo il processo creato da npm start e riavviamolo. Ricaricando la pagina del browser aperta prima, vedrai il messaggio modificato.

Live reloading

Ora ogni volta che modifichiamo un file non dovremo più rieseguire la build dell'immagine tuttavia rimane l'inconveniente di dover riavviare manualmente l'applicazione.

Possiamo risolverlo utilizzando nodemon, un utility in Node.js per riavviare un'applicazione web automaticamente quando i suoi file vengono modificati.

Installiamola quindi nel container tramite

root@13f480d37103:/app# npm install nodemon -D

Aggiungiamo uno script all'interno del nostro package.json per eseguire l'applicazione in modalità sviluppo con nodemon ed avviamolo con npm run dev.

{
  "name": "nodejs-app",
  "dependencies": {
    "express": "^4.17.0"
  },
  "scripts": {
    "start": "node index.js",
    "dev": "npx nodemon index.js",
  },
  "devDependencies": {
    "nodemon": "^1.19.1"
  }
}

Nota: Se utilizzi Docker su Windows e il comando non riavvia correttamente l'applicazione una volta fatte le modifiche, usa npx nodemon -L index.js. Come detto prima, quando utilizziamo un volume condiviso tra un host Windows e un container, possiamo imbatterci in alcuni problemi di compatibilità tra i filesystem. Per risolvere questo basta eseguire Nodemon con il flag -L per indicargli di usare una differente strategia (il polling della libreria JS Chokidar) per monitorare i cambiamenti dei file nella nostra cartella.

Ora siamo in grado di vedere applicate le modifiche fatte ai nostri file dal browser senza dover riavviare manualmente l'applicazione.

Build multi-stage

Rimane un'ultima questione lasciata in sospeso: i differenti Dockerfile. Abbiamo visto che non è possibile utilizzare lo stesso Dockerfile sia per il deploy che per lo sviluppo dell'applicazione. Come soluzione temporanea abbiamo rinominato i due Dockerfile ma questo approccio comporta il doverli tenere costantemente allineati e perciò non è scalabile. Potremo pensare alla creazione di uno script con questo scopo, ma in futuro potremo aver bisogno di altri Dockerfile ad esempio per configurare un ambiente di testing quindi ci serve una soluzione più efficiente.

A partire dalla versione 17.05 di Docker, possiamo usare le build multi-stage. Tramite un unico Dockerfile possiamo definire più di una build e comporre l'immagine finale decidendo quale utilizzare.

Vediamo un esempio per il nostro caso d'uso

# Usa l'immagine di Node.js per lo sviluppo
FROM node:10.15 AS development

# 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}

# Usa l'immagine di Node.js per il deploy
FROM node:10.15-slim AS deploy

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

# 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"]

Come puoi vedere, abbiamo più istruzioni FROM. Ciascuna di esse definisce uno stage con un nome (es. AS deploy). Durante la creazione di un'immagine, Docker crea una build per ogni stage rispettandone l'ordine ma nell'immagine include solo i file contenuti nell'ultima build. Possiamo usare il flag opzionale --target per decidere l'ultimo stage di cui fare la build

$ docker build --target development -t nodejs-app-dev:1.0.0 .

È possibile inoltre copiare i file contenuti in una build verso un'altra tramite l'istruzione COPY --from=<NOME-DELLO-STAGE> (es. COPY --from=deploy) nel Dockerfile.

Conclusioni

Giunti a questo punto siamo in grado di sviluppare e deployare un'applicazione web in Node.js tramite Docker. Uno dei maggiori benefici di questo approccio è che possiamo utilizzare facilmente e indipendentemente programmi e script sia all'interno del container che all'esterno, mantenendo allo stesso tempo la nostra macchina di lavoro pulita e pronta ad ospitare miriadi di diversi progetti con i linguaggi e le configurazioni più disparate. Nei prossimi articoli approfondiremo il debugging, il testing e l'orchestrazione con altri servizi.

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.

Come creare un'applicazione Node.js con Docker, Parte 5: Servizi

Come creare un'applicazione Node.js con Docker, Parte 5: Servizi

Nella quinta parte di queste serie impareremo come utilizzare Docker Compose per gestire un'applicazione containerizzata ed aggiungervi dei servizi.