ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Ubuntu 14.04 上基于 Docker 与 Compose 的持续集成测试环境实战

Ubuntu 14.04 上基于 Docker 与 Compose 的持续集成测试环境实战

容器化测试环境这件事,放在今天看好像已经是标配了,但真要把时间拨回到 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 Engine1.12.x内核兼容性好,API 稳定不支持 overlay2,需用 aufs
Docker Compose1.8.x - 1.9.x支持健康检查与依赖编排版本过高会触发 API 不匹配
存储驱动aufs3.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.conf

2.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: bridge

3.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: - testnet

3.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: 256

5.4 常见问题速查表

现象可能原因排查命令解决方向
容器启动报 no spacethin pool 满docker info看 Data Space换 aufs 或扩容
服务间连接超时DNS 解析旧 IPgetent hosts 服务名重启容器刷新 DNS
测试随机失败资源竞争docker stats观察加资源限制
构建极慢缓存未命中观察构建输出调整 Dockerfile 顺序
磁盘快速耗尽未清理残留docker system df加清理步骤
守护进程起不来cgroups 未挂载`mountgrep 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一下就能定位到是哪次提交改坏了环境。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进