freeeの開発情報ポータルサイト

Kubernetes の CronJob の急な停止に対応するための Tips

SRE の hatajoe です。

私が所属しているチームでは、これまで数多くのサービスのインフラ基盤を Amazon EKS へ移行してきました。
その過程で多くの乗り越えなければいけない課題に直面して来ましたが、今回は Kubernetes クラスタで安全に CronJob を扱うための Tips を紹介したいと思います。

CronJob

CronJob とは、定義したスケジュールに応じて Kubernetes クラスタで実行されるジョブリソースです。
Kubernetes のジョブリソースには Job と CronJob の2種類がありますが、今回は CronJob の話になります。
CronJob は定義したスケジュールに応じて Pod を生成します。そして、ジョブの実際の処理は Pod 内のコンテナで実行されます。

課題背景

ところで、Kubernetes クラスタで稼働中の Pod は、様々な理由で中断されます。
Pod が中断されるとまずコンテナごとの preStop hook 処理が呼ばれます。その後 Pod 内のコンテナに SIGTERM が送信されます。
最終的に、そのままコンテナが正常終了する、あるいは terminationGracePeriodSeconds 秒経過した場合コンテナは強制終了します。
これが Pod 中断時における正規のフローです。

Pod が中断されるタイミングは様々ですが、以下にいくつか一般的なケースを紹介します。

  • 何らかの理由で優先度の高い Pod が新規に配置出来ない場合、優先度の低い Pod が選択され中断されます
  • デプロイなどで manifest spec 更新のためロールアウトした場合、旧 Pod は中断されます(ただし実行中のジョブに影響はありません)
  • その他

また、以下のケースは Kubernetes の管理外で発生する Node 停止によって Pod が中断されるパターンです。

  • cluster-autoscaler によって Node がスケールインの対象に選択された場合
  • autoscaling group の Multi-AZ リバランシングによって Node が入れ替わる場合
  • その他

こちらのケースでは、前述した Pod を停止する際の正規フローを通らないので悩ましい問題となりますが、1つの対処法を後述します。
多くの場合、処理中のプロセスは所定の終了フローを実行した上で停止(graceful shutdown)させることが望ましいはずです。

戦略

課題背景にて説明した様々な中断ケースを前提にした上で、Pod を正常に終了させるためには以下の要件があることがわかります。

  • Node は Pod の終了を待ってから停止する必要がある
  • Pod はコンテナプロセスの終了を待ってから終了する必要がある
  • コンテナプロセスは SIGTERM をトラップし正常終了フローを実行する必要がある

これに加えて、CronJob はその特性から以下を追加で考慮する必要があります。

  • 同じ CronJob が同時に2つ以上実行されても問題が無いか?
  • 失敗した場合に再実行しても問題が無いか?

これら5つの要件をどのようにクリアするかを紹介します。

Node は Pod の終了を待ってから停止する

EKS において Node は autoscaling group に属する EC2インスタンスのことを指します。Node 終了のタイミングは前述した通りです。
いずれのケースにおいても Node は停止する際に autoscaling group lifecycle status の terminating という状態に遷移するので、これを監視し Pod の退避、退避完了待機、必要であれば Node 停止期間を延長するといったことが必要になります。
https://docs.aws.amazon.com/autoscaling/ec2/userguide/AutoScalingGroupLifecycle.html

freee ではこれを内製の daemonset をクラスタで稼働させることで実現しています。
処理の流れは大体こんな感じです。

  • autoscaling group を describe し、terminating 状態のインスタンスID を取得
  • もし自分が乗っているインスタンスなら drain を実行(Pod の退避)
  • drain が終わるまで定期的に record-lifecycle-action-heartbeat を実行(Node 停止期間を延長)

drain を実行すると対象 Node 上の Pod は正規の終了フローを通るため「Node は Pod の終了を待ってから停止する」という要件は達成です。
ただし、Node 停止期間の延長はオンデマンドインスタンスで最大48時間という制限がある点は注意です。
https://docs.aws.amazon.com/autoscaling/ec2/userguide/lifecycle-hooks.html

また、スポットインスタンスは停止まで2分のみの保証となっているためこちらも注意ポイントになります。

Pod はコンテナプロセスの終了を待ってから終了する

「Node は Pod の終了を待ってから停止する」という要件を達成しているので、基本的に Pod は以下の正規終了フローを通る前提です。

  • Pod の状態が terminating に遷移
  • preStop hook 処理の実行と同時に terminationGracePeriodSeconds のカウントダウン開始
  • preStop hook 処理の終了
  • Pod 内のコンテナプロセスに SIGTERM を送信
  • コンテナプロセスが終了コードを返すか terminationGracePeriodSeconds 経過したら Pod が終了

上記を前提に graceful に Pod を落とすには、terminationGracePeriodSeconds がコンテナプロセスの終了処理実行時間より長くなるよう設定する必要があることがわかります。

terminationGracePeriodSeconds の最大は、Node の停止待ち時間の最大が上限となります。
これは、前述したとおりオンデマンドインスタンスで48時間、スポットインスタンスで2分までとなります。その範囲で適切に設定します。
仮に、terminationGracePeriodSeconds に数時間を設定したとしても、コンテナプロセスが終了コードを返せば Pod はそこで終了します。

コンテナプロセスは SIGTERM をトラップし正常終了フローを実行する

ここまでで、Pod は終了時にコンテナプロセスにシグナルを送信し、終了コードを返すまで待ち続ける仕組みとなっています。
コンテナプロセスでは、以下を実現する必要があります。

  • SIGTERM をトラップし、適切な終了コードを返す
  • SIGTERM をトラップする前に SIGTERM が送られないようにする

SIGTERM を受信したら終了処理を行うという要件上、アプリケーション側にそのような機構が必要になります。

また、コンテナプロセスが SIGTERM をトラップする前に SIGTERM が送られてしまう懸念があります。
起動するまでに数分掛かるような巨大なコードベースを含むジョブなどが該当しますが、ジョブが起動してからシグナルハンドリングを実行しても遅い場合があります。
何かしらの手段をもって preStop hook でシグナルハンドリングしたかどうかを確認するという方法もありますが、freee では単に簡素な bash スクリプトでシグナルハンドリングを実装しジョブ起動コマンドをラップするという手法を採用しています。

ちょっとした Tips ですが、Dockerfile の STOPSIGNAL に SIGQUIT を指定すると便利です。
このようにすることで、コンテナプロセスは終了時に SIGQUIT を受け取るようになります。Nginx や Passenger、Unicorn そして Resque などは SIGQUIT を受け取ることで graceful mode に移行してくれます。


さて、ここまでで

  • Node は載せている Pod が終了するまで最大48時間は停止しない
  • Pod は terminationGracePeriodSeconds 以内に処理を終わらせれば graceful に落ちる

という状態となりました。
ここからは CronJob 特有の課題に対する戦略です。

同じ CronJob が同時に2つ以上実行されても問題が無いか?

いつ何度実行しても同じ結果が得られるというのが理想的なジョブの形ではあるものの、実現が難しいケースも否定できません。
仮に、同時に2つ以上実行されると問題があるという特性のジョブを CronJob として扱うにはどうすればよいでしょうか。

残念ながら、Kubernetes の CronJob ジョブの実行を1度に保証することは出来ないとドキュメントに明記されています。
https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/#cron-job-limitations

また、どのような設定を持ってしても Pod が terminate されると JobController が直ちに起動し直してしまいます。
Pod は中断されると terminating 状態に遷移し、graceful shutdown を施したコンテナプロセスを持つ Pod はこのまま待ちます。このとき、Pod内のコンテナプロセスは処理を継続中です。
一方、Pod が terminating に遷移したため JobController はもう1つ Pod を起動します。ここで同じ CronJob が2つ同時に実行されるというわけです。
CronJob には、ジョブの並行実行を制限する設定として concurrencyPolicy がありますが、これは定義されたスケジュールにおいて、前回実行したジョブがまだ実行中である場合の挙動を制御するものであり、Pod が terminate された場合においてはすぐさま新たに Pod が起動します。また、クラスタのバージョンアップ作業時など、同じ構成のクラスタが一時的に2つ以上存在するケースも想定されます。

つまり、Kubernetes の機能で CronJob の排他制御をすることは不可能です。
どうしても排他制御する必要がある場合は、ドキュメントにもあるとおりアプリケーション側でロック機構を実装するなどが必要となります。
https://kubernetes.io/docs/concepts/workloads/controllers/job/#handling-pod-and-container-failures

失敗した場合に再実行しても問題が無いか?

ジョブは terminating 状態になると不死鳥のごとく蘇るので、いかなる失敗時においても再実行させないというのは不可能です。
ただし、ジョブを正常に失敗させる(終了コードを返す)場合は、以下の設定をしておくことで再実行を防ぐことが可能です。

  • restartPolicy に Never を設定する
  • backoffLimit に 0 を設定する

restartPolicy を Never に設定しておくことで、Pod の状態が Error になった場合は再実行されません。
落とし穴として、restartPolicy が Never であっても backoffLimit の設定値だけ再実行されるという点です。backoffLimit は default で 6 となっているため注意が必要です。
https://kubernetes.io/docs/concepts/workloads/controllers/job/#pod-backoff-failure-policy

OOM など終了コードが返せないケースのエラーにおいて再実行したくないケースは、前述したロック機構が使えます。
突然死したプロセスにロックは解除できないため、新たなジョブが直ちに起動してきたとしても排他制御により正常にエラー終了させることが可能だからです。

さいごに

CronJob を安全に運用するための Tips を紹介しました。
この他にも、インフラ基盤を EKS へ移行する過程で悩んだことやワークアラウンドが色々とあるので、興味のある方は是非お話できたらなと思います。

jobs.freee.co.jp