Graceful shutdown w Springu - czyli prawidłowe zamykanie aplikacji

26.05.2022 | Jarosław Czekalski

Wstęp

Proces zatrzymywania aplikacji często powoduje błędy. Na przykład aplikacja może być w trakcie obsługi zapytania www, które wymaga dostępu do bazy danych, a tymczasem połączenie z bazą danych właśnie jest zamykane. To bardzo typowy scenariusz, a więc również i taki, na który wynaleziono już rozwiązanie. Rozwiązanie typu graceful shutdown polega na takim skoordynowaniu zamykania aplikacji, by w miarę możliwości dokończyć bieżące zadania i dopiero potem przejść do faktycznego wyłączania poszczególnych komponentów.

Scenariusz „wdzięcznego zamykania” dla aplikacji restowej wygląda następująco:

  1. Inicjacja procesu zamykania aplikacji.
  2. Wyłączenie obsługi przychodzących żądań http (nieprzyjmowanie nowych zadań).
  3. Oczekiwanie na zakończenie bieżących procesów.
  4. Wyłączanie komponentów.
  5. Zakończenie głównego procesu aplikacji.

We wpisie przedstawię proces graceful shutdown dla aplikacji Java Spring Boot. Omówię zakres objęty standardem i możliwości jego rozszerzania.

 

Java a shutdown

Nic nie zdziałalibyśmy, gdyby nie wsparcie Javy dla procesu zamykania aplikacji. Mamy możliwość zarejestrowania shutdown hooka, poprzez wywołanie java.lang.Runtime.addShutdownHook. Badanie naszej aplikacji warto zacząć od sprawdzenia (breakpoint) jakie biblioteki zarejestrowały swój hook. Jedną z bibliotek, które to robią jest Spring. My nie będziemy dodawać własnego haka, prawidłowym postępowaniem jest podpięcie się pod proces springowy.

Jak można zmusić aplikację do zakończenia pracy? Jest kilka eleganckich sposobów:
 
  1. Przesłać do niej sygnał SIGTERM (tylko Linux).
  2. Wywołać System.exit(n) z wnętrza aplikacji, na przykład na skutek wywołania endpointu.
  3. Zakończyć usługę Windows (wrapper powinien to wspierać).
  4. Zakończyć aplikację z poziomu środowiska programistycznego (np. IntelliJ).
  5. Zatrzymać prawidłowo skonfigurowany kontenter dockerowy.
  6. Ctrl-C w terminalu.

Standard Spring Boot

Standardowy mechanizm graceful shutdown w Springu współpracuje z serwerem http. Po otrzymaniu sygnału o rozpoczęciu zamykania aplikacji, do serwera http (np. Tomcat) przesyłana jest informacja, że rozpoczynamy graceful shutdown. Od tego momentu nie są przyjmowane nowe żądania http. Serwer http czeka określony czas, na zakończenie bieżących żądań. Po zakończeniu oczekiwania (co powinno trwać krótko, tyle ile przeciętny czas obsługi żądania) Spring przystępuje do ostatecznej fazy zamykania aplikacji – destrukcji beanów.

Zachodzi tu więc istotna prawidłowość: komponenty nie są niszczone w trakcie trwania okresu wyczekiwania, graceful period. Jest to ważne, ponieważ obsługa bieżących żądań może wymagać aktywności dowolnego komponentu. Ich zamykanie musi zostać wstrzymane.

Aktywacja mechanizmu graceful shutdown odbywa się, jak to w Spring Boocie, poprzez podanie odpowiednich property:

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



Gdybyśmy tych property nie skonfigurowali, to Spring zacznie zamykać komponenty w losowy sposób, co rzadko da oczekiwane efekty. Z reguły wygląda to tak, że aktualne procesy, np. obsługa żądań http, próbuje dalej wykonywać pracę, ale beany z których korzysta już są zamknięte i rzucają wyjątki. Co prawda klauzule @DependsOn wpływają na kolejność destrukcji beanów, ale mnożenie tych klauzul nie jest stosownym podejściem.

Czy wystarczy skonfigurowanie tych property, żeby być spokojnym o poprawne zamknięcie aplikacji? W prostym przypadku tak, ale zdarza się, że to nie wystarcza. Dzieje się tak np. w poniższych przypadkach:
 
  1. Procesy asynchroniczne.
  2. Schedulery.
  3. Komponenty wymagające specjalnej konfiguracji pod Springa (kafka, hazelcast itp.).

Spring od środka

Jeżeli standard Springa nie wystarcza do obsłużenia pewnych typów aplikacji, będziemy musieli zanurzyć się w kodzie źródłowym Springa (nawiasem mówiąc to prawdziwa przyjemność). Umożliwi to dodanie kompatybilnej obsługi nowych komponentów, oprócz web servera.

Poniższy rysunek prezentuje podejście Springa do zamykania aplikacji. Bardziej ogólnie mówiąc, jest to zamykanie kontekstu. Widzimy tu przedstawienie generalnej koncepcji, bez zaznaczania jak odbywa się graceful shutdown.
 
Rysunek 1. Etapy zamykania kontekstu Springa.

Źrodło: opracowanie własne, materiały wewnętrzne autora


Przejdźmy teraz do samego procesu graceful shutdown. W jego trakcie współpracuje kilka springowych klas:
 
  • AbstractApplicationContext
Metoda doClose wykonuje kolejne etapy zatrzymywania aplikacji (pokazane tylko fragmenty kodu):
 

    publishEvent(new ContextClosedEvent(this));

    /* stopBeans */

    this.lifecycleProcessor.onClose();
    destroyBeans();

Istotne dla nas i interesujące jest to, że przed etapem usuwania beanów (destroyBeans) jest etap zatrzymywania beanów (stopBeans, realizowane przez lifecycle processor).
 
  • DefaultLifecycleProcessor
Klasa sterująca procesem smart lifecycle. Pozwala beanom implementującym interfejs SmartLifecycle na wykonanie czynności zamykających w określonej kolejności, definiowanej numerami faz. Metody stop wszystkich beanów o tym samym numerze fazy uruchamiane są „jednocześnie”. „Jednocześnie”
 w cudzysłowie, bo w rzeczywistości uruchamiane są sekwencyjnie, jednak architektura procesu nakazuje im działać w oddzielnych wątkach. Powoduje to faktyczne zrównoleglenie tej fazy.

Klasa ta pilnuje jednocześnie by każda faza trwała nie więcej niż sparametryzowany okres czasu (spring.lifecycle.timeout-per-shutdown-phase).

Lifecycle procesor jest interesujący jeszcze z jednego punktu widzenia. Pozwala na wykonanie pewnych czynności zamykających zanim rozpocznie się destrukcja beanów. Możemy go wykorzystać, nawet jeżeli nie stosujemy typowego graceful shutdown.
 
  • WebServerGracefulShutdownLifecycle
Inicjuje proces zamykania webservera. Implementuje SmartLifecycle, co pozwala jej na wykonanie działania w odpowiedniej fazie zamykania aplikacji. W tym przypadku jest to faza domyślna, wykonywana jako pierwsza. Przekazuje webserwerowi sygnał do rozpoczęcia zamykania.
 
  • tomcat.GracefulShutdown
Zamyka webserver, czyli powoduje, że kolejne requesty nie będą obsługiwane (nie będą przyjmowane połączenia). Czeka na zakończenie obecnych requestów, obserwując przy tym SmartLifecycle, czy nie nadszedł już czas przejścia do kolejnej fazy. Wtedy zaprzestaje czekania.
 
  • WebServerStartStopLifecycle
Proces uruchamiany w fazie smart lifecycle o numerze (Integer.MAX_VALUE - 1), czyli później. Przekazuje webserverowi informację, że powinien już zakończyć pracę bezwarunkowo.

Współpraca pomiędzy tymi modułami wygląda dość skomplikowanie. Uruchamiane są nowe wątki (co ciekawe, zwyczajnym konstruktorem new Thread, a uczyli nas, że to tylko do uczniowskich eksperymentów). Synchronizacja między nimi odbywa się przez callbacki i CountDownLatche. Podobny mechanizm należy więc zastosować, jeżeli chcemy podłączyć się do tego procesu.

Kolejny rysunek przedstawia przebieg procesu graceful shutdown w Springu.

 
Rysunek 2. Przebieg procesu graceful shutdown w Springu.


Źrodło: opracowanie własne, materiały wewnętrzne autora

 

Jak wpiąć się do Springa - prosta czynność zamykająca, np. zatrzymanie listenerów


Załóżmy, że nasza aplikacja nasłuchuje na topicu kafki. W chwili, kiedy przestajemy odbierać requesty, powinniśmy też przestać przyjmować nowe eventy z topica. W tym celu wystarczy przygotować bean implementujący SmartLifecycle na domyślnej fazie. W ramach akcji stop zatrzymać listenery kafki. Może to wyglądać tak:
 

@Component
public class KafkaGracefulShutdown implements SmartLifecycle {

    @Override
    public void stop(Runnable callback) {
        this.running = false;
        stopKafkaListeners();
        callback.run();
    }



Jeżeli kod może wymagać więcej czasu, warto opakować go w new Thread(). Na koniec obowiązkowo callback.run(), co powiadomi Springa, że proces stop wykonał się prawidłowo.

Tak naprawdę, powinniśmy także zaczekać, aż obsłużymy bieżące eventy. Analogicznie jak czekamy na bieżące requesty. To jednak wykracza poza ramy tego przykładu.

Inny, popularny przykład czynności do wykonania na tym etapie, to zatrzymanie schedulerów. Robimy to by zapobiec uruchamianiu nowych zadań, zaplanowanych zgodnie z harmonogram.
 

Złożony proces oczekiwania

Jeżeli zachodzi potrzeba oczekiwania, aż jakieś procesy naszej aplikacji dobiegną końca, musimy sporządzić nieco bardziej skomplikowany kod. Tu również wepniemy się w fazę lifecycle o domyślnym numerze:

@Override
public void stop(Runnable callback) {
    this.running = false;
    LOGGER.info("Starting graceful shutdown...");


    new Thread(() -> {

        while (webServerStartStopLifecycle.isRunning() && areAllTasksFinished()) {

            Thread.sleep(100);

        }

        LOGGER.info("Graceful shutdown complete");

        callback.run();

    }).start();
}


W powyższym rozwiązaniu obserwujemy bean WebServerStartStopLifecycle, który również implementuje SmartLifecycle, ale o niższym numerze fazy. Bean o niższym numerze fazy zostaje zatrzymany później, więc isRunning będzie trwało przez całą naszą fazę, aż do rozpoczęcia fazy kolejnej. Możemy w tym celu wykorzystać inny bean, np. nasz własny o nazwie ApplicationState. Ten trik pozwala nam czekać dokładnie tak długo, jak Spring czeka na każdą z faz. Tak więc czekanie na zakończenie naszych tasków odbywa się równolegle z czekaniem na bieżące żądania http. Spring przejdzie do kolejnej fazy po zakończeniu ostatniego z tych zadań, obojętne z której puli.

Jeżeli nie wszystkie zadania zostaną zakończone w wyznaczonym czasie, i tak nadejdzie kolejna faza i aplikacja się zamknie. Stanie się tak dlatego, że nasze oczekiwanie odbywa się w oddzielnych wątkach, nie blokując procesu smart lifecycle.
 

Hazelcast

Hazelcast to przykład komponentu, który rejestruje własny shutdown hook w JVM. Nie współpracuje to dobrze ze Springiem, ponieważ shutdown hooki odpalane są na samym początku springowego procesu. Jeżeli pozwolimy działać hookowi z hazelcasta, klaster zostanie zamknięty od razu, nie będzie czekał na dokończenie aktywnych procesów.

Właściwa droga to zablokowanie hooka i dopisanie kodu zamykającego klaster Hazelcasta w ramach fazy destroy beans.

config.setProperty("hazelcast.shutdownhook.enabled", "false");

@PreDestroy
public void shutdownHook() {
    if (hazelcastInstance != null) {
        hazelcastInstance.shutdown();
    }
}
 

Task executor

W wersji Springa 2.4.2 ThreadPoolTaskExecutor nie współpracuje prawidłowo z procesem graceful shutdown. Jest w nim możliwość dokonania następującej konfiguracji:

threadPoolTaskExecutor.setWaitForTasksToCompleteOnShutdown(true);
threadPoolTaskExecutor.setAwaitTerminationSeconds(20);

Niestety konfiguracja ta nie działa na etapie lifecycle, lecz na etapie destroy beans. To za późno. Na tym etapie beany są usuwane w trudnej do ustalenia kolejności, więc możliwa jest sytuacja, w której akcja task executora próbuje skorzystać z komponentu (np. hazelcast), który już został zatrzymany. Skutkowałoby to błędami wykonania.

Dobrze jest więc nie ustawiać przytoczonej wyżej konfiguracji, a zamiast tego dopisać odpowiednie beany obsługujące lifecycle, które pogodzą task executor z procesem graceful shutdown. Schemat tego działania opisany jest w rozdziale Złożony proces oczekiwania.

 

Konkretny przykład kodu

Opisane wcześniej podejście wpięcia się w proces graceful shutdown wymaga utworzenia przynajmniej 2 beanów i zapewnienia synchronizacji między nimi. Na przykład przez CountDownLatch, jak w klasie WebServerStartStopLifecycle. Po przemyśleniu tego mechanizmu można jednak dojść do wniosku, że pewne rzeczy da się uprościć.

Synchronizacja wątków miała na celu eleganckie obsłużenie sytuacji, w której czekaliśmy na zakończenie bieżących procesów, jednak procesy nie dobiegły końca. Wtedy wątek oczekujący również przerywał swoje działanie, bo nie było już na co czekać. A co by się stało, gdyby wątek oczekujący dalej czekał? Niewiele. Po prostu wątek zostanie zakończony razem z całą aplikacją. Jedyne co tracimy to komunikat o tym, że zadania nie zostały ukończone w wymaganym czasie. Jeżeli godzimy się z tą stratą, można nasz kod znacznie uprościć.

Zobaczmy klasę pomocniczą, która pozwala wpiąć się w proces graceful shutdown w ten uproszczony sposób:

/*
This class allows to quickly add a shutdownTask to the moment where graceful shutdown begins.
This is the moment, where web server stops accepting requests, but all beans are still active.
 */
public abstract class GracefulShutdownSmartLifecycle implements SmartLifecycle {
    private volatile boolean running;

    public abstract void shutdownTask();

    @Override
    public void start() {
        this.running = true;
    }

    @Override
    public void stop() {
        throw new UnsupportedOperationException("Stop must not be invoked directly");
    }

    @Override
    public void stop(Runnable callback) {
        this.running = false;
        new Thread(() -> {
            shutdownTask();
            callback.run();
        }, "cp-gracefulsh-" + this.getClass().getSimpleName()).start();
    }

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



Mając powyższą klasę abstrakcyjną jesteśmy w stanie wpiąć nasz kod w bardzo prosty sposób:

@Bean
public GracefulShutdownSmartLifecycle threadPoolGracefulShutdown() {
    return new GracefulShutdownSmartLifecycle() {
        @Override
        public void shutdownTask() {
            LOGGER.info("Waiting for executor tasks to finish.");
            getAsyncExecutor().waitForTasksToFinish();
            LOGGER.info("Waiting for scheduler tasks to finish.");
            schedulingExecutor().waitForTasksToFinish();
            LOGGER.info("All tasks are finished.");
        }
    };
}



Powyższa metoda, należąca do klasy konfiguracyjnej, tworzy nowy bean oparty na naszej klasie pomocniczej. Implementujemy w nim tylko jedną metodę, shutdownTask. Kod tej metody wykonany zostanie po rozpoczęciu fazy graceful shutdown, czyli równo z wyłączeniem ruchu przychodzącego do web serwera.

W tym przypadku czekamy na zakończenie zadań asynchronicznych. Od specyfiki aplikacji zależy, czy taki kod jest potrzebny. Zwykle zadania asynchroniczne wykonywane są w ramach obsługi konkretnego api, więc nie potrzeba się o nie troszczyć. W niektórych aplikacjach zadania asynchroniczne mogą być jednak związane z bardziej skomplikowanymi procesami. Na przykład przy api asynchronicznych, kiedy wynik działania zapisywany jest we współdzielonym cache’u. Wtedy klient api odpytuje o wynik oddzielnymi zapytaniami i zaprezentowany kod powinien być obecny.
 

Dodatkowe czynności

Kiedy uporamy się już z tym, żeby aplikacja springowa zamykała się we wdzięczny i czysty sposób, pozostaje zsynchronizować ten proces ze środowiskiem, w którym działa nasza aplikacja.
 

Docker

Na dockerze nasz kontener otrzymuje sygnał SIG_TERM, który Java rozumie jako rozpoczęcie procesu shutdown. Musimy jednak zapewnić, żeby ten sygnał dotarł do naszego procesu. Zobaczmy przykładowy entry point dockera:

#!/bin/bash

exec java –jar application.jar


To jest kod, który zadziała prawidłowo. Polecenie exec powoduje, że proces, który przetwarzał nasz skrypt bashowy – zostanie podmieniony na proces java. Dzięki temu proces java będzie adresatem sygnału płynącego z kontenera.

Gdybyśmy słowo exec pominęli, zostanie dla Javy utworzony nowy proces, który będzie miał inny identyfikator (pid) niż główny proces dockera. Sygnał SIG_TERM otrzyma nasz skrypt basha, który w żaden sposób go nie zinterpretuje. Nasza aplikacja, nieświadoma tego, że ma zakończyć działanie, będzie chodzić do końca działania kontenera, który po upływie 30 sekund zostanie gwałtownie zamknięty.

Wartość 30 sekund należy jeszcze skonfigurować w naszym środowisku dockerowym, tak żeby była minimalnie dłuższa od czasu, który dajemy springowi na graceful shutdown.
 

Windows

Na środowisku Windows Java działa jako usługa utworzona przez aplikację typu wrapper. Wrapper dba o szczegóły systemu operacyjnego, takie jak instalowanie, uruchamianie i zatrzymywanie usługi. Tak więc jeżeli system operacyjny otrzyma żądanie zatrzymania usługi, wrapper przekazuje to żądanie do Javy i proces zamykania przebiega prawidłowo. Pozostaje tylko skonfigurować we wrapperze czas, po jakim usługa ma zostać zamknięta bezwarunkowo. Na wypadek, gdyby grzeczny proces nie zmieścił się w tym czasie. Czas ten powinien być o kilkanaście sekund dłuższy niż czas zdefiniowany w Springu.
 

Podsumowanie

Mechanizm graceful shutdown pozwala uniknąć błędów wykonania w końcowej fazie działania aplikacji, kiedy jest zamykana. Spring boot posiada mechanizm graceful shutdown, który łatwo jest włączyć przez property. Jest to więc pierwsza czynność, która należy wykonać.

W przypadku bardziej skomplikowanym, warto zaznajomić się z klasą SmartLifecycle i z rolą jaką pełni ona w procesie zamykania aplikacji. Dzięki temu możemy prawidłowo oprogramować komponenty, dla których wymagany jest szczególny sposób zamykania.

W Springu wykorzystane zostały fazy lifecycle o numerach max i max-1. Z naszego punktu widzenia nie jest to dobre. Nie mamy możliwości wstrzelić się ani przed pierwszą fazę, ani pomiędzy nie. Użycie innych liczb dawałoby więcej możliwości. Z drugiej zaś strony, na ten moment nie widać wyraźnej potrzeby, by się tak wstrzeliwać.

Zwrócić trzeba uwagę na inne komponenty zewnętrzne, z których składa się aplikacja i przeanalizować, czy potrzebne jest dla nich dedykowane podejście.
Proces zamykania synchronizujemy ze środowiskiem uruchomieniowym naszej aplikacji, np. docker i testujemy.
 
Wolisz obejrzeć wideo?

W maju miałem przyjemność uczestniczyć w webinarze organizowanym przez ING we współpracy ze Stacją IT pt.: Graceful shutdown w Springu - czyli prawidłowe zamykanie aplikacji.  Zapraszam do obejrzenia nagrania z mojego wystąpienia. Miłego oglądania!

Zachęcam również do pobrania dodatkowych materiałów: