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
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.
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.
4. jlink: um runtime só com os módulos usados
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 runtimejlinke 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.
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
- Remova
retryExceptionsdo teste e adicione um teste que lançaIllegalStateExceptionduas vezes. Conte as chamadas e explique por que o número esperado mudou. - Crie um teste que deixa o disjuntor aberto, espera o tempo de
waitDurationInOpenStatecom um valor pequeno, e verifica a transição paraHALF_OPEN. Use um tempo de espera de duzentos milissegundos e explique por que o teste precisa de cuidado para não ficar intermitente. - Mude a ordem da composição para
CircuitBreakerpor fora eRetrypor dentro. Conte as chamadas em um cenário com falha persistente e compare com o resultado da ordem original. - 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. - Remova
--strip-debugdojlinke 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.
- Resilience4j, retry (
maxAttemptscontando a chamada inicial, padrão 3,retryExceptionse a exigência deCheckedSupplierpara exceções verificadas): https://resilience4j.readme.io/docs/retry - Spring Boot, imagens eficientes com camadas (nomes das camadas e ordem do índice): https://docs.spring.io/spring-boot/reference/packaging/container-images/efficient-images.html (a página não descreve o comando
jarmode=tools; as opções foram conferidas executando o comando) - Spring Boot, Buildpacks (menção ao
bootBuildImage, não executado): https://docs.spring.io/spring-boot/reference/packaging/container-images/cloud-native-buildpacks.html - Assinaturas conferidas com
javapnos jars do Maven Central:IntervalFunction.ofExponentialRandomBackoff(Duration, double, double)emresilience4j-core-2.4.0.jar - Versão
resilience4j-spring-boot42.4.0 publicada no Maven Central: https://repo1.maven.org/maven2/io/github/resilience4j/resilience4j-spring-boot4/maven-metadata.xml - Oracle, documentação do
jlinkno Java SE 21: https://docs.oracle.com/en/java/javase/21/docs/specs/man/jlink.html (a opção--compresscom valor2foi confirmada como obsoleta pelo próprio JDK 21 na execução)
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.