Autoscaling については過去に何度か書いているのですが、今回は ECS Fargate について少し掘り下げつつ整理してみたいと思います。
仕組みとしては難しくはなく、わりと雑な理解度でも動くっちゃ動くとはいえ、リソースとしての重要度は高い箇所であり、正しく理解するとより関連箇所の最適化が見込めるところでもあります。
目次
概要
ECS は on EC2 で動かすと、インスタンスとタスクの二段階での Autoscaling になるところが、Fargate だとタスクのみで考えられる簡素さが強みです。ECS Service のタスク群に対して、特定の条件(主に平均CPU使用率)を満たした時にタスク数を自動的に増減することで、負荷対策とコスト削減という目的を達成しつつ、運用者が基本は放置できることになります。
ただ、それだけの理解では浅すぎるので、増減における詳細やリスクなどについて把握しておくと、より安心安全に放置できるようになるので、最終的な目的としては安定したサービスの提供と良好な睡眠環境ということで参りましょう。
一応、関連する過去記事も貼っておくので、合わせるとより空白部分を埋められるかもしれません。
Autoscaling の設定
まずは ECS Service に対して、Autoscaling の設定をします。参考例は terraform で、多少濁しているところはあるけど、ほぼそのままです。aws_appautoscaling_target
公式ドキュメントはコード例は
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
resource "aws_appautoscaling_target" "ecs_service" { for_each = ... service_namespace = "ecs" resource_id = "service/${aws_ecs_cluster.default[each.value["group"]].name}/${aws_ecs_service.default[each.key].name}" scalable_dimension = "ecs:service:DesiredCount" max_capacity = lookup(each.value, "max_capacity", 1) min_capacity = lookup(each.value, "min_capacity", 1) lifecycle { ignore_changes = [ min_capacity, max_capacity, ] } depends_on = [aws_ecs_service.default] } |
特に面白いところはないですが、運用中に自動変更される部分は terraform が戻そうとしないように、ignore_changes を入れておくくらいです。
aws_appautoscaling_policy
公式ドキュメントはコード例は
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
resource "aws_appautoscaling_policy" "ecs_service" { for_each = ... name = "ecs-service-target-cpu-utilization" policy_type = "TargetTrackingScaling" service_namespace = aws_appautoscaling_target.ecs_service[each.key].service_namespace resource_id = aws_appautoscaling_target.ecs_service[each.key].resource_id scalable_dimension = aws_appautoscaling_target.ecs_service[each.key].scalable_dimension target_tracking_scaling_policy_configuration { predefined_metric_specification { predefined_metric_type = "ECSServiceAverageCPUUtilization" } target_value = lookup(each.value, "cpu_target", 50) scale_in_cooldown = lookup(each.value, "scale_in_cooldown", 180) scale_out_cooldown = lookup(each.value, "scale_out_cooldown", 180) } } |
この設定により、平均CPU使用率を 50% に保つように増減してくれます。どのような条件だとか、cooldown については後述します。
平均CPU使用率の条件
まずは TargetTracking の CPU 50% 条件とはなんなのか、を確認していきます。条件を追加すると、CloudWatch → アラーム に以下の2つの条件が追加されます。- TargetTracking-service/${cluster}/${service}-AlarmHigh-${id}
3 分内の3データポイントのCPUUtilization > 50
- TargetTracking-service/${cluster}/${service}-AlarmLow-${id}
15 分内の15データポイントのCPUUtilization < 45
データポイントは1分に1個なので、単純にメトリクス値が条件を3回や15回連続で満たしたら発動する、という内容になっています。このアラーム自体は自動作成されたもので、単体での削除は不可能です。
Increase 条件の AlarmHigh は、指定 CPU 数値そのままで、Decrease 条件の AlarmLow は指定数値の 90% の値が使用されます。それぞれの回数条件は固定であり、使い勝手としては非常にほどよい加減になっています。
この設定によって、多くの一般的なサービスならば、常に Service の平均CPU使用率が 48~52% くらいに保たれることになります。トラフィック増加の暴れ具合によっては、この辺について考えておく必要があるので、後述します。
CPU条件の意味
ここで、なぜ一般的な条件が CPU 50% といった数値なのかを整理します。リスクとコスト
CPU使用率は、特に何も大きな処理をしていない状態でも OS 関連は動くので 0~数% という無風状態で、最高に忙しい状態になると CPU 100% に張り付き、それ以上の処理は受け付けられずに待機なりエラーにするなりになります。あまりに使用量が少ないとコスト的に勿体ないし、あまりに忙しくすると処理が追いつかなくなるので、ほどよく忙しくほどよく暇な状態を保つのが理想で、それを 50% といったん定めます。
常時 50% に近い状態の場合、トラフィックが突然2倍になっても、ギリ 100% の状態で凌ぎきれるという条件になります。40% なら 2.5倍 まで、60% なら 1.66倍 まで、という簡単な計算です。サービスがどのくらいトラフィック急増する性質なのか、を理解した上でこれを設定することになり、低リスクにしたければ条件を低く、急増見込みがなくコスト効率を求めるなら高くします。
平均と最大値
補足知識として、上記の計算はあくまでも概算です。”平均” CPU 100% の状態は、一部の処理は凌げていないと考えたほうがよいです。メトリクスにも average の他に minimum , maximum とあるように、複数あるサーバーは環境条件ごとに平均よりは暇な minimum で動いているタスクや、平均より忙しい maximum なタスクが存在します。avg 50% な状況では min 45% や max 60% なタスクがいるのは珍しくなく、その状態からトラフィックが2倍になったとすると、忙しいタスクは 120% のパワーが必要になるので、20% 分は遅延やエラーが発生していると考えるのが自然です。
そのため、50% と設定することは一般的でも、急増耐久率としては 2倍 ではなく max を考慮して(この例なら)1.66倍 である、と考えるようにします。
CPU使用率の差
なぜタスクごとに CPU使用率に差がでるのかというと、いくつかの複合的な要素が考えられます。まずはシンプルに複数種類ある処理において、たまたま長く重い処理を担当した場合。全ての処理が数百ミリ~数秒以下の処理時間だとしても、何かしら偏りはあるかもしれません。
次に CPU の違いです。EC2 の場合もインスタンスタイプを混在できましたが、Fargate の場合は X86 の場合に混在となり、ARM64 は Graviton 固定となります。混在すると性能差が数十%出ることもあるので、スケーリング条件としては最も弱いCPUを基準に考慮する必要が出てきます。
あとは AZ 場所によっても傾向が変わる可能性があります。WEBサーバーが 1a にいて DB も 1a にいれば、DBとの通信はレイテンシが低いですが、これが 1a → 1c となると、レイテンシの合計としては結構な差になるので、レスポンスタイムや CPU使用率の偏りに関わってくる可能性はあります。
ロードバランサーやアプリケーションサーバーにおける処理の振り分けの多くは、ラウンドロビンのように順々に分散されるため、分散先の環境条件によってリソース使用量の差が出ることになります。そのため、ここでは深堀りしませんが、各所の分散アルゴリズムについても理解しておくと、より安全かつ効率化を目指せる可能性もでてきます。
タスク数の変化
スケーリング条件を満たした時に、タスク数はどう変化するのかを整理します。この辺からは、ドキュメントも読みつつの方が良いかもですね。ここでは噛み砕いて説明しているだけにすぎないので。増加 ~Increase~
TargetTarcking の条件は、メトリクス値に対して、可能な限りその条件を保とうとしてくれます。この可能な限りってのは他の要素も関わるミソで、スケーリングの仕組み側から見ると、数値を保つことを保障はできないという点があります。例えば、1分間隔で 49%, 51%, 53%, 55% と上昇した場合、55% の時点でアラームが発動して、50% 以下になるようなタスク数が追加されます。
──されますが、タスクが起動し、ミドルウェアが起動し、ヘルスチェックが通り、負荷分散に参加し、次のメトリクス値に反映されるまで、が一連の流れとなり、その間にもトラフィックが上昇していた場合、どこまで保たれるかは調整次第となります。
タスクのイメージがバカでかいとか、タスクの起動時間がクソ遅いとか、ヘルスチェックの条件がアホみたいにガッチガチだとか、事前の構築において最適化が十分でない場合に、リソースの追加が遅くてスケーリング処理速度が十分でない、という場合も考えられます。
常識を逸脱したような品質でなければ、TargetTracking はおおむね指定数値の前後になるように漂ってくれますが、それ以外にも自身が用意した要素と合わせて成り立つ仕組みである、という考えは持っておくべきです。
減少 ~Decrease~
タスクを増やすのは、トラフィック面では大変でも、機能的には簡単な部類です。逆の減らすのはトラフィック的には安全な状態なので簡単で、機能的にはしっかり考える必要があります。理由は、処理中の処理が正常に終了する必要があるからで、例えば通信中のユーザーにエラーが出たらダメでしょう、という当たり前の話です。普通の HTTP(S) 通信なら、今はほぼ何もしなくても大丈夫なようになっていますが、その理由は知っておくべきだし、それ以外のプロトコルや長い処理があれば特に気をつけるところになります。
これについては、公式が丁寧に説明してくれているので、リンクだけで済ませます。シグナルを受け取った後にどうなるのか、スポットとして落ちる時はどうなるの、あたりを把握しつつ設計していきます。
減少の度合い
専門のエンジニアではない場合、サーバーの減少はどれだけ理論的で計画的でも怖がられるものです。なので、どれくらいの雰囲気で減少するのかの例を提示しておきます。これは、CPU条件 30% で稼働中の状態から、条件を 40% に変更した場合のタスク数とCPU使用率の変化例です。擬似的に、トラフィックが減少し続ける状況と同じです。
- 10:54 → 105 task, CPU 29.5%
- 10:58 → 100 task, CPU 30.4%
- 11:02 → 095 task, CPU 31.7%
- 11:06 → 091 task, CPU 32.1%
減少条件を満たすと、現在のタスク数の5%切り捨て台数が減らされます。減らされると当然、平均CPU使用率は上昇します。
でも通常はトラフィックが下がり続ける時間帯に起こるので、タスクが減った瞬間は多少は平均CPU使用率が上昇しても、それより多くトラフィックが減少するので、結果的にタスク数は CPU条件を満たすか、min 数に到達するまで減少します。
ガバっと大きな台数が減少するわけではなく、15分間の連続的な減少条件を満たすことから、もの凄く緩やかに減少する結果になります。もし減らしたい時にガバっと減らしたい場合は、TargetTracking ではなく他の方法を用いることになります。
クールダウンの意義
スケールアウトとスケールインには、どちらにもクールダウンの秒数を設定することができます。これは、それぞれのアクションが実行されてから、最低限に次のアクションが実行されるまでに空ける時間です。減少 ~Decrease~
簡単かつ、さきほどの例があるので減少について先に書きますが、4分間隔で減少を続けたのは scale_in_cooldown が 180(秒) だからです。これが 0 秒ならば、データポイントの1分間隔でチェックされた時に、条件を満たす限りは最速1分間隔で Decrease が実行されることになり、それなりの急減になる可能性が高いです。
この秒数を設定しておくと、その間のチェックは無視されて、それが終わった次に 15回連続しているか でまた判断されるので、どれくらい緩やかな減少にしたいかで調整するとよいです。
増加 ~Increase~
クールダウン自体の挙動は上記の減少と同じですが、増加の場合はさきほどチラッと書いたように考慮する事項があります。それは増加のアクションが実行されてから、実際に負荷分散に参加して結果が表れるまでの時間で、だいたいこのあたりの合計となります。
- イメージのダウンロード時間
- タスクの起動時間
- ミドルウェアの起動時間、その他あればデプロイ処理
- コンテナ内でのヘルスチェック
- ALB TargetGroup からのヘルスチェック
- 次のデータポイントまでの分散結果反映までの時間
早いと3~5分、遅くとも5~10分、それ以上だと設計を見直して改善した方がいいかもしれないな、というくらいの感覚です。
もし10分かかるとして、クールダウンを5分にしていた場合、1回目の増加の結果が反映されない状態で、5分後に2回目の増加が発動されることになります。まぁ少ないよりは多めに増えたほうが安全なので考えようによってはこれでもよいのですが、クールダウンの調整をなんとなくした場合、この辺の挙動を正しく認識しないままに運用することになります。
逆に、3分で反映完了しているのに、5分のクールダウンを取っていたら、2分間は増加条件を満たしていても増加しない、ということになりますしね。
なので、まずは自分の扱うコンテナ周りの各所要時間を把握しつつ調整することと、把握後には少なくとも1回は、リスクを伴わずより早くできる箇所がないかを探して改善する、という設計・構築フェーズを経るとよいでしょう。
TargetTracking の条件とコスト
スケーリング条件の種類
ここまでCPU条件として説明しましたが、他にメモリ使用容量率や、Targetあたりのリクエストカウントという項目もあります。システムによっては、CPU よりもメモリ使用量を主体として稼働する場合もあるので、メモリの空きが足りなくなってきたらタスクを増加するようにもできます。モノによるかもですが、キャパシティとしては CPU よりメモリの方がクリティカルな障害になりやすいので、平均的なラウンドロビン分散ではなく、新規タスクに寄せたほうがよい場合もあります。
ALB TargetGroup の Routing algorithms には種類があり、Round robin についで Least outstanding requests が最適化の選択肢として有効なので、知っておくと救われるかもしれません。
コストの最適化
Autoscaling は基本、サービスがオンラインの状態でも変化させられる仕組みのことを指すため、リスクやコストの調整を随時行うことが可能です。サービスのリリース直後や、新規機能の追加などで、負荷を読みづらいけどリスクはできるだけ小さくしたい場合、いったん想定より多く最小台数を設定したり、余裕をもってスケーリングするようにしつつ、数日間のメトリクスを元に徐々に最適化していけば、リスクを少なくコストの最適化をすることができます。
最初は CPU 20% 条件とし、30% , 40% と引き上げることで、タスク数はおおむね比率通りに 2/3 , さらに 3/4 と減少するので、20 → 40% なら半減します。
そこから先を、50% , 60% としたり、スポットの割合を 0~50% で調整したり、は傾向を把握しつつシステムの仕様理解ありきのチャレンジングな領域になるので、できるならギリギリ安全といえる範囲での攻めの調整をするとよいです。
実戦のトラフィック変化
まだ抜けは全然あるかもですが、ここまでを基礎知識として、では実戦ではどういうトラフィック変化が起きるのか、について軽く例を出しておきます。日常的なピークタイム
少しでもメトリクスを見ていれば、どういう時間帯にアクセスが増えるのか、は一目瞭然となります。シンプルに、人々の生活リズムに合わせて変化します。一日の中で最初に訪れるのは、07:00~08:00 あたりで、これは起床後や通勤・通学中に触れるためです。対象の年齢層によっては、けっこうここが盛り上がるサービスも存在します。
次にお昼休みな 12:00~12:30 です。トラフィックとしての最大値は夜と同等前後のパワーがあり、短時間である分は急増率としては夜よりはるかに高かったりします。
最後に夜の 21:00~23:00 です。仕事や学校、夕飯が済んだあたりの 18:00 頃から増加しはじめ、22時前後には日のピークを迎えることが多いです。23:30 あたりを過ぎると急激に減少していきますが、24時という日替わりタイミングがあるので、サービスの性質によっては注意したいポイントです。
これらの時間帯は、5分間隔でチェックすると、増加率としては多くても 10~20% / 5分 以下であることが通常です。グラフにすると結構グワッと盛り上がるのですが、短期的には意外と少ない増加率になっていたりします。なぜ5分間隔なのかは後述します。
日常的なアイドルタイム
逆に、落ち着く時間帯は上記のピークタイム以外となります。まずは 01:00~06:00 あたりが日の中で最も減少するところです。サービスによっては 04:00 などがシステム内の日替わり時間となり、そこだけ一瞬盛り上がったりもします。
次は 08:00~11:30 あたりで、仕事や学業に勤しんでいます。そして最後に 13:00~17:30 あたりも同様です。
これらの時間帯はトラフィックが低く安定するので、タスク数を減らしてコスト削減とする絶好の機会となり、まずはここの CPU使用率に着目して、無駄の少ないタスク数にしていくことが肝要です。
突発的なトラフィック
サービスごとに、日常的ではない突発的な急増トラフィックを発生させることはよくあります。日単位・週単位で、この時間からイベントを始めますよ集まってくださいね、という性質の機能がある場合は、それ以外が少なめでそこだけ数倍に集中することもあります。
新サービスのリリース・新機能のリリース・新イベントの開始・そして広告が打たれたタイミング、などはどれだけ予想してもそれを上回る可能性を秘めているので、事前の準備を万端にしつつも、いざという時の対応瞬発力を求められることもあります。
そのサービスにとって、その経験が積まれていれば、ある程度は自動的なリソース対応が可能になりますが、初見では手動対応を待機しておくべきで、傾向が判明してから徐々に人の手を離れるように調整するとよいです。
リソース増加率 vs トラフィック増加率
さきほど5分間隔の話を出したのは、リソースの増加がだいたいその前後かかるからです。仮にトラフィックが多めに 20%/5分 ずつ上昇したとすると、12:00 に CPU 50%, 12:05 に 60%, 12:10 に 72% と増加することになります。
これに対して、タスクの起動時間とクールタイムを合わせて、5分間隔で増加アクションが実行されれば結構安定の範囲に見えるでしょうが、10分間隔での増加となるとちょっと危なげな数値に感じるはずです。
それをどのくらいで危なく感じるのかは、サービスの性質と人次第ではあるのですが、トラフィック増加率の性質とリソース増加率の性質を具体的な数値で考えた時に、それが安全と判断できれば通常の Autoscaling のみで。そうでなければ他の手段と合わせて対処していくことになります。
急増トラフィックの対処
TargetTracking だけでも多くの場面でほぼ対処可能ではあるとはいえ、サービスによって特徴がまちまちであるため、それ以外の手段も知っておきつつ、必要があれば合せ技にしていきます。- Application Auto Scaling のスケジュールされたスケーリング – Application Auto Scaling
- Application Auto Scaling のステップスケーリングポリシー – Application Auto Scaling
私見としては、スケジュールが好きなので、これで二本立てにすることが多いです。スケジュールは管理画面で編集どころかリストの確認すらできないので、API を使う運用のみになるのが弱点です。(※1 確認・編集できるようになりました)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
resource "aws_appautoscaling_scheduled_action" "ecs_service" { for_each = ... name = "ecs-service-${local.env}-${each.key}" service_namespace = aws_appautoscaling_target.ecs_service[each.value["target"]].service_namespace resource_id = aws_appautoscaling_target.ecs_service[each.value["target"]].resource_id scalable_dimension = aws_appautoscaling_target.ecs_service[each.value["target"]].scalable_dimension schedule = each.value["schedule"] timezone = lookup(each.value, "timezone", "Asia/Tokyo") scalable_target_action { min_capacity = each.value["min_scheduled"] max_capacity = lookup(each.value, "max_scheduled", null) } } |
ある程度のトラフィックの傾向がわかった上で、min 数をスケジュールで調整することで、以下の目的を達成します。
- 事前に判明している急増トラフィックに確実に対処する
- トラフィックの減少時間帯に確実なコスト削減とする
- 定期メンテナンスや想定外の現象が起きた際に、極端にリソース量が減らないようにする
もし 12:00 に急増することが確定なら、11:45 には min サイズを上げておくことで、CPU条件に関係なく先回りで十分なリソースを用意します。そして、自然減少となる 12:30 には min を下げることで、CPU条件による自然減少に任せることでコスト削減とします。
リスク帯にはガバっと増やし、不要になったらガバっと減らす、というクラウドならではの対応は、普段からそう安くない料金を支払っているのですから、惜しみなく活用するとよいです。
※1 予測スケーリングが追加された時に、スケジュールも操作できるようになりました
ECS にきた予測スケーリング、のためにか「スケーリング」タブができて、スケジュール一覧が見れるようになってた!レコメンデーションの記録はまだないので評価はできないhttps://t.co/JdmOvV4JJF
— 外道父 | Noko (@GedowFather) November 26, 2024
おわりに
特にコンテナならではという仕組みがあるわけでもなく、EC2 Autoscaling と機能的にはそう大差はないのですが、昔と比べるとところどころで簡略化されシンプルになっている部分もあるので、簡単だけにしっかりと理解をしてしまいたいところです、ということで整理してみました。この辺の知識と判断が整うと、じゃあ他の Aurora Reader の Autoscaling はどうなんだとか、システム部位ごとに Autoscaling を可能にできるのか必要性はあるのか、といったことを考えていくことができるようになります。
なんでもかんでも Autoscaling にすればよいというわけでもないですが、Autoscaling できる仕様であるということは、イコール耐障害性が強いということでもあるので、自動増加・自動減少を考えた時に自然とそのシステム周辺の理解度を求められることになります。
時間がないから Autoscaling にはしない、と考えるよりは、時間をかけても Autoscaling に対応した方が、その後の運用にかける時間とリスクが低減するという考えで、設計と構築を進めると多くの場合で幸せになれることでしょう。
──と言っても、最終的には人事を尽くして天命に対処する、みたいな側面もあるので、技術的な基礎力と瞬発力の両方を鍛える感じで頑張っていきましょい:-)