Todos os artigos

// Knowledge.log — 技術記事

Drain de verdade no Spring Boot 4.1 com SIGTERM e workers

Configure e verifique o drain de HTTP e workers no Spring Boot 4.1.1, com SIGTERM, SmartLifecycle e Kubernetes sem cortar trabalho em voo.

Um pod recebe SIGTERM, a aplicação fecha e o deploy termina. Ainda assim, uma requisição em voo volta como erro e o último lote do outbox fica pela metade. O processo encerrou; o trabalho, nem tanto.

No Spring Boot 4.1.1, o drain HTTP já é o comportamento padrão. O ponto que costuma faltar é coordenar três relógios diferentes: o servidor web, os workers da aplicação e o prazo dado pelo Kubernetes. O resultado que queremos verificar é simples: requisições aceitas terminam, novas conexões deixam de entrar, nenhum lote novo começa e o processo só então libera os recursos gerenciados pelo contexto.

Os exemplos usam Spring Boot 4.1.1 com Java 25. Segundo os requisitos oficiais do Spring Boot, essa versão requer Java 17 e é compatível até Java 26, inclusive. Java 25 cabe nessa janela e é a base usada aqui; Boot 3.x e Java 21 não são o ponto de partida.

O que realmente acontece depois do SIGTERM

Não precisamos registrar um tratador de sinal. Ao receber SIGTERM, o shutdown hook instalado por SpringApplication fecha o ApplicationContext. O fechamento publica ContextClosedEvent no início e passa a interromper os componentes que implementam SmartLifecycle.

O servidor participa dessa sequência por meio de WebServerGracefulShutdownLifecycle. A fase é WebServerApplicationContext.GRACEFUL_SHUTDOWN_PHASE, equivalente a SmartLifecycle.DEFAULT_PHASE - 1024. Como fases maiores param primeiro, um worker na fase padrão começa seu encerramento antes da fase de drain do servidor web.

Durante o graceful shutdown, Tomcat, Jetty e Reactor Netty deixam de aceitar novas requisições na camada de rede e permitem que as requisições em voo terminem dentro do prazo da fase. Essa é a semântica documentada em Graceful Shutdown. Em 4.1.1, server.shutdown já vale graceful; immediate desabilita o comportamento. Repetir a propriedade no YAML serve apenas para registrar a decisão.

O limite de spring.lifecycle.timeout-per-shutdown-phase é de 30 segundos por padrão.

server:
  shutdown: graceful # padrão no Boot 4.1.1; explícito para registrar a decisão

spring:
  lifecycle:
    timeout-per-shutdown-phase: 20s

management:
  endpoint:
    health:
      probes:
        enabled: true
  endpoints:
    web:
      exposure:
        include: health

Estado de disponibilidade e observação por HTTP durante o drain são coisas diferentes. A documentação de probes e ciclo de vida descreve a aplicação como REFUSING_TRAFFIC durante o graceful shutdown, mas informa que as probes HTTP também deixam de aceitar tráfego. Portanto, chamar /actuator/health/readiness depois do sinal e exigir um 503 não é uma prova confiável. O conector pode recusar a conexão antes de entregar qualquer status.

A prova correta é comportamental:

  • uma requisição aceita antes do sinal conclui;
  • uma nova conexão não é aceita durante o drain;
  • os componentes SmartLifecycle recebem a parada e concluem dentro do prazo.

Graceful shutdown HTTP não drena o outbox

server.shutdown=graceful controla o servidor web. Ele não conhece a unidade de trabalho de um @Scheduled, listener de fila ou relay de outbox. Se o worker puder buscar um lote novo enquanto o contexto fecha, a aplicação pode iniciar uma transação que já não terá tempo de terminar.

A correção é dar ao worker um protocolo próprio: parar de aceitar lotes, esperar o lote ativo com um limite explícito e, ao concluir ou esgotar esse limite, invocar o callback de SmartLifecycle. O DataSource continua sob responsabilidade do Spring e será destruído depois da parada dos lifecycles; o worker não deve fechá-lo por conta própria.

O exemplo abaixo é uma aplicação mínima executável. O OutboxBatchProcessor usa uma espera apenas para tornar o lote visível no teste local. Em uma aplicação real, esse método chama o serviço transacional que seleciona e confirma um lote do outbox.

package devdojo.drain;

import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.SmartLifecycle;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@EnableScheduling
public class DrainApplication {

  public static void main(String[] args) {
    SpringApplication.run(DrainApplication.class, args);
  }
}

@RestController
class SlowController {

  @GetMapping("/slow")
  String slow() throws InterruptedException {
    Thread.sleep(Duration.ofSeconds(8));
    return "concluida";
  }
}

@Component
class OutboxBatchProcessor {

  void processNextBatch() throws InterruptedException {
    // Substitua pelo serviço transacional e idempotente do outbox.
    Thread.sleep(Duration.ofSeconds(5));
  }
}

@Component
class OutboxWorker implements SmartLifecycle {

  private static final Logger log = LoggerFactory.getLogger(OutboxWorker.class);
  private static final Duration DRAIN_TIMEOUT = Duration.ofSeconds(15);

  private final OutboxBatchProcessor processor;
  private final ReentrantLock lock = new ReentrantLock();
  private final Condition batchFinished = lock.newCondition();

  private volatile boolean running;
  private boolean accepting;
  private boolean activeBatch;

  OutboxWorker(OutboxBatchProcessor processor) {
    this.processor = processor;
  }

  @Override
  public void start() {
    lock.lock();
    try {
      accepting = true;
      running = true;
    } finally {
      lock.unlock();
    }
  }

  @Scheduled(fixedDelay = 500)
  void poll() {
    lock.lock();
    try {
      if (!accepting || activeBatch) {
        return;
      }
      activeBatch = true;
    } finally {
      lock.unlock();
    }

    try {
      processor.processNextBatch();
    } catch (InterruptedException interrupted) {
      Thread.currentThread().interrupt();
    } finally {
      lock.lock();
      try {
        activeBatch = false;
        batchFinished.signalAll();
      } finally {
        lock.unlock();
      }
    }
  }

  @Override
  public void stop(Runnable callback) {
    lock.lock();
    try {
      accepting = false;
    } finally {
      lock.unlock();
    }

    Thread.ofVirtual().name("outbox-drain").start(() -> {
      boolean completed = false;
      lock.lock();
      try {
        long remaining = DRAIN_TIMEOUT.toNanos();
        while (activeBatch && remaining > 0) {
          remaining = batchFinished.awaitNanos(remaining);
        }
        completed = !activeBatch;
        running = false;
      } catch (InterruptedException interrupted) {
        Thread.currentThread().interrupt();
        running = false;
      } finally {
        lock.unlock();
        log.info("Outbox drain finalizado: completed={}", completed);
        callback.run();
      }
    });
  }

  @Override
  public void stop() {
    stop(() -> { });
  }

  @Override
  public boolean isRunning() {
    return running;
  }

  @Override
  public boolean isAutoStartup() {
    return true;
  }

  @Override
  public int getPhase() {
    return SmartLifecycle.DEFAULT_PHASE;
  }
}

O lock fecha a pequena corrida entre “posso começar?” e “o shutdown começou?”. Quando stop muda accepting para false, nenhuma execução posterior consegue marcar outro lote como ativo. O lote que já começou pode terminar; a thread virtual apenas espera e chama o callback sem bloquear o processamento das demais fases.

A fase padrão faz esse worker parar antes do WebServerGracefulShutdownLifecycle. Isso é adequado para um poller independente, como o relay de outbox. Se uma requisição HTTP em voo entrega trabalho diretamente a esse mesmo worker em memória, o desenho precisa mudar: persista o trabalho antes de responder ou escolha conscientemente outra fase. Trocar um número de fase até o teste ficar verde é configuração por astrologia.

O callback precisa ser chamado tanto quando o lote termina quanto quando DRAIN_TIMEOUT expira; sem ele, o Spring não recebe a confirmação de parada. O lote também deve ser idempotente e ter seu próprio limite transacional, porque o encerramento coordenado não corrige uma operação sem prazo.

Kubernetes: retire a rota antes de enviar o sinal

No Kubernetes, o preStop roda antes do SIGTERM. Esse intervalo permite que a mudança do EndpointSlice e dos balanceadores se propague enquanto o processo ainda atende. Depois do hook, o kubelet envia o sinal e o Spring inicia seu encerramento.

terminationGracePeriodSeconds precisa cobrir o tempo do preStop, a soma dos limites que as fases de shutdown podem consumir e uma margem operacional. Como o worker na fase padrão e o servidor em GRACEFUL_SHUTDOWN_PHASE podem usar limites separados, o valor deve ser calculado para a sequência inteira; ao fim desse período, o Kubernetes encerra o processo com SIGKILL.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: drain-demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: drain-demo
  template:
    metadata:
      labels:
        app: drain-demo
    spec:
      terminationGracePeriodSeconds: 70
      containers:
        - name: app
          image: registry.example.com/drain-demo:1.0.0
          ports:
            - name: http
              containerPort: 8080
          lifecycle:
            preStop:
              sleep:
                seconds: 10
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: http
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            periodSeconds: 10

O handler sleep exige Kubernetes 1.32 ou mais recente. Em versões anteriores, o equivalente é um preStop.exec com sleep, desde que a imagem contenha o executável. O YAML é um modelo de configuração, não o relato de um teste em cluster. A ordem e o orçamento estão descritos no ciclo de vida de Pods e no guia de terminação de Pods e endpoints.

Não há garantia de que todo proxy deixe de encaminhar no instante em que o endpoint passa a terminating. É justamente essa janela que o preStop absorve. A duração correta deve ser medida no ambiente do time, não copiada dos dez segundos do exemplo.

Como provar localmente com kill -TERM

Com spring-boot-starter-web e spring-boot-starter-actuator no projeto, gere o jar e execute a aplicação com Java 25:

./mvnw clean package
java -jar target/drain-demo-0.0.1-SNAPSHOT.jar > /tmp/drain-demo.log 2>&1 &
pid=$!

until curl -fsS http://localhost:8080/actuator/health/readiness; do
  sleep 1
done

curl -fsS http://localhost:8080/slow > /tmp/slow-response.txt &
slow_curl=$!
sleep 1

kill -TERM "$pid"
sleep 1

if curl --connect-timeout 2 -fsS http://localhost:8080/actuator/health/readiness; then
  printf 'ERRO: uma nova requisição foi aceita durante o drain\n'
else
  printf 'OK: a nova conexão não foi aceita\n'
fi

wait "$slow_curl"
printf 'Resposta em voo: '
cat /tmp/slow-response.txt

wait "$pid"
printf 'Exit status: %s\n' "$?"
grep 'Outbox drain finalizado' /tmp/drain-demo.log

O sinal esperado é a resposta concluida da chamada iniciada antes do SIGTERM, a falha da nova conexão e o log completed=true do worker. O status final do processo pode refletir o sinal e o launcher usado; ele não substitui essas três verificações.

Para criar o controle negativo, repita o teste iniciando o jar com o shutdown imediato:

SERVER_SHUTDOWN=immediate \
  java -jar target/drain-demo-0.0.1-SNAPSHOT.jar \
  > /tmp/drain-immediate.log 2>&1 &
pid=$!

until curl -fsS http://localhost:8080/actuator/health/readiness; do
  sleep 1
done

curl -fsS http://localhost:8080/slow > /tmp/immediate-response.txt &
slow_curl=$!
sleep 1
kill -TERM "$pid"
wait "$slow_curl"
printf 'curl exit status com shutdown imediato: %s\n' "$?"

Nesse controle, a requisição lenta não recebe a janela do graceful shutdown e o curl deve falhar. Isso compara semântica de encerramento, não desempenho; throughput e percentis não têm papel aqui.

Parar pelo botão da IDE também não é um substituto garantido para SIGTERM, pois alguns launchers encerram o processo por outro caminho e pulam a sequência esperada.

Remediação e observação em produção

Os sintomas se sobrepõem, então vale usar um único roteiro em vez de três checklists quase iguais:

Problema observadoCorreção concretaObservação segura em produção
Requisições aceitas são cortadasMantenha server.shutdown=graceful e dê à fase tempo maior que a duração máxima admitida para a requisiçãoCompare requisições iniciadas antes da terminação com conclusões e erros do balanceador; acompanhe containers terminados por SIGKILL
O balanceador ainda envia tráfego ao pod terminandoExecute preStop antes do sinal e dimensione terminationGracePeriodSeconds para preStop mais todas as fasesObserve o EndpointSlice passando a terminating/not ready e correlacione esse instante com erros do ingress ou load balancer
O outbox começa lote durante o fechamentoUse SmartLifecycle para fechar a admissão, aguardar o lote e só depois liberar o contextoExponha gauges de accepting e activeBatch, registre o último lote confirmado e alerte para completed=false no drain

Evite usar apenas uma mensagem “shutdown complete”. Ela confirma o fim, mas não diz se o lote foi confirmado, se houve SIGKILL ou se uma conexão nova entrou durante a janela.

Quando adotar e quando recuar

A DevDojo adotaria esse desenho em serviços Spring Boot com deploy rolling quando houver requisições que não podem ser cortadas ou workers que confirmam trabalho em banco, SQS ou outbox. O requisito é que cada unidade de trabalho seja idempotente, tenha timeout conhecido e produza um sinal observável de conclusão.

A equipe recuaria do worker em memória se o pior caso do lote não coubesse com margem no prazo operacional de terminação, se o processador não pudesse ser interrompido com segurança ou se o tráfego HTTP criasse trabalho não persistido durante o drain. Nesses casos, o caminho é reduzir e tornar idempotentes os lotes, persistir a entrega antes da resposta ou mover o consumo para um componente com protocolo explícito de lease e reentrega. Aumentar o grace period sem corrigir essas condições apenas adia a falha.

Como próximo passo, rode a receita local uma vez com graceful e outra com immediate, depois substitua o processador de demonstração por um único lote idempotente do seu outbox e confirme o mesmo trio: requisição em voo concluída, conexão nova recusada e lote final confirmado.

javaspring-boot

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos