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à.

Negli scorsi articoli abbiamo imparato come gestire le fasi di deploy e di sviluppo di un'applicazione in Node.js con Docker. Ma ben sappiamo che all'aumentare della complessità della nostra app, aumenteranno anche le linee di codice, le variabili, i flussi di esecuzione, ecc. rendendo il debugging un'attività determinante per l'analisi, la correzione di bug e l'ottimizzazione di codice non troppo perfomante.

Ora esamineremo le differenti strategie per debuggare l'applicazione e il suo ambiente di esecuzione tramite le funzionalità di Docker e il live debugger di Node.js.

Prerequisiti

  • I file Dockerfile, index.js e package.json usati nei precedenti articoli.
  • Visual Studio Code. Dovendo scegliere a scopo illustrativo un client per il debugger, ho optato per questo in quanto molto popolare e flessibile come IDE all'interno dell'ecosistema Javascript (e non solo). Resta inteso che, capita la procedura per configurarlo, sarà semplice applicarla anche ad altri client.

Esaminare il container

Se vuoi avere un elenco molto dettagliato di tutte le impostazioni con cui è stato avviato un container, puoi usare

$ docker inspect <CONTAINER ID>
[
  {
    "Id": "0e46f33ada2544247c6ec3e12eb6ac782b5b083fc4b4b28b54fe1432e11705ef",
    "Created": "2019-07-02T22:16:41.680373653Z",
    "Path": "npm",
    "Args": [
      "start"
    ],
...

Da qui puoi vedere le variabili d'ambiente, i volumi montati, i mapping delle porte e così via. Se ti interessa sapere con quali istruzioni è stata creata l'immagine, basta eseguire

$ docker history <IMAGE NAME>
IMAGE          CREATED          CREATED BY                                      SIZE    COMMENT
21c4b1c3caa6   13 minutes ago   /bin/sh -c #(nop)  ENTRYPOINT ["npm" "start"]   0B
184e7d2e01d2   13 minutes ago   /bin/sh -c #(nop) COPY dir:0f185e89d258ae6b2…   2.02kB
6ef474952ee5   13 minutes ago   /bin/sh -c npm install                          11.5MB
d47428ad6508   14 minutes ago   /bin/sh -c #(nop) COPY file:d5ccac30a20f01f2…   270B
57cb52807557   14 minutes ago   /bin/sh -c #(nop) WORKDIR /app                  0B

Invece per vedere i processi in esecuzione nel tuo container

$ docker top <CONTAINER ID>
UID    PID    PPID   C    STIME   TTY   TIME       CMD
root   4800   4779   10   22:16   ?     00:00:00   npm
root   4848   4800   0    22:16   ?     00:00:00   sh -c node index.js
root   4849   4848   6    22:16   ?     00:00:00   node index.js

Questi comandi ti consentono di avere una rapida panoramica del container e verificare facilmente la presenza di errori durante la fase di creazione e avvio.

Consultare i logs

Prima di proseguire aggiungiamo un semplice log nella nostra applicazione modificando il file index.js

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

app.get('/', function (req, res) {
  console.log('[' + new Date().toISOString() + '] Request for homepage')
  res.send('Hello World!');
});

app.listen(port);

Rieseguiamo la build dell'immagine ed avviamo il container. Per vedere i log emessi dalla nostra app ci basterà lanciare il comando

$ docker logs <CONTAINER ID>

> nodejs-app@ start /app
> node index.js

[2019-07-05T22:03:10.234Z] Request for homepage

Come puoi notare, il comando mostra l'output del processo avviato con l'istruzione ENTRYPOINT, nel nostro caso npm start. Ogni volta che accederai alla homepage dell'applicazione, potrai vedere nuovi log aggiunti. Se invece vuoi collegare lo STDIO (il canale di input/output) del tuo terminale al processo e fare in modo che i log siano emessi direttamente nel terminale senza dover ogni volta rieseguire il comando precedente, puoi usare

$ docker attach <CONTAINER ID>

Modificare l'entrypoint

Potremmo aver bisogno di usare temporaneamente un diverso comando per l'istruzione ENTRYPOINT. Immagina ad esempio quando viene lanciato un errore durante l'esecuzione del comando impedendo l'avvio del container o quando vogliamo avviare l'applicazione in una modalità differente. Possiamo modificare il comando all'avvio del container in 2 modi:

  1. Passandogli degli argomenti

    $ docker run -d -p 4000:3000 nodejsapp arg1 arg2
  2. Sovrascrivendo l'istruzione ENTRYPOINT

    $ docker run -d -p 4000:3000 --entrypoint npm nodejsapp install --verbose

    Nell'esempio abbiamo sovrascritto il comando dell'istruzione con npm install --verbose. Nota come ogni argomento da passare al comando va sempre messo in coda.

Per vedere il comando di entrypoint con cui il container è stato avviato basta controllare l'apposito campo COMAND nella lista dei container

$ docker ps
CONTAINER ID   IMAGE       COMMAND                 CREATED         STATUS         PORTS                    NAMES
de5cfbb0ee14   nodejsapp   "npm start arg1 arg2"   2 minutes ago   Up 2 minutes   0.0.0.0:4000->3000/tcp   vigilant_feistel

Eseguire comandi nel container

Spesso abbiamo bisogno di eseguire dei comandi specifici all'interno del container. Anche qui abbiamo 2 opzioni:

  1. Usare il comando exec

    $ docker exec <CONTAINER ID> node --version
    v10.15.3
  2. Sovrascrivere il comando di entrypoint come abbiamo visto poco prima per avviare una shell interattiva collegata al container

    $ docker run -it --entrypoint /bin/sh nodejsapp

    Un vantaggio di sovrascrivere l'entrypoint è che possiamo evitare che un container venga stoppato a causa di un errore durante l'avvio e tenerlo attivo finchè vogliamo.

Mettere in pausa il container

Un'altra funzionalità molto carina, utile ad esempio se si vogliono consultare dei log molto verbosi poco per volta, è poter mettere in pausa tutti i processi all'interno del container utilizzando

$ docker pause <CONTAINER ID>

e farli ripartire quando vogliamo

$ docker unpause <CONTAINER ID>

Live debugging

Per esaminare nel dettaglio ed efficientemente i flussi e le variabili della nostra applicazione mentre è in esecuzione, è indispensabile armarci di un buon debugger. Node.js già ne include uno suo che è possibile utilizzare con un client a nostra scelta, nel nostro caso Visual Studio Code.

Per prima cosa aggiungiamo un script al package.json per avviare la nostra applicazione in modalità debug ed eseguiamo una nuova build dell'immagine.

Nota: Puoi usare comandi di questo tipo anche senza bisogno di una nuova build dell'immagine ma semplicemente sovrascrivendo l'entrypoint all'avvio del container come abbiamo visto prima.

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

Il flag --inspect permette la comunicazione tra un processo Node.js e un debugger. Accetta come opzioni l'host (in questo caso 0.0.0.0) e la porta (9229) da usare.

Nota: Sebbene sia l'host che la porta abbiano dei valori di default, ho deciso di configurarli ugualmente per fornire un esempio completo del comando e per evitare che alcuni utenti Windows riscontrino dei problemi qualora abbiano installato Docker tramite Docker Toolbox. In quel caso infatti il container Docker è visto dal sistema host come una macchina remota e per comunicare con essa è necessario collegarsi a un IP pubblico, ad esempio 0.0.0.0 o l'IP della Docker Machine.

Ora dobbiamo configurare il debugger. Apriamo la sezione debug di VS Code e aggiungiamo la seguente configurazione

{
  "name": "Docker: Attach to Node",
  "type": "node",
  "request": "attach",
  "port": 9229,
  "address": "localhost",
  "localRoot": "${workspaceFolder}",
  "remoteRoot": "/app",
  "protocol": "inspector"
}

how-to-configure-debugger

Esaminiamo un pò più da vicino in cosa consiste questa configurazione:

  • name: il nome della configurazione.
  • type: il debugger da usare.
  • request: Visual Studio supporta due tipi di richieste, attach e launch. La prima consente al debugger di collegarsi a un processo già in esecuzione mentre la seconda avvia il processo insieme al debugger. Ho scelto la prima perchè permette una maggiore flessibilità, specie quando si parla di debuggare app containerizzate o in remoto.
  • port: Questa è la porta su cui il debugger e il processo comunicheranno. Ricordati che dovrai esporla così come fai per la porta da cui l'applicazione è accessibile (vedi sotto).
  • address: Questo è l'indirizzo a cui il debugger deve collegarsi per monitorare il processo. Anche questo è configurabile tramite --inspect. Nei sistemi in cui Docker è stato installato tramite Docker Toolbox, l'indirizzo da usare è quello restituito dal comando $ docker-machine ip.
  • localRoot: Il percorso della tua applicazione nel tuo filesystem. Puoi usare ${workspaceFolder} per indicare la cartella in cui hai aperto Visual Studio.
  • remoteRoot: Il percorso della tua applicazione nel filesystem del tuo container Docker.
  • protocol: Il protocollo da usare per la comunicazione tra il debugger e il processo. Ci sono due opzioni, inspector quando si usa il flag --inspect o legacy quando si usa una vecchia versione di Node.js.

Avviamo il nostro container esponendo la porta per il debugger e specificando il comando di entrypoint.

$ docker run -d -p 4000:3000 -p 9229:9229 --entrypoint npm nodejsapp run debug

Ed ecco fatto, ora puoi usare Visual Studio per debuggare la tua applicazione containerizzata!

Conclusioni

Abbiamo visto differenti strategie di debugging, ognuna pensata per uno o più specifici casi d'uso. Tuttavia il live debugging rimane lo strumento più potente che abbiamo perchè ci permette di esaminare ogni linea di codice e il relativo contesto facilmente, comodamente e approfonditamente.

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.

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

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.