Spring Framework のアプリケーションを停止したときの振る舞い

Spring Framework で実装したアプリケーションを停止するときに何が起きているのか整理しました。

具体的な停止方法については Shutdown a Spring Boot Application(Baeldung) にまとまっているので参照してください。

要点

  • Spring Framework のアプリケーションを安全に停止する方法が知りたい
  • Spring Framework のアプリケーションに停止処理を追加する方法が知りたい
    • ContextClosedEvent に対するイベントリスナーを実装するか、@PreDestroy などで修飾したメソッドに記述する
  • コンテナ実行基盤で実行している Spring Framework のアプリケーションに停止処理を追加したい
    • 停止処理を実行できる時間には制限があるので気を付けましょう

停止のきっかけと振る舞い

フレームワークと関係ない Java HotSpot VM の話です。

Java HotSpot VM が停止するきっかけには内部要因と外部要因があります。

  • 内部要因
    • daemonize していない全てのスレッドが終了した場合
      • 例 - main スレッドと daemonize した複数のスレッドが存在するプログラムで main スレッドが終了した場合
    • System.exit() を呼び出した場合
  • 外部要因
    • OS からシグナルを受信した場合

どちらの場合でも、 Runtime#addShutdownHook で登録されて待機状態になっていたスレッド(シャットダウンフック)が起動するようになっています。 (参考 - Runtime (Java SE 11 & JDK 11))

Spring Framework の登録するシャットダウンフック

ConfigurableApplicationContext を実装した抽象基底クラスの AbstractApplicationContex がシャットダウンフックを登録します。

https://github.com/spring-projects/spring-framework/blob/master/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java#L990

    /**
     * Register a shutdown hook {@linkplain Thread#getName() named}
     * {@code SpringContextShutdownHook} with the JVM runtime, closing this
     * context on JVM shutdown unless it has already been closed at that time.
     * <p>Delegates to {@code doClose()} for the actual closing procedure.
     * @see Runtime#addShutdownHook
     * @see ConfigurableApplicationContext#SHUTDOWN_HOOK_THREAD_NAME
     * @see #close()
     * @see #doClose()
     */
    @Override
    public void registerShutdownHook() {
        if (this.shutdownHook == null) {
            // No shutdown hook registered yet.
            this.shutdownHook = new Thread(SHUTDOWN_HOOK_THREAD_NAME) {
                @Override
                public void run() {
                    synchronized (startupShutdownMonitor) {
                        doClose();
                    }
                }
            };
            Runtime.getRuntime().addShutdownHook(this.shutdownHook);
        }
    }

doClose メソッドはアプリケーションコンテキストイベントの ContextClosedEvent を通知します。 (つまり、アプリケーションコードに何かしら終了処理を追加したいなら ContextClosedEvent に対するイベントリスナーを実装すればよい)

ライフサイクル管理イベントも通知する (onClose) ので、@PreDestroy などで修飾したメソッドも呼び出されるはずです。

https://github.com/spring-projects/spring-framework/blob/master/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java#L1050

    protected void doClose() {
        // Check whether an actual close attempt is necessary...
        if (this.active.get() && this.closed.compareAndSet(false, true)) {
            if (logger.isDebugEnabled()) {
                logger.debug("Closing " + this);
            }

            if (!IN_NATIVE_IMAGE) {
                LiveBeansView.unregisterApplicationContext(this);
            }

            try {
                // Publish shutdown event.
                publishEvent(new ContextClosedEvent(this));
            }
            catch (Throwable ex) {
                logger.warn("Exception thrown from ApplicationListener handling ContextClosedEvent", ex);
            }

            // Stop all Lifecycle beans, to avoid delays during individual destruction.
            if (this.lifecycleProcessor != null) {
                try {
                    this.lifecycleProcessor.onClose();
                }
                catch (Throwable ex) {
                    logger.warn("Exception thrown from LifecycleProcessor on context close", ex);
                }
            }

            // Destroy all cached singletons in the context's BeanFactory.
            destroyBeans();

            // Close the state of this context itself.
            closeBeanFactory();

            // Let subclasses do some final clean-up if they wish...
            onClose();

            // Reset local application listeners to pre-refresh state.
            if (this.earlyApplicationListeners != null) {
                this.applicationListeners.clear();
                this.applicationListeners.addAll(this.earlyApplicationListeners);
            }

            // Switch to inactive.
            this.active.set(false);
        }
    }

コンテナ実行基盤ごとの停止処理と猶予時間

ここでは Docker コンテナを実行できる環境のことを コンテナ実行基 と呼んでいます。

停止操作を行うと、コンテナのメインプロセス(PID が 1 のプロセス) に TERM シグナル (SIGTERM) を通知します。 その後、所定の猶予時間を経過すると KILL シグナル (SIGKILL) を通知します。

KILL シグナルはプログラムで処理できないシグナルで、対象のプロセスは OS により停止されます。 (参考 - signal(7) - Linux manual page)

つまり、アプリケーションで終了処理を実行できるのは TERM シグナルを受信してから KILL シグナルを受信するまでの間だけです。

コンテナ実行基盤ごとに猶予時間の初期値を整理してみました。

種類 猶予時間 変更できるかどうか
Docker Daemon 10 秒 コマンド引数で指定できる [1]
Kubernetes 30 秒 マニフェストで設定できる [2]
Amazon ECS 30 秒 コンテナエージェントの環境変数で設定できる [3] [4]

今のところ設定できる猶予時間に上限値は設けられていないようです。 Too big terminationGracePeriodSeconds value leads to pod hanging forever in Terminating state #84298

あまり長い時間を設定するとコンテナの特徴である軽量な停止と開始を阻害するだけなので、仕組み自体を検討したほうがいいでしょう。