公司用 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 容器里跑,转换完就销毁。容器的生命周期就是一次转换请求的生命周期。
public byte[] 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-xxx
throw new 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 本身也会卡。
用信号量限制同时运行的容器数:
@Component
public class ConversionService {
// 最多同时运行 5 个转换容器
private final Semaphore semaphore = new Semaphore(5);
public byte[] convert(byte[] input, String format) throws Exception {
boolean acquired = semaphore.tryAcquire(30, TimeUnit.SECONDS);
if (!acquired) {
throw new 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 秒或提示"繁忙"。卡住的不影响别人,超时的自动强杀。
