ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot自动化部署:基于GitLab CI与Docker的完整实践

SpringBoot自动化部署:基于GitLab CI与Docker的完整实践

如果你所在的团队还在用“上传Jar包 + kill旧进程 + nohup启动”的方式发布SpringBoot应用,我觉得这篇东西值得你认真看看。我用GitLab CI和Docker把整套发布流程改造成了自动流水线:代码一推,构建、打包镜像、推仓库、远程部署全部自动完成,整个过程不需要任何人登录服务器。文章不是讲一堆抽象概念,而是把我从零搭建这套链路时踩过的坑、想清楚的取舍、以及真正能用起来的配置都写出来,适合已经在用SpringBoot但还没跑通自动部署的团队参考。

1. 手动发布到底有多痛:一个还算常见的真实场景

先描述一下很多人熟悉的发布现场。某次上线,你本地跑通所有测试,然后开始五步走:第一步用Maven打Jar包,注意要跳过测试、要打正式环境Profile;第二步用scp、rsync或者某个网盘把几十上百MB的Jar传到服务器;第三步登上去找进程号,ps -ef | grep java,然后kill -9,运气不好还会遇到进程杀不掉的情况;第四步用nohup java -jar xxx.jar > nohup.out 2>&1 &启动,等个几十秒,打开日志看看有没有启动成功;第五步确认端口通了、注册中心没报错,发布完成。

这套流程如果只是每两周发一次版,勉强能忍。但一旦迭代节奏加快,或者赶上多人并行改需求,痛点就全暴露了:谁最后一次改过服务器的启动参数,没人说得清;生产环境的Jar包到底是不是和GitLab上那个提交对应,靠人工核对;发布的时候手滑把测试环境配置带上去了,或者旧进程没杀干净,新进程起不来,只能在晚上十一点盯着冒烟日志干瞪眼。

我待过的某团队就是从这种状态里下决心改造的。一开始大家想上Jenkins,但团队本来就重度使用GitLab,代码托管、Merge Request评审都在上面,再维护一套额外的Jenkins费心费力。后来发现GitLab自带的CI/CD功能已经够用:Pipeline和MR天然关联,MR里就能看到构建结果,合并之后还可以自动触发部署。再配合Docker把应用和环境一起打包,至少能让“开发机器能跑”和“生产环境能跑”的差距缩小一大截。

自动化要解决的核心问题不是“省掉一个人”,而是把可重复的操作变成脚本和配置,把人为犯错的概率压到最低。构建用哪个JDK版本、依赖从哪里拉、镜像打什么标签、部署脚本往哪台机器执行、回滚切到哪个版本,这些全部固化在代码仓库里,任何人去查看仓库都能搞清楚“这个应用是怎么发布出去的”。

也有人会问:上Kubernetes不是更彻底吗?确实,如果团队已经具备维护K8s集群的能力,那滚动更新、自动扩缩容、故障自愈都更省心。但对于很多中小团队而言,跑上一套K8s本身就成了新负担。用GitLab CI + Docker Compose做单机或少量多机部署,已经能覆盖大多数SpringBoot应用的真实需求,这也是这篇文章的主要思路。

2. 基础架构与Runner选型:CI流水线到底跑在哪台机器上

2.1 三种常见落地方案对比

搭建之前先明确一件事:GitLab CI本身不干活,真正干活的是Runner。

Runner是一个执行任务的程序,它可以从GitLab服务器领取任务,在Runner所在的机器上执行.gitlab-ci.yml里定义的脚本。Runner的注册方式有很多,但选择不同,后续维护成本和安全性差异很大。我把常见的三种做法放在一起对比:

方案工作方式优点缺点
Shell ExecutorRunner直接在机器上执行Shell命令配置简单,没有Docker嵌套问题,镜像构建、部署都直连本机环境环境依赖较重,机器上得装好各种软件;不同项目之间环境容易互相影响
Docker Executor + Docker Socket任务跑在容器里,容器通过挂载宿主机的Docker守护进程来执行Docker命令每个项目可以指定不同构建镜像,环境隔离性好挂载Docker Socket有安全隐患,需要控制Runner所在机器的可信度
Docker Executor + DinD容器里再启动一个Docker守护进程来完成构建隔离最彻底,适合多租户场景配置更复杂,Docker-in-Docker的网络和存储偶尔有莫名其妙的坑

我当时的取舍是:构建机和部署机都用独立的云主机,Runner采用Shell Executor方式。项目规模不大,机器环境可控,Shell Executor最省心,不用为Docker嵌套问题头疼,调试脚本也方便。

2.2 需要安装哪些基础软件

如果你是照着这套方案走,构建机上至少要准备以下内容:

  • GitLab Runner,安装完先不急着注册,等后面章节的流程一起做。
  • JDK和Maven,用来编译SpringBoot项目。建议JDK版本和项目目标版本一致,例如项目基于JDK 8,构建机器就不要默认装太高版本的JDK。
  • Docker和Docker Compose,用来构建镜像和发布容器。GitLab Runner执行脚本时,一般是以gitlab-runner用户身份跑的,需要在安装好后把该用户加入docker用户组,否则执行docker命令会报权限错误。
  • Git,Runner拉取代码时需要用。

运行机和构建机最好分开。运行机只需要Docker和Docker Compose,外加放docker-compose.yml和挂载目录的路径。这样做的好处是:构建机上的环境被搞坏了不影响线上业务;线上机器不用装一堆开发工具,暴露面也小很多。

实际执行远程部署时,通常由构建机通过SSH连到运行机,执行预先写好的部署脚本。所以还需要在构建机生成一对SSH密钥,把公钥放到运行机的授权文件里。后面我会专门写这个环节要注意的权限坑。

2.3 Runner注册和并发配置

注册Runner在GitLab操作很简单:进到项目或群组的Settings,找到CI/CD下的Runners区域,会看到注册地址和注册Token。然后命令行执行:

sudo gitlab-runner register \ --url http://gitlab.example.com/ \ --registration-token YOUR_TOKEN \ --executor shell \ --description "springboot-builder"

注册完可以打开/etc/gitlab-runner/config.toml确认配置,如果机器性能还可以,还可以设置并发数量。在concurrent字段处调整,比如concurrent = 4。但要注意,Shell Executor的每个并发任务都是独立进程,如果同时构建多个项目,机器CPU和内存会被瞬间拉高,所以并发数要根据机器实际配置评估,不要盲目拉大。

这里有一个很多人轻视的细节:Runner机器上的时区、编码、磁盘空间。如果Runner系统时区不是北京时间,后续日志时间会差好几个小时,排查线上问题会很别扭。建议在Runner机器上统一时区,并检查系统字符集是否为UTF-8,避免Maven构建时中文内容出现乱码。

3. Dockerfile和docker-compose的编排细节:从“能跑”到“好用”

3.1 多阶段Dockerfile,让镜像体积和构建速度兼顾

SpringBoot应用打包成镜像,最简单粗暴的写法是:在镜像里装Maven,把源码放进去,然后在镜像里执行mvn package。但这种方案产出的镜像动辄一两个GB,里面塞了一堆构建期的中间文件,不仅网络传输慢,还有安全隐患。

推荐的做法是多阶段构建:第一个阶段负责编译打包,第二个阶段只保留运行时环境和一个最终的Jar包。一个典型的Dockerfile是这样的:

# 构建阶段 FROM maven:3.8-openjdk-8 AS builder WORKDIR /app COPY pom.xml . RUN mvn -B dependency:go-offline COPY src . RUN mvn -B package -DskipTests # 运行阶段 FROM openjdk:8-jre-alpine ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone WORKDIR /app COPY --from=builder /app/target/demo-1.0.0.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-Xms256m", "-Xmx512m", "-jar", "/app/app.jar"]

这里有两个很实用的技巧。

第一个技巧是先复制pom.xml单独拉依赖。把COPY pom.xml和COPY src分成两步,利用Docker的镜像分层缓存机制:只要pom.xml没变,Maven依赖就不会重新下载,改代码后的构建时间会大幅缩短。我第一次没意识到这个问题,每次改一行代码都要重新下载一堆依赖,构建一次十几分钟,后来调整顺序后才压到两三分钟。

第二个技巧是运行阶段用JRE镜像而不是JDK镜像。SpringBoot应用运行期不需要编译器,带JDK纯属浪费空间。把基础镜像从openjdk:8-jdk-alpine换成openjdk:8-jre-alpine,体积能小一半以上。另外如果项目用到某些需要编译期的工具,记得单独验证,不然运行时报缺类就麻烦了。

3.2 docker-compose文件里的参数化设计

镜像构建好之后,真正控制容器运行的是运行机上的docker-compose.yml。我习惯把可能变化的配置全部参数化,用环境变量从外部注入,镜像里不烧死任何环境信息:

version: "3.8" services: app: image: registry.example.com/backend/springboot-demo:${APP_TAG} container_name: springboot-demo restart: unless-stopped ports: - "8080:8080" environment: TZ: Asia/Shanghai SPRING_PROFILES_ACTIVE: prod DB_URL: jdbc:mysql://db.internal.example.com:3306/demo?useSSL=false&characterEncoding=utf8 DB_USERNAME: demo_user DB_PASSWORD: ${DB_PASSWORD} REDIS_HOST: redis.internal.example.com volumes: - /data/app/logs:/logs - /data/app/config:/config logging: driver: "json-file" options: max-size: "50m" max-file: "3"

APP_TAG是部署时从GitLab的流水线ID传进来的,保证每次部署对应到唯一的镜像;DB_PASSWORD这类敏感值不写在仓库,而是放到运行机的环境变量文件里,由部署脚本加载。日志路径要挂载出来,不然容器一删日志全丢。日志驱动也要限制文件大小和数量,常年不清理的话,主机磁盘会被几十个上百MB的JSON日志撑爆。

3.3 健康检查:启动成功不等于可以接流量

SpringBoot应用常见的启动信号是“端口通了”。但端口能建立连接,不代表应用真的准备好处理请求了——数据库连接池还没初始化、定时任务还在加载,都有可能导致短暂的服务异常。所以健康检查环节我让它多等一会儿。

如果你的运行机上有curl,可以在部署脚本里循环请求Spring Boot Actuator的/actuator/health接口,状态返回UP才算部署成功。也可以用Docker原生的healthcheck功能,但要注意基础镜像里有没有curl,alpine镜像经常不带,需要额外装,或者直接用Java层的健康检查加轮询判断更省事。实际部署脚本我会在第五章详细展开。

4. .gitlab-ci.yml流水线的拆解:推一次代码,究竟发生了什么

4.1 流水线模型和触发时机

GitLab CI的流水线是分层模型:Pipeline(流水线)由多个Stage(阶段)组成,每个Stage里可以有一个或多个Job(任务)。Stage之间默认按顺序执行,前一个阶段失败,后面不会继续。Job之间可以并行。

我设计的流水线划分是:

  1. build阶段:编译SpringBoot项目,产出Jar包,跑单元测试。
  2. package阶段:把Jar打成Docker镜像,推送到镜像仓库。
  3. deploy阶段:远程登录运行机,拉取新镜像,重新创建容器。

触发时机要刻意区分。日常开发分支的提交只做MR验证,不发正式环境;只有合并到主分支之后才触发测试环境构建;只有打了Git tag才触发生产环境部署。这样不会出现“谁随便推了个分支就上线”的混乱状况。用GitLab的only关键字就能控制。

4.2 完整的.gitlab-ci.yml长什么样

我拿一个实际精简过的配置来说明:

stages: - build - package - deploy variables: MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository -Dmaven.test.skip=false" cache: paths: - .m2/repository/ maven-build: stage: build only: - master - tags script: - mvn clean package -U artifacts: paths: - target/springboot-demo.jar expire_in: 1 day docker-build: stage: package only: - tags script: - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY" - docker build -t "$CI_REGISTRY_IMAGE:$CI_PIPELINE_ID" -t "$CI_REGISTRY_IMAGE:latest" . - docker push "$CI_REGISTRY_IMAGE:$CI_PIPELINE_ID" - docker push "$CI_REGISTRY_IMAGE:latest" after_script: - docker logout deploy-prod: stage: deploy only: - tags environment: name: production script: - bash deploy/prod.sh "$CI_PIPELINE_ID" "$CI_REGISTRY_IMAGE"

逐段说明一下。

maven-build阶段可以跑在主分支也可以跑在tag上。Jar包会被GitLab当作Artifact保留一天,用来快速下载排查问题。-U参数会强制更新快照版本依赖,如果你依赖的是本地私服里的SNAPSHOT包,不加这个参数很可能拉到旧的缓存。

docker-build阶段只在打tag之后运行。这里我没有在Runner上额外装镜像仓库服务,而是直接复用GitLab自带的Container Registry。GitLab CI会给出一组默认变量:$CI_REGISTRY是仓库地址,$CI_REGISTRY_USER是GitLab机器人账号,$CI_REGISTRY_PASSWORD是部署令牌,$CI_REGISTRY_IMAGE是当前项目的镜像路径,$CI_PIPELINE_ID是本次流水线的唯一编号。

镜像打两个标签,一个用$CI_PIPELINE_ID保证每次构建都不同,一个用latest方便调试。正式部署时用流水线ID这个唯一标签,这样以后想回滚到某个版本,只要找到那次构建的流水线ID就行。

deploy-prod阶段是整条链路的终点。它没有直接在这个Runner机器上执行docker命令,而是调用了一个部署脚本,脚本内部通过SSH远程到运行机操作。这样构建机和运行机的职责彻底分开,运维上更安全。

4.3 敏感信息放进CI/CD变量,而不是写死在配置里

.gitlab-ci.yml一旦提交到代码仓库,仓库里的人都能看到。像SSH私钥、数据库密码、私服账号这类信息绝对不能直接写在配置里。

GitLab CI有专门的Variables管理功能,可以在项目或群组的Settings里配置。变量分为两类:普通变量和File类型变量。普通变量在脚本里用$变量名引用;File类型变量会把整段内容写入一个临时文件,适合用来处理多行SSH私钥。

部署脚本通过SSH登录运行机时需要私钥,我在CI/CD变量里配了一个SSH_PRIVATE_KEY,类型选择File。然后在部署脚本里这样加载:

#!/bin/bash set -euo pipefail PIPELINE_ID=$1 IMAGE_NAME=$2 DEPLOY_HOST=deploy_user@10.0.0.8 APP_DIR=/data/app # 将私钥临时注入SSH Agent eval "$(ssh-agent -s)" ssh-add "$SSH_PRIVATE_KEY" # 登录运行机,拉取镜像并发布服务 ssh -o StrictHostKeyChecking=no "$DEPLOY_HOST" \ "export APP_TAG=${PIPELINE_ID} IMAGE_NAME=${IMAGE_NAME} && \ cd ${APP_DIR} && \ docker pull ${IMAGE_NAME}:${PIPELINE_ID} && \ docker-compose up -d --force-recreate --no-deps app && \ sleep 15 && \ curl -fs http://127.0.0.1:8080/actuator/health" # 移除密钥 ssh-add -d "$SSH_PRIVATE_KEY"

这里有三个关键点。

第一,set -euo pipefail让脚本在任意一步失败时立刻退出,避免“镜像没拉成功但容器被重建了”的尴尬。

第二,StrictHostKeyChecking=no会让SSH首次连接时不再询问主机指纹确认。第一次部署时会自动把主机加入known_hosts。缺点是降低了一点安全性,严格做法是提前把主机指纹写入known_hosts,但自动化部署里为了流畅,很多人会用这一招,可以接受。

第三,docker-compose up -d --force-recreate --no-deps app只重建app容器,如果docker-compose.yml里还定义了下游依赖(比如一个Nginx),不会去动它。这样部署失败不会连带其他服务一起重启。

5. 部署实战中的典型坑:SSH权限、端口冲突与回滚方案

5.1 SSH私钥“丢”了:多行文件被吃掉的坑

第一次部署时我遇到最多的问题就是SSH登录失败,报错信息通常是Permissions 0644 for 'xxx' are too open,或者Load key "xxx": invalid format。原因有几种:

私钥权限太宽松时,SSH会拒绝使用。私钥文件要确保只有当前用户可读写,chmod 600必须做。如果使用的是File类型变量,GitLab临时生成的私钥文件默认权限有时不对,可以在ssh-add之前强制修正权限:

chmod 600 "$SSH_PRIVATE_KEY" ssh-add "$SSH_PRIVATE_KEY"

另外,在GitLab CI/CD变量里粘贴私钥时,要特别注意换行符。如果用网页版的变量编辑框分两段复制,或者类型没选对,私钥内容可能会被拼成一行,导致SSH解析失败。遇到这类问题时,先加一个cat "$SSH_PRIVATE_KEY" | head -n 2查看前面几行是否正常,确认私钥是否完整。

还有一个小细节:SSH私钥尽量用不带密码的专用部署密钥。如果私钥设置了passphrase,SSH Agent在无人值守环境下会交互式询问密码,流水线会卡死。别拿自己的个人私钥去当部署密钥,一个人离职后所有机器都要改。

5.2 端口冲突与容器名冲突

SpringBoot默认监听8080端口,如果你的运行机上有多个项目都抢8080,新容器会启动失败。Docker不会像旧进程那样“端口被占用就退出”,而是启动时报端口映射失败,docker-compose up直接报错退出,这时排查起来相对直接。

但另外有一种隐蔽情况:容器创建成功了,服务却一直不健康。有一次排查了半天,发现是运行机上的另一个旧Jar包进程占着同一端口,容器内部的8080映射到宿主机时失败但没被检查到,因为--force-recreate把旧容器删了,新容器由于端口绑定失败一直处于重启状态。

排查套路我总结为四步:

  1. docker ps -a看容器状态,是Exited还是Restarting。
  2. docker logs <容器名> --tail 100看是否有端口绑定异常。
  3. ss -lntp | grep 8080确认宿主机端口占用情况。
  4. 如果旧进程占用,ps -ef | grep java定位到进程,确认不是其他应用后再处理。

容器名冲突是第二个常见问题。docker-compose.yml里写的container_name是全运行机唯一的,两个项目不小心用了同一个容器名,后创建的那个会直接创建失败,报错信息里会明确写出容器名。规范做法是在项目名或容器名里带上应用缩写,比如springboot-demo-prod,避免互相冲突。

5.3 回滚:给失败部署留好“后悔药”

自动部署做得再顺,也保不齐某天新版本出问题。回滚方案必须提前想好,不能到了着急的时候现翻历史。

因为每次构建的镜像都带有$CI_PIPELINE_ID标签,镜像仓库里天然保留了每一次历史版本。只要知道上一次正常发布的流水线ID,回滚就是重新执行一次部署脚本,把APP_TAG指向上一次的ID:

# 假设上次好的版本是 12345 export APP_TAG=12345 cd /data/app docker pull registry.example.com/backend/springboot-demo:12345 docker-compose up -d --force-recreate --no-deps app

只要镜像没有主动清理,每个线上版本都能回滚。日常建议保留最近二三十个流水线编号的镜像,太老的镜像可以定时清理,避免仓库膨胀。这个回滚思路比“重新跑一遍流水线、改代码发布”快得多,真正做到了分钟级恢复。

如果部署脚本里的健康检查失败,脚本会自动中断,不会把流量切到新容器。但要注意,docker-compose up -d会先创建新容器再移除旧容器,如果健康检查失败后没有自动回滚,旧容器实际上已经被替换掉了。所以更稳妥的做法是在部署脚本里加一段失败处理逻辑,一旦健康检查不通过,立即重新拉取当前线上版本并恢复:

if ! curl -fs http://127.0.0.1:8080/actuator/health; then echo "health check failed, rolling back" docker-compose up -d --force-recreate --no-deps app exit 1 fi

这里的docker-compose up -d执行前需要把镜像标签切回旧版本。在脚本里可以先保存当前的线上标签,再在失败分支里重置回去。回滚过程虽然会给调用方带来几秒的波动,但总比服务一直挂死好得多。

6. 从“能自动部署”到“放心自动部署”:一些后续建议

6.1 把构建产物和容器风险分离

整套流程跑通之后,最容易忽略的是镜像供应链的安全。构建机上的Docker镜像和运行机之间隔着镜像仓库,如果镜像仓库的访问权限管理不当,有人往仓库里推了一个同名漏洞镜像,下次部署就直接上线了。

建议做到两条:第一,镜像仓库的推送权限只放开给CI机器和少数管理员,不开放给普通开发;第二,基础镜像尽量固定版本,不要用latest漂移的标签,否则哪天openjdk:8-jre-alpine指向的内容变了,线上构建出来的镜像可能和上次完全不一样。我在Dockerfile里实际用的是带具体日期或具体版本号的标签,比如openjdk:8u332-jre-alpine,避免这种隐性问题。

6.2 加一道质量门禁,让CI不只是“部署工具”

流水线里除了构建和部署,还可以挂单元测试、接口测试、代码扫描。Maven构建阶段如果mvn package执行了测试,测试失败流水线就会自动中断,不会进入部署阶段。更进一步,我后来在测试环境之前加了一个“静态检查”阶段,检查一些常见安全漏洞,虽然会增加两分钟流水线时间,但能挡掉相当一部分“线上才发现问题”的尴尬。

对于有代码评审习惯的团队,还可以利用GitLab的Merge Request流水线:开发分支的MR里就自动跑测试和构建,不通过不允许合并。这样主分支的质量天然有保障,而不是靠上线前那一哆嗦。

6.3 环境分离做到位,正式环境和测试环境各走各的门

前期为了省事,测试环境和正式环境用同一套docker-compose、同一台机器也没大问题。但随着项目变多,强烈建议用不同的environment配置隔离。GitLab CI里可以给不同环境定义不同变量:

deploy-staging: stage: deploy only: - master environment: name: staging script: - bash deploy/staging.sh deploy-prod: stage: deploy only: - tags environment: name: production script: - bash deploy/prod.sh

这样普通开发合并到主分支只会触发测试环境部署,正式环境永远只有通过评审、打了tag的版本才能上。environment还有一个好处:GitLab的项目页面会生成一个环境状态按钮,可以直接查看当前部署的版本和对应提交,发布历史一目了然。

6.4 不要让“自动化”变成“黑盒”

最后想多说一句,自动化最怕变成黑盒。当所有人习惯了“推代码自动上线”,一旦部署失败,如果连维护这套流水线的人都看不明白日志,场面会非常混乱。所以从一开始就要约定:部署脚本全部放仓库;运行机上保留最近若干次部署的日志;异常时先看流水线日志,再看运行机容器状态,不要一上来就手动重启容器。

我自己在这套路上踩过的最大一个教训是,某次新同事不知道部署脚本里带私有配置,直接在运行机上改了docker-compose.yml,结果下次CI部署把这份改动覆盖了,服务一起,参数不对,差点事故。后来我们明确了一条规矩:运行机的docker-compose.yml不允许手工修改,只能由部署脚本决定,所有变更走代码仓库。这个原则看上去简单,但能避免大量“环境漂移”问题。

回头看整个改造过程,从最开始只能靠手动发布,到后来形成“提交代码、喝杯水、看下流水线变绿”的日常,最核心的功劳来自把构建和部署的决策都收敛到了代码里。环境是代码描述出来的,部署步骤是代码体现出来的,历史版本是镜像标签记录下来的,任何一个环节出了问题都可以追溯。如果你的团队还停留在手动上传Jar包的时代,建议先从“构建自动化”开始:先让CI自动跑测试、自动打Jar包,等跑顺了再加镜像构建,最后再加远程自动部署。一步步来,比一次性推倒重来要稳得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进