Spring Boot 2.3 – Graceful Shutdown w Praktyce

TL;DR: Spring Boot 2.3 wprowadza graceful shutdown – funkcję która pozwala aplikacji dokończyć przetwarzanie aktywnych requestów przed wyłączeniem. Zamiast brutalnego kill, aplikacja dostaje czas na uporządkowanie spraw i bezpieczne zamknięcie.

Dlaczego graceful shutdown jest ważny?

Gdy aplikacja zostaje wyłączona w środku przetwarzania requestu, użytkownik może stracić dane lub otrzymać błąd 500. W środowisku produkcyjnym, gdzie deployments odbywają się regularnie, graceful shutdown to różnica między zadowolonymi użytkownikami a support tickets o zaginionych transakcjach.

Z perspektywy biznesowej oznacza to zero downtime deployments, lepsze user experience i mniej problemów operacyjnych podczas aktualizacji systemu.

Co się nauczysz:

  • Jak włączyć graceful shutdown w Spring Boot 2.3
  • Konfigurację timeout i grace period
  • Implementację custom shutdown hooks
  • Monitoring procesu wyłączania aplikacji
  • Best practices dla różnych typów aplikacji

Wymagania wstępne:

  • Spring Boot 2.x (znajomość podstaw)
  • Pojęcie o HTTP requestach i lifecycle
  • Podstawy Java i web applications
  • Doświadczenie z deploymentami aplikacji

Czym jest graceful shutdown?

Graceful shutdown – proces wyłączania aplikacji który pozwala dokończyć aktywne operacje przed całkowitym zamknięciem, w przeciwieństwie do natychmiastowego zabicia procesu.

Gdy aplikacja otrzymuje sygnał wyłączenia (SIGTERM), graceful shutdown:
1. **Przestaje przyjmować nowe requesty**
2. **Kończy aktywne requesty** w określonym czasie
3. **Wywołuje cleanup hooks** (zamykanie połączeń DB, cache, etc.)
4. **Wyłącza się** po zakończeniu wszystkich operacji

Analogia: To jak zamykanie restauracji – nie wyrzucasz klientów w środku posiłku, tylko przestajesz wpuszczać nowych, obsługujesz tych co już są, sprzątasz kuchnię i dopiero wtedy zamykasz.

Konfiguracja graceful shutdown w Spring Boot 2.3

Podstawowa konfiguracja w application.yml:

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

Informacja: timeout-per-shutdown-phase określa maksymalny czas oczekiwania na zakończenie każdej fazy wyłączania. Domyślnie to 30 sekund.

Alternatywnie w application.properties:

server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s

Jak to działa w praktyce?

Przykład kontrolera który symuluje długotrwałe operacje:

@RestController
public class OrderController {
    
    private static final Logger logger = LoggerFactory.getLogger(OrderController.class);
    
    @PostMapping("/api/orders")
    public ResponseEntity processOrder(@RequestBody OrderRequest request) {
        logger.info("Starting order processing for: {}", request.getOrderId());
        
        try {
            // Symulacja długotrwałego przetwarzania (np. płatność, walidacja)
            Thread.sleep(10000); // 10 sekund
            
            OrderResponse response = OrderResponse.builder()
                .orderId(request.getOrderId())
                .status("COMPLETED")
                .processedAt(LocalDateTime.now())
                .build();
                
            logger.info("Order processing completed: {}", request.getOrderId());
            return ResponseEntity.ok(response);
            
        } catch (InterruptedException e) {
            logger.warn("Order processing interrupted: {}", request.getOrderId());
            return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
                .body(OrderResponse.builder()
                    .orderId(request.getOrderId())
                    .status("PROCESSING_INTERRUPTED")
                    .build());
        }
    }
}
Pro tip: Graceful shutdown automatycznie obsługuje przerwanie Thread.sleep() i innych blocking operations, więc pamiętaj o obsłudze InterruptedException.

Custom shutdown hooks

Możesz dodać własne operacje cleanup używając @PreDestroy lub custom hooks:

@Service
public class OrderProcessingService {
    
    private final ExecutorService executorService = Executors.newFixedThreadPool(10);
    private final Logger logger = LoggerFactory.getLogger(OrderProcessingService.class);
    
    @PreDestroy
    public void cleanup() {
        logger.info("Starting graceful shutdown of OrderProcessingService");
        
        executorService.shutdown();
        
        try {
            // Czekaj maksymalnie 20 sekund na zakończenie zadań
            if (!executorService.awaitTermination(20, TimeUnit.SECONDS)) {
                logger.warn("Some tasks didn't complete within timeout, forcing shutdown");
                executorService.shutdownNow();
            }
        } catch (InterruptedException e) {
            logger.error("Shutdown interrupted", e);
            executorService.shutdownNow();
            Thread.currentThread().interrupt();
        }
        
        logger.info("OrderProcessingService shutdown completed");
    }
}

Alternative approach z DisposableBean:

@Component
public class DatabaseConnectionManager implements DisposableBean {
    
    private final Logger logger = LoggerFactory.getLogger(DatabaseConnectionManager.class);
    
    @Override
    public void destroy() throws Exception {
        logger.info("Closing database connections gracefully");
        
        // Zamknij connection pools
        // Flush pending transactions  
        // Clear caches
        
        logger.info("Database connections closed");
    }
}
Uwaga: Wszystkie @PreDestroy methods muszą zakończyć się w czasie określonym przez timeout-per-shutdown-phase, inaczej zostaną przerwane.

Monitoring graceful shutdown

Dodaj logging żeby monitorować proces wyłączania:

logging:
  level:
    org.springframework.boot.web.embedded.tomcat: INFO
    org.springframework.context.support: DEBUG
  pattern:
    console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"

Przykład custom ActuatorEndpoint dla monitoring:

@Component
@Endpoint(id = "shutdown-status")
public class ShutdownStatusEndpoint {
    
    private volatile boolean shutdownInitiated = false;
    private LocalDateTime shutdownStartTime;
    
    @EventListener
    public void handleContextClosedEvent(ContextClosedEvent event) {
        shutdownInitiated = true;
        shutdownStartTime = LocalDateTime.now();
    }
    
    @ReadOperation
    public Map shutdownStatus() {
        Map status = new HashMap<>();
        status.put("shutdownInitiated", shutdownInitiated);
        if (shutdownStartTime != null) {
            status.put("shutdownStartTime", shutdownStartTime);
            status.put("shutdownDuration", Duration.between(shutdownStartTime, LocalDateTime.now()).getSeconds());
        }
        return status;
    }
}

Różne strategie dla różnych aplikacji

Typ aplikacjiRecommended timeoutSpecjalne uwagi
REST API30-60sKrótkie requesty, szybkie wyłączenie
Batch processing5-15minDługotrwałe operacje, checkpoint saving
WebSocket apps60-120sNotyfikacja klientów o rozłączeniu
Message consumers2-5minDokończenie przetwarzania wiadomości
Typowy błąd: Ustawienie zbyt krótkiego timeout dla aplikacji z długotrwałymi operacjami. Efekt: requesty są przerywane w połowie przetwarzania.

Testing graceful shutdown

Prosty test integracyjny:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class GracefulShutdownTest {
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @LocalServerPort
    private int port;
    
    @Test
    void shouldCompleteRequestsBeforeShutdown() throws Exception {
        // Uruchom długotrwały request w osobnym wątku
        CompletableFuture> future = CompletableFuture.supplyAsync(() -> {
            return restTemplate.postForEntity(
                "http://localhost:" + port + "/api/long-running-task",
                new TaskRequest("test"),
                String.class
            );
        });
        
        // Poczekaj chwilę i wyślij sygnał shutdown
        Thread.sleep(1000);
        // W prawdziwym teście użyj SpringApplication.exit() lub podobne
        
        // Sprawdź czy request się ukończył pomyślnie
        ResponseEntity response = future.get(45, TimeUnit.SECONDS);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }
}
Co się stanie jeśli timeout zostanie przekroczony?

Spring Boot przerywa wszystkie aktywne operacje i wyłącza aplikację. Aktywne requesty otrzymają status 503 Service Unavailable lub connection reset.

Czy graceful shutdown działa z embedded Tomcat?

Tak, Spring Boot 2.3+ obsługuje graceful shutdown dla wszystkich embedded servers: Tomcat, Jetty, Undertow i Netty.

Jak graceful shutdown wpływa na performance?

Praktycznie wcale. Overhead występuje tylko podczas wyłączania aplikacji, w normalnej pracy nie ma różnicy w wydajności.

Czy mogę wyłączyć graceful shutdown?

Tak, ustaw server.shutdown=immediate w konfiguracji. To przywraca poprzednie zachowanie (natychmiastowe wyłączenie).

Jak testować graceful shutdown lokalnie?

Uruchom aplikację, wyślij długotrwały request, a następnie zatrzymaj aplikację przez IDE lub Ctrl+C. Sprawdź logi czy request się ukończył.

Czy graceful shutdown działa z Docker?

Tak, ale pamiętaj że docker stop domyślnie daje 10 sekund na wyłączenie. Użyj docker stop -t 60 dla dłuższego timeout.

Przydatne zasoby:

🚀 Zadanie dla Ciebie

Stwórz prostą Spring Boot aplikację z endpoint który symuluje 15-sekundowe przetwarzanie. Skonfiguruj graceful shutdown z timeout 20 sekund. Przetestuj scenario: uruchom request, zatrzymaj aplikację i sprawdź czy request się ukończy. Dodaj custom @PreDestroy hook który loguje informacje o zamykaniu.

Jak planujesz wykorzystać graceful shutdown w swoich projektach? Podziel się swoimi doświadczeniami z deployment w komentarzach!

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry