Salta al contenuto principale

08 - CI/CD multi-env: GitHub Actions, deploy zero-downtime

Un workflow GitHub Actions a tre ambienti, il cambio atomico di versione, i controlli di salute e la migrazione in due fasi per rilasciare senza fermi.

Cosa ne pensi di questo articolo?

08 - CI/CD multi-env: GitHub Actions, deploy zero-downtime
Sviluppo Web Fullstackfedericocalo.dev

In Breve

Un workflow GitHub Actions a tre ambienti, il cambio atomico di versione, i controlli di salute e la migrazione in due fasi per rilasciare senza fermi.

Una pipeline è una promessa: ciò che ha passato i test è ciò che arriva in produzione

Deploy senza interruzioni vuol dire tre problemi distinti, e confonderli è la ragione per cui le pipeline «a zero downtime» si rompono al primo schema cambiato. Il primo è cambiare la versione del codice senza che per un istante il sito non risponda. Il secondo è tenere il database compatibile con due versioni dell'applicazione che girano insieme per qualche minuto. Il terzo è togliere un'istanza dal traffico senza interrompere chi sta ancora usando i suoi servizi. In questo articolo costruisci un workflow di GitHub Actions a tre ambienti (test, staging, production) e, accanto, tre piccole prove in-process: il cambio atomico di versione, i controlli di salute di un'istanza e la migrazione in due fasi. Il workflow l'ho validato nella forma contro lo schema pubblico, non eseguito: per eseguirlo servirebbe un repository su GitHub, e nessun deploy è stato lanciato.

Il workflow: tre ambienti, un solo artefatto

La regola che tiene insieme il workflow è «si costruisce una volta e si distribuisce lo stesso artefatto in ogni ambiente». Il job test installa, prova, compila e salva la cartella dist/ come artefatto; staging e production lo scaricano invece di ricompilare, così ciò che hai provato in staging è, byte per byte, ciò che va in produzione. Le versioni delle azioni sono quelle correnti al 2026-11-13, lette dalle release pubbliche su GitHub: actions/checkout v7.0.1, actions/setup-node v7.1.0, actions/upload-artifact v7.0.2 e actions/download-artifact v8.0.2. Ciascuna è fissata allo SHA completo del commit, con la versione in commento.

name: ci-cd
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm test
      - run: npm run build
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: rilascio
          path: dist/
          retention-days: 7

  staging:
    if: github.event_name == 'push'
    needs: test
    runs-on: ubuntu-latest
    environment: staging
    concurrency:
      group: deploy-staging
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: rilascio
          path: dist
      - run: ./deploy.sh staging
        env:
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

  production:
    needs: staging
    runs-on: ubuntu-latest
    environment: production
    concurrency:
      group: deploy-production
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: rilascio
          path: dist
      - run: ./deploy.sh production
        env:
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

Le righe che contano sono cinque. permissions: contents: read toglie al token del workflow tutto ciò che non serve: la documentazione di GitHub raccomanda il minimo privilegio per le credenziali usate nei workflow. Lo SHA al posto del tag protegge da un'azione modificata dopo che l'hai scelta: la guida alla sicurezza di GitHub spiega che per falsificare uno SHA servirebbe una collisione SHA-1. needs disegna la catena test poi staging poi production: se un job fallisce, i successivi non partono. environment collega il job a un ambiente di GitHub, e la documentazione dice che un job che lo referenzia deve rispettare le regole di protezione dell'ambiente prima di partire o di accedere ai suoi segreti: è lì, nelle impostazioni del repository e non nel file YAML, che si configura l'approvazione manuale prima di production. Infine concurrency: sui deploy ha cancel-in-progress: false, perché interrompere un deploy a metà è peggio che farne attendere un altro.

Due avvertenze sul perimetro. Primo: secondo la documentazione degli ambienti (consultata il 2026-11-13), con i piani Free, Pro e Team le regole di protezione come i revisori obbligatori sono disponibili solo per i repository pubblici; per un repository privato servono piani superiori, e la tabella dei piani può cambiare, quindi verifica prima di basarci l'approvazione manuale. Secondo: il comportamento predefinito di concurrency per i job in coda, descritto nella stessa documentazione, è che un job in attesa venga sostituito dal più recente; per i test su un pull request ha senso cancellare quelli in corso (è ciò che fa l'espressione sul gruppo di workflow), per i deploy no.

Validare un workflow senza GitHub

Un errore di battitura in un file YAML si scopre di solito al push, dopo qualche minuto di coda. Si può anticipare: il progetto SchemaStore mantiene lo schema JSON del formato dei workflow (github-workflow.json, ultima modifica del file al 2026-11-09). Il programma sotto lo scarica a parte, lo applica con Ajv (8.20.0) al file letto con la libreria yaml (2.9.1), e aggiunge due controlli propri che lo schema non può fare: che ogni needs punti a un job esistente e che ogni uses sia fissato a uno SHA di 40 caratteri esadecimali. Poi introduce tre guasti di proposito, per verificare che il controllo non sia a vuoto.

import { readFileSync } from 'node:fs';
import Ajv from 'ajv';
import { parse } from 'yaml';

const schema = JSON.parse(readFileSync('schema.json', 'utf8'));
const valida = new Ajv({ strict: false, validateFormats: false, allErrors: false }).compile(schema);

function controlla(testo) {
  const wf = parse(testo);
  const problemi = [];
  if (!valida(wf)) problemi.push(`schema: ${valida.errors[0].instancePath || '/'} ${valida.errors[0].message}`);
  for (const [nome, job] of Object.entries(wf.jobs ?? {})) {
    for (const dip of [].concat(job.needs ?? [])) if (!wf.jobs[dip]) problemi.push(`${nome}: needs "${dip}" non esiste`);
    for (const passo of job.steps ?? []) {
      if (passo.uses && !/@[0-9a-f]{40}$/.test(passo.uses)) problemi.push(`${nome}: ${passo.uses} non e' fissata a uno SHA`);
    }
  }
  return { wf, problemi };
}

const originale = readFileSync('workflow.yml', 'utf8');
const { wf, problemi } = controlla(originale);
console.log('workflow.yml: job =', Object.keys(wf.jobs).join(' -> '), '| problemi =', problemi.length);

const guasti = {
  'chiave inesistente (run-on)': originale.replace('runs-on: ubuntu-latest', 'run-on: ubuntu-latest'),
  'needs verso un job assente': originale.replace('needs: staging', 'needs: collaudo'),
  'azione fissata a un tag': originale.replace(/actions\/checkout@[0-9a-f]{40} # v7.0.1/, 'actions/checkout@v7'),
};
for (const [nome, testo] of Object.entries(guasti)) console.log(`guasto "${nome}":`, controlla(testo).problemi[0]);

// Output:
// workflow.yml: job = test -> staging -> production | problemi = 0
// guasto "chiave inesistente (run-on)": schema: /jobs/test must have required property 'runs-on'
// guasto "needs verso un job assente": production: needs "collaudo" non esiste
// guasto "azione fissata a un tag": test: actions/checkout@v7 non e' fissata a uno SHA

La prima riga dice che il workflow ha i tre job attesi e nessun problema. Le tre righe successive mostrano che ciascun guasto viene visto: la chiave run-on al posto di runs-on è bocciata dallo schema, perché a quel job manca la proprietà obbligatoria; il needs verso un job che non esiste e l'azione fissata a un tag sono invece intercettati dai controlli propri. Questo è un controllo di forma: non prova che il workflow funzioni su GitHub, che i segreti esistano o che deploy.sh faccia ciò che deve. Per i controlli più ricchi esistono strumenti dedicati, che qui non ho usato.

Cambiare versione senza un istante di vuoto

Il modo più semplice di avere un deploy atomico su un solo server è tenere ogni versione in una cartella (releases/r1, releases/r2) e far servire al web server il collegamento simbolico current. Cambiare versione vuol dire spostare quel collegamento. Il trucco è come lo si sposta: cancellarlo e ricrearlo sono due operazioni, e fra le due il percorso non esiste. La pagina di manuale di rename(2) dice invece che quando il nuovo nome esiste già viene sostituito in modo atomico, e che non c'è nessun momento in cui un altro processo lo trovi mancante. Quindi si crea un collegamento provvisorio e lo si sposta con rename sopra quello vecchio.

import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, renameSync, unlinkSync, readlinkSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const radice = mkdtempSync(join(tmpdir(), 'rilasci-'));
const corrente = join(radice, 'current');
const stato = () => (existsSync(corrente) ? readlinkSync(corrente).split('/').pop() : 'ASSENTE');

for (const id of ['r1', 'r2']) {
  mkdirSync(join(radice, 'releases', id), { recursive: true });
  writeFileSync(join(radice, 'releases', id, 'index.html'), `versione ${id}`);
}
symlinkSync(join(radice, 'releases', 'r1'), corrente);
console.log('inizio                      : current ->', stato());

// Cambio ingenuo: due operazioni. Fra la prima e la seconda il percorso non esiste.
unlinkSync(corrente);
console.log('ingenuo, dopo unlink        : current ->', stato());
symlinkSync(join(radice, 'releases', 'r2'), corrente);
console.log('ingenuo, dopo symlink       : current ->', stato());

// Cambio atomico: si prepara un collegamento nuovo e lo si sposta sopra quello vecchio con rename.
function attiva(id) {
  const provvisorio = join(radice, 'current.nuovo');
  symlinkSync(join(radice, 'releases', id), provvisorio);
  console.log(`atomico, collegamento pronto : current ->`, stato());
  renameSync(provvisorio, corrente);
}
attiva('r1');
console.log('atomico, dopo rename        : current ->', stato(), '(rollback = stessa operazione verso la versione precedente)');
rmSync(radice, { recursive: true });

// Output:
// inizio                      : current -> r1
// ingenuo, dopo unlink        : current -> ASSENTE
// ingenuo, dopo symlink       : current -> r2
// atomico, collegamento pronto : current -> r2
// atomico, dopo rename        : current -> r1 (rollback = stessa operazione verso la versione precedente)

L'uscita mostra la differenza stato per stato. Con il metodo ingenuo, dopo unlink il percorso risulta ASSENTE: in quell'istante qualunque richiesta troverebbe una cartella inesistente. Con il metodo atomico current punta a r2 mentre il collegamento nuovo è pronto, e passa a r1 in un solo passo con rename. Il rollback è la stessa operazione verso la versione precedente, ed è per questo che si tengono le ultime release invece di sovrascrivere. Il programma mostra gli stati, non misura la durata della finestra: la dimensione del vuoto nel caso ingenuo dipende dalla macchina, ed è irrilevante rispetto alla garanzia che con il metodo atomico non esiste.

Prima di spostare il traffico: «vivo» non vuol dire «pronto»

Un'istanza può essere viva (il processo gira) e non pronta (non ha ancora caricato la configurazione, sta chiudendo). Chi decide dove mandare il traffico, un bilanciatore o il tuo script di deploy, deve poter distinguere i due casi: per questo si espongono due percorsi. Per convenzione, quello di liveness risponde 200 finché il processo non è bloccato, e se fallisce l'istanza va riavviata; quello di readiness risponde 200 solo quando l'istanza può servire richieste, e quando si avvicina la chiusura passa a 503 per far uscire l'istanza dal traffico, mentre le richieste già in corso finiscono.

import Fastify from 'fastify';

const attesa = (ms) => new Promise((ok) => setTimeout(ok, ms));
const app = Fastify();
const processo = { inChiusura: false };

app.get('/livez', async () => ({ vivo: true }));
app.get('/readyz', async (req, reply) =>
  processo.inChiusura ? reply.code(503).send({ pronto: false }) : { pronto: true });
app.get('/lento', async () => { await attesa(50); return { completata: true }; });

const stato = async (url) => (await app.inject({ url })).statusCode;
console.log('avvio          : livez', await stato('/livez'), '| readyz', await stato('/readyz'));

const inCorso = app.inject({ url: '/lento' });           // una richiesta e' gia' in volo
processo.inChiusura = true;                              // arriva SIGTERM: si smette di dichiararsi pronti
console.log('dopo SIGTERM   : livez', await stato('/livez'), '| readyz', await stato('/readyz'));
console.log('richiesta in volo termina con', (await inCorso).statusCode, (await inCorso).body);

// Output:
// avvio          : livez 200 | readyz 200
// dopo SIGTERM   : livez 200 | readyz 503
// richiesta in volo termina con 200 {"completata":true}

Il modello riproduce il contratto, non il sistema operativo: un flag che cambia quando arriva il segnale di terminazione. All'avvio entrambi i percorsi rispondono 200; dopo il segnale livez resta 200 (il processo è vivo) e readyz diventa 503 (non mandarmi altro traffico); la richiesta lenta già partita termina con 200. Non ho provato la parte reale: l'ascolto del segnale, la chiusura ordinata del server HTTP e il tempo che il bilanciatore impiega a smettere di inviare traffico dopo il primo 503. Quel tempo va misurato sul tuo bilanciatore prima di decidere per quanto aspettare tra il primo 503 e lo spegnimento.

Il database: espandere, poi contrarre

Durante un deploy graduale la vecchia e la nuova versione girano insieme contro lo stesso database. Se la migrazione rinomina una colonna, una delle due si rompe in quel momento. La soluzione è dividere il cambiamento in due fasi, dette expand e contract: prima si aggiungono le nuove colonne, si copiano i dati e si distribuisce il nuovo codice; solo quando nessuna istanza vecchia gira più si toglie la colonna vecchia. Il programma sotto lo mostra con node:sqlite (SQLite 3.50.4 incluso in Node 24.11), spezzando la colonna nome_completo in nome e cognome, con le due versioni dell'applicazione ridotte alla loro query.

import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync(':memory:');
db.exec("CREATE TABLE utenti (id INTEGER PRIMARY KEY, nome_completo TEXT); INSERT INTO utenti VALUES (1, 'Ada Lovelace');");

// Le due versioni dell'applicazione, ridotte alla loro query.
const v1 = () => db.prepare('SELECT nome_completo AS n FROM utenti WHERE id = 1').get().n;
const v2 = () => { const r = db.prepare('SELECT nome, cognome FROM utenti WHERE id = 1').get(); return `${r.nome} ${r.cognome}`; };
const prova = (nome, f) => { try { return `${nome}: ${f()}`; } catch (e) { return `${nome}: ERRORE ${e.message}`; } };

console.log('1. schema iniziale        ', prova('v1', v1));

// Fase "expand": solo aggiunte. Il vecchio codice non se ne accorge, il nuovo puo' partire.
db.exec('ALTER TABLE utenti ADD COLUMN nome TEXT; ALTER TABLE utenti ADD COLUMN cognome TEXT;');
db.exec("UPDATE utenti SET nome = substr(nome_completo, 1, instr(nome_completo, ' ') - 1), cognome = substr(nome_completo, instr(nome_completo, ' ') + 1);");
console.log('2. dopo expand + backfill  ', prova('v1', v1), '|', prova('v2', v2));

// Fase "contract": si toglie il vecchio solo quando nessuna istanza v1 e' piu' in esecuzione.
db.exec('ALTER TABLE utenti DROP COLUMN nome_completo;');
console.log('3. dopo contract           ', prova('v2', v2), '|', prova('v1 (ancora attiva?)', v1));

// Output:
// 1. schema iniziale         v1: Ada Lovelace
// 2. dopo expand + backfill   v1: Ada Lovelace | v2: Ada Lovelace
// 3. dopo contract            v2: Ada Lovelace | v1 (ancora attiva?): ERRORE no such column: nome_completo

La seconda riga dell'uscita è il punto della tecnica: dopo l'aggiunta delle colonne e la copia, la versione 1 e la versione 2 leggono entrambe «Ada Lovelace» dallo stesso schema, e quindi puoi distribuire la 2 con calma e fare rollback alla 1 senza perdere nulla. La terza riga mostra cosa succede se la fase di contrazione arriva troppo presto: la versione 1 ancora attiva fallisce con l'errore vero di SQLite no such column: nome_completo. Una conseguenza da dichiarare: dopo la contrazione il rollback del codice non basta più, perché la colonna è sparita. Per questo la contrazione è un rilascio a sé, successivo, quando sei sicuro di non tornare indietro. Tieni presente che SQLite non è PostgreSQL o MySQL: la forma delle due fasi vale in generale, ma i costi di ALTER TABLE su tabelle grandi cambiano da un motore all'altro e vanno verificati sul tuo.

Che cosa questa prova non copre

Non ho eseguito il workflow su GitHub, non ho configurato ambienti né segreti, e non ho lanciato un deploy su un server. Non ho trattato il blue-green con due ambienti completi dietro un bilanciatore, né il canary che manda una percentuale di traffico alla nuova versione: sono estensioni naturali di quanto sopra (stessa idea di spostare il traffico solo dopo la readiness) ma richiedono un'infrastruttura che qui non c'è. Non ho misurato tempi di deploy o durate di build. E il cambio atomico con il collegamento simbolico vale su un solo server: con più server serve coordinarli, e quello è un problema diverso. I controlli degli articoli precedenti, come le intestazioni di sicurezza dell'articolo Sicurezza web o la raccolta dei dati di campo di Core Web Vitals, sono i candidati naturali per il passo npm test del job test: scritti come test con inject(), girano a ogni push senza aprire una porta.

Che cosa sai fare adesso, e la serie in otto pezzi

Sai scrivere un workflow a tre ambienti che costruisce una volta e distribuisce lo stesso artefatto, con azioni fissate a uno SHA, permessi minimi e deploy che non si cancellano a vicenda; sai validarlo nella forma prima del push; sai spostare una versione in modo atomico e tornare indietro; sai distinguere «vivo» da «pronto»; e sai dividere una migrazione in due rilasci. Con questo la serie si chiude. Gli otto articoli, in ordine:

  1. TypeScript essentials per fullstack dev: tipi strutturali, unioni discriminate, generics e il confine che i tipi non attraversano.
  2. Angular SSR: signals, componenti standalone, rendering sul server e routing con guardie.
  3. Spring Boot: Clean Architecture a quattro cartelle e REST API.
  4. GraphQL contro REST: i compromessi nella scelta del contratto fra client e server.
  5. Core Web Vitals: LCP, INP e CLS, e che cosa ne decide il server.
  6. Sicurezza web: ogni rischio OWASP assegnato al livello che deve fermarlo.
  7. Authentication: sessione, JWT, refresh token e OAuth con PKCE.
  8. CI/CD multi-ambiente: questo articolo.

Se segui l'ordine, i primi tre articoli costruiscono il prodotto (linguaggio, interfaccia, servizio), il quarto sceglie il contratto fra le due metà, e gli ultimi quattro lo rendono utilizzabile da persone reali: veloce, difeso, con un'identità e rilasciabile senza fermare il servizio. Si può leggere in disordine, ma l'ordine proposto va dal prodotto al suo uso reale. Tutti gli articoli sono raccolti nella pagina della serie.

Domande frequenti

Perché fissare le azioni a uno SHA e non a un tag?

Perché un tag si può spostare su un altro commit, mentre uno SHA completo identifica un contenuto preciso. La guida alla sicurezza di GitHub lo raccomanda: per falsificare uno SHA servirebbe una collisione SHA-1. Il commento con la versione tiene il file leggibile.

Perché non cancellare i deploy in corso quando ne parte uno nuovo?

Perché un deploy interrotto a metà può lasciare l'ambiente in uno stato misto. Con cancel-in-progress: false il deploy in corso finisce e quello nuovo aspetta; per i test di un pull request, invece, cancellare è comodo e sicuro.

Come faccio rollback dopo una migrazione che toglie una colonna?

Non puoi, se la colonna non c'è più: il vecchio codice fallirebbe. Per questo la migrazione si divide in due rilasci, con la contrazione solo dopo che la nuova versione è stabile e il rollback non serve più.

Fonti

I listati Node (cambio atomico, controlli di salute, migrazione, validazione) sono stati eseguiti con Node 24.11.0 e Fastify 5.12.8 (inject(), nessuna porta aperta), due volte con uscita identica; il cambio atomico opera su una cartella temporanea creata e rimossa dal programma. Il workflow è uno schema da adattare: validato nella forma, non eseguito su GitHub. Nessun deploy, container o servizio esterno è stato usato.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti