我们的项目迁移到 Monorepo 架构后面临的第一个、也是最尖锐的痛点,就是 CI/CD 流水线的执行效率。最初的 Jenkinsfile
简单粗暴,任何一次提交,无论改动的是 Elixir 后端的某个 Phoenix Context,还是 Angular 前端的一个 CSS 样式,都会触发完整的后端编译、测试、前端构建、测试,以及两者的 Docker 镜像打包。一次流水线跑下来,平均耗时超过15分钟,这对于追求快速迭代的团队是不可接受的。开发人员的反馈循环被严重拉长,等待 CI 结果成了一种新的精神内耗。
问题的根源在于流水线缺乏对变更的感知能力。它将整个代码仓库视为一个不可分割的单元。而 Monorepo 的核心优势之一,恰恰是独立部署与构建不同应用的能力。我们的目标很明确:重构 Jenkins 流水线,使其能够智能识别变更范围,只对受影响的应用执行必要的构建与测试步骤。
初步构想与技术栈约束
我们的技术栈是固定的:后端是 Elixir + Phoenix,使用 Absinthe 提供 GraphQL API;前端是 Angular,通过 Apollo Client 与后端通信;CI/CD 工具则是公司内部统一的 Jenkins。整个项目结构如下:
/
├── apps/
│ ├── backend_elixir/ # Elixir/Phoenix aplication
│ │ ├── lib/
│ │ ├── test/
│ │ ├── mix.exs
│ │ └── mix.lock
│ ├── frontend_angular/ # Angular application
│ │ ├── src/
│ │ ├── angular.json
│ │ ├── package.json
│ │ └── package-lock.json
│ └── ...
├── libs/
│ └── shared_types/ # (未来) 可能的共享 TypeScript 类型
└── Jenkinsfile
初步的构想是利用 Git 命令来检测两次提交之间的文件差异。通过分析变更文件的路径,我们可以判断出本次提交影响了哪个应用。例如,如果所有变更文件都在 apps/backend_elixir/
目录下,那么我们只需要执行后端的 CI 流程。
基于这个思路,新的流水线逻辑应该是:
- 在流水线开始时,执行一个脚本来检测变更。
- 脚本输出一个或多个标志,如
BACKEND_CHANGED
、FRONTEND_CHANGED
。 - Jenkins Pipeline 的各个 Stage 根据这些标志,通过
when
指令决定是否执行。
失败的初版:一个简单的 Jenkinsfile
这是我们最初那个效率低下的 Jenkinsfile
,它为后续的优化提供了一个性能基准。
// Jenkinsfile.naive - 效率低下的版本
pipeline {
agent any
stages {
stage('Install Dependencies') {
parallel {
stage('Backend Deps') {
steps {
dir('apps/backend_elixir') {
sh 'mix local.rebar --force'
sh 'mix local.hex --force'
sh 'mix deps.get'
}
}
}
stage('Frontend Deps') {
steps {
dir('apps/frontend_angular') {
sh 'npm install'
}
}
}
}
}
stage('Run Tests') {
parallel {
stage('Backend Tests') {
steps {
dir('apps/backend_elixir') {
sh 'mix test'
}
}
}
stage('Frontend Tests') {
steps {
dir('apps/frontend_angular') {
// --watch=false is crucial for CI environments
sh 'npm run test -- --watch=false --browsers=ChromeHeadless'
}
}
}
}
}
stage('Build Artifacts') {
parallel {
stage('Backend Release') {
steps {
dir('apps/backend_elixir') {
// Elixir releases require this setup
sh 'mix deps.get --only prod'
sh 'MIX_ENV=prod mix release'
}
}
}
stage('Frontend Build') {
steps {
dir('apps/frontend_angular') {
// Assuming production configuration is set in angular.json
sh 'npm run build -- --configuration production'
}
}
}
}
}
// ... 后续的 Docker 打包和部署阶段
}
}
这个版本的问题显而易见:没有逻辑判断,所有阶段都会执行。
核心改造:引入变更检测逻辑
要实现增量构建,核心在于准确识别变更范围。我们决定在 Jenkinsfile
内部编写一个 Groovy 函数,该函数调用 git
命令来完成这个任务。在真实项目中,目标分支通常是 main
或 develop
,对于 Pull Request 构建,则是与目标分支进行比较。
// Jenkinsfile - 引入变更检测函数
// ... pipeline定义之前
/**
* Detects changes in specified application paths within the monorepo.
* @param targetBranch The branch to compare against, e.g., 'origin/main'.
* @return A map indicating which paths have changed, e.g., [backend: true, frontend: false]
*/
def detectChangedApps(String targetBranch = 'origin/main') {
// 默认情况下,我们假设所有应用都未变更
def changes = [backend: false, frontend: false]
// 使用 git diff 获取从目标分支到当前 HEAD 的变更文件列表
// --name-only 只输出文件名
def changedFiles = sh(
script: "git diff --name-only ${targetBranch}...HEAD",
returnStdout: true
).trim().split('\n')
echo "Detected changed files: ${changedFiles}"
// 遍历变更文件列表,检查其路径
for (file in changedFiles) {
if (file.startsWith('apps/backend_elixir/')) {
echo "Change detected in backend: ${file}"
changes.backend = true
}
if (file.startsWith('apps/frontend_angular/')) {
echo "Change detected in frontend: ${file}"
changes.frontend = true
}
// 如果根目录的 Jenkinsfile 发生变化,我们认为需要全量构建,以防流水线逻辑本身变更
if (file.startsWith('Jenkinsfile')) {
echo "Jenkinsfile changed. Triggering a full build."
changes.backend = true
changes.frontend = true
}
}
// 在一个PR中,如果没有任何可识别的应用代码变更(例如只改了README.md),
// 那么两个标志都将是false,流水线会快速结束。
// 但在实践中,如果变更集为空,可能意味着git命令有问题或这是一个全新的分支。
// 一个更鲁棒的策略是,如果分支是第一次构建,或者无法与目标分支比较,就执行全量构建。
if (currentBuild.changeSets.isEmpty() && env.BRANCH_NAME != 'main') {
echo "No changesets detected or new branch. Forcing full build for safety."
changes.backend = true
changes.frontend = true
}
return changes
}
这个 detectChangedApps
函数是整个优化的心脏。它返回一个 map,清晰地告诉我们哪个部分需要重新构建。这里的坑在于:git diff
的比较基准需要小心选择。对于合并到主干前的 PR 构建,应该是 origin/main...HEAD
。对于直接推送到主干的构建,可以是 HEAD~1...HEAD
。为了通用性,我们将其参数化。
重构 Jenkinsfile
:实现条件化 Stage
有了变更检测函数,我们就可以重构 Jenkinsfile
,在 environment
块中调用它,并将结果存储为环境变量,然后在每个 stage
的 when
指令中使用这些变量。
我们还需要考虑缓存。对于 Elixir,需要缓存 deps
和 _build
目录。对于 Angular,则是 node_modules
。Jenkins 的 cache
指令(需要 Cache Plugin)或者在 Docker Agent 中挂载卷都可以实现这一点。这里我们采用更为现代和推荐的 cache
指令。
// Jenkinsfile.optimized - 最终优化版本
// 全局变量,用于存储变更检测结果
def changes
pipeline {
agent {
// 使用一个包含所有构建工具的 Docker 镜像,确保环境一致性
docker {
image 'your-company/elixir-node-builder:latest'
args '-u root' // 某些 docker 镜像需要 root 权限来安装工具或写缓存
}
}
options {
// 在发生故障时保留最近几次构建的工作空间用于调试
preserveStashes(buildCount: 5)
}
stages {
stage('Initialize & Detect Changes') {
steps {
script {
// 在执行任何操作前,首先拉取目标分支的最新信息
sh 'git fetch origin main'
// 调用变更检测函数
changes = detectChangedApps('origin/main')
echo "Build flags: Backend=${changes.backend}, Frontend=${changes.frontend}"
}
}
}
// 使用 parallel 来并行化后端和前端的流程,当两者都需要构建时可以节省时间
stage('Build & Test Changed Applications') {
parallel {
// --- Backend Stage ---
stage('Backend CI') {
when { expression { return changes.backend } }
steps {
dir('apps/backend_elixir') {
// 使用 cache 指令缓存 Elixir 依赖和编译产物
// key 包含了 mix.lock 的 hash,当依赖变更时缓存自动失效
cache(path: 'deps', key: "elixir-deps-${hashFiles('mix.lock')}") {
sh 'mix deps.get'
}
cache(path: '_build', key: "elixir-build-${env.BRANCH_NAME}-${hashFiles('mix.lock')}") {
echo "Running Backend Tests..."
// 在CI环境中,确保数据库已准备好
// sh 'mix ecto.create'
// sh 'mix ecto.migrate'
sh 'mix test'
echo "Building Backend Release..."
sh 'mix deps.get --only prod'
sh 'MIX_ENV=prod mix release'
}
}
}
}
// --- Frontend Stage ---
stage('Frontend CI') {
when { expression { return changes.frontend } }
steps {
dir('apps/frontend_angular') {
// 缓存 node_modules
// key 包含了 package-lock.json 的 hash
cache(path: 'node_modules', key: "node-modules-${hashFiles('package-lock.json')}") {
echo "Installing Frontend Dependencies..."
sh 'npm ci' // 'npm ci' is faster and more reliable for CI
}
// Apollo Client 代码生成
// 这一步依赖于后端的 GraphQL schema,在真实项目中可能需要先从某个地方获取schema.json
// 如果 schema 在后端仓库里,那么后端变更时前端也需要重新生成代码
// 这里我们简化处理,假设 schema 是稳定的
echo "Generating Apollo GraphQL types..."
sh 'npm run generate-graphql-types' // 假设这是一个 package.json 中的脚本
echo "Running Frontend Tests..."
sh 'npm run test -- --watch=false --browsers=ChromeHeadless'
echo "Building Frontend Application..."
sh 'npm run build -- --configuration production'
}
}
}
}
}
stage('Package Artifacts') {
// 只有当有任何一个应用被构建时,才进入打包阶段
when { expression { return changes.backend || changes.frontend } }
steps {
script {
// 使用 stash 来暂存构建产物,以便在后续的 stage (可能在不同的 agent 上) 使用
if (changes.backend) {
echo "Stashing backend release..."
dir('apps/backend_elixir') {
// 将 Elixir release 的 tarball 暂存起来
def release_path = sh(returnStdout: true, script: 'ls _build/prod/rel/backend_elixir/releases/*/*.tar.gz').trim()
stash name: 'backend-release', includes: release_path
}
}
if (changes.frontend) {
echo "Stashing frontend distribution..."
dir('apps/frontend_angular') {
// 将 Angular 构建出的静态文件暂存起来
stash name: 'frontend-dist', includes: 'dist/**'
}
}
}
}
}
// 部署阶段同样应该是条件化的
stage('Deploy') {
parallel {
stage('Deploy Backend') {
when { expression { return changes.backend } }
steps {
// ... 从 stash 中取出 backend-release 并部署
echo "Deploying backend... (Implementation skipped)"
}
}
stage('Deploy Frontend') {
when { expression { return changes.frontend } }
steps {
// ... 从 stash 中取出 frontend-dist 并部署
echo "Deploying frontend... (Implementation skipped)"
}
}
}
}
}
}
流程可视化
这个经过优化的流水线逻辑可以用 Mermaid 图来清晰地表示:
graph TD A[Start] --> B{Detect Changes}; B --> C{Backend Changed?}; B --> D{Frontend Changed?}; C -- Yes --> E[Run Backend CI]; C -- No --> F[Skip Backend]; D -- Yes --> G[Run Frontend CI]; D -- No --> H[Skip Frontend]; subgraph Parallel Stages E; G; end subgraph Endpoints F; H; end E --> I{Package Backend}; G --> J{Package Frontend}; I --> K[Deploy Backend]; J --> L[Deploy Frontend]; F --> M{Check if Any Build Ran}; H --> M; M -- No --> N[End Pipeline]; K --> N; L --> N;
结果与分析
切换到新的 Jenkinsfile
后,效果是立竿见影的:
- 只修改前端代码: 流水线跳过了所有后端相关的阶段。
mix deps.get
、mix test
、mix release
这些耗时操作被完全规避。构建时间从原来的 15 分钟缩短到了 4-5 分钟。 - 只修改后端代码:
npm ci
和 Angular 的测试与构建被跳过。构建时间缩短到 6-7 分钟。 - 同时修改前后端代码: 由于
parallel
的使用,总时间约等于后端和前端中耗时较长的一方,大约在 8-10 分钟,也比原来的 15 分钟有显著提升。 - 只修改文档(非应用代码): 流水线在“Detect Changes”阶段后就快速结束,耗时不到 1 分钟。
一个常见的错误是在 detectChangedApps
中使用 git diff HEAD~1...HEAD
。这个命令只比较最近两次提交,对于一个包含了多个提交的 Pull Request,它会漏掉之前的变更。因此,使用 origin/main...HEAD
这种与目标分支比较的方式,在 PR 场景下是更稳健的选择。
局限性与未来迭代方向
尽管当前的方案极大地提升了效率,但它并非完美,依然存在一些局限和可以改进的方向。
首先,基于文件路径的变更检测逻辑相对脆弱。它无法处理更复杂的依赖关系。例如,如果我们引入了一个 libs/shared_types
目录,其中的代码被前端和后端同时依赖,那么当这个目录发生变更时,我们的脚本需要更新逻辑以触发前后端的双重构建。随着 Monorepo 变得越来越复杂,手动维护这些依赖规则会成为一个新的负担。
其次,Jenkins 本身并不是为处理 Monorepo 优化的最佳工具。像 Nx、Bazel 或 Lerna 这样的构建系统,它们内置了更强大的依赖关系图分析和受影响项目检测(affected commands)的能力。迁移到这类工具,可以让我们从手写 git diff
脚本的模式中解放出来,获得更精确、更可靠的增量构建。例如,使用 nx affected:build
命令会自动完成所有这些检测。
最后,缓存策略仍有优化空间。Jenkins 的 cache
指令依赖于 SCM 的实现,有时在不同 agent 间共享缓存可能效率不高。对于依赖项这种不常变动的内容,一个更优的策略是构建一个基础 Docker 镜像,预先安装好所有依赖,CI 流水线直接使用这个镜像,从而完全跳过 npm ci
和 mix deps.get
这两个步骤,只在依赖锁文件更新时才触发基础镜像的重建。