ECS Gateway と CloudFront VPC オリジンの構成を CloudFormation と CLI で組んでみた

ECS Gateway と CloudFront VPC オリジンの構成を CloudFormation と CLI で組んでみた

ECS Gateway と CloudFront VPC オリジンの構成を CloudFormation で組みました。ECS Gateway の ALB の ARN など、スタック作成直後に存在しない値の参照は、CLI で取得してパラメータとして渡す形で回避しました。
2026.11.14

はじめに

2026年10月20日に Amazon ECS Gateway が発表されました。

https://aws.amazon.com/jp/about-aws/whats-new/2026/10/announcing-amazon-ecs-gateway/

ECS Gateway は、サービスごとのホスト名、証明書、リスナールールを持つ共有 ALB を作る機能です。パブリックとプライベートのゲートウェイをコンソールと CLI で作る手順は、前回の記事で紹介しています。

https://dev.classmethod.jp/articles/try-amazon-ecs-gateway/

今回は、ECS Gateway が作る internal ALB を CloudFront の VPC オリジンにつないで Nginx を公開する構成を、CloudFormation で組みます。共有 ALB は最初のサービスを接続した時点で ECS Gateway が作るため、ALB の ARN をテンプレートから参照できません。そこで、ARN は CLI で取得し、CloudFormation のパラメータで渡しました。

この記事では、テンプレートに書けた範囲と CLI で補った箇所を整理します。

構成

東京リージョン(ap-northeast-1)と us-east-1 にまたがる7つのスタックを作成しました。

ECS Gateway と CloudFront VPC オリジンの構成図

スタック(リージョン) 作った CloudFormation のリソースタイプ CLI で補った値
gwcf-network(ap-northeast-1) AWS::EC2::VPC、AWS::EC2::InternetGateway、AWS::EC2::VPCGatewayAttachment、AWS::EC2::Subnet、AWS::EC2::NatGateway、AWS::EC2::RouteTable、AWS::EC2::Route、AWS::EC2::SubnetRouteTableAssociation、AWS::EC2::VPCEndpoint、AWS::EC2::SecurityGroup なし
gwcf-gateway(ap-northeast-1) AWS::IAM::Role、AWS::EC2::SecurityGroup、AWS::ECS::Cluster、AWS::ECS::Gateway、AWS::ECS::GatewayClusterAssociations CloudFront のプレフィックスリストの ID、VPC オリジンの管理セキュリティグループの ID(VPC オリジンの作成後に更新で渡す)
gwcf-nginx(ap-northeast-1) AWS::Logs::LogGroup、AWS::IAM::Role、AWS::ECS::TaskDefinition、AWS::ECS::Service なし
gwcf-vpc-origin(us-east-1) AWS::CloudFront::VpcOrigin ALB の ARN
gwcf-cloudfront(us-east-1) AWS::CloudFront::Distribution serviceUrl のホスト名
gwcf-alb-logs(ap-northeast-1) AWS::S3::Bucket、AWS::S3::BucketPolicy、AWS::Logs::DeliverySource、AWS::Logs::DeliveryDestination、AWS::Logs::Delivery ALB の ARN
gwcf-cf-logs(us-east-1) gwcf-alb-logs と同じ5種類のリソースタイプ なし
  • ゲートウェイのスタックは2段階で構成しました。ALB の受信元は、VPC オリジンの作成前は CloudFront のマネージドプレフィックスリストにし、作成後は CloudFront が作る管理セキュリティグループ CloudFront-VPCOrigins-Service-SG に更新します。管理セキュリティグループにすると、自分の CloudFront ディストリビューションからの通信だけに絞れます。
  • ALB の ARN が必要な VPC オリジンは、Nginx サービスのスタックの後に作成します。
  • アクセスログ配信は、別スタックで追加しました。

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-vpc-origins.html

CloudFormation で書けなかった範囲

ECS Gateway が作るリソース

ゲートウェイを作ってクラスターに関連付けた時点では、ALB はまだありません。describe-gateway-resources の出力では、hostSharedAlb が空です。

{
    "gatewayResources": {
        "gatewayArn": "<GATEWAY_ARN>",
        "resources": {
            "hostSharedAlb": []
        }
    }
}

最初のサービスを接続すると、ECS Gateway が ALB、リスナー、ターゲットグループ、証明書、リスナールール、サービス用セキュリティグループを作ります。AWS::ECS::Gateway と AWS::ECS::Service の GetAtt では、ALB などの ARN を取得できません。

接続後は、describe-service-revisions の ecsManagedResources が次のキーで ARN を返します。ALB とリスナーの ARN は describe-gateway-resources でも取れます。

"acmCertificateArn": "<ACM_CERTIFICATE_ARN>",
"loadBalancerArn": "<ALB_ARN>",
"listenerArn": "<LISTENER_ARN>",
"listenerRuleArn": "<LISTENER_RULE_ARN>",
"targetGroupArn": "<TARGET_GROUP_ARN>"

CLI で取得した値

CLI で取得してパラメータで渡した値は4つです。

値 渡す先 CloudFormation での取得
ALB の ARN gwcf-vpc-origin と gwcf-alb-logs の AlbArn AWS::ECS::Gateway の GetAtt で取れるのは AccessType、Arn、Configuration.HostSharedAlb.CurrentAlbCount、CreatedAt、Status、UpdatedAt のみ。AWS::ECS::Service の GetAtt で取れるのは Name と ServiceArn のみ
serviceUrl gwcf-cloudfront の OriginDomainName AWS::ECS::Service の GetAtt で取れるのは Name と ServiceArn のみ
VPC オリジンの管理セキュリティグループの ID gwcf-gateway の VpcOriginSecurityGroupId AWS::CloudFront::VpcOrigin の GetAtt で取れるのは AccountId、Arn、CreatedTime、Id、LastModifiedTime、Status のみ
CloudFront のプレフィックスリストの ID gwcf-gateway の CloudFrontPrefixListId AWS が管理するプレフィックスリスト(OwnerId が AWS)

https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-ecs-gateway.html

https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-ecs-service.html

https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-cloudfront-vpcorigin.html

取得に使ったコマンドです。

# ALB の ARN(出力の loadBalancerArn)
aws ecs describe-gateway-resources --region ap-northeast-1 --gateway gwcf-gateway --output json

# サービスリビジョンの ARN(CLI ヘルプを基に記載。出力は保存していない)
aws ecs list-service-deployments --region ap-northeast-1 --service <SERVICE_ARN>

# serviceUrl
aws ecs describe-service-revisions --region ap-northeast-1 --service-revision-arns <SERVICE_REVISION_ARN> --query 'serviceRevisions[0].ecsManagedResources' --output json

# VPC オリジンの管理セキュリティグループの ID
aws ec2 describe-security-groups --region ap-northeast-1 --filters 'Name=group-name,Values=CloudFront-VPCOrigins-Service-SG*' --query 'SecurityGroups[].{name:GroupName,id:GroupId,desc:Description,vpc:VpcId,tags:Tags}' --output json

# CloudFront のプレフィックスリストの ID
aws ec2 describe-managed-prefix-lists --region ap-northeast-1 --filters 'Name=prefix-list-name,Values=com.amazonaws.global.cloudfront.origin-facing' --output json

各コマンドの出力のうち、使った行です。

"loadBalancerArn": "<ALB_ARN>",
"serviceUrl": "https://gw-<32桁のID>.ecs.ap-northeast-1.on.aws",
"id": "<VPC_ORIGIN_SG_ID>",
"PrefixListId": "<PREFIX_LIST_ID>",

describe-service-revisions に渡すリビジョン ARN は、list-service-deployments が返す targetServiceRevisionArn から取れます。

serviceUrl は https:// 付きで返るので、OriginDomainName には https:// を除いたホスト名を渡します。管理セキュリティグループは、グループ名だけで絞り込むと1件が返りました。VPC オリジンが複数ある環境では、Name=vpc-id,Values=<VPC_ID> も加えて絞ります。

スタック間の値の受け渡し

CLI で取得した4つ以外のスタック間の値は、Fn::GetStackOutput で渡しました。Region を指定すると、東京のスタックから us-east-1 のスタックの Output も参照できます。

参照するスタック Output を持つスタック Output
gwcf-gateway gwcf-network VpcId、PrivateSubnet1Id、PrivateSubnet2Id、PrivateSubnet3Id
gwcf-nginx gwcf-network PrivateSubnet1Id、PrivateSubnet2Id、PrivateSubnet3Id、TaskSecurityGroupId
gwcf-nginx gwcf-gateway ClusterName
gwcf-nginx gwcf-cloudfront(us-east-1) DistributionDomainName
gwcf-cloudfront gwcf-vpc-origin VpcOriginId
gwcf-cf-logs gwcf-cloudfront DistributionArn

gwcf-cloudfront は gwcf-nginx のサービスの serviceUrl を必要とします。gwcf-nginx は gwcf-cloudfront のドメインを必要とします。2つのスタックは互いに依存しています。そこで gwcf-nginx は、最初は BaseUrl パラメータの既定値(プレースホルダー)で作りました。CloudFront の作成後に UseCloudFrontDomain を true にして更新し、DistributionDomainName を読ませました。

https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/intrinsic-function-reference-getstackoutput.html

テンプレート

テンプレートは 01-network.yaml から 07-cf-logs.yaml の7ファイルで、次の順にデプロイしました。山括弧の値は、各コマンドの実行前に CLI で取得した値です。

aws cloudformation deploy --region ap-northeast-1 --stack-name gwcf-network --template-file 01-network.yaml --no-fail-on-empty-changeset
aws cloudformation deploy --region ap-northeast-1 --stack-name gwcf-gateway --template-file 02-gateway.yaml --capabilities CAPABILITY_NAMED_IAM --no-fail-on-empty-changeset --parameter-overrides CloudFrontPrefixListId=<PREFIX_LIST_ID>
aws cloudformation deploy --region ap-northeast-1 --stack-name gwcf-nginx --template-file 03-nginx.yaml --capabilities CAPABILITY_NAMED_IAM --no-fail-on-empty-changeset
aws cloudformation deploy --region us-east-1 --stack-name gwcf-vpc-origin --template-file 04-vpc-origin.yaml --no-fail-on-empty-changeset --parameter-overrides AlbArn=<ALB_ARN>
aws cloudformation deploy --region us-east-1 --stack-name gwcf-cloudfront --template-file 05-cloudfront.yaml --no-fail-on-empty-changeset --parameter-overrides OriginDomainName=<SERVICE_HOST>
aws cloudformation deploy --region ap-northeast-1 --stack-name gwcf-alb-logs --template-file 06-alb-logs.yaml --no-fail-on-empty-changeset --parameter-overrides AlbArn=<ALB_ARN>
aws cloudformation deploy --region us-east-1 --stack-name gwcf-cf-logs --template-file 07-cf-logs.yaml --no-fail-on-empty-changeset

ネットワークとゲートウェイ

プライベートサブネット3つの VPC と、internal で HTTPS(443)のゲートウェイを作ります。ゲートウェイの InboundSecurityGroup には、同じスタックで作ったセキュリティグループを指定します。VpcOriginSecurityGroupId が空のときはプレフィックスリストからの443を、値を渡したときは管理セキュリティグループからの443だけを許可する条件にしてあります。

01-network.yaml と 02-gateway.yaml
01-network.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Sample 1/7 network (ap-northeast-1). Minimal VPC for the ECS Gateway sample:
  3 private subnets, IGW, one regional NAT gateway, S3 gateway endpoint, and a security group for the tasks.

Resources:
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.20.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - { Key: Name, Value: gwcf-vpc }

  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - { Key: Name, Value: gwcf-igw }

  InternetGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref Vpc
      InternetGatewayId: !Ref InternetGateway

  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [0, !GetAZs ""]
      CidrBlock: 10.20.0.0/24
      Tags:
        - { Key: Name, Value: gwcf-private-1 }
  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [1, !GetAZs ""]
      CidrBlock: 10.20.1.0/24
      Tags:
        - { Key: Name, Value: gwcf-private-2 }
  PrivateSubnet3:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [2, !GetAZs ""]
      CidrBlock: 10.20.2.0/24
      Tags:
        - { Key: Name, Value: gwcf-private-3 }

  # Regional NAT gateway (auto AZ expansion). Only used so that the tasks can pull the public ECR image.
  NatGateway:
    Type: AWS::EC2::NatGateway
    DependsOn: InternetGatewayAttachment
    Properties:
      AvailabilityMode: regional
      ConnectivityType: public
      VpcId: !Ref Vpc
      Tags:
        - { Key: Name, Value: gwcf-natgw }

  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
      Tags:
        - { Key: Name, Value: gwcf-private-rt }

  PrivateDefaultRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NatGateway

  PrivateSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet1
      RouteTableId: !Ref PrivateRouteTable
  PrivateSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet2
      RouteTableId: !Ref PrivateRouteTable
  PrivateSubnet3RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet3
      RouteTableId: !Ref PrivateRouteTable

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

  # Security group of the Nginx tasks. No inbound rule here.
  TaskSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: gwcf-task-sg
      GroupDescription: gwcf Nginx tasks
      VpcId: !Ref Vpc
      Tags:
        - { Key: Name, Value: gwcf-task-sg }

Outputs:
  VpcId:
    Value: !Ref Vpc
  PrivateSubnet1Id:
    Value: !Ref PrivateSubnet1
  PrivateSubnet2Id:
    Value: !Ref PrivateSubnet2
  PrivateSubnet3Id:
    Value: !Ref PrivateSubnet3
  TaskSecurityGroupId:
    Value: !GetAtt TaskSecurityGroup.GroupId
02-gateway.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Sample 2/7 gateway (ap-northeast-1). ECS Gateway (internal ALB, HTTPS 443), the ALB security group, the cluster and
  the gateway-cluster association. The ALB itself is created by ECS when the first service connects.

Parameters:
  CloudFrontPrefixListId:
    Type: String
    Description: ID of com.amazonaws.global.cloudfront.origin-facing in this Region (taken with the AWS CLI).
  VpcOriginSecurityGroupId:
    Type: String
    Default: ""
    Description: >-
      ID of CloudFront-VPCOrigins-Service-SG (taken with the AWS CLI after the VPC origin exists).
      Empty = allow the CloudFront managed prefix list. Set = allow only this security group.

  DeletionProtection:
    Type: String
    Default: "true"
    AllowedValues: ["true", "false"]
    Description: Deletion protection of the gateway. Set to "false" (stack update) before deleting the stack.

Conditions:
  UseVpcOriginSecurityGroup: !Not [!Equals [!Ref VpcOriginSecurityGroupId, ""]]

Resources:
  InfrastructureRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: gwcf-ecs-gateway-infra-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: { Service: ecs.amazonaws.com }
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonECSInfrastructureRoleForGatewayHostSharedALB

  # Inbound security group of the ALB (ours, so the gateway creates no inbound rules on it).
  AlbSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: gwcf-gateway-alb-sg
      GroupDescription: gwcf ECS Gateway ALB inbound (HTTPS 443 from CloudFront)
      VpcId:
        Fn::GetStackOutput:
          StackName: gwcf-network
          OutputName: VpcId
          Region: ap-northeast-1
      SecurityGroupIngress:
        - !If
          - UseVpcOriginSecurityGroup
          - IpProtocol: tcp
            FromPort: 443
            ToPort: 443
            SourceSecurityGroupId: !Ref VpcOriginSecurityGroupId
            Description: HTTPS from the CloudFront VPC origin (managed SG)
          - !Ref AWS::NoValue
        - !If
          - UseVpcOriginSecurityGroup
          - !Ref AWS::NoValue
          - IpProtocol: tcp
            FromPort: 443
            ToPort: 443
            SourcePrefixListId: !Ref CloudFrontPrefixListId
            Description: HTTPS from the CloudFront managed prefix list
      Tags:
        - { Key: Name, Value: gwcf-gateway-alb-sg }

  Cluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: gwcf-cluster

  Gateway:
    Type: AWS::ECS::Gateway
    DependsOn: InfrastructureRole
    Properties:
      GatewayName: gwcf-gateway
      InfrastructureRoleArn: !GetAtt InfrastructureRole.Arn
      DeletionProtection: !Ref DeletionProtection
      PropagateTags: GATEWAY
      Configuration:
        HostSharedAlb:
          Scheme: internal
          Protocol: HTTPS
          Port: 443
          HttpDesync: defensive
          MaxAlbCount: 2
          NetworkConfiguration:
            IpAddressType: ipv4
            InboundSecurityGroup: !GetAtt AlbSecurityGroup.GroupId
            Subnets:
              - Fn::GetStackOutput:
                  StackName: gwcf-network
                  OutputName: PrivateSubnet1Id
                  Region: ap-northeast-1
              - Fn::GetStackOutput:
                  StackName: gwcf-network
                  OutputName: PrivateSubnet2Id
                  Region: ap-northeast-1
              - Fn::GetStackOutput:
                  StackName: gwcf-network
                  OutputName: PrivateSubnet3Id
                  Region: ap-northeast-1
      Tags:
        - { Key: Name, Value: gwcf-gateway }

  GatewayAssociation:
    Type: AWS::ECS::GatewayClusterAssociations
    Properties:
      Cluster: !Ref Cluster
      Gateway: !GetAtt Gateway.Arn

Outputs:
  GatewayArn:
    Value: !GetAtt Gateway.Arn
  ClusterName:
    Value: !Ref Cluster
  AlbSecurityGroupId:
    Value: !GetAtt AlbSecurityGroup.GroupId

Nginx サービス

Nginx は Fargate サービスとして3つのプライベートサブネットに置きました。コンテナは起動時に環境変数を展開して設定ファイルを作ります。応答として、サービス名、Host 系のヘッダー、BASE_URL を返します。

ゲートウェイコネクタの accessTypes には PRIVATE を指定します。指定すると internal のゲートウェイにだけ接続します。省略した場合、後からインターネット向けのゲートウェイをクラスターに関連付けると、次のデプロイでサービスが公開されます。

https://docs.aws.amazon.com/AmazonECS/latest/developerguide/gateway-connect-service.html

タスク用セキュリティグループの gwcf-task-sg には、ALB からの受信ルールを書いていません。それでもデプロイは成功し、ターゲットは healthy になりました。タスクの ENI に、gwcf-task-sg に加えて ECS Gateway が作ったサービス用セキュリティグループも付いているためです。このセキュリティグループは、ECS Gateway が ALB に付けたセキュリティグループ(自前の gwcf-gateway-alb-sg とは別)からのポート 80 を許可しています。

03-nginx.yaml
03-nginx.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Sample 3/7 nginx (ap-northeast-1). Nginx Fargate service(s) connected to the internal ECS Gateway.
  Nginx answers with the host name it sees and the BASE_URL environment variable.

Parameters:
  BaseUrl:
    Type: String
    Default: https://example.invalid
    Description: Public URL of the app (the CloudFront domain). A placeholder at first, replaced after CloudFront exists.
  UseCloudFrontDomain:
    Type: String
    Default: "false"
    AllowedValues: ["true", "false"]
    Description: >-
      "true" = BASE_URL is https:// + the CloudFront domain, read from the cloudfront stack (us-east-1) with Fn::GetStackOutput.
      "false" = BASE_URL is the BaseUrl parameter.

Conditions:
  UseCloudFrontDomainCondition: !Equals [!Ref UseCloudFrontDomain, "true"]

Resources:
  LogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: /ecs/gwcf-nginx
      RetentionInDays: 3

  TaskExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: gwcf-task-execution-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: { Service: ecs-tasks.amazonaws.com }
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

  # ---------------------------------------------------------------- service 1
  TaskDefinition1:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: gwcf-nginx-1
      RequiresCompatibilities: [FARGATE]
      NetworkMode: awsvpc
      Cpu: "256"
      Memory: "512"
      RuntimePlatform:
        CpuArchitecture: X86_64
        OperatingSystemFamily: LINUX
      ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn
      ContainerDefinitions:
        - Name: nginx
          Image: public.ecr.aws/docker/library/nginx:stable
          Essential: true
          PortMappings:
            - { Name: http, ContainerPort: 80, Protocol: tcp, AppProtocol: http }
          Environment:
            - { Name: SERVICE_NAME, Value: gwcf-nginx-1 }
            - Name: BASE_URL
              Value: !If
                - UseCloudFrontDomainCondition
                - !Join
                  - ""
                  - - https://
                    - Fn::GetStackOutput:
                        StackName: gwcf-cloudfront
                        OutputName: DistributionDomainName
                        Region: us-east-1
                - !Ref BaseUrl
          # The official image expands ${VAR} in /etc/nginx/templates/*.template at start-up.
          EntryPoint: [/bin/sh, -c]
          Command:
            - |
              mkdir -p /etc/nginx/templates
              cat > /etc/nginx/templates/default.conf.template <<'EOF'
              server {
                listen 80;
                location / {
                  default_type text/plain;
                  return 200 "service=${SERVICE_NAME}\nhost=$host\nhttp_host=$http_host\nx_forwarded_host=$http_x_forwarded_host\nx_forwarded_proto=$http_x_forwarded_proto\nx_forwarded_for=$http_x_forwarded_for\nBASE_URL=${BASE_URL}\n";
                }
              }
              EOF
              exec /docker-entrypoint.sh nginx -g 'daemon off;'
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: nginx1

  Service1:
    Type: AWS::ECS::Service
    Properties:
      ServiceName: gwcf-nginx-1
      Cluster:
        Fn::GetStackOutput:
          StackName: gwcf-gateway
          OutputName: ClusterName
          Region: ap-northeast-1
      TaskDefinition: !Ref TaskDefinition1
      LaunchType: FARGATE
      DesiredCount: 1
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: DISABLED
          SecurityGroups:
            - Fn::GetStackOutput:
                StackName: gwcf-network
                OutputName: TaskSecurityGroupId
                Region: ap-northeast-1
          Subnets:
            - Fn::GetStackOutput:
                StackName: gwcf-network
                OutputName: PrivateSubnet1Id
                Region: ap-northeast-1
            - Fn::GetStackOutput:
                StackName: gwcf-network
                OutputName: PrivateSubnet2Id
                Region: ap-northeast-1
            - Fn::GetStackOutput:
                StackName: gwcf-network
                OutputName: PrivateSubnet3Id
                Region: ap-northeast-1
      GatewayConnectors:
        - PortName: http
          Filters:
            AccessTypes: [PRIVATE]
          HealthCheck:
            HealthCheckPath: /

Outputs:
  Service1Name:
    Value: !GetAtt Service1.Name

VPC オリジンと CloudFront

04-vpc-origin.yaml は、ALB の ARN をパラメータで受け取ります。

04-vpc-origin.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Sample 4/7 vpc-origin (us-east-1). CloudFront VPC origin that points at the internal ALB of the ECS Gateway.
  The ALB ARN is not a CloudFormation attribute of the gateway or the service, so it is passed as a parameter.

Parameters:
  AlbArn:
    Type: String
    Description: ARN of the ALB created by the ECS Gateway (taken with `aws ecs describe-gateway-resources`).

Resources:
  VpcOrigin:
    Type: AWS::CloudFront::VpcOrigin
    Properties:
      VpcOriginEndpointConfig:
        Name: gwcf-alb
        Arn: !Ref AlbArn
        HTTPPort: 80
        HTTPSPort: 443
        OriginProtocolPolicy: https-only
      Tags:
        - { Key: Name, Value: gwcf-vpc-origin }

Outputs:
  VpcOriginId:
    Value: !Ref VpcOrigin

05-cloudfront.yaml は、オリジンのドメイン名をパラメータで、VPC オリジンの ID を Fn::GetStackOutput で受け取ります。キャッシュポリシーにはキャッシュしない Managed-CachingDisabled を指定しました。オリジンリクエストポリシーには Host を転送しない Managed-AllViewerExceptHostHeader を指定しました。

05-cloudfront.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Sample 5/7 cloudfront (us-east-1). CloudFront distribution whose origin is the ECS Gateway service host name
  (reached through the VPC origin). The Host header is not forwarded; nothing is cached. No log settings here.

Parameters:
  OriginDomainName:
    Type: String
    Description: Host name of serviceUrl without https:// (taken with `aws ecs describe-service-revisions`).
  OriginRequestPolicyId:
    Type: String
    # Managed-AllViewerExceptHostHeader (Host is not forwarded).
    Default: b689b0a8-53d0-40ab-baf2-68738e2966ac

Resources:
  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        Comment: gwcf sample
        HttpVersion: http2
        IPV6Enabled: false
        PriceClass: PriceClass_100
        Origins:
          - Id: gateway-alb
            DomainName: !Ref OriginDomainName
            VpcOriginConfig:
              VpcOriginId:
                Fn::GetStackOutput:
                  StackName: gwcf-vpc-origin
                  OutputName: VpcOriginId
                  Region: us-east-1
        DefaultCacheBehavior:
          TargetOriginId: gateway-alb
          ViewerProtocolPolicy: redirect-to-https
          AllowedMethods: [GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE]
          CachedMethods: [GET, HEAD]
          Compress: true
          # Managed-CachingDisabled
          CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
          OriginRequestPolicyId: !Ref OriginRequestPolicyId
      Tags:
        - { Key: Name, Value: gwcf-cloudfront }

Outputs:
  DistributionId:
    Value: !Ref Distribution
  DistributionDomainName:
    Value: !GetAtt Distribution.DomainName
  DistributionArn:
    Value: !Sub arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${Distribution}

アクセスログ配信

ALB と CloudFront のアクセスログは、それぞれ別のスタックで S3 へ配信します。DeliverySource の ResourceArn には、ALB は CLI で取得した ARN を、CloudFront は Fn::GetStackOutput で取得した gwcf-cloudfront の DistributionArn を渡しています。

06-alb-logs.yaml
06-alb-logs.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Sample 6/7 alb-logs (ap-northeast-1). ALB access logs v2 (CloudWatch Logs vended logs delivery) to S3.
  Added after the gateway exists; the ALB attributes (access_logs.s3.*) are not touched.

Parameters:
  AlbArn:
    Type: String
    Description: ARN of the ALB created by the ECS Gateway.

Resources:
  LogBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Delete
    Properties:
      BucketName: !Sub gwcf-alb-logs-${AWS::AccountId}-${AWS::Region}
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      OwnershipControls:
        Rules:
          - ObjectOwnership: BucketOwnerEnforced
      LifecycleConfiguration:
        Rules:
          - Id: expire-logs
            Status: Enabled
            ExpirationInDays: 7

  LogBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref LogBucket
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: AllowLogDeliveryPutObject
            Effect: Allow
            Principal: { Service: delivery.logs.amazonaws.com }
            Action: s3:PutObject
            Resource: !Sub ${LogBucket.Arn}/*
            Condition:
              StringEquals:
                aws:SourceAccount: !Ref AWS::AccountId
              ArnLike:
                aws:SourceArn: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:delivery-source:*
          - Sid: AllowLogDeliveryGetBucketAcl
            Effect: Allow
            Principal: { Service: delivery.logs.amazonaws.com }
            Action: s3:GetBucketAcl
            Resource: !GetAtt LogBucket.Arn
            Condition:
              StringEquals:
                aws:SourceAccount: !Ref AWS::AccountId
              ArnLike:
                aws:SourceArn: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:delivery-source:*

  DeliverySource:
    Type: AWS::Logs::DeliverySource
    Properties:
      Name: gwcf-alb-access-logs
      ResourceArn: !Ref AlbArn
      LogType: ALB_ACCESS_LOGS

  DeliveryDestination:
    Type: AWS::Logs::DeliveryDestination
    DependsOn: LogBucketPolicy
    Properties:
      Name: gwcf-alb-logs-s3-json
      DeliveryDestinationType: S3
      OutputFormat: json
      DestinationResourceArn: !GetAtt LogBucket.Arn

  Delivery:
    Type: AWS::Logs::Delivery
    Properties:
      DeliverySourceName: !Ref DeliverySource
      DeliveryDestinationArn: !GetAtt DeliveryDestination.Arn
      S3SuffixPath: "alb/{yyyy}/{MM}/{dd}"

Outputs:
  BucketName:
    Value: !Ref LogBucket
07-cf-logs.yaml
07-cf-logs.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Sample 7/7 cf-logs (us-east-1). CloudFront standard logging v2 to S3, added to an existing distribution
  from a separate stack. The distribution ARN comes from the cloudfront stack through Fn::GetStackOutput.

Resources:
  LogBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Delete
    Properties:
      BucketName: !Sub gwcf-cf-logs-${AWS::AccountId}-${AWS::Region}
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      OwnershipControls:
        Rules:
          - ObjectOwnership: BucketOwnerEnforced
      LifecycleConfiguration:
        Rules:
          - Id: expire-logs
            Status: Enabled
            ExpirationInDays: 7

  LogBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref LogBucket
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: AllowLogDeliveryPutObject
            Effect: Allow
            Principal: { Service: delivery.logs.amazonaws.com }
            Action: s3:PutObject
            Resource: !Sub ${LogBucket.Arn}/*
            Condition:
              StringEquals:
                aws:SourceAccount: !Ref AWS::AccountId
              ArnLike:
                aws:SourceArn: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:delivery-source:*
          - Sid: AllowLogDeliveryGetBucketAcl
            Effect: Allow
            Principal: { Service: delivery.logs.amazonaws.com }
            Action: s3:GetBucketAcl
            Resource: !GetAtt LogBucket.Arn
            Condition:
              StringEquals:
                aws:SourceAccount: !Ref AWS::AccountId
              ArnLike:
                aws:SourceArn: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:delivery-source:*

  DeliverySource:
    Type: AWS::Logs::DeliverySource
    Properties:
      Name: gwcf-cf-access-logs
      ResourceArn:
        Fn::GetStackOutput:
          StackName: gwcf-cloudfront
          OutputName: DistributionArn
          Region: us-east-1
      LogType: ACCESS_LOGS

  DeliveryDestination:
    Type: AWS::Logs::DeliveryDestination
    DependsOn: LogBucketPolicy
    Properties:
      Name: gwcf-cf-logs-s3-json
      DeliveryDestinationType: S3
      OutputFormat: json
      DestinationResourceArn: !GetAtt LogBucket.Arn

  Delivery:
    Type: AWS::Logs::Delivery
    Properties:
      DeliverySourceName: !Ref DeliverySource
      DeliveryDestinationArn: !GetAtt DeliveryDestination.Arn
      S3SuffixPath: "cf/{yyyy}/{MM}/{dd}"

Outputs:
  BucketName:
    Value: !Ref LogBucket

動作確認

CloudFront からの到達

プレフィックスリストからの通信を許可した段階で、CloudFront のドメインにリクエストを送ると、プライベートサブネットの Nginx から応答が返りました。

HTTP/2 200 
content-type: text/plain
content-length: 279
date: Fri, 13 Nov 2026 16:30:39 GMT
server: nginx/1.30.6
x-cache: Miss from cloudfront

service=gwcf-nginx-1
host=gw-<32桁のID>.ecs.ap-northeast-1.on.aws
http_host=gw-<32桁のID>.ecs.ap-northeast-1.on.aws
x_forwarded_host=
x_forwarded_proto=https
x_forwarded_for=<client-ip>, <cloudfront-ip>
BASE_URL=https://example.invalid

受信元の絞り込み

プレフィックスリスト指定で疎通を確認した後、VpcOriginSecurityGroupId に管理セキュリティグループの ID を渡して gwcf-gateway を更新しました。更新後も CloudFront から疎通できました。

aws cloudformation deploy --region ap-northeast-1 --stack-name gwcf-gateway --template-file 02-gateway.yaml --capabilities CAPABILITY_NAMED_IAM --no-fail-on-empty-changeset --parameter-overrides CloudFrontPrefixListId=<PREFIX_LIST_ID> VpcOriginSecurityGroupId=<VPC_ORIGIN_SG_ID>

Host ヘッダーと BASE_URL

ECS Gateway の ALB はホスト名でサービスを振り分けるため、オリジンリクエストポリシーは Host を転送しない Managed-AllViewerExceptHostHeader にしています。そのため、アプリに届く host は CloudFront のドメインではなく、serviceUrl の on.aws のホスト名です。

画面遷移などで host を利用するアプリは、BASE_URL のような環境変数で公開 URL を渡す必要があります。

gwcf-nginx を UseCloudFrontDomain=true で更新し、https:// を付けた DistributionDomainName を ECS の環境変数 BASE_URL に渡すと、次の応答になりました。

service=gwcf-nginx-1
host=gw-<32桁のID>.ecs.ap-northeast-1.on.aws
http_host=gw-<32桁のID>.ecs.ap-northeast-1.on.aws
x_forwarded_host=
x_forwarded_proto=https
x_forwarded_for=<client-ip>, <cloudfront-ip>
BASE_URL=https://<CLOUDFRONT_DOMAIN>

まとめ

ECS Gateway、ECS サービス、VPC オリジン、CloudFront などを、CloudFormation のテンプレートで作成できました。

ECS Gateway の ALB はホスト名で振り分けるため、アプリ側の環境変数で BASE_URL(NUXT_APP_BASE_URL など)の設定が必要になる場合があります。それでも、共有 ALB をシンプルに実現できます。

マルチテナント環境などで、多数のサービスをなるべく少ない台数の ALB にまとめたい場合や、ALB を公開せず CloudFront 経由のアクセスだけに限定したい場合に、今回の構成をお試しください。

ECS Gateway の作成時点では存在しない ALB の ARN など、スタック間で参照できない値は、今回は CLI で取得して渡しました。Parameter Store やカスタムリソースを使う方法も、今後試してみたいと思います。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事