每个 QA 工程师都知道缓慢的测试环境和臃肿的容器带来的挫败感,这些容器构建和部署需要花费大量时间。
如果你能够将测试镜像大小减少 70%,并且在不牺牲测试质量的情况下加快 CI 流水线的速度,那会怎样?Docker 多阶段构建就是这样一个功能,自 Docker 17.05 以来就已存在,但在 QA 自动化工作流程中仍然令人惊讶地被低估。
传统测试容器的问题
传统的基于 Docker 的测试环境通常因为包含以下内容而变得不必要地庞大:
- 在运行时不需要的构建工具和依赖项;
- 源代码和中间构建产物;
- 开发库和头文件;
- 文档和其他非必要文件。
这种臃肿带来了几个问题:
- 镜像构建速度变慢;
- 在 CI/CD 流水线中增加网络传输时间;
- 存储成本增加;
- 容器启动时间变长;
- 在测试执行期间使用更多资源。
多阶段构建登场
多阶段构建允许我们在 Dockerfile 中使用多个 FROM 语句。
每个 FROM 指令可以使用不同的基础镜像,并开始构建的一个新阶段。我们可以选择性地将一个阶段中的工件复制到另一个阶段,留下我们不需要的所有内容。
这对于 QA 自动化来说是完美的,因为我们可以:
- 在一个阶段构建我们的测试依赖项;
- 在另一个阶段编译我们的测试工具;
- 创建一个最终的最小运行时镜像,其中只包含运行测试所需的内容。
实际示例
让我们为一个基于 Python 的 Web 应用程序测试环境创建一个多阶段 Dockerfile,该环境使用 Selenium WebDriver 和 Chrome。
第一阶段:构建依赖项
第一阶段使用完整的 Python 镜像作为基础。我们使用 AS builder 为这个阶段命名,这允许我们在 Dockerfile 的后续部分引用它。我们在 /build 处设置工作目录,并将 requirements.txt 文件(而不是整个项目)复制到容器中。
然后,我们使用 pip 的 wheel 命令将所有 Python 依赖项预先构建为 wheel 文件,这些文件存储在 /build/wheels 目录中。
--no-cache-dir 和 --no-deps 标志有助于创建最小的 wheel 包。这个阶段的唯一目的是构建一次 Python 包,因此我们不需要在后续阶段重新构建它们。
FROM python:3.11 AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /build/wheels -r requirements.txt
第二阶段:Chrome 和驱动程序准备
第二阶段从一个精简的 Debian 镜像开始,并命名为 "chrome"。在这里,我们安装下载和安装 Chrome 所需的最小实用工具。
这个过程从安装基本工具开始:wget 用于下载文件,gnupg 用于加密操作,ca-certificates 用于安全连接,unzip 用于解压压缩文件。我们使用 --no-install-recommends 标志来防止安装不必要的包。
在安装这些先决条件之后,我们下载谷歌的官方签名密钥,并使用 GPG 处理它以创建一个适当的密钥环文件。
这遵循现代 Debian 安全实践,将密钥存储在 /usr/share/keyrings/ 中,而不是使用已弃用的 apt-key 命令。然后,我们使用 sources 列表中的 signed-by 属性配置软件包仓库,该属性引用了这个密钥环文件。
接下来,我们更新软件包列表,并再次使用 --no-install-recommends 标志安装 Chrome,以保持安装的最小化。
最后,我们通过删除 apt 软件包列表目录来清理,这通过删除安装后不再需要的缓存软件包元数据来减小镜像大小。
整个过程使用 && 运算符链接在一起,创建一个单一的 Docker 层,这比使用单独的 RUN 命令更高效。
FROM debian:bullseye-slim AS chrome
RUN apt-get update && apt-get install -y \
wget \
gnupg \
ca-certificates \
unzip \
--no-install-recommends && \
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor > /usr/share/keyrings/google-linux-signing-key.gpg && \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-linux-signing-key.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list && \
apt-get update && \
apt-get install -y google-chrome-stable --no-install-recommends && \
rm -rf /var/lib/apt/lists/*
在接下来的部分中,我们通过查询 LATEST_RELEASE 端点来确定最新兼容的 ChromeDriver 版本。我们动态地捕获这个版本号到 CHROME_DRIVER_VERSION 变量中。
然后,我们安静地下载对应的 ChromeDriver zip 文件,并将其保存到临时目录中。解压到 /usr/bin/ 之后,我们删除 zip 文件以节省空间,并使用 chmod 命令使 ChromeDriver 可执行。
这种方法确保我们拥有一对兼容的 Chrome 和 ChromeDriver,而不会硬编码可能会过时的版本号。
RUN CHROME_DRIVER_VERSION=$(wget -qO- https://chromedriver.storage.googleapis.com/LATEST_RELEASE) \
&& wget -q --no-verbose -O /tmp/chromedriver.zip https://chromedriver.storage.googleapis.com/$CHROME_DRIVER_VERSION/chromedriver_linux64.zip \
&& unzip /tmp/chromedriver.zip -d /usr/bin/ \
&& rm /tmp/chromedriver.zip \
&& chmod +x /usr/bin/chromedriver
第三阶段:最终精简测试镜像
现在,我们从一个精简的 Python 镜像开始最终阶段。我们在 /app 处创建一个工作目录,并使用 COPY --from=chrome 有选择地从上一个阶段拉取必要的 Chrome 文件。
这就是多阶段构建真正发光的地方:我们不会带上安装 Chrome 所使用的所有构建工具和依赖项。这种方法允许我们获得 Chrome 和 ChromeDriver,而不会带来它们安装过程中的所有包袱。
FROM python:3.11-slim
WORKDIR /app
COPY --from=chrome /opt/google/chrome /opt/google/chrome
COPY --from=chrome /usr/bin/chromedriver /usr/bin/chromedriver
COPY --from=chrome /usr/bin/google-chrome-stable /usr/bin/google-chrome-stable
接下来,我们只安装 Chrome 在运行时需要的最小运行时库。这些是共享库,Chrome 在运行时需要这些库来处理各种系统级操作,如渲染和输入处理。
我们使用 rm -rf /var/lib/apt/lists/\* 清理 apt 的缓存,以减小镜像大小。再次,我们使用 --no-install-recommends 来保持简洁。这种对依赖项的专注方法比简单地在最终镜像中安装 Chrome 要高效得多,因为我们会得到许多在我们的测试环境中不需要的不必要的包。
RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libnss3 \
libx11-6 \
libx11-xcb1 \
libxcomposite1 \
libxcursor1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxi6 \
libxrandr2 \
libxrender1 \
libxss1 \
libxtst6 \
fonts-liberation \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
之后,我们从第一阶段的 "builder" 复制预先构建的 Python 轮子和 requirements.txt 文件(用于参考)。我们使用 pip 安装轮子,这比从源代码构建包要快得多。然后,我们在安装后通过删除轮子和 requirements.txt 来清理。这种方法让我们在不需要编译器或开发头文件的情况下安装 Python 包,这显著减小了镜像大小并提高了构建时间。
COPY --from=builder /build/wheels /wheels
COPY requirements.txt .
RUN pip install --no-cache /wheels/* \
&& rm -rf /wheels \
&& rm requirements.txt
最后,我们只复制测试文件和 pytest 配置,而不是整个项目代码库。这种选择性复制进一步减小了镜像大小,并使其专注于测试。
我们设置环境变量以在无头模式下配置 Chrome,并使用适当的 Docker 设置使其能够在没有显示或沙箱要求的情况下运行。
我们设置默认命令以在我们的测试目录上运行 pytest 并输出详细信息。这种最终配置保持对测试的关注,并消除了任何对测试执行不必要的代码或文件。
COPY tests/ /app/tests/
COPY conftest.py /app/
ENV CHROME_OPTIONS="--headless --no-sandbox --disable-dev-shm-usage"
CMD ["pytest", "tests/", "-v"]
好处与实施策略
多阶段构建方法改变了我们为 QA 自动化创建 Docker 容器的方式。我们的测试现在在大约比传统单阶段构建小 70% 的镜像中运行,显著减少了存储需求和网络传输时间。这种大小减少直接转化为 CI/CD 流水线中更快的启动时间,允许团队更快地获得反馈并更快速地迭代。
随着我们从最终镜像中移除不必要的构建工具和包,安全性显著提高。每个被移除的组件都代表着一个潜在的漏洞,创建了一个更安全的测试环境,攻击面显著减少。
精简的容器还为测试提供了一个更干净、更接近生产的环境,最小化了困扰测试工作的“在我的机器上可以工作”的问题。
Docker 的智能缓存机制与多阶段构建配合得非常好。每个阶段都可以独立缓存,这意味着对测试代码的更改不会触发依赖项或浏览器安装的耗时重建。当我们修改测试时,只有最终阶段需要重建,节省了宝贵的开发时间。
为了最大化这些好处,我们应该有条理地组织我们的测试代码。让我们将测试与测试工具和支持文件分开,以便更有效地将它们复制到最终镜像中。
我们应该始终使用基础镜像的具体版本,而不是 “latest” 标签,以确保在不同环境和时间中可重复构建。这种版本控制策略防止了意外更改破坏我们的测试基础设施。
我们应该花时间分析哪些工件实际上需要在运行时存在。通常,源代码或中间构建产品对于测试执行来说不是必要的,可以被留下。我们应该创建一个全面的 .dockerignore 文件,以防止在构建过程中复制不必要的文件,进一步精简我们的镜像。
对于高级场景,我们可以利用构建参数来创建参数化构建,以满足不同的测试环境需求,允许一个 Dockerfile 根据需要生成不同浏览器或测试配置的容器。
结论
多阶段 Docker 构建可能不是最新的功能,但它们无疑是QA自动化武器库中一个被低估的秘密武器。通过有条理地分离构建和测试关注点,我们可以创建更精简、更快、更可靠的测试环境,这将使我们的开发人员和财务部门都感到满意。
完整的Dockerfile可在我们的GitHub页面上轻松访问和实验,允许你立即在自己的QA自动化项目中实施这种高效的多阶段构建方法。试试吧!
可以到我的个人号:atstudy-js
这里有一起交流行业热点和offer机会,可加入↓↓↓↓↓↓
行业测试涨薪交流群,内含银行业务、车载、AI测试、互联网、游戏更多行业测试实战和面试题库 &【AI智能体】等各种好用的
助你快速转行&进阶测试开发技术,稳住当前职位同时走向高薪之路