Selenium Grid KEDA External Scaler:为 Grid 量身定制的独立 gRPC 弹性伸缩器

原创2026-10-01 21:29:081,455 阅读
文章标签:测试后端云原生容器编排可观测性

Selenium Grid KEDA External Scaler:为 Grid 量身定制的独立 gRPC 弹性伸缩器

导读

本文带你完整认识 docker-selenium 仓库中新增的 .keda-external-scaler/ 子项目:一个独立于 KEDA 主干版本节奏、以 gRPC 服务形式存在的 Selenium Grid 外部伸缩器(External Scaler)。它把 KEDA 内置 selenium-grid 伸缩器的能力匹配与槽位预留算法原样移植,让 Selenium Grid 的自动伸缩逻辑随 docker-selenium 的发布节奏同步演进。读完本文,你将掌握:外部伸缩器与内置伸缩器的差异、全部触发元数据(Metadata)参数与取值约定、includeOngoingSessions 在 ScaledJob/ScaledObject 场景下的正确配置,以及从部署、构建到本地调试的完整实操路径。

背景:为什么要从 KEDA 内置伸缩器中抽离出来

KEDA 内置了一个名为 selenium-grid 的伸缩器(位于 KEDA 源码树的 pkg/scalers/selenium_grid_scaler.go),docker-selenium 的自动伸缩一度完全依赖它。但这种方式存在一个节奏错配:伸缩逻辑的 bug 修复只能跟随 KEDA 的版本节奏发布,而不是 docker-selenium 的发布节奏。典型例子是 SeleniumHQ/docker-selenium#3167 中关于 accurate/eager 策略下对进行中会话重复计数导致 Node 无限创建、无法缩回的问题——这类修复往往要等几个月后的 KEDA 次版本才落地。

.keda-external-scaler/ 子项目的方案是:把 KEDA 内置伸缩器整体抽离为独立的 KEDA 外部伸缩器(实现 KEDA 的 externalscaler.ExternalScaler gRPC 服务),由 SeleniumHQ/docker-selenium 项目自己拥有并随 docker-selenium 发布。其核心诉求在 SPEC.md 中写得很清楚:

  • 解耦伸缩器发布与 KEDA 发布节奏,bug 修复随 docker-selenium 一起上线;
  • Selenium 项目完整掌控自动伸缩技术栈(Grid、Helm chart、scaler),capability 匹配逻辑可以与 Grid 的 DefaultSlotMatcher 保持同步演进;
  • 用户得到的伸缩行为与内置伸缩器完全一致,迁移时只需把触发器的 type 从 selenium-grid 改成 external,并补上一个 scalerAddress。

从源码结构看(README.md 的 Layout 一节),该项目由五部分组成:

proto/externalscaler.proto   vendored from KEDA, byte-identical
externalscaler/              generated gRPC stubs (committed)
internal/gridscaler/         logic (ported), metadata, grid client, gRPC server
cmd/scaler/                  binary entrypoint
deploy/                      reference manifests

其中 proto/externalscaler.proto 是从 KEDA 逐字节复制的契约文件,生成的 externalscaler/externalscaler.pb.go 与 externalscaler/externalscaler_grpc.pb.go 已提交入库,模块完全不依赖 github.com/kedacore/keda——与 KEDA 的唯一耦合就是这份 proto 契约。

与 KEDA 内置伸缩器的差异:行为相同,接线不同

README.md 的 “How it differs” 一节用一张表点明了唯一对用户可见的变化:

内置伸缩器 外部伸缩器
触发器 type selenium-grid external
scalerAddress n/a 必填——指向本伸缩器 Service 的 host:port
Grid URL / 凭据 通过 TriggerAuthentication 的 authParams 通过触发器 metadata、*FromEnv、或伸缩器自身环境变量

其中最关键的是凭据传递路径的差异:KEDA 不会把 TriggerAuthentication 的 authParams 转发给外部伸缩器。因此 Grid 的 URL 与凭据必须通过其他途径送达伸缩器,这是理解整个配置体系的前提(详见后文 Authentication 一节)。

从代码层面看,server.go 中 Server 是无状态的:每次 RPC 都重新解析 KEDA 传来的 scalerMetadata,再重新查询 Grid,天然满足 KEDA 每个 pollingInterval 都调用 GetMetrics + IsActive 的轮询模型。

KEDA 外部伸缩器契约:四个 RPC 与五个约束

SPEC.md 从 KEDA 的 pkg/scalers/externalscaler/externalscaler.proto 摘录了服务契约:

service ExternalScaler {
    rpc IsActive(ScaledObjectRef) returns (IsActiveResponse) {}
    rpc StreamIsActive(ScaledObjectRef) returns (stream IsActiveResponse) {}   // only used by trigger type `external-push`
    rpc GetMetricSpec(ScaledObjectRef) returns (GetMetricSpecResponse) {}
    rpc GetMetrics(GetMetricsRequest) returns (GetMetricsResponse) {}
}
message ScaledObjectRef { string name; string namespace; map<string,string> scalerMetadata; }

围绕这份契约,有五个约束直接塑造了本项目的设计(SPEC.md “Facts that constrain the design”):

  1. 只有 triggerMetadata 能到达外部伸缩器:KEDA 会把 config.TriggerMetadata 拷贝进 scalerMetadata,其中以 FromEnv 结尾的 key 会在发送前用伸缩目标(scale target)容器的环境变量解析。TriggerAuthentication 的 authParams 一律不转发,所以凭据只能走 *FromEnv 触发器 metadata 或伸缩器自身环境变量/挂载的 Secret。
  2. KEDA 每次 pollingInterval 会先调 GetMetrics 再调 IsActive,服务端必须无状态且调用开销小。
  3. MetricValue/TargetSize 的 int64 字段在 float 字段为 0 时被优先使用,因此可以安全地用 int64 返回整数 Node 数量。
  4. KEDA 与伸缩器之间的传输层支持 TLS/mTLS(KEDA 侧配置 enableTLS、caCert、tlsClientCert、tlsClientKey),服务端必须可选地以 TLS 方式提供服务。
  5. GetMetricSpec 返回的 metricName 在 GetMetricsRequest 中回传时会被去掉 sX- 触发器索引前缀,因此服务端必须生成确定性的、HPA 安全的指标名,并沿用内置伸缩器的命名方案(selenium-grid[-browserName][-browserVersion][-platformName])。

对应到本仓库的实现:cmd/scaler/main.go 中 run() 同时注册了 ExternalScalerServer 与 gRPC 健康服务(grpc_health_v1.Health),并在同时设置 --tls-cert-file 与 --tls-key-file 时启用 TLS;server.go 的 buildMetricName 用 normalizeString(替换 / . : % ( ) 为 -)生成 HPA 安全的指标名。而 StreamIsActive 作为 external-push 入口未被实现——Grid 本身不提供推送信号,因此只支持轮询式 external 触发器,StreamIsActive 直接返回 Unimplemented(见 server.go)。

Metadata 完整参数表:与内置伸缩器逐键对齐

README.md 给出了完整的 metadata 参数表(内置伸缩器的全部 key,外加由 KEDA 消费的 scalerAddress):

Key 默认值 说明
scalerAddress — KEDA 侧使用;本伸缩器 Service 的 host:port
url — Grid GraphQL 端点;必填(metadata、urlFromEnv 或 SE_GRID_URL 三选一)
browserName "" 例如 chrome、firefox、MicrosoftEdge
sessionBrowserName = browserName 例如 Edge 用 msedge
browserVersion "" 对请求做前缀匹配
platformName "" 平台家族感知(platform-family aware)
capabilities "" 额外必需 capability 的 JSON
nodeMaxSessions 1 每个 Node 的槽位数
enableManagedDownloads true 是否要求 se:downloadsEnabled
activationThreshold 0 从零伸缩的激活阈值
unsafeSsl false 跳过 Grid TLS 校验
includeOngoingSessions true 是否把进行中的会话计入指标;ScaledJob scalingStrategy.strategy 为 accurate 或 eager 时必须为 false
authType "" Basic(默认)或其他 bearer 方案,如 OAuth2/Bearer
username / password — Grid Basic 认证
accessToken — Grid bearer token(配合非 Basic 的 authType 使用)

任意 key 也都可以写成 <key>FromEnv 形式,由 KEDA 在发送前从伸缩目标容器的环境变量中解析填充。

这些默认值与校验逻辑在 metadata.go 中有一一对应的实现:NodeMaxSessions、EnableManagedDownloads、IncludeOngoingSessions 默认分别为 1、true、true,TargetValue 恒为 1,SessionBrowserName 为空时回退为 BrowserName;布尔与整数均通过 strconv.ParseBool/ParseInt 解析,解析失败会返回带明确前缀的错误(例如 parsing activationThreshold)。若 url 在 metadata、FromEnv、环境变量三层都缺失,parseMetadata 会直接报错“no url given in trigger metadata, FromEnv, or scaler environment (SE_GRID_URL)”,对应 gRPC 侧的 InvalidArgument 状态码。

On-going sessions:指标计数的两种约定

伸缩器返回的指标是 Grid 需要的新 Node 数。进行中的会话是 Grid 已经在服务的工作,是否应计入该数字,取决于 KEDA 执行器替你扣减的是哪一类 Job 数(KEDA 源码 pkg/scaling/executor/scale_jobs.go)。README 用一张表总结了全部组合:

ScaledJob 策略 KEDA 扣减 includeOngoingSessions 发出的指标
default 运行中 Job 数 true(默认) 排队请求 + 进行中会话
custom customScalingRunningJobPercentage × 运行中 Job 数 true(默认) 排队请求 + 进行中会话
accurate 待处理(pending)Job 数 false 仅排队请求
eager 运行中 + 待处理 Job 数(受 maxScale 限制) false 仅排队请求
ScaledObject (HPA) 通过 HPA 扣减运行中副本数 true(默认) 排队请求 + 进行中会话

accurate 和 eager 永远不会重新加上运行中的工作,因此在那种场景下把进行中会话也计入指标,会对已在进行中的会话重复计数,造成 Node 被无限创建、永远不会缩回来(即 SeleniumHQ/docker-selenium#3167 所描述的问题)。

这个约定在 server.go 的 scrapeAndCount 中实现为:先算出 newRequestNodes,若 IncludeOngoingSessions 为真再加上 onGoingSessions。对应的调度语义是:ScaledObject(HPA)和 ScaledJob 的 default/custom 策略会从期望规模中扣减运行中 Job 数,进行中会话恰好在那次扣减中抵消,因此指标必须包含它们,最终算术才收敛到“需要新建的 Node 数”。

与 Helm chart 的联动:自动推导 includeOngoingSessions

使用仓库内 charts/selenium-grid Helm chart 时无需手动配这个值。_helpers.tpl 中,当 autoscaling.scalingType 为 job 且未显式设置 <node>.hpa.includeOngoingSessions 时,chart 会根据 autoscaling.scaledJobOptions.scalingStrategy.strategy 推导:策略为 accurate 或 eager 则输出 "false",否则输出 "true"。这与 chart 默认策略 accurate(见 CONFIGURATION.md)配合默契——默认即得到 includeOngoingSessions: false 的正确组合。如需覆盖,设置 <node>.hpa.includeOngoingSessions 即可。

Authentication:凭据的三种送达方式(按优先级)

因为 authParams 不转发,Grid URL 与凭据按如下优先级(高 → 低,逐 key 生效)解析:

  1. 触发器 metadata——url、username、password、accessToken 直接写在 trigger 的 metadata 中。对 URL 没问题,但避免把密钥写在里面(它会直接出现在 ScaledObject 上)。
  2. *FromEnv metadata——例如 usernameFromEnv: SE_ROUTER_USERNAME;由 KEDA 从伸缩目标的容器环境解析后填充。
  3. 伸缩器环境变量——把 Secret 挂载到伸缩器自己的 Deployment 中,使用 SE_GRID_URL、SE_GRID_AUTH_TYPE、SE_USERNAME、SE_PASSWORD、SE_ACCESS_TOKEN。这种方式让密钥完全不进入 ScaledObject。

方式 2 和 3 都受支持,可按你的密钥管理偏好选择。这一优先级在 metadata.go 中实现为三层的 lookup 链:

scalerMetadata[<name>] > scalerMetadata[<name>FromEnv] > env[<fallback>]

其中 envFallbacks 表把 url/authType/username/password/accessToken 分别映射到 SE_GRID_URL/SE_GRID_AUTH_TYPE/SE_USERNAME/SE_PASSWORD/SE_ACCESS_TOKEN。由于 KEDA 在发送前已把 *FromEnv key 解析成实际值并保留在原始 *FromEnv key 下,scalerMetadata["usernameFromEnv"] 在服务端拿到的就是密文本身。

凭据最终如何加到 Grid 请求上,见 grid_client.go:当 authType 为空或为 Basic 且用户名密码齐全时走 req.SetBasicAuth;否则(非 Basic 的 authType)使用 Authorization: <authType> <accessToken> 头。

部署:两条 kubectl 命令 + 参考清单

参考清单位于 deploy/ 目录,部署只需:

kubectl apply -f deploy/deployment.yaml          # scaler Deployment + Service
kubectl apply -f deploy/scaledjob-example.yaml   # or scaledobject-example.yaml

deployment.yaml 展示了关键细节:

  • 镜像为 selenium/keda-external-scaler:latest,启动参数 --listen-address=:8080,gRPC 端口 8080;
  • SE_GRID_URL 指向 http://selenium-hub.selenium.svc:4444/graphql(即 Selenium Grid Router 的 GraphQL 端点);
  • Basic 认证凭据以 Secret 挂载的方式注释在清单中(secretKeyRef 引用 selenium-basic-auth 的 SE_ROUTER_USERNAME/SE_ROUTER_PASSWORD);
  • 就绪与存活探针均使用 Kubernetes 的 grpc 探针类型直连 8080,对应代码里注册的 grpc_health_v1.Health 健康服务(main.go);
  • 资源与安全基线:runAsNonRoot、readOnlyRootFilesystem、drop: ["ALL"]、禁止提权,CPU/内存请求分别为 50m/32Mi。

scaledjob-example.yaml 则演示了迁移的“机械改动”:trigger type 改为 external,新增 scalerAddress: selenium-grid-scaler.selenium.svc:8080,其余 metadata 与内置伸缩器完全一致;includeOngoingSessions 必须与 spec.scalingStrategy.strategy 一致(default/custom 用 true,accurate/eager 用 false)。scaledobject-example.yaml 面向 Deployment 型负载,始终采用“总期望数”约定,includeOngoingSessions 保持默认 true,由 HPA 除以 target(1)得出副本数。

构建、测试与运行时参数

仓库内的 Makefile 提供了一组标准目标:

make build     # -> bin/selenium-grid-scaler
make test      # go test ./... -race -cover  (includes the verbatim upstream table)
make image     # multi-arch (linux/amd64, linux/arm64) selenium/keda-external-scaler
make proto     # regenerate gRPC stubs (only needed if proto/externalscaler.proto changes)
  • make build 以 CGO_ENABLED=0 静态编译出 bin/selenium-grid-scaler;
  • make test 使用 -race -cover 运行全部测试,其中包含从 KEDA 上游原样移植的测试表——这是与内置伸缩器行为逐字节对齐的“一致性证明”;
  • make image 通过 docker buildx 构建 linux/amd64,linux/arm64 双架构镜像(可通过 IMAGE/TAG/PLATFORMS 变量覆盖);
  • make proto 只有在 vendored 的 proto/externalscaler.proto 发生变化时才需要执行,生成代码已提交。

运行时参数(均可通过环境变量配置,见 main.go):

Flag 环境变量 默认值
--listen-address LISTEN_ADDRESS :8080
--grid-http-timeout SE_GRID_HTTP_TIMEOUT 3s
--tls-cert-file TLS_CERT_FILE —
--tls-key-file TLS_KEY_FILE —

同时设置两个 TLS flag 即让 gRPC 端点走 TLS(KEDA 侧相应配置 enableTLS/caCert)。另外注意 SE_GRID_HTTP_TIMEOUT 支持 3s 这类 duration 字符串,也支持裸整数(按毫秒解析,与 KEDA 的超时配置语义一致,见 envDurationOr)。构建需要 Go 1.26+(go.mod 中的 go 指令为 go 1.27.1),模块路径为 github.com/SeleniumHQ/docker-selenium/keda-external-scaler。

底层原理:GraphQL 抓取与“先预留、后打包”的计数算法

伸缩器对 Grid 的每一次轮询,本质上是一条 GraphQL POST 查询加上一套纯函数计数。查询语句在 grid_client.go 中与 KEDA 内置伸缩器逐字节一致:

{ grid { sessionCount, maxSession, totalSlots }, nodesInfo { nodes { id, status, sessionCount, maxSession, slotCount, stereotypes, sessions { id, capabilities, slot { id, stereotype } } } }, sessionsInfo { sessionQueueRequests } }

响应体结构在 types.go 中建模:Grid(会话数/最大会话/总槽位)、Nodes(每个 Node 的状态、会话数、槽位、stereotype、进行中会话)、SessionsInfo.SessionQueueRequests(排队请求列表)。HTTP 客户端按 unsafeSsl 缓存两个 *http.Client(grid_client.go),使重复轮询复用连接而无需反复做 TLS 握手。

计数核心 getCountFromSeleniumResponse 在 logic.go,算法分三步:

  1. 能力匹配:对每个排队请求,用 checkRequestCapabilitiesMatch 判断是否命中伸缩器 metadata(browserName、browserVersion 前缀匹配、platformName 平台家族匹配、se:downloadsEnabled、扩展 capability)。匹配逻辑忠实还原 Grid 的 DefaultSlotMatcher:filterCapabilities 剔除 goog:/moz:/ms:/se: 前缀的扩展能力(se:downloadsEnabled 例外保留),Node 的 stereotype 与进行中会话用 checkStereotypeCapabilitiesMatch 做同样的校验(平台家族判定依赖 platform.go 中的 Platform 表与 isSameFamily)。
  2. 先预留现有 Node 的可用槽位:对命中的请求,遍历 UP 状态的 Node,计算其 stereotype 中可匹配的槽位数与进行中会话数,若“可匹配槽位 > 进行中会话”则占用一个槽位(通过 updateOrAddReservedNode 维护预留计数),同时把命中的请求计入 queueSlots。
  3. 再把剩余请求打包到假设的新 Node:nodeMaxSessions 大于 1 时先填满已开辟的新 Node 剩余槽位,还不够就按 nodeMaxSessions 再开一个新 Node(newRequestNodes 追加,ID 用请求索引)。

函数最终返回 (len(newRequestNodes), onGoingSessions)——前者是所有现有 Node 槽位都无法吸收、必须新建的 Node 数,后者是所有 Node 上进行中且匹配的会话总数。加上 includeOngoingSessions 约定后,即得到 KEDA 用于伸缩的最终指标。

从测试结构看,internal/gridscaler/ 下除了 server_test.go、logic_test.go、metadata_test.go、platform_test.go,还有 *_extra_test.go 文件,测试策略(SPEC.md “Testing Strategy” 一节)明确:单元测试为主,移植 KEDA 上游约 4000 行的表驱动用例作为对等性证明(预期值不得改动);服务端测试用进程内 gRPC server + httptest 伪造 Grid GraphQL 端点,验证 *FromEnv 解析、环境变量回退、认证头透传与错误映射;StreamIsActive 测试断言返回 Unimplemented。另外,后续 E2E 计划把仓库现有 tests/ 中的 K8s 自动伸缩套件通过一个开关切换为 type: external 触发器接入验证。

迁移与边界:从内置伸缩器切换的注意事项

综合 README.md 与 SPEC.md,从内置伸缩器迁到外部伸缩器只需要三步:把 trigger 的 type 改为 external、加上 scalerAddress 指向本伸缩器 Service、把 Grid URL 与凭据改从 metadata/*FromEnv/伸缩器环境送达。行为保持一致,指标名沿用 selenium-grid[-browser][-version][-platform],HPA 指标身份在迁移前后不变。

项目边界同样值得留意:logic.go/platform.go 必须与 KEDA 上游镜像文件保持语义一致(分歧即规格变更);vendored proto 必须与上游逐字节一致;生成的 *.pb.go 不得手改;模块绝不耦合 github.com/kedacore/keda。只支持轮询式 external 触发器,external-push 不在范围内。Helm chart 的触发器切换与 KEDA 内置伸缩器的弃用方案,在 SPEC.md 的 “Open Questions” 中列为后续独立事项。

登录后查看全文
docker-selenium