def yasuharu519(self):

日々の妄想

Docker stop 時に別のコマンドを実行して Graceful shutdown を実現する

FOLIO Advent Calendar 2018 16 日目の記事です。 昨日は FOLIO Androidチームを初期から支えるふじたくの FOLIOに転職してからAndroidアプリリリースまでの出来事(日記) でした。 前職でも同期のふじたくが贈る、開発初期から Google Play ベストオブ2018までのFOLIO Android アプリの軌跡についての記事でした。エモい。

本日は普段、 株式会社FOLIO にて SRE チームの一員として働いている私の、技術的な Tips 紹介記事です。

現在 FOLIO では、現在稼働しているアプリケーションをコンテナ化する動きを進めています。 今回はアプリケーションのコンテナ化にあたって、必要だった対応について紹介します。

FOLIO のバックエンドアプリケーションについて

FOLIO ではバックエンドアプリケーションについてはほぼ Scala で作成されています。 ただ使用しているフレームワークでは、フレームワーク自体に Graceful shutdown 用のシグナルハンドラが用意されておらず、別途 Graceful shutdown 用のコマンドを実行する必要がありました。

現状はこのサーバアプリケーションについては Systemd を利用して動かしており、その場合は

ExecStop=<stop 用のコマンド>

として、サービス終了時に別のコマンドを実行するようになっていました。 ただ、 Docker コンテナ化した場合、上記の処理をどう実現するかについて考える必要がありました。

案1. アプリケーションにシグナルハンドラを実装する

素直にシグナルハンドラを Graceful shutdown 用にアプリケーション側に実装するアプローチをまず考えました。 ただし、この方法では現状既に存在している Graceful shutdown とは別に実装する必要があり、 その場合その実装についてもテストをする必要がありそうです。

案2. Docker コンテナ起動用の entrypoint に共通処理を入れる

Dockerコンテナ起動用の entrypoint シェルスクリプトにシグナルハンドラを用意し、そちらにシグナル発生時のコマンドを設定しておくことで、既存の Graceful shutdown の機構もそのまま使用できそうです。 またこの方法の場合、古いバージョンのアプリケーションについても再ビルドが必要なく、entrypoint シェルスクリプトを用意するだけで古いバージョンのものについてもそのままコンテナ化できそうと考えました。

今回は、既存の Graceful shutdown をなるべく使用できそうな案2の方法で検討しました。

signal trap を設定した entrypoint の作成

シグナルハンドリングの方法として、 shell の trap コマンドを使用する方法があります。 trap コマンドはビルトインのコマンドで、補足するシグナルと、シグナルが来たときのコマンドを設定することができます。 そのため、Docker の ENTRYPOINT として設定されている shell にて trap を設定することで、 docker stop 時に特定のコマンドを実行できるのでは、と考えました。

一般的な Docker コンテナイメージの設定はどうなっているか

Docker コンテナイメージを作成する際の Dockerfile の設定としては、 entrypoint.sh などのシェルを Entrypoint として設定し、最後に exec コマンドで実行したいコマンドを実行する形が一般的です。 exec コマンドは、現在のプロセスの PID か環境変数等を引き継いで、別のプロセスに入れ替えを行うといったものです。

例えば、Node.js を使った簡単な例だと

const http = require('http');
http.createServer((req, res) => {
  res.end('Hello World\n');
}).listen(3000);

main.js として準備し、

#!/bin/sh

exec "$@"

entrypoint.sh

FROM node

WORKDIR /app

ADD entrypoint.sh /app/entrypoint.sh
ADD main.js /app/main.js

ENTRYPOINT ["/app/entrypoint.sh"]

Dockerfile として準備します。

$ docker build -t node-test .
$ docker run --init --rm -p 3000:3000 --name node-test node-test node main.js

こうすることで Shell などの余計なプロセスなしで、Node.js のプロセスのみを起動させることができます。

Scala アプリケーションの場合

Scala のパッケージングには、sbt-native-packagerプラグインを使用しています。 こちらでパッケージングした Docker コンテナイメージでも、アプリケーション実行時に exec で Java プロセスが立ち上がるようになっています。

ただし、そうすると最終的に起動するのは Java プロセスのみで、Shell でのシグナルトラップができませんでした。 そのため、

dev.to

でも紹介されているように、 sbt-native-packager で設定される entrypoint.sh を少し書き換え、シグナルハンドラの設定をいれました。

#!/bin/bash

# A wrapper around /entrypoint.sh to trap the SIGTERM signal (Ctrl+D)
sigterm_handler() {
  echo "Stopping service with SIGTERM pid $1"
  command_to_stop_application
  while kill -0 $1 > /dev/null 2>&1; do
    echo "Waiting for pid $1 to exit..."
    sleep 1
  done
}

asyncRun() {
    "$@" &
    pid="$!"
    trap "sigterm_handler $pid" SIGTERM

    while kill -0 $pid > /dev/null 2>&1; do
        wait
    done
}

asyncRun $@

上記は実際に使っているコードとは多少異なりますが、上記ようなスクリプトを使って起動するように設定しました。

流れとしては、

  1. entrypoint.sh が実行される
  2. Docker の Command で指定したコマンドをバックグラウンドで実行。 pid=$! でPIDを取得。
  3. SIGTERM に取得した PID のアプリケーションを安全に停止するハンドラを設定
  4. 子プロセスの終了を wait で待つ

というような流れで作成しています。👍

まとめ

Docker stop 時に Graceful shutdown を実現するために、別のコマンドを実行するためのテクニックについて紹介しました。 サーバフレームワークを使用している場合、シグナルハンドラが既に実装されている場合も多いため、上記のテクニックを使用する場面は少ないかもしれませんが、docker stop 時に別のコマンドを実行するようにしたい場合などはぜひ参考にしてみてください!

明日はFOLIOのバックエンド、まっちゃらさん の Protocol-Aware Recovery for Consensus-Based Storage という記事です。お楽しみに!