用 CDK 在 AWS 上托管 WordPress:共享 ALB、CloudFront、EFS 与 Aurora 的取舍

本站 WordPress 的 AWS 架构:Fargate 跑官方镜像,整站放 EFS,Aurora Serverless v2 空闲暂停到 0 ACU,共享 ALB 只接受带 CloudFront 秘密头的请求。逐项说明选择理由与代价。

本站(物理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 在两个没有出网路由的子网

文件放 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 核心的升级交给它自己(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 上还有别的服务,应用不改它的安全组(代码里以只读方式导入),也不改默认动作,只加一条规则:

  • 条件一:Host 等于站点域名;
  • 条件二:带有 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 链接或者陷入重定向循环。两种前门的判断依据不同:

  • 共享 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。静态资源则在第二次请求时由 CloudFront 从缓存返回,实测 wp-includes、主题和插件下的 CSS 都是如此,缓存键包含查询字符串,因为 WordPress 用 ?ver= 给静态资源标版本。自建变体的 CDK 代码把这套规则写得很直白:默认行为用 CachingDisabled,wp-includes/* 和 wp-content/uploads/* 单独用一个按查询字符串缓存的策略。

一个坑:VPC 接口终端节点和 wp_safe_remote_*()

站点所在 VPC 有一个开启私有 DNS 的 bedrock-runtime 接口终端节点,Bedrock 的域名因此解析到 VPC 内的私有地址。栈给终端节点的安全组加了一条 443 入站规则,来源只限 WordPress 任务。但 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,保留一个月;需要时用 aws ecs execute-command 进容器。
  • 定时任务:用 WordPress 自带的 WP-Cron,在页面访问时触发。到期的任务要等下一次访问才跑,不够用时再加外部定时触发。
  • 先定域名:WordPress 在文章里存绝对 URL,写了内容再换域名,就要对数据库做一次搜索替换。

局限 / 注意

  • 只有一个 Fargate 任务(desiredCount: 1),没有多副本高可用;Aurora 也只有一个 writer。
  • 共享 ALB 的空闲超时是 60 秒。模型持续输出 token 时没问题,但如果模型沉默思考超过 60 秒,连接会被切断;调大这个值会影响 ALB 后面的所有服务。
  • 数据库暂停后的第一次访问要多等约 15 秒。
  • 成本是项目 README 的粗略估算(东京区域、免费额度之前、低流量):共享 ALB 方案每月约 25–35 美元,其中 Fargate 约 18 美元、任务公网 IPv4 约 4 美元;数据库醒着时另计。自建 ALB 和 CloudFront 的变体约 55–70 美元。Bedrock 按 token 另外计费。

链接