接手过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执行流水线,流水线依次经历以下阶段:
| 阶段 | 做什么 | 产物 |
|---|---|---|
| compile | Maven编译 | 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-Docker(DinD)方案。
2.2 Docker-in-Docker方案的关键考量
构建SpringBoot镜像必然需要用到Docker命令来执行docker build。问题来了:CI任务本身跑在一个Docker容器里,容器里怎么调用Docker来构建另一个镜像?
两种主流方案:一种是挂载宿主机docker.sock(DinD),一种是跑一个真正的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 --from=builder /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.active=prod"]多阶段构建是这个文件的核心思路。第一阶段用的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_ACTIVE=prod - 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: "3"4.2 健康检查与平滑升级
容器编排文件里最容易被忽略的是healthcheck配置。这个配置的价值在部署时就会体现出来:Docker会根据健康检查的结果判断容器是否真正可用,而不是简单看进程有没有活着。SpringBoot项目只要引入了spring-boot-starter-actuator,就会暴露/actuator/health端点,把它配置成健康检查的探针是标准做法。
有两点需要提醒:一是actuator的健康检查端点务必要排除敏感信息,具体做法是配置management.endpoint.health.show-details=never,否则会暴露数据库连接状态等内部信息;二是如果应用启动比较慢(比如启动需要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守护进程。如果项目里没有配.dockerignore,SpringBoot项目动辄几百MB的target目录、node_modules、.git都会被打包过去,构建速度和镜像仓库推送速度都会被拖慢。
我项目的.dockerignore长这样:
target/ .git/ .idea/ *.iml .gitlab-ci.yml docker-compose.yml配置越简单越好,原则是:构建镜像用不到的文件通通排除。
5.4 常见问题速查表
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 任务一直pending | tag不匹配或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_TAG=34b8c1d" > .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和镜像构建的逻辑,一旦跑通,后面就是长期受益。有任何一步卡住了,按我上面提到的排查思路逐条核对,大部分问题都能在那个速查表里找到答案。
最后分享一个我自己养的“毛病”:每次流水线跑完,我都会去检查一下构建出来的镜像大小和启动日志。镜像越来越大或者启动越来越慢,往往是项目里有些依赖在悄悄膨胀,及早发现要比上线前突然爆出来好处理得多。这套自动化部署方案不仅仅是把发布流程变了,它会反过来推动你更关注应用本身的运行质量和启动效率,这种正向反馈,是我当初动手搭建时完全没预料到的收获。