Pular para o conteúdo

Trilha de Kotlin · parte 58 de 59

Resiliência e empacotamento

Aula da fase 8 da trilha de Kotlin: Como aplicar circuit breaker e retry com jitter, e como gerar uma imagem Docker enxuta com camadas e `jlink`?

avançadoPublicada em · 16 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Uma dependência externa vai falhar. Pode ser um timeout de um segundo, uma queda de três minutos ou um erro que só aparece em uma de cada mil chamadas. A pergunta de resiliência é o que o seu serviço faz nesses momentos: tenta de novo, desiste rápido, ou fica preso esperando junto com a dependência que caiu. A segunda parte da aula trata da outra ponta do serviço, a imagem que vai para produção. Uma imagem de quase 360 MB, com um JDK inteiro, demora a subir, ocupa espaço em cada nó e tem uma superfície de ataque maior do que precisa. Esta aula mostra as duas partes com código que rodou: retentativa com intervalo exponencial aleatório, disjuntor com Resilience4j, e uma imagem em camadas com um runtime montado por jlink, com os tamanhos medidos nesta revisão.

As versões são as da fase 7: Kotlin 2.3.21, Spring Boot 4.1.1 com Spring Framework 7.0.9, Resilience4j 2.4.0, e JDK 21 (Temurin) para o jlink. Os exemplos de resiliência e empacotamento foram executados com Maven, não com Gradle, porque o Gradle não está instalado nesta máquina.

Uma comparação de elevador ajuda. Se o elevador trava e você aperta o botão sem parar, você só cria fila. Se você tenta de novo um pouco depois e, quando a manutenção avisa que o elevador está fora, para de tentar até que ele volte, você evita o pior. O retry é o botão apertado com espera. O disjuntor é o aviso de manutenção que impede que mais gente entre no elevador quebrado.

o mapa da aula

A aula começa pelo problema que a retentativa ingênua cria, que é o efeito de manada, e pela solução de intervalo exponencial com aleatoriedade. Em seguida mostra o disjuntor e seus estados, e como o Resilience4j compõe retry e disjuntor numa mesma chamada. A segunda metade trata do empacotamento: as camadas do jar do Spring Boot, o jlink para montar um runtime enxuto, e o Dockerfile que combina as duas coisas. Os números de tamanho vêm de docker images nesta revisão, e cada afirmação que não foi executada está marcada como tal.

1. retentativa com intervalo que não sincroniza

Retentar uma chamada que falhou é a forma mais simples de tolerar falhas passageiras, como um pacote perdido ou um pico de latência. O problema aparece quando muitos clientes falham ao mesmo tempo e todos retentam com o mesmo intervalo fixo. Se mil clientes falham às 10h00 e retentam a cada dois segundos, o servidor recebe mil requisições no mesmo instante, a cada dois segundos, justamente quando está tentando se recuperar. Esse é o efeito de manada, e ele transforma uma falha curta em uma falha longa.

A solução usual é o intervalo exponencial: cada tentativa espera mais que a anterior, por exemplo 200 ms, 400 ms, 800 ms. A aleatoriedade, chamada de jitter, espalha os instantes de retentativa dos clientes para que eles não batam juntos. O Resilience4j oferece essa combinação em IntervalFunction. A assinatura ofExponentialRandomBackoff(Duration, double, double) foi conferida no jar resilience4j-core-2.4.0 com javap, e recebe a duração inicial, o multiplicador e o fator de randomização. A fórmula exata do intervalo não foi conferida, então a aula não a descreve. O que o projeto usa é isto:

fun retry() = Retry.of(
    "pagamento",
    RetryConfig.custom<Any>()
        .maxAttempts(3)
        .intervalFunction(IntervalFunction.ofExponentialRandomBackoff(Duration.ofMillis(200), 2.0, 0.5))
        .retryExceptions(IOException::class.java)
        .build(),
)

Três parâmetros merecem atenção. O maxAttempts(3) inclui a chamada inicial, segundo a documentação do Resilience4j, que diz “including the initial call as the first attempt”, então são uma chamada e duas retentativas. O valor padrão do Resilience4j também é 3. O retryExceptions(IOException::class.java) restringe a retentativa às falhas de transporte. Uma exceção de regra de negócio, como IllegalStateException, não deve ser retentada, porque repetir a mesma operação não muda o resultado. O failAfterMaxAttempts, que controla se a retentativa lança uma exceção específica ao esgotar as tentativas, não foi usado.

Uma regra da documentação muda a forma de compor a chamada. Para exceções verificadas do Java, ela diz que é preciso usar um CheckedSupplier. A verificação mostrou o efeito dessa regra: com Retry.decorateFunction e uma função que lançou IOException em toda chamada, a função foi executada uma única vez e a exceção saiu sem nenhuma retentativa. A versão que funciona usa Retry.decorateCheckedFunction, que existe na versão 2.4.0. Com ela, o teste de falhas transitórias fez três chamadas e devolveu a aprovação na terceira, e o teste passou.

2. o disjuntor e seus estados

Retentar é útil quando a falha é passageira. Quando a dependência está fora do ar por minutos, cada retentativa gasta conexão, thread e tempo, e o serviço inteiro passa a esperar uma resposta que não vem. O circuit breaker resolve isso com uma regra simples: se a taxa de falhas passar de um limite, o circuito abre, e as chamadas seguintes falham na hora, sem tocar na dependência.

Os estados do disjuntor são três. Em CLOSED, as chamadas passam e o resultado de cada uma entra numa janela de resultados. Quando a taxa de falhas atinge o limite, o circuito vai para OPEN, e as chamadas são rejeitadas com CallNotPermittedException, sem executar a função protegida. Depois de um tempo de espera, ele vai para HALF_OPEN e deixa passar algumas chamadas de teste. Se elas derem certo, volta a CLOSED, e se a falha persistir, volta a OPEN. A descrição detalhada desses estados vem da documentação do Resilience4j, que não foi reconferida nesta revisão, e a transição para HALF_OPEN não foi exercitada com espera real.

A configuração da aula usa valores explícitos:

val disjuntor = CircuitBreaker.of(
    "pagamento",
    CircuitBreakerConfig.custom()
        .slidingWindowSize(10)
        .minimumNumberOfCalls(5)
        .failureRateThreshold(50.0f)
        .waitDurationInOpenState(Duration.ofSeconds(10))
        .build(),
)

O minimumNumberOfCalls(5) impede que o circuito abra com base em uma ou duas chamadas, o que seria instável. O failureRateThreshold(50.0f) é o percentual de falhas que abre o circuito. O slidingWindowSize(10) define o tamanho da janela de resultados. Nos testes desta revisão, a janela foi de 3 chamadas e o mínimo de 3, porque com janela de 2 o disjuntor abriu depois de duas falhas e bloqueou a terceira tentativa do retry. Esse caso mostra que o tamanho da janela deve ser escolhido com o número de tentativas em mente.

A figura abaixo mostra como a composição das duas peças funciona numa mesma chamada, com o retry por fora e o disjuntor por dentro.

Retry envolve o disjuntor. cobrar(pedido) → Retry; Retry → Disjuntor aberto? (tentativa); Disjuntor aberto? → Chamada remota (não)…
figura 1 · Retry envolve o disjuntor

A figura lê de cima para baixo. A chamada entra no retry, que faz a tentativa e passa para o disjuntor. O losango é a decisão do circuito: se estiver aberto, a chamada remota nem é feita, e a exceção CallNotPermittedException sobe sem retentativa, porque o retry só repete as exceções da lista. Se estiver fechado, a chamada vai à dependência, e uma IOException volta pelo caminho pontilhado para o retry, que decide se faz nova tentativa. A ordem importa: o retry por fora significa que cada tentativa passa pelo disjuntor, e um circuito aberto interrompe as tentativas sem gastar nenhuma.

Na prática, a composição do projeto é esta:

private val protegido = Retry.decorateCheckedFunction(
    retry,
    CircuitBreaker.decorateCheckedFunction(disjuntor) { pedido: Pedido -> remota.enviar(pedido) },
)

Dois testes verificam o comportamento. O primeiro falha duas vezes e aprova na terceira, e a chamada remota é executada três vezes. O segundo deixa o disjuntor aberto: depois da primeira cobrança, com três falhas, o circuito abre, e a segunda cobrança lança CallNotPermittedException sem incrementar o contador da chamada remota. Os dois testes passaram. O segundo é a prova de que o circuito de fato impede a chamada, e não só registra o erro.

Um aviso sobre a versão do Spring Boot. O Resilience4j publica o módulo resilience4j-spring-boot4 na versão 2.4.0. Esta aula não o usou: a configuração foi feita em código, com os módulos de núcleo resilience4j-retry e resilience4j-circuitbreaker, e a compatibilidade do módulo de integração com o Spring Boot 4.1.1 não foi testada.

Pergunta de entrevistaComo fazer retry sem sincronizar todos os clientes?

Retentar com intervalo fixo faz todos os clientes voltarem juntos, e o serviço que está se recuperando recebe uma onda de carga. Uso backoff exponencial com jitter aleatório, para espalhar as tentativas. Também limito o número de tentativas e só repito erros que podem mudar, como timeout ou 503, nunca erro de validação. Retry só é seguro se a operação for idempotente, senão pode duplicar o efeito.

3. empacotamento: as camadas do jar

Um Spring Boot empacota a aplicação em um jar grande, o chamado fat jar, com todas as dependências dentro. Esse formato é prático para rodar, mas ruim para a imagem Docker, porque qualquer mudança no código força o envio do jar inteiro, com as bibliotecas, para o registro de imagens. O Docker reaproveita camadas do cache, e um único arquivo gigante é uma única camada que muda sempre.

O Spring Boot resolve isso com camadas. O comando jarmode=tools extrai o jar em diretórios. Nesta revisão, o comando foi rodado no jar do projeto de verificação, na versão 4.1.1, e as opções que ele aceitou foram estas: --launcher, que extrai o carregador do Spring Boot, --layers, que extrai as camadas, e --destination, que define a pasta de saída. A extração produziu as camadas application, dependencies, snapshot-dependencies e spring-boot-loader:

java -Djarmode=tools -jar pedidos-0.0.1.jar extract --layers --launcher --destination extraido

A documentação do Spring Boot lista as camadas na ordem em que devem entrar na imagem: dependencies, spring-boot-loader, snapshot-dependencies e application. A ideia é colocar primeiro o que muda menos. Bibliotecas de terceiros mudam quando o pom muda. O carregador muda com a versão do Spring Boot. A aplicação muda a cada commit. Se a aplicação vem por último no Dockerfile, uma mudança de código só invalida a camada final, e as anteriores vêm do cache. Esse é o motivo de a ordem importar.

O JDK completo tem dezenas de módulos, e a aplicação usa uma fração deles. O jlink monta uma imagem de runtime com apenas os módulos escolhidos e suas dependências transitivas. As opções usadas aqui são --add-modules, que define os módulos raiz, --strip-debug, que remove informações de depuração, --no-header-files e --no-man-pages, que removem arquivos que um runtime de produção não usa, e --output, que define o destino.

A opção de compressão merece um cuidado. Com --compress=2, o JDK 21 avisa que o valor está obsoleto, com a mensagem The 2 argument for --compress is deprecated and may be removed in a future release. A aula usa --compress=zip-6, que foi o valor aceito na montagem da imagem.

A lista de módulos vem do jdeps. Rodar o jdeps direto no jar gordo deu só java.base e java.sql, porque ele não enxerga as classes aninhadas em BOOT-INF. A análise que funcionou foi feita sobre as classes extraídas, com o classpath das bibliotecas:

jdeps --ignore-missing-deps --multi-release 21 --print-module-deps \
  --class-path "extraido/dependencies/BOOT-INF/lib/*" extraido/application/BOOT-INF/classes

O resultado foram doze módulos: java.base, java.compiler, java.desktop, java.instrument, java.net.http, java.prefs, java.scripting, java.security.jgss, java.sql.rowset, jdk.jfr, jdk.management e jdk.unsupported. O runtime gerado com esses módulos listou vinte módulos com --list-modules, porque o jlink acrescenta as dependências transitivas. Essa diferença é normal, e por isso o tamanho deve ser medido, e não estimado.

Há uma limitação que o jdeps não resolve. Código carregado por reflexão não aparece na análise de dependências. Por isso o teste de verdade é subir a aplicação com o runtime montado. Nesta revisão, a aplicação subiu com o runtime, respondeu UP no health check, gravou um pedido no PostgreSQL e não registrou nenhum erro no log.

Um ponto que precisa estar claro: o jlink gera um runtime para o sistema operacional e a arquitetura em que roda. Um runtime montado no macOS não serve para um contêiner Linux. Por isso o Dockerfile monta o runtime dentro de um estágio Linux, com a imagem do JDK, e copia só o resultado para a imagem final.

5. a imagem final em duas medidas

O Dockerfile abaixo tem dois estágios. O primeiro usa a imagem do JDK em Alpine, extrai as camadas, calcula os módulos com jdeps e monta o runtime com jlink. O segundo parte de alpine:latest e copia o runtime e as camadas, na ordem do cache. Este é o arquivo que foi construído nesta revisão:

FROM eclipse-temurin:21-jdk-alpine AS montagem
WORKDIR /build
COPY target/pedidos-0.0.1.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extraido \
 && MODULOS=$(jdeps --ignore-missing-deps --multi-release 21 --print-module-deps --class-path "extraido/dependencies/BOOT-INF/lib/*" extraido/application/BOOT-INF/classes | tail -1) \
 && jlink --add-modules "$MODULOS" --strip-debug --no-header-files --no-man-pages --compress=zip-6 --output /opt/jre-pedidos

FROM alpine:latest
COPY --from=montagem /opt/jre-pedidos /opt/java
WORKDIR /app
COPY --from=montagem /build/extraido/dependencies/ ./
COPY --from=montagem /build/extraido/spring-boot-loader/ ./
COPY --from=montagem /build/extraido/snapshot-dependencies/ ./
COPY --from=montagem /build/extraido/application/ ./
ENTRYPOINT ["/opt/java/bin/java", "org.springframework.boot.loader.launch.JarLauncher"]

Para comparar, construí também uma imagem ingênua, com eclipse-temurin:21-jre-alpine e o jar inteiro copiado:

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY target/pedidos-0.0.1.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

As duas foram construídas e medidas com docker images na mesma máquina, com a mesma aplicação:

  • imagem ingênua, com JRE e o fat jar: 360 MB
  • imagem enxuta, com alpine:latest, o runtime jlink e as quatro camadas: 189 MB

A imagem enxuta foi executada ligada a um PostgreSQL 17 numa rede Docker. O health check respondeu UP, a criação de um pedido devolveu 200 com o estado Aprovado, e o log não tinha nenhuma linha de nível ERROR. O runtime da imagem tem 20 módulos, verificados com --list-modules.

A figura abaixo mostra as duas imagens e a divisão das camadas da enxuta.

Duas imagens do mesmo serviço. Imagem enxuta → dependencies (camada 1); dependencies → spring-boot-loader (camada 2)…
figura 2 · Duas imagens do mesmo serviço

A figura compara as imagens e depois abre a imagem enxuta nas camadas. A diferença de tamanho vem de duas fontes. A primeira é a base e o runtime: o jlink leva só os 20 módulos usados, enquanto o JRE inteiro carrega todos os módulos. A segunda é a organização em camadas, que não reduz o tamanho, mas controla o que muda a cada build. Os dois tamanhos são medidos. A figura não mostra tempo de build, porque o tempo não foi comparado.

A documentação do Spring Boot menciona o bootBuildImage, do plugin Gradle e do Maven, como outra forma de gerar a imagem com Cloud Native Buildpacks. Esse comando não foi executado nesta revisão, então a aula não afirma tamanho nem comportamento dele.

6. erros comuns e boas práticas

O primeiro erro é retentar tudo. Uma regra de negócio que recusou um pedido não deve ser retentada, e uma exceção de programação também não. A boa prática é listar as exceções de falha de transporte e retentar só essas.

O segundo erro é retentar sem intervalo crescente e sem aleatoriedade. Isso cria o efeito de manada. A boa prática é usar ofExponentialRandomBackoff, ou uma função equivalente, e limitar o número de tentativas.

O terceiro erro é colocar o retry por dentro do disjuntor. Assim, o disjuntor enxerga só a chamada final e não conta as tentativas intermediárias, e a janela de falhas fica subestimada. A ordem usada aqui, com o retry por fora, é a que a composição do projeto descreve.

O quarto erro é usar decorateFunction com exceções verificadas do Java e esperar a retentativa. O experimento desta revisão mostrou que a função foi chamada uma única vez. A boa prática é usar a variante Checked para qualquer exceção que o compilador Java obriga a tratar.

O quinto erro é copiar um fat jar para a imagem e achar que o cache do Docker resolve. Sem camadas, cada mudança de código reenvia tudo. A boa prática é extrair as camadas e copiar a aplicação por último.

O sexto erro é montar o jlink no sistema errado. O runtime precisa ser gerado para o sistema alvo, e a forma segura é gerar dentro de um estágio Linux do próprio Dockerfile.

O sétimo erro é manter opções obsoletas do jlink porque a documentação antiga as cita. O aviso de --compress=2 apareceu na execução, e a troca para zip-6 resolveu sem mudar o resultado.

7. exercícios

  1. Remova retryExceptions do teste e adicione um teste que lança IllegalStateException duas vezes. Conte as chamadas e explique por que o número esperado mudou.
  2. Crie um teste que deixa o disjuntor aberto, espera o tempo de waitDurationInOpenState com um valor pequeno, e verifica a transição para HALF_OPEN. Use um tempo de espera de duzentos milissegundos e explique por que o teste precisa de cuidado para não ficar intermitente.
  3. Mude a ordem da composição para CircuitBreaker por fora e Retry por dentro. Conte as chamadas em um cenário com falha persistente e compare com o resultado da ordem original.
  4. Gere a imagem com o plugin de build do seu sistema (bootBuildImage) e compare o tamanho com as duas imagens desta aula. Registre o tamanho e a versão do builder usado.
  5. Remova --strip-debug do jlink e meça o tamanho do runtime. Explique a diferença, sem presumir o valor.

8. glossário

Retentativa (retry): repetição de uma operação que falhou, com limite de tentativas.

Jitter: variação aleatória do intervalo entre retentativas, para evitar que clientes batam juntos.

Efeito de manada: sincronização de retentativas que sobrecarrega um serviço que tenta se recuperar.

Disjuntor (circuit breaker): componente que interrompe chamadas a uma dependência com falha alta, para evitar desperdício e cascata.

Estado OPEN: circuito aberto, em que as chamadas são rejeitadas sem execução.

Estado HALF_OPEN: circuito em teste, que deixa passar um número limitado de chamadas.

Camada (layer) de imagem: diretório de uma imagem Docker que muda com uma frequência específica e é reaproveitado do cache.

jlink: ferramenta do JDK que monta um runtime com apenas os módulos escolhidos.

jdeps: ferramenta do JDK que lista as dependências de módulos de classes e bibliotecas.

fontes

Consultadas em 20/10/2026.

Não verificado nesta revisão: a página de circuit breaker do Resilience4j (estados e transições), a fórmula do intervalo exponencial, o comportamento de HALF_OPEN com espera real, o módulo resilience4j-spring-boot4 com o Spring Boot 4.1.1, e o bootBuildImage.

Executado em 20/10/2026, com Maven e JDK 21 (Temurin): dois testes de disjuntor e retry passaram, junto com o teste de retry com decorateFunction. O empacotamento com jarmode=tools, jdeps e jlink rodou, e o runtime montado respondeu UP e gravou um pedido. As duas imagens foram construídas e medidas com docker images, em 360 MB e 189 MB, e a imagem enxuta respondeu UP e gravou um pedido contra o PostgreSQL 17 numa rede Docker.

relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?