在《基于 Jenkins+GitLab+Docker 实现 Spring Boot 项目自动部署、持续集成、持续交付(无废话版)》前两篇里,基础架子已经搭好了:Jenkins 能监听 GitLab 的提交,Maven 打包能自动跑,构建结果也归档到了 Jenkins。但很多人做到这一步会误以为自动化已经完成,等你兴冲冲打开浏览器访问服务,发现还是旧版本,心里就会凉了半截。
原因其实很简单:之前做的是 CI 的 Build 部分,代码并没有真正“交付”到目标服务器上。自动部署的闭环,关键在于镜像构建、私有仓库推送,以及远程主机上的容器启动动作。这一篇我直接讲“Jenkins 构建成功之后,服务怎么在远程服务器上自动变成新版本”的完整做法。如果你已经能通过 Jenkins 出包,但还在手动 SSH 上去 docker run,这篇能帮你省掉这部分人工操作。
1. 先明确最终部署链路与部署参数约定
1.1 一套完整的部署链路长什么样
我习惯把这条链路分成九步,每一环都必须有明确责任人:
- 开发者提交代码到 GitLab,触发 Webhook。
- Jenkins 收到通知后,从 GitLab 拉取本次源码。
- Maven 执行
clean package,产出可运行的 jar 包。 - Docker 根据 Dockerfile 构建应用镜像。
- Jenkins 登录私有镜像仓库,把带版本号的镜像推送上去。
- 通过 SSH 在目标服务器执行部署脚本。
- 目标服务器拉取指定 tag 的镜像。
- 停止旧容器,删除旧容器,启动新容器。
- 健康检查通过,流水线结束;健康检查失败则自动回滚。
前面两步在第二篇已经解决,这篇从第 3 步之后的动作讲起。第 5 步是整个“持续交付”链路里最容易被忽略但最值得加固的部分,很多团队的自动部署不稳定,问题都出在镜像搬运和容器启停这一段。
1.2 本文使用的环境角色与参数约定
为了后面所有命令都能直接抄,先把本文的约定参数列出来。你可以按自己环境替换,但替换时建议保持命名结构一致。
| 角色 | 地址/名称 | 说明 |
|---|---|---|
| GitLab 服务器 | git.example.com | 代码源,触发流水线 |
| Jenkins 主机 | worker01 | 执行构建、推送、SSH 部署 |
| 私有镜像仓库 | reg.example.com | 内网镜像中转站 |
| 目标应用服务器 | 192.0.2.10 | 最终跑业务容器的主机 |
| 应用 jar 包名 | app.jar | Spring Boot 构建产物 |
| 应用端口 | 8080 | Spring 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 -BDocker 构建每一条指令都会生成一个 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 deploy | SSH 执行远程脚本 | 远程脚本卡死、端口占用、健康检查超时 |
| 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 构建缓存。
我当时的排查过程是这样:
先看远端容器内 jar 的时间戳:
docker exec springboot-demo ls -l /app发现 jar 时间停留在上一次构建时间,说明打进镜像的 jar 就是旧的。
回到 Jenkins 主机,重新执行
docker build,发现COPY src ./src这一层命中缓存。按理说src变了,Docker 会自动失效该层缓存。继续往下查,发现 Jenkins 工作区里的src目录虽然是最新的,但target/目录里残留了上一次构建的旧 class 文件。真正原因浮出水面:我在 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 非交互模式的细节。
排查链路:
- 手动执行 Jenkins 里的原命令,前面用
timeout 10包一层,发现命令会一直停在那里。 - 用
ssh -v打开调试输出,看到 SSH 在等待确认 host key。第一次连接时,SSH 会询问“是否信任该主机”,非交互环境下没人回答,就一直挂着。StrictHostKeyChecking=no 就是解决这个问题的。 - 还有一次,远程脚本里
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 秒后依然超时。这时候不要急着怀疑端口映射,先看容器日志。
排查过程:
- 登录目标服务器,执行
docker logs --tail 100 springboot-demo。 - 发现 Spring Boot 启动日志在“开始加载数据源”这一步反复出现连接超时。应用依赖的数据库不在本机,而数据库服务器在维护期。
- 原来不是应用代码问题,是周边依赖不可用。容器本身 Spring 进程还在,但 Actuator 健康检查接口一直返回 502,因为数据源连接池建立失败。
处理思路有两个层面:部署脚本层面,健康检查循环次数可以适当增加,给应用更长的启动时间,尤其是首次从私有仓库拉完镜像、冷启动 JVM 的场景;架构层面,如果应用强依赖外部服务,容器编排里应该加“依赖就绪”检查,或者至少把数据库等基础软件的可用性纳入部署前巡检。
健康检查失败时,脚本会输出docker logs --tail 50,这个设计有讲究。容器启动失败的原因千奇百怪,没有日志直接看全靠猜,把最近 50 行日志自动打出来,问题定位时间能压缩一大截。
7. 一些自动化部署之外的建议
整个方案跑通之后,建议给 Jenkins 主机和目标服务器都加上镜像清理计划。私有仓库和本地 Docker daemon 会随着版本堆积越来越大,Jenkins 磁盘被构建产物塞满的情况很常见。可以写一个定时任务,保留最近 10 个版本的镜像和 tag,其余自动清理。
还有一件事:部署脚本里的固定时间等待和固定探测次数,只适合第一版自动化。如果团队对发布质量要求高,建议后续把健康检查升级成“多维度探活”,比如检查 Actuator health 之外,再看一个业务自研的只读接口,确认不仅进程活着,业务核心链路也是通的。
回顾整套流程,最值得反复打磨的不是 Jenkinsfile 语法,而是部署脚本的容错设计。把异常路径处理清楚,自动部署才能真的做到无人值守。