先说明一件事这套流程不是什么高深玩意儿但它能实打实省掉你每周上线时的重复劳动。人手不多的小团队或者还在“本地打包、FTP上传、远程重启”阶段的朋友看完这篇基本可以照搬。我这里拿 SpringBoot 应用举例但思路换成别的 Java 服务、Node 服务也通用。GitLab CI 负责调度Docker 负责打包和运行环境两者拼在一起就形成了“代码推上去、流水线自动构建镜像、服务器自动拉取并重启”的完整链路。我先后给好几个项目搭过这套东西踩过的坑不算少这里把完整过程和教训一并写出来。1. 先把整体流程想透提交代码之后到底发生了什么1.1 一条流水线的核心任务拆解很多人一听 CI/CD 就头大其实拆开看就是四件事拉代码、跑构建与测试、产出交付物、部署到目标环境。以 SpringBoot 项目为例手工部署时你大概要做这些操作先mvn package打成 jar 包再把 jar 传到服务器然后登录服务器找到旧进程、杀掉、重新启动。中间还可能涉及备份、切换目录、确认端口占用这些杂事。整个过程既费时又容易因为“某一步忘了做”出幺蛾子。GitLab CI 把上面这些步骤固化成.gitlab-ci.yml文件推到仓库后由 GitLab Runner 自动执行。具体到我的方案里链路是这样的代码推送后 Runner 拉取项目 - 用 Maven 镜像编译、跑单测 - 构建 Docker 镜像并推送到镜像仓库 - 通过 SSH 通知应用服务器执行docker compose pull和docker compose up -d。这套链路的关键在于“把所有操作变成可复现的脚本”。人手动操作会有情绪、会忘步骤脚本不会。任何人只要拿到仓库权限推送一次代码剩下的交给流水线就行。1.2 为什么选择 GitLab CI Docker而不是继续写 Shell 脚本我也走过“写一堆脚本在服务器上跑”的路线早期确实够用但到后面问题就来了脚本散落在服务器上谁也说不清当前线上跑的到底是哪个版本出了问题只能登录服务器翻nohup.out和history效率很低。GitLab CI 的好处在于流水线本身是代码跟着项目仓库走。每次提交对应哪次构建、构建日志在哪、产物是什么全都有据可查。哪怕部署失败你也能直接在流水线页面上看到是哪一步挂了省掉大把排查时间。Docker 解决的是环境问题。SpringBoot 应用依赖 JDK 版本、时区、中文字体、系统库这些在服务器上很容易出现“我本地好的服务器上不行”。把应用连同运行环境一起打成镜像推到镜像仓库再在服务器上拉取运行环境差异就被彻底隔离了。只要构建机上的镜像没问题运行机上就一定能跑。1.3 整条链路里的几个角色给刚接触的朋友列一下这套方案里的成员角色职责放在哪里GitLab代码仓库CI/CD 任务的调度中心可以是自建也可以用托管平台GitLab Runner真正执行构建/测试/部署任务的工作进程一台 Linux 机器即可Docker负责把应用构建成镜像并在服务器上运行容器构建机和部署机都需要镜像仓库存放打包好的镜像供部署机拉取GitLab Registry 也可以单独服务也行应用服务器跑 SpringBoot 容器的目标机器云主机或内网机器都行我习惯让 Runner 直接跑在应用服务器上这样部署阶段不用跨机器传镜像省掉不少网络折腾。但如果你应用服务器资源紧张也可以单独搞一台构建机Runner 装构建机上通过 SSH 让远端服务器拉镜像。2. 环境准备Runner、Docker、目录规划一次到位2.1 注册一个只属于这个项目的 RunnerRunner 的安装不复杂官方源里直接装就行。真正要花心思的是注册参数尤其是executor这一项。我强烈建议选择dockerexecutor而不是shellexecutor。原因很直接shell executor 直接在宿主机上跑命令先不说安全性光是“上一次构建留下的文件”就够你喝一壶。docker executor 每次任务都会临时起一个干净容器跑完直接销毁环境天然隔离我后面所有构建脚本都能在上面放心执行。注册命令大概长这样gitlab-runner register \ --url http://gitlab.example.local \ --registration-token glrt-xxxxxx \ --executor docker \ --docker-image alpine:3.20 \ --tag-list springboot-runner \ --description springboot build runner注册时候给 Runner 打上 tag比如springboot-runner。然后在.gitlab-ci.yml里通过tags字段指定要用哪个 Runner否则任务会被丢到共享 Runner 池里行为不可控。2.2 Docker 环境和 Compose 插件的准备部署机上需要 Docker 和 Compose 插件。Docker 装好后先确认docker compose这个子命令可用因为新版本已经不再推荐docker-compose那个独立命令了。docker --version docker compose version如果这两条命令能正确输出环境就差不多齐了。注意 Runner 执行 Docker 任务时需要把宿主机的 Docker socket/var/run/docker.sock挂载到 Runner 的构建容器里否则任务里面执行docker build会报“Cannot connect to the Docker daemon”。Runner 的配置路径一般在/etc/gitlab-runner/config.toml找到对应 runner 的[runners.docker]段加上一句volumes [/var/run/docker.sock:/var/run/docker.sock, /cache]挂载 Docker socket 是有安全代价的毕竟容器里能操作宿主机 Docker 了。对于收编项目、可信 Runner 来说可以接受如果跑的是公共项目、来路不明的提交强烈建议不要这么干改用动态注册 Runner 或者独立部署机。2.3 服务器上的目录规划别把东西全堆在根目录部署目录看着不起眼但规划不好后面很痛苦。我在应用服务器上的/opt/app下放编排文件和配置大致是这样/opt/app/ docker-compose.yml .env logs/ app.log data/ upload/.env文件用来存放环境变量比如数据库地址、密码、APP_VERSION等。docker compose会自动读取同目录下的.env这样版本号在部署时可以通过环境变量替换进去不用每个版本都改docker-compose.yml。日志单独挂到宿主机上是因为容器一旦重建里面的日志文件就没了。把容器内的日志目录用 volume 映射到宿主机的logs目录排查问题直接tail宿主机文件方便得多。3. Dockerfile 编写先给 SpringBoot 应用一个可复现的镜像3.1 用多阶段构建把编译环境和运行环境分开SpringBoot 应用最常见的构建方式是 Maven但 Maven 和 JDK 完整版体积很大不适合直接作为运行镜像。多阶段构建能解决这个问题第一阶段用 Maven 镜像完成编译打包第二阶段只把产物拷进瘦身 JRE 镜像。FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /workspace COPY pom.xml . RUN mvn -B dependency:go-offline COPY src ./src RUN mvn -B clean package -DskipTests FROM eclipse-temurin:17-jre RUN groupadd -r app useradd -r -g app app USER app WORKDIR /app COPY --frombuild /workspace/target/*.jar ./app.jar EXPOSE 8080 ENTRYPOINT [java, -Xms256m, -Xmx512m, -jar, /app/app.jar]USER app这一步千万别省略。SpringBoot 应用默认的 Tomcat 在容器里监听 8080 端口非 root 用户完全够用。用 root 跑容器一旦应用被攻击攻击者直接拥有容器内最高权限风险不值得冒。dependency:go-offline是为了在构建前期就把依赖拉好。后面的mvn package如果依赖没下载会走go-offline留下的本地缓存避免因网络抖动导致构建失败。3.2 基础镜像怎么选小没错但别小到没法排障JRE 镜像我用的是eclipse-temurin因为它有官方的 17-jre 版本体积比完整 JDK 小很多该有的运行时组件都有。Alpine 版本体积确实更小但有的 SpringBoot 应用会依赖 glibc 相关库Alpine 用得是 musl容易出现“本地可以跑镜像里起不来”的诡异问题。新手如果拿不准优先选带 JRE 的完整发行版不要追极致的镜像大小。镜像大几十兆换来的是排障时不缺工具值。另外时区问题要提前处理。容器默认时区是 UTC日志时间会跟业务时间差 8 个小时排查问题时真的很误事。我在 Dockerfile 里固定时区ENV TZEtc/GMT-8只要业务团队约定好用这个时区日志时间就对得上了。要注意不要图省事直接把宿主机的/etc/localtime挂进去那样会引入宿主机时区不确定性。3.3 镜像标签策略别再用 latest 了镜像标签直接决定你能不能回滚。用latest有个坏处服务器上跑的服务到底是今天构建的还是上周构建的完全靠猜。我采用的策略是“提交号短 SHA”TAG$(CI_COMMIT_SHORT_SHA)在.gitlab-ci.yml里直接引用这个变量构建出来的镜像就是registry/myapp:abcd1234。谁部署了什么版本看镜像列表一目了然要回滚时把 compose 文件里的APP_VERSION改成上一个镜像标签重新up -d就行。4. 编写 .gitlab-ci.yml把流程固化成版本化的代码4.1 阶段划分不追求一步到位但求清晰可追溯我的stages只分四段build、test、package、deploy。每个阶段对应一个 job名字也起得直白。stages: - build - test - package - deploybuild阶段只做编译和单元测试package打镜像并推送deploy触发服务器更新。为什么要分开因为失败时你能直接看出来是哪一类问题编译挂了去查代码测试挂了去查逻辑镜像构建失败去查 Dockerfile。4.2 一个可直接套用的完整的 .gitlab-ci.yml下面是我实际项目的精简版本去掉了业务相关配置但结构是完整的variables: MAVEN_OPTS: -Dmaven.repo.local$CI_PROJECT_DIR/.m2/repository cache: key: $CI_COMMIT_REF_SLUG paths: - .m2/repository/ build: stage: build image: maven:3.9-eclipse-temurin-17 script: - mvn -B clean compile tags: - springboot-runner test: stage: test image: maven:3.9-eclipse-temurin-17 script: - mvn -B test tags: - springboot-runner rules: - if: $CI_COMMIT_REF_NAME main package: stage: package image: docker:26 services: - docker:26-dind variables: IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker build -t $IMAGE_TAG . - docker push $IMAGE_TAG tags: - springboot-runner rules: - if: $CI_COMMIT_REF_NAME main deploy: stage: deploy image: alpine:3.20 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 - echo -e Host *\n\tStrictHostKeyChecking no\n ~/.ssh/config - ssh $DEPLOY_USER$DEPLOY_SERVER cd /opt/app echo APP_VERSION$CI_COMMIT_SHORT_SHA .env docker compose pull web docker compose up -d web tags: - springboot-runner rules: - if: $CI_COMMIT_REF_NAME main - when: manual注意这个文件里的几个关键点。package阶段用了docker:26-dind作为 service这是因为这个 job 运行在 Runner 的 docker executor 里它自己需要 Docker 能力来执行docker build。如果你的 Runner 挂载了宿主机的 docker.sock可以不启用 DinD直接构建。两种方式各有取舍前者隔离性好后者速度快一点。deploy阶段用了alpine:3.20作为 job 镜像只装 openssh-client 就够了。SSH 私钥通过 GitLab CI 变量传入用tr -d \r去掉 Windows 换行符这是个容易踩的坑后面我会专门讲。cache的配置用于缓存 Maven 依赖key按分支区分避免不同分支互相污染。$CI_COMMIT_REF_SLUG是当前分支名转成的安全字符串。4.3 敏感信息全放到 CI 变量里不要出现在代码中部署需要用到服务器地址、SSH 私钥、镜像仓库密码这些绝不能明文写在.gitlab-ci.yml。GitLab 提供了 Settings - CI/CD - Variables 页面把变量定义在那里流水线运行时自动注入。我一般会定义这几个变量名含义SSH_PRIVATE_KEY部署用的 SSH 私钥DEPLOY_USER服务器登录用户DEPLOY_SERVER服务器地址CI_REGISTRY_USER镜像仓库用户名CI_REGISTRY_PASSWORD镜像仓库密码把这些变量设为protected和masked。protected意味着只有受保护分支的流水线才能使用masked可以让 GitLab 在日志里自动打码防止敏感信息被打印出来。4.4 构建加速Maven 依赖缓存和 Docker 层缓存没做缓存之前每次构建都要重新下载 Spring Boot 全家桶依赖一次构建三五分钟都下不完依赖。加入 Maven 依赖缓存后构建时间明显下降。但光有 Maven 缓存还不够Docker 镜像构建也需要缓存。Dockerfile 里的每一行RUN都会产生一个镜像层如果某层指令没变Docker 会直接复用缓存层。问题在于 Maven 仓库如果每次都变后续层全失效。我的优化方案是先在 Dockerfile 里单独COPY pom.xml并执行RUN mvn dependency:go-offline这样只要pom.xml不变依赖层就能命中缓存。加上 Runner 侧挂载的/cache目录构建时间能压缩 50% 以上。5. 部署阶段的实战细节从流水线到服务真正跑起来5.1 让流水线通过 SSH 远程操作服务器部署动作最终发生在应用服务器上流水线跑在一台机器上中间需要安全通信。我选择的是 SSH原因很简单复用现有 Linux 账号体系配置起来最直接。在应用服务器上准备一个专用部署账号把公钥加到它的authorized_keys中私钥存到 GitLab CI 变量里。然后部署 job 里用ssh执行命令。需要特别提醒的是SSH 命令里的远程脚本如果包含环境变量容易出问题。我一开始写的时候没注意引号导致$CI_COMMIT_SHORT_SHA在远程端被展开为空值部署完成才发现线上容器跑的还是旧镜像。经过好几次试验后才稳定下来注意整个远程命令要写成单引号包裹或者转义确保本地先展开变量远程只收到具体值。5.2 用 docker compose 管理容器的生命周期服务器上不建议直接docker run参数太多容易漏。用 compose 文件声明服务可读性高也方便管理环境变量、网络、卷。这是对应的docker-compose.ymlservices: web: image: $APP_REGISTRY/web:${APP_VERSION} container_name: app-web restart: unless-stopped ports: - 8080:8080 environment: - TZEtc/GMT-8 - SPRING_PROFILES_ACTIVEprod volumes: - ./logs:/app/logs healthcheck: test: [CMD-SHELL, wget -qO- http://127.0.0.1:8080/actuator/health || exit 1] interval: 30s timeout: 5s retries: 3 start_period: 30s部署流水线里我执行的是docker compose pull web docker compose up -d web。pull先拉取新镜像up -d负责重建容器。restart: unless-stopped保证服务器重启后服务能自动拉起来不用人为干预。healthcheck是后期加上去的让我在容器内之间判断应用是否就绪。因为 SpringBoot 有actuator/health这个探针端点正好拿来用。如果你没引入 actuator也可以检查一个内部接口或者干脆用docker compose up后的进程存在性来判断。5.3 分支策略和手动触发不是每次提交都要上线不是所有提交都值得部署到生产。我的规则是只有main分支合并后的构建才会进入部署阶段其他分支只构建不部署。.gitlab-ci.yml里的rules块负责这件事rules: - if: $CI_COMMIT_REF_NAME main - when: manual第一行表示 main 分支自动触发部署。第二行给其他分支留了一个手动执行的入口方便在测试环境手动部署某个分支。when: manual的 job 在页面里会显示为一个可点击的按钮只有点了才跑。这个设计很实用比如你想在预发环境看某个功能分支的效果直接手动触发一次部署就行。5.4 回滚别慌把版本号改回去就行部署最怕的就是发布后出问题。由于镜像 tag 是 commit SHA回滚变得特别简单。在服务器上执行cd /opt/app echo APP_VERSION上一个版本的SHA .env docker compose pull web docker compose up -d web如果做了流水线也可以重新跑一遍 deploy job但要注意.env里的值不能写死否则每次部署都会覆盖成固定版本。一般建议.env由流水线写入回滚时手动改它。6. 常见问题与排查记录这里全是实际操作中踩过的水坑6.1 Runner 构建容器里没有 Docker 权限初次配置时最容易踩的坑是docker build报Cannot connect to the Docker daemon at unix:///var/run/docker.sock。原因是 Runner 的 task 容器没挂载宿主机的 Docker socket或者 job 里没有启动 DinD 服务。两种方案按需选择要么在 Runner 配置里挂载 socketvolumes [/var/run/docker.sock:/var/run/docker.sock, /cache]要么在 job 里声明 DinD 服务。挂载 socket 简单直接但安全性弱一些DinD 隔离性更好但要额外拉取镜像构建速度稍慢。我在正式环境用的挂载 socket 方案因为项目可信、构建任务不多。6.2 镜像构建缓存不生效每次像第一次一样这个问题烦了很久。后来发现是 Docker BuildKit 没有打开导致构建器不会复用之前构建的缓存层。在运行docker build前我加了一行export DOCKER_BUILDKIT1同时 Dockerfile 里把依赖下载放在源码拷贝之前这样pom.xml没变时依赖层能直接命中缓存。如果你用 GitLab Runner 挂载了共用缓存目录构建机重启后缓存还在效果更明显。6.3 容器起来之后马上退出日志却什么都看不到有时候docker compose up -d显示容器创建成功但两三秒后状态变成Exit 1。第一件事永远是看日志docker logs app-web --tail 100常见原因有三个。一是 8080 端口被宿主机其他进程占用SpringBoot 启动直接失败二是数据库连接不上等不到连接池建立就退出了三是 JVM 参数分配过大容器内存限制不够被 OOM Killer 杀掉。前两种情况在日志里都会明确报错第三种要查dmesg或容器状态。我在项目里给容器加了mem_limit: 1g和健康检查问题出现时能第一时间精确定位。6.4 依赖服务没就绪SpringBoot 启动就挂如果应用依赖 MySQL、Redis部署阶段很容易遇到的问题是数据库容器才刚启动SpringBoot 就去连库结果连不上直接报错退出。depends_on在 compose 里默认只保证启动顺序不保证服务就绪。我的解决办法是给数据库服务加健康检查然后让 web 依赖它的 healthy 状态services: db: image: mysql:8.0 healthcheck: test: [CMD, mysqladmin, ping, -h, 127.0.0.1] interval: 10s timeout: 5s retries: 5 web: depends_on: db: condition: service_healthy这种方式比在 web 脚本里sleep 30要可靠得多。sleep只能赌时间足够健康检查是确认服务真的就绪了才放行。6.5 Windows 换行符导致 SSH 私钥验证失败这个坑很隐蔽。开发同事在 Windows 上把私钥复制进 GitLab CI 变量时私钥内容带上了\r换行符SSH 连接时直接报invalid format。后来我在部署 job 里统一加了处理echo $SSH_PRIVATE_KEY | tr -d \r | ssh-add -这个方法简单有效不管之前在哪个平台准备的私钥都能被正确解析。6.6 流水线变量在远程命令中被吃掉把$CI_COMMIT_SHORT_SHA传给远程服务器时如果远程命令用了双引号变量可能在本地被正确展开也可能被远程 shell 再次解释。我的经验是尽量让远程命令保持简单把要传递的值放到.env文件里远程只执行不涉及变量的固定命令。例如ssh userserver cd /opt/app echo APP_VERSION$CI_COMMIT_SHORT_SHA .env docker compose pull web docker compose up -d web这里的变量在本地展开后再发送远程拿到的就是具体版本号。如果写成单引号远程 shell 会尝试解释$CI_COMMIT_SHORT_SHA结果为空部署必定翻车。7. 一些实在的体会跑通这套流程之后我最大的感受是自动部署的价值不只在省时间更在于把“发布”这件事从玄学变成科学。以前线上出问题第一反应是“服务器是不是没配好”现在第一反应是“最近哪个镜像出了问题”。如果要给刚准备入手的朋友一个建议就是别一上来就追求全流程自动化、蓝绿发布、灰度发布这些花活。先把“手动操作”按顺序列出来照着写成流水线跑通一次再说。第一条流水线哪怕是mvn compile只构建不部署也比没有强。后面再慢慢加测试、加镜像扫描、加健康检查。最后分享一个小技巧部署完成后别再盯屏幕让流水线把构建结果推送到团队聊天工具。GitLab 提供了 webhook 或者集成方式构建失败时自动发一条消息到工作群。对于小团队来说这比任何监控系统都直接。