
1. 从手动部署到自动化流水线我们到底在解决什么问题先说一个我见过无数次的场景项目要上线负责部署的同事打开终端先连服务器再把代码拉下来执行构建命令等构建跑完把产物拷到目标目录然后重启服务最后还要盯几分钟日志确认没报错。整个过程如果是白天做期间还得在群里喊一句“正在发布大家先别提交代码”。如果遇到构建失败、依赖装不上、端口被占用那这个“半小时的发布”拖成两三个小时也很正常。这套流程本身没什么问题小项目、低频发布、一个人全包的时候甚至挺顺手的。但一旦团队超过两三个人发布频率从一周一次变成一天几次问题就全暴露出来了每次部署的步骤不统一有人先拉代码有人先装依赖结果就是“我本地是好的啊”成为最高频的甩锅台词服务器上的环境飘忽不定上次能跑通的构建这次过不去最要命的是发布过程完全依赖某一个人他请假了或者手头有事整个发布就得等。CI/CD要解决的说白了就是把这些“靠人盯、靠手记、靠运气”的环节变成“代码一提交机器自动帮你把剩下的活干完”。持续集成管的是“从代码提交到构建、测试通过”这一段持续交付和持续部署管的是“从构建产物到发布上线”这一段。整条链路串起来就是自动化流水线。这篇文章我想完整拆一遍从手动部署改造到自动化流水线的全过程包括工具选型怎么考虑、流水线怎么设计、关键配置怎么写、实际跑起来会遇到哪些坑。内容更适合正在做团队基础设施、或者被重复部署折磨得想离职的读者参考不管你是后端、前端还是测试出身只要手里管着项目发布这件事都应该能从中找到能直接抄作业的部分。2. 工具选型不是越流行越好关键看你的托管平台和团队规模2.1 主流CI/CD工具的一次横向对比市面上做CI/CD的工具非常多我先列几个最常见的再说说它们在真实团队里的表现差异。Jenkins是老牌选手能活这么多年不是没道理的。它的插件生态极其庞大不管你是要跑Java、Node、Python还是Docker构建都能找到插件。但Jenkins的维护成本也高主节点挂了要恢复、插件版本冲突、构建队列堆积这些都是自己扛。对一个小团队来说花在维护Jenkins本身的时间有时候比省下来的部署时间还多。GitLab CI是近几年很多团队的首选尤其是代码本来就托管在GitLab上的团队。它的特点是不用额外搭服务GitLab自带的Runner就是你执行构建的agent流水线配置文件直接放在仓库里跟着代码走。这个设计非常贴合“配置即代码”的思路分支策略、合并请求验证、环境部署都可以在同一个平台里配好。GitHub Actions本质上和GitLab CI是同一种思路只是托管在GitHub上生态也很活跃marketplace里现成的action非常多很多通用步骤直接引用别人的action就能搞定比如上传产物到S3、发通知到钉钉或者飞书。Drone这类容器化CI工具也有一批忠实用户它的特点是每个构建步骤都跑在容器里本地环境和CI环境完全一致配置是yaml格式文件非常简洁。但Drone需要自己搭建服务端而且它的生态比Jenkins、GitLab CI小不少遇到问题查资料相对费劲一些。Tekton是云原生的路子跑在Kubernetes之上每个步骤都是Pod。它的优势是伸缩性强适合大规模并行构建的需求但学习曲线明显更陡对普通业务团队来说其实有点重了。2.2 我的选型逻辑不纠结“最好”只选“最合适”选工具的时候我一般会先问三个问题代码托管在哪里团队有多少人愿意义务维护CI基础设施发布的目标环境是什么形态如果代码在GitLab我基本不会想太多就直接用GitLab CI代码在GitHub就优先GitHub Actions。这不是从众而是因为CI和代码托管平台绑定得越紧密权限管理、Webhook触发、MR/PR状态反馈这些环节就越不用自己操心。比如GitLab CI天然能感知到“这个MR刚才有新的commit推上来”然后在MR界面直接显示流水线是红是绿这比任何外部CI工具反查GitLab状态都顺滑得多。如果目标是Kubernetes集群那Tekton在技术上是更配的但它要求团队里有人懂K8s如果你们连K8s都还没完全吃透那还是先从GitLab CI或者Jenkins做起更稳。我个人的实践经验是5到20人的团队、代码在GitLab、部署目标是传统服务器或者Docker主机GitLab CI是最省心的方案。它不需要额外维护一套CI服务端Runner本身很轻配置跟着代码走新人接手也容易看懂。Jenkins如果你已经跑得很顺了没必要为了换而换但如果是从零开始那我认为GitLab CI的性价比确实更高。3. 流水线设计不是写个yaml就行核心是把阶段切对3.1 三个阶段的拆分build、test、deploy流水线的骨架基本都由三个阶段组成构建、测试、部署。但真实落地的时候每个阶段内部怎么分、哪些环节该串行哪些该并行、失败后要不要继续往下走这些都是需要认真设计的。构建阶段最常见的问题是“每个人构建出来的产物不一样”。这个问题根源在于构建环境不统一有人用本机的Node 14有人用Node 16有人系统里装了一堆全局依赖各自构建出来的镜像或者包自然可能有差异。自动化流水线里解决这个问题的思路是固化环境要么用固定版本的容器镜像作为构建环境要么把依赖锁文件提交到仓库里保证每次拉下来的依赖版本完全一致。测试阶段经常被忽视很多人觉得“我有测试但跑得太慢就不放进流水线了”。这其实是捡了芝麻丢了西瓜。测试跑得慢正确做法是拆分——单元测试、接口测试、静态检查可以并行跑哪个出现问题直接告诉开发是哪个环节挂了而不是把发布流程里的测试环节整个砍掉。砍掉测试环节流水线看起来是快了但等于把问题全部留到部署之后才发现到时候排查成本高出一个数量级。部署阶段又分几种情况持续交付是“构建出来的包可以一键部署到生产”但部署动作还要人工确认持续部署是连这个确认都省了代码合并到主干自动发布。新团队我建议先做持续交付保留一个手动确认的关卡等发布频率真的高到人工点确认都烦了再升级成持续部署。3.2 产物管理把“构建过的东西”当成正式资产来管这是很多人做流水线时最容易忽略的一个环节。没有经验的做法是部署阶段又去重新拉代码、重新构建。这听起来没什么问题但实际埋了很多雷。举个例子一个前端项目CI里构建出的dist目录和部署时在生产服务器上重新构建出的dist目录理论上应该一样但如果CI环境和生产环境依赖版本有细微差异或者构建过程中拉到了不同的依赖版本构建出的产物就可能不一样。这会带来一个很恶心的现象CI验证通过的版本生产上重新构建出来跑起来的行为却不一样。解决方案是构建产物必须在CI环境中产出一次然后以产物artifact的形式保存下来部署阶段直接用这个产物绝不重新构建。在GitLab CI里可以用artifacts关键字把dist目录或者jar包保存下来部署的job通过dependencies字段来依赖这些产物。这种做法同时还有一个好处就是产物上可以带构建号比如frontend-20250610-001.tar.gz想回滚到哪个版本一目了然。3.3 环境管理开发、测试、生产不能共用一套配置环境管理这块核心原则是流水线的配置要能区分环境不同环境使用不同参数而代码本身不应该感知环境的差异。常见做法是在仓库里维护不同环境对应的变量文件比如.env.dev、.env.test、.env.prod流水线部署时根据当前目标环境选择对应的变量文件。这里有个细节要特别注意变量文件里的敏感信息比如数据库密码、密钥不应该以明文形式提交到仓库里。GitLab CI有CI/CD Variables功能可以把环境中需要的密钥在项目设置里配置好流水线运行时自动注入代码仓库里能看到变量名但看不到变量值。这样既方便了开发本地调试又保证了生产环境的配置安全。另一个值得说的点是环境命名的规范。我见过有些团队在流水线里直接写prod、test、dev但后来又冒出个staging再到后面又有preprod而且每个环境在流水线里的配置是复制粘贴改了个地址。这种做法的隐患是随着环境数量增加流水线配置会越来越乱。建议从第一天就把环境列表和它们对应的作用整理清楚至少保持dev、test、staging、prod四个环境名称的稳定不要搞得团队成员都在猜“到底哪个环境是给客户演示用的”。4. 实战配置用GitLab CI跑通一条完整流水线4.1 基础配置先让流水线跑起来再谈花活我用一个Node.js后端项目做示例目标是把代码推送到main分支后自动执行安装依赖、跑测试、构建镜像、发布到测试服务器这四个环节。这是最常见的后端项目场景配置内容可以直接套用到自己的项目里。首先在项目根目录创建.gitlab-ci.yml文件这是GitLab CI的配置入口。第一步先定义流水线的基础信息和工作阶段stages: - install - test - build - deploy cache: paths: - node_modules/ install_job: stage: install image: node:18-alpine script: - npm ci tags: - docker这个配置里stages定义了各个环节的执行顺序GitLab会按照这个顺序依次执行job同一stage的多个job可以并行。cache配置是用来缓存node_modules的这样下次构建就不需要全部重新下载依赖可以省不少时间。npm ci和npm install的区别在于npm ci是严格按package-lock.json安装依赖不会去更新依赖版本保证本地和CI环境一致这个必须用ci而不是install。然后是测试和构建阶段test_job: stage: test image: node:18-alpine script: - npm run lint - npm run test tags: - docker build_job: stage: build image: docker:24 services: - docker:24-dind script: - docker build -t registry.example.com/myapp:${CI_COMMIT_SHORT_SHA} . - docker push registry.example.com/myapp:${CI_COMMIT_SHORT_SHA} tags: - docker这里我用了${CI_COMMIT_SHORT_SHA}作为镜像的tag这是GitLab预定义的环境变量含义是当前commit的短哈希。用commit号做镜像标签有几个好处每个镜像和代码版本一一对应部署的时候可以明确知道线上跑的是哪一次提交的代码回滚的时候只需要重新指定之前提交的镜像标签即可。版本号的计算逻辑可以扩展一下很多团队习惯用三位版本号加构建号的方式比如v1.2.3-20250610-001前三位是语义化版本号后面是日期和流水线构建号。这种可读性更强但需要额外维护版本号的来源。最省事的是直接用commit哈希用着用着你会发现其实短哈希已经足够日常交流了。下面这步是关键部署。在这个示例中我假设测试服务器是通过SSH部署的传统方式部署阶段这么做deploy_test: stage: deploy image: alpine:latest before_script: - apk add --no-cache openssh-client - eval $(ssh-agent -s) - echo $SSH_PRIVATE_KEY | ssh-add - - mkdir -p ~/.ssh - echo $SSH_KNOWN_HOSTS ~/.ssh/known_hosts script: - scp -r artifacts/* usertest-server:/opt/myapp/ - ssh usertest-server cd /opt/myapp docker-compose up -d --force-recreate myapp environment: name: test only: - main tags: - docker部署脚本里有几个值得注意的细节。首先是SSH私钥的来源$SSH_PRIVATE_KEY和$SSH_KNOWN_HOSTS这两个变量不是写在仓库里的是在GitLab项目设置的CI/CD Variables里配置的。这样既保证了密钥安全又可以在不同环境间复用同一套流水线模板。其次是only: - main意思是在非main分支的提交上不会执行部署job。这可以避免开发在feature分支上折腾时频繁触发部署。但也要注意如果你希望从MR中也能部署到临时测试环境那就要再单独加一个带environment: review的job这里先不展开。4.2 前端项目的配置差异规则与产物缓存如果你负责的是前端项目流程里没有“打镜像”这一步更多是把静态资源构建出来然后传到服务器或对象存储。前端项目的流水线配置有一点很不一样构建阶段结束之后调试“产物到底传没传上去”比后端麻烦得多。一个典型的前端项目配置大概是这样的stages: - install - test - build - deploy cache: key: ${CI_COMMIT_REF_SLUG} paths: - node_modules/ install_job: stage: install image: node:18-alpine script: - npm ci tags: - docker build_job: stage: build image: node:18-alpine script: - npm run build artifacts: paths: - dist/ expire_in: 1 week tags: - docker deploy_prod: stage: deploy image: alpine:latest before_script: - apk add --no-cache rsync openssh-client - eval $(ssh-agent -s) - echo $SSH_PRIVATE_KEY | ssh-add - script: - rsync -avz --delete dist/ userprod-server:/var/www/myapp/ environment: name: production only: - main when: manual tags: - docker这个地方我用artifacts把构建出来的dist目录保存了下来并设置了1个星期的保留时间。这样当你需要在某个历史流水线上重新部署可以直接复用当时构建出来的产物不用回到那个commit重新构建一遍。生产环境的部署job加了when: manual这个值的含义是流水线跑到这个job时会暂停需要人工在GitLab界面上点击“执行”按钮才会继续。这是从持续交付到持续部署之间的一层保险强烈建议刚上流水线的团队都保留这个手动确认关卡。等团队对CI/CD流程完全建立信任之后再决定要不要把when: manual去掉。前端项目还有一个容易踩的坑是rsync --delete参数的使用。这个参数的含义是“把远端目录里本地产物中不存在的文件全部删掉”好处是保证远端目录和本地构建产物完全一致坏处是如果远端目录里还有其他你需要保留的文件比如用户上传的图片目录那就不能用--delete需要把不需要删除的路径用--exclude排除掉。我第一次用的时候没注意直接把测试服务器上用户上传的图片全删了那种感觉你现在应该想象得到。4.3 从推到main到上线一次完整流程的现场回放我把上面这些配置放在一起跑一条完整流水线看看整个流程是什么样的。开发在本地改完代码推到GitLab的main分支GitLab听到push事件后自动创建一条流水线。流水线的第一个job是install_job用node:18-alpine容器执行npm ci。这个job可能耗时30到60秒取决于依赖数量和网络情况因为有缓存第二次起一般在10秒级。install_job通过之后test_job和build_job会同时开始。test_job跑lint和单元测试build_job执行docker build构建后端镜像并推送到仓库。这两个job没有依赖关系所以GitLab会并行执行总共耗时取决于最慢的那个job。最后是deploy_test等待构建阶段结束之后开始。这个job通过SSH连上测试服务器把相关产物拷过去然后执行远程命令重新拉起容器。到这里一次“代码提交-自动化部署到测试环境”的完整闭环就完成了全程不需要任何人手动登录服务器。如果你看这条流水线在GitLab界面上的展示效果你会看到五个任务图标依次亮起绿色的打勾表示通过红色的叉表示失败。每个job点进去能看到实时的控制台日志哪一步出错、报了什么错一目了然。这跟以前“部署失败只能登录服务器翻日志”的体验完全是两码事。5. 常见问题与排查技巧实录5.1 变量作用域带来的困惑GitLab CI的变量体系有几个层级项目级别的变量Settings - CI/CD - Variables、Group级别的变量、以及流水线预定义变量。优先级是流水线触发时手动传入的变量 项目变量 Group变量 预定义变量。 我在实际使用中踩过一次比较隐蔽的坑项目A引用了Group级别的变量DEPLOY_SERVER_IP后来为了给某个环境单独指定IP在项目级别又加了一个同名变量结果发现所有环境都用了项目级别的值。后来才反应过来变量是全局的并不是按环境或job单独覆盖的。因此如果你确实需要“不同环境不同值”正确做法是在job里显式覆盖比如deploy_test: stage: deploy variables: DEPLOY_SERVER: test-server.internal script: - ssh deploy$DEPLOY_SERVER docker-compose up -d myapp5.2 CI里的缓存到底有没有生效这是GitLab Runner尤其是用dockerexecutor的时候最容易出的问题。cache配置本身很简单但当你发现每次构建依赖都要重新下载时大概率是缓存key的作用域太大或太小导致的。缓存key默认是全局的就是你仓库根目录下所有分支共享同一个cache key。但有的时候由于依赖版本在分支间差异很大共享缓存会导致频繁失效。建议的做法是给缓存加上分支相关信息cache: key: ${CI_COMMIT_REF_SLUG} paths: - node_modules/这样每个分支维护自己的一份缓存避免互相干扰。同时也要注意node_modules目录缓存的适用性不是绝对的如果你每次构建都用同一个镜像、依赖版本没有频繁变化npm ci其实很快这时候缓存就变得可有可无了。5.3 并发构建时的资源竞争问题小团队刚开始可能没注意这个问题但一旦多个MR同时在跑Runner资源紧张经常会有job卡在Pending状态很久。这个状态通常出现在没有可用的Runner或者Runner并发数限制太低的情况下。解决办法有三个方向一是增加Runner的并发配置在config.toml里把concurrent值调大二是把不同类型的job分配到不同的Runner上比如构建流程占用资源大的job可以单独指定一个专用Runner三是利用GitLab CI的resource_group设置资源锁让同一个环境相关的多个job串行执行避免部署到同一个服务器的多个job互相打架deploy_test: resource_group: test-server script: - ./deploy.sh这个配置的作用是同一时间只允许一个用到test-server这个资源组的job在跑其他依赖这一资源的job排队等待。对于“代码合并太快、多个流水线同时部署到同一个测试环境”这种场景非常有用否则测试环境会被后面那一次构建覆盖掉完全没法复现前一个MR的问题。5.4 流水线通了但发布后服务起不来这类问题多半不是流水线配置的问题而是“发布本身的问题”。例如服务依赖某个配置文件但配置文件没有包含在部署产物里或者容器启动时需要挂载某个目录但目录还没有创建。我的排查习惯是看到部署job显示绿色通过但服务异常第一件事不是去看业务日志而是先回到部署脚本里确认这次到底执行了什么命令、命令的执行目录对不对、有没有依赖宿主机特定路径的内容。因为CI里的“通”和你手动操作的“通”有一个本质区别CI脚本是在一个非常干净的环境里运行的它不会自动替你创建目录不会自动设置环境变量不会自动拉取私有镜像。所以写部署脚本的时候有一个心态要转过来不要假设服务器上“已经有了什么”要假设“服务器上除了操作系统什么都没有”然后脚本要能处理这种从零开始的状态。5.5 一个速查表高频报错与应对方案现象常见原因处理方式job一直PendingRunner不可用或并发数已达上限查看Runner状态调整concurrent值拆分专用Runnernpm ci报错lock文件和package.json不一致在本地重新生成lock文件并提交确认依赖源可用镜像构建失败Dockerfile依赖的基础镜像tag不存在确认镜像仓库地址是否可访问tag拼写是否正确SSH部署失败私钥格式不对或known_hosts没有配置确认私钥为pem格式确认known_hosts里包含目标服务器指纹artifact被清理默认保留时间过期在job里指定expire_in或者下载需要长期保留的产物生产环境部署了测试配置变量作用域覆盖导致检查变量层级和job内显式覆盖同时部署到同一台服务器互相覆盖缺少并发控制使用resource_group把同一环境的部署job串行化5.6 关于失败重试的一个实战心得GitLab CI的job界面有重试按钮流水线也有整体重跑的功能。一般人失败后就只会点重试但实际上重试有两种Retry是只重跑失败的jobRun again或类似功能是整个流水线重跑。如果你提供的构建环境是幂等的相同的输入一定能得到相同的输出那重跑失败的job就够了。但如果你的job里有一些不是幂等的操作比如推进产物时做了增量同步失败时可能已经同步了一半这时候重跑失败job反而可能因为数据不一致而依然失败完整重跑流水线倒是更可靠。重试验证这个事我自己吃过亏有个部署job里执行了一个远程构建脚本脚本不是幂等的第一次执行到一半失败点重试后脚本从头执行结果因为之前留下的临时文件冲突又失败。后来我把远程脚本改成了“开始前先清理现场保证脚本可以反复执行”的写法从此重试才变得可靠起来。这也引出一个原则流水线里的每个job都应该能被安全地重复执行。6. 从自动化到自服务的下一步演进当你把一条原始的“代码提交-部署上线”流水线跑稳定之后团队对CI/CD的信任感会逐渐建立起来。这时候会有新的需求冒出来测试环境能不能按分支自动创建回滚能不能在界面上直接操作新人进来能不能不用看文档就理解发布流程这些需求背后其实同一个演进方向从“自动化”走向“自服务”。流水线不光要能自动跑还要让团队成员觉得发布是件可以自助完成的事情。比如基于GitLab的环境面板给每个部署过的环境打上标签团队里任何人打开环境页面就能看到当前环境跑的是哪个commit、部署时间是什么时候、谁触发的这次部署。再比如用MR描述里的特定关键字触发特定流程比如在MR描述里加一句/deploy test就让流水线执行测试环境部署把操作入口直接嵌到开发日常的协作流程里。这一步走完之后你会明显感觉发布这件事从一个“需要专门的人负责的仪式”变成了“日常代码变更的自然延伸”。这其实才是CI/CD真正想带来的改变不是省了几次手工操作而是让发布本身变得不值得被注意。我个人的体会是改造流程这件事最难的不是技术而是整个团队是否愿意在前期投入那一两次“看起来更慢了”的成本。第一次把构建从本地挪到CI里跑的时候因为要写配置、调环境可能比原来手动构建还慢。但这个阵痛期扛过去之后后面每一分钟都是赚回来的。最后再分享一个自己在多次改造中沉淀下来的经验不要试图一步到位搞一条“完美”的流水线。先用最简单的配置把install和build跑通哪怕只是把原来手敲的命令原封不动写进script里先让机器替你做一遍然后逐步加测试、加产物管理、加多环境部署。流水线是长出来的不是设计出来的。你现在的每一步调整都是在为团队积累一套可复用、可扩展的发布基础设施。