+39 3662317539/+34 620899163
info@simonecosci.com

Docker multi-stage: immagini PHP senza compilatori dentro

Sviluppo Siti Web a Tenerife

Docker multi-stage: immagini PHP senza compilatori dentro

C’è un’immagine Docker che gira in produzione e pesa 900 MB. Dentro ha Composer, git, gcc, gli header di sviluppo di libpng e una copia della cartella .git del progetto. Niente di tutto questo serve per rispondere a una richiesta HTTP: serviva per costruire l’applicazione, non per eseguirla.

Il multi-stage build è il modo documentato per separare le due cose. Non è una tecnica avanzata: sono due FROM invece di uno.

Perché cancellare i file non basta

La prima soluzione che viene in mente è installare quello che serve, usarlo e poi rimuoverlo:

RUN apt-get update && apt-get install -y git unzip \
 && composer install --no-dev \
 && apt-get purge -y git unzip

Non funziona, e la documentazione di Docker lo dice in una riga sola. Sulla pagina degli storage driver: “both adding, and removing files will result in a new layer. In the example above, the $HOME/.cache directory is removed, but will still be available in the previous layer and add up to the image’s total size.”

Ogni istruzione del Dockerfile produce un layer, e ogni layer tranne l’ultimo è in sola lettura (“Each layer except the very last one is read-only”). Cancellare un file in un layer successivo lo nasconde alla vista: il contenuto resta nel layer sotto, e chi scarica l’immagine lo scarica comunque. Vale per i pacchetti, vale per la cache di Composer, vale per il file .env copiato per sbaglio e rimosso alla riga dopo — quello è ancora lì, e si tira fuori senza fatica.

Due stage: uno costruisce, uno esegue

L’idea del multi-stage è che il Dockerfile contenga più FROM, e che l’immagine finale sia solo l’ultimo. Dalla documentazione: “With multi-stage builds, you use multiple FROM statements in your Dockerfile”, e “you can selectively copy artifacts from one stage to another, leaving behind everything you don’t want in the final image.”

Per un’applicazione PHP con le dipendenze Composer:

# ---- stage 1: build ----
FROM php:8.4-cli AS build

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
ENV COMPOSER_ALLOW_SUPERUSER=1

WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader \
      --no-interaction --no-progress --no-scripts

COPY . .
RUN composer dump-autoload --no-dev --optimize

# ---- stage 2: runtime ----
FROM php:8.4-fpm-alpine

COPY --from=build /app /var/www/html
WORKDIR /var/www/html

Lo stage si chiama con AS name: “Optionally a name can be given to a new build stage by adding AS name to the FROM instruction.” Il nome poi si usa in COPY --from=<name>, e serve proprio a questo — se domani riordini le istruzioni, la COPY non si rompe, mentre con l’indice numerico (--from=0) sì.

COPY --from funziona anche con un’immagine esterna, non solo con uno stage: è quello che fa la riga di Composer. L’immagine ufficiale di Composer è pensata esattamente per questo uso — i maintainer scrivono di non voler “encourage using Composer as a base image or a production image”, e l’esempio che pubblicano è COPY --from=composer /usr/bin/composer /usr/bin/composer. Nel Dockerfile qui sopra c’è composer:2, non composer: il tag mobile latest è comodo in locale e velenoso in una build riproducibile.

Nell’immagine finale ci sono i vendor e il codice. Composer no. Nemmeno i layer in cui è passato: “The Go SDK and any intermediate artifacts are left behind, and not saved in the final image” — nell’esempio della documentazione è Go, il meccanismo è lo stesso per PHP.

L’ordine delle COPY non è casuale

Nel Dockerfile sopra ci sono due COPY distinte: prima composer.json e composer.lock, poi tutto il resto. Non è pedanteria. La cache delle build invalida un layer e tutti quelli che vengono dopo: se copi l’intero progetto prima di composer install, ogni virgola cambiata in un template fa rifare l’installazione delle dipendenze. Copiando prima i due file del lockfile, composer install si rifà solo quando cambiano davvero le dipendenze.

Sulle opzioni, dalla documentazione di Composer: --no-dev “skip installing packages listed in require-dev. The autoloader generation skips the autoload-dev rules”, cioè PHPUnit e i suoi amici restano fuori dall’immagine di produzione; --optimize-autoloader “convert PSR-0/4 autoloading to classmap to get a faster autoloader. This is recommended especially for production, but can take a bit of time to run so it is currently not done by default”. E COMPOSER_ALLOW_SUPERUSER, che sembra una scorciatoia sospetta ma è documentata proprio per questo caso: “you should really only set this if you use Composer as a super user at all times like in docker containers.”

Un dettaglio che si paga caro se lo si ignora: install usa il lockfile quando c’è — “If there is a composer.lock file in the current directory, it will use the exact versions from there instead of resolving them”. Quindi composer.lock va committato e va copiato nell’immagine. Senza, la build di stamattina e quella di stasera possono avere versioni diverse.

Estensioni PHP: il caso in cui serve davvero

Finché installi solo dipendenze Composer il guadagno è modesto. Diventa consistente quando servono estensioni compilate. L’immagine ufficiale PHP fornisce tre script — “We provide the helper scripts docker-php-ext-configure, docker-php-ext-install, and docker-php-ext-enable to more easily install PHP extensions” — ma docker-php-ext-install compila, e per compilare servono i pacchetti -dev, che pesano.

FROM php:8.4-fpm-alpine AS ext

RUN apk add --no-cache --virtual .build-deps \
        $PHPIZE_DEPS libpng-dev libzip-dev \
 && docker-php-ext-install gd zip pdo_mysql \
 && apk del .build-deps

Qui il trucco di --virtual + apk del nello stesso RUN funziona davvero, perché è un layer solo. La stessa cosa spezzata su due RUN no. È la stessa regola che la documentazione PHP applica ai sorgenti: “if you do use docker-php-source to extract the source, be sure to delete it in the same layer of the docker image.”

E se compili un’estensione da PECL, mettici la versione: “It is strongly recommended that users use an explicit version number in their pecl install invocations to ensure proper PHP version compatibility.”

--target: fermarsi a metà

Gli stage non servono solo a buttare via roba. Servono anche ad avere due immagini dallo stesso Dockerfile. docker build --target build si ferma allo stage build: “When building a Dockerfile with multiple build stages, use the --target option to specify an intermediate build stage by name as a final stage for the resulting image.”

Da qui viene l’immagine di sviluppo — con Composer, Xdebug e i pacchetti require-dev — e l’immagine di produzione, dallo stesso file, con le stesse versioni di base. Un Dockerfile solo, due destinazioni.

E non paghi per gli stage che non usi: fra le capacità di BuildKit c’è “detect and skip executing unused build stages”, oltre a “parallelize building independent build stages”. Due stage indipendenti si costruiscono insieme.

Cosa evitare

  • Non passare segreti con --build-arg o ENV. La documentazione è esplicita: “Build arguments and environment variables are inappropriate for passing secrets to your build, because they persist in the final image.” Un token di un repository privato passato così resta nell’immagine. La strada è RUN --mount=type=secret, che rende il segreto disponibile “temporarily … for the duration of the build instruction”.
  • Non usare la cache di Composer come se fosse un layer. Per quello c’è RUN --mount=type=cache, e il suo contenuto non finisce nell’immagine. Con un avvertimento dalla documentazione: “Your build should work with any contents of the cache directory as another build may overwrite the files or GC may clean it if more storage space is needed.” La cache accelera, non è una dipendenza.
  • Non copiare la cartella del progetto senza un .dockerignore. COPY . . porta dentro .git, node_modules, i dump SQL e il .env locale. Il .dockerignore serve a “exclude files and directories from the build context”, e nel multi-stage conta doppio: quello che non entra nel contesto non può finire in nessuno stage.
  • Non spedire l’immagine senza un php.ini. L’immagine ufficiale non ne installa uno: ci sono php.ini-development e php.ini-production, e la documentazione avverte che “it is strongly recommended to use the production config for images used in production environments!”

In pratica

Se hai un’immagine PHP costruita con un FROM solo, la prova è rapida: entra nel container ed esegui which composer git gcc. Se rispondono, stai spedendo in produzione strumenti che servivano solo a costruire — peso inutile da scaricare a ogni deploy, e superficie di attacco che nessuno ha chiesto.


Fonti (verificate mentre scrivevo): Multi-stage builds · Dockerfile reference · Storage drivers · BuildKit · Build secrets · docker buildx build · immagine ufficiale PHP · immagine ufficiale Composer · Composer CLI