Amazon EKS上でアプリケーションをGraceful Shutdownさせる際に注意すべきポイント

SRE課で、主にBacklogのSREを担当しているMuziです。

物理サーバやインスタンスで動作していたアプリケーションを、Kubernetesクラスタに移行する際には、いままで暗黙的に存在していた前提に目を向ける必要があります。そのような前提を無視すると、アプリケーションは動作したとしても、可用性が悪化する可能性があるためです。

私たちがBacklogをEC2インスタンスからKubernetesクラスタに移行した際にも、可用性の悪化に繋がる問題に対処する必要が生じました。今回は、そのような問題の一つであるGraceful Shutdownに関する注意点を、私たちの実体験をもとにご紹介します。

なお、以下の内容はAmazon EKSのKubernetesバージョン1.22で確認しました。Amazon EKSに固有の話題も含みますが、Kubernetes全般に共通する部分も多いかと思いますので、ぜひご参考ください。

backlog-webのコンテナ化

Backlogでは、2019年からAmazon Elastic Kubernetes Service(Amazon EKS)を利用し始めました。2019年以降にリリースされたBacklogの新機能であるGit LFSやボードは、最初からAmazon EKS上で動作するよう実装されています。

しかし、Backlogを構成するサービスの中で、HTTPリクエストの約9割を処理する、モノリシックなWebアプリケーションサーバ(以下、backlog-web)は依然としてEC2インスタンス上で動作していました。私たちは、2021年4月からこのbacklog-webをコンテナ化するプロジェクトを開始しました。

このコンテナ化プロジェクトで私たちが遭遇した問題とその解決方法については、今年5月のSRE NEXT 2022にて、「長年運用されてきたモノリシックアプリケーションをコンテナ化しようとするとどんな問題に遭遇するか?」というタイトルで発表しました。このようなプロジェクトに興味のある方は、ぜひ以下の発表スライドや動画をご覧ください。

この発表時点ではまだEC2インスタンスからコンテナへと切り替えている最中だったのですが、2022年7月にコンテナへの切り替えおよびEC2インスタンスの削除が無事完了しました。

今後は、このコンテナ化プロジェクトで得られた知見をいろいろな形で発表していきたいと考えています。このブログ記事もその一つです。

Podの終了の基本

まず最初に、Kubernetesの基本的な知識として、Podの終了時の動作について説明します。Podの終了は、以下のようなユーザー操作や、コントローラーの動作を契機に実行されます。

  • ユーザーがkubectl delete podを実行して、特定のPodを削除した
  • ユーザーがkubectl scaleを実行して、Podのレプリカ数を減らした
  • ユーザーがDeploymentを更新して、ローリングアップデートを実行した
  • Cluster Autoscalerが、ノードのスケールダウンのために、削除対象のノード上にあるPodを削除した

Podの終了時には、kubeletが、コンテナランタイムを通して、Pod上の各コンテナ内のプロセス番号1にSIGTERMを送信します。それと同時に、終了中のPodをServiceから切り離します。また、そのコンテナにPreStopフックが登録されている場合は、最初にそのPreStopフックが実行され、PreStopの終了後にSIGTERMが送信されます。

Podの終了の基本Podの終了の基本

Podの終了処理にはタイムアウト時間があり、terminationGracePeriodSecondsで変更できます(デフォルト30秒)。terminationGracePeriodSecondsが経過してもコンテナ内のプロセスが終了しない場合、kubeletは、コンテナランタイムを通して、Pod内で実行中のプロセスに対してSIGKILLを送信します。

そのため、Kubernetesの利用者である私たちは、SIGTERMを受信したときにGraceful Shutdown処理を行うように、アプリケーションを実装する必要があります。また、このGraceful Shutdownに30秒以上かかる可能性があるなら、terminationGracePeriodSecondsを30秒より長い値に設定する必要があります。

Podの終了に関する詳しい説明については、公式ドキュメントのPodのライフサイクルの「Podの終了」の節や、コンテナライフサイクルフックをご参照ください。

backlog-webのシステム構成

これ以降の話の前提として、backlog-webのコンテナ化前後のシステム構成を簡単にご紹介します。

注目すべき変化としては、運用のために収集するアプリケーションログのためのFluentdと、Nulab Passの監査ログサービスのために収集する監査ログ(以下、監査ログ)のためのFluentdが、backlog-webアプリケーションとは別のコンテナで動作するように変わっています。その影響で、Graceful Shutdownを考える際に注意すべき点が増えました。

コンテナ化の前後で共通

  • BacklogのサービスクラスタはWebサーバ、Webアプリケーションサーバ、データベースの3層構成
  • backlog-webアプリケーションはPlay Framework上に実装されている
  • Webサーバ上でNGINXが動作する

Backlogの基本的なアーキテクチャBacklogの基本的なアーキテクチャ

コンテナ化する前

  • EC2インスタンス上で、以下の3つのプロセスが動作する
    • backlog-webアプリケーション
    • アプリケーションログを、監視用のOpenSearchクラスタに送信するためのTD Agent(Fluentd)
    • 監査ログを専用のS3バケットに送信するためのTD Agent Bit(Fluent Bit)
  • backlog-webアプリケーションの更新時は、Webサーバ上のnginx.conf内のupstreamからそのサーバを削除し、更新完了後に再びupstreamに追加する
  • nginx.confの更新作業はFabricを用いたPythonスクリプトで自動化されており、Jenkins上に実装されたデプロイジョブのなかでキックされる

コンテナ化する前のシステム構成コンテナ化する前のシステム構成

コンテナ化した後

  • backlog-web Pod上で、以下の2つのコンテナが動作する
    • backlog-webアプリケーションが動作するbacklog-webコンテナ
    • 監査ログを、Amazon Kinesis Data Firehoseに送信するためのFluentdが動作するfluentd-audit-logコンテナ(以下、サイドカーコンテナ)
  • backlog-webアプリケーションは、標準出力にアプリケーションログを出力する。DaemonSetで定義されたfluentd Pod上のFluentdは、/var/log/containers以下に出力されたアプリケーションログを、監視用のOpenSearchクラスタに送信する。このfluentd Podはノード1台につき、1台起動する
  • WebサーバとEKSクラスタの間に、backlog-web専用の内部ALB(Internal ALB)が新たに設置されている。この内部ALBは、AWS Load Balancer ControllerのTargetGroupBindingカスタムリソースで制御される。backlog-web PodがServiceから切り離されると、内部 ALBのターゲットからも自動的に外される(ターゲットタイプがipの場合の動作)
  • Cluster Autoscalerがノードの自動スケールアップ・スケールダウンを実行する

コンテナ化した後のシステム構成コンテナ化した後のシステム構成

上記のシステム構成の変更に加えて、いままでは永続的なローカルディスクに頼っていたbacklog-webアプリケーションのGraceful Shutdown処理(特にメール送信と監査ログ出力に関する部分)を、永続的なローカルディスクのないコンテナ上でも動作するように改修しました。この改修の詳細については、SRE NEXT 2022の発表にて紹介済みのため、この記事では割愛します。

余談ですが、コンテナ化する前はTD AgentとTD Agent Bitを併用していたところを、コンテナ化の後はFluentdに揃えました。Fluent Bitを採用するという選択肢もあったのですが、開発環境で検証したところ、Fluent Bitのkinesis_firehoseプラグインには不具合があり、頻繁にログを欠損することがわかりました。また、欠損の際にエラーが出力されず、欠損を検知できないこともわかりました。そのため、今回はFluentdおよびfluent-plugin-kinesisを採用しました。

backlog-webのコンテナ化によって起こった問題とその解決方法

私たちはコンテナ化プロジェクトのなかでbacklog-webアプリケーションのGraceful Shutdown処理を改修し、コンテナ上で問題なく動作するようにしました。しかし、このアプリケーション単体では問題がなくても、これをそのままPod上に配置して運用すると、可用性が悪化することに気づきました。

今回私たちが遭遇した問題は主に3点ありました。以下では、それぞれの内容と解決方法を詳しく説明します。

  • 問題1. 内部ALBからの切り離しと、Podの終了の間の順序
  • 問題2. backlog-webコンテナの終了と、サイドカーコンテナの終了の間の順序
  • 問題3. backlog-web Podの終了と、fluentd Pod(DaemonSet)の終了の間の順序

問題1. 内部ALBからの切り離しと、Podの終了の間の順序

コンテナ化する前は、Pythonスクリプトが「EC2インスタンスをNGINXのupstreamから外す」、「EC2インスタンス上で動作しているbacklog-webアプリケーションを更新する」という順序を保証していました。

しかし、コンテナ化した後は、backlog-web Podの終了開始と同時に、backlog-web PodがServiceから切り離されるようになりました。また、AWS Load Balancer ControllerがそのPodを内部ALBのターゲットから外すまでにはタイムラグがあり、開発環境で実験して確かめたところ、数秒〜数十秒かかることがわかりました。

その結果、backlog-web Podが内部ALBから切り離される前に、Podの終了処理が完了することが時々起こるようになりました。その場合、すでに終了したbacklog-web Podに送られたリクエストは失敗し、内部ALBが502 Bad Gatewayを返してしまいます。

解決方法

内部ALBからの切り離しに時間がかかるのであれば、backlog-webコンテナのPreStopフックで一定時間sleepすることで、この問題を解決できると考えました。

そこで、私たちは、backlog-webコンテナに対してリクエストを送り続けるスクリプトを動かした状態で、backlog-webの再起動(kubectl rollout restart)を実行するという実験を行いました。そして、backlog-webコンテナのPreStopフックの実行開始(”Killing” イベントの発生時刻)から、そのコンテナにリクエストが到達しなくなる(スクリプトがリクエスト失敗し始める)までの時間を測定しました。

実験の結果、PreStopフックの実行開始から、そのコンテナにリクエストが到達しなくなるまでの時間は15秒未満でした。そのため、PreStopフックで15秒のsleepを行うように設定しました。

しかし、本番環境にこの値を設定したところ、Podの終了時に、内部ALBがごくまれに502 Bad Gatewayを返すことがありました。そのため、sleepの時間を15秒から20秒に変更しました。この変更後は問題は発生しなくなりました。

          lifecycle:
            preStop:
              exec:
                command: ["sleep", "20s"]

なお、今回私たちの環境では20秒のsleep時間でうまく動作しましたが、どのような環境でも20秒にすればよいと保証しているわけではないことにご注意ください。この方法を採用される際は、必ずご自身の環境で、適切なsleep時間の長さを調査してください。

私たちの環境でも、今後20秒ではうまく動作しなくなった場合にそれをすぐ検知できるよう、内部ALBが5xx応答を返した件数を表すHTTPCode_ELB_5XX_Countメトリクスに対して、CloudWatchアラームを設定しました。なお、HTTPCode_ELB_502_Countメトリクスを使用しても問題ありません。

ALBのメトリクスには似た名前のものが多数あるのですが、HTTPCode_Target_5XX_Countメトリクスのほうは、ターゲット(この場合はbacklog-web Pod)が5xx応答を返した件数になるため、ご注意ください(参考:Application Load Balancer の CloudWatch メトリクス)。

問題2. backlog-webコンテナの終了と、サイドカーコンテナの終了の間の順序

コンテナ化する前は、backlog-webアプリケーションを更新する際も、監査ログを送信するためのTD Agent Bitのプロセスは動き続けていました。また、TD Agent Bitを更新する際は、そのサーバをNGINXのupstreamから外していました。そのため、両者を終了させる順序については考える必要がありませんでした。

しかし、コンテナ化した後は、backlog-webコンテナとサイドカーコンテナに対して、同時にSIGTERMが送信されるようになりました。backlog-webプロセスはGraceful Shutdown処理中に、未処理の監査ログをすべてファイルに出力するのですが、何も対策をしないと、監査ログのファイル出力が終わるより先に、サイドカーコンテナ上のFluentdが終了してしまう可能性があります。

サイドカーコンテナは、Podの管理を容易にするために、永続ボリューム(PersistentVolume、PV)を持たない構成にしました。そのため、何も対策をしないと、監査ログの一部が失われる可能性がありました。

解決方法

backlog-webコンテナが終了するまで、サイドカーコンテナのPreStopフックが終了しないようにすることができれば、この問題を解決できると考えました。

そのため、まずbacklog-webコンテナにrun.shという起動スクリプトを追加し、このなかでbacklog-webアプリケーションを起動するようにしました。この起動スクリプトは、Podの終了時に送信されるSIGTERMをキャッチし、backlog-webプロセスにSIGTERMを送信します。そしてbacklog-webプロセスの終了後に、/var/log/audit-logディレクトリにshutdownファイルを出力します。

backlog-webコンテナとサイドカーコンテナは、監査ログを受け渡しするために、同じemptyDirボリューム(/var/log/audit-log)をマウントしています。そのため、同じボリュームを使って、backlog-webコンテナの終了を伝えるファイル(shutdown)を受け渡しすることができました。

以下は、そのrun.shの内容です。説明のため、実際のものよりも簡略化しています。このファイルの作成にあたっては、Docker Container: Uncaught Kill Signalを参考にさせていただきました。

#!/bin/bash

# cf. https://dev.to/mdemblani/docker-container-uncaught-kill-signal-10l6
signal_handler() {
  "$@" &
  pid="$!"
  trap "echo 'Stopping PID $pid'; kill -SIGTERM $pid" SIGTERM

  # A signal emitted while waiting will make the wait command return code > 128
  # Let's wrap it in a loop that doesn't end before the process is indeed stopped
  while kill -0 $pid > /dev/null 2>&1; do
    wait
  done

  touch /var/log/audit-log/shutdown
}

rm -f /var/log/audit-log/shutdown

signal_handler /opt/backlog/bin/backlog-web

そして、サイドカーコンテナのPreStopフックにpre_stop.shというスクリプトを登録し、このスクリプトのなかでbacklog-webコンテナの終了を待つようにしました。

以下は、pre_stop.shの内容です。/var/log/audit-log/shutdownの有無を1秒間隔で監視しています。

#!/bin/sh

# Wait for creating /var/log/audit-log/shutdown
while [ ! -e /var/log/audit-log/shutdown ]
do
  sleep 1
done

/var/log/audit-log/shutdownにファイルが作られるとpre_stop.shが終了し、kubeletからFluentdプロセスに対してSIGTERMが送信されます。そして、FluentdのGraceful Shutdownが実行されます。

pre_stop.shには、timeoutコマンドを使って、Play FrameworkのCoordinated Shutdown機能に設定したタイムアウト時間と、PreStopフックでのsleep時間の和よりも長いタイムアウト時間を設定しました。これは、backlog-webのGraceful Shutdownが何らかの理由で止まってしまった場合でも、FluentdのGraceful Shutdownを実行し、その時刻までに出力された監査ログをAmazon Kinesis Data Firehoseに送信するためです。

問題1〜2の解決方法を実施したあとの、backlog-webコンテナとサイドカーコンテナの動作をまとめると、以下のようになります。

backlog-web PodのGraceful Shutdownの流れbacklog-web PodのGraceful Shutdownの流れ

問題3. backlog-web Podの終了と、fluentd Pod(DaemonSet)の終了の間の順序

コンテナ化する前は、backlog-webアプリケーションを更新する際も、アプリケーションログを送信するためのTD Agentのプロセスは動き続けていました。また、TD Agentを更新する際は、そのサーバをNGINXのupstreamから外していました。そのため、両者を終了させる順序については考える必要がありませんでした。

しかし、コンテナ化した後は、Cluster Autoscalerによってノードのスケールダウンが発生するようになりました。このスケールダウン時には、そのノード上のすべてのPod(backlog-web Podとfluentd Podを含む)に対して、同時にSIGTERMが送信されます。backlog-web Pod上のbacklog-webプロセスは、Graceful Shutdownのために数十秒の時間を要し、その間にもGraceful Shutdownに関する新しいアプリケーションログを出力します。しかし、fluentd Pod上のFluentdプロセスは、バッファ内のログをOpenSearchクラスタに送信するとすぐに終了してしまいます。

その結果、ノードのスケールダウン時にはGraceful Shutdownに関するアプリケーションログが監視用のOpenSearchクラスタに送信されず、Graceful Shutdownが正常に終了したかどうかをログから判断できないようになってしまいました。

ちなみに、これは開発環境では気づけなかった問題でした。Pod台数が多い本番クラスタで、アプリケーションのデプロイを頻繁に行うようになって、初めてこの問題に気づきました。Graceful ShutdownのログをKibanaから確認できなかったため、最初はGraceful Shutdownせずにbacklog-webプロセスがダウンするようになってしまったのかと考えて冷や汗をかきました……。

解決方法

この問題は、backlog-webアプリケーションに限らず、Graceful Shutdownに時間がかかるアプリケーション全般に共通する問題です。そのため、DaemonSetが配置するfluentd Podを、そのノード上で一番最後に終了させることができれば、この問題を解決できると考えました。

この記事執筆時点の最新版であるCluster Autoscaler v1.23.1から、“cluster-autoscaler.kubernetes.io/enable-ds-eviction”アノテーションが追加されました。このアノテーションの値を”false”に設定することで、Cluster AutoscalerはそのPodをノードのスケールダウン時に率先して退避(evict)させなくなります。

このアノテーションはDaemonSetに対してではなく、Podに設定する必要があります。以下はその例です。

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fluentd
  labels:
    app.kubernetes.io/name: fluentd
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: fluentd
  updateStrategy:
    type: RollingUpdate
  template:
    metadata:
      labels:
        app.kubernetes.io/name: fluentd
      annotations:
        "cluster-autoscaler.kubernetes.io/enable-ds-eviction": "false"
    spec:
      containers:
        - name: fluentd
(略)

開発環境で実験したところ、backlog-web Podが動作するノードがスケールダウンの対象になり、ノードが削除された場合であっても、backlog-webのGraceful Shutdownに関するログがすべてOpenSearchクラスタに送信されることが確認できました。

また、Cluster Autoscalerコントローラのログを確認したところ、スケールダウン時に削除対象のノード上で動作していたPodのうち、fluentd PodだけはPod削除のイベントが記録されていませんでした。

以上の結果から、”cluster-autoscaler.kubernetes.io/enable-ds-eviction”アノテーションにより、fluentd Podはノードが削除されるまで削除されなくなったことを確認できました。

Graceful Shutdownに関するその他の工夫

いままで、アプリケーションに問題が発生した場合は、開発者がEC2インスタンスにAWS Systems Manager (SSM) Agent経由で接続し、JVMが出力するGCログやヒープダンプの調査を行っていました。しかし、コンテナ化のタイミングで最小権限になるよう見直しを行い、一部のSRE以外はkubectl exec(コンテナへのシェルの取得)を実行できないようにしました。

しかし、そのままではSREが調査作業のボトルネックになってしまう可能性があります。そのため、backlog-webコンテナの起動スクリプト(run.sh)の最後に、GCログやヒープダンプが存在する場合は、それらを調査用のS3バケットにアップロードする処理を追加しました。

また、頻繁に問題が発生する場合には、任意のタイミングでヒープダンプを取得する必要があるかもしれません。そのため、上記のrun.shをさらに改善し、環境変数ENABLE_AUTO_HEAP_DUMPの値がtrueの場合は、内部ALBからの切り離し直後にヒープダンプを自動取得するようにしました。

まとめ

今回は、私たちがbacklog-webをコンテナ化してAmazon EKS上で動作させるにあたり、Graceful Shutdownまわりで注意した点についてご紹介しました。また、可用性が悪化していないことを確認するために行った実験や、CloudWatchで監視しているメトリクスについてもお伝えしました。

従来は手続き的に実装されていた運用ツールを、宣言的に設定できるKubernetesに置き換えたとしても、個々の処理の実行順序については引き続き考慮する必要があります。今回の具体的な事例情報が、これからコンテナ化を検討される方や、すでにKubernetesを利用されている方のご参考になれば幸いです。

開発メンバー募集中

より良いチームワークを生み出す

チームの創造力を高めるコラボレーションツール

製品をみる