公司用 LibreOffice 做 Office 文件的在线预览——把 Word/Excel/PPT 转成 PDF,前端用 PDF.js 展示。平时用着没问题,但每个月财务批量导出报表的时候,几十个人同时点预览。LibreOffice 进程 fork 了十几个,其中一个转一个 50MB 的 Excel 文件卡了 10 分钟。这个卡住的进程占着 CPU 不干活,后面排队的请求全堵住了。运维 ssh 上去 kill -9 才恢复。
LibreOffice 的--headless 模式是为单次使用设计的,不是为高并发设计的。今天聊聊怎么用 Docker 容器隔离每个转换任务、超时强杀、再加一个队列缓冲——让文件预览服务从"一卡俱卡"变成"各管各的"。
问题:共享的 LibreOffice 进程没有隔离
最简单的预览方案是在服务器上直接调 LibreOffice:
soffice --headless --convert-to pdf input.docx第一次调会 fork 一个 LibreOffice 后台进程。后续调用如果这个进程还在,就复用。如果这个进程卡死了——比如遇到了一个损坏的 docx 文件——后续所有转换请求都被卡住。
更糟的是:被 fork 出来的 LibreOffice 进程是共享同一份用户目录的。一个任务的临时文件可能被另一个任务读到。隔离性为零。
方案:Docker 容器池,每个任务独立容器
每个转换任务在独立的 Docker 容器里跑,转换完就销毁。容器的生命周期就是一次转换请求的生命周期。
publicbyte[] convertToPdf(byte[] input, String format) throws Exception { String containerId = UUID.randomUUID().toString().substring(0, 8);// 1. 把输入文件写入临时目录 Path workDir = Files.createTempDirectory("convert-" + containerId); Files.write(workDir.resolve("input." + format), input);// 2. 启动 Docker 容器执行转换 Process docker = new ProcessBuilder("docker", "run", "--rm","--name", "convert-" + containerId,"--memory=512m", // 内存限制"--cpus=1", // CPU 限制"-v", workDir + ":/data","libreoffice-convert:latest","soffice", "--headless", "--convert-to", "pdf","/data/input." + format ).start();// 3. 等待完成,最多 60 秒超时boolean finished = docker.waitFor(60, TimeUnit.SECONDS);if (!finished) { docker.destroyForcibly(); // 超时 → 强杀容器// docker kill convert-xxxthrownew TimeoutException("转换超时"); }// 4. 读取输出byte[] pdf = Files.readAllBytes(workDir.resolve("input.pdf"));// 5. 清理临时文件 Files.walk(workDir).sorted(Comparator.reverseOrder()) .forEach(p -> p.toFile().delete());return pdf;}关键点:--rm 保证容器退出后自动清理,--memory=512m 限制单个容器的内存防止 OOM,waitFor(60, TimeUnit.SECONDS) 如果超时就destroyForcibly() 强杀。
一个请求卡了,只影响它自己的容器。其他请求在各自的容器里正常运行。隔离性做到了进程级别(Docker 比进程更彻底)。
队列调度:用信号量控制并发数
Docker 容器也有开销——启动一个容器要几百毫秒。如果 100 个人同时点预览,100 个容器同时启动,Docker daemon 本身也会卡。
用信号量限制同时运行的容器数:
@ComponentpublicclassConversionService{// 最多同时运行 5 个转换容器privatefinal Semaphore semaphore = new Semaphore(5);publicbyte[] convert(byte[] input, String format) throws Exception {boolean acquired = semaphore.tryAcquire(30, TimeUnit.SECONDS);if (!acquired) {thrownew BusyException("预览服务繁忙,请稍后重试"); }try {return doConvert(input, format); } finally { semaphore.release(); } }}如果 5 个容器都在跑,第 6 个请求最多等 30 秒。超过了就返回"预览服务繁忙"。这比让用户无限等要好——用户看到"繁忙"会稍后重试,而不会一直盯着 loading 转圈。
容器预热池:消除启动延迟
Docker 容器每次启动都需要几百毫秒,对于频繁的预览请求来说太慢了。可以维护一个"预热容器池"——提前启动 5 个容器,请求来了直接分配:
预热池(5 个容器提前跑好,idle 状态) → 请求来了 → 分配容器 → 传文件 → 转换 → 转换完 → 容器回池(或销毁重建)预热池把容器启动的几百毫秒延迟平摊到了空闲时间。用户请求的响应时间从 800ms 降到 200ms。
总结
进程级卡死+并发无隔离+无超时→文件预览一卡俱卡。
三条防线:
Docker 隔离 —— 每个任务独立容器, --rm自动清理 +--memory限制内存 +waitFor超时强杀信号量并发控制 —— tryAcquire(30s),超过 5 个并发就排队等待预热容器池 —— 提前建好容器,消除启动延迟
装配之后,100 个人同时预览,5 个并发跑、剩下的排队 30 秒或提示"繁忙"。卡住的不影响别人,超时的自动强杀。


夜雨聆风