Docker+Selenium Grid搭建容器化Web自动化测试环境全实战
做Web自动化测试的朋友大概率都经历过这种尴尬本地Python环境跑得风生水起一到CI机器上就各种报错Chrome顺手升了个级之前的driver全部罢工想多开几个浏览器并行跑内存直接告急。这次我花了两天时间用Docker和Selenium把整套自动化测试环境重新搭了一遍从镜像选型、Compose编排到测试脚本接入全部走通过程中踩了不少坑也沉淀了一套可以直接复用的方案。这篇文章适合正在被环境问题折磨的测试开发、以及刚接触容器化测试的自动化工程师读完你至少能少踩一半坑。1. 为什么我把自动化测试环境搬到了Docker上1.1 传统测试环境的三个痛点我先说下之前用传统方式搭环境的感受。最典型的问题是在我机器上能跑究其原因就是环境漂移。本地装好的依赖、浏览器版本、系统库和别人的机器都不一样一旦换一台机器执行用例结果就变得不可控。我做过一个模拟项目X当时团队新同学拉下代码后在本地跑测试光装ChromeDriver就折腾了半个下午最后发现是浏览器版本和driver版本不匹配。这种问题消耗的不是时间是大家对自动化测试的信心。另一个痛点是浏览器的版本管理。Selenium对不同浏览器版本有对应的driver版本Chrome升级后如果忘记同步driver脚本就会直接报错。第三是并行能力本地只有一台机器有界面或者无界面模式都受限于内存想同时跑多组用例需要手动开虚拟机或者加设备资源利用率很低。1.2 容器化架构与组件选择Docker把这些痛点一次性打包解决了。容器本质上是轻量级隔离环境统一镜像、即启即弃。我选的方案是基于Selenium Grid的容器化部署核心组件是hub、node和event bus。hub负责分发测试请求node才是真正跑浏览器的执行器两者通过事件总线通信。用Docker Compose把这一组服务编排起来一条命令就能把整套网格拉起来。这里有个概念要分清Selenium官方提供了单独的hub镜像和node镜像也提供了standalone镜像。如果是单机使用standalone更省心如果要模拟多浏览器并行或者做分布式必须走hub/node架构。这次项目要同时验证Chrome和Firefox两套浏览器所以我选的是hub node-chrome node-firefox的组合。1.3 这套方案解决的核心痛点用容器化方案之后环境一致性得到了保障同一个镜像到任何机器上只要Docker环境一样浏览器版本和driver组合就是完全相同的。版本升级也变成了重新拉取镜像tag的问题而不是在每台机器上手动装driver。并行执行用一条命令就能扩出来多个node内存不够就加节点不用再独占整台机器。还有一点容易被忽略容器的生命周期管理让测试的垃圾处理变得非常简单。测试跑完直接docker compose down容器里残留的临时文件、无头浏览器进程、driver进程全部一并清理不会污染宿主机。这一点在CI上尤其重要跑完不留痕避免环境越积越脏。这里我需要额外说明一下这套方案不是银弹。如果你只需要跑一个超轻量的冒烟用例用独立容器反而增加启动时间。我的建议是当你有两种以上浏览器、需要并行用例、或者需要频繁在不同机器上复现环境时才值得把Selenium网格放进Docker。2. 从零准备Docker环境Selenium镜像选型要稳2.1 宿主机环境准备动手之前先确认宿主机Docker装好了。我这次用的是某台Ubuntu服务器内存16GB磁盘剩余50GB。理论上4GB内存也能跑起来但如果你同时启动hub、两个node再加一个被测服务内存低于8GB会比较紧张。Docker安装完成后顺手把Compose插件也装上现在新版Docker自带docker compose命令不需要单独安装旧版docker-compose。验证环境时我习惯先跑两个命令docker version docker compose version能看到客户端和服务端版本、以及Compose版本就说明基础环境没问题。另外建议把Docker daemon设置成开机自启避免服务器重启后整个测试环境处于一片混乱。如果你是在本机Mac或者Windows上用Docker Desktop内存设置也要调大一些默认的2GB肯定不够跑多个浏览器节点。2.2 Selenium镜像家族怎么选Selenium官方镜像在Docker Hub上维护得比较勤命名也很有规律。简单拆一下selenium/hub调度中心不包含浏览器。selenium/node-chrome集成了Chrome浏览器和对应ChromeDriver的执行节点。selenium/node-firefox对应Firefox系列。selenium/standalone-chrome浏览器和driver都装在一个容器里没有hub适合单机直接连。selenium/standalone-chrome-debug带VNC可以跳到容器里看浏览器界面。版本tag方面我建议直接锁定4.x的稳定tag。不要用latest因为latest会随更新漂移你在时间点A拉取的镜像和时间点B拉取的镜像可能不是同一个版本这就违背了环境锁定的初衷。我这次用的是4.15.0-20240331这个固定tag选它的原因是当时官方发布后社区反馈稳定实际测试两个node跑了一整天没有异常。获取镜像的方式很简单但有个容易忽略的点官方镜像里的driver绑定在镜像构建时的浏览器版本上。你只要不轻易改tagdriver和浏览器的配对关系就是稳定的这一点恰恰是很多新手踩坑的根源。2.3 端口、内存与网络规划先用一张表格给你看这次规划的容器资源占用服务名镜像对外映射端口预期内存hubselenium/hub4442、4443、4444约200MBnode-chromeselenium/node-chrome无约1.2GBnode-firefoxselenium/node-firefox无约1GB端口这里有个关键点Selenium Grid 4的hub占用了三个端口4442是事件总线发布端口4443是事件总线订阅端口4444才是对外提供WebDriver访问的端口。三个端口缺一个node都无法正常注册到hub。很多网上旧教程只映射4444照着写能启动但节点就是不显示原因就在这里。node镜像的5900/5901调试端口我并没有在Compose里映射出来平时跑无头测试用不上。如果你需要VNC连进去看浏览器界面再单独加端口映射就好。内存规划同样有讲究node-chrome容器启动后默认会跑一个Chrome实例测试脚本再开多个并发用例时每个Chrome进程大约占用300MB到500MB容器内存如果不限制反而会拖垮宿主机。所以我在Compose里显式配置了内存上限。3. 写一份Compose编排把网格一键拉起来3.1 最小可用的docker-compose.yml直接给一份我这次实际用到的编排文件按需修改即可。version: 3.8 services: hub: image: selenium/hub:4.15.0-20240331 container_name: sel-hub ports: - 4442:4442 - 4443:4443 - 4444:4444 networks: - sel-grid healthcheck: test: [CMD, curl, -f, http://localhost:4444/status] interval: 5s timeout: 3s retries: 12 node-chrome: image: selenium/node-chrome:4.15.0-20240331 container_name: sel-node-chrome shm_size: 2gb mem_limit: 1536m mem_reservation: 1g depends_on: hub: condition: service_healthy environment: - SE_EVENT_BUS_PUBLISH_PORT4442 - SE_EVENT_BUS_SUBSCRIBE_PORT4443 - SE_NODE_MAX_SESSIONS3 - SE_NODE_OVERRIDE_MAX_SESSIONStrue networks: - sel-grid healthcheck: test: [CMD, curl, -f, http://localhost:5555/status] interval: 10s timeout: 3s retries: 5 node-firefox: image: selenium/node-firefox:4.15.0-20240331 container_name: sel-node-firefox shm_size: 1gb mem_limit: 1280m mem_reservation: 768m depends_on: hub: condition: service_healthy environment: - SE_EVENT_BUS_PUBLISH_PORT4442 - SE_EVENT_BUS_SUBSCRIBE_PORT4443 - SE_NODE_MAX_SESSIONS2 - SE_NODE_OVERRIDE_MAX_SESSIONStrue networks: - sel-grid healthcheck: test: [CMD, curl, -f, http://localhost:5555/status] interval: 10s timeout: 3s retries: 5 networks: sel-grid: driver: bridge这份文件是实践验证过的。我把两个node都放进了同一个自定义网络sel-grid这样做的好处是服务名可以直接作为访问地址避免了跨容器通信时的IP变动问题。hub启动后如果没有healthchecknode有可能在hub完全就绪前就开始尝试注册导致注册失败。所以我给hub加了一个基于/status接口的探测node的depends_on条件设置为service_healthy只有hub健康了才启动node这个顺序控制是网格能否可靠拉起的关键。3.2 资源限制与健康检查的细节配置里最容易被忽略的是shm_size。Chrome在容器里默认使用/dev/shm做共享内存如果shm只有默认的64MB浏览器加载复杂页面时极易崩通常会报DevToolsActivePort file doesnt exist或者tab崩溃。我一开始没配Chrome节点跑20分钟后就开始随机失败后来把所有node的shm_size调成2GB问题再没出现过。这个经验可以直接抄。内存限制这里我刻意没用Swarm模式下的deploy.resources而是选了mem_limit字段。原因很简单普通单机Compose对deploy.resources的支持在不同版本里表现不一而mem_limit在所有以docker run为底层的场景下都稳定生效。你如果用的是最新Docker Compose v2deploy.resources也能识别但为了少踩兼容性坑我保留了这个更保守的写法。健康检查在Compose里属于比较新的用法但价值很大。curl命令在Selenium官方镜像里自带可以直接用。如果不做健康检查依赖启动顺序就不可靠之前遇到过node在hub初始化之前注册导致hub列表里一直看不到node测试请求全部分发失败。加上healthcheck之后整个链路从定时猜变成了确认就绪后再动作稳定性提升很明显。3.3 服务发现与通信机制这里稍微讲下Selenium Grid 4的通信机制。hub和node之间不是简单的HTTP调用node启动后会主动向事件总线发布自身的状态信息同时订阅任务请求。所以在node的环境变量里必须设置SE_EVENT_BUS_PUBLISH_PORT和SE_EVENT_BUS_SUBSCRIBE_PORT分别对应hub的4442和4443端口。端口配错node不会报错但控制台会一直处于pending状态。如果以后想把node扩展到多台物理机单机Compose里的自定义网络就不够用了需要换成Overlay网络或者直接让不同机器上的node配置指向hub所在机器的地址。单机场景下Compose的自定义网络已经足够我目前很少折腾跨主机部署因为大多数项目的测试规模用一台配置不错的服务器就能扛住。启动整套环境的命令很简单docker compose up -d跑完之后打开http://localhost:4444/ui进入Selenium Grid的Web界面就能看到Chrome和Firefox两个node是否已经注册。正常的话状态栏会显示UP并显示当前可用的session数。4. 测试脚本接入的四个关键细节4.1 用Remote WebDriver连接容器环境搭好之后测试脚本这边最关键的变化是不能再直接用本地ChromeDriver。你需要用WebDriver的Remote模式通过HTTP请求把自动化命令发给hub再由hub分发给具体node。Python代码很简单from selenium import webdriver from selenium.webdriver.chrome.options import Options options Options() options.add_argument(--headlessnew) options.add_argument(--no-sandbox) options.add_argument(--disable-gpu) options.add_argument(--disable-dev-shm-usage) driver webdriver.Remote( command_executorhttp://localhost:4444/wd/hub, optionsoptions ) driver.get(http://example.com) print(driver.title) driver.quit()这里有一个新手很容易踩的坑command_executor的路径是/wd/hub。Selenium Grid 4虽然对外端口是4444但仍然保留了/wd/hub这个兼容路径所以需要写成http://localhost:4444/wd/hub。如果你自己起的是standalone容器路径同样是/wd/hub只是端口可能不同。远程模式最大的好处是脚本端完全不需要关心浏览器怎么启动、driver在哪里。你的测试代码只需要提供一个会话请求剩下的事交给网格。这也意味着你本地可以不用安装任何浏览器只要网络能访问到hub所在机器就行。4.2 容器内浏览器参数配置的细节上面代码里的几个参数每一个都是血泪教训。首先是--headlessnew这是Chrome新无头模式比旧版--headless更接近有头浏览器的渲染行为。Selenium 4.15以上版本配合新无头模式跑出来的页面截图和真实浏览器差异更小遇到某些依赖窗口大小或者媒体查询的页面不会像旧无头模式那样出现诡异的样式错乱。--no-sandbox必须加因为在容器里默认是以root用户运行Chrome而Chrome沙箱依赖的某些权限容器里不支持不加这个参数启动会直接报错。--disable-dev-shm-usage同样重要和前面提到的shm_size是一对它会让Chrome把临时文件写到/tmp而不是/dev/shm大幅降低崩溃概率。--disable-gpu则是为了去掉容器内没有意义且容易出问题的GPU加速无头环境本来就不需要。4.3 与CI流水线集成脚本本地能跑之后进入CI只是顺水推舟。我这边用的是某台CI Runner流水线里其实只需要三步启动整个测试环境运行pytest用例无论结果如何都要down掉环境用伪代码写出来就是docker compose up -d --wait pytest tests/ -q --maxfail1 status$? docker compose down -v exit $status这里有个容易被遗漏的点docker compose down要加-v把容器关联的匿名卷一起清理。测试过程中如果有截图、下载文件写入容器内部路径不加-v在下次启动时可能会残留导致用例之间互相影响。另外Compose的--wait参数会等待healthcheck全部通过后才继续比sleep固定时间要靠谱得多。5. 实战完整跑通一个登录流程测试5.1 模拟被测系统与项目结构理论说再多不如直接跑一遍。这次我起了一个本地模拟登录系统用Flask写了个最简单的登录页面没有任何外部依赖。项目结构如下selenium-docker-demo/ ├── docker-compose.yml ├── app/ │ ├── Dockerfile │ └── login_app.py ├── tests/ │ ├── conftest.py │ └── test_login.py └── requirements.txt模拟系统就用一个/login路由返回一个带用户名密码框的HTML表单校验账号是demo_admin、密码是demo_pass123时跳转到/welcome否则在页面上显示错误提示。之所以不直接拿公网网站测试是因为公网页面结构不受控可能你写好的选择器今天能用明天就失效整个演示就变成跟外部站点斗智斗勇了。app目录下的Dockerfile很简单FROM python:3.11-slim RUN pip install flask WORKDIR /app COPY login_app.py /app/ CMD [python, login_app.py]5.2 被测应用与测试脚本编写login_app.py的代码很短from flask import Flask, request, redirect, render_template_string app Flask(__name__) LOGIN_HTML form methodPOST action/login input nameusername placeholderusername/ input namepassword typepassword placeholderpassword/ button typesubmitLogin/button /form app.route(/) def index(): return redirect(/login) app.route(/login, methods[GET, POST]) def login(): if request.method GET: return render_template_string(LOGIN_HTML) if request.form.get(username) demo_admin and request.form.get(password) demo_pass123: return Welcome admin return Login failed, 401 if __name__ __main__: app.run(host0.0.0.0, port5000)测试脚本用的是pytest风格的fixture。conftest.py里定义一个driver实例每个用例结束会自动退出import pytest from selenium import webdriver from selenium.webdriver.chrome.options import Options pytest.fixture def driver(): options Options() options.add_argument(--headlessnew) options.add_argument(--no-sandbox) options.add_argument(--disable-gpu) options.add_argument(--disable-dev-shm-usage) driver webdriver.Remote( command_executorhttp://localhost:4444/wd/hub, optionsoptions ) yield driver driver.quit()注意这里command_executor写的是宿主机地址localhost:4444因为测试脚本跑在宿主机上。但在test_login.py里页面地址必须写http://app:5000/login不能写localhost。原因是driver.get()这个操作是由node容器里的浏览器执行的它所在网络能解析到Compose服务名app而localhost指向的是node容器自己。test_login.py里面放两个用例一个验证正确账号可以登录成功一个验证错误密码会提示失败from selenium.webdriver.common.by import By def test_login_success(driver): driver.get(http://app:5000/login) driver.find_element(By.NAME, username).send_keys(demo_admin) driver.find_element(By.NAME, password).send_keys(demo_pass123) driver.find_element(By.TAG_NAME, button).click() assert Welcome admin in driver.page_source def test_login_failed(driver): driver.get(http://app:5000/login) driver.find_element(By.NAME, username).send_keys(demo_admin) driver.find_element(By.NAME, password).send_keys(wrong_pass) driver.find_element(By.TAG_NAME, button).click() assert Login failed in driver.page_source5.3 执行与结果分析执行前把第3章的docker-compose.yml扩展一下额外加一个app服务并让app容器和hub、node处于同一个sel-grid网络app: build: ./app container_name: sel-demo-app ports: - 5000:5000 networks: - sel-grid healthcheck: test: [CMD, python, -c, import urllib.request; urllib.request.urlopen(http://localhost:5000/login)] interval: 5s timeout: 3s retries: 12然后启动整套环境docker compose up -d --wait再执行测试pip install -r requirements.txt pytest tests/ -v输出会比较清晰两个用例都通过测试时长一般在20秒以内。如果某个用例失败我习惯在fixture里加上失败截图逻辑用driver.get_screenshot_as_png()拿到二进制数据后在宿主机写入文件这样调试的时候能直接看到容器里浏览器渲染出来的页面是什么样子而不是纯靠日志猜。这里有个细节远程模式下driver.get_screenshot_as_file()的路径是node容器里的路径如果目录权限不对很容易报错。换成get_screenshot_as_png()把图片数据拿回宿主机写入当前目录的reports文件夹权限问题就彻底绕开了。这个技巧我建议你直接抄走。6. 常见问题与排查实录6.1 容器一直重启如何定位如果你执行docker compose up -d之后发现某个node容器状态一直是Restarting大概率是资源限制配得太紧或者端口被占用。先别急着改配置第一条命令永远是docker compose logs node-chrome日志里如果出现Address already in use说明端口冲突换一个映射端口就行。如果出现java.lang.OutOfMemoryError说明内存限制给得太小建议把mem_limit调高到2GB再试。如果出现关于/dev/shm的警告就把shm_size调大。6.2 WebDriver连接超时排查思路另一个高频问题是脚本里webdriver.Remote这一步直接报连接超时。我会依次检查三件事hub的4444端口有没有在宿主机上真正暴露用curl http://localhost:4444/status看一下。node有没有注册成功查看Grid UI或者调用Grid的API。脚本访问地址是localhost还是容器服务名运行位置不一样地址写法完全不同。如果node一直没注册问题基本出在SE_EVENT_BUS_PUBLISH_PORT和SE_EVENT_BUS_SUBSCRIBE_PORT的配置上。这两个值必须和hub暴露的端口一致仔细比对Compose文件里的端口映射。还有一种情况某些云服务器安全组会挡住非标准端口宿主机上测试没事一到远端访问就超时记得在防火墙里放行4442到4444端口。6.3 测试稳定性问题的根源容器化环境跑起来之后稳定性会成为新的矛盾。我最开始遇到的是用例偶尔失败、重跑就通过这种间歇性问题通常有三个来源。第一个是页面还没加载完就点击元素解决方案是显式等待不要用time.sleep()撑时间。第二个是node容器并发数设置得太高一台node内存不够时会有session排队排队一久用例就超时。SE_NODE_MAX_SESSIONS要根据内存实测调整不是越大越好。第三个是webdriver.Remote创建会话时Grid把所有请求都发到同一个node上如果脚本里没有正确使用driver.quit()连接没有被释放会话数会被耗尽。在fixture里加yield后清理是对治这个问题的基本操作。6.4 疑难杂症速查表最后整理一个速查表按照我踩坑的概率排了优先级症状可能原因处理方式node一直Restarting内存限制过小、端口冲突看docker compose logs定位调大限制或改端口hub看不到node事件总线端口配置错误检查SE_EVENT_BUS_PUBLISH_PORT和SUBSCRIBE_PORTChrome启动报DevToolsActivePort/dev/shm空间不足调大shm_size并加--disable-dev-shm-usageWebDriver连接超时地址写错、防火墙拦截分运行环境核对地址放行4442-4444端口用例偶尔失败缺少显式等待、会话未释放用WebDriverWait确保driver.quit()截图写入失败远程模式下路径在容器内用get_screenshot_as_png()拿回宿主机保存最后再分享一个我自己的习惯。刚搭建完这套环境时我总觉得自动化测试跑得快才是王道于是把node并发参数调得很大结果用例反而因为资源竞争频繁失败。后来我改成保守配置每个node最多3个会话宁可多跑一分钟也不要产生随机失败。稳定永远比速度更重要环境搭建阶段尤其如此。如果你也想把这套方案落到自己的项目里建议先从一个固定tag开始跑通最简单的用例再逐步加浏览器、加并行度。