Neste guia, descrevemos otimizações para serviços do Knative serving 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 Knative serving, que é executado somente quando exibe solicitações ativamente. Nesta página, você verá algumas otimizações e contrapartidas diferentes para o Knative serving que podem ser usadas para reduzir o tempo de inicialização e o uso de memória.
Como 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
Como 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.
Como evitar JARs de arquivos de bibliotecas aninhadas
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 fornecimento do Knative serving. Quando possível, crie um JAR thin com bibliotecas externas: isso pode ser automatizado usando o Jib para conteinerizar seu aplicativo.
Como usar o 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 de fornecimento do Knative serving pode melhorar o desempenho e o uso da memória.
Como usar versões da JVM com reconhecimento de contêiner
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 de memória da 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 Knative serving, 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.
Como 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 Knative serving, configure a variável de ambiente:
JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
Como 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 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
Como desativar a verificação de classe
Quando a JVM carrega classes na memória para execução, ela verifica se a classe foi alterada, corrompida ou tem edições maliciosas. Se o pipeline de entrega de software for confiável (por exemplo, é possível verificar e validar cada saída), se o bytecode da imagem do contêiner for totalmente confiável e seu aplicativo não carregar classes de fontes remotas arbitrárias, você poderá desativar a verificação. A desativação da verificação pode melhorar a velocidade de inicialização se um grande número de classes for carregado no momento da inicialização.
Para um serviço do Knative serving, configure a variável de ambiente:
JAVA_TOOL_OPTIONS="-noverify"
Como 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
. É possível criar o perfil do aplicativo e encontrar o tamanho ideal da pilha de linhas de execução para configurar.
Para um serviço de veiculação do Knative serving, 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.
Como 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 Knative serving permite no máximo 80 solicitações
simultâneas. Com o modelo de linhas de execução por conexão, você precisa de no máximo 80 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
Como 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 Knative serving. 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.
Como evitar atividades em segundo plano
O Knative serving limita uma CPU de instância quando essa instância não recebe mais solicitações. As cargas de trabalho tradicionais que têm tarefas em segundo plano precisam de consideração especial ao serem executadas no Knative serving.
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 estiver limitada. 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.
Preste atenção em alguns padrões conhecidos que ficam em segundo plano, como:
- 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, ScheduledThreadPoolExecutor, Quartz ou a anotação do Spring
@Scheduled
) pode não ser executado quando as CPUs estão limitadas. - 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 Knative serving.
Otimizações de aplicativos
No código de serviço de veiculação do Knative serving, também é possível otimizar para tempos de inicialização e uso de memória mais rápidos.
Como 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 Knative serving 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. No momento, a veiculação do Knative serving não tem uma verificação de "prontidão" para evitar o envio de solicitações a aplicativos que não estão prontos.
Como 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. Calcular o número máximo de conexões que podem ser estabelecidas por instância do Knative serving, e configurar o número máximo de instâncias do Knative serving de modo que o número máximo de instâncias vezes o número de conexões por instância seja menor que o máximo permitido.
Como usar o Spring Boot
Se você usa o Spring Boot, precisa considerar as seguintes otimizações.
Como 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.
Como 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.
Como evitar a verificação de classe
A verificação de classe vai causar leituras adicionais de disco na veiculação do Knative serving porque, nessa veiculação, o acesso ao disco geralmente é mais lento do 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>
Como usar as ferramentas de desenvolvedor do Spring Boot que não estão em produção
Se você usar as Ferramentas para desenvolvedores 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 para desenvolvedores do Spring Boot.
A seguir
Veja mais dicas em