Questa guida descrive le ottimizzazioni per i servizi di serving Knative scritti nel linguaggio di programmazione Java, insieme a informazioni di base per aiutarti a comprendere i compromessi coinvolti in alcune delle ottimizzazioni. Le informazioni contenute in questa pagina integrano i suggerimenti generali per l'ottimizzazione, che si applicano anche a Java.
Le tradizionali applicazioni web basate su Java sono progettate per gestire richieste con concorrenza elevata e bassa latenza e tendono a essere applicazioni a esecuzione prolungata. 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 best practice e delle ottimizzazioni in queste applicazioni web tradizionali basate su Java riguardano:
- Gestione delle richieste simultanee (I/O basato su thread e non bloccante)
- Riduzione della latenza di risposta utilizzando il pooling delle connessioni e il batching di funzioni non critiche, ad esempio l'invio di trace 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 Knative serving, che viene eseguito solo quando gestisce attivamente le richieste. Questa pagina illustra alcune ottimizzazioni e compromessi diversi per Knative serving che puoi utilizzare per ridurre il tempo di avvio e l'utilizzo della memoria.
Ottimizzazione dell'immagine container
Ottimizzando l'immagine container, puoi ridurre i tempi di caricamento e avvio. Puoi ottimizzare l'immagine:
- Riduzione al minimo dell'immagine container
- Evitare l'utilizzo di file JAR di archiviazione di librerie nidificate
- Utilizzo di Jib
Riduzione al minimo dell'immagine container
Per ulteriori informazioni su questo problema, consulta la pagina dei suggerimenti generali su come ridurre al minimo il contenitore. La pagina dei suggerimenti generali consiglia di ridurre i contenuti dell'immagine del container solo a quelli necessari. Ad esempio, assicurati che l'immagine container non contenga :
- Codice sorgente
- Artefatti build Maven
- Strumenti di creazione
- Directory Git
- Binari/utilità inutilizzati
Se crei il codice da un Dockerfile, utilizza la build multistadio di Docker in modo che l'immagine container finale contenga solo la JRE e il file JAR dell'applicazione.
Evitare i file JAR di archivi di librerie nidificati
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 durante l'avvio e possono aumentare la velocità di avvio in Knative Serving. Se possibile, crea un JAR sottile con librerie esterne: questa operazione può essere automatizzata utilizzando Jib per containerizzare l'applicazione
Utilizzo di Jib
Utilizza il plug-in Jib per creare un container minimale e comprimere automaticamente l'archivio dell'applicazione. Jib funziona sia con Maven che con Gradle e con le applicazioni Spring Boot pronte all'uso. Alcuni framework applicativi potrebbero richiedere configurazioni Jib aggiuntive.
Ottimizzazioni JVM
L'ottimizzazione della JVM per un servizio Knative può migliorare le prestazioni e l'utilizzo della memoria.
Utilizzo di versioni JVM compatibili con i container
In VM e 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
dell'JDK continuano 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é 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 la memoria in modo aggressivo prima di eseguire la garbage collection. In questo modo, il container può facilmente superare il limite di memoria e venire terminato per errore.
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.
Informazioni sull'utilizzo della memoria JVM
L'utilizzo della memoria della JVM è composto dall'utilizzo della memoria nativa e dall'utilizzo dell'heap. La memoria di lavoro dell'applicazione si trova in genere nell'heap. La dimensione dell'heap è vincolata dalla configurazione Max Heap. Con un'istanza Knative Serving 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 interrotta a causa di un errore di memoria insufficiente e devi conoscere l'utilizzo della memoria JVM (memoria nativa + heap), attiva il monitoraggio della memoria nativa per visualizzare gli utilizzi in caso di uscita corretta dall'applicazione. Se l'applicazione viene chiusa per esaurimento della memoria, 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 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 di memoria Java open source per stimare le esigenze di memoria.
Disattivazione del compilatore di ottimizzazione
Per impostazione predefinita, la JVM prevede diverse fasi di compilazione JIT. Sebbene queste fasi migliorino l'efficienza della tua applicazione nel tempo, possono anche aumentare l'overhead dell'utilizzo della memoria e 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 Knative serving, configura la variabile di ambiente:
JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
Utilizzo della condivisione dei dati tra le classi delle applicazioni
Per ridurre ulteriormente il tempo JIT e l'utilizzo della memoria, valuta la possibilità 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 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.
Le seguenti considerazioni si applicano all'utilizzo di AppCDS:
- L'archivio AppCDS da riutilizzare deve essere riprodotto esattamente dalla stessa distribuzione, versione e architettura di OpenJDK utilizzata originariamente per produrlo.
- Devi eseguire l'applicazione almeno una volta per generare l'elenco delle classi da condividere, quindi 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 in modo programmatico più percorsi di codice.
- L'applicazione deve essere chiusa correttamente per generare questo elenco di corsi. 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 file JAR nidificati.
Esempio di Spring Boot che utilizza un file JAR ombreggiato
Le applicazioni Spring Boot utilizzano per impostazione predefinita un file 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 file JAR ombreggiato contiene tutte le dipendenze, puoi produrre un
archivio semplice durante la creazione del contenitore utilizzando un Dockerfile
:
# Use Docker's multi-stage build
FROM adoptopenjdk:11-jre-hotspot 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 adoptopenjdk:11-jre-hotspot
# 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
Disattivazione della verifica della classe
Quando la JVM carica le classi in memoria per l'esecuzione, verifica che la classe non sia stata manomessa e non contenga modifiche o danneggiamenti dannosi. Se la tua pipeline di distribuzione del software è attendibile (ad esempio, puoi verificare e convalidare ogni output), se puoi considerare attendibile il bytecode nell'immagine container e se la tua applicazione non carica classi da origini remote arbitrarie, puoi valutare la possibilità di disattivare la verifica. La disattivazione della verifica può migliorare la velocità di avvio se viene caricato un numero elevato di classi all'avvio.
Per un servizio Knative serving, configura la variabile di ambiente:
JAVA_TOOL_OPTIONS="-noverify"
Riduzione delle dimensioni dello stack dei thread
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 dello stack dei thread utilizzato. La memoria si aggiunge alla dimensione dell'heap. Il valore predefinito potrebbe essere superiore al necessario. Puoi ridurre le dimensioni dello stack del thread.
Se riduci troppo, vedrai java.lang.StackOverflowError
. Puoi
profilare la tua applicazione e trovare la dimensione ottimale dello stack di thread da configurare.
Per un servizio Knative serving, 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 attività in background.
Riduzione del numero di thread
Ogni thread Java può aumentare l'utilizzo della memoria a causa dello stack di thread.
Knative Serving consente un massimo di 80 richieste simultanee. Con il modello thread per connessione, hai bisogno di un massimo di
80 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 il numero massimo
di connessioni 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 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 in genere hanno tempi di avvio più rapidi.
Se continui a scrivere codice di blocco in un framework non bloccante, il throughput e i tassi di errore saranno significativamente peggiori in un servizio Knative. 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 scaricare il codice di blocco in un pool di thread senza limiti, il che significa che, sebbene possa accettare molte richieste simultanee, il codice di bloccaggio verrà eseguito in nuovi thread. Se i thread si accumulano in modo illimitato, esaurirai la risorsa CPU e inizierai a thrashare. La latenza sarà fortemente influenzata. Se utilizzi un framework non bloccante, assicurati di comprendere i modelli di pool di thread e di limitare i pool di conseguenza.
Evitare attività in background
Knative Serving limita la CPU di un'istanza quando questa istanza non riceve più richieste. I workload tradizionali con attività in background richiedono una considerazione speciale quando vengono eseguiti in Knative serving.
Ad esempio, se raccogli metriche delle applicazioni e le raggruppi in batch in background per inviarle periodicamente, queste metriche non verranno inviate quando la CPU è limitata. 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:
- Pool di connessioni JDBC: le pulizie e i controlli delle connessioni vengono eseguiti in genere in background
- Mittenti di trace distribuite: le trace distribuite vengono in genere 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, tutti i metodi annotati con l'annotazione
@Async
- Timer: qualsiasi attivatore basato sul timer (ad es. ScheduledThreadPoolExecutor, Quartz o
@Scheduled
Spring annotation) potrebbero non essere eseguiti quando le CPU sono limitate. - Destinatari dei 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 necessità di richieste. Questi non funzioneranno quando l'applicazione non ha richieste. La ricezione di messaggi in questo modo non è consigliata in Knative serving.
Ottimizzazioni delle applicazioni
Nel codice del servizio Knative Serving, puoi anche ottimizzare per tempi di avvio e utilizzo della memoria più rapidi.
Riduzione delle attività di avvio
Le tradizionali applicazioni web basate su Java possono avere molte attività da completare durante l'avvio, ad esempio il precaricamento dei dati, il riscaldamento della cache, la creazione di 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, Knative serving invia una richiesta di un 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. Knative serving attualmente non dispone di un controllo di "idoneità" per evitare di inviare richieste ad applicazioni non pronte.
Utilizzo del pool di connessioni
Se utilizzi i pool di connessioni, 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 ogni richiesta. Se la tua applicazione ha un valore QPS elevato, le espulsioni 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 di Knative Serving e configura il numero massimo di istanze di Knative Serving in modo che il numero massimo di istanze moltiplicato per le connessioni per istanza sia inferiore al numero massimo di connessioni consentite.
Utilizzo di Spring Boot
Se utilizzi Spring Boot, devi prendere in considerazione le seguenti ottimizzazioni
Utilizzo di 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 manualmente le singole ottimizzazioni.
Utilizzo dell'inizializzazione lazy
In Spring Boot 2.2 e versioni successive è disponibile un flag di inizializzazione differita 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
In alternativa, utilizzando una variabile di ambiente:
SPRING_MAIN_LAZY_INITIALIZATIION=true
Tuttavia, se utilizzi min-instances, l'inizializzazione differita non ti aiuterà, poiché l'inizializzazione avrebbe dovuto verificarsi all'avvio di min-instances.
Evitare la scansione delle classi
La scansione delle classi causerà ulteriori letture del disco in Knative serving perché in Knative serving, l'accesso al disco è generalmente più lento rispetto a 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 dipende dall'applicazione.
Ad esempio, in Maven pom.xml
aggiungi la dipendenza dell'indicizzatore (in realtà
è un processore di annotazioni):
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-indexer</artifactId>
<optional>true</optional>
</dependency>
Utilizzo degli strumenti per sviluppatori 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. Ciò 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 Jib per creare il container).
In questi casi, assicurati che lo strumento di compilazione escluda esplicitamente Spring Boot Devtools. In alternativa, disattiva esplicitamente lo strumento per sviluppatori Spring Boot.
Passaggi successivi
Per altri suggerimenti, vedi