CloudFormation でハンズオン環境を構築

EC2+ALB+RDS / ECS Fargate のベース環境を IaC で一括作成・再利用する

CloudFormation EC2 / ALB / RDS ECS Fargate CloudShell / マネコン IaC 所要時間 30〜45 分 v1.0

📋 概要

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-vpchandson-rdssg-alb など)は元のハンズオンと一致させています。マネコンでの手順を CloudFormation に置き換えただけなので、作成後の画面・動作は手作業版とまったく同じです。

💰 課金注意

ALB(約 $0.028/h)・RDS Multi-AZ(約 $0.052/h)・NAT Gateway(約 $0.062/h)は起動している間ずっと課金されます。検証が終わったら「クリーンアップ」でスタックを必ず削除してください。スタックを消せば全リソースが自動で消えます。

🏗️ アーキテクチャ

2 つのテンプレートは ネットワーク(VPC / サブネット / NAT / IGW)と RDS は共通で、コンピュート層だけが異なります

🌐 インターネット → ⚖️ ALB(パブリックサブネット 1a / 1c・HTTP:80)
【A】EC2 ×2(Nginx:80 → Node.js:8080)【B】ECS Fargate タスク ×2(Node.js:8080)
プライベートサブネット 1a / 1c / アウトバウンドは NAT GW 経由
↓ PostgreSQL 5432
🗄️ RDS PostgreSQL 16(Multi-AZ)
プライベート DB サブネット 1a / 1c / sample データ自動投入
項目A: EC2+ALB+RDSB: ECS Fargate+RDS
コンピュートEC2 t2.micro ×2(Nginx + Node.js + pm2)Fargate タスク ×2(CPU 256 / Mem 512)
ALB ターゲットインスタンス型 / HTTP:80IP 型 / HTTP:8080
イメージ不要(UserData でセットアップ)ECR に事前 push(ImageUri パラメータ)
セキュリティグループsg-alb / sg-webap / sg-rdssg-alb / sg-ecs / sg-rds
DB 初期化UserData で psql 実行コンテナ起動時に自動実行

✅ 前提条件

ℹ️ テンプレートの入手

本ページ下部の <pre> からコピーするか、以下から直接ダウンロードできます。

CloudShell では curl でそのまま取得できます。

curl -O https://aws-handson-portal.pages.dev/cfn-base/ec2-alb-rds.yaml curl -O https://aws-handson-portal.pages.dev/cfn-base/ecs-fargate.yaml

🅰️ スタック A ― EC2 + ALB + RDS を作成する

イメージのビルドは不要です。EC2 の UserData が Nginx・Node.js・アプリのセットアップと DB の初期データ投入まで自動で行います。方法①(CloudShell / CLI)と方法②(マネジメントコンソール)のどちらか一方を実施してください。結果は同じです。

方法① CloudShell(CLI)で作成

A-1. テンプレートを取得

CloudShell を開き、テンプレートを取得します。

curl -O https://aws-handson-portal.pages.dev/cfn-base/ec2-alb-rds.yaml
A-2. スタックをデプロイ

RDS のマスターパスワードを指定して作成します(必要に応じて変更)。

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-1
⚠️ 作成には 15〜20 分かかります

RDS Multi-AZ の作成が律速です。CAPABILITY_NAMED_IAM は IAM ロール(handson-ec2-ssm-role)を作るために必須です。

A-3. 出力(アクセス URL)を確認

完了後、ALB の URL と RDS エンドポイントが出力されます。

aws cloudformation describe-stacks \ --stack-name handson-ec2-alb-rds \ --query "Stacks[0].Outputs" --output table \ --region ap-northeast-1

ALBUrl をブラウザで開き、「動作確認」へ進みます。EC2 のセットアップ(dnf install・pm2 起動)が完了するまで、作成後さらに 2〜3 分かかります。

方法② マネジメントコンソールで作成

CLI を使わず、ブラウザ操作だけで同じスタックを作成する手順です。

A-M1. テンプレートファイルを手元に保存

上の「⬇ ダウンロード」から ec2-alb-rds.yaml をパソコンに保存します。

A-M2. スタックの作成を開始

マネジメントコンソールで 「CloudFormation」 を開き(リージョンは東京)、「スタックの作成」→「新しいリソースを使用(標準)」 をクリックします。

項目選択
準備されたテンプレートテンプレートの準備完了
テンプレートソーステンプレートファイルのアップロード
ファイル保存した ec2-alb-rds.yaml を選択

「次へ」をクリックします。

A-M3. スタックの詳細とパラメータを指定
スタックの名前handson-ec2-alb-rds
DBPasswordAdminPass123!
InstanceTypet2.micro(デフォルトのまま)
LatestAmiIdデフォルトのまま(最新 Amazon Linux 2023 を自動取得)

「次へ」をクリックします。

A-M4. スタックオプション

特に変更は不要です。そのまま下までスクロールして「次へ」をクリックします。

A-M5. 確認して作成

最下部の 「AWS CloudFormation によって IAM リソースがカスタム名で作成される場合があることを承認します」 のチェックボックスにチェックを入れ(handson-ec2-ssm-role を作成するため必須)、「送信」 をクリックします。

⚠️ 作成には 15〜20 分かかります

ステータスが CREATE_IN_PROGRESS から CREATE_COMPLETE になるまで待ちます。RDS Multi-AZ の作成が律速です。

A-M6. 出力(アクセス URL)を確認

スタックを選択 →「出力(Outputs)」タブを開きます。ALBUrl の値(ブラウザでアクセスする URL)と RDSEndpoint が表示されます。EC2 のセットアップ完了まで作成後さらに 2〜3 分待ってから「動作確認」へ進みます。

テンプレート全文:ec2-alb-rds.yaml

⬇ ダウンロード

ec2-alb-rds.yaml を表示
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

🅱️ スタック B ― ECS Fargate + RDS を作成する

Fargate はコンテナイメージが必要です。先に ECR リポジトリを作成してイメージを push し、その URI をパラメータでテンプレートに渡します。

ℹ️ 全体の流れ

ステップ 1(イメージの build/push)は Docker が必要なため CloudShell で実施します。その後の ステップ 2(スタックの作成)は、方法①(CLI)か方法②(マネジメントコンソール)のどちらかで行えます。

ステップ 1:コンテナイメージを ECR に push(CloudShell)

B-1. ECR リポジトリを作成
aws ecr create-repository --repository-name handson-ecr --region ap-northeast-1
B-2. アプリと Dockerfile を用意

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
B-3. イメージをビルドして ECR に push

アカウント 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"

ステップ 2 ・ 方法① CloudShell(CLI)で作成

B-4. スタックをデプロイ

テンプレートを取得し、push したイメージ URI を ImageUri に渡してデプロイします。

curl -O https://aws-handson-portal.pages.dev/cfn-base/ecs-fargate.yaml aws cloudformation deploy \ --stack-name handson-ecs-fargate \ --template-file ecs-fargate.yaml \ --capabilities CAPABILITY_NAMED_IAM \ --parameter-overrides DBPassword='AdminPass123!' ImageUri="$REPO:latest" \ --region ap-northeast-1
⚠️ 作成には 15〜20 分かかります

RDS Multi-AZ の作成が律速です。ECS サービスはイメージが ECR に存在する前提で 2 タスクを起動します。

B-5. 出力(アクセス URL)を確認
aws cloudformation describe-stacks \ --stack-name handson-ecs-fargate \ --query "Stacks[0].Outputs" --output table \ --region ap-northeast-1

ALBUrl をブラウザで開いて「動作確認」へ進みます。

ステップ 2 ・ 方法② マネジメントコンソールで作成

イメージ push(ステップ 1)まで終えていれば、スタック作成はブラウザ操作だけで行えます。

B-M1. イメージ URI を控える

ステップ 1(B-3)で表示された ImageUri をコピーしておきます。形式は次のとおりです。

ImageUri<アカウントID>.dkr.ecr.ap-northeast-1.amazonaws.com/handson-ecr:latest

テンプレートファイルは「⬇ ダウンロード」からパソコンに保存します。

B-M2. スタックの作成を開始

マネジメントコンソールで 「CloudFormation」 を開き、「スタックの作成」→「新しいリソースを使用(標準)」 をクリックします。

項目選択
準備されたテンプレートテンプレートの準備完了
テンプレートソーステンプレートファイルのアップロード
ファイル保存した ecs-fargate.yaml を選択

「次へ」をクリックします。

B-M3. スタックの詳細とパラメータを指定
スタックの名前handson-ecs-fargate
DBPasswordAdminPass123!
ImageUriB-M1 で控えたイメージ URI を貼り付け

「次へ」をクリックします。

B-M4. スタックオプション・確認

スタックオプションは変更不要で「次へ」。確認画面の最下部で 「IAM リソースがカスタム名で作成される場合があることを承認します」 にチェックを入れ(handson-ecs-task-execution-role を作成するため必須)、「送信」 をクリックします。

⚠️ 作成には 15〜20 分かかります

ステータスが CREATE_COMPLETE になるまで待ちます。

B-M5. 出力(アクセス URL)を確認

スタックを選択 →「出力(Outputs)」タブで ALBUrl を確認し、ブラウザで開いて「動作確認」へ進みます。

テンプレート全文:ecs-fargate.yaml

⬇ ダウンロード

ecs-fargate.yaml を表示
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名】/usersRDS の users テーブル 3 件
✅ 確認ポイント

/users で 3 件のデータが返れば、ALB → コンピュート層 → RDS の経路が成立しています。複数回アクセスすると server/container 名が 2 台で切り替わり、冗長化が確認できます。

⚠️ すぐに 200 が返らない場合

A はスタック完了後も EC2 のセットアップに 2〜3 分、B はタスク起動とヘルスチェック通過に 1〜2 分かかります。ターゲットが healthy になるまで待ってください。

🧹 クリーンアップ

⚠️ 課金停止のためスタックを削除してください

スタックを削除すると、作成された全リソースが自動的に削除されます。

方法① CloudShell(CLI)で削除

A: EC2 スタックの削除

aws cloudformation delete-stack --stack-name handson-ec2-alb-rds --region ap-northeast-1 aws cloudformation wait stack-delete-complete --stack-name handson-ec2-alb-rds --region ap-northeast-1

B: ECS スタックの削除

aws cloudformation delete-stack --stack-name handson-ecs-fargate --region ap-northeast-1 aws cloudformation wait stack-delete-complete --stack-name handson-ecs-fargate --region ap-northeast-1

ECS の場合は ECR リポジトリがスタック外に残るため、不要なら個別に削除します。

aws ecr delete-repository --repository-name handson-ecr --force --region ap-northeast-1

方法② マネジメントコンソールで削除

C-M1. スタックを削除

マネジメントコンソールで 「CloudFormation」 を開き、対象スタック(handson-ec2-alb-rds または handson-ecs-fargate)を選択 → 「削除」 をクリック → 確認ダイアログで 「削除」 を押します。

ステータスが DELETE_IN_PROGRESS → スタックが一覧から消えれば(DELETE_COMPLETE)削除完了です。全リソースが自動で削除されます。

C-M2. (ECS のみ)ECR リポジトリを削除

「ECR」 コンソール → 「リポジトリ」 → handson-ecr を選択 → 「削除」。イメージが残っていても、リポジトリ削除の確認を進めれば一緒に削除されます。

ℹ️ 削除が失敗するときは

RDS の削除に時間がかかるため、削除完了まで数分待ちます。ENI が残ってサブネット削除に失敗する場合は、数分後に delete-stack を再実行するか、マネコンの CloudFormation から「削除を再試行」してください。

学習のまとめ

習得したスキル実践内容
IaC による環境構築マネコン手順を CloudFormation テンプレートで再現
2 つの 2 層構成EC2+ALB+RDS と ECS Fargate+RDS の作り分け
パラメータ活用DBPassword・ImageUri を外部から注入
環境の再利用・破棄deploy / delete-stack による一括ライフサイクル管理