CDK で WordPress を AWS にホストする:共有 ALB、CloudFront、EFS、Aurora のトレードオフ

本サイトの WordPress 構成:Fargate、EFS、0 ACU まで停止する Aurora、シークレットヘッダーで守る共有 ALB。選択理由と代償を解説。

本サイト(フィジカルAIラボ)の AWS 上の WordPress は、TypeScript で書かれた CDK アプリケーションで定義されています。Fargate で公式 WordPress イメージを動かし、/var/www/html 全体を EFS に置き、データベースには 0 ACU まで一時停止できる Aurora Serverless v2 を使い、手前には既存の共有 ALB と CloudFront ディストリビューションがあります。本記事では、各コンポーネントをなぜそう選んだのか、その代償は何かを一つずつ説明します。同様の方法で WordPress をホストしたい読者が、そのまま流用したり、同じ落とし穴を避けたりできるようにするためです。

全体構成

訪問者 ─TLS─> CloudFront ─TLS─> 共有 ALB の HTTPS リスナー
                                 └─ ルール:Host = サイトのドメイン かつ CloudFront が付けたシークレットヘッダーあり
                                      └─> Fargate タスク(Graviton、0.5 vCPU / 1 GB、公式 wordpress:php8.3-apache イメージ)
                                           ├─ /var/www/html に EFS をマウント(コア、プラグイン、アップロードファイル、wp-config.php)
                                           ├─ Aurora MySQL Serverless v2、0–2 ACU、アイドル 30 分で一時停止
                                           └─ タスクロールで Amazon Bedrock を呼び出す(WordPress にキーを保存しない)

この構成は、サイト上のチャットプラグイン AI Chat for Amazon Bedrock のために決めたもので、主な選択はいくつかの要件で決まりました。

要件方法
チャットの回答を SSE でストリーミング返却するALB はストリームをそのまま中継できる。プロジェクトの README には、API Gateway + Lambda では回答全体をバッファしてから返すという判断が記録されており、そのため Lambda は選ばなかった
wp-admin でプラグインをインストール・更新する/var/www/html 全体が EFS 上にあり、タスクの再起動や再デプロイでも失われない
Bedrock を呼び出すための認証情報ECS タスクロール。bedrock:InvokeModel* とモデル一覧取得の権限だけを付与
アイドル時のコストを抑えるAurora は 0 ACU まで一時停止できる。ALB のヘルスチェックは静的ファイルを読むので、PHP とデータベースを起こさない
NAT ゲートウェイを使わないタスクはパブリックサブネットに置き、パブリック IP を持つ(インバウンドは ALB のセキュリティグループからのみ許可)。データベースと EFS は、インターネットへのルートがない 2 つのサブネットに置く

ファイルを EFS に置く:WordPress の「可変部分」を残す

WordPress は読み取り専用のアプリケーションではありません。プラグイン、テーマ、アップロードファイル、wp-config.php はいずれも実行時に書き込まれます。/var/www/html 全体を EFS にマウントし、wp-content/uploads だけにしなかったのは、wp-admin からプラグインをインストール・更新できるようにするためです。

こうすると、コンテナイメージの役割が変わります。公式イメージの docker-entrypoint.sh は、カレントディレクトリに index.php も wp-includes/version.php もない場合にだけ WordPress をコピーし、既存のファイルは上書きしません。初回起動後は EFS 上にすでに WordPress があるため、イメージタグ php8.3-apache が実質的に決めるのは PHP と Apache のバージョンだけです。WordPress コアの更新は WordPress 自身に任せています(WP_AUTO_UPDATE_CORE を 'minor' に設定し、マイナーバージョンを自動でインストール)。

細かい点:

  • EFS アクセスポイントはルートディレクトリを /html とし、所有者を uid/gid 33(イメージ内の www-data)にしています。転送時の暗号化と IAM 認可はどちらも有効で、タスクロールには ClientMount と ClientWrite だけを付与しています。
  • 初回起動時に WordPress を EFS にコピーする必要があるため、ECS サービスのヘルスチェック猶予期間を 3 分にしています。デプロイでは circuit breaker を有効にしており、失敗すると自動でロールバックします。
  • テーマとプラグインのオンラインファイルエディターは無効にしています(DISALLOW_FILE_EDIT)。

EFS がページのレイテンシに与える影響は、このプロジェクトでは測定していません。アクセスの多いサイトでは各自で評価する必要があります。

データベース:Aurora Serverless v2 を 0 ACU まで一時停止する

最小容量を 0 ACU にすると、自動一時停止が有効になります。一定時間ユーザー接続がなければインスタンスが一時停止し、インスタンス料金はゼロになります。ストレージは通常どおり課金されます。このプロジェクトでは、アイドル 30 分で一時停止、最大 2 ACU に設定しています。代償はコールドスタートです。AWS のドキュメントでは、再開までの典型的な時間は約 15 秒、24 時間を超えて一時停止していた場合は 30 秒以上かかることがあるとされています。そのため CloudFront 版の構成では、オリジンの読み取りタイムアウトを 60 秒にしてデータベースの再開時間をカバーしています。

データベースを実際に停止させるには、定期的なリクエストがデータベースに一切触れないようにする必要があります。ALB のヘルスチェックは 30 秒ごとに行われるため、トップページをチェックしていると PHP とデータベースを起こし続けてしまいます。このプロジェクトでは静的ファイルをチェックしています。

healthCheck: { path: "/wp-includes/images/blank.gif", healthyHttpCodes: "200", interval: Duration.seconds(30) }

バックアップについて:Aurora はポイントインタイムリカバリを 7 日間保持し、削除保護を有効にし、スタック削除時には最終スナップショットを残します。EFS は AWS Backup の対象に入れ、スタック削除時にもファイルシステムを保持します。

CloudFront からしかオリジンに到達できないようにする

ALB はインターネットから到達できるため、Host ヘッダーだけで転送していると、誰でも CloudFront を迂回して ALB に直接リクエストできます。AWS が推奨するのは、CloudFront がオリジンへのリクエストにカスタムヘッダーを付け、ALB のルールはこのヘッダーを持つリクエストだけを転送し、それ以外のリクエストはリスナーのデフォルトアクションに落とす方法です。ヘッダーの名前と値はパスワードとして扱う必要があります。

このプロジェクトの共有 ALB には他のサービスも載っているため、アプリケーションはそのセキュリティグループを変更せず(コードでは読み取り専用でインポート)、デフォルトアクションも変えず、ルールを 1 つ追加するだけです。

  • 条件 1:Host がサイトのドメインと一致する。
  • 条件 2:CloudFront のオリジン設定にあるシークレットヘッダーを持ち、値が完全に一致する。
  • アクション:WordPress のターゲットグループに転送する。

シークレットヘッダーはリポジトリに書きません。ディストリビューションはこのアプリケーションの外で作成されているため、デプロイ時に AwsCustomResource で CloudFront の GetDistributionConfig を呼び出し、最初のオリジンの最初のカスタムヘッダーを取り出して、リスナールールの条件に入れます。

const path = "DistributionConfig.Origins.Items.0.CustomHeaders.Items.0";
const origin = new cr.AwsCustomResource(this, "OriginHeader", {
  onUpdate: {
    service: "CloudFront",
    action: "getDistributionConfig",
    parameters: { Id: distributionId },
    region: "us-east-1",
    physicalResourceId: cr.PhysicalResourceId.of(`${distributionId}-origin-header`),
    outputPaths: [`${path}.HeaderName`, `${path}.HeaderValue`],
  },
  policy: cr.AwsCustomResourcePolicy.fromSdkCalls({
    resources: [`arn:aws:cloudfront::${this.account}:distribution/${distributionId}`],
  }),
});
const headerCondition = elbv2.ListenerCondition.httpHeader(
  origin.getResponseField(`${path}.HeaderName`),
  [origin.getResponseField(`${path}.HeaderValue`)],
);

トレードオフ:ディストリビューション側でヘッダーの値を変えた場合、スタックを再デプロイしないとルールに反映されません。また、ルール内の値は ALB の設定を読む権限を持つ人には見えます。これはシークレットヘッダーにもとづくどの方式でも同じです。デプロイ後、このプロジェクトでは、正しい Host を付けてシークレットヘッダーを付けずに ALB に直接リクエストすると、リスナーのデフォルトの 404 が返ることを確認しました。

コードには別の構成も残しています。アプリケーション自身が VPC、ALB、CloudFront を作成し、ALB のセキュリティグループでは CloudFront のマネージドプレフィックスリスト com.amazonaws.global.cloudfront.origin-facing だけを許可する構成です。プレフィックスリストはネットワーク層で CloudFront 以外のトラフィックを遮断しますが、含まれるのはすべての CloudFront のオリジン向けアドレスで、他人のディストリビューションもその中に入ります。そのため AWS のドキュメントでは、シークレットヘッダーに加えて使う任意の補完策として位置付けられています。

プロキシの背後での HTTPS と訪問者 IP

WordPress は、現在のリクエストが HTTPS かどうかを知る必要があります。そうでないと http のリンクを生成したり、リダイレクトループに陥ったりします。2 種類の前段構成では、判断の根拠が異なります。

  • 共有 ALB:CloudFront から ALB へは TLS で接続し、ALB が TLS を終端したうえで X-Forwarded-Proto: https を送ります。公式イメージの wp-config-docker.php は、このヘッダーに https が含まれていれば $_SERVER['HTTPS'] = 'on' を設定するので、追加の対応は不要です。
  • 自前 ALB 構成:CloudFront から ALB へは HTTP なので、ALB が送る X-Forwarded-Proto は http です。この場合は、CloudFront が付ける CloudFront-Forwarded-Proto を見る必要があります。なお、CloudFront は訪問者が自分で付けた X-Forwarded-Proto を削除します。

追加の設定はすべてイメージの WORDPRESS_CONFIG_EXTRA 環境変数で注入しており、イメージは wp-config.php の中でこれを eval します。このプロジェクトでは、ここで WP_HOME、WP_SITEURL(いずれも https のアドレス)と FORCE_SSL_ADMIN を固定しています。訪問者 IP の扱いには陥りやすい落とし穴があるため、「CloudFront + ALB の背後で訪問者 IP を取得する:PHP で X-Forwarded-For を解析してはいけない理由」で別途説明しています。

ページはキャッシュしません。CloudFront のデフォルトのビヘイビアはオリジンリクエストポリシーに AllViewerAndCloudFrontHeaders-2022-06 を使い、訪問者の Host、Cookie、CloudFront ヘッダーをオリジンに渡します。実測でもページは毎回 Miss です。一方、静的リソースは 2 回目のリクエストから CloudFront のキャッシュが返します。wp-includes、テーマ、プラグインの CSS のいずれでも確認しました。キャッシュキーにはクエリ文字列が含まれます。WordPress が ?ver= で静的リソースにバージョンを付けるためです。自前構成の CDK コードではこのルールが明示されています。デフォルトのビヘイビアは CachingDisabled、wp-includes/* と wp-content/uploads/* にはクエリ文字列ごとにキャッシュする専用のポリシーを使います。

落とし穴:VPC インターフェイスエンドポイントと wp_safe_remote_*()

サイトのある VPC には、プライベート DNS を有効にした bedrock-runtime インターフェイスエンドポイントがあり、そのため Bedrock のドメイン名は VPC 内のプライベートアドレスに解決されます。スタックはエンドポイントのセキュリティグループに、送信元を WordPress タスクに限った 443 のインバウンドルールを追加しています。ところが WordPress の wp_safe_remote_*() はプライベートアドレスを拒否するため、プラグインの診断ページと非ストリーミング呼び出しで「could not be reached」というエラーが出ました。ストリーミング呼び出しは curl を直接使うので影響を受けません。このプロジェクトでは、まず暫定策として wp-config.php で http_request_host_is_external フィルターを使い、bedrock*.<region>.amazonaws.com だけを許可しました。プラグイン 1.47.1 以降はプラグイン自身が自分の AWS リクエストを許可するようになったため、このフィルターは削除しました。VPC 内で WordPress を動かし、プライベート DNS のエンドポイントも使っている読者は、まずこの点を確認するとよいでしょう。

運用

  • 初回インストールはすぐに行う:WordPress のインストールが完了するまでは、サイトを開いた誰でも先にインストールを済ませてしまえます。このプロジェクトでは、scripts/install.sh でデプロイ直後にインストールを完了させます。管理者パスワードはスタックが Secrets Manager に生成し、スクリプトはそれを読み取ってプロセス置換で curl に渡します。画面に表示せず、コマンドライン引数にも現れません。
  • ログ:コンテナのログは CloudWatch に送られ、1 か月保持されます。必要に応じて aws ecs execute-command でコンテナに入ります。
  • 定期タスク:WordPress 標準の WP-Cron を使い、ページへのアクセス時に実行されます。期限の来たタスクは次のアクセスまで実行されないので、足りなくなったら外部からの定期トリガーを追加します。
  • ドメインは先に決める:WordPress は記事内に絶対 URL を保存するため、コンテンツを書いた後にドメインを変えると、データベースで検索置換を行う必要があります。

制約と注意点

  • Fargate タスクは 1 つだけで(desiredCount: 1)、複数レプリカによる高可用性はありません。Aurora も writer が 1 つだけです。
  • 共有 ALB のアイドルタイムアウトは 60 秒です。モデルがトークンを出力し続けている間は問題ありませんが、モデルが何も出力せずに 60 秒を超えて思考すると、接続が切断されます。この値を大きくすると、ALB の背後のすべてのサービスに影響します。
  • データベースの一時停止後、最初のアクセスでは約 15 秒余計に待つことになります。
  • コストはプロジェクトの README にある概算です(東京リージョン、無料利用枠の適用前、低トラフィック)。共有 ALB 構成は月額約 25–35 米ドルで、うち Fargate が約 18 米ドル、タスクのパブリック IPv4 が約 4 米ドルです。データベースが起動している間の料金は別途かかります。ALB と CloudFront を自前で作る構成は約 55–70 米ドルです。Bedrock はトークン単位で別途課金されます。

リンク