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 21 启动等个几十秒打开日志看看有没有启动成功第五步确认端口通了、注册中心没报错发布完成。这套流程如果只是每两周发一次版勉强能忍。但一旦迭代节奏加快或者赶上多人并行改需求痛点就全暴露了谁最后一次改过服务器的启动参数没人说得清生产环境的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命令会报权限错误。GitRunner拉取代码时需要用。运行机和构建机最好分开。运行机只需要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 TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone WORKDIR /app COPY --frombuilder /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?useSSLfalsecharacterEncodingutf8 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: 3APP_TAG是部署时从GitLab的流水线ID传进来的保证每次部署对应到唯一的镜像DB_PASSWORD这类敏感值不写在仓库而是放到运行机的环境变量文件里由部署脚本加载。日志路径要挂载出来不然容器一删日志全丢。日志驱动也要限制文件大小和数量常年不清理的话主机磁盘会被几十个上百MB的JSON日志撑爆。3.3 健康检查启动成功不等于可以接流量SpringBoot应用常见的启动信号是“端口通了”。但端口能建立连接不代表应用真的准备好处理请求了——数据库连接池还没初始化、定时任务还在加载都有可能导致短暂的服务异常。所以健康检查环节我让它多等一会儿。如果你的运行机上有curl可以在部署脚本里循环请求Spring Boot Actuator的/actuator/health接口状态返回UP才算部署成功。也可以用Docker原生的healthcheck功能但要注意基础镜像里有没有curlalpine镜像经常不带需要额外装或者直接用Java层的健康检查加轮询判断更省事。实际部署脚本我会在第五章详细展开。4. .gitlab-ci.yml流水线的拆解推一次代码究竟发生了什么4.1 流水线模型和触发时机GitLab CI的流水线是分层模型Pipeline流水线由多个Stage阶段组成每个Stage里可以有一个或多个Job任务。Stage之间默认按顺序执行前一个阶段失败后面不会继续。Job之间可以并行。我设计的流水线划分是build阶段编译SpringBoot项目产出Jar包跑单元测试。package阶段把Jar打成Docker镜像推送到镜像仓库。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.skipfalse 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_HOSTdeploy_user10.0.0.8 APP_DIR/data/app # 将私钥临时注入SSH Agent eval $(ssh-agent -s) ssh-add $SSH_PRIVATE_KEY # 登录运行机拉取镜像并发布服务 ssh -o StrictHostKeyCheckingno $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让脚本在任意一步失败时立刻退出避免“镜像没拉成功但容器被重建了”的尴尬。第二StrictHostKeyCheckingno会让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私钥尽量用不带密码的专用部署密钥。如果私钥设置了passphraseSSH Agent在无人值守环境下会交互式询问密码流水线会卡死。别拿自己的个人私钥去当部署密钥一个人离职后所有机器都要改。5.2 端口冲突与容器名冲突SpringBoot默认监听8080端口如果你的运行机上有多个项目都抢8080新容器会启动失败。Docker不会像旧进程那样“端口被占用就退出”而是启动时报端口映射失败docker-compose up直接报错退出这时排查起来相对直接。但另外有一种隐蔽情况容器创建成功了服务却一直不健康。有一次排查了半天发现是运行机上的另一个旧Jar包进程占着同一端口容器内部的8080映射到宿主机时失败但没被检查到因为--force-recreate把旧容器删了新容器由于端口绑定失败一直处于重启状态。排查套路我总结为四步docker ps -a看容器状态是Exited还是Restarting。docker logs 容器名 --tail 100看是否有端口绑定异常。ss -lntp | grep 8080确认宿主机端口占用情况。如果旧进程占用ps -ef | grep java定位到进程确认不是其他应用后再处理。容器名冲突是第二个常见问题。docker-compose.yml里写的container_name是全运行机唯一的两个项目不小心用了同一个容器名后创建的那个会直接创建失败报错信息里会明确写出容器名。规范做法是在项目名或容器名里带上应用缩写比如springboot-demo-prod避免互相冲突。5.3 回滚给失败部署留好“后悔药”自动部署做得再顺也保不齐某天新版本出问题。回滚方案必须提前想好不能到了着急的时候现翻历史。因为每次构建的镜像都带有$CI_PIPELINE_ID标签镜像仓库里天然保留了每一次历史版本。只要知道上一次正常发布的流水线ID回滚就是重新执行一次部署脚本把APP_TAG指向上一次的ID# 假设上次好的版本是 12345 export APP_TAG12345 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包等跑顺了再加镜像构建最后再加远程自动部署。一步步来比一次性推倒重来要稳得多。