Ubuntu 14.04 上基于 Docker 与 Compose 的持续集成测试环境实战
发布时间:2026/10/26 12:05:43 锦皓数字建站

容器化测试环境这件事放在今天看好像已经是标配了但真要把时间拨回到 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 dont have same version这类让人抓狂的报错。所以选版本不是越新越好而是要卡在兼容区间内。组件推荐版本选择理由风险点Docker Engine1.12.x内核兼容性好API 稳定不支持 overlay2需用 aufsDocker 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_forward1 echo net.ipv4.ip_forward1 | sudo tee -a /etc/sysctl.conf2.2 Docker 引擎的安装与守护进程调优前面说了不要用系统仓库那怎么装我的做法是下载官方提供的静态二进制包解压后把可执行文件放到/usr/bin下然后手写一份 init 脚本或者 systemd 单元来管理守护进程。Ubuntu 14.04 用的是 upstart 而不是 systemd所以需要写一个 upstart 的 job 配置放在/etc/init/docker.conf。守护进程的启动参数是调优的重点。--storage-driveraufs明确指定存储驱动避免 Docker 自动探测出错--iptablesfalse在某些网络环境下能避免和宿主机防火墙规则打架但如果你的容器需要端口映射这个参数就不能加得让 Docker 自己管理 iptables 规则。我一般会加上--log-driverjson-file --log-opt max-size10m --log-opt max-file3防止日志把磁盘撑爆——测试环境跑久了日志增长是非常恐怖的。# 下载并解压 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-driveraufs \ --log-driverjson-file \ --log-opt max-size10m \ --log-opt max-file3 注意第一次启动守护进程时建议前台运行观察输出里有没有存储驱动初始化失败、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_HOSTdb - CACHE_HOSTredis depends_on: - db - redis networks: - testnet db: image: mysql:5.6 environment: - MYSQL_ROOT_PASSWORDtestpass - MYSQL_DATABASEtestdb networks: - testnet redis: image: redis:3.0 networks: - testnet networks: testnet: driver: bridge3.2 启动顺序控制与健康检查机制depends_on只能保证容器启动顺序不能保证服务就绪。数据库容器启动了不代表 MySQL 已经能接受连接这个时间差在测试环境里是致命的——应用容器连过去直接报连接拒绝测试全挂。所以必须引入健康检查。Compose 1.9 支持healthcheck指令可以给每个服务定义检测命令、间隔、超时和重试次数。MySQL 的健康检查我一般用mysqladmin pingRedis 用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 规则了。如果守护进程启动时加了--iptablesfalse容器间的转发规则需要手动配置很容易漏。我的建议是除非有明确的网络隔离需求否则不要关 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 spacethin pool 满docker info看 Data Space换 aufs 或扩容服务间连接超时DNS 解析旧 IPgetent hosts 服务名重启容器刷新 DNS测试随机失败资源竞争docker stats观察加资源限制构建极慢缓存未命中观察构建输出调整 Dockerfile 顺序磁盘快速耗尽未清理残留docker system df加清理步骤守护进程起不来cgroups 未挂载mountgrep cgroup6. 稳定性加固与长期维护建议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一下就能定位到是哪次提交改坏了环境。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。