前两篇把环境、工具、基础配置都搭好了这篇直接进正题手动点构建、看控制台日志的时代已经过去了我们直接让代码从推送到上线全程无人值守。严格说这篇文章要解决的就是两个问题推代码后自动构建出可部署的镜像以及新镜像出来后自动替换掉正在运行的旧容器。同时还会把回滚、镜像清理、多分支策略这些实战里躲不开的细节一并说清楚全程没有废话。整套方案围绕一个非常典型的SpringBoot服务展开代码在Gitlab上Jenkins负责编排Docker负责提供一致的运行环境和交付载体。下面从链路设计开始逐步拆到每一步的具体写法。1. 先把CI/CD链路走一遍再谈配置1.1 从一次git push开始整个系统是怎么转起来的很多人一上来就写Jenkinsfile我建议先花十分钟把链路在脑子里过一遍。不搞清楚每个环节的输入输出写出来的流水线多半是拼凑出来的跑通一次容易长期稳定很难。这条链路实际上是这样的开发者执行git push把代码推送到Gitlab的某个分支。Gitlab收到推送后根据仓库里配置的Webhook规则向Jenkins发出一个HTTP请求。Jenkins收到请求后找到对应的流水线任务开始执行该任务里定义的一系列步骤。比如先拉取代码接着跑单元测试和打包然后构建Docker镜像并推送到镜像仓库。如果流水线配置了部署环节最后一步就是通知目标服务器拉取新镜像并重新启动容器。这里有一个很重要的认知持续集成和持续交付的分界线在于“是否自动完成了部署”。只做到镜像推送到仓库那是持续集成做到新版本自动跑起来那才是持续交付。这篇文章把两部分都覆盖到但部署环节会单独拿出来详细写因为这一块最容易出问题。再说一下为什么要选择“Jenkins通过SSH远程操作部署服务器”这个方案。现在市面上有很多更复杂的方案比如在目标服务器装Agent或者引入专门的编排平台。但对一个中小团队或者单个SpringBoot服务来说这些方案引入的额外复杂度远大于收益。通过SSH远程执行命令整个过程透明可控Jenkins控制台日志里每一步操作都看得见出了问题能直接定位。目标服务器不需要装任何Jenkins相关组件只需要能跑Docker就可以了。1.2 为什么把Pipeline当作核心而非一堆插件刚开始接触Jenkins的人很容易陷入插件陷阱觉得插件装得越多越厉害。实际上插件只负责打通系统间的基础连接真正决定流程质量的是Pipeline本身。把构建流程写进Jenkinsfile和用界面点鼠标配置构建任务是完全不同的维护体验。我的切身体会是这样用界面配置构建任务流程改动一次就要进到设置页面翻半天而且改完之后没有版本记录。谁改的、哪次改的、为什么改全是一笔糊涂账。而Jenkinsfile是放在Git仓库里的文本文件每次改动都有commit记录和代码一样走评审流程出了问题还能看到历史版本。论可追溯性后者完胜。Pipeline带来的另一个好处是失败定位直观。构建任务如果挂在Maven步骤日志直接显示是哪条命令、哪个stage出的错。整个流程分成了多个stage之后一眼就能看到“拉取代码正常、测试正常、打包正常、构建镜像失败”排查范围瞬间缩小。这种体验在流水线跑到几十次之后价值特别明显。所以我这篇不太讲插件怎么点重点放在Jenkinsfile怎么写、各个stage为什么这样安排。因为这才是整套流程里真正有技术含量的部分。2. Jenkinsfile核心写法一份能直接改改就用的Pipeline2.1 声明式Pipeline骨架以及每个字段为什么这样写直接上一份我在实际项目中使用的Jenkinsfile针对一个标准的SpringBoot服务。这个文件放在Git仓库根目录名字就叫JenkinsfileJenkins会自动去仓库里找它。pipeline { agent any environment { // 镜像仓库地址按实际情况替换 REGISTRY registry.example.com // 项目名会拼进镜像名和容器名 PROJECT_NAME demo-service // 部署服务器连接信息 DEPLOY_HOST 192.168.1.100 DEPLOY_USER deploy DEPLOY_PORT 22 // 服务器上的部署脚本目录 DEPLOY_PATH /opt/${PROJECT_NAME} } parameters { string(name: BRANCH, defaultValue: main, description: 要构建的分支) string(name: VERSION, defaultValue: , description: 镜像版本号留空则用构建号) choice(name: ACTION, choices: [build_and_deploy, build_only], description: 构建并部署或仅构建) } stages { stage(拉取代码) { steps { checkout scm } } stage(单元测试) { steps { sh mvn test -q } } stage(打包) { steps { sh mvn package -DskipTests -q } } stage(构建镜像) { steps { script { def imageTag params.VERSION ?: ${env.BUILD_NUMBER} env.IMAGE_TAG imageTag sh docker build -t ${REGISTRY}/${PROJECT_NAME}:${imageTag} . docker push ${REGISTRY}/${PROJECT_NAME}:${imageTag} } } } stage(部署) { when { expression { params.ACTION build_and_deploy } } steps { script { sshCommand( host: params.DEPLOY_HOST, user: params.DEPLOY_USER, port: params.DEPLOY_PORT, command: sh ${DEPLOY_PATH}/deploy.sh ${PROJECT_NAME} ${REGISTRY} ${env.IMAGE_TAG} ) } } } } post { success { echo 构建成功镜像标签为 ${env.IMAGE_TAG} } failure { echo 构建失败请检查上方日志 } } }几个关键细节单独拿出来说。environment块里的变量可以在stages里到处用好处是改一处全局生效。比如镜像仓库迁移了只需要改REGISTRY这个值后面所有构建和推送的地址都跟着变。之前我用过一个写法把仓库地址散落在各个stage的shell命令里结果换一次仓库地址改了五六个地方还漏了一处推送到了旧地址。教训很深刻。parameters里的ACTION字段是后来补上的。为什么要加因为实际工作中经常出现只想验证代码能不能编译打包、不想触发部署的情况。比如依赖的下游服务还没准备好或者联调环境正在调整这时候部署上去反而添乱。给了这个选择之后每次构建之前可以灵活决定这次是要全流程走完还是只到镜像构建这一步。checkout scm这行看起来简单实际承担了很重的职责。它会根据当前流水线关联的仓库地址和分支信息拉取代码。在多分支流水线场景下它还能自动识别当前触发构建的分支不需要你手动指定。这一点到第三章会展开说。2.2 镜像标签策略不要在生产环境用latest镜像标签的选择直接决定了回滚能力。在本地开发时打latest图方便没问题一旦上了生产自动化部署lateest风格的标签会让你在回滚时抓狂。原因很简单latest不代表一个确定的版本你只知道“最新”不知道具体是哪一次构建的产物。我采用的方案是用构建号作为镜像标签的主体比如构建号是47那镜像标签就是47。同时为了让镜像和代码提交关联得更直接可以在镜像标签中附加Git短哈希比如47。这样一来看到镜像标签里有47这样的长标签既能知道它是第47次构建出来的又能定位到对应的代码提交点。具体实现就是在构建镜像的stage里把env.BUILD_NUMBER和Git短哈希拼在一起作为标签。标签策略定下来之后回滚操作就变得很简单了知道了当前线上版本是哪一次构建要回到哪一次在Jenkins上手动触发一次构建VERSION参数填上目标构建号整体重跑一遍部署流程就行。2.3 凭据管理敏感信息绝不写进代码密码和密钥写进Jenkinsfile是最大的安全反模式我早期也犯过这个错。后来被安全评审打回才老老实实改用了Jenkins的凭据中心。凭据中心的好处是敏感信息只存在Jenkins服务器上代码仓库里看不到密钥内容团队成员拿不到服务器密码人员变动时只需在Jenkins里及时吊销对应凭据不用改服务器上所有密码。以部署用的SSH私钥为例做法是在Jenkins中创建一个SSH User Private Key类型的凭据在Pipeline的environment块里通过credentials方法引用它environment { SSH_KEY credentials(deploy_ssh_key) }引用了这个变量之后后面所有需要SSH鉴权的操作都可以直接使用这个凭据不需要手写密码或密钥内容。SSH插件的sshCommand步骤会自动读取该环境变量对应的私钥完成认证。镜像仓库的账号密码也用同样的方式创建Username with password类型的凭据在docker login的时候引用。记住一条原则凡是能在凭据中心存放的敏感信息一律不写进代码。3. Gitlab和Jenkins联动细节Webhook与多分支策略3.1 配置Webhook让Gitlab主动触发Jenkins代码推送到Gitlab之后有两种方式通知Jenkins轮询和Webhook。轮询是Jenkins定期去Gitlab检查有没有新提交这样做的缺点很明显有延迟而且会产生大量无意义的请求。Webhook则是Gitlab在代码发生变更时主动通知Jenkins几乎零延迟资源消耗也小得多。配置Webhook的步骤不复杂。先在Jenkins的系统设置里找到API Token这个Token用来让Gitlab请求Jenkins时有合法身份。然后到Gitlab仓库的Settings → Webhooks页面填入Jenkins的触发地址格式大致是http://jenkins地址/project/流水线名称事件类型勾选Push events有合并请求场景的再勾上Merge request events。然后点击Add webhook再点一下Test按钮验证连通性。配置正确的话点Test的瞬间Jenkins就会开始跑一次构建。这里有几个坑值得提前提醒。第一Gitlab和Jenkins之间的网络要通。很多人在本地测试时把地址填成localhostWebhook请求在Gitlab服务器上根本找不到Jenkins。如果两台服务器之间有防火墙或安全组规则要先把端口放通再配置Webhook。第二Push events的过滤规则。如果该字段留空意味着仓库里任何一个分支的推送都会触发Jenkins构建。这在多分支协作时容易造成构建风暴。建议指定需要触发构建的分支模式例如main和release/*其他分支的推送不触发流水线。第三安全校验很重要。建议在Webhook配置里勾选Enable SSL verification并且设置一个Secret Token。这个Token需要同步配置到Jenkins侧否则请求会被拒绝。加上这层校验之后别人就无法伪造请求来触发你的构建任务了。3.2 多分支流水线让分支规范自动生效如果团队采用常见的分支管理模型比如main分支对应生产、develop分支对应测试、feature分支只做验证那么多分支流水线会让整个流程自动起来。多分支流水线是Jenkins的一种任务类型它会自动扫描Gitlab仓库里的所有分支和合并请求为每个分支创建一个流水线实例。每个实例都读取该分支上的Jenkinsfile来执行所以分支策略可以统一写在代码里。这样做的好处直接体现在新增分支时开发者推了一个feature分支到远端Jenkins自动为它创建对应的流水线任务不用人工干预。每个分支的任务之间互相独立跑挂了也不会影响其他分支的构建。在Jenkinsfile里可以通过env.BRANCH_NAME这个内置变量拿到当前分支名结合when条件来判断是否进入部署环节。举个例子stage(部署到生产) { when { branch main } steps { // 只有main分支的流水线才会执行这里的部署逻辑 } }这样写的意思很明确feature分支的代码推送只触发测试和打包不会部署到任何环境main分支的推送才会走完整流程进入生产部署。如果后续想让develop分支自动部署到测试环境只需要再加一个when条件逻辑完全交给Jenkinsfile控制不需要人工维护多个构建任务。4. SpringBoot镜像构建和部署脚本重点全在这里4.1 Dockerfile多阶段构建镜像体积有效瘦身一个SpringBoot项目的Dockerfile核心是解决两件事用什么来编译用什么来运行。网上一搜一大把教程很多还是老式的单阶段写法把Maven和JDK全塞进一个镜像里。这样做镜像体积大安全风险也高生产环境很不推荐。推荐使用多阶段构建。第一阶段使用包含Maven和JDK的镜像做编译第二阶段只使用一个精简的JRE镜像来运行jar包。看一下具体写法FROM maven:3.8-jdk-11 AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline -q COPY src ./src RUN mvn package -DskipTests -q FROM openjdk:11-jre-slim COPY --frombuild /app/target/demo-service.jar /app/app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, /app/app.jar]注意里面有两个FROM这就是多阶段构建的标记。最终产出的镜像只包含第二阶段的JRE和jar包第一阶段的所有编译工具和下载的依赖都不会保留。我实测下来同一个SpringBoot项目单阶段构建出来的镜像有500多MB改成多阶段之后直接降到200MB左右体积差距非常明显。体积小带来的好处是直接的镜像推送更快、部署服务器拉取的流量更少、容器启动时间更短、潜在的安全漏洞面更小。在生产环境多台服务器同时拉取镜像的时候每省下100MB都是肉眼可见的速度提升。第一阶段里RUN mvn dependency:go-offline这一步也不可忽略。它的作用是提前把pom.xml里声明的依赖全部下载到本地仓库。因为Docker构建是有层缓存的只要pom.xml没有变化这一层缓存就不会失效后续改代码再构建时Maven不会重复下载依赖构建速度提升明显。第二阶段选择openjdk:11-jre-slim而不是jdk道理很简单运行时只需要JREJDK里那些编译器工具完全用不上装了反而增加体积和暴露面。4.2 部署脚本旧容器如何换成新容器镜像是构建出来了真正让它跑起来的部署脚本才是收口的一步。放在服务器上的deploy.sh长这样#!/bin/bash PROJECT_NAME$1 REGISTRY$2 IMAGE_TAG$3 CONTAINER_NAME${PROJECT_NAME}_container IMAGE_NAME${REGISTRY}/${PROJECT_NAME}:${IMAGE_TAG} DEPLOY_PATH/opt/${PROJECT_NAME} echo 拉取镜像: ${IMAGE_NAME} docker pull ${IMAGE_NAME} echo 停止并移除旧容器 docker stop ${CONTAINER_NAME} || true docker rm ${CONTAINER_NAME} || true echo 启动新容器 docker run -d \ --name ${CONTAINER_NAME} \ -p 8080:8080 \ -e SPRING_PROFILES_ACTIVEprod \ -v ${DEPLOY_PATH}/logs:/app/logs \ --restart unless-stopped \ ${IMAGE_NAME} echo 检查容器状态 sleep 5 docker ps | grep ${CONTAINER_NAME}脚本的几个细节分别解释一下为什么这样处理。docker stop和docker rm后面都加了|| true是为了避免“容器不存在”导致脚本执行中断。第一次部署场景下服务器上本来就没有旧容器如果不加这个容错脚本在停止旧容器的环节就直接报错退出了后续的新容器启动根本不会执行。加了|| true之后容器存在就正常停掉删掉不存在就跳过继续往下走。端口映射8080:8080是SpringBoot默认端口直接映射到宿主机。如果一台服务器上只部署一个服务这个写法很合适。如果一台机器要跑多个服务就得对应改成其他宿主端口比如8081:8080。这个要根据实际部署拓扑来调整不能一概而论。日志目录通过-v挂载出来是因为容器一旦被删容器内部的日志文件会跟着消失。线上出了问题需要排查时没有历史日志是非常被动的。把宿主机的/opt/{项目名}/logs挂到容器内的/app/logs日志就保留在宿主机上容器怎么删都不会丢。这里直接使用docker run而不是docker-compose是我有意做的选择。单个SpringBoot服务的场景下docker run配合一个写好的deploy.sh已经足够清晰而且少了一层配置。如果你的项目同时要启动多个服务比如数据库、缓存、消息队列一起跑那确实应该用docker-compose部署命令可以简化为docker compose up -d --force-recreate。原则是别为了复杂而复杂够用就好。4.3 快速回滚一条命令恢复旧版本前面提到镜像标签采用构建号作为版本标识这个设计最大的价值就体现在回滚上。整个回滚操作其实非常简单在Jenkins上手动触发一次构建ACTION选build_and_deployVERSION参数填上上一次稳定运行时的构建号然后等流水线跑完线上服务就恢复到那个版本的状态。为什么能这样做因为deploy.sh接收的第三个参数IMAGE_TAG就是镜像版本号。VERSION参数传入旧构建号docker pull拉取的就是旧镜像docker run启动的也就是旧版本。整个回滚过程和你平时部署新版没有区别只是拉取的镜像版本不同。不需要额外写回滚逻辑部署机制本身就自带回滚能力。在实际项目中我遇到过好几次需要立即回滚的场景。比如新版本发布后监控显示接口超时率明显上升研发需要时间定位问题但线上挂着一个有问题的版本太久会影响用户体验。这时候直接在Jenkins触发一次构建填上旧版本号几分钟就能恢复服务。等研发找到根因修复后再重新走正常发布流程。整个过程不需要登录服务器敲任何命令也不需要运维人工介入。有一点要提醒服务器上保留的旧镜像要足够多至少保留最近5到10个版本。如果只留了当前版本回滚时旧的镜像已经被清理掉了docker pull会重新从仓库拉取回滚时间会变长。镜像清理策略见下一章。5. 实战中反复踩到的坑汇总成一份排查手册5.1 Webhook显示请求成功Jenkins就是不构建这个问题我遇到不止一次。Gitlab的Webhook测试显示HTTP 200但Jenkins侧毫无反应构建列表一直空着。排查路径按这个顺序来。先看Jenkins的系统日志确认有没有收到这个请求。如果连请求日志都没有那就是网络问题比如Gitlab没法访问到Jenkins的地址或者防火墙拦截了端口。这种情况先去测一下两台机器之间的连通性用telnet或curl直接访问Jenkins地址看通不通。如果Jenkins日志能看到请求进来了但构建没触发问题多半出在事件过滤上。比如你只在Webhook的Push events里指定了main分支但实际推送的是feature分支那请求虽然到了Jenkins也会被过滤掉。把Webhook的过滤规则和你期望触发的分支对一下大概率能找到原因。还有一个容易被忽略的点如果Jenkins任务配置了“构建触发器”里的Webhook Token但是Gitlab那边填的Secret Token对不上请求会直接认证失败。这种情况通常会在Jenkins控制台或系统日志里看到401或403的报错核对一下两边Token是否一致即可。5.2 Maven在Jenkins里构建时下载依赖失败这个问题在内网环境中非常常见。开发者在本地开发时Maven可以正常访问中央仓库但跑到Jenkins流水线里就提示无法下载依赖。原因通常是Jenkins服务器所在网络无法访问外网或者访问中央仓库的速度极慢超时。解决办法是配置内网Maven私服比如Nexus这类仓库管理工具。具体操作分为两步把Maven的settings.xml放在Jenkins服务器上全局生效的位置并把中央仓库地址替换成内网私服地址同时把pom.xml里需要的依赖都提前同步到私服上。如果项目使用了Docker多阶段构建还需要注意构建阶段里Maven的网络配置。一种情况是Jenkins服务器上能访问中央仓库但进入Docker构建后下载失败这种多半是Docker构建时的网络设置问题。确保构建阶段能使用正确的DNS解析和网络配置问题就能解决。建议在流水线里加上一步先验证Maven配置是否生效比如执行mvn help:evaluate或mvn dependency:list来确认依赖解析正常不要一上来就跑完整打包。5.3 容器启动后几秒就退出SpringBoot容器启动失败的排查核心就一句话看日志。命令行执行docker logs -f 容器名日志会直接告诉你应用为什么退出。端口被占用、数据库连接不上、配置文件缺失、注册中心连不上这些问题都会在应用日志里有所体现。多数情况下容器退出的根因是应用自身启动失败而非Docker配置问题。这时候应该先看SpringBoot的启动日志而不是改Docker参数。还有一类情况是容器反复重启启动起来过几秒又被杀掉陷入循环。这种情况通常和健康检查机制有关。如果项目使用了编排工具并配置了health check检查路径和SpringBoot实际暴露的健康端点不匹配编排平台会判定容器不健康反复重启。把健康检查的路径和SpringBoot acuator的实际端点对比一下能快速定位。另一个细节是优雅停机。docker stop命令会向容器主进程发送SIGTERM信号SpringBoot应用默认会在收到这个信号后开始关闭流程。但如果你的应用里有线程池、数据库连接池等资源建议在部署脚本里显式等待容器彻底停止或者调整优雅停机超时参数保证旧容器完全释放资源后新容器再启动避免端口或文件锁冲突。5.4 多次构建后服务器磁盘爆满磁盘占满是自动化部署这种反复构建模式下的必然结果只是时间早晚问题。每次流水线跑一次就要拉一次基础镜像、构建一次新镜像、推送一次镜像。这些镜像和构建缓存都会占磁盘空间几天不清理几十个G就没了。解决方法是定期清理Docker的悬空镜像和构建缓存docker image prune -f docker builder prune -f第一条命令清理没有被任何容器使用的悬空镜像第二条清理构建过程中产生的缓存层。对镜像版本管理比较规范的项目还可以定期删除保留版本之外的老镜像比如只保留最近10个版本其余全部删除。这里有一个安全底线清理镜像时一定不能误删正在运行的容器所依赖的镜像。建议在清理脚本里加一个保护逻辑先获取当前运行容器的镜像列表清理时跳过这些镜像。镜像清理可以在部署服务器上配置成定时任务每日执行一次。磁盘监控也可以配置一个告警空间超过某个阈值时提醒管理员介入这样双保险更稳妥。5.5 部署后服务间调用失败网络模式要考虑清楚SpringBoot服务如果是一个微服务部署时还需要考虑网络互通问题。比如服务A和服务B都部署在Docker容器里服务A需要调用服务B这时候如果A里配置的B地址还是localhost必然调用失败。因为容器间的网络是隔离的每个容器都有自己的网络命名空间。比如服务A容器内通过localhost无法访问服务B。解决方式取决于容器启动方式。最简单的方式是让所有部署在同一个宿主机上的容器共用宿主网络模式这样服务间可以通过localhost互相访问。如果要求更灵活的网络策略建议改用docker-compose部署让它创建一个自定义网络服务之间通过服务名进行访问。这一块没有统一的银弹要根据实际的项目架构来决定。如果你的SpringBoot服务依赖不多一个容器独立跑那默认bridge网络没有任何问题如果多个服务要互相调用提前把网络模式规划好能省去后面大量的联调排查时间。最后聊几点实在的体会这套用Jenkins、Gitlab和Docker搭起来的SpringBoot自动化部署方案我前前后后在多个不同的项目里反复用过。整体跑起来之后最大的感受是发布不再是一个让人紧张的操作。代码推送后一切都自动进行你能在Jenkins的构建记录里看到每个版本的构建过程和结果线上出问题时也能快速定位到对应的代码提交点。如果你是第一次搭这套东西我建议按照这样的节奏推进先把“推送代码后自动构建出镜像”这一环跑通确认Gitlab的Webhook和Jenkins的流水线配合没问题再做“镜像自动部署到服务器”这一步把deploy.sh调顺最后再考虑加测试覆盖率门禁、多环境管理、动静分离之类的增强项。每一步都验证稳定之后再往下走稳扎稳打。最后分享一个小细节在整个CI/CD体系里最值钱的不是那些炫酷的配置而是构建和部署过程中留下的日志与记录。每次发布之后能回答这些问题——这个版本对应哪次构建、哪个代码提交、哪次部署、线上是哪台服务器在跑哪个镜像——就已经比大多数团队强了。这些记录就是你线上排查和快速回滚的底气。