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 implicati in alcune di queste ottimizzazioni. Le informazioni contenute in questa pagina integrano i suggerimenti generali di ottimizzazione, che si applicano anche a Java.

Le applicazioni web Java convenzionali sono progettate per gestire richieste con elevata concorrenza e bassa latenza e tendono ad essere applicazioni a lunga esecuzione. La JVM stessa ottimizza il codice di esecuzione nel tempo con JIT, in modo che i percorsi attivi vengano ottimizzati e le applicazioni vengano eseguite in modo più efficiente nel tempo.

Molte delle migliori pratiche e ottimizzazioni in queste applicazioni web Java convenzionali ruotano attorno a:

  • Gestione delle richieste simultanee (I/O basato su thread e non bloccante)
  • Riduzione della latenza di risposta tramite il connection pooling e il raggruppamento delle funzioni non critiche, ad esempio l'invio di tracce e metriche ad attività in background.

Sebbene molte di queste ottimizzazioni convenzionali funzionino bene per le applicazioni a esecuzione prolungata, potrebbero non funzionare altrettanto bene in un servizio Cloud Run, che viene eseguito solo quando gestisce attivamente le richieste. Questa pagina illustra alcune ottimizzazioni e compromessi per Cloud Run che è possibile utilizzare per ridurre i tempi di avvio e la memoria utilizzata.

Utilizza l'incremento 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 al fine di ridurre la latenza di avvio.

Le metriche di Google hanno dimostrato che le applicazioni Java traggono vantaggio dall'utilizzo di Startup CPU Boost, che può ridurre i tempi di avvio fino al 50%.

Ottimizza l'immagine container della tua applicazione Java

Ottimizzando l'immagine container, puoi ridurre i tempi di caricamento e avvio. Puoi ottimizzare l'immagine in questo modo:

  • Riduzione a icona dell'immagine container
  • Evitare l'uso di file JAR di archivio di librerie nidificate
  • Utilizzo del braccio meccanico

Riduci a icona l'immagine del contenitore

Per maggiori informazioni su questo problema, consulta la pagina dei suggerimenti generali su come ridurre al minimo i contenitori. La pagina dei suggerimenti generali raccomanda di ridurre il contenuto delle immagini contenitore a quello strettamente necessario. Ad esempio, assicurati che l'immagine contenitore non contenga :

  • Codice sorgente
  • artefatti di build Maven
  • Strumenti di creazione
  • Directory Git
  • Binari o utilità inutilizzati

Se crei il codice da un Dockerfile, utilizza la build multifase di Docker in modo che l'immagine container finale contenga solo la JRE e il file JAR dell'applicazione.

Evitare archivi di libreria annidati in formato JAR

Alcuni framework popolari, come Spring Boot, creano un file di archivio dell'applicazione (JAR) che contiene ulteriori file JAR di libreria (JAR nidificati). Questi file devono essere decompressi durante l'avvio, il che può influire negativamente sulla velocità di avvio in Cloud Run. Pertanto, quando possibile, crea un JAR sottile con librerie esternalizzate: questa operazione può essere automatizzata utilizzando Jib per containerizzare l'applicazione.

Utilizzare Jib

Utilizza il plugin Jib per creare un contenitore minimale e appiattire automaticamente l'archivio dell'applicazione. Jib funziona sia con Maven che con Gradle e con le applicazioni Spring Boot pronte all'uso. Alcuni framework di applicazioni potrebbero richiedere configurazioni Jib aggiuntive.

Ottimizzazioni della JVM per le applicazioni Java di Cloud Run

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

Utilizzare versioni JVM compatibili con i container

Nelle macchine virtuali e nelle macchine tradizionali, 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 si esegue in un container, i vincoli di CPU e memoria vengono memorizzati in /proc/cgroups/.... Le versioni precedenti di JDK continuano a cercare in /proc anziché in /proc/cgroups, il che può comportare un utilizzo di CPU e memoria utilizzata maggiore di quello assegnato. Ciò può causare:

  • Un numero eccessivo di thread perché la dimensione del pool di thread è configurata da Runtime.availableProcessors()
  • Un valore predefinito di heap massimo che supera il limite di memoria del contenitore. La JVM utilizza la memoria in modo aggressivo prima di eseguire la garbage collection. Questo può facilmente causare il superamento del limite di memoria del container e la sua terminazione per esaurimento della memoria (OOMKilled).

Pertanto, utilizza una versione della JVM compatibile con i container. Le versioni di OpenJDK superiori o uguali alla versione 8u192 sono compatibili con i container per impostazione predefinita.

Come comprendere l'utilizzo della memoria della JVM

La memoria utilizzata dalla JVM è composta dalla memoria nativa utilizzata e dalla memoria heap utilizzata. La memoria di lavoro della tua applicazione si trova solitamente nell'heap. La dimensione dell'heap è vincolata 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 di thread, cache di codice, handle di file, buffer e così via. Se la tua applicazione viene terminata a causa di un errore di memoria insufficiente e devi conoscere la memoria utilizzata dalla JVM (memoria nativa + heap), attiva il monitoraggio della memoria nativa per visualizzare l'utilizzo al termine dell'applicazione. Se l'applicazione viene terminata per esaurimento della memoria, non sarà in grado di stampare le informazioni. In tal caso, esegui prima l'applicazione con più memoria in modo che possa generare correttamente l'output.

Il tracciamento della memoria nativa non può essere attivato tramite la variabile di ambiente JAVA_TOOL_OPTIONS. È necessario aggiungere l'argomento di avvio della riga di comando Java al punto di ingresso dell'immagine container, in modo che l'applicazione venga avviata con questi argomenti:

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

Valuta la possibilità di utilizzare un calcolatore di memoria Java open source per stimare le esigenze di memoria.

Disattivare il compilatore di ottimizzazione

Per impostazione predefinita, la JVM prevede diverse fasi di compilazione JIT. Sebbene queste fasi migliorino l'efficienza dell'applicazione nel tempo, possono anche aumentare il consumo di memoria e i tempi di avvio.

Per le applicazioni serverless a esecuzione breve (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 d'ambiente:

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

Utilizzare la condivisione dei dati di classe dell'applicazione

Per ridurre ulteriormente i tempi JIT e l'utilizzo della memoria, si consiglia di utilizzare application class data sharing (AppCDS) per condividere le classi Java compilate in anticipo come archivio. L'archivio AppCDS può essere riutilizzato all'avvio di una nuova istanza della stessa applicazione Java. La JVM può riutilizzare i dati precalcolati dall'archivio, il che riduce il tempo 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 di OpenJDK utilizzate originariamente per la sua creazione.
  • È necessario eseguire l'applicazione almeno una volta per generare l'elenco delle classi da condividere, e quindi utilizzare tale 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 programmaticamente più percorsi di codice.
  • L'applicazione deve chiudersi correttamente per generare questo elenco di classi. Valuta la possibilità di implementare un flag dell'applicazione utilizzato per indicare la generazione dell'archivio AppCDS, in modo che possa uscire immediatamente.
  • L'archivio AppCDS può essere riutilizzato solo se si avviano nuove istanze esattamente nello stesso modo in cui è stato generato.
  • L'archivio AppCDS funziona solo con un normale pacchetto di file JAR; non puoi utilizzare file JAR nidificati.

Esempio di Spring Boot che utilizza un file JAR con contenuto oscurato

Le applicazioni Spring Boot utilizzano per impostazione predefinita un uber JAR annidato, che non è compatibile con AppCDS. Pertanto, se utilizzi AppCDS, devi creare un 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 tuo JAR ombreggiato contiene tutte le dipendenze, puoi produrre un semplice archivio durante la creazione del container 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

Ridurre le dimensioni del pacco filettato

La maggior parte delle applicazioni web Java si basa su un thread per connessione. Ogni thread Java consuma memoria nativa (non nell'heap). Questo è noto come stack di thread ed è impostato per impostazione predefinita su 1 MB per thread. Se la tua applicazione gestisce 80 richieste simultanee, potrebbe avere almeno 80 thread, il che si traduce in 80 MB di spazio utilizzato nello stack dei thread. La memoria si aggiunge alla dimensione dell'heap. Il valore predefinito potrebbe essere superiore al necessario. È possibile ridurre la dimensione dello stack dei thread.

Se riduci troppo, vedrai java.lang.StackOverflowError. È possibile analizzare le prestazioni dell'applicazione e individuare la dimensione ottimale dello stack di thread da configurare.

Per un servizio Cloud Run, configura la variabile d'ambiente:

JAVA_TOOL_OPTIONS="-Xss256k"

Riduzione dei thread per le prestazioni delle applicazioni Java

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

ridurre il numero di thread

Ogni thread Java può aumentare la memoria utilizzata a causa dello stack dei thread. Cloud Run consente un massimo di 1.000 richieste simultanee. Con il modello thread-per-connection, sono necessari al massimo 1.000 thread per gestire tutte le richieste simultanee. La maggior parte dei server web e dei framework consente di configurare il numero massimo di thread e connessioni. Ad esempio, in Spring Boot è possibile 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 effettivamente il numero di thread, è consigliabile adottare un modello di programmazione reattiva non bloccante, in modo da poter ridurre significativamente il numero di thread gestendo al contempo un maggior numero di richieste simultanee. I framework applicativi come Spring Boot con Webflux, Micronaut e Quarkus supportano le applicazioni web reattive.

I framework reattivi come Spring Boot con Webflux, Micronaut e Quarkus generalmente offrono tempi di avvio più rapidi.

Se si continua a scrivere codice bloccante in un framework non bloccante, la velocità di elaborazione e i tassi di errore saranno significativamente peggiori in un servizio Cloud Run. Questo perché i framework non bloccanti avranno solo pochi thread, ad esempio 2 o 4. Se il codice è bloccante, può gestire pochissime richieste simultanee.

Questi framework non bloccanti possono anche trasferire il codice di blocco in un pool di thread senza limiti, il che significa che, sebbene possa accettare molte richieste in parallelo, il codice di blocco verrà eseguito in nuovi thread. Se i thread si accumulano in modo illimitato, le risorse della CPU si esauriranno e il sistema inizierà a rallentare eccessivamente. La latenza ne risentirà gravemente. Se si utilizza un framework non bloccante, è fondamentale comprendere i modelli dei pool di thread e configurarli di conseguenza.

Configura la fatturazione basata sull'istanza se utilizzi attività in background

Le attività in background sono tutto ciò che accade dopo l'invio della risposta HTTP. I carichi di lavoro tradizionali che includono attività in background richiedono un'attenzione particolare quando vengono eseguiti in Cloud Run.

Configura la fatturazione basata sulle istanze

Se vuoi supportare le attività in background nel tuo servizio Cloud Run, imposta il servizio Cloud Run sulla fatturazione basata sulle istanze in modo da poter eseguire le attività in background al di fuori delle richieste e avere comunque accesso alla CPU.

Evitate le attività in background se utilizzate la fatturazione su richiesta.

Se devi impostare il servizio sulla fatturazione basata sulle richieste, devi essere consapevole dei potenziali problemi con le attività in background. Ad esempio, se si raccolgono metriche dell'applicazione e le si raggruppano in background per inviarle periodicamente, tali metriche non verranno inviate quando è configurata la fatturazione basata sulle richieste. Se la tua applicazione riceve costantemente richieste, potresti riscontrare meno problemi. Se la tua applicazione ha un valore QPS basso, l'attività in background potrebbe non essere mai eseguita.

Alcuni pattern noti che vengono eseguiti in background a cui devi prestare attenzione se scegli la fatturazione basata sulle richieste:

  • Pool di connessioni JDBC: le operazioni di pulizia e i controlli di connessione vengono generalmente eseguiti in background.
  • Mittenti di tracce distribuite - Le tracce distribuite vengono solitamente raggruppate e inviate periodicamente o quando il buffer è pieno in background.
  • Mittenti delle metriche: le metriche vengono in genere raggruppate e inviate periodicamente in background.
  • Per Spring Boot, qualsiasi metodo annotato con l'annotazione @Async
  • Timer: tutti i trigger basati su timer (ad es. ScheduledThreadPoolExecutor, Quartz o annotazione Spring @Scheduled) potrebbero non essere eseguiti quando è configurata la fatturazione basata sulle richieste.
  • I destinatari dei messaggi, ad esempio i client di streaming Pub/Sub, i client JMS o i client Kafka, vengono solitamente eseguiti in thread in background senza bisogno di richieste. Questi metodi non funzioneranno se la tua applicazione non riceve richieste. La ricezione di messaggi in questo modo non è consigliata in Cloud Run.

Ottimizzazione delle applicazioni

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

Riduci le attività di avvio

Durante l'avvio, le applicazioni web Java spesso devono gestire più attività, come il precaricamento dei dati, il riscaldamento delle cache e la creazione di pool di connessioni. Quando esegui queste attività in sequenza, l'applicazione potrebbe rallentare. Per eseguire queste attività in parallelo, aumenta il numero di core CPU.

Cloud Run invia una richiesta utente reale per avviare un'istanza a freddo. Gli utenti che hanno una richiesta assegnata a un'istanza appena avviata potrebbero riscontrare lunghi ritardi.

Per le applicazioni con tempi di avvio lunghi, si consiglia di utilizzare un probe di avvio. Un controllo di avvio garantisce che Cloud Run invii le richieste degli utenti a un'istanza solo dopo che questa si è completamente inizializzata e ha superato il controllo di integrità iniziale. Per saperne di più, consulta Configurare i controlli di integrità dei container per i servizi.

Utilizzare il pool di connessioni

Se si utilizzano pool di connessioni, tenere presente che i pool di connessioni potrebbero chiudere le connessioni non necessarie in background (vedere Evitare attività in background). Se la tua applicazione ha un basso numero di query al secondo (QPS) e può tollerare un'elevata latenza, valuta la possibilità di aprire e chiudere le connessioni per ogni richiesta. Se la tua applicazione ha un elevato numero di richieste al secondo (QPS), le espulsioni in background potrebbero continuare a essere eseguite finché ci sono richieste attive.

In entrambi i casi, l'accesso al database dell'applicazione sarà limitato dal numero massimo di connessioni consentite dal database. Calcola il numero massimo di connessioni che puoi stabilire per istanza Cloud Run e configura il numero massimo di istanze Cloud Run in modo che il numero massimo di istanze moltiplicato per le 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

Utilizzare Spring Boot versione 2.2 o superiore.

A partire dalla versione 2.2, Spring Boot è stato fortemente ottimizzato per la velocità di avvio. Se stai utilizzando versioni di Spring Boot inferiori alla 2.2, valuta la possibilità di aggiornarle oppure applica manualmente le singole ottimizzazioni.

Utilizzare l'inizializzazione differita

In Spring Boot 2.2 e versioni successive è disponibile un flag di inizializzazione lazy globale che può essere attivato. Ciò migliorerà la velocità di avvio, ma con lo svantaggio che la prima richiesta potrebbe avere una latenza maggiore perché dovrà attendere l'inizializzazione dei componenti per la prima volta.

È possibile 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 si utilizzano istanze minime, l'inizializzazione differita non sarà d'aiuto, poiché l'inizializzazione dovrebbe essere avvenuta all'avvio dell'istanza minima.

Evitare la scansione delle classi

La scansione delle classi comporterà letture aggiuntive del disco in Cloud Run perché in Cloud Run l'accesso al disco è generalmente più lento rispetto a una macchina normale. Assicurati che la scansione dei componenti sia limitata o completamente evitata.

Utilizzare gli strumenti di sviluppo di Spring Boot non in produzione

Se utilizzi Spring Boot Developer Tool durante lo sviluppo, assicurati che non sia incluso nell'immagine del container di produzione. Questo può accadere se hai creato l'applicazione Spring Boot senza i plugin di build di Spring Boot (ad esempio, utilizzando il plugin Shade o Jib per la containerizzazione).

In questi casi, assicurarsi che lo strumento di compilazione escluda esplicitamente Spring Boot Dev Tool. Oppure, disattiva esplicitamente lo strumento di sviluppo Spring Boot).

Passaggi successivi

Per ulteriori suggerimenti, vedere