パブリックサブネットレスなVPCにECS Express Mode環境を構築してみた

パブリックサブネットレスなVPCにECS Express Mode環境を構築してみた

2025年11月リリースのRegional NAT GatewayとECS Express Modeを組み合わせ、パブリックサブネットを持たないVPC環境をCloudFormationで構築しました。Internal ALBとCloudFront VPC Originを利用したセキュアな配信経路の確立手順と、実機での疎通確認結果を紹介します。
2025.12.14

2025年11月にリリースされたRegional NAT Gatewayの登場により、パブリックサブネットの設置を省略したVPCでも、VPC内からのインターネット通信に NAT Gatewayを利用することが可能になりました。

今回、パブリックサブネットを持たず、プライベートサブネットとIPv6のみを有効にしたVPC上に、同時期にリリースされたECS Express Modeの環境を構築。CloudFrontのVPCオリジンサポートを組み合わせることで、パブリックサブネットに依存しないWeb配信基盤の実装を検証する機会がありました。

本構成では、Internal ALB(Express Modeにより管理)とCloudFront VPC Originを利用し、パブリックIPアドレスを持たないセキュアなコンテナ環境を実現します。本記事では、その構築に使用したCloudFormationテンプレートと、AWS環境での動作確認結果を紹介します

テストイメージ用意

検証用のアプリケーションとして、Node.js 22を利用した簡易サーバーを用意しました。 アクセス元のIPアドレス確認やヘルスチェック機能を実装しています。以下のスクリプトをCloudShell上で実行し、ECRへの登録を完了させました。

テストイメージ作成スクリプト
#!/bin/bash
# CloudShell用ECRテストイメージ作成スクリプト
# ECS Express Mode Test Application

set -e

# 設定値
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=${AWS_REGION:-$(aws configure get region 2>/dev/null || echo "ap-northeast-1")}
REPO_NAME="ecs-express-test"
IMAGE_TAG="latest"

echo "=== ECS Express Mode Test Image Setup ==="
echo "Account ID: $ACCOUNT_ID"
echo "Region: $REGION"
echo "Repository: $REPO_NAME"
echo ""

# Docker確認
echo "Docker environment check..."
docker --version
echo ""

# 1. ECRリポジトリ作成
echo "Creating ECR repository..."
aws ecr create-repository \
  --repository-name $REPO_NAME \
  --region $REGION \
  --image-scanning-configuration scanOnPush=true \
  --no-cli-pager 2>/dev/null || echo "Repository already exists"

# 2. テストアプリケーション作成
echo "Creating test application..."
mkdir -p test-app
cd test-app

# package.json作成
cat > package.json << 'EOF'
{
  "name": "ecs-express-test",
  "version": "1.0.0",
  "description": "ECS Test Application (node22)",
  "main": "app.js",
  "scripts": {
    "start": "node app.js"
  }
}
EOF

# app.js作成
cat > app.js << 'EOF'
const http = require('http');
const https = require('https');
const url = require('url');

const port = 3000;

// IPアドレス取得関数(修正版)
function getExternalIP(callback) {
  const options = {
    hostname: 'api64.ipify.org',
    path: '?format=json',
    method: 'GET',
    timeout: 5000
  };

  // コールバックの二重呼び出し防止用フラグ
  let isCalled = false;
  const done = (err, res) => {
    if (isCalled) return;
    isCalled = true;
    callback(err, res);
  };

  const req = https.request(options, (res) => {
    let data = '';
    res.on('data', (chunk) => {
      data += chunk;
    });
    res.on('end', () => {
      try {
        const result = JSON.parse(data);
        done(null, result);
      } catch (error) {
        // ここで生データもログに出しておくとデバッグしやすい
        console.error("Parse Error Data:", data); 
        done(error, null);
      }
    });
  });

  req.on('error', (error) => {
    done(error, null);
  });

  req.on('timeout', () => {
    req.destroy();
    done(new Error('Request timeout'), null);
  });

  req.end();
}

const server = http.createServer((req, res) => {
  const parsedUrl = url.parse(req.url, true);

  if (parsedUrl.pathname === '/') {
    res.setHeader('Content-Type', 'application/json');
    res.writeHead(200);
    res.end(JSON.stringify({
      message: 'ECS Test Application (node22)',
      timestamp: new Date().toISOString(),
      hostname: require('os').hostname(),
      environment: process.env.NODE_ENV || 'development',
      version: '1.0.0'
    }, null, 2));
  } else if (parsedUrl.pathname === '/api/health/') {
    res.setHeader('Content-Type', 'application/json');
    res.writeHead(200);
    res.end(JSON.stringify({
      status: 'healthy',
      uptime: process.uptime(),
      memory: process.memoryUsage(),
      timestamp: new Date().toISOString()
    }, null, 2));
  } else if (parsedUrl.pathname === '/api/ipify/') {
    res.setHeader('Content-Type', 'application/json');
    getExternalIP((error, result) => {
      if (error) {
        res.writeHead(500);
        res.end(JSON.stringify({
          error: 'Failed to get external IP',
          message: error.message,
          timestamp: new Date().toISOString()
        }, null, 2));
      } else {
        res.writeHead(200);
        res.end(JSON.stringify({
          ...result,
          timestamp: new Date().toISOString(),
          source: 'api64.ipify.org',
          note: 'ECS Node External IP Address'
        }, null, 2));
      }
    });
  } else if (parsedUrl.pathname === '/robots.txt') {
    res.setHeader('Content-Type', 'text/plain');
    res.writeHead(200);
    res.end('User-agent: *\nDisallow: /');
  } else {
    res.setHeader('Content-Type', 'application/json');
    res.writeHead(404);
    res.end(JSON.stringify({ error: 'Not Found' }));
  }
});

server.listen(port, '0.0.0.0', () => {
  console.log(`ECS Test Application (node22) listening on port ${port}`);
  console.log(`Health check: http://localhost:${port}/api/health/`);
  console.log(`Robots.txt: http://localhost:${port}/robots.txt`);
  console.log(`IP check: http://localhost:${port}/api/ipify/`);
});
EOF

# Dockerfile作成
cat > Dockerfile << 'EOF'
FROM node:22-alpine

# メタデータ
LABEL maintainer="ECS Express Mode Test"
LABEL description="ECS Test Application (node22) with ECS Exec support"

# ECS Exec用の必要なツールをインストール
RUN apk add --no-cache \
    curl \
    ca-certificates \
    bash \
    procps

# アプリケーションディレクトリ作成
WORKDIR /app

# package.jsonをコピーして依存関係をインストール(キャッシュ効率化)
COPY package.json .
# 現在は依存関係なしのため npm install 不要
# RUN npm ci --only=production

# アプリケーションコードをコピー
COPY app.js .

# 非rootユーザーで実行
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
USER nodejs

# ポート3000を公開
EXPOSE 3000

# ヘルスチェック追加
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:3000/api/health/ || exit 1

# アプリケーション実行
CMD ["npm", "start"]
EOF

# 3. ECR認証
echo "ECR authentication..."
aws ecr get-login-password --region $REGION 2>/dev/null | \
docker login --username AWS --password-stdin \
$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com >/dev/null 2>&1

if [ $? -eq 0 ]; then
  echo "ECR authentication successful"
else
  echo "ECR authentication failed"
  exit 1
fi

# 4. Dockerイメージビルド
echo "Building Docker image..."
docker build -t $REPO_NAME:$IMAGE_TAG .

# 5. イメージタグ付け
echo "Tagging image..."
docker tag $REPO_NAME:$IMAGE_TAG \
$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO_NAME:$IMAGE_TAG

# 6. ECRにプッシュ
echo "Pushing to ECR..."
docker push $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO_NAME:$IMAGE_TAG

# 7. イメージ情報確認
echo "Checking image information..."
aws ecr describe-images \
  --repository-name $REPO_NAME \
  --region $REGION \
  --query 'imageDetails[0].[imageTags[0],imageSizeInBytes,imagePushedAt]' \
  --output table

AWS構成

VPC

まず、基盤となるVPCを作成しました。

パブリックサブネットレスVPC

  • パブリックサブネットレス: インターネットからの直接のIngress通信を受け付けないプライベートサブネットのみで構成しました。

  • Regional NAT Gateway: 冗長性を持たせるため通常はAZごとにNAT Gatewayを配置しますが、コスト最適化とIPv6活用の観点から、1AZ (AZ-a) のみにRegionalモードで配置しました。AvailabilityMode: regional を指定、利用AZを明示することで単独AZ利用を実現しました。

  • IPv6対応: Egress-Only Internet Gatewayを配置し、IPv6通信を可能にしました。

VPC作成 テンプレート
AWSTemplateFormatVersion: '2010-09-09'
Description: 'ECS Express Mode Test VPC - Complete private VPC with Regional NAT Gateway'

Parameters:
  ProjectName:
    Type: String
    Default: 'ecs-express-vpc'
    Description: 'ECS Express Mode Test VPC - Project name prefix for AWS resources'

Resources:
  # VPC
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: '192.168.0.0/18'
      EnableDnsHostnames: true
      EnableDnsSupport: true
      Tags:
        - Key: Name
          Value: !Ref ProjectName

  # IPv6 CIDR Block
  IPv6CidrBlock:
    Type: AWS::EC2::VPCCidrBlock
    Properties:
      VpcId: !Ref VPC
      AmazonProvidedIpv6CidrBlock: true

  # プライベートサブネット
  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    DependsOn: IPv6CidrBlock
    Properties:
      VpcId: !Ref VPC
      CidrBlock: '192.168.0.0/20'
      AvailabilityZone: !Select [0, !GetAZs '']
      Ipv6CidrBlock: !Select [0, !Cidr [!Select [0, !GetAtt VPC.Ipv6CidrBlocks], 3, 64]]
      AssignIpv6AddressOnCreation: true
      Tags:
        - Key: Name
          Value: !Sub '${ProjectName}-private-subnet-1a'

  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    DependsOn: IPv6CidrBlock
    Properties:
      VpcId: !Ref VPC
      CidrBlock: '192.168.16.0/20'
      AvailabilityZone: !Select [1, !GetAZs '']
      Ipv6CidrBlock: !Select [1, !Cidr [!Select [0, !GetAtt VPC.Ipv6CidrBlocks], 3, 64]]
      AssignIpv6AddressOnCreation: true
      Tags:
        - Key: Name
          Value: !Sub '${ProjectName}-private-subnet-1c'

  PrivateSubnet3:
    Type: AWS::EC2::Subnet
    DependsOn: IPv6CidrBlock
    Properties:
      VpcId: !Ref VPC
      CidrBlock: '192.168.32.0/20'
      AvailabilityZone: !Select [2, !GetAZs '']
      Ipv6CidrBlock: !Select [2, !Cidr [!Select [0, !GetAtt VPC.Ipv6CidrBlocks], 3, 64]]
      AssignIpv6AddressOnCreation: true
      Tags:
        - Key: Name
          Value: !Sub '${ProjectName}-private-subnet-1d'

  # Internet Gateway (Regional NAT Gateway用)
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: !Sub '${ProjectName}-igw'

  # Internet Gateway Attachment
  InternetGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway

  # Egress-Only Internet Gateway
  EgressOnlyIGW:
    Type: AWS::EC2::EgressOnlyInternetGateway
    Properties:
      VpcId: !Ref VPC

  # Regional NAT Gateway用 Elastic IP
  NATGatewayEIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc
      Tags:
        - Key: Name
          Value: !Sub '${ProjectName}-nat-eip'

  # Regional NAT Gateway
  RegionalNATGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AvailabilityMode: regional
      ConnectivityType: public
      VpcId: !Ref VPC
      AvailabilityZoneAddresses:
        - AvailabilityZone: !Select [0, !GetAZs '']
          AllocationIds: 
            - !GetAtt NATGatewayEIP.AllocationId
      Tags:
        - Key: Name
          Value: !Sub '${ProjectName}-regional-nat-gw'

  # プライベートルートテーブル
  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub '${ProjectName}-private-rt'

  # IPv4 NAT Gateway ルート
  PrivateRouteIPv4:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      DestinationCidrBlock: '0.0.0.0/0'
      NatGatewayId: !Ref RegionalNATGateway

  # IPv6 Egress-Only ルート
  PrivateRouteIPv6:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      DestinationIpv6CidrBlock: '::/0'
      EgressOnlyInternetGatewayId: !Ref EgressOnlyIGW

  # サブネット関連付け
  PrivateSubnet1Association:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet1
      RouteTableId: !Ref PrivateRouteTable

  PrivateSubnet2Association:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet2
      RouteTableId: !Ref PrivateRouteTable

  PrivateSubnet3Association:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet3
      RouteTableId: !Ref PrivateRouteTable

  # VPCエンドポイント - S3
  S3Endpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref VPC
      ServiceName: !Sub 'com.amazonaws.${AWS::Region}.s3'
      VpcEndpointType: Gateway
      RouteTableIds:
        - !Ref PrivateRouteTable

  # VPCエンドポイント - DynamoDB
  DynamoDBEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref VPC
      ServiceName: !Sub 'com.amazonaws.${AWS::Region}.dynamodb'
      VpcEndpointType: Gateway
      RouteTableIds:
        - !Ref PrivateRouteTable

Outputs:
  VPCId:
    Description: 'VPC ID'
    Value: !Ref VPC
    Export:
      Name: !Sub '${ProjectName}-vpc-id'

  PrivateSubnet1Id:
    Description: 'Private Subnet 1 ID'
    Value: !Ref PrivateSubnet1
    Export:
      Name: !Sub '${ProjectName}-private-subnet-1-id'

  PrivateSubnet2Id:
    Description: 'Private Subnet 2 ID'
    Value: !Ref PrivateSubnet2
    Export:
      Name: !Sub '${ProjectName}-private-subnet-2-id'

  PrivateSubnet3Id:
    Description: 'Private Subnet 3 ID'
    Value: !Ref PrivateSubnet3
    Export:
      Name: !Sub '${ProjectName}-private-subnet-3-id'

  RegionalNATGatewayId:
    Description: 'Regional NAT Gateway ID'
    Value: !Ref RegionalNATGateway
    Export:
      Name: !Sub '${ProjectName}-regional-nat-gw-id'

ECS・CloudFront

ECS Express ModeとCloudFront

  • Express Mode: AWS::ECS::ExpressGatewayService リソースを使用して、Expressサービスを作成しました。

  • Internal ALB: プライベートサブネットを指定したことで、Express Modeによって自動的にInternal ALBがプロビジョニングされました。

  • CloudFront VPC Origin: CloudFrontからInternal ALBへの接続を可能にするため、VPC Origin (AWS::CloudFront::VpcOrigin) を作成しました。ターゲットとしてExpress ModeのロードバランサーARNを指定しています。

最初のECSとCloudFront作成テンプレート
AWSTemplateFormatVersion: '2010-09-09'
Description: 'ECS Express Mode Service - CloudFormation equivalent of CLI creation'

Parameters:
  ServiceName:
    Type: String
    Default: 'ecs-express-initial'
    Description: 'Service name for resources'

  ContainerImage:
    Type: String
    Default: '****.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-express-test:latest'
    Description: 'Container image URI'

  VPCId:
    Type: AWS::EC2::VPC::Id
    Default: 'vpc-****'
    Description: 'VPC ID for the ECS service'

  PrivateSubnets:
    Type: List<AWS::EC2::Subnet::Id>
    Default: 'subnet-****,subnet-****,subnet-****'
    Description: 'Private subnet IDs (comma-separated)'

  CreateCloudFront:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: 'Create CloudFront Distribution (true/false)'

Conditions:
  ShouldCreateCloudFront: !Equals [!Ref CreateCloudFront, 'true']

Resources:
  # CloudWatch Log Group
  ECSLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub '/aws/ecs/default/${ServiceName}-service'
      RetentionInDays: 14

  # IAM Role for ECS Task Execution
  ECSTaskExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ServiceName}-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

  # IAM Role for Task (SSM permissions for ECS Exec)
  ECSTaskRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ServiceName}-task-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: ECSExecPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - ssmmessages:CreateControlChannel
                  - ssmmessages:CreateDataChannel
                  - ssmmessages:OpenControlChannel
                  - ssmmessages:OpenDataChannel
                Resource: "*"

  # IAM Role for ECS Infrastructure with additional permissions
  ECSInfrastructureRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ServiceName}-infrastructure-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices
      Policies:
        - PolicyName: AdditionalAutoScalingPermissions
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - application-autoscaling:DeregisterScalableTarget
                  - application-autoscaling:DeleteScalingPolicy
                  - application-autoscaling:DescribeScalableTargets
                  - application-autoscaling:DescribeScalingPolicies
                Resource: '*'

  # ECS Express Mode Service
  ExpressModeService:
    Type: AWS::ECS::ExpressGatewayService
    DependsOn: ECSLogGroup
    Properties:
      ServiceName: !Sub '${ServiceName}-service'
      Cluster: 'default'
      ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn
      InfrastructureRoleArn: !GetAtt ECSInfrastructureRole.Arn
      TaskRoleArn: !GetAtt ECSTaskRole.Arn

      # Primary Container Configuration
      PrimaryContainer:
        Image: !Ref ContainerImage
        ContainerPort: 3000
        AwsLogsConfiguration:
          LogGroup: !Sub '/aws/ecs/default/${ServiceName}-service'
          LogStreamPrefix: 'ecs'

      # Network Configuration
      NetworkConfiguration:
        Subnets: !Ref PrivateSubnets

      # Resource Allocation
      Cpu: '1024'
      Memory: '2048'

      # Health Check
      HealthCheckPath: '/api/health/'

      # Auto Scaling Configuration
      ScalingTarget:
        MinTaskCount: 1
        MaxTaskCount: 3
        AutoScalingMetric: 'AVERAGE_CPU'
        AutoScalingTargetValue: 60

      # Tags
      Tags:
        - Key: Name
          Value: !Sub '${ServiceName}-express-service'
        - Key: Environment
          Value: 'development'
        - Key: Project
          Value: !Ref ServiceName

  # CloudFront VPC Origin for Express Mode ALB
  ExpressModeVpcOrigin:
    Type: AWS::CloudFront::VpcOrigin
    Properties:
      VpcOriginEndpointConfig:
        Name: !Sub '${ServiceName}-initial-origin'
        Arn: !GetAtt ExpressModeService.ECSManagedResourceArns.IngressPath.LoadBalancerArn
        HTTPPort: 80
        HTTPSPort: 443
        OriginProtocolPolicy: 'https-only'

  # CloudFront Distribution
  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    Condition: ShouldCreateCloudFront
    Properties:
      DistributionConfig:
        Comment: !Sub 'CloudFront for ${ServiceName} - ${ExpressModeService.Endpoint}'
        Enabled: true
        HttpVersion: http2and3
        IPV6Enabled: true

        # Custom Domain Configuration
        Aliases:
          - !Sub '${ServiceName}.developers.io'

        ViewerCertificate:
          AcmCertificateArn: 'arn:aws:acm:us-east-1:****:certificate/9bfb09c0-88aa-48e9-8685-5a176271dc91'
          SslSupportMethod: sni-only
          MinimumProtocolVersion: TLSv1.2_2021

        # VPC Origin Configuration
        Origins:
          - Id: VPCOrigin
            DomainName: !GetAtt ExpressModeService.Endpoint
            VpcOriginConfig:
              VpcOriginId: !Ref ExpressModeVpcOrigin

        # Default Cache Behavior
        DefaultCacheBehavior:
          TargetOriginId: VPCOrigin
          ViewerProtocolPolicy: https-only
          Compress: true

          # Cache Settings
          MinTTL: 0
          DefaultTTL: 60
          MaxTTL: 120
          ForwardedValues:
            QueryString: true
            Cookies:
              Forward: none

      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}-distribution'

Outputs:
  ServiceEndpoint:
    Description: 'ECS Express Mode Service Endpoint'
    Value: !GetAtt ExpressModeService.Endpoint
    Export:
      Name: !Sub '${AWS::StackName}-ServiceEndpoint'

  TaskExecutionRoleArn:
    Description: 'ECS Task Execution Role ARN'
    Value: !GetAtt ECSTaskExecutionRole.Arn
    Export:
      Name: !Sub '${AWS::StackName}-TaskExecutionRoleArn'

  InfrastructureRoleArn:
    Description: 'ECS Infrastructure Role ARN'
    Value: !GetAtt ECSInfrastructureRole.Arn
    Export:
      Name: !Sub '${AWS::StackName}-InfrastructureRoleArn'

  TaskRoleArn:
    Description: 'ECS Task Role ARN'
    Value: !GetAtt ECSTaskRole.Arn
    Export:
      Name: !Sub '${AWS::StackName}-TaskRoleArn'

  PrivateSubnets:
    Description: 'Private Subnet IDs'
    Value: !Join [',', !Ref PrivateSubnets]
    Export:
      Name: !Sub '${AWS::StackName}-PrivateSubnets'

  VPCOriginArn:
    Description: 'CloudFront VPC Origin ARN'
    Value: !Ref ExpressModeVpcOrigin
    Export:
      Name: !Sub '${AWS::StackName}-VPCOriginArn'

  VPCId:
    Description: 'VPC ID'
    Value: !Ref VPCId
    Export:
      Name: !Sub '${AWS::StackName}-VPCId'

  CloudFrontDistributionId:
    Condition: ShouldCreateCloudFront
    Description: 'CloudFront Distribution ID'
    Value: !Ref CloudFrontDistribution

  CloudFrontDomainName:
    Condition: ShouldCreateCloudFront
    Description: 'CloudFront Distribution Domain Name'
    Value: !GetAtt CloudFrontDistribution.DomainName

  CloudFrontURL:
    Condition: ShouldCreateCloudFront
    Description: 'CloudFront Distribution URL'
    Value: !Sub 'https://${CloudFrontDistribution.DomainName}'

  CustomDomainURL:
    Description: 'Custom Domain URL'
    Value: !Sub 'https://${ServiceName}.developers.io'

Expressサービスの追加

ECS Express Modeサービスの追加イメージ

  • ELBの共有: Expressサービスの追加時、サブネット指定を1つ目のサービスと共通にすることで、既存のALBが共有される設定となります。

  • ルーティング: CloudFrontのオリジン設定は流用しつつ、Expressサービスごとに発行されるアプリケーションURL(de-***.ecs.<region>.on.aws)をターゲットとして設定しました。

Expressサービスの追加 テンプレート

AWSTemplateFormatVersion: '2010-09-09'
Description: 'ECS Express Mode Secondary Service - References initial template resources'

Parameters:
  ServiceName:
    Type: String
    Default: 'ecs-express-secondary'
    Description: 'Service name for resources'

  InitialStackName:
    Type: String
    Default: 'ecs-express-initial'
    Description: 'Initial stack name to reference VPC and IAM roles'

  ContainerImage:
    Type: String
    Default: '****.dkr.ecr.ap-northeast-1.amazonaws.com/ecs-express-test:latest'
    Description: 'Container image URI'

  ContainerPort:
    Type: Number
    Default: 3000
    Description: 'Container port number'

  CreateCloudFront:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: 'Create CloudFront Distribution (true/false)'

Conditions:
  ShouldCreateCloudFront: !Equals [!Ref CreateCloudFront, 'true']

Resources:
  # CloudWatch Log Group
  ECSLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub '/aws/ecs/default/${ServiceName}-service'
      RetentionInDays: 14

  # ECS Express Mode Service
  ExpressModeService:
    Type: AWS::ECS::ExpressGatewayService
    DependsOn: ECSLogGroup
    Properties:
      ServiceName: !Sub '${ServiceName}-service'
      Cluster: 'default'
      ExecutionRoleArn: 
        Fn::ImportValue: !Sub '${InitialStackName}-TaskExecutionRoleArn'
      InfrastructureRoleArn: 
        Fn::ImportValue: !Sub '${InitialStackName}-InfrastructureRoleArn'
      TaskRoleArn: 
        Fn::ImportValue: !Sub '${InitialStackName}-TaskRoleArn'

      # Primary Container Configuration
      PrimaryContainer:
        Image: !Ref ContainerImage
        ContainerPort: !Ref ContainerPort
        AwsLogsConfiguration:
          LogGroup: !Sub '/aws/ecs/default/${ServiceName}-service'
          LogStreamPrefix: 'ecs'

      # Network Configuration
      NetworkConfiguration:
        Subnets: 
          Fn::Split:
            - ','
            - Fn::ImportValue: !Sub '${InitialStackName}-PrivateSubnets'

      # Resource Allocation
      Cpu: '1024'
      Memory: '2048'

      # Health Check
      HealthCheckPath: '/'

      # Auto Scaling Configuration
      ScalingTarget:
        MinTaskCount: 1
        MaxTaskCount: 20
        AutoScalingMetric: 'AVERAGE_CPU'
        AutoScalingTargetValue: 60

      # Tags
      Tags:
        - Key: Name
          Value: !Sub '${ServiceName}-express-service'
        - Key: Environment
          Value: 'development'
        - Key: Project
          Value: !Ref ServiceName

  # CloudFront Distribution
  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    Condition: ShouldCreateCloudFront
    Properties:
      DistributionConfig:
        Comment: !Sub 'CloudFront for ${ServiceName} - ${ExpressModeService.Endpoint}'
        Enabled: true
        HttpVersion: http2and3
        IPV6Enabled: true

        # VPC Origin Configuration
        Origins:
          - Id: VPCOrigin
            DomainName: !GetAtt ExpressModeService.Endpoint
            VpcOriginConfig:
              VpcOriginId: 
                Fn::ImportValue: !Sub '${InitialStackName}-VPCOriginArn'

        # Default Cache Behavior
        DefaultCacheBehavior:
          TargetOriginId: VPCOrigin
          ViewerProtocolPolicy: https-only
          Compress: true

          # Cache Settings
          MinTTL: 0
          DefaultTTL: 60
          MaxTTL: 120
          ForwardedValues:
            QueryString: true
            Cookies:
              Forward: none

      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}-distribution'

Outputs:
  ServiceEndpoint:
    Description: 'ECS Express Mode Service Endpoint'
    Value: !GetAtt ExpressModeService.Endpoint

  CloudFrontDistributionId:
    Condition: ShouldCreateCloudFront
    Description: 'CloudFront Distribution ID'
    Value: !Ref CloudFrontDistribution

  CloudFrontDomainName:
    Condition: ShouldCreateCloudFront
    Description: 'CloudFront Distribution Domain Name'
    Value: !GetAtt CloudFrontDistribution.DomainName

  CloudFrontURL:
    Condition: ShouldCreateCloudFront
    Description: 'CloudFront Distribution URL'
    Value: !Sub 'https://${CloudFrontDistribution.DomainName}'

疎通確認

構築した環境に対し、外部(CloudFront経由)と内部(VPC内CloudShell)の双方から疎通確認を実施しました。

1. CloudFront経由のアクセス

CloudFrontのドメインに対して curl を実行しました。

! curl  https://***.cloudfront.net -v
<>
> GET / HTTP/2
> Host: ***.cloudfront.net
> User-Agent: curl/8.7.1
> Accept: */*
<>
< via: 1.1 xxxx.cloudfront.net (CloudFront)
<>
< 
{
  "message": "ECS Test Application (node22)",
  "timestamp": "2025-12-14T06:22:24.744Z",
  "hostname": "ip-192-168-**-**.ap-northeast-1.compute.internal",
  "environment": "development",
  "version": "1.0.0"
* Connection #0 to host ***.cloudfront.net left intact
}

レスポンスヘッダに via: ... (CloudFront) が含まれており、正常にCloudFrontを経由してECS上のアプリケーションからのレスポンスが返ってきていることを確認しました。

2. VPC内部からのアクセス (CloudShell)

VPC内に配置されたCloudShellから、ExpressサービスのアプリケーションURLに対して直接リクエストを行いました。

$ curl  https://**-**.ecs.ap-northeast-1.on.aws  -v
<>
> GET / HTTP/2
> Host: **-**.ecs.ap-northeast-1.on.aws
> User-Agent: curl/8.11.1
> Accept: */*
> 
* Request completely sent off
< HTTP/2 200 
< date: Sun, 14 Dec 2025 06:31:42 GMT
< content-type: application/json
< 
{
  "message": "ECS Test Application (node22)",
  "timestamp": "2025-12-14T06:31:42.650Z",
  "hostname": "ip-192-168-**-**.ap-northeast-1.compute.internal",
  "environment": "development",
  "version": "1.0.0"
* Connection #0 to host **-**.ecs.ap-northeast-1.on.aws left intact
}

Internal ALBを経由して、正常に応答が得られることを確認しました。

まとめ

今回の検証を通じ、ECS Express ModeとRegional NAT Gatewayを組み合わせることで、運用負荷とコストの両面において合理的かつコスト効率の高い構成が実現可能なことを確認できました。

運用面においては、Express Modeで発行されるアプリケーションURLを活用することで、従来のパスベースルーティングのような複雑な設定を介さずに、単一のInternal ALBを複数のサービス間でシンプルに共有する利用が可能となります。

コスト面では、ELBの共用に加え、IPv4パブリックIPアドレス課金の発生しないInternalモードの利用により、ELBに関連する費用を最小化できます。加えて、NAT Gatewayをリージョナルモードで単一AZに集約する構成をとることで、可用性設計とのトレードオフはありますが、標準的なマルチAZ配置と比較して維持コストを大幅に抑制できると判断しました。

さらに、CloudFront VPC Originを利用して外部公開ポイントをCloudFrontのみに限定するアーキテクチャは、AWS WAFによるセキュリティ対策を一元管理できる点でも優れています。IPv4アドレス関連コストや、VPCエンドポイントの維持費削減を目指す場合、今回紹介した構成をぜひお試しください。

この記事をシェアする

FacebookHatena blogX

関連記事