在转转做测试基础设施的同学,这两年对一套好用的测试环境体会应该特别深。业务线多、服务多、迭代快,分支场景经常要同时拉好几套环境,传统物理机和虚拟机那套玩法,光是准备依赖、清理数据、协调资源就能耗掉半天,真正跑到用例上的时间反而所剩无几。我们后来把测试环境整体做了一次 Docker 化改造,用容器加 Compose 把服务编排起来,一套完整业务链路从申请到跑通,从原来的两三个小时压缩到几分钟,而且环境与环境之间彻底隔离,再也不用互相踩脚。这篇文章就围绕转转测试环境 Docker 化的整个实践过程展开,包括设计思路、核心架构、实操步骤、踩坑记录和最终效果,适合正在被测试环境折腾的同学参考,无论你是测试开发、运维还是后端工程师,只要和测试环境打过交道,这套思路基本都能直接复用。
1. 为什么要动测试环境——转转测试团队的环境痛点
1.1 从一次“环境事故”说起
我先说一个真实到不能再真实的场景。某天上午十点,业务线 QA 收到一堆报障:登录接口超时、订单状态查不到、消息推送延迟。大家第一反应是线上挂了,结果查了一圈发现不是线上,是测试环境。再往下定位,原因让人哭笑不得——隔壁业务线的同学前一天晚上做联调,为了测一个极端场景,直接把公共测试环境里的 Redis flush 掉了,还把 MySQL 的配置参数顺手改了一把。这一个小动作,影响了三条业务线当天的日常测试和冒烟回归。
这种环境事故在传统测试环境下几乎每周都在发生。一套环境所有人共用,谁都能动,谁都不对环境的最终状态负责。改配置、清数据、重启服务,本意都是为各自需求服务,但后果由整个团队一起承担。更难受的是,环境被弄坏之后很难快速恢复,因为依赖 MySQL、Redis、ES、Kafka 一堆有状态组件,数据恢复和配置还原往往要折腾很久,而这段时间所有相关的用例执行只能干等。
1.2 传统测试环境四大痛点
细想下来,传统测试环境的痛点集中在四个字:慢、乱、脏、死。我拉了一张表,基本能说明问题所在。
| 痛点 | 具体表现 | 对测试效率的影响 |
|---|---|---|
| 环境准备慢 | 手工装 MySQL、Redis、ES、消息队列,再部署服务,耗时以小时计 | 联调和用例执行被长时间阻塞 |
| 互相污染 | 多任务共用一套环境,数据和配置随时被改动 | 用例结果不稳定,经常出现莫名失败 |
| 环境不一致 | 开发本地、公共环境、预发环境版本和配置差异大 | 本地验证通过,测试环境一跑就挂 |
| 资源闲置浪费 | 申请了机器无法及时释放,空闲期也继续占资源 | 成本居高不下,高峰期又不够用 |
“慢”是因为环境准备全凭手工;“乱”是因为权限边界不清晰;“脏”是因为没有标准的交付物和清理机制;“死”是因为环境生命周期完全靠人盯。这四点叠加起来,测试环境就成了一个“用时方恨差、平时没人管”的公共资源池。要解决这些问题,必须先换一个思路:把测试环境当成代码来管理,让环境可描述、可重建、可版本化。而 Docker 恰好提供了最合适的载体——镜像负责描述运行内容,容器负责隔离运行过程,Compose 负责定义多服务之间的拓扑关系。
2. 测试环境 Docker 化的整体设计思路
2.1 方案选型:物理机、VMware 还是 Docker
很多团队在动手前都会纠结同一个问题:环境隔离到底用物理机、虚拟机还是 Docker?我们在选型时做过一轮比较。
物理机的隔离性和性能最好的,但缺点是每一套环境都要单独占一台或几台机器,申请周期长,资源闲置极其严重,而且无法快速复制出同样的环境。虚拟机(VMware/KVM 这类)能解决一部分隔离问题,但每个 VM 都包含完整操作系统,启动是分钟级,镜像动辄几个 GB,多开几台之后宿主机内存就告急。Docker 则完全不同:容器共享宿主机内核,启动是秒级,镜像可以做分层复用,资源开销远小于虚拟机,同一台机器上可以跑出很多套互相隔离的环境。
我们的结论是,对于测试环境这种典型的高吞吐、轻隔离、性能敏感度低的场景,Docker 是明显更合适的选项。测试环境的关注点在于快速创建、快速销毁、批量复制,并不需要生产环境那种严格的安全隔离。一个容器把进程和文件系统隔离好,同一宿主机上多套环境互不干扰,这就足够了。当然,Docker 也不是万能的。如果被测软件需要操作内核模块、修改系统级内核参数、依赖特殊硬件驱动,那容器方案就不适用,这种场景老老实实保留部分物理机或者虚拟机就好。
2.2 架构设计:镜像、容器、网络与数据怎么管
Docker 化不是把服务随便丢进容器就完事,设计上要把四件事想清楚:镜像分层、容器编排、网络通信、数据持久化。
镜像分层方面,基础组件尽量使用官方镜像和固定版本,比如 mysql:8.0.32、redis:7.0.8,禁止使用 latest 标签。业务服务通过 Dockerfile 基于编译产物构建,构建时做多阶段构建,把“编译环境”和“运行环境”分开,镜像体积能从 GB 级压到几百 MB。版本号固定这一点非常重要,测试环境收敛版本,是为了当问题出现时能快速判断是代码变更导致,还是依赖基础镜像变动导致。
容器编排方面,单机场景直接用 Docker Compose 完全够用,多套环境通过不同 project name 隔离。只有当服务数量超过几十个、需要跨多台宿主机调度时,才值得考虑引入 K8s。我们当时的判断很直接:测试环境不需要 K8s 那套弹性伸缩和自愈能力,引入 K8s 等于又多了一个复杂系统要运维,得不偿失。
网络通信方面,同一个 Compose 项目内的容器之间用服务名互访,不要写死 IP。业务服务连接数据库时,地址写 mysql:3306 而不是 192.168.x.x。端口映射遵循“能不外露就不外露”的原则,只有需要从宿主机外部访问的服务才映射端口,这样既能减少端口冲突,也降低暴露面。
数据持久化方面,测试数据分两类:一类是基础主数据,比如地区、类目、测试账号,这类数据要持久化,而且要能通过初始化脚本自动重建;另一类是测试过程产生的脏数据,随时可以清空。所以初始化脚本和清理脚本要分开维护,既保证环境可用,又能在需要时快速重置。
2.3 容器编排:Docker Compose 解决多服务联动
为什么不一上来就上 K8s?我把这个问题单独拿出来说,是因为很多团队一听到“容器化”就直奔 Kubernetes,结果光搭集群就忙活了几周,测试环境本身反而没推进多少。测试环境常见的服务规模在几十个以内,单台机器用 Compose 管理就够了。Compose 提供的 depends_on、healthcheck、网络和卷配置,完全覆盖了多服务联动的需求。真到了服务过百、需要跨机器调度的阶段,再引入调度平台也不迟,而且那时候的容器化基础已经打好了,迁移成本很低。
举一个典型业务链路的例子:订单 + 支付 + 库存 + 商品 + 用户,再加上 MySQL 和 Redis。把这些服务写进一个 docker-compose.yaml,每个服务占一段定义,依赖关系和健康检查都写在同一个文件里,管理起来非常直观。后续接入 CI 时,只需用同一个文件配合不同的 project name 启动,就能得到一套干净的隔离环境,互不感知。
这里要特别强调一点:depends_on 只能控制容器的启动顺序,不能保证服务已经处于可用状态。比如 MySQL 容器进程起来了,但内部 InnoDB 初始化还没完成,业务容器这时候去连数据库,照样失败。解决思路是有状态服务上加 healthcheck,业务容器通过 condition: service_healthy 来等待真正就绪。我在实践里见过太多次“容器起来了但业务连环报错”的现象,绝大多数都是忽略了这一步。
3. 实操:从零搭建一套可复用的测试环境
3.1 宿主机准备与 Docker 部署
先说宿主机。测试环境机器建议直接用 Linux 服务器,CentOS 7.x 或 Ubuntu 20.04 以上都可以。不要为了省事在服务器上装 Docker Desktop,那东西是给个人开发机用的,服务器上应该装 Docker Engine。这一点对大团队格外重要,Docker Desktop 自身带虚拟机层,在服务器场景下不仅有额外资源损耗,还容易引发系统兼容性问题。
Docker Engine 的安装其实很简单,官方提供了自动化安装脚本:
curl -fsSL get.docker.com -o get-docker.sh sudo sh get-docker.sh如果你用的服务器在国内,docker 镜像下载慢的问题大概率不是带宽问题,而是没配镜像加速。安装完成后,修改 /etc/docker/daemon.json:
{ "registry-mirrors": ["https://docker.m.daocloud.io"], "log-driver": "json-file", "log-opts": { "max-size": "100m", "max-file": "3" } }这个配置顺手把日志轮转也做了,防止测试环境跑一段时间后日志把磁盘撑爆。改完配置后执行:
sudo systemctl daemon-reload sudo systemctl restart docker然后做两件最容易忽略的事:一是把使用机器的账号加入 docker 用户组,省得每次命令都要 sudo;二是用 hello-world 镜像验证安装是否正常。
sudo usermod -aG docker $USER newgrp docker docker run hello-world注意,普通用户刚加入 docker 组后,需要重新登录 shell 或者执行 newgrp 才生效,否则会碰到 permission denied while trying to connect to the docker api 这个经典报错,我后面单独展开讲。
3.2 如何设计一套基于 Compose 的环境脚本
接下来给一个可以直接抄作业的 docker-compose.yaml 示例。这个示例模拟一套基础服务链路:MySQL + Redis + MinIO,再加一个最简单的订单业务服务。
version: "3.8" services: mysql: image: mysql:8.0.32 container_name: env-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: order_db MYSQL_USER: order_user MYSQL_PASSWORD: order_pass volumes: - mysql-data:/var/lib/mysql - ./init:/docker-entrypoint-initdb.d ports: - "13306:3306" healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-proot123"] interval: 10s timeout: 5s retries: 5 redis: image: redis:7.0.8 container_name: env-redis volumes: - redis-data:/data ports: - "16379:6379" command: ["redis-server", "--appendonly", "yes"] minio: image: minio/minio:RELEASE.2023-03-20T20-16-18Z container_name: env-minio environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin volumes: - minio-data:/data command: server /data --console-address ":9001" ports: - "19000:9000" - "19001:9001" order-service: build: ./order-service container_name: env-order environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/order_db?useSSL=false&serverTimezone=Asia/Shanghai SPRING_DATA_REDIS_HOST: redis MINIO_ENDPOINT: http://minio:9000 depends_on: mysql: condition: service_healthy redis: condition: service_started ports: - "18080:8080" volumes: mysql-data: redis-data: minio-data:几个关键点解释一下。
数据库初始化 SQL 脚本放到挂载的 ./init 目录下,MySQL 容器第一次启动时,会自动按脚本文件名顺序执行,把库表结构和基础数据建好。业务服务连接数据库时不要用 localhost,而是用 mysql 这个 service 名,这是 Compose 自动创建的网络 DNS 解析的效果。端口映射原则我前面提过,能不外露就不外露:MySQL 外部端口用 13306,Redis 用 16379,MinIO 用 19000 和 19001,用端口段来预设整套环境,既方便记忆,也避免多套环境同时启动时互相冲突。
healthcheck 配置值得展开讲讲。MySQL 的 healthcheck 用 mysqladmin ping,每隔十秒探一次,连续失败五次认为服务不健康。order-service 的 depends_on 里写 condition: service_healthy,意味着只有 MySQL 真正就绪后,订单服务才会被启动。这正是解决“容器已起、服务未就绪”依赖问题的标准姿势。
3.3 环境一键启停与数据初始化
有了 Compose 文件,环境的生命周期管理就能完全脚本化。我们内部维护了三个脚本:start_env.sh、stop_env.sh 和 clean_env.sh,分别对应启动、停止、清理三种状态切换。
#!/usr/bin/env bash # start_env.sh # 用法:start_env.sh <环境名> ENV_NAME=${1:-sandbox} docker compose -p ${ENV_NAME} up -d --buildstop 和 clean 的逻辑类似:
# stop_env.sh:停掉容器但保留数据 docker compose -p ${ENV_NAME} stop # clean_env.sh:彻底清理容器、网络和数据卷 docker compose -p ${ENV_NAME} down --volumes --rmi "local"clean_env.sh 这步不要轻易执行,因为 --volumes 会连数据卷一起删除。我们的规矩是:基础数据都通过 init 脚本重建,所以删掉也不慌;但如果环境里有人工录入的配置型数据,就必须先在脚本里确认是否有备份导出动作。真实环境里,因为误执行 clean 导致数据丢失的案例我听说过好多次,脚本里最好加一个二次确认交互,不要一键无脑执行。
业务服务的镜像构建,强烈建议用多阶段构建。以 Java 服务为例:
FROM maven:3.8-openjdk-8 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests FROM openjdk:8-jre-slim WORKDIR /app COPY --from=builder /build/target/*.jar ./app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]第一个阶段负责编译,第二个阶段只拷贝出构建产物,最终镜像里不包含 Maven、源码和中间文件,体积能压缩到原来的三分之一左右。Python、Node.js 服务也是同样的思路,把依赖安装过程和运行环境拆开,效果立竿见影。镜像体积小了,内网镜像仓库的存储压力小了,容器启动速度也更快。
3.4 接入 CI 实现测试环境的自动拉起
环境脚本化之后,再往前走一步就是接入 CI。我们当时的场景是:当开发合并代码到某个 release 分支时,CI 自动构建业务镜像,然后登录测试环境机器执行 compose 更新,完成环境刷新。这样测试环境始终和最新代码保持接近,省掉了每天手工部署的时间。
GitLab CI 中的关键步骤大致长这样:
deploy_test_env: stage: deploy only: - release/test script: - docker build -t ${DOCKER_REGISTRY}/order-service:${CI_COMMIT_SHORT_SHA} ./order-service - docker push ${DOCKER_REGISTRY}/order-service:${CI_COMMIT_SHORT_SHA} - ssh deploy@test-server "cd /opt/env && IMAGE_TAG=${CI_COMMIT_SHORT_SHA} docker compose -p test up -d"这里有一个常见误区:在 CI 容器里直接调 docker 命令,大概率会报权限错误,原因是 CI runner 容器本身没有 docker socket。常见做法是把 docker host 通过环境变量暴露给 runner,或者在 runner 宿主机上挂载 socket。但要提醒一句,把 docker socket 直接挂给 runner 风险很高,等于让 CI 任务获得宿主机 root 权限,如果没有配套的权限隔离方案,不建议这么干。更稳妥的方式是让 CI 通过 SSH 登录测试环境机器,再在远端执行 compose 命令,我们用的就是这个方案。
4. 常见问题与排查技巧实录
4.1 权限类问题:permission denied while trying to connect to the docker api
这个报错我前前后后见过不下五次,分布在不同人群和不同阶段:新同学第一天搭环境、CI 配置踩坑、批量脚本执行不顺,都能碰到它。报错信息一般是 permission denied while trying to connect to the docker daemon socket at unix:///var/run/docker.sock。
正常情况下,解决路径很清晰。先检查 docker 服务是否在运行,systemctl status docker;再检查当前用户是否在 docker 组,groups $USER;如果不在,用 usermod -aG docker 添加用户然后重新登录;最后查看 socket 文件的属主和权限,ls -l /var/run/docker.sock。
这里要特别强调一个禁忌:不要直接 chmod 777 /var/run/docker.sock。这个 socket 是 docker daemon 的控制入口,权限放得太宽意味着任何普通用户都能执行 Docker 命令,相当于把宿主机的 root 控制权拱手让人。正确做法是通过用户组管理,把可信账号加进 docker 组即可。如果你在公司服务器上碰到权限问题,并且自己不是管理员,不要自己擅自动 socket 权限,直接找基础设施负责人加组或者申请专用的 docker host 配置,安全边界要靠流程来守。
4.2 启动失败类问题:端口冲突与依赖顺序
docker compose up 之后遇到异常状态是家常便饭。排查下来的高发原因,第一个是端口冲突。比如你映射 13306:3306,启动 MySQL 时提示 Bind for 0.0.0.0:13306 failed: port is already allocated,说明这个端口已经被其他进程或者其他容器占用了。排查命令是:
ss -lntp | grep 13306找到占用进程后,要么停掉旧进程,要么换一个映射端口。但这个问题的根子在于端口规划混乱。如果每个人都随手映射 3306 默认端口,几套环境一启动就会撞车。我们内部用端口段来区分环境和组件:数据库用 133xx 段,Redis 用 163xx 段,HTTP 服务用 18xxx 段,新环境按段分配,基本不会再出现端口随手乱改的情况。
第二个高发问题是依赖顺序。我在前面反复提过 depends_on 和 healthcheck 的区别,这里再补一个实际案例。某次环境启动后,订单服务一直报数据库连接拒绝,docker logs 一看,MySQL 容器确实已经启动了,但它的初始化脚本还没执行完。业务容器依赖的是 mysql 服务“可用”,而不仅仅是“启动”,所以必须用 healthcheck 做就绪探测。很多服务在初始启动时会做表结构迁移和基础数据写入,这个过程可能持续几十秒,业务端加启动重试也能缓解,但最可靠的还是把 condition: service_healthy 配上。
另外还见过容器退出码 127 的情况,多半是镜像里缺运行时或者启动命令路径不对,比如用了 alpine 基础镜像但服务依赖 glibc。排查思路是先用 docker logs 看具体报错,再针对性调整基础镜像或者启动命令,不要一上来就重装整个环境。
4.3 网络通信问题:容器互通与外部访问
最典型的现象:业务服务容器里连不上数据库,报 Access denied 或者 connect timeout。很多刚接触 Compose 的同学会把 JDBC 地址写成 localhost,这在容器世界里是必踩的坑。localhost 指的是容器自己,不是宿主机,更不是 MySQL 容器。容器要访问另一个容器,必须走容器网络。
解决办法很简单:在同一个 Compose 项目内,直接用服务名访问即可。MySQL 服务的名称是 mysql,那么业务服务的数据库地址就是 jdbc:mysql://mysql:3306/dbname,Redis 地址就是 redis:6379,不需要关心底层 IP 是多少。这是 Compose 自动创建的网络 DNS 带来的好处,也是“环境即代码”的一个重要体现——服务间的调用关系通过配置文件表达,而不是靠人记住一堆 IP。
排查网络问题的时候,两条命令非常好用。一条是 docker network inspect,查看网络里有哪些容器及其 IP;另一条是 docker exec -it <容器ID> ping <其他容器服务名>,直接在容器内部验证 DNS 解析和网络连通。如果业务服务和数据库不在同一个 Compose 项目里,而是跨项目互通,则需要预先创建外部共享网络,并在 Compose 配置里引用这个 external 网络。这种情况我们一般在多套环境需要共享某个公共服务时才会用。
外部访问容器的场景也要说一下。映射端口之后,用宿主机 IP 加映射端口访问就行,比如访问 MySQL 用宿主机 IP:13306。如果发现通过 127.0.0.1 访问宿主机端口不通,先检查容器是否在运行,再检查宿主机防火墙和云安全组是否放行了对应端口。这一条在云环境下尤其容易忽略,出现过不少“容器明明起来了,外面就是连不上”的案例。
4.4 性能与存储问题:镜像膨胀、资源占用
Docker 带来的“轻量”是相对虚拟机而言的,如果不管镜像大小、不限制资源、不清理垃圾,测试环境照样能把磁盘和 CPU 吃满。我们定期会用 docker system df 看宿主机上镜像、容器、数据卷和构建缓存各占了多少空间。很多人不知道,Build Cache 这一项经常是最大的容量黑洞,无人维护的测试机器上动辄几个 GB 的缓存都是常态。
清理动作要分层执行,不要图省事一把梭。docker builder prune 清理构建缓存,docker image prune 清理悬空镜像,docker container prune 清理已停止的容器,数据卷的清理要慎重,确认没有需要保留的数据后再动。如果你真的确定整套环境都不想要了,才建议用 docker system prune -af --volumes,这个命令会把所有未被使用的资源全部清除,我见过有人在开发机上误执行导致本地镜像全没了,所以务必看清命令行提示再确认。
资源限制同样不可忽视。测试环境虽然性能敏感度不高,但不能让某个容器把整台机器内存吃光。在 Compose 配置里可以直接声明资源上限,比如给业务服务设置 memory 512m、cpus 1,这样既保证环境稳定,又能在一台机器上塞下更多环境。后面我们统一在 Compose 模板里给有状态服务都加上了资源限制,之后再没出现过一台宿主机被个别容器拖死的情况。
再补充一个很多人遇到过的具体问题:docker 安装 mysql 失败。这个“失败”往往不是 Docker 的问题,而是数据目录权限问题。MySQL 容器内部以 mysql 用户(uid 1000)写数据,如果你把宿主机某个目录挂载进去,而这个目录的属主不是 1000,容器启动的一瞬间就会因为无法写入而初始化失败。解决方式有两个:用具名数据卷让 Docker 自己管理权限;或者主动 chown -R 1000:1000 挂载目录。我们统一推荐用具名数据卷,省心、可移植性更好,也更符合“环境即代码”的定位。
5. 落地效果与扩展思考
5.1 从 2 小时到 2 分钟的量化变化
改造完成之后,效果其实是可以用数字来衡量的。以前手工搭一套包含 5 个业务服务加 MySQL、Redis、ES、MQ 的测试环境,运气好也要两三个小时,中间还有各种组件装不上、版本不对、初始化失败的状况。Docker 化之后,compose up 一条命令搞定,服务全部就绪基本在两分钟以内。环境申请从提工单等人分配,变成了在脚本后加一个环境名参数,想开几套开几套。原来的环境冲突问题,也从排队等待变成了项目名隔离,互不干扰。
环境的一致性也有了质的提升。镜像和 Compose 文件都存放在代码仓库,新同学拉下来就能跑出和团队一致的环境,再也不会出现“我本机好好的,测试环境就是不行”的玄学问题。从环境维度来说,测试环境真正变成了可以随时创建、随时销毁的“一次性资源”,这比任何优化手段带来的效率提升都明显。
5.2 后续还可以怎么扩展
这套方案落地之后,后续的扩展方向其实很清晰。一个是把初始化数据做得更完整,把基础数据脚本、埋点配置、权限模板全部版本化,这样环境重建后能接近线上真实情况,用例的参考价值更高。另一个方向是引入更细粒度的环境拓扑管理,比如同一套环境里只替换某个服务到指定版本,其他服务保持稳定,这在联调和回归场景下很有用。再往后,如果服务规模涨到需要多台机器,就考虑在 Compose 之上接一个轻量级的调度层,K8s 也是那时候才需要进场。
最后再分享一条我个人的体会:测试环境 Docker 化这件事,技术难度并不高,真正的难点在于把环境相关的所有细节“代码化”和“规范化”。镜像版本固定、端口段规划、命名规范、初始化脚本管理,每一项都得像对待生产代码一样重视。如果只是在某台机器上手工拉了几个容器跑起来,那和以前手工装环境没有本质区别。只有当你能够用一份配置文件加上一条命令,在任何一台机器上复制出完全相同的环境时,测试环境的效率瓶颈才算真正被打破。这条路我们走通了,你们照着这个思路走,应该能避开绝大部分我踩过的坑。