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 di ottimizzazione generali, che si applicano anche a Java.

Le applicazioni web Java convenzionali sono progettate per gestire le richieste con elevata contemporaneità e bassa latenza e tendono a essere applicazioni a lunga esecuzione. La JVM stessa ottimizza anche 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 best practice e delle ottimizzazioni in queste applicazioni web Java convenzionali riguardano:

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

Sebbene molte di queste ottimizzazioni convenzionali funzionino bene per le applicazioni a lunga esecuzione, 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 diversi per Cloud Run che puoi utilizzare per ridurre il tempo di avvio e la memoria utilizzata.

Utilizzare 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 al fine di ridurre la latenza di avvio.

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

Ottimizzare l'immagine container dell'applicazione Java

Ottimizzando l'immagine container, puoi ridurre i tempi di caricamento e di avvio. Puoi ottimizzare l'immagine nei seguenti modi:

  • Riduzione al minimo dell'immagine container
  • Evitare l'utilizzo di file JAR di archiviazione di librerie nidificate
  • Utilizzo di Jib

Ridurre al minimo l'immagine container

Per ulteriori informazioni su questo problema, consulta la pagina dei suggerimenti generali su riduzione al minimo del container per ulteriori contesto. La pagina dei suggerimenti generali consiglia di ridurre i contenuti dell'immagine container solo a quelli necessari. Ad esempio, assicurati che l'immagine container non contenga :

  • Codice sorgente
  • Artefatti di build Maven
  • Strumenti di build
  • Directory Git
  • File binari o utilità inutilizzati

Se stai creando il codice da un Dockerfile, utilizza la build multi-stage di Docker in modo che l'immagine container finale contenga solo la JRE e il file JAR dell'applicazione stessa.

Evitare i file JAR di archiviazione di librerie nidificate

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

Utilizzare Jib

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

Ottimizzazioni della JVM per le applicazioni Java Cloud Run

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

Utilizzare le versioni della JVM compatibili con i container

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 note, ad esempio, in Linux, /proc/cpuinfo e /proc/meminfo. Tuttavia, quando viene eseguito in un container, i vincoli di CPU e memoria vengono archiviati in /proc/cgroups/.... Le versioni precedenti della JDK continuano a cercare in /proc anziché in /proc/cgroups, il che può comportare un utilizzo di CPU e memoria utilizzata superiore a quello assegnato. Questo 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 container. La JVM utilizza la memoria in modo aggressivo prima di eseguire la garbage collection. In questo modo, il container può facilmente superare il limite di memoria del container e ricevere un segnale OOMKilled.

Pertanto, utilizza una versione della JVM compatibile con i container. Le versioni di OpenJDK maggiori 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 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 con 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 riceve un segnale OOMKilled e devi conoscere la memoria utilizzata dalla JVM (memoria nativa + heap), attiva il monitoraggio della memoria nativa per visualizzare gli utilizzi all'uscita dell'applicazione. Se la tua applicazione riceve un segnale OOMKilled, non sarà in grado di 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 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 ha diverse fasi di compilazione JIT. Sebbene queste fasi migliorino l'efficienza dell'applicazione nel tempo, possono anche aggiungere overhead alla memoria utilizzata e aumentare il tempo 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 di ambiente:

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

Utilizzare la condivisione dei dati di classe dell'applicazione

Per ridurre ulteriormente il tempo JIT e la memoria utilizzata, valuta la possibilità di utilizzare la condivisione dei dati di classe dell'applicazione (AppCDS) per condividere le classi Java compilate in anticipo come archivio. L'archivio AppCDS può essere riutilizzato all'avvio di un'altra istanza della stessa applicazione Java. La JVM può riutilizzare i dati precalcolati dall'archivio, il che riduce il tempo di avvio.

Per l'utilizzo di AppCDS si applicano le seguenti considerazioni:

  • L'archivio AppCDS da riutilizzare deve essere riprodotto esattamente dalla stessa distribuzione, versione e architettura di OpenJDK utilizzate originariamente 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 programmaticamente più percorsi del codice.
  • L'applicazione deve uscire 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 avvii nuove istanze esattamente nello stesso modo in cui è stato generato l'archivio.
  • L'archivio AppCDS funziona solo con un pacchetto di file JAR normale; non puoi utilizzare JAR nidificati.

Esempio di Spring Boot che utilizza un file JAR ombreggiato

Per impostazione predefinita, le applicazioni Spring Boot utilizzano un uber JAR nidificato, che non funziona per 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 JAR ombreggiato contiene tutte le dipendenze, puoi produrre un semplice archivio durante la build 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 dello stack di 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 di thread e per impostazione predefinita è di 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 dello stack di thread utilizzato. La memoria si aggiunge alle dimensioni dell'heap. Il valore predefinito potrebbe essere maggiore del necessario. Puoi ridurre le dimensioni dello stack di thread.

Se riduci troppo, vedrai java.lang.StackOverflowError. Puoi profilare l'applicazione e trovare le dimensioni ottimali dello stack di thread da configurare.

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

JAVA_TOOL_OPTIONS="-Xss256k"

Riduzione dei thread per le prestazioni delle applicazioni Java

Puoi 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 di thread. Cloud Run consente un massimo di 1000 richieste simultanee. Con il modello thread per connessione, hai bisogno di un massimo di 1000 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 puoi limitare le connessioni massime nel file applications.properties:

server.tomcat.max-threads=80

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

Per ridurre davvero il numero di thread, valuta la possibilità di adottare un modello di programmazione reattiva non bloccante, in modo che il numero di thread possa essere ridotto in modo significativo durante la gestione di più richieste simultanee. I 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, la velocità effettiva e le percentuali di errore saranno notevolmente peggiori 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 simultanee.

Questi framework non bloccanti possono anche trasferire il codice di blocco in un pool di thread illimitato, 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, esaurirai la risorsa CPU e inizierai a eseguire lo swapping. La latenza sarà gravemente compromessa. Se utilizzi un framework non bloccante, assicurati di comprendere i modelli di pool di thread e di limitare i pool di conseguenza.

Configurare la fatturazione basata sull'istanza se si utilizzano attività in background

Le attività in background sono tutto ciò che accade dopo la pubblicazione della risposta HTTP. I carichi di lavoro tradizionali con attività in background richiedono una considerazione speciale quando vengono eseguiti in Cloud Run.

Configurare la fatturazione basata sull'istanza

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

Evitare le attività in background se si utilizza la fatturazione basata sulle richieste

Se devi impostare il servizio sulla fatturazione basata sulle richieste, devi essere a conoscenza dei potenziali problemi con le attività in background. Ad esempio, se raccogli le metriche delle applicazioni e le raggruppi in batch in background per inviarle periodicamente, queste 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 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 pulizie e i controlli delle connessioni vengono in genere eseguiti in background
  • Mittenti di tracce distribuite: le tracce distribuite vengono in genere raggruppate in batch e inviate periodicamente o quando il buffer è pieno in background.
  • Mittenti di metriche: le metriche vengono in genere raggruppate in batch e inviate periodicamente in background.
  • Per Spring Boot, tutti i metodi annotati con l'annotazione @Async
  • Timer: tutti i trigger basati su timer (ad es. ScheduledThreadPoolExecutor, Quartz o l'annotazione @Scheduled di Spring) potrebbero non essere eseguiti quando è configurata la fatturazione basata sulle richieste.
  • Ricevitori di messaggi: ad esempio, i client pull di streaming Pub/Sub, i client JMS o i client Kafka vengono in genere eseguiti nei thread in background senza bisogno di richieste. Questi non funzioneranno quando l'applicazione non ha richieste. La ricezione di messaggi in questo modo non è consigliata in Cloud Run.

Ottimizzazioni delle applicazioni

Nel codice del servizio Cloud Run puoi anche ottimizzare i tempi di avvio e la memoria utilizzata.

Ridurre 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 della CPU.

Cloud Run invia una richiesta utente reale per attivare un'istanza di avvio a freddo. Gli utenti a cui è stata assegnata una richiesta a un'istanza appena avviata potrebbero riscontrare ritardi.

Per le applicazioni con tempi di avvio lunghi, valuta la possibilità di utilizzare un probe di avvio. Un controllo di avvio garantisce che Cloud Run invii le richieste utente a un'istanza solo dopo che è stata inizializzata completamente e ha superato il controllo di integrità di avvio. Per ulteriori informazioni, consulta Configurare i controlli di integrità dei container per i servizi.

Utilizzare il pool di connessioni

Se utilizzi i pool di connessioni, tieni presente che i pool di connessioni 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 eliminazioni in background potrebbero continuare a essere eseguite finché sono presenti 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 tenere in considerazione le seguenti ottimizzazioni

Utilizzare 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, oppure applica manualmente le singole ottimizzazioni.

Utilizzare l'inizializzazione lazy

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

Puoi attivare l'inizializzazione lazy in application.properties:

spring.main.lazy-initialization=true

Oppure, utilizzando una variabile di ambiente:

SPRING_MAIN_LAZY_INITIALIZATIION=true

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

Evitare la scansione delle classi

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

Utilizzare gli strumenti per sviluppatori di Spring Boot non in produzione

Se utilizzi lo strumento per sviluppatori di Spring Boot durante lo sviluppo, assicurati che non sia incluso nell'immagine container di produzione. Questo potrebbe accadere se hai creato l'applicazione Spring Boot senza i plug-in di build di Spring Boot (ad esempio, utilizzando il plug-in Shade o Jib per containerizzare).

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

Passaggi successivi

Per altri suggerimenti, vedi