团队新启动的 Elixir Phoenix 项目,在 Jenkins 上的 CI 构建时间已经悄然攀升到了12分钟。对于一个追求快速迭代的团队而言,这是个无法容忍的数字。每次提交后,开发者都需要花费一杯咖啡的时间才能等到测试结果,这严重拖慢了反馈循环。问题的根源很清晰:每一次构建都是一个全新的、纯净的环境,mix deps.get
和 mix compile
这两个步骤占据了整个流程超过70%的时间。
我们的目标是明确的:将构建时间压缩到3分钟以内。初步构想的优化路径主要集中在“缓存”上——复用那些在多次构建之间不会发生变化的东西。具体来说,就是项目的依赖(deps
目录)和编译产物(_build
目录)。
技术选型上,我们沿用现有的技术栈:
- 应用框架: Elixir + Phoenix,提供 RESTful API 服务。
- 数据库: PostgreSQL,通过 Ecto 进行交互。
- CI/CD: Jenkins,使用声明式流水线(Declarative Pipeline)进行编排。
挑战在于,如何在 Jenkins 的无状态执行环境中,实现智能、高效且可靠的缓存策略,同时还要妥善处理数据库测试的隔离与并行化。
阶段一:基线流水线与性能瓶颈分析
在优化之前,必须先建立一个基准。这是一个未经任何优化的 Jenkinsfile
,它忠实地模拟了在一个干净环境中从零开始构建的全过程。
// Jenkinsfile.baseline
pipeline {
agent {
docker {
image 'elixir:1.14-alpine'
args '-u root' // 在容器内以root用户运行,避免权限问题
}
}
environment {
MIX_ENV = 'test'
LANG = 'C.UTF-8'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Install Dependencies') {
steps {
sh 'mix local.hex --force'
sh 'mix local.rebar --force'
sh 'mix deps.get'
}
}
stage('Compile') {
steps {
sh 'mix compile --warnings-as-errors'
}
}
stage('Run Tests') {
// 此时还没有数据库,测试会因为无法连接而失败
// 仅为演示流程结构
steps {
echo 'Skipping tests due to no database...'
// sh 'mix test'
}
}
}
}
将这个流水线在 Jenkins 中运行,其结果完全在预料之中:
-
Install Dependencies
阶段耗时约 5-6 分钟。hex
需要从远程仓库拉取每一个依赖包。 -
Compile
阶段耗时约 3-4 分钟。编译器需要处理所有依赖和项目本身的源码。 - 总计时间:**~11分钟**(包含 Jenkins 调度、容器启动等开销)。
这就是我们面临的技术痛点,每一次构建都在重复下载和编译完全相同的依赖代码。
阶段二:引入依赖缓存 (deps
)
第一个优化的目标是 mix deps.get
。Elixir 的依赖被下载到项目根目录下的 deps
文件夹。只要 mix.lock
文件没有变化,这个文件夹的内容就是可以安全复用的。
在 Jenkins 中实现缓存,有多种方式。对于单节点的 Jenkins Master,最简单直接的方法是利用一个 Master 上的特定目录作为缓存卷,并将其挂载到 Docker Agent 容器中。
我们创建一个缓存目录,例如 /var/jenkins_home/caches/elixir-deps
,然后修改 Jenkinsfile
:
// Jenkinsfile.with_deps_cache
pipeline {
agent {
docker {
image 'elixir:1.14-alpine'
args '-u root -v /var/jenkins_home/caches/elixir-deps:/root/.mix'
}
}
environment {
MIX_ENV = 'test'
LANG = 'C.UTF-8'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Install Dependencies') {
steps {
sh 'mix local.hex --force'
sh 'mix local.rebar --force'
// deps.get 现在会优先使用 /root/.mix 中的缓存
sh 'mix deps.get'
}
}
stage('Compile') {
steps {
sh 'mix compile --warnings-as-errors'
}
}
// ... 其他阶段
}
}
这里的关键改动是 agent
部分的 args
。我们通过 -v
参数将主机的 /var/jenkins_home/caches/elixir-deps
目录挂载到容器内的 /root/.mix
。mix
默认会在这里缓存 Hex 包的 tarball。当 mix deps.get
执行时,它会首先检查本地缓存,从而避免了大量的网络下载。
注意一个常见的错误: 直接缓存项目工作区内的 deps
目录是不可靠的。因为 Jenkins 的工作区可能会在构建开始时被清理。更稳健的做法是缓存 mix
的全局包缓存目录 (/root/.mix
)。
执行优化后的流水线,Install Dependencies
阶段的时间从 5-6 分钟骤降到 30秒 以内(主要是检查依赖版本的耗时)。整体构建时间缩短至 ~5分钟。这是一个巨大的进步,但我们离目标还有一半的距离。
阶段三:缓存编译产物 (_build
)
下一个耗时大户是 mix compile
。编译产物默认存放在 _build
目录中。与 deps
目录类似,只要源码和依赖没有变化,编译产物也可以复用。
缓存 _build
目录比缓存 deps
要复杂一些。因为它的有效性不仅与 mix.lock
相关,还与项目本身的 Elixir 源码(lib/
, test/
等目录)相关。如果只缓存而不做任何有效性校验,可能会导致使用过期的编译产物,引发难以排查的运行时错误。
一个务实的策略是,将 deps
和 _build
两个目录打包成一个 tar 文件,并以 mix.lock
文件的哈希值作为缓存键。这样,只有在依赖不变的情况下,我们才尝试恢复缓存。
// Jenkinsfile.with_build_cache
def cacheKey = sh(script: 'sha256sum mix.lock | cut -d " " -f 1', returnStdout: true).trim()
def cacheArchive = "elixir-cache-${cacheKey}.tar.gz"
pipeline {
agent {
docker {
image 'elixir:1.14-alpine'
args '-u root'
}
}
environment {
MIX_ENV = 'test'
LANG = 'C.UTF-8'
CACHE_DIR = "/var/jenkins_home/caches/elixir-builds"
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Restore Cache') {
steps {
script {
// 确保缓存目录存在
sh "mkdir -p ${env.CACHE_DIR}"
// 检查缓存文件是否存在
if (fileExists("${env.CACHE_DIR}/${cacheArchive}")) {
echo "Cache found, restoring..."
// 将缓存解压到工作区
sh "tar -xzf ${env.CACHE_DIR}/${cacheArchive} -C ."
} else {
echo "Cache not found for key: ${cacheKey}"
}
}
}
}
stage('Install Dependencies') {
steps {
sh 'mix local.hex --force'
sh 'mix local.rebar --force'
sh 'mix deps.get'
}
}
stage('Compile') {
steps {
// 如果缓存恢复成功,此步骤会非常快
sh 'mix compile --warnings-as-errors'
}
}
// ... 测试阶段 ...
}
post {
always {
stage('Save Cache') {
steps {
script {
// 只有当缓存不存在时才创建,避免重复打包
if (!fileExists("${env.CACHE_DIR}/${cacheArchive}")) {
echo "Saving cache for key: ${cacheKey}"
// --ignore-failed-read 避免在目录不存在时报错
sh "tar --ignore-failed-read -czf ${env.CACHE_DIR}/${cacheArchive} deps _build"
} else {
echo "Cache already exists. Skipping save."
}
}
}
}
}
}
}
这个版本的 Jenkinsfile
引入了几个关键概念:
- 缓存键
cacheKey
: 使用mix.lock
的 SHA256 哈希值生成。当依赖更新时,mix.lock
改变,哈希值随之改变,导致缓存失效,触发重新构建,这正是我们期望的行为。 -
Restore Cache
阶段: 在构建早期,尝试根据缓存键查找并解压缓存包。 -
Save Cache
阶段: 在post
块的always
条件中执行,确保无论构建成功与否,只要缓存不存在,就将deps
和_build
目录打包并保存。这里的坑在于,必须确保只有在缓存缺失时才进行打包,否则会浪费大量时间在 I/O 操作上。
再次运行流水线。在 mix.lock
未变的情况下,Compile
阶段的时间从 3-4 分钟缩短到 10秒 左右(仅进行少量增量编译或检查)。整体构建时间现在稳定在 ~2分钟!我们已经达到了最初设定的目标。
阶段四:集成数据库与并行测试
虽然编译时间达标了,但我们的流水线还不完整——它没有运行与数据库交互的集成测试。这是生产级 CI 中至关重要的一环。
我们需要在 Jenkins 环境中启动一个临时的 PostgreSQL 数据库,并让 Elixir 应用能够连接到它。利用 Jenkins 对 Docker 的原生支持,这实现起来非常直观。我们可以在 agent
中定义一个 “sidecar” 容器。
同时,为了进一步缩短测试时间,可以将运行快的单元测试和运行慢的、依赖数据库的集成测试拆分到并行的 stage
中执行。
graph TD A[Start] --> B(Checkout); B --> C(Restore Cache); C --> D(Deps & Compile); D --> E{Setup Test DB}; E --> F(Parallel Stages); subgraph F direction LR F1[Run Unit Tests] F2[Run Integration Tests] end F --> G(Save Cache); G --> H[End];
以下是集成了数据库和并行测试的最终版 Jenkinsfile
:
// Jenkinsfile.final
def cacheKey = sh(script: 'sha256sum mix.lock | cut -d " " -f 1', returnStdout: true).trim()
def cacheArchive = "elixir-cache-${cacheKey}.tar.gz"
pipeline {
agent {
// 使用 docker-compose 文件来定义多容器环境
// 或者使用多个 docker agent,但 sidecar 模式更简洁
docker {
image 'elixir:1.14-alpine'
args '-u root'
// 在同一个网络中启动一个postgres服务
// 确保 Jenkins agent 可以访问
// 这里使用一个简化的 sidecar 示例,实际可能需要更复杂的网络配置
// 更健壮的方式是使用 label 来让 Jenkins 调度到带有 docker 的 agent
}
}
environment {
MIX_ENV = 'test'
LANG = 'C.UTF-8'
CACHE_DIR = "/var/jenkins_home/caches/elixir-builds"
// 数据库连接信息
// 这些将传递给 Elixir 应用
DB_HOSTNAME = 'postgres'
DB_USERNAME = 'postgres'
DB_PASSWORD = 'password'
DB_DATABASE = 'app_test'
}
stages {
stage('Services') {
// 在独立的 stage 中启动 sidecar 容器
agent any
steps {
script {
// 启动一个命名的 postgres 容器,方便后续引用
sh "docker run --name postgres -d --rm -e POSTGRES_PASSWORD=${env.DB_PASSWORD} -e POSTGRES_USER=${env.DB_USERNAME} -e POSTGRES_DB=${env.DB_DATABASE} postgres:14-alpine"
// 将主 agent 连接到 postgres 容器所在的网络
// 这是一个简化的演示,实际生产中推荐使用 docker-compose 或 Kubernetes pod
}
}
}
stage('Build') {
// 将构建步骤放在一个 stage 中
steps {
script {
// checkout scm
checkout scm
// Restore Cache
sh "mkdir -p ${env.CACHE_DIR}"
if (fileExists("${env.CACHE_DIR}/${cacheArchive}")) {
echo "Cache found, restoring..."
sh "tar -xzf ${env.CACHE_DIR}/${cacheArchive} -C ."
} else {
echo "Cache not found."
}
// Deps & Compile
sh 'mix local.hex --force'
sh 'mix local.rebar --force'
sh 'mix deps.get'
sh 'mix compile --warnings-as-errors'
// DB Setup
echo "Waiting for PostgreSQL to be ready..."
sh 'apk add --no-cache postgresql-client' // 安装 psql 工具
// 这是一个简单的等待循环,生产中应使用更健壮的健康检查
sh 'while ! pg_isready -h ${DB_HOSTNAME} -p 5432 -U ${DB_USERNAME}; do sleep 1; done'
sh 'mix ecto.create'
sh 'mix ecto.migrate'
}
}
}
stage('Test') {
parallel {
stage('Unit Tests') {
steps {
// 使用 tag 来排除需要数据库的集成测试
sh 'mix test --exclude db'
}
}
stage('Integration Tests') {
steps {
// 使用 tag 来只运行需要数据库的测试
sh 'mix test --only db'
}
}
}
}
}
post {
always {
script {
// 保存缓存
if (!fileExists("${env.CACHE_DIR}/${cacheArchive}")) {
echo "Saving cache for key: ${cacheKey}"
sh "tar --ignore-failed-read -czf ${env.CACHE_DIR}/${cacheArchive} deps _build"
}
// 清理数据库容器
echo "Stopping database container..."
sh "docker stop postgres"
}
}
}
}
为了使上述流水线工作,需要对 Elixir 项目做一些配置:
数据库配置 (
config/test.exs
):
必须修改测试环境的数据库配置,使其从环境变量中读取连接信息。# config/test.exs config :my_app, MyApp.Repo, username: System.get_env("DB_USERNAME") || "postgres", password: System.get_env("DB_PASSWORD") || "postgres", hostname: System.get_env("DB_HOSTNAME") || "localhost", database: System.get_env("DB_DATABASE") || "my_app_test", pool_size: 10, # 在测试中启用 SQL 日志,方便调试 show_sensitive_data_on_connection_error: true
测试标记 (
test/support/conn_case.ex
):
为了区分单元测试和集成测试,我们使用 ExUnit 的标签功能。在需要数据库连接的测试文件中,添加@moduletag :db
。// test/my_app_web/controllers/post_controller_test.exs defmodule MyAppWeb.PostControllerTest do use MyAppWeb.ConnCase @moduletag :db # 标记此模块所有测试都需要数据库 # ... tests that interact with the database end
通过这一系列改造,我们不仅拥有了快速的编译流程,还建立了一套完整的、带数据库的并行化测试体系。最终,在缓存命中的情况下,整个 CI 流水线(包含检出、编译、数据库准备、并行测试)的总耗时稳定在 2分30秒 左右,成功达成并超越了既定目标。
方案的局限性与未来展望
尽管当前的方案极大地提升了效率,但它并非完美,依然存在一些局限性:
缓存存储的单点问题: 目前的缓存是存储在 Jenkins Master 的本地磁盘上。这在单节点环境下工作良好,但如果 Jenkins 是多 Agent 的集群架构,这种本地缓存会失效,因为下一次构建可能会调度到没有缓存的另一个 Agent 节点上。届时,需要引入分布式缓存方案,例如使用 S3、Artifactory 或 Nexus 作为缓存后端。
缓存键的粒度: 当前的缓存键仅基于
mix.lock
。这意味着即使只修改了一个项目源文件,只要依赖不变,缓存就不会失效,这依赖于mix compile
的增量编译能力。在某些复杂场景下,可能需要更精细的缓存策略,例如将缓存键与lib
目录内容的哈希值结合,但这会显著增加流水线的复杂性。Docker-in-Docker (DinD) 的复杂性: 在 Jenkins Docker Agent 中再启动 Docker 容器(如 PostgreSQL)需要特定的配置,可能涉及挂载 Docker socket,存在一定的安全风险和配置复杂性。在 Kubernetes 环境下,使用 Pod Template 定义一个包含应用容器和数据库容器的 Pod 是一个更云原生的解决方案。
未来的迭代方向可以考虑将流水线迁移到 Jenkins on Kubernetes,利用 Pod 作为构建环境,可以更优雅地处理服务依赖和资源隔离。同时,探索使用 Elixir 1.11+ 引入的编译器追踪功能,或许能实现更智能、更细粒度的编译缓存策略。