☰
Jenkins+GitLab+Docker实现Spring Boot自动部署:CI/CD完整闭环实践
2026/10/13 1:09:25 网站建设 项目流程

在《基于 Jenkins+GitLab+Docker 实现 Spring Boot 项目自动部署、持续集成、持续交付(无废话版)》前两篇里,基础架子已经搭好了:Jenkins 能监听 GitLab 的提交,Maven 打包能自动跑,构建结果也归档到了 Jenkins。但很多人做到这一步会误以为自动化已经完成,等你兴冲冲打开浏览器访问服务,发现还是旧版本,心里就会凉了半截。

原因其实很简单:之前做的是 CI 的 Build 部分,代码并没有真正“交付”到目标服务器上。自动部署的闭环,关键在于镜像构建、私有仓库推送,以及远程主机上的容器启动动作。这一篇我直接讲“Jenkins 构建成功之后,服务怎么在远程服务器上自动变成新版本”的完整做法。如果你已经能通过 Jenkins 出包,但还在手动 SSH 上去 docker run,这篇能帮你省掉这部分人工操作。

1. 先明确最终部署链路与部署参数约定

1.1 一套完整的部署链路长什么样

我习惯把这条链路分成九步,每一环都必须有明确责任人:

  1. 开发者提交代码到 GitLab,触发 Webhook。
  2. Jenkins 收到通知后,从 GitLab 拉取本次源码。
  3. Maven 执行clean package,产出可运行的 jar 包。
  4. Docker 根据 Dockerfile 构建应用镜像。
  5. Jenkins 登录私有镜像仓库,把带版本号的镜像推送上去。
  6. 通过 SSH 在目标服务器执行部署脚本。
  7. 目标服务器拉取指定 tag 的镜像。
  8. 停止旧容器,删除旧容器,启动新容器。
  9. 健康检查通过,流水线结束;健康检查失败则自动回滚。

前面两步在第二篇已经解决,这篇从第 3 步之后的动作讲起。第 5 步是整个“持续交付”链路里最容易被忽略但最值得加固的部分,很多团队的自动部署不稳定,问题都出在镜像搬运和容器启停这一段。

1.2 本文使用的环境角色与参数约定

为了后面所有命令都能直接抄,先把本文的约定参数列出来。你可以按自己环境替换,但替换时建议保持命名结构一致。

角色地址/名称说明
GitLab 服务器git.example.com代码源,触发流水线
Jenkins 主机worker01执行构建、推送、SSH 部署
私有镜像仓库reg.example.com内网镜像中转站
目标应用服务器192.0.2.10最终跑业务容器的主机
应用 jar 包名app.jarSpring Boot 构建产物
应用端口8080Spring Boot 监听端口
宿主机映射端口8080容器端口映射到主机端口
镜像名reg.example.com/springboot-demo包含仓库地址的完整镜像名
固定容器名springboot-demo部署脚本里增删改查用的名字
部署脚本路径/data/deploy.sh放在目标服务器上

1.3 为什么容器名和主机端口必须固定

很多人第一次写自动部署脚本时,喜欢用 Jenkins 构建号当容器名,比如springboot-demo-42,理由是“每个版本一个容器,好区分”。这个思路适合多版本并存的环境,但不适合单机单实例的典型部署。

部署脚本本质上是个状态机,它必须能快速找到“当前正在运行的版本”,才能做停止、清理、启动、回滚。如果容器名每次都变,脚本就要额外去查容器列表,还要解析名字里的版本号,复杂度凭空翻一倍。所以这里直接用固定的容器名springboot-demo,镜像 tag 保持唯一,容器名不变,谁正在跑一目了然。

主机端口同理。8080 写死在部署脚本里,同一个时间只允许一个容器占用。虽然换端口可以支持蓝绿切换,但那是另一套架构,后面第 5 节会单独说。

2. 镜像瘦身的起点:Spring Boot 应用多阶段构建

2.1 为什么不要再用“一个大 JDK 镜像直接跑 jar”

很多网上教程给的 Dockerfile 是这样:

FROM openjdk:11-jre-slim COPY app.jar app.jar ENTRYPOINT ["java", "-jar", "app.jar"]

这种写法确实能跑,但有两个问题:第一,构建和运行不分离,Jenkins 里 Maven 产出的 jar 是个中间产物,镜像变成了一个普通载体,多阶段构建的价值体现不出来;第二,传统 JDK 镜像体积偏大,传输一个 300MB 的镜像和内网一个 100MB 的镜像,在多次发布时差距很现实。

多阶段构建的思路是:第一个阶段用 Maven 镜像完成编译,第二个阶段只拷入编译好的 jar 和精简 JRE。最终镜像里没有源码、没有 Maven 依赖、没有编译器,攻击面小很多。

2.2 一个可直接落地的 Dockerfile

假设项目基于 Spring Boot 3.x、JDK 17,pom.xml在仓库根目录,源码在src/下:

# 阶段一:构建 FROM maven:3.9.4-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests=false -B # 阶段二:运行 FROM eclipse-temurin:17-jre-jammy RUN groupadd -r app && useradd -r -g app app WORKDIR /app COPY --from=builder /build/target/app.jar app.jar ENV TZ=Asia/Shanghai \ JAVA_OPTS="-Xms256m -Xmx512m" USER app ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

如果项目里的 jar 名不是app.jar,要么在pom.xml的build节点里显式配置<finalName>app</finalName>,要么用COPY --from=builder /build/target/*.jar app.jar统一改名。我建议前者,因为 jar 名固定,Dockerfile 也稳定。

2.3 依赖缓存的分水岭:先 COPY pom.xml 再拉依赖

上面 Dockerfile 里有一行容易被忽略却极其关键:

COPY pom.xml . RUN mvn dependency:go-offline -B

Docker 构建每一条指令都会生成一个 layer,如果前后指令输入没变化,构建时会直接复用缓存。把pom.xml单独复制进镜像,执行一次依赖下载,后面每次改源码再构建时,只要pom.xml没变,这一层缓存就一直有效。

如果图省事写成这样:

COPY . . RUN mvn clean package

那每次代码提交都会导致整个构建阶段缓存失效,Maven 依赖必须全部重新下载,一次构建多出好几分钟。这在“无废话”的自动化流水线里属于不应该出现的时间损耗。

2.4 运行阶段的三个坑

第一,别用 root 运行。容器里服务以 root 运行,一旦 Web 应用有远程命令执行漏洞,攻击者拿到的就是容器内最高权限。加两行建一个普通用户,成本几乎为零。

第二,时区问题。Spring Boot 默认使用 UTC,日志时间会和本地对不上。用ENV TZ=Asia/Shanghai显式设置时区,比在启动参数里额外传-Duser.timezone更干净。

第三,JVM 参数建议用环境变量而不是写死。我在镜像里定义了JAVA_OPTS,在 Jenkinsfile 里如果发现新版本需要更大堆内存,可以通过运行时传入环境变量覆盖,不需要重新出镜像。

3. 私有镜像仓库接入:登录、推送与 Tag 策略

3.1 为什么镜像不能只放在 Jenkins 主机本地

只放在 Jenkins 主机的代价很直接:目标服务器每次都要从 Jenkins 主机拷贝镜像,或重新构建一次。拷贝镜像文件容易产生版本不一致,重新构建则等于把 CI 动作在服务器上又做了一遍。通过内网私有镜像仓库中转,Jenkins 推上去,目标服务器拉下来,链路最干净。

如果你目前还没有私有仓库,最省事的方式是用 Docker 官方 registry 镜像起一个内网服务,端口用 5000。我这边环境里用的地址是reg.example.com,不暴露公网,只在公司内网访问。

3.2 客户端 Docker 配置

新装的 Docker 默认走 HTTPS 访问仓库,而内网 registry 如果没有配证书,目标服务器拉镜像时会报:

Error response from daemon: Get "https://reg.example.com/v2/": http: server gave HTTP response to HTTPS client

应对方式是在每台需要访问仓库的机器上修改 Docker daemon 配置。以 Linux 为例,编辑/etc/docker/daemon.json:

{ "insecure-registries": ["reg.example.com"] }

改完必须重启 Docker:

sudo systemctl restart docker

这里的insecure-registries等于告诉 Docker“这个仓库走 HTTP 是可以接受的”。内网环境图省事没问题,但如果仓库要跨公网访问,强烈建议上 TLS,否则镜像内容在网络传输中是明文。

3.3 Jenkins 里的凭据管理

我习惯在 Jenkins 里存两组凭据:

凭据 ID用途类型
reg-account私有镜像仓库账号密码Username with password
deploy-credential目标服务器 SSH 账号密码Username with password

流水线中通过withCredentials读取,避免在 Jenkinsfile 里写明文。实际项目里我更推荐 SSH 密钥方案:把公钥放进目标服务器的authorized_keys,Jenkins 里保存私钥,不会再出现密码泄露、密码过期这类问题。

3.4 Tag 策略:版本号+短提交,别再只用 latest

镜像 tag 我遵循一个公式:

${BUILD_NUMBER}-${GIT_COMMIT前7位}

比如 Jenkins 第 42 次构建,Git 提交哈希是a3f9c21,镜像 tag 就是42-a3f9c21。这个 tag 本身就能回答三个问题:哪个构建产物、哪次提交、什么时候发布。

部署时同时给这个镜像打一个latest标签,主要是方便开发环境手动拉。但流水线里的部署脚本必须用唯一 tag,不能用 latest。否则线上跑的是不是最新版本,只能靠猜。

推镜像的命令组合是:

docker tag reg.example.com/springboot-demo:42-a3f9c21 reg.example.com/springboot-demo:latest docker push reg.example.com/springboot-demo:42-a3f9c21 docker push reg.example.com/springboot-demo:latest

先推唯一 tag,再推 latest,这样即使处理中断,latest 对应的也至少是某个完整版本。

4. Jenkinsfile 完整闭环:从拉取代码到远程部署

4.1 整条流水线各阶段速览

阶段动作常见失败表现
checkout拉取本次提交代码工作区脏、分支不存在
maven跑单测并打包测试失败、依赖拉不下来
docker build构建多阶段镜像Dockerfile 路径错误、缓存命中旧 jar
docker push推送到私有仓库登录失败、仓库磁盘满
remote deploySSH 执行远程脚本远程脚本卡死、端口占用、健康检查超时
cleanup清理临时构建产物一般不会失败

4.2 一个可用于生产的 Jenkinsfile

pipeline { agent any environment { REGISTRY = 'reg.example.com' PROJECT = 'springboot-demo' IMAGE_NAME = "${REGISTRY}/${PROJECT}" IMAGE_TAG = "${env.BUILD_NUMBER}-${env.GIT_COMMIT?.substring(0, 7)}" DEPLOY_HOST = '192.0.2.10' DEPLOY_USER = 'deployer' } stages { stage('拉取代码') { steps { checkout scm } } stage('Maven 构建') { steps { sh 'mvn clean package -DskipTests=false' } } stage('构建镜像') { steps { sh "docker build -t ${IMAGE_NAME}:${IMAGE_TAG} -t ${IMAGE_NAME}:latest ." } } stage('推送镜像') { steps { withCredentials([usernamePassword( credentialsId: 'reg-account', usernameVariable: 'REG_USER', passwordVariable: 'REG_PASS' )]) { sh """#!/bin/bash set -e echo "${REG_PASS}" | docker login ${REGISTRY} -u "${REG_USER}" --password-stdin docker push ${IMAGE_NAME}:${IMAGE_TAG} docker push ${IMAGE_NAME}:latest """ } } } stage('远程部署') { environment { SSHPASS = credentials('deploy-credential') } steps { sh """#!/bin/bash set -e export SSHPASS sshpass -e ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_HOST} \ "bash /data/deploy.sh ${PROJECT} ${IMAGE_TAG}" """ } } } post { success { echo "部署完成,镜像版本:${IMAGE_TAG}" } failure { echo "部署失败,请检查远程服务器日志" } } }

4.3 SSH 远程执行的三处细节

第一,sshpass -e配合环境变量SSHPASS,避免把密码直接写在命令行。命令行里的进程参数是全用户可见的,裸密码放在那等于主动泄露。

第二,StrictHostKeyChecking=no必不可少。首次 SSH 到目标服务器时,SSH 会提示确认主机指纹,Jenkins 是非交互环境,没人按回车,命令会一直挂着直到超时。这个参数就是告诉 SSH“首次连接也直接通过”。

第三,远程命令尽量写成bash /data/deploy.sh ...,通过 bash 执行。不要用默认的sh,不同发行版/bin/sh可能指向 dash,行为有差异,出问题不好排查。

4.4 远程部署脚本 deploy.sh 第一步版本

目标服务器上的/data/deploy.sh,先写一个基础版:

#!/usr/bin/env bash set -euo pipefail APP_NAME="${1}" IMAGE_TAG="${2}" REGISTRY="reg.example.com" IMAGE="${REGISTRY}/${APP_NAME}" CONTAINER_NAME="${APP_NAME}" HOST_PORT=8080 echo "开始拉取镜像:${IMAGE}:${IMAGE_TAG}" docker pull "${IMAGE}:${IMAGE_TAG}" echo "停止并删除旧容器" docker stop "${CONTAINER_NAME}" || true docker rm "${CONTAINER_NAME}" || true echo "启动新容器" docker run -d \ --name "${CONTAINER_NAME}" \ --restart unless-stopped \ -p "${HOST_PORT}:8080" \ "${IMAGE}:${IMAGE_TAG}" echo "等待健康检查" for i in $(seq 1 30); do if curl -sf "http://127.0.0.1:${HOST_PORT}/actuator/health" > /dev/null; then echo "健康检查通过,第 ${i} 次探测成功" exit 0 fi sleep 2 done echo "健康检查失败,容器启动后未能正常服务" docker logs --tail 50 "${CONTAINER_NAME}" || true exit 1

脚本里set -euo pipefail是 Bash 的严格模式:任何命令失败立即退出、变量未定义就报错、管道中一个环节失败就失败。自动部署脚本如果不用严格模式,某个步骤失败后流水线照样显示绿色,最后线上全是问题。

如果目标服务器没有curl,把健康检查命令换成wget -qO- --timeout=3 http://127.0.0.1:8080/actuator/health也可以,效果一样。

5. 平滑升级与自动回滚:给部署脚本加上自我保护

5.1 先记录旧镜像,再动容器

上面的脚本有个致命缺口:如果新容器启动后健康检查不通过,脚本只是exit 1,旧容器已经没了,服务变成了“完全不可用”。这是刚开始做自动部署的人最容易踩的坑。

要在部署前把当前容器的镜像完整名称记录下来:

OLD_IMAGE=$(docker inspect --format '{{.Config.Image}}' "${CONTAINER_NAME}" 2>/dev/null || echo "")

这条命令从正在运行的容器里取它的镜像名,比如reg.example.com/springboot-demo:41-e3b9c88。只要这个镜像还存在于本地,回滚时就能原封不动恢复。

5.2 完整版 deploy.sh:带自动回滚

在基础版脚本里加上回滚逻辑:

#!/usr/bin/env bash set -euo pipefail APP_NAME="${1}" IMAGE_TAG="${2}" REGISTRY="reg.example.com" IMAGE="${REGISTRY}/${APP_NAME}" CONTAINER_NAME="${APP_NAME}" HOST_PORT=8080 rollback() { echo "回滚到旧版本:${OLD_IMAGE}" docker stop "${CONTAINER_NAME}" || true docker rm "${CONTAINER_NAME}" || true docker run -d \ --name "${CONTAINER_NAME}" \ --restart unless-stopped \ -p "${HOST_PORT}:8080" \ "${OLD_IMAGE}" echo "回滚完成" } OLD_IMAGE=$(docker inspect --format '{{.Config.Image}}' "${CONTAINER_NAME}" 2>/dev/null || echo "") echo "拉取新镜像:${IMAGE}:${IMAGE_TAG}" docker pull "${IMAGE}:${IMAGE_TAG}" docker stop "${CONTAINER_NAME}" || true docker rm "${CONTAINER_NAME}" || true docker run -d \ --name "${CONTAINER_NAME}" \ --restart unless-stopped \ -p "${HOST_PORT}:8080" \ "${IMAGE}:${IMAGE_TAG}" for i in $(seq 1 30); do if curl -sf "http://127.0.0.1:${HOST_PORT}/actuator/health" > /dev/null; then echo "健康检查通过,第 ${i} 次探测成功" exit 0 fi sleep 2 done echo "健康检查失败,准备回滚" rollback exit 1

这里有个细节:旧镜像名来自docker inspect,如果服务器从未部署过该容器,OLD_IMAGE为空,回滚函数会尝试运行一个空镜像名,自然失败。所以在rollback里加一个判断,OLD_IMAGE为空时直接退出并提示“无旧版本可回滚”。

5.3 单机单容器的升级必然有短暂停机,想要零停机得多做一步

老实说,单容器固定端口方案做不到严格零停机。docker stop到新容器启动完成之间,服务至少有几秒钟不可用。对于内部系统或者允许短时闪断的场景,这套方案完全够用。

如果业务要求绝对不能断,常见做法是双端口切换:旧容器继续监听 8080,新容器先在 8081 启动,健康检查通过后,用 Nginx 把流量切到 8081,再停旧容器。这个方案需要在目标服务器上装 Nginx,并把 Nginx 配置放到部署脚本的管理范围里。整体复杂度会高一截,但原理并不复杂。

5.4 回滚的第一风险不是代码,是数据库结构

自动回滚脚本解决的是“应用容器回退”,但新版本如果已经执行了数据库变更,旧代码面对新表结构很可能直接崩溃。在引入 Flyway 或 Liquibase 这类迁移工具时,一定要提前约定:数据库变更必须是向前兼容的,或者旧版本代码能容忍新增字段。这个问题不解决,回滚脚本越自动化,线上事故反而越不可控。

6. 实测中绕不开的三个坑与完整排查链路

6.1 坑一:容器里跑的还是旧 jar,构建缓存惹的祸

症状很奇怪:GitLab 上代码已经更新,Jenkins 控制台显示构建成功,镜像也推送了,但登录目标服务器看日志,业务还是老逻辑。如果你已经手动确认“新镜像确实拉了、容器确实重建了”,那问题十有八九出在 Docker 构建缓存。

我当时的排查过程是这样:

  1. 先看远端容器内 jar 的时间戳:

    docker exec springboot-demo ls -l /app

    发现 jar 时间停留在上一次构建时间,说明打进镜像的 jar 就是旧的。

  2. 回到 Jenkins 主机,重新执行docker build,发现COPY src ./src这一层命中缓存。按理说src变了,Docker 会自动失效该层缓存。继续往下查,发现 Jenkins 工作区里的src目录虽然是最新的,但target/目录里残留了上一次构建的旧 class 文件。

  3. 真正原因浮出水面:我在 Jenkinsfile 里只写了mvn package,没写mvn clean package。Maven 打包时如果target/classes下的旧 class 文件没清理,恰巧有些新代码对应的类在增量编译时没被覆盖,打进 jar 的就是混合代码。

解决方式很简单,把打包命令从mvn package全面改成mvn clean package。同时,Dockerfile 里也应该在构建阶段再次执行mvn clean package,形成双保险。这个坑排查链路不长,但复现概率很高,尤其是当你修改过.gitignore或者 Jenkins 工作区没有及时清理的时候。

6.2 坑二:SSH 远程部署步骤在 Jenkins 里“假死”

现象是流水线卡在远程部署阶段,既不报错也不结束,直到 Jenkins 全局超时时间到了才失败。如果你在 Jenkins 主机上手动跑同样的 ssh 命令却正常,那问题在 SSH 非交互模式的细节。

排查链路:

  1. 手动执行 Jenkins 里的原命令,前面用timeout 10包一层,发现命令会一直停在那里。
  2. 用ssh -v打开调试输出,看到 SSH 在等待确认 host key。第一次连接时,SSH 会询问“是否信任该主机”,非交互环境下没人回答,就一直挂着。StrictHostKeyChecking=no 就是解决这个问题的。
  3. 还有一次,远程脚本里docker login时提示输入密码,交互式询问挂住了整个远程会话。把登录信息全部改成从环境变量读取后,问题消失。

最后形成的规范是:

sshpass -e ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 user@host 'bash /data/deploy.sh xx yy' < /dev/null

末尾的< /dev/null尤为重要,它确保 SSH 不会从标准输入读取任何交互式数据。远程脚本再也不用担心被一个莫名输入卡死。

6.3 坑三:容器看起来正常,健康检查却一直失败

现象是docker ps显示容器是Up状态,但部署脚本循环 60 秒后依然超时。这时候不要急着怀疑端口映射,先看容器日志。

排查过程:

  1. 登录目标服务器,执行docker logs --tail 100 springboot-demo。
  2. 发现 Spring Boot 启动日志在“开始加载数据源”这一步反复出现连接超时。应用依赖的数据库不在本机,而数据库服务器在维护期。
  3. 原来不是应用代码问题,是周边依赖不可用。容器本身 Spring 进程还在,但 Actuator 健康检查接口一直返回 502,因为数据源连接池建立失败。

处理思路有两个层面:部署脚本层面,健康检查循环次数可以适当增加,给应用更长的启动时间,尤其是首次从私有仓库拉完镜像、冷启动 JVM 的场景;架构层面,如果应用强依赖外部服务,容器编排里应该加“依赖就绪”检查,或者至少把数据库等基础软件的可用性纳入部署前巡检。

健康检查失败时,脚本会输出docker logs --tail 50,这个设计有讲究。容器启动失败的原因千奇百怪,没有日志直接看全靠猜,把最近 50 行日志自动打出来,问题定位时间能压缩一大截。

7. 一些自动化部署之外的建议

整个方案跑通之后,建议给 Jenkins 主机和目标服务器都加上镜像清理计划。私有仓库和本地 Docker daemon 会随着版本堆积越来越大,Jenkins 磁盘被构建产物塞满的情况很常见。可以写一个定时任务,保留最近 10 个版本的镜像和 tag,其余自动清理。

还有一件事:部署脚本里的固定时间等待和固定探测次数,只适合第一版自动化。如果团队对发布质量要求高,建议后续把健康检查升级成“多维度探活”,比如检查 Actuator health 之外,再看一个业务自研的只读接口,确认不仅进程活着,业务核心链路也是通的。

回顾整套流程,最值得反复打磨的不是 Jenkinsfile 语法,而是部署脚本的容错设计。把异常路径处理清楚,自动部署才能真的做到无人值守。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询