尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从手动发版到自动化流水线:GitLab CI与Docker实战改造

从手动发版到自动化流水线:GitLab CI与Docker实战改造 星期五下午四点半我端着杯子刚准备收拾东西测试同事在群里发了句“生产环境登录不上了”。我还没来得及回复运维那边已经甩过来一个截图下午那次手动发布的jar包有一台服务器漏更新了Nginx轮询把流量打到旧节点上新旧版本混跑直接把会话状态搞崩了。这已经不是第一次了。那个项目从上线第一天起发布流程就一直靠人肉完成本地打包、上传服务器、停服、备份、替换、重启、验证。每一步都在赌赌自己不会漏掉一台机器赌不会有人临时改个配置没同步给团队。直到那天下午我才下定决心把整套流程从手动部署迁到自动化流水线。这篇文章不是教科书式的概念科普而是我完整落地的一次CI/CD改造实录。你不需要是全职DevOps只要手上有一个维护中的业务项目、觉得发版太折腾就可以照着下面这套思路和配置改造自己的部署流程。我会把工具选型理由、配置文件逐段拆解、跑通之后遇到的坑和排查过程全部摊开讲。1. 手动部署的账一笔笔算给你看很多人觉得手动部署就是“多敲几条命令”慢是慢点但至少可控。这种想法我理解但真实情况是手动部署的问题从来不是慢而是不可复现。我下面把一次典型的手动发版全过程拆开你就能理解为什么要下决心改。1.1 一次典型的手动发版全过程复盘假设这是一个Spring Boot项目打包产物是一个可执行jar部署在两台云服务器上前面挂了Nginx做负载均衡。手动发布的完整流程大概是这样的开发本地执行mvn clean package跳过测试因为测试太慢赶时间的时候没人愿意等把jar包通过scp传到两台服务器的/opt/app目录SSH登录服务器执行systemctl stop app停服手动备份旧版本比如cp app.jar app-20240517.jar替换新jarsystemctl start app启动用tail -f /var/log/app.log观察启动日志确认没有异常按F5刷新页面登录系统跑几个核心功能。这整个过程顺利的话大概需要20到30分钟。但在实际操作中耗时最大的往往是步骤6——你要反复确认日志输出如果启动失败还要排查是配置问题、数据库连接问题还是依赖缺失这个问题可能又要花一个多小时。我见过很不幸的情况是项目终于启动起来了但忘记清掉旧的Redis缓存新版本的字段调整导致缓存数据反序列化直接报错。整个过程从下午五点半折腾到晚上九点最后发现只是忘了重启一个清理缓存的定时任务。1.2 手动部署模式真正的三宗罪如果只是慢还能忍。真正让人头大的是下面这三件事第一操作不可复现。每台服务器的环境可能都有细小的差别。比如A服务器是之前的老运维手动装的环境JDK版本是8u201B服务器是后来用脚本装的JDK版本是8u311。两个JDK版本在某些字符串处理的底层实现上有细微差异结果同一个jar包在A服务器上运行正常在B服务器上就报一个诡异的异常。这类问题在手动部署模式下极难排查因为你根本没法快速复现对方的操作系统环境和JDK版本。第二环境漂移。开发环境能启动、测试环境能启动、一到生产环境就报错这类问题几乎每个人都遇过。根因往往是生产环境某个系统库版本和开发环境不一致或者某个配置文件是“历史遗留产物”里面有一段早就失效的配置。手动部署模式下这些差异全靠人肉记住记不住就出事。第三发版恐惧症。一旦项目有了一定规模每次发版就变成一件让人精神紧张的事。发版窗口要选在凌晨流量低的时候操作要按checklist逐项执行出了问题要手工回滚。长期下来团队会下意识地减少发版频率——从每周一发变成两周一发、一个月一发。而发布周期拉长意味着每次发布的变更量更大风险反而更高形成恶性循环。如果从“编程模型”的角度去看手动部署它其实也是一种朴素的编排模型——只是这个模型的“运行时”是每个开发者的个人电脑状态保存在人脑里流程靠口口相传配置离散在各自的家目录里。这套模型最大的缺陷是不会自动检查和验证任何一个步骤的记忆偏差都会直接变成线上事故。2. 自动化流水线到底自动了什么很多人对CI/CD有一个误解觉得“自动化流水线”就是“把手动敲的命令写成脚本”。这个认知不能说错但只说对了一半。如果只是写个shell脚本代替手动操作你依然解决不了环境漂移和不可复现的问题——因为脚本依赖的运行时环境依然是那台可能已经“长歪”了的服务器。2.1 持续集成、持续交付、持续部署的分界线先理清楚三个经常被混为一谈的概念概念英文缩写核心动作典型问题持续集成CI每次代码合并都自动执行编译、测试、静态检查代码能不能合合进去会不会挂持续交付CD每次通过验证的构建产物都随时可以部署到生产环境这一个版本能不能发持续部署CD通过验证的构建产物自动部署到生产环境要不要直接发布CI解决的是“代码合入的安全性”持续交付解决的是“发布准备就绪”持续部署解决的是“免人工发布的最后一公里”。实践中很少有人一开始就上全自动部署到生产主流做法是先做到自动化构建和自动化部署到测试环境生产环境保留一个人工确认的按钮。2.2 一条标准流水线的六道关卡以我改造的那个项目为例最终跑通的流水线包含六个阶段序号关卡作用失败处理1代码提交推送/合并触发流水线不触发则无后续2静态代码检查检查代码规范、潜在的bug模式有error级别问题直接中断3单元测试与覆盖率验证核心逻辑失败或覆盖率不达标则阻断4构建产物编译打包生成不可变产物编译失败中断5构建Docker镜像把应用和运行环境一并打包镜像构建失败中断6部署到目标环境更新环境执行健康检查健康检查失败自动回滚这六道关卡最核心的变化是从“人盯着每一步操作”变成“代码一旦提交后续动作全部自动执行”。对于基础好的读者这套流水线并不复杂对于基础一般的读者你只需要先明白流水线把“做什么”和“怎么做”固化到代码里后续的每一次发布都是对同一套流程的重复执行不会再出现“昨天怎么部署的来着”这类问题。2.3 流水线不是把手动步骤脚本化那么简单这里我想认真讲一个观点流水线的本质不是脚本而是机制。脚本解决的问题是“不用手动敲命令”但脚本依然是运行在某台机器上的、由某个人触发的东西。如果这台机器挂了或者环境变量没有同步、脚本用到了一个只有某位同事电脑上才有的工具脚本就变成了一堆毫无价值的废代码。流水线不一样它有三个关键特性隔离性。流水线的每个阶段都在受控环境中运行。比如用Docker容器来跑编译和测试容器里只安装必要的工具不存在“污染”的问题。可追踪性。每次流水线运行都对应一次代码提交所有的构建日志、产物、部署结果都自动关联到那个提交id上。哪次构建是哪个commit触发的、部署到哪台机器、操作用了哪个密钥全部可回溯。可重放性。只要代码版本固定在任何时间任何一台干净的环境上跑流水线产出的结果是一致的。这一点手动部署永远做不到。所以你现在再看“自动化流水线”它真正的价值不只是“省去了你敲命令的时间”而是把部署从一门手艺变成了一条工业化生产线的重复动作。3. 工具选型我为什么选了GitLab CI加Docker市面上能做CI/CD的工具很多选择困难症在这个领域同样存在。我直接讲我的结论和理由你可以根据自己团队的情况参考。3.1 主流CI/CD工具横向对比先说我自己实际用过的感受放在一个表里方便对照工具托管方式配置文件形式学习曲线适合场景Jenkins自建Jenkinsfile / 界面配置较陡峭复杂的传统企业项目、已有大量插件依赖的场景GitLab CI自建或SaaS.gitlab-ci.yml平缓代码已托管在GitLab的团队GitHub ActionsSaaS为主.github/workflows/*.yml平缓开源项目、代码托管在GitHub的团队Drone CI自建.drone.yml平缓喜欢云原生、Kubernetes生态的团队Jenkins插件生态是最丰富的但它的问题是配置分散在界面和文件里很难做到“配置即代码”。GitHub Actions体验很好但想把Runner跑在自己机房、控制成本需要额外配置。Drone和Container环境配合得好但生态相对小众。我最后选的是GitLab CI核心原因只有一个代码仓库本身已经迁移到了GitLab用GitLab CI不需要额外引入一套新系统流水线配置直接和代码放在同一个仓库里改配置走代码评审合到主干就生效。这种“代码和流水线配置同源”的体验是Jenkins给不了的。3.2 GitLab CI不需要额外维护一套控制台用过Jenkins的人应该懂我的意思Jenkins本身也是一个需要维护的系统它有插件要升级、有权限要管理、有节点要维护。如果你本身不是专职的DevOps管一套Jenkins还是挺占精力的。GitLab CI的Runner可以是轻量的二进制程序注册到一个项目或者一个Group下就能跑。即使只有一个Runner也能覆盖所有阶段的执行。在GitLab CI里流水线的状态直接展示在Merge Request页面。开发提一个MR就能在同一页看到测试有没有过、构建有没有成功。评审者不再需要问“你本地测过了吗”看流水线状态就行。3.3 自建Runner时的资源规划经验Runner本身对硬件要求很低2核4G的实例就能带起来。但我建议你在初始阶段就把以下三点考虑进去并发数控制。Runner的并发数和预期的MR数量、提交频率强相关。我一开始图省事没限制结果三个MR同时触发构建直接把Runner所在服务器CPU打满连带着把Docker守护进程也拖垮了。后来在Runner的config.toml里把concurrent从10改成3并把单个Job的并发也做了限制才恢复正常。构建缓存目录。Maven、npm这类构建工具都会产生本地缓存如果不做持久化每个Job都在新的容器里全量下载依赖第一次构建花15分钟后面每次构建都是15分钟。我在Runner宿主机上挂载了一个持久化目录给Maven仓库并在.gitlab-ci.yml里配置了cache key构建时间从15分钟直接降到2分钟左右。Docker-in-Docker还是绑Socket。这个问题很现实构建镜像的时候Runner里的build命令需要访问Docker守护进程。主流有两种做法一种是在Job里启动一个DinD服务作为Docker host隔离性好但性能有折损另一种是给Runner直接挂载宿主机的/var/run/docker.sock性能好但所有Job都共享同一个Docker环境。我选择了挂载Socket的方案因为使用更方便docker build的时候能利用宿主机已有的镜像层缓存构建速度快很多。安全上因为Runner只跑受信任的代码合入分支风险是可控的。4. 从零落地一份能直接抄作业的流水线配置工具选完接下来是实战环节。我会以之前那个出过事故的Spring Boot项目为例给你看一套可以直接抄的.gitlab-ci.yml配置。项目本身用的是Maven构建最终产物是Docker镜像部署目标是一台有Docker Compose的运行服务器。4.1 项目背景和落地目标这个项目是一个典型的业务后端服务有几十个接口、依赖MySQL和Redis、通过Nginx对外提供服务。改造前是手动发版改造后的目标定得很明确每次合入main分支自动执行测试、构建镜像、推送到私有镜像仓库合入main后自动部署到测试环境生产环境部署保留人工确认按钮按下后才执行部署到生产之后自动执行健康检查失败自动回滚到上一个可用版本。需要说明的是这套配置里的镜像仓库是自建的Harbor但改成Docker Hub或者云厂商的容器镜像服务逻辑都一样只要把地址和账号信息改成你自己的。4.2 .gitlab-ci.yml逐段拆解完整的配置文件如下我加了一些关键注释stages: - test - build - deploy variables: MAVEN_OPTS: -Dmaven.repo.local$CI_PROJECT_DIR/.m2/repository IMAGE_NAME: registry.example.com/myapp IMAGE_TAG: $CI_COMMIT_SHORT_SHA cache: key: $CI_COMMIT_REF_SLUG paths: - .m2/repository/ - target/ test: stage: test image: maven:3.8-openjdk-11 script: - mvn test only: - main - merge_requests build: stage: build image: docker:20.10.16 services: - docker:20.10.16-dind script: - echo $REGISTRY_PASSWORD | docker login registry.example.com -u $REGISTRY_USER --password-stdin - docker build -t $IMAGE_NAME:$IMAGE_TAG . - docker push $IMAGE_NAME:$IMAGE_TAG only: - main deploy-test: stage: deploy image: alpine:latest before_script: - apk add --no-cache openssh-client - eval $(ssh-agent -s) - echo $SSH_PRIVATE_KEY | tr -d \r | ssh-add - script: - ssh -o StrictHostKeyCheckingno deploytest-server cd /opt/app docker compose pull docker compose up -d environment: name: test only: - main deploy-prod: stage: deploy image: alpine:latest before_script: - apk add --no-cache openssh-client - eval $(ssh-agent -s) - echo $SSH_PRIVATE_KEY | tr -d \r | ssh-add - script: - ssh -o StrictHostKeyCheckingno deployprod-server cd /opt/app docker compose pull docker compose up -d --remove-orphans sleep 15 curl -fsS http://localhost:8080/actuator/health environment: name: production when: manual下面把几个关键点展开讲讲。stages的顺序决定了执行顺序。test、build、deploy三个stage会按顺序执行只有前一个stage里的所有Job成功后一个stage才会开始。同一个stage里的多个Job默认并行执行。cache和maven本地仓库。这里我把Maven本地仓库放在了$CI_PROJECT_DIR/.m2/repository并用cache关键字把它缓存起来。key用的是$CI_COMMIT_REF_SLUG也就是当前分支名的小写字母和数字。同一分支的Job会复用相同的缓存不同分支各自独立避免两个分支依赖冲突导致缓存污染。test这个Job。我写的是mvn test。注意这里没有加-DskipTests。很多人为了图快跳过测试但流水线的核心价值就是让测试成为合入代码的硬门槛。测试不过就停在test阶段后面的build和deploy压根不触发。build里的Docker-in-Docker。这个Job的image是docker:20.10.16同时声明了services里的docker:20.10.16-dind。这意味着这个Job会启动两个容器一个是执行命令的客户端容器另一个是提供Docker守护进程的服务容器。构建命令通过Docker客户端连接服务容器完成操作。这个方案的好处是Job和Job之间、Job和宿主机之间完全隔离不会互相污染。deployjob利用SSH远程执行。deploy阶段我没有用现成的部署插件而是直接在docker:alpine镜像里装上openssh-client然后用SSH_PRIVATE_KEY这个环境变量登录目标服务器在服务器上执行docker compose pull docker compose up -d。这样跳过了复杂的agent安装只要目标服务器上有Docker Compose就能完成部署。手工确认的部署。deploy-prod的when: manual意味着流水线跑到这一步会停住等待人工在GitLab界面上按下“Play”按钮才继续。这就是持续交付和持续部署的分界线——生产环境的发布权始终保留在人手上。4.3 部署到服务器时几个容易忽略的细节这套配置能跑起来但能把细节做对部署过程才能真正稳定。我额外提三个我在实际部署中踩过坑的点第一SSH密钥不要用密码登录。生产服务器禁止用密码登录必须改成密钥登录。我在before_script里用SSH Agent的方式加载私钥私钥放在GitLab项目的CI/CD变量里。注意变量的类型要选Protected和Masked防止在日志里被打印出来。第二docker-compose.yml里的镜像tag必须参数化。如果你在docker-compose.yml里硬编码了image: registry.example.com/myapp:latest那docker compose pull会把远程最新的latest拉下来但本地缓存可能会导致旧版本还在跑。我的做法是使用$IMAGE_TAG环境变量来指定完整tag在服务器上执行docker compose up -d之前先通过sed -i s#IMAGE_TAG#$IMAGE_TAG#g docker-compose.yml动态替换。这样可以让tag在GitLab CI里生成也能保证服务器上和仓库里的镜像完全对应。第三健康检查不能省。部署脚本里我加了一段curl -fsS http://localhost:8080/actuator/health。这一步是判断服务是否有存活能力的关键手段。如果没有这一步万一新版本启动就崩容器会自动退出但你完全不知道。加了健康检查之后脚本可以拿到明确的结果失败就执行回滚逻辑。4.4 部署时的滚动发布配置如果目标服务器上有多个实例或者你部署的是前端项目我可以顺便给一个用Docker Compose实现滚动更新的示例。假设目标服务器上运行着两个应用实例你可以用下面的方式实现不中断的滚动更新docker compose up -d --no-deps --scale app2 --no-recreate app # 等新实例健康检查通过后再更新旧实例 docker compose up -d --no-deps --scale app1 --no-recreate app这个做法依赖于Compose的多副本能力。相比之下如果你的项目没有条件上Kubernetes这已经算是一个性价比挺高的轻量方案了。它会短暂地多跑一个实例但对外服务的端口并不会断。只有当你把app这个服务的所有容器都替换完成之后Nginx才会把流量全部转到新版本。整个过程中用户最多遇到几次轻微的timeout刷新不会感知到实际的发布行为。5. 跑通只是开始自动化发布路上的真实踩坑配置写完了流水线第一次全绿很多人会觉得“大功告成”。但我要说真正的工作从这个时候才刚刚开始。我把自己实际跑过的坑列出来每一个都是真金白银换来的教训。5.1 Runner权限的两难root还是普通用户Runner刚注册完测试第一个Job就跑挂了错误信息提示没有权限访问Docker的Socket。当时的想法很简单把Runner的用户加到docker组里。结果跑了一段时间发现一个更麻烦的问题——docker组和root其实没有本质区别因为docker命令本身就相当于有root的权限。这意味着任何能在这个Runner上触发流水线的人都间接有了Runner宿主机的root权限。后来我换了一种做法给Runner单独分配一个低权限用户用它来执行Job涉及到需要写Docker Socket的构建任务就给这个用户单独加一个docker组权限组。这就已经能兼顾构建需要和安全隔离了。不要图省事直接用root跑尤其是Shared Runner部署在公共机器上的时候。5.2 缓存失效的完整排查链路流水线跑了大概两周突然有人反馈说构建时间从2分钟飙升到12分钟每次都是重新下载全部依赖。我的第一反应是“是不是有人改了cache key”看了代码记录没有改。接着怀疑是不是Runner磁盘满了缓存写不进去结果磁盘占用率只有40%。最后发现问题出在分支策略上。我们规定了一些短生命周期的特性分支这些分支创建频繁、合入后很快删除。cache的key是$CI_COMMIT_REF_SLUG每个分支都有自己的缓存。特性分支多了之后旧的缓存没有及时清理而新分支第一次构建时永远拿不到旧缓存只能全量下载。解决办法是给cache key多加一个维度把相同基线代码的分支共用一个缓存cache: key: $CI_COMMIT_REF_SLUG-$CI_COMMIT_BEFORE_SHA这样同一个基线的新分支就能复用上一轮的缓存只有依赖文件真正变化时缓存才失效。实现后构建时间恢复到2到3分钟。5.3 环境变量泄漏事故日志里惊现生产数据库密码有段时间我在排查一个部署问题点开了一个Job的日志往下翻了几屏赫然发现日志中打印出了目标服务器的SSH私钥内容。排查后的结论是有人在before_script里为了调试执行了set -x把所有命令的执行过程都打印出来了其中就包含了环境变量的赋值语句。GitLab CI对Masked变量是有保护的一旦变量被标记为MaskedGitLab会主动对日志中出现的该变量做替换。但那个私钥变量因为是“多行字符串”类型GitLab CI不支持对多行字符串做Masked导致私钥被完整打印出来了。这次的教训有三条不要在流水线里加set -x尤其是涉及密钥、Token的Job多行密钥不要存在Masked变量里GitLab不会对它生效密钥要定期轮换这次事故后我第一时间重置了生产环境的部署密钥。5.4 并发构建把同一台服务器搞挂改造的第二个月一次常规上线日测试团队同时提了5个合并请求跑了一个多小时突然线上环境服务变慢。查监控发现Runner宿主机负载五六十Docker守护进程的线程数飙到上千宿主机上同时有十几个镜像在构建I/O全部打满连带着Runner上运行的轻量监控容器都响应不过来了。这个问题的根因就是Runner并发数和宿主机资源的不匹配。我把Runner的并发数调低之后又把build阶段的job执行时间分散了一下——两个Docker镜像构建Job不会同时触发人为错峰。团队里其实只有两三个项目会同时走这套流水线所以并发数设为2就已经很稳妥了。6. 流水线稳定运行后的进阶玩法流水线跑顺之后它给你腾出了大量重复劳动的时间。这时候不要把省下来的时间用来“摸鱼”而是应该把这条流水线的能力往外再扩一步。下面这几件事是我认为投入产出比最高的方向。6.1 质量门禁测试覆盖率不达标就不许合入流水线跑通之后第一个值得加的是覆盖率门禁。以Java项目为例我用的是JaCoCo和Maven的org.jacoco:jacoco-maven-plugin。配置如下plugin groupIdorg.jacoco/groupId artifactIdjacoco-maven-plugin/artifactId version0.8.8/version configuration excludes exclude**/config/**/exclude exclude**/dto/**/exclude /excludes rules rule implementationorg.jacoco.core.analysis.IBundleCoverage elementBUNDLE/element limits limit counterINSTRUCTION/counter valueCOVEREDRATIO/value minimum0.60/minimum /limit /limits /rule /rules /configuration executions execution goals goalprepare-agent/goal /goals /execution execution idcheck/id goals goalcheck/goal /goals phaseverify/phase /execution /executions /plugin有了这个配置单元测试覆盖率低于60%执行mvn verify时构建直接失败流水线停在test阶段部署不会发生。一开始团队可能不适应但从长期看它逼着开发者给核心逻辑补测试这就是质量基建。6.2 自动回滚发布失败10秒恢复我上面提到deploy-prod脚本里有健康检查那失败之后怎么自动回滚最直接的方式是利用Docker本身的镜像标签机制。部署前先记录当前运行的镜像tag健康检查失败后立刻恢复原tag并重新启动CURRENT_TAG$(docker ps --format {{.Image}} | grep myapp | awk -F: {print $2}) docker compose up -d --no-deps app$IMAGE_TAG sleep 15 if ! curl -fsS http://localhost:8080/actuator/health; then echo health check failed, rolling back to $CURRENT_TAG docker compose up -d --no-deps app$CURRENT_TAG exit 1 fi这个脚本的核心逻辑是先记录当前版本尝试启动新版本如果健康检查不过立刻回滚到旧版本。这里的exit 1会让Job显示为失败在GitLab的流水线页面上能看到红色提醒团队这次发布没有成功。6.3 多环境策略开发、测试、生产的差异化管理最后说一下多环境。一套流水线在不同环境下应该有不同的行为不应该三个环境都用同一个Job。我最终跑出来的配置是这样的环境触发方式是否自动特殊动作dev推送dev分支自动构建后直接部署不做人工确认test推送main分支后自动自动化测试跑完后部署staging手动触发手动部署前同步生产数据库脱敏数据prod手动触发手动部署前备份数据库部署后健康检查dev和test环境可以放开自动部署因为环境坏了重建成本很低。staging和prod环境必须保留人工把关的步骤尤其是prod至少要有一个人按下确认按钮之后再执行。这个过程从原来的“发版焦虑”变成“偶尔点一下确认”团队的发布压力完全变了。最后再分享两个小技巧流水线改造落地一年半了我自己最大的体会是自动化流水线真正改变的不只是发布效率而是团队对代码质量的信心。以前说“发版”大家如临大敌现在说“发版”跟说“提交代码”一样自然。如果你也准备动手改造我给你两个可以立刻上手的建议第一别追求一步到位。你不需要第一天就上多环境、自动回滚、质量门禁这些全部能力。先把“提交代码自动编译测试、构建镜像、部署测试环境”这三步跑通就已经解决了绝大多数手动部署的痛点。稳定运行一两周之后再逐步加上生产环境的确认部署和健康检查。第二流水线配置本身也要当代码来管理。.gitlab-ci.yml要有Code Review不能做一次性提交。改流水线配置的人一定要在MR描述里写清改动目的和验证方式。这套文件最终会成为团队的一项技术资产而不是某一个人电脑里的私有脚本。如果你手头正好有个项目还在手动发版那这个周末就可以动手了。不用追求用什么最潮的工具选一个你和团队顺手的方向把第一次自动化部署跑通的感觉还蛮有成就感的。
返回列表