ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Selenium Grid 分布式测试实战:5000条用例从8小时优化到40分钟

Selenium Grid 分布式测试实战:5000条用例从8小时优化到40分钟 上个季度我们做了一次全量回归5000多条Web UI用例挂在CI上跑从凌晨1点一直跑到早上9点。所有人上班第一件事不是看测试结果而是看它跑完了没有。后来我花了一个周末把整套执行环境迁到Selenium Grid上回归时间从8小时级别压到40分钟左右。这篇文章不是Selenium文档的翻译是我自己从单机硬等、到搭Grid、再到并发调优的全过程记录适合已经到了回归跑不动阶段、想上分布式的测试团队参考。1. 回归测试跑到8小时的时候问题已经不在浏览器本身1.1 先描述一下那个让人焦虑的回归周期当时团队的用例规模大约是5000条覆盖线上核心流程和几个大模块的回归。跑批环境是一台8核16G的Windows服务器浏览器用的是Chrome脚本框架是Java TestNG。最开始是串行跑后面改成了TestNG的线程并发线程数从5逐步加到15但只要超过8个线程服务器CPU就持续100%浏览器窗口频繁无响应甚至出现chromedriver崩溃。然后我们做了两件事一是优化等待时间把一堆Thread.sleep(5000)改成显式等待二是把用例按模块拆分分到不同的测试类里。有效果但依然撑不到可接受的回归频率。问题的核心在于我们一直在单台机器上做垂直堆叠机器资源的上限就是瓶颈浏览器实例之间还会互相挤占CPU和内存。如果你也是这个阶段我建议你先别急着上Grid先想清楚一个最基础的问题你的用例到底能不能并行如果两条用例都依赖同一个测试账号或者都要操作同一批数据那它们即使分到100台机器上跑结果依然是互相踩。我当时做的第一件事是梳理用例间的数据依赖强制隔离测试账号、清理脏数据这是后面一切分布式改造的前提。1.2 单机执行环境的三个结构性瓶颈单机跑批看起来是慢本质上是三个问题叠加浏览器执行通道是有状态的。一个WebDriver实例对应一个浏览器实例driver的所有命令都要发送到这个指定的浏览器。单机上你就算用多线程每个线程还是要创建独立的浏览器实例而这些实例共享CPU、内存、磁盘IO线程一多反而互相拖累。环境耦合太严重。浏览器版本、驱动版本、操作系统补丁、缓存、弹窗、代理设置任何一个环境因子发生变化都可能导致一批用例失败。单机模式下环境出问题就是整个跑批出问题而且很难定位是环境还是脚本的原因。失败恢复能力几乎为零。脚本中途抛异常、机器蓝屏、网络断开整个回归就废了只能重跑重跑期间团队就处于盲等状态。这三个瓶颈靠调整用例代码是解决不了的。你需要在架构层面把产生测试命令和执行浏览器操作拆开让浏览器可以独立分布在多台机器上这就是Selenium Grid分布式测试最核心的价值。1.3 一个常见误区多线程并发不等于分布式我见过很多团队把TestNG线程数调到20、30以为这就是并行测试结果发现执行时间并没有等比下降反而机器越来越卡。原因很简单多线程只是在测试进程内部让多个方法同时发起WebDriver调用但所有浏览器实例还是跑在同一台机器上受限于这台机器的CPU核数、内存大小和chromedriver的稳定性。真正的分布式是把执行浏览器操作这一层从测试脚本所在机器上剥离出去。脚本发送命令远程节点接收命令并操作本机的浏览器脚本和浏览器可以通过网络分离。这样你才能扩浏览器实例数而不用管脚本所在机器有多少资源。理解这个区别之后你再去看Selenium Grid的架构就顺了。2. Selenium Grid在架构上做了什么事2.1 从Hub-Node到Grid 4的组件化演进Selenium Grid的核心设计一直没变一个调度中心多个执行节点。Grid 3时代大家熟悉的架构是Hub和NodeHub维护节点注册表Node启动浏览器实例执行任务。这套模式能解决基础的需求但有个明显的单点问题Hub挂了整套Grid就瘫痪。而且Hub的扩展只能靠堆内存不适合大吞吐场景。Grid 4把原来大而全的Hub拆成了多个独立组件每个组件职责单一可以根据压力单独扩容Router统一入口接收所有WebDriver请求并转发到对应组件。Distributor负责任务分发知道哪些Node有空闲能力。Session Map维护Session ID和Node之间的映射关系让后续命令能精确路由到正确的节点。Session Queue当所有Node都繁忙时新的Session创建请求先进队列排队。Node在具体机器上启动浏览器执行测试。Event Bus组件之间的通信总线。以我实际搭建的感受来说这种拆分的最大价值不是性能提升多少而是可观测性。每个组件有独立的日志、独立的健康状态你通过/status接口能清楚看到是Router出了问题、Distributor没有发现节点、还是Node的会话数满了。之前用Grid 3遇到调度卡死只能重启Hub无从排查。2.2 一个Session从创建到关闭的完整旅程理解Grid的工作方式最有效的方法是跟着一次WebDriver请求走完整个生命周期。我以一次newSession调用为例测试代码里的RemoteWebDriver向Grid的Router发送创建Session的请求。Router收到请求后把它交给Session Queue。如果当前有Node空闲Queue里的请求会被Distributor拉走如果没有请求就在Queue里等待。Distributor根据请求的capabilities比如browserNamechromebrowserVersion120从已注册的Node中选出一个匹配的节点。Node收到创建Session的指令后在该机器上启动对应的浏览器实例。Session创建成功后Session Map会记录下这个Session ID对应哪个Node、对应哪个浏览器实例。之后所有driver命令findElement、click、get等都会带这个Session ID。Router每次收到后续请求都会查Session Map把请求转发给正确的Node。关键在于第5步。Session一旦创建它就和一个特定的浏览器实例绑定了Session Map就是为了维护这种绑定关系。这也是为什么Grid强调一个Session对应一个浏览器实例任何试图跨Session共享driver的做法都会破坏这个模型。2.3 Grid解决了的和它管不到的事Grid解决了浏览器执行能力的横向扩展想加并发加Node就行不用再纠结单机资源。环境和用例之间的隔离不同Node之间互相独立一个Node崩溃不影响其他Node上的任务。跨平台覆盖可以同时接入Windows、Linux、macOS的不同浏览器版本。Grid管不到的事得靠你自己解决测试代码本身的并发控制。Grid只是接收来自测试框架的并发请求它不会主动把你的用例拆分成多线程。真正发起并发的是TestNG、JUnit、pytest这些框架。测试数据隔离。前面提过用例之间数据互相污染在分布式环境下会被放大因为多个浏览器同时在操作同一套后台数据。用例顺序依赖。如果用例B依赖用例A产生的状态那它俩就不能被分到不同Node上执行。一开始就把这些边界搞清楚后面调优会顺利很多。3. 亲手搭一套Grid从0到可用的两种部署方案3.1 环境选型Docker优先但要留意老版本如果是从零开始搭Grid我强烈建议直接用Docker Compos方式不要在物理机上手动部署JAR包。Docker的好处不只是环境一致更重要的是你可以在几秒内增加一个Node跑完批量任务后又能立刻回收容器这对CI场景特别友好。版本上要特别注意Selenium 4的镜像和Selenium 3的Hub-Node体系不互通。Selenium 3镜像用的是selenium/hub:3.141.59Selenium 4用的是selenium/hub:4.x或拆分的独立组件镜像。如果你线上还有老脚本依赖旧版协议升级Grid版本之前一定要先在测试环境跑通一小批用例确认driver和Grid之间的通信协议兼容。硬件方面我给一个比较务实的建议每个Node容器至少要2核4G如果跑的是重交互类页面比如大量富文本编辑器、地图组件建议4核8G。顺带说一句Chrome跑在Docker容器里有个老毛病默认的/dev/shm只有64MB页面稍微复杂点就直接崩溃。所以Node容器一定要配置shm_size: 2gb这是大多数人第一个会踩的坑。3.2 方案一快速验证用的单Hub多Node如果你的目标是先跑通一条用例不需要在一个小时内部署完整的生产集群那么用selenium/hub和selenium/node-chrome就够了。下面这份docker-compose.yml是我用来做小规模验证的模板services: hub: image: selenium/hub:4.16.1 container_name: grid-hub ports: - 4444:4444 environment: - SE_SESSION_REQUEST_TIMEOUT500 - SE_SESSION_RETRY_INTERVAL5 node-chrome-1: image: selenium/node-chrome:4.16.1 container_name: grid-node-chrome-1 depends_on: - hub environment: - SE_EVENT_BUS_HOSThub - SE_EVENT_BUS_PUBLISH_PORT4442 - SE_EVENT_BUS_SUBSCRIBE_PORT4443 shm_size: 2gb node-firefox-1: image: selenium/node-firefox:4.16.1 container_name: grid-node-firefox-1 depends_on: - hub environment: - SE_EVENT_BUS_HOSThub - SE_EVENT_BUS_PUBLISH_PORT4442 - SE_EVENT_BUS_SUBSCRIBE_PORT4443 shm_size: 2gb这里的关键点有几个Node容器必须通过SE_EVENT_BUS_HOST指向Hub容器同时指定事件总线的发布和订阅端口。如果你少了这几个环境变量Node会启动失败或者注册不上Hub。我用docker compose up -d启动之后验证命令很简单curl http://localhost:4444/status | jq .正常的话你会看到value.nodes里存在node-chrome-1和node-firefox-1并且availability是UP。这个方案适合临时环境、本地调试、Demo演示但不建议直接拿到生产环境长期跑因为Hub仍然是单点。3.3 方案二生产级组件拆分部署如果你要承载大并发、需要单组件独立扩容那就不要用单Hub容器了而是把Grid 4的组件拆开。下面是一个常用的Compose结构services: event-bus: image: selenium/event-bus:4.16.1 container_name: grid-event-bus ports: - 4442:4442 - 4443:4443 session-map: image: selenium/session-map:4.16.1 container_name: grid-session-map depends_on: - event-bus environment: - SE_EVENT_BUS_PUBLISH_PORT4442 - SE_EVENT_BUS_SUBSCRIBE_PORT4443 distributor: image: selenium/distributor:4.16.1 container_name: grid-distributor depends_on: - event-bus - session-map environment: - SE_EVENT_BUS_PUBLISH_PORT4442 - SE_EVENT_BUS_SUBSCRIBE_PORT4443 router: image: selenium/router:4.16.1 container_name: grid-router ports: - 4444:4444 depends_on: - event-bus - distributor - session-map environment: - SE_EVENT_BUS_PUBLISH_PORT4442 - SE_EVENT_BUS_SUBSCRIBE_PORT4443 node-chrome: image: selenium/node-chrome:4.16.1 container_name: grid-node-chrome depends_on: - event-bus - distributor environment: - SE_EVENT_BUS_PUBLISH_PORT4442 - SE_EVENT_BUS_SUBSCRIBE_PORT4443 shm_size: 2gb这套结构里event-bus是通信基础设施session-map和distributor都要订阅它的消息router对外暴露4444端口其他组件不需要暴露端口。要注意的是所有组件的事件总线端口必须配对否则会出现router能访问、但distributor发现不了节点的情况。我遇到过明明是同一套Compose就因为改了event-bus的端口而全部失联最后只能挨个看容器日志才排查出来。组件拆分的另一个好处是可以分别看日志。比如docker logs -f grid-distributor能看到节点上下线记录docker logs -f grid-router能看到所有Session请求的进出。生产环境排查问题时这个信息密度比单Hub容器高得多。3.4 上线前的验证清单搭完Grid不要直接拿全部用例去压先跑通五步再进下一步用浏览器打开http://localhost:4444/ui确认Grid的控制台能看到所有节点。手动执行一个最简单的脚本创建session、打开百度首页、退出session确认命令链路完整。在同一时间发起两个并发脚本确认Session Queue能正常工作。停掉一个Node容器确认任务会被调度到存活节点而不是卡死。观察各Node的内存占用情况确认没有出现内存缓慢增长的问题。4. 测试代码适配从本地Driver到远程Driver只改两步4.1 最小改造用RemoteWebDriver替代本地Driver这是整个改造里最技术含量低但最容易出错的一步。以Python为例原来的代码是这样的from selenium import webdriver driver webdriver.Chrome() driver.get(https://example.com) driver.quit()改成远程执行只需要把启动driver的位置换成这样from selenium import webdriver from selenium.webdriver.chrome.options import Options options Options() driver webdriver.Remote( command_executorhttp://localhost:4444, optionsoptions ) driver.get(https://example.com) print(driver.title) driver.quit()Java版本也很直观import org.openqa.selenium.WebDriver; import org.openqa.selenium.chrome.ChromeOptions; import org.openqa.selenium.remote.RemoteWebDriver; import java.net.URL; ChromeOptions options new ChromeOptions(); WebDriver driver new RemoteWebDriver(new URL(http://localhost:4444), options); driver.get(https://example.com); System.out.println(driver.getTitle()); driver.quit();从new ChromeDriver()变成new RemoteWebDriver()本质就是把浏览器启动的命令通过网络发送给Grid。注意一点RemoteWebDriver的command_executor地址要能被脚本所在的机器访问到。如果你脚本跑在CI里CI机器和Grid不在同一个网络就要把localhost换成Grid的对外IP或域名。我见过有人只改了这里结果录制的视频里浏览器确实打开了但脚本一直超时。原因出在防火墙只放行了4444端口而Grid内部的路由在转发命令时用的还是Event Bus的4442/4443端口被挡在外面。所以部署时如果脚本机不在Grid同一内网一定要把这三个端口一并放通或者干脆让CI机器和Grid处在同一个内网网段。4.2 capability配置Keys很重要但别乱用Selenium 4的客户端里配置浏览器类型和版本的方式是用Options对象而不是老的DesiredCapabilities。ChromeOptions、FirefoxOptions这些类都继承自Capabilities接口天然支持指定browserName、browserVersion、platformName。实际使用中我不建议在同一个脚本里同时指定platformName和browserVersion来筛选节点因为一旦你指定的版本在某个节点上不存在Grid会拒绝创建Session。更稳妥的做法是在路上只需要指定browserName让它匹配任何可用的Chrome节点options Options() options.browser_version 120 # 只在明确需要时加 driver webdriver.Remote( command_executorhttp://localhost:4444, optionsoptions )Chrome自身的配置参数比如禁用沙箱、设置语言、加启动参数依然通过options.add_argument()放在ChromeOptions里。Grid只负责把Options原封不动传给远端Node不关心具体参数含义。4.3 真正决定并行度的是测试框架不是Grid很多人误以为把driver改成RemoteWebDriver后Grid就会自动并行执行。实际上你需要让测试框架在多个线程里同时发起请求。以Java TestNG为例要先有一个XML配置文件!DOCTYPE suite SYSTEM https://testng.org/testng-1.0.dtd suite namegrid-suite parallelmethods thread-count20 test nameall-tests classes class namecom.example.login.test.LoginTest/ class namecom.example.order.test.OrderTest/ /classes /test /suiteparallelmethods表示同一测试类的不同方法可以并行thread-count20表示最多20个线程。这20个线程会同时向Grid发送Session请求Grid再根据Node的空闲情况分配浏览器。如果用的是JUnit5可以这样Test Execution(ExecutionMode.CONCURRENT) void shouldLogin() { ... }跑pytest-xdist的话更简单pytest -n 8就是8个进程并行。这里有个非常容易踩的坑driver实例的线程安全性。很多团队的用例代码里用了static driver或者单例driver这在串行执行时没问题一旦开启多线程所有线程会共享同一个driver命令全部乱套。正确的做法是让driver以线程局部变量的形式存在每个测试方法创建独立的driver测试结束马上driver.quit()。Java里可以用ThreadLocalWebDriverPython里直接在测试方法内部创建driver实例不要放到类级别。4.4 多环境切换与代码可回退我不建议把线上代码一把梭改成远程driver好的做法是保留环境变量控制。比如在启动器里加一段判断import os from selenium import webdriver from selenium.webdriver.chrome.options import Options GRID_URL os.getenv(GRID_URL, ) BROWSER os.getenv(BROWSER, chrome).lower() def create_driver(): options Options() # 添加统一的启动参数比如无头模式 options.add_argument(--headlessnew) if GRID_URL: driver webdriver.Remote(command_executorGRID_URL, optionsoptions) else: driver webdriver.Chrome(optionsoptions) return driver本地开发的时候不设置GRID_URL跑的是本地浏览器不影响调试CI和回归的时候设置GRID_URL走Grid执行。这样一套代码可以同时满足开发、调试、并行回归三种场景。5. 并发规模真的上去了决定成败的是容量和细节5.1 三个并发控制点必须对齐Grid跑起来之后并发量不是由某一个参数决定的而是三个地方取最小值测试框架的线程数。TestNG的thread-count、pytest的-n决定有多少个并发Session请求发出来。Grid的总Session容量。所有节点加起来的最大并发浏览器数量。单个Node的实例数。比如SE_NODE_MAX_SESSIONS设成4这个Node最多同时起4个浏览器。一个经典的失败场景是测试框架线程数设为50Grid有10个Node每个Node默认SE_NODE_MAX_SESSIONS1那么同时只有10个浏览器能跑。剩下的40个线程会在Session Queue里排队超过SE_SESSION_REQUEST_TIMEOUT后直接报错。你去看Grid日志会看到大量Session request timed out。这不是Grid的Bug是你没把三个值对齐。实际操作时我会先算一遍容量总并发上限 每个Node的 SE_NODE_MAX_SESSIONS 之和 推荐线程数 ≈ 总并发上限 × 0.9留10%的余量是为了避免所有浏览器同时争抢CPU导致页面加载变慢。5.2 Node资源配置的参考值我按页面复杂度给过团队一份参考表目前用下来比较稳场景类型每个节点资源建议会话数普通表单类页面2核4G2~3中重交互页面地图、编辑器4核8G3~4视频/流媒体页面4核8G2极限压测场景8核16G6~8实际跑的时候一个Chrome实例空闲时的内存大约200MB打开中等复杂度页面后能到400~600MB。如果你在一个2核4G的Node上强行开6个Chrome实例系统会开始使用swap整体执行时间反而变慢。我见过最夸张的情况是有人把SE_NODE_MAX_SESSIONS设为20结果Node直接OOM容器被宿主机杀掉。所以判断Node是否健康不能只看CPU使用率内存占用率才是决定因素。一个Node上同时跑的浏览器实例越多每个实例的速度就越慢单条用例的执行时间会线性变长。5.3 超时、排队和僵尸Session的处理方案Session Queue是Grid 4特有的一层缓冲。当所有Node都忙碌时新请求会进队列等待。这本来是好事但配置不当就变成灾难。Grid有几个关键环境变量需要你在启动的时候想清楚SE_SESSION_REQUEST_TIMEOUTSession创建请求在队列里最多等多久超时就报错。SE_SESSION_RETRY_INTERVALDistributor重试拉取请求的间隔。SE_NODE_SESSION_TIMEOUTNode上一个Session超过该时间没有活动会被强制注销。大批量回归时如果测试框架线程数远大于Grid容量我建议把SE_SESSION_REQUEST_TIMEOUT设大一点比如500秒给排队任务更长容忍窗口同时把框架层面的连接超时设成同样的值避免两端超时不一致导致任务被误杀。还有僵尸Session的问题。有时候脚本执行完毕但没有正确调用driver.quit()Session就会一直挂在Node上占着浏览器实例不释放。你会在/status看到某个Node的currentSessions不再降下来。处理办法有两个层面一是让代码里一定用try/finally保证quit()执行二是运维上定期检查/status里的会话数发现异常就重启对应Node。5.4 视频录制、日志和网络这三个隐形消耗Selenium的Docker镜像里有一个很有用的能力录制视频。启动Node的时候设置SE_ENABLE_RECORDINGtrue它会把浏览器窗口的屏幕录下来测试结束后把视频拷出来。这功能对缺陷定位非常友好但在大并发下是个隐形杀手。录屏要持续占用CPU做视频编码内存也会涨录制文件还要写到磁盘。如果你跑的是几百条用例的短视频磁盘空间和IO都会成为瓶颈。我的建议是日常全量回归不开录屏只在失败用例重跑时针对性地让一个Node开启录屏。或者干脆在测试脚本中捕获失败页面截图截图比视频轻量得多排查大多数问题时够用。网络环境也要认真对待。WebDriver的命令是逐条走的一次findElement可能就要传输几百字节的命令和响应如果每个操作都有跨地域网络延迟那么一条用例可能多出几十秒。Grid和脚本执行机之间最好是同一个内网宁可多买几台机器放在同一个机房也不要图省事走公网。6. 一次实际调优复盘5000条用例从6小时到40分钟6.1 初始状态默认配置直接压结果还不如单机我们当时搭建了一套30个Node的Grid集群每个Node都是2核4G的容器Chrome节点。第一轮我直接用TestNG的thread-count50去跑5000条用例满心期待能稳定在2小时以内结果跑了3个多小时而且有接近400条用例因为Session超时失败。一看Grid的/status30个Node每个Node的maxSessions是1所有请求都在排队。这就是典型的默认配置和框架线程数没有对齐。我先把SE_NODE_MAX_SESSIONS从默认的1调到3每个Node的并发浏览器数变成3总并发能力从30提升到90。同时把TestNG的thread-count从50降到80保证请求略小于容量。第一轮改完全量回归时间直接掉到1小时40分钟。6.2 真正见效的是第二轮减少重复初始化第一轮虽然时间下降了但我注意到每个Node的CPU使用率忽高忽低而且日志显示大量的时间消耗在浏览器启动和初始化用例环境上。每个Session创建时都会重新登录、初始化测试数据、清缓存这部分成本占了单条用例的三分之一。我们做了两个改造在测试框架里加入了浏览器会话复用机制。让相同账号体系的用例尽量在同一个ThreadLocal driver下连续执行减少重复登录和数据初始化次数。把测试数据准备从用例内拆出去换成本地预置数据通过接口直接注入而不是每次通过UI去创建。这一轮之后回归时间从1小时40分钟压到了55分钟。而且因为浏览器启动次数少了Node的内存占用也下降了整体更稳定。6.3 第三轮任务分优先级把资源留给关键路径全量回归里其实有大量低频用例跑一遍只是为了求个心安。我们把用例分成了三个级别P0核心交易主链路回归必须优先跑完。P1重要模块跑批必须覆盖。P2边缘场景和兼容性检查有空闲资源再跑。P0和P1单独建一个测试套件优先启动P2套件滞后启动。这样整个回归周期内关键路径始终先被调度即使后面发生资源不足牺牲的也是P2用例而不是核心链路。最终把稳定执行时间控制在40分钟左右。这里面有一个小细节Task执行顺序在TestNG里完全靠preserve-order和priority控制Grid本身不感知你的用例优先级它只负责按请求顺序分配浏览器。所以资源编排是在测试框架层面完成的不依赖Grid。6.4 这一年里反复踩过的坑挨个列给你执行高峰时Chrome在容器里会出现进程没完全退出的现象表现为Node内存缓慢上涨跑几天后可用内存归零。最直接的解决办法是定期重启Node容器或者在CI任务完成后主动清理所有容器。也试过在Node里加定时任务清理僵尸Chrome进程效果还行但不如重启彻底。还有个很隐蔽的问题出现在TestNG的线程池复用上多线程跑完之后线程并没有销毁而是被TestNG缓存了起来。如果driver对象不小心放到了实例变量而不是局部变量第二个测试方法会复用上一个方法残留的driverSession ID对不上报NoSuchSessionException。排查的时候你会发现所有失败用例都集中在某几个线程ID上这就是典型的driver未隔离。坚持用ThreadLocal后这个问题再也没出现过。网络抖动是分布式的宿命尤其当脚本执行机和Grid不在同一网络时。我们最后在框架层面给每个用例加了失败重试机制遇到网络异常或Node崩溃这类非脚本问题自动重跑一次重跑后用例通过率在97%以上。注意重试要限制在连接层异常和Session丢失这两类脚本断言失败不要重试否则会把真的Bug掩盖掉。最后说点个人的体会把Grid整套跑顺之后我最深的感受是Selenium Grid本身并不复杂它把浏览器分布到多台机器这件事做到了极致但你真正要解决的核心问题其实是测试用例的可并行能力。如果你的用例互相共享登录态、共享数据库记录、共享账户Grid给你100个节点也白搭节点越多失败越多。反过来一旦用例做好了数据隔离、线程隔离、失败重试Grid带来的收益是立竿见影的。建议你不要一口气把5000条用例全部切到Grid上先挑一个模块、几十条用例用一套小的Compose跑通再逐渐扩大。等你某天发现又到周五了这个版本的回归已经开始跑了而不是又到周五了这个版本的回归还没开始你就知道这套东西值不值得了。
返回列表