结论先行: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"
|
首请求 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"
|
第二次只要 1.36 秒,且是真实图片(200 + image/webp + 222KB)。说明 7.9 秒是冷请求,不是稳定慢。
冷请求慢,会不会是「实时加水印 + 缩放 + 转 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
| 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"
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; const WM_MAX_WIDTH = 2000; const WM_OPACITY = 0.65; const WM_PAD_RATIO = 0.02;
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) }; if (u[0] === 0xff && u[1] === 0xd8) { 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) { 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 }); } } };
|
避坑清单(给后来者)
- 跨境慢 ≠ 裂图主因:R2 在国内确实慢(节点在海外),但稳定 ~1–2s,微信抓得到。裂图的真正元凶是 Worker 冷启动的偶发长尾。
- 公众号链接别走 Worker:任何需要微信抓取的外链图,都该走纯静态直出。没办法水印就用微信自带的,毕竟CF免费,忍了吧。
总结
R2 做图床降本很好用,零 egress 真香。但把「实时图片处理」架在微信公众号的抓取链路上,冷启动就是隐形炸弹。如果你也在用 R2 + Cloudflare Workers 做图床、又被公众号裂图折磨,希望这篇能省下你几小时。