2022年の re:Invent にて ECS の新しいネットワーク機能として Service Connect がリリースされました(アナウンス)。これまで、ECS におけるサービス間通信として ELB、Service Discovery(Cloud Map)、App Mesh がありましたが、新しく 4つ目の選択肢として登場しました。今回、この Service Connect を使用した ECS サービスにおいて Auto Scaling の設定を検討する機会がありましたのでどんなメトリクスが使えるか調査してみました。

Service Connect とは

詳しい説明は公式ドキュメントや他のサイトで詳しく紹介されていますのでポイントだけ。

  • これまでの 3つの方式(ELB、Service Discovery、App Mesh)のいいとこ取りをした方式
  • ECS サービスに付与した名前ベースで接続する
  • Service Discovery(Cloud Map)では提供されていなかった、テレメトリデータ(コネクション数とか)の提供
  • Envoy を内包した Service Connect エージェントがタスクに暗黙的に追加される
    • ただし、タスク定義にエージェントコンテナを追加する必要はない
    • エージェントのためのリソースとして 256 CPU ユニットと 64 MiB 以上の追加を推奨。負荷に応じてそれ以上の割り当ても検討(ドキュメント
  • ELB のようにインフラのコストは不要

3点目のテレメトリデータを取得できるようになったことで、これまで Service Discovery だけではできなかったタスクのネットワークコネクション数やリクエスト数に基づいた Auto Scaling ができるようになるのでは?というのが今回の検討のモチベーションです。

検討した際のアーキテクチャ

以下のようなアーキテクチャで検証してみました。ALB からのリクエストを Frontend サービスで受け、Frontend のアプリから Backend サービスの API を呼び出すイメージです。実線矢印がリクエストのフローになります。
今回は Service Connect のテレメトリデータを使って Backend サービスの Auto Scaling を検証したいと思います。

Acrchitecutre Diagram

  • Frontend は Java(Micronaut)で作成したもので、java.net.http.HttpClient を使って Backend に接続します
  • Backend は Nginx で nginx_echo というモジュールを使って 0.5秒ほど wait させてから固定の JSON を返します

Service Connect の用語の整理

Service Connect で登場する用語のうち設定する上で意識しておかなければいけないものを整理しておきます。詳しくはドキュメントを。

  • ポート名 (port name)
    • ECS タスク定義の portMappings に設定するポート名で、Service Connect でのみ使用されます。
      "portMappings": [
          {
            "name": "backend",
            "protocol": "tcp",
            "hostPort": 8080,
            "containerPort": 8080
          }
        ]
      
  • ディスカバリー名 (discovery name)
    • ECS サービスに対して設定するもので、前述のポート名と関連づけられます。この名前は Cloud Map のサービスを作成する際にも使用され、<サービス名>.<名前空間> のような形式になります(今回の例だと backend.local)。
  • 名前空間 (namespace)
    • Service Connect で使用する Cloud Map の名前空間の短縮名や ARN を指定できるようです。個人的には Cloud Map の名前空間と合わせておいた方がわかりやすいかなと思います(今回の例だと local
  • クライアント / クライアントとサーバ
    • Service Connect では「クライアント」と「クライアントとサーバ」の2つの役割があります。
      • クライアントは Service Connect を利用してエンドポイント(他のサービス)に接続するだけの役割
      • クライアントとサーバはクライアントの役割に加えて、エンドポイント(今回の例では http://backend.local:8080)を提供し、クライアントからの接続を受け付ける役割
        Roles for Service Connect
    • 今回の例だと Frontend サービスに「クライアント」、Backend サービスに「クライアントとサーバ」を設定します。

Service Connect 利用時のデプロイの順番

Service Connect では展開順序を考慮する必要があるようです(ドキュメント)。

When you prepare to start using Service Connect, start with a client-server service.

上記の通り、まずは「クライアントとサーバ」のサービスから展開する必要があります。

Existing tasks can’t resolve and connect to the new endpoint. Only new Amazon ECS tasks that have a Service Connect configuration in the same namespace and that start running after this deployment can resolve and connect to this endpoint.

上記は運用時にも意識する必要がありそうです。Service Connect での通信の場合、新しいサービスを追加した後に、クライアント側のタスクを再デプロイしないと新しいサービスに接続できないようです。

Service Connect のセットアップ

今回は既存の ECS Cluster (Fargate)、既存の Cloud Map 名前空間に、Service Connect および Backend のサービスを CDK でデプロイしました(Auto Scaling については後述)。なのでちょっと歪ですが、以下のように紹介したいと思います。

  1. 新規に Backend サービスを CDK でデプロイ
  2. 既存の Frontend サービスで Service Connect を利用するように、マネジメントコンソールから変更

尚、本投稿では各コンテナの以下のような実装部分の詳細については触れません。ご了承ください🙇‍♂️

  • Frontend から Backend の API を呼び出すアプリの実装部分(Micronaut Java アプリから java.net.http.HttpClient を使って呼び出しています)
  • Backend の実装部分(Nginx に nginx_echo モジュールで sleep してから固定 JSON を返すだけにしています)

1. Backend サービス(サーバ側)のデプロイ(CDK)

デプロイ順序に倣って、まずは「クライアントとサーバ」側の Backend サービスからデプロイしていきます。

1-1. ECS Cluster

今回は Frontend サービスが実行されている既存のクラスタに Backend サービスを作成しましたので CDK でも既存のクラスタを読み込みます

const cluster = ecs.Cluster.fromClusterAttributes(this, 'Cluster', {
  clusterName: 'CLUSTER_NAME',
  securityGroups: [],
  vpc: vpc
});

新規にクラスタを作成する際は以下のようにデフォルトの名前空間を設定することができます。

import { aws_ecs as ecs } from 'aws-cdk-lib';
const cluster = new ecs.Cluster(this, 'cluster', {
  clusterName: 'new-cluster',
  vpc: vpc,
  defaultCloudMapNamespace: {
    name: 'local',
    useForServiceConnect: true,
  },
});

1-2. タスク定義(Backend 用)

コンテナのポートマッピング設定にてポート名を指定します。また、appProtocolプロパティも指定します。http の他に有効な値は http2grpc でそれぞれのプロトコルに合ったテレメトリメトリクスが提供されるようになるようです。指定しない場合は tcp になるようでその場合はメトリクスが提供されないとのことなので設定しておきましょう(ドキュメント)。

const taskDefifition = new ecs.FargateTaskDefinition(this, 'task-definition', {
  //  :
};
taskDefifition.addContainer('container', {
  //  :
  portMappings: {
    'name': 'backend',
    'protocol': ecs.Protocol.TCP,
    'hostPort': 8080,
    'containerPort': 8080,
    'appProtocol': ecs.AppProtocol.http
  }
  //  :
});

1-3. 名前空間

名前空間は Cloud Map を通じて作成します。今回は既存の名前空間を使用しました。

import { aws_servicediscovery as servicediscovery } from 'aws-cdk-lib';
const cloudMap = servicediscovery.PrivateDnsNamespace.fromPrivateDnsNamespaceAttributes(this, 'cloud-map', {
  namespaceArn: `arn:aws:servicediscovery:${region}:${accountId}:namespace/${NAMESPACE.id}`,
  namespaceId: NAMESPACE.id,
  namespaceName: NAMESPACE.name,
});

NAMESPACE は以下が設定されています。

  • NAMESPACE.id : 既存の Cloud Map 名前空間の ID(“ns-xxxxxxxxx” という形式)
  • NAMESPACE.name : 今回の例だと “local”

Cloud Map 名前空間を新規に作成する場合は、以下のようになります。

const cloudMap = new servicediscovery.PrivateDnsNamespace(this, 'cloud-map', {
  name: 'local',
  vpc: vpc
});

1-4. ECS サービス

Service Connect のメインとなる設定は ECS サービスに設定します(Service Connect に関するところだけ抜粋)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const service = new ecs.FargateService(this, 'ecs-service', {
  //  :
  serviceConnectConfiguration: {
    namespace: NAMESPACE.name,
    services: [
      {
        portMappingName: 'backend',
        port: 8080,
        discoveryName: 'backend',
      }
    ],
    logDriver: ecs.LogDrivers.awsLogs({
      streamPrefix: 'service-connect'
    }),
  },
  //  :
});
service.node.addDependency(cloudMap);
  • 3行目 : serviceConnectConfiguration から Service Connect の設定をしていきます
  • 7行目 : portMappingName には前述のタスク定義で設定した portMappings.name の値を設定します
  • 9行目 : discoveryName で検出時に使用する名前を指定できます。portMappingName と合わせる必要はありません
  • 13行目 : Service Connect のエージェントが出力するログのプレフィックスを指定します
  • 18行目 : ECS サービスに対して Cloud Map への依存を追加しています

1-5. デプロイ

ここまでで、CDK による Backend サービスとその Service Connect の定義は完了したので cdk deploy でデプロイしておきます。

2. 既存の Frontend サービス(クライアント側)の Service Connect 対応(マネジメントコンソール)

続いて、既存の Frontend サービスを Service Connect に対応させていきます。Frontend サービスには Service Connect のクライアントとしての役割を追加していきます。

Frontend サービスを選択し、「設定とタスク」タブの「サービス構成」セクションで Service Connect の設定 を選択します。

Configure Service Connect

「クライアント」を選択、名前空間を選択し、「設定」します。
「設定」を押すと、タスクの入れ替えが発生します(Service Connect エージェントコンテナが入ったタスクに入れ替わります)のでご注意ください。 FireLens のようにタスク定義に追記しなくてもコンテナを追加してくれるのは楽で良いですね 🤗

Configure Service Connect

この時の注意点として、ECS サービスのデプロイメント方式はローリングアップデートである必要があります。CodeDeploy による Blue/Green などを利用していると下記のエラーとなり構成できません。

Unable to update service with CODE_DEPLOY deployment

ドキュメント にも記載があり、Blue/Green や外部のデプロイメントツールを利用している場合は Service Connect の利用がサポートされていません。

Only services that use rolling deployments are supported with Service Connect. Services that use the blue/green and external deployment types aren’t supported.

名前解決の仕組みを見てみる

これで Service Connect を使用して Frontend から Backend に接続できるようになりました。

ちなみに、Backend のデプロイが完了すると Cloud Map の名前空間 local に、サービス backend が追加されています。興味深いところでは「次によって検出可能」のところでは API コールのみとなっており、DNS クエリでの名前解決はできないことがわかります。

Cloud Map Service

試しに Frontend サービスのタスクに ECS Exec して名前解決を試みてみます。

Frontend サービスで ECS Exec

Frontend のコンテナに ECS Exec して名前解決してみます。

aws ecs execute-command --cluster ${CLUSTER} --container frontend --interactive \
  --command "/bin/bash" \
  --task ${TASK_ID_or_ARN}
bash-4.2# dig +short backend.local
bash-4.2#

結果は返ってきません。なので /etc/hosts も見てみます。Backend サービスのディスカバリー名が登録されていました。

bash-4.2# cat /etc/hosts
127.0.0.1 localhost
10.254.11.17 ip-10-254-11-17.ec2.internal
127.255.0.1 backend.local
2600:f0f0:0:0:0:0:0:1 backend.local

アドレスから察するに hosts ファイルを使って Service Connect エージェントコンテナに接続していそうです。固定的なアドレスであり、Service Discovery の時のように DNS プロトコルによる名前解決ではないため、Frontend のアプリ側で 「DNS 名をキャッシュしてしまって、存在しないタスクに繋がってしまう…」みたいな状況にはならなさそうです

また、前述での確認の通り API でのみ参照できるため、Service Connect から Cloud Map に対して API で検出しているのではないかと想像されます。すなわち、Service Discovery で登録される DNS レコードの TTL は通常 60秒ですが、API で Cloud Map にアクセスする場合は 5秒以内での検出が可能“AWS Cloud Map の特徴"より)ですので、より迅速な検出が期待できそうです。

(余談1)EC2 や Lambda からのサービス検出(名前解決)

ECS タスク間の接続は Service Connect によって、あたかも DNS で名前解決できるかのようになっていることがわかりました。一方、EC2 や Lambda、Service Connect のクライアントとして構成されていない ECS タスクなどから接続したい場合は Cloud Map の DiscoverInstances API を使って IP を取得することができます。複数の Instance が返ってきますのでクライアントサイドでロードバランスしてあげる必要がありそうです。
以下は CLI での実行例ですが、各言語の SDK でも同じように取得できます。

aws servicediscovery discover-instances --namespace-name "local" --service-name "backend"
{
  "Instances": [
    {
      "InstanceId": "83b1d57ca845406d995a05a11268d557",
      "NamespaceName": "local",
      "ServiceName": "backend",
      "HealthStatus": "UNKNOWN",
      "Attributes": {
        "AWS_INSTANCE_IPV4": "10.254.10.34",
        "AWS_INSTANCE_PORT": "8080"
      }
    }
  ]
}
  • InstanceId は ECS のタスク ID と同じ値が返ってきます

(余談2)Cloud Map におけるヘルスチェック

上記の DiscoverInstances API の実行結果から分かるとおり、ヘルスチェック(HealthStatus)が UNKNOWN となっています。Cloud Map では公開 DNS を有効にしない限り、ヘルスチェックは「なし」にするかもしくは「カスタムヘルスチェック」として実装する必要があります(公開 DNS も有効になっている場合は Route53 の機能を使ってヘルスチェックできます)。

カスタムヘルスチェックで実装する場合は、UpdateInstanceCustomHealthStatus API で該当のインスタンスのヘルスステータスを更新してあげます。CLI だと以下のようになります。

aws servicediscovery update-instance-custom-health-status \
  --service-id "srv-XXXXXXXX" \
  --instance-id "YYYYYYYY"
  --status HEALTHY # もしくは UNHEALTHY
  • srv-XXXXXXXX : Cloud Map のサービスの ID
  • YYYYYYYY : サービスに関連付けられている Instance ID です。今回のケースでは ECS のタスク ID と同じになります

ECS サービスの外からヘルスチェックを実行し、上記の API で各タスクのヘルスステータスを更新してあげるのが理想的ですが、簡易的にはタスク定義で設定する Docker ヘルスチェックの中でステータスに応じて上記 API を呼んであげても良いかもしれません(Docker ヘルスチェックすら実行できないような状況になるとヘルスステータスが更新できないのでタスクの外からチェックした方が良いです)。この辺は、機会があれば別途記事にしてみたいと思います。

Service Connect によるメトリクス

Backend のサービスに Auto Scaling を設定したいわけですが、Service Connect でどんなメトリクスが使えるかみてみたいと思います。

まず、CloudWatch メトリクスで ECS を見てみると以下のディメンションが新たに使えそうです。

New ECS Metrics

ディメンションとして、大きく2つの軸があります。

  • DiscoveryName"、もしくは “ClusterName、ServiceName、DiscoveryName
  • TargetDiscoveryName"、もしくは “ClusterName、ServiceName、TargetDiscoveryName

それぞれのメトリクスはドキュメントの定義や、マネジメントコンソールの「正常性とメトリクス」で表示されるメトリクスの見え方も踏まえると、以下のような解釈になるのかなと思います。

DiscoveryName、もしくは ClusterName、ServiceName、DiscoveryName のメトリクス

  • サーバ側に設定された DiscoveryName をサーバ側の視点から見たメトリクス(今回の例だと Backend サービス側で取得できるメトリクス)

TargetDiscoveryName、もしくは ClusterName、ServiceName、TargetDiscoveryName のメトリクス

  • クライアント側から検出する DiscoveryName をクライアント側の視点から見たメトリクス(今回の例だと backend.local に対して Frontend サービス側で取得できるメトリクス)

各ディメンションでは以下のようなメトリクスが取れるようです(詳細はドキュメントを参照)。

DiscoveryName、もしくは ClusterName、ServiceName、DiscoveryName

  • ActiveConnectionCount
  • NewConnectionCount
  • ProcessedBytes
  • RequestCount
  • GrpcRequestCount (*1)

(*1 : サーバ側のタスク定義の containerDefinitions.portMappings[].appProtocolgrpc を指定した場合)

TargetDiscoveryName、もしくは ClusterName、ServiceName、TargetDiscoveryName

  • HTTPCode_Target_2XX_Count (*2)
  • HTTPCode_Target_3XX_Count (*2)
  • HTTPCode_Target_4XX_Count (*2)
  • HTTPCode_Target_5XX_Count (*2)
  • RequestCountPerTarget
  • TargetProcessedBytes
  • TargetResponseTime

(*2 : サーバ側のタスク定義の containerDefinitions.portMappings[].appProtocolhttp もしくは http2 を指定した場合)

メトリクスはどのような波形になるか

メトリクスがどんな感じになるか確認するため、今回のケースでは以下の条件で負荷をかけてみました。

  • Frontend は Java アプリ、Backend は Nginx で nginx_echo というモジュールを使って 0.5秒ほど sleep させてから JSON を返すだけ、という構成
  • 負荷は ab -n 36000 -c 10 https://frontend.example.com/backend という感じで Frontend にかけていきます
  • Frontend の台数は固定 1台のまま、Backend の数を10台まで手で増やしていきます(波形がどう変化するか確認するため)
    • Backend の Capacity Provider は FARGATE_SPOT
  • 構成を簡単に表すと以下のようになります
ab コマンド => ALB => Frontend(Fargate/Java) => (Service Connect) => Backend(Fargate/Nginx)

以下のグラフは、今回の負荷と Backend タスクの追加の様子を表したもので、左 Y 軸に Frontend の前に位置する ALB での RequestCount(グレー)、右 Y 軸が Backend サービスの RunningTaskCount (ピンク)(CloudWatch Container Insights から取得)です。

Metrics - Load

今回は個人的に Auto Scaling に使えそうな、以下のメトリクスについて注目してみました。

  • ActiveConnectionCount
  • RequestCount
  • RequestCountPerTarget

ActiveConnectionCount

左の Y 軸が Backend サービスの ActiveConnectionCount の合計(茶色)、右の Y 軸が前述の通り Backend サービスの RunningTaskCount (ピンク)です。当然と言えば当然ですが、Backend のタスクを追加(ピンク)すると、コネクション数が増えていきます。

Metrics - Active Connection Count

以下は ActiveConnectionCount の平均です。今回の Frontend アプリから Backend への接続は 1つのコネクションで多くのリクエストを捌いているかと思われるのでリクエストの数に対してコネクションの増減はかなり緩やかです。

Metrics - Active Connection Count

RequestCount

左の Y 軸が Backend サービスの RequestCount の平均(紫)、右の Y 軸が前述の通り Backend サービスの RunningTaskCount (ピンク)です。タスクが増えればリクエスト数がちゃんと分散され、平均値として下がっていってます。ターゲット追跡スケーリングポリシーで使えそうです。

Metrics - Request Count

RequestCountPerTarget

Service Connect のメトリクスでクライアント側から見たメトリクスとして RequestCountPerTarget というものがあります。“PerTarget” とあったので、Backend のタスクあたりのリクエスト数が取れるのかと思いましたので見てみました。

が、実際にはクライアントから見た TargetDiscoveryName、すなわちサーバへのリクエスト数、今回のケースでいうと Frontend からみた Backend へのリクエスト数を表すもののようでした。なので今回のケースでは、ALB で受けたリクエストカウントと同じ波形となり、また、Frontend は1台のままで実行したので合計(青)でも平均(オレンジ)でも同じ波形となりました。

Metrics - Request Count Per Target

今回のケースでは RequestCount をターゲット追跡で Auto Scaling してあげるのが適していそうなのでこちらを CDK で追加してみます。

(余談3)実行中のタスク数の取得について

実行中のタスク数を表すメトリクスはドキュメントにもあるとおり、2つの方法があります。

  1. CloudWatch Container Insightsの RunningTaskCount (名前空間: ECS/ContainerInsights)
  2. ECS のディメンション ClusterName, ServiceNameCPUUtilization または MemoryUtilization を選択し、統計サンプル数 にする

1.のContainer Insights は別途費用がかかる一方、2. のサンプル数を使った方法であれば追加コストは不要です。

メトリクスとして両者の違いがあるかみてみました。以下は今回手で増やしていった際のそれぞれのメトリクスです。タスク追加時に強制デプロイをしなくても一斉にタスクが入れ替わることがあったため、サンプル数で見たタスク数は大きく変動がありました。

Metrics - Running Task Count

精度を求めるのであれば Container Insights のメトリクスを使った方が良さそうです。

RequestCount による Auto Scaling 設定(CDK)

本題となる Auto Scaling の設定を入れていきます。

まず ECS サービスのタスク数の最小 / 最大キャパシティを設定します(とりあえず 最小:1、最大:10 にします)。

const autoScaling = service.autoScaleTaskCount({  // service は ECS サービス
  minCapacity: 1,
  maxCapacity: 10,
});

続いて、ターゲット追跡スケーリングポリシーを構成します。

タスクあたりの RequestCount が 50 になるように設定します。

autoScaling.scaleToTrackCustomMetric('target-tracking-scaling-policy', {
  metric: new cloudwatch.Metric({
    metricName: 'RequestCount',
    namespace: 'AWS/ECS',
    dimensionsMap: {
      ClusterName: cluster.clusterName,
      ServiceName: service.serviceName,
      TargetDiscoveryName: DISCOVERY_NAME,
    },
    period: Duration.minutes(1),
    statistic: cloudwatch.Statistic.AVERAGE,
  }),
  targetValue: 50,
  policyName: 'target-tracking-request-count',
});
  • DISCOVERY_NAME は今回のケースでは backend が入ります

動作確認

メトリクスの波形を確認する際に使用した以下のコマンドでもう一度負荷をかけてみます。

ab -n 36000 -c 10 https://frontend.example.com/backend

Metrics Check Auto Scaling Behavior

最初急激になってしまいましたが、想定通りにいい感じにスケールしてくれました。

まとめ

新しく登場した Service Connect について特にメトリクスに注目して確認してみました。Service Connect によるテレメトリデータにより、Auto Scaling が設定しやすくなりました。また、サービス検出、名前解決の仕組みは興味深いところですが、それらを意識せずに簡単に ECS サービス間の接続が確立できるという点ではとても使いやすいと感じました。

ポイントをおさらいしますと、ECS Service Connect は…

  • 従来の ELB、Service Discovery、App Mesh のいいとこ取りをしたサービス
  • タスク定義をさほど変更しなくても利用できる
  • テレメトリデータを取得できる
  • ELB のようなインフラや、App Mesh のような別途の管理が不要で利用できる

という感じでしょうか。ご参考になれば幸いです🙇‍♂️

最後に・・・

この投稿は個人的なものであり、所属組織を代表するものではありません。ご了承ください。