アドベントカレンダー大幅どこではないレベルで遅刻したのですが、AWS ContainersのAdvent Calendarの2023/12/23の記事です、、、
AWS Containersのカレンダー | Advent Calendar 2023 - Qiita
ECS Fargateのタスクをオートスケーリングにおいて、速度のどこに時間がかかっているのかの確認観点、それを高速化させる方法について記載する。
ECSのスケーリング速度について
ECSのスケーリング速度を考える際には、いくつか要素を分解する。
- スケーリングキックまでの時間
- CloudWatchの発火までの時間
- アラーム発火によりECSがスケーリングをキックする(
desiredCount
を増やす)までの時間(ECSスケーリングのタイプ)
- ECSタスク起動時間
スケーリングキックまでの時間
ECS Fargateのスケーリングではタスクを増減させる速度について考えれば良い。
ECSオートスケーリングでは、最終的にスケーラーがタスクの desiredCount
を増やし、ECSコントロールプレーンがそれに応じてタスク起動を行う。
CPU使用率などのメトリクスをベースにスケーラーが desiredCount
を増やすまでの時間を短縮する方向について考える。
ここでは「CloudWatchメトリクス送信〜アラーム発火までの時間」+「アラーム発火によりECSがスケーリングをキックする(desiredCount
を増やす)までの時間」として、それぞれを考える。
CloudWatchメトリクス送信〜アラーム発火までの時間
ECSのオートスケーラーはCloudWatch のアラームをもってスケールを開始するので、メトリクスをしきい値で評価してアラームを発火するまでの時間を最初に考える。
CloudWatchのネイティブなメトリクスを利用すると1分粒度で送られるため、ネイティブなメトリクスを利用する場合は、データポイントを「1分内の1データポイント」にしたとして、最速でも発火まで1分かかる。
※1回でもしきい値を超えると即座にアラーム状態になることが期待されるが、実際に計測をしてみると、しきい値を超えてアラームが発火されるまでには、2分程度時間がかかりそうだった。このあたりは内部仕様なので、逐次検証が必要。
※1データポイントで即座にアラーム状態となるような設定にした場合、データポイントが誤りでアラームが誤報される可能性もあるため注意。
データポイントが違反していないのにトリガーされる CloudWatch アラームをトラブルシューティングする | AWS re:Post
ここを高速化するためには、高解像カスタムメトリクスにより、1分未満の粒度でメトリクスを送れ、アラームも10秒か30秒で指定できる。
https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/cloudwatch_concepts.html
カスタムメトリクスを発行するときは、標準解像度または高解像度のいずれかとして定義できます。高分解能のメトリクスをパブリッシュすると、CloudWatch はそれを 1 秒の分解能で保存します。ユーザーは、1 秒、5 秒、10 秒、30 秒、または 60 秒の倍数の期間でメトリクスを読み取り、取得できます。 .... 高解像度メトリクスでアラームを設定する場合、10 秒または 30 秒の期間で高解像度アラームを指定するか、60 秒の倍数の期間で通常のアラームを設定できます。高解像度のアラームには高い料金が発生します。高解像度メトリクスの詳細については、「 カスタムメトリクスをパブリッシュする」を参照してください。
高解像カスタムメトリクスの発行方法は、PutMetricData
APIで直接メトリクスを送信する方法以外に、埋め込みメトリクスフォーマット(EMF)でログをCloudWatch Logsに送って、メトリクス化する方法がある。
PutMetricData
を呼び出すやり方- 料金
- リクエストされた 1,000 のメトリクスあたり0.01USD
- これはメトリクスの送信ごとに料金がかかるので注意が必要
- https://aws.amazon.com/jp/cloudwatch/pricing/
- Quotaも注意
- PutMetricData リクエストのレート:サポートされている各リージョン: 500/秒
- 料金
- EMFを使うやり方
StorageResolution
を1に指定することで、高解像度メトリクスとして収集が可能- 仕様: 埋め込みメトリクスフォーマット - Amazon CloudWatch
StorageResolution – 対応するメトリクスのストレージ解像度を表すオプションの整数値。これを 1 に設定すると、このメトリクスが高解像度メトリクスとして指定されるので、CloudWatch は 1 分未満 (最小 1 秒) の解像度でメトリクスを保存します。
- 料金
- 収集 (データの取り込み) 0.50USD/GB
- ※OpenTelemetry Collectorのawsemfエクスポーターではまだ
Storage Resolution
は指定できない。以下Issueでトラックされている。
- ※高解像度メトリクスアラーム料金
- アラームメトリクスあたり 0.30USD
- https://aws.amazon.com/jp/cloudwatch/pricing/
アラーム発火によりECSがスケーリングをキックする(desiredCount
を増やす)までの時間(ECSスケーリングのタイプ)
ECSのオートスケーリングには、複数のタイプがある。
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/service-auto-scaling.html
Amazon ECS Service Auto Scaling は、以下のタイプの自動スケーリングをサポートします。 - ターゲット追跡スケーリングポリシー - 特定のメトリクスのターゲット値に基づいて、サービスが実行するタスク数を増減させます。これはサーモスタットが家の温度を維持する方法に似ています。温度を選択すれば、後はサーモスタットがすべてを実行します。
- ステップスケーリングポリシー - アラーム超過のサイズに応じて変動する一連のスケーリング調整値 (ステップ調整値) に基づいて、サービスが実行するタスク数を増減させます。 - スケジュールに基づくスケーリング日付と時刻に基づいてサービスが実行するタスクの数を増減させます。
CPU使用率などのメトリクスベースでスケールさせる場合には、ターゲット追跡スケーリングか、ステップスケーリングを利用する。
ターゲット追跡スケーリングは、指定したメトリクスの値にサービス内のタスク平均値が収束するように、良しなにスケールしてくれるため、考えることが少なく、まず最初に導入することが多いと思われる。
結論から言うと、ターゲット追跡スケーリングではスケールアウトするのに最低でも3分間のメトリクス評価期間が必要なため、より高速にしたい場合にはステップスケーリングか独自のスケーリングをする必要がある。
ターゲット追跡スケーリングでは現状以下のようなCloud Watch Alarmが自動作成され、それについて編集することが推奨されていない。
スケールアウト:3分内の3個のデータポイント スケールイン:15分内の15個のデータポイント
ターゲット追跡スケーリングポリシーのためにサービスの自動スケーリングが管理する CloudWatch アラームを編集または削除しないでください。
したがって、スケールアウト時には3分間で3回CloudWatch Alarmが発火することが条件となるため、アラーム発火までに最低でも3分かかる。
一方で、ステップスケーリングであれば柔軟にCloudWatch Alarmの設定を変更できるため、発火まで評価期間と必要なデータポイントを厳しくする(E.g. 1分間に1個のデータポイント違反)ことも可能。
CDK で Step Scaling
Application Autoscaling で以下のように実装可能
const scalableTarget = new appscaling.ScalableTarget(this, 'ScalableTarget', { serviceNamespace: appscaling.ServiceNamespace.ECS, resourceId: `service/${props.cluster.clusterName}/${service.serviceName}`, scalableDimension: 'ecs:service:DesiredCount', minCapacity: 1, maxCapacity: 10, }); new appscaling.StepScalingPolicy(this, 'ScalingPolicy', { scalingTarget: scalableTarget, metricAggregationType: appscaling.MetricAggregationType.AVERAGE, adjustmentType: appscaling.AdjustmentType.CHANGE_IN_CAPACITY, scalingSteps: [ { lower: 0, upper: 20, change: -2 }, { lower: 20, upper: 40, change: -1 }, { lower: 40, upper: 60, change: 0 }, { lower: 60, upper: 80, change: 1 }, { lower: 80, upper: undefined, change: 2 } // upppder: 100だと `Error: Can have at most one no-change interval` エラー ], cooldown: cdk.Duration.seconds(60), metric: new cloudwatch.Metric({ namespace: 'AWS/ECS', metricName: 'CPUUtilization', dimensionsMap: { ClusterName: props.cluster.clusterName, ServiceName: service.serviceName, }, period: cdk.Duration.minutes(1), }), });
評価期間とデータポイントはdatapointsToAlarm
, evaluationPeriods
にて設定可能(デフォルト1/1)
スケーリング アクションをトリガーするために違反する必要がある評価期間外のデータ ポイントの数。 「M out of N」アラームを作成します。このプロパティは M で、evaluationPeriods に設定された値は N 値です。
CloudWatchを経由しないことで高速化させる案
CloudWatchを経由する場合、どうしてもメトリクス化→アラームの発火とオーバヘッドが上述の通りかかる。
よりもっと1分未満のような短期間でメトリクス評価して、desiredCount
を変化させたい場合、以下の aws-fargate-fast-autoscaler
のような考えで、ECSのネイティブなスケーリングを利用しない方法が考えられる。
https://github.com/aws-samples/aws-fargate-fast-autoscaler
すごく単純で、定期的にLambdaでメトリクス(例だとNginxのコネクション数)を直接取得し、直接ECSのdesiredCount
を更新する。
スケーリングメカニズムの独自管理が必要だが、上述したオーバーヘッドを乗り越えられる。
ECSタスク起動時間
desireCount
を増やしたあとは、Fargateが起動し、Fargate上のエージェントがタスクを起動する。
ECSのコントロールプレーンがタスク追加(作成)をキックしてから、実際にタスクが起動してトラフィックを受けられるようになるまでの時間を考える。
ざっくりとは以下の時間が含まれる。そして、 DescribeTasks にてある程度それぞれの時間を計算することができる。
以下のどの時間がタスク起動において支配的なのかを測定することが重要である。
- Fargate エージェントのタスク起動準備時間
- Fargate のVMが起動して、Fargate エージェントがタスク起動のための準備をする時間。
DescribeTasks
での測定:pullStartedAt
-createdAt
- イメージプル時間
- コンテナイメージをレジストリからプルする時間
DescribeTasks
での測定:pullStoppedAt
-pullStartedAt
- ALBやAPI Gatewayへの登録時間
describe-taskを集計するスクリプト(get-test-launch-latency.py
)で以下計算する。
import boto3 import sys import pprint import pandas as pd # cluster is 1st arg, servcie is 2nd arg args = sys.argv if len(args) < 2: print ('Arguments are too short. Please specify cluster and service.') ecs = boto3.client('ecs') cluster = args[1] service = args[2] df = pd.DataFrame({ }) taskList = ecs.list_tasks( cluster=cluster, maxResults=100, serviceName=service ) tasksDetail = ecs.describe_tasks( cluster=cluster, tasks=taskList['taskArns'] ) for taskDetail in tasksDetail['tasks']: taskArn = taskDetail['taskArn'] taskName = taskArn.split('/')[-1] tillPullStart = taskDetail['pullStartedAt'] - taskDetail['createdAt'] tillPullStop = taskDetail['pullStoppedAt'] - taskDetail['pullStartedAt'] tillStart = taskDetail['startedAt'] - taskDetail['pullStoppedAt'] df = pd.concat([df, pd.DataFrame({'tillPullStart': tillPullStart.total_seconds(), 'tillPullStop': tillPullStop.total_seconds(), 'tillStart': tillStart.total_seconds()}, index=[taskName])]) # print (tillPullStart.total_seconds(), tillPullStop.total_seconds(), tillStart.total_seconds()) df['Sum'] = df.sum(axis=1) df.loc['Average'] = df.mean() print (df)
以下のようにタスクごとに測定できる。
$ python get-test-launch-laytency.py hoge-cluster hoge-cluster/hoge-service tillPullStart tillPullStop tillStart 4abb7389389e413f809137ddd64cda86 13.9070 13.0970 39.6820 4eaea82dae4c4f369910f9c9b009d0e3 12.2910 13.0780 46.7470
ただし、実際にタスクがトラフィックを受診可能になるのは、 startedAt
になる時間よりも早いことが測定から判明した。
例えば、以下のように。
- 20:44:18.046 createdAt - 20:44:46.279(28.2s) pullStoppedAt プル完了 - 20:45:17.703(1.7s) CURL ← 実質ここからトラフィック受付開始 - 20:45:29.736(12.0s) startedAt
したがって、startedAt
までの時間、上記のスクリプトで言うなら tillStart
に時間がかかっている場合、トラフィックの受付開始時間も合わせて確認するようにしたほうが良いと考えられる。
実際にタスク起動速度を測定してみた 2023年9月頃
ALB連携だとタスクがStartedになるまでに1分強かかる(実際には1分弱でトラフィックを受けられるようにはなる)ので、その内訳を確認した。
※2023年の9月ごろの測定なので、再度検証するのが望ましい。
結果として、2023年9月時点だと、ALBへの登録よりは、 Service Discovery (Cloud Map) への登録&API Gateway HTTP APIへのVPCリンクの方がトラフィック受付可能になるまでに高速に動作することがインサイトとして得られた。
ECS上にNginx + PHP (php-fpm)で複数条件下で10タスク起動した時間の平均値と、ALB+ECS構成、API Gateway+ECS構成を参考として置いておく。
表:各条件下での起動速度の内訳
ALB + ECS構成
以下のようなよくあるALBとECSの連携構成で測定。
測定結果
ALBと接続するECSサービスの場合、起動時間全体で68秒、トラフィック受付可能まで56秒だった。
内訳としては、起動処理 16秒、プル時間 12秒、AP起動 4秒、ALB登録 15秒、トラフィック受付可能まで 12秒。
→ALBへの登録処理(RegisterTargets
)とトラフィック受付るまでに時間がかかっている
※ちなみに新規タスクのALB登録では、初回ヘルスチェックが合格すればトラフィックルーティングが開始する
登録処理が完了し、ターゲットが最初のヘルスチェックに合格するとすぐに、ロードバランサーは新しく登録したターゲットへのリクエストのルーティングを開始します。登録プロセスが完了し、ヘルスチェックが開始されるまで数分かかることがあります。
$ python get-test-launch-laytency.py test-cluster test-cluster/test-service-alb tillPullStart tillPullStop tillStart 4abb7389389e413f809137ddd64cda86 13.9070 13.0970 39.6820 4eaea82dae4c4f369910f9c9b009d0e3 12.2910 13.0780 46.7470 5f619736ef0640cea1891390282a7e93 15.1720 11.9100 46.1860 5f8b0404c99248a5b3a2fe41684e377f 11.1310 12.7310 40.4710 7291a5fe2adf4db187f97afcd7600730 11.9260 11.8800 51.4210 87d9b73edac042e7aafe470f0c887381 15.9030 12.1810 43.5920 bd91ba33c3ec4c2ba9de9f26de6645dc 12.2310 12.3150 44.9860 d43fda6aee16406c9a3a689a8bcd4f32 15.7220 12.2290 43.7360 d82fca250d74462d997320a332c3165b 11.2890 12.4930 44.2970 ee9a1af19d9f4e1d9007a6e2f38d3b32 13.9090 12.2140 40.7780 Average 13.3481 12.4128 44.1896
タスクログやCloudTrailからの時系列を整理すると以下のようになっていた。
- 20:44:18.046 createdAt - 20:44:46.279(28.2s) pullStoppedAt プル完了 - 20:44:48.599(2.3s) php fpm準備完了(ここからNginxコンテナセットアップ←DependsOn) - 20:44:50.897(1.3s) Nginxセットアップ完了 - 20:45:05 (14.1s) RegisterTargets - 20:45:16.055(11.0s) 1回目 ALBヘルスチェック - 20:45:16.059(0.0s) 2回目 ALBヘルスチェック - 20:45:16.072(0.0s) 3回目 ALBヘルスチェック - 20:45:17.703(1.7s) CURL ← 実質ここからトラフィック受付開始 - 20:45:29.736(12.0s) startedAt
API Gateway HTTP API + ECS構成(Service Discovery 経由)
ALBへの登録が遅いのであれば、Service Discovery での接続が可能なAPI GatewayのHTTP APIにて以下構成のように連携してみる。
※API GatewayのHTTP APIであればプライベート統合でCloud Mapとのプライベート連携が可能。これによりECS Service Discoveryが有効なサービスを呼び出すことができる。
HTTP API のプライベート統合の使用 - Amazon API Gateway
VPC リンクを作成したら、Application Load Balancer、Network Load Balancer、または AWS Cloud Map サービスに登録されたリソースに接続するプライベート統合を設定できます。
ちなみに、このAPI Gateway HTTP APIとCloud Mapを連携させる構成は、Service Connectでの実装であれば、AWS Senior SAにてCDK Constructが提案されており、実装も公開されている。
- 提案: API Gateway load balanced Fargate service with Cloud Map using CDK construct | Containers on AWS
- 実装: https://github.com/aws-samples/container-patterns/blob/main/pattern/ecs-fargate-apigateway-cloudmap-cdk/files/src/agw-balanced-fargate-service.ts
Service Connectの実装部分をコメントアウトし、以下のようにService Discoveryの定義に書き換えるとうまく動作した。
... const service = new ecs.FargateService(this, 'FargateService', { // ここがService Connect有効箇所 // serviceConnectConfiguration: { // namespace: props.cluster.defaultCloudMapNamespace ? undefined : this.createCloudMapNamespace(id).namespaceArn, // services: [{ // portMappingName: props.discoveryName ?? 'default', // }], // }, // Service Discovery (CloudMap)定義 cloudMapOptions: { cloudMapNamespace: props.cluster.defaultCloudMapNamespace ? undefined : this.createCloudMapNamespace(id, props.vpc), name: props.discoveryName ?? 'default', dnsTtl: Duration.seconds(10), container: props.taskDefinition.findContainer('nginx'), containerPort: 80, dnsRecordType: DnsRecordType.SRV }, ...
※REST APIでは、NLBを作成が必要。 API Gateway プライベート統合の設定 - Amazon API Gateway
プライベート統合を作成するには、まず Network Load Balancer を作成する必要があります。
測定結果
API Gateway と Service Discovery で接続する場合、全体で71秒、トラフィック受付可能まで36秒だった。
内訳としては、起動処理 12秒、プル時間 13秒、AP起動 5秒、サービス登録 5秒、トラフィック受付可能まで 2秒。
→ALBへの登録よりは、 Service Discovery (Cloud Map) への登録&API GatewayへのVPCリンクの方がトラフィック受付可能になるまでに高速に動作する
$ python get-test-launch-laytency.py test-cluster test-cluster/test-service-apigw tillPullStart tillPullStop tillStart 1cd4a3ea186046f287012d3894d8e3bd 18.6730 12.0110 46.681 2d8ec4095f3c45a8868f9204dce96225 17.1880 12.2830 48.285 31b284404ceb48898ed1ed7d1aa79be9 17.2040 13.0950 45.799 48632168c675467ca2472396dcdb8d41 17.2260 12.8680 40.961 49c771597cd04298a0f5562a1ab9347c 17.5020 12.4610 46.768 54886c3eccfc4b919bfd2392e9bcc80b 17.8780 11.6850 47.153 9ce6ea3c9cbd405aa33dade552b95887 18.8580 10.8540 42.396 ba361d122da64460b4d498decef10f56 11.5880 13.3630 40.008 baa2b52e2d6941669fb5aa95c16ba7bb 17.5290 11.6090 38.508 c6e5f0ef46134434a78b8a5db8acd33b 17.8750 11.2960 56.511 Average 17.1521 12.1525 45.307
タスクログやCloudTrailからの時系列を整理すると以下のようになっていた。
- 17:34:04.690 createdAt - 17:34:30.110(25.42s) pullStoppedAt プル完了 - 17:34:32.829(2.71s) Nginxセットアップ完了 - 17:34:35.337(2.51s) php fpm準備完了(ここからNginxコンテナセットアップ←DependsOn) - 17:34:40 (5s) RegisterInstance - 17:34:41.967(1.63s) CURL ← 実質ここからトラフィック受付開始 - 17:34:47 UpdateInstanceCustomHealthStatus : ServiceNotFound - 17:34:55 UpdateInstanceCustomHealthStatus : ServiceNotFound - 17:35:04 UpdateInstanceCustomHealthStatus : ServiceNotFound - 17:35:13 UpdateInstanceCustomHealthStatus : 成功 - 17:35:16.346(35.3s) startedAt
ECS Fargate スケーリング速度について参照できる情報
ECSとFargateについて、それぞれのスケーリングがどれくらいになるのかについて記載される情報を置いておく。
ECSとFargateの起動レートについて解説するブログ
詳解: Amazon Elastic Container Service と AWS Fargate のタスク起動レートの向上 | Amazon Web Services ブログ
- ECS on Fargate起動レート関連について、整理すると以下2点の速度調整がある
- 2よりも1のほうがレートが早くあたるので、2を緩和してもあまり効果ないかもしれない。ブログ記載の実測値で、以下のように1000タスク起動に4分強かかっている。
ECS, EKS, Lamnda, AppRunner の大規模スケールについて検証結果を発表されている Blog
https://www.vladionescu.me/posts/scaling-containers-on-aws-in-2022/
- ECS FargateのほうがECS on EC2よりも今は高速にスケールする(3000タスクスケールまで3分半くらい)
- ECS Fargateの改善の歴史が見れる
- 3500タスクのスケーリング60分→5分強
- 3500タスクへのスケーリングの最終盤でスケール速度が失速しているが、1万タスクにするとやはりリニアにスケールし1万タスク11分くらい
- だいたい1000タスク1分くらいでリニアにスケールする
まとめ
ECSはマネージドサービスでスケーリングや各タスクの起動についてお任せできる点が多い。
それが楽なのが一番のメリットだが、細かくより早くスケールさせたいと考えた際にどこを高速化できるのかという点で以下ポイントを整理した。
- スケーリングキックまでの時間
- CloudWatchの発火までの時間
- アラーム発火によりECSがスケーリングをキックする(
desiredCount
を増やす)までの時間(ECSスケーリングのタイプ)
- ECSタスク起動時間
EKS(Kubernetes)にするとなんとなく早くなるみたいな考え方はあまり好きじゃないが、状況によってEKS(Kubernetes)のほうがスケール速度が早いこともありうる(例えばここのコンテナのスケール評価期間はHPAなら15秒なので、単コンテナのきめ細かい起動であればKubernetesのほうが早いケースは起こり得ると考えられる)が、aws-fargate-fast-autoscaler
のような独自メカニズムで対応する方法もある。
また、ALBへの登録が以外と時間がかかることがインサイトだったが、この点はEKSのAWS LB Controllerのmode instance(NodePortでマウントするやつ)なら、ALB登録が新規Node追加時に走るようになるので高速化するかもしれない(未検証)。
最後の参照ブログにもあるように、ECSとFargateは常に進化してきており、スケール速度も高速化している。 どこまで細かいスケール速度が現時点で必要化のバランスで、独自メカニズムでカバーしたり、Kubernetesを検討したりしてみると良いと思う。