Otimize aplicações Java para o Cloud Run

Este guia descreve as otimizações para serviços do Cloud Run escritos na linguagem de programação Java, juntamente com informações gerais para ajudar a compreender as concessões envolvidas em algumas das otimizações. As informações nesta página complementam as sugestões de otimização gerais, que também se aplicam ao Java.

As aplicações tradicionais baseadas na Web Java são concebidas para publicar pedidos com alta concorrência e baixa latência, e tendem a ser aplicações de execução prolongada. A própria JVM também otimiza o código de execução ao longo do tempo com JIT, para que os caminhos frequentes sejam otimizados e as aplicações sejam executadas de forma mais eficiente ao longo do tempo.

Muitas das práticas recomendadas e otimizações nesta aplicação Web baseada em Java tradicional giram em torno do seguinte:

  • Processar pedidos simultâneos (com base em threads e E/S sem bloqueio)
  • Reduzir a latência de resposta através da pool de ligações e do processamento em lote de funções não críticas, por exemplo, o envio de rastreios e métricas para tarefas em segundo plano.

Embora muitas destas otimizações tradicionais funcionem bem para aplicações de execução prolongada, podem não funcionar tão bem num serviço do Cloud Run, que só é executado quando está a publicar ativamente pedidos. Esta página explica algumas otimizações e compromissos diferentes para o Cloud Run que pode usar para reduzir o tempo de arranque e a utilização de memória.

Use o aumento da CPU de arranque para reduzir a latência de arranque

Pode ativar o aumento da CPU no arranque para aumentar temporariamente a atribuição da CPU durante o arranque da instância de modo a reduzir a latência do arranque.

As métricas da Google mostraram que as apps Java beneficiam se usarem o aumento da velocidade do CPU de arranque, o que pode reduzir os tempos de arranque até 50%.

Otimize a imagem do contentor

Ao otimizar a imagem do contentor, pode reduzir os tempos de carregamento e arranque. Pode otimizar a imagem das seguintes formas:

  • Minimizar a imagem do contentor
  • Evitar a utilização de JARs de arquivos de bibliotecas aninhados
  • Usar o Jib

Minimize a imagem de contentor

Consulte a página de sugestões gerais sobre como minimizar o contentor para ver mais contexto sobre este problema. A página de sugestões gerais recomenda reduzir o conteúdo das imagens dos contentores apenas ao necessário. Por exemplo, certifique-se de que a imagem do contentor não contém :

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

Se estiver a criar o código a partir de um Dockerfile, use a criação de várias fases do Docker para que a imagem do contentor final tenha apenas o JRE e o próprio ficheiro JAR da aplicação.

Evite JARs de arquivos de bibliotecas aninhados

Algumas frameworks populares, como o Spring Boot, criam um ficheiro de arquivo de aplicação (JAR) que contém ficheiros JAR de biblioteca adicionais (JARs aninhados). Estes ficheiros têm de ser descomprimidos durante o tempo de arranque, o que pode afetar negativamente a velocidade de arranque no Cloud Run. Assim, quando possível, crie um JAR simples com bibliotecas externalizadas: isto pode ser automatizado usando o Jib para colocar a sua aplicação num contentor

Use o Jib

Use o plug-in Jib para criar um contentor mínimo e reduzir o arquivo da aplicação automaticamente. O Jib funciona com o Maven e o Gradle, e funciona com aplicações Spring Boot prontas a usar. Algumas frameworks de aplicações podem exigir configurações adicionais do Jib.

Otimizações da JVM

A otimização da JVM para um serviço do Cloud Run pode resultar num melhor desempenho e utilização da memória.

Use versões da JVM compatíveis com contentores

Nas VMs e nas máquinas, para as atribuições de CPU e memória, a JVM compreende a CPU e a memória que pode usar a partir de localizações conhecidas, por exemplo, no Linux, /proc/cpuinfo e /proc/meminfo. No entanto, quando executado num contentor, as restrições de CPU e memória são armazenadas em/proc/cgroups/.... As versões mais antigas do JDK continuam a procurar em /proc em vez de /proc/cgroups, o que pode resultar numa utilização da memória e da CPU superior à atribuída. Isto pode causar:

  • Um número excessivo de threads porque o tamanho do conjunto de threads é configurado por Runtime.availableProcessors()
  • Um limite máximo de memória dinâmico predefinido que excede o limite de memória do contentor. A JVM usa a memória de forma agressiva antes de recolher o lixo. Isto pode facilmente fazer com que o contentor exceda o limite de memória do contentor e seja terminado por falta de memória.

Por isso, use uma versão da JVM compatível com contentores. As versões do OpenJDK iguais ou superiores à versão 8u192 são compatíveis com contentores por predefinição.

Como compreender a utilização de memória da JVM

A utilização de memória da JVM é composta pela utilização de memória nativa e pela utilização de memória heap. Normalmente, a memória de trabalho da aplicação está na heap. O tamanho da área de memória dinâmica está limitado pela configuração de área de memória dinâmica máxima. Com uma instância do Cloud Run de 256 MB de RAM, não pode atribuir os 256 MB completos ao Max Heap, porque a JVM e o SO também requerem memória nativa, por exemplo, a pilha de threads, as caches de código, os identificadores de ficheiros, os buffers, etc. Se a sua aplicação estiver a receber a mensagem OOMKilled e precisar de saber a utilização de memória da JVM (memória nativa + heap), ative a monitorização de memória nativa para ver as utilizações após uma saída bem-sucedida da aplicação. Se a sua aplicação for terminada por falta de memória, não vai poder imprimir as informações. Nesse caso, execute primeiro a aplicação com mais memória para que possa gerar o resultado com êxito.

Não é possível ativar o acompanhamento de memória nativa através da variável de ambiente JAVA_TOOL_OPTIONS. Tem de adicionar o argumento de inicialização da linha de comandos Java ao ponto de entrada da imagem do contentor para que a sua aplicação seja iniciada com estes argumentos:

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

A utilização de memória nativa pode ser estimada com base no número de classes a carregar. Considere usar uma calculadora de memória Java de código aberto para estimar as necessidades de memória.

Desative o compilador de otimização

Por predefinição, a JVM tem várias fases de compilação JIT. Embora estas fases melhorem a eficiência da sua aplicação ao longo do tempo, também podem adicionar sobrecarga à utilização de memória e aumentar o tempo de arranque.

Para aplicações sem servidor de execução curta (por exemplo, funções), considere desativar as fases de otimização para trocar a eficiência a longo prazo por um tempo de início reduzido.

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

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

Use a partilha de dados de classe de aplicações

Para reduzir ainda mais o tempo JIT e a utilização de memória, considere usar a partilha de dados de classes de aplicações (AppCDS) para partilhar as classes Java compiladas antecipadamente como um arquivo. O arquivo AppCDS pode ser reutilizado quando iniciar outra instância da mesma aplicação Java. A JVM pode reutilizar os dados pré-calculados do arquivo, o que reduz o tempo de arranque.

As seguintes considerações aplicam-se à utilização do AppCDS:

  • O arquivo AppCDS a ser reutilizado tem de ser reproduzido exatamente pela mesma distribuição, versão e arquitetura do OpenJDK que foi originalmente usado para o produzir.
  • Tem de executar a sua aplicação, pelo menos, uma vez para gerar a lista de classes a partilhar e, em seguida, usar essa lista para gerar o arquivo AppCDS.
  • A cobertura das classes depende do caminho de código executado durante a execução da aplicação. Para aumentar a cobertura, acione programaticamente mais caminhos de código.
  • A aplicação tem de ser fechada com êxito para gerar esta lista de turmas. Considere implementar uma flag de aplicação que seja usada para indicar a geração do arquivo AppCDS e, assim, poder sair imediatamente.
  • O arquivo AppCDS só pode ser reutilizado se iniciar novas instâncias exatamente da mesma forma que o arquivo foi gerado.
  • O arquivo AppCDS só funciona com um pacote de ficheiros JAR normal. Não pode usar JARs aninhados.

Exemplo do Spring Boot com um ficheiro JAR sombreado

As aplicações Spring Boot usam um JAR abrangente aninhado por predefinição, que não funciona para o AppCDS. Assim, se estiver a usar o AppCDS, tem de criar um JAR sombreado. Por exemplo, usando o Maven e o Maven Shade Plugin:

<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 seu JAR sombreado contiver todas as dependências, pode produzir um arquivo simples durante a compilação do contentor através de 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

Reduza o tamanho da pilha de threads

A maioria das aplicações Web Java baseia-se em threads por ligação. Cada thread Java consome memória nativa (não na heap). Isto é conhecido como a pilha de threads e tem um valor predefinido de 1 MB por thread. Se a sua aplicação processar 80 pedidos simultâneos, pode ter, pelo menos, 80 threads, o que se traduz em 80 MB de espaço de pilha de threads usado. A memória é adicional ao tamanho da área dinâmica para dados. O valor predefinido pode ser superior ao necessário. Pode reduzir o tamanho da pilha de threads.

Se reduzir demasiado, é apresentado o erro java.lang.StackOverflowError. Pode criar um perfil da sua aplicação e encontrar o tamanho da pilha de threads ideal para configurar.

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

JAVA_TOOL_OPTIONS="-Xss256k"

Reduzir discussões

Pode otimizar a memória reduzindo o número de threads, usando estratégias reativas não bloqueadoras e evitando atividades em segundo plano.

Reduza o número de threads

Cada thread Java pode aumentar a utilização de memória devido à pilha de threads. O Cloud Run permite um máximo de 1000 pedidos concorrentes. Com o modelo de um thread por ligação, precisa de, no máximo, 1000 threads para processar todos os pedidos simultâneos. A maioria dos servidores Web e frameworks permite configurar o número máximo de threads e ligações. Por exemplo, no Spring Boot, pode limitar o número máximo de ligações no ficheiro applications.properties:

server.tomcat.max-threads=80

Escreva código reativo não bloqueador para otimizar a memória e o arranque

Para reduzir verdadeiramente o número de threads, considere adotar um modelo de programação reativo sem bloqueio, para que o número de threads possa ser significativamente reduzido ao processar mais pedidos simultâneos. As frameworks de aplicações, como o Spring Boot, com Webflux, Micronaut e Quarkus, suportam aplicações Web reativas.

As frameworks reativas, como o Spring Boot com Webflux, o Micronaut e o Quarkus, têm geralmente tempos de arranque mais rápidos.

Se continuar a escrever código de bloqueio numa framework sem bloqueio, o débito e as taxas de erro serão significativamente piores num serviço do Cloud Run. Isto deve-se ao facto de as frameworks não bloqueadoras terem apenas alguns threads, por exemplo, 2 ou 4. Se o seu código estiver a bloquear, só pode processar muito poucos pedidos em simultâneo.

Estas frameworks não bloqueadoras também podem transferir código de bloqueio para um conjunto de threads ilimitado, o que significa que, embora possa aceitar muitos pedidos simultâneos, o código de bloqueio é executado em novas threads. Se as threads se acumularem de forma ilimitada, esgota o recurso de CPU e começa a ter um desempenho instável. A latência é gravemente afetada. Se usar uma framework não bloqueadora, certifique-se de que compreende os modelos de conjunto de threads e limita os conjuntos em conformidade.

Configure a faturação baseada em instâncias se usar atividades em segundo plano

A atividade em segundo plano é tudo o que acontece depois de a resposta HTTP ter sido enviada. As cargas de trabalho tradicionais que têm tarefas em segundo plano requerem consideração especial quando são executadas no Cloud Run.

Configure a faturação baseada em instâncias

Se quiser suportar atividades em segundo plano no seu serviço do Cloud Run, defina o serviço do Cloud Run para faturação baseada em instâncias, para que possa executar atividades em segundo plano fora dos pedidos e continuar a ter acesso à CPU.

Evite atividades em segundo plano se usar a faturação baseada em pedidos

Se precisar de definir o seu serviço para a faturação baseada em pedidos, tem de estar atento a potenciais problemas com as atividades em segundo plano. Por exemplo, se estiver a recolher métricas de aplicações e a processá-las em lote em segundo plano para envio periódico, essas métricas não são enviadas quando a faturação baseada em pedidos está configurada. Se a sua aplicação estiver constantemente a receber pedidos, pode ver menos problemas. Se a sua aplicação tiver um QPS baixo, a tarefa em segundo plano pode nunca ser executada.

Alguns padrões bem conhecidos que são executados em segundo plano aos quais tem de prestar atenção se escolher a faturação baseada em pedidos:

  • JDBC Connection Pools - clean ups and connection checks usually happens in the background
  • Remetentes de rastreio distribuído: normalmente, os rastreios distribuídos são processados em lote e enviados periodicamente ou quando o buffer está cheio em segundo plano.
  • Remetentes de métricas: normalmente, as métricas são processadas em lote e enviadas periodicamente em segundo plano.
  • Para o Spring Boot, todos os métodos anotados com a anotação @Async
  • Temporizadores: quaisquer acionadores baseados em temporizadores (por exemplo, O ScheduledThreadPoolExecutor, o Quartz ou a anotação Spring @Scheduled podem não ser executados quando a faturação baseada em pedidos está configurada.
  • Recetores de mensagens: por exemplo, os clientes de obtenção de streaming do Pub/Sub, os clientes JMS ou os clientes Kafka são normalmente executados em threads em segundo plano sem necessidade de pedidos. Estas não funcionam quando a sua aplicação não tem pedidos. Não é recomendado receber mensagens desta forma no Cloud Run.

Otimizações de aplicações

No código do serviço do Cloud Run, também pode otimizar para tempos de arranque e utilização de memória mais rápidos.

Reduza as tarefas de arranque

As aplicações Web baseadas em Java tradicionais podem ter muitas tarefas a concluir durante o início, por exemplo, o pré-carregamento de dados, o aquecimento da cache, o estabelecimento de pools de ligações, etc. Estas tarefas, quando executadas sequencialmente, podem ser lentas. No entanto, se quiser que sejam executados em paralelo, deve aumentar o número de núcleos do CPU.

Atualmente, o Cloud Run envia um pedido de utilizador real para acionar uma instância de arranque a frio. Os utilizadores que têm um pedido atribuído a uma instância iniciada recentemente podem sofrer longos atrasos. Atualmente, o Cloud Run não tem uma verificação de "prontidão" para evitar o envio de pedidos a aplicações não preparadas.

Use o agrupamento de ligações

Se usar conjuntos de ligações, tenha em atenção que os conjuntos de ligações podem rejeitar ligações desnecessárias em segundo plano (consulte a secção Evitar tarefas em segundo plano). Se a sua aplicação tiver um QPS baixo e puder tolerar uma latência elevada, considere abrir e fechar ligações por pedido. Se a sua aplicação tiver um QPS elevado, as remoções em segundo plano podem continuar a ser executadas desde que existam pedidos ativos.

Em ambos os casos, o acesso à base de dados da aplicação é limitado pelo número máximo de ligações permitidas pela base de dados. Calcule o número máximo de ligações que pode estabelecer por instância do Cloud Run e configure o número máximo de instâncias do Cloud Run de modo que o número máximo de instâncias multiplicado pelo número de ligações por instância seja inferior ao número máximo de ligações permitidas.

Se usar o Spring Boot

Se usar o Spring Boot, tem de considerar as seguintes otimizações

Use a versão 2.2 ou superior do Spring Boot

A partir da versão 2.2, o Spring Boot foi fortemente otimizado para a velocidade de arranque. Se estiver a usar versões do Spring Boot inferiores a 2.2, pondere fazer a atualização ou aplicar otimizações individuais manualmente.

Use a inicialização em diferido

Existe uma flag de inicialização tardia global que pode ser ativada no Spring Boot 2.2 e superior. Isto melhora a velocidade de arranque, mas com a desvantagem de que o primeiro pedido pode ter uma latência mais longa porque tem de aguardar que os componentes sejam inicializados pela primeira vez.

Pode ativar a inicialização em diferido em application.properties:

spring.main.lazy-initialization=true

Em alternativa, através de uma variável de ambiente:

SPRING_MAIN_LAZY_INITIALIZATIION=true

No entanto, se estiver a usar min-instances, a inicialização tardia não vai ajudar, uma vez que a inicialização deve ter ocorrido quando a min-instance foi iniciada.

Evite a leitura de turmas

A análise de classes provoca leituras de disco adicionais no Cloud Run porque, no Cloud Run, o acesso ao disco é geralmente mais lento do que numa máquina normal. Certifique-se de que a análise de componentes é limitada ou totalmente evitada.

Use as ferramentas para programadores do Spring Boot que não estão em produção

Se usar a ferramenta para programadores do Spring Boot durante o desenvolvimento, certifique-se de que não está incluída na imagem do contentor de produção. Isto pode acontecer se tiver criado a aplicação Spring Boot sem os plug-ins de compilação do Spring Boot (por exemplo, usando o plug-in Shade ou usando o Jib para criar contentores).

Nestes casos, certifique-se de que a ferramenta de compilação exclui explicitamente a ferramenta de programador do Spring Boot. Em alternativa, desative explicitamente a ferramenta para programadores do Spring Boot).

O que se segue?

Para mais sugestões, consulte