0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

AWS Lightsail は、月額数ドルから使える手軽なVPSサービスとして、個人ブログや検証用のWordPress環境でよく利用されています。しかし、実運用に乗せる際に気になるのが「セキュリティ対策」です。

WordPressは世界で最も利用されているCMSであるがゆえに、wp-login.php への総当たり攻撃やプラグインの脆弱性を狙ったSQLインジェクション、XSSなど、常に攻撃対象になりやすいという側面があります。

「じゃあ AWS WAF を付けよう」と考えて Lightsail のコンソールを触ると、すぐに壁にぶつかります。

Lightsail のロードバランサには、AWS WAF を直接アタッチできません。

この記事では、この制約を CloudFront を前段に配置することで解決し、さらに AWS CDK でコード化して誰でも再現できる形にする方法を解説します。あわせて、WAFの基本的な仕組み、導入のメリット・デメリット、そして気になる月額料金についても実際の料金体系をもとに解説します。

Lightsailは東京リージョン(ap-northeast-1)に置きつつ、WAFのWebACLだけをCloudFront用の制約により us-east-1 に作成する、実運用に近いマルチリージョン構成をCDKで実装します。環境依存の値(リージョン、インスタンスプランなど)は .envファイル に切り出し、環境ごとに設定を切り替えやすい構成にしています。

この記事で得られるもの

  • AWS WAFの基本的な役割と防御できる攻撃の種類
  • Lightsail環境でWAFを使うための正しいアーキテクチャ
  • リージョンを分割し、設定値を.env管理するCDK(TypeScript)構成コード
  • WordPress + CloudFrontで必須になるキャッシュビヘイビアの分割
  • wordpress_ls_1_0 ブループリントをCloudFront対応にする具体的な手順(userData でIaC化)
  • WAFのブロックを確認する具体的なCLI手順(get-sampled-requests / Logs Insights)
  • クリーンアップ手順と、作り直すときに必要な作業
  • WAF導入のメリット・デメリットと月額コストの目安

対象読者

  • Lightsail + WordPressで個人ブログやサイトを運用している方
  • コストを抑えつつセキュリティを強化したい方
  • CDKでインフラをコード管理したい方

検証環境

項目
Lightsail ブループリント wordpress_ls_1_0
Lightsail バンドル nano_3_0
Lightsail リージョン ap-northeast-1
WAF リージョン us-east-1
aws-cdk-lib 2.266.0
Node.js 24.x(18.x以上であれば動作します)

本記事は独自ドメイン + ACM証明書の設定を対象外としています。 CloudFrontが払い出す *.cloudfront.net のドメインをそのまま使う構成です。実運用に載せる際は別途ACM証明書(これも us-east-1 固定)と代替ドメイン名(CNAME)の設定が必要になります。


1. AWS WAFとは何か

AWS WAF(Web Application Firewall)は、Webアプリケーション層(レイヤー7)への攻撃を防ぐためのマネージドサービスです。

Lightsailのファイアウォールやセキュリティグループが「どのポート・IPからの通信を通すか」というネットワーク層(レイヤー3/4)の制御を行うのに対し、AWS WAFは HTTPリクエストの内容そのもの を検査し、悪意のあるパターンを検知してブロックします。

WAFが防御できる代表的な攻撃

攻撃種別 概要
SQLインジェクション フォームやURLパラメータに不正なSQL文を注入し、DB情報を抜き出す攻撃
XSS(クロスサイトスクリプティング) 悪意のあるスクリプトをページに埋め込み、他の利用者に実行させる攻撃
総当たり攻撃(Brute Force) wp-login.php 等に対してパスワードを機械的に試行する攻撃
Bot・スクレイピング 悪意のあるクローラーやスパムボットによる大量アクセス
既知の脆弱性を突く攻撃 古いプラグイン・CMSの既知の脆弱性(CVE)を狙った攻撃

AWS WAFでは、これらを自分でルールを1つずつ書かなくても、AWSが管理する マネージドルールグループAWSManagedRulesCommonRuleSetAWSManagedRulesWordPressRuleSet など)を有効化するだけで、代表的な攻撃パターンに対応できます。特にWordPress向けの専用マネージドルールが用意されている点は、今回のケースにおいて非常に相性が良いポイントです。


2. 【重要】Lightsail + WAFにおける構成上の制約

ここが本記事のもっとも重要なポイントです。

AWS WAFは、以下のリソースに対してのみアタッチが可能です。

  • Amazon CloudFront
  • Application Load Balancer(ALB)
  • Amazon API Gateway
  • AWS AppSync
  • Amazon Cognito ユーザープール
  • AWS Verified Access

Lightsailのロードバランサ(Lightsail Load Balancer)はこのリストに含まれておらず、WAFを直接アタッチすることができません。 Lightsailは「シンプルさ」を売りにしたサービスであるため、EC2やALBほど高度なリソース連携ができない仕様になっています。

解決策:CloudFrontを前段に配置する

そこで採用するのが、Lightsailのロードバランサの手前に Amazon CloudFront を配置し、CloudFrontディストリビューションに対してWAFをアタッチする構成です。

[ユーザー]
   ↓ HTTPS
[Amazon CloudFront] ← AWS WAF (WebACL) をアタッチ … us-east-1
   ↓ HTTP(オリジンアクセス / X-Origin-Verify ヘッダー付与)
[Lightsail Load Balancer] … ap-northeast-1
   ↓ HTTP :80
[Lightsail Instance (WordPress)] … ap-northeast-1

この構成であれば、CloudFrontに到達した時点でWAFによる検査が行われ、悪意のあるリクエストはLightsailインスタンスに到達する前にブロックされます。さらに、CloudFrontを挟むことで静的コンテンツのキャッシュ配信による高速化という副次的なメリットも得られます。

CloudFrontにアタッチするWAFのWebACLは、リージョンを us-east-1(バージニア北部) に作成する必要があります。一方でLightsailインスタンスやロードバランサ自体は、レイテンシを抑えるために利用者に近いリージョン(今回は東京:ap-northeast-1)に配置したいはずです。この「リージョンのねじれ」をCDKでどう解決するかが、本記事の実装面での核心になります。

オリジン直接アクセスの防止

CloudFrontを前段に置いても、Lightsailロードバランサのグローバルエンドポイントに直接アクセスされてしまうと、WAFを迂回されてしまいます。これを防ぐため、CloudFrontから送信する カスタムヘッダー をLightsailのWebサーバー側(Apacheの設定)でチェックし、ヘッダーが無いリクエストは拒否する設定を併用します(10-10 で解説します)。

このカスタムヘッダーの値は秘密情報として扱う必要があります。値が漏洩すると、攻撃者が同じヘッダーを付けてオリジンへ直接アクセスできてしまい、WAFを迂回されます。したがって、

  • コードから機械的に導出できる値にしてはいけない(後述しますが、cdk.Names.uniqueId(this) を使う実装は罠です)
  • CloudFormationの Outputs に出してはいけないdescribe-stacks 権限があれば誰でも読めます)

本記事では .envORIGIN_VERIFY_SECRET から渡す形にしています。


3. WAF導入のメリット・デメリット

メリット

  • マネージドルールにより運用負荷が低い:ルールの個別実装が不要で、AWSが継続的に更新してくれる
  • WordPress特有の脆弱性に対応しやすい:専用マネージドルールで管理画面への攻撃を軽減できる
  • CloudFrontとのセット導入で配信が高速化する:キャッシュにより表示速度も改善される
  • レート制限が容易:一定時間内の異常なリクエスト数を検知してブロックできる
  • 可視性の向上:CloudWatchやログでブロックしたリクエストを可視化できる

デメリット

  • Lightsail単体では利用できない:CloudFrontの追加構成が必須となり、構成が複雑化する
  • 追加コストが発生する:WAFとCloudFrontの両方に料金がかかる
  • 誤検知(False Positive)のリスク:正規のリクエストが誤ってブロックされる可能性があり、チューニングが必要
  • 学習コストがかかる:ルールの意味を理解し、必要に応じてカスタムルールを追加する知識が求められる
  • リージョン制約への対応が必要:CloudFront用WAFは us-east-1 固定のため、Lightsailを東京リージョンに置く場合はCDKのマルチリージョン対応が必要になる
  • WordPress側の追い込みが必要:CloudFrontを挟むこと自体がWordPressにとって想定外の環境になるため、キャッシュ設定とHTTPS判定の両方に手当てが必要(本記事の7章・8章がまさにこれです)

4. 料金の目安

AWS WAFの料金体系は以下の通りです(2026年時点の一般的な価格。最新情報は必ず公式ページを確認してください)。

項目 料金
Web ACL(1つあたり) 約 $5.00 / 月
ルール(1つあたり) 約 $1.00 / 月
リクエスト数 約 $0.60 / 100万リクエスト

これに加えて、CloudFrontの配信料金(データ転送量・リクエスト数に応じた従量課金)が発生します。CloudFrontは無料利用枠(データ転送 1TB/月など)が用意されているため、個人ブログ規模であれば配信料金自体はごく小さく収まることが多いです。

個人ブログ規模での試算例

マネージドルール3つ(Common、WordPress、SQLi)+レート制限ルール1つ、月間リクエスト数100万件程度を想定した場合の概算です。

項目 概算費用
Web ACL $5.00
ルール ×4 $4.00
リクエスト処理(100万件) $0.60
WAF合計 約 $9.60 / 月(≒ 1,500円前後)
CloudFront配信 無料利用枠内であればほぼ$0
Lightsailインスタンス(既存) 別途 $3.50〜
Lightsailロードバランサ(既存) 別途 $18.00〜

Lightsail本体の料金(インスタンス代・LB代)は既存の契約に含まれるため、WAF導入による追加コストは概ね 月額1,000円台後半 で収まるケースが多いでしょう。ただし、DDoS的な大量アクセスを受けた場合はリクエスト課金が増加するため、AWS Budgetsでのコスト監視を併用することを強く推奨します。

実はこの構成でいちばん高いのはLightsailのロードバランサ($18/月〜) です。WAFのためだけにLBを新設するのであれば、「EC2 + ALB + CloudFront」に寄せた方がトータルで安く、かつVPCオリジンなどより堅い選択肢が使えます。すでにLBを使っている構成にWAFを足すのが本記事の想定です(12章で代替案に触れます)。


5. CDKで構築するアーキテクチャ全体像

今回構築する構成要素は以下の通りです。

  • Lightsail Instanceap-northeast-1):WordPress Blueprintを使用。userData でCloudFront対応の設定を自動適用
  • Lightsail Load Balancerap-northeast-1):インスタンスの前段に配置(HTTPSはCloudFront側で終端するため、LB自体はHTTPでよい)
  • CloudFront Distribution:標準の aws-cloudfront + aws-cloudfront-origins を使用し、オリジンにLightsail LBのドメイン名を指定。パスの性質ごとに3種類のキャッシュビヘイビアへ分割
  • AWS WAF (WebACLv2)us-east-1):CloudFront用の制約によりこのリージョンにのみ作成可能
  • マネージドルールグループ:Common Rule Set、WordPress Rule Set、SQLi Rule Set、レート制限ルール
  • WAFログ(CloudWatch Logs)+ CloudWatchアラーム + SNS通知

ポイント①:LightsailリソースはCDKのL2コンストラクトが未対応

Lightsailは執筆時点でCDKのL2コンストラクト(高レベルAPI)が提供されていないため、aws-cdk-lib.aws_lightsailL1コンストラクト(Cfnリソース) である CfnInstanceCfnLoadBalancer を使用します。CloudFront側は標準の aws-cloudfront / aws-cloudfront-origins(L2コンストラクト)で構築します。

ポイント②:Lightsailは東京、WAFはus-east-1という「リージョンのねじれ」

CloudFront用のWebACLは us-east-1 にしか作成できません。一方でLightsailインスタンスは、利用者(日本)に近い ap-northeast-1 に置くのが自然です。

この「リージョンが異なるリソース同士を1つのアプリとして連携させる」ために、CDKの crossRegionReferences というオプションを利用します。これを使うと、CDKが裏側でSSM Parameter Store経由のカスタムリソースを自動生成し、リージョンを跨いだ値(今回はWebACLのARN)を、あたかも同一リージョンのスタック間参照のようにコード上で扱えるようになります。

具体的に何が生成されるかは 11-2 で実物を確認します。

ポイント③:環境依存値は.envで管理する

リージョン、Lightsailのプラン(blueprintIdbundleId)、リソース名、そしてオリジン検証用の秘密値は環境によって変える必要がある値です。これらをコードに直接埋め込むのではなく .envファイル に切り出し、dotenvパッケージで読み込む構成にします。

前提ツール

  • Node.js 18.x以上
  • AWS CDK CLI(npm install -g aws-cdk
  • AWSアカウント・CLI認証設定済み

スタック構成

lightsail-waf-cdk/
├── bin/
│   └── lightsail-waf-cdk.ts          ← 2スタックを生成するエントリーポイント
├── lib/
│   ├── waf-stack.ts                  ← us-east-1: WAF WebACL / ログ / アラーム
│   └── lightsail-cloudfront-stack.ts ← ap-northeast-1: Lightsail + CloudFront
├── scripts/
│   └── configure-wordpress-for-cloudfront.sh ← userDataに埋め込むWordPress設定スクリプト
├── .env                              ← 環境依存値・秘密値(Git管理対象外)
├── .env.example                      ← .envのテンプレート(Git管理対象)
├── .gitignore
├── package.json
├── tsconfig.json
└── cdk.json

6. CDK実装

6-1. 設定ファイル

cdk.json
{
  "app": "npx ts-node --prefer-ts-exts bin/lightsail-waf-cdk.ts"
}

最小構成の cdk.json です。cdk init で生成される context の feature flag 群を持たないため、cdk synth 時に「82 feature flags are not configured」という案内が出ます。動作に支障はありませんが、新規プロジェクトでは cdk initcdk.json をベースにするのが無難です。

package.json
{
  "name": "lightsail-waf-cdk",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build": "tsc",
    "synth": "cdk synth",
    "deploy": "cdk deploy --all",
    "destroy": "cdk destroy --all"
  },
  "dependencies": {
    "aws-cdk-lib": "^2.266.0",
    "constructs": "^10.0.0",
    "dotenv": "^16.4.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "aws-cdk": "^2.1138.0",
    "typescript": "^5.0.0",
    "ts-node": "^10.0.0"
  }
}
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "moduleResolution": "Node",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": [
    "bin/**/*.ts",
    "lib/**/*.ts"
  ]
}
.env.example
CDK_ACCOUNT_ID=123456789012
LIGHTSAIL_REGION=ap-northeast-1
WAF_REGION=us-east-1

# ---- Lightsail Instance設定 ----
LIGHTSAIL_INSTANCE_NAME=wordpress-instance
LIGHTSAIL_BLUEPRINT_ID=wordpress_ls_1_0
LIGHTSAIL_BUNDLE_ID=nano_3_0

# ---- Lightsail Load Balancer設定 ----
LIGHTSAIL_LB_NAME=wordpress-lb
# 初回デプロイ後に aws lightsail get-load-balancer で確認し、2回目のデプロイ前に設定する
LIGHTSAIL_LB_DNS_NAME=

# ---- WAF設定 ----
WAF_WEB_ACL_NAME=wordpress-web-acl
WAF_RATE_LIMIT=2000
# WAFのブロック数急増を通知するメールアドレス
WAF_ALARM_EMAIL=you@example.com

# ---- CloudFront設定 ----
CLOUDFRONT_COMMENT=Lightsail WordPress distribution protected by AWS WAF

# ---- オリジン直アクセス防止 ----
# openssl rand -hex 24 などで生成したランダム値を設定する(絶対にコミットしない)
ORIGIN_VERIFY_SECRET=

.env.example のキー名は AWS_ACCOUNT_ID ではなく CDK_ACCOUNT_ID です。コード側(bin/lightsail-waf-cdk.ts)が requireEnv("CDK_ACCOUNT_ID") を読むため、ここが食い違うと 環境変数 CDK_ACCOUNT_ID が .env に設定されていません で必ず落ちます。地味ですが、記事のコードをコピーして最初に踏むエラーはこれです。

.gitignore
node_modules/
dist/
cdk.out/
*.js
!jest.config.js
*.d.ts

# 環境依存値・秘密値(絶対にコミットしない)
.env
.env.*
!.env.example

6-2. WAFスタック(us-east-1)

lib/waf-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as wafv2 from "aws-cdk-lib/aws-wafv2";
import * as logs from "aws-cdk-lib/aws-logs";
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
import * as cw_actions from "aws-cdk-lib/aws-cloudwatch-actions";
import * as sns from "aws-cdk-lib/aws-sns";
import * as subscriptions from "aws-cdk-lib/aws-sns-subscriptions";

export interface WafStackProps extends cdk.StackProps {
  webAclName: string;
  rateLimit: number;
  alarmEmail?: string; // 通知先メールアドレス(任意)
}

export class WafStack extends cdk.Stack {
  public readonly webAcl: wafv2.CfnWebACL;

  constructor(scope: Construct, id: string, props: WafStackProps) {
    super(scope, id, props);

    this.webAcl = new wafv2.CfnWebACL(this, "WordPressWebAcl", {
      name: props.webAclName,
      scope: "CLOUDFRONT", // CloudFront用WebACLは必ずus-east-1で作成する
      defaultAction: { allow: {} },
      visibilityConfig: {
        sampledRequestsEnabled: true,
        cloudWatchMetricsEnabled: true,
        metricName: props.webAclName,
      },
      rules: [
        {
          name: "AWS-AWSManagedRulesCommonRuleSet",
          priority: 0,
          statement: {
            managedRuleGroupStatement: {
              vendorName: "AWS",
              name: "AWSManagedRulesCommonRuleSet",
            },
          },
          overrideAction: { none: {} },
          visibilityConfig: {
            sampledRequestsEnabled: true,
            cloudWatchMetricsEnabled: true,
            metricName: "CommonRuleSet",
          },
        },
        {
          name: "AWS-AWSManagedRulesWordPressRuleSet",
          priority: 1,
          statement: {
            managedRuleGroupStatement: {
              vendorName: "AWS",
              name: "AWSManagedRulesWordPressRuleSet",
            },
          },
          overrideAction: { none: {} },
          visibilityConfig: {
            sampledRequestsEnabled: true,
            cloudWatchMetricsEnabled: true,
            metricName: "WordPressRuleSet",
          },
        },
        {
          name: "RateLimitRule",
          priority: 2,
          statement: {
            rateBasedStatement: {
              limit: props.rateLimit, // .env の WAF_RATE_LIMIT を使用
              aggregateKeyType: "IP",
            },
          },
          action: { block: {} },
          visibilityConfig: {
            sampledRequestsEnabled: true,
            cloudWatchMetricsEnabled: true,
            metricName: "RateLimitRule",
          },
        },
        {
          name: "AWS-AWSManagedRulesSQLiRuleSet",
          priority: 3, // 既存のRateLimitRule(2)の後
          overrideAction: { none: {} },
          statement: {
            managedRuleGroupStatement: {
              vendorName: "AWS",
              name: "AWSManagedRulesSQLiRuleSet",
            },
          },
          visibilityConfig: {
            sampledRequestsEnabled: true,
            cloudWatchMetricsEnabled: true,
            metricName: "SQLiRuleSet",
          },
        },
      ],
    });

    new cdk.CfnOutput(this, "WebAclArnOutput", {
      value: this.webAcl.attrArn,
      exportName: "WordPressWebAclArn",
    });

    // ------------------------------------------------------------
    // WAFログ設定(CloudWatch Logsへ出力)
    // ロググループ名は "aws-waf-logs-" プレフィックス必須(AWS側の制約)
    // ------------------------------------------------------------
    const wafLogGroup = new logs.LogGroup(this, "WafLogGroup", {
      logGroupName: `aws-waf-logs-${props.webAclName}`,
      retention: logs.RetentionDays.ONE_MONTH,
      removalPolicy: cdk.RemovalPolicy.DESTROY, // 検証用途。本番で残したい場合はRETAINへ変更
    });

    new wafv2.CfnLoggingConfiguration(this, "WafLoggingConfig", {
      resourceArn: this.webAcl.attrArn,
      logDestinationConfigs: [wafLogGroup.logGroupArn],
    });

    // ------------------------------------------------------------
    // CloudWatchアラーム設定(ブロック数急増時に通知)
    // ------------------------------------------------------------
    const alarmTopic = new sns.Topic(this, "WafAlarmTopic", {
      topicName: `${props.webAclName}-alarm-topic`,
    });

    if (props.alarmEmail) {
      alarmTopic.addSubscription(
        new subscriptions.EmailSubscription(props.alarmEmail)
      );
    }

    const blockedRequestsMetric = new cloudwatch.Metric({
      namespace: "AWS/WAFV2",
      metricName: "BlockedRequests",
      dimensionsMap: {
        WebACL: props.webAclName,
        Region: "us-east-1", // CLOUDFRONTスコープのメトリクスはus-east-1固定
        Rule: "ALL",
      },
      period: cdk.Duration.minutes(5),
      statistic: "Sum",
    });

    const blockedRequestsAlarm = new cloudwatch.Alarm(
      this,
      "WafBlockedRequestsAlarm",
      {
        alarmName: `${props.webAclName}-blocked-requests-high`,
        metric: blockedRequestsMetric,
        threshold: 100, // 5分間で100件以上ブロックされたら通知(要件に応じて調整)
        evaluationPeriods: 1,
        comparisonOperator:
          cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
        treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
        alarmDescription: `${props.webAclName} のブロック数が5分間で100件を超えました`,
      }
    );
    blockedRequestsAlarm.addAlarmAction(
      new cw_actions.SnsAction(alarmTopic)
    );

    // RateLimitRule単体の発火状況を監視したい場合(任意)
    const rateLimitBlockedMetric = new cloudwatch.Metric({
      namespace: "AWS/WAFV2",
      metricName: "BlockedRequests",
      dimensionsMap: {
        WebACL: props.webAclName,
        Region: "us-east-1",
        Rule: "RateLimitRule",
      },
      period: cdk.Duration.minutes(5),
      statistic: "Sum",
    });

    new cloudwatch.Alarm(this, "WafRateLimitAlarm", {
      alarmName: `${props.webAclName}-rate-limit-triggered`,
      metric: rateLimitBlockedMetric,
      threshold: 1,
      evaluationPeriods: 1,
      comparisonOperator:
        cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
      treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
      alarmDescription: `${props.webAclName} でレート制限が発動しました`,
    }).addAlarmAction(new cw_actions.SnsAction(alarmTopic));

    new cdk.CfnOutput(this, "WafAlarmTopicArnOutput", {
      value: alarmTopic.topicArn,
      exportName: "WordPressWafAlarmTopicArn",
    });
  }
}

CfnLoggingConfigurationlogDestinationConfigs には wafLogGroup.logGroupArn を渡しています。このARNは末尾に :* が付いた形式(arn:aws:logs:us-east-1:123456789012:log-group:aws-waf-logs-wordpress-web-acl:*)ですが、WAF側はこれをそのまま受け付けます。手で :* を削る必要はありません。

6-3. Lightsail + CloudFrontスタック(ap-northeast-1)

本記事の中核です。7章・8章の内容を反映した最終形を示します。

lib/lightsail-cloudfront-stack.ts
import * as fs from "fs";
import * as path from "path";
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as lightsail from "aws-cdk-lib/aws-lightsail";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";

/**
 * WordPressをCloudFront経由のHTTPS配信に対応させるスクリプトを userData に組み込む。
 * userData は初回起動時にのみ実行されるが、ブループリントのWordPressセットアップと
 * 並行して走るため、wp-config.php が生成されるまで待つ必要がある。
 */
function buildUserData(): string {
  const patchScript = fs.readFileSync(
    path.join(__dirname, "..", "scripts", "configure-wordpress-for-cloudfront.sh"),
    "utf8"
  );

  return [
    "#!/bin/bash",
    "exec >> /var/log/cloudfront-compat.log 2>&1",
    "set -x",
    'echo "=== CloudFront compatibility setup: $(date -Is) ==="',
    "",
    "# ブループリントのWordPressセットアップ完了を待つ(最大10分)",
    "for i in $(seq 1 60); do",
    '  grep -q "WP_HOME" /var/www/wp-config.php 2>/dev/null && break',
    "  sleep 10",
    "done",
    "",
    "cat > /opt/configure-wordpress-for-cloudfront.sh <<'PATCH_EOF'",
    patchScript,
    "PATCH_EOF",
    "",
    "bash /opt/configure-wordpress-for-cloudfront.sh",
    'echo "=== done: $(date -Is) ==="',
  ].join("\n");
}

export interface LightsailCloudFrontStackProps extends cdk.StackProps {
  webAclArn: string;          // us-east-1スタックから受け取るWebACLのARN
  instanceName: string;
  blueprintId: string;
  bundleId: string;
  loadBalancerName: string;
  lbDnsName?: string;         // 初回デプロイ時は未設定(空文字)の場合がある
  cloudFrontComment: string;
  originVerifySecret: string; // オリジン直アクセス防止用のカスタムヘッダー値(.env)
}

export class LightsailCloudFrontStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: LightsailCloudFrontStackProps) {
    super(scope, id, props);

    // -----------------------------
    // 1. Lightsail Instance (WordPress) - ap-northeast-1
    // -----------------------------
    const instance = new lightsail.CfnInstance(this, "WordPressInstance", {
      instanceName: props.instanceName,
      availabilityZone: `${this.region}a`, // ap-northeast-1a
      blueprintId: props.blueprintId,       // .env の LIGHTSAIL_BLUEPRINT_ID
      bundleId: props.bundleId,             // .env の LIGHTSAIL_BUNDLE_ID
      // 初回起動時に wp-config.php をCloudFront対応にパッチする
      userData: buildUserData(),
      // CloudFront経由のみを許可したいので、Lightsail側FirewallはHTTP/HTTPS/SSHのみ許可
      networking: {
        ports: [
          { fromPort: 80, toPort: 80, protocol: "tcp", cidrs: ["0.0.0.0/0"] },
          { fromPort: 443, toPort: 443, protocol: "tcp", cidrs: ["0.0.0.0/0"] },
          {
            fromPort: 22, toPort: 22, protocol: "tcp",
            // SSHはLightsailコンソールのブラウザSSHからのみ許可する
            cidrListAliases: ["lightsail-connect", "lightsail-setup-ipv4"],
            accessType: "public",
          },
        ],
      },
    });

    // -----------------------------
    // 2. Lightsail Load Balancer - ap-northeast-1
    // -----------------------------
    const lb = new lightsail.CfnLoadBalancer(this, "WordPressLB", {
      loadBalancerName: props.loadBalancerName, // .env の LIGHTSAIL_LB_NAME
      instancePort: 80,
      attachedInstances: [props.instanceName],
      // "/" はブループリントのApacheがhttpsへ301リダイレクトするため
      // Instance.ResponseCodeMismatch で永久にunhealthyになる。
      // userDataでリダイレクト対象外にした静的ファイルを使う。
      healthCheckPath: "/lb-healthcheck.html",
    });
    lb.addDependency(instance);

    // -----------------------------
    // 3. CloudFront Distribution
    //    us-east-1で作成したWebACLをリージョンを跨いでアタッチ
    // -----------------------------

    // cdk.Names.uniqueId(this) をStack自身に対して呼ぶと構築ID("LightsailCloudFrontStack")が
    // そのまま返るため、末尾16文字は "lCloudFrontStack" という推測可能な固定値になってしまう。
    // オリジン直アクセス防止として機能させるには、.env の秘密値を使う必要がある。
    const customHeaderValue = props.originVerifySecret;

    // .env の LIGHTSAIL_LB_DNS_NAME が未設定の場合はダミー値でデプロイし、
    // 初回デプロイ後にDNS名を確認して .env を更新、再デプロイする
    const lbDnsName = props.lbDnsName && props.lbDnsName.length > 0
      ? props.lbDnsName
      : "dummy-origin.example.com";

    // オリジンは1つだけ生成し、全ビヘイビアで共有する。
    // ビヘイビアごとに new HttpOrigin(...) すると同一オリジンが重複定義されてしまう。
    const origin = new origins.HttpOrigin(lbDnsName, {
      protocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY,
      customHeaders: {
        "X-Origin-Verify": customHeaderValue,
      },
    });

    // すべてのビヘイビアで共通のオリジンリクエストポリシー。
    // ALL_VIEWER ではなく ALL_VIEWER_AND_CLOUDFRONT_2022 を使うのが重要。
    // WordPressにHTTPSであることを伝える CloudFront-Forwarded-Proto は
    // CloudFrontが生成するヘッダーなので、ALL_VIEWER では転送されない。
    const originRequestPolicy =
      cloudfront.OriginRequestPolicy.ALL_VIEWER_AND_CLOUDFRONT_2022;

    // 動的HTML用のキャッシュポリシー。
    // ログインCookieをキャッシュキーに含めることで、ログイン中のページが
    // 未ログインユーザーに配信されるのを防ぐ。
    const dynamicCachePolicy = new cloudfront.CachePolicy(this, "WordPressDynamicCachePolicy", {
      cachePolicyName: `${props.instanceName}-wp-dynamic`,
      comment: "WordPress dynamic HTML: vary by login cookies and viewer protocol",
      minTtl: cdk.Duration.seconds(0),
      defaultTtl: cdk.Duration.minutes(5),
      maxTtl: cdk.Duration.hours(1),
      cookieBehavior: cloudfront.CacheCookieBehavior.allowList(
        "wordpress_logged_in_*",
        "wordpress_sec_*",
        "wp-settings-*",
        "comment_author_*"
      ),
      // 閲覧者の実プロトコルをキャッシュキーに含める(http/https でHTMLが変わるため)
      headerBehavior: cloudfront.CacheHeaderBehavior.allowList("CloudFront-Forwarded-Proto"),
      queryStringBehavior: cloudfront.CacheQueryStringBehavior.all(),
      enableAcceptEncodingGzip: true,
      enableAcceptEncodingBrotli: true,
    });

    // キャッシュしてはいけないパス(管理画面・ログイン・REST API・cron)
    const noCacheBehavior: cloudfront.BehaviorOptions = {
      origin,
      viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
      originRequestPolicy,
      allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
    };

    // 静的アセット(テーマ・プラグインのCSS/JS/画像)は積極的にキャッシュする
    const staticBehavior: cloudfront.BehaviorOptions = {
      origin,
      viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
      originRequestPolicy,
      allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
      compress: true,
    };

    const distribution = new cloudfront.Distribution(this, "WordPressDistribution", {
      comment: props.cloudFrontComment, // .env の CLOUDFRONT_COMMENT
      webAclId: props.webAclArn,        // us-east-1スタックから渡されたARN
      defaultBehavior: {
        origin,
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        cachePolicy: dynamicCachePolicy,
        originRequestPolicy,
        allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
        compress: true,
      },
      additionalBehaviors: {
        "/wp-admin/*": noCacheBehavior,
        "/wp-login.php": noCacheBehavior,
        "/wp-cron.php": noCacheBehavior,
        "/wp-json/*": noCacheBehavior,
        "/xmlrpc.php": noCacheBehavior,
        "/wp-content/*": staticBehavior,
        "/wp-includes/*": staticBehavior,
      },
      priceClass: cloudfront.PriceClass.PRICE_CLASS_200,
    });

    // -----------------------------
    // Outputs
    // -----------------------------
    new cdk.CfnOutput(this, "LightsailLoadBalancerHint", {
      value: "aws lightsail get-load-balancer でDNS名を確認し、.envのLIGHTSAIL_LB_DNS_NAMEに設定して再デプロイしてください",
    });

    new cdk.CfnOutput(this, "CloudFrontDomainName", {
      value: distribution.distributionDomainName,
      description: "CloudFront経由のアクセスURL(こちらを利用してください)",
    });

    // 秘密値そのものは Outputs に出さない(describe-stacks 権限があれば誰でも読めてしまう)。
    // オリジン側の Apache に設定する値は .env の ORIGIN_VERIFY_SECRET を参照する。
    new cdk.CfnOutput(this, "OriginVerifyHeaderName", {
      value: "X-Origin-Verify",
      description: "オリジン側で検証するカスタムヘッダー名(値は .env の ORIGIN_VERIFY_SECRET)",
    });
  }
}

6-4. エントリーポイント

bin/lightsail-waf-cdk.ts
#!/usr/bin/env node
import * as dotenv from "dotenv";
dotenv.config(); // .env を読み込む(最初に実行する必要がある)

import * as cdk from "aws-cdk-lib";
import { WafStack } from "../lib/waf-stack";
import { LightsailCloudFrontStack } from "../lib/lightsail-cloudfront-stack";

// -----------------------------
// .env から必須の値を取得するヘルパー
// -----------------------------
function requireEnv(key: string): string {
  const value = process.env[key];
  if (!value) {
    throw new Error(`環境変数 ${key} が .env に設定されていません`);
  }
  return value;
}

const app = new cdk.App();

const account = requireEnv("CDK_ACCOUNT_ID");
const lightsailRegion = requireEnv("LIGHTSAIL_REGION");
const wafRegion = requireEnv("WAF_REGION");

// ① WAF用スタック:必ず us-east-1(.envで指定)に作成
const wafStack = new WafStack(app, "WafStack", {
  env: { account, region: wafRegion },
  crossRegionReferences: true, // ← リージョンを跨いだ参照を許可
  webAclName: requireEnv("WAF_WEB_ACL_NAME"),
  rateLimit: Number(process.env.WAF_RATE_LIMIT ?? "2000"),
  alarmEmail: process.env.WAF_ALARM_EMAIL,
});

// ② Lightsail + CloudFront用スタック:.envで指定したリージョンに作成
const lightsailStack = new LightsailCloudFrontStack(app, "LightsailCloudFrontStack", {
  env: { account, region: lightsailRegion },
  crossRegionReferences: true, // ← こちらにも同じ設定が必要
  webAclArn: wafStack.webAcl.attrArn, // スタックを跨いで直接プロパティ参照が可能
  instanceName: requireEnv("LIGHTSAIL_INSTANCE_NAME"),
  blueprintId: requireEnv("LIGHTSAIL_BLUEPRINT_ID"),
  bundleId: requireEnv("LIGHTSAIL_BUNDLE_ID"),
  loadBalancerName: requireEnv("LIGHTSAIL_LB_NAME"),
  lbDnsName: process.env.LIGHTSAIL_LB_DNS_NAME, // 初回は未設定でもOK
  cloudFrontComment: process.env.CLOUDFRONT_COMMENT ?? "Lightsail WordPress distribution protected by AWS WAF",
  originVerifySecret: requireEnv("ORIGIN_VERIFY_SECRET"),
});

// 依存関係を明示(WAFスタックが先にデプロイされるように)
lightsailStack.addDependency(wafStack);

6-5. userDataに埋め込むWordPress設定スクリプト

scripts/configure-wordpress-for-cloudfront.sh
#!/usr/bin/env bash
#
# Lightsail WordPress ブループリント (wordpress_ls_1_0) を
# CloudFront 経由の HTTPS 配信に対応させる。冪等(何度実行しても結果は同じ)。
#
#   1) wp-config.php の WP_HOME / WP_SITEURL を閲覧者の実プロトコルに追従させる
#   2) ロードバランサのヘルスチェック用パスを http→https リダイレクトから除外する
#
#   実行方法: sudo bash configure-wordpress-for-cloudfront.sh
#
set -uo pipefail

WP_CONFIG=/var/www/wp-config.php
VHOST=/etc/apache2/sites-available/000-default.conf
DOCROOT=/var/www/html
HEALTHCHECK_FILE=lb-healthcheck.html

# ------------------------------------------------------------------
# 1) wp-config.php: WP_HOME / WP_SITEURL のスキームを動的にする
# ------------------------------------------------------------------
[ -f "$WP_CONFIG" ] || { echo "ERROR: $WP_CONFIG が見つかりません" >&2; exit 1; }

WP_BACKUP="${WP_CONFIG}.bak.$(date +%Y%m%d%H%M%S)"
cp -p "$WP_CONFIG" "$WP_BACKUP"
echo "backup: $WP_BACKUP"

python3 - "$WP_CONFIG" <<'PY'
import sys

path = sys.argv[1]
src  = open(path, encoding='utf-8').read()

MARK = "/* --- CloudFront compatibility (managed by CDK userData) --- */"
if MARK in src:
    print("wp-config: skip (既に適用済み)")
    sys.exit(0)

# ブループリント既定の定義。'http://' がハードコードされているのが問題の原因。
OLD_HOME = "define( 'WP_HOME', 'http://' . $_SERVER['HTTP_HOST'] . '/' );"
OLD_SITE = "define( 'WP_SITEURL', 'http://' . $_SERVER['HTTP_HOST'] . '/' );"

if OLD_HOME not in src or OLD_SITE not in src:
    print("ERROR: 想定した WP_HOME / WP_SITEURL の定義が見つかりません。"
          "ブループリントのバージョンが変わった可能性があります。", file=sys.stderr)
    sys.exit(1)

BLOCK = MARK + r"""
/*
 * CloudFront が付与する CloudFront-Forwarded-Proto から閲覧者の実プロトコルを判定する。
 *
 * 注意: Lightsail ロードバランサが付与する X-Forwarded-Proto は
 *       「CloudFront → LB 間」のプロトコル(= http)を指すため、ここでは使えない。
 */
if ( isset( $_SERVER['HTTP_CLOUDFRONT_FORWARDED_PROTO'] )
     && 'https' === $_SERVER['HTTP_CLOUDFRONT_FORWARDED_PROTO'] ) {
    $_SERVER['HTTPS']       = 'on';
    $_SERVER['SERVER_PORT'] = 443;
}

$ls_scheme = ( ! empty( $_SERVER['HTTPS'] ) && 'off' !== $_SERVER['HTTPS'] ) ? 'https' : 'http';

/* 管理画面は常にHTTPSで扱う */
define( 'FORCE_SSL_ADMIN', 'https' === $ls_scheme );
/* --- end CloudFront compatibility --- */

"""

src = src.replace(
    OLD_HOME, BLOCK + "define( 'WP_HOME', $ls_scheme . '://' . $_SERVER['HTTP_HOST'] . '/' );", 1)
src = src.replace(
    OLD_SITE, "define( 'WP_SITEURL', $ls_scheme . '://' . $_SERVER['HTTP_HOST'] . '/' );", 1)

open(path, 'w', encoding='utf-8').write(src)
print("wp-config: applied")
PY

# 構文エラーがあればここで検出する(壊れた wp-config.php を残さない)
if ! php -l "$WP_CONFIG"; then
  echo "ERROR: PHP構文エラー。バックアップから復元します" >&2
  cp -p "$WP_BACKUP" "$WP_CONFIG"
  exit 1
fi

# ------------------------------------------------------------------
# 2) ヘルスチェック用パスを http→https リダイレクトの対象外にする
#
# ブループリントの vhost は CloudFront-Forwarded-Proto / X-Forwarded-Proto の
# どちらも https でないリクエストを 301 で https に飛ばす。
# Lightsail LB のヘルスチェックはどちらのヘッダーも付けないため 301 を受け取り、
# Instance.ResponseCodeMismatch で永久に unhealthy になる。
# ------------------------------------------------------------------
printf 'OK\n' > "${DOCROOT}/${HEALTHCHECK_FILE}"
chown www-data:www-data "${DOCROOT}/${HEALTHCHECK_FILE}"

if grep -q "${HEALTHCHECK_FILE}" "$VHOST"; then
  echo "vhost: skip (既に適用済み)"
else
  cp -p "$VHOST" "${VHOST}.bak.$(date +%Y%m%d%H%M%S)"
  # RewriteRule の直前に除外条件を挿入する
  sed -i "s|^\(\s*\)RewriteRule \^ https://|\1RewriteCond %{REQUEST_URI} !^/${HEALTHCHECK_FILE}\$\n&|" "$VHOST"
  if ! apache2ctl configtest 2>&1 | grep -q "Syntax OK"; then
    echo "ERROR: Apache設定の構文エラー" >&2
    exit 1
  fi
  echo "vhost: applied"
fi

# ------------------------------------------------------------------
# 反映。userData から呼ばれる場合、この時点では Apache がまだ
# 起動していないことがあるため、失敗してもスクリプト全体は止めない。
# ------------------------------------------------------------------
if systemctl is-active --quiet apache2; then
  systemctl reload apache2 && echo "apache: reloaded"
else
  echo "apache: 未起動のため reload をスキップ(初回起動時に設定が読み込まれる)"
fi

echo "done"

6-6. 実装のポイント

  1. .envdotenv.config()で最初に読み込む
    bin/lightsail-waf-cdk.ts の先頭で読み込むことで、以降のコード全体で process.env から値を取得できるようにしています。

  2. 必須項目はrequireEnvでチェックする
    .envの設定漏れによる意図しない挙動(空文字でのデプロイなど)を防ぐため、必須項目が未設定の場合は明確なエラーで停止するようにしています。特に ORIGIN_VERIFY_SECRET は空でデプロイされると防御が無効化されるので、必ず requireEnv を通します。

  3. crossRegionReferences: true は両方のスタックに必要
    出力側(WafStack)・参照側(LightsailCloudFrontStack)の双方に設定することで、CDKが自動的にSSM Parameter経由の橋渡し用カスタムリソースを生成します。

  4. Lightsail LBのDNS名は2段階デプロイで.envに反映する
    CfnLoadBalancer の属性からはDNS名を直接取得できないため、1回目はダミーのオリジンでデプロイし、DNS名を取得して .env を更新してから2回目をデプロイします(9-5・9-6)。


7. なぜキャッシュビヘイビアを分割するのか

「CloudFront + WordPress」でいちばん危険なのがここです。キャッシュ設定を分けずにデプロイすると、ログイン中のユーザー向けに生成されたHTMLが未ログインの訪問者に配信され得ます。

7-1. 素直に書いたコードで起きること

defaultBehaviorCachePolicy.CACHING_OPTIMIZED を指定して全パスに同じ設定を適用した場合を考えます。このマネージドポリシーの実体を確認しましょう。

aws cloudfront get-cache-policy --id 658327ea-f89d-4fab-a63d-7e88639e58f6 \
  --query "CachePolicy.CachePolicyConfig.{Name:Name,DefaultTTL:DefaultTTL,Cookies:ParametersInCacheKeyAndForwardedToOrigin.CookiesConfig}"
{
  "Name": "Managed-CachingOptimized",
  "DefaultTTL": 86400,
  "Cookies": { "CookieBehavior": "none" }
}

ポイントは2つです。

  • CookieBehavior: none … Cookieがキャッシュキーに一切含まれない
  • DefaultTTL: 86400 … オリジンが Cache-Control を返さない場合、24時間キャッシュされる

そしてWordPressのフロントエンドは、ログイン中のユーザーにも Cache-Control を送りません。実際にトップページを確認すると次の通りです。

curl -s -D - -o /dev/null "https://<CloudFrontドメイン>/" | grep -iE 'x-cache|^age|cache-control'
x-cache: Hit from cloudfront
age: 657

Cache-Control が無いため DefaultTTL の24時間が適用され、657秒前のレスポンスがそのまま返っています。ここに CookieBehavior: none が組み合わさると、ログイン中の管理者が表示したページ(管理バー付き・編集リンク付き)がキャッシュされ、未ログインの訪問者に配信され得ます。逆方向も同様で、キャッシュ状態がユーザー間で混ざります。

これは表示崩れの問題ではなく、ログイン状態のHTMLが第三者に配信されうる情報漏洩リスクです。CloudFrontをWordPressの前に置く場合、キャッシュビヘイビアの分割は「あると良い最適化」ではなく必須の設定です。

7-2. 設計方針

パスの性質ごとに3種類に分けます。

分類 対象パス キャッシュポリシー 理由
キャッシュ禁止 /wp-admin/*, /wp-login.php, /wp-cron.php, /wp-json/*, /xmlrpc.php CACHING_DISABLED 認証・状態変更を伴うため一切キャッシュしない
静的アセット /wp-content/*, /wp-includes/* CACHING_OPTIMIZED CSS/JS/画像。Cookieに依存しないので積極的にキャッシュ
動的HTML(既定) 上記以外すべて カスタムポリシー ログインCookieをキャッシュキーに含めて分離する

動的HTML用のカスタムキャッシュポリシーが本題です。ポイントは2つあります。

① ログインCookieだけをキャッシュキーに含める

CacheCookieBehavior.all() にすると、広告計測やアナリティクスの無関係なCookieひとつひとつで別のキャッシュエントリが作られ、キャッシュヒット率が壊滅します(キャッシュ分裂)。WordPressのログイン判定に使われるCookieだけをワイルドカードで許可します。

cookieBehavior: cloudfront.CacheCookieBehavior.allowList(
  "wordpress_logged_in_*",  // ログイン判定の本体
  "wordpress_sec_*",        // HTTPS環境での認証Cookie
  "wp-settings-*",          // 管理画面のUI設定
  "comment_author_*"        // コメント投稿者名の自動入力
),

allowList にワイルドカード(*)が使えるのが重要です。WordPressのログインCookieは wordpress_logged_in_<ハッシュ> という名前になるため、完全一致では指定できません。

CloudFront-Forwarded-Proto をキャッシュキーに含める

次の8章で WordPress が閲覧者のプロトコルに応じて http:// / https:// を出し分けるようにするため、HTMLの内容がプロトコルによって変わります。したがってキャッシュキーに含める必要があります。

7-3. オリジンリクエストポリシーの落とし穴

キャッシュポリシーと並んで、オリジンリクエストポリシーの選択が重要です。

// ❌ これでは CloudFront-Forwarded-Proto がオリジンに届かない
const originRequestPolicy = cloudfront.OriginRequestPolicy.ALL_VIEWER;

// ✅ CloudFrontが生成するヘッダーまで転送する
const originRequestPolicy = cloudfront.OriginRequestPolicy.ALL_VIEWER_AND_CLOUDFRONT_2022;

ALL_VIEWER は「閲覧者が送ってきたヘッダーをすべて転送する」ポリシーです。CloudFront-Forwarded-Proto は閲覧者ではなく CloudFrontが生成するヘッダーなので、ALL_VIEWER では転送されません。ALL_VIEWER_AND_CLOUDFRONT_2022 を使う必要があります。

この1行の違いで8章の対応が丸ごと無効になるので、ここは覚えておく価値があります。

7-4. 実装上の注意:オリジンは1つだけ作る

// ✅ オリジンを1つ生成して全ビヘイビアで共有する
const origin = new origins.HttpOrigin(lbDnsName, { /* ... */ });

const noCacheBehavior: cloudfront.BehaviorOptions = { origin, /* ... */ };
const staticBehavior:  cloudfront.BehaviorOptions = { origin, /* ... */ };

ビヘイビアごとに new origins.HttpOrigin(...) を呼ぶと、CloudFrontのテンプレート上で同一オリジンが重複定義されてデプロイエラーになります。BehaviorOptions をオブジェクトとして切り出し、同じ origin インスタンスを渡すのがポイントです。

synth後のテンプレートでオリジンが1つになっていることを確認できます。

node -e "
const t=require('./cdk.out/LightsailCloudFrontStack.template.json');
const d=Object.values(t.Resources).find(r=>r.Type==='AWS::CloudFront::Distribution');
console.log('origins   :', d.Properties.DistributionConfig.Origins.length);
console.log('behaviors :', d.Properties.DistributionConfig.CacheBehaviors.map(b=>b.PathPattern).join(' '));
"
origins   : 1
behaviors : /wp-admin/* /wp-login.php /wp-cron.php /wp-json/* /xmlrpc.php /wp-content/* /wp-includes/*

実装コードは 6-3 に掲載済みです。動作確認は 10-7 で行います。


8. WordPress側のCloudFront対応設定

CloudFrontを前に置くと、WordPressから見た環境が「HTTPで来ているのに、閲覧者にはHTTPSで見えている」というねじれた状態になります。ここを直さないと、リダイレクトの往復と混在コンテンツが発生します。

8-1. まずブループリントの構成を把握する

wordpress_ls_1_0 ブループリントは Bitnami ではありません。 Web上の「Lightsail WordPress」の記事はBitnami版(/opt/bitnami/.../opt/bitnami/ctlscript.sh~/bitnami_application_password)を前提にしたものが多く残っていますが、wordpress_ls_1_0 ではそれらのパスはすべて存在しません。実際の構成は以下の通りです。

項目
OS Debian GNU/Linux 12 (bookworm)
Webサーバー Debian標準の apache2 2.4系(/etc/apache2/
ドキュメントルート /var/www/html
wp-config.php /var/www/wp-config.php(ドキュメントルートの1つ上)
WP-CLI /usr/local/bin/wp(プリインストール済み)
チューニングスクリプト /opt/lightsail/
管理者資格情報 /opt/aws/wordpress/credentials.log(ユーザー名は user
再起動 sudo systemctl restart apache2
SSHユーザー名 admin

8-2. 何が壊れているのか

CloudFront経由で /wp-admin/ にアクセスすると、次のレスポンスが返ります。

curl -s -D - -o /dev/null "https://<CloudFrontドメイン>/wp-admin/" | grep -iE '^(HTTP/|location)'
HTTP/2 302
location: http://dcle77ufw1j5w.cloudfront.net/wp-login.php?redirect_to=http%3A%2F%2F...

リダイレクト先が http:// になっています。 CloudFrontの viewerProtocolPolicyREDIRECT_TO_HTTPS なので、この http:// へのアクセスは再び https:// へ301され、WordPressは再び http:// を返す——という往復が発生します。加えて、生成されるHTML内のCSS/JS/画像URLもすべて http:// になるため、ブラウザが混在コンテンツとしてブロックします。

原因は wp-config.php にあります。

/var/www/wp-config.php(ブループリント既定)
define( 'WP_HOME', 'http://' . $_SERVER['HTTP_HOST'] . '/' );
define( 'WP_SITEURL', 'http://' . $_SERVER['HTTP_HOST'] . '/' );

ホスト名は $_SERVER['HTTP_HOST'] から動的に取っているので、CloudFrontのドメインは正しく反映されます。しかし スキームが http:// でハードコードされています。

wp option get siteurl で確認すると http://127.0.0.1 が返りますが、これは WP_HOME / WP_SITEURL の定義が優先されるため実際には使われません。DB側の値を wp search-replace などで書き換えても直りません。 直すべきは wp-config.php です。

8-3. どのヘッダーを見るべきか(X-Forwarded-Proto は使えない)

「リバースプロキシ配下のHTTPS判定は X-Forwarded-Proto を見る」というのが定石ですが、この構成では通用しません。オリジンに届く実際のヘッダーを確認します。

<?php // ドキュメントルートに置いて curl でアクセスし、確認後に必ず削除する
header("Content-Type: text/plain");
foreach ($_SERVER as $k => $v) {
    if (str_starts_with($k, "HTTP_")) echo "$k=$v\n";
}

CloudFront経由(https://)でアクセスした結果:

HTTP_X_FORWARDED_PROTO=http           ← ★ http のまま
HTTP_CLOUDFRONT_FORWARDED_PROTO=https ← ★ これが閲覧者の実プロトコル
HTTP_HOST=dcle77ufw1j5w.cloudfront.net

X-Forwarded-ProtoLightsailロードバランサが付与するもので、「CloudFront → LB」間のプロトコルを指します。この区間は OriginProtocolPolicy.HTTP_ONLY なので http です。閲覧者との間のプロトコルを知りたい場合に使ってはいけません。

見るべきは CloudFront が付与する CloudFront-Forwarded-Proto です。これが 7-3 で ALL_VIEWER_AND_CLOUDFRONT_2022 を選んだ理由でもあります。

8-4. ロードバランサのヘルスチェックが永久にunhealthyになる罠

ブループリントのvhostを確認すると、Apacheレベルでは既にCloudFrontが考慮されています。

/etc/apache2/sites-available/000-default.conf(ブループリント既定)
<VirtualHost *:80>
	DocumentRoot /var/www/html
	RewriteEngine On
	RewriteCond %{HTTP:CloudFront-Forwarded-Proto} !=https
	RewriteCond %{HTTP:X-Forwarded-Proto} !=https
	RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301,NE]
</VirtualHost>

「どちらのヘッダーも https でなければ https へ301する」という設定です。CloudFront経由のリクエストは CloudFront-Forwarded-Proto: https を持つのでリダイレクトされず、正常に動作します。

問題はロードバランサのヘルスチェックです。 ヘルスチェックはLB自身が発行するリクエストで、どちらのヘッダーも付きません。したがって必ず 301 が返り、healthCheckPath: "/" を指定していると次の状態になります。

aws lightsail get-load-balancer --load-balancer-name wordpress-lb \
  --region ap-northeast-1 --query "loadBalancer.instanceHealthSummary"
[
  {
    "instanceName": "wordpress-instance",
    "instanceHealth": "unhealthy",
    "instanceHealthReason": "Instance.ResponseCodeMismatch"
  }
]

永久に unhealthy のままになります。

インスタンスが1台だけの場合、Lightsailロードバランサはunhealthyでも転送を続けるためサイトは表示され続けます。そのため気づきにくいのですが、この状態ではLBのヘルスチェックが監視として何の役にも立ちません。インスタンスを増やしたときに初めて障害として顕在化します。

対策として、リダイレクト対象外の静的ファイル(/lb-healthcheck.html)を用意し、そこをヘルスチェックパスにします。

8-5. 設定スクリプト

上記2点(wp-config.php のスキーム対応+ヘルスチェックパスの除外)をまとめた冪等なスクリプトを用意します。

scripts/configure-wordpress-for-cloudfront.sh
#!/usr/bin/env bash
#
# Lightsail WordPress ブループリント (wordpress_ls_1_0) を
# CloudFront 経由の HTTPS 配信に対応させる。冪等(何度実行しても結果は同じ)。
#
#   1) wp-config.php の WP_HOME / WP_SITEURL を閲覧者の実プロトコルに追従させる
#   2) ロードバランサのヘルスチェック用パスを http→https リダイレクトから除外する
#
#   実行方法: sudo bash configure-wordpress-for-cloudfront.sh
#
set -uo pipefail

WP_CONFIG=/var/www/wp-config.php
VHOST=/etc/apache2/sites-available/000-default.conf
DOCROOT=/var/www/html
HEALTHCHECK_FILE=lb-healthcheck.html

# ------------------------------------------------------------------
# 1) wp-config.php: WP_HOME / WP_SITEURL のスキームを動的にする
# ------------------------------------------------------------------
[ -f "$WP_CONFIG" ] || { echo "ERROR: $WP_CONFIG が見つかりません" >&2; exit 1; }

WP_BACKUP="${WP_CONFIG}.bak.$(date +%Y%m%d%H%M%S)"
cp -p "$WP_CONFIG" "$WP_BACKUP"
echo "backup: $WP_BACKUP"

python3 - "$WP_CONFIG" <<'PY'
import sys

path = sys.argv[1]
src  = open(path, encoding='utf-8').read()

MARK = "/* --- CloudFront compatibility (managed by CDK userData) --- */"
if MARK in src:
    print("wp-config: skip (既に適用済み)")
    sys.exit(0)

# ブループリント既定の定義。'http://' がハードコードされているのが問題の原因。
OLD_HOME = "define( 'WP_HOME', 'http://' . $_SERVER['HTTP_HOST'] . '/' );"
OLD_SITE = "define( 'WP_SITEURL', 'http://' . $_SERVER['HTTP_HOST'] . '/' );"

if OLD_HOME not in src or OLD_SITE not in src:
    print("ERROR: 想定した WP_HOME / WP_SITEURL の定義が見つかりません。"
          "ブループリントのバージョンが変わった可能性があります。", file=sys.stderr)
    sys.exit(1)

BLOCK = MARK + r"""
/*
 * CloudFront が付与する CloudFront-Forwarded-Proto から閲覧者の実プロトコルを判定する。
 *
 * 注意: Lightsail ロードバランサが付与する X-Forwarded-Proto は
 *       「CloudFront → LB 間」のプロトコル(= http)を指すため、ここでは使えない。
 */
if ( isset( $_SERVER['HTTP_CLOUDFRONT_FORWARDED_PROTO'] )
     && 'https' === $_SERVER['HTTP_CLOUDFRONT_FORWARDED_PROTO'] ) {
    $_SERVER['HTTPS']       = 'on';
    $_SERVER['SERVER_PORT'] = 443;
}

$ls_scheme = ( ! empty( $_SERVER['HTTPS'] ) && 'off' !== $_SERVER['HTTPS'] ) ? 'https' : 'http';

/* 管理画面は常にHTTPSで扱う */
define( 'FORCE_SSL_ADMIN', 'https' === $ls_scheme );
/* --- end CloudFront compatibility --- */

"""

src = src.replace(
    OLD_HOME, BLOCK + "define( 'WP_HOME', $ls_scheme . '://' . $_SERVER['HTTP_HOST'] . '/' );", 1)
src = src.replace(
    OLD_SITE, "define( 'WP_SITEURL', $ls_scheme . '://' . $_SERVER['HTTP_HOST'] . '/' );", 1)

open(path, 'w', encoding='utf-8').write(src)
print("wp-config: applied")
PY

# 構文エラーがあればここで検出する(壊れた wp-config.php を残さない)
if ! php -l "$WP_CONFIG"; then
  echo "ERROR: PHP構文エラー。バックアップから復元します" >&2
  cp -p "$WP_BACKUP" "$WP_CONFIG"
  exit 1
fi

# ------------------------------------------------------------------
# 2) ヘルスチェック用パスを http→https リダイレクトの対象外にする
#
# ブループリントの vhost は CloudFront-Forwarded-Proto / X-Forwarded-Proto の
# どちらも https でないリクエストを 301 で https に飛ばす。
# Lightsail LB のヘルスチェックはどちらのヘッダーも付けないため 301 を受け取り、
# Instance.ResponseCodeMismatch で永久に unhealthy になる。
# ------------------------------------------------------------------
printf 'OK\n' > "${DOCROOT}/${HEALTHCHECK_FILE}"
chown www-data:www-data "${DOCROOT}/${HEALTHCHECK_FILE}"

if grep -q "${HEALTHCHECK_FILE}" "$VHOST"; then
  echo "vhost: skip (既に適用済み)"
else
  cp -p "$VHOST" "${VHOST}.bak.$(date +%Y%m%d%H%M%S)"
  # RewriteRule の直前に除外条件を挿入する
  sed -i "s|^\(\s*\)RewriteRule \^ https://|\1RewriteCond %{REQUEST_URI} !^/${HEALTHCHECK_FILE}\$\n&|" "$VHOST"
  if ! apache2ctl configtest 2>&1 | grep -q "Syntax OK"; then
    echo "ERROR: Apache設定の構文エラー" >&2
    exit 1
  fi
  echo "vhost: applied"
fi

# ------------------------------------------------------------------
# 反映。userData から呼ばれる場合、この時点では Apache がまだ
# 起動していないことがあるため、失敗してもスクリプト全体は止めない。
# ------------------------------------------------------------------
if systemctl is-active --quiet apache2; then
  systemctl reload apache2 && echo "apache: reloaded"
else
  echo "apache: 未起動のため reload をスキップ(初回起動時に設定が読み込まれる)"
fi

echo "done"

スクリプト側の実装で意識した点は4つです。

  1. set -euo pipefail ではなく set -uo pipefail-e を外す)
    最後の systemctl reload apache2 は、userData から呼ばれる場合 Apache がまだ起動しておらず失敗します。-e があるとここでスクリプト全体が異常終了します。
  2. マーカーコメントによる冪等化
    /* --- CloudFront compatibility ... --- */ の有無で適用済みかを判定し、二重適用を防いでいます。
  3. sed ではなく python3 で書き換える
    wp-config.php の対象行は $_SERVER['HTTP_HOST'] などを含み、sed のエスケープが極めて煩雑です。単純な文字列置換に落とすため python3 を使い、期待する行が見つからなければエラー終了させています(ブループリントの仕様変更を黙って見逃さないため)。
  4. php -l による検証と自動ロールバック
    wp-config.php を壊すとサイト全体が落ちるため、構文チェックに失敗したらタイムスタンプ付きバックアップから自動復元します。

8-6. CDKのuserDataで自動適用する

このスクリプトを手作業でSSH実行してもよいのですが、それでは cdk destroy して作り直した瞬間に設定が消えます。Lightsailの CfnInstanceuserData をサポートしているので、CDK側に組み込んでIaC管理下に置きます。実装は 6-3 の buildUserData() です。

userData には2つの重要な注意点があります。

① ブループリントのセットアップ完了を待つ必要がある

userData は cloud-init によって初回起動時に実行されますが、ブループリント自身のWordPressセットアップと並行して走りますwp-config.php の生成を待たずに実行すると失敗します。そのため WP_HOME の文字列が現れるまで最大10分ポーリングしています。

# ブループリントのWordPressセットアップ完了を待つ(最大10分)
for i in $(seq 1 60); do
  grep -q "WP_HOME" /var/www/wp-config.php 2>/dev/null && break
  sleep 10
done

userData を後から変更しても既存インスタンスには適用されない

cdk diff 上は [~] AWS::Lightsail::Instance という通常の更新として表示され、インスタンスの再作成も起きません。しかし userData初回起動時にしか実行されないため、実機には何も反映されません

既存のインスタンスに反映したい場合は、scripts/configure-wordpress-for-cloudfront.sh をSSHで手動実行してください(冪等なので何度実行しても安全です)。「cdk deploy が成功したのに設定が反映されない」というのは、userData の性質による正常な挙動です。

動作確認は 10-8・10-9 で行います。


9. デプロイ手順

9-1. 事前準備の確認

# Node.js のバージョン確認(18.x 以上)
node -v

# AWS CLI の認証確認(表示される Account が .env の CDK_ACCOUNT_ID と一致すること)
aws sts get-caller-identity
# 依存パッケージのインストール
npm install

9-2. .env の作成

cp .env.example .env

# オリジン検証用の秘密値を生成する
openssl rand -hex 24

.env を開き、以下を自分の環境の値に書き換えます。

キー 設定内容
CDK_ACCOUNT_ID aws sts get-caller-identity で確認した12桁のアカウントID
LIGHTSAIL_INSTANCE_NAME / LIGHTSAIL_LB_NAME 既存リソースと重複しない名前
ORIGIN_VERIFY_SECRET openssl rand -hex 24 で生成したランダム値
WAF_ALARM_EMAIL アラーム通知を受け取るメールアドレス
LIGHTSAIL_LB_DNS_NAME この時点では空のままでOK(9-5 で設定)

9-3. CDK Bootstrap(2リージョン分)

crossRegionReferences はカスタムリソース(Lambda)を使うため、両リージョンで bootstrap が必要です。片方だけだとデプロイ途中で失敗します。

cdk bootstrap aws://<CDK_ACCOUNT_ID>/ap-northeast-1
cdk bootstrap aws://<CDK_ACCOUNT_ID>/us-east-1

9-4. 1回目のデプロイ

デプロイ前に、まず synth でテンプレートが生成できることを確認します。

# .env の読み込みと必須値チェックがここで走る
cdk synth

# 生成されるスタックが2つであることを確認
cdk list
# WafStack
# LightsailCloudFrontStack
# WafStack → LightsailCloudFrontStack の順に自動でデプロイされる
cdk deploy --all

1回目のデプロイでは、CloudFront のオリジンが dummy-origin.example.com になります。これは意図した挙動です。この時点で CloudFront のURLにアクセスしても 502 系のエラーになります。

デプロイ完了後、SNS からサブスクリプション確認メールWAF_ALARM_EMAIL 宛に届きます。メール内の Confirm subscription リンクを開いてください。これを忘れるとアラームが発火しても通知が届きません。

# 確認済みかどうかをCLIでチェック(SubscriptionArn が PendingConfirmation でないこと)
aws sns list-subscriptions-by-topic \
  --topic-arn "arn:aws:sns:us-east-1:<CDK_ACCOUNT_ID>:wordpress-web-acl-alarm-topic" \
  --region us-east-1 \
  --query "Subscriptions[].{Endpoint:Endpoint,Arn:SubscriptionArn}" --output table

9-5. Lightsail ロードバランサの DNS 名を取得して .env に反映

コンソールを開かず、CLI で一発で取得できます。

aws lightsail get-load-balancer \
  --load-balancer-name wordpress-lb \
  --region ap-northeast-1 \
  --query "loadBalancer.dnsName" --output text

Lightsail のロードバランサの DNS 名は *.lightsail.com ではなく *.elb.amazonaws.com の形式で払い出されます(内部的に ELB が使われているため)。ドメインの見た目に驚かず、そのまま .env に設定してください。

あわせて、インスタンスが running になっているかも確認しておきます(LB のヘルスチェックが通る前提条件)。

aws lightsail get-instance --instance-name wordpress-instance \
  --region ap-northeast-1 \
  --query "instance.{state:state.name,ip:publicIpAddress}" --output json

取得した DNS 名を .env に設定します。

.env
LIGHTSAIL_LB_DNS_NAME=xxx.ap-northeast-1.elb.amazonaws.com

9-6. 2回目のデプロイ

# 差分を確認(CloudFront のオリジンが dummy から実DNS名に変わることを確認)
cdk diff LightsailCloudFrontStack

cdk deploy --all

CloudFront ディストリビューションの更新は反映完了まで数分かかりますcdk deploy が完了しても即座に切り替わらない場合があるため、動作確認は数分待ってから行ってください。

9-7. userData の実行結果を確認する

初回起動時に走った設定スクリプトのログを確認します。Lightsailコンソールの「ブラウザベースのSSH」で接続してください(本記事の構成では、SSHはコンソール経由のみ許可しています)。

sudo tail -20 /var/log/cloudfront-compat.log

wp-config: applied / vhost: applied / done の3つが出ていれば成功です。

最後の行が apache: 未起動のため reload をスキップ になっているのは正常です。userData はブループリントがApacheを起動するに完走するため、この時点では systemctl reload ができません。設定はその後のApache初回起動時に読み込まれます。

なおスクリプトの先頭を set -euo pipefail にすると、この systemctl reload の失敗でスクリプト全体が異常終了し、done が出ないまま終わります。userData から呼ぶスクリプトは「サービスがまだ起動していない前提」で書く必要があります。

9-8. WordPress の初期パスワードを取得

wordpress_ls_1_0 ブループリントは、初期パスワードがインスタンス内のファイルに置かれています。ブラウザSSHで以下を実行します。

sudo tail -n1 /opt/aws/wordpress/credentials.log

ログイン URL は https://<CloudFrontドメイン>/wp-login.php、ユーザー名は user です。

9-9. デプロイ結果の取得

以降の動作確認で使う値を、CLI でまとめて取得しておきます。

# CloudFront のドメイン名
aws cloudformation describe-stacks --stack-name LightsailCloudFrontStack \
  --region ap-northeast-1 --query "Stacks[0].Outputs" --output table

# WebACL の ARN
aws cloudformation describe-stacks --stack-name WafStack \
  --region us-east-1 --query "Stacks[0].Outputs" --output table

実際の出力例:

CloudFrontDomainName      : xxx.cloudfront.net
OriginVerifyHeaderName    : X-Origin-Verify
WebAclArnOutput           : arn:aws:wafv2:us-east-1:123456789012:global/webacl/wordpress-web-acl/....

以降の手順で使う環境変数をまとめて設定しておきます。

$CF="xxx.cloudfront.net"
$LB_DNS="xxx.ap-northeast-1.elb.amazonaws.com"
$WEB_ACL_ARN="arn:aws:wafv2:us-east-1:123456789012:global/webacl/wordpress-web-acl/..."
$DIST_ID=$(aws cloudfront list-distributions \
  --query "DistributionList.Items[?DomainName=='${CF}'].Id" --output text)

10. 動作確認

CloudFront経由の疎通WAFのブロックログ・メトリクスキャッシュビヘイビアHTTPS対応ヘルスチェックオリジン直アクセス遮断 の順に確認します。以下はすべて実際に実行して得られた結果です。

10-1. CloudFront 経由の正常系アクセス

curl -s -o /dev/null -w "status=%{http_code}\n" "https://${CF}/"
status=200

200 が返れば、CloudFront → Lightsail LB → WordPress の経路が通っています。ブラウザで開いて WordPress のトップページが表示されることも確認してください。

10-2. WAF が攻撃パターンをブロックすることの確認

自分で立てた検証環境に対してのみ実施してください。 疑似攻撃を送り、403 が返ることを確認します。

# ① SQLインジェクション(SQLiRuleSet / CommonRuleSet が反応)
curl -s -o /dev/null -w "SQLi: status=%{http_code}\n" \
  "https://${CF}/?id=1%27%20OR%20%271%27=%271"

# ② XSS(CommonRuleSet の CrossSiteScripting_QUERYARGUMENTS が反応)
curl -s -o /dev/null -w "XSS : status=%{http_code}\n" \
  "https://${CF}/?s=%3Cscript%3Ealert(1)%3C/script%3E"

実行結果:

SQLi: status=403
XSS : status=403

いずれも 403 が返り、WAF がリクエストをブロックしていることが確認できました。

「その403はWAFによるものか?」の見分け方

WordPress 自身が返す 403 と区別するため、レスポンスヘッダーを確認します。

curl -s -D - -o /dev/null "https://${CF}/?s=%3Cscript%3Ealert(1)%3C/script%3E" \
  | Select-String -Pattern '^(HTTP/|server|x-cache|via)'
HTTP/2 403
server: CloudFront        ← ★ここが判定ポイント
x-cache: Error from cloudfront
via: 1.1 xxx.cloudfront.net (CloudFront)

判定の決め手は server: CloudFront です。 オリジンのWordPressが返したレスポンスであれば server: Apache/2.4.x (Debian) になります。CloudFrontのエッジ(=WAF)で止められた場合はオリジンに到達していないため、serverCloudFront になります。

x-cache: Error from cloudfront だけでWAFブロックと判断してはいけません。 このヘッダーは「CloudFrontがエラーレスポンスを生成または返した」ことを示すだけで、オリジンが返した4xx/5xxでも同じ値が付きます。実際に検証したところ、/wp-json/wp/v2/posts へのアクセスでWordPress(Apache)が返した 404 にも x-cache: Error from cloudfront が付いていました。403 かつ server: CloudFront の組み合わせで判定してください。

10-3. どのルールがブロックしたかを特定する(Sampled requests)

コンソールを開かず CLI で確認できます。ここが最も実用的な確認方法です。

$now = [DateTimeOffset]::UtcNow
$EndTime = $now.ToString("yyyy-MM-ddTHH:mm:ssZ")
$StartTime = $now.AddMinutes(-20).ToString("yyyy-MM-ddTHH:mm:ssZ")

aws wafv2 get-sampled-requests \
  --web-acl-arn "$WEB_ACL_ARN" \
  --rule-metric-name "CommonRuleSet" \
  --scope CLOUDFRONT --region us-east-1 \
  --time-window "StartTime=$StartTime,EndTime=$EndTime" \
  --max-items 5 \
  --query "SampledRequests[].{URI:Request.URI,Action:Action,Rule:RuleNameWithinRuleGroup}" \
  --output json

実際の出力:

[
  {
    "URI": "/?s=%3Cscript%3Ealert(1)%3C/script%3E",
    "Action": "BLOCK",
    "Rule": "AWS#AWSManagedRulesCommonRuleSet#CrossSiteScripting_QUERYARGUMENTS"
  }
]

Ruleマネージドルールグループ内の個別ルール名まで出るのが重要です。誤検知のチューニングでは、この個別ルール名(CrossSiteScripting_QUERYARGUMENTS など)を ruleActionOverrides で Count に落とす、という流れになります。

--rule-metric-name には、CDK 側の visibilityConfig.metricName に指定した値(CommonRuleSet / WordPressRuleSet / SQLiRuleSet / RateLimitRule)を渡します。WebACL 全体を見たい場合は .envWAF_WEB_ACL_NAME と同じ値(wordpress-web-acl)を指定します。

コンソールで確認する場合は、リージョンを us-east-1(バージニア北部)に切り替えてから WAF & Shield → 対象の WebACL → Sampled requests タブを開きます(ap-northeast-1 のままでは WebACL が一覧に出ません)。

10-4. WAF ログ(CloudWatch Logs)の確認

WafStackaws-waf-logs-<WebACL名> というロググループへの出力を設定済みです。まずログストリームが作られているか確認します。

aws logs describe-log-streams \
  --log-group-name "aws-waf-logs-wordpress-web-acl" \
  --region us-east-1 --order-by LastEventTime --descending --max-items 5 \
  --query "logStreams[].logStreamName" --output json
["cloudfront_wordpress-web-acl_0"]

ストリーム名が cloudfront_<WebACL名>_0 の形式で作られていれば、ログ配信は成功しています。次に、ブロックされたリクエストだけを CloudWatch Logs Insights で抽出します。

fields @timestamp, httpRequest.clientIp, httpRequest.country, httpRequest.uri, terminatingRuleId
| filter action = "BLOCK"
| sort @timestamp desc
| limit 50

ログ1件は以下のような JSON になっています(許可されたリクエストの実例、抜粋・整形)。

{
  "webaclId": "arn:aws:wafv2:us-east-1:123456789012:global/webacl/wordpress-web-acl/...",
  "terminatingRuleId": "Default_Action",
  "action": "ALLOW",
  "httpSourceName": "CF",
  "ruleGroupList": [
    { "ruleGroupId": "AWS#AWSManagedRulesCommonRuleSet",    "terminatingRule": null, "nonTerminatingMatchingRules": [] },
    { "ruleGroupId": "AWS#AWSManagedRulesWordPressRuleSet", "terminatingRule": null, "nonTerminatingMatchingRules": [] },
    { "ruleGroupId": "AWS#AWSManagedRulesSQLiRuleSet",      "terminatingRule": null, "nonTerminatingMatchingRules": [] }
  ],
  "rateBasedRuleList": [
    {
      "rateBasedRuleName": "RateLimitRule",
      "limitKey": "IP",
      "maxRateAllowed": 2000,
      "evaluationWindowSec": 300
    }
  ]
}

読み方のポイントは3つです。

  • actionALLOW / BLOCK / COUNT。最終的にどう処理されたか
  • terminatingRuleId … ブロックを決定したルール。通過した場合は Default_Action
  • nonTerminatingMatchingRulesCount モードで一致したルール。ここに入っているルールは「ブロックには至らなかったが反応した」ものなので、Count 運用中の誤検知チューニングではこの配列を見るのが本番です

また rateBasedRuleListevaluationWindowSec: 300 から、レート制限が 5分間のウィンドウmaxRateAllowed: 2000.envWAF_RATE_LIMIT)として評価されていることが読み取れます。

10-5. CloudWatch メトリクスとアラームの確認

# 直近1時間のブロック数を5分刻みで取得
aws cloudwatch get-metric-statistics \
  --namespace AWS/WAFV2 --metric-name BlockedRequests \
  --dimensions Name=WebACL,Value=wordpress-web-acl Name=Region,Value=us-east-1 Name=Rule,Value=ALL \
  --start-time $(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
  --period 300 --statistics Sum --region us-east-1 \
  --query "Datapoints[].{Time:Timestamp,Blocked:Sum}" --output table

10-2 で送った疑似攻撃の分がカウントされているはずです。アラーム側の状態も確認します。

aws cloudwatch describe-alarms \
  --alarm-names wordpress-web-acl-blocked-requests-high wordpress-web-acl-rate-limit-triggered \
  --region us-east-1 \
  --query "MetricAlarms[].{Name:AlarmName,State:StateValue}" --output table

INSUFFICIENT_DATA ではなく OK になっていれば、メトリクスの次元(dimensions)指定が正しく、アラームがメトリクスを掴めています。INSUFFICIENT_DATA のまま張り付く場合は、WebACL / Region / Rule の次元名か値が実際のメトリクスと一致していないのが典型的な原因です。

アラームの通知経路自体をテストしたい場合は、一時的にアラーム状態を手動でセットします。

aws cloudwatch set-alarm-state \
  --alarm-name wordpress-web-acl-blocked-requests-high \
  --state-value ALARM --state-reason "通知経路のテスト" --region us-east-1

数十秒でメールが届けば、SNS のサブスクリプション確認まで含めて経路が通っています。

10-6. レート制限ルールの確認(任意)

デフォルトの WAF_RATE_LIMIT=2000(5分あたり2000リクエスト)を実測で発火させるのは負荷が大きいため、検証したい場合は .env を一時的に下げて再デプロイします。

.env
WAF_RATE_LIMIT=100   # 検証用。確認後は必ず戻す
cdk deploy WafStack

# 150リクエストを送る
for ($i = 1; $i -le 150; $i++) {
    $code = curl -s -o NUL -w "%{http_code}" "https://$CF/?cachebust=$i"
    Write-Host "$code " -NoNewline
}

途中から 403 に切り替われば、レート制限が機能しています。

レート制限の反映(および解除)には数分のラグがあります。また ?cachebust=$i のようにクエリを変えてキャッシュヒットを避けることがポイントです。確認後は WAF_RATE_LIMIT を必ず元の値に戻して再デプロイしてください。

10-7. キャッシュビヘイビアの検証(7章の確認)

キャッシュを消してから確認します。

aws cloudfront create-invalidation --distribution-id $DIST_ID --paths "/*"
sleep 60

① パスごとのキャッシュ挙動

function hit {
    param([string]$Url)

    $response = curl.exe -s -D - -o NUL $Url
    $cacheLine = $response | Where-Object { $_ -match '^X-Cache:' }
    if ($cacheLine) {
        ($cacheLine -replace '^[Xx]-[Cc]ache:\s*', '').Trim()
    }
}

$paths = @("/", "/wp-admin/", "/wp-login.php", "/wp-cron.php", "/wp-includes/css/dashicons.min.css")

foreach ($p in $paths) {
    $url = "https://$CF$p"
    $result1 = hit $url
    $result2 = hit $url
    $line = "{0,-42} 1回目={1} 2回目={2}" -f $p, $result1, $result2
    Write-Host $line
}

実際の出力:

/                                          1回目=Miss from cloudfront 2回目=Hit from cloudfront
/wp-admin/                                 1回目=Miss from cloudfront 2回目=Miss from cloudfront
/wp-login.php                              1回目=Miss from cloudfront 2回目=Miss from cloudfront
/wp-cron.php                               1回目=Miss from cloudfront 2回目=Miss from cloudfront
/wp-includes/css/dashicons.min.css         1回目=Miss from cloudfront 2回目=Hit from cloudfront

管理画面系は2回目も Miss(=キャッシュされていない)、トップページと静的アセットは2回目が Hit になっており、意図どおりです。

② Cookieによるキャッシュキー分離(ここが本題)

Write-Host ("匿名(温め)          : {0}" -f (hit "https://$CF/"))
Write-Host ("無関係Cookie        : {0}" -f (hit "https://$CF/" @{Cookie = "foo=bar"}))
Write-Host ("logged_in ユーザーA : {0}" -f (hit "https://$CF/" @{Cookie = "wordpress_logged_in_x=A"}))
Write-Host ("logged_in ユーザーA : {0}" -f (hit "https://$CF/" @{Cookie = "wordpress_logged_in_x=A"}))
Write-Host ("logged_in ユーザーB : {0}" -f (hit "https://$CF/" @{Cookie = "wordpress_logged_in_x=B"}))
Write-Host ("匿名(汚染確認)      : {0}" -f (hit "https://$CF/"))

実際の出力:

匿名(温め)          : Hit from cloudfront
無関係Cookie        : Hit from cloudfront     ← キャッシュ分裂していない
logged_in ユーザーA : Miss from cloudfront    ← ログインユーザー専用のキャッシュエントリ
logged_in ユーザーA : Hit from cloudfront     ← 同一ユーザーはキャッシュが効く
logged_in ユーザーB : Miss from cloudfront    ← 別ユーザーはAのキャッシュを再利用しない
匿名(汚染確認)      : Hit from cloudfront     ← 匿名向けキャッシュは汚染されていない

「無関係なCookieではキャッシュが分裂せず、ログインCookieでは確実に分離される」という、狙いどおりの状態になっています。

③ 実際のログインを通した確認

# ログインしてCookieを取得(パスワードは 9-8 で取得したもの)
curl -s -c cj.txt -D loginhdr.txt -o /dev/null \
  --data-urlencode "log=user" --data-urlencode "pwd=<取得したパスワード>" \
  --data-urlencode "redirect_to=https://$CF/wp-admin/" --data "testcookie=1" \
  "https://$CF/wp-login.php"

(Select-String -Path "loginhdr.txt" -Pattern '^set-cookie: wordpress_logged_in' -CaseSensitive:$false).Count   # → 1

# ログイン済みCookieで管理画面を取得
curl -s -b cj.txt -D - -o admin.html "https://$CF/wp-admin/" | Select-String -Pattern '^(HTTP/|x-cache)'
HTTP/2 200
x-cache: Miss from cloudfront

wordpress_logged_in_* Cookie がCloudFrontを通過して発行され、管理画面が 200 で取得でき、かつキャッシュされていないことが確認できました。

④ ログイン状態が漏れないことの確認

# 匿名で管理画面にアクセス
curl -s -D - -o anon.html "https://$CF/wp-admin/" | Select-String -Pattern '^(HTTP/|location)'
(Select-String -Path "anon.html" -Pattern "adminmenu" -SimpleMatch -AllMatches -ErrorAction SilentlyContinue).Matches.Count    # → 0

# トップページ: ログイン中 vs 匿名
Invoke-WebRequest -Uri "https://$CF/" -WebSession $session -UseBasicParsing -OutFile "top_login.html"
(Select-String -Path "top_login.html" -Pattern "wp-admin-bar" -SimpleMatch -AllMatches -ErrorAction SilentlyContinue).Matches.Count   # → 1以上

Invoke-WebRequest -Uri "https://$CF/" -UseBasicParsing -OutFile "top_anon.html"
(Select-String -Path "top_anon.html" -Pattern "wp-admin-bar" -SimpleMatch -AllMatches -ErrorAction SilentlyContinue).Matches.Count    # → 0

匿名アクセスは 302 でログイン画面へリダイレクトされ、管理画面のHTMLは一切返りません。トップページも、ログイン中は管理バーあり・匿名は管理バーなしで正しく分離されました。

10-8. HTTPS対応の検証(8章の確認)

① リダイレクト先が https:// になっていること

curl -s -D - -o /dev/null "https://$CF/wp-admin/" | Select-String '^location'
location: https://xxx.cloudfront.net/wp-login.php?redirect_to=https%3A%2F%2F...

http:// から https:// に変わりました。リダイレクトを追跡してループしないことも確認します。

curl -sL -o /dev/null -w "status=%{http_code} redirects=%{num_redirects}\n" "https://$CF/wp-admin/"
status=200 redirects=1

② 混在コンテンツが解消したこと

$html = (Invoke-WebRequest -Uri "https://$CF/" -UseBasicParsing).Content
$httpsCount = ([regex]::Matches($html, [regex]::Escape("https://$CF"))).Count
$httpCount = ([regex]::Matches($html, [regex]::Escape("http://$CF"))).Count
Write-Output "https:// $httpsCount 件 / http:// $httpCount 件"
https:// 30 件 / http:// 0 件

自サイトを指すURL30件すべてが https:// になり、http:// は0件です。

10-9. ロードバランサのヘルスチェックの確認(8-4 の確認)

healthCheckPath の変更をデプロイした後、判定が切り替わるまで1〜2分かかります。

for ($i = 1; $i -le 6; $i++) {
    Start-Sleep -Seconds 40
    $timestamp = (Get-Date).ToUniversalTime().ToString("HH:mm:ssZ")
    $health = aws lightsail get-load-balancer --load-balancer-name wordpress-lb --region ap-northeast-1 `
        --query "loadBalancer.instanceHealthSummary[0].[instanceHealth,instanceHealthReason]" --output text
    Write-Host "[$timestamp] $health"
}

実際の出力:

[08:30:40Z] unhealthy	Instance.ResponseCodeMismatch
[08:31:27Z] healthy	None
[08:32:14Z] healthy	None
[08:33:02Z] healthy	None

healthy に切り替わりました。

10-10. オリジン直接アクセスが遮断されることの確認

この確認は、次のオリジン側設定を行った後でなければ意味がありません。 未設定の状態で試すと、リクエストがオリジンまで到達します。

curl -s -o /dev/null -w "status=%{http_code}\n" "http://${LB_DNS}/"
status=301      ← WordPress がリダイレクトを返している = WAFを迂回して到達している

ブループリントのApacheは(8-4 のvhost設定により)HTTPSへリダイレクトするため一見アクセスできないように見えますが、これは遮断ではありません。カスタムヘッダーによる検証をオリジン側に必ず入れてください。

wordpress_ls_1_0Debian標準の Apache 2.4 です。記事でよく見る Order Deny,Allow(2.2系の構文)は使えないため、Require 系ディレクティブを使います。

# 新規作成
sudo vi /etc/apache2/conf-available/origin-verify.conf
/etc/apache2/conf-available/origin-verify.conf
# CloudFront が付与するカスタムヘッダーが一致するリクエストのみ許可する
<Directory "/var/www/html">
  <RequireAll>
    Require expr "%{HTTP:X-Origin-Verify} == '<ORIGIN_VERIFY_SECRET に設定した値>'"
  </RequireAll>
</Directory>

# ロードバランサのヘルスチェックは検証を免除する
# (ヘルスチェックには X-Origin-Verify が付かないため、除外しないと unhealthy になる)
<Files "lb-healthcheck.html">
  Require all granted
</Files>

筆者の環境では、000-default.conf で以下の5行(RewriteEngine On〜RewriteRule)をコメントアウトしないと確認できませんでした。

sudo vi /etc/apache2/sites-available/000-default.conf

# コメントアウト箇所
#       RewriteEngine On
#       RewriteCond %{HTTP:CloudFront-Forwarded-Proto} !=https
#       RewriteCond %{HTTP:X-Forwarded-Proto} !=https
#       RewriteCond %{REQUEST_URI} !^/lb-healthcheck.html$
#       RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301,NE]
# 設定を有効化して反映
sudo a2enconf origin-verify
sudo apache2ctl configtest      # Syntax OK を確認
sudo systemctl reload apache2

反映後、以下の3つを比較します。

SECRET='<ORIGIN_VERIFY_SECRET に設定した値>'

# ① ヘッダーなしでオリジンに直接アクセス → 403 になること
curl -s -o /dev/null -w "no header  : status=%{http_code}\n" "http://${LB_DNS}/"

# ② 正しいヘッダーを付けてオリジンに直接アクセス → 200/301 になること
curl -s -o /dev/null -w "with header: status=%{http_code}\n" \
  -H "X-Origin-Verify: $SECRET" "http://$LB_DNS/"

# ③ CloudFront 経由 → 200 のままであること(★これが崩れていないか必ず確認)
curl -s -o /dev/null -w "cloudfront : status=%{http_code}\n" "https://${CF}/"

# ④ ヘルスチェックパスは免除されていること
curl -s -o /dev/null -w "healthcheck: status=%{http_code}\n" "http://${LB_DNS}/lb-healthcheck.html"

① 403 / ② 200系 / ③ 200 / ④ 200 になれば、WAF を迂回する経路が塞がれた状態です。

③ が 403 になってしまった場合は、CloudFront のキャッシュに古いレスポンスが残っているか、ヘッダー値の設定ミスです。aws cloudfront create-invalidation --distribution-id $DIST_ID --paths "/*" でキャッシュを消してから再確認してください。

④ を忘れると、ヘルスチェックが 403 を受け取って 8-4 で直したはずの unhealthy が再発します。設定後は必ず aws lightsail get-load-balancerhealthy を再確認してください。

この設定も scripts/ 配下のスクリプトに組み込んで userData から適用すれば、cdk destroy → 再デプロイで自動復元されます。ただし秘密値をインスタンス内のファイルに書き込むことになるため、本記事では手動設定として分離しています。

10-11. 動作確認チェックリスト

# 確認項目 期待結果
1 CloudFront ドメインへのアクセス 200 / WordPress 表示
2 SQLi 疑似リクエスト 403 + server: CloudFront
3 XSS 疑似リクエスト 403 + server: CloudFront
4 get-sampled-requests ブロックしたルール名が取得できる
5 ロググループのストリーム cloudfront_<WebACL名>_0 が存在
6 BlockedRequests メトリクス 疑似攻撃分がカウントされる
7 CloudWatch アラーム状態 OKINSUFFICIENT_DATA でない)
8 SNS サブスクリプション 確認済み(PendingConfirmation でない)
9 /wp-admin/ のキャッシュ 2回目も Miss
10 /wp-content/* のキャッシュ 2回目は Hit
11 ログインCookieによるキャッシュ分離 ユーザーA/Bで Miss が分かれる
12 匿名の / にログイン状態が漏れない wp-admin-bar が含まれない
13 /wp-admin/ のリダイレクト先 https:// で始まる
14 トップページ内の自サイトURL http:// が 0 件
15 Lightsail LB のヘルスチェック インスタンスが healthy
16 オリジン直アクセス(ヘッダーなし) 403
17 オリジン直アクセス(ヘッダーあり) 200/301
18 ヘルスチェックパスへの直アクセス 200(検証免除)

11. クリーンアップ(cdk destroy

検証用に作った環境は、使い終わったら必ず削除してください。Lightsailインスタンスとロードバランサは起動している限り課金が続きます(特にLBは $18/月〜)。

11-1. 削除の実行

cdk destroy --all

「削除時は LightsailCloudFrontStackWafStack の順に手動で実行する必要がある」という説明を見かけますが、cdk destroy --all を使う場合は不要ですaddDependency() で宣言した依存関係の逆順に削除が行われます。実際のログでも LightsailCloudFrontStack [1/2]WafStack [2/2] の順に処理され、エラーは一切発生しませんでした。

ただしスタックを個別に指定する場合は順序が重要です(理由は 11-2)。

CloudFrontの削除が全体の大半を占めます。CloudFormationが内部で Disable → Delete を自動的に行うため、コンソールで手動で無効化する必要はありません

cdk destroy が「応答していないように見える」のはほぼCloudFrontの削除待ちです。焦って Ctrl+C で中断すると、スタックが DELETE_IN_PROGRESS のまま残って次の操作ができなくなります。数分間は待ってください。

11-2. なぜ削除順序が重要なのか(crossRegionReferences の内部構造)

crossRegionReferences: true を使うと、CDKは以下を自動生成します。

  • WafStack(us-east-1)側Custom::CrossRegionExportWriter とそのLambda・IAMロール。WebACLのARNを 参照先リージョン(ap-northeast-1)のSSMパラメータ に書き込む
  • LightsailCloudFrontStack(ap-northeast-1)側Custom::CrossRegionExportReader とそのLambda・IAMロール。上のSSMパラメータを読む

実際に作られるSSMパラメータを確認できます。

aws ssm describe-parameters --region ap-northeast-1 \
  --query "Parameters[?starts_with(Name,'/cdk/exports/')].Name" --output json
["/cdk/exports/LightsailCloudFrontStack/WafStackuseast1FnGetAttWordPressWebAclArn36425EA7"]

Writer側(WafStack)を先に消すとパラメータが失われ、Reader側が参照できなくなります。だから LightsailCloudFrontStack を先に削除する必要があるわけで、--all を使えばCDKがこの順序を守ってくれます。逆にスタックを個別に cdk destroy WafStack から実行すると失敗します。

11-3. 残留リソースの確認

削除後、課金対象が残っていないかを確認します。

$resources = @("instances", "load-balancers", "instance-snapshots", "disks", "static-ips", "key-pairs")

foreach ($r in $resources) {
    Write-Host -NoNewline "$r`: "
    $json = aws lightsail get-$r --region ap-northeast-1 --output json
    $obj = $json | ConvertFrom-Json
    $key = ($obj.PSObject.Properties.Name)[0]
    $values = $obj.$key
    if ($values.Count -gt 0) {
        ($values | ForEach-Object { $_.name }) -join ", "
    } else {
        "(none)"
    }
}

aws wafv2 list-web-acls --scope CLOUDFRONT --region us-east-1 --query "WebACLs[].Name"
aws logs describe-log-groups --log-group-name-prefix "aws-waf-logs-" --region us-east-1 --query "logGroups[].logGroupName"
aws ssm describe-parameters --region ap-northeast-1 --query "Parameters[?starts_with(Name,'/cdk/exports/')].Name"
aws cloudfront list-cache-policies --type custom --query "CachePolicyList.Items[].CachePolicy.CachePolicyConfig.Name"
aws sns list-topics --region us-east-1 --query "Topics[?contains(TopicArn,'wordpress')].TopicArn"

実際の結果:

instances: (none)
load-balancers: (none)
instance-snapshots: (none)
disks: (none)
static-ips: (none)
key-pairs: (none)
[]        ← WebACL
[]        ← WAFロググループ
[]        ← クロスリージョン参照のSSMパラメータ
null      ← カスタムキャッシュポリシー
[]        ← SNSトピック

残留ゼロでした。 特に注目すべき点が2つあります。

① WAFのロググループも消える

CDKの logs.LogGroup は既定で RemovalPolicy.RETAIN ですが、6-2 のコードで明示的に removalPolicy: cdk.RemovalPolicy.DESTROY を指定しているため削除されます。本番環境で監査ログを残したい場合は RETAIN に変更してください(当然ながらログ保管料が継続します)。

② 手動で作ったリソースは残る

今回はスナップショットを取っていないためゼロでしたが、Lightsailのスナップショット・静的IP・追加ディスクはCloudFormation管理外なので cdk destroy では消えません。手動で作成したものは個別に削除してください。

# 手動作成したスナップショットがある場合
aws lightsail delete-instance-snapshot --instance-snapshot-name <名前> --region ap-northeast-1
# 手動で割り当てた静的IPがある場合
aws lightsail release-static-ip --static-ip-name <名前> --region ap-northeast-1

11-4. CDK Bootstrap は残る

cdk destroy --all では CDKToolkit スタック(bootstrap用のS3バケット・ECRリポジトリ・IAMロール)は削除されません。他のCDKプロジェクトでも共有されるリソースなので、通常は残しておくのが正解です。

このアカウント・リージョンでCDKを完全に使わなくなった場合のみ、S3バケットを空にしてから削除します。

# ★ 他のCDKプロジェクトが動いている場合は絶対に実行しないこと
aws cloudformation delete-stack --stack-name CDKToolkit --region ap-northeast-1
aws cloudformation delete-stack --stack-name CDKToolkit --region us-east-1

11-5. 作り直すときに必要な作業

destroy 後に再デプロイすると、識別子が変わるものがあります。実測値です。

項目 削除前 再デプロイ後
CloudFrontドメイン sample1.cloudfront.net sample2.cloudfront.net
LBのDNS名 sample1.ap-northeast-1.elb.amazonaws.com sample2.ap-northeast-1.elb.amazonaws.com

したがって再構築時は次の対応が必要です。

.envLIGHTSAIL_LB_DNS_NAME を空にする

古いDNS名が残っていると、存在しないオリジンを指したCloudFrontができてしまいます。9章の2段階デプロイをやり直してください。

② SNSサブスクリプションを再承認する

SNSトピックが作り直されるため、メール購読は未承認状態に戻ります。

aws sns list-subscriptions --region us-east-1 \
  --query "Subscriptions[?contains(TopicArn,'wordpress-web-acl')].{Arn:SubscriptionArn,Endpoint:Endpoint}"
[{ "Arn": "PendingConfirmation", "Endpoint": "you@example.com" }]

PendingConfirmation のままではアラーム通知が届きません。確認メールのリンクを開いてください。

③ WordPressの中身は失われる

記事・メディア・プラグイン・管理者パスワードはすべて初期化されます。残したい場合は削除前にLightsailのスナップショットを取得してください(スナップショットは cdk destroy の対象外なので残ります)。

aws lightsail create-instance-snapshot \
  --instance-name wordpress-instance \
  --instance-snapshot-name "wordpress-backup-$(Get-Date -Format 'yyyyMMdd')" \
  --region ap-northeast-1

userData の設定は自動で再適用される

8-6 で userData に組み込んだため、再デプロイした新しいインスタンスでは wp-config.php のHTTPS対応とヘルスチェックファイルが自動で適用されます。SSHでの手作業は不要です(実際に destroy → 再デプロイして確認済み)。

一方、10-10 のオリジン検証設定(origin-verify.conf)は手動設定のため、再度適用が必要です。

11-6. 再デプロイの所要時間(実測)

工程 所要時間
1回目 cdk deploy --all(2スタック新規作成) 約5分40秒
LBのDNS名を取得して .env を更新 数十秒
2回目 cdk deploy --all(CloudFrontオリジン更新) 約1分55秒
LBのヘルスチェックが healthy になるまで 追加で1〜2分
合計 10分前後

12. 運用時の注意点・トラブルシューティング

12-1. 運用Tips

  • 最初は Count モードで試験運用する:ルールを本番導入する前に overrideAction: { count: {} } で一定期間動作させ、誤検知が無いかログで確認してから none(ブロック実行)に切り替えるのが安全です。WordPressでの誤検知の定番は SizeRestrictions_BODY(メディアアップロード)と NoUserAgent_HEADER です
  • 誤検知は個別ルール単位で緩める:ルールグループ全体をCountに落とすのではなく、ruleActionOverrides で該当ルールだけをCountにします。対象の個別ルール名は 10-3 の get-sampled-requests か 10-4 の nonTerminatingMatchingRules から取得できます
  • コスト監視を必ず設定する:AWS BudgetsでWAF・CloudFront関連のコストにアラートを設定しておくと、想定外のリクエスト急増時に早期検知できます
  • オリジンヘッダーの値は定期的にローテーションする.envORIGIN_VERIFY_SECRET を更新して cdk deploy し、オリジン側の origin-verify.conf も合わせて更新します。CloudFront側とオリジン側を同時に切り替えられないため、移行中は両方の値を許可する期間を設けるのが安全です
  • リージョンをまたぐ構成であることを忘れない:WAFのログ・メトリクス・Sampled requests は us-east-1 側のコンソールでしか見えません
  • .envは絶対にGitにコミットしないORIGIN_VERIFY_SECRET という実質的な認証情報を含むようになったため、これは必須です
  • SSHはLightsailコンソール経由に限定する:本記事の構成では cidrListAliases: ["lightsail-connect", "lightsail-setup-ipv4"] を指定し、ポート22をインターネットに開けていません。作業のため一時的に開けた場合は、作業後に必ず閉じてください

12-2. トラブルシューティング

症状 原因と対処
環境変数 CDK_ACCOUNT_ID が .env に設定されていません .env.example のキー名が AWS_ACCOUNT_ID になっている。CDK_ACCOUNT_ID に統一する
デプロイ途中でカスタムリソースが失敗する us-east-1 側の cdk bootstrap を忘れている。両リージョンで実行する
cdk destroy WafStack が失敗する SSMパラメータのWriter側を先に消そうとしている。cdk destroy --all を使う(11-2)
CloudFrontのURLが502を返す 1回目のデプロイでオリジンが dummy-origin.example.com のまま。9-5〜9-6 を実施する
管理画面にログインできない/ログイン状態が保持されない キャッシュビヘイビアが分割されていない。7章を適用する
リンクやCSS/JSが http:// になり混在コンテンツになる wp-config.php が未対応。8-5 のスクリプトを適用する(wp option update siteurl では直りません)
X-Forwarded-Proto を見てもHTTPS判定できない このヘッダーはLBが付ける「CloudFront→LB間」の値で常に httpCloudFront-Forwarded-Proto を見る(8-3)
CloudFront-Forwarded-Proto がオリジンに届かない オリジンリクエストポリシーが ALL_VIEWER になっている。ALL_VIEWER_AND_CLOUDFRONT_2022 にする(7-3)
LBが unhealthy / Instance.ResponseCodeMismatch healthCheckPath: "/" が301を受け取っている。/lb-healthcheck.html に変える(8-4)
userData を直したのに反映されない userData は初回起動時のみ実行される。既存インスタンスにはSSHで手動実行する(8-6)
アラームが INSUFFICIENT_DATA のまま メトリクスの次元(WebACL / Region / Rule)が実際の値と一致していない
アラームは発火するのにメールが来ない SNSサブスクリプションが PendingConfirmation。確認メールのリンクを開く
CloudFrontのオリジン重複エラー ビヘイビアごとに new HttpOrigin(...) している。origin を1つ作って共有する(7-4)
設定を変えたのに挙動が変わらない CloudFrontのキャッシュが残っている。create-invalidation --paths "/*" してから確認する

12-3. この構成の限界と代替案

正直に書いておくべき限界が3つあります。

  1. Lightsail LBのエンドポイント自体は公開されたまま
    10-10 のカスタムヘッダー検証はアプリケーション層(Apache)での防御であり、TCPレベルではLBに到達できます。IPレベルで塞ぐことはできません。
  2. Lightsail標準の「ディストリビューション」機能ではWAFをアタッチできない
    LightsailにもCDN機能(Lightsail Distribution)がありますが、これはWAFのアタッチ対象ではありません。本記事のように通常のCloudFrontを使う必要があります。
  3. より厳密に守るなら EC2 + ALB への移行が正解
    CloudFront VPC Origin やALBのセキュリティグループ制限を使えば、オリジンをインターネットから完全に隔離できます。4章で触れたようにLightsail LBは$18/月〜なので、コスト面でも大差ありません

「すでにLightsail + LBで動いているサイトに、最小の変更でWAFを載せる」のが本構成の適用範囲です。これから新規に組むのであれば、EC2 + ALB + CloudFront を検討する価値があります。


まとめ

アーキテクチャ

  • Lightsailのロードバランサには AWS WAFを直接アタッチできない
  • 解決策は CloudFrontを前段に配置し、CloudFrontにWAFをアタッチする 構成
  • WAF用のWebACLは us-east-1固定 という制約があるが、Lightsail自体は東京リージョンに配置できる
  • CDKでは crossRegionReferences: true を使うことで、リージョンを跨いだスタック間の値参照(WebACLのARN)をシンプルなコードで実現できる
  • LightsailリソースはCDKの L1コンストラクト(Cfn系) を使用し、CloudFrontは標準の L2コンストラクト で構築可能

「動いた」で終わらせないために必須の3点

  • キャッシュビヘイビアの分割CACHING_OPTIMIZED を全パスに適用すると CookieBehavior: none + DefaultTTL 86400 により、ログイン中のHTMLが未ログインユーザーに配信され得る。管理画面は CACHING_DISABLED、静的アセットは CACHING_OPTIMIZED、動的HTMLはログインCookieをキャッシュキーに含めたカスタムポリシーに分ける
  • WordPress側のHTTPS対応wp-config.phphttp:// をハードコードしているため、CloudFront-Forwarded-ProtoX-Forwarded-Proto ではない)を見てスキームを出し分ける。オリジンリクエストポリシーは ALL_VIEWER_AND_CLOUDFRONT_2022 が必須
  • ヘルスチェックパスの分離:ブループリントのvhostが / を301リダイレクトするため、healthCheckPath: "/" では永久に unhealthy。リダイレクト対象外の静的ファイルを用意する

運用面

  • WordPress側の設定は userData に組み込むことでIaC管理下に置ける。ただし userData初回起動時のみ実行される点に注意
  • cdk destroy --all は依存関係の逆順に削除するため手動での順序指定は不要。ただしスナップショット・静的IPは対象外
  • 再デプロイすると CloudFrontドメインとLBのDNS名が変わる.env の更新とSNSの再承認が必要
  • 月額コストの目安は WAFで1,000円台後半。ただしこの構成でいちばん高いのはLightsailのロードバランサ($18/月〜)

ハマりどころの覚え書き

  • wordpress_ls_1_0Bitnamiではない(Debian 12 + 素のapache2、wp-config.php/var/www/wp-config.php
  • x-cache: Error from cloudfrontWAFブロックの証拠にならない(オリジンの4xxでも付く)。403 + server: CloudFront で判定する
  • cdk.Names.uniqueId(this) を Stack 自身に呼ぶと構築IDが返るだけなので、秘密値の生成には使えない

Lightsailの手軽さとレイテンシの良さ(東京リージョン配置)を維持しながら、AWS WAFによる本格的なセキュリティ対策を導入できるこの構成は、個人開発から小規模なビジネスサイトまで幅広く活用できます。ただし「CloudFrontを前に置くだけ」では済まないポイントが確実にあるので、7章・8章の対応をセットで入れることを強くおすすめします。

この記事が、誰かのお役に立てば幸いです。


参考リンク

AWS WAF

CloudFront

AWS CDK / Lightsail

WordPress

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?