Skip to main content
infrastructure7 dicembre 20256 min di lettura

Configurazione della Pipeline CI/CD Che Funziona Davvero in Produzione

Modelli CI/CD collaudati per progetti reali — dai workflow di GitHub Actions alle strategie di deployment che non mandano in crash la produzione di venerdì.

cicdgithub-actionsdevops
Configurazione della Pipeline CI/CD Che Funziona Davvero in Produzione

La prima pipeline CI/CD che ho configurato per un progetto in produzione era un singolo workflow di GitHub Actions che eseguiva npm test e poi faceva il deployment su un VPS via SSH. Ha funzionato finché non ha smesso di farlo — un deployment fallito ha lasciato il server in uno stato semi-aggiornato un venerdì sera, e ho passato il weekend a ripristinare manualmente i file. Quell'esperienza mi ha insegnato che una pipeline di deployment non è solo "esegui i test e poi fai il deployment". È l'intero sistema di controlli, gate e meccanismi di rollback che si frappongono tra un git push e i tuoi utenti che vedono il nuovo codice.

Questo post copre l'architettura della pipeline che ho affinato attraverso molteplici progetti in produzione, con esempi concreti di GitHub Actions che puoi adattare.

Architettura della Pipeline

Una pipeline di produzione ha fasi distinte, e ogni fase ha uno scopo specifico. Saltare le fasi fa risparmiare minuti ora e costa ore dopo.

Le Fasi

Code Push
  │
  ├─→ Fase 1: Validazione (lint, format, type-check)
  │
  ├─→ Fase 2: Testing (unit, integration)
  │
  ├─→ Fase 3: Build (compilazione, bundling, containerizzazione)
  │
  ├─→ Fase 4: Deployment in Staging
  │
  ├─→ Fase 5: Smoke Tests / E2E in Staging
  │
  └─→ Fase 6: Deployment in Produzione

Ogni fase agisce come un gate. Se la validazione fallisce, i test non vengono mai eseguiti. Se i test falliscono, il build non inizia mai. Questo risparmia tempo di calcolo e fornisce un feedback rapido — gli sviluppatori sanno entro 30 secondi se hanno dimenticato di eseguire il linter, invece di aspettare 8 minuti che una suite di test fallisca.

Strategia di Trigger

Non ogni push richiede la pipeline completa. Ecco la configurazione del trigger che utilizzo:

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]
    types: [opened, synchronize, reopened]

Le pull request eseguono le fasi 1-3 (validazione, test, build). Le merge su develop fanno il deployment in staging. Le merge su main fanno il deployment in produzione. Questo mantiene il feedback delle PR veloce, garantendo al contempo che i deployment avvengano solo da rami protetti.

Fase 1: Validazione

La validazione rileva le incongruenze di formattazione e gli errori di tipo prima che i test vengano eseguiti. Questi controlli sono veloci (meno di 30 secondi) e catturano i problemi più comuni.

jobs:
  validate:
    name: Valida
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configura Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Installa dipendenze
        run: npm ci

      - name: Controlla formattazione
        run: npx prettier --check "src/**/*.{ts,tsx,js,jsx,json,css}"

      - name: Lint
        run: npx eslint src/ --max-warnings 0

      - name: Controllo tipi
        run: npx tsc --noEmit

La Regola --max-warnings 0

ESLint distingue tra errori (che fanno fallire il processo) e avvisi (che non lo fanno). Senza --max-warnings 0, i team accumulano centinaia di avvisi che tutti ignorano. Trattare gli avvisi come errori in CI costringe il team a risolverli o a disabilitare esplicitamente la regola. Nessuna via di mezzo.

Formattazione come Controllo CI, Non Solo un Suggerimento

Eseguire Prettier in CI (con --check, non --write) garantisce una formattazione coerente senza fare affidamento sul fatto che ogni sviluppatore abbia l'estensione dell'editor giusta. Se la formattazione fallisce in CI, lo sviluppatore esegue npx prettier --write . localmente e commette la correzione. Questo non è negoziabile — i dibattiti sulla formattazione finiscono quando uno strumento prende le decisioni.

Fase 2: Testing

Il testing è la spina dorsale della pipeline. Divido i test in job paralleli basati sul tipo per un feedback più rapido.

  unit-tests:
    name: Test Unitari
    runs-on: ubuntu-latest
    needs: validate
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run unit tests
        run: npx vitest run --coverage --reporter=verbose

      - name: Carica report di copertura
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

  integration-tests:
    name: Test di Integrazione
    runs-on: ubuntu-latest
    needs: validate
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7
        ports:
          - 6379:6379
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Esegui migrazioni database
        run: npx prisma migrate deploy
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/testdb

      - name: Esegui test di integrazione
        run: npx vitest run --config vitest.integration.config.ts
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/testdb
          REDIS_URL: redis://localhost:6379

Container di Servizio per i Test di Integrazione

I container di servizio di GitHub Actions sono sottoutilizzati. Invece di simulare il tuo database nei test di integrazione (che testano la simulazione, non il tuo codice), avvia un'istanza PostgreSQL reale. Il blocco services gestisce il ciclo di vita — il container si avvia prima dei tuoi test e si ferma dopo.

Questo aggiunge circa 15-20 secondi al job per l'avvio del container, ma la fiducia che ottieni testando contro un database reale ne vale la pena.

Esecuzione Parallela dei Test

I test unitari e i test di integrazione vengono eseguiti in parallelo (entrambi needs: validate, non needs: unit-tests). Questo riduce il tempo totale della pipeline. Se i tuoi test unitari richiedono 2 minuti e i test di integrazione 4 minuti, l'esecuzione parallela significa che aspetti 4 minuti invece di 6.

Fase 3: Build

La fase di build convalida che il progetto compili e produca artefatti deployabili.

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: [unit-tests, integration-tests]
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Costruisci applicazione
        run: npm run build
        env:
          NODE_ENV: production

      - name: Carica artefatti di build
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
          retention-days: 7

Caching del Build

Per i deployment basati su Docker, il caching dei layer accelera drasticamente i build:

  build-docker:
    name: Costruisci Immagine Docker
    runs-on: ubuntu-latest
    needs: [unit-tests, integration-tests]
    steps:
      - uses: actions/checkout@v4

      - name: Configura Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Accedi al Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Costruisci e pusha
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

Il cache-from: type=gha utilizza la cache di GitHub Actions per memorizzare i layer Docker tra le esecuzioni. Per un'applicazione Node.js tipica, questo riduce il tempo di build da 3-4 minuti a 30-60 secondi per le modifiche solo alle dipendenze.

Best Practice Dockerfile per CI

La struttura del Dockerfile influisce direttamente sull'efficienza della cache di build. Ordina i layer dal meno frequentemente modificato al più frequentemente modificato:

FROM node:20-alpine AS base

# Dipendenze di sistema (cambiano raramente)
RUN apk add --no-cache libc6-compat

# Manifest dei pacchetti (cambiano quando cambiano le dipendenze)
WORKDIR /app
COPY package.json package-lock.json ./

# Installa dipendenze (cached a meno che i manifest non cambino)
FROM base AS deps
RUN npm ci --production

FROM base AS build
RUN npm ci
COPY . .
RUN npm run build

# Immagine di produzione
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./

EXPOSE 3000
CMD ["node", "dist/server.js"]

I build multi-stage mantengono l'immagine finale piccola (nessuna dipendenza di sviluppo, nessun codice sorgente), e l'ordinamento dei layer assicura che npm ci venga eseguito solo quando package.json o package-lock.json cambiano.

Fase 4: Deployment in Staging

Il deployment in staging avviene automaticamente sulle merge al ramo develop. L'ambiente di staging dovrebbe rispecchiare la produzione il più fedelmente possibile — stessa infrastrutt

DU

Danil Ulmashev

Full Stack Developer

Interessato a collaborare?