☰
Selenium + Docker:构建稳定可复现的自动化测试容器化环境
2026/10/20 8:32:56 网站建设 项目流程

做自动化测试久了,几乎所有人都会走到这一步:把测试环境从自己电脑挪到容器里。我最早也不理解,Selenium 本地跑得好好的,为什么非要 Docker?直到有一次帮某团队搭回归测试环境,发现几台电脑上的浏览器版本、驱动路径、系统字体全都不一样,同样的用例换一台机器结果就变,才彻底想明白“Selenium + Docker 实现自动化测试环境的容器化部署”这件事,解决的从来不是“能不能跑”,而是“在哪儿都能稳定地跑”。

这篇内容适合正在被测试环境反复折磨的同学。包括刚把 Selenium 脚本跑通、但不知道怎么交付给别人的新手,也包括已经接触过 Docker、想搭一套多浏览器并发测试环境但一直没落地的进阶用户。我会从为什么容器化、怎么选镜像和架构,到一条 docker-compose 文件拉起 Selenium Grid,再到实际跑用例时最容易踩的坑,按真实项目里走过的路完整过一遍。我会尽量说人话,不堆概念,只要你照着抄就能先把环境跑起来,再慢慢理解背后的原理。

1. 容器化部署要解决什么问题

1.1 本地测试环境的三个老难题

先说本地跑 Selenium,大家最先遇到的都是“环境不一致”。你在自己电脑上装的是某个版本的浏览器,隔壁同学装的是另一个版本,某个浏览器驱动版本又不匹配。写出来的用例在你这儿能过,到他那台机器上就超时、找不到元素、甚至浏览器启动白屏。最怕的不是报错,而是报错方式五花八门,根本不知道是脚本问题还是环境问题。

第二个难题是并发能力。本地电脑的资源是有限的,开三个浏览器窗口还能忍,开十个就直接卡成 PPT。想模拟多用户同时操作?把用例改成多线程也只是把一台电脑的内存榨干。更何况很多自动化测试还要跑在 CI 上,CI 服务器上的环境不一定装了 GUI、装了浏览器,你总不能每天重装一遍吧?

第三个难题是环境重建成本。系统重装一次、换一台电脑、新同事入职,都得重新装浏览器、装驱动、配系统路径。就算现在 Selenium Manager 能自动下载一部分驱动,但浏览器本身、对应的依赖库、字体、时区这些问题还是得手动处理。手工做一遍没问题,反复手工做就是在浪费生命。

1.2 容器化之后测试环境变成啥样

Docker 的思路很简单:把浏览器、驱动、运行依赖、以及 Selenium Server 全部打到一个镜像里。启动一个容器就等于打开一台“预装好所有环境”的测试机。容器之间互相隔离,不会污染宿主机系统;不管在本地、CI 还是某云服务器上,只要装了 Docker,拉下来的镜像一致,跑出来的结果就大体一致。

而且容器化之后,自动化测试环境变成“用代码声明”的东西。你写一份 docker-compose 文件,里面写清楚要几个浏览器节点、每个节点是什么系统、内存限制是多少。别人拿到这份文件后只需要执行一条启动命令,就能得到一套和你在线上环境几乎一致的 Selenium Grid。这就把“环境问题”从“靠人维护”变成了“靠配置管理”,本质上是把不可控的环境差异,收敛到一个可以反复生成、销毁的单元里。

当然,容器化不是银弹。它不会让你的脚本本身变稳,也不会把一个本来就乱写的测试项目变成稳定项目。它解决的是“环境复现”和“环境交付”的问题。脚本逻辑有问题,容器也一样跑挂。

2. 技术选型与架构拆解

2.1 单容器还是多节点

用 Docker 跑 Selenium 不一定非要上 Selenium Grid,具体看你的场景。如果只是自己定期跑几条冒烟用例,最简单的方式是直接用selenium/standalone-chrome这个镜像。它把 WebDriver 服务和浏览器装在同一个容器里,启动后暴露 4444 端口,你的测试代码只需把 WebDriver 指向这个容器的地址就行。这种方式特别适合小项目、个人验证、或者刚迁移到 Docker 阶段的团队。

但如果你需要跑多浏览器、多版本、并发执行,那 standalone 单容器就不太够用了。因为一个容器里的资源始终有限,就算你设置跑多个 session,浏览器进程全挤在同一个容器里,内存和 /dev/shm 很容易被占满。这时候我一般建议上 Selenium Grid,架构拆成一个 Hub(也可以理解为 Router)加多个 Node。Hub 负责接收客户端的请求、分配任务、维护 session 信息;Node 是真正执行任务的浏览器节点。

Selenium Grid 4 的架构里还有 Event Bus 这样的组件,不过你不用太纠结内部细节。你只需要知道:Hub 和 Node 在同一个 Docker 网络里,Node 启动时会自动注册到 Hub,之后客户端只跟 Hub 通信,Hub 再把任务转发给空闲的 Node。这种多节点架构天然适合按并发量横向扩容。测试任务多了,直接再多启动几个 Node 容器,而不是在一台机器上硬塞更多浏览器进程。

2.2 核心镜像怎么挑

官方维护的 Selenium 镜像通常有selenium/hub、selenium/node-chrome、selenium/node-firefox、selenium/standalone-chrome等。选择的时候我建议遵循一个原则:先用你最熟悉、最容易复现问题的浏览器,不要一上来就把所有浏览器都拉满。很多团队最终稳定跑起来的就是 Chrome 节点加 Firefox 节点,其他浏览器有需要再单独加。

版本方面,请一定要固定镜像标签,不要用latest。latest的好处是每次拉取都很新鲜,坏处是你无法掌控新鲜的定义。昨天还好好的,今天重新拉取之后浏览器大版本变了,驱动也跟着变,测试就可能大面积失败。我当时就吃过这个亏,某天 CI 突然红了一片,最后发现问题出在镜像悄悄升级了。所以 docker-compose 里尽量写具体版本,比如selenium/hub:4.16.1,升级镜像时提前在一个分支里验证,确认稳定后再改标签。

另外要注意镜像大小。Selenium 镜像包含了桌面环境和浏览器,体积通常都以 GB 计。局域网内首次拉取还好,但 CI 上新机器每次拉完整环境会比较耗时。如果内网有镜像仓库,建议提前把常用镜像同步到内网,避免每次从公共仓库拉,既慢又不安全。

2.3 网络与共享目录的规划

容器化部署里最容易被忽略的就是网络。Selenium Grid 的 Hub 和 Node 必须在同一个 Docker 网络里才能互通。如果用 docker-compose 一起声明,Compose 会自动帮你建一个独立网络,Node 通过服务名直接访问 Hub,这个最省心。

真正容易出问题的是“浏览器节点如何访问被测系统”。如果你的被测系统也跑在同一套 compose 文件里,那很简单,浏览器节点直接通过服务名访问它,比如测试代码里的地址可以写成http://app:8080。如果被测系统不在 Docker 里,而是在你宿主机上跑的,那么 Windows 和 Mac 的 Docker 一般支持host.docker.internal这种特殊域名,指向宿主机;Linux 上可能需要通过extra_hosts手动映射。

共享目录也是老坑。容器默认文件系统是临时的,容器一销毁,生成的截图、日志、测试报告就没了。你需要在 compose 里挂载一个目录到容器里,比如./artifacts:/tmp/artifacts。这样容器里产生的文件能直接落盘到宿主机,测试结束后去 artifacts 目录拿结果。挂载的时候要注意权限问题,容器里的用户不一定能写宿主机的目录,遇到权限报错就先docker exec进去看下用户和目录权限,再决定是改宿主机目录权限,还是调整挂载路径。

3. 实操:用 Docker Compose 搭一套 Selenium Grid

3.1 先搭目录结构

我不会一上来就写又长又复杂的配置。第一步,先在项目里建一个干净的结构:

selenium-grid-demo/ docker-compose.yml tests/ test_login.py artifacts/

docker-compose.yml是环境声明,tests放自动化脚本,artifacts用来接住容器里产生的截图和报告。这个结构后面接 CI 也很好扩展,换一台机器只需要把这个目录带过去,然后执行启动命令就行。

3.2 编写编排文件

下面这份 compose 文件是我常用的一套基础模板,里面包含一个 Hub、一个 Chrome 节点、一个 Firefox 节点。你可以直接复制改版本号:

services: hub: image: selenium/hub:4.16.1 ports: - "4444:4444" environment: SE_SESSION_REQUEST_TIMEOUT: 300 SE_NODE_SESSION_TIMEOUT: 300 networks: - grid-net chrome: image: selenium/node-chrome:4.16.1 depends_on: - hub shm_size: 2g environment: SE_EVENT_BUS_HOST: hub SE_EVENT_BUS_PUBLISH_PORT: 4442 SE_EVENT_BUS_SUBSCRIBE_PORT: 4443 SE_NODE_MAX_SESSIONS: 2 SE_NODE_SESSION_TIMEOUT: 300 volumes: - ./artifacts:/tmp/artifacts networks: - grid-net firefox: image: selenium/node-firefox:4.16.1 depends_on: - hub shm_size: 2g environment: SE_EVENT_BUS_HOST: hub SE_EVENT_BUS_PUBLISH_PORT: 4442 SE_EVENT_BUS_SUBSCRIBE_PORT: 4443 SE_NODE_MAX_SESSIONS: 2 SE_NODE_SESSION_TIMEOUT: 300 volumes: - ./artifacts:/tmp/artifacts networks: - grid-net networks: grid-net: driver: bridge

这里有几个参数特别值得解释。

SE_EVENT_BUS_HOST和两个端口,是让 Node 知道去哪找 Hub 的 Event Bus。在同一个 Compose 网络里,直接用服务名hub就能互相访问,所以这个值一般不用改。如果改成另一个网络,或者与 Hub 分开部署,这里就要跟着调整。

SE_NODE_MAX_SESSIONS表示这个节点最多同时执行多少个测试任务。别把这个值当成“并发越大越好”,它是上限而不是目标。一个 Chrome 节点如果允许同时跑 4 个 session,那容器内就是 4 个浏览器进程,内存和共享内存都会成倍增长。小机器建议先从 1 到 2 开始,观察资源占用再慢慢加。

shm_size: 2g看着不起眼,但少了它 Chrome 很容易崩。浏览器进程会把很多渲染数据写到/dev/shm,而这个目录默认只有 64MB。64MB 对现代网页来说太紧张了,尤其在多 tab、多 iframe、页面有大量图片时,经常出现“浏览器启动后白屏”或“DevTools 连接断开”这类诡异问题。我给每个浏览器节点都至少分 2G,实测下来稳定很多。

SE_NODE_SESSION_TIMEOUT是 session 空闲超时时间。测试代码没写driver.quit(),或者用例异常中断时,节点上的 session 会一直占着不释放。设置一个超时时间,至少能让资源在几分钟内自动回收,不至于把整个 Grid 堵死。

3.3 启动、扩容与验证

配好 compose 文件之后,启动非常简单:

docker compose up -d

第一次拉镜像会比较久,之后基本秒起。启动完先确认容器状态:

docker compose ps

然后看 Grid 是否就绪,Selenium Grid 4 提供了一个状态接口:

curl -s http://localhost:4444/status

返回结果里如果有"ready": true,并且value.nodes下面能看到 chrome 和 firefox 节点,那就说明环境已经通了。也可以在浏览器里打开http://localhost:4444/ui,进入 Grid 的管理界面查看节点注册情况。

如果要扩容,比如某天要跑一个并发量比较大的压测,可以这样:

docker compose up -d --scale chrome=3 --scale firefox=1

这个命令会额外再拉起两个 Chrome 节点。注意,如果你在 compose 里给容器取了固定名字container_name,就不能用--scale,因为容器名会冲突。所以我在模板里刻意没写container_name。

验证并发是否有效,可以从测试代码里同时发起多个任务,观察 Grid UI 里的节点列表是否被多个会话占用。如果所有任务都堆在QUEUED状态,说明节点 sessions 已经满了或 Hub 配置限制太多,这时候适当调大SE_NODE_MAX_SESSIONS或者增加节点数量。

3.4 自动化测试代码怎么连

环境搭好后,测试代码要连的地址不再是localhost的本地 driver,而是 Hub 的地址。我用 Python 举个例子:

import pytest from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.chrome.options import Options def test_login_success(): options = Options() options.set_capability("se:name", "登录模块冒烟测试") driver = webdriver.Remote( command_executor="http://localhost:4444/wd/hub", options=options ) try: driver.get("http://host.docker.internal:8080/login") driver.find_element(By.ID, "username").send_keys("tester") driver.find_element(By.ID, "password").send_keys("123456") driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click() WebDriverWait(driver, 10).until( EC.text_to_be_present_in_element((By.ID, "welcome"), "你好") ) assert "你好" in driver.page_source finally: driver.quit()

重点注意driver.quit()写在finally里。很多人只在用例正常结束时调用quit,一旦断言失败或者抛异常,session 就一直挂在 Grid 上,久而久之节点资源被耗尽。容器化环境里这特别致命,因为你看不到“本机残留的浏览器窗口”,只有 Grid 上的 session 数量在悄悄增加。

4. 常见问题与排查技巧实录

4.1 浏览器容器无端崩溃、白屏、页面加载一半卡住

这是最常见的问题。最初我在某项目上遇到 Chrome 节点启动后页面加载到一半就卡死,看容器日志也没异常,最后怀疑是共享内存不够。在容器里执行:

docker exec -it <chrome节点容器名> df -h /dev/shm

发现确实只有 64MB。后来在 compose 里给节点加上shm_size: 2g,问题立刻消失。排查方向基本都是:页面大、图片多、并发多、iframe 复杂时,优先查共享内存,不要一上来就怀疑 Selenium 版本。

如果是单容器方式启动,也可以在docker run里加--shm-size=2g。这个参数对浏览器容器非常重要,别省。

4.2 节点明明启动了,Hub 上却看不到

Node 不是一启动就能自动出现在 Hub 上,它要经过注册流程。如果 wait 了一会儿还是看不到,先看 Hub 日志:

docker logs <hub容器名> --tail 50

如果日志里有节点注册失败的记录,多半是事件总线地址或端口写错了。检查 compose 里SE_EVENT_BUS_HOST和两个端口是否和 Hub 暴露的一致。有时候是网络隔离的问题,Node 和 Hub 不在同一个自定义网络里,互相 ping 都 ping 不通。这时直接把两个服务放在同一个 compose 网络里是稳妥的解法。

另外,depends_on: hub只是等 Hub 容器启动,并不能保证 Hub 已经完全就绪。遇到偶发的“首次节点未注册”,可以给启动脚本加一个轮询逻辑:循环检查/status接口,直到ready为 true,再开始跑测试。

4.3 测试脚本超时,浏览器节点访问不到被测服务

容器里的浏览器访问宿主机服务,和你在电脑上直接访问不是一回事。如果你在浏览器节点里访问localhost:8080,这个localhost对应的是浏览器节点容器自己,而不是你的宿主机器,所以必然失败。

调试方法很简单,进入浏览器节点容器:

docker exec -it <chrome节点容器名> bash curl http://host.docker.internal:8080/login

如果 curl 不通,说明这段网络链路还没打通。正确的做法是:

  • 被测系统也在 Docker 里:用 compose 服务名访问,例如http://app:8080。
  • 被测系统在宿主机上:Windows/Mac 一般可以用host.docker.internal;Linux 需要在节点服务的extra_hosts里做映射。
  • 被测系统在远程服务器:直接用远程地址,但要注意防火墙和网络策略。

4.4 本地跑得好好的,容器里用例全挂

这种情况十有八九是浏览器版本被镜像定了,而测试脚本隐式依赖了某一种浏览器的行为。本地可能是 Chrome 120,容器里镜像可能是 Chrome 121,虽然大版本相近,但有些元素偏移、截图差异、权限弹窗这些细节很容易被放大。

排查时可以先对比版本:

docker exec -it <chrome节点容器名> google-chrome --version

确认本地和容器浏览器版本差了多少。如果差异很大,可以把镜像标签锁定到和本地一致的版本。另外,容器里通常没有中文字体,某些页面文字会显示成方块;如果截图断言涉及文字,要注意装字体。

我把这些高频问题整理成一张速查表,排查时可以直接对着表看:

症状大概率原因快速排查处理建议
浏览器启动白屏或崩溃/dev/shm 太小docker exec ... df -h /dev/shm设置shm_size: 2g
Node 无法注册到 Hub事件总线地址/端口错误看 Hub 日志,查/status同网络启动,检查 SE_EVENT_BUS 配置
测试访问被测服务超时容器无法访问目标地址容器内curl目标 URL使用服务名或host.docker.internal
本地过、容器挂浏览器版本或字体差异对比浏览器版本、检查字体固定镜像版本,安装字体
Session 占着不释放脚本没写quit看 Grid UI 节点会话列表finally里quit,设置超时时间

5. 几个越早明白越好的经验

容器化部署这套东西,技术难点真的不高,真正让人头疼的往往是工程习惯。我后来搭环境基本坚持几条原则。

第一,把 docker-compose 文件当作唯一的环境事实来源。不要在宿主机上手动docker run各种临时容器,也不要偷偷在某台机器上用--memory调参数。所有环境参数都写进 compose 文件里,改动走代码评审,这样环境才可追溯、可重放。

第二,固定镜像标签,但保持升级节奏。用latest不安全,锁死在旧版本也有风险。我的做法是每半年左右升级一次 Selenium 镜像版本,单独开一个分支,跑一遍全量回归,确认没问题再合入主线。这样既能享受版本更新带来的修复,又不会让环境悄悄漂移。

第三,浏览器节点和被测系统尽量放同一个 Compose 项目里。这不是一个技术硬性要求,但能省掉无数网络问题。测试的依赖边界越清晰,排障效率越高。否则你每天都要解释“为什么容器里的浏览器打不开你本机的 localhost”。

第四,不要把测试报告和截图直接放在容器里。所有需要持久化的产物都通过挂载卷写出来。我见过有人把报告存在容器内,最后docker compose down后数据全没了,只能重跑一遍。容器生来就是“用完即弃”的,凡是你要留的东西,都要主动挂出来。

最后留一句我自己的口头禅:容器只是把环境变得可复现,并不会让烂测试自动变稳。先保证脚本自身稳定,再谈并发和扩容。环境层面做到“一条命令拉起、一条命令销毁”,测试代码层面做到每个用例有稳定的等待、有干净的清理,这套体系才能真正省心。

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

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

立即咨询