如何加速 CI 编译:前端与后端依赖复用实战
一次容器镜像构建,时间基本花在四件事上:
| 阶段 | 典型耗时 | 能否复用 |
|---|---|---|
| 拉基础镜像 | 10s ~ 60s | 节点上已有就很快 |
| 下载依赖 | 30s ~ 5min | ✅ 可以完全避免重复 |
| 编译源码 | 20s ~ 3min | ❌ 源码变了就必须重编 |
| 打包 / 推送镜像 | 20s ~ 2min | ✅ 层没变可整体跳过 |
其中编译源码是必须付出的成本,下载依赖是纯粹的重复劳动。CI 编译加速的全部目标,就是让"下载依赖"这一步尽可能少发生——理想状态下,一次源码提交只应该重新编译,不应该重新下载任何依赖。
还有一点需要先说清楚:如果依赖源已经是内网私服(Nexus、npmmirror 等),单次下载本身并不慢。真正的浪费在于"每次都重新下一遍"。所以加速有两条互补的路线:一是让单次下载更快(换内网源),二是让下载干脆不要重复发生(本地复用)。
一、两条加速路线¶
- 源头加速是"一次性投入、全局受益":把公网拉包改成内网拉包,配置一次,所有构建都受益。
- 本地复用是"持续受益":依赖一旦落到本地,下次构建直接拿来用,连内网都不用访问。
两条都要做,但优先级不同:先把源换到内网(改动最小、收益最直接),再解决复用(需要理解构建流程,收益更大)。
二、前端加速¶
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
对应地在构建镜像里也要指定,否则容器内仍然走公网:
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,不同项目的层会互相顶掉,命中率被无谓拉低。
验证缓存是否生效,看构建日志里有没有这行:
出现即代表该层被跳过;如果日志里仍在输出 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/
因此卷的布局是这样,各自独立、互不干扰:
附带的好处是:/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 语法:
所以 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 本地仓库放到持久卷,跨构建复用已下载的依赖:
settings.xml 里的本地仓库路径与之对应:
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 的构建阶段加一行探针,跑一次构建看日志:
- 输出
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。解决办法和前端一样——按项目分目录:
需要在 CI 侧把这两个变量导出到环境里(${env.*} 由 Maven 插值):
两点注意:
- 目录名不要中途更改。已经下好的本地仓库如果因为路径变了而读不到,等于白下一遍;
- 如果走 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 的依赖打进镜像既拖慢推送又毫无意义:
反过来,如果是 3.2 那种"依赖留在镜像层里靠层缓存复用"的做法,就不要加这个参数——它会把依赖挡在快照之外,层缓存里永远存不下依赖。
四、kaniko 层缓存参数速查¶
| 参数 | 作用 |
|---|---|
--cache=true |
启用层缓存(总开关,必须显式写) |
--cache-repo=<repo> |
缓存存放的镜像仓库,建议按项目名隔离 |
--snapshot-mode=redo |
更精确的文件系统快照(略慢但更准) |
--compressed-caching=false |
处理大层时更快,代价是内存占用略高 |
--use-new-run |
使用新的 RUN 执行方式,减少不必要快照 |
--ignore-path=<path> |
快照时忽略该路径(用于卷挂载场景) |
缓存命中与否看日志:
五、收益对照¶
| 场景 | 优化前 | 优化后 | 手段 |
|---|---|---|---|
| 前端依赖安装(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 |
六、落地清单¶
按投入产出比排序:
- 源指向内网:前端
.npmrc/ 后端<mirrors>,改动最小、全局受益; - 依赖描述文件分层:
package.json+ lock / 各模块pom.xml必须在COPY . .之前; - 打开
--cache=true,并把--cache-repo按项目名隔离; - 需要持久化时先确认挂载点位置:编译在 agent 容器里才挂得有意义(见 3.4);
- 多项目共用一个卷:前端用
subPath,后端用${env.GROUP}/${env.PROJECT_NAME}分目录; - 前端有持久化目录时改用
npm install,没有时继续用npm ci; - 后端加上
-T1C、-pl -am、跳过测试,压掉编译本身的开销。
七、常见坑¶
- 分层写对了,却没开
--cache=true——最容易漏的一步,Dockerfile 再规范也不会命中缓存; - 卷挂在 agent 容器上,编译却在 Dockerfile 里执行——挂载与编译不在同一个文件系统视图,等于没挂(3.4);
--cache-repo全局共用一个——不同项目的层互相顶替,命中率被无谓拉低;- 多个构建共用同一个本地仓库目录——互相抢锁,NFS 上可能直接
Stale file handle; dependency:go-offline没带|| true——父 POM 解析异常时构建偶发失败;- 有持久化目录时仍用
npm ci——每次都先删掉 node_modules,缓存白做; - 用 BuildKit 专属语法——
RUN --mount=type=cache、heredoc 在 kaniko 下不生效; - 把带密码的文件快照进镜像层——
settings.xml里用${env.*}插值,凭据交给 CI 注入; - 改了本地仓库路径——PVC 上已有的依赖读不到了,下次构建等于冷启动。
- 构建环境连不上公网 npm 源——kaniko 里访问
registry.npmjs.org会ETIMEDOUT,install 命令要显式带--registry=https://registry.npmmirror.com,别只依赖.npmrc; - 基础镜像里没有 node/npm——
nginx:alpine这类镜像直接跑RUN npm install会exit status 127(npm: not found)。要用node:*-alpine当构建阶段,多阶段再出 nginx 镜像; - catch 块里
throw new RuntimeException(...)——脚本沙箱没批准这个构造签名会抛RejectedAccessException,把真正的失败原因整个顶掉。改用 Pipeline 的error("..."),别用throw; - 把 subPath 误当成"卷里的 node_modules 子目录"——
subPath: web-a挂的是web-a/这个目录本身,卷里存的是依赖内容,没有node_modules这一层(见 2.5)。
八、一句话总结¶
让依赖只在真正需要时下载一次: 源走内网(下载快), 描述文件分层 + 打开层缓存(同一状态不重装), 编排放进挂了卷的容器(不同状态也能复用)。