Published on

L'arte di mettere in produzione

Authors

Ok, facciamo finta di avere un progetto che ha bisogno di essere messo online. Il codice del progetto risiede nella sua bella repository di Git, ovviamente. Come facciamo a costruire un sistema di produzione con lo scopo di pushare una nuova versione nel minor tempo possibile?

Io sono solito creare un orchestrator, ossia un progetto a parte (che dunque risiede sempre in una repo a parte), che ha l'unico scopo di orchestrare, appunto, la messa in produzione. Ma come funziona?

1 - 'Dockerization' del progetto

Il primo passaggio dipende dalla natura del progetto. Sono un client e un server scritti rispettivamente in React e Node? Oppure il client è scritto in Angular, e il server con Ruby on Rails? Chi lo sa.

Ed è questo il punto: dobbiamo avere un sistema che ci permette di "isolare" il progetto e le sue dipendenze all'interno di un "container", così da dover evitare qualunque tipo di setup esterno direttamente sull'OS della macchina host (ad esempio una VPS). Questo porta a innumerevoli vantaggi organizzativi, primo fra tutti la possibilità di re-installare il progetto in pochissimi minuti su un nuovo host, oltre all'evidente vantaggio di sicurezza intrinseco che si ottiene avendo il progetto che gira su una specie di "sandbox" isolata dal resto dell'OS.

Docker è proprio questo: un gestore di container.

1.1 - Il Dockerfile

Come si crea un container? In primis bisogna creare uno o più Dockerfile nelle varie root delle repo del progetto. Occorre immaginare il Dockerfile come una specie di "maggiordomo" che step-by-step (riga per riga) esegue i comandi che gli ordiniamo di fare, comandi che servono a costruire il progetto in modalità di produzione, e infine ad esporlo al network.

1.2 - Esempio con React

Ecco un esempio relativo a React:

# Build lane
FROM node:latest as builder

RUN mkdir /usr/src/app
WORKDIR /usr/src/app

ENV PATH /usr/src/app/node_modules/.bin:$PATH
ADD package.json /usr/src/app/package.json

RUN npm install --silent

ADD . /usr/src/app

RUN npm run build

# Production lane
FROM nginx:1.13.5-alpine

RUN rm -rf /etc/nginx/conf.d/*
COPY nginx.conf /etc/nginx/conf.d/

COPY --from=builder /usr/src/app/build /usr/share/nginx/html

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

In questo caso il Dockerfile è diviso in due lanes (corsie), la prima è la "build lane" che si occupa della costruzione del progetto, la seconda è la "production lane" che si occupa dell'effettiva esposizione online di ciò che è stato costruito nella lane precedente.

Importantissima è la direttiva FROM, grazie alla quale specifichiamo un'immagine esistente come base per questa nuova immagine. Esatto: il Dockerfile non costruisce effettivamente il container, definisce solo come costruire l'immagine, che sarebbe una sorta di "scheletro" sul quale poi andrà a girare il container vero e proprio.

In questo caso diciamo al maggiordomo di usare come base l'ultima versione (latest) dell'immagine ufficiale di Node, così abbiamo già pre-installati e pronti nel futuro container Node e npm, così posso lanciare i comandi di install e di build tramite quest'ultimo.

Allora creo la directory /usr/src/app dove andrò a piazzare i file del progetto, e tramite WORKDIR dico che tutti i futuri comandi saranno lanciati da questa directory.

Dopo aver aggiunto la cartella .bin/ di node_modules/ al PATH di sistema , aggiungo il file package.json nella directory. Questo passaggio è particolare perché aggiungendo package.json prima di eseguire npm install lo sto memorizzando nella cache (perché tutti i comandi del Dockerfile sono memorizzati in cache) che mi permette di evitare di eseguire npm install ogni singola volta. Questo accellera di molto la creazione dell'immagine quando il package.json non viene alterato (ad esempio quando metto in produzione un hotfix e voglio fare veloce)

Adesso posso lanciare npm install (con la flag silent per dire che non mi importa del suo output). Poi lancio il comando build da npm, che poi andrà a lanciare react-scripts build. Dato che ho aggiunto ./bin al PATH, ora lui sa dove andare a prendere react-scripts per lanciare questo comando!

Quando la build sarà andata a buon fine, una nuova cartella di nome build sarà apparsa, e avrò concluso la fase di costruzione.

La fase di produzione usa come immagine base la versione di Nginx 1.13.5 su sistema Alpine (che è un Linux leggerissimo, così leggero che se soffi forte vola). Uso Nginx per servire i file HTML/JS/CSS/etc creati dal processo di costruzione solo perché mi ci trovo bene, sinceramente lo preferisco ad Apache, ma sarete voi a decidere di che morte morire.

Inserisco all'interno di Nginx il mio file di configurazione:

server {
  listen 80;
  location / {
    root /usr/share/nginx/html;
    index index.html index.htm;
    try_files $uri $uri/ /index.html;
  }
  error_page 500 502 503 504 /50x.html;
  location = /50x.html {
    root /usr/share/nginx/html;
  }
}

Senza andare troppo nel dettaglio sto definendo un nuovo server che gira sulla porta 80: alla location / va a prendere i file nella directory /usr/share/nginx/html e prova a visualizzare index.html

Ora che abbiamo detto dove andare a cercare i file, non ci resta che copiarli dalla lane precedente con il comando COPY. Infine espongo la porta 80 (perché è lì che ho detto che gira il server!) ed eseguo il comando richiesto per far girare Nginx sull'stdout, così se ci sono errori li vedo direttamente dalla console.

Alla fine della fiera, avrò un container dove effettivamente gira Nginx che sulla porta 80 del network ti mostra il progetto React finale.

Nota bene - Le porte esposte sono virtuali

E' importantissimo capire una cosa fondamentale: ora che ho esposto la porta 80, in realtà non l'ho esposta all'esterno, l'ho esposta solo nel container. In altre parole, solo altri container che fanno parte dello stesso network di Docker riusciranno a vederla...

Sarà poi compito di docker-compose definire le porte reali che puntano a queste porte virtuali. Lo so, è complicato...

1.3 - Esempio con Node

Questo è un Dockerfile per un server Node-based:

FROM keymetrics/pm2:latest-alpine

WORKDIR /home/app

COPY package.json /home/app/package.json
RUN npm install

COPY . /home/app

CMD [ "pm2-runtime", "start", "pm2.json" ]

Come base ho usato l'ultima versione di PM2 su sistema Alpine. PM2 tiene in vita Node 24/7 e lo riavvia in caso di errori. La configurazione pm2.json che uso è la seguente:

{
  "name": "node-server-example",
  "script": "index.js",
  "instances": 2,
  "exec_mode": "cluster",
  "env": {
    "NODE_ENV": "production"
  }
}

Notare che stavolta c'è solo la lane di produzione. Questo è perché questo server non ha bisogno di essere costruito, solo servito. Non uso nemmeno il comando EXPOSE perché già di suo il server si avvia e si mette in ascolto sulla porta 5000 (perché gliel'ho detto io in index.js)

Ora che abbiamo 2 Dockerfile, dobbiamo solo farli comunicare tra loro e creare i veri container.

2 - Il direttore d'orchestra: docker-compose

Proprio come un direttore d'orchestra, docker-compose si occupa di costruire i container e farli comunicare tra loro attraverso un network. Come al solito saremo noi a dare le istruzioni a questo tool, e per farlo si usa un file che si chiama docker-compose.yml

Creo un file per gestire i due Dockerfile che ho appena creato:

version: '3.3'

services:
  server:
    image: or2/node-server-test
    container_name: node-server-test
    hostname: node-server
    restart: always
    build:
      context: ./node-server-test
      dockerfile: Dockerfile
  client:
    image: or2/react-client-test
    container_name: react-client-test
    hostname: react-client
    restart: always
    build:
      context: ./react-client-test
      dockerfile: Dockerfile
    environment:
      - NODE_ENV=production
    depends_on:
      - server

Un docker-compose.yml è composto da N servizi, in questo caso solo 2. In entrambi i casi informo compose che il processo di build va fatto nel context della cartella del progetto, usando il file Dockerfile.

E' importante la direttiva hostname (node-server e react-client), ci servirà più avanti.

Ovviamente questo file, appena creato nella root del progetto orchestrator, farà riferimento a due cartelle di progetto che saranno clonate da git, quindi queste due cartelle a loro volta non dovranno essere aggiunte alla repo git dell'orchestrator (in parole povere, mettetele in .gitignore!)

Ora possiamo lanciare il comando della vita:

docker-compose up -d --build

-d è una flag per dire "lancia i container i maniera detached", altrimenti i container ve li lancia nel contesto della vostra sessione di terminale, e appena la chiudete si spegneranno.

--build serve per dire a docker-compose di fare la build delle immagini così come definito nel file di configurazione. Se non la usassi, si limiterebbe a fare il restart dei container senza aggiornarli.

Dopo aver costruito il network, le immagini, i container, eseguendo il comando

docker ps -a

Possiamo notare 2 container che stanno girando belli contenti.

Ma non è ancora finita: questi container sono isolati dal mondo esterno! Le loro porte sono virtuali, quindi possono vedersi a vicenda ma il mondo esterno non può vedere loro.

Manca l'ultimo tassello: un proxy che si occupi proprio di questo.

3 - L'anello mancante: il proxy

Spoiler: sarà di nuovo Nginx, con una configurazione atta all'esposizione di ciò che risiede nei container.

Creo una nuova cartella nel progetto orchestrator (che chiamo nginx) e dentro ci butto un file chiamato nginx.conf

worker_processes 2;

events { worker_connections 1024; }

http {
  server {
    listen 80;
    listen [::]:80;

    server_name test.or2.life;

    location /api/ {
      rewrite /api(.*) $1 break;
      proxy_connect_timeout 1200;
      proxy_send_timeout 1200;
      proxy_read_timeout 1200;
      send_timeout 1200;
      proxy_ignore_client_abort on;
      proxy_pass http://node-server:5000;
    }

    location / {
      proxy_connect_timeout 1200;
      proxy_send_timeout 1200;
      proxy_read_timeout 1200;
      send_timeout 1200;
      proxy_ignore_client_abort on;
      proxy_pass http://react-client;
    }
  }
}

Creo un solo server sulla porta 80, che chiamo "test.or2.life". Questo dominio ovviamente non esiste, però visto che possiedo il dominio or2.life potrei creare un terzo livello DNS (ottenendo test.or2.life) che punta proprio all'IP della macchina dove sta girando tutta questa roba. Ma non lo farò perché questo è un esempio.

Definisco 2 location fondamentali: /api/ e /

Su /api/ tramite la direttiva proxy_pass faccio puntare all'hostname node-server (che lui riconoscerà perché farà parte dello stesso network di docker-compose) alla porta 5000 (perché è lì che gira)

Allo stesso modo su / con proxy_pass faccio puntare all'hostname react-client, stavolta sulla porta 80 (se non scrivi la porta e stai su HTTP questa è di default la 80)

Tramite questo sistema che si chiama Reverse Proxy, posso quindi esporre all'esterno ciò che gira sui container.

Dunque test.or2.life/api punterà al server, qualunque altra cosa cercherà di puntare al client. Comodo no?

Non ci resta che aggiungere questo nuovo service a docker-compose. Stavolta non mi serve nessun Dockerfile perché sto usando solo Nginx, e non devo fare processi strani di build.

Il service lo definisco così:

proxy:
  image: nginx:stable-alpine
  container_name: proxy
  restart: always
  ports:
    - '80:80'
  volumes:
    - ./nginx:/etc/nginx/

Notare la nuova direttiva ports: dico che la porta 80 reale punta alla porta 80 virtuale, che all'interno del container corrisponde al server che ho definito con i suoi proxy.

Altra direttiva importante è volumes: dico che la cartella nginx sulla mia macchina punta alla cartella /etc/nginx sul container. Quindi il file nginx.conf risiederà nel container senza dover usare un Dockerfile per copiarlo. Neat!

Adesso se faccio girare di nuovo docker-compose otterrò un terzo container di nome proxy, e se vado su test.or2.life troverò il client ad attendermi.

La messa in produzione è finita, andate in pace!

(Forse...)

3.1 - HTTPS con Letsencrypt

Tutto molto bello, sì, funzionante, ma HTTPS?

Oggi è la vostra giornata fortunata: grazie a Letsencrypt sarà facilissimo creare gratuitamente un certificato HTTPS, e con il nostro sistema che abbiamo già messo su basterà solo aggiungere un paio di configurazioni sul proxy e saremo pronti.

Avete due opzioni: potete far girare Certbot (che sarebbe la CLI di Letsencrypt) su Docker oppure installarlo sulla macchina e buonanotte.

Io personalmente preferisco installarlo sulla macchina in questo caso, anche perché non è un software che richiede costanti aggiornamenti e nel peggiore dei casi basterà re-installarlo su una nuova macchina in caso di spostamento di host.

Installare Certbot è molto facile: basterà seguire le istruzioni da qui: https://certbot.eff.org/instructions

Fermatevi al passo 3 (quello per installarlo) e non andate oltre, perché il resto dei passaggi sono un po' diversi nel nostro caso.

Ma prima un po' di teoria: il nostro obiettivo è quello di ottenere un certificato HTTPS da Letsencrypt. Questi certificati saranno poi disponibili nella cartella /etc/letsencrypt/live.

Un certificato valido è composto da due file PEM (estensione .pem), di nome fullchain.pem e privkey.pem

Per ottenere un certificato occorre richiederlo all'authority e passare una "challenge" a scelta. Lista di challenges disponibili

La challenge HTTP-01 è perfetta per il nostro caso: per passarla occorre esporre un file qualsiasi sotto la location

http://test.or2.life/.well-known/acme-challenge/<TOKEN>

Perché questa challenge funzioni occorre ovviamente modificare la configurazione di Nginx per esporre il nostro file. Prima, però, creiamolo.

Creo una cartella di nome letsencrypt, e dentro ci metto una cartella di nome .well-known, e ancora dentro ci metto un file di nome index.html Su questo file HTML posso scrivere effettivamente quello che voglio, basta che esista e che sia accessibile.

Ora che ho il file, andiamo su nginx/nginx.conf e aggiungiamo una nuova location:

location ^~ /.well-known {
  allow all;
  root /data/letsencrypt;
}

La nuova location beccherà /.well-known e qualunque sua sublocation, e punterà a /data/letsencrypt del container.

Andiamo a modificare il servizio proxy per aggiungere un nuovo volume:

proxy:
  image: nginx:stable-alpine
  container_name: proxy
  restart: always
  ports:
    - '80:80'
  volumes:
    - ./nginx:/etc/nginx/
    - ./letsencrypt:/data/letsencrypt/

Tutto ciò che sta nella cartella letsencrypt starà nella cartella /data/letsencrypt del container, che è proprio quella a cui punta la nuova location che abbiamo aggiunto!

Lancio nuovamente docker-compose up -d --build per riavviare il proxy, e provo a visitare il sito sotto /.well-known/test per controllare se si veda il file HTML che ho creato prima.

Se non si vede, sicuramente ci sarà qualcosa di sbagliato nella config di Nginx.

Appena si vede, siamo sicuri di passare la challenge, quindi non ci resta che richiedere il certificato:

sudo certbot certonly --webroot -w letsencrypt/ -d test.or2.life

Con questo comando chiedo a certbot di generare solo un certificato (certonly) con la challenge HTTP-01 (--webroot) specificando la cartella letsencrypt/ come fonte (-w letsencrypt/) per il dominio test.or2.life

Lui proverà ad accedere al file che sta sotto well-known, e se ci riuscirà vi darà il certificato! Semplice, no?

Adesso il certificato sarà presente sotto /etc/letsencrypt/live/test.or2.life

Questo certificato ha una validità di 90 giorni, ma siamo fortunati: Certbot crea un CRON job per richiedere ogni 30 giorni un nuovo certificato, quindi non dobbiamo preoccuparci! E possiamo stare tranquilli che la challenge funzionerà sempre perché il file è sempre disponibile per la lettura.

Ora non ci resta che modificare nginx/nginx.conf per servire HTTPS, aggiungere un volume che punta ai certificati, e siamo a posto!

Ecco la nuova configurazione:

worker_processes 2;

events { worker_connections 1024; }

http {
  server {
    listen 443 ssl;
    listen [::]:443 ssl;

    server_name test.or2.life;

    ssl_certificate     /etc/letsencrypt/live/test.or2.life/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/test.or2.life/privkey.pem;

    location /api/ {
      rewrite /api(.*) $1 break;
      proxy_connect_timeout 1200;
      proxy_send_timeout 1200;
      proxy_read_timeout 1200;
      send_timeout 1200;
      proxy_ignore_client_abort on;
      proxy_pass http://node-server:5000;
    }

    location / {
      proxy_connect_timeout 1200;
      proxy_send_timeout 1200;
      proxy_read_timeout 1200;
      send_timeout 1200;
      proxy_ignore_client_abort on;
      proxy_pass http://react-client;
    }
  }

  server {
    listen 80;
    listen [::]:80;

    server_name test.or2.life;

    location ^~ /.well-known {
      allow all;
      root /data/letsencrypt;
    }

    location / {
      return 301 https://$host$request_uri;
    }
  }
}

Adesso ho 2 server: uno gira sulla porta 80 (default HTTP) e uno sulla porta 443 (default HTTPS). Il server che sta sulla porta 80 funge solo come redirect per l'HTTPS (nel caso in cui si visiti il sito tramite HTTP, si sarà subito re-indirizzati su HTTPS), e per servire il file per la challenge che serve quando si deve rinnovare il certificato.

Sul server alla porta 443, dico di usare come certificato SSL e chiave privata il risultato della chiamata a Certbot.

Fatto! Ora modifichiamo docker-compose.yml in accordo con queste modifiche.

proxy:
  image: nginx:stable-alpine
  container_name: proxy
  restart: always
  ports:
    - '80:80'
    - '443:443'
  volumes:
    - ./nginx:/etc/nginx/
    - ./letsencrypt:/data/letsencrypt/
    - /etc/letsencrypt:/etc/letsencrypt/

Ho aggiunto che la porta reale 443 punta alla porta virtuale 443 (che ora esiste e punta al nostro server HTTPS), e ho aggiunto un volume che fa puntare il mio /etc/letsencrypt/ a /etc/letsencrypt/ del container, così sono simmetrici, e i certificati saranno disponibili anche all'interno del container.

Ora riavviamo e bam, HTTPS! E senza pagare una lira!

Prima di salutarci però vediamo...

3.2 - Alcune configurazioni aggiuntive HTTPS

Queste configurazioni non sono obbligatorie, ma miglioreranno la sicurezza generale del server HTTPS. Vanno inserite su nginx.conf sotto le direttive ssl_certificate e ssl_certificate_key

ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
ssl_ciphers "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH+aRSA RC4 !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS +RC4 RC4";
ssl_dhparam dh4096.pem;

Serve il file dh4096.pem, che genereremo nella cartella nginx con il comando

openssl dhparam -out dh4096.pem 4096

Dopo un processo un po' lunghetto (pausa caffè?) otterremo il file, e poi potremo riavviare i container.

Adesso abbiamo ottenuto la Perfect Forward Secrecy, un requisito aggiuntivo di sicurezza per fare i fighi sui test!

That's all folks!

Bene, abbiamo finito. Questo è tutto quello di cui abbiamo bisogno per mettere seriamente in produzione una web-app con un server associato. Ovviamente il processo può complicarsi nel caso in cui si abbiano più di 2 progetti e molte più configurazioni da gestire. Ricordate inoltre che i certificati vanno richiesti per ogni sottodominio!

Alla prossima!