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
SmartLifecyclerecebem 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 observado | Correção concreta | Observação segura em produção |
|---|---|---|
| Requisições aceitas são cortadas | Mantenha server.shutdown=graceful e dê à fase tempo maior que a duração máxima admitida para a requisição | Compare 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 terminando | Execute preStop antes do sinal e dimensione terminationGracePeriodSeconds para preStop mais todas as fases | Observe 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 fechamento | Use SmartLifecycle para fechar a admissão, aguardar o lote e só depois liberar o contexto | Exponha 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.