はじめに
現在わたくしモリタロウ🌳は App Runner がAPIサーバーの役割を担うシングルページアプリケーションを開発しています。
このAPIサーバーにてなんでもかんでも処理を任せていたので、非同期OKな重たい処理は別のサーバー(ワーカーサーバー)に任せて、APIサーバーの他リクエストに影響が出ないような負荷分散に取り組んでみました。
現状の構成図
App Runner でのワーカーサーバー検討
APIサーバーが App Runner でできていたので、デプロイも楽だし一旦 App Runner でのワーカーサーバーを構築してみました。
APIサーバーからSQSにジョブを投げて、常時ポーリングを行うワーカーサーバーにて重たい処理を実行、という流れです。
構成図
メリット
- デプロイフローがAPIサーバーと同じでOK
- インフラ構成が簡素で理解しやすい
課題
この構成でもAPIサーバーの負荷を減らすことが実現できましたが、以下の課題があるなぁと。
- 常時起動が必要で、仕事が少ない時はリソースに無駄が発生
- 若干コストが高額
- 本来はWebサーバー用のサービスなので今回のケースでは用途が違う
- SQSメッセージ数によるスケーリング設定が柔軟にできない
ということでECSでのワーカーを検討してみました
ECS でのワーカーサーバー検討
APIサーバーの App Runner がECRに保管されているコンテナイメージをもとに立ち上がるので、このコンテナイメージを利用すれば簡単に構築できるのでは?という算段のもと構築。
SQSメッセージの数に応じて、ECSタスクをスケーリングさせるような設計思想。
構成図
メリット
- 基本0台ベースで、イベントが発生したときにコンテナが立ち上がるのでリソースに無駄がない
- コストもタスクが立ち上がってきた時だけしかかからない
- スケーリング設定が柔軟にできる
課題
- デプロイフローに手を入れる必要がある
- メッセージ発生からコンテナが起動して処理を開始するまでに時間がかかる
課題1について
デプロイフローに手を入れる必要がありましたが、それはCodeBuildでAWS CLIを利用してECSサービスを更新するようにしました。
aws ecs update-service --cluster $CLUSTER_NAME --service $SERVICE_NAME --force-new-deployment
課題2について
コンテナイメージが巨大で、全てをpullしてからコンテナを立ち上げるのに3分ほど時間がかかっていました。
これをSeekable OCIを利用することでコンテナ起動時間を1分ほどに短縮することができました。
Seekable OCIとは
ざっくり言うと、コンテナイメージにインデックスを作成することで、コンテナ全体をダウンロードしなくても必要な部分を効率的に取得して起動時間を短縮する技術。これによって、コンテナイメージの一部だけをpullしながらコンテナを起動することが可能になるとのこと。
CloudFormationでテンプレート設定があるので利用はとても簡単です。
※参照
まとめ
ECSを利用することでイベント駆動式の効率が良いワーカー環境を用意することができました。
ECSタスクが0台の時は、SQSにメッセージが発生してから処理が開始されるまで約2分30秒ほどかかります。
オートスケールのメトリクスが発火するまでの時間(約1分)
+ ECSタスクの起動(約1分30秒)
このラグが許される環境であれば、コスト効率良く、APIサーバーの負荷を減らすことが可能になるかと思います。
具体的なコードや設定値など質問あればご連絡ください!
オートスケーリングの様子
270個のSQSメッセージが発生したことを検知し、0→5台のタスクが立ち上がり、また0台に戻る様子✨
これを実現させたかった☺️
おまけ
ECSタスクのGraceful Shutdown調査
以下レビューコメントをもらい動作確認したところ、想定と違いジョブの完了を待たずに旧タスクが落ちることを確認。
デプロイ後のタスク切替時の処理中ジョブ
デプロイによってタスク(コンテナ)が旧→新に切り替わると思いますが、このとき旧側で処理中のジョブはそれがきちんと完了してから切り替わる(終了する)でしょうか?
- ヘルスチェック用のSQSジョブを処理時にsetTimeOutで30分待たせて処理をさせます。待機開始と待機終了のところにログを出力します
- これで待機している間にデプロイを行なって、旧インスタンスが30分待ちジョブを消化してから落ちることをログで確認してみます
SIGTERMの受信
ECSタスクが終了する時にSIGTERM という終了要求イベントをリッスンできるらしい。
これをリッスンして、ジョブの処理中だったら終了を待ってもらうコードを書いてみる。
process.on('SIGTERM', async () => {
await this._handleSigterm()
})
/**
* ECS環境にて SIGTERM (コンテナの終了要求) シグナルをハンドリング
* デプロイ時などでECSタスクの終了する時に呼ばれる
* ここに来る時には新しいECSタスクのコンテナが起動している想定
*/
private async _handleSigterm() {
this.isShuttingDown = true
Logger.loggingInfo({
title: 'handleSigterm',
message: 'SIGTERMシグナルを受信しました。シャットダウン処理を開始します...',
})
const shutdownTimeout = 60 * 60 * 1000 // 1時間
const shutdownStartedAt = $dayjs()
// 現在処理中のジョブが完了するまで待機
while (this.activeJobCount > 0) {
Logger.loggingInfo({
title: 'handleSigterm',
message: `ジョブが処理中です。シャットダウンを5秒待機します... activeJobCount: ${this.activeJobCount}`,
})
await sleep(5000)
// 1時間処理中なのは異常なので強制終了を行う
if ($dayjs().diff(shutdownStartedAt) > shutdownTimeout) {
Logger.loggingError({
title: 'handleSigterm',
message: 'シャットダウンタイムアウト開始から1時間経ちました。強制終了します。',
})
break
}
}
Logger.loggingInfo({
title: 'handleSigterm',
message: '全てのジョブが完了しました。シャットダウンを行いプロセスを終了します。',
})
// ECSタスクを終了させる
process.exit(0)
}
全然リッスンできない。_handleSigterm()
叩けない。めちゃハマった。
Dockerが起動しているプロセスに、SIGTERMイベントが届いていないそう。
tiniというコンテナ内の全てのプロセスに対してシグナルを適切に伝達してくれるプロセスマネージャがあるそうな。
公式ドキュメントでも紹介されていた。
tiniを導入することで、ECSタスク上のアプリコードでSIGTERMイベントをリッスンすることができた。
SIGTERMを受信して、ジョブの完遂を待つようにしても途中で強制終了される
タスク定義にて停止タイムアウトを設定
Maxの120秒に設定。
なーるほど。
終了開始のSIGTERMを受信してから停止タイムアウトを設定することで MAX 120 秒は猶予作れるけど、そのあとは SIGKILLによってタスク終了させられる。
ワーカーは 120秒以内のジョブじゃないと、デプロイと重なると強制終了されるかも、っていうのを気をつけた方がいい。
参考記事 ウン十万接続のECSサービスを平和にアップデートしたい
参考記事 ECSFargateにおけるLaravelキューワーカーのGracefulShutdown
強制終了されるとデッドレターキューに移行する。
のでアラートは発報され気がつける。
そもそもデプロイ時にそんな重たい処理をやっているっていうケースはレア。
基本的に1タスクは120秒内で収まるような設計にする。
動作確認
通常デプロイ
新タスクが立ち上がりSQSのポーリング開始
2024-05-22T08:12:07.385Z
旧タスクがSIGTERMを受信して終了
2024-05-22T08:12:50.377Z
新タスクが起き上がってから旧タスクが終了することを確認
処理中(タスクが3分)にデプロイ
SIGTERMを受信してから強制終了のSIGKILLが来る2分の間に処理が完了しないケース。
SIGTERM契機のイベントで、Warnログを吐いてタスク終了することを確認
その後SQSを確認するとデッドレターキューに3件ジョブが移行している
デプロイ時にこんな重い処理をやっているケースは想定していない。
これが発生した場合は処理を見直すべき。
処理中(タスクが1.5分)にデプロイ
SIGTERMを受信してから強制終了のSIGKILLが来る2分の間に処理が完了するケース。
SIGTERMを受信したタイミングでジョブが実行中だったため、ジョブの完了を待つ。
ジョブが完了したことを検知してタスクが落ちることを確認。