Sidekiq のワーカーノードをオートスケールさせてEC2の稼働コストを60%削減させるまで

「Sidekiq のワーカーノードが稼働する EC2 インスタンスをオートスケールしたい」という依頼人の願いを、匠が叶えます。

このストーリーの登場人物は以下の2人です。

* 依頼人 = アプリケーションエンジニア or マネジメント層（適宜文脈によって読み替えてください）
* 匠 = インフラエンジニア

## Before

まずは依頼人が抱えるお悩みについて話を聞いてみることにします。

### インフラ構成

依頼人のサーバ構成は、 Sidekiq では標準的な構成を採用していました（図1, 2）。
Web ノードでタスクを Redis 登録し、 Worker ノードで Redis からタスクを取り出し実行します。
Web ノードは API として外部に公開されているほか、 Sidekiq の GUI として利用しています。
各ノードは AutoScalingGroup を構成していますが、**台数固定で稼働**しています。

**図1. サービス構成**:
![Sidekiq.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239145/248ddfa2-ab30-82dc-52ce-86e877448fff.png)

**図2. AWS構成図**:
![Sidekiq_AWS.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239145/1dca7415-20ea-fc72-b58c-793dfd91b30f.png)

以下の文章では、便宜的に以下の用語を用います:

* **マスターノード**: タスクを登録するノード（Web API）
* **ワーカーノード**: タスクを実行するノード（Worker）

### 1. インスタンスの停止が困難

まず、匠が気になったのは、「**インスタンスを固定台数で稼働している**」ということでした。
依頼人は「**インスタンスを簡単に落とせない**」「**スケールアウトは簡単にできるが、スケールインは面倒**」といったことを口にしていました。

話を詳しく聞くと、 Sidekiq がタスクを処理中にインスタンスを停止すると、**処理中のタスクがタスクの途中で異常終了する**ため、これを防ぐために、依頼人は以下のような手順でインスタンスを停止させていました。

1. Sidekiq の GUI から、落としたいインスタンス上で稼働しているプロセスを選び、「一時停止」ボタンを押して Quiet状態にする
2. プロセスが処理中のタスクが0になるのを待つ
3. インスタンスを停止する

少ない台数で稼働している場合は大した問題になりませんでしたが、10台を超えるインスタンスを稼働していると、**間違ったプロセスを終了させてしまうかもしれない**、という危険性がありました。

とくに、依頼人はサーバの入れ替えに blue-green デプロイを採用しているため、入れ替え中は一時的に**平常時の2倍のインスタンスが起動**します。その中から旧運用系のインスタンスだけを選んで GUI 上から停止することは、大変神経を使う作業になっていました。

### 2. 手動でスケーリングする運用

インスタンスの停止の前に手動の作業を挟むために、依頼人はオートスケーリングを組むことができませんでした。

また、キャンペーン期間中など、普段よりも高負荷が想定される場合は、依頼人はあらかじめ多めにインスタンスをスケールアウトしておき、ピーク負荷が収まったタイミングで通常時のインスタンス数に**手動でスケールインする**必要がありました。

### 3. 負荷のピークに備えて、通常時も余分なインスタンスを待機させておく

依頼人のサービスの特性上、**毎日深夜に負荷のピーク**が来ますが、依頼人が毎日手動でスケーリングするわけにもいかず、**ピーク時の負荷に合わせたインスタンス数を確保しておく必要**がありました。したがって、**平常時は必要のない、余分なインスタンスを常に稼働させておく必要があり**ました。

依頼人は余計な出費をなるべく抑えたいとは思いつつ、サービスの安定稼働のためには仕方ない出費と考えて、しぶしぶ運用していました。

### 4. 勘と経験に基づいたキャパシティ計画

依頼人は、 Sidekiq の GUI から「再試行が増えている」「待機中のキューが数百件溜まっている」といったリアルタイムな状況は把握できますが、 GUI からは過去の時系列まではわからないため、「ピーク時の待機中のキューの長さはどれくらいか？」「平常時のBusyなスレッド数の割合はどのくらいか？」といった疑問にははっきりと答えることができませんでした。

このため、「ピーク時／平常時にどの程度のインスタンスが必要か？」「負荷のピークは何時から何時までか？」といったことが判断できないため、エビデンスに基づいて設計することができず、依頼人は**勘と経験に基づいてキャパシティ計画する**しかありませんでした。

## 改善方針

依頼人のお悩みに対して、匠は3つのステップで解決を図ることにしました。

### 1. アプリケーションの稼働状況の可視化する

改善の課題に取り組むときの最初のステップは、**可視化**です。

匠は CloudWatch から確認できる「CPU使用率」「メモリ使用率」といった一般的なOSリソースの利用状況のメトリクスに加えて、「待機中のキューの長さ」「キューに入ってから実際に処理されるまでの待ち時間」「Busyなスレッドの割合」といった、**アプリケーションの稼働状況を表すメトリクス**を取得することにしました。

OSリソースと同じように CloudWattch で確認（あるいは Grafana で可視化）できると便利と考えた匠は、これらのメトリクスを**カスタムメトリクスとして公開**することにしました。

Sidekiq のメトリクスを取得し、カスタムメトリクスを公開するには、以下のような方法があります:

1. インスタンス内の cron ジョブでシェルスクリプトを起動する
2. Sidekiq のマスターノードにメトリクス監視用のエンドポイントを生やし、CloudWatch Event で 1分 ごとに Lambda Function を起動させ、 CloudWatch にメトリクスを送る

ワーカーノードが1台の場合は1のように、**インスタンス内で完結して実装する**のが簡単ですが、依頼人の構成のようにワーカーノードが複数台稼働している場や、ワーカーノードをオートスケーリングさせたい場合は、**インスタンスの外部からメトリクスを取得する**しくみが必要になります。

匠は以下のような方法でこれを解決しました。

1. Web API ノードで Sidekiq のメトリクスを HTTP のエンドポイントとして外部に公開する（参考資料[4]参照）
2. Cloudwatch Event で 1分ごとに Lambda Function を実行し、1 のエンドポイントから取得したメトリクスを、CloudWatch にカスタムメトリクスとして公開する

**図3. Lambda Function を呼び出してアプリケーションのメトリクスを取得する**
<img width="549" alt="スクリーンショット 2019-08-24 12.06.23.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239145/5f99943d-c56a-e810-101c-9fa076a8c2d6.png">


### 2. インスタンスのシャットダウンの前に Sidekiq をグレースフルシャットダウンする

次に匠が取り組んだのは、**何も考えずにワーカーノードをシャットダウンできるようにする**ことでした。

Auto Scaling Group に Lifecycle Hook を設定し、**インスタンスのシャットダウンの直前に、Sidekiq をグレースフルにシャットダウン**させることにしました。

匠は、以下のようなピタゴラスイッチを構成することで実現しました（参考資料[3]参照）。

* Lifecycle Hook - Instance Shutdown
* Cloudwatch Event
* Lambda Function
* Systems Manager - Run Command

**図4. ASG の Lifecycle Hook をトリガーにして Sidekiq をグレースフルにシャットダウンする**
<img width="610" alt="スクリーンショット 2019-08-24 12.17.20.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239145/4ba71d27-a1a0-1ba4-f851-b6312e4f3c8a.png">

### 3. オートスケーリングポリシーを設定する

2まで実装できれば、何も恐れることなくオートスケーリングポリシーを設計することができます。

ここで匠の一工夫。

Sidekiq のワーカーノードをオートスケーリングする場合、ステップスケーリングポリシーを採用している記事をよく見かけますが、ターゲット値を「1インスタンスあたりのキュー長」にして**ターゲットトラックングポリシーを採用**することができます。

これはAWS公式ドキュメント[2]にも紹介されている方法です。

さらに、ターゲット値として「1スレッドあたりのキュー長」を採用すると、**Sidekiq の同時実行数(concurrency)を変更した場合でも、ターゲット値の目標値を変更しなくて済みます**。

ターゲットトラッキングポリシーのターゲット値は、1の方法と同じようにして、カスタムメトリクスとして公開します。

ターゲットトラッキングポリシーにすると、**予め指定するパラメータが少なくてすむので設計が楽**です。スケーリングの振る舞いに影響するのは、「ターゲット値の目標値」と「ウォームアップ期間」の2つだけだからです。

**図5. Target Tracking Policy によるオートスケーリング**
<img width="832" alt="スクリーンショット 2019-08-24 12.52.13.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239145/0350faa7-1f52-4e4d-92e8-397d12511813.png">

## After

以前は必要もないのに、ピーク負荷に合わせて多めにインスタンスを起動していました。また、Sidekiq の稼働状況のメトリクスもなかったため、**ピーク時は逆にキャパシティが足りず、最大で 8m 程度の遅延が生じていることがわかりました**。

**before - 8台で常時稼働**:
<img width="1600" alt="スクリーンショット 2019-08-24 2.20.23.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239145/d89beec6-c70e-1757-b31a-06e559b468bc.png"> <img width="787" alt="スクリーンショット 2019-08-24 3.02.49.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239145/fd6f1ebf-e55f-3f66-c833-9c5ed920e839.png">

一方、匠の手によって生まれ変わった構成では、負荷(ELB Request Count) に応じて最適なインスタンス数にスケールしているのがわかります。

**after - 2-12台でオートスケーリング**:
<img width="1595" alt="スクリーンショット 2019-08-24 2.14.18.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239145/7a987b87-f7f0-640f-e93f-57faf93e1c94.png">

なお、匠はすぐに 2-12 台の範囲でスケールさせずに、**最初は 4-8 台でスケールさせて様子をみる**ことにしました。最大値と最小値の振れ幅を小さくしたことで、系が予期しない振る舞いをしたとしても、傷が浅くて済むからです。実際、最初にリリースしたときは Sidekiq のグレースフルシャットダウンの途中で失敗しているケースが数件あり、翌日切り戻しました。

**最初は4-8台で様子を見ながら稼働させた**:
<img width="1598" alt="スクリーンショット 2019-08-24 2.37.49.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239145/2a777e0b-3c7b-f0a2-055b-8862d4b3a749.png">

### 1. エビデンスに基づいてチューニングできるように

それまでは「CPU使用率」「メモリ使用率」といったOSの基本的なメトリクスしか取得していなかったため、「平常時にどの程度リソースに余裕があるのか？」「ピーク時にリソースが足りているのか？」について、誰も説得力のある回答をすることができませんでした。

**before**:
<img width="650" alt="スクリーンショット 2019-08-24 3.20.55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239145/579e9a54-2db7-cdcc-b72e-2ed2c2c55d03.png">

これに対し、匠は以下の3つのメトリクスを可視化しました。

* 1スレッドあたりのキュー長
* レイテンシー
* Busy なプロセスの割合

これにより、「ピーク時は何時から何時までか？」「平常時にどの程度リソースに余裕があるのか？」「ピーク時はどの程度レイテンシーが生じているのか？」といった疑問に対して、具体的な数値で答えられるようになりました。

その結果、依頼人は勘と経験ではなく、**客観的なエビデンスに基づいてキャパシティプランニングやチューニングができるように**なりました。

**after**:
<img width="786" alt="スクリーンショット 2019-08-24 3.39.11.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239145/28f0977a-4ac1-07c2-cd12-81ccd2644778.png">

### 2. インスタンスの停止が簡単に

Lifecycle Hook によって、インスタンスの停止前に自動的に Sidekiq をグレースフルシャットダウンしてくれるので、**処理中のタスクが失われる**リスクがなくなりました。

このため、依頼人は ASG の要求数を変えるだけでスケールイン、スケールアウトを自由にできるようになりました。

また、 Sidekiq の GUI からおっかなびっくりプロセスを停止する必要がなくなり、**間違ったプロセスを停止してしまう**リスクもなくなりました。

### 3. 手動のスケーリング運用が不要に

ピーク負荷が予想される、キャンペーンの直前／直後にエンジニアが手動でスケールアウト、スケールインする必要がなくなりました。これによって、単純作業が何よりも嫌いな依頼人は、よりクリエイティブなことに時間を使うことができるようになりました。

### 4. インスタンスの稼働費用を60%削減

<img width="261" alt="スクリーンショット 2019-08-24 3.23.49.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239145/03779964-cb59-0d04-c647-cb4f260daf74.png"> <img width="256" alt="スクリーンショット 2019-08-24 3.24.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239145/7b5779c3-b883-3f28-67e3-e225b3224382.png">

匠の業により、負荷に合わせて動的にスケーリングしてくれるようになったので、平常時は少ないインスタンス数で稼働でき、インスタンスの稼働費用が抑えられるようになりました。

どのくらい削減できるかは、サービスの負荷の特性によりますが、このサービスの場合では、常時8台稼働していたものが平均で3.29台の稼働ですむようになり、60%近くEC2の稼働費用を削減することができました。これには依頼人にも思わず笑みがこぼれます。

## 終わりに

Sidekiq の Worker Node をオートスケールさせるまでの工数を考えると、それなりにしきいが高いですが、少しづつ改善していくだけでも効果は得られるので、まずは可視化あたりから始めてみてはいかがでしょうか。

## 参考資料

1. [ターゲット追跡ポリシーを使った Sidekiq のスケーリング](https://qiita.com/billthelizard/items/f31471fdefa6e0817c54)
2. [Amazon SQS に基づくスケーリング - AWS Document](https://docs.aws.amazon.com/ja_jp/autoscaling/ec2/userguide/as-using-sqs-queue.html)
3. [Cleanly Scaling Sidekiq - Honeybadger Development Blog](https://www.honeybadger.io/blog/cleanly-scaling-sidekiq/)
4. [Monitoring Sidekiq Queue Latency Using AWS CloudWatch](https://sethdeckard.com/2017/05/15/monitoring-sidekiq-queue-latency-using-aws-cloudwatch/)