Ottimizza 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 che ti aiutano a comprendere i compromessi associati ad alcune ottimizzazioni. Le informazioni contenute in questa pagina integrano i suggerimenti per l'ottimizzazione generali, applicabili anche a Java.

Le applicazioni Java tradizionali basate sul web sono progettate per gestire richieste con elevata contemporaneità e bassa latenza e tendono a essere applicazioni a lunga esecuzione. La stessa JVM ottimizza anche il codice di esecuzione nel tempo con JIT, in modo da ottimizzare i percorsi a caldo e le applicazioni eseguite in modo più efficiente nel tempo.

Molte delle best practice e delle ottimizzazioni di questa applicazione Java tradizionale basata sul web riguardano:

  • Gestione delle richieste in parallelo (I/O basate su thread e non)
  • Riduzione della latenza di risposta utilizzando il pool di connessioni e il raggruppamento di 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 lunga esecuzione, potrebbero non funzionare altrettanto bene in un servizio Cloud Run, che viene eseguito solo quando gestisce 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.

Usa il booster della CPU all'avvio per ridurre la latenza di avvio

Puoi abilitare il booster 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 mostrato che le app Java sono vantaggiose se utilizzano il booster della CPU di avvio, che può ridurre i tempi di avvio fino al 50%.

Ottimizza l'immagine container

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

  • Ridurre al minimo l'immagine container
  • Evita l'utilizzo di JAR di archiviazione delle librerie nidificate
  • Utilizzo del fiocco

Riduci a icona l'immagine container

Consulta la pagina di suggerimenti generali su come ridurre al minimo i container per maggiori informazioni su questo problema. La pagina dei suggerimenti generali consiglia di ridurre il contenuto delle immagini container solo a ciò che è necessario. Ad esempio, assicurati che l'immagine container non contenga :

  • Codice sorgente
  • Artefatti build Maven
  • Strumenti di creazione
  • Directory Git
  • Programmi binari/utility inutilizzati

Se stai creando il codice dall'interno di un Dockerfile, usa la build multifase Docker in modo che l'immagine container finale contenga solo il JRE e il file JAR dell'applicazione stesso.

Evita i file JAR di archivi di librerie nidificati

Alcuni framework popolari, come Spring Boot, creano un file JAR (Application Archive) che contiene file JAR di libreria aggiuntivi (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. Quindi, quando possibile, crea un JAR thin con librerie esterne. L'operazione può essere automatizzata utilizzando Jib per containerizzare l'applicazione

Utilizza Fiocco

Usa il plug-in Jib per creare un container minimo e suddividere automaticamente l'archivio dell'applicazione. Jib funziona sia con Maven che con Gradle e con applicazioni Spring Boot pronte all'uso. Alcuni framework di applicazioni potrebbero richiedere configurazioni di Jib aggiuntive.

Ottimizzazioni JVM

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

Utilizza versioni JVM sensibili ai container

Nelle VM e nelle macchine, per le allocazioni di CPU e memoria, la JVM conosce la CPU e la memoria che può utilizzare da località note, ad esempio in Linux, /proc/cpuinfo e /proc/meminfo. Tuttavia, durante l'esecuzione in un container, i vincoli di CPU e memoria sono archiviati in /proc/cgroups/.... La versione precedente del JDK continua a cercare in /proc anziché in /proc/cgroups, il che può comportare un utilizzo di CPU e memoria maggiore di quello assegnato. Ciò può causare:

  • Un numero eccessivo di thread perché la dimensione del pool di thread è configurata da Runtime.availableProcessors()
  • Un heap massimo predefinito che supera il limite di memoria del container. La JVM utilizza in modo aggressivo la memoria prima della garbage collection. Questo può causare facilmente il superamento del limite di memoria del container e l'interruzione della funzionalità OOM.

Quindi, utilizza una versione JVM sensibile al container. Le versioni OpenJDK successive o uguali alla versione 8u192 sono sensibili al container per impostazione predefinita.

Come interpretare l'utilizzo della memoria JVM

L'utilizzo della memoria JVM è costituito dall'utilizzo della memoria nativo e dall'utilizzo heap. La memoria di lavoro dell'applicazione è in genere nell'heap. La dimensione dell'heap è vincolata dalla configurazione dell'heap massimo. Con un'istanza di Cloud Run da 256 MB di RAM, non puoi assegnare tutti i 256 MB all'heap massimo, poiché 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 è terminata e devi conoscere l'utilizzo della memoria JVM (memoria di monitoraggio + heap) per attivare la memoria nativa. Se la tua applicazione viene interrotta, non potrà stampare le informazioni. In questo caso, prima esegui l'applicazione con più memoria in modo che possa generare correttamente l'output.

Non è possibile attivare il monitoraggio della memoria nativo 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 i seguenti 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. Prendi in considerazione l'utilizzo di un calcolatore di memoria Java open source per stimare le esigenze di memoria.

Disattivare il compilatore di ottimizzazione

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

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

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

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

Utilizzare la condivisione dei dati della classe dell'applicazione

Per ridurre ulteriormente l'utilizzo del tempo JIT e della memoria, valuta la possibilità di utilizzare la condivisione dei dati delle classi di applicazioni (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 precalcolati dall'archivio, riducendo così i tempi 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 OpenJDK originariamente utilizzato per crearlo.
  • Devi eseguire l'applicazione almeno una volta per generare l'elenco di classi da condividere, quindi utilizzare questo elenco per generare l'archivio AppCDS.
  • La copertura delle classi dipende dal percorso di codice eseguito durante l'esecuzione dell'applicazione. Per aumentare la copertura, attiva in modo programmatico più percorsi di codice.
  • Per generare questo elenco di classi, l'applicazione deve chiudersi correttamente. 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 normale pacchetto di file JAR; non puoi utilizzare JAR nidificati.

Esempio di Spring Boot utilizzando un file JAR ombreggiato

Le applicazioni Spring Boot utilizzano JAR nidificato per impostazione predefinita, che non funziona per AppCDS. Quindi, se utilizzi AppCDS, devi creare un JAR ombreggiato. Ad esempio, se utilizzi 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 archivio semplice 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

Riduci 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 heap). È noto come stack di thread ed è il valore predefinito di 1 MB per thread. Se l'applicazione gestisce 80 richieste in parallelo, potrebbe avere almeno 80 thread, il che significa 80 MB di spazio dello stack dei thread utilizzato. La memoria si aggiunge alla dimensione heap. Il valore predefinito potrebbe essere superiore al necessario. Puoi ridurre le dimensioni dello stack dei thread.

Se riduci troppo, vedrai java.lang.StackOverflowError. Puoi profilare la tua 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

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 potrebbe aumentare l'utilizzo della memoria a causa dello stack di thread. Cloud Run consente un massimo di 1000 richieste in parallelo. 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 che non blocca per ottimizzare la memoria e l'avvio

Per ridurre davvero il numero di thread, valuta la possibilità di adottare un modello di programmazione reattivo che non blocca il numero di thread, in modo da ridurre notevolmente il numero di thread gestendo più richieste in parallelo. I framework applicativi come Spring Boot con Webflux, Micronaut e Quarkus supportano applicazioni web reattive.

Framework reattivi come Spring Boot with Webflux, Micronaut e Quarkus di solito hanno tempi di avvio più rapidi.

Se continui a scrivere codice di blocco in un framework non bloccato, la velocità effettiva e le percentuali di errore saranno notevolmente inferiori in un servizio Cloud Run. Questo perché i framework che non comportano il blocco avranno solo pochi thread, ad esempio 2 o 4. Se il codice viene bloccato, è in grado di gestire un numero molto ridotto di richieste in parallelo.

Questi framework non bloccanti possono anche trasferire il codice di blocco a un pool di thread illimitato. Ciò significa che, anche se possono accettare molte richieste in parallelo, il codice di blocco verrà eseguito in nuovi thread. Se i thread si accumulano in modo illimitato, la risorsa CPU si esaurirà e inizierà il thrash. La latenza risentirà gravemente. Se utilizzi un framework non bloccante, assicurati di comprendere i modelli dei pool di thread e associa i pool di conseguenza.

Configura la CPU da allocare sempre se utilizzi attività in background

L'attività in background è tutto ciò che accade dopo che la risposta HTTP è stata inviata. I carichi di lavoro tradizionali con attività in background richiedono un'attenzione particolare durante l'esecuzione in Cloud Run.

Configura la CPU da allocare sempre

Se vuoi supportare le attività in background nel 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 senza rinunciare all'accesso alla CPU.

Evita attività in background se la CPU viene allocata solo durante l'elaborazione della richiesta

Se devi impostare il servizio in modo da allocare la CPU solo durante l'elaborazione delle richieste, devi essere a conoscenza dei potenziali problemi relativi alle attività in background. Ad esempio, se raccogli metriche delle applicazioni e raggruppa le metriche in background per inviarle periodicamente, queste metriche non vengono inviate se la CPU non è allocata. Se la tua applicazione riceve costantemente richieste, potresti riscontrare meno problemi. Se l'applicazione ha un numero di QPS basso, l'attività in background potrebbe non essere mai eseguita.

Alcuni pattern 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 della connessione vengono generalmente 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 l'Avvio di primavera, tutti i metodi annotati con l'annotazione @Async
  • Timer: qualsiasi attivatore basato su timer (ad es. ScheduleThreadPoolExecutor, Quartz o l'annotazione @Scheduled Spring) potrebbe non essere eseguita quando non sono allocate le CPU.
  • Ricevitori di messaggi: ad esempio, i client pull di flussi Pub/Sub, i client JMS o i client Kafka, di solito vengono eseguiti nei thread in background senza la necessità di richieste. Questi codici non funzionano se la tua applicazione non ha richieste. La ricezione di messaggi in questo modo non è consigliata in Cloud Run.

Ottimizzazioni delle applicazioni

Inoltre, puoi ottimizzare il codice del servizio Cloud Run per accelerare i tempi di avvio e l'utilizzo della memoria.

Riduci le attività all'avvio

Le applicazioni Java tradizionali basate sul web possono avere molte attività da completare durante l'avvio, ad esempio precaricamento dei dati, riscaldamento della cache, creazione di pool di connessione 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 CPU.

Al momento Cloud Run invia una richiesta dell'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 lunghi ritardi. Cloud Run al momento non dispone di un controllo di idoneità per evitare di inviare richieste ad applicazioni non pronte.

Utilizza pool di connessioni

Se utilizzi i pool di connessioni, tieni presente che i pool di connessioni potrebbero rimuovere le connessioni non necessarie in background (vedi Evitare le attività in background). Se la tua applicazione ha un numero di QPS basso e può tollerare un'elevata latenza, ti consigliamo di aprire e chiudere le connessioni per richiesta. Se l'applicazione ha un numero di QPS elevato, le rimozioni in background potrebbero continuare a essere eseguite finché sono presenti richieste attive.

In entrambi i casi, l'accesso dell'applicazione al database sarà colpito dal numero massimo di connessioni consentite dal database. Calcolare il numero massimo di connessioni che puoi stabilire per ogni istanza di Cloud Run e configurare il numero massimo di istanze di Cloud Run in modo che il numero massimo di istanze per ciascuna 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 successive

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

Usa l'inizializzazione lazy

Esiste un flag di inizializzazione lazy globale che può essere attivato in Spring Boot 2.2 e versioni successive. Questo migliorerà la velocità di avvio, ma con il compromesso che 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 istanze minime, l'inizializzazione lazy non sarà di aiuto, poiché l'inizializzazione dovrebbe essersi verificata all'avvio dell'istanza minima.

Evita la scansione dei corsi

La scansione delle classi causerà letture del disco aggiuntive in Cloud Run perché in Cloud Run l'accesso al disco è generalmente più lento di una macchina normale. Assicurati che l'analisi dei componenti sia limitata o completamente evitata. Valuta la possibilità di utilizzare Spring Context Indexer per pregenerare un indice. Il miglioramento della velocità di avvio varia in base all'applicazione.

Ad esempio, nel tuo Maven pom.xml aggiungi la dipendenza dell'indicizzatore (in realtà si tratta di un processore di annotazioni):

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

Utilizzare strumenti per sviluppatori Spring Boot non in produzione

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

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

Passaggi successivi

Per altri suggerimenti, vedi