构建 Elixir Phoenix 应用的高效 Jenkins CI 流水线实战


团队新启动的 Elixir Phoenix 项目,在 Jenkins 上的 CI 构建时间已经悄然攀升到了12分钟。对于一个追求快速迭代的团队而言,这是个无法容忍的数字。每次提交后,开发者都需要花费一杯咖啡的时间才能等到测试结果,这严重拖慢了反馈循环。问题的根源很清晰:每一次构建都是一个全新的、纯净的环境,mix deps.getmix 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/.mixmix 默认会在这里缓存 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 引入了几个关键概念:

  1. 缓存键 cacheKey: 使用 mix.lock 的 SHA256 哈希值生成。当依赖更新时,mix.lock 改变,哈希值随之改变,导致缓存失效,触发重新构建,这正是我们期望的行为。
  2. Restore Cache 阶段: 在构建早期,尝试根据缓存键查找并解压缓存包。
  3. 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 项目做一些配置:

  1. 数据库配置 (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
  2. 测试标记 (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秒 左右,成功达成并超越了既定目标。

方案的局限性与未来展望

尽管当前的方案极大地提升了效率,但它并非完美,依然存在一些局限性:

  1. 缓存存储的单点问题: 目前的缓存是存储在 Jenkins Master 的本地磁盘上。这在单节点环境下工作良好,但如果 Jenkins 是多 Agent 的集群架构,这种本地缓存会失效,因为下一次构建可能会调度到没有缓存的另一个 Agent 节点上。届时,需要引入分布式缓存方案,例如使用 S3、Artifactory 或 Nexus 作为缓存后端。

  2. 缓存键的粒度: 当前的缓存键仅基于 mix.lock。这意味着即使只修改了一个项目源文件,只要依赖不变,缓存就不会失效,这依赖于 mix compile 的增量编译能力。在某些复杂场景下,可能需要更精细的缓存策略,例如将缓存键与 lib 目录内容的哈希值结合,但这会显著增加流水线的复杂性。

  3. Docker-in-Docker (DinD) 的复杂性: 在 Jenkins Docker Agent 中再启动 Docker 容器(如 PostgreSQL)需要特定的配置,可能涉及挂载 Docker socket,存在一定的安全风险和配置复杂性。在 Kubernetes 环境下,使用 Pod Template 定义一个包含应用容器和数据库容器的 Pod 是一个更云原生的解决方案。

未来的迭代方向可以考虑将流水线迁移到 Jenkins on Kubernetes,利用 Pod 作为构建环境,可以更优雅地处理服务依赖和资源隔离。同时,探索使用 Elixir 1.11+ 引入的编译器追踪功能,或许能实现更智能、更细粒度的编译缓存策略。


  目录