Progetto NodeJS su Docker

Due rapide note su come costruire un container Docker attorno a un progetto realizzato in NodeJS.

Come esempio utilizziamo un serverino di prova basato su Express. Una cosa semplice semplice, giusto per provare. Per prima cosa inizializziamo il progetto con npm in una cartella vuota:

npm init --yes

Successivamente andiamo a creare una cartella src; in questa crediamo un file index.js in cui andremo a scrivere il nostro programmino NodeJS, un semplice server http in ascolto sulla porta 3000 con un endpoint /ciao in GET:

import express from 'express';

console.log(`Avvio server in corso...`)

const app = express()
const port = 3000

app.get('/ciao', (req, res) => {
  res.send('Hello World!')
})

app.listen(port, () => {
  console.log(`Server in ascolto sulla porta ${port}`)
})

Installiamo le dipendenze:

npm install express

Modifichiamo il package.json per aggiungere la solita chiave di configurazione:

...
"type": "module",
...

Già che abbiamo aperto il package.json, aggiungiamo lo script di avvio nel blocco "scripts":

  ...
  "scripts": {
    "start": "node src/index.js"
  },
  ...

Proviamo a eseguirlo in locale per vedere che vada tutto bene.

D:\Progetti\node\simple-server>npm start

> simple-server@1.0.0 start D:\Progetti\node\simple-server
> node src/index.js

Avvio server in corso...
Server in ascolto sulla porta 3000

Una volta verificato il tutto passiamo a Docker. Per prima cosa creiamo nella root del progetto un file, chiamato Dockerfile, che servirà a indicare a Docker come costruire l'immagine.

FROM node:12

# Crea cartella di lavoro
WORKDIR /usr/src/app

# Installa le dipendenze
COPY package*.json ./
RUN npm install

# Copia il resto dell'applicazione
COPY . .

# Esegue l'applicazione all'avvio dell'immagine
CMD [ "npm", "start" ]

La direttiva FROM indica che vogliamo costruire la nostra immagine modificando una immagine esistente; l'immagine node:12 è standard (scaricata in automatico da Docker Hub) e contiene quanto serve per eseguire NodeJS versione 12.

La direttiva WORKDIR indica il percorso all'interno dell'immagine in cui andremo a piazzare la nostra applicazione.

La direttiva COPY serve a copiare il package.json (e l'eventuale package-lock.json) dentro l'immagine. A partire da questa situazione viene chiamata la direttiva RUN che esegue l'install di npm (quindi la creazione del node_folder e il recupero delle dipendenze). Vogliamo infatti che sia il NodeJS dentro l'immagine a recuperarsi le dipendenze, che potrebbero essere diverse da quelle che abbiamo recuperato in locale (diverse architetture, bla bla..); in questo modo siamo sicuri che tutto funzioni per il meglio.

La successiva direttiva COPY copia tutto il contenuto della cartella del progetto nella workdir dell'immagine. In realtà non verrà copiato tutto (la cartella node_modules non deve essere copiata), poi vedremo come limitare l'elenco dei file copiati.

Per concludere, la direttiva CMD specifica il comando da eseguire quando verrà avviato un container Docker costruito a partire da questa immagine.

Ultima cosa: come detto in precedenza, ci sono dei file che non debbono essere copiati nell'immagine (nel nostro caso tutta la cartella node_modules). Possiamo indicare a Docker di ignorare questi files creando un file nella root del progetto chiamato .dockerignore (mi raccomando il punto iniziale). In questo file andiamo a elencare cosa escludere dalla copia; nel nostro caso:

node_modules/

Una nota di cultura generale: c'è un motivo se, nel Dockerfile, si copia prima il package.json, poi si fa l'install e solo dopo si copiano i file Javascript. Il motivo è che ogni volta che viene eseguita una direttiva nel Dockerfile il sistema crea una nuova immagine; la direttiva successiva andrà a operare su questa e a sua volta ne creerà una nuova, e così via fino ad arrivare all'immagine completa. Se devo ricreare una immagine, il sistema parte dall'ultima immagine temporanea valida.

Capita spesso mentre si sviluppa di effettuare correzioni nel codice, e ricreare l'immagine. Avendo messo la copia dei file Javascript (COPY . . ) DOPO l'install di npm (RUN npm install), la creazione di una nuova versione dell'immagine parte già dalla fase successiva all'install (che, come sa chi sviluppa in Javascript, può richiedere decine di secondi ogni volta). In questo modo posso apportare modifiche al codice e ricreare l'immagine in un istante, tutte le volte che sarà necessario.

La fase di install verrà eseguita nuovamente solo nel caso in cui siano state apportate delle modifiche al package.json.


A questo punto è arrivato il momento di creare l'immagine con Docker.

docker build --tag prova:latest .

Il comando sopra specifica di creare un'immagine (build) a partire da un Dockerfile disponibile nella cartella corrente (il . finale) con un tag "prova:latest" (le immagini sono sempre descritte come nome:versione, e "latest" indica la versione più recente).

Nel log che ho riportato qui sotto si vede come vengono eseguiti i passaggi descritti nel Dockerfile; si possono scorgere le singole immagini temporanee di cui ho parlato in precedenza (sono quelli indicate con le diciture "—> XXXXX"):

Sending build context to Docker daemon  20.48kB
Step 1/6 : FROM node:12
 ---> 28faf336034d
Step 2/6 : WORKDIR /usr/src/app
 ---> Using cache
 ---> bc172301a9e1
Step 3/6 : COPY package*.json ./
 ---> e1c3a4409216
Step 4/6 : RUN npm install
 ---> Running in 440bf8375f57

added 50 packages from 37 contributors and audited 50 packages in 1.203s
found 0 vulnerabilities

Removing intermediate container 440bf8375f57
 ---> 86c1b7fed395
Step 5/6 : COPY . .
 ---> 1831f5d60e9e
Step 6/6 : CMD [ "npm", "start" ]
 ---> Running in 553c87be3db9
Removing intermediate container 553c87be3db9
 ---> 814cb3f36a8e
Successfully built 814cb3f36a8e
Successfully tagged prova:latest

Ora possiamo eseguire l'immagine:

docker run --rm -d -p 3000:3000 prova:latest

Il comando sopra specifica di creare ed eseguire immediatamente un nuovo container a partire dalla nostra immagine prova:latest. Le altre opzioni sono:

  • -d (detached): indica che il container deve essere eseguito in background e liberare il terminale.
  • --rm: indica che il container deve essere cancellato una volta terminato il programma.
  • -p: indica al sistema Docker di connettere la porta 3000 della macchina locale alla porta 3000 del container. In questo modo possiamo accedere alla porta interna del nostro container passando da localhost.

Vediamo la lista dei container in esecuzione:

$ docker ps

CONTAINER ID        IMAGE               COMMAND                  CREATED 
c23a56e68789        prova:latest        "docker-entrypoint.s…"   3 minutes ago

Prendiamo nota del container id, poiché quello è il nome del nostro container. A questo punto proviamo a sbirciare il log del programma in esecuzione dentro il nostro container:

$ docker logs c23a56e68789

> simple-server@1.0.0 start /usr/src/app
> node src/index.js

(node:19) ExperimentalWarning: The ESM module loader is experimental.
Avvio server in corso...
Server in ascolto sulla porta 3000

D:\Progetti\node\simple-server>

Perfetto, sembra che il la nostra applicazione NodeJS stia funzionando correttamente. Proviamo a vedere se risponde sulla porta 3000 (dell'host, che è connessa alla porta 3000 del container) dal browser:

Perfetto!

Per terminare l'immagine è sufficiente il comando:

$ docker stop c23a56e68789