kashinoki38 blog

something like tech blog

ECS Fargate のスケール速度について因数分解して考える

アドベントカレンダー大幅どこではないレベルで遅刻したのですが、AWS ContainersのAdvent Calendarの2023/12/23の記事です、、、

AWS Containersのカレンダー | Advent Calendar 2023 - Qiita

ECS Fargateのタスクをオートスケーリングにおいて、速度のどこに時間がかかっているのかの確認観点、それを高速化させる方法について記載する。

ECSのスケーリング速度について

ECSのスケーリング速度を考える際には、いくつか要素を分解する。

  • スケーリングキックまでの時間
    • CloudWatchの発火までの時間
    • アラーム発火によりECSがスケーリングをキックする(desiredCount を増やす)までの時間(ECSスケーリングのタイプ)
  • ECSタスク起動時間
    • Fargateエージェントのタスク起動準備時間
    • イメージプル時間
    • ALBやAPI Gatewayへの登録時間

スケーリングキックまでの時間

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 を呼び出すやり方
    • 料金
    • Quotaも注意
      • PutMetricData リクエストのレート:サポートされている各リージョン: 500/秒
  • EMFを使うやり方
    • StorageResolution を1に指定することで、高解像度メトリクスとして収集が可能
    • 仕様: 埋め込みメトリクスフォーマット - Amazon CloudWatch
      • StorageResolution – 対応するメトリクスのストレージ解像度を表すオプションの整数値。これを 1 に設定すると、このメトリクスが高解像度メトリクスとして指定されるので、CloudWatch は 1 分未満 (最小 1 秒) の解像度でメトリクスを保存します。

      • 料金
        • 収集 (データの取り込み) 0.50USD/GB
      • ※OpenTelemetry Collectorのawsemfエクスポーターではまだ Storage Resolution は指定できない。以下Issueでトラックされている。
  • ※高解像度メトリクスアラーム料金

アラーム発火により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個のデータポイント

https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/service-autoscaling-targettracking.html

ターゲット追跡スケーリングポリシーのためにサービスの自動スケーリングが管理する 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)

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_applicationautoscaling.StepScalingPolicy.html#datapointstoalarm

スケーリング アクションをトリガーするために違反する必要がある評価期間外のデータ ポイントの数。 「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 にてある程度それぞれの時間を計算することができる。
以下のどの時間がタスク起動において支配的なのかを測定することが重要である。

  1. Fargate エージェントのタスク起動準備時間
    • Fargate のVMが起動して、Fargate エージェントがタスク起動のための準備をする時間。
    • DescribeTasks での測定:pullStartedAt - createdAt
  2. イメージプル時間
    • コンテナイメージをレジストリからプルする時間
    • DescribeTasks での測定:pullStoppedAt - pullStartedAt
  3. ALBやAPI Gatewayへの登録時間
    • DescribeTasks での測定:startedAt - pullStoppedAt
      • ※実際にはトラフィックを受けられるようになるのは startedAt よりも前になることが多いため、別途トラフィック開始までの時間をヘルスチェック等で測定するのが良い。
    • またALBへ登録完了したタイミングは、CloudTrailで RegisterTargets イベントのタイムスタンプにて確認可能

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登録では、初回ヘルスチェックが合格すればトラフィックルーティングが開始する

https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/network/target-group-health-checks.html

登録処理が完了し、ターゲットが最初のヘルスチェックに合格するとすぐに、ロードバランサーは新しく登録したターゲットへのリクエストのルーティングを開始します。登録プロセスが完了し、ヘルスチェックが開始されるまで数分かかることがあります。

$ 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が提案されており、実装も公開されている。

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点の速度調整がある
    • 1.サービスデプロイ速度:AWS Fargate キャパシティを使用する場合は最大 500/分 タスク
      • →調整不可
    • 2.Fargateの起動レート:最大 20 回/秒の継続的なオンデマンドキャパシティでのタスク起動
      • →サポートケースで調整可能
      • ※同じ AWS アカウントにあるすべてのクラスターのすべてのサービスが、同じ AWS Fargate タスク起動レート制限を共有する

  • 2よりも1のほうがレートが早くあたるので、2を緩和してもあまり効果ないかもしれない。ブログ記載の実測値で、以下のように1000タスク起動に4分強かかっている。
    • AWS Fargate オンデマンドキャパシティ、ロードバランサー有り1,000 タスク
    • 252 秒 ≒ 4 タスク/ 秒
    • 期待値としては2分(上記1の500タスク/分制限のため)ですが、それ以上かかる理由の一つとしてALBへの登録が挙げられている
      • ロードバランサーをサービスに追加すると、タスクの起動速度がわずかに低下します。非公開のワークロードで迅速にタスクを起動したい場合には、ロードバランサーよりも DNS ベースのサービスディスカバリ、またはサービスメッシュを使用することをお勧めします。

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タスク起動時間
    • Fargateエージェントのタスク起動準備時間
    • イメージプル時間
    • ALBやAPI Gatewayへの登録時間

EKS(Kubernetes)にするとなんとなく早くなるみたいな考え方はあまり好きじゃないが、状況によってEKS(Kubernetes)のほうがスケール速度が早いこともありうる(例えばここのコンテナのスケール評価期間はHPAなら15秒なので、単コンテナのきめ細かい起動であればKubernetesのほうが早いケースは起こり得ると考えられる)が、aws-fargate-fast-autoscalerのような独自メカニズムで対応する方法もある。
また、ALBへの登録が以外と時間がかかることがインサイトだったが、この点はEKSのAWS LB Controllerのmode instance(NodePortでマウントするやつ)なら、ALB登録が新規Node追加時に走るようになるので高速化するかもしれない(未検証)。

最後の参照ブログにもあるように、ECSとFargateは常に進化してきており、スケール速度も高速化している。 どこまで細かいスケール速度が現時点で必要化のバランスで、独自メカニズムでカバーしたり、Kubernetesを検討したりしてみると良いと思う。