☰
测试环境云化:基于Selenium Grid与Docker的浏览器矩阵按需实践
2026/10/10 6:06:05 网站建设 项目流程

我团队上个月差点因为一个很蠢的问题延期发版:测试环境里跑着一套 Chrome 89 的固定镜像,而开发那边早就用 Chrome 115 的本地环境联调了两周。等提测时,前端页面在旧浏览器上渲染出了诡异错位,测试报了三个 Bug,开发一查说"我本地明明是好的"。

这种戏码,做过几年测试的人应该都不陌生。问题不在某个浏览器版本,而在"测试环境怎么提供一套干净、可随时切换、按需启停的浏览器矩阵"。我最近半年把团队的核心测试环境改造成了云化架构,核心思路就是标题里这句话:测试环境云化,按需使用浏览器矩阵。这篇文章就是这次改造的完整复盘——为什么做、怎么设计、踩了哪些坑,以及最终跑起来的效果。对正被测试环境搞得头大的测试开发、DevOps 和平台团队,应该有直接参考价值。

1. 浏览器矩阵为什么会成为测试环境的"定时炸弹"

1.1 先定义清楚:浏览器矩阵到底是什么

"浏览器矩阵"不是一个高深术语,它指的是在测试时需要覆盖的浏览器与操作系统组合集合。比如:

  • Windows 10 / 11 上的 Chrome 最新两个大版本
  • macOS 上的 Safari 最新版
  • 全平台上的 Firefox ESR 与最新版
  • Edge Chromium 稳定版
  • 偶尔还有移动端 WebView 或手机浏览器

把这些组合展开成一个二维表(浏览器 × 系统),就是矩阵。理论上,Web 项目覆盖的矩阵节点越多,兼容性风险越低。但现实里,矩阵规模一旦超过某个阈值,维护成本会以非线性方式上涨,最终变成谁都不愿意碰的"定时炸弹"。

1.2 机房/虚拟机方案里,矩阵是怎么失控的

传统的做法很直接:准备几台物理机或虚拟机,每台装一个操作系统,再装几款浏览器,然后用 Selenium Grid 把节点挂到 Hub 下。我见过不少团队就是这么跑了好几年。

一开始规模小,一台机器装 3 个浏览器,完全够用。但项目多了之后问题就来了:

  • 环境互相污染:同一个虚拟机上既跑自动化测试,又装了调试工具,还可能有同事手动操作残留的登录态、缓存、代理配置。测试结果一出现"偶发性失败",第一反应不是查代码,而是"环境是不是又脏了"。
  • 版本升级要全量动:Chrome 到了大版本升级的时候,负责环境的人要么全部升级,要么全部锁死。全部升级有回归风险,全部锁死又跟不上开发节奏——因为开发本地永远是新的。
  • 资源利用率极度不均:平时可能只有 20% 的时间用满 10 台机器,但压测或版本发布前又需要 15 台。买物理机跟不上波峰,靠虚拟机临时克隆又慢又占磁盘。

这些问题的根源是同一个:浏览器运行环境"有状态"。一旦有状态,就会产生漂移、冲突和回收难。

1.3 矩阵失控的三个典型症状

如果你不确定自己的团队是不是已经处于失控边缘,对照一下这三个症状:

  1. 环境配置文档失效。最早写文档的人已经离职,文档里写的镜像版本和线上对不上,新来的同事照着部署发现根本起不来。
  2. 自动化用例开始依赖执行顺序。同一个用例先跑 A 浏览器再跑 B 浏览器结果不同,说明环境之间有残留状态。
  3. 环境团队成为瓶颈。开发要一个新的浏览器版本,不是自己拉一下镜像就行,而是要提工单找环境负责人改 VM 配置,排队等半天。

我在做这次云化改造前,三条全中。所以接下来的核心判断是:与其继续修修补补,不如把"浏览器执行环境"彻底做成无状态、可编排、用后即焚的形态。

2. 云化不等于上云:本地动态扩容才是多数团队的性价比之选

2.1 商业云测平台与自建矩阵的本质差异

提到云化,很多人的第一反应是买商业云测平台,比如 Sauce Labs、BrowserStack 这类服务。它们确实好用,不需要自己维护基础设施,打开接口就能跑。但对于大多数国内团队来说,有几个现实阻碍:

  • 数据合规:被测系统很多是内网应用,或带敏感数据,测试执行时的请求内容、录屏、网络抓包都不能流到外部服务。
  • 成本:按分钟收费,长期跑自动化回归的话,月成本很容易超过一台服务器的数倍。
  • 网络延迟:浏览器节点在远端,被测应用在内网,跨公网访问内网测试环境会有额外的链路延迟和稳定性风险。

所以"云化"的核心不是"放到公有云上",而是**"把环境变成可以动态分配的资源池"**。自己做,一样能拿到云化的收益。

2.2 Selenium Grid 4 的组件架构为什么适合做弹性底座

选择自建路线后,第一个决策点是用什么作为网格底座。我用的是 Selenium Grid 4,理由很直接:

  • Grid 4 把原来简单的 Hub/Node 关系拆成了 Router、Distributor、Session Map、Session Queue 四个组件。分离之后,节点只需要向 Event Bus 注册,不需要把每个浏览器进程的细节暴露给上层。
  • 它原生支持 Docker 镜像作为节点,也就是说"一个容器 = 一个浏览器执行环境"这件事,已经被官方设计好了。
  • 它可以动态注册/注销节点。容器起来了,向 Router 发一个注册请求,Grid 就能开始调度;容器销毁了,会话自动失效,不残留状态。

这三点组合起来,正好是我实现"按需使用"的地基。

2.3 我选定的目标形态:容器化节点 + 会话级调度

架构上没有搞得很复杂,目标形态是这样的:

  • 浏览器节点全部容器化,一个容器就是一个干净的浏览器环境,内置对应版本的 Chrome 或 Firefox。
  • 节点分为两组:常驻热节点(应对常规回归任务的稳态水位)和动态冷节点(按需拉起、用完销毁)。
  • 调度粒度细化到"会话级",也就是每次测试启动时,Grid 从空闲节点池里分配一个可用的浏览器会话;没有空闲节点就排队或触发扩容。
  • 整个网格是自包含的,跟 CI 流水线之间只通过 WebDriver 协议交互,不关心测试代码用什么语言、什么测试框架。

这套形态的好处在于:对使用方来说,浏览器矩阵从"环境清单"变成了"服务"。开发不用关心环境里有没有 Safari,只需要在脚本里指定 browserVersion 和 platformName,网格负责找到满足条件的节点。

3. 按需分配的实现:从"常驻一堆节点"到"只有用的时候才起"

3.1 会话级调度的触发链路

在 Selenium Grid 4 里,一次浏览器测试会话的完整链路是这样的:

  1. 测试代码通过 Remote WebDriver 向 Router 发送"创建会话"请求,带 Capabilities(浏览器名、版本、操作系统等)。
  2. Router 把请求投递给 Session Queue,Distributor 从队列中取出请求。
  3. Distributor 在它维护的心跳列表中查找能够匹配 Capabilities 的可用节点,找到后把会话任务分配给该节点。
  4. 节点收到请求后启动对应的浏览器进程,返回一个 sessionId 给执行方。
  5. 执行期间,Session Map 记录会话状态;执行完毕,节点关闭浏览器进程,重新变为空闲。

这里的关键是第 3 步:Distributor 只调度**"当前还活着"**的节点。所以想实现"按需扩容",本质上要做两件事:在容器没起来的时候,让 Distributor 有足够多的节点来承接请求;在容器起来之前,让请求不至于一直卡死。

我的处理方式不是修改 Grid 本身,而是加了一个轻量协调脚本。它负责两件事:

  • 监控 Session Queue 的长度(也就是当前卡在等待队列里的请求数)。
  • 当等待中的请求数超过阈值,就调用 Docker API 拉起新的浏览器节点容器,同时通过/se/grid/node?token=接口把新节点注册进网格。

3.2 动态节点注册的两个关键点

动态注册在 Grid 4 里比 Grid 3 简单,但有三个细节必须处理对,否则节点起不来或者注册不进去:

第一,容器环境变量必须正确传递注册信息。我用的是 Selenium 官方selenium/node-chrome和selenium/node-firefox镜像,注册依赖四个环境变量:

SE_EVENT_BUS_HOST=<grid-hub-ip> SE_EVENT_BUS_PUBLISH_PORT=4442 SE_EVENT_BUS_SUBSCRIBE_PORT=4443 SE_NODE_MAX_SESSIONS=2

SE_NODE_MAX_SESSIONS很关键,它控制一个容器里允许并发运行的浏览器进程数。我实际测下来,单容器开 1~2 个会话最稳,开多了内存会爆,还会偶发浏览器崩溃,得不偿失。

第二,新容器要能在注册完成后把自己"隐藏"。这听起来有点绕,但场景是真实的:扩容脚本拉起了 3 个节点,其中 1 个一直没能注册成功,Grid 里始终只有 2 个可用。所以协调脚本要每隔一段时间捞一遍容器列表,把"超出预期运行的时期且注册失败"的容器杀掉。

第三,节点注册后要保持心跳。Grid 4 里节点每隔 60 秒会向 Event Bus 上报一次状态,如果心跳断了,Distributor 会自动把它标记为不可用。这个机制本身没问题,但在容器里跑的时候要留意:如果宿主机有网络隔离策略,可能需要放开 Event Bus 两个端口(4442/4443)的心跳通信。

3.3 空闲回收和资源水位控制

有了扩容,自然就需要缩容。如果不做回收,按需使用就退化成"常驻一堆冷节点",云化也就失去意义了。

我的回收策略是分两层的:

第一层:会话空闲时间控制。每个容器节点上通过环境变量设置会话超时时间,例如SE_NODE_SESSION_TIMEOUT=1800,意思是某个会话空闲超过 30 分钟自动关闭。这样即使测试脚本挂了、没发删除会话请求,节点也会自动释放资源。

第二层:容器空闲回收。协调脚本每隔 5 分钟检查一次处于"空闲但无新会话"状态的动态节点容器。如果某个节点在过去 60 分钟内没有被分配任何会话,并且当前队列中没有等待请求,就调用 Docker 把它停掉并删除。常驻热节点不受这条规则约束,始终保留。

做回收时容易踩的坑是:节点刚注册完还没接到第一个会话,就被回收脚本当成"空闲节点"杀掉。所以脚本里必须加一个保护期:容器启动后 10 分钟内不参与回收判定。我在这上面吃过亏,刚开始跑的那两天,扩容起来的节点总是莫名其妙消失,查了半天日志才发现是回收脚本误杀。

4. 动手部署:一套可复现的浏览器矩阵云化配置

4.1 docker-compose 基础编排

下面给出一个可以直接复现的最小配置。它包含一个 Grid 4 Hub 和两类浏览器节点,读者可以根据实际需要增删浏览器版本。

version: "3.8" services: selenium-grid: image: selenium/hub:4.15.0 container_name: grid-hub ports: - "4442:4442" - "4443:4443" - "4444:4444" environment: SE_SESSION_REQUEST_TIMEOUT: "180000" SE_SESSION_RETRY_INTERVAL: "5000" networks: - grid-net chrome-node-116: image: selenium/node-chrome:116.0 container_name: chrome-node-116 depends_on: - selenium-grid environment: - SE_EVENT_BUS_HOST=selenium-grid - SE_EVENT_BUS_PUBLISH_PORT=4442 - SE_EVENT_BUS_SUBSCRIBE_PORT=4443 - SE_NODE_MAX_SESSIONS=2 - SE_NODE_OVERRIDE_MAX_SESSIONS=true volumes: - /dev/shm:/dev/shm shm_size: 2gb networks: - grid-net firefox-node-115: image: selenium/node-firefox:115.0 container_name: firefox-node-115 depends_on: - selenium-grid environment: - SE_EVENT_BUS_HOST=selenium-grid - SE_EVENT_BUS_PUBLISH_PORT=4442 - SE_EVENT_BUS_SUBSCRIBE_PORT=4443 - SE_NODE_MAX_SESSIONS=2 - SE_NODE_OVERRIDE_MAX_SESSIONS=true volumes: - /dev/shm:/dev/shm shm_size: 2gb networks: - grid-net networks: grid-net: driver: bridge

几个配置项的说明:

  • SE_SESSION_REQUEST_TIMEOUT表示一个会话请求在队列里最长等待时间,超过这个时间没匹配到节点就直接失败。我在配置里给到 180 秒,配合扩容脚本正好够用。
  • SE_NODE_OVERRIDE_MAX_SESSIONS设置为 true,是让节点忽略镜像自身的默认并发限制,采用我们显式指定的值。
  • /dev/shm挂载和shm_size: 2gb是 Chrome 容器最容易被忽略的一个点。浏览器进程依赖共享内存,默认 /dev/shm 只有 64MB,并发一高就会出现"无法创建共享内存"之类的诡异报错。

如果只有这套 Compose,那它只是把原来的 VM 换成了容器,还没有"按需"。动态扩容能力由下面的协调脚本补上。

4.2 测试代码侧的低侵入改造

对测试代码的改造,比很多人想象的要小。只要原来用的是 Selenium,把 WebDriver 的初始化方式从"本地启动"改成"连接远端 Grid"即可。以 Python 为例:

from selenium import webdriver from selenium.webdriver.chrome.options import Options chrome_options = Options() # 这个 capabilities 会被转发给 Grid,由 Distributor 决定分配给哪个节点 chrome_options.set_capability("browserName", "chrome") chrome_options.set_capability("browserVersion", "116.0") chrome_options.set_capability("platformName", "linux") # 自定义标签,Grid 4 也可以配合配置做更精确的过滤 chrome_options.set_capability("se:name", "test-case-001") driver = webdriver.Remote( command_executor="http://grid-host:4444/wd/hub", options=chrome_options )

不用改任何用例逻辑,只需要把command_executor指向网格地址。这一点非常关键——它意味着测试环境云化改造可以不以重写测试代码为前提,渐进式推进。我团队里有几个老项目,测试代码还是三年前的写法,但只需要把本地 driver 换成 remote 就能接入新环境。

4.3 与 CI 流水线的整合

网格搭好之后,要成为团队日常工具,还必须解决"测试跑起来时环境要 Ready"的问题。

我的做法是在 Jenkins 流水线里加一个"环境检查"阶段:

stage("check grid ready") { steps { sh """ for i in $(seq 1 30); do resp=$(curl -s http://grid-host:4444/status) total=$(echo $resp | python3 -c "import sys, json; data=json.load(sys.stdin); print(data['value']['totalNodes'])") available=$(echo $resp | python3 -c "import sys, json; data=json.load(sys.stdin); print(data['value']['availableNodes'])") if [ "$total" -gt 0 ] && [ "$available" -gt 0 ]; then echo "grid is ready" exit 0 fi sleep 5 done echo "grid not ready" exit 1 """ } }

逻辑很简单:循环探测 Grid 的/status接口,直到出现可用节点才继续跑用例。这个步骤看起来不起眼,但能省掉很多"第一个用例失败、后续用例全挂"的假性稳定问题。

另外,CI 流水线里每个构建任务应该显式声明自己需要的浏览器矩阵,由平台侧做资源配额控制。比如 A 项目的任务只能申请 Chrome 和 Firefox 各 2 个并发会话,B 项目最多申请 4 个 Chrome 会话。这样避免某个项目测试任务把整个网格的资源全吃完,其他项目排队等到超时。

5. 踩坑实录:运营测试网格一个月的真实问题清单

5.1 会话泄漏把节点池打满

上线第一周最严重的问题,是"会话泄漏"。具体表现是:跑完回归后,Grid 的状态页显示可用节点为 0,所有节点都显示"busy",但实际上并没有测试任务在跑。

根因有两个:

  • 用例异常中断后没有调用driver.quit()。比如 JVM 被 kill、Python 进程崩溃,WebDriver 会话没人管,Grid 就一直认为它还在活跃。
  • 节点上的浏览器进程挂掉但容器没退出。Chrome 主进程崩溃后,容器还活着,节点上报给 Grid 的状态是"有活跃会话"。

解决方案是双管齐下:

  • 在 Grid 层面设置会话超时SE_NODE_SESSION_TIMEOUT=1800,空闲 30 分钟强制回收。
  • 在协调脚本里增加"僵尸浏览器进程"巡检。每 10 分钟进入容器检查一次,如果容器内 Chrome 主进程不存在但节点的会话状态是 busy,就重启容器。

加了这个巡检后,会话泄漏导致节点耗尽的问题基本不再出现。

5.2 版本漂移导致的兼容性误报

浏览器矩阵的价值在于版本控制,但容器化的版本标签如果管理不严,会变成新的混乱来源。

我在配置里写死了 Chrome 116,但有一次运营发现某些用例实际跑在 Chrome 118 上。查了半天发现,团队里有同事手动更新了 Docker 镜像标签,把selenium/node-chrome:116.0的标签覆盖成了新版本,而 Compose 文件里依赖的标签名没变,所以所有新增容器都"静默"用上了新版本。

这个问题的通用解法是:不要在 Compose 里用浮动标签(比如 latest),要锁定到不可变镜像摘要,比如selenium/node-chrome@sha256:xxxx。但要做到完全锁定也有成本——每次更新浏览器版本时都要手动改摘要。我在实践中的折中方案是:内部搭一个小型镜像仓库,把官方镜像重新打上带构建日期的标签,比如chrome-116-20240701,同时在 Harbor 里开启镜像扫描,任何覆盖行为都会留下审计记录。

5.3 带宽与容器资源的隐性瓶颈

容器化看着轻量,但浏览器非常吃资源。一个月跑下来,我统计了典型瓶颈,按优先级排序:

  • 内存:单 Chrome 会话平均占 400~600MB 内存,如果SE_NODE_MAX_SESSIONS=2,一个 2GB 内存的容器差不多是安全的。调成 3 或 4 就会频繁触发 OOM。
  • CPU:页面渲染和 JS 执行都吃 CPU,并发执行多任务时,单容器多会话的 CPU 争抢是性能衰减的主要原因。解决思路不是加 CPU,而是控制单容器并发数,宁可多起容器,不要让一个容器扛太多会话。
  • 网络 I/O:如果被测系统要走视频流或大数据量接口,容器网络会成为瓶颈。我在实践里把 Grid 网络从 bridge 改成自定义网络,容器间走内部 DNS 解析,避免了大量跨宿主机流量打在同一网卡上。

5.4 视频录制与日志采集的成本控制

浏览器容器有个很吸引人的功能:可以通过配置自动录制测试视频。镜像里自带SE_VIDEO_RECORD相关的支持,启动时打开,跑完测试就能拿到一段 mp4。这功能对排查偶发性失败真的很有用。

但全量开启的代价不小:一段 5 分钟的用例就能生成 20~50MB 视频,跑一百个用例就是几个 GB。我最后采用的策略是:

  • 默认关闭视频录制,只在环境变量里预留SE_VIDEO_RECORD=true的开关。
  • 当用例失败时,由测试框架的监听器在失败回调里带上特殊的 capability,让 Grid 仅对失败会话开启录屏。
  • 录好的视频统一存盘到独立目录,保留 7 天自动清理。

这样既保留了追溯能力,又不至于让存储成本失控。

6. 这套方案的边界在哪里

写到这里,我把这次"测试环境云化、按需使用浏览器矩阵"的改造经验基本讲完了。最后聊几句这套方案的适用边界,避免读者直接照抄后发现不适合自己。

先说适合的场景:团队有一定规模,自动化测试用例量在几十到几百之间,浏览器矩阵需求明确,而且业务数据敏感程度高,不适合直接上外部商业云测平台。这类团队用"自建 Selenium Grid 4 + Docker 动态节点"的方案,性价比是最高的。我踩完所有这些坑之后,团队目前大概是这样的运营状态:日常回归跑 3 组浏览器矩阵,约 20 个并发容器;大促前压测时自动扩容到 40 个容器,压完自动缩回去;整个环境的运维精力大约每周一小时,主要就是看一下脚本日志和存储水位。

再说边界:如果团队的测试并发需求已经动辄几百会话,或者需要跨多个 Kubernetes 集群调度,那这套"协调脚本 + Docker"的实现就会显得简陋。这时候应该考虑 K8s 原生的自动伸缩方案,利用 HPA 按会话队列长度扩缩容 Deployment,或者直接用 Kubernetes Event-driven Autoscaling(KEDA)这类组件来驱动。架构会复杂很多,但收益也更大。不过对于多数团队来说,先跑通这套简单方案,看清自己的真实并发水位,再决定要不要上 K8s,是更稳妥的路径。

最后给一个小建议:如果打算动手做,第一周不要把目标定成"全量云化",而是先选一个低风险的回归测试项目做试点。把网格跑稳、把回收策略调对、让开发和测试都习惯"环境是服务,不是机器"这个思路,再逐步扩大范围。我这次改造唯一后悔的,就是一开始步子迈得太快,导致前两周大部分时间花在处理各种边缘情况上。稳一点,反而更快。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询