先说结论:给每个商户人工出小程序页面设计图这件事,商户一上量就是不可能完成的体力活。我们把它做成了服务:HTML 模板注入商户数据,交给 Playwright 驱动的无头 Chromium 渲染,整页截图直接出图。这套东西的难点不在 API 调用,而在串行队列、硬超时、内存账这些工程纪律。本文是完整复盘,代码均为脱敏伪代码。
一、背景:设计图是人肉体力活
我们做多租户小程序 SaaS,每个商户拿到的都是带自己品牌的独立小程序。签约后、开工前,商户最关心的一件事是"我的店做出来长什么样"。早期这套"效果图"是人工出的。
把人工出图的账摊开算:打开设计工具加载模板约 40 秒;替换品牌名、主色辅色并核对一遍约 2 分钟;换菜品图、调分类结构约 3 分钟;导出 PNG 再逐张检查约 1 分钟——单页稳定在 7 分钟上下,一家店按 9 个页面算是 63 分钟。再算上商户来回确认、改版重导,一家店要吃掉会出图的同事大半天。
真正把问题钉死的是某个周三:当天集中签约 11 家,11 × 9 = 99 张图,按 7 分钟一张是 693 分钟——将近 12 个小时的纯出图时间。全组会出图的就两个人,其它活全停也得干满一天;加上确认与改版,图到第三天才齐。商户群里第二天就来了一句:"效果图好了吗?老板想先发个朋友圈。"出图速度第一次成了交付瓶颈,"设计图"也第一次从设计问题变成了工程问题。
回头看,这活儿没有任何创造性:改的全是数据,动的是同一套模板。而"模板 + 数据 → 出图",恰好是浏览器最擅长的事。方案自然浮现:让无头浏览器替人"截屏"。流水线跑通后,单任务渲染加截图 8 秒上下,99 张排进队列 15 分钟内跑完,高峰期一夜几百张也不需要有人守着——同一道题的另一份答案。
二、架构:模板、数据、无头浏览器
整体链路一句话讲完:商户配置 → 模板注入 → Chromium 渲染 → 截图 → 对象存储。
| 层 | 内容 | 谁维护 |
|---|---|---|
| 模板层 | 每种页面类型一个 HTML 模板(首页、点单、券页……) | 随设计规范迭代,版本走 Git |
| 数据层 | 每家商户一份 JSON:品牌名、色板(主色/辅色)、菜品图、分类结构 | 商户配置,不是代码 |
| 渲染层 | Node + Playwright 驱动 Chromium,移动端视口渲染,整页截图 | 渲染服务,无人值守 |
新商户接入 = 新增一份配置,模板零改动。两层之间定死 schema:字段缺失当场报错,不让一张"缺 logo 的图"流到商户面前。模板资源与商户图片一律走白名单域名,渲染时其它请求全部 abort——既是安全边界,也让 networkidle 的等待不被无关请求拖长。
三、工程要点:纪律比技巧多
1. 串行队列。 单容器同一时刻只跑一个 Chromium。理由很朴素:一个 Chromium 渲染峰值要吃几百 MB 内存,串行让峰值可预测、不会 OOM;渲染是批量任务不是实时接口,排队延迟完全可接受。
队列没有引入消息中间件,直接用数据库一张任务表实现,核心是一个状态列加一个心跳时间戳:
-- 脱敏伪代码:队列表核心列
job_id | template_name | merchant_data | status | retry_count | locked_at
-- status: pending / running / done / failed
worker 主循环每轮只取一条:一条带 FOR UPDATE SKIP LOCKED 的 UPDATE,把最老的一条 pending 置为 running 并写入 locked_at。SKIP LOCKED 保证将来多副本也互不抢同一条;locked_at 当心跳用——worker 崩了任务会卡在 running,看门狗把超过 5 分钟没心跳的任务重置回 pending,等于数据库层面的兜底超时。取到任务后循环里绝不再取第二条:单并发是渲染服务自己立的规矩,靠代码自觉,不靠数据库强制。
2. 模板与数据分离,版本可追溯。 模板改版不碰商户数据,商户换图不碰模板,出问题好定位。产物文件名里记录所用模板版本号,设计规范升级后,任何一张旧图都能回答"出自哪版模板"。
3. 失败重试与硬超时。 参数逻辑摆出来:正常任务 8 秒内完成,硬超时给 30 秒,约 4 倍余量——卡死任务宁可错杀,不能让它占着队列;超时后连浏览器带进程树一起杀。失败退避重试 3 次,间隔 5 秒、30 秒、2 分钟,防止外部图片源抖动时连环撞墙;3 次都败标记 failed 交人工。渲染幂等:同一任务重跑产出同名文件直接覆盖,重试不需要清理逻辑。
4. 产物命名与清理。 命名带确定性:商户/模板/版本.png;产物全部进对象存储、不落本地盘,容器随时可弃;定时任务清掉超期旧版本。
四、伸缩的诚实账
并发上限不靠猜,靠算。单实例峰值一步步推:Node 主进程常驻约 150 MB,Chromium 空载约 250 MB,长页面渲染时图片解码是大头、瞬时再加 300 MB 上下——峰值合计约 700 MB。容器限额 1 GB,余量三成,结论就是单并发。想在单机塞两个 Chromium?理论峰值 1.4 GB 直接越过限额,OOM 会把正在渲染的那个任务一起带走——批量场景下"慢而稳"完胜"快而崩"。
吞吐的账同样直白:单副本 8 秒一张,一小时 450 张。日常一天新签 20 家共 180 张图,单副本 25 分钟消化完;假设活动高峰要求一小时出 2000 张,2000 ÷ 450 ≈ 4.4,上取整开 5 个副本,每个副本内部依旧串行。副本数 = 目标吞吐 / 单副本吞吐,小学数学,不需要玄学。
五、伪代码:渲染 → 截图流水线
// 脱敏伪代码:单 worker 串行消费渲染队列(Node + Playwright)
async function renderOne(job) {
const browser = await chromium.launch({
args: ['--no-sandbox', '--disable-dev-shm-usage'], // 容器内标配
});
try {
const page = await browser.newPage({
viewport: { width: 375, height: 812 },
deviceScaleFactor: 2, // 2 倍图,出图清晰
});
await page.route('**/*', (route) => // 请求白名单:只放行模板资源与商户图片域
ALLOWED_HOSTS.has(new URL(route.request().url()).host)
? route.continue() : route.abort());
const html = renderTemplate(job.templateName, job.merchantData); // 注入品牌名/色板/菜品图
await page.setContent(html, { waitUntil: 'networkidle', timeout: 20_000 });
await page.evaluate(() => document.fonts.ready); // 字体就绪再截
await page.evaluate(scrollThrough); // 分步滚到底,触发懒加载
await page.waitForTimeout(300); // 留解码余量
const shot = await page.screenshot({ fullPage: true });
await storage.put(objectKey(job), shot); // 商户/模板/版本.png
} finally {
await browser.close(); // 无论成败都回收,防僵尸进程
}
}
// 队列主循环:硬超时 + 退避重试,始终单并发
const BACKOFF_MS = [5_000, 30_000, 120_000];
while (true) {
const job = await queue.take(); // 取最老一条 pending 并置 running
for (let i = 0; i < 3; i++) {
try { await withTimeout(renderOne(job), 30_000); break; }
catch (e) { await sleep(BACKOFF_MS[i]); } // 5s/30s/2min,末次失败落 failed
}
}
每个任务起一个全新浏览器是刻意为之:实例级隔离,内存不跨任务累积,慢一点换的是省心。
六、踩坑清单
- 中文全是方块。 现象:第一版截图里中文全渲染成"口口口",英文和数字正常。定位:本地跑同一份代码完全没事,差异只能出在镜像——基础镜像没装 CJK 字体;装完字体后偶发仍方块,再查是 web 字体异步加载,截图抢在字体就绪之前。修法:镜像里装中文字体(如思源黑体),截图前
await page.evaluate(() => document.fonts.ready),两道都上才算修完。
- 容器里 Chromium 起不来。 现象:本地一切正常,进了容器启动即崩。定位:容器日志是共享库缺失的加载错误——精简基础镜像砍掉了 Chromium 的运行时依赖;另一路症状是能启动但渲染中途崩,根因在容器默认
/dev/shm只有 64 MB。修法:换 Playwright 官方镜像,或执行其依赖安装命令补齐;启动参数固定带--no-sandbox与--disable-dev-shm-usage。
- 懒加载没触发就截图。 现象:整页截图首屏正常,往下全是灰色占位块。定位:模板图片走"滚动进可视区才加载"的策略,无头模式里没人滚动,触发条件永远不满足。修法:截图前用脚本分步滚到底部,每步稍作停留让图片请求发出,等解码完成再补 300 ms 余量;更彻底的做法是模板加"渲染模式"参数直接关掉懒加载——出图场景本来就不需要它。
三句话总结
- 设计图自动生成的本质是"模板 + 商户数据 + 无头浏览器整页截图",Playwright 把工程量压到一天级别。
- 稳定来自纪律而非技巧:容器内单实例串行、硬超时加幂等重试、产物确定性命名,渲染服务才能无人值守。
- 伸缩别玄学:峰值内存估算定单机并发,吞吐不够加副本——批量渲染拼的是不挂,不是单机多开。
运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。
