Otimizar aplicativos Java para o Cloud Run

Neste guia, descrevemos otimizações para serviços do Cloud Run escritos na linguagem de programação Java, além de informações básicas para ajudar você a entender as contrapartidas envolvidas em algumas das otimizações. As informações desta página complementam as dicas gerais de otimização, que também se aplicam ao Java.

Os aplicativos tradicionais em Java baseados na Web foram projetados para atender a solicitações com alta simultaneidade e baixa latência e tendem a ser aplicativos de longa duração. A própria JVM também otimiza o código de execução ao longo do tempo com o JIT. Assim, os hot paths são otimizados e os aplicativos são executados com mais eficiência ao longo do tempo.

Muitas das práticas recomendadas e otimizações desses aplicativos tradicionais em Java baseados na Web giram em torno de:

  • como processar solicitações simultâneas (E/S com base em linhas de execução e sem bloqueio);
  • como reduzir a latência de resposta usando o pool de conexões e agrupando em lote funções não críticas, como o envio de traces e métricas para tarefas em segundo plano.

Embora muitas dessas otimizações tradicionais funcionem bem para aplicativos de longa duração, elas podem não funcionar tão bem em um serviço do Cloud Run, que é executado somente quando exibe solicitações ativamente. Nesta página, você verá algumas otimizações e contrapartidas diferentes para o Cloud Run que podem ser usadas para reduzir o tempo de inicialização e o uso de memória.

Usar a otimização da CPU de inicialização para reduzir a latência da inicialização

É possível ativar a otimização da CPU de inicialização para aumentar temporariamente a alocação de CPU durante a inicialização de instâncias para reduzir a latência da inicialização.

As métricas do Google mostraram que os apps Java se beneficiam com o uso da otimização da CPU de inicialização, o que pode reduzir os tempos de inicialização até 50%.

Otimizar a imagem do contêiner

Ao otimizar a imagem do contêiner, é possível reduzir os tempos de carregamento e de inicialização. É possível otimizar a imagem:

  • minimizando a imagem do contêiner;
  • evitando o uso de JARs de arquivos de bibliotecas aninhadas;
  • Como usar o Jib

Minimizar a imagem do contêiner

Veja mais contexto sobre esse problema na página de dicas gerais sobre como minimizar o contêiner. A página de dicas gerais recomenda reduzir o conteúdo da imagem do contêiner a apenas o necessário. Por exemplo, verifique se a imagem do contêiner não contém:

  • Código-fonte
  • Artefatos de compilação do Maven
  • Ferramentas de desenvolvimento
  • Diretórios Git
  • Binários/utilitários não usados

Se você estiver criando o código a partir de um Dockerfile, use a versão de vários estágios do Docker para que a imagem final do contêiner tenha apenas o JRE e o próprio arquivo JAR do aplicativo.

Evitar JARs de arquivos de biblioteca aninhados

Alguns frameworks conhecidos, como o Spring Boot, criam um arquivo de aplicativo (JAR, na sigla em inglês) que contém outros arquivos JAR de biblioteca (JARs aninhados). Esses arquivos precisam ser descompactados durante o tempo de inicialização e podem aumentar a velocidade de inicialização no Cloud Run. Quando possível, crie um JAR thin com bibliotecas externas: isso pode ser automatizado usando o Jib para conteinerizar seu aplicativo.

Usar Jib

Use o plug-in Jib para criar um contêiner mínimo e nivelar o arquivo do aplicativo automaticamente. O Jib funciona com o Maven e o Gradle e com aplicativos Spring Boot prontos para uso. Alguns frameworks de aplicativos podem exigir outras configurações do Jib.

Otimizações da JVM

Otimizar a JVM para um serviço do Cloud Run pode resultar em melhor desempenho e uso de memória.

Usar versões da JVM compatíveis com contêineres

Em VMs e máquinas, para alocações de CPU e memória, a JVM entende a CPU e a memória que podem ser usadas a partir de locais conhecidos, como no Linux, /proc/cpuinfo e /proc/meminfo. No entanto, ao executar em um contêiner, as restrições de CPU e memória ficam armazenadas em /proc/cgroups/.... Uma versão mais antiga do JDK continua verificando /proc em vez de /proc/cgroups, o que pode resultar em mais uso de CPU e memória do que foi atribuído. Isso pode causar:

  • Um número excessivo de linhas de execução porque o tamanho do pool de linhas de execução está configurado por Runtime.availableProcessors()
  • Um heap máximo padrão que excede o limite de memória do contêiner. A JVM usa agressivamente a memória antes de coletar o lixo. Isso pode facilmente fazer com que o contêiner exceda o limite de memória e cause um OOMKill.

Portanto, use uma versão da JVM com reconhecimento de contêiner. As versões do OpenJDK posteriores ou iguais à versão 8u192 têm reconhecimento de contêiner por padrão.

Como entender o uso da memória do JVM

O uso de memória da JVM é composto de uso de memória nativa e uso de heap. A memória de trabalho do aplicativo geralmente está no heap. O tamanho do heap é restringido pela configuração de heap máximo. Com uma instância de 256 MB de RAM do Cloud Run, não é possível atribuir todos os 256 MB ao heap máximo, porque a JVM e o SO também exigem memória nativa, por exemplo, pilha de linhas de execução, caches de código, gerenciadores de arquivos, buffers etc. Se ocorrer OOMKill e você precisar saber o uso de memória da JVM (memória nativa + heap), ative o rastreamento de memória nativa para ver os usos após uma saída de aplicativo bem-sucedida. Se ocorrer OOMKill no aplicativo, não será possível imprimir as informações. Nesse caso, execute o aplicativo com mais memória primeiro para que ele possa gerar a saída.

O rastreamento de memória nativa não pode ser ativado por meio da variável de ambiente JAVA_TOOL_OPTIONS. É necessário adicionar o argumento de inicialização da linha de comando do Java ao ponto de entrada da imagem do contêiner para que seu aplicativo seja iniciado com estes argumentos:

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

O uso de memória nativa pode ser estimado com base no número de classes a serem carregadas. Use uma calculadora de memória Java de código aberto para estimar as necessidades de memória.

Desativar o compilador de otimização

Por padrão, a JVM tem várias fases da compilação JIT. Essas fases melhoram a eficiência do aplicativo ao longo do tempo, mas também podem gerar sobrecarga no uso da memória e aumentar o tempo de inicialização.

Para aplicativos de curta duração e sem servidor (por exemplo, funções), desative as fases de otimização para trocar a eficiência de longo prazo por um tempo de inicialização reduzido.

Para um serviço do Cloud Run, configure a variável de ambiente:

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

Usar o compartilhamento de dados de classe do aplicativo

Para reduzir ainda mais o uso de tempo e memória do JIT, considere usar o compartilhamento de dados de classe do aplicativo (AppCDS, na sigla em inglês) para compartilhar antecipadamente as classes Java compiladas como um arquivo. O arquivo AppCDS pode ser reutilizado ao iniciar outra instância do mesmo aplicativo Java. A JVM pode reutilizar os dados pré-computados do arquivo, o que reduz o tempo de inicialização.

As considerações a seguir se aplicam ao uso do AppCDS:

  • O arquivo AppCDS a ser reutilizado precisa ser reproduzido exatamente pela mesma distribuição, versão e arquitetura do OpenJDK usadas originalmente para criá-lo.
  • É preciso executar seu aplicativo pelo menos uma vez para gerar a lista de classes a serem compartilhadas e, em seguida, usar essa lista para gerar o arquivo AppCDS.
  • A cobertura das classes depende do codepath executado durante a execução do aplicativo. Para aumentar a cobertura, acione programaticamente mais codepaths.
  • O aplicativo precisa ser encerrado corretamente para gerar essa lista de classes. Considere a implementação de uma sinalização de aplicativo usada para indicar a geração do arquivo AppCDS. Assim, ela pode ser encerrada imediatamente.
  • O arquivo AppCDS só poderá ser reutilizado se você iniciar novas instâncias exatamente da mesma maneira que o arquivo foi gerado.
  • O arquivo AppCDS funciona somente com um pacote de arquivos JAR normal. Não é possível usar JARs aninhados.

Exemplo do Spring Boot usando um arquivo JAR sombreado

Por padrão, os aplicativos do Spring Boot usam um JAR uber aninhado, que não funcionará para o AppCDS. Portanto, se você estiver usando o AppCDS, será preciso criar um JAR sombreado. Por exemplo, usando o Maven e o 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 o JAR sombreado contiver todas as dependências, será possível produzir um arquivo simples durante o build do contêiner usando um 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

Reduzir o tamanho da pilha de linhas de execução

A maioria dos aplicativos da Web em Java são baseados em linhas de execução por conexão. Cada linha de execução Java consome memória nativa (e não em heap). Isso é conhecido como pilha de linhas de execução, e o padrão é 1 MB por linha de execução. Se o aplicativo gerenciar 80 solicitações simultâneas, ele poderá ter pelo menos 80 linhas de execução, o que representa o uso de 80 MB de espaço da pilha de linha de execução. A memória é um acréscimo ao tamanho do heap. O padrão pode ser maior que o necessário. É possível reduzir o tamanho da pilha de linhas de execução.

Se você reduzir muito, verá java.lang.StackOverflowError. Você pode criar o perfil do aplicativo e encontrar o tamanho ideal da pilha de linhas de execução para configurar.

Para um serviço do Cloud Run, configure a variável de ambiente:

JAVA_TOOL_OPTIONS="-Xss256k"

Como reduzir as linhas de execução

Para otimizar a memória, reduza o número de linhas de execução. Para isso, use estratégias reativas sem bloqueios e evite atividades em segundo plano.

Reduzir o número de linhas de execução

Cada linha de execução Java pode aumentar o uso de memória devido à pilha de linhas de execução. O Cloud Run permite no máximo 1000 solicitações simultâneas. Com o modelo de linhas de execução por conexão, você precisa de no máximo 1000 linhas para processar todas as solicitações simultâneas. A maioria dos servidores e frameworks da Web permite configurar o número máximo de linhas de execução e conexões. Por exemplo, no Spring Boot, você pode limitar o número máximo de conexões no arquivo applications.properties:

server.tomcat.max-threads=80

Escrever código reativo sem bloqueios para otimizar a memória e a inicialização

Para realmente reduzir o número de linhas de execução, adote um modelo de programação reativa sem bloqueios a fim de reduzir esse número de maneira significativa ao processar mais solicitações simultâneas. Frameworks de aplicativos como o Spring Boot com Webflux, Micronaut e Quarkus são compatíveis com aplicativos da Web reativos.

Frameworks reativos, como Spring Boot com Webflux, Micronaut e Quarkus costumam ter tempos de inicialização mais rápidos.

Se você continuar gravando código de bloqueio em um framework sem bloqueios, as taxas de transferência e erro serão significativamente piores em um serviço do Cloud Run. Isso ocorre porque os frameworks sem bloqueios terão apenas algumas linhas de execução, como 2 ou 4. Se o código estiver bloqueando, ele poderá processar poucas solicitações simultâneas.

Esses frameworks sem bloqueios também podem descarregar o código de bloqueio para um pool de linhas de execução ilimitado. Isso significa que, embora possa aceitar muitas solicitações simultâneas, o código de bloqueio será executado em novas linhas de execução. Se as linhas de execução se acumularem de forma ilimitada, você esgotará o recurso da CPU e começará a sofrer sobrecarga. A latência será gravemente afetada. Se você usar um framework sem bloqueios, precisará entender os modelos de pool de linhas de execução e vincular os pools adequadamente.

Configurar a CPU para ser sempre alocada se você usar atividades em segundo plano

Uma atividade em segundo plano é qualquer evento ocorrido depois que sua resposta HTTP foi entregue. As cargas de trabalho tradicionais que têm tarefas em segundo plano precisam de consideração especial ao serem executadas no Cloud Run.

Configurar CPU para que seja sempre alocada

Se você quiser oferecer suporte a atividades em segundo plano no serviço do Cloud Run, defina a CPU do serviço do Cloud Run como sempre alocada para que você possa executar atividades em segundo plano fora das solicitações e ainda assim têm acesso à CPU.

Evitar atividades em segundo plano se a CPU for alocada somente durante o processamento da solicitação

Se precisar configurar o serviço para alocar CPU somente durante o processamento da solicitação, você precisa estar ciente de possíveis problemas com atividades em segundo plano. Por exemplo, se você coletar métricas de aplicativos e enviar em lote as métricas em segundo plano periodicamente, elas não serão enviadas quando a CPU não estiver alocada. Se o aplicativo receber solicitações constantemente, talvez ocorram menos problemas. Se o aplicativo tiver QPS baixa, talvez a tarefa em segundo plano não seja executada.

Alguns padrões conhecidos que são colocados em segundo plano e que precisam ser verificados caso você decida alocar a CPU somente durante o processamento da solicitação:

  • Pools de conexão JDBC: limpeza e verificações de conexão costumam ocorrer em segundo plano.
  • Remetentes de traces distribuídos: os traces distribuídos geralmente são agrupados e enviados em lote periodicamente ou quando o buffer está cheio em segundo plano.
  • Remetentes de métricas: as métricas geralmente são agrupadas e enviadas em lote periodicamente em segundo plano.
  • No caso do Spring Boot, todos os métodos anotados com a anotação @Async.
  • Timers: qualquer gatilho baseado em timer (por exemplo, SchedulingThreadPoolExecutor, Quartz ou a anotação do Spring @Scheduled podem não ser executados quando as CPUs não estão alocadas.
  • Destinatários de mensagens: por exemplo, clientes de pull de streaming do Pub/Sub, clientes do JMS ou clientes do Kafka geralmente são executados nas linhas de execução em segundo plano sem precisar de solicitações. Isso não funcionará quando seu aplicativo não tiver solicitações. Não é recomendado receber mensagens dessa maneira no Cloud Run.

Otimizações de aplicativos

No código de serviço do Cloud Run, também é possível otimizar para tempos de inicialização e uso de memória mais rápidos.

Reduzir tarefas de inicialização

Os aplicativos tradicionais em Java baseados na Web podem ter muitas tarefas a serem concluídas durante a inicialização, como pré-carregamento de dados, aquecimento do cache, estabelecimento de pools de conexão etc. Essas tarefas, quando executadas sequencialmente, podem ser lentas. No entanto, se você quiser que elas sejam executadas em paralelo, aumente o número de núcleos de CPU.

No momento, o Cloud Run envia uma solicitação de usuário real para acionar uma instância de inicialização a frio. Os usuários que têm uma solicitação atribuída a uma instância recém-iniciada podem enfrentar atrasos demorados. O Cloud Run não tem uma verificação de "prontidão" para evitar o envio de solicitações a aplicativos que não estão prontos.

Usar o pool de conexões

Se você estiver usando pools de conexão, lembre-se de que eles podem remover conexões desnecessárias em segundo plano. Veja Como evitar tarefas em segundo plano. Se o aplicativo tiver QPS baixa e puder tolerar alta latência, abra e feche as conexões por solicitação. Se o aplicativo tiver QPS alta, talvez as remoções em segundo plano continuem sendo executadas, desde que haja solicitações ativas.

Em ambos os casos, o acesso ao banco de dados do aplicativo sofrerá um gargalo devido ao limite de conexões permitidas pelo banco de dados. Calcule o limite de conexões que você pode estabelecer por instância do Cloud Run e configure o limite de instâncias do Cloud Run para que o número máximo de instâncias multiplicado pelo número máximo de conexões por instância seja menor que o limite de conexões permitido.

Se você usar o Spring Boot

Se você usa o Spring Boot, precisa considerar as seguintes otimizações.

Usar o Spring Boot versão 2.2 ou posterior

A partir da versão 2.2, o Spring Boot foi extremamente otimizado quanto à velocidade de inicialização. Se você estiver usando versões do Spring Boot anteriores à 2.2, faça um upgrade ou execute otimizações individuais manualmente.

Usar a inicialização lenta

Há uma sinalização de inicialização lenta global que pode ser ativada no Spring Boot 2.2 e posterior. Isso melhora a velocidade de inicialização, mas com a contrapartida de que a primeira solicitação pode ter uma latência maior porque é necessário aguardar a inicialização dos componentes pela primeira vez.

Você pode ativar a inicialização lenta em application.properties:

spring.main.lazy-initialization=true

Também é possível usar uma variável de ambiente:

SPRING_MAIN_LAZY_INITIALIZATIION=true

No entanto, se você estiver usando instâncias mínimas, a inicialização lenta não ajudará, porque a inicialização deveria ter ocorrido quando as instâncias mínimas foram inicializadas.

Evitar a verificação de classe

A verificação de classe causará leituras adicionais de disco no Cloud Run porque, no Cloud Run, o acesso ao disco costuma ser mais lento que em uma máquina normal. Certifique-se de que a verificação de componentes seja limitada ou completamente evitada. Use o Spring Context Indexer para pré-gerar um índice. A melhora na velocidade de inicialização dependerá do aplicativo.

Por exemplo, no pom.xml do Maven, adicione a dependência do indexador (na verdade, é um processador de anotações):

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

Usar as ferramentas para desenvolvedores do Spring Boot que não estão em produção

Se você usar as ferramentas de desenvolvedor do Spring Boot durante o desenvolvimento, certifique-se de que não sejam empacotadas na imagem do contêiner de produção. Isso pode acontecer se você tiver criado o aplicativo Spring Boot sem os plug-ins de build do Spring Boot (por exemplo, usando o plug-in Shade ou o Jib para conteinerização).

Nesses casos, verifique se a ferramenta de build exclui explicitamente a ferramenta Spring Boot Dev. Se preferir, desative explicitamente a ferramenta de desenvolvedor do Spring Boot.

A seguir

Veja mais dicas em