容器化测试环境这件事,放在今天看好像已经是标配了,但真要把时间拨回到 Ubuntu 14.04 那个年代,Docker 才刚刚 1.0 发布没多久,Compose 还叫 fig,能把这套东西跑通并且稳定用于持续集成,绝对算得上是"手艺人"的活儿。我之所以想把这个话题单独拎出来聊,是因为最近帮几个团队做遗留系统的测试环境改造,发现大量老项目仍然卡在 Ubuntu 14.04 这套底座上,而网上能找到的资料要么是官方文档的只言片语,要么是各种抄来抄去、跑不通的片段。这篇文章就是把我自己踩过的坑、验证过的配置、以及那些文档里不会写的细节,完整地摊开来讲一遍。
Docker 和 Docker Compose 组合起来做持续集成测试环境,核心价值在于把"测试环境"这个过去需要人工维护、状态漂移严重的东西,变成一份可版本化、可复现、可一键拉起的基础设施描述。它解决的是测试环境不一致、依赖冲突、清理困难这几个老生常谈但极其致命的问题。这篇文章适合两类人看:一类是手上还维护着老版本 Ubuntu 服务器、需要给遗留项目补上自动化测试能力的运维或后端同学;另一类是想理解容器化测试环境底层逻辑、不满足于只会敲几条命令的开发者。哪怕你用的是更新的系统,里面关于镜像分层、服务编排、健康检查的思路也是通用的。
1. 整体方案设计与选型考量
1.1 为什么在 Ubuntu 14.04 上这件事格外棘手
Ubuntu 14.04 的内核版本是 3.13,这个内核虽然支持 cgroups 和 namespace,但对 Docker 后期依赖的一些特性支持并不完整,比如 overlay2 存储驱动在这个内核上就跑不起来,只能用 aufs 或者 devicemapper。这不是个小细节,它直接决定了你后续镜像构建的速度和磁盘占用。我在一个模拟项目里做过对比,同样的镜像用 aufs 和 devicemapper 构建,前者耗时大约 40 秒,后者能拖到 2 分半,差距非常明显。
另一个麻烦是软件源。Ubuntu 14.04 官方仓库里的 Docker 版本停留在很老的 1.x,功能残缺,Compose 更是压根没有。所以第一件必须想清楚的事就是:不要试图用系统自带的包管理器装 Docker,而是要走官方提供的二进制安装或者配置独立的软件源。这个决策背后的逻辑很简单——持续集成环境对工具链版本的一致性要求极高,你用一个残缺版本搭出来的环境,后面遇到问题根本无从排查。
至于为什么选 Docker Compose 而不是手写一堆docker run脚本,我的理由很直接:测试环境通常不是单个容器,而是"应用容器 + 数据库容器 + 缓存容器"这样的组合,容器之间还有启动顺序和网络依赖。用脚本管理这些关系,一旦服务数量超过三个,维护成本就指数级上升。Compose 用一份 YAML 把服务拓扑、网络、卷、依赖关系全部声明出来,改配置就是改文件,回滚就是版本控制回滚,这对持续集成场景来说太重要了。
1.2 测试环境的架构分层思路
我把整个测试环境拆成三层来设计,这个分层不是拍脑袋定的,而是根据变更频率来的。最底层是基础镜像层,包含操作系统、运行时、系统级依赖,这层变更最少,可以长期缓存;中间层是服务定义层,也就是 Compose 文件里描述的各服务及其配置,变更频率中等;最上层是测试数据与用例层,每次跑测试都可能不同,必须做到用完即弃。
这样分层的好处在于,持续集成每次触发时,底层和中间层可以直接复用缓存,只有最上层需要重新构建,能把单次测试的准备时间从几分钟压缩到几十秒。我在实际项目里实测过,一个包含应用、MySQL、Redis 三个服务的测试环境,首次冷启动大约 90 秒,后续热启动稳定在 25 秒左右,这个速度对于提交即触发的持续集成来说是完全可接受的。
提示:分层设计的核心是"让变化慢的东西沉在下面,变化快的东西浮在上面"。如果你把所有东西塞进一个 Dockerfile,每次改一行测试配置都要重建整个镜像,那持续集成的体验会非常糟糕。
1.3 工具链版本的选择逻辑
在 Ubuntu 14.04 上装 Docker,我推荐锁定在 1.12 到 1.13 这个区间。太老的版本不支持 Compose 文件格式的某些字段,太新的版本又对内核有更高要求,容易在运行时出现莫名其妙的挂起。Compose 这边对应选择 1.8 到 1.9 的版本,它支持depends_on、健康检查这些关键特性,同时不会强依赖更新的 Docker API。
这里有个容易被忽略的点:Docker 版本和 Compose 版本之间存在兼容矩阵。比如 Compose 1.9 需要 Docker API 1.24 以上,而 Docker 1.12 提供的正好是 API 1.24。如果你随手装了个 Compose 1.13,它可能要求 API 1.25,然后你就会看到"client and server don't have same version"这类让人抓狂的报错。所以选版本不是越新越好,而是要卡在兼容区间内。
| 组件 | 推荐版本 | 选择理由 | 风险点 |
|---|---|---|---|
| Docker Engine | 1.12.x | 内核兼容性好,API 稳定 | 不支持 overlay2,需用 aufs |
| Docker Compose | 1.8.x - 1.9.x | 支持健康检查与依赖编排 | 版本过高会触发 API 不匹配 |
| 存储驱动 | aufs | 3.13 内核下性能最佳 | 需要额外安装内核模块 |
| 基础镜像 | ubuntu:14.04 | 与宿主机环境一致 | 镜像较老,需自行补安全更新 |
2. 环境准备与核心组件安装
2.1 系统前置条件检查与内核模块加载
动手装 Docker 之前,先花五分钟做几项检查,能省掉后面几小时的排查。第一件事是确认内核版本和 cgroups 挂载情况,执行uname -r应该看到 3.13 开头的版本,然后mount | grep cgroup要能看到 cgroup 相关的挂载项。如果 cgroups 没挂上,Docker 守护进程根本起不来,而且报错信息往往很隐晦。
第二件事是加载 aufs 模块。Ubuntu 14.04 默认可能没加载这个模块,需要手动modprobe aufs,然后lsmod | grep aufs确认。我建议把这条命令写进/etc/modules文件里,保证开机自动加载,否则服务器一重启,Docker 就罢工了。这个坑我踩过一次,当时排查了半天才发现是重启后模块没加载。
第三件事是检查内核的转发功能。容器之间要通信、容器要访问外网,都依赖 IP 转发。执行sysctl net.ipv4.ip_forward,如果返回 0 就需要改成 1,并且写进/etc/sysctl.conf持久化。这一步在纯内网的测试环境里看似可有可无,但一旦你的测试用例需要访问外部依赖,没有转发就会直接超时。
# 检查并加载 aufs 模块 sudo modprobe aufs lsmod | grep aufs # 持久化模块加载 echo "aufs" | sudo tee -a /etc/modules # 开启 IP 转发 sudo sysctl -w net.ipv4.ip_forward=1 echo "net.ipv4.ip_forward=1" | sudo tee -a /etc/sysctl.conf2.2 Docker 引擎的安装与守护进程调优
前面说了不要用系统仓库,那怎么装?我的做法是下载官方提供的静态二进制包,解压后把可执行文件放到/usr/bin下,然后手写一份 init 脚本或者 systemd 单元来管理守护进程。Ubuntu 14.04 用的是 upstart 而不是 systemd,所以需要写一个 upstart 的 job 配置,放在/etc/init/docker.conf。
守护进程的启动参数是调优的重点。--storage-driver=aufs明确指定存储驱动,避免 Docker 自动探测出错;--iptables=false在某些网络环境下能避免和宿主机防火墙规则打架,但如果你的容器需要端口映射,这个参数就不能加,得让 Docker 自己管理 iptables 规则。我一般会加上--log-driver=json-file --log-opt max-size=10m --log-opt max-file=3,防止日志把磁盘撑爆——测试环境跑久了,日志增长是非常恐怖的。
# 下载并解压 Docker 二进制 wget https://get.docker.com/builds/Linux/x86_64/docker-1.12.6.tgz tar -xzvf docker-1.12.6.tgz sudo cp docker/* /usr/bin/ # 启动守护进程(前台调试用) sudo docker daemon --storage-driver=aufs \ --log-driver=json-file \ --log-opt max-size=10m \ --log-opt max-file=3 &注意:第一次启动守护进程时建议前台运行,观察输出里有没有存储驱动初始化失败、cgroups 挂载异常之类的错误。确认无误后再改成后台服务,否则问题会被日志淹没。
2.3 Docker Compose 的安装与版本校验
Compose 在 1.9 时代还是一个 Python 脚本,直接下载对应版本的二进制放到/usr/local/bin并赋予执行权限就行。装完之后必须做版本校验,docker-compose --version和docker version两个输出对照着看,确认客户端 API 版本和服务端 API 版本能对上。
这里有个实操细节:Compose 二进制文件的名字必须严格是docker-compose,中间是连字符不是下划线,否则命令找不到。另外,如果你在脚本里调用 Compose,建议用绝对路径/usr/local/bin/docker-compose,避免因为 PATH 环境变量在持续集成执行器里没配置好而报"command not found"。这种低级错误在自动化脚本里特别常见,而且排查起来很浪费时间。
# 安装 Compose 1.9.0 sudo curl -L "https://github.com/docker/compose/releases/download/1.9.0/docker-compose-$(uname -s)-$(uname -m)" \ -o /usr/local/bin/docker-compose sudo chmod +x /usr/local/bin/docker-compose # 校验版本兼容性 docker-compose --version docker version --format '{{.Server.APIVersion}}'3. 测试环境的编排实现
3.1 Compose 文件的结构设计与服务拆分
一份用于持续集成的 Compose 文件,和开发环境用的那份有本质区别。开发环境追求的是"改代码即时生效",所以会挂载源码目录、开热重载;测试环境追求的是"干净、隔离、可重复",所以绝对不能挂载宿主机目录,所有代码必须在构建阶段打进镜像。这个原则听起来简单,但很多团队就是把开发用的 Compose 文件直接拿来跑测试,结果测试结果受宿主机残留文件影响,时好时坏,非常折磨人。
我的做法是维护两份文件:docker-compose.yml作为基础定义,docker-compose.test.yml作为测试覆盖层,用-f参数叠加。基础文件里定义服务、网络、卷这些不变的部分,测试文件里覆盖命令、环境变量、以及加上测试专用的服务。这样既避免了重复,又能让测试配置独立演进。
服务拆分上,我通常拆成四类:被测应用服务、数据依赖服务(数据库、缓存)、测试执行服务(跑测试用例的容器)、辅助服务(比如 mock 外部接口的桩服务)。测试执行服务单独拎出来很关键,它让"跑测试"这件事本身也容器化了,不依赖宿主机的语言运行时,真正做到环境无关。
# docker-compose.yml 基础定义 version: '2' services: app: build: context: . dockerfile: Dockerfile environment: - DB_HOST=db - CACHE_HOST=redis depends_on: - db - redis networks: - testnet db: image: mysql:5.6 environment: - MYSQL_ROOT_PASSWORD=testpass - MYSQL_DATABASE=testdb networks: - testnet redis: image: redis:3.0 networks: - testnet networks: testnet: driver: bridge3.2 启动顺序控制与健康检查机制
depends_on只能保证容器启动顺序,不能保证服务就绪。数据库容器启动了,不代表 MySQL 已经能接受连接,这个时间差在测试环境里是致命的——应用容器连过去直接报连接拒绝,测试全挂。所以必须引入健康检查。
Compose 1.9 支持healthcheck指令,可以给每个服务定义检测命令、间隔、超时和重试次数。MySQL 的健康检查我一般用mysqladmin ping,Redis 用redis-cli ping,应用服务则用一个轻量的 HTTP 探针。健康检查通过后,依赖它的服务才会真正开始工作。不过要注意,depends_on在 1.9 版本里还不会自动等待健康状态,需要配合condition: service_healthy这种写法(这个特性在后续版本才完善),所以在 1.9 上更稳妥的做法是在测试执行脚本里主动轮询等待。
我写过一个通用的等待脚本,逻辑是循环执行健康检查命令,直到成功或者超时。这个脚本放在测试执行容器的入口处,比依赖 Compose 的编排能力更可靠。轮询间隔设 2 秒,超时设 120 秒,这个参数是根据 MySQL 冷启动时间定的——在资源紧张的构建机上,MySQL 首次初始化可能要一分钟以上。
# docker-compose.test.yml 测试覆盖层 version: '2' services: db: healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 5s timeout: 3s retries: 10 tester: build: context: ./tests depends_on: - app - db command: sh -c "./wait-for-it.sh db:3306 -- ./run-tests.sh" networks: - testnet3.3 网络隔离与端口管理策略
测试环境最怕的就是端口冲突。构建机上可能同时跑着好几个项目的测试,如果都用默认端口映射到宿主机,必然打架。我的策略是测试环境内部完全不映射端口到宿主机,所有通信走 Compose 自动创建的内部网络,服务之间用服务名做主机名互相访问。这样无论跑多少个测试环境,端口都不会冲突。
只有在需要从宿主机访问某个服务做调试时,才临时映射端口,而且映射到高位随机端口。这个思路和"测试环境应该完全自包含"的原则是一致的。内部网络的 DNS 解析由 Docker 内置的 DNS 服务负责,服务名直接解析成容器 IP,用起来和访问本地服务没区别。
提示:如果你的测试用例里硬编码了
localhost:3306这种地址,容器化之后必然失败。正确做法是把数据库地址做成环境变量注入,测试代码里读环境变量。这个改造一次性做完,后面所有环境都能复用。
4. 持续集成流水线的对接
4.1 触发、构建与清理的完整生命周期
持续集成的核心是"每次提交都跑一遍完整测试",所以流水线必须覆盖三个动作:拉起环境、执行测试、清理环境。清理这一步最容易被忽视,但恰恰是最重要的——如果每次跑完不清理,构建机的磁盘很快就会被停止的容器、悬空镜像、匿名卷塞满。我见过一台构建机因为没清理,三个月后磁盘爆掉,所有流水线全部失败。
清理策略我推荐"跑完即删",用docker-compose down -v --rmi local一条命令搞定。-v删除匿名卷,--rmi local删除本次构建产生的本地镜像。注意不要加--rmi all,那会把基础镜像也删掉,下次构建又要重新拉取,得不偿失。另外,建议在流水线里加一个前置的清理步骤,防止上一次异常中断留下的残留影响本次运行。
#!/bin/bash set -e # 前置清理,容忍失败 docker-compose -f docker-compose.yml -f docker-compose.test.yml down -v --rmi local || true # 构建并启动 docker-compose -f docker-compose.yml -f docker-compose.test.yml build docker-compose -f docker-compose.yml -f docker-compose.test.yml up -d # 执行测试并捕获退出码 set +e docker-compose -f docker-compose.yml -f docker-compose.test.yml run --rm tester TEST_EXIT_CODE=$? set -e # 无论成败都清理 docker-compose -f docker-compose.yml -f docker-compose.test.yml down -v --rmi local exit $TEST_EXIT_CODE这段脚本里有个关键细节:run --rm tester的退出码必须被捕获并作为整个脚本的退出码返回,否则持续集成系统无法判断测试是否通过。我见过不少流水线脚本忘了这一步,结果测试失败了流水线还显示绿色,问题被带到生产环境才发现。
4.2 构建缓存与镜像分层优化
持续集成的速度很大程度上取决于镜像构建能不能命中缓存。Dockerfile 的指令顺序直接决定缓存命中率,原则是把变化频率低的指令放前面,变化频率高的放后面。比如先装系统依赖,再装语言依赖,最后才拷贝源码。如果反过来,改一行源码就会导致依赖重新安装,构建时间从几十秒变成几分钟。
多阶段构建在 1.12 时代还不支持,所以只能靠精心组织 Dockerfile 来减小镜像体积。我的做法是把构建工具和运行时分离到不同的基础镜像,构建阶段产生的中间产物通过COPY --from拷到运行镜像里——等等,这个特性也是后来才有的。在 1.12 上,更实际的做法是构建完在同一个 Dockerfile 里清理掉构建工具,或者干脆接受镜像大一点,用.dockerignore把无关文件排除掉,减少构建上下文传输时间。
.dockerignore这个文件很多人不重视,但它的作用很实在。构建上下文里如果有node_modules、.git、日志文件这些大目录,每次构建都要打包传给守护进程,白白浪费时间。我一般会把.git、node_modules、*.log、tmp这些都排除掉,构建上下文能从几百兆降到几兆。
4.3 测试结果收集与失败诊断
测试跑完,结果得能拿出来看。容器里的测试报告、日志、覆盖率数据,都要在容器销毁前拷贝到宿主机。用docker cp或者挂载一个专门的结果卷都行。我倾向于用卷,因为docker cp在容器已经退出时容易出问题,而卷可以在down之前从容拷贝。
失败诊断的关键是日志。测试失败时,光看测试框架的输出往往不够,还需要看应用容器的日志、数据库容器的日志。所以流水线里要加一步:测试失败时,把所有服务的日志 dump 出来。docker-compose logs --no-color能一次性输出所有服务的日志,配合--tail限制行数,避免日志太多把流水线输出刷屏。
| 收集项 | 收集方式 | 用途 |
|---|---|---|
| 测试报告 | 结果卷挂载 | 判断通过率、生成趋势图 |
| 应用日志 | docker-compose logs | 定位应用层异常 |
| 依赖服务日志 | docker-compose logs | 排查数据库、缓存问题 |
| 覆盖率数据 | 结果卷挂载 | 质量趋势分析 |
5. 常见问题排查与实操避坑
5.1 存储驱动相关的典型故障
在 Ubuntu 14.04 上跑 Docker,存储驱动引发的问题占了故障的一大半。最常见的现象是容器启动时报 "no space left on device",但df -h看磁盘明明还有空间。这通常是 devicemapper 的 thin pool 满了,而不是磁盘满了。解决办法要么是扩大 thin pool,要么是换回 aufs。这也是我前面坚持用 aufs 的原因之一。
另一个高频问题是镜像层损坏,表现为构建时随机报错、拉取镜像校验失败。aufs 在某些异常断电后会留下不一致的层数据。遇到这种情况,docker rmi删掉相关镜像重新拉取通常能解决,如果不行就得清理/var/lib/docker/aufs下的残留目录。清理前务必确认没有正在运行的容器依赖这些层,否则会把运行中的环境搞坏。
注意:
/var/lib/docker目录千万不要手动乱删,尤其是devicemapper或aufs子目录。要清理就用docker system prune这类官方命令,手动删文件很容易把 Docker 的元数据搞乱,最后只能重装。
5.2 网络与 DNS 解析问题
容器间通信失败,十有八九是 DNS 解析的问题。Compose 创建的网络里,服务名解析依赖 Docker 内置 DNS,但这个 DNS 在某些老版本上对容器重启后的 IP 变更处理不及时,导致解析到旧 IP。表现就是服务刚启动时能连上,跑一会儿就连接超时。
排查这类问题的第一步是在容器里执行nslookup 服务名或者getent hosts 服务名,看解析结果对不对。如果解析到错误 IP,重启一下相关容器通常能刷新 DNS 缓存。更彻底的办法是在应用层做重试,连接失败后重新解析。测试环境里我一般会在数据库客户端配置里加上连接重试,重试间隔 1 秒,重试 5 次,能覆盖绝大多数 DNS 抖动。
还有一种情况是容器能解析但连不上,那就要检查 iptables 规则了。如果守护进程启动时加了--iptables=false,容器间的转发规则需要手动配置,很容易漏。我的建议是除非有明确的网络隔离需求,否则不要关 iptables,让 Docker 自己管理。
5.3 资源限制与并发测试的冲突
构建机上同时跑多个测试环境时,资源竞争会导致各种诡异问题:MySQL 启动超时、测试随机失败、容器被 OOM killer 干掉。这些问题的根源是没做资源限制。Compose 支持给每个服务设置 CPU 和内存上限,虽然 1.9 版本的资源限制功能还不算完善,但基本的mem_limit和cpu_shares是可用的。
我的经验值是:MySQL 容器给 512MB 内存,Redis 给 128MB,应用容器根据实际占用给 256MB 到 512MB,测试执行容器给 256MB。这些值不是拍脑袋,而是根据实际运行时的内存峰值留了 30% 余量。设置上限的好处是,即使某个测试用例内存泄漏,也只会影响它自己的容器,不会拖垮整台构建机。
services: db: image: mysql:5.6 mem_limit: 512m cpu_shares: 512 redis: image: redis:3.0 mem_limit: 128m cpu_shares: 2565.4 常见问题速查表
| 现象 | 可能原因 | 排查命令 | 解决方向 |
|---|---|---|---|
| 容器启动报 no space | thin pool 满 | docker info看 Data Space | 换 aufs 或扩容 |
| 服务间连接超时 | DNS 解析旧 IP | getent hosts 服务名 | 重启容器刷新 DNS |
| 测试随机失败 | 资源竞争 | docker stats观察 | 加资源限制 |
| 构建极慢 | 缓存未命中 | 观察构建输出 | 调整 Dockerfile 顺序 |
| 磁盘快速耗尽 | 未清理残留 | docker system df | 加清理步骤 |
| 守护进程起不来 | cgroups 未挂载 | `mount | grep cgroup` |
6. 稳定性加固与长期维护建议
6.1 镜像版本锁定与依赖固化
测试环境最忌讳"今天能跑明天不能跑"。基础镜像如果用latest标签,某天上游更新了,你的测试可能突然全挂,而且根本不知道发生了什么。所以所有镜像都必须锁定到具体版本,甚至锁定到 digest。MySQL 用mysql:5.6.35而不是mysql:5.6,更不能用mysql:latest。
语言依赖同理。Python 的requirements.txt要锁版本,Node 的package.json要用 lock 文件,系统包安装要指定版本号。这些做法在开发环境可能显得繁琐,但在持续集成环境里是稳定性的基石。我维护的一个测试环境,所有依赖都锁死,两年没动过配置,至今跑得好好的。
6.2 定期维护与镜像仓库清理
构建机上的镜像会越积越多,即使每次跑完都清理本地镜像,从仓库拉取的基础镜像还是会缓存。建议每周做一次docker system prune -a,清理所有未使用的镜像、容器、网络。执行前确认没有正在运行的测试任务,最好放在低峰期。
另外,如果团队自建了镜像仓库,也要定期清理旧版本镜像。仓库膨胀不仅占空间,还会拖慢拉取速度。我一般保留最近 10 个版本的镜像,更早的自动清理。这个策略要根据团队的发布频率调整,发布频繁的团队可以保留更多。
6.3 监控与告警的最小化配置
测试环境本身不需要复杂的监控,但有几个指标值得关注:构建成功率、单次构建耗时、构建机磁盘使用率。这三个指标能覆盖 90% 的潜在问题。构建成功率下降说明环境不稳定,耗时上升说明缓存失效或资源不足,磁盘使用率上升说明清理没做到位。
实现方式不需要上重型监控系统,一个定时脚本采集数据写进日志,配合简单的阈值告警就够了。我在一个模拟项目里用 shell 脚本加 cron 实现了这套最小监控,运行了半年,提前发现过两次磁盘将满和一次缓存失效,效果比想象中好。
我在实际维护这类老环境的过程中最大的体会是:容器化测试环境的价值不在于技术多先进,而在于把"环境"这件事变成了代码。一旦环境是代码,它就能被审查、被测试、被回滚,这才是持续集成真正需要的确定性。Ubuntu 14.04 这套底座确实老旧,但只要把版本锁定、资源限制、清理策略这几件事做扎实,它依然能稳定地支撑起一套可靠的测试流水线。最后分享一个小技巧:把 Compose 文件和构建脚本放在同一个仓库里,和被测代码一起做版本控制,这样任何一次环境变更都有迹可循,出问题时git log一下就能定位到是哪次提交改坏了环境。