Da Alpine a Distroless: immagini container sicure per applicazioni Spring Boot
Un report di scansione di duecento pagine, quasi tutte sullo stesso strato: quello dell'immagine base. Non erano vulnerabilità dell'applicazione, erano venti pacchetti di un sistema operativo che nessuno nel team aveva mai installato a mano, arrivati come effetto collaterale di FROM alpine. Per chiuderle serviva un apk upgrade a ogni nuova immagine: nel frattempo la pipeline continuava a produrre artefatti identici, con gli stessi debiti.
Quella scena si ripete ogni volta che un'applicazione Java diventa un binario autosufficiente ma viene impacchettata come se avesse ancora bisogno di un sistema operativo sotto. Alpine Linux non è "insicura" — è solo pensata per un'altra situazione: un sistema minimale ma completo, con una shell, un gestore di pacchetti, un libc alternativo. Se nell'immagine finale non serve niente di tutto questo, quella completezza diventa superficie d'attacco e rumore.
Questo articolo racconta la migrazione che oggi considero lo standard per applicazioni Spring Boot compilate con GraalVM: builder pesante, runtime Distroless, e una serie di controlli che restano obbligatori perché l'immagine minima non li sostituisce. Le due pagine che contano davvero sono la documentazione distroless e la guida GraalVM agli eseguibili statici e quasi-statici: tutto quello che segue è una conseguenza di quelle due letture.
Cosa resta di una JVM dentro un native image
Vale la pena ripartire da cosa contiene davvero l'artefatto. Con Spring Boot su GraalVM Native Image, l'analisi dell'applicazione avviene a build time a partire dal main: il codice non raggiungibile viene scartato, il classpath è fissato e non può cambiare a runtime, non esiste caricamento lazy delle classi e non serve spedire una JVM per eseguire il risultato. La documentazione Spring Boot lo dice in modo esplicito, aggiungendo che questi binari sono adatti proprio ai deployment in container.
Tradotto in termini di immagine: quel processo non ha bisogno di un sistema operativo con un runtime Java. Ha bisogno di un kernel (che gli dà l'host), di una libc, di poche librerie collegate e del file dell'eseguibile. Il resto — sh, apk, curl, ls, il gestore di pacchetti, gli strumenti di rete — non viene mai invocato dal processo. Eppure era tutto lì, pronto per chiunque riuscisse a eseguire codice nell'applicazione.
È la stessa logica che il NIST aveva già messo per iscritto nell'Application Container Security Guide (SP 800-190) parlando di immagini: meno componente applicativo, meno superficie da patchare. Sei anni dopo quel documento, la parte difficile non è più capirlo ma decidere quale base minima scegliere, perché la risposta cambia a seconda di come il binario è stato linkato.
musl contro glibc: la decisione che viene prima del Dockerfile
Alpine è costruita intorno a musl libc e BusyBox, con accesso a un repository di pacchetti tramite apk: l'immagine ufficiale pesa circa 5 MB e gran parte di quel peso sono proprio gli strumenti che poi ci si dimentica dentro (descrizione dell'immagine ufficiale). Per un'applicazione interpretata o per una utility va benissimo. Per un native image cambia il conto, perché musl non è glibc con un altro nome e GraalVM compila di default contro glibc.
Da qui nascono due strade legittime, entrambe documentate, e una terza che sembra ragionevole ma non lo è:
| Strategia | Comando | Cosa serve | Dove gira |
|---|---|---|---|
| Statico completo | --static --libc=musl |
toolchain musl, zlib, make, configure nel builder |
anche un'immagine scratch |
| Quasi-statico | --static-nolibc |
solo GraalVM, nessun libc statico | immagini basate su glibc (Debian, distroless Debian) |
| "Alpine con il binario glibc" | nessuno | — | non funziona |
La terza riga è quella che sorprende chi arriva da altre piattaforme: il link statico di glibc non è supportato e la build fallisce con un errore esplicito. Non c'è un flag per "fare come con musl" restando su glibc, e non c'è modo di infilare un binario glibc in un'immagine Alpine senza portarsi dietro l'intero albero glibc, cioè un pezzo di Debian dentro Alpine.
Il caso static completo è elegante e ha un costo: la toolchain musl va installata e mantenuta nel builder, zlib compilato insieme alla toolchain, e l'ambiente di build diventa un'immagine a sé da aggiornare. Il caso quasi-statico con --static-nolibc è la scorciatoia ragionevole: GraalVM collega staticamente tutto tranne la libc e lascia al container il compito di fornirla. Ed è esattamente il profilo di un'immagine distroless basata su Debian, che di glibc ne ha una sola, aggiornata dal progetto.
Scelgo la seconda. Non perché sia superiore in assoluto, ma perché sposta un pezzo di complessità fuori dalla pipeline: la toolchain musl è un'ulteriore cosa da compilare e da tenere aggiornata, e in cambio ottengo un container che va su scratch. In un team che builda tre volte al giorno e non distribuisce su bare metal, la differenza tra 30 e 5 MB di immagine non ripaga quella manutenzione.
Perché Distroless e non "Alpine con qualche pacchetto in meno"
Le immagini distroless contengono l'applicazione e le sue dipendenze di runtime: niente shell, niente gestore di pacchetti, niente comandi che ci si aspetta di trovare in una distribuzione. È una scelta che Google e altre aziende usano in produzione da anni per un motivo preciso, che la documentazione mette in primo piano: migliora il rapporto segnale/rumore degli scanner CVE e riduce il lavoro necessario a dimostrare la provenienza dell'immagine.
Sui numeri, sempre dalla documentazione del progetto: l'immagine più piccola, gcr.io/distroless/static-debian13, sta intorno ai 2 MiB, circa la metà di Alpine; una debian piena arriva a 124 MiB. Le basi sono ora Debian 13 (trixie) e i tag riportano esplicitamente la versione della distribuzione: conviene citarla sempre nel FROM, perché il default cambierà con la prossima release Debian e una build che si rompe da sola è il tipo di sorpresa che si evita con quattro caratteri.
Per un native image quasi-statico l'immagine corretta è gcr.io/distroless/base-debian13, nella variante nonroot. static-debian13 è per binari senza dipendenze dinamiche (il caso classico: Go compilato con CGO_ENABLED=0); base è quella che porta la libc e le librerie che un binario linkato dinamicamente si aspetta. Esiste anche la variante -nossl per chi non ha bisogno di TLS: se il vostro servizio parla solo HTTP in una rete interna, è un ulteriore strato che sparisce.
C'è poi un dettaglio che vale più di tutti gli altri messi insieme quando si parla di supply chain: tutte le immagini distroless sono firmate con cosign, con chiavi effimere e identità keyless, e la documentazione fornisce il comando di verifica da usare prima di costruirci sopra. Nel 2026, con la filiera degli artefatti al centro di buona parte degli incidenti, poter verificare la firma dell'immagine base dentro la pipeline non è un ornamento.
Il Dockerfile, riga per riga
Il primo pezzo non sta nel Dockerfile ma nel pom.xml: l'opzione va passata al plugin GraalVM, che la inoltra a native-image. Il plugin accetta <buildArgs>, e ogni espressione va nel suo <buildArg>:
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<imageName>ordini-api</imageName>
<buildArgs>
<buildArg>--static-nolibc</buildArg>
</buildArgs>
</configuration>
</plugin>
A quel punto il Dockerfile a due stadi è breve, e la brevità è il punto. Allineate il tag del builder alla distribuzione GraalVM che usate: quello sotto è solo un esempio.
FROM container-registry.oracle.com/graalvm/native-image:25 AS builder
WORKDIR /workspace
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw -q -B dependency:go-offline
COPY src/ src/
RUN ./mvnw -q -B -Pnative native:compile -DskipTests
FROM gcr.io/distroless/base-debian13:nonroot
WORKDIR /app
COPY --from=builder /workspace/target/ordini-api /app/ordini-api
EXPOSE 8080
ENTRYPOINT ["/app/ordini-api"]
Tre cose da notare, più una che non c'è.
La prima: la copia dei file in due passaggi separati (pom.xml prima, src/ dopo) tiene la cache dei layer al posto giusto. Senza, ogni modifica a una classe invalida il download delle dipendenze, e una build da dodici minuti diventa una build da dodici minuti sempre.
La seconda: ENTRYPOINT è in forma di vettore. Non è una preferenza di stile. Le immagini distroless non contengono una shell, quindi la forma ENTRYPOINT /app/ordini-api — che Docker traduce aggiungendo sh -c — non ha un interprete da invocare e il container non parte. La documentazione del progetto lo scrive nero su bianco, ed è l'errore che genera il ticket "l'immagine va in crash loop senza messaggi".
La terza: il tag nonroot. Non c'è un USER da scrivere, perché la variante lo imposta già; è comunque utile ripetere il controllo nel manifest Kubernetes, perché l'immagine non è l'unico posto in cui si può sbagliare.
Quello che invece non c'è è un HEALTHCHECK. Senza curl né wget, non c'è modo di farla dentro il container, e non è un limite da aggirare: è un invito a spostare il controllo dove appartiene, cioè nelle probe di Kubernetes che interrogano l'endpoint di Actuator dall'esterno.
Attenzione a un effetto collaterale meno evidente: anche --enable-sbom di GraalVM è attivo di default e incorpora un SBOM CycloneDX nel binario. Buona notizia per la compliance; da sapere quando un tool di scansione vi segnala componenti nell'eseguibile che non vi aspettavate di vedere elencati.
Tre errori che si pagano in produzione
Il primo è già detto, ma vale ripeterlo nella forma in cui capita davvero. Un Dockerfile migrato a metà, con ENTRYPOINT java -jar app.jar rimasto dal passaggio precedente, produce un container che esce subito. Il log non dice "manca la shell": dice niente. Chi non conosce il dettaglio perde mezza giornata.
Il secondo è l'assenza di strumenti di diagnostica. In un'immagine distroless non si entra con kubectl exec -it ... sh, perché sh non esiste. La documentazione offre due risposte: i tag :debug e :debug-nonroot, che aggiungono una busybox shell all'immagine, e i container effimeri di Kubernetes, che affiancano un container di diagnostica a quello in esecuzione senza ricostruire l'immagine. In pratica: :debug per la riproduzione locale, container effimeri in cluster. Chi ha costruito runbook basati su exec deve riscriverli, e questo è il costo nascosto più grosso di tutta la migrazione.
Il terzo è la dipendenza implicita da un package manager. Entrypoint wrapper che fanno apt-get install all'avvio, init container che si aspettano apk, job di manutenzione che montano l'immagine applicativa per lanciare uno script: tutto questo smette di funzionare, ed è un bene. Un'immagine che installa pacchetti a runtime non è più immutabile e non è più la stessa cosa che avete scansionato in CI.
C'è poi una famiglia di problemi minori che vale la pena controllare una volta sola: certificati CA, tzdata, file di locale e font. Se l'applicazione formatta date in fuso italiano o valida un certificato TLS, verificate che quelle risorse siano presenti nell'immagine scelta, invece di scoprirlo in staging.
L'immagine minima non sostituisce l'hardening
Questo è il punto in cui il discorso si biforca, e in cui vedo più confusione. Sostituire Alpine con Distroless non rende sicuro un pod: rende più difficile proseguire dopo una compromissione. Il resto delle difese va scritto nel manifest, e la pagina Kubernetes sui security context elenca i campi che servono:
securityContext:
runAsNonRoot: true
runAsUser: 65532
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
Il profilo seccomp RuntimeDefault merita una nota: su un'applicazione che fa HTTP, JDBC e log su file, non rompe nulla, e taglia fuori una fetta di syscall che nessun attaccante vorrebbe perdere. capabilities: drop: ["ALL"] chiude il capitolo dei binari con permessi strani — capitolo che in un'immagine senza binari era già mezzo chiuso.
La combinazione è quello che conta. Un attaccante che ottiene esecuzione di codice in un container distroless non trova una shell, non trova un package manager, non trova curl; se il container gira non-root, ha il filesystem in sola lettura e nessuna capability, gli resta molto poco da fare oltre a quello che l'applicazione già faceva. È la differenza tra un incidente che finisce in una riga di log e uno che finisce nel report di un CSIRT.
Su questo vale la pena leggere anche 12 errori da evitare prima del deployment: quasi tutti sopravvivono alla migrazione all'immagine minima, ed è proprio questo il punto — il Dockerfile è un pezzo del problema, non il problema.
Quando Alpine resta la scelta giusta
Non voglio trasformare una scelta tecnica in un'identità. Alpine continua a essere la base corretta in almeno quattro situazioni:
- immagini di utilità o di build, dove la shell e
apksono esattamente ciò che serve; - applicazioni interpretate con dipendenze native compilate, dove musl funziona e il risparmio di dimensione conta davvero;
- contesti in cui la pipeline non può compilare un native image (startup lento accettabile, memoria abbondante, team senza esperienza GraalVM): lì una JVM su Alpine è una scelta onesta, e i CVE dello strato base si gestiscono con un rebuild programmato;
- chi ha già la toolchain musl in casa e punta a
scratch: in quel caso Alpine non c'entra più, ma il ragionamento sulla superficie d'attacco è lo stesso.
Il discrimine non è "Alpine sì o no". È una domanda precedente: il processo ha bisogno degli strumenti di un sistema operativo per girare? Se la risposta è no, tutto quello che c'è in più va giustificato; se la risposta è sì, Distroless non è un'opzione e forzarla produce container che nessuno sa riparare.
Migrare senza rompere: sei controlli
- Baseline misurata prima. Numero di CVE per severità sull'immagine attuale, dimensione, tempo di avvio, consumo di memoria a regime. Senza baseline, la migrazione è un atto di fede.
- Build riproducibile. Stesso commit, stesso digest di immagine, stesso binario. Se il builder tira dentro un layer
latest, avete spostato il problema, non risolto. - Firma verificata in pipeline.
cosign verifysull'immagine base con l'identità keyless indicata nella documentazione, prima deldocker build. Un fallimento deve fermare la build. - Probe al posto di
HEALTHCHECK. Actuator esposto su un endpoint dedicato, liveness e readiness distinte: la prima guarda il processo, la seconda le dipendenze. - Runbook riscritti. Dove prima c'era
exec sh, ora c'è un container effimero o un'immagine:debug. Vanno aggiornati prima del primo incidente, non durante. - Scansione dell'artefatto finale. L'immagine distroless riduce il rumore ma non è vuota: resta la libc, restano le librerie dell'applicazione e restano le dipendenze Java finite nel binario.
Fatti questi sei passi, il numero che di solito colpisce non è la dimensione — che scende di un fattore tra cinque e dieci — ma il tempo tra la pubblicazione di un CVE nello strato base e la sua sparizione dai vostri report. Con Alpine dipende dagli aggiornamenti della distribuzione e dai vostri rebuild; qui, nella maggior parte dei casi, il CVE non arriva proprio.
Se il team ha già automatizzato build e deployment, l'ultimo tassello è la parte che nessuno scrive nel Dockerfile: la catena di fornitura. Ne parlo in GitOps e DevSecOps e vale anche qui: firma, provenienza e immutabilità dei tag sono la stessa conversazione vista da un'altra angolazione.
Un'ultima cosa, che è più un'opinione che un dato. La migrazione a un'immagine minima è uno dei pochi interventi di sicurezza che riduce il lavoro invece di aggiungerlo: meno pacchetti da patchare, meno scanner da ripulire, meno accessi da giustificare in un audit. Il costo è tutto in avanti, nei due giorni in cui si scopre che lo script di backup si aspettava tar dentro il container. Vale la pena pagarlo una volta.
Chi vuole vedere la catena completa — build GraalVM, immagine distroless, verifica della firma, security context, container effimeri per la diagnostica — la trova nei laboratori dei percorsi DevSecOps e Kubernetes di Digital Lab srl, dove si parte da un'applicazione funzionante e si arriva a un'immagine che non contiene nulla di più di quello che serve.
Fonti
- GoogleContainerTools/distroless — documentazione del progetto
- GraalVM Native Image — Build a Statically Linked or Mostly-Statically Linked Native Executable
- GraalVM Native Image — Command-line Options
- Spring Boot Reference — Introducing GraalVM Native Images
- GraalVM Native Build Tools — Maven plugin
- musl libc — About
- Docker Official Images — Alpine Linux
- Kubernetes Documentation — Configure a Security Context for a Pod or Container
- NIST SP 800-190 — Application Container Security Guide