Il container Java che impiega 40 secondi per avviarsi e pesa 800 MB non è un problema tecnico. È un costo che paghi a ogni deploy, a ogni scalata, a ogni pipeline CI. Lo vediamo spesso nei progetti che ci arrivano: immagini basate su JDK completi, layer mal costruiti, JVM configurata con i parametri di una macchina fisica. Il risultato? Risorse sprecate, tempi di risposta lenti e un cloud provider che ti sorride mentre ti fattura.
Noi di Meteora Web lavoriamo con Java in produzione da anni. Non parliamo di teoria: abbiamo ottimizzato container per clienti che dovevano scalare in momenti di picco senza bruciare il budget. Questa guida ti mostra esattamente come si fa — dalla scelta dell'immagine base al tuning della JVM, con esempi copia-incolla.
Perché le immagini Docker Java standard sono troppo pesanti?
L'errore più comune? Partire da un'immagine openjdk:latest o peggio, da una distribuzione completa con tutto il sistema operativo. Prendi un JDK da 300 MB, aggiungi una distro Linux da 200 MB, ci metti sopra la tua applicazione e ottieni un'immagine da 600-800 MB. Per cosa? Per eseguire codice che, alla fine, usa solo una frazione di quelle librerie.
Il problema non è solo lo spazio su disco. Ogni layer pesante rallenta il push e il pull dal registry, aumenta il tempo di avvio dei container e, in un cluster Kubernetes, moltiplica i costi di rete e storage. Un'immagine più piccola significa deploy più veloci, meno banda consumata e un attacco superficiale ridotto.
La soluzione: immagini base minimali e JRE al posto del JDK
La prima regola è usare un JRE (Java Runtime Environment) invece del JDK completo in produzione. Il compilatore javac non serve a runtime. La seconda regola è scegliere una base image minimalista come eclipse-temurin:17-jre-alpine o eclipse-temurin:21-jre-jammy. Alpine Linux è ultra-leggera, ma attenzione: usa musl libc, che può causare problemi con alcune librerie native. In quel caso, la variante jammy (Ubuntu LTS) è una scelta più sicura, comunque molto più snella di una full desktop.
Sponsored Protocol
Ecco un esempio di Dockerfile ottimizzato per un'applicazione Spring Boot:
# Fase di build: usa il JDK completo solo per compilare
FROM eclipse-temurin:17-jdk-alpine AS builder
WORKDIR /app
COPY . .
RUN ./mvnw clean package -DskipTests
# Fase di runtime: solo JRE, immagine minima
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
# Copia solo il jar eseguibile, non l'intero progetto
COPY --from=builder /app/target/*.jar app.jar
# Utente non-root per sicurezza
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]Questo approccio a due fasi (multi-stage build) ti fa passare da 700 MB a circa 200 MB. La differenza si sente: il container parte in pochi secondi, non in minuti.
Come ottimizzare i layer Docker per un'applicazione Java?
Docker costruisce le immagini a strati. Ogni istruzione RUN, COPY o ADD crea un layer. Se un layer cambia, Docker ricostruisce solo quello e tutti quelli successivi. Sfruttare questa meccanica è la chiave per build veloci.
Con Spring Boot, il jar finale è un esecutabile che contiene tutte le dipendenze. Ma se lo copi come unico blocco, qualsiasi modifica al codice invalida l'intero layer. La soluzione è sfruttare il layering automatico di Spring Boot, che separa le dipendenze dal codice applicativo.
Sponsored Protocol
Usare il layering di Spring Boot per build più veloci
Spring Boot 2.3+ genera un jar con layer predefiniti: dependencies, spring-boot-loader, snapshot-dependencies e application. Puoi estrarli nel Dockerfile per copiarli separatamente:
FROM eclipse-temurin:17-jre-alpine AS builder
WORKDIR /app
COPY target/*.jar app.jar
# Estrai i layer del jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
# Copia i layer in ordine: le dipendenze cambiano raramente
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]Ora, quando modifichi solo il tuo codice, Docker ricostruisce solo l'ultimo layer. Il pull e il push dell'immagine diventano molto più rapidi, e anche il deploy in produzione ne beneficia. Se usi un registry remoto, il risparmio di banda è notevole.
Quali parametri JVM servono per i container?
La JVM è stata progettata per macchine fisiche con molta memoria. In un container, se non la configuri, rischia di allocare più heap di quanto disponibile e il kernel Linux la uccide con un OOMKilled. Il primo passo è usare le flag -XX:MaxRAMPercentage e -XX:InitialRAMPercentage, che impostano l'heap come percentuale della memoria totale del container, non della macchina host.
Il secondo passo è disabilitare le funzionalità pensate per i server fisici, come il ergonomics che sceglie il garbage collector in base all'hardware. In un container, vuoi un comportamento prevedibile.
Sponsored Protocol
Configurazione JVM consigliata per Docker
Ecco i parametri che usiamo noi per i container Java in produzione:
java \
-XX:MaxRAMPercentage=75.0 \
-XX:InitialRAMPercentage=50.0 \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+UseStringDeduplication \
-XX:TieredStopAtLevel=1 \
-jar app.jar- MaxRAMPercentage=75.0: lascia il 25% di memoria al sistema operativo e ai buffer di rete.
- UseG1GC: il garbage collector predefinito, bilanciato tra throughput e pause.
- MaxGCPauseMillis=200: limita le pause del GC, fondamentale per API che devono rispondere in fretta.
- TieredStopAtLevel=1: disabilita la compilazione C2 dopo l'avvio, riduce il tempo di startup. Da usare solo se non hai bisogno di picchi di performance estremi a runtime.
Attenzione: TieredStopAtLevel=1 accelera l'avvio ma riduce la performance di picco. Se la tua applicazione fa calcoli intensivi, lascia la compilazione completa. Per la maggior parte delle API web, la differenza è trascurabile.
Come ridurre il tempo di avvio con CDS e AppCDS?
Il Class Data Sharing (CDS) è una funzionalità JVM che salva le classi caricate in un file, così alla prossima esecuzione la JVM le carica più velocemente. Con Java 17, puoi usare l'AppCDS per includere anche le classi della tua applicazione, non solo quelle di sistema.
Il risultato? Un avvio che passa da 5-6 secondi a 2-3 secondi. In un ambiente con molti microservizi che scalano spesso, questo si traduce in risposta più rapida ai picchi di traffico.
Generare il file CDS nel Dockerfile
Ecco come integrare AppCDS nel tuo processo di build:
Sponsored Protocol
FROM eclipse-temurin:17-jre-alpine AS builder
WORKDIR /app
COPY target/*.jar app.jar
# Genera il file di class list
RUN java -XX:ArchiveClassesAtExit=app.jsa \
-Dspring.context.exit=onRefresh \
-jar app.jar
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /app/app.jar .
COPY --from=builder /app/app.jsa .
ENTRYPOINT ["java", "-XX:SharedArchiveFile=app.jsa", "-jar", "app.jar"]Il trucco è usare spring.context.exit=onRefresh per far uscire l'applicazione subito dopo l'avvio del contesto Spring, senza doverla fermare manualmente. Questo genera il file app.jsa con tutte le classi caricate. Al runtime, la JVM usa quel file e salta la fase di parsing delle classi.
Attenzione: il file CDS è legato alla versione della JVM e alle librerie. Se aggiorni una dipendenza, devi rigenerarlo. È un passo in più, ma il guadagno in termini di startup time è significativo.
Come gestire la sicurezza e le dipendenze native nei container?
Un container Java sicuro non è solo un'immagine piccola. È un'immagine che non gira come root, che non contiene strumenti di debug e che ha le dipendenze verificabili. Il primo passo è creare un utente dedicato nel Dockerfile, come abbiamo fatto nell'esempio iniziale. Il secondo è usare solo immagini base ufficiali e firmate.
Le librerie native (JNI) sono il tallone d'Achille dei container Java. Se la tua applicazione usa una libreria come OpenCV o un driver JDBC con componenti nativi, devi assicurarti che la base image contenga le dipendenze di sistema necessarie. Alpine, con la sua musl libc, può causare errori misteriosi. In quel caso, passa a una base Ubuntu o Debian slim.
Sponsored Protocol
Verifica delle vulnerabilità e build riproducibili
Non pubblicare mai un'immagine senza averla scannerizzata. Strumenti come trivy o grype si integrano nella pipeline CI e ti dicono se ci sono CVE note nelle dipendenze. Noi lo facciamo per ogni progetto: è un controllo che richiede un minuto e ti evita di andare in produzione con una falla nota.
Per le build riproducibili, usa versioni fisse delle immagini base (es. eclipse-temurin:17-jre-alpine@sha256:... ) e blocca le versioni delle dipendenze nel tuo pom.xml o build.gradle. Un container che si comporta allo stesso modo ogni volta è un container che puoi debuggare con fiducia.
In sintesi
Ottimizzare Docker con Java non è un lusso, è una necessità operativa. Immagini più piccole, avvii più rapidi e una JVM configurata per i container significano costi cloud ridotti e applicazioni che rispondono quando serve. Ecco le azioni immediate da fare:
- Riscrivi il Dockerfile con un multi-stage build: JDK per compilare, JRE per eseguire.
- Configura la JVM con
MaxRAMPercentageeUseG1GCper evitare OOMKilled. - Genera il file CDS per tagliare il tempo di avvio del 30-50%.
- Scannerizza l'immagine con Trivy prima di ogni push nel registry.
- Non usare mai l'utente root nel container: crea un utente dedicato.
Se vuoi approfondire come queste tecniche si integrano in un'architettura più ampia, dai un'occhiata alla nostra guida su Java moderno e JVM per codice che scala. Noi, di Meteora Web, usiamo questi stessi principi ogni giorno per i clienti che devono reggere il traffico senza bruciare il budget. Il tuo codice Java merita un container che sia all'altezza.