html2pdf-chrome:一个 HTML 转 PDF 的 Go 库 / 服务

Go + Chrome Headless + CDP,支持连接池复用、可组合等待策略、Docker 部署。
约 3400 行 Go,无 Node 依赖。

仓库反正是开源的,写这个贴子主要分享一下技术方案和踩过的坑,给有类似需求的同学一个参考。
现阶段效果最佳的,直接想使用的同学请看章节不用 Go 也能用,仅下载html2pdf-server,在服务器安装chrome即可。
仓库:github.com/PiZhai/html2pdf-chrome

解决了什么问题

服务端 HTML 转 PDF 的几种常见方案:

方案 优势 短板
Puppeteer/Playwright 渲染保真,生态成熟 需要 Node 运行时,启动开销大
wkhtmltopdf 资源开销低 不支持现代 CSS(Grid/Flexbox)、Canvas
纯 Go 库 (unidoc 等) 纯 Go,静态二进制 不是真实浏览器,复杂页面易翻车
Selenium WebDriver 真实浏览器 重,速度慢

我们的需求场景:日均数万次转换,Go 技术栈,需要在 Kubernetes 上运行,页面含 Canvas 图表和 Web Font。

结论:必须用真实浏览器引擎,但不想引入 Node 运行时。方案是直接走 CDP 协议驱动 Chrome Headless。

实现原理

一句话:Go 程序通过 WebSocket 连接 Chrome 的调试端口,用 CDP 协议下发 Page.navigatePage.printToPDF 命令,Chrome 负责渲染,Go 程序只做编排。

Request → Go → WebSocket → Chrome Headless → Page.printToPDF → PDF 文件

CDP 通信层用了 chromedp,Go 生态里最成熟的选择。

连接池设计(重点)

v0.1 版每次调用启动一个 Chrome,用完就关。适合 CLI,不适合服务端——Chrome 冷启动 1-3 秒,内存开销几百 MB。

v0.2 核心改动:Chrome 实例池

Pool {
    idle:       []*Instance    // 空闲队列
    activeCount int             // 使用中
    totalCount  int             // 存活总数
    mu:         sync.Mutex
    cond:       *sync.Cond      // 阻塞等待
}

Acquire(获取实例):

  1. 从 idle 队列 pop → 做健康检查(HTTP 探 /json/version
  2. 空闲 + 未达上限 → 新建实例
  3. 已达上限 → cond.Wait() 阻塞,等别人归还

Release(归还实例):

  1. taskCount >= MaxTasksPerInstance(默认 100) → 销毁重建(防 Chrome 内存泄漏)
  2. 不健康 → 销毁重建
  3. 正常 → 放回 idle 队列,cond.Broadcast() 唤醒等待者

后台 reaper:每 30 秒扫一次,空闲超过 IdleTimeout(默认 5 分钟)的被回收,但至少保留 MinInstances 个热备。

关键细节:

  • 新建实例前先 totalCount++ 预留槽位,防止并发冲破上限
  • 每个请求用 chromedp 创建独立 Tab(NewContext),Tab 之间不共享 cookie/storage
  • Tab 关闭后 Chrome 自己回收内存,不会累积

等待策略

静态页面简单——等 readyState === "complete"。但实际页面经常需要等更多东西:

Navigate
  → WaitReady("body")
  → WaitDocumentReady(15s)            // readyState === "complete"
  → WaitFontsReady(10s)               // document.fonts.status === "loaded"
  → [可选] WaitNetworkIdle(idle, timeout)  // 网络空闲 + 静默期
  → [可选] WaitVisible(selector)       // CSS 选择器可见
  → [可选] WaitForExpression(expr, timeout)  // 自定义 JS 条件
  → printToPDF

网络空闲检测的原理:Enable Network domain → 监听 requestWillBeSent / loadingFinished / loadingFailed → 维护 inflight counter → 归零后静默 500ms → 判定空闲。

忽略了 WebSocket、data:blob: 请求。如果页面有 SSE 长轮询,用 -wait-expression 替代:

-wait-expression "document.querySelector('#chart') !== null"

不用 Go 也能用

虽然库本身是 Go 写的,但实际使用者完全不需要懂 Go。

启动一个 HTTP 服务(一行命令),然后任何语言都能用:

# 启动服务
html2pdf-server -addr :8080 -max-instances 4 -min-instances 2 -no-sandbox

# 任何语言调用(curl / Python requests / Java HttpClient / Node fetch / ...)
curl -X POST localhost:8080/convert \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com","paper":"a4"}' \
  -o output.pdf

没有语言限制,没有 SDK 依赖。Docker 镜像拉下来就能跑,K8s 上就是一个 Deployment + Service。

CLI / HTTP / Go 库三种模式

三种模式复用同一条渲染链路:

CLI(本地 / 脚本):

html2pdf-chrome -url https://example.com -paper a4 -landscape -out output.pdf

HTTP 服务(微服务):

curl -X POST localhost:8080/convert \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com","paper":"a4","waitNetworkIdle":true}' \
  -o output.pdf

服务内部用连接池,/health 端点返回 {"idle":2,"active":1,"total":3}

Go 库(嵌入式):

converter, _ := html2pdf.NewConverter(html2pdf.ConverterConfig{
    MaxInstances: 4, MinInstances: 2,
})
defer converter.Close()
converter.Convert(html2pdf.Request{
    URL: "https://example.com", OutputPath: "./out.pdf",
})

Docker 部署

docker build -t html2pdf-chrome .
docker run --rm html2pdf-chrome -url https://example.com -out /app/output/out.pdf

镜像特点:

  • Noto CJK + STIX 数学 + DejaVu + Emoji → 中英数全覆盖
  • --no-sandbox 默认开启(容器本身是隔离层,不需要 Chrome 再开沙箱)
  • 另有 Dockerfile.cn(Chromium + 阿里云镜像),给国内服务器用

一些踩坑记录

  1. Chrome headless 模式:用 --headless=new 而不是旧版 --headless,新版的渲染行为和 GUI 模式一致,Canvas/SVG 不会出现偏移。
  2. WebSocket 断开重连:chromedp 的 RemoteAllocator 不负责断线重连,每次任务结束 cancel() 关闭 Tab,下次任务重新 Connect 建新 Tab,避免重连问题。
  3. PDF stream 模式:默认 base64 返回对大 PDF(50MB+)可能 OOM,stream 模式通过 IO 流分块读取,适合大文件场景。
  4. 进程退出信号:Chrome 有时不会正常退出,Close() 直接用 Kill 而不是 Signal(os.Interrupt) 确保清理。不优雅但可靠。
  5. 共享内存:Docker 里 Chrome 需要 /dev/shm,否则页面加载会 hang。K8s 里需要设 shm-size 或挂载 emptyDir

当前状态和规划

当前约 3400 行 Go,34 个文件。已经能跑生产,但还有几个明显短板:

  • 错误处理是扁平 wrap,没分类。计划加 ErrorCode 枚举,让调用方能做类型判断(启动失败 vs 渲染超时)
  • 缺可观测性:没 metrics,没结构化日志。需要加 Prometheus 指标和 slog
  • 平台验证:路径查找逻辑写了 macOS/Linux/Windows 三套,但只在 macOS 测过
  • 没有发布工程:缺交叉编译脚本、版本注入、自动发版

欢迎 issue 和 PR。


仓库:github.com/PiZhai/html2pdf-chrome

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐