
CFnでゼロからWeb三層(マルチAZ)のWordPress環境を構築して、アクセスしてみた 〜EFSでEC2を2台構成に〜
はじめに
おはようございます、みつぐちです(╹◡╹)
今回は単純にやりたいことやります。
Web三層アーキテクチャ(マルチAZ)でWordPress環境を構築し、自分でアクセスしてみたい!です🙃
2台のEC2間をEFS共有で同一のWordPressを表示させるような冗長構成にします。
ALBへのアクセス、本当はちゃんと暗号化したかったのですが、諸事情によりHTTPアクセスとします。
また、CFnにてIaCで一発構築します〜
概要はざっとこんな感じです!
- Web3層構成(ALB/EC2/RDS)をマルチAZで構築
- EC2を2台構成にしたときの課題「アップロード画像などのファイル共有」を EFS で解決
- DBのパスワードはテンプレートに書かず Secrets Manager で管理、EC2へは Session Manager で接続(SSH不要)
- 検証用のため HTTP接続とし、ALBへのアクセスは自分のIPアドレスだけに制限
この記事の対象読者
- CFnでWebアプリの基盤を作ってみたい方
- ALB+EC2+RDSのWeb3層構成を、手作業ではなくコードで再現してみたい方
- WordPressをマルチAZで動かすときの落とし穴(EC2を2台にしたときのファイル共有)を知りたい方
こだわりポイント
| 観点 | この記事 |
|---|---|
| 構成 | ALB+EC2×2(2AZ)+RDS Multi-AZ のWeb3層をCFnで一括構築 |
| ファイル共有 | EFSでWordPressのファイルを共有し、2台構成でも画像が不整合にならないことを確認 |
| セキュリティ | DBパスワードをSecrets Managerで管理、SSHなし(Session Manager)、IMDSv2必須、SGは前段参照のみ、ALBは自分のIPのみ許可 |
| 完了判定 | cfn-signal により、スタックの完了=サイトが動く状態にしている |
| 検証 | EC2の台数変更・停止、RDSフェイルオーバー時の挙動を確認【TODO:実施した場合のみ記載】 |
今回の構成

| 層 | リソース | 配置 |
|---|---|---|
| Web層 | ALB | パブリックサブネット(2AZ) |
| アプリ層 | EC2(Amazon Linux 2023)×2、Auto Scaling | プライベートサブネット(2AZ) |
| データ層 | RDS for MySQL(Multi-AZ)、EFS | データ用サブネット(2AZ、インターネットへのルートなし) |
通信は次の順にのみ許可しています。
自分のIP → ALB(80) → EC2(80はALBから) → RDS(3306はEC2から) / EFS(2049はEC2から)
構築されるリソースと主な設定値
テンプレートで作成される主なリソースと、重要な設定値をまとめました!
ネットワーク(VPC・サブネット・ルート)
| リソース | 主な設定値 | 設定の意図 |
|---|---|---|
| VPC | CIDR:10.0.0.0/16、DNSサポート:有効、DNSホスト名:有効 |
EFSのマウントなどでDNS名の解決が必要なため、DNSホスト名を有効化 |
| インターネットゲートウェイ | 1つ、VPCにアタッチ | パブリックサブネットのインターネット接続用 |
| パブリックサブネット×2 | 10.0.0.0/24(1つ目のAZ)、10.0.1.0/24(2つ目のAZ)、パブリックIPの自動割り当て:なし |
ALBとNAT Gatewayを配置 |
| アプリ用サブネット×2 | 10.0.10.0/24(1つ目のAZ)、10.0.11.0/24(2つ目のAZ) |
EC2を配置(プライベート) |
| データ用サブネット×2 | 10.0.20.0/24(1つ目のAZ)、10.0.21.0/24(2つ目のAZ) |
RDSとEFSを配置(プライベート、インターネットへのルートなし) |
| NAT Gateway | パブリックサブネットに配置、Elastic IPを関連付け | プライベートのEC2が、パッケージ取得などで外部へ出るため |
| ルートテーブル | ルート |
|---|---|
public-rt |
0.0.0.0/0 → インターネットゲートウェイ |
app-rt-a |
0.0.0.0/0 → nat-a |
app-rt-b |
0.0.0.0/0 → Single:nat-a / PerAZ:nat-b |
data-rt |
ローカル(VPC内)のみ。デフォルトルートなし |
セキュリティグループ(通信の許可)
| セキュリティグループ | インバウンド | ソース | アウトバウンド |
|---|---|---|---|
| ALB用 | TCP 80 | パラメータ AllowedCidr(自分のIPの /32) |
既定(すべて許可) |
| EC2用 | TCP 80 | alb-sg |
既定(すべて許可) |
| RDS用 | TCP 3306 | app-sg |
既定(すべて許可) |
| EFS用 | TCP 2049 | app-sg |
既定(すべて許可) |
※ 各層は前段のセキュリティグループからの通信のみ許可しています(IPアドレスの範囲指定ではなく、SGの参照)。
ロードバランサー(ALB)
| リソース | 主な設定値 | 設定の意図 |
|---|---|---|
| ALB | タイプ:application、スキーム:internet-facing | 2AZのパブリックサブネットに配置してマルチAZ化 |
| リスナー | HTTP:80 → ターゲットグループへ転送 | 検証用のためHTTPのみ(HTTPS・証明書なし) |
| ターゲットグループ | プロトコル:HTTP、ポート:80、ターゲットタイプ:instance | Auto Scalingグループのインスタンスを登録 |
| ヘルスチェック | パス:/、間隔:15秒、正常しきい値:2回、異常しきい値:3回、成功コード:200-399 |
WordPressのセットアップ前はリダイレクト(301/302)を返すため、成功コードに3xxを含める |
アプリ層(EC2・Auto Scaling)
| リソース | 主な設定値 | 設定の意図 |
|---|---|---|
| 起動テンプレート | AMI:Amazon Linux 2023の最新(SSMパラメータ /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64)、インスタンスタイプ:t3.micro |
AMI IDを直書きしない |
| Auto Scalingグループ | 最小・希望:DesiredCapacity(既定2)、最大:4、ヘルスチェックタイプ:ELB |
2台を別AZに分散。ALBのヘルスチェックで異常なEC2を入れ替え |
| 作 | ||
| reationPolicy | シグナル数:DesiredCapacity、タイムアウト:30分 |
UserData完了(cfn-signal)までスタックを完了させない |
IAM
| リソース | 主な設定値 | 設定の意図 |
|---|---|---|
| IAMロール | 信頼先:ec2.amazonaws.com、マネージドポリシー:AmazonSSMManagedInstanceCore |
Session ManagerでSSHなしに接続 |
| インラインポリシー | secretsmanager:GetSecretValue、リソース:DBのシークレット1つのみ |
DBパスワードの取得権限を最小限に絞る |
| インスタンスプロファイル | 上記ロールを関連付け | EC2にロールを付与 |
データ層(RDS)
| 項目 | 設定値 | 設定の意図 |
|---|---|---|
| エンジン/バージョン | MySQL/8.4 |
利用可能なバージョンは事前に確認 |
| インスタンスクラス | db.t3g.micro |
検証用に小さく |
| ストレージ | gp3、20GB、暗号化:有効 | — |
| マスターパスワード | ManageMasterUserPassword: true |
パスワードをテンプレートやパラメータに書かない |
| マルチAZ | true |
別AZにスタンバイを持ち、フェイルオーバーに対応 |
| バックアップ保持期間 | 1日 | 検証用の最小設定 |
| 削除保護 | 無効 | 検証後にスタックごと削除できるようにする |
| 削除時の動作 | DeletionPolicy: Delete、UpdateReplacePolicy: Delete |
検証後に課金対象のスナップショットを残さない |
データ層(EFS)
| 項目 | 設定値 | 設定の意図 |
|---|---|---|
| ファイルシステム | 暗号化:有効、パフォーマンスモード:generalPurpose、スループットモード:elastic | 検証用の標準的な設定 |
| マウント方法(UserData) | /mnt/efs に、tls 付き |
通信を暗号化 |
| 配置するデータ | /mnt/efs/html にWordPress一式。EC2の /var/www/html はこのディレクトリへのシンボリックリンク |
2台のEC2で同じファイルを共有 |
WordPress/Webサーバー(UserDataで設定)
| 項目 | 設定値 | 設定の意図 |
|---|---|---|
| Webサーバー | Apache(httpd)+ PHP 8.3(PHP-FPM)、.htaccess を許可 |
パーマリンク等の設定に対応 |
wp-config.php |
DB名:wordpress、DBホスト:RDSのエンドポイント、DBユーザー/パスワード:Secrets Managerから取得 |
パスワードをテンプレートに直書きしない |
| WordPressのURL | WP_HOME / WP_SITEURL を http:// + アクセス元のホスト名(HTTP_HOST)に設定 |
ALBのDNS名はスタックごとに変わるため |
| 管理者アカウント | テンプレートでは作成しない(ブラウザのセットアップ画面で作成) | パスワードをテンプレートに埋め込まない |
出力値(Outputs)
| 出力キー | 内容 |
|---|---|
SiteUrl |
http://<ALBのDNS名>/(WordPressのURL) |
DbSecretArn |
DBの認証情報を保存しているSecrets ManagerシークレットのARN |
テンプレート
今回使用したテンプレートのyamlファイルを置いておきます。
テンプレート全文(wordpress-3tier.yaml)
AWSTemplateFormatVersion: "2010-09-09"
Description: >-
Multi-AZ WordPress 3-tier (ALB / EC2 Auto Scaling / EFS + RDS), accessed over HTTP via the ALB DNS name.
For verification use only. Region: ap-northeast-1 is assumed.
Metadata:
AWS::CloudFormation::Interface:
ParameterGroups:
- Label: { default: "Site / Access" }
Parameters: [ProjectName, AllowedCidr]
- Label: { default: "Compute (Web tier)" }
Parameters: [LatestAmiId, InstanceType, DesiredCapacity]
- Label: { default: "Database (Data tier)" }
Parameters: [DBInstanceClass, DBEngineVersion, DBMultiAZ]
- Label: { default: "Network" }
Parameters: [NatGatewayMode]
Parameters:
ProjectName:
Type: String
Default: wp-3tier
AllowedPattern: "^[a-z][a-z0-9-]{1,20}$"
Description: Name prefix for resources (lowercase letters, digits, hyphen).
AllowedCidr:
Type: String
AllowedPattern: "^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$"
ConstraintDescription: Must be a CIDR such as 203.0.113.10/32
Description: >-
CIDR allowed to access the site over HTTP. Use your own public IP as /32 (e.g. 203.0.113.10/32).
0.0.0.0/0 opens the site to the whole internet (not recommended: HTTP is not encrypted).
LatestAmiId:
Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64
InstanceType:
Type: String
Default: t3.micro
AllowedValues: [t3.micro, t3.small, t3.medium]
DesiredCapacity:
Type: Number
Default: 2
MinValue: 2
MaxValue: 4
Description: Number of web servers (2 or more to span 2 AZs).
DBInstanceClass:
Type: String
Default: db.t4g.micro
AllowedValues: [db.t4g.micro, db.t4g.small, db.t3.micro, db.t3.small]
DBEngineVersion:
Type: String
Default: "8.4"
Description: >-
RDS for MySQL version. Check availability with
aws rds describe-db-engine-versions --engine mysql
DBMultiAZ:
Type: String
Default: "true"
AllowedValues: ["true", "false"]
NatGatewayMode:
Type: String
Default: Single
AllowedValues: [Single, PerAZ]
Description: Single = one NAT Gateway (cheaper). PerAZ = one per AZ (resilient to AZ failure).
Conditions:
IsPerAzNat: !Equals [!Ref NatGatewayMode, PerAZ]
Resources:
# ---------------------------------------------------------------
# Network
# ---------------------------------------------------------------
Vpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.0.0.0/16
EnableDnsSupport: true
EnableDnsHostnames: true
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-vpc" }
InternetGateway:
Type: AWS::EC2::InternetGateway
Properties:
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-igw" }
IgwAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref Vpc
InternetGatewayId: !Ref InternetGateway
# Public subnets (ALB, NAT Gateway)
PublicSubnetA:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
AvailabilityZone: !Select [0, !GetAZs ""]
CidrBlock: 10.0.0.0/24
MapPublicIpOnLaunch: false
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-public-a" }
PublicSubnetB:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
AvailabilityZone: !Select [1, !GetAZs ""]
CidrBlock: 10.0.1.0/24
MapPublicIpOnLaunch: false
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-public-b" }
# Private app subnets (EC2)
AppSubnetA:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
AvailabilityZone: !Select [0, !GetAZs ""]
CidrBlock: 10.0.10.0/24
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-app-a" }
AppSubnetB:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
AvailabilityZone: !Select [1, !GetAZs ""]
CidrBlock: 10.0.11.0/24
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-app-b" }
# Private data subnets (RDS, EFS) - no route to the internet
DataSubnetA:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
AvailabilityZone: !Select [0, !GetAZs ""]
CidrBlock: 10.0.20.0/24
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-data-a" }
DataSubnetB:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
AvailabilityZone: !Select [1, !GetAZs ""]
CidrBlock: 10.0.21.0/24
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-data-b" }
# Route tables
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref Vpc
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-public-rt" }
PublicDefaultRoute:
Type: AWS::EC2::Route
DependsOn: IgwAttachment
Properties:
RouteTableId: !Ref PublicRouteTable
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref InternetGateway
PublicSubnetARouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnetA
RouteTableId: !Ref PublicRouteTable
PublicSubnetBRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnetB
RouteTableId: !Ref PublicRouteTable
NatEipA:
Type: AWS::EC2::EIP
DependsOn: IgwAttachment
Properties:
Domain: vpc
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-nat-eip-a" }
NatGatewayA:
Type: AWS::EC2::NatGateway
Properties:
AllocationId: !GetAtt NatEipA.AllocationId
SubnetId: !Ref PublicSubnetA
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-nat-a" }
NatEipB:
Type: AWS::EC2::EIP
Condition: IsPerAzNat
DependsOn: IgwAttachment
Properties:
Domain: vpc
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-nat-eip-b" }
NatGatewayB:
Type: AWS::EC2::NatGateway
Condition: IsPerAzNat
Properties:
AllocationId: !GetAtt NatEipB.AllocationId
SubnetId: !Ref PublicSubnetB
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-nat-b" }
AppRouteTableA:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref Vpc
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-app-rt-a" }
AppDefaultRouteA:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref AppRouteTableA
DestinationCidrBlock: 0.0.0.0/0
NatGatewayId: !Ref NatGatewayA
AppSubnetARouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref AppSubnetA
RouteTableId: !Ref AppRouteTableA
AppRouteTableB:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref Vpc
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-app-rt-b" }
AppDefaultRouteB:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref AppRouteTableB
DestinationCidrBlock: 0.0.0.0/0
NatGatewayId: !If [IsPerAzNat, !Ref NatGatewayB, !Ref NatGatewayA]
AppSubnetBRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref AppSubnetB
RouteTableId: !Ref AppRouteTableB
DataRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref Vpc
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-data-rt" }
DataSubnetARouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref DataSubnetA
RouteTableId: !Ref DataRouteTable
DataSubnetBRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref DataSubnetB
RouteTableId: !Ref DataRouteTable
# ---------------------------------------------------------------
# Security groups (chain: Internet -> ALB -> App -> DB / EFS)
# ---------------------------------------------------------------
AlbSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: ALB (HTTP from the allowed CIDR only)
VpcId: !Ref Vpc
SecurityGroupIngress:
- { IpProtocol: tcp, FromPort: 80, ToPort: 80, CidrIp: !Ref AllowedCidr, Description: HTTP from the allowed CIDR }
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-alb-sg" }
AppSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Web servers (HTTP from ALB only)
VpcId: !Ref Vpc
SecurityGroupIngress:
- { IpProtocol: tcp, FromPort: 80, ToPort: 80, SourceSecurityGroupId: !Ref AlbSecurityGroup, Description: HTTP from ALB }
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-app-sg" }
DbSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: RDS (MySQL from web servers only)
VpcId: !Ref Vpc
SecurityGroupIngress:
- { IpProtocol: tcp, FromPort: 3306, ToPort: 3306, SourceSecurityGroupId: !Ref AppSecurityGroup, Description: MySQL from web servers }
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-db-sg" }
EfsSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: EFS (NFS from web servers only)
VpcId: !Ref Vpc
SecurityGroupIngress:
- { IpProtocol: tcp, FromPort: 2049, ToPort: 2049, SourceSecurityGroupId: !Ref AppSecurityGroup, Description: NFS from web servers }
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-efs-sg" }
# ---------------------------------------------------------------
# Data tier: RDS (Multi-AZ) / EFS
# ---------------------------------------------------------------
DbSubnetGroup:
Type: AWS::RDS::DBSubnetGroup
Properties:
DBSubnetGroupDescription: Data subnets for RDS
SubnetIds: [!Ref DataSubnetA, !Ref DataSubnetB]
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-db-subnet-group" }
DbInstance:
Type: AWS::RDS::DBInstance
# For verification: do not leave a final snapshot behind (it would keep costing money).
DeletionPolicy: Delete
UpdateReplacePolicy: Delete
Properties:
Engine: mysql
EngineVersion: !Ref DBEngineVersion
DBInstanceClass: !Ref DBInstanceClass
AllocatedStorage: "20"
StorageType: gp3
StorageEncrypted: true
DBName: wordpress
MasterUsername: wpadmin
ManageMasterUserPassword: true # RDS creates/manages the password in Secrets Manager
MultiAZ: !Ref DBMultiAZ
PubliclyAccessible: false
DBSubnetGroupName: !Ref DbSubnetGroup
VPCSecurityGroups: [!Ref DbSecurityGroup]
BackupRetentionPeriod: 1
DeletionProtection: false
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-db" }
FileSystem:
Type: AWS::EFS::FileSystem
Properties:
Encrypted: true
PerformanceMode: generalPurpose
ThroughputMode: elastic
FileSystemTags:
- { Key: Name, Value: !Sub "${ProjectName}-efs" }
MountTargetA:
Type: AWS::EFS::MountTarget
Properties:
FileSystemId: !Ref FileSystem
SubnetId: !Ref DataSubnetA
SecurityGroups: [!Ref EfsSecurityGroup]
MountTargetB:
Type: AWS::EFS::MountTarget
Properties:
FileSystemId: !Ref FileSystem
SubnetId: !Ref DataSubnetB
SecurityGroups: [!Ref EfsSecurityGroup]
# ---------------------------------------------------------------
# Web tier: ALB / Target group / Auto Scaling group
# ---------------------------------------------------------------
LoadBalancer:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Type: application
Scheme: internet-facing
Subnets: [!Ref PublicSubnetA, !Ref PublicSubnetB]
SecurityGroups: [!Ref AlbSecurityGroup]
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-alb" }
TargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
VpcId: !Ref Vpc
TargetType: instance
Protocol: HTTP
Port: 80
HealthCheckPath: /
HealthCheckIntervalSeconds: 15
HealthyThresholdCount: 2
UnhealthyThresholdCount: 3
Matcher:
HttpCode: "200-399" # WordPress redirects (301/302) before the setup is completed
TargetGroupAttributes:
- { Key: deregistration_delay.timeout_seconds, Value: "30" }
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-tg" }
HttpListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref LoadBalancer
Port: 80
Protocol: HTTP
DefaultActions:
- Type: forward
TargetGroupArn: !Ref TargetGroup
WebServerRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal: { Service: ec2.amazonaws.com }
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore # Session Manager (no SSH / bastion)
Policies:
- PolicyName: read-db-secret
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource: !GetAtt DbInstance.MasterUserSecret.SecretArn
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-web-role" }
WebServerInstanceProfile:
Type: AWS::IAM::InstanceProfile
Properties:
Roles: [!Ref WebServerRole]
LaunchTemplate:
Type: AWS::EC2::LaunchTemplate
Properties:
LaunchTemplateData:
ImageId: !Ref LatestAmiId
InstanceType: !Ref InstanceType
IamInstanceProfile:
Arn: !GetAtt WebServerInstanceProfile.Arn
SecurityGroupIds: [!Ref AppSecurityGroup]
MetadataOptions:
HttpTokens: required # IMDSv2 only
HttpEndpoint: enabled
TagSpecifications:
- ResourceType: instance
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-web" }
UserData:
Fn::Base64: !Sub |
#!/bin/bash
set -Eeuxo pipefail
# ---- 0. cfn-signal (tells CloudFormation the instance is ready) ----
dnf install -y aws-cfn-bootstrap
signal() { /opt/aws/bin/cfn-signal -e $1 --stack ${AWS::StackName} --resource AutoScalingGroup --region ${AWS::Region}; }
trap 'signal 1' ERR
# ---- 1. Packages ----
dnf install -y httpd php8.3 php8.3-fpm php8.3-mysqlnd php8.3-gd php8.3-xml php8.3-mbstring php8.3-intl php8.3-opcache amazon-efs-utils jq
# ---- 2. Mount EFS (shared by all web servers) ----
mkdir -p /mnt/efs
echo "${FileSystem}:/ /mnt/efs efs _netdev,noresvport,tls 0 0" >> /etc/fstab
mount -a -t efs
mountpoint -q /mnt/efs
# ---- 3. Get DB credentials from Secrets Manager ----
set +x # keep the password out of the log
SECRET_JSON=$(aws secretsmanager get-secret-value --secret-id ${DbInstance.MasterUserSecret.SecretArn} --region ${AWS::Region} --query SecretString --output text)
DB_USER=$(echo "$SECRET_JSON" | jq -r .username)
DB_PASS=$(echo "$SECRET_JSON" | jq -r .password)
set -x
# ---- 4. Install WordPress ONCE onto EFS (lock so that only one server does it) ----
# WP-CLI needs more than PHP's default 128MB to unpack WordPress, so wrap it with a larger limit
curl -fsSL -o /usr/local/bin/wp-cli.phar https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
printf '#!/bin/bash\nexec php -d memory_limit=512M /usr/local/bin/wp-cli.phar "$@"\n' > /usr/local/bin/wp
chmod +x /usr/local/bin/wp /usr/local/bin/wp-cli.phar
exec 200>/mnt/efs/.init.lock
flock -x 200
if [ ! -f /mnt/efs/html/wp-config.php ]; then
mkdir -p /mnt/efs/html
/usr/local/bin/wp core download --path=/mnt/efs/html --locale=ja --force --allow-root
/usr/local/bin/wp config create --path=/mnt/efs/html --allow-root \
--dbname=wordpress --dbuser="$DB_USER" --dbpass="$DB_PASS" --dbhost=${DbInstance.Endpoint.Address} \
--extra-php <<'PHPEOF'
// Use the host name the browser used (ALB DNS name), over plain HTTP.
define('WP_HOME', 'http://' . $_SERVER['HTTP_HOST']);
define('WP_SITEURL', 'http://' . $_SERVER['HTTP_HOST']);
PHPEOF
chown -R apache:apache /mnt/efs/html
fi
flock -u 200
# ---- 5. Apache / PHP-FPM ----
rm -rf /var/www/html
ln -s /mnt/efs/html /var/www/html
cat > /etc/httpd/conf.d/wordpress.conf <<'CONFEOF'
<Directory "/var/www/html">
AllowOverride All
</Directory>
CONFEOF
systemctl enable --now php-fpm httpd
signal 0
AutoScalingGroup:
Type: AWS::AutoScaling::AutoScalingGroup
DependsOn: [AppDefaultRouteA, AppDefaultRouteB, MountTargetA, MountTargetB, DbInstance, HttpListener]
CreationPolicy:
ResourceSignal:
Count: !Ref DesiredCapacity
Timeout: PT30M
Properties:
MinSize: !Ref DesiredCapacity
MaxSize: "4"
DesiredCapacity: !Ref DesiredCapacity
VPCZoneIdentifier: [!Ref AppSubnetA, !Ref AppSubnetB]
LaunchTemplate:
LaunchTemplateId: !Ref LaunchTemplate
Version: !GetAtt LaunchTemplate.LatestVersionNumber
TargetGroupARNs: [!Ref TargetGroup]
HealthCheckType: ELB
HealthCheckGracePeriod: 600
Tags:
- { Key: Name, Value: !Sub "${ProjectName}-web", PropagateAtLaunch: true }
Outputs:
SiteUrl:
Description: WordPress site URL (ALB DNS name, HTTP)
Value: !Sub "http://${LoadBalancer.DNSName}/"
AlbDnsName:
Description: ALB DNS name
Value: !GetAtt LoadBalancer.DNSName
DbEndpoint:
Description: RDS endpoint (private)
Value: !GetAtt DbInstance.Endpoint.Address
DbSecretArn:
Description: Secrets Manager secret holding the DB master credentials
Value: !GetAtt DbInstance.MasterUserSecret.SecretArn
FileSystemId:
Description: EFS file system ID
Value: !Ref FileSystem
主なポイントだけ抜粋して説明します!
1. パスワードをテンプレートに書かない(Secrets Manager)
DbInstance:
Type: AWS::RDS::DBInstance
Properties:
MasterUsername: wpadmin
ManageMasterUserPassword: true # RDSがSecrets Managerでパスワードを管理
EC2側は、IAMロールに secretsmanager:GetSecretValue を許可し、UserData内でAWS CLIから取得します。パラメータでパスワードを渡す必要がありません。
2. AMIを直書きしない(SSMパラメータ)
LatestAmiId:
Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64
3. 3層を分けるネットワークとSG
- パブリック(ALB/NAT Gateway)、プライベート(EC2)、データ(RDS/EFS)の3種類のサブネットを2AZ分作成
- データ用サブネットのルートテーブルには、インターネット向けルートを設定していません
- SGは前段のSGを参照して許可(ALB-SG → App-SG → DB-SG/EFS-SG)
- ALB-SGは自分のIPの
/32からのHTTPのみ許可する設定としています
AllowedCidr:
Type: String
Description: CIDR allowed to access the site over HTTP (e.g. 203.0.113.10/32)
4. EFSでWordPressを共有する
EC2を2台にすると、片方にアップロードした画像がもう片方には存在しない、という問題が起きます。そこで、EFSを両方のEC2にマウントし、WordPress一式をEFS上に置きました。
UserDataでは、最初に起動したEC2だけがWordPressを展開するよう、flock で排他制御しています。
exec 200>/mnt/efs/.init.lock
flock -x 200
if [ ! -f /mnt/efs/html/wp-config.php ]; then
# WordPressのダウンロード、wp-config.phpの作成(最初の1台だけが実行)
fi
flock -u 200
5. ALBのDNS名でそのまま動かす(WordPressのURL設定)
ALBのDNS名はスタックごとに変わるため、wp-config.php では、アクセス元のホスト名からURLを決めるようにしています。
define('WP_HOME', 'http://' . $_SERVER['HTTP_HOST']);
define('WP_SITEURL', 'http://' . $_SERVER['HTTP_HOST']);
6. スタックの完了=サイトが動く状態にする
CreationPolicy と cfn-signal を使い、EC2のUserDataが完了するまでスタックが CREATE_COMPLETE にならないようにしています。
やってみた
デプロイ
CloudFormationから、先ほどのテンプレートファイルをアップロードしてスタックを作成します。
今回、自端末のみアクセス許可したいので、パラメータ AllowedCidr に自端末のIPを入力します。
実行したら、20-30分ほど待ちます。(マルチAZのRDSデプロイは時間がかかるので、夕飯について考えましょう。)

問題なくデプロイ完了しました。
主要リソースの確認
主要リソースが想定通り作成されているかを確認していきましょう〜
・VPC

ALB

EC2

RDS

EFS

動作確認
続いて動作確認をしていきます。
-
出力したサイトのURLにアクセスして、WordPressのセットアップ画面が表示されました。(HTTP接続なので、ブラウザには「保護されていない通信」と表示されます)

-
ALBのターゲットグループで、サーバ2台が
healthyになっていることを確認しました。

-
WordPressの初期設定を行い、無事にインストールできることを確認しました!

-
簡易サイトを作成して表示してみました。今回は検証用なので雑魚クオリティで作ってます。
画像(私の可愛いブログアイコンです)も1枚アップロードして、EFS共有がしっかりできているかも後続で確認します。

障害テスト
ここではサーバ障害を想定して、EC2インスタンス1台を意図的に壊してみます。
コンソールからインスタンスを1台強制終了させ、サイトの継続性を確認していきます。
時間軸がわかるように、時刻つきエビデンスを取得しました。
・まずはインスタンス1台を強制終了します。

・その後再度サイトにアクセスしてみます。

今回の冗長構成が正常に機能していました、よかったです!
ALBが正常なターゲットにトラフィックを流し、EFS共有により同一画像がしっかり表示されました。
注意点(本番利用時の考慮)
- HTTPのため通信が暗号化されません。 本番では、独自ドメインとACMでHTTPS化してください
- WordPress本体をEFSに置く構成は検証用です。本番では
wp-contentのみ共有する、S3を使うなどを検討する必要があります。 - 今回は簡易構成のため、WAF、CloudFront、バックアップ設計、監視、スケーリングポリシーはスコープ外です
- 検証後はCFnのスタック削除からリソースを削除します。(今回のリソースだけでも月n万円の費用になりそうです。)
まとめ
テンプレートを再利用すれば、同じ構成を何度でも作って壊せます。CFnで構築すると、手順の再現性が高くなり、学習したことをコードとして残せる点も利点です。次は独自ドメインを取得し、ACMとRoute 53でHTTPS化する構成にも挑戦してみたいと思います〜
この記事が、WordPressをマルチAZで構築したい方の参考になれば幸いです!
最後に
最後まで読んでくださりありがとうございます。
本日の写真はこちら(ででん)

こちらは北海道美瑛町というところです〜
すごく田舎で人工物が少ないところですが、のどかな景色が美しすぎる街です。
ほんっっっっっとうに素敵なところなので絶対行ってみてください⛄️
特に冬がおすすめです。当たり前にすごく寒いですけど。







