Cloudflare R2 图床在微信公众号排版时的踩坑

结论先行:R2 图床在公众号裂图的真凶,不是水印、不是跨境、也不是 Workers 的 CPU 上限,而是 Worker 冷启动 + 实时图片处理管线导致首请求超时。治本是「让公众号绕过 Worker,走 R2 原生直出」,同时用子域分流把水印保留给博客。本文复盘完整排查链路。

背景:为什么从 OSS 迁到 R2

我的博客图床原来用阿里云 OSS 存图。OSS 有个很香的特性——水印靠 URL 参数触发

1
2
无水印:https://macinorg-blog.oss-cn-chengdu.aliyuncs.com/blog/xxx.webp
带水印:https://macinorg-blog.oss-cn-chengdu.aliyuncs.com/blog/xxx.webp?x-oss-process=style/wechat-mp

同一份源图,博客用带水印链接、公众号用无水印链接,体验很顺。

但 OSS 出流量要钱,而 Cloudflare R2 零 egress 费。为了降本,我把图床迁到 R2。R2 是纯对象存储,没有「URL 参数触水印」能力,于是我用一个 Cloudflare Worker + Images binding 复刻 OSS 的体验:原图在 R2 里是干净的,访问时由 Worker 实时加水印、缩放、转 webp 再返回。

一切看起来很完美——直到我把文章推到公众号。

踩坑:公众号编辑器里大面积裂图

把 Markdown 转成公众号排版后推送,结果 9 张图裂了 5 张,只显示 4 张。网页端和预览都正常,唯独公众号编辑器里大面积裂图。

这立刻让我警觉:这是典型的「微信服务器抓取外链失败」。微信发文章时会把文中的外链图片抓回自己的服务器(mmbiz 域名)转存,抓取有超时限制。如果图片响应太慢,微信就放弃,显示裂图。

于是我开始排查,过程里踩了三个坑。

坑一:以为是 Worker CPU 超限(1102)

第一反应是:R2 图片处理是不是太重,触发了 Cloudflare Workers Free 套餐的 CPU 10ms 上限(超限报 1102)?

我用 curl 打了一次冷请求:

1
2
curl.exe -o NUL -w "ttfb=%{time_starttransfer} total=%{time_total}\n" --ssl-no-revoke "https://img.macin.org/blog/ShirleyIP16_IMG_7179.webp"
# ttfb=7.918917 total=7.921386

首请求 7.9 秒!这足以让微信超时。但再测一次:

1
2
curl.exe -o NUL -w "http=%{http_code} type=%{content_type} size=%{size_download}\n" --ssl-no-revoke "https://img.macin.org/blog/ShirleyIP16_IMG_7179.webp"
# http=200 type=image/webp size=227148

第二次只要 1.36 秒,且是真实图片(200 + image/webp + 222KB)。说明 7.9 秒是冷请求,不是稳定慢。

坑二:以为是水印/transform 太慢

冷请求慢,会不会是「实时加水印 + 缩放 + 转 webp」这条处理链太重?为了排除,我去 Cloudflare 后台翻了那次请求的日志:

1
2
3
4
5
6
7
{
"outcome": "ok",
"wallTimeMs": 670,
"cpuTimeMs": 8,
"response": { "status": 200 },
"cf": { "colo": "SEA", "country": "CN", "city": "Chongqing" }
}

关键三行:

  • cpuTimeMs: 8 —— 处理只花 8ms CPU,远低于 10ms 上限,根本没触发 1102
  • wallTimeMs: 670 —— 暖请求实际只跑 670ms
  • status: 200 —— 正常出图

这说明:水印和 transform 非常便宜,不是元凶。那 7.9 秒到底是什么?

坑三:真相是 Worker 冷启动

日志里的 colo: SEA 暴露了真相。第一次 curl 撞上了一个从未服务过该请求的 Cloudflare 节点,Worker 运行时要从零拉起、Images 绑定要首次初始化——Free 套餐的冷启动能到数秒。第二次请求落到已暖的节点,670ms 就搞定。

对照裂图现象就全对上了:

谁在访问 命中状态 结果
我的浏览器(看博客) 暖(我先访问预热了边缘缓存) 网页正常
微信爬虫(抓公众号图) (从它自己的节点冷抓) 撞上 7.9s 冷启动 → 超时 → 裂图

「裂 5 显 4」也吻合:4 张恰好命中了某处已暖缓存踩线通过,5 张大图全是冷请求被掐。

还有一个被排除的嫌疑——纯跨境慢。我实测 R2 节点在 LAX/SEA,国内访问要走跨境。但日志证明暖请求只要 ~1.3s,微信完全抓得到。所以跨境只是叠加项,不是裂图主因

验证根因:拿掉 Worker 就不裂

为了坐实,我新建了一个测试桶 + 新子域,直接绑 R2 原生直出(不经过任何 Worker),把同样的图重传公众号——结果一张都没裂

这彻底锁定:只要热路径上没有 Worker,公众号就不裂。所以,如果你用CF R2来建图床,不碰水印这个事情,也没有什么大问题。

治本方案:子域分流

光删 Worker 虽然不裂,但水印也没了(水印是 Worker 实时叠的)。我想要的是:

  • 公众号:无水印、不裂 → 走 R2 原生直出
  • 博客:带水印、防搬运 → 走 Worker 实时叠水印
  • 上传:PicGo 只传一次,不双传

最终方案是子域分流,几乎 1:1 复刻 OSS 的「同一源 + 不同链接」体验:

平台 链接 走向 结果
公众号 img.macin.org/blog/xxx.webp R2 原生直出 无水印、零裂图
博客 wm.img.macin.org/blog/xxx.webp Worker 实时加水印 带水印

关键认知:Worker 只服务 wm. 这一个子域。公众号用的 img. 完全不碰 Worker,所以永远不会撞冷启动。

CF 后台操作步骤

1. 生产桶去 Worker(根治裂图)

  • Workers & Pages → macin-image-worker域(Domains) → 删除 img.macin.org
  • R2 → macin-img → Settings → Custom Domains → Connect img.macin.org → 等 Active

2. 给 wm. 子域单独绑 Worker(只给博客)

  • Workers & Pages → macin-image-worker域(Domains) → 添加 wm.img.macin.org → 等 Active
  • 只绑 wm. 这一个子域,别绑 img.、别用 /* 通配

3. 验证

1
2
3
4
5
# 公众号(无水印,纯 R2 原生)
curl.exe -o NUL -w "http=%{http_code} type=%{content_type} size=%{size_download}\n" --ssl-no-revoke "https://img.macin.org/blog/xxx.webp"

# 博客(带水印,过 Worker;加 ?r=2 绕过边缘缓存看新效果)
curl.exe -o NUL -w "http=%{http_code} type=%{content_type} size=%{size_download}\n" --ssl-no-revoke "https://wm.img.macin.org/blog/xxx.webp?r=2"

最终的 Worker 代码

水印自动按「占输出图面积 5% + 固定右下角」计算,不再写死像素:

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// ===== 可调参数 =====
const WM_AREA_RATIO = 0.05; // 水印面积占输出图面积比例(5%)
const WM_MAX_WIDTH = 2000; // 输出图宽度上限(原图更窄则不放大)
const WM_OPACITY = 0.65; // 水印透明度
const WM_PAD_RATIO = 0.02; // 右下角留白 = 输出宽度的 2%

let _wm = null;
async function loadWatermark(env) {
if (_wm) return _wm;
const wm = await env.BUCKET.get('_wm/watermark.png');
if (!wm) { _wm = false; return null; }
const buf = await wm.arrayBuffer();
const s = getImageSize(buf) || { w: 300, h: 80 };
_wm = { buf, w: s.w, h: s.h };
return _wm;
}

function getImageSize(ab) {
const u = new Uint8Array(ab);
const dv = new DataView(ab);
if (u[0] === 0x89 && u[1] === 0x50 && u[2] === 0x4e && u[3] === 0x47)
return { w: dv.getUint32(16), h: dv.getUint32(20) }; // PNG
if (u[0] === 0xff && u[1] === 0xd8) { // JPEG
let off = 2;
while (off + 9 < u.length) {
if (u[off] !== 0xff) break;
const m = u[off + 1];
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc)
return { w: dv.getUint16(off + 7), h: dv.getUint16(off + 5) };
const len = dv.getUint16(off + 2);
if (len <= 0) break;
off += 2 + len;
}
return null;
}
if (u[0] === 0x52 && u[1] === 0x49 && u[2] === 0x46 && u[3] === 0x46 &&
u[8] === 0x57 && u[9] === 0x45 && u[10] === 0x42 && u[11] === 0x50) { // WebP
const fmt = String.fromCharCode(u[12], u[13], u[14], u[15]);
if (fmt === 'VP8X')
return { w: (u[24] | (u[25] << 8) | (u[26] << 16)) + 1, h: (u[27] | (u[28] << 8) | (u[29] << 16)) + 1 };
if (fmt === 'VP8 ')
return { w: (u[26] | (u[27] << 8)) & 0x3fff, h: (u[28] | (u[29] << 8)) & 0x3fff };
if (fmt === 'VP8L') {
const b = u[21] | (u[22] << 8) | (u[23] << 16) | (u[24] << 24);
return { w: (b & 0x3fff) + 1, h: ((b >> 14) & 0x3fff) + 1 };
}
}
return null;
}

export default {
async fetch(request, env) {
const url = new URL(request.url);
const key = decodeURIComponent(url.pathname.slice(1));
if (!key || key.endsWith('/')) return new Response('Not Found', { status: 404 });
const object = await env.BUCKET.get(key);
if (!object) return new Response('Not Found', { status: 404 });

const wm = await loadWatermark(env);
if (!wm) {
const rawFb = await object.arrayBuffer();
return new Response(rawFb, {
headers: { 'Content-Type': object.httpMetadata.contentType || 'image/webp', 'Cache-Control': 'public, max-age=86400' }
});
}

try {
const raw = await object.arrayBuffer();
const src = getImageSize(raw) || { w: WM_MAX_WIDTH, h: Math.round(WM_MAX_WIDTH * 0.75) };
const scale = src.w > WM_MAX_WIDTH ? WM_MAX_WIDTH / src.w : 1;
const outW = Math.round(src.w * scale);
const outH = Math.round(src.h * scale);

const wmRatio = wm.h / wm.w;
const wmWidth = Math.max(20, Math.round(Math.sqrt(WM_AREA_RATIO * outW * outH / wmRatio)));
const wmHeight = Math.round(wmWidth * wmRatio);
const pad = Math.round(outW * WM_PAD_RATIO);

const result = (await env.IMAGES.input(raw)
.transform({ width: WM_MAX_WIDTH, withoutEnlargement: true })
.draw(env.IMAGES.input(wm.buf), { width: wmWidth, height: wmHeight, bottom: pad, right: pad, opacity: WM_OPACITY })
.output({ format: 'image/webp', quality: 82 })).response();

return new Response(result.body, {
headers: { ...Object.fromEntries(result.headers), 'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800' }
});
} catch (err) {
return new Response('Watermark error: ' + (err && err.message ? err.message : String(err)), { status: 500 });
}
}
};

避坑清单(给后来者)

  1. 跨境慢 ≠ 裂图主因:R2 在国内确实慢(节点在海外),但稳定 ~1–2s,微信抓得到。裂图的真正元凶是 Worker 冷启动的偶发长尾。
  2. 公众号链接别走 Worker:任何需要微信抓取的外链图,都该走纯静态直出。没办法水印就用微信自带的,毕竟CF免费,忍了吧。

总结

R2 做图床降本很好用,零 egress 真香。但把「实时图片处理」架在微信公众号的抓取链路上,冷启动就是隐形炸弹。如果你也在用 R2 + Cloudflare Workers 做图床、又被公众号裂图折磨,希望这篇能省下你几小时。


Cloudflare R2 图床在微信公众号排版时的踩坑
https://macin.org/2026/08/19/r2-image-wechat/
作者
Macin CHEN
发布于
2026年8月19日
更新于
2026年8月19日
许可协议