本站(物理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 另外计费。



