Skip to content

如何加速 CI 编译:前端与后端依赖复用实战

一次容器镜像构建,时间基本花在四件事上:

阶段 典型耗时 能否复用
拉基础镜像 10s ~ 60s 节点上已有就很快
下载依赖 30s ~ 5min ✅ 可以完全避免重复
编译源码 20s ~ 3min ❌ 源码变了就必须重编
打包 / 推送镜像 20s ~ 2min ✅ 层没变可整体跳过

其中编译源码是必须付出的成本,下载依赖是纯粹的重复劳动。CI 编译加速的全部目标,就是让"下载依赖"这一步尽可能少发生——理想状态下,一次源码提交只应该重新编译,不应该重新下载任何依赖。

还有一点需要先说清楚:如果依赖源已经是内网私服(Nexus、npmmirror 等),单次下载本身并不慢。真正的浪费在于"每次都重新下一遍"。所以加速有两条互补的路线:一是让单次下载更快(换内网源),二是让下载干脆不要重复发生(本地复用)。


一、两条加速路线

依赖成本 = 单次下载耗时 × 重复次数
            │                │
            │                └── 本地复用:持久卷(PVC)/ 镜像层缓存
            └── 源头加速:内网私服 / 镜像源
  • 源头加速是"一次性投入、全局受益":把公网拉包改成内网拉包,配置一次,所有构建都受益。
  • 本地复用是"持续受益":依赖一旦落到本地,下次构建直接拿来用,连内网都不用访问。

两条都要做,但优先级不同:先把源换到内网(改动最小、收益最直接),再解决复用(需要理解构建流程,收益更大)。


二、前端加速

2.1 源头:把 registry 换到内网或国内镜像

前端包的下载量很大(一个中等项目动辄上千个包),源的速度直接决定安装耗时。项目级的 .npmrc:

# 只对本工程生效,可按需移除
registry=https://registry.npmmirror.com

# 拉取健壮性:快速失败 + 多次重试,避免单个包卡死整个安装
fetch-retries=8
fetch-retry-factor=2
fetch-retry-mintimeout=10000
fetch-retry-maxtimeout=60000
fetch-timeout=90000

对应地在构建镜像里也要指定,否则容器内仍然走公网:

ENV COREPACK_NPM_REGISTRY=https://registry.npmmirror.com

2.2 分层:把依赖描述文件放在源码前面

这是收益最大、最容易被忽略的一条。Dockerfile 的层缓存原理是:

一条指令的缓存是否命中,取决于这条指令之前的所有文件系统状态是否变化。

所以只要 COPY . .(全量源码)出现在 install 之前,任意一行代码改动都会让依赖安装层缓存失效。

正确的分层顺序:

WORKDIR /app

# 第一步:只复制依赖描述文件
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./

# 第二步:安装依赖(这一步的缓存只受上面几个文件影响)
RUN pnpm install --frozen-lockfile --ignore-scripts

# 第三步:复制源码并构建(源码变更只影响这两步)
COPY . ./
RUN pnpm run build

这样只要 package.json / lock 文件没变,第二层永远命中缓存,源码怎么改都不会触发重新安装。

几个细节:

  • --ignore-scripts 跳过 husky 之类的生命周期脚本——容器里没有 git 环境,这些脚本要么报错要么浪费时间;
  • --frozen-lockfile 保证按 lock 文件精确安装,不会因为 package.json 与 lock 不一致而偷偷改动依赖;
  • pnpm-workspace.yaml、.npmrc 这类会影响依赖解析的文件也要一起 COPY 到前面(漏了会让缓存命中不稳定)。

2.3 打开 kaniko 的层缓存开关

Dockerfile 分层写对了,还需要构建工具允许使用缓存。这是最常漏的一步:命令里不带 --cache=true,前面所有分层工作等于白做。

/kaniko/executor \
    --dockerfile Dockerfile \
    --context `pwd` \
    --destination ${IMAGEURL} \
    --use-new-run \
    --snapshot-mode=redo \
    --compressed-caching=false \
    --cache=true \
    --cache-repo=${env.HARBOR_ADDR}/cicd/kaniko-cache-${projectName.toLowerCase()} \

两个关键参数:

  • --cache=true:总开关,没有它其余缓存相关的参数都不生效;
  • --cache-repo=<harbor>/cicd/kaniko-cache-<项目名>:缓存写在哪个仓库。必须按项目隔离,如果所有项目共用一个 cache repo,不同项目的层会互相顶掉,命中率被无谓拉低。

验证缓存是否生效,看构建日志里有没有这行:

Using caching version of cmd: RUN pnpm install --frozen-lockfile --ignore-scripts

出现即代表该层被跳过;如果日志里仍在输出 Progress: resolved ... 的长串下载进度,说明缓存没命中。

层缓存是整层粒度:一旦失效就是全量

这里有个必须理解的边界——层缓存只提供"整层跳过",不提供"部分复用":

  • 命中:整条 RUN npm install 被跳过,安装进程根本不启动;
  • 不命中:整条指令从头执行。而它执行时的初始文件系统来自上一层——也就是 COPY package.json ... ./ 那一层,那里只有依赖描述文件,没有 node_modules。

所以依赖只要动了一个包(lock 变了,或者 Dockerfile 里这条指令本身改了),层缓存就完全帮不上忙,npm install 依然要从零下载全部依赖。层缓存省下的只是"依赖完全没变"这一种情况。

2.4 持久化:让依赖变动时只下增量

持久化和层缓存是互补关系,它补的正是上面那个缺口:

情况 只有层缓存 层缓存 + 持久化目录
lock 与 Dockerfile 都没变 整层跳过,安装进程不启动 整层跳过,安装进程不启动
lock 变了(增删了依赖) 全量重下 只下新增 / 变更的包(增量)
Dockerfile 里安装指令改了 全量重下 只下新增 / 变更的包(增量)

原因在于:持久卷上的 node_modules 是 Pod 挂载,跨构建一直存在,每次 npm install 执行时都能看到已经装好的包,于是只补齐差异;而它又不参与镜像层快照,不会把几百 MB 依赖打进镜像。

做法是把依赖目录挂到持久卷上,在 Jenkins 的 Kubernetes 插件里通过 podTemplate 挂载:

podTemplate(
    label: label,
    cloud: buildConfig.cloud,
    containers: [ /* kaniko 等容器 */ ],
    volumes: [
        persistentVolumeClaim(claimName: 'npm-cache', mountPath: '/app/node_modules'),
    ],
) {
    node(label) { /* ... */ }
}

配套把 Dockerfile 的安装命令从 npm ci 换成 npm install,原因见 2.6。

2.5 多个前端项目共用一个卷:用 subPath 分目录

一个集群里通常有多个前端项目,如果都往 /app/node_modules 挂同一个卷的根目录,它们的依赖会互相覆盖。正确做法是一个 PVC、按项目分个子目录:

spec:
  containers:
  - name: kaniko
    volumeMounts:
    - name: npm-node-modules
      mountPath: /app/node_modules
      subPath: web-a          # 关键:只挂卷里的这个子目录
  volumes:
  - name: npm-node-modules
    persistentVolumeClaim:
      claimName: npm-cache

子目录不需要预先创建,kubelet 会按需建。

这里有个容易画错的地方:卷里并没有 node_modules 这一层。subPath: web-a 是把卷的 web-a/ 整个目录当作 /app/node_modules 挂进去,所以 web-a/ 里存的是 node_modules 的内容(各依赖包),而不是一个叫 node_modules 的子目录:

容器视角                      卷里的真实结构
/app/node_modules/        ≡   npm-cache/web-a/
   ├── .package-lock.json      ├── .package-lock.json
   └── ms/                     └── ms/
   └── vue/                    └── vue/

因此卷的布局是这样,各自独立、互不干扰:

npm-cache/
├── web-a/     ← 这个目录本身就是容器里的 node_modules
├── web-b/     ← 同上
└── web-c/

附带的好处是:/app/node_modules 是挂载点,kaniko 不会把它快照进镜像层,几百 MB 的依赖不会污染产物镜像。

实测:一个 PVC、两个前端,靠 subPath 互不干扰

用两个只声明了一个相同依赖(ms)的前端项目各跑一次完整构建,构建完看卷里的实际内容:

npm-cache/
├── web1/                        web2/
│   ├── .package-lock.json       ├── .package-lock.json
│   └── ms/                      └── ms/
│       ├── license.md           │   ├── license.md
│       ├── package.json         │   ├── package.json
│       ├── index.js             │   ├── index.js
│       └── readme.md            │   └── readme.md

结构一样是因为两个项目的依赖声明一模一样;关键是它们是两份独立的副本,不是同一份数据被引用两次:

文件 web1 inode web2 inode
.package-lock.json 14 25
ms/package.json 22 17
ms/index.js 21 16
ms/readme.md 24 19

inode 全不同,.package-lock.json 的 md5 也不同(lock 里记了各自的项目名)。交叉检查 web1/ 下没有 web2 的文件、web2/ 下没有 web1 的文件,隔离成立。

验证方法很简单,构建完起一个只读 Pod 把卷挂出来看即可(不要在同一个 Pod 里同时挂卷根目录和 subPath,RWO 卷会让 kubelet 卡在 ContainerCreating):

volumeMounts:
- { name: cache, mountPath: /cache }        # 挂卷根目录,只为查看布局
volumes:
- { name: cache, persistentVolumeClaim: { claimName: npm-cache } }
ls -a /cache/web1
find /cache/web1 /cache/web2 | sort
stat -c '%n inode=%i' /cache/web1/ms/* /cache/web2/ms/*

如果使用的 Jenkins Kubernetes 插件版本的 persistentVolumeClaim() DSL 只支持 mountPath / claimName / readOnly 而没有 subPath,可以用 podTemplate 的 yaml 参数直接写原生 Pod 片段,并显式指定 yamlMergeStrategy: merge()——该参数默认值是 override()(整体替换插件生成的 Pod 定义),只写 yaml 而不改策略会把其他容器和挂载全部冲掉。

PVC 的 accessMode 会限制并发。 npm-cache 若是 ReadWriteOnce,同一个卷只能被一个节点上的 Pod 挂载:单节点集群上多个 agent Pod 可以同时挂(都在同一节点),但集群一旦扩到多节点,第二个构建的 Pod 会卡在 ContainerCreating / Multi-Attach error。要支持多节点并发构建,卷必须建成 ReadWriteMany。同理,别在同一个 Pod 里同时挂卷根目录和某个 subPath,RWO 卷下 kubelet 会卡住不起容器。

2.6 npm ci 与 npm install 的选择

npm ci npm install
node_modules 处理 每次先整体删除再重建 增量对比,只补缺失/变更的包
依赖已完整时 仍然全量重装 输出 up to date in 6s 直接返回
适用场景 无任何缓存的干净环境 依赖目录被持久化复用

实测对照(同一项目,同一台构建节点):

构建 依赖安装 说明
第 1 次 added 1274 packages in 33s 无缓存,npm ci 全量
第 2 次 added 1295 packages in 36s 无缓存,npm ci 全量
第 3 次 added 1295 packages in 34s 打开层缓存,首次填充
第 4 次 added 1295 packages in 24s 层缓存命中
第 5 次 up to date in 6s npm install + node_modules 落在 PVC 上

结论:有持久化目录时用 npm install,没有时用 npm ci。ci 的"每次删掉重装"在有缓存时恰恰是负优化。

2.7 pnpm 的差异

pnpm 不用 node_modules 铺平安装,而是维护一个内容寻址的 store,再用硬链接把包链到 node_modules 里。这带来两个影响:

  • 安装更快、磁盘占用更小(同一个包在多个项目间只存一份);
  • 想复用下载内容,要持久化的是 store 目录(存放包内容的中央仓库),不是 node_modules。store 的位置可以自由指定:
# 1. 命令行参数
pnpm install --store-dir=/pnpm-store

# 2. 写进 .npmrc(项目级,或 pnpm config set 写进用户级配置)
store-dir=/pnpm-store

# 3. 容器里用环境变量(PNPM_HOME 同时决定全局 bin 位置,store 默认落在其下)
ENV PNPM_HOME=/pnpm

顺带区分一个容易混的概念:node_modules 里的 .pnpm 虚拟目录位置由 virtual-store-dir 控制(默认就是 node_modules/.pnpm),通常不需要改。

  • 但有个硬性前提:pnpm 靠在 store 和 node_modules 之间建立硬链接来省空间,而硬链接只能在同一文件系统内建立。如果 store 挂在卷上、node_modules 却落在容器根文件系统(overlay)里,pnpm 会退化成复制并打印警告——不报错,但既慢又占空间,持久化就失去意义了。

所以真要在 CI 里持久化 store,正确姿势是让两者落在同一个卷上:

volumeMounts:
- name: pnpm-volume
  mountPath: /pnpm-store
  subPath: store
- name: pnpm-volume
  mountPath: /app/node_modules
  subPath: node_modules

同一个 PVC 的两个 subPath,底层是同一个文件系统,硬链接才成立。

但要注意:在镜像内构建的场景下,这个挂载大概率不生效(原因见第四章 4.3)。而 kaniko 也不支持 BuildKit 的 RUN --mount=type=cache 语法:

# 这种写法 kaniko 不支持,会被忽略并告警
RUN --mount=type=cache,target=/pnpm-store pnpm install

所以 pnpm 项目最划算的做法是做好 2.2 的分层 + 2.3 的层缓存——lock 文件不变时整个 install 层被跳过,效果和"store 挂卷"接近,但没有额外复杂度。

选型:不要为了缓存更换包管理器

一个常见的想法是:既然 npm 只要挂 node_modules 就能增量,那全都用 npm 不就完了。单看 CI 缓存,这在 npm 项目上确实成立:

npm pnpm
持久化要挂什么 只挂 node_modules 即可(自包含的真实文件) 必须同时挂 store + node_modules,且需同一文件系统
只挂 node_modules 的后果 正常,依赖就在里面 无法建硬链接 → 退化成复制并告警,丢掉 pnpm 的空间优势
只靠层缓存 有效 有效

但包管理器决定的不只是安装方式,还有依赖树结构和锁文件格式:

  • 已经用 pnpm 的项目,pnpm-lock.yaml 与 package-lock.json 不是一回事。CI 里改跑 npm install 会忽略前者、按 package.json 重新解析依赖树——本地开发用的是 pnpm 的树、CI 得到的是 npm 的树,构建产物可能因此不一致(peer deps、hoisting 差异最容易出这类问题)。要换就得连锁文件一起换,并重新验证依赖树。
  • pnpm 的价值本来也不在 CI 缓存,而在依赖严格隔离(不扁平化 node_modules,避免幽灵依赖)、多项目共享 store 省磁盘、monorepo 支持。

所以结论是:包管理器由项目本身决定,CI 缓存方案去适配它,而不是为了缓存把它换掉。已经用 pnpm 的项目,把分层和层缓存做好就行。


三、后端加速

后端(Maven / Gradle)的依赖体积比前端更大,单个 Spring Boot 项目首次拉全量依赖通常要几分钟,收益也更明显。

3.1 源头:内网 Nexus 镜像

settings.xml 里把所有仓库指向内网 Nexus:

<mirrors>
  <mirror>
    <id>nexus-public</id>
    <name>内部 Nexus</name>
    <url>https://nexus.internal/repository/maven-public/</url>
    <!-- 除了发布仓库,其余全部走镜像 -->
    <mirrorOf>*,!maven-releases,!maven-snapshots</mirrorOf>
  </mirror>
</mirrors>

私服通常不允许匿名读,凭据用环境变量插值,不要写进仓库:

<servers>
  <server>
    <id>nexus-public</id>
    <username>${env.NEXUS_USERNAME}</username>
    <password>${env.NEXUS_PASSWORD}</password>
  </server>
</servers>

CI 侧用 Jenkins 凭据注入(Maven 支持 ${env.*} 插值,所以变量在容器环境里可见即可):

withCredentials([usernamePassword(credentialsId: 'nexus-account',
        usernameVariable: 'NEXUS_USERNAME',
        passwordVariable: 'NEXUS_PASSWORD')]) {
    sh "mvn -B -s settings.xml clean package -DskipTests"
}

这样凭据只存在于运行时环境变量里,settings.xml 可以安全地提交到代码仓库。

3.2 分层:POM 先行 + dependency:go-offline

和在 Dockerfile 里构建的前端项目一样,Maven 项目也需要把"依赖解析"和"源码编译"分开:

WORKDIR /build

# 层1:只拷贝 POM(根 POM + 各模块 POM),先预取依赖
COPY pom.xml .
COPY module-a/pom.xml module-a/
COPY module-b/pom.xml module-b/
COPY module-c/pom.xml module-c/
RUN mvn -B dependency:go-offline -Dmaven.repo.local=/root/.m2/repository || true

# 层2:拷贝全部源码并构建目标模块
COPY . .
RUN mvn -B -pl module-a -am -DskipTests -Dmaven.deploy.skip=true package

要点:

  • dependency:go-offline 会按 POM 把所有依赖(含插件)预取到本地仓库。因为这一层只依赖 POM,任何源码改动都不会让它失效;
  • || true 是必须的:go-offline 在父 POM 或某些插件的解析上可能非零退出,但它本身是"尽力预取",失败不应该让构建挂掉——真正的依赖补齐会在层2的 package 里自动完成;
  • 新增模块时必须在层1补一行 COPY <新模块>/pom.xml。漏了的话 -pl <新模块> -am 会因为找不到模块直接报错。建议在模板里写一行注释提醒后来者;
  • -pl <模块> -am 只构建目标模块和它依赖的模块,不必编译整个 reactor。

3.3 持久化:把本地仓库挂到卷上

把 Maven 本地仓库放到持久卷,跨构建复用已下载的依赖:

volumes: [
    persistentVolumeClaim(claimName: 'maven-repo', mountPath: '/root/.m2'),
]

settings.xml 里的本地仓库路径与之对应:

<localRepository>${user.home}/.m2/repository</localRepository>

3.4 关键前提:挂载必须和"执行编译的进程"在同一个文件系统视图里

这是最容易踩空的一步,值得单独说明。

同一个"挂 PVC + 编译"的组合,位置不同,效果完全相反:

编译在哪执行 卷挂在哪 是否生效
agent Pod 的容器里(container('maven') { sh 'mvn ...' }) 同一个 Pod 的容器 ✅ 生效
Dockerfile 内部(kaniko 构建 FROM maven 阶段) agent Pod 的 kaniko 容器 ❌ 基本不生效

原因在于:kaniko 构建镜像时,RUN mvn ... 是在它自己从 registry 拉取并解包的基础镜像 rootfs 里执行的(chroot 到那个 rootfs),而不是在 agent 容器的文件系统里。Pod 上挂载的 PVC 属于 agent 容器的挂载命名空间,两者并不共享同一个 /root/.m2。

所以会出现这种典型现象:配置了持久卷、settings.xml 也写对了,但每次构建依赖还是从头下载——因为编译发生在镜像内部,那个 /root/.m2 是基础镜像里的空目录。

判断方法:在 Dockerfile 的构建阶段加一行探针,跑一次构建看日志:

RUN du -sh /root/.m2/repository 2>/dev/null || echo "NO_CACHE_VOLUME"
  • 输出 NO_CACHE_VOLUME 或只有几十 KB → 卷没生效,编译在镜像内,走 3.5;
  • 输出几百 MB → 卷已生效,那么重点是 3.6 的隔离问题。

3.5 方案:把编译从镜像内搬到 agent 容器

要让持久卷真正发挥作用,就得让执行 mvn 的进程和挂载点处在同一个容器里。做法是在 agent Pod 里增加一个 maven 容器,编译阶段在它内部执行,kaniko 只负责把产物打成镜像:

podTemplate(
    label: label,
    containers: [
        containerTemplate(name: 'kaniko', image: '<kaniko 镜像>', command: 'cat', ttyEnabled: true),
        containerTemplate(
            name: 'maven',
            image: buildConfig.code_images,   // 按项目选 JDK 版本的 maven 镜像
            command: 'cat',
            runAsUser: '0',
            runAsGroup: '0',
            ttyEnabled: true,
        ),
    ],
    volumes: [
        persistentVolumeClaim(claimName: 'maven-repo', mountPath: '/root/.m2'),
    ],
) {
    node(label) {
        stage('代码编译') {
            container('maven') {                       // ← 编译在挂载了 PVC 的容器里
                sh "mvn -B -s settings.xml package -pl <模块> -am -DskipTests"
            }
        }
        stage('镜像构建推送') {
            container('kaniko') { /* 只 COPY 编译产物 */ }
        }
    }
}

此时 Dockerfile 退化成单阶段脚本,不需要 maven 基础镜像,也不需要 Nexus 凭据:

FROM <jre 基础镜像>

WORKDIR /app
COPY --from=builder /build/<模块>/target/*.jar app.jar
# ... 运行时配置

ENTRYPOINT ["java", "-jar", "/app/app.jar"]

(如果是多阶段构建,--from=builder 的部分可以整体删掉,改为从构建上下文 COPY 已编译好的 jar。)

收益:依赖真正落到持久卷上,不只是"同一 lock 命中层缓存",而是跨分支、跨 POM 变更都能复用大部分依赖。

代价:需要准备 maven 构建镜像、在共享库里写一个编译函数、并在编排侧加一个容器定义。属于"改动一次、长期受益"的投入。

只有在确定不需要 maven 容器时,才用 needMavenBuild 这类判断按需注入,避免所有项目都多拉一个镜像。

3.6 隔离:本地仓库按项目分目录

多个后端服务同时构建时,如果共用同一个 /root/.m2/repository,会互相抢 Maven 的文件锁,在 NFS 类存储上还可能直接报 Stale file handle。解决办法和前端一样——按项目分目录:

<localRepository>${user.home}/.m2/${env.GROUP}/${env.PROJECT_NAME}/repository</localRepository>

需要在 CI 侧把这两个变量导出到环境里(${env.*} 由 Maven 插值):

env.GROUP = groupName
env.PROJECT_NAME = projectName

两点注意:

  • 目录名不要中途更改。已经下好的本地仓库如果因为路径变了而读不到,等于白下一遍;
  • 如果走 3.5 的"agent 内编译"路线,配合单次 reactor 构建(不 install 到本地仓库),本地仓库会退化成只读依赖缓存,多任务并发时连锁都不会抢。

3.7 编译本身的开销

依赖搞定之后,还能压一压编译时间:

参数 作用
-T1C 按 CPU 核数并行编译各模块(多模块项目收益明显)
-pl <模块> -am 只构建目标模块及其依赖,不碰整个 reactor
-Dmaven.test.skip=true 跳过测试的编译与执行(若 CI 有独立测试阶段)
-o 离线模式,本地仓库完整时可用,彻底避免网络访问
-Dmaven.deploy.skip=true 只做镜像时跳过 deploy 阶段

3.8 不要把缓存目录写进镜像层

如果卷挂载确实生效(3.5 的场景),要避免把卷里的内容被 kaniko 快照进镜像层——几百 MB 的依赖打进镜像既拖慢推送又毫无意义:

--ignore-path=/root/.m2

反过来,如果是 3.2 那种"依赖留在镜像层里靠层缓存复用"的做法,就不要加这个参数——它会把依赖挡在快照之外,层缓存里永远存不下依赖。


四、kaniko 层缓存参数速查

参数 作用
--cache=true 启用层缓存(总开关,必须显式写)
--cache-repo=<repo> 缓存存放的镜像仓库,建议按项目名隔离
--snapshot-mode=redo 更精确的文件系统快照(略慢但更准)
--compressed-caching=false 处理大层时更快,代价是内存占用略高
--use-new-run 使用新的 RUN 执行方式,减少不必要快照
--ignore-path=<path> 快照时忽略该路径(用于卷挂载场景)

缓存命中与否看日志:

Using caching version of cmd: RUN pnpm install --frozen-lockfile --ignore-scripts

五、收益对照

场景 优化前 优化后 手段
前端依赖安装(lock 未变) 30s ~ 36s 全量下载 依赖层整体跳过,安装进程不启动 分层 + --cache=true
前端依赖安装(lock 变了) 全量重下 只下新增 / 变更的包 持久化目录 + npm install
前端依赖安装(实测) 33s 全量 up to date in 6s 层失效 + npm install + PVC
后端依赖解析(POM 未变) 每次从私服全量拉取 依赖层命中,只剩编译 POM 分层 + go-offline
后端依赖解析(agent 内编译) — 跨分支复用本地仓库 maven 容器 + PVC
基础镜像拉取 每次从公网拉 节点上已有则秒级 内网 Harbor

六、落地清单

按投入产出比排序:

  1. 源指向内网:前端 .npmrc / 后端 <mirrors>,改动最小、全局受益;
  2. 依赖描述文件分层:package.json + lock / 各模块 pom.xml 必须在 COPY . . 之前;
  3. 打开 --cache=true,并把 --cache-repo 按项目名隔离;
  4. 需要持久化时先确认挂载点位置:编译在 agent 容器里才挂得有意义(见 3.4);
  5. 多项目共用一个卷:前端用 subPath,后端用 ${env.GROUP}/${env.PROJECT_NAME} 分目录;
  6. 前端有持久化目录时改用 npm install,没有时继续用 npm ci;
  7. 后端加上 -T1C、-pl -am、跳过测试,压掉编译本身的开销。

七、常见坑

  1. 分层写对了,却没开 --cache=true——最容易漏的一步,Dockerfile 再规范也不会命中缓存;
  2. 卷挂在 agent 容器上,编译却在 Dockerfile 里执行——挂载与编译不在同一个文件系统视图,等于没挂(3.4);
  3. --cache-repo 全局共用一个——不同项目的层互相顶替,命中率被无谓拉低;
  4. 多个构建共用同一个本地仓库目录——互相抢锁,NFS 上可能直接 Stale file handle;
  5. dependency:go-offline 没带 || true——父 POM 解析异常时构建偶发失败;
  6. 有持久化目录时仍用 npm ci——每次都先删掉 node_modules,缓存白做;
  7. 用 BuildKit 专属语法——RUN --mount=type=cache、heredoc 在 kaniko 下不生效;
  8. 把带密码的文件快照进镜像层——settings.xml 里用 ${env.*} 插值,凭据交给 CI 注入;
  9. 改了本地仓库路径——PVC 上已有的依赖读不到了,下次构建等于冷启动。
  10. 构建环境连不上公网 npm 源——kaniko 里访问 registry.npmjs.org 会 ETIMEDOUT,install 命令要显式带 --registry=https://registry.npmmirror.com,别只依赖 .npmrc;
  11. 基础镜像里没有 node/npm——nginx:alpine 这类镜像直接跑 RUN npm install 会 exit status 127(npm: not found)。要用 node:*-alpine 当构建阶段,多阶段再出 nginx 镜像;
  12. catch 块里 throw new RuntimeException(...)——脚本沙箱没批准这个构造签名会抛 RejectedAccessException,把真正的失败原因整个顶掉。改用 Pipeline 的 error("..."),别用 throw;
  13. 把 subPath 误当成"卷里的 node_modules 子目录"——subPath: web-a 挂的是 web-a/ 这个目录本身,卷里存的是依赖内容,没有 node_modules 这一层(见 2.5)。

八、一句话总结

让依赖只在真正需要时下载一次: 源走内网(下载快), 描述文件分层 + 打开层缓存(同一状态不重装), 编排放进挂了卷的容器(不同状态也能复用)。