本 issue 附带完整根因定位与已验证的修复方案,可直接参考。
一、环境
| 项 |
值 |
| 版本 |
XianYuSmart 2.0.7 |
| 部署方式 |
Docker Compose(compose.yaml,含 MySQL 服务) |
| 运行时镜像 |
eclipse-temurin:21-jre-jammy |
| Playwright |
com.microsoft.playwright:playwright:1.61.0 |
| 宿主机 |
Ubuntu,8 GB RAM,Docker 默认配置 |
容器 /dev/shm |
64 MB(默认,未设 shm_size) |
二、现象
服务启动后一切正常。运行约 1 小时以上,当 WebSocket 断开重连触发滑块验证时,界面提示:
创建浏览器进程失败:Failed to launch driver
此后所有需要浏览器的操作(滑块验证、Cookie 浏览器兜底刷新)持续失败,不再自愈。
重启容器可恢复,但约 1 小时后必然再次复现。
三、复现步骤
快速复现(约 1 分钟,推荐)
利用「driver 是静态单例、删除后不会重新解压」这一特性,跳过 1 小时等待:
# 1. 先触发一次滑块验证,让 Playwright 完成驱动解压
# (任何需要浏览器的操作均可,例如界面点「滑块验证」)
# 2. 确认驱动目录已生成,然后手动删除它
docker exec xianyusmart-app-1 sh -c 'ls -d /tmp/playwright-java-*'
docker exec xianyusmart-app-1 sh -c 'rm -rf /tmp/playwright-java-*'
# 3. 再次触发滑块验证 → 立即复现「创建浏览器进程失败」
完整复现(约 70 分钟,还原真实时序)
docker compose up -d 启动服务
- 触发一次需要浏览器的操作,使 Playwright 解压 node 驱动
- 确认目录已生成:
docker exec xianyusmart-app-1 sh -c 'ls -d /tmp/playwright-java-*'
- 保持浏览器空闲(不触发滑块),等待 60 分钟以上,让定时清理任务执行一轮
- 再次触发滑块验证 → 复现
观察清理是否真的执行过:
docker logs xianyusmart-app-1 2>&1 | grep -i "清理Playwright临时文件"
四、根因分析
三处代码叠加导致,缺一不可。
1. Playwright Java 侧(第三方既定行为)
反编译 driver-1.61.0.jar 可得:
// com.microsoft.playwright.impl.driver.Driver
public static synchronized Driver ensureDriverInstalled(Map env, Boolean installBrowsers) {
if (instance == null) {
instance = createAndInstall(env, installBrowsers);
}
return instance; // ← 静态单例,整个 JVM 生命周期内只解压一次
}
// com.microsoft.playwright.impl.driver.jar.DriverJar
private final Path driverTempDir; // 形如 /tmp/playwright-java-XXXX
void extractDriverToTempDir() { ... } // 内含 node 二进制 + driver/package
关键:驱动目录只在首次 Playwright.create() 时解压一次,之后永久复用。目录一旦消失,Playwright 不会重新解压。
2. 项目侧清理逻辑误伤
src/main/java/com/xianyusmart/config/PlaywrightManager.java,cleanTempFiles():
Files.list(tmpDir)
.filter(path -> {
String name = path.getFileName().toString();
return name.startsWith("playwright") || name.contains("chromium") // ← 问题所在
|| name.startsWith("core.") || name.endsWith(".pipe")
|| name.endsWith(".sock");
})
playwright-java-XXXX 正好被 startsWith("playwright") 命中。
而清理只跳过「最后修改时间在 1 小时内」的条目(PlaywrightManager.java:171,thresholdMs = TimeUnit.HOURS.toMillis(1))——驱动目录建好后不再写入,运行满 1 小时必然满足条件。
3. 触发时机
src/main/java/com/xianyusmart/service/impl/TokenRefreshServiceImpl.java:586:
@Scheduled(fixedDelay = 60 * ONE_MINUTE_MS, initialDelay = 5 * ONE_MINUTE_MS)
public void scheduledCleanPlaywrightTempFiles() {
playwrightManager.cleanTempFiles();
}
每小时执行一次。而 cleanTempFiles() 开头的守卫:
if (isInitialized()) { // 浏览器懒加载,平时绝大多数时间都是 false
log.debug("Playwright浏览器运行中,跳过临时文件清理");
return;
}
浏览器是懒加载的,日常不启动,所以这个守卫基本不生效,清理会照常执行。
4. 失败链路
定时清理删除 /tmp/playwright-java-XXXX
↓
用户触发滑块验证 → Playwright.create()
↓
Driver.ensureDriverInstalled() 返回已缓存实例,不重新解压
↓
driver.createProcessBuilder() → ProcessBuilder.start()
↓
IOException: Cannot run program ".../node": No such file or directory
↓
PlaywrightException("Failed to launch driver") ← PlaywrightImpl.createImpl 捕获 IOException 后抛出
↓
PlaywrightCaptchaBrowserRunner.java:439
failureStage = "创建浏览器进程"
↓
界面显示「创建浏览器进程失败」
5. 判定特征(重要)
PlaywrightCaptchaBrowserRunner.java 中相邻两行设置了不同文案:
439: failureStage = "创建浏览器进程";
440: playwright = Playwright.create();
441: BrowserType browserType = chromiumAfterProcessAttached(playwright, processSession);
442: failureStage = "启动浏览器";
443: Browser browser = browserType.launch(...);
报错落在「创建浏览器进程」而不是「启动浏览器」,说明 Chromium 根本没被拉起,故障在 Node 驱动派生阶段。这一点可以快速把问题与Chromium 本身的配置问题(沙箱、共享内存、二进制缺失)区分开。
五、已排除的可能性
以下均在真实环境交叉验证,可省去重复排查:
| 排查项 |
实测结果 |
结论 |
/dev/shm 耗尽 |
Used 0 / 64M |
排除 |
| 内存耗尽 |
available 5.2 GB,java RSS 639 MB |
排除 |
| 僵尸进程堆积 |
PID 1 为 docker-init,init: true 生效 |
排除 |
| 浏览器二进制缺失 |
/app/ms-playwright 下 chromium-1228 等三个目录齐全 |
排除 |
driverProcess 反射失败 |
反编译确认 private java.lang.Process driverProcess 字段存在 |
排除 |
| 容器内进程数超限 |
仅 docker-init / sh / java 三个进程 |
排除 |
六、建议修复
1. 清理过滤器排除驱动目录(必须)
.filter(path -> {
String name = path.getFileName().toString();
// Playwright Java 的 node 驱动解压目录,删除后 Playwright.create() 将永久失败
if (name.startsWith("playwright-java-")) {
return false;
}
return name.startsWith("playwright_chromiumdev_profile")
|| name.startsWith("playwright-artifacts-")
|| name.startsWith(".org.chromium.Chromium.")
|| name.startsWith("core.")
|| name.endsWith(".pipe")
|| name.endsWith(".sock");
})
2. 把驱动目录迁出 /tmp(推荐,双保险)
compose.yaml 的 JAVA_OPTS 增加:
-Dplaywright.driver.tmpdir=/app/data/pw-driver
cleanTempFiles() 只扫描 java.io.tmpdir,迁移后即使过滤器被误改也不会误删。
3. 单例浏览器补齐容器启动参数
PlaywrightManager.doInit() 当前只有 setHeadless(true),建议补:
.setArgs(Arrays.asList("--no-sandbox", "--disable-dev-shm-usage"))
(PlaywrightCaptchaBrowserRunner 已带 --disable-dev-shm-usage,单例这边遗漏了。容器无 user namespace 且 /dev/shm 仅 64 MB,两个参数都必要。)
4. 提升错误信息可读性(可选)
browserFailureMessage() 当前只看 exception.getMessage(),而真正的系统错误在 cause 里,导致日志只剩一句无信息量的 Failed to launch driver。建议拼接 cause 链:
private static String causeChainSummary(Throwable throwable) {
StringBuilder summary = new StringBuilder();
Throwable current = throwable.getCause();
int depth = 0;
while (current != null && depth < 5) {
String message = current.getMessage();
if (message != null && !message.isBlank()) {
summary.append(" | cause: ")
.append(current.getClass().getSimpleName())
.append(": ").append(message);
}
current = current.getCause();
depth++;
}
return summary.toString();
}
本次排障中,若日志直接输出 No such file or directory,可节省大量时间。
5. 部署模板补齐 init: true
deploy/server/compose-existing-mysql.yaml 缺少根 compose.yaml 中已有的:
根 compose 的注释已说明原因(tini 回收孤儿与僵尸进程)。该模板还设了 pids_limit: 256,缺少 tini 时风险更大。
七、相关日志片段
驱动解压成功的日志(可用于定位目录路径):
extracted driver from jar to /tmp/playwright-java-1234567890
故障发生时的原始日志:
WARN c.x.s.impl.WebSocketTokenServiceImpl - 【账号1】检测到滑块验证,已保存待处理地址
WARN c.x.s.impl.WebSocketServiceImpl - 启动WebSocket需要滑块验证: accountId=1, url=...
com.xianyusmart.exception.CaptchaRequiredException: 需要完成滑块验证
浏览器失败记录(修复前只输出外层 message):
浏览器滑块验证失败: type=PlaywrightException, reason=创建浏览器进程失败:Failed to launch driver
八、备注
- 本人在 2.0.7 源码上已按上述 1–4 修复并编译通过,部署后需观察满 1 小时确认
- 该缺陷与账号风控(
RGV587_ERROR)无关,是两个独立问题
- 复现与验证均在真实 Docker 部署环境完成
一、环境
compose.yaml,含 MySQL 服务)eclipse-temurin:21-jre-jammycom.microsoft.playwright:playwright:1.61.0/dev/shmshm_size)二、现象
服务启动后一切正常。运行约 1 小时以上,当 WebSocket 断开重连触发滑块验证时,界面提示:
此后所有需要浏览器的操作(滑块验证、Cookie 浏览器兜底刷新)持续失败,不再自愈。
重启容器可恢复,但约 1 小时后必然再次复现。
三、复现步骤
快速复现(约 1 分钟,推荐)
利用「driver 是静态单例、删除后不会重新解压」这一特性,跳过 1 小时等待:
完整复现(约 70 分钟,还原真实时序)
docker compose up -d启动服务观察清理是否真的执行过:
四、根因分析
三处代码叠加导致,缺一不可。
1. Playwright Java 侧(第三方既定行为)
反编译
driver-1.61.0.jar可得:关键:驱动目录只在首次
Playwright.create()时解压一次,之后永久复用。目录一旦消失,Playwright 不会重新解压。2. 项目侧清理逻辑误伤
src/main/java/com/xianyusmart/config/PlaywrightManager.java,cleanTempFiles():playwright-java-XXXX正好被startsWith("playwright")命中。而清理只跳过「最后修改时间在 1 小时内」的条目(
PlaywrightManager.java:171,thresholdMs = TimeUnit.HOURS.toMillis(1))——驱动目录建好后不再写入,运行满 1 小时必然满足条件。3. 触发时机
src/main/java/com/xianyusmart/service/impl/TokenRefreshServiceImpl.java:586:每小时执行一次。而
cleanTempFiles()开头的守卫:浏览器是懒加载的,日常不启动,所以这个守卫基本不生效,清理会照常执行。
4. 失败链路
5. 判定特征(重要)
PlaywrightCaptchaBrowserRunner.java中相邻两行设置了不同文案:报错落在「创建浏览器进程」而不是「启动浏览器」,说明 Chromium 根本没被拉起,故障在 Node 驱动派生阶段。这一点可以快速把问题与Chromium 本身的配置问题(沙箱、共享内存、二进制缺失)区分开。
五、已排除的可能性
以下均在真实环境交叉验证,可省去重复排查:
/dev/shm耗尽Used 0 / 64Mdocker-init,init: true生效/app/ms-playwright下chromium-1228等三个目录齐全driverProcess反射失败private java.lang.Process driverProcess字段存在docker-init/sh/java三个进程六、建议修复
1. 清理过滤器排除驱动目录(必须)
2. 把驱动目录迁出
/tmp(推荐,双保险)compose.yaml的JAVA_OPTS增加:cleanTempFiles()只扫描java.io.tmpdir,迁移后即使过滤器被误改也不会误删。3. 单例浏览器补齐容器启动参数
PlaywrightManager.doInit()当前只有setHeadless(true),建议补:(
PlaywrightCaptchaBrowserRunner已带--disable-dev-shm-usage,单例这边遗漏了。容器无 user namespace 且/dev/shm仅 64 MB,两个参数都必要。)4. 提升错误信息可读性(可选)
browserFailureMessage()当前只看exception.getMessage(),而真正的系统错误在 cause 里,导致日志只剩一句无信息量的Failed to launch driver。建议拼接 cause 链:本次排障中,若日志直接输出
No such file or directory,可节省大量时间。5. 部署模板补齐
init: truedeploy/server/compose-existing-mysql.yaml缺少根compose.yaml中已有的:根 compose 的注释已说明原因(tini 回收孤儿与僵尸进程)。该模板还设了
pids_limit: 256,缺少 tini 时风险更大。七、相关日志片段
驱动解压成功的日志(可用于定位目录路径):
故障发生时的原始日志:
浏览器失败记录(修复前只输出外层 message):
八、备注
RGV587_ERROR)无关,是两个独立问题