4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【実験】AWS 東京と大阪を閉域で繋ぐ3つの方法、実際どれくらい速さが違うのか

4
Posted at

1. はじめに

マルチリージョン構成でのDR(Disaster Recovery)や、ワークロードのレイテンシ要件から、東京リージョン(ap-northeast-1)と大阪リージョン(ap-northeast-3)のような同一パーティション内の複数リージョンをインターネットを経由しない閉域網で接続したい場面が出てくると思います。AWSにはこれを実現する手段が複数あり、それぞれ特性・制約・コスト構造が異なります。

Transit Gatewayによるマルチリージョン構成自体は試したことがありますが、速さについては比較したことがなかったなと思い、実際どれくらいの違いになるのか実験してみることにしました。
本記事は、以下の3方式を実際に個人のAWSアカウント上に構築し、AZ内・同一リージョン内AZ間・リージョン間でレイテンシとスループットを実測し、差異とその要因、選定指針をまとめたものです。

  • VPC Peering(inter-region)
  • AWS PrivateLink(cross-region)
  • AWS Transit Gateway(inter-region peering)

みなさんもどれが一番早いか、ぜひ予想しながら読んでみてください。

と言っていると、Site-to-Site VPN等もあるといった声も聞こえてきますが、これらは実測対象から外しています。理由は後ほど述べます。

なお、AWSのVPCネットワーキングの基礎(サブネット、ルートテーブル、セキュリティグループ)をある程度理解しており、マルチリージョン設計の実践的なトレードオフを知りたいエンジニアの方に向けた内容です。

2. 検証の全体像

2.1 比較対象パス

以下の内容で比較してみました。

分類 パス 接続方式
A 東京1a ↔ 東京1a 同一AZ内(ベースライン)
B 東京1a ↔ 東京1c 同一リージョン内AZ間(ベースライン)
C 東京1a ↔ 大阪3a VPC Peering
D 東京1a ↔ 大阪3a Transit Gateway inter-region peering
E 東京1a ↔ 大阪3a PrivateLink(cross-region)

architecture.png

AとBをベースラインに置くのは、「リージョンをまたぐことそのもののコスト」と「AZをまたぐことのコスト」を切り分けて見るためです。AとBは、これまで多くの方が計測されていますが、C〜Eの差に加えて、A・Bとの比較も行うことで、そもそも東京-大阪間は物理的にどれくらい離れているのかというベースラインが把握できると思い、計測することにしました。

2.2 なぜSite-to-Site VPNを比較しないか

VPC間直結でSite-to-Site VPNを構成する場合、双方のVPCにVirtual Private GatewayとCustomer Gatewayを用意し、Customer Gateway側はEIPを持つEC2上でソフトウェアVPN(例: strongSwan)を稼働させるなどの構成が必要になります。これはAWSネットワーク層でのVPC間接続という本記事の主眼(マネージドなAWSサービスだけで完結する閉域接続)からはやや外れる上、構成の複雑さに対して得られる知見(IPsecオーバーヘッドの影響など)は既知の内容が多いため、今回は実測を見送りました。

3. 各方式の技術詳細

一応、各方式について簡単に解説しておきます。

3.1 VPC Peering

  • 2つのVPC間を1対1で接続する。推移的ピアリングは不可(AがBと、BがCとピアリングしていても、AからCへは到達できない)。VPCが増えるとフルメッシュ構成が必要になり、N個のVPCで N(N-1)/2 本のピアリング接続が必要になる。
  • ルーティングは各VPCのルートテーブルへの静的ルート追加によって行う。ルート伝播の自動化はない。

なお、調べたところ、東京リージョンでは、inter-region peering(リージョンをまたぐVPC Peering)は2018年から利用可能になったようです。

3.2 AWS PrivateLink(cross-region)

  • PrivateLinkは、NLB(Network Load Balancer)の背後にあるサービスを、VPC Endpoint Service経由で他のVPCから一方向に公開する仕組み。
  • 従来は同一リージョン内でしかInterface EndpointからEndpoint Serviceへ接続できませんでしたが、2024年11月にcross-region接続がGAし、別リージョンのEndpoint Serviceへネイティブに接続できるようになった。

PrivateLinkに関して、個人的に重要と考えているのが、Private DNSの挙動です。同一リージョン内のPrivateLinkではPrivateDnsEnabled: trueにすることでAWS内部のDNS解決だけでサービス名にアクセスできますが、cross-regionのエンドポイントではこのプライベートDNS自動解決に対応していません。そのため、エンドポイント固有のDNS名(vpce-xxxx.vpce-svc-xxxx.ap-northeast-3.vpce.amazonaws.comのような形式)を明示的に名前解決して利用する必要があります。

また、今回初めてリージョン間をPrivateLinkで繋ぎました。構築中に3つほど制約を踏んだので、記録しておきます。(知っているよという方は読み飛ばしていただければと...)

制約1: NLBはIPv6 Source NAT無効時にUDPリスナーを持てない

PrivateLinkで公開するNLBにTCP_UDPリスナーを設定しようとしたら、Network Load Balancers used with AWS PrivateLink cannot have TCP_UDP listeners when 'EnablePrefixForIpv6SourceNat' is set to 'off'というエラーで弾かれました。試しにUDP単体のリスナーに変えても同じエラーになったので、PrivateLink配下のNLBは、IPv6 Source NAT(EnablePrefixForIpv6SourceNat)を有効にしない限りUDPリスナーをそもそも持てないようです。IPv6化にはVPC・サブネットへのIPv6 CIDR付与やデュアルスタックNLBへの変更が必要で、今回のスコープを超えるため、PrivateLink経由の計測(パスE)はTCPのみに絞りました。他のパス(A〜D)はTCP・UDPどちらも計測しているので、6章の結果を見るときはその点にご注意ください。

制約2: NLBはICMPを転送しない

最初はレイテンシもICMP pingで測るつもりでしたが、動作確認をした際にpingが100%ロスになりました。PrivateLinkのNLBはL4パススルーですが、L3プロトコル(ICMP)はそもそも転送しない、という仕様のようです。この結果から、全パス共通でTCPコネクト確立時間ベースの計測に切り替えました(詳細は5章に記載します)。地味にハマりましたが、後から振り返るとPrivateLinkの一方向公開・プロトコル限定という設計思想を体感できた良い経験でした。

制約3: cross-region対応には提供側NLBが2AZ以上必要

cross-region PrivateLinkを有効化する際にも制約を踏みました。aws ec2 modify-vpc-endpoint-service-configuration --add-supported-regionsを実行したところ、Services must support at least 2 availability zones for cross regionというエラーで失敗しました。cross-region PrivateLinkを有効にするには、提供側のNLBが2つ以上のAZにまたがっている必要があるようです(最初は計測用インスタンスと同じ1AZだけにNLBを置いていました)。NLB専用のサブネットをもう1AZ分追加してAZを2つにしたところ通りました。

3.3 AWS Transit Gateway(inter-region peering)

  • Transit Gatewayはハブアンドスポーク型の接続で、複数VPCを集約的に管理できる。
  • リージョンをまたぐ場合はTGW peering attachmentを使う。
  • ピアリングアタッチメントはルート伝播に対応していません。VPCアタッチメントと異なり、対向のCIDRへのルートはTGWルートテーブルに手動で静的ルートを追加する必要がある。
  • ピアリングアタッチメントがpending-acceptance状態の間はルートを追加できない。アタッチメントを作成→acceptを実行→状態がavailableになったことを確認→ルート追加、という順序を踏む必要がある。

3.4 MTUの違い

リージョン間(VPC Peering・TGW)はMTU 1500に制限されるという認識で準備を進めていましたが、ping -M do -s <size>(Don't Fragmentフラグ)で実際に計測したところ、想定と異なる結果になりました。

経路 MTU(実測)
同一リージョン内(AZ内・AZ間) 9001
リージョン間 VPC Peering 8500
リージョン間 TGW peering 8500
リージョン間 PrivateLink 計測不可(NLBがICMPを転送しないため)

パスEも他パスと同じ方法(ping -M do)で実際に試しましたが、1200バイト程度の小さいサイズでも100%パケットロスになりました。3.2の制約2で述べた通り、PrivateLinkのNLBはICMP(L3プロトコル)をそもそも転送しないため、通常の方法ではMTUを計測できませんでした。

調べたところ、2025年3月のアップデートでinter-region VPC peeringでもJumbo Frame(最大8500バイト)をサポートするようになっており、私の1500byteという認識はこのアップデート前の古い情報でした。

なお、AWS公式ドキュメントにも"The MTU for inter-region VPC peering connections is 8500 bytes"と明記されていました。

3.5 暗号化

VPC Peering・TGW peeringのいずれも、リージョンをまたぐ通信はAWSのネットワークインフラ層でAES-256により自動的に暗号化されます。これはユーザー側で追加設定する必要がない、閉域接続方式を選ぶ上での地味に強い訴求ポイントです。

方式 リージョン間の暗号化
VPC Peering(inter-region) AES-256(自動、ネットワーク層)
TGW peering(inter-region) AES-256(自動、ネットワーク層)
PrivateLink(cross-region) 公式ドキュメントで暗号化方式が明記されているのを確認できず(AWSバックボーンを経由するため何らかの保護は期待していいのか。VPC Peering/TGWのように"AES-256"と明記された記述は見当たらず)

3.6 コスト比較

方式ごとにコスト構造がかなり違います。東京リージョン(ap-northeast-1)の実際の単価を元に計算しました。

項目 単価(東京リージョン)
VPC Peering接続そのもの 無料(時間課金なし)
Transit Gateway VPCアタッチメント $0.07/時間
Transit Gateway ピアリングアタッチメント $0.07/時間
Transit Gatewayのデータ処理料 $0.02/GB
Interface VPC Endpoint $0.014/時間(AZごと)
VPC Endpointのデータ処理料 $0.01/GB(月間1PBまで)
Network Load Balancer $0.0243/時間
NLB Capacity Unit(NLCU) $0.006/時間(使用量に応じて変動)
リージョン間データ転送(東京→大阪、送信元課金) $0.09/GB

まず、東京-大阪間のリージョン間データ転送は$0.09/GBです。

これを踏まえて、常時接続を維持する場合の月額固定費(データ量に依存しない部分、730時間/月換算)を概算すると次のようになります。従量課金のデータ転送料は含みません。

方式 内訳 月額固定費
VPC Peering 接続自体は無料 $0
Transit Gateway アタッチメント4本(東京・大阪双方のVPCアタッチメント+ピアリングアタッチメント)× $0.07/時間 × 730時間 約$204
PrivateLink NLB 1台 × $0.0243/時間 × 730時間 ≒ $17.7
+ Interface Endpoint 2AZ × $0.014/時間 × 730時間 ≒ $20.4
約$38

データ量が少ない・接続数が少ないうちはVPC Peeringが圧倒的に安く、TGWは複数VPCを集約管理するという規模のメリットと引き換えに固定費がかかる、PrivateLinkはその中間、というのが実際の数字で見た印象です。VPC数が増えてフルメッシュのPeering本数が管理しきれなくなった時点で、TGWの固定費を払う価値が出てくる、というのがコストのポイントになると思います。

4. 検証環境構築

インフラはすべてCloudFormationで構築しました。

4.1 スタック構成

ファイル 内容
tokyo-network.yaml 東京VPC、2AZ分のサブネット・EC2、SSM用インターフェースエンドポイント、S3結果バケット
osaka-network.yaml 大阪VPC、1AZ分のサブネット・EC2、同上
connectivity-peering-*.yaml(3ファイル) VPC Peering接続と双方向の静的ルート
connectivity-tgw-*.yaml(5ファイル) 東京・大阪それぞれのTGW、inter-region peering、accept後に追加する静的ルート
connectivity-privatelink-*.yaml(2ファイル) 大阪側の内部NLB+VPC Endpoint Service(提供側)、東京側のInterface Endpoint(利用側)

実際に使ったテンプレートの中身です。長いので折りたたんでおきます。

東京・大阪ネットワーク

tokyo-network.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: Tokyo network for benchmark

Parameters:
  PeerVpcCidr:
    Type: String
    Default: 10.1.0.0/16
    Description: Peer VPC CIDR
  InstanceType:
    Type: String
    Default: c6i.large
  LatestAl2023AmiId:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64
  S3PrefixListId:
    Type: String
    Description: S3 prefix list ID

Resources:
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-vpc"

  # AZ is pinned (not Fn::GetAZs) because the AZ-pair identity is the
  # independent variable under test (path A: 1a-1a vs path B: 1a-1c).
  SubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: ap-northeast-1a
      CidrBlock: 10.0.1.0/24
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-subnet-1a"

  SubnetC:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: ap-northeast-1c
      CidrBlock: 10.0.2.0/24
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-subnet-1c"

  RouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-rt"

  RouteTableAssocA:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref SubnetA
      RouteTableId: !Ref RouteTable

  RouteTableAssocC:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref SubnetC
      RouteTableId: !Ref RouteTable

  S3GatewayEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref Vpc
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.s3"
      VpcEndpointType: Gateway
      RouteTableIds:
        - !Ref RouteTable

  SgVpce:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: HTTPS to SSM endpoints
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - Description: HTTPS same VPC
          IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 10.0.0.0/16
      SecurityGroupEgress:
        - Description: Response same VPC
          IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 10.0.0.0/16
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-sg-vpce"

  SsmEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref Vpc
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.ssm"
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      SubnetIds: [!Ref SubnetA, !Ref SubnetC]
      SecurityGroupIds: [!Ref SgVpce]

  SsmMessagesEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref Vpc
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.ssmmessages"
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      SubnetIds: [!Ref SubnetA, !Ref SubnetC]
      SecurityGroupIds: [!Ref SgVpce]

  Ec2MessagesEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref Vpc
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.ec2messages"
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      SubnetIds: [!Ref SubnetA, !Ref SubnetC]
      SecurityGroupIds: [!Ref SgVpce]

  SgEc2:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: ICMP and iperf3 between instances
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - Description: ICMP same VPC
          IpProtocol: icmp
          FromPort: -1
          ToPort: -1
          CidrIp: 10.0.0.0/16
        - Description: ICMP peer VPC
          IpProtocol: icmp
          FromPort: -1
          ToPort: -1
          CidrIp: !Ref PeerVpcCidr
        - Description: iperf3 TCP same VPC
          IpProtocol: tcp
          FromPort: 5201
          ToPort: 5201
          CidrIp: 10.0.0.0/16
        - Description: iperf3 TCP peer VPC
          IpProtocol: tcp
          FromPort: 5201
          ToPort: 5201
          CidrIp: !Ref PeerVpcCidr
        - Description: iperf3 UDP same VPC
          IpProtocol: udp
          FromPort: 5201
          ToPort: 5201
          CidrIp: 10.0.0.0/16
        - Description: iperf3 UDP peer VPC
          IpProtocol: udp
          FromPort: 5201
          ToPort: 5201
          CidrIp: !Ref PeerVpcCidr
      SecurityGroupEgress:
        - Description: ICMP same VPC
          IpProtocol: icmp
          FromPort: -1
          ToPort: -1
          CidrIp: 10.0.0.0/16
        - Description: ICMP peer VPC
          IpProtocol: icmp
          FromPort: -1
          ToPort: -1
          CidrIp: !Ref PeerVpcCidr
        - Description: iperf3 TCP same VPC
          IpProtocol: tcp
          FromPort: 5201
          ToPort: 5201
          CidrIp: 10.0.0.0/16
        - Description: iperf3 TCP peer VPC
          IpProtocol: tcp
          FromPort: 5201
          ToPort: 5201
          CidrIp: !Ref PeerVpcCidr
        - Description: iperf3 UDP same VPC
          IpProtocol: udp
          FromPort: 5201
          ToPort: 5201
          CidrIp: 10.0.0.0/16
        - Description: iperf3 UDP peer VPC
          IpProtocol: udp
          FromPort: 5201
          ToPort: 5201
          CidrIp: !Ref PeerVpcCidr
        - Description: HTTPS to SSM endpoints
          IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 10.0.0.0/16
        - Description: HTTPS to S3 endpoint
          IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          DestinationPrefixListId: !Ref S3PrefixListId
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-sg-ec2"

  ResultsBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      BucketName: !Sub "${AWS::StackName}-results-${AWS::AccountId}"
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      VersioningConfiguration:
        Status: Enabled

  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/AmazonSSMManagedInstanceCore
      Policies:
        - PolicyName: ResultsBucketWrite
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: s3:PutObject
                Resource: !Sub "${ResultsBucket.Arn}/*"

  InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Roles: [!Ref InstanceRole]

  InstanceA:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: !Ref InstanceType
      ImageId: !Ref LatestAl2023AmiId
      SubnetId: !Ref SubnetA
      SecurityGroupIds: [!Ref SgEc2]
      IamInstanceProfile: !Ref InstanceProfile
      UserData:
        Fn::Base64: |
          #!/bin/bash
          set -eux
          dnf install -y iperf3 mtr traceroute
          cat > /etc/systemd/system/iperf3-server.service <<'UNIT'
          [Unit]
          Description=iperf3 server
          After=network.target

          [Service]
          ExecStart=/usr/bin/iperf3 -s
          Restart=always
          RestartSec=2

          [Install]
          WantedBy=multi-user.target
          UNIT
          systemctl daemon-reload
          systemctl enable --now iperf3-server.service
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-instance-1a"

  # Second AZ-1a instance: path A ("同一AZ内") needs two distinct hosts in
  # the same AZ to measure intra-AZ latency/throughput as a baseline.
  InstanceA2:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: !Ref InstanceType
      ImageId: !Ref LatestAl2023AmiId
      SubnetId: !Ref SubnetA
      SecurityGroupIds: [!Ref SgEc2]
      IamInstanceProfile: !Ref InstanceProfile
      UserData:
        Fn::Base64: |
          #!/bin/bash
          set -eux
          dnf install -y iperf3 mtr traceroute
          cat > /etc/systemd/system/iperf3-server.service <<'UNIT'
          [Unit]
          Description=iperf3 server
          After=network.target

          [Service]
          ExecStart=/usr/bin/iperf3 -s
          Restart=always
          RestartSec=2

          [Install]
          WantedBy=multi-user.target
          UNIT
          systemctl daemon-reload
          systemctl enable --now iperf3-server.service
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-instance-1a-2"

  InstanceC:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: !Ref InstanceType
      ImageId: !Ref LatestAl2023AmiId
      SubnetId: !Ref SubnetC
      SecurityGroupIds: [!Ref SgEc2]
      IamInstanceProfile: !Ref InstanceProfile
      UserData:
        Fn::Base64: |
          #!/bin/bash
          set -eux
          dnf install -y iperf3 mtr traceroute
          cat > /etc/systemd/system/iperf3-server.service <<'UNIT'
          [Unit]
          Description=iperf3 server
          After=network.target

          [Service]
          ExecStart=/usr/bin/iperf3 -s
          Restart=always
          RestartSec=2

          [Install]
          WantedBy=multi-user.target
          UNIT
          systemctl daemon-reload
          systemctl enable --now iperf3-server.service
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-instance-1c"

Outputs:
  VpcId:
    Value: !Ref Vpc
  VpcCidr:
    Value: 10.0.0.0/16
  RouteTableId:
    Value: !Ref RouteTable
  SubnetAId:
    Value: !Ref SubnetA
  SubnetCId:
    Value: !Ref SubnetC
  SgEc2Id:
    Value: !Ref SgEc2
  InstanceAId:
    Value: !Ref InstanceA
  InstanceAPrivateIp:
    Value: !GetAtt InstanceA.PrivateIp
  InstanceA2Id:
    Value: !Ref InstanceA2
  InstanceA2PrivateIp:
    Value: !GetAtt InstanceA2.PrivateIp
  InstanceCId:
    Value: !Ref InstanceC
  InstanceCPrivateIp:
    Value: !GetAtt InstanceC.PrivateIp
  ResultsBucketName:
    Value: !Ref ResultsBucket
osaka-network.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: Osaka network for benchmark

Parameters:
  PeerVpcCidr:
    Type: String
    Default: 10.0.0.0/16
    Description: Peer VPC CIDR
  InstanceType:
    Type: String
    Default: c6i.large
  LatestAl2023AmiId:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64
  S3PrefixListId:
    Type: String
    Description: S3 prefix list ID

Resources:
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.1.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-vpc"

  # AZ is pinned (not Fn::GetAZs) to keep the topology deterministic and
  # comparable across repeated stack deployments.
  Subnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: ap-northeast-3a
      CidrBlock: 10.1.1.0/24
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-subnet-3a"

  # Second-AZ subnet, NLB only (no EC2 instance here). Cross-region
  # PrivateLink requires the endpoint service's NLB to span at least 2 AZs
  # (ModifyVpcEndpointServiceConfiguration --add-supported-regions fails
  # with "Services must support at least 2 availability zones for cross
  # region" otherwise).
  SubnetB:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: ap-northeast-3b
      CidrBlock: 10.1.2.0/24
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-subnet-3b"

  RouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-rt"

  RouteTableAssoc:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref Subnet
      RouteTableId: !Ref RouteTable

  RouteTableAssocB:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref SubnetB
      RouteTableId: !Ref RouteTable

  S3GatewayEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref Vpc
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.s3"
      VpcEndpointType: Gateway
      RouteTableIds:
        - !Ref RouteTable

  SgVpce:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: HTTPS to SSM endpoints
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - Description: HTTPS same VPC
          IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 10.1.0.0/16
      SecurityGroupEgress:
        - Description: Response same VPC
          IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 10.1.0.0/16
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-sg-vpce"

  SsmEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref Vpc
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.ssm"
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      SubnetIds: [!Ref Subnet]
      SecurityGroupIds: [!Ref SgVpce]

  SsmMessagesEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref Vpc
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.ssmmessages"
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      SubnetIds: [!Ref Subnet]
      SecurityGroupIds: [!Ref SgVpce]

  Ec2MessagesEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref Vpc
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.ec2messages"
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      SubnetIds: [!Ref Subnet]
      SecurityGroupIds: [!Ref SgVpce]

  SgEc2:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: ICMP and iperf3 between instances, plus NLB health check
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - Description: ICMP same VPC
          IpProtocol: icmp
          FromPort: -1
          ToPort: -1
          CidrIp: 10.1.0.0/16
        - Description: ICMP peer VPC
          IpProtocol: icmp
          FromPort: -1
          ToPort: -1
          CidrIp: !Ref PeerVpcCidr
        - Description: iperf3 TCP same VPC, NLB health check
          IpProtocol: tcp
          FromPort: 5201
          ToPort: 5201
          CidrIp: 10.1.0.0/16
        - Description: iperf3 TCP peer VPC
          IpProtocol: tcp
          FromPort: 5201
          ToPort: 5201
          CidrIp: !Ref PeerVpcCidr
        - Description: iperf3 UDP same VPC
          IpProtocol: udp
          FromPort: 5201
          ToPort: 5201
          CidrIp: 10.1.0.0/16
        - Description: iperf3 UDP peer VPC
          IpProtocol: udp
          FromPort: 5201
          ToPort: 5201
          CidrIp: !Ref PeerVpcCidr
      SecurityGroupEgress:
        - Description: ICMP same VPC
          IpProtocol: icmp
          FromPort: -1
          ToPort: -1
          CidrIp: 10.1.0.0/16
        - Description: ICMP peer VPC
          IpProtocol: icmp
          FromPort: -1
          ToPort: -1
          CidrIp: !Ref PeerVpcCidr
        - Description: iperf3 TCP same VPC
          IpProtocol: tcp
          FromPort: 5201
          ToPort: 5201
          CidrIp: 10.1.0.0/16
        - Description: iperf3 TCP peer VPC
          IpProtocol: tcp
          FromPort: 5201
          ToPort: 5201
          CidrIp: !Ref PeerVpcCidr
        - Description: iperf3 UDP same VPC
          IpProtocol: udp
          FromPort: 5201
          ToPort: 5201
          CidrIp: 10.1.0.0/16
        - Description: iperf3 UDP peer VPC
          IpProtocol: udp
          FromPort: 5201
          ToPort: 5201
          CidrIp: !Ref PeerVpcCidr
        - Description: HTTPS to SSM endpoints
          IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 10.1.0.0/16
        - Description: HTTPS to S3 endpoint
          IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          DestinationPrefixListId: !Ref S3PrefixListId
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-sg-ec2"

  ResultsBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      BucketName: !Sub "${AWS::StackName}-results-${AWS::AccountId}"
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      VersioningConfiguration:
        Status: Enabled

  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/AmazonSSMManagedInstanceCore
      Policies:
        - PolicyName: ResultsBucketWrite
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: s3:PutObject
                Resource: !Sub "${ResultsBucket.Arn}/*"

  InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Roles: [!Ref InstanceRole]

  Instance:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: !Ref InstanceType
      ImageId: !Ref LatestAl2023AmiId
      SubnetId: !Ref Subnet
      SecurityGroupIds: [!Ref SgEc2]
      IamInstanceProfile: !Ref InstanceProfile
      UserData:
        Fn::Base64: |
          #!/bin/bash
          set -eux
          dnf install -y iperf3 mtr traceroute
          cat > /etc/systemd/system/iperf3-server.service <<'UNIT'
          [Unit]
          Description=iperf3 server
          After=network.target

          [Service]
          ExecStart=/usr/bin/iperf3 -s
          Restart=always
          RestartSec=2

          [Install]
          WantedBy=multi-user.target
          UNIT
          systemctl daemon-reload
          systemctl enable --now iperf3-server.service
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-instance-3a"

Outputs:
  VpcId:
    Value: !Ref Vpc
  VpcCidr:
    Value: 10.1.0.0/16
  RouteTableId:
    Value: !Ref RouteTable
  SubnetId:
    Value: !Ref Subnet
  SubnetBId:
    Value: !Ref SubnetB
  SgEc2Id:
    Value: !Ref SgEc2
  InstanceId:
    Value: !Ref Instance
  InstancePrivateIp:
    Value: !GetAtt Instance.PrivateIp
  ResultsBucketName:
    Value: !Ref ResultsBucket

VPC Peering

connectivity-peering-connection.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: VPC Peering connection Tokyo to Osaka

Parameters:
  TokyoVpcId:
    Type: String
  OsakaVpcId:
    Type: String
  OsakaRegion:
    Type: String
    Default: ap-northeast-3

Resources:
  PeeringConnection:
    Type: AWS::EC2::VPCPeeringConnection
    Properties:
      VpcId: !Ref TokyoVpcId
      PeerVpcId: !Ref OsakaVpcId
      PeerRegion: !Ref OsakaRegion
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-tokyo-osaka"

Outputs:
  PeeringConnectionId:
    Value: !Ref PeeringConnection
connectivity-peering-routes-tokyo.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: Tokyo route to Osaka via Peering

Parameters:
  TokyoRouteTableId:
    Type: String
  OsakaVpcCidr:
    Type: String
    Default: 10.1.0.0/16
  PeeringConnectionId:
    Type: String

Resources:
  RouteToOsaka:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref TokyoRouteTableId
      DestinationCidrBlock: !Ref OsakaVpcCidr
      VpcPeeringConnectionId: !Ref PeeringConnectionId
connectivity-peering-routes-osaka.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: Osaka route to Tokyo via Peering

Parameters:
  OsakaRouteTableId:
    Type: String
  TokyoVpcCidr:
    Type: String
    Default: 10.0.0.0/16
  PeeringConnectionId:
    Type: String

Resources:
  RouteToTokyo:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref OsakaRouteTableId
      DestinationCidrBlock: !Ref TokyoVpcCidr
      VpcPeeringConnectionId: !Ref PeeringConnectionId

Transit Gateway

connectivity-tgw-tokyo.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: Tokyo Transit Gateway

Parameters:
  TokyoVpcId:
    Type: String
  TokyoSubnetIds:
    Type: List<String>

Resources:
  TransitGateway:
    Type: AWS::EC2::TransitGateway
    Properties:
      Description: !Sub "${AWS::StackName}-tgw"
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-tgw"

  VpcAttachment:
    Type: AWS::EC2::TransitGatewayAttachment
    Properties:
      TransitGatewayId: !Ref TransitGateway
      VpcId: !Ref TokyoVpcId
      SubnetIds: !Ref TokyoSubnetIds
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-attachment"

Outputs:
  TransitGatewayId:
    Value: !Ref TransitGateway
  VpcAttachmentId:
    Value: !Ref VpcAttachment
connectivity-tgw-osaka.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: Osaka Transit Gateway

Parameters:
  OsakaVpcId:
    Type: String
  OsakaSubnetIds:
    Type: List<String>

Resources:
  TransitGateway:
    Type: AWS::EC2::TransitGateway
    Properties:
      Description: !Sub "${AWS::StackName}-tgw"
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-tgw"

  VpcAttachment:
    Type: AWS::EC2::TransitGatewayAttachment
    Properties:
      TransitGatewayId: !Ref TransitGateway
      VpcId: !Ref OsakaVpcId
      SubnetIds: !Ref OsakaSubnetIds
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-attachment"

Outputs:
  TransitGatewayId:
    Value: !Ref TransitGateway
  VpcAttachmentId:
    Value: !Ref VpcAttachment
connectivity-tgw-peering.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: TGW peering Tokyo to Osaka

Parameters:
  TokyoTransitGatewayId:
    Type: String
  OsakaTransitGatewayId:
    Type: String
  OsakaRegion:
    Type: String
    Default: ap-northeast-3

Resources:
  PeeringAttachment:
    Type: AWS::EC2::TransitGatewayPeeringAttachment
    Properties:
      TransitGatewayId: !Ref TokyoTransitGatewayId
      PeerTransitGatewayId: !Ref OsakaTransitGatewayId
      PeerRegion: !Ref OsakaRegion
      PeerAccountId: !Ref AWS::AccountId
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-tgw-peering"

Outputs:
  PeeringAttachmentId:
    Value: !Ref PeeringAttachment
connectivity-tgw-routes-tokyo.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: Tokyo TGW route to Osaka

Parameters:
  TokyoTgwRouteTableId:
    Type: String
    Description: Tokyo TGW route table ID
  OsakaVpcCidr:
    Type: String
    Default: 10.1.0.0/16
  PeeringAttachmentId:
    Type: String

Resources:
  RouteToOsaka:
    Type: AWS::EC2::TransitGatewayRoute
    Properties:
      TransitGatewayRouteTableId: !Ref TokyoTgwRouteTableId
      DestinationCidrBlock: !Ref OsakaVpcCidr
      TransitGatewayAttachmentId: !Ref PeeringAttachmentId
connectivity-tgw-routes-osaka.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: Osaka TGW route to Tokyo

Parameters:
  OsakaTgwRouteTableId:
    Type: String
    Description: Osaka TGW route table ID
  TokyoVpcCidr:
    Type: String
    Default: 10.0.0.0/16
  PeeringAttachmentId:
    Type: String

Resources:
  RouteToTokyo:
    Type: AWS::EC2::TransitGatewayRoute
    Properties:
      TransitGatewayRouteTableId: !Ref OsakaTgwRouteTableId
      DestinationCidrBlock: !Ref TokyoVpcCidr
      TransitGatewayAttachmentId: !Ref PeeringAttachmentId
connectivity-tgw-vpcroute-tokyo.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: Tokyo VPC route to Osaka via TGW

Parameters:
  TokyoRouteTableId:
    Type: String
  OsakaVpcCidr:
    Type: String
    Default: 10.1.0.0/16
  TokyoTransitGatewayId:
    Type: String

Resources:
  RouteToOsakaViaTgw:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref TokyoRouteTableId
      DestinationCidrBlock: !Ref OsakaVpcCidr
      TransitGatewayId: !Ref TokyoTransitGatewayId
connectivity-tgw-vpcroute-osaka.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: Osaka VPC route to Tokyo via TGW

Parameters:
  OsakaRouteTableId:
    Type: String
  TokyoVpcCidr:
    Type: String
    Default: 10.0.0.0/16
  OsakaTransitGatewayId:
    Type: String

Resources:
  RouteToTokyoViaTgw:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref OsakaRouteTableId
      DestinationCidrBlock: !Ref TokyoVpcCidr
      TransitGatewayId: !Ref OsakaTransitGatewayId

PrivateLink

connectivity-privatelink-provider.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: PrivateLink provider NLB

Parameters:
  OsakaVpcId:
    Type: String
  OsakaSubnetIds:
    Type: List<String>
  OsakaInstanceId:
    Type: String

Resources:
  Nlb:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Type: network
      Scheme: internal
      Subnets: !Ref OsakaSubnetIds

  TcpTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      VpcId: !Ref OsakaVpcId
      Protocol: TCP
      Port: 5201
      TargetType: instance
      Targets:
        - Id: !Ref OsakaInstanceId
          Port: 5201
      HealthCheckProtocol: TCP
      HealthCheckPort: "5201"

  TcpListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref Nlb
      Protocol: TCP
      Port: 5201
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref TcpTargetGroup

  EndpointService:
    Type: AWS::EC2::VPCEndpointService
    Properties:
      NetworkLoadBalancerArns: [!Ref Nlb]
      AcceptanceRequired: false

Outputs:
  EndpointServiceId:
    Value: !Ref EndpointService
  NlbArn:
    Value: !Ref Nlb
connectivity-privatelink-consumer.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: PrivateLink consumer endpoint

Parameters:
  TokyoVpcId:
    Type: String
  TokyoSubnetIds:
    Type: List<String>
  TokyoVpcCidr:
    Type: String
    Default: 10.0.0.0/16
  OsakaEndpointServiceId:
    Type: String
  OsakaRegion:
    Type: String
    Default: ap-northeast-3

Resources:
  SgEndpoint:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: iperf3 TCP to PrivateLink endpoint
      VpcId: !Ref TokyoVpcId
      SecurityGroupIngress:
        - Description: iperf3 TCP same VPC
          IpProtocol: tcp
          FromPort: 5201
          ToPort: 5201
          CidrIp: !Ref TokyoVpcCidr
      SecurityGroupEgress:
        - Description: Response same VPC
          IpProtocol: tcp
          FromPort: 5201
          ToPort: 5201
          CidrIp: !Ref TokyoVpcCidr
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-sg-endpoint"

  Endpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref TokyoVpcId
      ServiceName: !Sub "com.amazonaws.vpce.${OsakaRegion}.${OsakaEndpointServiceId}"
      ServiceRegion: !Ref OsakaRegion
      VpcEndpointType: Interface
      PrivateDnsEnabled: false
      SubnetIds: !Ref TokyoSubnetIds
      SecurityGroupIds: [!Ref SgEndpoint]

Outputs:
  VpcEndpointId:
    Value: !Ref Endpoint

4.2 CloudFormationのリージョンをまたぐ制約

CloudFormationのExport/Fn::ImportValueは同一リージョン内でしか機能しません。そのため、東京・大阪それぞれのネットワークスタックをデプロイした後、describe-stacksでOutputsを取得し、次のスタックへ--parameter-overridesとして明示的に受け渡す運用で対処しました。

OSAKA_VPC_ID=$(aws cloudformation describe-stacks \
  --region ap-northeast-3 --stack-name osaka-network \
  --query "Stacks[0].Outputs[?OutputKey=='VpcId'].OutputValue" --output text)

aws cloudformation deploy \
  --region ap-northeast-1 --stack-name connectivity-peering-connection \
  --template-file connectivity-peering-connection.yaml \
  --parameter-overrides TokyoVpcId=$TOKYO_VPC_ID OsakaVpcId=$OSAKA_VPC_ID

4.3 注意点: ルートテーブルは同じ宛先を2つの経路で持てない

実際にデプロイした後に気づいたのですが、VPC PeeringのルートとTGWのルートを同時に東京側ルートテーブルへ追加しようとしたところ、The route identified by 10.1.0.0/16 already exists というエラーで失敗しました。

落ち着いて考えてみれば当たり前で、ルートテーブルは同一の宛先CIDRに対して1つの経路しか持てません。VPC PeeringとTGWは両方とも対向VPCのCIDR全体を宛先にするため、同じルートテーブルに両方を共存させることができません。

そのため、VPC側の静的ルートはPeering用・TGW用でそれぞれ独立した小さいスタックに分け、計測するときにどちらか一方だけをデプロイする(切り替える)運用にしました。なお、PrivateLinkはVPC CIDR宛のルーティングを使わず、Interface EndpointのENIに直接到達する仕組みなので、この制約の対象外でPeering・TGWと並行して常に有効にできます。

5. 計測方法

単純な平均値ではなく、p50/p95/p99パーセンタイルで評価します。単発の平均値は外れ値や瞬間的な輻輳に弱く、方式間の実質的な差を見誤る可能性があるためです。

  • レイテンシ: 当初はICMP pingで測る予定でしたが、前述の通りPrivateLink経由(NLB)ではICMPが素通りしないため、全パス共通でTCPコネクト確立時間ベースの計測に切り替えました。各パスでiperf3の待受ポート(5201)に対してTCPの3-way handshakeを300回(0.2秒間隔)行い、確立にかかった時間をRTT相当として記録します。タイムアウトは0.4秒(LinuxのSYN再送は約1秒後に発生するため、それより十分短く設定し、タイムアウトを遅いRTTではなくロスとして扱う)。
  • スループット: iperf3を30秒、-i 1(1秒間隔のインターバルレポート)付きで実行。TCP単一ストリーム(-P 1)、TCP並列ストリーム(-P 4)、UDP(-u、ジッター・パケットロス測定)の3パターンをそれぞれ3回実行し、区間サンプルをプールしてp50/p95/p99を算出する(1パスあたり約270点のサンプル)。当初は300秒×3回で計画していましたが、後述するネットワークバーストクレジット枯渇の問題を受けて段階的に短縮し、最終的に30秒に落ち着きました。

5.1 ハマった点: ネットワークバーストクレジットの枯渇

最初に300秒×3回のフルボリュームで全パスを計測したところ、最初に実行したパスAだけが期待通りの高スループット(TCP単一4.96Gbps、4並列12.4Gbps、UDP4.98Gbps)を示し、それ以降のパスB〜Eは軒並みTCP4並列・UDPが約0.77Gbpsで張り付くという現象に遭遇しました。

原因は、検証に使ったc6i.largeのネットワークI/Oバーストクレジットと考えています。パスAの45分間にわたる連続高負荷送信でクレジットを使い切ってしまい、後続のパスがクレジット回復の暇もなく実行されたため、後続パスの結果は接続方式の差ではなくクレジット枯渇のタイミングの差を測ってしまったと考えられます。

一度目の対策として、iperf3の実行時間を300秒→60秒に短縮し、パス間に5〜10分のクールダウンを挟む方式に変更しました。これによりパスA単体の劣化は解消しましたが、パスB以降は60秒プロトコル+クールダウンを挟んでも同様の劣化が再現しました。ネットワークI/Oバーストクレジットの回復は、EC2のCPUクレジットのような比較的短時間での回復ではなく、インスタンス起動からの累積で管理されていると考えられ、数分〜10分程度のクールダウンでは足りなかったようです。

そこで、さらに実行時間を30秒に短縮した上で、計測順序をE→D→C→B→Aと逆順にして再実行してみました。すると、最初に走ったE・Dは高スループットを維持できましたが、3番目のCで16秒間高スループットを維持した直後に1秒で0.77Gbps付近へ下落するという、クレジットを使い切る瞬間を見ることができました。これで特定の方式が遅いのではなく、起動からの累積使用量でクレジットが尽きるという仮説が、順方向・逆方向どちらでも同じ挙動で再現することが確認できました。

5.2 (注意)この検証について

パーセンタイル評価により外れ値への耐性は高めていますが、個人検証のため計測期間は数分程度に限られ、日をまたいだ時間帯・曜日による変動までは捕捉できません。また、6章のスループットは各パスをインスタンス再起動直後に単独計測することでクレジット枯渇の影響を排除していますが、それでも同一セッション内・短時間での計測である点は変わらないため、この記事の数値はあくまで傾向を掴むものとして扱ってください。

5.3 実行コマンド

計測は手元のPCからaws ssm send-commandでEC2上に計測スクリプトを送り込む形で自動化しています。パスごとに以下のように実行するだけです。

./scripts/run_measurement.sh <REGION> <CLIENT_INSTANCE_ID> <PATH_ID> <PEER_HOST> <RESULTS_BUCKET> [SKIP_UDP] [DURATION_SEC]

# 例: パスA(東京AZ内)
./scripts/run_measurement.sh ap-northeast-1 $TOKYO_INSTANCE_A_ID A $TOKYO_INSTANCE_A2_IP $TOKYO_RESULTS_BUCKET 0 30

# 例: パスE(PrivateLink、UDP非対応なのでSKIP_UDP=1)
./scripts/run_measurement.sh ap-northeast-1 $TOKYO_INSTANCE_A_ID E $PL_ENDPOINT_DNS $TOKYO_RESULTS_BUCKET 1 30

内部的にはEC2上でtcp_latency.py(TCPコネクト確立時間ベースのレイテンシプローブ、300回)→iperf3(TCP -P 1 / -P 4 / UDP、各30秒×3回、-JでJSON出力)を実行し、生データをS3にアップロードするだけのシンプルな作りです。前述の通り、クレジット枯渇を避けるため、最終的な計測では各パスの直前にEC2をstopstartし直してから1本だけ計測する運用にしています。

VPC PeeringとTGWは同一VPC CIDRへの経路を同時に持てないため(4.3参照)、パスC・Dをそれぞれ計測する前に、対応するVPC側ルートスタックだけをデプロイし直しています。

# Peering -> TGW へ切り替え
aws cloudformation delete-stack --region ap-northeast-1 --stack-name peering-routes-tokyo
aws cloudformation delete-stack --region ap-northeast-3 --stack-name peering-routes-osaka
aws cloudformation deploy --region ap-northeast-1 --stack-name tgw-vpcroute-tokyo \
  --template-file connectivity-tgw-vpcroute-tokyo.yaml \
  --parameter-overrides TokyoRouteTableId=$TOKYO_RT_ID TokyoTransitGatewayId=$TOKYO_TGW_ID
aws cloudformation deploy --region ap-northeast-3 --stack-name tgw-vpcroute-osaka \
  --template-file connectivity-tgw-vpcroute-osaka.yaml \
  --parameter-overrides OsakaRouteTableId=$OSAKA_RT_ID OsakaTransitGatewayId=$OSAKA_TGW_ID
# TGW -> Peering へ戻す場合はこの逆

6.2の補足実験(NLBを挟むレイテンシ差の切り出し)は、PrivateLink提供側の内部NLBがVPC Peering/TGW経由でも直接プライベートIPに到達できることを利用し、宛先をインスタンスからNLBのプライベートIPに変えるだけで実現しています。

# NLBのプライベートIPを取得(AZごとに複数返る)
NLB_ARN=$(aws cloudformation describe-stacks --region ap-northeast-3 \
  --stack-name privatelink-provider \
  --query "Stacks[0].Outputs[?OutputKey=='NlbArn'].OutputValue" --output text)
aws ec2 describe-network-interfaces --region ap-northeast-3 \
  --filters "Name=description,Values=ELB net/xxxxxxxxxx/xxxxxxxxxxxxxxxx" \
  --query 'NetworkInterfaces[].[AvailabilityZone,PrivateIpAddress]'

最後に、全パス分のS3上の生データをローカルに同期し、p50/p95/p99を算出します。

python3 scripts/aggregate_results.py \
  --bucket <tokyo-results-bucket> \
  --bucket <osaka-results-bucket> \
  --out results_summary.csv

6. 結果

いよいよ測定結果です。

6.1 レイテンシ(TCPコネクト確立時間、単位ms)

パス p50 p95 p99 max
A(同一AZ内) 0.21 0.26 0.31 2.08
B(AZ間) 1.65 1.70 1.74 4.53
C(VPC Peering) 8.89 9.94 10.02 10.81
D(TGW) 11.01 12.33 12.77 17.95
E(PrivateLink) 13.32 14.58 16.00 19.65

ロス率はいずれのパスも0%でした(Eのみ、後述の理由により60秒プロトコル計測時の値を採用しています)。A→B→C→D→Eの順できれいに単調増加しており、AZを跨ぐコスト(AとB)、リージョンを跨ぐコスト(BとC/D/E)、接続方式ごとの追加コスト(CとDとE)が素直に積み上がっている様子が見えました。

6.2 補足実験: NLBを挟むと何ms増えるか

E(PrivateLink)が最も遅い結果になりましたが、これがPrivateLinkという仕組み固有の遅さなのか、単にNLBを経由すること自体の遅さなのか切り分けたくなったので、追加で実験しました。PrivateLink提供側に使っている内部NLB(大阪VPC内)は、VPC Peering・TGW経由でも直接そのプライベートIPに到達できるので、C・Dの経路のまま素のインスタンスではなくNLB経由のプライベートIP宛にTCPコネクト計測をやり直してみました(300プローブ、単発計測。この実験は6.1とは別タイミングで行っているため、C・Dの直接値は6.1の値と数百µsほど誤差があります)。

パス 宛先 p50(ms) NLBによる増分
C インスタンス直接 8.73 -
C+NLB NLB経由 9.94 +1.21ms
D インスタンス直接 10.88 -
D+NLB NLB経由 11.59 +0.71ms
E PrivateLink(cross-region) 13.32 -

C・Dどちらの経路でも、NLBを挟むだけで+0.7〜1.2ms程度増えることがわかりました。さらにD+NLB(11.59ms)とE(13.32ms)を比べると、まだ+1.7ms程度の差が残ります。つまりEの遅さは、大まかに次のように分解できそうです。

  • 「リージョンを跨ぐ」こと自体のコスト(C・Dの8〜11ms)
  • 「NLBを経由する」ことのコスト(+0.7〜1.2ms程度)
  • 「PrivateLinkのcross-regionエンドポイント機構」固有のコスト(+1.7ms程度、Interface EndpointのENI処理(NAT含む)やcross-regionトンネリングによるものと推測)

6.3 スループット

5.1で述べた通り、各パスの直前にインスタンスを再起動してクレジットを満タンにした状態で単独計測することで、クレジット枯渇の影響を排除したクリーンな値が取得できました。

パス TCP単一(p50, Gbps) TCP4並列(p50, Gbps) UDP(p50, Gbps)
A(同一AZ内) 4.96 12.41 4.98
B(AZ間) 4.96 12.41 4.98
C(VPC Peering) 2.41 10.58 4.98
D(TGW) 2.50 9.67 4.98
E(PrivateLink) 2.36 9.99 計測なし(3.2参照)

同一リージョン内(A・B)はAZ内・AZ間どちらもTCP4並列で12.4Gbps程度と、ほぼ変わらずインスタンスタイプの実力値に近い値が出ています。一方リージョンをまたぐC〜Eでは、TCP4並列でVPC Peering(10.58G) > PrivateLink(9.99G) > TGW(9.67G)という、方式間の実質的な差が見えました。同一リージョン内と比べると、リージョンをまたぐだけで2Gbps弱のスループット低下があることもわかります(後述するMTUの違い(9001 vs 8500)が要因の一つと考えられます)。

TCP単一ストリームはC〜Eで2.36〜2.50Gbps程度に収まっており、方式間の差はTCP4並列ほど明確ではありません。RTTが8ms以上ある経路では単一TCPフローの実効帯域が帯域×RTTで頭打ちになりやすいため、並列化していない分、方式そのものの違いよりRTTの影響を強く受けているのではと考えています。

UDPはA〜Dいずれも4.98Gbps付近で揃っており、方式による違いはほぼ見られませんでした。iperf3 -u -b 0(無制限指定)で送信していますが、クライアント側の送信レートそのものが頭打ちになっている可能性があり、UDPに関しては方式間の帯域差を見るには不向きな計測条件だったかもしれません。

7. 考察

今回の計測結果をまとめつつ、結果に対する私なりの見解をまとめていきます。

7.1 なぜレイテンシがこの順に並ぶのか

  • A→B(+1.4ms程度): 同一リージョン内でAZを跨ぐことそのもののコスト。AZ間は物理的に別のデータセンターであるため、これくらいの増加は妥当です。
  • B→C(+7.2ms程度): リージョンを跨ぐことそのもののコスト。東京-大阪間の物理的な距離・AWSバックボーンのホップ数が主要因と考えられます。
  • C→D(+2.1ms程度): VPC PeeringとTGWの差。TGWはハブ機能を提供する分、VPCアタッチメント・ピアリングアタッチメントという追加のホップが挟まるため、単純な1対1接続であるVPC Peeringよりわずかに遅くなるのは構造的に自然です。
  • D→E(+2.3ms程度): 6.2の補足実験で、このうち+0.7〜1.2ms程度は「NLBを経由すること」で説明でき、残りの+1.7ms程度がPrivateLinkのcross-regionエンドポイント機構固有のオーバーヘッドと考えられます。

なぜPrivateLink固有の+1.7msが生じるのか

あくまでも私の見解ですが、TGWとPrivateLinkは、そもそもやっていることの種類が違うからではないかと考えました。(この辺り、ご存知の方がいらっしゃれば、教えてください)

  • TGWはL3ルーティングで繋ぐ: TGW peeringは2つのTGW間の暗号化された拠点間で、パケットはホップごとに素直にL3転送されるだけです。VPC PeeringやTGWは基本的に経路を提供する仕組みで、途中で接続を終端したりパケットを詰め替えたりはしません。
  • PrivateLinkはプロキシとして繋ぐ: Interface Endpointは対向VPCへの単純な経路だけではなく双方向NATを含めたサービスを接続する機構が含まれているためと考えられます。(PrivateLinkは、セグメントが重複しても通信できるようNATが入ります)

この違いから、PrivateLinkを経由した際に1.7msの増加が見られたと考えました。

7.2 MTUとの関係

3.4で実測した通り、リージョン間のVPC Peering・TGW peeringのMTUは8500でした(同一リージョン内は9001)。差は約500バイトとそれほど大きくありませんが、6.3のクリーンな計測結果でも、同一リージョン内(A・B、TCP4並列12.4Gbps前後)とリージョンをまたぐC〜E(9.7〜10.6Gbps)の間に明確な差が出ています。MTUの差だけで2Gbps弱もの差が説明できるかというとやや過大な気もするので、MTU以外にも、リージョン間の物理的な距離・ホップ数によるオーバーヘッドが効いていそうです。

7.3 ネットワークバーストクレジットは難しい

今回、最も苦労したのはスループット計測でした。EC2のc6i.largeのようなバーストパフォーマンス系インスタンスタイプは、ネットワーク帯域も内部的にクレジット制で管理されていて、テスト設計(実行時間・実行順序・クールダウン)次第で同じ経路のはずなのに全然違う数値が出てしまいます。300秒→60秒への短縮とパス間クールダウンだけでは最初に実行したパスだけが有利になる不公平は解消できず、最終的にパスごとにインスタンスを再起動してから単独計測するという力技で決着させました。この種のベンチマークを行う際は、以下のようなことを意識すると良さそうです。

  • ネットワーク帯域が保証された、バースト特性のないインスタンスタイプを使う(今回はコストの関係で見送り)
  • パスごとに完全に独立した(クレジット履歴のない)インスタンスを用意する、あるいは計測直前にインスタンスを再起動する

7.4 なぜスループットにも方式間の差が出るのか

6.3で見た通り、TCP4並列ではVPC Peering(10.58G) > PrivateLink(9.99G) > TGW(9.67G)という序列になりました。レイテンシではTGWがPrivateLinkより速かったのに、スループットでは逆転しているのが個人的に興味深かったです。

TGWが最も遅い理由は、7.1で触れたVPCアタッチメント・ピアリングアタッチメントという追加のホップに加えて、TGW自体がハブとして複数VPC・複数接続を集約処理する共有インフラである以上、単純な1対1接続よりスループットの天井が低くなりやすいのではと推測しています。一方で、PrivateLinkは、レイテンシこそNAT+NLBを経由する分が上乗せされますが、NLBそのものは高スループットを捌く前提で設計されたリソースなので、スループットの天井という観点ではTGWほど不利にならなかったのではないかと考えています(あくまで推測です)。

8. 選定指針

これまでの結果を受けて、速度(レイテンシ)だけを見てVPC Peering一択、とするのは早計だと思っています。実際にアーキテクチャを選ぶときは、速度・構成(運用管理のしやすさ・将来の拡張性)・費用の3つを合わせて見る必要があります。速度だけならVPC Peeringが有利ですが、VPCの数が増えたときの運用コストや、公開範囲をサービス単位に絞りたいという要件を考えると、答えは変わってきます。

4つの観点で今回の結果を整理すると次のようになります。

方式 速度(p50レイテンシ) スループット(TCP4並列、p50) 構成 費用(常時接続の固定費/月)
VPC Peering 8.89ms(最速) 10.58Gbps(最速) 1対1のみ。VPCが増えるとN(N-1)/2本のフルメッシュが必要で運用管理が破綻する $0(データ転送料のみ従量)
Transit Gateway 11.01ms(Peering比+2.1ms) 9.67Gbps(最遅) ハブ&スポークで多数のVPCを集約管理できる。静的ルートの保守は必要 約$204/月(アタッチメント4本分)
PrivateLink 13.32ms(Peering比+4.4ms) 9.99Gbps(中間) VPC全体ではなくサービス単位で片方向公開。フルメッシュの到達性は不要 約$38/月

レイテンシ・スループットどちらの軸で見てもVPC Peeringが最も高性能という結果でしたが、TGWとPrivateLinkの序列がレイテンシとスループットで逆転している(レイテンシはTGWが上、スループットはPrivateLinkが上)のが面白いところです。どちらの性能特性を重視するかでも判断が変わりえます。

これらを踏まえてリージョン間接続におけるアーキテクチャ選定を考えてみます。

レイテンシを最重視し、少数のVPCを1対1で単純に繋ぐ

  • VPC Peeringを選択。追加コンポーネント不要でシンプル、費用も最安。ただしVPC数が増えるとフルメッシュの管理コストが急増する。(もちろんセグメント間のCIDR重複はNGです)

多数のVPC・複数リージョンを集約的に管理したい(将来的な拡張を見込む場合も含む)

  • Transit Gatewayを選択。ハブ&スポークで管理を集約できるが、月$200程度の固定費と静的ルート管理の運用コストを払う価値があるかどうかが判断ポイントになる。

特定のサービスだけを一方向に、片方のVPCから利用させたい(フルメッシュの到達性は不要)

  • PrivateLinkを選択。VPC全体を繋げるのではなく、公開したいサービス単位でアクセスを絞れる。固定費はTGWより安いが、レイテンシは最速のVPC Peering比で最も増える。

一番速いから、一番安いからで単純に選択はできず、VPCの数・将来の拡張性・公開したい単位・許容できるレイテンシ増分・固定費予算のバランスで決める話だと思います。

9. 制約・注意点(備忘)

途中で色々と書いてきましたが、私が同様のことをしようとした際に忘れたり、躓きそうなので、まとめておきます。

  • VPC PeeringはVPC間を推移できません。中継VPCを経由した接続はできません。
  • TGW peering attachmentはルート伝播非対応です。静的ルートの追加・保守が確実な制御を可能にする一方で、運用負荷にもなります。
  • VPC PeeringとTGWは同じ対向VPC CIDRを宛先にするため、同じルートテーブルに両方のルートを共存させることはできません。あまりないと思いますが、本番で併用する場合は、サブネットを分けてルートテーブルごと使い分けるなどの設計が必要になります。
  • PrivateLink(cross-region)はNLBベースのサービスに限定され、Private DNS自動解決が使えないため、エンドポイント固有のDNS名を明示的に扱う必要があります。
  • PrivateLink配下のNLBは、IPv6 Source NATを有効にしない限りUDPリスナーを持てません(TCP_UDP統合リスナーも単体UDPリスナーも不可)。UDPを流したい場合はデュアルスタックNLB+IPv6 CIDRの追加構成が前提になります。
  • リージョン間のVPC Peering・TGW peeringのMTUは実測で8500。(同一リージョン内はJumbo Frame 9001)。2025年3月のアップデートで8500まで引き上げられている。

10. まとめ

AWS環境でマネージドで東京-大阪間を閉域で接続する3方式(VPC Peering、Transit Gateway、PrivateLink)を実際に構築して計測してみました。

レイテンシに関しては、AZ内(0.2ms)→AZ間(1.7ms)→VPC Peering(8.9ms)→TGW(11.0ms)→PrivateLink(13.3ms)ときれいな序列が確認でき、さらに追加実験でPrivateLinkの遅さをNLBを経由することによる遅延(+0.7〜1.2ms)とcross-regionエンドポイント機構を経由する(と想定)遅延(+1.7ms程度)に分解できたのは、実際に手を動かしてみないと得られない収穫でした。

スループットに関しては、c6i.largeのネットワークバーストクレジットという見えない変数に何度も足元をすくわれました。最初は300秒、次は60秒+クールダウンと対策しても解消せず、最終的にパスごとに時間を空けてインスタンスを再起動してから単独計測するという力技でようやくクリーンな値にたどり着きました。その結果、TCP4並列でVPC Peering(10.6G) > PrivateLink(10.0G) > TGW(9.7G)という、方式間の実質的な差を確認できました。レイテンシではTGWがPrivateLinkより速いのに、スループットでは逆転するという意外な発見もありました。ベンチマークの実行時間・実行順序・インスタンスの状態次第で数値が大きくブレるという事実そのものが、この記事のもう一つの大きな収穫です。

また、構築の過程では気づきにくい制約(VPCルートテーブルの競合、PrivateLink+NLBのUDP非対応、cross-region PrivateLinkに必要な2AZ要件、PrivateLinkがICMPを転送しないこと)にいくつも遭遇しました。これらは実際に手を動かして初めて見えてくるもので、設計段階で知っておければ手戻りを減らせるはずです。まだまだ学ぶことが多くあります。

方式選定にあたっては、レイテンシ要件がシビアでないなら管理のしやすさ(VPC数・将来の拡張性・公開したい単位)で選び、レイテンシがシビアならまずVPC Peeringを検討し、TGW・PrivateLinkを使う場合はその分のオーバーヘッドを織り込んでおく、というのが今回の実測から得られた実践的な結論です。

タイトルではどれが速いかと書きましたが、どれが一番速いかだけでアーキテクチャを決めるのは危険だと思います。今回実測した通り、速度だけならVPC Peeringが有利ですが、実際の設計ではVPCの数・将来の拡張性・公開したい範囲・運用のしやすさ、そして月々の固定費(TGWは約$204/月、PrivateLinkは約$38/月、Peeringは$0)まで含めて総合的に判断する必要があります。速度・構成・費用のどれか一つに引っ張られず、自分たちのワークロードにとって何が本当に必要な条件なのかを見極めた上で、最適なアーキテクチャを選んでいきたいと思っています。

これからリージョン間接続のアーキテクチャ設計をしようとされるどなたかの参考になれば幸いです。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?