聊聊我在二线厂搭CI流水线被坑到怀疑人生的那些日子
说来惭愧,入职这家二线互联网公司刚满两个月,我就被组里的CI/CD流水线折磨得够呛。之前在上家公司主要搞React性能优化,天天跟Lighthouse分数、首屏时间、长列表渲染死磕,偶尔翻翻Vue3和React的源码,自认为对前端工程化还算有点心得。结果到了新团队,leader第一周就甩给我一个任务:"把咱们组的持续集成流程优化一下,最近发版太慢了,开发怨声载道。"
我当时内心OS是:我是写前端的啊,让我搞CI?但转念一想,这不正好是了解公司整体技术架构的好机会嘛,而且之前研究开源项目的时候,对GitHub Actions、Jenkins这些工具也略有耳闻,就硬着头皮接了。
两个月下来,踩的坑比写两年业务代码踩的都多。今天就把这些血泪经验分享出来,希望能帮到同样在CI泥潭里挣扎的兄弟们。
先说说我们团队的现状
我们组大概十几个人,前端5个,后端6个,测试2个,还有1个运维大哥(对,就一个,还是兼职的)。技术栈的话,前端是React + TypeScript,后端是Java微服务,部署在阿里云的K8s上。
来之前我就发现一个问题:每次发版,前端同学都得手动跑一遍构建、手动上传OSS、手动去Jenkins上点构建、手动通知测试去验证。一套流程下来,顺利的话半小时,不顺利的话一两个小时就没了。更要命的是,有时候忘了跑lint,代码合到main分支之后才发现有格式问题,整个团队都得停下来等修复。
leader开会的时候说:"咱们得搞个正经的CI,不能再这么原始人了。"
于是这个光荣而艰巨的任务,就落到了我这个刚入职两个月、连公司WiFi密码都还没记全的新人头上。
选型:为什么选了GitHub Actions
说实话,刚接到任务的时候,我脑子里闪过好几个选项:Jenkins、GitLab CI、GitHub Actions、CircleCI,甚至考虑过自建一套基于Drone的方案。
| 工具 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| Jenkins | 生态成熟,插件丰富 | 配置复杂,维护成本高 | 大型企业,有专职运维 |
| GitLab CI | 和GitLab深度集成 | 我们代码在GitHub上 | GitLab用户 |
| GitHub Actions | 配置简单,社区活跃 | 私有化部署贵 | 中小团队,GitHub用户 |
| CircleCI | 速度快,缓存好 | 免费额度有限 | 预算充足的小团队 |
我们代码托管在GitHub Enterprise上,团队规模不大,而且运维大哥明确表示他没有精力维护一个Jenkins实例(原话是:"你别给我找事,我现在一个人管三个环境的K8s已经要猝死了")。综合考虑下来,GitHub Actions成了最合适的选择。
其实我私下里更想用Jenkins,因为之前在上家公司用过,比较熟。但仔细想了想,Jenkins的Groovy脚本写起来真的太反人类了,而且我们这种规模,用Jenkins有点杀鸡用牛刀。GitHub Actions的YAML配置虽然也有坑,但至少可读性好很多。
第一版流水线:天真地以为一天就能搞定
刚开始的时候,我信心满满,觉得不就是跑几个命令嘛,写个workflow文件就完事了。于是花了半天时间,写出了第一版配置:
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm install
- name: Run lint
run: npm run lint
- name: Run tests
run: npm test
- name: Build
run: npm run build
- name: Deploy to OSS
run: |
npm install -g ali-oss-cli
ali-oss upload ./dist oss://our-bucket/
写完提交,美滋滋地提了个PR,心想这下可以交差了。
结果一跑,好家伙,整个流水线跑了18分钟。18分钟啊兄弟们!开发提个PR等18分钟才能看到结果,这谁受得了?
我仔细分析了一下耗时:
- checkout: 15s
- setup node: 20s
- npm install: 6min 30s
- lint: 1min 20s
- test: 4min 15s
- build: 5min 40s
- deploy: 30s
最大的瓶颈在npm install,占了快7分钟。其次是build和test。
优化第一步:依赖缓存
这太明显了,每次跑都要重新下载几百MB的依赖,这不是纯纯的浪费吗?我立马加了缓存:
- name: Cache node modules
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
加上缓存之后,npm install从6分半降到了1分半。不错,有进步。
但我很快发现一个问题:缓存命中了,但npm install还是要跑一分多钟。后来查了资料才知道,GitHub Actions的缓存是整体打包上传的,恢复的时候也要整体解压,如果node_modules很大,这个时间也省不了多少。
于是我又做了个改进,换成了直接缓存node_modules目录:
- name: Cache node modules
uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('**/package-lock.json') }}
这样npm install在缓存命中的时候只需要几秒钟校验一下,基本可以忽略不计了。
优化第二步:任务拆分与并行
之前所有步骤都塞在一个job里串行执行,这显然不合理。lint、test、build这三个任务之间其实没有依赖关系,完全可以并行跑。
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('**/package-lock.json') }}
- run: npm ci --prefer-offline
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('**/package-lock.json') }}
- run: npm ci --prefer-offline
- run: npm test
build:
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('**/package-lock.json') }}
- run: npm ci --prefer-offline
- run: npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: dist
path: dist/
改完之后,lint和test并行跑,整体时间从原来的18分钟降到了12分钟左右。但还不够,我想要的目标是5分钟以内。
优化第三步:按需触发,别什么都跑
这里我犯了一个很蠢的错误。最开始,每次push任何分支都会触发完整的CI流程,包括build和deploy。结果有一天,某个同事在develop分支上连续push了5次commit,直接把我们组的Actions并发额度跑满了,其他人的PR全在排队等待。
当时测试小姐姐跑过来找我:"哎,我提的PR怎么一直pending啊?"我一看队列,好家伙,排了七八个任务。那一刻我真的想找个地缝钻进去。
赶紧改策略:PR只跑lint和test,只有合到main才跑build和deploy。
on:
push:
branches: [main]
pull_request:
branches: [main, develop]
jobs:
lint:
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
# ... lint steps
test:
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
# ... test steps
build:
if: github.ref == 'refs/heads/main'
needs: [lint, test]
# ... build steps
deploy:
if: github.ref == 'refs/heads/main'
needs: [build]
# ... deploy steps
这样改完之后,普通的PR只需要跑lint和test,大概3-4分钟就能出结果。只有合到main的代码才会触发完整的构建和部署流程。
那些让我崩溃的坑
说了这么多优化,接下来说说我踩过的坑,这些才是真正让我血压飙升的东西。
坑一:npm ci vs npm install
一开始我用的是npm install,结果有一次CI跑出来的包和本地开发的不一样,排查了半天发现是package-lock.json没有及时更新,npm install会根据package.json的semver范围去下载最新兼容版本,而本地开发的时候可能用的是旧版本。
后来全换成了npm ci(clean install),严格按照lock文件安装,保证CI和本地环境一致。这里有个小细节,npm ci会先删除node_modules再安装,所以如果缓存策略没搞好,反而会比npm install更慢。我的做法是缓存node_modules,然后用npm ci --prefer-offline,这样既保证了一致性,又能利用缓存。
坑二:环境变量泄露
有一次我在workflow里直接写了OSS的access key:
- name: Deploy
env:
OSS_ACCESS_KEY: LTAI5txxxxxxxxxx
OSS_ACCESS_SECRET: xxxxxxxxxxxxxxxx
run: ali-oss upload ...
提完PR我就觉得不对劲,赶紧去查。好在GitHub对secret有检测机制,立马给我发了告警邮件。我吓得赶紧把key撤销了重新生成,然后改用GitHub Secrets:
- name: Deploy
env:
OSS_ACCESS_KEY: ${{ secrets.OSS_ACCESS_KEY }}
OSS_ACCESS_SECRET: ${{ secrets.OSS_ACCESS_SECRET }}
run: ali-oss upload ...
运维大哥知道这件事之后,语重心长地跟我说:"兄弟,这种低级错误犯一次就行了,要是被安全扫描扫出来,咱俩都得被约谈。"
坑三:Docker镜像越来越大
我们后端服务是打成Docker镜像部署的。最开始Dockerfile写得很随意:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
这个镜像打出来有1.2GB。每次CI构建都要上传这个镜像到阿里云容器镜像服务,光上传就要好几分钟。
后来我做了多阶段构建:
# 构建阶段
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && \
cp -R node_modules prod_node_modules && \
npm ci
COPY . .
RUN npm run build
# 运行阶段
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/prod_node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]
改完之后镜像大小降到了280MB,构建速度也提升了不少。这里有个小trick,先安装production依赖并备份,再安装全部依赖用于构建,最后只拷贝production依赖到最终镜像。这样既保证了构建时需要的devDependencies,又让最终镜像尽可能小。
坑四:测试环境的数据库连接
我们的单元测试需要连接数据库。最开始CI里直接连的是测试环境的数据库,结果有一次测试同学正在测试环境做手工测试,CI的测试任务跑了一堆脏数据进去,搞得测试同学排查了半天bug。
后来改成了用Docker Compose在CI里启动一个临时的数据库:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Run tests
env:
DATABASE_URL: postgres://test:test@localhost:5432/test_db
run: npm test
这样每次CI跑测试都是用的干净数据库,跑完就销毁,互不干扰。
坑五:Monorepo的全量构建
我们组后来上了Turborepo做monorepo管理,一开始CI还是按老方式全量构建所有包。但实际上,一个PR可能只改了其中一个package,没必要把所有包都重新构建一遍。
Turborepo自带了远程缓存功能,配合GitHub Actions用起来非常丝滑:
- name: Build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
run: npx turbo run build --filter=...[HEAD^1]
--filter=...[HEAD^1]这个语法表示只构建当前PR变更的包及其依赖。如果一个PR只改了@our-org/utils这个包,那只有这个包和依赖它的包会被重新构建,其他的直接从缓存里取。
这一招直接把build时间从5分多钟降到了平均1分半,效果立竿见影。
关于测试的一些思考
说到CI,不得不提测试。我们组之前的测试覆盖率大概只有30%出头,很多核心逻辑都没有单测。我在搭CI的时候,顺手加了覆盖率检查:
- name: Run tests with coverage
run: npm test -- --coverage
- name: Check coverage threshold
run: |
COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
echo "Current coverage: $COVERAGE%"
if (( $(echo "$COVERAGE < 60" | bc -l) )); then
echo "Coverage is below 60%, failing the build"
exit 1
fi
我把覆盖率门槛设在了60%,刚开始组里意见很大,觉得太高了。但leader支持了我,说"技术债早晚要还,现在不还以后利息更高"。
实施之后,前两周确实怨声载道,每次提PR都要补一堆测试。但一个月下来,大家发现线上bug明显少了,回归测试的时间也缩短了。现在组里同学反而觉得这个门槛设得合理,甚至有人主动把核心模块的覆盖率提到了80%以上。
| 指标 | CI优化前 | CI优化后 |
|---|---|---|
| PR平均反馈时间 | 18分钟 | 3-4分钟 |
| 发版频率 | 每周1-2次 | 每天2-3次 |
| 线上bug数(月均) | 12个 | 4个 |
| 测试覆盖率 | 32% | 67% |
| 构建产物大小 | 1.2GB | 280MB |
一些关于架构设计的心得
搞了两个月的CI,虽然过程痛苦,但确实让我对工程化有了更深的理解。说几点体会:
CI不是银弹,但它是底线。 你不能指望有了CI就万事大吉,但没有CI你的代码质量绝对没有保障。lint、单测、构建、部署,这些环节自动化之后,开发同学可以把精力集中在业务逻辑上,而不是被发版流程折磨。
缓存策略很重要。 不管是依赖缓存、构建缓存还是Docker层缓存,合理的缓存策略能让CI速度提升好几倍。但要注意缓存的key设计,太粗了会导致缓存失效不精准,太细了又增加管理成本。
安全不能偷懒。 密钥管理、权限控制、镜像扫描,这些看起来是"锦上添花"的东西,实际上出了问题就是"生死攸关"。我那次差点把OSS key泄露到公开仓库的经历,至今想起来还后背发凉。
要考虑团队的实际状况。 我们组就一个兼职运维,所以我不可能搞一套特别复杂的CI系统。GitHub Actions这种配置即代码、无需维护基础设施的方案,才是最适合我们的。不要盲目追求大厂的技术方案,适合团队的才是最好的。
最后的碎碎念
写这篇文章的时候,已经是凌晨一点半了。上周五晚上加班优化deploy步骤的时候,不小心把生产环境的配置覆盖了,虽然及时发现回滚了,但还是被leader叫去聊了半小时。好在leader人还不错,说"新人犯错很正常,重要的是从错误中学习"。
不过说真的,经过这两个月的折腾,我们组的CI流程总算是像模像样了。PR反馈时间从18分钟降到了3-4分钟,发版从每周1-2次变成了每天都可以发,测试覆盖率也上来了。最重要的是,开发同学不用再手动跑那些繁琐的流程了,可以把时间花在更有价值的事情上。
最近leader又给我安排了新任务,说是要搞前端性能监控,把Core Web Vitals的数据接入到CI里,如果LCP超过阈值就自动拦截发版。我一听就头大,但转念一想,这不正好是我之前在上家公司研究的领域嘛?
好吧,继续肝。程序员的路,就是这样一边踩坑一边成长。希望这篇文章能帮到正在被CI折磨的你,如果有什么好的实践或者建议,欢迎在评论区交流。
不说了,我去看看今晚的nightly build跑通了没有。🫠

评论 0