CFnでゼロからWeb三層(マルチAZ)のWordPress環境を構築して、アクセスしてみた 〜EFSでEC2を2台構成に〜

CFnでゼロからWeb三層(マルチAZ)のWordPress環境を構築して、アクセスしてみた 〜EFSでEC2を2台構成に〜

Web三層アーキテクチャでマルチAZなWordPress環境をCloudFormationで一括構築し、EFS共有とAuto Scalingで実際に動かしてみました。本番環境での注意点も交えながら、冗長構成の実装と障害テストの結果をご紹介します。
2026.10.09

はじめに

おはようございます、みつぐちです(╹◡╹)

今回は単純にやりたいことやります。
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

動作確認

続いて動作確認をしていきます。

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

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

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

  4. 簡易サイトを作成して表示してみました。今回は検証用なので雑魚クオリティで作ってます。
    画像(私の可愛いブログアイコンです)も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で構築したい方の参考になれば幸いです!

最後に

最後まで読んでくださりありがとうございます。
本日の写真はこちら(ででん)

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

参考

この記事をシェアする

関連記事