パブリックサブネットレスなVPCにECS Express Mode環境を構築してみた
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を作成しました。

-
パブリックサブネットレス: インターネットからの直接の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

-
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サービスの追加

-
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エンドポイントの維持費削減を目指す場合、今回紹介した構成をぜひお試しください。








