Containerizzare Laravel con Docker: dallo sviluppo alla produzione

Containerizzare Laravel con Docker: dallo sviluppo alla produzione

"Sul mio computer funziona" è la frase che precede la maggior parte delle nottate passate a inseguire un bug che esiste solo in produzione. È il sintomo di una malattia precisa: l'ambiente in cui lo sviluppatore scrive il codice e quello in cui il codice gira davvero sono diversi, e quella differenza si nasconde finché non esplode nel posto peggiore, sul server, davanti agli utenti. Docker esiste per curare questa malattia, ma solo se usato con metodo: un docker-compose.yml copiato da un tutorial non garantisce nulla, e anzi spesso introduce una falsa sensazione di sicurezza. In un progetto di modernizzazione per una PMI mi capita spesso di trovare applicazioni Laravel "dockerizzate" in cui l'ambiente di sviluppo e quello di produzione condividono il logo di Docker e nient'altro. Containerizzare bene un'applicazione Laravel significa costruire un setup dove dev e produzione sono identici dove conta e diversi solo dove devono, eliminando alla radice la classe di bug che si manifestano solo sul server. In questo articolo mostro come farlo in modo pulito: l'ambiente di sviluppo riproducibile, il Dockerfile multi-stage per la produzione, la gestione separata di code, scheduler e cache, e i punti precisi in cui dev e prod devono divergere.

Perché lo stesso setup Docker elimina una classe di bug?

Perché la maggior parte dei bug "che si vedono solo in produzione" non sono bug del codice, sono differenze d'ambiente, e containerizzare bene le azzera. Quando lo sviluppatore gira PHP 8.3 sul portatile e il server ha PHP 8.4, quando in locale c'è un'estensione che in produzione manca, quando la versione di una libreria di sistema differisce, il codice si comporta in modo diverso nei due posti. Questi non sono errori di programmazione, sono derive ambientali, e sono subdoli perché non si manifestano dove li puoi vedere mentre sviluppi. Un container porta con sé il proprio ambiente, la versione di PHP, le estensioni, le librerie di sistema: se il container che giri in locale è costruito dallo stesso Dockerfile di quello in produzione, quelle derive spariscono per costruzione.

Il principio che guida tutto è la parità tra ambienti: dev e produzione devono essere il più possibile identici nelle cose che influenzano il comportamento del codice, e diversi solo nelle cose che riguardano la comodità dello sviluppo o la sicurezza della produzione. La versione di PHP, le estensioni, la struttura dell'applicazione devono essere uguali; il montaggio del codice come volume, il debugger, il livello di log possono e devono differire. La distinzione tra questi due insiemi, cosa deve essere identico e cosa deve divergere, è il cuore di una containerizzazione fatta bene, ed è esattamente ciò che un compose copiato non ti dà, perché non è stato pensato per la tua applicazione.

La parità d'ambiente non significa che dev e produzione siano identici in tutto, significa che sono identici in tutto ciò che cambia il comportamento del codice. La comodità di sviluppo e la sicurezza di produzione sono le sole cose che possono e devono divergere, e governare quel confine è il mestiere.

Come faccio un ambiente di sviluppo Laravel riproducibile?

Lo costruisci con un Docker Compose che mette insieme i servizi che l'applicazione usa, montando il codice come volume così le modifiche sono immediate. In sviluppo la priorità è l'iterazione veloce: vuoi modificare un file e vederne subito l'effetto, senza ricostruire l'immagine a ogni cambiamento. Per questo, in dev, il codice si monta come volume dentro il container, e i servizi di contorno, il database, Redis, vivono ognuno nel proprio container orchestrato da Compose.

# docker-compose.yml (sviluppo)
services:
  app:
    build: { context: ., target: dev }
    volumes: [".:/var/www"]
    environment: { APP_ENV: local, APP_DEBUG: "true" }
  db:
    image: mysql:8.4
    environment: { MYSQL_DATABASE: app }
  redis:
    image: redis:7

Il valore di questo setup è che un nuovo sviluppatore che entra nel progetto clona il repository, lancia un comando, e ha l'intero ambiente funzionante in pochi minuti, identico a quello di tutti gli altri: stessa versione di PHP, stesso database, stessa cache. Sparisce la giornata persa a configurare l'ambiente locale, e sparisce la classe di problemi del tipo "a te funziona perché hai una versione diversa". Il database e Redis girano come servizi effimeri, ricreabili in qualunque momento, il che incoraggia anche una buona pratica: non affezionarsi allo stato locale, ma poterlo ricostruire da zero. Questa riproducibilità è la base su cui ho costruito i flussi di provisioning e deploy che ho descritto nell'articolo sull'automazione del deploy Laravel su server Linux.

Cosa cambia nel Dockerfile di produzione?

Cambia tutto ciò che riguarda l'ottimizzazione e la sicurezza: il codice viene cotto dentro l'immagine, le dipendenze di sviluppo spariscono, gli asset sono compilati e le cache di Laravel sono pre-generate. Il Dockerfile di produzione è multi-stage, e produce un artefatto immutabile e leggero. Lo stage di build installa le dipendenze con Composer senza quelle di sviluppo, compila gli asset front-end con il sistema di build di Laravel, e prepara l'applicazione; lo stage di runtime parte da un'immagine minimale con PHP-FPM e copia solo il risultato.

Le ottimizzazioni specifiche di Laravel in produzione fanno una differenza concreta di prestazioni. Il comando di ottimizzazione di Laravel pre-genera le cache di configurazione, delle rotte e delle viste: invece di leggere e interpretare decine di file di configurazione a ogni richiesta, l'applicazione carica una cache compilata, e su un'applicazione reale questo si traduce in una latenza sensibilmente più bassa. A questo si aggiunge OPcache di PHP, che tiene in memoria il bytecode compilato evitando di ricompilare il codice a ogni richiesta. In produzione queste cache sono sempre attive; in sviluppo sono sempre disattivate, perché altrimenti le modifiche al codice non si vedrebbero, ed è uno dei punti dove i due ambienti devono divergere. Il codice, in produzione, vive dentro l'immagine e non come volume: questo rende l'immagine un artefatto versionato e il rollback un'operazione banale, si torna all'immagine precedente e si ha esattamente lo stato di prima. Le versioni vanno verificate: per un'applicazione Laravel attuale, la base è PHP 8.4 secondo la documentazione ufficiale di Laravel.

Come gestisco code, scheduler e cache in un'architettura a container?

Li gestisci come processi separati, perché un'applicazione Laravel in produzione non è un solo processo ma diversi, e infilarli tutti nello stesso container è l'anti-pattern classico. Il container PHP-FPM serve le richieste web, ma Laravel ha altri due ruoli che vanno fatti girare a parte. Il primo sono le code: i job in coda vengono elaborati da un queue worker, un processo a lunga vita che va in un proprio container, replicabile per gestire più carico, e monitorato perché se muore i job si accumulano. Mettere il worker nello stesso container del web è sbagliato, perché i due hanno cicli di vita e necessità di scaling diversi.

Il secondo ruolo è lo scheduler, le attività pianificate di Laravel, che richiede che il comando di schedule venga invocato ogni minuto: in un'architettura a container questo si realizza con un container dedicato che esegue lo scheduler a intervalli regolari, non con un cron sparso sul sistema host. Infine la cache e le sessioni: in sviluppo si può tollerare la cache su file, ma in produzione, e ancora di più in un'architettura con più container o più nodi, cache e sessioni devono stare in un servizio condiviso come Redis. Il motivo è semplice e spesso scoperto troppo tardi: se la sessione è su file e hai due container web, un utente che viene servito ora da uno e ora dall'altro perde la sessione, perché i file non sono condivisi. Centralizzare lo stato in Redis è ciò che permette di scalare orizzontalmente senza rompere l'esperienza utente. Su come blindare questi container ho scritto nella guida all'hardening dei container Docker per Laravel e Symfony, e se vuoi una mano a progettare questa separazione sul tuo progetto, nel mio profilo professionale trovi l'esperienza concreta su architetture Laravel in produzione.

Come gestisco gli asset front-end senza appesantire l'immagine?

Li compili nello stage di build e copi solo il risultato, così Node e le sue dipendenze non finiscono mai nell'immagine di produzione. Un'applicazione Laravel moderna ha un front-end costruito con un sistema di build basato su Node, che prende i file sorgente di JavaScript e CSS e produce i bundle ottimizzati per il browser. Il punto delicato è che Node, con la sua intera catena di dipendenze, serve solo per costruire quegli asset, non per servirli: una volta compilati, i file statici risultanti sono tutto ciò che la produzione deve contenere. Infilare Node nell'immagine di runtime è l'errore che gonfia le immagini di centinaia di megabyte inutili e aggiunge superficie d'attacco senza alcun beneficio.

La soluzione è la stessa logica multi-stage applicata al front-end: nello stage di build si installa Node, si compilano gli asset, e nello stage di runtime si copia solo la cartella degli asset compilati. Il container di produzione finisce così per contenere PHP-FPM, il codice dell'applicazione e gli asset statici già pronti, e nient'altro: niente Composer, niente Node, niente toolchain. È un'immagine che fa esattamente una cosa, servire l'applicazione, ed è leggera, veloce da distribuire e con una superficie minima. Questo principio, separare ciò che serve a costruire da ciò che serve a eseguire, è il filo conduttore di tutta la containerizzazione fatta bene, e vale per le dipendenze PHP tanto quanto per gli asset front-end.

Cosa controllo al momento del deploy?

Controllo che le migrazioni del database e l'attivazione delle nuove cache avvengano nell'ordine giusto, perché è lì che un deploy containerizzato può rompersi in modi sottili. Quando distribuisci una nuova immagine, ci sono operazioni che vanno eseguite fuori dal semplice avvio dei container: le migrazioni dello schema del database, la rigenerazione delle cache di configurazione, lo svuotamento delle cache vecchie. Il punto critico è l'ordine e la coordinazione: se hai più container web e ne aggiorni uno alla volta per evitare downtime, per un breve istante convivono codice vecchio e nuovo, e una migrazione che cambia lo schema in modo incompatibile con il codice ancora in esecuzione può causare errori.

La pratica corretta è progettare le migrazioni in modo che siano compatibili con entrambe le versioni del codice durante la transizione, eseguirle come passo esplicito del deploy e non come effetto collaterale dell'avvio di un container, e gestire i queue worker con attenzione, perché un worker che esegue codice vecchio mentre la coda contiene job nel formato nuovo è un'altra fonte di problemi sottili. Questi non sono dettagli da specialisti: sono esattamente i punti dove un deploy "che dovrebbe funzionare" si rompe in produzione sotto carico, e prevederli è la differenza tra un rilascio sereno e una notte di rollback. Un'architettura container ben progettata rende questi passaggi espliciti e ripetibili, invece di affidarli alla speranza che l'ordine giusto capiti per caso.

Dove dev e produzione devono divergere?

Devono divergere esattamente nei punti che riguardano la comodità di sviluppo e la sicurezza di produzione, e tenere questi punti sotto controllo esplicito è ciò che evita le sorprese. Il primo punto è il debug: in sviluppo APP_DEBUG è attivo e mostra gli errori dettagliati con lo stack completo, indispensabile per lavorare; in produzione è tassativamente disattivato, perché un errore dettagliato esposto agli utenti rivela struttura interna, percorsi e a volte credenziali, ed è una vulnerabilità di information disclosure seria. Il secondo è il debugger: strumenti come Xdebug sono preziosi in sviluppo e vanno assolutamente esclusi dall'immagine di produzione, sia per le prestazioni, perché rallentano sensibilmente, sia per la superficie d'attacco.

Il terzo punto, già visto, sono le cache di ottimizzazione: attive in produzione per la velocità, disattivate in sviluppo perché altrimenti nasconderebbero le modifiche al codice. Il quarto è il montaggio del codice: volume in sviluppo per l'iterazione immediata, codice cotto nell'immagine in produzione per l'immutabilità. Il quinto è il livello di log: verboso in sviluppo, mirato in produzione, e indirizzato a un sistema di raccolta centralizzata quando i container sono più d'uno. La cosa importante è che ognuna di queste divergenze sia una scelta dichiarata e gestita tramite la configurazione e gli stage del Dockerfile, non un effetto collaterale casuale: la differenza tra un setup professionale e uno improvvisato è proprio che nel primo sai esattamente in cosa i due ambienti differiscono e perché, mentre nel secondo lo scopri quando qualcosa si rompe.

Il filo che lega tutto è che containerizzare Laravel bene non significa avere un container, significa avere un'architettura dove la parità d'ambiente elimina i bug ambientali, i diversi ruoli dell'applicazione, web, code, scheduler, vivono in processi separati e scalabili, lo stato è centralizzato per poter crescere, e le poche divergenze necessarie tra sviluppo e produzione sono governate in modo esplicito invece che subite. Un docker-compose.yml copiato da un tutorial ti dà l'illusione di tutto questo senza la sostanza, ed è per quello che le applicazioni "dockerizzate male" continuano a produrre i bug da cui Docker doveva proteggerle. La differenza è metodo, non strumenti, e si vede non quando tutto va bene ma quando arriva il carico, l'aggiornamento o l'emergenza. Se hai un'applicazione Laravel da containerizzare o un setup Docker che ti dà più problemi di quanti ne risolva, contattami per una consulenza diretta: partiamo da come gira oggi la tua applicazione e costruiamo un'architettura container in cui dev e produzione finalmente si parlano.

Ultima modifica: