Ottimizzare le build Docker per applicazioni PHP: layer caching e immagini minimali
Il Dockerfile dell'applicazione Laravel di un cliente del settore e-commerce era un monolite di 45 righe che installava PHP 8.2, Nginx, Node.js, Composer, tutte le estensioni PHP, le dipendenze npm, compilava gli asset frontend, copiava l'intero codice sorgente, e alla fine eseguiva composer install. Ogni build richiedeva 8 minuti e 20 secondi in media sulla pipeline GitHub Actions - non perché il server CI fosse lento, ma perché il Dockerfile era scritto in un modo che invalidava la cache di Docker ad ogni singolo push, anche quando l'unica modifica era un commento in un controller. Otto minuti possono sembrare pochi in assoluto, ma il team faceva 12-15 push al giorno: 15 build da 8 minuti sono 2 ore al giorno di attesa della CI, tempo in cui gli sviluppatori perdono il contesto di ciò che stavano facendo e passano a un altro task - una delle principali cause di inefficienza nella developer experience.
Dopo l'ottimizzazione del Dockerfile - separazione delle dipendenze Composer dal codice applicativo per il layer caching, multi-stage build per separare la build degli asset dal container di runtime, e immagine finale Alpine invece di Debian - il tempo di build è sceso da 8 minuti e 20 secondi a 90 secondi per le build incrementali (quando solo il codice PHP è cambiato) e a 3 minuti per le build complete (quando anche le dipendenze Composer o npm sono cambiate). L'immagine finale è passata da 1,2 GB a 180 MB. Il team ha iniziato ad aspettare il feedback della CI invece di passare a un altro task - un cambiamento nel workflow quotidiano che ha avuto un impatto misurabile sulla produttività.
Perché la maggior parte dei Dockerfile per PHP è scritta in modo sbagliato?
Il problema è l'ordine delle istruzioni. Docker costruisce le immagini eseguendo ogni istruzione in sequenza e memorizzando il risultato come layer. Se un layer non è cambiato rispetto alla build precedente, Docker lo riusa dalla cache. Ma la cache funziona solo finché nessun layer precedente è cambiato: se il layer 3 è cambiato, tutti i layer dal 4 in poi vengono rieseguiti, anche se non sono cambiati. Il Dockerfile tipico che trovo nei progetti PHP copia l'intero codice sorgente prima di installare le dipendenze Composer - il che significa che qualsiasi modifica a qualsiasi file PHP invalida la cache di Composer e forza una reinstallazione completa delle dipendenze ad ogni build. Un composer install su un'applicazione Laravel con 80 pacchetti richiede 2-3 minuti e scarica 200 MB di dati - un'attesa inutile quando l'unica modifica è stata un typo in un commento.
La regola d'oro del layer caching in Docker è: ordina le istruzioni dalla meno frequentemente cambiata alla più frequentemente cambiata. Le dipendenze del sistema operativo (apt-get install) cambiano raramente - vanno in cima. Le dipendenze Composer e npm cambiano quando aggiungi o aggiorni un pacchetto - vanno nel mezzo. Il codice sorgente dell'applicazione cambia ad ogni push - va in fondo. Nel mio profilo professionale trovi il dettaglio dell'esperienza che porto nell'ottimizzazione delle pipeline CI/CD con Docker - un'area dove 30 minuti di lavoro sul Dockerfile possono risparmiare ore al giorno di attesa al team di sviluppo.
Il Dockerfile ottimizzato: multi-stage build con layer caching
Il pattern che uso per ogni applicazione Laravel containerizzata è un multi-stage build a tre fasi: una fase di build per le dipendenze PHP (Composer), una fase di build per gli asset frontend (Node.js + Vite), e una fase finale di runtime con solo il necessario per eseguire l'applicazione:
# Fase 1: dipendenze Composer (cambia solo quando composer.lock cambia)
FROM php:8.2-cli-alpine AS composer-deps
WORKDIR /app
# Installa le estensioni PHP necessarie per Composer
RUN apk add --no-cache libzip-dev icu-dev \
&& docker-php-ext-install zip intl pdo_mysql opcache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Copia SOLO i file di Composer - non tutto il codice
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
# Fase 2: asset frontend (cambia solo quando package.json o i file JS cambiano)
FROM node:20-alpine AS frontend-build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production=false
COPY resources/ resources/
COPY vite.config.js ./
RUN npm run build
# Fase 3: immagine di runtime (minimale, solo il necessario)
FROM php:8.2-fpm-alpine AS runtime
WORKDIR /var/www/html
# Estensioni PHP per il runtime
RUN apk add --no-cache libzip-dev icu-dev \
&& docker-php-ext-install zip intl pdo_mysql opcache bcmath
# Copia le dipendenze Composer dalla fase 1
COPY --from=composer-deps /app/vendor ./vendor
# Copia gli asset compilati dalla fase 2
COPY --from=frontend-build /app/public/build ./public/build
# ORA copia il codice sorgente dell'applicazione
COPY . .
# Genera l'autoloader ottimizzato con il codice completo
RUN composer dump-autoload --optimize --classmap-authoritative
# Configurazione OPcache per produzione
RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.memory_consumption=256" >> /usr/local/etc/php/conf.d/opcache.ini
# Utente non-root per sicurezza
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 9000
CMD ["php-fpm"]La chiave dell'ottimizzazione è nella sequenza COPY composer.json composer.lock → RUN composer install → COPY . (codice applicativo). Con questa sequenza, quando uno sviluppatore modifica un controller PHP, Docker vede che composer.json e composer.lock non sono cambiati e riusa il layer di Composer dalla cache - skip istantaneo di 2-3 minuti di build. Solo il layer COPY . e il successivo dump-autoload vengono rieseguiti, il che richiede circa 5-10 secondi. L'unico caso in cui Composer viene rieseguito è quando qualcuno modifica composer.json o composer.lock - cioè quando aggiunge o aggiorna un pacchetto, cosa che avviene 2-3 volte alla settimana, non 15 volte al giorno.
Lo stesso principio si applica alla fase frontend: COPY package.json package-lock.json → RUN npm ci → COPY resources/ → RUN npm run build. Le dipendenze npm vengono reinstallate solo quando cambia package-lock.json, mentre la build degli asset viene rieseguita solo quando cambiano i file in resources/ o vite.config.js.
Immagine Alpine vs Debian: la differenza è nelle dimensioni e nella superficie di attacco
L'immagine base php:8.2-fpm usa Debian Bookworm e pesa circa 500 MB. L'immagine php:8.2-fpm-alpine usa Alpine Linux e pesa circa 50 MB. La differenza di 450 MB si riflette su tre aspetti: tempo di pull dell'immagine (rilevante nella prima build su un server CI o in un deploy su un nodo Kubernetes nuovo), spazio disco sui registry e sui nodi, e superficie di attacco (Alpine include meno pacchetti e meno binari, riducendo il numero di potenziali vulnerabilità).
Il trade-off è la compatibilità: Alpine usa musl libc invece di glibc, e alcune estensioni PHP compilate per glibc non funzionano su Alpine senza ricompilazione. Le estensioni più comuni (pdo_mysql, redis, zip, intl, gd) funzionano perfettamente su Alpine. Le estensioni che possono dare problemi sono quelle che dipendono da librerie C specifiche di glibc - come alcune versioni di grpc e protobuf. La regola che applico è: usa Alpine come default, e passa a Debian solo se una specifica estensione richiede glibc e non ha un build alternativo per musl.
.dockerignore: il file che tutti dimenticano
Un errore che trovo nel 70% dei progetti Docker che eredito è l'assenza di un file .dockerignore adeguato. Senza .dockerignore, il COPY . . copia nell'immagine Docker tutto il contenuto della directory del progetto - inclusi node_modules/ (centinaia di megabyte), .git/ (l'intera storia del repository), storage/logs/ (log di produzione potenzialmente contenenti dati sensibili), file .env (credenziali in chiaro), e test che non servono nel container di produzione. Oltre ad aumentare inutilmente le dimensioni dell'immagine, questo è un rischio di sicurezza: un attaccante che ottiene accesso al registry Docker può estrarre credenziali e dati sensibili dall'immagine.
Il .dockerignore minimo per un'applicazione Laravel è:
.git
.env
.env.*
node_modules
storage/logs
storage/framework/cache
tests
.github
docker-compose*.yml
*.mdQuesto file riduce il contesto di build da 500+ MB a 20-30 MB (solo il codice sorgente e le configurazioni), accelerando il COPY . . da 15-20 secondi a 1-2 secondi e riducendo le dimensioni dell'immagine finale del 10-15%. Ho documentato un approccio analogo all'ottimizzazione dei container nel mio articolo sul deployment Docker su VPS con pattern e anti-pattern, dove il principio è lo stesso: ogni byte inutile nell'immagine è un costo in storage, in banda e in superficie di attacco.
BuildKit e cache mount: la leva finale per build sotto il minuto
Docker BuildKit (abilitato di default da Docker 23+) introduce i cache mount - una funzionalità che permette di persistere la cache di Composer e npm tra le build successive anche quando il layer è stato invalidato. Senza cache mount, quando composer.lock cambia e il layer di Composer viene rieseguito, Composer scarica tutti i pacchetti da zero - circa 200 MB di download e 2-3 minuti di attesa. Con un cache mount, la cache dei pacchetti già scaricati viene preservata in un volume locale e Composer scarica solo i pacchetti nuovi o aggiornati - riducendo il tempo di composer install da 2-3 minuti a 20-30 secondi nella maggior parte dei casi.
La sintassi nel Dockerfile richiede il pragma BuildKit nella prima riga e l'opzione --mount=type=cache nell'istruzione RUN. La cache viene salvata in una directory del Docker daemon (non nell'immagine) e persiste tra le build - è il meccanismo più efficace per accelerare le build che modificano le dipendenze. L'unico vincolo è che il cache mount funziona solo sulla stessa macchina (non è condiviso tra server CI diversi), quindi se il CI/CD usa runner effimeri (come i runner hosted di GitHub Actions), il beneficio è limitato. Per i runner self-hosted - che è la configurazione che uso per i clienti con build frequenti - il cache mount fa la differenza tra una build da 3 minuti e una build sotto il minuto.
Un altro aspetto di BuildKit che sfrutto nelle pipeline CI è la build multi-piattaforma con --platform linux/amd64,linux/arm64. Hetzner Cloud offre istanze ARM (CAX) a un prezzo significativamente inferiore rispetto alle istanze x86, ma l'immagine Docker deve essere compilata per l'architettura corretta. Con BuildKit e il driver docker-buildx, una singola pipeline CI produce immagini per entrambe le architetture e le pubblica come manifest list nel registry - il nodo Kubernetes fa pull dell'immagine corretta automaticamente in base alla propria architettura. Questo pattern è particolarmente utile per i cluster k3s Hetzner che ho descritto nel mio articolo su Kubernetes su Hetzner Cloud, dove i nodi ARM costano il 40% in meno a parità di vCPU.
La dimensione finale dell'immagine è l'ultimo indicatore che monitoro dopo ogni ottimizzazione. Un'immagine Docker di produzione per un'applicazione Laravel ottimizzata dovrebbe pesare tra 150 e 250 MB con Alpine, incluse tutte le estensioni PHP e le dipendenze Composer, ma escludendo Node.js (che vive solo nella fase di build, non nel runtime). Se la tua immagine pesa più di 500 MB, ci sono quasi certamente layer inutili o dipendenze di build rimaste nel container di runtime - il multi-stage build è la soluzione sistematica per eliminare tutto ciò che serve solo durante la compilazione.
Il progetto del cliente e-commerce con il Dockerfile ottimizzato è in produzione da 12 mesi. Il team fa 12-15 deploy al giorno con feedback CI in 90 secondi, l'immagine Docker pesa 180 MB invece di 1,2 GB, e il tempo totale di deploy - dalla push su GitHub al container in produzione su Kubernetes - è sceso da 12 minuti a 3 minuti. Se le tue build Docker per applicazioni PHP sono lente e le immagini sono pesanti, contattami per una sessione di ottimizzazione: in mezza giornata riscriviamo il Dockerfile con multi-stage build e layer caching ottimizzato, configuriamo il .dockerignore, e misuriamo il delta di tempo e dimensioni prima e dopo.