AWS

ECS で動作させるPythonアプリケーションのGraceful Shutdownを行う

mackey

こんにちは、エンジニアのmackeyです。今回は、ECSのコンテナ上で動作させているPythonアプリケーションのタスク終了時にGraceful Shutdown(正常終了)を行うための方法を具体的なコードの例も併せて紹介したいと思います。

背景

業務でECSサービスを常時起動させてループ処理を行うアプリケーションを実装する機会がありました。このような場合、タスクの終了時にループ処理の途中で中断させずに正常に終了させることが重要となります。

ECSにおけるタスクの終了は、以下のような場合があります。

  • コード・設定の変更によるデプロイのため、既存のタスクを終了して再度起動する
  • EC2スポットインスタンス・Fargate Spotを使用している場合、タスクが中断される可能性がある

特に今回の場合はEC2スポットインスタンスを使用することを想定していたので、予期しない中断に正しく対処できることはより重要となります。

ECSタスクの終了を検知するには

それでは、どのようにしてECSタスクの終了を検知すればよいでしょうか。こちらの記事(外部リンク)が詳しいので、詳細に関してはそちらをご覧いただくとして、以下に関連する部分を引用します。

タスクが停止すると、各コンテナのエントリプロセス (通常は PID 1) に SIGTERM シグナルを送信します。タイムアウトが経過すると、今度は SIGKILL シグナルをプロセスに送信します。デフォルトでは、SIGTERM シグナルの送信後 30 秒のタイムアウトで SIGKILL シグナルを送信します。この値は、ECS タスクのパラメータの stopTimeout を更新することによってタスクのコンテナ単位で調整するか、ECS エージェントの環境変数 ECS_CONTAINER_STOP_TIMEOUT を設定して EC2 コンテナインスタンス単位で調整できます。この最初の SIGTERM シグナルを適切に処理して、正常にコンテナのプロセスを終了させる必要があります。SIGTERM シグナルを処理することを意識しておらずタイムアウトまでに終了しなかったプロセスは、SIGKILL シグナルが送信され、コンテナが強制的に停止されます。

https://aws.amazon.com/jp/blogs/news/graceful-shutdowns-with-ecs/

つまり、ECSタスクが終了する際に送信されるSIGTERMシグナルを検知して、適切な処理を行う必要があります。また、設定できるタイムアウトの時間は最大でも120秒のため、この時間以内に処理を終える必要があります。

要件とコード例

今回は、ECSタスクのGraceful Shutdownを行うにあたって、以下の要件を満たすようなコードを作成することにします。

  • 常時ループ処理を行なっており、一回のループにかかる時間は長いと5分以上かかる(つまり、タイムアウトの最大時間120秒に収まらない可能性がある)
  • SIGTERMが送信された後、一定の時間内にループ処理の一周が最後まで終わった場合は、その時点で終了する
  • 上記が終わらなかった場合は、何らかの後処理をして強制的に終了させる

Pythonバージョン : 3.12.2

import signal
import sys
from threading import Event, Thread
import time


WAIT_SECONDS_AFTER_SIGTERM = 100  # SIGTERMを受け取ってから強制終了するまでの待機時間

sigterm_event = Event()
exit_event = Event()
can_gracefully_exit = True


def handle_sigterm(signum, frame):
    print("SIGTERM received. Preparing to shut down...")
    sigterm_event.set()
    if not exit_event.wait(WAIT_SECONDS_AFTER_SIGTERM):
        print(f"{WAIT_SECONDS_AFTER_SIGTERM} seconds have passed. Force exit...")

        # ここに強制終了前に必要な後処理を書く

        global can_gracefully_exit
        can_gracefully_exit = False
        exit_event.set()


if __name__ == "__main__":
    signal.signal(signal.SIGINT, handle_sigterm)
    signal.signal(signal.SIGTERM, handle_sigterm)

    def service():
        while not sigterm_event.is_set():
            # 一回に時間がかかる処理
            print("loop start")
            time.sleep(5)
            print("loop finish")
        exit_event.set()

    service_thread = Thread(target=service)
    service_thread.daemon = True  # メインスレッドの終了時にservice_threadも終了する
    service_thread.start()

    exit_event.wait()

    if can_gracefully_exit:
        print("gracefully exit")
        sys.exit(0)
    else:
        print("force exit")
        sys.exit(1)

コードの解説

  • 29行目:SIGTERMシグナルに対する処理を登録しています。第二引数にセットされた関数が、第一引数のシグナルを受け取った時に実行されます。(今回はテストしやすいように、SIGINTシグナルに対する処理も登録しています)
  • 39行目など:メインのループ処理と、SIGTERMを受け取ってから一定時間待機する処理を同時に行うために、threading.Threadを使用して別スレッドで処理を実行しています。そして、スレッド間の通信のために、threading.Eventを使用しています。
  • 17行目:threading.Eventにはwait()というメソッドがあります。これは、別スレッドからset()が実行されるまで待機するというものです。また、wait()の引数にはタイムアウト時間を設定することができます。時間内にset()が実行された場合は、Trueが返され、タイムアウトした場合はFalseが返されます。これによって、タイムアウトした場合のみ後処理を実行して強制終了(17~24行目)するようにしています。

ローカル環境での実行結果

※ タイムアウト内に終了しない場合を再現するため、WAIT_SECONDS_AFTER_SIGTERMの値を2秒に変更してテストしました。

タイムアウトの時間までに正常終了する場合

処理開始後から約4秒後にSIGTERM(この例ではSIGINT)を送信
→WAIT_SECONDS_AFTER_SIGTERMの2秒以内に処理が終わるので、gracefully exitとなる

$ python main.py
loop start
^CSIGTERM received. Preparing to shut down...
loop finish
gracefully exit

タイムアウトの時間までに処理が終わらず強制終了する場合

処理開始後すぐにSIGTERM(この例ではSIGINT)を送信
→WAIT_SECONDS_AFTER_SIGTERMの2秒以内に処理が終わらないので、force exitとなる

$ python main.py
loop start
^CSIGTERM received. Preparing to shut down...
2 seconds have passed. Force exit...
force exit

おわりに

Pythonでの単純なSIGTERMのハンドリングはネット上に色々例があったものの、SIGTERMを受け取ってから一定時間は待機させるような例は筆者がざっと調べた限りでは見つからなかったので、記事にしてみました。お役に立てば幸いです。

AUTHOR
mackey
mackey
記事URLをコピーしました