使用 Cloudflare R2 + Workers 构建稳定、高性能的软件下载分发

本文记录了在实际项目中使用 Cloudflare R2 + Workers 构建软件下载分发体系时,从最初设计、踩坑、排错,到最终稳定方案的完整过程。内容包含 Cache Rules 与 Worker 缓存冲突的真实问题,适合直接作为生产级参考。


一、背景与目标

在为 AirTools 构建桌面端分发体系时,我希望实现:

  • 安装包(.exe / .dmg 等)全球 CDN 强缓存
  • 版本元信息(latest.json)实时更新、不被缓存
  • 不自建服务器,尽量使用 Cloudflare 原生能力
  • 缓存行为稳定、可预测,不出现随机 500

最终技术选型为 Cloudflare R2 + Cloudflare Workers


二、初始架构设计

目标结构如下:

1
2
3
4
5
Client

Cloudflare Edge
├── latest.json → Worker → R2(不缓存)
└── *.exe/*.dmg → Worker → R2 → Edge Cache(强缓存)

逻辑上并不复杂,但在实际落地过程中,问题主要集中在 缓存控制细节 上。


三、最早遇到的异常现象

1️⃣ 缓存始终不生效

即便返回了:

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

响应中仍反复出现:

1
cf-cache-status: DYNAMIC

2️⃣ 同一个 URL,返回结果不一致

对同一下载地址多次执行:

1
curl -I https://dl.airtools.app/v0.1.19/AirTools_0.1.19_x64-setup.exe

会出现:

  • 有时 200
  • 有时 500
  • 缓存状态随机变化

这在下载分发场景中是非常危险的信号。


3️⃣ Cloudflare Dashboard 偶发 502

在 Workers / Builds 页面中,Cloudflare 控制台本身也开始报:

1
API Request Failed (502)

这通常意味着某个 Worker 在边缘节点频繁返回 5xx。


四、Cache Rules 带来的隐藏问题(关键)

在排查过程中,一个非常容易被忽略的问题逐渐浮现:Cache Rules 与 Worker 的缓存逻辑发生了冲突

1️⃣ Cache Rules 的“误导性描述”

Cloudflare Cache Rules 中常见的选项包括:

  • 缓存资格(Eligible for cache)
  • 绕过缓存(Bypass cache)

这些规则的描述很容易让人误以为:

“只要标记为符合缓存条件,Cloudflare 就一定会缓存”

这是不准确的。

Cache Rules 只是在「是否允许缓存」这一层做判断,真正是否缓存,仍然取决于:

  • Worker 中是否使用 cacheEverything
  • 返回的 HTTP 状态码是否为 200
  • 实际响应头中的 Cache-Control

2️⃣ Cache Rules 覆盖 Worker Header

在早期配置中,我曾在 Cache Rules 中尝试:

  • 针对 .exe / .dmg 设置缓存
  • 或统一修改 Cache-Control

结果是:

  • Worker 返回的 Cache-Control 被规则覆盖
  • 错误响应(500)也被错误地标记为可缓存候选
  • 实际缓存行为变得不可预测

结论:当请求经过 Worker 时,Cache Rules 很容易成为“干扰项”。


3️⃣ 最终选择:让 Worker 成为唯一缓存决策点

在最终方案中:

  • 不再依赖 Cache Rules 控制下载缓存
  • Cache Rules 保持最小化甚至删除
  • 所有缓存判断统一在 Worker 中完成

这样可以保证:

缓存逻辑是可读的、可调试的、可预测的。


五、真正的根因总结

综合排查后,问题根因集中在三点:

  1. Worker 未处理回源失败,导致 500 被返回
  2. 500 响应参与缓存资格判断,污染后续请求
  3. Cache Rules 与 Worker 缓存逻辑发生冲突

六、最终稳定方案设计

核心原则只有一句话:

只缓存成功的安装包,失败请求永不进入缓存路径

缓存策略表

类型 是否缓存 说明
.exe / .dmg / .zip 强缓存 30 天
latest.json 永远回源
500 / 502 禁止缓存

七、最终 Worker 实现(生产可用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url)
const path = url.pathname

const ORIGIN = 'https://dl.airtools.app'
const originUrl = ORIGIN + path + url.search

if (path.endsWith('/latest.json')) {
let res
try {
res = await fetch(originUrl, { cf: { cacheEverything: false } })
} catch {
return new Response('Upstream error', { status: 502 })
}

const headers = new Headers(res.headers)
headers.set('Cache-Control', 'no-store, no-cache, must-revalidate')

return new Response(res.body, { status: res.status, headers })
}

const CACHEABLE_EXT = [
'.dmg', '.exe', '.msi', '.AppImage',
'.deb', '.rpm', '.zip', '.tar.gz',
'.sha256', '.sig'
]

const isCacheable = CACHEABLE_EXT.some(ext =>
path.toLowerCase().endsWith(ext)
)

let res
try {
res = await fetch(originUrl, {
cf: {
cacheEverything: isCacheable,
cacheTtl: isCacheable ? 60 * 60 * 24 * 30 : undefined
}
})
} catch {
return new Response('Upstream fetch failed', {
status: 502,
headers: { 'Cache-Control': 'no-store' }
})
}

const headers = new Headers(res.headers)

if (isCacheable && res.status === 200) {
headers.set('Cache-Control', 'public, max-age=2592000, immutable')
} else {
headers.set('Cache-Control', 'no-store, no-cache')
}

return new Response(res.body, { status: res.status, headers })
}
}

八、最终验证结果

  • latest.json

    • cf-cache-status: DYNAMIC
    • 实时回源
  • 安装包下载

    • cf-cache-status: HIT
    • age 持续增长
    • 支持断点续传

九、经验总结

  1. 不要缓存失败响应
  2. Worker 必须处理 fetch 异常
  3. Cache Rules 不适合与复杂 Worker 缓存逻辑混用
  4. 一个域名只承担一种角色(Worker 或 R2)
  5. 判断缓存是否生效,以 cf-cache-status 为准

十、结语

Cloudflare R2 + Workers 非常适合用于软件下载分发,但稳定性来自于细节,而不是默认配置

只要把缓存决策收敛到 Worker 内部,并明确区分“可缓存内容”和“实时内容”,这套架构可以长期稳定运行,且维护成本极低。

Comments

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×