如何通过亚马逊S3存储桶 + CloudFront + Cloudflare 部署 H5 项目

目录

最近折腾了一次 H5 项目的上线,原本以为这种纯前端静态项目会很简单,结果真正麻烦的地方根本不在打包,而是在部署链路上:S3 目录权限、CloudFront 的 SPA 回退、Cloudflare 的域名解析切换、还有 App 内 WebView 的缓存问题。

这篇文章就把这次完整过程整理一下,专门讲如何通过 Amazon S3、CloudFront 和 Cloudflare 部署 H5 项目,以及上线过程中最容易踩的坑和对应处理方式。

1. 先说结论:H5 静态部署真正难的不是上传文件

很多人第一次部署 H5,会以为流程很简单:

  1. 打包出 dist
  2. 上传到 S3
  3. 绑定域名
  4. 结束

但实际线上出问题时,往往不是文件没上传成功,而是下面这些地方出了问题:

  • 前端路由是 SPA,直接访问子路径会 403 或 404
  • 新建了 S3 目录,但 CloudFront 回源读不到
  • CloudFront 还缓存着旧的 index.html
  • App 内 WebView 缓存了旧入口页,导致脚本加载报错
  • 新版本上线时把旧的 hash 静态资源删掉了,结果老缓存直接炸了

所以如果想把这件事做稳,不能只盯着 S3 上传成功没成功,而是要把整条链路看成一个整体。

2. 整体架构是什么样的

我这里的部署架构比较典型:

  • S3:负责存静态文件
  • CloudFront:负责对外访问、缓存和 HTTPS
  • Cloudflare:负责 DNS 解析

对应关系如下:

flowchart LR
    User[浏览器 / App WebView] --> DNS[Cloudflare DNS]
    DNS --> CDN[CloudFront]
    CDN --> Bucket[S3 Bucket]
    Bucket --> AppFiles[index.html]
    Bucket --> Assets[assets js css images]
flowchart LR
    User[浏览器 / App WebView] --> DNS[Cloudflare DNS]
    DNS --> CDN[CloudFront]
    CDN --> Bucket[S3 Bucket]
    Bucket --> AppFiles[index.html]
    Bucket --> Assets[assets js css images]
flowchart LR
    User[浏览器 / App WebView] --> DNS[Cloudflare DNS]
    DNS --> CDN[CloudFront]
    CDN --> Bucket[S3 Bucket]
    Bucket --> AppFiles[index.html]
    Bucket --> Assets[assets js css images]

如果再细化一点,实际请求链路可以理解成这样:

sequenceDiagram
    participant U as 用户/App
    participant CF as Cloudflare
    participant CDN as CloudFront
    participant S3 as Amazon S3

    U->>CF: 访问 h5-app.example.com
    CF->>CDN: DNS 解析到 CloudFront
    CDN->>S3: 请求 index.html 或静态资源
    S3-->>CDN: 返回文件
    CDN-->>U: 返回页面与资源
sequenceDiagram
    participant U as 用户/App
    participant CF as Cloudflare
    participant CDN as CloudFront
    participant S3 as Amazon S3

    U->>CF: 访问 h5-app.example.com
    CF->>CDN: DNS 解析到 CloudFront
    CDN->>S3: 请求 index.html 或静态资源
    S3-->>CDN: 返回文件
    CDN-->>U: 返回页面与资源
sequenceDiagram
    participant U as 用户/App
    participant CF as Cloudflare
    participant CDN as CloudFront
    participant S3 as Amazon S3

    U->>CF: 访问 h5-app.example.com
    CF->>CDN: DNS 解析到 CloudFront
    CDN->>S3: 请求 index.html 或静态资源
    S3-->>CDN: 返回文件
    CDN-->>U: 返回页面与资源

这里面三个服务各干各的事:

  • S3 只负责存文件,不负责聪明地理解你的前端路由。
  • CloudFront 负责把静态文件作为站点发布出去,同时承担缓存和回退逻辑。
  • Cloudflare 只是把域名指向 CloudFront,它不负责修复 S3 和 CloudFront 的配置问题。

3. S3 目录应该怎么规划

如果线上只有一个目录,每次都直接覆盖,例如:

1
h5-app/

那每次上线都会有几个风险:

  1. 覆盖到一半,用户可能拿到半新半旧的资源
  2. 新版本有问题时回滚麻烦
  3. App 或 WebView 还缓存着旧入口页时,旧静态资源可能已经被删掉

所以我更推荐一开始就把目录拆开,例如:

1
2
h5-app/
h5-app-next/

含义很简单:

  • h5-app/:当前正式版本
  • h5-app-next/:新版本、预发布版本

这样做的好处是:

  1. 新版本可以先独立部署、独立验证
  2. 正式切换时,不需要直接覆盖当前线上目录
  3. 旧版本始终保留,便于快速回滚

这其实就是一个很轻量的蓝绿发布思路。

4. 新建 S3 目录后,为什么明明上传成功了却访问不了

这个坑我觉得很值得单独拿出来说,因为非常容易忽略。

很多时候我们会在 S3 里新建一个目录,比如:

1
h5-app-next/

然后把新版本文件传进去,CloudFront 也指过去了,结果一访问还是报错,常见表现有:

  • 403 AccessDenied
  • 通过 CloudFront 分配域名访问不了
  • 静态资源加载失败

这类问题经常不是“目录没建对”,而是权限没配对。

4.1. 为什么会这样

S3 里的“目录”其实只是对象前缀,不是真正的文件夹。你看到的 h5-app-next/,本质上只是对象 key 的一部分。

也就是说,目录存在不等于可访问。真正决定它能不能被访问的是:

  1. Bucket 的公共访问设置
  2. Bucket Policy
  3. CloudFront 是否有权限读取这个目录下的对象

4.2. 如果你是公开 Bucket 的方式

如果当前用的是“公开读取的 S3 + CloudFront”,那新增目录后要确认:

  1. 打开 S3 Bucket
  2. 进入 Permissions
  3. 检查 Block public access
  4. 检查 Bucket Policy

至少要保证对象可以被读取,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::your-bucket-name/*"
    }
  ]
}

如果整桶被公共访问限制拦住了,那么你即使把文件传进去了,通过 CloudFront 也可能还是读不到。

4.3. 如果你是 CloudFront 私有回源方式

如果你们是通过 CloudFront 的 OAC 或 OAI 回源 S3,那也一样需要检查:

  1. CloudFront 的源站访问身份是否正确
  2. Bucket Policy 是否允许对应 CloudFront 读取对象
  3. 新目录下的对象是否同样处于可读取范围内

一句话总结就是:

在 S3 里新增目录并上传文件之后,一定要顺手检查 Bucket 公共访问权限或者 CloudFront 回源权限,否则目录虽然存在,但就是访问不了。

5. CloudFront 最重要的不是加速,而是把 H5 站点“发布正确”

很多人提到 CloudFront,会先想到 CDN 和加速,但对于 H5 项目来说,它还有一个更重要的作用:把一个“静态文件集合”正确发布成一个“可以访问的前端站点”。

对于 H5 项目,CloudFront 至少要做三件事:

  1. 提供 HTTPS 和域名绑定
  2. 做缓存
  3. 做 SPA 路由回退

5.1. 默认根对象

CloudFront 的默认根对象建议设置成:

1
index.html

否则访问域名根路径时,可能不能自动返回首页。

5.2. SPA 路由回退为什么必须配

H5 项目往往会有很多前端路由,例如:

  • /dl
  • /lucky-gifts
  • /recharge/faq
  • /invitation

这些路由并不是 S3 里的真实文件路径。

这就意味着:

  1. 从首页点进去通常没问题,因为页面已经加载,前端路由自己接管了。
  2. 直接访问这些路径时,如果 CloudFront 不处理,S3 会去找对应对象。
  3. 找不到对象就会返回 403404

所以必须在 CloudFront 配置自定义错误页回退:

  • 403 -> /index.html -> 200
  • 404 -> /index.html -> 200

这样用户直接打开 /dl 时,CloudFront 会把它兜到底层的 index.html,再让前端路由接管。

5.3. 为什么会看到 AccessDenied

如果没有配这一步,直接访问页面子路径时,经常就会看到:

  • AccessDenied
  • NoSuchKey

这并不一定说明你的前端代码有问题,而是说明 S3 正在认真地找一个并不存在的对象路径。

6. Cloudflare 在这套方案里的角色是什么

Cloudflare 在这里更像一个“域名入口层”,负责把业务域名解析到 CloudFront。

例如:

  • h5-app.example.com -> 正式 CloudFront
  • h5-app-next.example.com -> 预发布 CloudFront

这样做的好处是:

  1. 正式环境和测试环境可以分开
  2. 切换时只需要改解析或迁移域名归属
  3. 回滚也更方便

这里要注意一个点:Cloudflare 只是负责 DNS,不负责替代 CloudFront 做 SPA 回退,也不负责修复 S3 权限问题。

7. 推荐的上线方式:蓝绿发布,而不是直接覆盖线上目录

如果每次上线都直接拿新的文件覆盖旧的 h5-app/,短期看似省事,长期一定踩坑。

我更推荐的方式是:

  • 旧正式版本放在 h5-app/
  • 新版本放在 h5-app-next/

上线流程大致是:

  1. 新版本先上传到 h5-app-next/
  2. 新的 CloudFront 指向 h5-app-next/
  3. h5-app-next.example.com 做完整测试
  4. 验证没有问题后,再切正式域名

这样做的价值很明显:

  1. 正式流量不受影响
  2. 可以提前测试真实线上环境效果
  3. 有问题随时回滚

8. 正式切换时,域名应该怎么切

假设正式域名是:

1
h5-app.example.com

预发布域名是:

1
h5-app-next.example.com

推荐切换步骤如下。

8.1. 切换前准备

  1. 新版本已经上传到 h5-app-next/
  2. 新的 CloudFront 源站已经指向 h5-app-next/
  3. h5-app-next.example.com 已经完整测试通过
  4. 新 CloudFront 的证书已经覆盖 h5-app.example.com
  5. 新 CloudFront 已经配好 SPA 路由回退

8.2. 不推荐的做法

不要这么做:

  1. 先去旧 CloudFront 删除 h5-app.example.com
  2. 再去新 CloudFront 加上这个域名
  3. 最后再去 Cloudflare 改解析

这种做法中间会有空窗期,正式域名可能短暂不可用。

8.3. 推荐的做法

更稳的方式是通过 CloudFront 的“移动备用域名”能力,把正式域名从旧 distribution 迁到新 distribution。

迁移完成后,再去 Cloudflare 中把:

1
h5-app.example.com

这条 CNAME 的值改成新的 CloudFront 域名。

也就是说,“切 DNS”真正改的是:

1
旧 CloudFront 域名 -> 新 CloudFront 域名

不是把它改成 h5-app-next.example.com,而是改成预发布 CloudFront 背后的分配域名。

9. invalidation 到底是什么

部署过程中另一个高频问题是:S3 上明明已经是新文件了,为什么线上访问还是旧页面?

答案通常是:CloudFront 还缓存着旧内容。

invalidation 的作用就是让 CloudFront 的缓存失效。失效后,下次请求会重新回源拉最新文件。

9.1. 什么时候需要做 invalidation

以下场景建议做:

  1. 发布新版本后,希望用户尽快拿到最新首页
  2. 正式域名切换到新的 CloudFront 后,希望立刻生效
  3. 线上已经出现资源错配、页面异常、白屏等问题时

9.2. 一般刷哪些路径

常规发布时,至少建议刷新:

1
/index.html

如果已经出现明显异常,建议直接刷新:

1
/*

10. 为什么不能随便删除旧的静态资源

这一点是我这次踩坑之后感受最深的地方之一。

现代前端打包后,静态资源文件通常都带 hash,例如:

1
2
assets/js/index-abc123.js
assets/js/invitation-xyz456.js

如果发布新版本时直接执行这种命令:

1
aws s3 sync dist s3://bucket/path --delete

那旧版 hash 静态资源就会被删掉。

一旦用户设备里还缓存着旧版 index.html,它就会继续请求这些旧资源。如果这些资源已经不存在,页面就会异常。

10.1. 推荐的上传策略

更稳妥的顺序是:

  1. 先上传新的 assets/
  2. 不删除线上已有的旧 assets/
  3. 最后再上传新的 index.html
  4. 然后执行 CloudFront invalidation

这样做的好处是:

  1. 旧入口页仍然能拿到它依赖的旧资源
  2. 新入口页也能拿到新资源
  3. 可以显著减少新旧资源错配

11. 为什么浏览器正常,App 内 WebView 却报错

这类问题非常常见。

常见表现是:

  1. 浏览器里打开 H5 正常
  2. App 内打开同一个 H5 页面报错
  3. 错误类似:
1
'text/html' is not a valid JavaScript MIME type

11.1. 根本原因是什么

通常不是页面代码本身坏了,而是 App 内 WebView 缓存了某一版旧的 index.html

旧的 index.html 会去请求某些旧的 chunk 文件,例如:

1
assets/js/invitation-oldhash.js

如果这些旧 chunk 在发布时已经被删除,或者当前目录只保留了新的一套资源,就会出现:

  1. WebView 继续请求旧 JS
  2. 服务器找不到该文件
  3. 由于启用了 SPA 回退,又返回了 index.html
  4. 浏览器把 HTML 当成 JS 解析
  5. 最终报出 MIME type 错误

11.2. 为什么切回旧域名解析也不一定立刻恢复

这是很多人会疑惑的点。

DNS 切回旧环境,只能影响之后的新请求,但并不能保证:

  1. App WebView 会丢掉本地缓存的旧入口页
  2. CloudFront 或 Cloudflare 没有缓存旧结果
  3. 用户设备不会继续请求已经失效的旧 chunk

所以就算解析切回去了,App 里也不一定立刻恢复。

12. 遇到 MIME type 报错时怎么止血

如果线上已经出现:

1
'text/html' is not a valid JavaScript MIME type

我更推荐先做服务端补救,而不是第一时间让用户去清缓存或者重装 App。

12.1. 推荐止血步骤

  1. 保留当前线上目录,不要先删除旧文件
  2. 将当前最新版本的 assets/ 上传到正式目录
  3. 不要删除已有的旧 assets/
  4. 再上传当前最新版本的 index.html
  5. 如果手里还有前一版本的 assets/,也一并补回正式目录
  6. 对正式 CloudFront 执行:
1
/*

12.2. 为什么这招有效

因为它的本质是让旧版和新版的 hash 静态资源同时存在。

只要用户设备里缓存的旧入口页仍然能拿到它依赖的旧 JS,页面通常就能恢复,不一定非要让用户重装 App。

13. WebView 缓存一般会持续多久

这个问题没有统一答案。

WebView 的缓存时长通常取决于:

  1. Cache-Control
  2. Expires
  3. ETag
  4. App 是否做了额外缓存

也就是说:

  • 有可能几分钟
  • 有可能几小时
  • 有可能几天
  • 甚至一直到用户清缓存、重装 App 或客户端主动刷新

所以不能指望“等一会儿就好了”,更稳妥的方式还是服务端兼容旧资源。

14. 推荐的缓存策略

为了尽量减少 H5 发布后的缓存问题,入口页和静态资源最好使用不同的缓存策略。

14.1. index.html

建议短缓存或不缓存:

1
Cache-Control: no-cache, no-store, must-revalidate

14.2. assets/*.jsassets/*.css

建议长缓存:

1
Cache-Control: public, max-age=31536000, immutable

前提是这些静态资源文件名带 hash。

15. 一次完整的标准流程

最后给出一份我比较推荐的完整流程。

15.1. 预发布

  1. 将新版本上传到 s3://example-h5-bucket/h5-app-next/
  2. 确认新的 CloudFront 指向 h5-app-next/
  3. 检查 S3 公共访问权限或 CloudFront 回源权限
  4. 确认 h5-app-next.example.com 可访问
  5. 测试关键路由://dl/lucky-gifts/recharge/faq/invitation

15.2. 正式切换

  1. 确认新 CloudFront 证书已覆盖正式域名
  2. 迁移正式备用域名到新 distribution
  3. 在 Cloudflare 中将正式域名的 CNAME 指向新 CloudFront 域名
  4. 执行 invalidation
  5. 再次验证正式页面和关键路由

15.3. 回滚

如果新版本有问题:

  1. 将 Cloudflare 中正式域名的 CNAME 改回旧 CloudFront
  2. 对旧 CloudFront 执行 invalidation
  3. 验证首页和关键页面
  4. 保留新环境继续排查,不要立即删除新旧资源

16. 关键结论

最后总结几条我觉得最值得记住的结论。

16.1. H5 部署真正难的不是上传文件,而是缓存和路由

  • S3 只负责存静态文件
  • CloudFront 负责把静态文件发布成一个真正可访问的站点
  • Cloudflare 只负责 DNS,不负责替你修前端路由问题

16.2. 新建 S3 目录后,权限一定要顺手检查

  • 目录上传成功不代表能访问
  • 要检查 Bucket 公共访问权限或 CloudFront 回源权限
  • 否则新的目录即使存在,也可能始终 403

16.3. 正式发布推荐蓝绿思路

  • 新版本先放 h5-app-next
  • 测通过后再切正式域名
  • 保留旧环境,方便回滚

16.4. 不要急着删旧静态资源

  • 旧入口页可能还在用户 WebView 里缓存
  • 旧资源一旦删除,App 内非常容易直接报错
  • 新旧资源共存通常比“立刻清理旧资源”稳得多

16.5. 遇到问题时,优先补资源和刷 CDN

解决顺序更推荐这样:

  1. 先补齐旧版和新版静态资源
  2. 再做 CloudFront invalidation
  3. 再看是否需要让客户端清缓存

这样通常比一上来就要求用户重装 App 更稳妥。

0%