1. はじめに
先日、JAWS-UGコンテナ支部×NW-JAWSのコラボイベント「コンテナネットワークを学ぼう!!」会に参加し、ECS Service ConnectとVPC Latticeをハンズオンで実際に構築してきました。
ハンズオンでは各サービスを実際に動かすところまで体験できました。一方で、接続方式によって通信速度がどの程度変わるのかが気になったため、自分でも環境を作って計測してみることにしました。
本記事は、ECSサービス間を接続する複数の方式を東京リージョン内に構築し、原則としてタスクを同一AZに配置した上で、レイテンシを実測します。
比較対象は以下の6方式です。
- 同一VPC・タスクIP直接(ベースライン)
- VPC Peering・タスクIP直接
- VPC Peering・ECS Service Connect
- Transit Gateway・タスクIP直接
- AWS PrivateLink
- Amazon VPC Lattice
上記に加えて、同じNLBとサーバータスクを使用し、PrivateLinkを経由することで増えるレイテンシを切り出す補足実験と、AZをまたぐとどの程度遅くなるかを測る実験も行いました。
なお、実験シリーズ第2弾になりますが、前回の記事(東京-大阪リージョン間の閉域接続3方式の実測)とは計測環境も測定方法も異なるため、数値としての比較は行いません。
本記事はECSのタスク定義・サービスの基本的な使い方をある程度理解しており、サービス間通信の接続方式ごとの実践的なトレードオフを知りたいエンジニアの方に向けた内容です。
みなさんもどれが一番速いか、予想しながら読んでみてください。
2. 検証の全体像
2.1 用語の整理
接続方式は、パケットがどう届くかと相手をどう見つけるかの2層に分けられます。前者はOSI参照モデルのネットワーク層(L3)そのものの話なので、以降は「L3到達性」と表現します。
後者は、DNS名、直接IP、プロキシ、ロードバランサなど、クライアントが接続先をどのように指定し、そこから実際のサービスへ到達するかを示すものです。本記事ではこれらを「接続先の指定方式」と表現します。
今回計測した6方式は、このL3到達性と接続先の指定方式の組み合わせで計測しました。
PrivateLinkとVPC Latticeは、L3到達性と接続先の指定方式が一体のサービスになっていて、他の方式のように2層を自由に組み合わせられません。同一VPC・VPC Peering・Transit Gatewayは、タスクIP直接でもService Connectでも自由に選べる一方、PrivateLinkは、クライアントVPCに作成したInterface VPC Endpointを経由して接続し、通常はエンドポイントに付与されたDNS名を使用します。LatticeはLatticeサービスのDNS名を使用します。これらの違いも意識しながら計測を行いました。
2.2 比較対象パス
最初に図式化すると以下のようになります。私自身、やっていて混乱したので、図に起こして整理しました。

| パス | 構成 | L3到達性 | 接続先の指定方式 | 差分で分かること |
|---|---|---|---|---|
| A | 同一VPC・タスクIP直接 | なし | 直接IP | ベースラインの数値 |
| B | Peering + タスクIP直接 | Peering | 直接IP | B−A = VPCまたぎの素のコスト |
| C | Peering + Cloud Map DNS | Peering | Cloud Map DNS | (計測対象外)名前解決後の動作はBと差がなくなるため見送り |
| D | Peering + Service Connect | Peering | Envoyサイドカー | D−B = サイドカー2ホップのコスト |
| E | 内部ALB | - | ALB DNS | (計測対象外)ALB固有の処理を含み、比較軸から外れるため見送り |
| F | TGW + タスクIP直接 | TGW | 直接IP | F−B = TGWホップのコスト |
| G | PrivateLink | PrivateLink | エンドポイントDNS | G−B = PrivateLink+NLB経由構成とPeering直接構成のレイテンシ差 |
| G′ | Peering + 内部NLB直接(補足実験) | Peering | 内部NLBのDNS | G−G′ = 同一NLB構成でPrivateLinkを経由することによるレイテンシ増加 |
| H | VPC Lattice | VPC Lattice | Latticeサービス名 | H−B = VPC Lattice経由構成とPeering直接構成のレイテンシ差 |
-
Cは、Cloud Mapのサービス名をDNS解決し、返ってきたタスクIPに直接TCP接続するパスです。keep-alive ON/OFFなど計測条件の詳細は5章で説明しますが、keep-alive ONの条件では一度確立した接続を使い回すため、名前解決は最初の1回しか発生しません。接続確立後の通信経路はパスBと完全に同じになるので、今回の規模のリクエスト全体の平均値で見ると1回の名前解決の時間は埋もれてしまい、BとCの差を有効に評価できないだろうと考え、計測を見送りました。
-
Eの内部ALBは、ALB自体のプライベートIPが変動しうるため、GのようにIPを直接指定してPrivateLinkやPeering越しに接続する、というやり方が使えません。内部ALBはDNS名でアクセスします。VPC Peering経由で別VPCから接続する構成も可能ですが、ALB固有のL7処理を含めると比較軸が増えるため、今回は対象外としました。L3接続性の部分についても検討する前に計測対象外にしたので、上の表でも「-」としています。
CとEはこうして計測対象から外したパスですが、パスのID番号は記事・S3・CSVで共通のキーとして使っているため、欠番としました。
- G′は補足実験用のパスで、GとG′は同じタスクを利用し、クライアントからNLBまでの接続経路だけを変えています。G−G′を取ると、PrivateLinkを挟むことで増える時間だけを取り出せます。
さらに、接続方式の比較とは別軸として、AZ配置の影響も測っています。計測前は、A〜Hの接続方式によるレイテンシ差はほとんどないだろうと予想していました。一方、同じ構成でAZをまたぐとどの程度差が出るのかも気になったため、接続方式とは別の比較軸として計測しました。
| パス | 構成 | 差分で分かること |
|---|---|---|
| L1 | 同一VPC・同一AZ(パスAと同一構成) | ベースライン |
| L2 | 同一VPC・AZ間 | L2−L1 = AZを1つまたぐコスト |
2.3 揃えた条件
接続方式の差だけを取り出すために、以下を全パス共通で揃えました。
※前回計測時に途中でNGのパターンが出てきて、対処を検討したので、初期段階で確認して方式を確定しておくことは大事です。
各ECSサービスのDesired Countを1に固定し、さらに、1つのManaged Instanceに複数の計測対象タスクが配置されないようにしました(この方法は後述します)。全パスでタスクのCPU・メモリを同一にし、パスDだけEnvoyサイドカーの分をプラスして乗せる構成にしました。
負荷生成用のクライアントタスクは計測対象のサーバータスクとは別インスタンスにしました。また、パスAの対向タスクについても同様に別インスタンスに置きました。同一インスタンス上のタスク間通信は、他のパスとネットワーク経路が異なりうる(どこで通信が折り返すかわからない)ためです。
※同一インスタンス上のタスク間通信の仕組みをご存知の方がいれば、教えてください。
3. 各方式の技術詳細
今回計測した各ルートの解説をしていきます。先ほどの表を物理構成に落とし込むと以下のようになります。

3.1 同一VPC・タスクIP直接(パスA)
同一VPC内で、クライアントタスクがサーバータスクのプライベートIPに直接HTTPリクエストを送るだけの構成です。VPCや中継サービスを経由しない最も単純な構成であり、他のパスと比較するためのベースラインとします。
3.2 VPC Peering・タスクIP直接(パスB)
2つのVPC間を1対1で接続し、ルートテーブルへの静的ルート追加で到達できるようにした上で、クライアントがサーバーのタスクIPへ直接アクセスします。
今回は関係ありませんが、VPC Peeringは推移的ではないため、VPCが増えるとフルメッシュが必要になる制約は前回記事でも触れた通りです。
3.3 Peering + ECS Service Connect(パスD)
ECS Service Connectは、タスクにEnvoyサイドカーを自動的に注入し、Cloud Map経由のサービス名前解決とプロキシ経由の通信を提供する仕組みです。
サイドカーはタスク定義に明示的に書く必要はなく、サービス側のServiceConnectConfigurationを設定するだけで、ECSが自動的に追加してくれます。
クライアントは自分のタスク内のEnvoyに接続し、Envoyがサーバー側のEnvoyへ転送し、そこからサーバー側のアプリに届くという2ホップ構成になります。App Meshは2026年9月30日にサポート終了予定であり、AWSからはユースケースに応じてECS Service ConnectやVPC Latticeへの移行情報が案内されています。本検証では、App Meshを入れない代わりに、それらを比較対象に含めている点も補足しておきます。
3.4 Transit Gateway・タスクIP直接(パスF)
Transit Gatewayはハブアンドスポーク型の接続です。今回は東京リージョン内の2VPCだけなので、TGWの本来の使い方(多数のVPCの集約管理)ではありませんが、純粋にTGWを経由するホップのコストだけを測る目的で計測しています。
パスBと同じクライアント・サーバーのペアを使い回し、ルートだけをPeeringからTGWに切り替えることで、F − Bが純粋にTGWホップの差になるようにしています。
3.5 AWS PrivateLink(パスG)
NLBの背後にあるサービスをエンドポイントサービスとして公開し、利用側VPCに作成したInterface VPC Endpointからプライベートに接続する構成です。サービス側から利用側へ接続を開始する用途ではなく、利用側からサービス側への接続に使用します。クライアント側にInterface型のVPC Endpointを作成し、そのDNS名でアクセスします。
今回は同一リージョン内での接続なのでPrivateDnsEnabledを有効にすることもできましたが、Endpoint Service側でカスタムドメインを用意していなかったためfalseとし、AWSが自動発行するDNS名をそのまま使っています。
3.6 Amazon VPC Lattice(パスH)
VPC Latticeは、サービスメッシュ的な機能をマネージドに提供する比較的新しいサービスです。ECSサービスにVPC Latticeのターゲットグループを紐づけるだけで、タスク起動時にECSが自動でIPを登録してくれます。NLBのような明示的なターゲット登録は不要です。
VPC Latticeのサービスリスナーとして選択できるプロトコルはHTTP、HTTPS、TLSです。TLSリスナーでは暗号化されたTCPトラフィックをターゲットへパススルーできますが、任意の平文TCPリスナーを構成する方式ではありません。今回は全方式で条件を揃えたかったので、HTTPを計測に統一しました(5章で改めて触れます)。
3.7 補足
CIDR重複可否
VPC Peering・TGWはCIDRが重複しているVPC同士では接続できません。一方PrivateLinkとVPC Latticeは、エンドポイント経由・サービス名経由という接続先の指定方式側の仕組みで到達させるため、L3到達性としてのCIDR設計を意識しなくてよく、両VPCのCIDRが重複していても構成できます。今回はCIDR重複が起きないように設計しましたが、CIDR設計の自由度を優先するか、シンプルにL3到達性を優先するか、どのサービスを利用するか検討する際の観点になると考えます。
App Meshについて
App Meshは2026年9月30日にサポート終了予定であるため、今回の比較対象から外しています。AWSからはユースケースに応じてECS Service ConnectやVPC Latticeへの移行情報が案内されているため、本検証では両方を比較対象に含めました。
本記事の検証は2026年8月末時点の各サービスの仕様に基づいています。対応プロトコルや制約など、今後のアップデートで変わる可能性があります。
4. 検証環境構築
インフラは、一部を手動で構築し、その内容をCloudFormationで再現しつつ最終的に全体を構築しました。
4.1 Managed Instancesの構築
IAMロールの作成
まずIAMロールを4つ作成しました。Managed Instances用に必要なものが2つ、その上で動くタスク側に必要なものが2つです。
ecs-conn-cluster-infra-role
ECSが裏側でEC2インスタンスを起動・管理するためのロール。パスHのVPC Latticeへのターゲット登録もこのロールが行う。iam:PassRoleをインラインで足しているのは、AWS管理ポリシー側のPassRole許可がarn:aws:iam::*:role/ecsInstanceRole*という名前に限定されていて、今回のように自分で名前を付けたインスタンスロールだとカバーされなかったため、後から追加しました。

ecs-conn-cluster-instance-role
起動したEC2インスタンス自身がECSクラスタに参加するためのロール。インスタンスプロファイル経由でインスタンスに渡す。

ecs-conn-app-common-exec-role
タスク起動時にECSエージェントが使うロール。ECRからのイメージ取得とCloudWatch Logsへのログ出力を担う。

ecs-conn-app-common-task-role
コンテナの中で動くアプリ自身が使うロール。ECS Execでコンテナに入る権限と、計測結果をS3に置く権限を付与。

ECSクラスター作成
クラスタは1つだけ作り、その中にキャパシティプロバイダを3つぶら下げました。VPCが2つなのにキャパシティプロバイダが3つなのは、キャパシティプロバイダに指定するセキュリティグループが1つのVPC内でしか有効にならないためです。1つのキャパシティプロバイダでVPC-AとVPC-Bの両方をカバーすることはできないので、VPC-A用・VPC-B用に分ける必要がありました。3つ目はAZ配置の比較(L2)でサーバーを別AZに置くための、VPC-A内のもう1つのサブネット用です。

キャパシティプロバイダの名前をecs-で始めようとしたら弾かれました。ecsで始まる名前は予約されていました。

インスタンスの条件は、vCPU数もメモリも最小値と最大値に同じ値を入れてc7g.xlargeに固定しました。後述する1インスタンス1タスクの力技のために、インスタンスの容量を確定させておく必要があったためです。


タスク定義
タスク定義は、クライアント用とサーバー用の2つを用意しました。
起動タイプにあたるRequiresCompatibilitiesにはMANAGED_INSTANCESを指定します。ネットワークモードはawsvpc、CPUは4096、メモリは7168にしました。c7g.xlargeが4vCPU/8192MiBなので、メモリだけ1024MiB分をホストOSとECSエージェント用に残した形です。CPUアーキテクチャはGravitonなのでARM64を明示します。
サービス側で1つハマりました。AvailabilityZoneRebalancingは既定で有効になっているのですが、これが有効だと、デプロイのたびに、新旧のタスクが一時的に同時に存在する前提の容量が必要になります。タスクが1つしかないサービスでも2倍の容量を要求される形になってしまいます。
前述の通り、今回は同時に動かすタスク数を最小限にしたかったので、AvailabilityZoneRebalancingを無効にした上で、MinimumHealthyPercentを0、MaximumPercentを100にして、一度止めてから作り直す形のデプロイに制御しました。
また、ECS Execを使うのでEnableExecuteCommandも有効にしました。
具体的な定義ファイルは次項のCloudFormation定義内に記載してあります。
CloudFormation定義
一部を手で作りつつ確認した内容をAIエージェントに取り込んでもらってCloudFormationに起こしたものです。
長いので折りたたんでおきます。
ecs-conn-cluster.yaml(クラスタ・キャパシティプロバイダ・IAMロール)
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
ECS service connectivity benchmark: cluster layer. One ECS cluster, three
Managed Instances capacity providers, and the IAM roles they need.
Parameters:
VpcAId:
Type: String
SubnetAPrimaryId:
Type: String
SubnetASecondaryId:
Type: String
SubnetBPrimaryId:
Type: String
SgTaskAId:
Type: String
SgTaskBId:
Type: String
InstanceType:
Type: String
Default: c7g.xlarge
InstanceVCpuCount:
Type: Number
Default: 4
Description: Must match InstanceType's vCPU count exactly (single-task sizing trick).
InstanceMemoryMiB:
Type: Number
Default: 8192
TaskMemoryReservationMiB:
Type: Number
Default: 7168
Description: InstanceMemoryMiB minus headroom for the host OS and ECS agent.
ServiceConnectNamespaceName:
Type: String
Default: ecs-conn.local
Resources:
Cluster:
Type: AWS::ECS::Cluster
Properties:
ClusterName: !Sub "${AWS::StackName}"
ServiceConnectDefaults:
Namespace: !Ref ServiceConnectNamespace
ClusterSettings:
- Name: containerInsights
Value: enhanced
# Service Connect requires a Cloud Map namespace to exist before the
# cluster references it as a default. Not an ECS-managed namespace
# (that only happens via the console "create cluster" flow).
ServiceConnectNamespace:
Type: AWS::ServiceDiscovery::PrivateDnsNamespace
Properties:
Name: !Ref ServiceConnectNamespaceName
Vpc: !Ref VpcAId
# ---------------- IAM ----------------
# The AWS managed policy's PassRole statement is scoped to
# arn:aws:iam::*:role/ecsInstanceRole* -- it does NOT cover an
# arbitrarily-named instance role like the one below.
InfrastructureRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub "${AWS::StackName}-infra-role"
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: ecs.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonECSInfrastructureRolePolicyForManagedInstances
- arn:aws:iam::aws:policy/AmazonECSInfrastructureRolePolicyForVpcLattice
Policies:
- PolicyName: PassInstanceRole
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action: iam:PassRole
Resource: !GetAtt InstanceRole.Arn
Condition:
StringLike:
iam:PassedToService: ec2.*
InstanceRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub "${AWS::StackName}-instance-role"
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: ec2.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonECSInstanceRolePolicyForManagedInstances
InstanceProfile:
Type: AWS::IAM::InstanceProfile
Properties:
Roles: [!Ref InstanceRole]
# ---------------- Capacity providers ----------------
CapacityProviderVpcAMain:
Type: AWS::ECS::CapacityProvider
Properties:
Name: conn-cp-vpc-a-main # CP names cannot start with "ecs"
ClusterName: !Ref Cluster
ManagedInstancesProvider:
InfrastructureRoleArn: !GetAtt InfrastructureRole.Arn
InstanceLaunchTemplate:
Ec2InstanceProfileArn: !GetAtt InstanceProfile.Arn
NetworkConfiguration:
Subnets: [!Ref SubnetAPrimaryId]
SecurityGroups: [!Ref SgTaskAId]
InstanceRequirements:
VCpuCount:
Min: !Ref InstanceVCpuCount
Max: !Ref InstanceVCpuCount
MemoryMiB:
Min: !Ref InstanceMemoryMiB
Max: !Ref InstanceMemoryMiB
AllowedInstanceTypes: [!Ref InstanceType]
Monitoring: BASIC
# VPC-A, L2 (apne1-az2). L2's peer task only.
CapacityProviderVpcAL2:
Type: AWS::ECS::CapacityProvider
Properties:
Name: conn-cp-vpc-a-l2
ClusterName: !Ref Cluster
ManagedInstancesProvider:
InfrastructureRoleArn: !GetAtt InfrastructureRole.Arn
InstanceLaunchTemplate:
Ec2InstanceProfileArn: !GetAtt InstanceProfile.Arn
NetworkConfiguration:
Subnets: [!Ref SubnetASecondaryId]
SecurityGroups: [!Ref SgTaskAId]
InstanceRequirements:
VCpuCount:
Min: !Ref InstanceVCpuCount
Max: !Ref InstanceVCpuCount
MemoryMiB:
Min: !Ref InstanceMemoryMiB
Max: !Ref InstanceMemoryMiB
AllowedInstanceTypes: [!Ref InstanceType]
Monitoring: BASIC
# VPC-B, main path (apne1-az1). Server tasks for A/B/D/F/G/H.
CapacityProviderVpcBMain:
Type: AWS::ECS::CapacityProvider
Properties:
Name: conn-cp-vpc-b-main
ClusterName: !Ref Cluster
ManagedInstancesProvider:
InfrastructureRoleArn: !GetAtt InfrastructureRole.Arn
InstanceLaunchTemplate:
Ec2InstanceProfileArn: !GetAtt InstanceProfile.Arn
NetworkConfiguration:
Subnets: [!Ref SubnetBPrimaryId]
SecurityGroups: [!Ref SgTaskBId]
InstanceRequirements:
VCpuCount:
Min: !Ref InstanceVCpuCount
Max: !Ref InstanceVCpuCount
MemoryMiB:
Min: !Ref InstanceMemoryMiB
Max: !Ref InstanceMemoryMiB
AllowedInstanceTypes: [!Ref InstanceType]
Monitoring: BASIC
ClusterCapacityProviderAssociations:
Type: AWS::ECS::ClusterCapacityProviderAssociations
Properties:
Cluster: !Ref Cluster
CapacityProviders:
- !Ref CapacityProviderVpcAMain
- !Ref CapacityProviderVpcAL2
- !Ref CapacityProviderVpcBMain
DefaultCapacityProviderStrategy: []
Outputs:
ClusterArn:
Value: !GetAtt Cluster.Arn
InfrastructureRoleArn:
Value: !GetAtt InfrastructureRole.Arn
Description: Used by path H's Service.VpcLatticeConfigurations.RoleArn.
TaskMemoryReservationMiB:
Value: !Ref TaskMemoryReservationMiB
ecs-conn-app-a.yaml(パスAのタスク定義とECSサービス)
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
ECS service connectivity benchmark: path A (same VPC, direct task IP).
Client and server are two independent services on the SAME capacity
provider; single-task-mode-by-sizing guarantees they land on different
instances automatically.
Parameters:
ClusterArn:
Type: String
CapacityProviderName:
Type: String
SubnetId:
Type: String
SecurityGroupId:
Type: String
RepositoryUri:
Type: String
ImageTag:
Type: String
Default: latest
ExecutionRoleArn:
Type: String
TaskRoleArn:
Type: String
TaskCpu:
Type: String
Default: "4096"
TaskMemory:
Type: String
Default: "7168"
AppPort:
Type: Number
Default: 8080
AzId:
Type: String
Default: apne1-az1
Description: >-
Passed as an env var, not resolved at runtime -- task metadata
returns an AZ NAME, not an AZ ID.
Resources:
ClientTaskDefinition:
Type: AWS::ECS::TaskDefinition
Properties:
Family: ecs-conn-a-client
RequiresCompatibilities: [MANAGED_INSTANCES]
NetworkMode: awsvpc
Cpu: !Ref TaskCpu
Memory: !Ref TaskMemory
RuntimePlatform:
CpuArchitecture: ARM64
OperatingSystemFamily: LINUX
ExecutionRoleArn: !Ref ExecutionRoleArn
TaskRoleArn: !Ref TaskRoleArn
ContainerDefinitions:
- Name: app
Image: !Sub "${RepositoryUri}:${ImageTag}"
Essential: true
Command: ["/app/measure-app", "-port", !Sub "${AppPort}", "-procs", "4"]
Environment:
- Name: AZ_ID
Value: !Ref AzId
PortMappings:
- Name: app
ContainerPort: !Ref AppPort
Protocol: tcp
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-create-group: "true"
awslogs-group: /ecs-conn/a-client
awslogs-region: !Ref AWS::Region
awslogs-stream-prefix: app
SystemControls:
# TIME_WAIT socket buildup found the hard way: --disable-keepalive
# conditions open a fresh connection per request, exhausting
# ephemeral ports. tcp_tw_reuse lets a TIME_WAIT socket be reused
- Namespace: net.ipv4.tcp_tw_reuse
Value: "1"
- Namespace: net.ipv4.ip_local_port_range
Value: "1024 65535"
# ServerTaskDefinition is identical except Family / awslogs-group.
ClientService:
Type: AWS::ECS::Service
Properties:
ServiceName: ecs-conn-a-client
Cluster: !Ref ClusterArn
TaskDefinition: !Ref ClientTaskDefinition
DesiredCount: 1
EnableExecuteCommand: true
CapacityProviderStrategy:
- CapacityProvider: !Ref CapacityProviderName
Weight: 1
NetworkConfiguration:
AwsvpcConfiguration:
Subnets: [!Ref SubnetId]
SecurityGroups: [!Ref SecurityGroupId]
AssignPublicIp: DISABLED
# AvailabilityZoneRebalancing defaults to ENABLED and forbids
# MaximumPercent<=100, which forces every deploy to briefly need
# 2x capacity even for a single desiredCount service. Found the
# hard way: 8 services force-deployed at once burned through the
# whole vCPU quota mid-rollout.
AvailabilityZoneRebalancing: DISABLED
DeploymentConfiguration:
MinimumHealthyPercent: 0
MaximumPercent: 100
DeploymentCircuitBreaker:
Enable: true
Rollback: true
Outputs:
ClientServiceName:
Value: !GetAtt ClientService.Name
4.2 CloudFormationのスタック構成の工夫
CloudFormationで組むのにあたり、いくつか工夫しました。
競合するルートの対応
VPC PeeringとTGWは同じ対向VPC CIDRを宛先にするため、同じルートテーブルに両方のルートを共存させることができません。前回記事のリージョン間接続でも踏んだのと同じ制約です。競合する2つのルートエントリはCloudFormationには書かず、CLIで対処しました。
コスト対策
途中でネットワーク層とエンドポイント層を分割しました。理由は私のスキル不足とコストです。思ったよりも計測がうまくいかず、何度かやり直すことになり、複数日にまたがって計測することになりました。VPCエンドポイント14個は時間課金が積み上がる一方、VPC・サブネット・SGといった基盤部分は無料です。安価で常設してよいものと、計測が終わったら都度消すべき高額なリソースを、スタック単位で分けて、課金をできるだけ抑える方式を取りました。タスク数も手動で0にしてコストを抑えつつ、次回計測開始を早められるようにしました。
4.3 なぜECS Managed Instancesを選んだか
今回はECS Managed Instancesを使っています。理由は単純で、Fargateはこれまでの検証記事で何度も使ってきたので、ECSの起動タイプの中で一番最後にGAされたManaged Instancesを今回は使ってみたかったからです。
Fargateとの速度比較は行っていません。基盤そのものが別物(ホストもENIの生え方も配置も違う)で、単純比較にならないためです。
Managed Instancesは「タスクを置くとインスタンスが自動で用意される」という体験としてはFargateに近いのですが、正体は管理されたEC2です。ホストOSはBottlerocket固定で、SSHは無効、カスタムAMIも配置グループも指定できません。計測はすべてECS Exec経由でコンテナ内から実行しています。
4.4 ハマった点: 1インスタンス1タスクの実現方法
ドキュメントには「必要に応じてシングルタスクモードを使用する – ワークロードでより強力な分離が必要な場合は、Amazon ECS マネージドインスタンスがシングルタスクモードを使用するように設定します。」という記載があったのですが、対応するパラメータが見当たりませんでした。
同じドキュメント内には「マルチタスク配置 – デフォルトでは、Amazon ECS マネージドインスタンスはコストと使用率を最適化するために 1 つのインスタンスに複数のタスクを配置するため、Fargate と比較してワークロード分離の制約が緩和されます。」という記載もあり、1インスタンスに複数パスのタスクが相乗りしてしまい、比較の前提が成り立たなくなってしまうと考えました。
結局、タスクのCPU・メモリをインスタンスの容量にほぼ一致させる力技(c7g.xlarge 4vCPU/8192MiBに対しタスク4vCPU/7168MiB)で対処しました。インスタンスの容量をタスクがほぼ使い切ってしまえば、2つ目のタスクが物理的に入る余地がなくなります。同一キャパシティプロバイダに2つのサービスを同時に投入しても相乗りしないことを実機で確認できたので、この力技で進めました。
※正しいやり方をご存知の方、教えてください。
4.5 ハマった点: プライベートサブネットのVPCエンドポイント不足
Managed Instancesのタスクはプライベートサブネットに置いており、NATゲートウェイは使っていません。最初、ECR・CloudWatch Logs・SSM関連のエンドポイントだけを用意してデプロイしたところ、Task timed out after 300というエラーが延々と続き、コンテナインスタンスが1台も登録されない状態になりました。
原因は、インスタンス自体がECSのコントロールプレーンに接続するためのecs・ecs-agent・ecs-telemetryのエンドポイントが抜けていたことでした。ECR・ログ・SSM用のエンドポイントだけでは、インスタンスがクラスタに参加できず失敗していました。AIエージェントと調査を進め、3つ追加したところ解決しました。
この3つのエンドポイントは、実際にEC2インスタンス上でECSエージェントが動くManaged Instances特有の要件です。Fargateにはユーザーが意識すべきEC2インスタンスという概念自体がないので、これまでFargateだけを使ってきた中では出会わなかった要件でした。今回Managed Instancesを初めて構築してみて気づいたポイントです。
5. 計測方法
5.1 指標をHTTPに一本化した理由
今回、接続方式の比較はHTTPのみで測っています。当初はTCPコネクト確立時間も併せて取る計画でしたが、プロキシを挟むパスで条件を揃えることができないことに気づき、やめました。
パスDはクライアントアプリが接続するのが自分のタスク内のEnvoyなので、TCPコネクト時間を測るとローカルプロキシへの接続時間(ほぼゼロと想定)になってしまい、パスAより速い数字が出ることが想定されました。パスHでは、VPC LatticeのサービスリスナーとしてHTTP、HTTPS、TLSを選択できますが、他の方式と同じ条件で任意の平文TCP接続時間を比較する構成にはできませんでした。一部のパスだけ取れる指標を比較表に載せると、誤解を生むと考え、全パス共通で成立するHTTPでの計測に統一しました。
リクエストのプロトコルも全パス平文HTTP/1.1に固定しています。Latticeはリスナー側でマネージドにTLS終端できるため、アプリを変えずにHTTPS/TLSリスナーも選べますが、今回使用した検証用アプリはTLSに対応していないため、全パスでHTTPS化することができませんでした。そのため全パス合わせて平文のままにしています。
5.2 計測条件
計測した際の条件は以下の通りです。
| 項目 | 設定 |
|---|---|
| 1条件あたりのリクエスト数 | 10,000 |
| 1条件あたりの試行回数 | 3 |
| keep-alive | ON / OFF 両方 |
| 並列度 | 1 / 16 |
| ペイロード | 1KBを全パス。1MBはB・D・G・Hでのみ計測 |
| プロトコル | 全パス平文 HTTP/1.1 |
| 統計 | p50 / p95 / p99 |
負荷生成にはoha(Rust製のHTTP負荷試験ツール)を使いました。JSON出力は--output-format jsonを指定することで実現できました。
実際に実行したコマンドの一例です(パスA・1KBペイロードの場合)。keep-alive ON/OFFと並列度1/16の組み合わせで4つ条件があるので、--disable-keepaliveの有無と-cの値を変えながら、同じコマンドを4回実行します。並列16のときは-c 16を指定します。
OHA="oha --no-tui --output-format json --worker-threads 4"
URL=http://<server-ip>:8080/bytes/1024
$OHA -n 10000 -c 1 --disable-keepalive $URL > A_ka-off_c1_1k.json
$OHA -n 10000 -c 1 $URL > A_ka-on_c1_1k.json
$OHA -n 10000 -c 16 --disable-keepalive $URL > A_ka-off_c16_1k.json
$OHA -n 10000 -c 16 $URL > A_ka-on_c16_1k.json
このうちA_ka-on_c1_1k.json(keep-alive ON・並列1)の出力を、metrics部分だけ抜粋します。
{
"metrics": {
"success_rate": 1.0,
"requests_per_sec": 15352.41,
"latency_ms": {
"min": 0.052,
"mean": 0.064,
"p50": 0.064,
"p95": 0.07,
"p99": 0.08,
"max": 0.329
}
}
}
同じ形の出力が、4条件×3回の試行×9パス分、繰り返し得られました。貼り付けた値はその1回分の実行結果です。6章の表(3回の平均)とは微妙にずれていますのでご注意ください。
5.3 ハマった点: パーセンタイル値と計測回数
当初は前回記事に倣って300回で計測するつもりでしたが、計測したらかなりのブレが出ました。調べたところ、ohaのp99は、全リクエストを速い順に並べて0.99×N番目(300件なら297番目)の実測値をそのまま採用する実装でした。つまり300件では、一番遅かった方から数えて3番目の、たった1回の実測値がそのままp99の数字になります。ランタイムのGCやOSのスケジューリングなどによる一時的な遅延が、その1回にたまたま入るだけで、p99の値は大きく振れてしまいます。
試走で3,000回まで増やして同一条件を3回繰り返したのですが、p99が平均から最大17%もばらつき、まだ不十分でした。さらに10,000回まで増やして同様に3回繰り返したところ、ばらつきは4%程度に収まったため、10,000回で全パターン計測することにしました。
5.4 ハマった点: keep-alive OFF条件でのTIME_WAIT枯渇
再計測を進める中で、keep-alive OFFの条件で、3〜4回に1回程度の頻度でp50が3〜25倍に跳ね上がる現象に遭遇しました。p99はさらに大きくズレました。原因が分からず、リソース不足の可能性も考慮してECS Container Insightsを有効にしてリソースを確認したところ、クライアント側のCPU使用率が異常発生時にだけ5倍近くまで跳ね上がっていることが分かりました。
原因が特定できず、AIエージェントに分析を手伝ってもらい調査を進めたところ、解決しました。--disable-keepaliveを指定した計測では各リクエストで新規TCP接続を張るため、10,000リクエストの実行によって、クライアント側で多数のポートを消費し、接続終了後、最大60秒程度TIME_WAIT状態としてカーネルに残留していました。計測を3回繰り返していたため、計測の実行間隔が短いと、TIME_WAITが自然解消される前にポートが使われて積み上がっていき、累積がおおよそ14,000件を超えたあたりで新規ポート確保のコストが跳ね上がる、という動きが分かりました。
タスク定義のSystemControlsでnet.ipv4.tcp_tw_reuse=1を設定し、TIME_WAITソケットの即時再利用を許可したところ、同じ条件を6回連続で実行してもp50が揺れなくなりました。
この設定は検証環境なので即時適用しましたが、本番環境への適用可否は通信経路、カーネルバージョン、NATやロードバランサの有無などのネットワークに影響する構成要素によって異なると考えられるため、個別に検証する必要があります。本記事では、あくまで計測環境を安定させるための設定として使用しています。
また、調査を進める中で、昔、ポート枯渇によって新規セッションを確立できない事象があったことを思い出しました。
さて、この対策を全パスに反映してkeep-alive OFFの条件で再々・・・計測したところ、p99のばらつき(3回計測の平均からの最大偏差)は対策前の最大249%から、対策後は最大33.7%まで改善しました。6章の結果は、この対策後のデータです。
6. 結果
いよいよ計測結果です。単位はすべてミリ秒です。
6.1 接続方式の比較(1KB、10,000リクエスト×3回の平均)
keep-alive ONの場合(1KB、単位はミリ秒)
| パス | p50 | p95 | p99 | 並列16 p50 | 並列16 p99 |
|---|---|---|---|---|---|
| A(同一VPC直接) | 0.065 | 0.072 | 0.085 | 0.104 | 0.249 |
| B(Peering直接) | 0.077 | 0.082 | 0.091 | 0.105 | 0.209 |
| D(Peering + Service Connect) | 0.463 | 0.508 | 0.564 | 1.614 | 2.792 |
| F(TGW直接) | 0.257 | 0.271 | 0.285 | 0.212 | 0.395 |
| G(PrivateLink) | 0.509 | 0.559 | 0.609 | 0.527 | 0.718 |
| H(VPC Lattice) | 1.330 | 1.712 | 1.872 | 1.221 | 1.858 |
keep-alive ON・並列1で見ると、A→B→F→D→G→Hの順に遅くなっていくのが分かります。この順序はp50・p95・p99のいずれで見ても同じでした。以下、差分はすべてp50同士の差です。同一VPC直接(A)とPeering直接(B)はほぼ同じで、B−Aの差は0.012ms程度とごくわずかでした。Service Connect(D)はEnvoyサイドカーを2ホップ挟む分、Bより0.39ms程度遅くなりました。PrivateLink(G)とVPC Lattice(H)はさらに遅く、Hが最も遅いという結果になりました。
順序は変わらないものの、差の大きさはパーセンタイルによって変わります。主な差分をp50・p95・p99で並べると次のようになりました(keep-alive ON・並列1、単位はミリ秒)。
パス別パーセンタイル値の整理
| 差分 | 意味 | p50 | p95 | p99 |
|---|---|---|---|---|
| B−A | VPCまたぎの素のコスト | 0.012 | 0.010 | 0.006 |
| F−B | TGWホップのコスト | 0.180 | 0.189 | 0.195 |
| D−B | サイドカー2ホップのコスト | 0.387 | 0.426 | 0.473 |
| G−B | PrivateLink+NLB経由構成とPeering直接構成との差 | 0.432 | 0.477 | 0.518 |
| H−B | VPC Latticeのコスト | 1.253 | 1.630 | 1.781 |
| G−G′ | 同一NLB構成でPrivateLinkを経由することによる増加 | 0.108 | 0.134 | 0.139 |
それぞれのp50を100として相対値でグラフ化すると、傾向がはっきりします。
VPC Lattice(H)は、遅いリクエストで見るほど、他の方式との差が大きくなりました。Peering直接との差(H−B)はp50では1.25msですが、p99では1.78msと1.4倍に開きました。PrivateLinkとの差で見ても同じ傾向で、p50の0.82msに対しp99では1.26msと1.5倍になっています。Latticeを選ぶ場合、p50で見た印象よりも、遅いリクエストでの差は大きくなると考えておいた方がよさそうです。
補足実験のG−G′(PrivateLinkを挟むことで増える分)も、p50の0.108msに対しp99では0.139msと1.3倍に広がっています。今回計測したPrivateLinkとVPC Latticeでは、比較対象としたベース構成との差が、p50よりもp99で大きくなる傾向が見られました。
逆にB−Aだけは、遅いリクエストほど差が縮まりました(p50で0.012ms、p99で0.006ms)。同一VPCとVPC Peeringの差は、遅い側で見るとかなり小さくなりました。
上記の順序は並列1で見た場合の話です。並列16になると順番が変わって、Service Connect(D)がVPC Lattice(H)より遅くなる逆転が起きました。並列1と並列16のp50を並べると次のようになります。
並列度によるp50の変化(keep-alive ON・1KB)
| パス | 並列1 | 並列16 | 倍率 |
|---|---|---|---|
| A(同一VPC直接) | 0.065 | 0.104 | 1.6倍 |
| B(Peering直接) | 0.077 | 0.105 | 1.4倍 |
| F(TGW直接) | 0.257 | 0.212 | 0.8倍 |
| D(Service Connect) | 0.463 | 1.614 | 3.5倍 |
| G(PrivateLink) | 0.509 | 0.527 | 1.0倍 |
| H(VPC Lattice) | 1.330 | 1.221 | 0.9倍 |
並列度を16に上げても、ほとんどのパスはp50がほぼ変わりませんでした。TGW(F)とVPC Lattice(H)は並列16の方が小さい値となりましたが、今回の試行回数では断言できるほどの改善とは判断できませんでした。その中でService Connect(D)だけが3.5倍に悪化し、PrivateLink(G)とVPC Lattice(H)を追い越して最下位になりました。
あくまでも推測ですが、Service Connectはクライアント側・サーバー側それぞれのタスク内で動くEnvoyサイドカーを経由する構造なので、並列度が上がるとタスク内の限られたCPU・メモリをアプリコンテナとEnvoyで奪い合い、ボトルネックになりやすいのではないかと考えています。一方Latticeは、AWSが管理する外部のデータプレーンを経由するため、単一タスクのリソース制約を受けにくく、並列度が上がってもDほど遅くならなかったのではないかと推測しています(あくまでも私の推測ですのでご注意ください)。
ここまではkeep-alive ONの結果です。OFFにすると傾向が変わりました。
keep-alive OFFの場合(1KB、単位はミリ秒)
| パス | p50 | p95 | p99 | 並列16 p50 | 並列16 p99 |
|---|---|---|---|---|---|
| A(同一VPC直接) | 0.222 | 0.272 | 0.293 | 0.357 | 0.645 |
| B(Peering直接) | 0.235 | 0.293 | 0.300 | 0.371 | 0.652 |
| D(Peering + Service Connect) | 0.589 | 0.651 | 0.707 | 2.403 | 4.224 |
| F(TGW直接) | 0.572 | 0.931 | 1.104 | 0.623 | 1.156 |
| G(PrivateLink) | 2.021 | 2.594 | 2.800 | 1.909 | 2.706 |
| H(VPC Lattice) | 2.621 | 3.710 | 4.051 | 2.256 | 3.366 |
OFFにすると全パスが遅くなりますが、影響の受け方はパスによってかなり違いました。最も大きかったのはPrivateLink(G)で、0.509msから2.021msと4.0倍に悪化しています。次いで同一VPC直接(A)が3.4倍、Peering直接(B)が3.1倍でした。
逆に最も影響が小さかったのはService Connect(D)で、1.3倍にとどまっています。クライアントが接続する相手が自分のタスク内のEnvoyなので、keep-aliveをOFFにしても切断されるのはローカルのプロキシとの接続だけで、Envoyがサーバー側との接続を維持しているためではないかと考えています。VPC Lattice(H)が2.0倍と中間だったのも、同じくマネージドなデータプレーンを経由しているためかもしれません。ただし、これは検証していない仮説です。
PrivateLinkがkeep-alive OFFの影響を大きく受けた理由としては、新規接続のたびにInterface VPC EndpointおよびNLBを経由する接続処理が発生することが考えられます。ただし、本検証では内部処理を直接観測していないため、原因の特定には至っていません。今回の計測ではkeep-alive ON時の影響は小さい傾向でしたが、都度接続を張るような使い方をする場合は、この差は意識しておいた方がよいと考えます。
6.2 補足実験: NLB直接接続にPrivateLinkを追加すると何ms増えるか
GとG′(Peering + 内部NLB直接)は同じサーバータスクを共有しており、クライアントの宛先だけが違います。G−G′を取ると、PrivateLinkを挟むことで増える時間だけを取り出せます。
| パス | keep-alive ON p50 | keep-alive OFF p50 |
|---|---|---|
| G′(NLB直接) | 0.401 | 1.532 |
| G(PrivateLink) | 0.509 | 2.021 |
p50で見ると、ONだと+0.11ms、OFFだと+0.49ms程度、PrivateLinkの方が余計にかかりました。私の想定ですが、PrivateLinkのInterface Endpointは、単純な経路ではなくCIDR重複に対応できる双方向NATのようなサービス接続の機構が含まれていて、この差が生じていると考えています。
6.3 AZ配置の影響(L2−L1)
接続方式の比較とは別軸として、サーバータスクを別AZに配置して、AZまたぎにした場合の影響も測りました。指標はTCPコネクト確立時間です。
| パス | p50 | p99 |
|---|---|---|
| L1(同一AZ、= パスA) | 0.260 | 0.318 |
| L2(AZ間) | 1.151 | 1.237 |
AZを1つまたぐことで、TCPコネクト確立時間はp50で0.89ms程度増えることが分かりました。6.1のHTTPレイテンシとは計測指標が異なるため単純比較はできませんが、少なくとも新規接続を頻繁に確立するワークロードでは、AZ配置が無視できない影響を持つことが分かりました。
6.4 スループット(1MB、並列16)
1MBペイロードで並列16のRPSを見ると、B・D・G・H・G′がいずれも1,470〜1,480RPS程度に収束していました。これは1MB(1,048,576バイト)×1,477RPS≒12.4Gbpsに相当します。
改めてiperf3で単一ストリーム・並列16ストリームの帯域の上限を別途計測したところ、単一ストリームは9.53Gbps、並列16ストリームは12.40Gbpsとなり、16並列でoha側の実測と一致しました。つまりこの条件では、接続方式の差ではなく、検証に使ったインスタンスタイプ(c7g.xlarge、公称ネットワーク性能12.5Gbps)のネットワーク帯域そのものが上限になっていると考えられます。
結果的にスループットの評価は諦めました。
7. 考察
一通りの結果を整理できましたので、バラバラと書いていた考察、見解をまとめていきます。
7.1 ベースラインとの差
keep-alive ON・並列1のp50を軸に考えると、次のように説明できそうです。以下の差分はすべてp50同士の差です。
- A→B(+0.01ms程度): 今回の環境および計測条件では、同一AZ内でVPC Peeringを経由することによるレイテンシ増加は、ごくわずかでした。
- B→D(+0.39ms程度): Envoyサイドカーを2ホップ挟むコストです。クライアント側・サーバー側それぞれにローカルプロキシが入る構造上、これくらいの増加は妥当に思えます。
- B→F(+0.18ms程度): TGWを挟むコストです。Peering直接より遅くなりました。前回記事のリージョン間検証でもTGWがPeeringよりレイテンシが遅くなる結果が出ており、今回の同一AZ内の結果も同じ傾向でした。
- B→G(+0.43ms程度): PeeringでタスクIPへ直接接続する構成と、PrivateLinkおよびNLBを経由する構成との差です。PrivateLinkを追加したことによる差は、同じNLBとサーバータスクを使用したG−G′で確認します。
- G→H(+0.82ms程度): VPC LatticeはPrivateLink構成よりさらに遅い結果となりました。VPC Latticeではサービス単位のルーティングなど、PrivateLinkより多くの処理を挟んでいるのではないかと推測します。
- B→H(+1.25ms程度): VPC Latticeを経由する構成では、PeeringでタスクIPへ直接接続する構成より遅くなり、今回の比較では最も差が大きくなりました。絶対値では約1.25msの増加ですが、もともとのBが0.077msなので、相対的には大きな差です。正直、もう少し他の方式に近い結果になると予想していました。
7.2 接続方式の差はミリ秒単位だが、条件によって傾向が変わる
今回の環境では、接続方式によるp50の絶対差はおおむねミリ秒単位に収まりました。一方、相対値では方式間に大きな差があり、並列度やkeep-aliveの有無によって順位や差の大きさも変化しました。
数ミリ秒の増加を許容できるワークロードであれば、速度だけを過度に重視する必要はなく、接続性、認証・認可、可観測性、運用負荷、コストなどを含めて選ぶのがよいと考えます。一方、マイクロ秒単位のレイテンシが重要なワークロードでは、直列・複数回のリクエストで差分が積み重なっていった場合など、方式ごとの差を無視できないと考えます。
なお、正直、VPC Latticeは他とほぼ差がないと思っていたので、想定していたより他サービスと差がついたという点はやってみたことで分かった点です。
8. 注意点
この検証は個人の検証であり、厳密さを保証するものではありませんのでご注意ください。
- 各条件は10,000リクエストを3回計測した平均値です。前回記事同様、時間帯や曜日など、統計的な面での計測、確認は行っていません。
- 同一の計測セッション中はインスタンスが固定されているため、インスタンスの個体差はカバーできていません。
9. まとめ
今回、ECSのサービス間通信6方式である、同一VPCでの直接接続、VPC Peeringでの直接接続、ECS Service Connect、Transit Gateway、AWS PrivateLink、Amazon VPC Latticeを東京リージョンに構築し、同一AZに配置した上でレイテンシを実測しました。
レイテンシに関しては、keep-alive ON、並列1のp50で見ると、同一VPC直接(0.065ms)→Peering直接(0.077ms)→TGW直接(0.257ms)→Service Connect(0.463ms)→PrivateLink(0.509ms)→VPC Lattice(1.330ms)という順に遅くなっていくことが確認できました。絶対値としての差はミリ秒単位でしたが、相対的には大きな差があり、並列度やkeep-aliveの有無によって傾向も変化しました。
一方で、AZをまたぐ構成では、TCPコネクト確立時間のp50が約0.89ms増加しました。HTTPレイテンシとは異なる指標のため単純に比較はできませんが、新規接続が多いワークロードでは、接続方式だけでなくAZ配置の影響も重要な評価要素になるという点が改めて分かりました。
検証の過程では、Managed Instancesの1インスタンス1タスク化、プライベートサブネットのVPCエンドポイント不足、パーセンタイル値とリクエスト数の関係、ネットワーク帯域の上限、keep-alive OFF時のTIME_WAIT枯渇と、いくつも手戻りを経験しました。これらは実際に手を動かして初めて見えてくるもので、同様の検証をする方の参考になるよう、なるべく書き残しました。他にも細々としたことをやらかしたので、思った以上に時間が掛かりました。
今回の結果から、速度だけで接続方式を選ぶのではなく、要求レイテンシ、並列度、接続の再利用、CIDR設計、認証・認可、運用管理のしやすさ、コストなどを含めて判断する必要があると考えています。次は、タスクのスケールアウト・スケールインやローリングデプロイ時の挙動も含め、ECSサービス間通信方式の選定指針を整理してみたいと思います。
これからECSのサービス間通信方式を検討されるどなたかの参考になれば幸いです。



