Optimiser les applications Java pour Cloud Run

Ce guide décrit des optimisations pour les services Cloud Run écrits dans le langage de programmation Java. Il présente également des informations générales pour vous aider à comprendre les compromis requis par certaines des optimisations. Les informations contenues sur cette page viennent en complément des conseils généraux d'optimisation, qui s'appliquent également à Java.

Les applications Web traditionnelles en langage Java sont conçues pour diffuser des requêtes avec une haute capacité de simultanéité et une faible latence. Il s'agit généralement d'applications à exécution longue. La JVM proprement dite optimise également le code d'exécution au fil du temps à l'aide de JIT, afin que les chemins d'accès les plus sollicités soient optimisés et que l'exécution des applications gagne progressivement en efficacité.

Un grand nombre de bonnes pratiques et d'optimisations de ces applications Web Java traditionnelles reposent sur les aspects suivants :

  • La gestion des requêtes simultanées (E/S non bloquantes et basées sur un thread)
  • La réduction de la latence de réponse à l'aide de fonctions non critiques de pooling des connexions et de traitement par lots, par exemple l'envoi de traces et de métriques à des tâches en arrière-plan

Bien que la plupart de ces optimisations traditionnelles fonctionnent bien pour les applications à exécution longue, il se peut qu'elles ne soient pas aussi efficaces dans un service Cloud Run, qui ne s'exécute que lors de la diffusion active des requêtes. Cette page présente plusieurs optimisations et compromis spécifiques à Cloud Run, que vous pouvez utiliser pour réduire le temps de démarrage et l'utilisation de la mémoire.

Utiliser l'optimisation du processeur au démarrage pour réduire la latence de démarrage

Vous pouvez activer l'optimisation du processeur au démarrage afin d'augmenter temporairement l'allocation de processeur lors du démarrage de l'instance et ainsi de réduire la latence de démarrage.

Les métriques de Google ont montré que les applications Java bénéficient de la fonctionnalité d'optimisation du processeur au démarrage, qui peut réduire le temps de démarrage jusqu'à 50 %.

Optimiser l'image de conteneur

Optimiser l'image du conteneur peut vous aider à réduire les temps de chargement et de démarrage. Différentes actions vous permettent d'optimiser l'image :

  • Réduire l'image de conteneur
  • Éviter l'imbrication de fichiers JAR d'archives de bibliothèques
  • Utiliser Jib

Réduire l'image de conteneur

Consultez la page de conseils généraux visant à réduire la taille de l'image de conteneur pour obtenir davantage de contexte sur ce problème. Sur la page "Conseils généraux", nous vous recommandons de limiter le contenu des images de conteneurs aux seuls éléments indispensables. Par exemple, assurez-vous que votre image de conteneur ne contient pas les éléments suivants :

  • Code source
  • Artefacts de compilation Maven
  • Outils de compilation
  • Répertoires Git
  • Binaires/utilitaires non utilisés

Si vous compilez le code à partir d'un fichier Dockerfile, utilisez une compilation Docker en plusieurs étapes afin que l'image finale du conteneur n'héberge que le JRE et le fichier JAR de l'application en soi.

Éviter l'imbrication de fichiers JAR d'archives de bibliothèques

Certains frameworks courants, tels que Spring Boot, créent un fichier d'archive d'application (JAR) contenant des fichiers JAR supplémentaires pour les bibliothèques (fichiers JAR imbriqués). Ces fichiers doivent être décompressés lors du démarrage, ce qui peut allonger le temps démarrage dans Cloud Run. Dans la mesure du possible, créez un fichier JAR léger avec des bibliothèques externalisées : vous pouvez automatiser cela en utilisant Jib pour conteneuriser votre application.

Utiliser Jib

Utilisez le plug-in Jib pour créer un conteneur minimal et désimbriquer automatiquement l'archive de l'application. Jib fonctionne avec Maven et Gradle, ainsi qu'avec les applications Spring Boot prêtes à l'emploi. Certains frameworks d'applications peuvent nécessiter des éléments de configuration supplémentaires pour Jib.

Optimiser la JVM

Optimiser la JVM pour un service Cloud Run peut conduire à améliorer les performances et l'utilisation de la mémoire.

Utiliser des versions de JVM compatibles avec les conteneurs

Dans les VM et les machines, pour l'allocation du processeur et de la mémoire, la JVM identifie le processeur et la mémoire qu'elle peut utiliser à partir d'emplacements bien connus : sous Linux, il s'agit par exemple de /proc/cpuinfo et /proc/meminfo. Toutefois, lors de l'exécution dans un conteneur, les contraintes de processeur et de mémoire sont stockées dans /proc/cgroups/.... Une ancienne version du JDK continuera d'examiner /proc au lieu de /proc/cgroups, ce qui peut entraîner une utilisation du processeur et de la mémoire supérieure à la valeur allouée. Cela peut avoir les conséquences suivantes :

  • Un nombre excessif de threads, car la taille du pool de threads est configurée par Runtime.availableProcessors().
  • Un segment de mémoire par défaut qui dépasse la limite de mémoire du conteneur. La JVM utilise la mémoire de manière intensive avant de réaliser la récupération de mémoire. Cela peut facilement entraîner le dépassement de la limite de mémoire du conteneur et aboutir à un arrêt OOMKilled.

Vous devez donc utiliser une version de JVM compatible avec les conteneurs. Les versions d'OpenJDK supérieures ou égales à la version 8u192 sont par défaut compatibles avec les conteneurs.

Comprendre l'utilisation de la mémoire par la JVM

L'utilisation de la mémoire par la JVM se décompose en utilisation de la mémoire native et utilisation du tas de mémoire. La mémoire de travail de votre application se trouve généralement dans le tas de mémoire. La taille du tas de mémoire est limitée par la configuration du tas de mémoire maximal. Sur une instance Cloud Run dotée de 256 Mo de RAM, vous ne pouvez pas allouer la totalité des 256 Mo au tas de mémoire maximal : en effet, la JVM et le système d'exploitation nécessitent également de la mémoire native, par exemple pour la pile de threads, les caches de code, les descripteurs de fichiers, les tampons de mémoire, etc. Si votre application subit un arrêt OOMKilled et que vous avez besoin de connaître l'utilisation de la mémoire par la JVM (mémoire native + tas de mémoire), activez le suivi de la mémoire native (Native Memory Tracking) pour observer les différentes valeurs d'utilisation lors d'un arrêt réussi de votre application. Si votre application subit un arrêt OOMKilled, elle ne sera pas en mesure d'imprimer ces informations. Dans ce cas, commencez par exécuter l'application avec davantage de mémoire, afin qu'elle puisse correctement générer la sortie.

Le suivi de la mémoire native ne peut pas être activé via la variable d'environnement JAVA_TOOL_OPTIONS. Vous devez ajouter l'argument de démarrage de ligne de commande Java au point d'entrée de votre image de conteneur, afin que votre application soit démarrée avec les arguments suivants :

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

L'utilisation de la mémoire native peut être estimée en fonction du nombre de classes à charger. Envisagez d'utiliser un simulateur de mémoire Java Open Source pour estimer les besoins en mémoire.

Désactiver le compilateur d'optimisation

Par défaut, la JVM comporte plusieurs phases de compilation JIT. Bien que ces phases améliorent l'efficacité de votre application au fil du temps, elles peuvent également alourdir l'utilisation de la mémoire et allonger le délai de démarrage.

Pour les applications sans serveur et à exécution de courte durée (par exemple, les fonctions), envisagez de désactiver les phases d'optimisation : vous perdrez en efficacité à long terme, mais vous y gagnerez en termes de délai de démarrage.

Pour un service Cloud Run, configurez la variable d'environnement suivante :

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

Utiliser le partage des données de la classe d'application

Pour réduire davantage le temps JIT et l'utilisation de la mémoire, envisagez d'utiliser le partage de données des classes d'application (AppCDS, application class-data sharing) pour partager les classes Java compilées à l'avance en tant qu'archive. L'archive AppCDS peut être réutilisée lorsque vous démarrez une autre instance de la même application Java. La JVM peut réutiliser les données précalculées de l'archive, ce qui réduit le temps de démarrage.

Les considérations suivantes s'appliquent à l'utilisation d'AppCDS :

  • L'archive AppCDS à réutiliser doit être reproduite par exactement les mêmes distribution, version et architecture OpenJDK que celles initialement utilisées pour la produire.
  • Vous devez exécuter votre application au moins une fois pour générer la liste des classes à partager, puis générer l'archive AppCDS à l'aide de cette liste.
  • La couverture des classes dépend du chemin d'accès au code utilisé lors de l'exécution de l'application. Pour augmenter la couverture, déclenchez plus de chemins d'accès au code par programmation.
  • L'application doit se fermer correctement pour générer cette liste de classes. Envisagez de mettre en œuvre une option d'application servant à indiquer la génération de l'archive AppCDS, de sorte qu'elle puisse se fermer immédiatement.
  • L'archive AppCDS ne peut être réutilisée que si vous lancez de nouvelles instances exactement de la même manière que l'archive.
  • L'archive AppCDS ne fonctionne qu'avec un package de fichiers JAR standard. Vous ne pouvez pas utiliser de fichiers JAR imbriqués.

Exemple Spring Boot avec un fichier JAR ombré

Par défaut, les applications Spring Boot utilisent un fichier uber JAR imbriqué, qui ne fonctionne pas pour AppCDS. Ainsi, si vous utilisez AppCDS, vous devez créer un fichier JAR ombré. Par exemple, avec Maven et le 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>

Si votre fichier JAR ombré contient toutes les dépendances, vous pouvez produire une archive simple pendant la création du conteneur à l'aide d'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

Réduire la taille de la pile de threads

La plupart des applications Web Java sont construites sur le modèle "un thread par connexion". Chaque thread Java consomme de la mémoire native (et non du tas de mémoire). C'est ce que l'on appelle la pile de threads, qui utilise par défaut 1 Mo par thread. Si votre application gère 80 requêtes simultanées, cela représente au moins 80 threads, c'est-à-dire 80 Mo d'espace utilisé pour la pile de threads. Cette mémoire s'ajoute à la taille du tas de mémoire. La valeur par défaut peut s'avérer plus grande que nécessaire. Vous pouvez réduire la taille de la pile de threads.

Si vous la réduisez trop, vous risquez d'être confronté à une erreur java.lang.StackOverflowError. Vous pouvez profiler votre application et déterminer la taille optimale à configurer pour la pile de threads.

Pour un service Cloud Run, configurez la variable d'environnement suivante :

JAVA_TOOL_OPTIONS="-Xss256k"

Réduire les threads

Vous pouvez optimiser la mémoire en réduisant le nombre de threads, en faisant appel à des stratégies réactives non bloquantes et en évitant les activités d'arrière-plan.

Réduire le nombre de threads

Chaque thread Java est susceptible d'augmenter l'utilisation de la mémoire en raison de la pile de threads. Cloud Run autorise un maximum de 1 000 requêtes simultanées. Avec le modèle "un thread par connexion", vous avez donc besoin d'un maximum de 1 000 threads pour gérer toutes les requêtes simultanées. La plupart des serveurs et des frameworks Web vous permettent de configurer le nombre maximal de threads et de connexions. Par exemple, dans Spring Boot, vous pouvez limiter le nombre maximal de connexions dans le fichier applications.properties :

server.tomcat.max-threads=80

Écrire du code réactif non bloquant pour optimiser la mémoire et le démarrage

Pour réellement réduire le nombre de threads, envisagez d'adopter un modèle de programmation réactif non bloquant, qui diminue considérablement le nombre de threads tout en gérant davantage de requêtes simultanées. Les frameworks d'application tels que Spring Boot avec Webflux, Micronaut et Quarkus sont compatibles avec les applications Web réactives.

Les frameworks réactifs tels que Spring Boot avec Webflux, Micronaut et Quarkus présentent généralement des temps de démarrage plus rapides.

Si vous continuez à écrire du code bloquant dans un framework non bloquant, le débit et les taux d'erreur en seront fortement affectés dans un service Cloud Run. En effet, les frameworks non bloquants ne présentent qu'un petit nombre de threads, par exemple deux ou quatre. Si votre code est bloquant, il ne peut traiter que très peu de requêtes simultanées.

Ces frameworks non bloquants peuvent également décharger le code bloquant dans un pool de threads illimité. Ainsi, même s'il peut accepter de nombreuses requêtes simultanées, le code bloquant s'exécute dans de nouveaux threads. Si les threads s'accumulent de manière illimitée, vous épuisez la ressource de processeur et aboutissez à une situation de thrashing. La latence en sera sévèrement affectée. Si vous utilisez un framework non bloquant, assurez-vous de comprendre les modèles de pool de threads et de leur appliquer des limitations en conséquence.

Configurer le processeur pour qu'il soit toujours alloué si vous utilisez des activités en arrière-plan

L'activité d'arrière-plan désigne tout ce qui se produit après la transmission de votre réponse HTTP. Les charges de travail traditionnelles ayant des tâches en arrière-plan nécessitent une attention particulière lors de l'exécution dans Cloud Run.

Configurer le processeur pour qu'il soit toujours alloué

Si vous souhaitez prendre en charge les activités d'arrière-plan dans votre service Cloud Run, configurez le processeur de votre service Cloud Run pour qu'il soit toujours alloué afin de pouvoir exécuter des activités d'arrière-plan en dehors des requêtes tout en conservant l'accès au processeur.

Éviter les activités d'arrière-plan si le processeur n'est alloué que pendant le traitement des requêtes

Si vous devez configurer votre service pour allouer un processeur uniquement pendant le traitement des requêtes, vous devez identifier les problèmes potentiels avec les activités d'arrière-plan. Par exemple, si vous collectez des métriques d'application et que vous regroupez ces métriques en arrière-plan pour les envoyer à intervalles réguliers, celles-ci ne seront pas envoyées si le processeur n'est pas alloué. Si votre application reçoit continuellement des requêtes, vous devriez rencontrer moins de problèmes. Si votre application présente un faible nombre de requêtes par seconde, il est possible que la tâche en arrière-plan ne s'exécute jamais.

Voici un certain nombre de structures bien connues qui s'exécutent en arrière-plan et nécessitent une attention particulière si vous choisissez d'allouer le processeur uniquement pendant le traitement des requêtes :

  • Pools de connexions JDBC : les nettoyages et les vérifications de connexion ont généralement lieu en arrière-plan.
  • Émetteurs de traces distribuées : les traces distribuées sont généralement regroupées et envoyées en arrière-plan, soit de façon périodique, soit lorsque la mémoire tampon est saturée.
  • Émetteurs de métriques : les métriques sont généralement regroupées et envoyées à intervalles réguliers en arrière-plan.
  • Avec Spring Boot, toutes les méthodes comportant l'annotation @Async.
  • Minuteurs : tout déclencheur basé sur un minuteur (par exemple, ScheduledThreadPoolExecutor, Quartz ou une annotation Spring @Scheduled) est susceptible de ne pas s'exécuter lorsque les processeurs ne sont pas alloués.
  • Récepteurs de messages : par exemple, les clients pull de flux Pub/Sub, les clients JMS ou les clients Kafka s'exécutent généralement dans des threads en arrière-plan sans nécessiter de requêtes. Ceux-ci ne fonctionnent pas lorsque votre application ne reçoit aucune requête. La réception de messages par ce biais n'est pas recommandée dans Cloud Run.

Optimiser les applications

Dans votre code de service Cloud Run, vous pouvez également optimiser le délai de démarrage et l'utilisation de la mémoire.

Limiter les tâches de démarrage

Les applications Web Java traditionnelles peuvent avoir de nombreuses tâches à exécuter au démarrage (préchargement des données, préchauffage du cache, établissement de pools de connexions, etc.). Lorsqu'elles sont exécutées de manière séquentielle, ces tâches peuvent se révéler lentes. Toutefois, si vous souhaitez qu'elles s'exécutent en parallèle, vous devez augmenter le nombre de cœurs de processeur.

Actuellement, Cloud Run envoie une requête utilisateur réelle pour déclencher le démarrage à froid d'une instance. Les utilisateurs dont la requête est attribuée à une instance tout juste démarrée peuvent être confrontés à des délais importants. À l'heure actuelle, Cloud Run ne dispose pas d'une "vérification d'aptitude" qui permettrait d'éviter l'envoi de requêtes à des applications qui ne sont pas prêtes.

Utiliser le regroupement de connexions

Si vous utilisez des pools de connexions, sachez que ceux-ci peuvent supprimer les connexions inutiles en arrière-plan (consultez la section Éviter les activités d'arrière-plan). Si votre application présente un faible nombre de requêtes par seconde et peut tolérer une latence élevée, envisagez d'ouvrir et de fermer les connexions pour chaque requête. Si votre application présente un nombre élevé de requêtes par seconde, les suppressions en arrière-plan peuvent se poursuivre tant qu'il existe des requêtes actives.

Dans les deux cas, le goulot d'étranglement se situe au niveau de l'accès à la base de données de l'application, qui est limité par le nombre maximal de connexions autorisées par la base de données. Calculez le nombre maximal de connexions que vous pouvez établir par instance Cloud Run et configurez le nombre maximal d'instances Cloud Run de sorte que le nombre maximal d'instances multiplié par le nombre de connexions par instance soit inférieur au nombre maximal de connexions autorisé.

Si vous utilisez Spring Boot

Si vous utilisez Spring Boot, vous pouvez envisager les optimisations suivantes.

Utiliser Spring Boot version 2.2 ou ultérieure

À partir de sa version 2.2, Spring Boot a été fortement optimisé pour gagner en vitesse de démarrage. Si vous utilisez des versions de Spring Boot antérieures à la version 2.2, envisagez d'effectuer la mise à niveau ou d'appliquer manuellement des optimisations individuelles.

Utiliser l'initialisation différée

Il existe dans Spring Boot en version 2.2 et ultérieure une option globale d'initialisation différée que vous pouvez activer. Celle-ci permet d'améliorer la vitesse de démarrage, à un coût : la latence de la première requête peut être supérieure, car il est nécessaire d'attendre la première initialisation des composants.

Vous pouvez activer l'initialisation différée dans application.properties :

spring.main.lazy-initialization=true

Vous pouvez également utiliser une variable d'environnement :

SPRING_MAIN_LAZY_INITIALIZATIION=true

Cependant, si vous utilisez des instances de type min, l'initialisation différée n'aura aucune utilité, car l'initialisation doit avoir lieu au démarrage de l'instance min.

Éviter l'analyse des classes

L'analyse des classes entraîne un surcroît de lectures de disque dans Cloud Run, où les accès au disque sont généralement plus lents qu'avec une machine normale. Assurez-vous que l'analyse des composants soit limitée ou même totalement évitée. Pensez à utiliser Spring Context Indexer pour prégénérer un index. L'amélioration effective que cela représente sur la vitesse de démarrage dépend de votre application.

Par exemple, dans votre fichier Maven pom.xml, ajoutez la dépendance d'indexation suivante (il s'agit en fait d'un processeur d'annotations) :

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

Utiliser les outils de développement Spring Boot sans les transférer en production

Si vous utilisez les outils pour les développeurs Spring Boot pendant le développement, assurez-vous que ceux-ci ne sont pas empaquetés dans l'image du conteneur de production. Cela peut se produire si vous avez compilé l'application Spring Boot sans les plug-ins de compilation Spring Boot (par exemple, en utilisant le plug-in Shade ou Jib pour effectuer la mise en conteneur).

Dans ce cas, assurez-vous que l'outil de compilation exclut explicitement les outils de développement Spring Boot. Vous pouvez aussi désactiver explicitement les outils de développement Spring Boot.

Étape suivante

Pour accéder à d'autres conseils, consultez les pages suivantes :