EC2+ALB+RDS / ECS Fargate のベース環境を IaC で一括作成・再利用する
「EC2 + ALB + RDS ハンズオン」と「ECS Fargate ハンズオン」で手作業(マネコン操作)で構築するベース環境を、CloudFormation テンプレートで一括作成できるようにしたものです。ほかのハンズオン(CI/CD・WAF・監視・自動化など)の土台として、同じ環境を 数コマンドで再現・破棄できます。
| 項目 | 内容 |
|---|---|
| 提供テンプレート | ① EC2+ALB+RDS(2層) / ② ECS Fargate+RDS(2層) |
| 作成方法 | AWS CloudShell から aws cloudformation deploy(マネコンのアップロードでも可) |
| 所要時間 | 30〜45 分(大半は RDS Multi-AZ 作成の待ち時間) |
| 難易度 | ★★★☆☆(中級者向け) |
| 前提知識 | CloudFormation・VPC・RDS の基礎 |
| 費用目安 | 数時間で削除すれば約 0.5〜1 USD(ALB・RDS・NAT GW は時間課金) |
テンプレートが作成するリソース名(handson-vpc、handson-rds、sg-alb など)は元のハンズオンと一致させています。マネコンでの手順を CloudFormation に置き換えただけなので、作成後の画面・動作は手作業版とまったく同じです。
ALB(約 $0.028/h)・RDS Multi-AZ(約 $0.052/h)・NAT Gateway(約 $0.062/h)は起動している間ずっと課金されます。検証が終わったら「クリーンアップ」でスタックを必ず削除してください。スタックを消せば全リソースが自動で消えます。
2 つのテンプレートは ネットワーク(VPC / サブネット / NAT / IGW)と RDS は共通で、コンピュート層だけが異なります。
| 項目 | A: EC2+ALB+RDS | B: ECS Fargate+RDS |
|---|---|---|
| コンピュート | EC2 t2.micro ×2(Nginx + Node.js + pm2) | Fargate タスク ×2(CPU 256 / Mem 512) |
| ALB ターゲット | インスタンス型 / HTTP:80 | IP 型 / HTTP:8080 |
| イメージ | 不要(UserData でセットアップ) | ECR に事前 push(ImageUri パラメータ) |
| セキュリティグループ | sg-alb / sg-webap / sg-rds | sg-alb / sg-ecs / sg-rds |
| DB 初期化 | UserData で psql 実行 | コンテナ起動時に自動実行 |
東京(ap-northeast-1) を使用(コンソール右上で確認)本ページ下部の <pre> からコピーするか、以下から直接ダウンロードできます。
CloudShell では curl でそのまま取得できます。
イメージのビルドは不要です。EC2 の UserData が Nginx・Node.js・アプリのセットアップと DB の初期データ投入まで自動で行います。方法①(CloudShell / CLI)と方法②(マネジメントコンソール)のどちらか一方を実施してください。結果は同じです。
CloudShell を開き、テンプレートを取得します。
curl -O https://aws-handson-portal.pages.dev/cfn-base/ec2-alb-rds.yamlRDS のマスターパスワードを指定して作成します(必要に応じて変更)。
aws cloudformation deploy \ --stack-name handson-ec2-alb-rds \ --template-file ec2-alb-rds.yaml \ --capabilities CAPABILITY_NAMED_IAM \ --parameter-overrides DBPassword='AdminPass123!' \ --region ap-northeast-1RDS Multi-AZ の作成が律速です。CAPABILITY_NAMED_IAM は IAM ロール(handson-ec2-ssm-role)を作るために必須です。
完了後、ALB の URL と RDS エンドポイントが出力されます。
aws cloudformation describe-stacks \ --stack-name handson-ec2-alb-rds \ --query "Stacks[0].Outputs" --output table \ --region ap-northeast-1ALBUrl をブラウザで開き、「動作確認」へ進みます。EC2 のセットアップ(dnf install・pm2 起動)が完了するまで、作成後さらに 2〜3 分かかります。
CLI を使わず、ブラウザ操作だけで同じスタックを作成する手順です。
上の「⬇ ダウンロード」から ec2-alb-rds.yaml をパソコンに保存します。
マネジメントコンソールで 「CloudFormation」 を開き(リージョンは東京)、「スタックの作成」→「新しいリソースを使用(標準)」 をクリックします。
| 項目 | 選択 |
|---|---|
| 準備されたテンプレート | テンプレートの準備完了 |
| テンプレートソース | テンプレートファイルのアップロード |
| ファイル | 保存した ec2-alb-rds.yaml を選択 |
「次へ」をクリックします。
「次へ」をクリックします。
特に変更は不要です。そのまま下までスクロールして「次へ」をクリックします。
最下部の 「AWS CloudFormation によって IAM リソースがカスタム名で作成される場合があることを承認します」 のチェックボックスにチェックを入れ(handson-ec2-ssm-role を作成するため必須)、「送信」 をクリックします。
ステータスが CREATE_IN_PROGRESS から CREATE_COMPLETE になるまで待ちます。RDS Multi-AZ の作成が律速です。
スタックを選択 →「出力(Outputs)」タブを開きます。ALBUrl の値(ブラウザでアクセスする URL)と RDSEndpoint が表示されます。EC2 のセットアップ完了まで作成後さらに 2〜3 分待ってから「動作確認」へ進みます。
AWSTemplateFormatVersion: '2010-09-09'
Description: >
Handson base infrastructure (EC2 + ALB + RDS / PostgreSQL Multi-AZ 2-tier).
Parameters:
DBPassword:
Type: String
NoEcho: true
Default: AdminPass123!
MinLength: 8
Description: RDS master password (master username is 'admin').
InstanceType:
Type: String
Default: t2.micro
LatestAmiId:
Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64
Resources:
# ---------- Network ----------
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.0.0.0/16
EnableDnsHostnames: true
EnableDnsSupport: true
Tags: [{ Key: Name, Value: handson-vpc }]
IGW:
Type: AWS::EC2::InternetGateway
Properties:
Tags: [{ Key: Name, Value: handson-igw }]
IGWAttach:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref VPC
InternetGatewayId: !Ref IGW
PublicSubnet1:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.1.0/24
AvailabilityZone: !Select [0, !GetAZs '']
MapPublicIpOnLaunch: true
Tags: [{ Key: Name, Value: handson-subnet-public-1a }]
PublicSubnet2:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.2.0/24
AvailabilityZone: !Select [1, !GetAZs '']
MapPublicIpOnLaunch: true
Tags: [{ Key: Name, Value: handson-subnet-public-1c }]
WebApSubnet1:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.3.0/24
AvailabilityZone: !Select [0, !GetAZs '']
Tags: [{ Key: Name, Value: handson-subnet-private-webap-1a }]
WebApSubnet2:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.4.0/24
AvailabilityZone: !Select [1, !GetAZs '']
Tags: [{ Key: Name, Value: handson-subnet-private-webap-1c }]
DbSubnet1:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.5.0/24
AvailabilityZone: !Select [0, !GetAZs '']
Tags: [{ Key: Name, Value: handson-subnet-private-db-1a }]
DbSubnet2:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.6.0/24
AvailabilityZone: !Select [1, !GetAZs '']
Tags: [{ Key: Name, Value: handson-subnet-private-db-1c }]
EIP:
Type: AWS::EC2::EIP
Properties:
Domain: vpc
NatGateway:
Type: AWS::EC2::NatGateway
Properties:
AllocationId: !GetAtt EIP.AllocationId
SubnetId: !Ref PublicSubnet1
Tags: [{ Key: Name, Value: handson-natgw }]
PublicRT:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags: [{ Key: Name, Value: handson-rt-public }]
PublicRoute:
Type: AWS::EC2::Route
DependsOn: IGWAttach
Properties:
RouteTableId: !Ref PublicRT
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref IGW
PublicAssoc1:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { RouteTableId: !Ref PublicRT, SubnetId: !Ref PublicSubnet1 }
PublicAssoc2:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { RouteTableId: !Ref PublicRT, SubnetId: !Ref PublicSubnet2 }
PrivateRT:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags: [{ Key: Name, Value: handson-rt-private-webap }]
PrivateRoute:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref PrivateRT
DestinationCidrBlock: 0.0.0.0/0
NatGatewayId: !Ref NatGateway
PrivateAssoc1:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { RouteTableId: !Ref PrivateRT, SubnetId: !Ref WebApSubnet1 }
PrivateAssoc2:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { RouteTableId: !Ref PrivateRT, SubnetId: !Ref WebApSubnet2 }
# ---------- Security Groups ----------
AlbSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: ALB security group
VpcId: !Ref VPC
SecurityGroupIngress:
- { IpProtocol: tcp, FromPort: 80, ToPort: 80, CidrIp: 0.0.0.0/0 }
- { IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: 0.0.0.0/0 }
Tags: [{ Key: Name, Value: sg-alb }]
WebApSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: WebAP server security group
VpcId: !Ref VPC
SecurityGroupIngress:
- { IpProtocol: tcp, FromPort: 80, ToPort: 80, SourceSecurityGroupId: !Ref AlbSG }
Tags: [{ Key: Name, Value: sg-webap }]
RdsSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: RDS PostgreSQL security group
VpcId: !Ref VPC
SecurityGroupIngress:
- { IpProtocol: tcp, FromPort: 5432, ToPort: 5432, SourceSecurityGroupId: !Ref WebApSG }
Tags: [{ Key: Name, Value: sg-rds }]
# ---------- RDS ----------
DBSubnetGroup:
Type: AWS::RDS::DBSubnetGroup
Properties:
DBSubnetGroupDescription: DB subnet group for Multi-AZ RDS
SubnetIds: [!Ref DbSubnet1, !Ref DbSubnet2]
Tags: [{ Key: Name, Value: handson-db-subnet-group }]
RDS:
Type: AWS::RDS::DBInstance
DeletionPolicy: Delete
Properties:
DBInstanceIdentifier: handson-rds
Engine: postgres
EngineVersion: '16'
DBInstanceClass: db.t3.micro
AllocatedStorage: '20'
StorageType: gp2
MultiAZ: true
DBName: handson_db
MasterUsername: admin
MasterUserPassword: !Ref DBPassword
DBSubnetGroupName: !Ref DBSubnetGroup
VPCSecurityGroups: [!Ref RdsSG]
PubliclyAccessible: false
BackupRetentionPeriod: 0
DeletionProtection: false
# ---------- IAM (SSM) ----------
InstanceRole:
Type: AWS::IAM::Role
Properties:
RoleName: handson-ec2-ssm-role
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: { Service: ec2.amazonaws.com }
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
InstanceProfile:
Type: AWS::IAM::InstanceProfile
Properties:
Roles: [!Ref InstanceRole]
# ---------- EC2 WebAP x2 ----------
WebAp1:
Type: AWS::EC2::Instance
DependsOn: [RDS, PrivateRoute]
Properties:
ImageId: !Ref LatestAmiId
InstanceType: !Ref InstanceType
SubnetId: !Ref WebApSubnet1
SecurityGroupIds: [!Ref WebApSG]
IamInstanceProfile: !Ref InstanceProfile
Tags: [{ Key: Name, Value: handson-webap-1a }]
UserData:
Fn::Base64: !Sub |
#!/bin/bash -xe
dnf install -y nginx nodejs npm postgresql16
systemctl enable --now nginx
mkdir -p /home/ec2-user/app && cd /home/ec2-user/app
npm init -y
npm install express pg
cat > app.js <<'APP'
const express=require('express');const {Pool}=require('pg');const os=require('os');
const app=express();
const pool=new Pool({host:process.env.DB_HOST,port:5432,user:'admin',password:process.env.DB_PASS,database:'handson_db',ssl:{rejectUnauthorized:false},max:10});
app.get('/health',(q,r)=>r.json({status:'ok',server:os.hostname()}));
app.get('/',(q,r)=>r.json({message:'WebAP server is running!',server:os.hostname(),tier:'webap'}));
app.get('/users',async(q,r)=>{try{const x=await pool.query('SELECT * FROM users ORDER BY id');r.json({count:x.rowCount,users:x.rows,server:os.hostname()});}catch(e){r.status(500).json({error:e.message});}});
app.listen(8080,'127.0.0.1',()=>console.log('up'));
APP
export DB_HOST=${RDS.Endpoint.Address}
export DB_PASS=${DBPassword}
npm install -g pm2
pm2 start app.js --name webap-app
pm2 save
cat > /etc/nginx/conf.d/webap.conf <<'NGINX'
server {
listen 80; server_name _;
location /health { proxy_pass http://127.0.0.1:8080/health; access_log off; }
location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
}
NGINX
nginx -t && systemctl reload nginx
export PGPASSWORD=${DBPassword}
psql -h ${RDS.Endpoint.Address} -U admin -d handson_db -c "CREATE TABLE IF NOT EXISTS users(id SERIAL PRIMARY KEY,name VARCHAR(100) NOT NULL,email VARCHAR(200) NOT NULL UNIQUE,created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);"
psql -h ${RDS.Endpoint.Address} -U admin -d handson_db -c "INSERT INTO users(name,email) SELECT v.n,v.e FROM (VALUES ('田中 太郎','taro@example.com'),('鈴木 花子','hanako@example.com'),('佐藤 一郎','ichiro@example.com')) AS v(n,e) WHERE NOT EXISTS (SELECT 1 FROM users);"
WebAp2:
Type: AWS::EC2::Instance
DependsOn: [RDS, PrivateRoute]
Properties:
ImageId: !Ref LatestAmiId
InstanceType: !Ref InstanceType
SubnetId: !Ref WebApSubnet2
SecurityGroupIds: [!Ref WebApSG]
IamInstanceProfile: !Ref InstanceProfile
Tags: [{ Key: Name, Value: handson-webap-1c }]
UserData:
Fn::Base64: !Sub |
#!/bin/bash -xe
dnf install -y nginx nodejs npm postgresql16
systemctl enable --now nginx
mkdir -p /home/ec2-user/app && cd /home/ec2-user/app
npm init -y
npm install express pg
cat > app.js <<'APP'
const express=require('express');const {Pool}=require('pg');const os=require('os');
const app=express();
const pool=new Pool({host:process.env.DB_HOST,port:5432,user:'admin',password:process.env.DB_PASS,database:'handson_db',ssl:{rejectUnauthorized:false},max:10});
app.get('/health',(q,r)=>r.json({status:'ok',server:os.hostname()}));
app.get('/',(q,r)=>r.json({message:'WebAP server is running!',server:os.hostname(),tier:'webap'}));
app.get('/users',async(q,r)=>{try{const x=await pool.query('SELECT * FROM users ORDER BY id');r.json({count:x.rowCount,users:x.rows,server:os.hostname()});}catch(e){r.status(500).json({error:e.message});}});
app.listen(8080,'127.0.0.1',()=>console.log('up'));
APP
export DB_HOST=${RDS.Endpoint.Address}
export DB_PASS=${DBPassword}
npm install -g pm2
pm2 start app.js --name webap-app
pm2 save
cat > /etc/nginx/conf.d/webap.conf <<'NGINX'
server {
listen 80; server_name _;
location /health { proxy_pass http://127.0.0.1:8080/health; access_log off; }
location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
}
NGINX
nginx -t && systemctl reload nginx
# ---------- ALB ----------
TargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Name: handson-tg-webap
VpcId: !Ref VPC
Protocol: HTTP
Port: 80
TargetType: instance
HealthCheckProtocol: HTTP
HealthCheckPath: /health
HealthyThresholdCount: 2
UnhealthyThresholdCount: 3
Targets:
- { Id: !Ref WebAp1 }
- { Id: !Ref WebAp2 }
ALB:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Name: handson-alb
Scheme: internet-facing
Type: application
Subnets: [!Ref PublicSubnet1, !Ref PublicSubnet2]
SecurityGroups: [!Ref AlbSG]
Listener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref ALB
Port: 80
Protocol: HTTP
DefaultActions:
- { Type: forward, TargetGroupArn: !Ref TargetGroup }
Outputs:
ALBUrl:
Description: Access URL (open in browser)
Value: !Sub "http://${ALB.DNSName}"
RDSEndpoint:
Description: RDS endpoint address
Value: !GetAtt RDS.Endpoint.Address
Fargate はコンテナイメージが必要です。先に ECR リポジトリを作成してイメージを push し、その URI をパラメータでテンプレートに渡します。
ステップ 1(イメージの build/push)は Docker が必要なため CloudShell で実施します。その後の ステップ 2(スタックの作成)は、方法①(CLI)か方法②(マネジメントコンソール)のどちらかで行えます。
CloudShell で作業ディレクトリを作り、アプリ・依存・Dockerfile を作成します。
mkdir -p handson-app && cd handson-app cat > app.js <<'APP' const express=require('express');const {Pool}=require('pg');const os=require('os'); const app=express(); const pool=new Pool({host:process.env.DB_HOST,port:process.env.DB_PORT||5432,user:process.env.DB_USER||'admin',password:process.env.DB_PASS,database:process.env.DB_NAME||'handson_db',ssl:{rejectUnauthorized:false},max:10}); async function initDb(){ await pool.query('CREATE TABLE IF NOT EXISTS users(id SERIAL PRIMARY KEY,name VARCHAR(100) NOT NULL,email VARCHAR(200) NOT NULL UNIQUE,created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)'); const {rowCount}=await pool.query('SELECT 1 FROM users LIMIT 1'); if(rowCount===0){await pool.query("INSERT INTO users(name,email) VALUES ('田中 太郎','taro@example.com'),('鈴木 花子','hanako@example.com'),('佐藤 一郎','ichiro@example.com')");} } initDb().catch(e=>console.error('init',e.message)); app.get('/health',(q,r)=>r.json({status:'ok',container:os.hostname()})); app.get('/',(q,r)=>r.json({message:'ECS Fargate app is running!',container:os.hostname(),tier:'ecs-fargate'})); app.get('/users',async(q,r)=>{try{const x=await pool.query('SELECT * FROM users ORDER BY id');r.json({count:x.rowCount,users:x.rows,container:os.hostname()});}catch(e){r.status(500).json({error:e.message});}}); app.listen(8080,()=>console.log('up')); APP cat > package.json <<'PKG' { "name":"handson-app","version":"1.0.0","main":"app.js","dependencies":{"express":"^4","pg":"^8"} } PKG cat > Dockerfile <<'DOCKER' FROM node:20-alpine WORKDIR /app COPY package.json . RUN npm install --production COPY app.js . EXPOSE 8080 CMD ["node","app.js"] DOCKERアカウント ID を変数に取得し、ログイン → ビルド → push します。
ACCOUNT=$(aws sts get-caller-identity --query Account --output text) REGION=ap-northeast-1 REPO=$ACCOUNT.dkr.ecr.$REGION.amazonaws.com/handson-ecr aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin $ACCOUNT.dkr.ecr.$REGION.amazonaws.com docker build -t handson-app . docker tag handson-app:latest $REPO:latest docker push $REPO:latest echo "ImageUri = $REPO:latest"テンプレートを取得し、push したイメージ URI を ImageUri に渡してデプロイします。
RDS Multi-AZ の作成が律速です。ECS サービスはイメージが ECR に存在する前提で 2 タスクを起動します。
ALBUrl をブラウザで開いて「動作確認」へ進みます。
イメージ push(ステップ 1)まで終えていれば、スタック作成はブラウザ操作だけで行えます。
ステップ 1(B-3)で表示された ImageUri をコピーしておきます。形式は次のとおりです。
テンプレートファイルは「⬇ ダウンロード」からパソコンに保存します。
マネジメントコンソールで 「CloudFormation」 を開き、「スタックの作成」→「新しいリソースを使用(標準)」 をクリックします。
| 項目 | 選択 |
|---|---|
| 準備されたテンプレート | テンプレートの準備完了 |
| テンプレートソース | テンプレートファイルのアップロード |
| ファイル | 保存した ecs-fargate.yaml を選択 |
「次へ」をクリックします。
「次へ」をクリックします。
スタックオプションは変更不要で「次へ」。確認画面の最下部で 「IAM リソースがカスタム名で作成される場合があることを承認します」 にチェックを入れ(handson-ecs-task-execution-role を作成するため必須)、「送信」 をクリックします。
ステータスが CREATE_COMPLETE になるまで待ちます。
スタックを選択 →「出力(Outputs)」タブで ALBUrl を確認し、ブラウザで開いて「動作確認」へ進みます。
AWSTemplateFormatVersion: '2010-09-09'
Description: >
Handson base infrastructure (ECS Fargate + RDS / PostgreSQL Multi-AZ 2-tier).
The container image must be pushed to ECR beforehand (ImageUri parameter).
Parameters:
DBPassword:
Type: String
NoEcho: true
Default: AdminPass123!
MinLength: 8
Description: RDS master password (master username is 'admin').
ImageUri:
Type: String
Description: ECR image URI pushed beforehand.
Resources:
# ---------- Network (VPC / subnets / NAT / routes) ----------
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.0.0.0/16
EnableDnsHostnames: true
EnableDnsSupport: true
Tags: [{ Key: Name, Value: handson-vpc }]
IGW:
Type: AWS::EC2::InternetGateway
Properties:
Tags: [{ Key: Name, Value: handson-igw }]
IGWAttach:
Type: AWS::EC2::VPCGatewayAttachment
Properties: { VpcId: !Ref VPC, InternetGatewayId: !Ref IGW }
PublicSubnet1:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.1.0/24
AvailabilityZone: !Select [0, !GetAZs '']
MapPublicIpOnLaunch: true
Tags: [{ Key: Name, Value: handson-subnet-public-1a }]
PublicSubnet2:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.2.0/24
AvailabilityZone: !Select [1, !GetAZs '']
MapPublicIpOnLaunch: true
Tags: [{ Key: Name, Value: handson-subnet-public-1c }]
EcsSubnet1:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.3.0/24
AvailabilityZone: !Select [0, !GetAZs '']
Tags: [{ Key: Name, Value: handson-subnet-private-ecs-1a }]
EcsSubnet2:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.4.0/24
AvailabilityZone: !Select [1, !GetAZs '']
Tags: [{ Key: Name, Value: handson-subnet-private-ecs-1c }]
DbSubnet1:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.5.0/24
AvailabilityZone: !Select [0, !GetAZs '']
Tags: [{ Key: Name, Value: handson-subnet-private-db-1a }]
DbSubnet2:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.6.0/24
AvailabilityZone: !Select [1, !GetAZs '']
Tags: [{ Key: Name, Value: handson-subnet-private-db-1c }]
EIP:
Type: AWS::EC2::EIP
Properties: { Domain: vpc }
NatGateway:
Type: AWS::EC2::NatGateway
Properties:
AllocationId: !GetAtt EIP.AllocationId
SubnetId: !Ref PublicSubnet1
Tags: [{ Key: Name, Value: handson-natgw }]
PublicRT:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags: [{ Key: Name, Value: handson-rt-public }]
PublicRoute:
Type: AWS::EC2::Route
DependsOn: IGWAttach
Properties:
RouteTableId: !Ref PublicRT
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref IGW
PublicAssoc1:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { RouteTableId: !Ref PublicRT, SubnetId: !Ref PublicSubnet1 }
PublicAssoc2:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { RouteTableId: !Ref PublicRT, SubnetId: !Ref PublicSubnet2 }
PrivateRT:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags: [{ Key: Name, Value: handson-rt-private-ecs }]
PrivateRoute:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref PrivateRT
DestinationCidrBlock: 0.0.0.0/0
NatGatewayId: !Ref NatGateway
PrivateAssoc1:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { RouteTableId: !Ref PrivateRT, SubnetId: !Ref EcsSubnet1 }
PrivateAssoc2:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties: { RouteTableId: !Ref PrivateRT, SubnetId: !Ref EcsSubnet2 }
# ---------- Security Groups ----------
AlbSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: ALB security group
VpcId: !Ref VPC
SecurityGroupIngress:
- { IpProtocol: tcp, FromPort: 80, ToPort: 80, CidrIp: 0.0.0.0/0 }
- { IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: 0.0.0.0/0 }
Tags: [{ Key: Name, Value: sg-alb }]
EcsSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: ECS Fargate task security group
VpcId: !Ref VPC
SecurityGroupIngress:
- { IpProtocol: tcp, FromPort: 8080, ToPort: 8080, SourceSecurityGroupId: !Ref AlbSG }
Tags: [{ Key: Name, Value: sg-ecs }]
RdsSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: RDS PostgreSQL security group
VpcId: !Ref VPC
SecurityGroupIngress:
- { IpProtocol: tcp, FromPort: 5432, ToPort: 5432, SourceSecurityGroupId: !Ref EcsSG }
Tags: [{ Key: Name, Value: sg-rds }]
# ---------- RDS ----------
DBSubnetGroup:
Type: AWS::RDS::DBSubnetGroup
Properties:
DBSubnetGroupDescription: DB subnet group for Multi-AZ RDS
SubnetIds: [!Ref DbSubnet1, !Ref DbSubnet2]
Tags: [{ Key: Name, Value: handson-db-subnet-group }]
RDS:
Type: AWS::RDS::DBInstance
DeletionPolicy: Delete
Properties:
DBInstanceIdentifier: handson-rds
Engine: postgres
EngineVersion: '16'
DBInstanceClass: db.t3.micro
AllocatedStorage: '20'
StorageType: gp2
MultiAZ: true
DBName: handson_db
MasterUsername: admin
MasterUserPassword: !Ref DBPassword
DBSubnetGroupName: !Ref DBSubnetGroup
VPCSecurityGroups: [!Ref RdsSG]
PubliclyAccessible: false
BackupRetentionPeriod: 0
DeletionProtection: false
# ---------- ECS ----------
TaskExecutionRole:
Type: AWS::IAM::Role
Properties:
RoleName: handson-ecs-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
LogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /ecs/handson
RetentionInDays: 7
Cluster:
Type: AWS::ECS::Cluster
Properties:
ClusterName: handson-ecs-cluster
TaskDef:
Type: AWS::ECS::TaskDefinition
Properties:
Family: handson-task-def
Cpu: '256'
Memory: '512'
NetworkMode: awsvpc
RequiresCompatibilities: [FARGATE]
ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn
ContainerDefinitions:
- Name: handson-app
Image: !Ref ImageUri
Essential: true
PortMappings:
- { ContainerPort: 8080, Protocol: tcp }
Environment:
- { Name: DB_HOST, Value: !GetAtt RDS.Endpoint.Address }
- { Name: DB_PORT, Value: '5432' }
- { Name: DB_USER, Value: admin }
- { Name: DB_PASS, Value: !Ref DBPassword }
- { Name: DB_NAME, Value: handson_db }
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Ref LogGroup
awslogs-region: !Ref AWS::Region
awslogs-stream-prefix: handson
TargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Name: handson-tg-ecs
VpcId: !Ref VPC
Protocol: HTTP
Port: 8080
TargetType: ip
HealthCheckProtocol: HTTP
HealthCheckPath: /health
HealthyThresholdCount: 2
UnhealthyThresholdCount: 3
ALB:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Name: handson-alb
Scheme: internet-facing
Type: application
Subnets: [!Ref PublicSubnet1, !Ref PublicSubnet2]
SecurityGroups: [!Ref AlbSG]
Listener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref ALB
Port: 80
Protocol: HTTP
DefaultActions:
- { Type: forward, TargetGroupArn: !Ref TargetGroup }
Service:
Type: AWS::ECS::Service
DependsOn: [Listener, PrivateRoute, RDS]
Properties:
ServiceName: handson-ecs-service
Cluster: !Ref Cluster
TaskDefinition: !Ref TaskDef
DesiredCount: 2
LaunchType: FARGATE
HealthCheckGracePeriodSeconds: 120
NetworkConfiguration:
AwsvpcConfiguration:
AssignPublicIp: DISABLED
Subnets: [!Ref EcsSubnet1, !Ref EcsSubnet2]
SecurityGroups: [!Ref EcsSG]
LoadBalancers:
- ContainerName: handson-app
ContainerPort: 8080
TargetGroupArn: !Ref TargetGroup
Outputs:
ALBUrl:
Description: Access URL (open in browser)
Value: !Sub "http://${ALB.DNSName}"
RDSEndpoint:
Description: RDS endpoint address
Value: !GetAtt RDS.Endpoint.Address
出力された ALBUrl にアクセスして、各エンドポイントの応答を確認します。
| URL | 期待するレスポンス |
|---|---|
http://【ALBのDNS名】/ | {"message":"... is running!","server"/"container":"..."} |
http://【ALBのDNS名】/health | {"status":"ok",...} |
http://【ALBのDNS名】/users | RDS の users テーブル 3 件 |
/users で 3 件のデータが返れば、ALB → コンピュート層 → RDS の経路が成立しています。複数回アクセスすると server/container 名が 2 台で切り替わり、冗長化が確認できます。
A はスタック完了後も EC2 のセットアップに 2〜3 分、B はタスク起動とヘルスチェック通過に 1〜2 分かかります。ターゲットが healthy になるまで待ってください。
スタックを削除すると、作成された全リソースが自動的に削除されます。
ECS の場合は ECR リポジトリがスタック外に残るため、不要なら個別に削除します。
aws ecr delete-repository --repository-name handson-ecr --force --region ap-northeast-1マネジメントコンソールで 「CloudFormation」 を開き、対象スタック(handson-ec2-alb-rds または handson-ecs-fargate)を選択 → 「削除」 をクリック → 確認ダイアログで 「削除」 を押します。
ステータスが DELETE_IN_PROGRESS → スタックが一覧から消えれば(DELETE_COMPLETE)削除完了です。全リソースが自動で削除されます。
「ECR」 コンソール → 「リポジトリ」 → handson-ecr を選択 → 「削除」。イメージが残っていても、リポジトリ削除の確認を進めれば一緒に削除されます。
RDS の削除に時間がかかるため、削除完了まで数分待ちます。ENI が残ってサブネット削除に失敗する場合は、数分後に delete-stack を再実行するか、マネコンの CloudFormation から「削除を再試行」してください。
| 習得したスキル | 実践内容 |
|---|---|
| IaC による環境構築 | マネコン手順を CloudFormation テンプレートで再現 |
| 2 つの 2 層構成 | EC2+ALB+RDS と ECS Fargate+RDS の作り分け |
| パラメータ活用 | DBPassword・ImageUri を外部から注入 |
| 環境の再利用・破棄 | deploy / delete-stack による一括ライフサイクル管理 |