SpringBoot 项目基于 GitLab CI 与 Docker 的自动化部署实践 接手过SpringBoot项目的同学基本都经历过这种场景代码本地跑得飞起一到上线就要人工打包、传包、杀进程、重启服务服务器环境稍微和本地不一样就得折腾半天。我之前维护的一个项目就长期处于这种状态每次发版少说二十分钟遇到依赖冲突或者端口被占用的怪问题能在线上一蹲一个钟头。后来花了一个周末把GitLab CI和Docker这套组合彻底打通git push完喝杯水的工夫测试环境和生产环境都已经自动更新完了那种“提交即交付”的畅快感用过一次就回不去了。这篇就把整套方案的搭建过程完整梳理一遍从Runner配置、Dockerfile编写到.gitlab-ci.yml流水线设计、服务平滑更新所有核心参数和踩坑点都会讲到适合正从手动部署向自动化流水线过渡的团队参考也适合想把自己个人项目工程化的小团队直接抄作业。1. 整体方案设计与思路1.1 手动部署的痛点在哪里先说清楚我们为什么要折腾这套东西否则很容易为了自动化而自动化。原来的部署流程是本地打包 - scp传到服务器 - ssh登录 - 找到旧进程kill掉 - 启动新的jar包。听起来也就五步但每一步都有隐患。打包环节依赖本地JDK版本和Maven配置本地能打包不代表服务器能运行JDK版本不一致是经典的“本地好好的线上就报错”。传包这一步用的是scp网络波动导致包传了一半你去启动就会发现jar包损坏又得重新传一遍。杀进程用的是ps -ef | grep java然后kill如果同时跑着多个Java服务误杀的情况真的会发生。最关键的是整个流程没有任何“可复制性”。换一个人来操作他可能连你的脚本路径都找不到更不用说环境差异带来的各种隐藏问题。自动化部署的本质不是省那二十分钟的操作时间而是把“人肉操作”变成“确定性的代码化流程”让每次部署的结果都一模一样。1.2 为什么选择GitLab CI Docker这套组合市面上做CI/CD的工具不少Jenkins、GitHub Actions、GitLab CI都是常见选择。我当时选型时主要权衡了三个因素。首先是项目托管位置。代码本来就在GitLab上直接用GitLab CI意味着不需要额外搭一套Jenkins服务器不需要单独维护一个持续集成平台减少了运维成本。GitLab Runner可以注册到已有的服务器上也可以装在单独的机器上灵活性很高。其次是Docker的隔离性。SpringBoot应用最头疼的就是环境依赖JDK版本、字体库、时区设置、系统字符集任何一个不一致都可能导致线上行为异常。Docker把所有依赖都固化到镜像里构建一次镜像在任何装Docker的机器上运行结果完全一致。这个特性直接消灭了“我本机跑得好好的”这类问题。最后是配置即代码。GitLab CI的流水线定义文件.gitlab-ci.yml是跟随代码仓库走的每一次修改都有版本记录可回溯、可review。相比在Jenkins网页上点来点去配置任务这种“流水线代码化”的方式更符合开发习惯也更容易做权限管理。1.3 整条流水线的运行逻辑先把这套方案的全貌画出来后面再逐个击破。当开发者往仓库推送代码后GitLab会触发Runner执行流水线流水线依次经历以下阶段阶段做什么产物compileMaven编译class文件test运行单元测试测试报告package打包jar包build-image构建Docker镜像镜像deploy推送并部署运行中的容器其中compile、test、package属于构建阶段在Runner上完成build-image构建镜像后推送到镜像仓库deploy阶段在目标服务器上把新容器跑起来完成服务更新。理解这套逻辑的关键在于构建和部署是分离的。构建在Runner上做得到的是一个完整的镜像部署在应用服务器上做只是把镜像拉下来换成新容器。这样构建环境的配置只需要维护一份业务服务器不需要装JDK、Maven等任何构建工具它只需要Docker环境。2. 环境准备与前置条件2.1 服务器与Runner的部署方式我当时的服务器不算多一台Runner两台应用服务器都是云主机。Runner装在独立的机器上而不是和应用服务器混用原因很简单构建过程会消耗CPU和内存Maven打包和Docker镜像构建都是吃资源的操作如果和应用服务跑在同一台机器上构建高峰期会影响线上服务的稳定性。Runner安装方式这里给个快速参考。安装GitLab Runner后先注册到GitLab实例# 安装Runner以Debian/Ubuntu为例 curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash sudo apt-get install gitlab-runner # 注册Runner sudo gitlab-runner register \ --url https://gitlab.example.com \ --registration-token YOUR_TOKEN \ --executor docker \ --docker-image docker:20.10.16 \ --docker-volumes /var/run/docker.sock:/var/run/docker.sock这里有个关键决策executor选的是docker意味着每个CI任务都会启动一个独立的Docker容器来执行。这样构建环境完全隔离这次跑坏了不会影响下次。--docker-volumes挂载了宿主机的docker.sock这是为了让CI任务内部能调用宿主机的Docker来构建镜像也就是后面要聊到的Docker-in-DockerDinD方案。2.2 Docker-in-Docker方案的关键考量构建SpringBoot镜像必然需要用到Docker命令来执行docker build。问题来了CI任务本身跑在一个Docker容器里容器里怎么调用Docker来构建另一个镜像两种主流方案一种是挂载宿主机docker.sockDinD一种是跑一个真正的DinD服务。我选了前者因为更轻量、配置少。挂载后CI容器内的docker命令直接和宿主机上的Docker守护进程通信镜像构建完成后就直接在宿主机上能看到。但挂载docker.sock有一个安全隐患所有能执行CI任务的人等于有了宿主机Docker的操作权限也就相当于有宿主机root权限。在小团队内部这个风险可控但如果团队规模较大、CI脚本里跑着不可信的代码建议改用真正的DinD服务方式把构建环境彻底隔离。Runner注册好以后在GitLab项目页面的Settings - CI/CD - Runners里能看到这个Runner已经处于在线状态。务必确认Runner的tag设置如果Runner指定了tag比如docker那.gitlab-ci.yml里的任务也必须用相同的tag去匹配否则任务永远排队不执行这是新手最容易卡住的地方。2.3 SpringBoot项目的Dockerfile编写这是工作量最小但影响最大的部分。Dockerfile写得好不好直接影响镜像大小和构建速度。我基于常见的SpringBoot项目给出一个可以直接用的模板# 多阶段构建第一阶段用来打包 FROM maven:3.8.6-openjdk-11 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests # 第二阶段只保留运行环境 FROM openjdk:11-jre-slim WORKDIR /app COPY --frombuilder /build/target/*.jar app.jar RUN ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime echo Asia/Shanghai /etc/timezone EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar, --spring.profiles.activeprod]多阶段构建是这个文件的核心思路。第一阶段用的maven镜像包含完整的JDK和Maven但体积很大差不多700MB第二阶段只保留JRE运行环境基础镜像只有200MB左右。最终运行镜像只包含JRE和打好的jar包体积直接缩小了一大半。有一个细节值得注意COPY pom.xml .和RUN mvn dependency:go-offline是特意分离出来的。这样做的目的是利用Docker的层缓存机制——只要pom.xml没变依赖下载步骤就不会重新执行每次构建能省下好几分钟。如果你把COPY src ./src放在前面那任何源码变动都会导致依赖重新下载Docker层缓存被白白浪费了。时区设置也是经验之谈。SpringBoot应用经常要和数据库交互时间差八小时这类问题排查起来特别费劲。与其在Java代码里手动设置时区不如在Dockerfile层面直接改掉一劳永逸。3. 编写.gitlab-ci.yml流水线配置3.1 流水线阶段的划分与配置这是整个方案中最核心的配置文件。我们在项目中创建.gitlab-ci.yml把流水线的执行步骤明确写清楚。以下是我实测可用的完整配置stages: - compile - test - package - build-image - deploy variables: MAVEN_OPTS: -Dmaven.repo.local/cache/maven IMAGE_NAME: registry.example.com/demo/springboot-app IMAGE_TAG: $CI_COMMIT_SHORT_SHA cache: paths: - .m2/ compile-job: stage: compile image: maven:3.8.6-openjdk-11 tags: - docker script: - mvn compile artifacts: expire_in: 2 hours paths: - target/classes/ test-job: stage: test image: maven:3.8.6-openjdk-11 tags: - docker script: - mvn test artifacts: expire_in: 2 hours paths: - target/surefire-reports/ package-job: stage: package image: maven:3.8.6-openjdk-11 tags: - docker script: - mvn clean package -DskipTests artifacts: expire_in: 2 hours paths: - target/*.jar build-image-job: stage: build-image image: docker:20.10.16 tags: - docker services: - docker:20.10.16-dind before_script: - echo $CI_REGISTRY_PASSWORD | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin script: - docker build -t $IMAGE_NAME:$IMAGE_TAG . - docker push $IMAGE_NAME:$IMAGE_TAG needs: - package-job deploy-prod-job: stage: deploy image: alpine:latest tags: - docker before_script: - apk add --no-cache openssh-client script: - eval $(ssh-agent -s) - echo $SSH_PRIVATE_KEY | tr -d \r | ssh-add - - mkdir -p ~/.ssh - chmod 700 ~/.ssh - ssh-keyscan $DEPLOY_SERVER_IP ~/.ssh/known_hosts - scp -i $SSH_PRIVATE_KEY docker-compose.yml deploy$DEPLOY_SERVER_IP:/opt/springboot-app/ - ssh deploy$DEPLOY_SERVER_IP cd /opt/springboot-app docker-compose pull docker-compose up -d environment: name: production only: - main3.2 关键参数的选择逻辑逐个拆解上面配置里的关键点。IMAGE_TAG: $CI_COMMIT_SHORT_SHA。这是GitLab预定义变量表示当前提交的短哈希值。用commit号做镜像标签的好处是每个镜像和某次代码提交一一对应想回滚到哪次版本直接用对应的镜像标签重新部署就行不需要记额外的版本号。比用latest标签不知道高到哪里去了latest最大的问题是无法追溯“这个镜像到底是哪个代码版本构建出来的”。cache配置。Maven每次构建都要下载大量依赖如果不做缓存每次CI任务都会重新下载一遍时间都浪费在网络上。cache: paths: - .m2/告诉GitLab把.m2目录缓存起来下次构建直接复用。这个简单一行配置能把构建时间缩短一半以上。artifacts配置。compile和test阶段产生的文件可以通过artifacts在任务之间传递。但要注意设置expire_in默认artifacts会保留很久占用gitlab服务器的磁盘空间。我设成2小时保证当前流水线能用到就可以了。needs: package-job。这段配置让build-image阶段不再等待test和compile全部跑完只需要package完成就可以开始构建镜像。在小项目中可能感知不明显但阶段多的项目中流水线整体时间能缩短很多。3.3 deploy阶段的权限处理详解deploy阶段是很多人觉得最绕的地方它需要从Runner服务器SSH到应用服务器执行部署命令。这里有几个注意点使用alpine镜像不是随便选的它体积最小几MB只需要执行ssh和scp命令不需要完整操作系统。before_script里先安装openssh-client这是alpine里执行ssh的先决条件。SSH私钥通过$SSH_PRIVATE_KEY环境变量传入这个变量要在GitLab项目的Settings - CI/CD - Variables里配置注意设为Masked防止日志里泄露。ssh-keyscan $DEPLOY_SERVER_IP这一行是自动把目标服务器的指纹加到known_hosts里否则ssh会提示确认并卡住。这个属于“第一次手动连接会失败”的经典坑。权限最小化原则部署用的SSH用户不要给root创建一个专门的deploy用户只赋予它操作/opt/springboot-app目录的权限。线上环境能少一点权限就少一点风险。4. 部署脚本与容器编排的配合4.1 用docker-compose管理多容器依赖单跑一个SpringBoot容器其实用不到docker-compose但真实项目中应用往往还需要数据库、Redis等配套服务。即便这些服务已经跑在专门的基础设施上用docker-compose统一管理应用容器也有两个明显好处一是配置可以声明式地写在文件里版本可控二是支持.env文件管理环境变量不同环境不用改文件内容。我用的是这样一个编排文件version: 3.8 services: app: image: registry.example.com/demo/springboot-app:${IMAGE_TAG} container_name: springboot-app restart: always ports: - 8080:8080 environment: - SPRING_PROFILES_ACTIVEprod - DB_HOST${DB_HOST} - DB_PORT${DB_PORT} - DB_USER${DB_USER} - DB_PASSWORD${DB_PASSWORD} healthcheck: test: [CMD, curl, -f, http://localhost:8080/actuator/health] interval: 30s timeout: 5s retries: 3 start_period: 40s logging: driver: json-file options: max-size: 10m max-file: 34.2 健康检查与平滑升级容器编排文件里最容易被忽略的是healthcheck配置。这个配置的价值在部署时就会体现出来Docker会根据健康检查的结果判断容器是否真正可用而不是简单看进程有没有活着。SpringBoot项目只要引入了spring-boot-starter-actuator就会暴露/actuator/health端点把它配置成健康检查的探针是标准做法。有两点需要提醒一是actuator的健康检查端点务必要排除敏感信息具体做法是配置management.endpoint.health.show-detailsnever否则会暴露数据库连接状态等内部信息二是如果应用启动比较慢比如启动需要40秒start_period: 40s这个参数很关键它告诉Docker在40秒内不把它当成健康检查失败避免启动慢导致一直重启循环。平滑升级是指旧容器退出前等新容器就绪两个容器短暂共存保证服务不中断。docker-compose的up -d默认就是这种方式先拉起新容器等健康检查通过后再停掉旧容器。整个过程用户无感知。不建议直接docker stop docker run那中间会有几秒的不可用窗口。4.3 日志与数据持久化的经验容器是无状态的但日志需要持久化。配置里指定了json-file驱动并限制单文件大小max-size: 10m和max-file: 3意味着最多保留30MB日志防止服务器磁盘被无限增长的日志撑爆。SpringBoot应用的日志建议同时配置两个出口一是输出到控制台由Docker来管理二是文件输出。如果使用文件输出务必记得把日志目录挂载出来否则容器一删日志全没了。挂载方式很简单在docker-compose的app服务下加volumes: - app-logs:/var/log/app volumes: app-logs:如果应用需要存储上传的文件或其他业务数据同样用命名卷named volume来持久化。这里的安全准则是容器是可丢弃的但数据必须留存。任何时候都不要把业务数据写到容器内部可写层否则容器升级时数据就丢了。5. 实操中的常见问题与排查实录5.1 Runner权限问题刚开始用GitLab CI的时候最容易遇到的是“任务卡在pending状态不动”。这通常有几种情况按出现频率排序Runner没有设置正确的tag任务指定的tag匹配不上任何在线Runner就会一直pending。Runner注册时用的executor是shell但任务里指定了docker这个tag两边一对一不上。Runner所在机器防火墙出方向被限制了Runner无法主动连接GitLab服务器拉取任务。排查方法在GitLab项目Settings - CI/CD - Runners页面看Runner是否显示为绿色在线状态。如果是在线的再看Runner配置的tag列表。之前我遇到过Runner明明在线但所有任务都pending的情况最后发现是Runner挂在组级别group-level而项目里也配置了同名的Runner组优先把任务抢走了但组的那个Runner已掉线。5.2 Maven仓库缓存导致构建失败有一种比较气人的情况代码上次还能构建通过这次什么都没改CI却报“Could not resolve dependencies”。这多半是Maven中央仓库源不稳定导致的。解决思路是换源具体做法是在Runner服务器的Maven配置或者项目pom.xml里配置阿里云镜像源。但这里要提一个更隐蔽的问题CI里的cache虽然能加速构建但缓存的.m2目录也可能因为网络问题存了残缺的依赖包。遇到这种找不到原因的构建失败先手动清掉缓存再试一次百分之七十的莫名其妙问题都能解决。顺带提醒下不要在CI里执行mvn clean package时把单元测试跑全如果项目里有集成测试依赖外部服务CI阶段很容易因为测试环境的问题导致流水线失败。独立测试阶段跑mvn test打包阶段用-DskipTests跳过这是合理的取舍。5.3 镜像构建上下文过大Docker构建时会把build命令指定的上下文目录整个打包发送给Docker守护进程。如果项目里没有配.dockerignoreSpringBoot项目动辄几百MB的target目录、node_modules、.git都会被打包过去构建速度和镜像仓库推送速度都会被拖慢。我项目的.dockerignore长这样target/ .git/ .idea/ *.iml .gitlab-ci.yml docker-compose.yml配置越简单越好原则是构建镜像用不到的文件通通排除。5.4 常见问题速查表现象可能原因处理方法任务一直pendingtag不匹配或Runner离线检查Runner在线状态和tag配置构建失败但本地正常Maven源不稳定或缓存损坏换源或清理CI缓存部署后访问不到服务端口未映射或容器启动失败docker logs查看容器日志健康检查一直失败应用启动慢或actuator端点不对调大start_period或检查健康检查路径镜像推送被拒登录凭证过期或权限不足重新登录镜像仓库并检查权限新版本代码未生效镜像tag被缓存旧tag没更新确认用commit短哈希做tag并正确传递5.5 回滚到底怎么做自动化部署做这么好万一出了新版本有Bug回滚方案也是必须想清楚的。这套方案的回滚操作其实十分简单登录到应用服务器进入/opt/springboot-app目录把.env文件里的IMAGE_TAG改成上一个版本对应的commit号# 假设当前是35f7a2c回滚到34b8c1d echo IMAGE_TAG34b8c1d .env docker-compose pull docker-compose up -d因为每个镜像都对应一个明确的commit号回滚到哪个版本是一清二楚的。这种“按需回退”的能力是手动部署很难做到的——手动部署时你可能早已忘了当前线上具体是哪个包哪个版本有Bug只能靠聊天记录去找。6. 进阶优化与经验总结6.1 多环境流水线的扩展方式上面只写了production一套环境的部署实际团队里通常会有dev、test、staging、prod多套环境。扩展思路不是复制一套deploy任务而是用GitLab的environment和rules特性来控制每套环境的部署条件。常见做法是开发分支dev每次推送都部署到测试环境主分支main通过MR合并后才部署到生产环境。实现的配置大概是deploy-dev-job: stage: deploy script: - sh deploy.sh dev environment: name: development only: - dev deploy-prod-job: stage: deploy script: - sh deploy.sh prod environment: name: production only: - main when: manual在生产环境那条任务里加上when: manual意味着流水线跑到这里会暂停等有权限的人在GitLab界面上手动点一下才能继续执行。这个设计在很多公司里是硬性要求给“自动化流水线”保留一个“人工确认”的关卡既能防止误操作也满足流程合规的要求。6.2 CI阶段执行效率的再提升流水线跑的时间也是成本。我实际优化后的配置从最初的平均8分钟压缩到了3分钟左右主要做了这几件事Maven依赖使用cache并在pom.xml不变时跳过下载节省2-3分钟。用needs让build-image阶段并行度更高不用等所有测试跑完。Docker构建启用BuildKit缓存docker build --cache-from $IMAGE_NAME:latest前提是镜像层的顺序稳定基础层不变就能复用。还有一个容易被忽略的点mvn dependency:go-offline这一步耗时最久如果能保证pom没有频繁变动它的执行时间会稳定在1分钟内。尽量不要在构建过程中频繁升级依赖版本稳定性对CI来说和速度同样重要。6.3 这套方案的边界在哪里GitLab CI和Docker这套方案也并非万能。如果你面对的是高并发、对弹性扩容有极高要求的业务Kubernetes才是更好的归宿Docker Compose这种方式管理单机容器明显力不从心。但如果你的项目规模不大服务器三五台内追求的是稳定、可追溯、低运维成本的部署方式这套组合就是性价比极高的选择。从维护成本来说GitLab CI这套链路的日常运维很少Runner基本不用管Docker镜像仓库做一下定期清理部署脚本稳定之后几个月都不用动。相比之前每周都要处理一次手动部署引发的线上问题现在整套流水线跑下来真正需要人工介入的只有生产环境的那个确认按钮。如果你也在手动部署的痛苦里挣扎我强烈建议你花一个周末把这套配置搭起来。前期最大的成本是理解Runner和镜像构建的逻辑一旦跑通后面就是长期受益。有任何一步卡住了按我上面提到的排查思路逐条核对大部分问题都能在那个速查表里找到答案。最后分享一个我自己养的“毛病”每次流水线跑完我都会去检查一下构建出来的镜像大小和启动日志。镜像越来越大或者启动越来越慢往往是项目里有些依赖在悄悄膨胀及早发现要比上线前突然爆出来好处理得多。这套自动化部署方案不仅仅是把发布流程变了它会反过来推动你更关注应用本身的运行质量和启动效率这种正向反馈是我当初动手搭建时完全没预料到的收获。