返回知识中心
对比

Selenium Grid:两个节点为何结果不同

会话会落到当时有空闲槽位的节点。分别记录配置文件固定的字段与主机仍决定的字段,才能把差异归因到节点而不是某次提交。

BotBrowser Team

文档中心

想直接进入 文档 文档吗?

这篇文章属于知识中心的指南库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

Selenium Grid 的测试不在提交它的那台机器上运行。Distributor 把会话分配给某个节点上的槽位,执行测试的浏览器就是那个节点上安装的浏览器。两个节点运行同一个测试、同一份代码、同一个浏览器版本,仍可能得到不同结果,因为槽位是调度单位,而不是环境一致的保证。这类失败之后的第一个问题不是哪条断言坏了,而是哪个节点跑了它,而答案只能来自记录,不能来自重跑。BotBrowser 用一份配置文件固定页面读到的身份取值,但它不改变主机光栅化出来的像素,也不改变主机维持的时钟,因此那两项恰恰是节点之间仍会不同的字段。

同一个 Grid 测试在两个节点上:Distributor 把会话分配到空闲槽位,每个节点记录应当一致的控制字段与可能不同的主机字段,比较后得出 node_divergence 结论。

摘要

  • Grid 槽位是调度单位。Distributor 把会话分配给任何匹配请求的槽位,因此运行测试的节点是运行时决定的,不是测试文件决定的。
  • 把记录分成控制字段与主机字段。在默认 WebGL 模式下,UNMASKED renderer 字符串、navigator.hardwareConcurrency 这类控制字段在应用同一份配置文件时应当一致;若不一致,说明配置文件或启动参数不同。
  • 像素哈希、耗时这类主机字段在配置文件完美时仍可能不同,因为配置文件既不负责光栅化帧,也不负责维持时钟。
  • 每次尝试都记录会话 id。capabilities 里没有节点 id,节点身份要用会话 id 事后从 Grid 的 GraphQL 或界面查询。
  • 集中在一个节点上的失败支持主机产物这一判断;在各节点上均匀分布的失败指向代码、数据或应用本身。

目录

节点与槽位是什么

Grid 由 Router、New Session Queue、Distributor、Session Map 以及一个或多个 Node 组成。Router 把进来的请求转发给队列,或者转发给已经拥有该会话的节点;Distributor 把新会话分配给空闲槽位。每个节点有一个或多个槽位,每个槽位携带一个 stereotype:请求必须匹配的最小能力集合,Distributor 才会把它送到该节点。Session Map 保存会话 id 与运行它的节点地址之间的映射。

这个结构解释了不稳定 Grid 测试最先教给人的那件事。一个能力相同的请求可以被任何槽位 stereotype 匹配的节点应答,所以同一个测试的连续两次运行可以在不同主机上执行,而测试本身没有任何改动。节点注册时会自动注册其自身 PATH 上可用的浏览器驱动,并为 Chromium 系浏览器与 Firefox 按可用 CPU 各创建一个槽位,而 Safari 只创建一个槽位。因此核数不同的两个节点,从同一个测试的角度看会公布不同数量的槽位。

节点注册还携带一个 nodeId UUID 和一个含 arch、name、version 的 osInfo 对象,Grid UI 与 GraphQL 查询会暴露这两个字段。正是它们把「测试失败了」变成「测试在这个节点上失败了」。读取它们只需一次查询,却能让一场本来要拖上几天的争论当场结束。

队列出于同样的原因重要。当所有匹配槽位都忙时,新会话会等待而不是失败,因此在高峰时段运行的测试会比同一测试单独运行更晚开始。从测试进程启动那一刻开始计时的超时,因此包含排队时间,而一个在不知道这一点的情况下自设截止时间的测试集,会把调度延迟报告成应用超时。Distributor 还维护一份 Grid 的模型,架构文档指出这份模型可能与现实脱节,例如在 Distributor 刚启动之后;被路由到不再应答的槽位的会话,从测试内部看就像一次网络失败。

这些组件没有一个会去比较两个节点。Grid 分配工作并跟踪会话;它不会告诉你节点七和节点十二是否产出相同的像素。这项比较必须建进测试自己的记录里。

一条记录里的两类字段

大多数节点比较在开始之前就失败了,因为它们把两个问题混进同一张表。一个问题是声明的环境是否到位;另一个问题是主机行为是否一致。配置文件回答第一个,回答不了第二个,所以记录必须把它们分开。

字段谁决定取值差异意味着什么
UNMASKED vendor 与 renderer 字符串配置文件,或在 --bot-webgl=real 模式下由主机提供配置文件不同,或两台主机的 WebGL 栈不同
navigator.hardwareConcurrency默认由配置文件决定,使用 --bot-cores=real 时由主机决定配置文件不同,或某个节点用了真实核数覆盖
屏幕尺寸、区域、时区由配置文件提供,除非按会话覆盖配置差异,而不是渲染差异
浏览器与驱动版本节点注册时上报的内容忽略版本的匹配把同一请求路由到了两个构建
固定绘制的像素哈希,--bot-webgl=real主机的光栅化器真正的宿主差异,即使配置文件正确、WebGL 为 real 模式也仍然存在
固定步骤的耗时主机的 CPU、负载与网络资源争用或硬件差异,而不是身份差异

前三行在启动使用默认 WebGL 模式时是控制字段;使用同一份配置文件与同一组启动参数时,它们应当在两个节点上一致,而这里的不一致是一个修复成本很低的配置缺陷。renderer 那一行有一个对本测量很重要的注意事项:在 --bot-webgl=real 模式下,WebGL 由主机提供且没有保护,所以 UNMASKED 字符串跟随主机栈,该行不再是控制字段。这就是为什么像素摘要需要 real 模式,而 renderer 字符串不应与它放在同一次控制比较里。BotBrowser 的配置文件文档列出了配置文件提供的身份取值,包括屏幕与窗口尺寸、字体、WebGL 与 WebGPU、媒体设备与语音;其配置文件管理文档把设备内存、CPU 核数与 GPU 型号这类硬件信号列入配置文件携带的取值。--bot-cores 被记录为在配置文件声明的核数与主机真实核数之间做选择,而默认保持配置文件声明的值。

后两行是主机字段,意外正藏在这里。配置文件控制的是页面被告知什么,而不是 GPU 驱动真正计算了什么。BotBrowser 的 Linux GPU 后端文档直接说明了这一点:不同的软件光栅化器对同一个着色器产生不同的像素输出,因此 readPixels 或 toDataURL 输出的确切字节在 SwiftShader 与 Mesa llvmpipe 之间不同,而在默认 WebGL 模式下,UNMASKED vendor 与 renderer 字符串仍由配置文件控制,不随后端迁移而变化。在 --bot-webgl=real 下这项保证被刻意关闭,所以同一次迁移会同时改变像素字节与上报的字符串。所以跨两个节点的画布比较,即使身份取值完全一致,仍然是一次宿主比较。

把两组字段放进同一条记录,诊断才成为可能。控制组不一致说明两次运行不是同一个实验;主机组不一致说明它们是同一实验在两台不同机器上。混淆两者会让团队在真正的问题出在光栅化器时去重掷配置文件,或者在真正的问题出在缺失的配置字段时去重写测试。

读取这些取值在会话内部完成:请求描述意图,页面描述实际到达的内容,两者之间的空隙正是大多数节点差异藏身之处。一个请求可以不指定版本地要求 Chrome,不指定版本地要求平台名,并要求一个节点会悄悄调整以适配显示器的窗口尺寸。页面随后报告的内容才是应用真正收到的内容,因此那才是应该进记录的内容。

下面的例子从运行中的会话内部收集一份短的固定清单:三个控制字段、一个像素摘要与一个计时。它打印一行,既够比较两个节点,又小到可以附进报告而不泄露账号数据。

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

options = Options()
options.add_argument("--bot-gpu-emulation=false")
options.add_argument("--bot-webgl=real")

driver = webdriver.Remote(command_executor="http://grid.internal:4444", options=options)
try:
    driver.get("https://app.example.test/health")
    values = driver.execute_script(
        """
        const canvas = document.createElement('canvas');
        canvas.width = 220; canvas.height = 90;
        const gl = canvas.getContext('webgl');
        const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
        const vs = [
          'attribute vec2 p;',
          'varying vec2 uv;',
          'void main() { uv = p * 0.5 + 0.5; gl_Position = vec4(p, 0.0, 1.0); }',
        ].join('\n');
        const fs = [
          'precision mediump float;',
          'varying vec2 uv;',
          'void main() {',
          '  float a = (sin(uv.x * 37.0 + 1.7) + sin(uv.y * 23.0 + 3.1) + sin((uv.x + uv.y) * 61.0)) / 3.0;',
          '  float b = fract(uv.x * 41.0) * fract(uv.y * 29.0);',
          '  gl_FragColor = vec4(0.5 + 0.45 * a, b, 0.5 - 0.45 * a, 1.0);',
          '}',
        ].join('\n');
        // Measured with --bot-gpu-emulation=false --bot-webgl=real. The defaults would answer
        // this draw from the profile GPU layer, and the digest would describe the profile.
        let digest = null;
        if (gl) {
          const compile = (type, src) => {
            const s = gl.createShader(type);
            gl.shaderSource(s, src);
            gl.compileShader(s);
            return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null;
          };
          const program = gl.createProgram();
          const vertex = compile(gl.VERTEX_SHADER, vs);
          const fragment = compile(gl.FRAGMENT_SHADER, fs);
          if (!vertex || !fragment) return { pixels: null, pixels_error: 'compile_failed' };
          gl.attachShader(program, vertex);
          gl.attachShader(program, fragment);
          gl.linkProgram(program);
          if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return { pixels: null, pixels_error: 'link_failed' };
          gl.useProgram(program);
          const buffer = gl.createBuffer();
          gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
          gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
          const p = gl.getAttribLocation(program, 'p');
          gl.enableVertexAttribArray(p);
          gl.vertexAttribPointer(p, 2, gl.FLOAT, false, 0, 0);
          gl.clearColor(0.0, 0.0, 0.0, 1.0);
          gl.clear(gl.COLOR_BUFFER_BIT);
          gl.drawArrays(gl.TRIANGLES, 0, 3);
          const bytes = new Uint8Array(220 * 90 * 4);
          gl.readPixels(0, 0, 220, 90, gl.RGBA, gl.UNSIGNED_BYTE, bytes);
          let h = 2166136261;
          for (let i = 0; i < bytes.length; i += 4) h = Math.imul(h ^ bytes[i + 1], 16777619) >>> 0;
          digest = h.toString(16).padStart(8, '0');
        }
        const started = performance.now();
        for (let i = 0; i < 20000; i += 1) Math.sqrt(i);
        return {
          renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null,
          cores: navigator.hardwareConcurrency,
          lang: navigator.language,
          dpr: window.devicePixelRatio,
          pixels: digest,
          elapsed: Math.round((performance.now() - started) * 100) / 100,
        };
        """
    )
    print({"session": driver.session_id, "browser": driver.capabilities.get("browserVersion"), **values})
finally:
    driver.quit()

这份清单是刻意短、也刻意混合的。cores、lang 与 dpr 是控制字段:配置文件提供它们,所以两个节点上应当一致,不一致就是第一个要查的地方。renderer 只有在默认 WebGL 模式下才是控制字段,在 --bot-webgl=real 下移到主机一侧,因此下面的示范记录把它放在控制组之外。pixels 是主机字段:它绘制一个固定的全屏三角形,片元着色器混合三个正弦项与一个 fract 乘积,用 readPixels 逐像素回读,并把绿通道折成一个短的 FNV-1a 摘要。插值与亚像素覆盖正是两个光栅化器分歧之处,所以着色器必须在表面上变化:常量色,尤其是只做 clear,在每个后端都返回相同字节,什么也测不到。只有两个启动条件都满足,这次测量才配得上它的结论。GPU 模拟必须关闭,WebGL 必须处于 real 模式:默认值会让 WebGL 绘制由配置文件层作答,而配置文件层不仅控制身份字符串,还控制 shader 输出与图像回读,所以留在默认值上测到的是配置文件,却被当成主机字段报告。记录里还要带 devicePixelRatio,因为摘要取决于表面有多少像素;同一场景以不同缩放渲染的节点会得到不同摘要,而这与它的光栅化器无关。elapsed 也是主机字段,而且是一个粗粒度的字段,因此它能区分争用与身份,而不假装自己是基准测试。

有两个实现细节会改变记录的含义。capabilities 里不包含节点 id,所以脚本打印会话 id,节点身份事后从 Grid 查询;假定存在某个 nodeId 能力字段会在每个节点上都得到 None,并悄悄删掉唯一让归因成为可能的字段。渲染器字符串只在 debug-renderer 扩展存在时可用;一个不提供它的浏览器返回 null,这本身就是该取值被遮蔽而不是被报告的信号。

每次尝试记一条记录

让差异可见的记录很小:节点身份、构建身份,以及两组字段。下面的构造器是一个构造用例,展示同一个测试在两台共享同一份配置文件的主机上分别长什么样。

// Constructed fixture: one test, two attempts, one shared profile, two hosts.
const attempts = [
  {
    n: 1,
    session: 'd41f-07',
    browser: 'Chrome 156.0.0.0',
    control: { cores: 8, lang: 'en-US', dpr: 1 },
    host: { pixels: 'fc0472cd', elapsed: 12.4 },
  },
  {
    n: 2,
    session: 'd41f-12',
    browser: 'Chrome 156.0.0.0',
    control: { cores: 8, lang: 'en-US', dpr: 1 },
    host: { pixels: '3b9e51a0', elapsed: 9.1 },
  },
];

const controlKeys = Object.keys(attempts[0].control);
const controlDrift = controlKeys.filter(k => attempts[0].control[k] !== attempts[1].control[k]);
const pixelsMissing = attempts.some(a => a.host.pixels == null);
const pixelDrift = !pixelsMissing && attempts[0].host.pixels !== attempts[1].host.pixels;
const elapsedDrift = attempts[0].host.elapsed !== attempts[1].host.elapsed;
const verdict = controlDrift.length
  ? 'configuration_drift'
  : pixelsMissing
    ? 'pixel_unavailable'
    : pixelDrift
      ? 'pixel_divergence'
      : elapsedDrift
        ? 'contention'
        : 'no_divergence';

const lines = [
  `attempts=${attempts.length} browser_same=${attempts[0].browser === attempts[1].browser}`,
  ...controlKeys.map(k => `control=${k} same=${attempts[0].control[k] === attempts[1].control[k]}`),
  `host=pixels same=${pixelDrift ? 'false' : 'true'}`,
  `host=elapsed same=${elapsedDrift ? 'false' : 'true'}`,
  `verdict=${verdict}`,
];

// The non-default branches are what separate the causes; print one line each so a
// reader can check the rule rather than trust it.
const cases = [
  { pixels: 'p', elapsed: 1 },
  { pixels: 'q', elapsed: 1 },
  { pixels: 'p', elapsed: 9 },
  { pixels: 'p', elapsed: 1 },
  { pixels: null, elapsed: 1 },
];
for (let i = 0; i + 1 < cases.length; i += 1) {
  const a = cases[i];
  const b = cases[i + 1];
  const missing = a.pixels == null || b.pixels == null;
  const branch = missing
    ? 'pixel_unavailable'
    : a.pixels !== b.pixels
      ? 'pixel_divergence'
      : a.elapsed !== b.elapsed
        ? 'contention'
        : 'no_divergence';
  const samePixels = a.pixels == null || b.pixels == null ? 'null' : a.pixels === b.pixels;
  lines.push(`branch=${branch} pixels_same=${samePixels} elapsed_same=${a.elapsed === b.elapsed}`);
}
console.log(lines.join('\n'));

构造输出的重点分为两部分。所有控制字段一致,因为两个节点加载了同一份配置文件,而像素摘要不同,所以结论是 pixel_divergence 而不是配置缺陷。随后打印的 branch 行展示了让结论保持诚实的规则:像素相同而耗时不同的记录被报成 contention,而不是节点渲染差异。

attempts=2 browser_same=true
control=cores same=true
control=lang same=true
control=dpr same=true
host=pixels same=false
host=elapsed same=false
verdict=pixel_divergence
branch=pixel_divergence pixels_same=false elapsed_same=true
branch=pixel_divergence pixels_same=false elapsed_same=false
branch=contention pixels_same=true elapsed_same=false
branch=pixel_unavailable pixels_same=null elapsed_same=true

三条规则让记录保持有用。在会话内部读取取值,而不是读测试请求的能力,因为请求描述意图而页面描述实际到达的内容。在比较任何东西之前先把每个字段归类为控制或主机,因为两种不一致有不同的负责人。让字段清单保持短而固定,这样两条记录可以机械比较而不是用眼睛比对。

把失败归因到节点

归因是一个分布问题。收集失败测试每次尝试的 nodeId,统计失败如何分布。只在一个节点出现、在其余节点从不出现的失败支持主机产物这一判断,下一步是把该节点的主机字段与一个通过节点的相比。在每个节点以大致相同比例出现的失败不是节点问题,花在对比主机上的时间就没有花在代码、fixture 或应用上。

第二个分布同样重要。当一个节点公布的槽位多于主机能舒适服务的数量时,多个会话共用一台机器,每个会话看到更多争用。只在节点满负荷时失败的测试是调度产物而不是渲染差异,而记录能区分两者,因为争用移动的是耗时字段,光栅化器差异移动的是像素字段。

Selenium 配置文件集成指南 说明谁拥有节点上的启动、用户数据目录与会话关闭;等待分析 覆盖同一诊断中与时间有关的那一半。这两步之前的一步,是判断差异到底是不是来自主机。

区分资源争用与节点差异

一个同时跑四个会话的节点,行为与只跑一个的节点不同。Distributor 在节点达到 maxSessionCount 时认为它已满,而默认情况下节点会为 Chromium 系浏览器与 Firefox 按可用 CPU 各公布一个槽位,所以一台十六核主机可以接受十六个并发会话,除非配置另有规定。这些会话争夺同一份内存带宽、同一个光栅化器与同一块磁盘,任何与时间有关的测试都会在看到身份差异之前先看到争用。

这个区分之所以重要,是因为两种失败形态在报告里很像而修法不同。争用失败只在节点忙时出现,在测试之间移动而不是在节点之间移动,并在串行运行时消失。差异失败只要某个特定节点运行该测试就出现,无论节点忙还是空闲,并在该节点串行运行时复现。把并发会话数记在节点身份旁边,正是分开两者的办法,因为会话数才是发生变化的那个变量。

争用也是「节点数量不是容量好代理」的原因。给 Grid 加节点增加的是可用主机数量,并不改变每台主机接受多少会话,而一个每会话内存不足的集群会在更大规模上继续产生同样的争用失败。maxSessionCount 设置与每会话可用内存是决定这件事的两个数字,两者都属于节点的记录,而不属于团队的记忆。

还有第三种形态,既不是争用也不是节点差异:依赖随时间变化的数据的测试。一个被 fixture 假定为固定的价格、日期或列表长度,会按一个与哪个节点运行它无关的日程失败,而记录能显示这一点,因为失败的尝试在节点之间与负载水平之间均匀分布。四成因归因模型 正是出于这个原因把它当作独立成因处理。

配置文件固定什么、固定不了什么

记录里的身份那一半正是配置文件的用途。BotBrowser 文档说明了配置文件提供的身份取值,并在任何页面脚本运行之前应用它们,但它不直接控制主机提供的光栅化器或时钟,因此在默认 WebGL 模式下,UNMASKED vendor 与 renderer 字符串、上报的核数、屏幕尺寸、字体清单与语言栈,在加载同一份配置文件并使用同一组启动参数的每台主机上都相同。在 --bot-webgl=real 下,这些字符串改为跟随主机,这也是该模式下唯一移出控制组的行。这正是控制字段比较有意义的原因,也是控制字段不一致指向配置而不是硬件的原因。

另一半按设计就够不到。配置文件不负责光栅化帧,所以同一个着色器由不同软件后端写出的字节不同,即使配置文件完全相同。它也不负责调度工作,所以固定步骤在更忙或更慢的主机上耗时更长。BotBrowser 的跨平台指南指出可移植性需要相同的 BotBrowser 版本、相同的配置文件与相同的启动参数,其无头服务器指南说明没有 GPU 的服务器会通过若干取舍不同的软件后端之一渲染。因此跨节点比较像素输出的测试集仍然必须记录后端与节点,而测量延迟的测试集仍然必须记录负载。

常见问题

为什么同一个测试在一个节点通过、在另一个节点失败?

Distributor 把两次运行分配给了不同槽位。在动测试之前先比较两份节点记录:先看控制字段,因为这里不一致意味着配置文件或启动参数不同;再看主机字段,因为这里不一致意味着两台机器对同一份工作的渲染或计时不同。

配置文件能让两个 Grid 节点变得相同吗?

不能。配置文件固定页面读到的身份取值,这消除了一大类差异,但主机仍然提供光栅化器、CPU 调度与时钟。像素一致需要相同的渲染后端与相同的配置文件,计时一致需要相同的负载。

为什么 renderer 字符串可以一致而像素不同?

因为两者由不同层、不同启动模式决定。在默认 WebGL 模式下,renderer 字符串是配置文件提供的身份取值,而像素是主机光栅化器计算出来的内容,所以「字符串一致而像素摘要不同」是预期组合。BotBrowser 的 GPU 后端文档说明,在该模式下 UNMASKED 字符串在后端迁移过程中仍由配置文件控制,而像素字节会变化。在 --bot-webgl=real 下,字符串改为跟随主机,所以在两台 WebGL 栈不同的节点上,字符串和摘要会一起变化。

需要多少次尝试才能说失败是节点特有的?

足以证明在相同负载下该失败不出现在其他节点。一个节点上一次失败、另一个节点上一次通过只是假设;跨重复尝试的模式才是归因。

来源

实践结论

与另一个节点不一致的节点,也可能跑的是不同构建的浏览器,那是另一个原因与另一个修法;Chrome for Testing:锁定构建而非指纹说明如何锁定,以及锁定之外还剩下什么。

在每个 Grid 测试的每次尝试上记录会话 id、浏览器与驱动版本、三个控制字段、一个像素摘要与一个计时。先比较控制字段:这里不一致是配置缺陷,不是硬件之谜。再比较主机字段:这里不一致是配置文件无法消除的差异,所以要记录后端、负载与节点,而不是重掷配置文件。把集中在一个节点上的失败当作主机产物的支持证据,把分散的失败当作代码或数据问题。

#Selenium Grid#测试自动化#浏览器指纹#节点差异

在 BotBrowser 里跑一遍同样的检查

加载 profile,让刚读到的这些信号在浏览器内部保持一致,并在正式上线前先在自己的机器上确认结果。