Ottimizzare le applicazioni Java per Cloud Run

Questa guida descrive le ottimizzazioni per i servizi Cloud Run scritti nel linguaggio di programmazione Java, insieme a informazioni di base per aiutarti a comprendere i compromessi coinvolti in alcune delle ottimizzazioni. Le informazioni riportate in questa pagina integrano i suggerimenti generali per l'ottimizzazione, che si applicano anche a Java.

Le applicazioni web Java tradizionali sono progettate per gestire richieste con alta concorrenza e bassa latenza e tendono a essere applicazioni a lungo termine. Anche la JVM ottimizza il codice di esecuzione nel tempo con il JIT, in modo che i percorsi caldi vengano ottimizzati e le applicazioni vengano eseguite in modo più efficiente nel tempo.

Molte delle best practice e delle ottimizzazioni di queste applicazioni web basate su Java tradizionali ruotano attorno a:

  • Gestione delle richieste in parallelo (I/O basate su thread e non bloccanti)
  • Riduzione della latenza di risposta mediante il pooling delle connessioni e l'aggregazione delle funzioni non critiche, ad esempio l'invio di tracce e metriche alle attività in background.

Sebbene molte di queste ottimizzazioni tradizionali funzionino bene per le applicazioni a lungo termine, potrebbero non funzionare altrettanto bene in un servizio Cloud Run, che viene eseguito solo quando vengono gestite attivamente le richieste. Questa pagina illustra alcuni diversi compromessi e ottimizzazioni per Cloud Run che puoi utilizzare per ridurre il tempo di avvio e l'utilizzo della memoria.

Utilizza il boosting della CPU all'avvio per ridurre la latenza di avvio

Puoi attivare il boosting della CPU all'avvio per aumentare temporaneamente l'allocazione della CPU durante l'avvio dell'istanza in modo da ridurre la latenza di avvio.

Le metriche di Google hanno dimostrato che le app Java traggono vantaggio dall'utilizzo dell'aumento della CPU all'avvio, che può ridurre i tempi di avvio fino al 50%.

Ottimizza l'immagine container

Ottimizzando l'immagine del contenitore, puoi ridurre i tempi di caricamento e di avvio. Puoi ottimizzare l'immagine:

  • Minimizzare l'immagine del contenitore
  • Evitare l'utilizzo di file JAR di archivi di librerie nidificati
  • Utilizzo di Jib

Riduci al minimo l'immagine container

Per ulteriori informazioni su questo problema, consulta la pagina dei suggerimenti generali sulla minimizzazione del contenitore. Nella pagina dei suggerimenti generali viene consigliato di ridurre i contenuti delle immagini del contenitore solo a ciò che è necessario. Ad esempio, assicurati che l'immagine del container non contenga :

  • Codice sorgente
  • Artefatti della build Maven
  • Strumenti di creazione
  • Directory Git
  • Binari/utility inutilizzati

Se stai creando il codice all'interno di un Dockerfile, utilizza la compilazione a più fasi di Docker in modo che l'immagine container finale contenga solo il JRE e il file JAR dell'applicazione.

Evita JAR di archivi di librerie nidificati

Alcuni framework popolari, come Spring Boot, creano un file di archivio dell'applicazione (JAR) contenente file JAR di librerie aggiuntive (JAR nidificati). Questi file devono essere scompattati/decompressi durante l'avvio, il che può influire negativamente sulla velocità di avvio in Cloud Run. Pertanto, se possibile, crea un JAR snello con librerie estruse: questa operazione può essere automatizzata utilizzando Jib per eseguire il containerizzazione della tua applicazione.

Utilizzare Jib

Utilizza il plug-in Jib per creare un contenuto minimo e appiattire automaticamente l'archivio dell'applicazione. Jib è compatibile sia con Maven che con Gradle e funziona immediatamente con le applicazioni Spring Boot. Alcuni framework di applicazioni potrebbero richiedere configurazioni Jib aggiuntive.

Ottimizzazioni JVM

L'ottimizzazione della JVM per un servizio Cloud Run può migliorare le prestazioni e l'utilizzo della memoria.

Utilizza le versioni JVM consapevoli del contenitore

Nelle VM e nelle macchine, per le allocazioni di CPU e memoria, la JVM comprende la CPU e la memoria che può utilizzare da posizioni ben note, ad esempio in Linux, /proc/cpuinfo e /proc/meminfo. Tuttavia, quando viene eseguito in un contenitore, i vincoli di CPU e memoria vengono memorizzati in/proc/cgroups/.... Le versioni precedenti del JDK continuano a cercare in /proc anziché in /proc/cgroups, il che può comportare un utilizzo maggiore di CPU e memoria rispetto a quanto assegnato. Ciò può causare:

  • Un numero eccessivo di thread perché le dimensioni del pool di thread sono configurate da Runtime.availableProcessors()
  • Un heap massimo predefinito che supera il limite di memoria del contenitore. La JVM utilizza in modo aggressivo la memoria prima di eseguire il garbage collection. Ciò può facilmente causare il superamento del limite di memoria del contenitore e l'interruzione OOM.

Pertanto, utilizza una versione JVM consapevole del contenitore. Le versioni OpenJDK superiori o uguali alla versione 8u192 sono compatibili con i contenitori per impostazione predefinita.

Come comprendere l'utilizzo della memoria JVM

L'utilizzo della memoria JVM è composto dall'utilizzo della memoria nativa e dall'utilizzo dell'heap. La memoria di lavoro dell'applicazione si trova in genere nell'heap. Le dimensioni dell'heap sono vincolate dalla configurazione dell'heap massimo. Con un'istanza Cloud Run da 256 MB di RAM, non puoi assegnare tutti i 256 MB all'heap massimo, perché la JVM e il sistema operativo richiedono anche memoria nativa, ad esempio stack dei thread, cache di codice, handle file, buffer e così via. Se la tua applicazione viene uccisa per OOM e devi conoscere l'utilizzo della memoria JVM (memoria nativa + heap), attiva il monitoraggio della memoria nativa per visualizzare gli utilizzi al termine dell'applicazione. Se l'applicazione viene uccisa per OOM, non potrà stampare le informazioni. In questo caso, esegui prima l'applicazione con più memoria in modo che possa generare correttamente l'output.

Il monitoraggio della memoria nativa non può essere attivato tramite la variabile di ambiente JAVA_TOOL_OPTIONS. Devi aggiungere l'argomento di avvio della riga di comando Java all'entrypoint dell'immagine del container in modo che l'applicazione venga avviata con questi argomenti:

java -XX:NativeMemoryTracking=summary \
  -XX:+UnlockDiagnosticVMOptions \
  -XX:+PrintNMTStatistics \
  ...

L'utilizzo della memoria nativa può essere stimato in base al numero di classi da caricare. Valuta la possibilità di utilizzare un calcolatore della memoria Java open source per stimare le esigenze di memoria.

Disattivare il compilatore di ottimizzazione

Per impostazione predefinita, la JVM ha diverse fasi di compilazione JIT. Sebbene queste fasi migliorino l'efficienza dell'applicazione nel tempo, possono anche aggiungere un overhead all'utilizzo della memoria e aumentare il tempo di avvio.

Per le applicazioni serverless con breve tempo di esecuzione (ad esempio le funzioni), valuta la possibilità di disattivare le fasi di ottimizzazione per scambiare l'efficienza a lungo termine con un tempo di avvio ridotto.

Per un servizio Cloud Run, configura la variabile di ambiente:

JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"

Utilizzare la condivisione dei dati a livello di classe dell'applicazione

Per ridurre ulteriormente il tempo e l'utilizzo della memoria del JIT, ti consigliamo di utilizzare la condivisione dei dati delle classi dell'applicazione (AppCDS) per condividere le classi Java compilate in anticipo come archivio. L'archivio AppCDS può essere riutilizzato quando viene avviata un'altra istanza della stessa applicazione Java. La JVM può riutilizzare i dati precomputati dall'archivio, il che consente di ridurre i tempi di avvio.

Le seguenti considerazioni si applicano all'utilizzo di AppCDS:

  • L'archivio AppCDS da riutilizzare deve essere riprodotto esattamente con la stessa distribuzione, versione e architettura OpenJDK utilizzata inizialmente per produrlo.
  • Devi eseguire l'applicazione almeno una volta per generare l'elenco delle classi da condividere e poi utilizzare questo elenco per generare l'archivio AppCDS.
  • La copertura delle classi dipende dal percorso del codice eseguito durante l'esecuzione dell'applicazione. Per aumentare la copertura, attiva tramite programmazione più percorsi di codice.
  • L'applicazione deve uscire correttamente per generare questo elenco di corsi. Valuta la possibilità di implementare un flag di applicazione utilizzato per indicare la generazione dell'archivio AppCDS, in modo che possa uscire immediatamente.
  • L'archivio AppCDS può essere riutilizzato solo se avvii nuove istanze nello stesso modo in cui è stato generato l'archivio.
  • L'archivio AppCDS funziona solo con un normale pacchetto di file JAR; non puoi utilizzare JAR nidificati.

Esempio di Spring Boot che utilizza un file JAR ombreggiato

Per impostazione predefinita, le applicazioni Spring Boot utilizzano un JAR uber nidificato, che non funziona per AppCDS. Pertanto, se utilizzi AppCDS, devi creare un file JAR ombreggiato. Ad esempio, utilizzando Maven e il plug-in Maven Shade:

<build>
  <finalName>helloworld</finalName>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <configuration>
        <keepDependenciesWithProvidedScope>true</keepDependenciesWithProvidedScope>
        <createDependencyReducedPom>true</createDependencyReducedPom>
        <filters>
          <filter>
            <artifact>*:*</artifact>
            <excludes>
              <exclude>META-INF/*.SF</exclude>
              <exclude>META-INF/*.DSA</exclude>
              <exclude>META-INF/*.RSA</exclude>
            </excludes>
          </filter>
        </filters>
      </configuration>
      <executions>
        <execution>
          <phase>package</phase>
          <goals><goal>shade</goal></goals>
          <configuration>
            <transformers>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.handlers</resource>
              </transformer>
              <transformer implementation="org.springframework.boot.maven.PropertiesMergingResourceTransformer">
                <resource>META-INF/spring.factories</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.schemas</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
              <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                <mainClass>${mainClass}</mainClass>
              </transformer>
            </transformers>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

Se il JAR ombreggiato contiene tutte le dipendenze, puoi produrre un semplice archivio durante la compilazione del contenitore utilizzando un Dockerfile:

# Use Docker's multi-stage build
FROM eclipse-temurin:11-jre as APPCDS

COPY target/helloworld.jar /helloworld.jar

# Run the application, but with a custom trigger that exits immediately.
# In this particular example, the application looks for the '--appcds' flag.
# You can implement a similar flag in your own application.
RUN java -XX:DumpLoadedClassList=classes.lst -jar helloworld.jar --appcds=true

# From the captured list of classes (based on execution coverage),
# generate the AppCDS archive file.
RUN java -Xshare:dump -XX:SharedClassListFile=classes.lst -XX:SharedArchiveFile=appcds.jsa --class-path helloworld.jar

FROM eclipse-temurin:11-jre

# Copy both the JAR file and the AppCDS archive file to the runtime container.
COPY --from=APPCDS /helloworld.jar /helloworld.jar
COPY --from=APPCDS /appcds.jsa /appcds.jsa

# Enable Application Class-Data sharing
ENTRYPOINT java -Xshare:on -XX:SharedArchiveFile=appcds.jsa -jar helloworld.jar

Riduci le dimensioni dello stack del thread

La maggior parte delle applicazioni web Java è basata su thread per connessione. Ogni thread Java consuma memoria nativa (non nell'heap). Questo è noto come stack del thread e il valore predefinito è 1 MB per thread. Se la tua applicazione gestisce 80 richieste contemporaneamente, potrebbe avere almeno 80 thread, il che si traduce in 80 MB di spazio dello stack del thread utilizzato. La memoria si aggiunge alle dimensioni dell'heap. Il valore predefinito potrebbe essere più grande del necessario. Puoi ridurre le dimensioni dello stack del thread.

Se riduci troppo, vedrai java.lang.StackOverflowError. Puoi eseguire il profiling dell'applicazione e trovare la dimensione dello stack del thread ottimale da configurare.

Per un servizio Cloud Run, configura la variabile di ambiente:

JAVA_TOOL_OPTIONS="-Xss256k"

Riduzione dei thread

Puoi ottimizzare la memoria riducendo il numero di thread, utilizzando strategie reattive non bloccanti ed evitando le attività in background.

Riduci il numero di thread

Ogni thread Java può aumentare l'utilizzo della memoria a causa della pila thread. Cloud Run consente un massimo di 1000 richieste contemporaneamente. Con il modello thread per connessione, sono necessari al massimo 1000 thread per gestire tutte le richieste in parallelo. La maggior parte dei server web e dei framework consente di configurare il numero massimo di thread e connessioni. Ad esempio, in Spring Boot puoi limitare il numero massimo di connessioni nel file applications.properties:

server.tomcat.max-threads=80

Scrivi codice reattivo non bloccante per ottimizzare la memoria e l'avvio

Per ridurre davvero il numero di thread, ti consigliamo di adottare un modello di programmazione reattiva non bloccante, in modo da ridurre notevolmente il numero di thread e gestire più richieste simultanee. Framework di applicazioni come Spring Boot con Webflux, Micronaut e Quarkus supportano le applicazioni web reattive.

I framework reattivi come Spring Boot con Webflux, Micronaut, Quarkus in genere hanno tempi di avvio più rapidi.

Se continui a scrivere codice bloccante in un framework non bloccante, il throughput e i tassi di errore peggioreranno notevolmente in un servizio Cloud Run. Questo perché i framework non bloccanti avranno solo alcuni thread, ad esempio 2 o 4. Se il codice è bloccante, può gestire pochissime richieste contemporaneamente.

Questi framework non bloccanti possono anche scaricare il codice di blocco in un pool di thread illimitato, il che significa che, pur potendo accettare molte richieste contemporaneamente, il codice di blocco verrà eseguito in nuovi thread. Se i thread si accumulano in modo illimitato, esaurirai la risorsa della CPU e inizierai a eseguire il thrashing. La latenza sarà notevolmente influenzata. Se utilizzi un framework non bloccante, assicurati di comprendere i modelli di pool di thread e di associarli di conseguenza.

Configura la CPU in modo che sia sempre allocata se utilizzi attività in background

L'attività in background è tutto ciò che accade dopo che la risposta HTTP è stata comunicata. I carichi di lavoro tradizionali con attività in background richiedono un'attenzione particolare quando vengono eseguiti in Cloud Run.

Configura la CPU in modo che sia sempre allocata

Se vuoi supportare le attività in background nel tuo servizio Cloud Run, imposta la CPU del servizio Cloud Run in modo che sia sempre allocata in modo da poter eseguire attività in background al di fuori delle richieste e avere comunque accesso alla CPU.

Evita le attività in background se la CPU viene allocata solo durante l'elaborazione delle richieste

Se devi impostare il servizio in modo da allocare la CPU solo durante l'elaborazione delle richieste, devi essere consapevole di potenziali problemi con le attività in background. Ad esempio, se raccogli le metriche dell'applicazione e le raggruppi in background per inviarle periodicamente, queste non verranno inviate quando la CPU non è allocata. Se la tua applicazione riceve costantemente richieste, potresti riscontrare meno problemi. Se la tua applicazione ha un QPS basso, l'attività in background potrebbe non essere mai eseguita.

Alcuni pattern ben noti in background a cui devi prestare attenzione se scegli di allocare la CPU solo durante l'elaborazione delle richieste:

  • Pool di connessioni JDBC: le operazioni di pulizia e i controlli delle connessioni di solito vengono eseguiti in background
  • Mittenti di tracce distribuite: in genere le tracce distribuite vengono raggruppate e inviate periodicamente o quando il buffer è pieno in background.
  • Mittenti di metriche: in genere le metriche vengono raggruppate e inviate periodicamente in background.
  • Per Spring Boot, tutti i metodi annotati con l'annotazione @Async
  • Timer: qualsiasi attivatore basato su timer (ad es. ScheduledThreadPoolExecutor, Quartz o @Scheduled (annotazione Spring) potrebbero non essere eseguiti quando le CPU non sono allocate.
  • Destinatari dei messaggi: ad esempio, i client pull Pub/Sub, i client JMS o i client Kafka di solito vengono eseguiti nei thread in background senza bisogno di richieste. Questi non funzioneranno se la tua applicazione non ha richieste. La ricezione dei messaggi in questo modo non è consigliata in Cloud Run.

Ottimizzazioni delle applicazioni

Nel codice del servizio Cloud Run, puoi anche ottimizzare per tempi di avvio e utilizzo della memoria più rapidi.

Riduci le attività di avvio

Le applicazioni web Java tradizionali possono avere molte attività da completare durante l'avvio, ad esempio il precaricamento dei dati, il riscaldamento della cache, l'impostazione dei pool di connessioni e così via. Queste attività, se eseguite in sequenza, possono essere lente. Tuttavia, se vuoi che vengano eseguiti in parallelo, devi aumentare il numero di core della CPU.

Al momento Cloud Run invia una richiesta di un utente reale per attivare un'istanza di avvio a freddo. Gli utenti che hanno una richiesta assegnata a un'istanza appena avviata potrebbero subire lunghi ritardi. Al momento Cloud Run non dispone di un controllo di "idoneità" per evitare di inviare richieste ad applicazioni non pronte.

Utilizzare il pool di connessioni

Se utilizzi pool di connessione, tieni presente che questi potrebbero eliminare le connessioni non necessarie in background (vedi Evitare le attività in background). Se la tua applicazione ha un QPS basso e può tollerare una latenza elevata, valuta la possibilità di aprire e chiudere le connessioni per richiesta. Se la tua applicazione ha un QPS elevato, le espulsioni in background potrebbero continuare a essere eseguite finché ci sono richieste attivate.

In entrambi i casi, l'accesso al database dell'applicazione sarà limitato dalle connessioni massime consentite dal database. Calcola il numero massimo di connessioni che puoi stabilire per istanza Cloud Run e configura le istanze Cloud Run massime in modo che il numero massimo di istanze per connessioni per istanza sia inferiore al numero massimo di connessioni consentite.

Se utilizzi Spring Boot

Se utilizzi Spring Boot, devi prendere in considerazione le seguenti ottimizzazioni

Utilizza Spring Boot versione 2.2 o successive

A partire dalla versione 2.2, Spring Boot è stato ottimizzato notevolmente per la velocità di avvio. Se utilizzi versioni di Spring Boot precedenti alla 2.2, valuta la possibilità di eseguire l'upgrade o applica le singole ottimizzazioni manualmente.

Utilizza l'inizializzazione lazy

Esiste un flag di inizializzazione lazy globale che può essere attivato in Spring Boot 2.2 e versioni successive. In questo modo, la velocità di avvio migliorerà, ma la prima richiesta potrebbe avere una latenza più lunga perché dovrà attendere l'inizializzazione dei componenti per la prima volta.

Puoi attivare l'inizializzazione lazy in application.properties:

spring.main.lazy-initialization=true

In alternativa, utilizzando una variabile di ambiente:

SPRING_MAIN_LAZY_INITIALIZATIION=true

Tuttavia, se utilizzi istanze minime, l'inizializzazione lazy non sarà di aiuto, poiché l'inizializzazione dovrebbe essere avvenuta all'avvio dell'istanza minima.

Evitare la scansione dei corsi

La scansione dei corsi causerà letture aggiuntive del disco in Cloud Run perché in Cloud Run l'accesso al disco è in genere più lento rispetto a una normale macchina. Assicurati che la ricerca componenti sia limitata o completamente evitata. Valuta la possibilità di utilizzare Spring Context Indexer per pregenerare un indice. L'eventuale miglioramento della velocità di avvio dipenderà dall'applicazione.

Ad esempio, in pom.xml Maven aggiungi la dipendenza dall'indice (in realtà è un elaboratore di annotazioni):

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context-indexer</artifactId>
  <optional>true</optional>
</dependency>

Utilizzare gli strumenti per sviluppatori Spring Boot non in produzione

Se utilizzi lo strumento per sviluppatori Spring Boot durante lo sviluppo, assicurati che non sia pacchettizzato nell'immagine del contenitore di produzione. Questo può accadere se hai creato l'applicazione Spring Boot senza i plug-in di compilazione di Spring Boot (ad esempio, utilizzando il plug-in Shade o Jib per la contenimento).

In questi casi, assicurati che lo strumento di compilazione escluda esplicitamente lo strumento di sviluppo Spring Boot. In alternativa, disattiva esplicitamente lo strumento per sviluppatori Spring Boot.

Passaggi successivi

Per altri suggerimenti, consulta