做文档转换这件事,每个后端团队大概都有一部血泪史。合同附件要转PDF,报表导出要转PDF,前端传上来的HTML要变成发票,浏览器自带的打印一缩水版式就乱。如果你现在还在为“把一个文件变成PDF”写各种底层补丁,那我建议你看一眼Gotenberg这个项目。它不是库,不是SDK,而是一个开箱即用的文档转换API服务——你把一个URL或者一个docx文件丢给它,它通过HTTP接口给你返回一份质量稳定的PDF。对开发者来说,它解决的是最现实的问题:你不用自己维护转换引擎,不用为每个格式适配写一堆兼容代码,只需要在自己的系统里发一个POST请求。这套方案适合后端开发者,适合前端偶尔要处理报表导出的团队,也适合需要在高并发环境里批量生成文档的服务。下面我把部署、调用和踩过的坑完整过一遍。1. 为什么选Gotenberg:先想清楚你们要的是“工具”还是“服务”1.1 文档转换的真正难点不在转换,在“复现”很多团队一开始会自己造轮子,在代码里直接调用工具命令,或者用开源库做转换。但做一段时间就会发现,格式转换本身其实不难,难的是转换出来的效果能不能稳定复现。比如你写好的一个HTML模板,在不同浏览器里打开,页边距、行高、分页位置全不一样。打印成PDF之后,客户可能收到一个边距不对、页码缺失的版本。再比如办公软件里排得整整齐齐的docx,如果不做兼容处理,转PDF后表格可能被削掉一截,字号也会悄悄变化。这些问题的根源在于,“渲染”这件事本身就耦合了大量周边依赖:字体、CSS支持、打印样式、分页控制、办公软件的页面设置。你需要的不是一段调用转换器的函数,而是一个能复现稳定渲染结果的运行时。Gotenberg把这类运行时打包成了一个容器。它的工作方式有点像一个专门做转换的小饭馆:你自己不用去菜市场买菜、不用琢磨火候,只要把食材(文件或URL)递进去,过一会儿它给你端出一道做好的菜(PDF)。这对开发团队来说,价值不在于省了几个命令,而在于把“转换”这个脏活累活从业务代码里剥离出去了。1.2 双引擎设计:一个容器覆盖三种需求Gotenberg核心有两套转换引擎。第一套是Chromium,负责“网页形态的输入”。URL转PDF或者HTML转PDF,本质上是让一个无声无息的Chromium打开页面,用它的打印引擎进行渲染。因为Chromium对CSS、JavaScript的支持很完整,所以你前端能用浏览器展示出来的效果,它基本都能呈现出来。第二套是LibreOffice,负责“办公文档形态的输入”。docx、xlsx、pptx、odt这类文件,通过LibreOffice的无头模式转换为PDF。这套引擎在处理办公文档时兼容性很好,尤其适合把业务报表、合同、幻灯片这类文件变成最终交付形态。除了这两套引擎,Gotenberg还把PDF层面的操作封装成了独立接口。合并PDF、拆分PDF、加密PDF,都有对应的路由。这三类能力被统一封装成REST API之后,好处非常直接:你不需要在本地安装三个工具链,不需要管理它们的依赖冲突,不需要关心谁先启动谁后启动。你只对着一个HTTP端点发请求就够了。1.3 对比三种常见实现思路:为什么Gotenberg更省心我自己以前也试过几种方案,这里把它们的优缺点摆出来对比一下,方便大家按自身情况做决策。方案优点缺点适合场景自己维护Puppeteer/Playwright脚本可定制性强,所有逻辑都在自己手里要管浏览器版本、字体、并发、进程回收,生产环境坑很多有专门人力维护,且转换需求非常定制化直接调用LibreOffice命令行实现简单,适合小批量离线转换接口化之后要自己处理调度、权限、错误恢复内部临时工具,不涉及对外服务使用Gotenberg开箱即用、容器化、API化,前端后端都能快速接入自定义程度受项目接口约束,需要理解它的设计约定大部分需要稳定文档转换能力的业务系统我见过不少项目,一开始用Puppeteer写得挺顺手,等上了生产环境就开始出事:并发一高,无头浏览器进程互相干扰;服务器一重启,字体缓存或者临时目录没初始化好,转换结果变得奇奇怪怪。Gotenberg把这些常见问题收敛到了容器的生命周期里,配合进程隔离和资源限制,至少不用你从零趟一遍这些坑。2. 部署一个可用实例:版本、镜像与关键配置2.1 镜像版本怎么选:别让latest坑了你Gotenberg官方提供了Docker镜像,安装方式很直接。不过这里我要多说一句:不要图省事直接用latest标签。镜像的latest会跟随项目版本更新,而Gotenberg的大版本之间存在接口差异,一旦你升级大版本,之前写好的请求字段可能不兼容。我自己习惯的做法是固定到一个大版本,比如使用gotenberg/gotenberg:7这个标签,它会跟随当前7.x系列的小版本更新,但不跨大版本。如果你的团队比较谨慎,也可以直接锁死到某一个具体小版本。锁死版本之后,每次升级都要重新跑一遍历史转换用例,确认没有回归再上生产。这不是折腾,而是负责任的生产态度。特殊环境下可能还需要自定义镜像。比如容器默认不带一些中文字体,后面我会专门讲字体问题,那时候你就需要基于官方镜像再包一层,把需要的字体打进去。2.2 最小化部署:先让服务跑起来最简单的启动命令是:docker run -d --name gotenberg \ -p 3000:3000 \ -e TZ=Asia/Shanghai \ gotenberg/gotenberg:7容器内服务默认监听3000端口,宿主机的3000端口映射给它。如果宿主机的3000端口被占了,可以换一个宿主机端口:docker run -d --name gotenberg \ -p 8088:3000 \ gotenberg/gotenberg:7这时你的服务地址就是http://localhost:8088。我建议在启动时显式设置时区环境变量,比如TZ=Asia/Shanghai。这个细节平时不起眼,但如果你生成的PDF带页眉页脚、需要显示日期时间,时区不一致会导致时间对不上,线上排查起来非常绕。对于需要用编排工具管理团队,可以参考这样一个最小化的docker-compose配置:services: gotenberg: image: gotenberg/gotenberg:7 restart: unless-stopped ports: - "3000:3000" environment: - TZ=Asia/Shanghai mem_limit: 1g2.3 安全设置:别把裸奔服务放到公网这是很多初次接触者容易忽略的一点。Gotenberg官方设计里,默认并没有做复杂的鉴权,它是给你放在可信内网环境里使用的。你可以在前面加一层网关做访问控制,或者让业务服务作为唯一入口。如果你的团队确实需要为它增加认证能力,可以通过官方提供的用户管理配置来实现,也可以选择在反向代理层统一做。我的实际经验是:不管用什么方式,永远不要把没有任何访问控制的Gotenberg实例直接暴露到公网路由器上。否则别人可以扫描到你的端口,不断提交转换任务,消耗服务器资源;更麻烦的是它可能被当作一个盲转工具,帮你做一些你没预料到的上传下载。这类滥用问题一旦出现,处理起来比配置鉴权要痛苦得多。2.4 资源预估与并发控制:经验值仅供参考Gotenberg虽然部署简单,但它本质上是把浏览器和办公套件跑在容器里,这两个都是吃内存大户。根据我的经验,常规HTML页面转PDF,峰值内存大约在100到200MB之间;如果页面有大量高清图片或者复杂布局,可能到300MB以上。LibreOffice转换一个几十MB的文档,占用的内存会更高。所以生产环境里一定要做两层控制。第一层是容器本身,加上内存限制,避免单个任务把整台机器拖垮。第二层是业务入口,控制同时运行的转换任务数量。比如一台4GB内存的服务器跑一个容器,我一般会把并发控制在八个左右,并且每个上传的文档大小尽量控制在合理范围内。这个数据不是精确值,不同文档差异很大,但可以作为一个起点,你根据实际压测结果再调。3. 把接口用熟:从URL转PDF到批量转换3.1 最常用的一招:URL直接转PDFGotenberg提供了一系列以/forms/开头的接口,其中使用频率最高的就是URL转PDF。这个能力非常适合做网页存档、报告生成、网页打印预览。最朴素的调用方式是用curl:curl -X POST http://localhost:3000/forms/chromium/convert/url \ -H 'Content-Type: multipart/form-data' \ -F 'url=https://example.com/' \ -o example.pdf这里有个容易想当然的地方:为什么不用JSON,而是用multipart/form-data?因为Gotenberg的接口设计允许你在一个请求里同时传URL、文件和其他控制参数。multipart/form-data天然支持文件上传和普通字段混合,对这类场景最合适。常用参数里,我重点说几个:waitDelay:单位是秒。比如页面加载后有1秒的动画,你希望等动画结束再打印,就传waitDelay=1。注意,不是毫秒,是秒。我第一次用的时候想当然传了5000,结果等了五千秒,反应过来人都傻了。waitForExpression:这个更强大。它可以传一段JavaScript表达式,只有表达式结果为真时才执行打印。适合处理异步渲染的页面,比如页面里有一段数据要等接口返回。实际使用时,目标页面需要配合设置一个状态,比如window.status = "ready",然后你传waitForExpression=window.status === "ready"。这样转换服务就会一直等待这个条件成立。paperWidth、paperHeight、marginTop等:控制纸张尺寸和页边距。再配合几个和打印相关的字段:字段作用典型值printBackground是否打印背景颜色和背景图片truedisplayHeaderFooter是否显示页眉页脚falseheaderHtmlFile页眉的HTML模板文件header.htmlfooterHtmlFile页脚的HTML模板文件footer.htmllandscape是否横向打印false如果你要生成带页码的PDF,通常会用到footerHtmlFile,在模板里写上一个带页码样式的HTML,转换时它会自动套到每一页。3.2 HTML文档转PDF:模板生成的利器除了传URL,还可以直接传HTML文件给Gotenberg,这就是/forms/chromium/convert/html接口。这个接口特别适合做业务模板,因为你可以把动态数据和静态模板拼装成一段完整HTML,再交给Gotenberg。一个典型的调用方式:curl -X POST http://localhost:3000/forms/chromium/convert/html \ -F 'index.html=@./report.html' \ -F 'logo.png=@./logo.png' \ -F 'style.css=@./style.css' \ -o report.pdf这里注意:入口文件必须叫index.html,其他引用的资源文件作为额外的表单字段一起提交。Gotenberg会以index.html为主体,把同一次请求中的其他文件作为它的关联资源加载。这个设计对前端模板开发特别友好:你在本地怎么写网页,这里就怎么组织文件。在写HTML模板时,请务必配合打印样式来设计。下面是一个最小可用示例:<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <style> @page { size: A4; margin: 20mm; } @media print { body { -webkit-print-color-adjust: exact; } .page-break { page-break-before: always; } } </style> </head> <body> <h1>合同内容</h1> <p>这里是正文。</p> <div class="page-break"></div> <p>这是第二页的内容。</p> </body> </html>@page规则定义了纸张大小和页边距,page-break-before: always用于强制分页。这些CSS打印特性是转换结果是否清爽的关键。不写打印样式的话,浏览器默认打印效果往往不如人意。3.3 办公文档转换:docx、xlsx、pptx一网打尽对于办公文档,用到的是/forms/libreoffice/convert接口。这个接口结合LibreOffice引擎,可以处理Word、Excel、PPT等格式。最简单的调用:curl -X POST http://localhost:3000/forms/libreoffice/convert \ -F 'files=@合同.docx' \ -o 合同.pdf这里字段名是files,注意是复数。接口支持同时提交多个文件。默认情况下,多个文件只取第一个进行转换,其他文件会被忽略。但如果加上merge=true参数,它会把所有文件依次转换成PDF,然后合并成一个PDF文件返回。这个能力做多文档合并非常省事,比如你需要把多个合同附件合并成一个完整PDF发给客户,就不用在业务层再去调PDF合并接口了。还有一个字段是landscape,设置为true时,文档会以横向页面输出。xlsx转PDF时如果列特别多,这个参数很有用。办公文档转换有一点坑:字体缺失。我在下一章详细说。这里先给一个经验:无论docx还是xlsx,如果原文档里用了特殊字体,尽量保证容器里有对应字体,否则PDF会退化成默认字体的样式。3.4 把Gotenberg当成PDF工具箱用除了各种转PDF,Gotenberg还提供几个PDF处理接口。合并PDF:curl -X POST http://localhost:3000/forms/pdf/merge \ -F 'files=@1.pdf' \ -F 'files=@2.pdf' \ -o merged.pdf拆分PDF:curl -X POST http://localhost:3000/forms/pdf/split \ -F 'file=@文档.pdf' \ -o split.zip拆分接口返回的是一个zip压缩包,里面包含按页拆开的多个PDF文件。这个细节和常规理解不同,我第一次测试时以为会返回多个文件,结果收到的就是zip。加密PDF:curl --request POST http://localhost:3000/forms/pdf/encrypt \ -H 'Content-Type: multipart/form-data' \ -F 'file=@文档.pdf' \ -F 'userPassword=123456' \ -F 'ownerPassword=123456' \ -o encrypted.pdf这里的userPassword是用户打开密码,ownerPassword是权限密码。如果只想限制打印、复制等操作权限,可以只设置ownerPassword。这个接口对内部系统生成受控文件挺有用的。3.5 批量转换与回调:从同步等待变成异步流水线Gotenberg的HTTP接口本质上是同步的。你发一个请求,它转换完成,再把结果返回给请求方。这对于单个转换没问题,但如果你要给用户批量生成几百份PDF,同步等待就会成为灾难。一种常见做法是业务后台维护一个任务列表,逐个把任务交给Gotenberg。每个请求设置合理的超时时间,处理完一个再处理下一个。这种方式实现简单,但吞吐量有限。另一种做法是利用Gotenberg自带的结果回调能力。你在表单字段里传一个webhook_url,它会在转换完成之后,主动向这个URL发送一个回调通知。这样你的业务服务不需要一直保持连接等待结果,可以先把请求交给Gotenberg,然后自己去处理别的事情。比如用户上传文件后,后端先把任务写入数据库,然后调用Gotenberg并带上回调地址,等Gotenberg转换完成回调时,再把PDF存储起来并发邮件通知用户。这套模式非常适合把“用户上传文件-转PDF-存OSS-发邮件”这类链路解耦。不过不同的Gotenberg版本对webhook支持程度不完全一样,字段名也可能有细微差异,接入前一定先看一下当前版本的官方文档确认一下,别直接套老代码。4. 踩坑记录:这些问题我挨个遇过4.1 中文全部变成豆腐块或乱码这是我见过最普遍的问题。你明明在本地预览HTML时一切正常,一扔给Gotenberg,中文全部变成一个个方块。原因很简单:容器里没装中文字体。Chromium或LibreOffice渲染时找不到中文字体,就只能用默认字体凑合,结果就是方块、乱码或者掉字。解决办法也很直接:给容器补上中文字体。一个可行的做法是写一个自定义Dockerfile:FROM gotenberg/gotenberg:7 # 将本地准备好的ttf/otf字体文件复制进容器 COPY fonts/ /usr/local/share/fonts/ # 刷新字体缓存 RUN fc-cache -f然后把本地中文字体文件放到fonts/目录下,构建自己的镜像。构建完成后,可以用fc-list :lang=zh来确认中文字体是否被正确识别。另一个我在实践中更推荐的方案:如果团队里有统一的设计规范,直接把需要用的几款字体文件放进一个专用目录,构建镜像时一起打进去。这样所有环境用的字体都完全一致,PDF呈现效果才稳定。特别提醒:字体文件要注意授权问题,商用场景不要直接用未授权字体。4.2 页面加载太快导致白屏、缺内容如果你转出来的PDF只有很窄的一段内容,或者大面积空白,多半是页面还没加载完就执行了打印。比如页面里有一段JavaScript需要从接口拉数据,数据没回来就被打印了。遇到这种情况,先检查是不是URL本身无法直接访问。如果页面需要登录态,Gotenberg的Chromium是独立会话,不会有你的登录Cookie,那自然加载不出数据。这是很多内部系统对接时的常见问题,首先要排除。如果是正常的异步加载,就用waitDelay或者waitForExpression。我建议优先用waitForExpression,因为固定等待时间是个很脆弱的方案。网络慢一点就白屏,网络快一点又浪费等待时间。而用表达式等待,可以精确等到你指定的条件成立再开始打印。目标页面里配合设置一个全局状态,比如在数据加载完成后执行window.status = 'ready',转换请求里传waitForExpression=window.status === 'ready',这才是比较稳的做法。4.3 跨机器调用不通本地用curl测试一切正常,换到另一台机器或者隔壁服务调用就超时、连不上。这种问题大多数不是Gotenberg的问题,而是网络层的问题。排查顺序我一般这样走:先确认目标机器能不能访问Gotenberg宿主机的端口,用telnet或nc测试。再确认中间有没有防火墙、安全组策略挡了端口。如果前面挂了Nginx之类的反向代理,重点检查上传大小限制。Nginx默认的client_max_body_size是1M,超过1M的文件会被直接拒绝。这些链路问题排查起来并不复杂,但如果不了解网络上下文,很容易误判成Gotenberg服务挂了。4.4 内存飙升、任务超时、容器被打崩转换任务本身是资源密集型操作。如果同时提交了几个超大文档,容器内存可能瞬间暴涨,任务超时,甚至整个服务无响应。解决思路就是前面说的:限制容器内存,同时在业务入口控制并发。下面这段Python代码是我常用的客户端超时配置,可以作为参考:import requests resp = requests.post( "http://localhost:3000/forms/libreoffice/convert", files={ "files": ("合同.docx", open("合同.docx", "rb")), }, timeout=(5, 600), )这里的(5, 600)意思是:连接超时5秒,读取超时600秒。给客户端一个合理的读取超时,而不是让它无限等下去,至少能让你的服务在Gotenberg繁忙时及时释放连接,不会把整个业务线程池拖垮。4.5 转出来的PDF和本地预览差别很大有时候你用Chrome直接打印的HTML页面,和Gotenberg转出来的PDF看起来不一样。这可能由几个因素造成:本地浏览器有插件、常用字体,而容器里是精简环境。CSS样式里没有显式写@media print规则,打印样式不一致。字体加载方式不同。你本地可能已经缓存了某些字体,但容器里的Chromium还需要从外部加载,一旦外部请求被限制,字体就渲染失败。针对最后一点,我在做模板时会把需要用到的字体以文件形式打包进HTML请求中,而不是依赖第三方字体托管的在线链接。这样既避免了外部网络依赖,也能保证每次转换的字体一致。4.6 排查工具与日志遇到问题先不要瞎猜,我一般按下面的顺序操作:# 查看容器是否正常 docker ps # 查看转换请求日志 docker logs gotenberg --tail 100Gotenberg的日志会记录请求路径和基本结果,虽然不会打印出转换内容的细节,但能帮你确认它到底有没有收到请求、返回了什么状态码。再用curl测试时,建议加上输出状态码的参数:curl -w '%{http_code}' -X POST http://localhost:3000/forms/chromium/convert/url \ -H 'Content-Type: multipart/form-data' \ -F 'url=https://example.com/' \ -o output.pdf如果看到400、413、500这些状态码,再去对照官方文档,基本都能定位是参数问题、文件大小问题还是服务内部错误。这里我也整理一个简单的速查表:现象大概率原因排查方向中文变方块容器缺少中文字体检查字体文件、fc-list结果白屏或内容缺失页面异步加载未完成使用waitForExpression413 Forbidden上传文件超过代理限制检查Nginx client_max_body_size转服务无响应并发数量过大、内存耗尽限制内存、降低并发PDF分页错乱缺少打印样式规则检查@page和page-break规则5. 写在最后的一些实践习惯最后说一个我自己的习惯。用Gotenberg一段时间后,我倾向于把它当作一个独立的转换微服务来对待,而不是让业务代码直接裸调它的API。前端提交一个文件,后端先记录任务状态,再把任务推给一个转换队列,由队列把请求发给Gotenberg,转换完成通过回调把结果拿回来,再继续后续的存储和通知。这样即使Gotenberg重启,或者短时间内请求量过大,任务也不会直接断在半路。另一个小技巧是:如果你们经常要给不同的客户生成不同模板的PDF,与其每次修改服务代码,不如把模板文件按目录放好,动态拼接HTML后再交给Gotenberg。把模板当作配置而不是代码来管理,后续加样式、改排版都会轻松得多。这个思路围绕Gotenberg展开后,整个文档生产链路会变得很清楚,也容易维护。
做文档转换这件事,每个后端团队大概都有一部血泪史。合同附件要转PDF,报表导出要转PDF,前端传上来的HTML要变成发票,浏览器自带的打印一缩水版式就乱。如果你现在还在为“把一个文件变成PDF”写各种底层补丁,那我建议你看一眼Gotenberg这个项目。它不是库,不是SDK,而是一个开箱即用的文档转换API服务——你把一个URL或者一个docx文件丢给它,它通过HTTP接口给你返回一份质量稳定的PDF。对开发者来说,它解决的是最现实的问题:你不用自己维护转换引擎,不用为每个格式适配写一堆兼容代码,只需要在自己的系统里发一个POST请求。这套方案适合后端开发者,适合前端偶尔要处理报表导出的团队,也适合需要在高并发环境里批量生成文档的服务。下面我把部署、调用和踩过的坑完整过一遍。1. 为什么选Gotenberg:先想清楚你们要的是“工具”还是“服务”1.1 文档转换的真正难点不在转换,在“复现”很多团队一开始会自己造轮子,在代码里直接调用工具命令,或者用开源库做转换。但做一段时间就会发现,格式转换本身其实不难,难的是转换出来的效果能不能稳定复现。比如你写好的一个HTML模板,在不同浏览器里打开,页边距、行高、分页位置全不一样。打印成PDF之后,客户可能收到一个边距不对、页码缺失的版本。再比如办公软件里排得整整齐齐的docx,如果不做兼容处理,转PDF后表格可能被削掉一截,字号也会悄悄变化。这些问题的根源在于,“渲染”这件事本身就耦合了大量周边依赖:字体、CSS支持、打印样式、分页控制、办公软件的页面设置。你需要的不是一段调用转换器的函数,而是一个能复现稳定渲染结果的运行时。Gotenberg把这类运行时打包成了一个容器。它的工作方式有点像一个专门做转换的小饭馆:你自己不用去菜市场买菜、不用琢磨火候,只要把食材(文件或URL)递进去,过一会儿它给你端出一道做好的菜(PDF)。这对开发团队来说,价值不在于省了几个命令,而在于把“转换”这个脏活累活从业务代码里剥离出去了。1.2 双引擎设计:一个容器覆盖三种需求Gotenberg核心有两套转换引擎。第一套是Chromium,负责“网页形态的输入”。URL转PDF或者HTML转PDF,本质上是让一个无声无息的Chromium打开页面,用它的打印引擎进行渲染。因为Chromium对CSS、JavaScript的支持很完整,所以你前端能用浏览器展示出来的效果,它基本都能呈现出来。第二套是LibreOffice,负责“办公文档形态的输入”。docx、xlsx、pptx、odt这类文件,通过LibreOffice的无头模式转换为PDF。这套引擎在处理办公文档时兼容性很好,尤其适合把业务报表、合同、幻灯片这类文件变成最终交付形态。除了这两套引擎,Gotenberg还把PDF层面的操作封装成了独立接口。合并PDF、拆分PDF、加密PDF,都有对应的路由。这三类能力被统一封装成REST API之后,好处非常直接:你不需要在本地安装三个工具链,不需要管理它们的依赖冲突,不需要关心谁先启动谁后启动。你只对着一个HTTP端点发请求就够了。1.3 对比三种常见实现思路:为什么Gotenberg更省心我自己以前也试过几种方案,这里把它们的优缺点摆出来对比一下,方便大家按自身情况做决策。方案优点缺点适合场景自己维护Puppeteer/Playwright脚本可定制性强,所有逻辑都在自己手里要管浏览器版本、字体、并发、进程回收,生产环境坑很多有专门人力维护,且转换需求非常定制化直接调用LibreOffice命令行实现简单,适合小批量离线转换接口化之后要自己处理调度、权限、错误恢复内部临时工具,不涉及对外服务使用Gotenberg开箱即用、容器化、API化,前端后端都能快速接入自定义程度受项目接口约束,需要理解它的设计约定大部分需要稳定文档转换能力的业务系统我见过不少项目,一开始用Puppeteer写得挺顺手,等上了生产环境就开始出事:并发一高,无头浏览器进程互相干扰;服务器一重启,字体缓存或者临时目录没初始化好,转换结果变得奇奇怪怪。Gotenberg把这些常见问题收敛到了容器的生命周期里,配合进程隔离和资源限制,至少不用你从零趟一遍这些坑。2. 部署一个可用实例:版本、镜像与关键配置2.1 镜像版本怎么选:别让latest坑了你Gotenberg官方提供了Docker镜像,安装方式很直接。不过这里我要多说一句:不要图省事直接用latest标签。镜像的latest会跟随项目版本更新,而Gotenberg的大版本之间存在接口差异,一旦你升级大版本,之前写好的请求字段可能不兼容。我自己习惯的做法是固定到一个大版本,比如使用gotenberg/gotenberg:7这个标签,它会跟随当前7.x系列的小版本更新,但不跨大版本。如果你的团队比较谨慎,也可以直接锁死到某一个具体小版本。锁死版本之后,每次升级都要重新跑一遍历史转换用例,确认没有回归再上生产。这不是折腾,而是负责任的生产态度。特殊环境下可能还需要自定义镜像。比如容器默认不带一些中文字体,后面我会专门讲字体问题,那时候你就需要基于官方镜像再包一层,把需要的字体打进去。2.2 最小化部署:先让服务跑起来最简单的启动命令是:docker run -d --name gotenberg \ -p 3000:3000 \ -e TZ=Asia/Shanghai \ gotenberg/gotenberg:7容器内服务默认监听3000端口,宿主机的3000端口映射给它。如果宿主机的3000端口被占了,可以换一个宿主机端口:docker run -d --name gotenberg \ -p 8088:3000 \ gotenberg/gotenberg:7这时你的服务地址就是http://localhost:8088。我建议在启动时显式设置时区环境变量,比如TZ=Asia/Shanghai。这个细节平时不起眼,但如果你生成的PDF带页眉页脚、需要显示日期时间,时区不一致会导致时间对不上,线上排查起来非常绕。对于需要用编排工具管理团队,可以参考这样一个最小化的docker-compose配置:services: gotenberg: image: gotenberg/gotenberg:7 restart: unless-stopped ports: - "3000:3000" environment: - TZ=Asia/Shanghai mem_limit: 1g2.3 安全设置:别把裸奔服务放到公网这是很多初次接触者容易忽略的一点。Gotenberg官方设计里,默认并没有做复杂的鉴权,它是给你放在可信内网环境里使用的。你可以在前面加一层网关做访问控制,或者让业务服务作为唯一入口。如果你的团队确实需要为它增加认证能力,可以通过官方提供的用户管理配置来实现,也可以选择在反向代理层统一做。我的实际经验是:不管用什么方式,永远不要把没有任何访问控制的Gotenberg实例直接暴露到公网路由器上。否则别人可以扫描到你的端口,不断提交转换任务,消耗服务器资源;更麻烦的是它可能被当作一个盲转工具,帮你做一些你没预料到的上传下载。这类滥用问题一旦出现,处理起来比配置鉴权要痛苦得多。2.4 资源预估与并发控制:经验值仅供参考Gotenberg虽然部署简单,但它本质上是把浏览器和办公套件跑在容器里,这两个都是吃内存大户。根据我的经验,常规HTML页面转PDF,峰值内存大约在100到200MB之间;如果页面有大量高清图片或者复杂布局,可能到300MB以上。LibreOffice转换一个几十MB的文档,占用的内存会更高。所以生产环境里一定要做两层控制。第一层是容器本身,加上内存限制,避免单个任务把整台机器拖垮。第二层是业务入口,控制同时运行的转换任务数量。比如一台4GB内存的服务器跑一个容器,我一般会把并发控制在八个左右,并且每个上传的文档大小尽量控制在合理范围内。这个数据不是精确值,不同文档差异很大,但可以作为一个起点,你根据实际压测结果再调。3. 把接口用熟:从URL转PDF到批量转换3.1 最常用的一招:URL直接转PDFGotenberg提供了一系列以/forms/开头的接口,其中使用频率最高的就是URL转PDF。这个能力非常适合做网页存档、报告生成、网页打印预览。最朴素的调用方式是用curl:curl -X POST http://localhost:3000/forms/chromium/convert/url \ -H 'Content-Type: multipart/form-data' \ -F 'url=https://example.com/' \ -o example.pdf这里有个容易想当然的地方:为什么不用JSON,而是用multipart/form-data?因为Gotenberg的接口设计允许你在一个请求里同时传URL、文件和其他控制参数。multipart/form-data天然支持文件上传和普通字段混合,对这类场景最合适。常用参数里,我重点说几个:waitDelay:单位是秒。比如页面加载后有1秒的动画,你希望等动画结束再打印,就传waitDelay=1。注意,不是毫秒,是秒。我第一次用的时候想当然传了5000,结果等了五千秒,反应过来人都傻了。waitForExpression:这个更强大。它可以传一段JavaScript表达式,只有表达式结果为真时才执行打印。适合处理异步渲染的页面,比如页面里有一段数据要等接口返回。实际使用时,目标页面需要配合设置一个状态,比如window.status = "ready",然后你传waitForExpression=window.status === "ready"。这样转换服务就会一直等待这个条件成立。paperWidth、paperHeight、marginTop等:控制纸张尺寸和页边距。再配合几个和打印相关的字段:字段作用典型值printBackground是否打印背景颜色和背景图片truedisplayHeaderFooter是否显示页眉页脚falseheaderHtmlFile页眉的HTML模板文件header.htmlfooterHtmlFile页脚的HTML模板文件footer.htmllandscape是否横向打印false如果你要生成带页码的PDF,通常会用到footerHtmlFile,在模板里写上一个带页码样式的HTML,转换时它会自动套到每一页。3.2 HTML文档转PDF:模板生成的利器除了传URL,还可以直接传HTML文件给Gotenberg,这就是/forms/chromium/convert/html接口。这个接口特别适合做业务模板,因为你可以把动态数据和静态模板拼装成一段完整HTML,再交给Gotenberg。一个典型的调用方式:curl -X POST http://localhost:3000/forms/chromium/convert/html \ -F 'index.html=@./report.html' \ -F 'logo.png=@./logo.png' \ -F 'style.css=@./style.css' \ -o report.pdf这里注意:入口文件必须叫index.html,其他引用的资源文件作为额外的表单字段一起提交。Gotenberg会以index.html为主体,把同一次请求中的其他文件作为它的关联资源加载。这个设计对前端模板开发特别友好:你在本地怎么写网页,这里就怎么组织文件。在写HTML模板时,请务必配合打印样式来设计。下面是一个最小可用示例:<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <style> @page { size: A4; margin: 20mm; } @media print { body { -webkit-print-color-adjust: exact; } .page-break { page-break-before: always; } } </style> </head> <body> <h1>合同内容</h1> <p>这里是正文。</p> <div class="page-break"></div> <p>这是第二页的内容。</p> </body> </html>@page规则定义了纸张大小和页边距,page-break-before: always用于强制分页。这些CSS打印特性是转换结果是否清爽的关键。不写打印样式的话,浏览器默认打印效果往往不如人意。3.3 办公文档转换:docx、xlsx、pptx一网打尽对于办公文档,用到的是/forms/libreoffice/convert接口。这个接口结合LibreOffice引擎,可以处理Word、Excel、PPT等格式。最简单的调用:curl -X POST http://localhost:3000/forms/libreoffice/convert \ -F 'files=@合同.docx' \ -o 合同.pdf这里字段名是files,注意是复数。接口支持同时提交多个文件。默认情况下,多个文件只取第一个进行转换,其他文件会被忽略。但如果加上merge=true参数,它会把所有文件依次转换成PDF,然后合并成一个PDF文件返回。这个能力做多文档合并非常省事,比如你需要把多个合同附件合并成一个完整PDF发给客户,就不用在业务层再去调PDF合并接口了。还有一个字段是landscape,设置为true时,文档会以横向页面输出。xlsx转PDF时如果列特别多,这个参数很有用。办公文档转换有一点坑:字体缺失。我在下一章详细说。这里先给一个经验:无论docx还是xlsx,如果原文档里用了特殊字体,尽量保证容器里有对应字体,否则PDF会退化成默认字体的样式。3.4 把Gotenberg当成PDF工具箱用除了各种转PDF,Gotenberg还提供几个PDF处理接口。合并PDF:curl -X POST http://localhost:3000/forms/pdf/merge \ -F 'files=@1.pdf' \ -F 'files=@2.pdf' \ -o merged.pdf拆分PDF:curl -X POST http://localhost:3000/forms/pdf/split \ -F 'file=@文档.pdf' \ -o split.zip拆分接口返回的是一个zip压缩包,里面包含按页拆开的多个PDF文件。这个细节和常规理解不同,我第一次测试时以为会返回多个文件,结果收到的就是zip。加密PDF:curl --request POST http://localhost:3000/forms/pdf/encrypt \ -H 'Content-Type: multipart/form-data' \ -F 'file=@文档.pdf' \ -F 'userPassword=123456' \ -F 'ownerPassword=123456' \ -o encrypted.pdf这里的userPassword是用户打开密码,ownerPassword是权限密码。如果只想限制打印、复制等操作权限,可以只设置ownerPassword。这个接口对内部系统生成受控文件挺有用的。3.5 批量转换与回调:从同步等待变成异步流水线Gotenberg的HTTP接口本质上是同步的。你发一个请求,它转换完成,再把结果返回给请求方。这对于单个转换没问题,但如果你要给用户批量生成几百份PDF,同步等待就会成为灾难。一种常见做法是业务后台维护一个任务列表,逐个把任务交给Gotenberg。每个请求设置合理的超时时间,处理完一个再处理下一个。这种方式实现简单,但吞吐量有限。另一种做法是利用Gotenberg自带的结果回调能力。你在表单字段里传一个webhook_url,它会在转换完成之后,主动向这个URL发送一个回调通知。这样你的业务服务不需要一直保持连接等待结果,可以先把请求交给Gotenberg,然后自己去处理别的事情。比如用户上传文件后,后端先把任务写入数据库,然后调用Gotenberg并带上回调地址,等Gotenberg转换完成回调时,再把PDF存储起来并发邮件通知用户。这套模式非常适合把“用户上传文件-转PDF-存OSS-发邮件”这类链路解耦。不过不同的Gotenberg版本对webhook支持程度不完全一样,字段名也可能有细微差异,接入前一定先看一下当前版本的官方文档确认一下,别直接套老代码。4. 踩坑记录:这些问题我挨个遇过4.1 中文全部变成豆腐块或乱码这是我见过最普遍的问题。你明明在本地预览HTML时一切正常,一扔给Gotenberg,中文全部变成一个个方块。原因很简单:容器里没装中文字体。Chromium或LibreOffice渲染时找不到中文字体,就只能用默认字体凑合,结果就是方块、乱码或者掉字。解决办法也很直接:给容器补上中文字体。一个可行的做法是写一个自定义Dockerfile:FROM gotenberg/gotenberg:7 # 将本地准备好的ttf/otf字体文件复制进容器 COPY fonts/ /usr/local/share/fonts/ # 刷新字体缓存 RUN fc-cache -f然后把本地中文字体文件放到fonts/目录下,构建自己的镜像。构建完成后,可以用fc-list :lang=zh来确认中文字体是否被正确识别。另一个我在实践中更推荐的方案:如果团队里有统一的设计规范,直接把需要用的几款字体文件放进一个专用目录,构建镜像时一起打进去。这样所有环境用的字体都完全一致,PDF呈现效果才稳定。特别提醒:字体文件要注意授权问题,商用场景不要直接用未授权字体。4.2 页面加载太快导致白屏、缺内容如果你转出来的PDF只有很窄的一段内容,或者大面积空白,多半是页面还没加载完就执行了打印。比如页面里有一段JavaScript需要从接口拉数据,数据没回来就被打印了。遇到这种情况,先检查是不是URL本身无法直接访问。如果页面需要登录态,Gotenberg的Chromium是独立会话,不会有你的登录Cookie,那自然加载不出数据。这是很多内部系统对接时的常见问题,首先要排除。如果是正常的异步加载,就用waitDelay或者waitForExpression。我建议优先用waitForExpression,因为固定等待时间是个很脆弱的方案。网络慢一点就白屏,网络快一点又浪费等待时间。而用表达式等待,可以精确等到你指定的条件成立再开始打印。目标页面里配合设置一个全局状态,比如在数据加载完成后执行window.status = 'ready',转换请求里传waitForExpression=window.status === 'ready',这才是比较稳的做法。4.3 跨机器调用不通本地用curl测试一切正常,换到另一台机器或者隔壁服务调用就超时、连不上。这种问题大多数不是Gotenberg的问题,而是网络层的问题。排查顺序我一般这样走:先确认目标机器能不能访问Gotenberg宿主机的端口,用telnet或nc测试。再确认中间有没有防火墙、安全组策略挡了端口。如果前面挂了Nginx之类的反向代理,重点检查上传大小限制。Nginx默认的client_max_body_size是1M,超过1M的文件会被直接拒绝。这些链路问题排查起来并不复杂,但如果不了解网络上下文,很容易误判成Gotenberg服务挂了。4.4 内存飙升、任务超时、容器被打崩转换任务本身是资源密集型操作。如果同时提交了几个超大文档,容器内存可能瞬间暴涨,任务超时,甚至整个服务无响应。解决思路就是前面说的:限制容器内存,同时在业务入口控制并发。下面这段Python代码是我常用的客户端超时配置,可以作为参考:import requests resp = requests.post( "http://localhost:3000/forms/libreoffice/convert", files={ "files": ("合同.docx", open("合同.docx", "rb")), }, timeout=(5, 600), )这里的(5, 600)意思是:连接超时5秒,读取超时600秒。给客户端一个合理的读取超时,而不是让它无限等下去,至少能让你的服务在Gotenberg繁忙时及时释放连接,不会把整个业务线程池拖垮。4.5 转出来的PDF和本地预览差别很大有时候你用Chrome直接打印的HTML页面,和Gotenberg转出来的PDF看起来不一样。这可能由几个因素造成:本地浏览器有插件、常用字体,而容器里是精简环境。CSS样式里没有显式写@media print规则,打印样式不一致。字体加载方式不同。你本地可能已经缓存了某些字体,但容器里的Chromium还需要从外部加载,一旦外部请求被限制,字体就渲染失败。针对最后一点,我在做模板时会把需要用到的字体以文件形式打包进HTML请求中,而不是依赖第三方字体托管的在线链接。这样既避免了外部网络依赖,也能保证每次转换的字体一致。4.6 排查工具与日志遇到问题先不要瞎猜,我一般按下面的顺序操作:# 查看容器是否正常 docker ps # 查看转换请求日志 docker logs gotenberg --tail 100Gotenberg的日志会记录请求路径和基本结果,虽然不会打印出转换内容的细节,但能帮你确认它到底有没有收到请求、返回了什么状态码。再用curl测试时,建议加上输出状态码的参数:curl -w '%{http_code}' -X POST http://localhost:3000/forms/chromium/convert/url \ -H 'Content-Type: multipart/form-data' \ -F 'url=https://example.com/' \ -o output.pdf如果看到400、413、500这些状态码,再去对照官方文档,基本都能定位是参数问题、文件大小问题还是服务内部错误。这里我也整理一个简单的速查表:现象大概率原因排查方向中文变方块容器缺少中文字体检查字体文件、fc-list结果白屏或内容缺失页面异步加载未完成使用waitForExpression413 Forbidden上传文件超过代理限制检查Nginx client_max_body_size转服务无响应并发数量过大、内存耗尽限制内存、降低并发PDF分页错乱缺少打印样式规则检查@page和page-break规则5. 写在最后的一些实践习惯最后说一个我自己的习惯。用Gotenberg一段时间后,我倾向于把它当作一个独立的转换微服务来对待,而不是让业务代码直接裸调它的API。前端提交一个文件,后端先记录任务状态,再把任务推给一个转换队列,由队列把请求发给Gotenberg,转换完成通过回调把结果拿回来,再继续后续的存储和通知。这样即使Gotenberg重启,或者短时间内请求量过大,任务也不会直接断在半路。另一个小技巧是:如果你们经常要给不同的客户生成不同模板的PDF,与其每次修改服务代码,不如把模板文件按目录放好,动态拼接HTML后再交给Gotenberg。把模板当作配置而不是代码来管理,后续加样式、改排版都会轻松得多。这个思路围绕Gotenberg展开后,整个文档生产链路会变得很清楚,也容易维护。