GitLab CI/CD与Docker Engine全流程实践:从提交代码到自动部署 改一个配置要在服务器上敲一下午命令改错了还得回滚半天这种日子我过了挺久。后来我把发布流程拆成一条完整的CI/CD全流程代码一推、流水线自动跑完构建测试打包部署环境一致性、版本可追溯、回滚秒级切换才真正落地。这篇文章我就以GitLab CI/CD为主线结合Docker Engine、GitLab Runner把从提交代码到服务上线的完整链路拆开讲清楚尤其是GitLab Runner配置、流水线stage设计、镜像构建以及前后端分离项目我拿若依RuoYi做了完整案例的部署实践。内容偏向实操适合正在做自动化交付、DevOps改造或者刚接触CI/CD但不知道从哪里下手的读者照着做基本能复现。1. CI/CD全流程怎么设计才算“全”1.1 从一次手动发布说起CI/CD到底在解决什么大部分团队最开始都有过这样的状态开发本地改代码测完没问题然后把项目打包上传到服务器停服务、替换文件、重启依赖的MySQL和Redis还得手动确认。这套流程最大的问题不在“麻烦”而在“不可靠”。你本地能跑起来的环境、依赖版本、配置项到服务器上可能完全是另一套同一个包在不同人手里打出来的结果理论上应该一致但Java版本、Maven配置、Node版本稍有差异产物就不同。CI/CD全流程要解决的就是把这一串人为操作标准化、自动化、可重复。持续集成CI解决的是代码提交之后自动完成编译、测试、静态检查、镜像构建这些环节让每次提交都有确定的产物和验证结果。持续交付CD解决的是把验证过的产物按需部署到测试环境或者预生产环境。持续部署则是更进一步合并到主干就自动发布到生产。实际落地时没人会一上来就追求“合并即上线”更多是先把CI做强CD一步一步放开。很多人把CI/CD等同于写一个.gitlab-ci.yml这是一个误区。流水线文件只是整个体系的表面背后还有执行环境、制品仓库、部署目标、权限控制、通知机制。全流程至少要覆盖代码触发、单元测试与构建、镜像或者包的生成与存储、环境部署、健康检查、失败回滚这六个环节。缺了任何一个流程都禁不起一次真实故障的考验。我当时拆分流程时给自己定了一个原则手工能做的每一步都要能映射成流水线里的一个job流水线每个job做完之后我要能回答三个问题——它做了什么、产物在哪、怎么验证。回答不了这个流程设计就是失败的。1.2 为什么我最终选了GitLab Docker Engine这套组合工具选型这件事不同团队背景会得出不同结论。团队里如果已经有Jenkins或者更偏向Kubernetes原生生态那么用Tekton、Argo CD都合理。但我自己最终选择了GitLab作为代码托管与CI/CD平台执行环境全部交给Docker Engine大概基于以下几个考虑。GitLab并不只是一个CI工具它是把代码仓库、Merge Request审批、Issue、制品库Container Registry和流水线放在一处的平台。这意味着开发、测试、运维看到的是同一套链路开发提交MR流水线自动跑测试结果直接显示在MR页面合并之后自动触发后续流程无需在多个系统之间来回跳。GitLab Runner作为流水线执行器可以选择不同的executor而Docker executor是我认为最平衡的方案——每个job跑在独立容器里环境是全新的依赖不会残留跟宿主机隔离。Docker Engine在这里扮演的角色不只是容器运行时更重要的是统一封装工具链。前端构建要Node 16后端构建要JDK 11测试环境要Python 3.9这些环境要求如果直接装在宿主机上一场灾难已经开始。用Docker镜像来承载构建环境每个项目的每个阶段都能声明自己需要的镜像互不干扰。有人会问那跟直接用Jenkins加Docker Plugin有什么区别区别在于耦合深度。GitLab把“MR也是发布入口”这件事做得很自然合并动作本身可以触发CDJenkins也能做但需要写大量脚本去对接GitLab的Webhook和API。另外GitLab自带的Container Registry省掉了一套独立的镜像仓库系统拉镜像走内网速度也快。如果你的团队已经重度使用GitLab再单独拉一套Jenkins出来收益真的不大。1.3 一套典型流水线的阶段划分与影响范围流水线设计不能一上来就写代码先画阶段stage。我习惯把一个完整流程拆成这么几个阶段prepare处理基础准备工作比如拉取依赖索引、准备环境变量build编译打包生成jar、dist、镜像这类可交付产物test单元测试、接口测试、质量扫描package把产物封装成镜像或者部署包并上传到制品库deploy按环境分别部署到测试机、验收机、生产机verify部署之后的健康检查、冒烟测试阶段的划分决定流水线的并行性和容错率。相邻阶段之间是严格串行的前一阶段失败后一阶段直接跳过整个流水线标记为失败。同一阶段里的多个job可以并行执行比如前端构建和后端构建互不依赖就可以放在同一个stage里并行跑能省一半时间。这套设计的影响范围比很多人想象的要大。从项目组视角看它改变了协作方式以前是“我代码好了你自己拉下来跑”现在是“我代码合并了流水线已经跑到测试环境了你有问题直接在环境上验证”。从运维视角看服务器上不再需要人工安装一堆构建工具链服务器权限也可以收得非常紧因为流水线通过Runner执行普通开发不再需要SSH到生产机器。从管理层视角看每一次构建和部署都有日志、有记录、可审计这是一个组织交付能力成熟的标志。2. Runner与Docker Engine的配置最容易被低估的一步2.1 GitLab Runner安装与注册的细节Runner是整个流水线的“手脚”你的流水线写得多漂亮Runner不干活或者跑错环境就全白搭。安装Runner本身不复杂官方提供了二进制、Helm、容器等安装方式。我在Linux服务器上最常做的就是下载二进制包直接安装# 以Ubuntu/Debian为例 curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash sudo apt-get install gitlab-runner安装完成后最关键的一步是注册。注册时你需要两个信息GitLab实例地址和注册token。token的位置在项目的Settings CI/CD Runners或者Group的Settings CI/CD Runners。注册命令大概是sudo gitlab-runner register \ --url https://gitlab.example.com \ --registration-token REGISTRATION_TOKEN \ --executor docker \ --docker-image docker:24.0 \ --tag-list docker,backend \ --description shared-runner-node1这里有个特别容易踩坑的点注册token是一次性的而且有有效期范围区分。项目runner的token只能给这个项目用group runner能给这个组下的所有项目用shared runner是实例级的权限范围不同职责也不同。个人项目注册一个project runner完全够用团队规模上来之后必须规划好group runner否则每个项目各搞一台机器资源根本管不住。注册完成后可以用gitlab-runner verify确认状态。Runner在GitLab界面上显示绿色的在线状态之后流水线执行前还有一步非常重要就是确认tag。如果job里写了tags: [docker]但Runner注册时没有绑定这个tagjob就会一直卡住显示stuck状态这是新人最常栽的跟头。2.2 config.toml里的关键参数Runner的核心配置在/etc/gitlab-runner/config.toml里。很多人装完Runner之后就不碰这个文件了但很多流水线问题恰恰出在这里。我挑几个必调的参数讲。concurrent决定这台Runner能同时跑多少个job。默认值是1意味着即使同一流水线里有很多并行job也得排队等着。一个2核4G的机器我通常会设2如果是4核8G或者专门的构建机可以设到4到6。具体数值不是越大越好因为每个docker executor job启动的是完整容器会吃CPU吃内存设太大会导致整个机器卡死。[[runners]]块里的docker参数最关键的是image、pull_policy和privileged。concurrent 2 check_interval 10 [[runners]] name docker-runner url https://gitlab.example.com token TOKEN executor docker [runners.docker] image docker:24.0 pull_policy if-not-present privileged true volumes [/cache, /var/run/docker.sock:/var/run/docker.sock] [runners.cache] Type s3 Shared truepull_policy有三种常见取值always每次都强制拉取最新镜像保证最新但慢if-not-present本地存在就不拉速度最快但可能用到旧镜像always和if-not-present之间的权衡要自己掌握。我自己的习惯是日常触发用if-not-present需要严格复现的时候在job里单独指定pull_policy: always覆盖兼顾速度与可靠。privileged这个参数很多人不敢开因为容器处于特权模式会有安全顾虑。但如果你需要在流水线里构建Docker镜像大概率还是要开或者你要用后面会讲到的Docker in Docker方案。如果你的Runner机器在内网、只服务内部项目privileged合理使用是可以接受的如果Runner对公网开放必须加安全护栏比如限制注册、限制镜像来源。2.3 Executor选型对比为什么Docker Executor最省心GitLab Runner支持很多executorshell、ssh、docker、kubernetes、parallels、virtualbox等等。我重点对比三个常见方案。shell executor直接在Runner宿主机上执行命令配置最简单但污染最严重。跑一次npm install、mvn package宿主机上就积累一堆依赖和进程环境互相影响。除非是跑一些非常简单的定时任务否则我不推荐。docker executor每个job用独立容器执行环境干净镜像即工具链隔离性最好这也是我主力使用的方案。kubernetes executor每跑一个job就起一个Pod资源弹性最好特别适合已经有K8s集群的团队。缺点是调试链路长日志获取复杂网络和RBAC都需要额外配置。如果你的流水线只是偶尔跑几个脚本shell executor也许够用。但一旦进入我前面说的CI/CD全流程多个项目、多套环境、多种工具链混跑不用Docker executor你很快会后悔。docker executor让“不同项目用不同镜像”变成一句配置的事不用在宿主机上装任何语言运行时这个收益会随着项目数量线性放大。我自己的实践中还处理过一个多Runner场景两台机器分别服务不同类型项目一台打标签为frontend跑Node构建一台打标签为backend跑Java构建。业务上不冲突资源分配也清晰。Runner多了之后要注意监控磁盘空间和镜像残留我后来加了一个每日定时任务清理悬空镜像才没有把构建机磁盘吃满。3. .gitlab-ci.yml流水线设计从提交到镜像的完整链路3.1 流水线的骨架stage、job、script、rulesCI/CD全流程的核心文件就是根目录下的.gitlab-ci.yml所有流水线的编排逻辑都在这里。初次接触的人经常把它当成“命令列表”来写实际上它是一套声明式的流程编排语言最基本的骨架是这样stages: - build - test - package - deploy variables: APP_NAME: demo-app IMAGE_TAG: $CI_COMMIT_SHORT_SHA build-job: stage: build image: maven:3.8-jdk-11 script: - mvn clean package -DskipTests artifacts: paths: - target/*.jar expire_in: 1 day tags: - backendstages定义全局阶段顺序job里的stage声明这个任务属于哪个阶段。script是真正的执行命令。tags用于匹配Runner。artifacts用于把构建产物传递给后续job比如package阶段要用build阶段打出来的jar包就必须通过artifacts传递。rules是控制job要不要运行的表达式这在水流线设计里地位极高。例如只有打了tag的提交才触发正式部署deploy-prod: stage: deploy script: - bash deploy/scripts/deploy_prod.sh rules: - if: $CI_COMMIT_TAG ~ /^v\d\.\d\.\d$/ when: manualwhen: manual表示这个job需要人工点击确认这是生产环境部署的安全闸门。我强烈建议在生产部署环节加上manual确认哪怕你觉得全自动很酷。自动部署到测试环境毫无问题生产环境至少留一个“人看一眼”的环节避免合并代码的瞬间直接引发线上事故。3.2 前后端协同构建缓存、产物、镜像标签一个典型的前后端分离项目在流水线里通常会有两条构建链路一条是Maven或Gradle构建后端一条是npm构建前端最后在package阶段合并成可部署的镜像组合。后端构建用Maven的时候有一个关键优化是依赖缓存。每次跑mvn clean package都会重新下载依赖几十个GB的仓库虽然服务器带宽内网快但下载还是浪费时间的。GitLab Runner支持缓存在job里声明cache: key: $CI_COMMIT_REF_NAME paths: - .m2/ variables: MAVEN_OPTS: -Dmaven.repo.local$CI_PROJECT_DIR/.m2这样每个分支共用一份Maven本地仓库缓存命中之后构建速度提升非常明显。前端构建同理把node_modules缓存起来再结合npm的lockfile依赖安装速度能快好几倍。镜像标签策略我踩过不少坑。很多人无脑打latest生产部署永远拉latest看起来自动化了实际上完全不可回滚——你根本不知道当前跑的是哪个版本。我的习惯是每次合并到主干或者打tag时用$CI_COMMIT_SHORT_SHA作为镜像标签同时给最新版本额外补打一个latest标签方便开发环境调试。生产环境尽量用带短SHA的精确版本部署这样回滚时就重新触发部署对应SHA的镜像干净利落。产物传递还有一个细节artifacts默认只在同一个流水线的不同job之间传递而且传递的是文件路径不是环境变量。前端dist和后端jar都上传为artifacts然后在package阶段分别复制进对应镜像这是多job协作的基本玩法。3.3 多环境部署的分支策略与手动确认关卡环境管理是CI/CD全流程里最容易搞乱的部分。就我的实践经验一个项目至少应该有dev、test、prod三个环境。dev环境是提交自动触发test环境是合并到主干触发prod环境是打tag加手动确认触发。这套分支策略用rules写出来非常清晰deploy-dev: stage: deploy environment: dev script: - sh deploy.sh dev only: - branches deploy-test: stage: deploy environment: test script: - sh deploy.sh test rules: - if: $CI_COMMIT_BRANCH main || $CI_COMMIT_BRANCH master deploy-prod: stage: deploy environment: production script: - sh deploy.sh prod rules: - if: $CI_COMMIT_TAG ~ /^v.*$/ when: manualGitLab里environment字段会生成部署记录配合environment: url甚至可以直接跳转到对应环境地址这一点在排查“这次部署的是哪个版本”时极其有用。我会要求生产环境的job必须把部署的镜像版本号打印到日志里配合部署记录的前后台审核谁在什么时间部署了什么版本一查便知。还有一点多环境部署时配置文件的管理要特别谨慎。代码仓库里至少需要区分application-dev.yml和application-prod.yml或者用环境变量注入。生产密码、数据库连接串这类敏感信息千万不要硬编码在镜像里使用GitLab CI/CD Variables在job里注入或者更安全的Vault方案。我见过太多把数据库密码写死在代码库里最后泄露的事故这个红线不能碰。4. 实际部署案例若依(RuoYi)前后端分离项目全流程4.1 项目结构拆解与部署模型说完了通用设计我用一个真实开源项目若依RuoYi来做全流程拆解。若依Vue版本是典型的前后端分离架构后端是Spring Boot多模块Maven项目包含ruoyi-admin、ruoyi-framework、ruoyi-system等模块前端是Vue2 Element UI构建产物就是一套纯静态文件。数据库使用MySQL缓存用Redis前端通过Nginx反向代理后端API。这个项目的CI/CD流水线比单服务复杂因为前端和后端可以分别构建但部署时又需要配合部署。我先确定部署模型后端是一个Spring Boot jar包跑在JVM里通过Docker容器运行前端是一个Nginx容器挂载构建后的静态文件和一份反向代理配置MySQL和Redis单独用docker-compose管理原则上不随业务代码频繁升级。我选择docker-compose作为部署编排工具而不是直接裸docker run因为RuoYi部署需要至少四个服务MySQL、Redis、后端、前端用compose可以统一网络、存储卷和重启策略。生产环境如果规模大了再上K8s也来得及初期用compose足以支撑几十个人的研发团队。4.2 后端Dockerfile与镜像构建RuoYi后端构建的核心是Maven。我在流水线的build阶段运行mvn clean package -DskipTests产物在ruoyi-admin/target/ruoyi-admin.jar。在写Dockerfile的时候有几个细节必须处理基础镜像选择要匹配JDK版本RuoYi基于Spring Boot 2.x通常用JDK 1.8或者11时区问题特别容易被忽略官方OpenJDK镜像默认是UTC时区如果代码里有定时任务或者日志按本地时间展示时间会差8小时。我最终用的后端镜像大概长这样FROM openjdk:8-jdk-alpine LABEL maintainerdevopsexample.com ENV TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone WORKDIR /app COPY ruoyi-admin/target/ruoyi-admin.jar /app/ruoyi-admin.jar EXPOSE 8080 ENTRYPOINT [java, -Xmx512m, -Xms256m, -Duser.timezoneAsia/Shanghai, -jar, /app/ruoyi-admin.jar]关于数据库连接、Redis连接这些配置我强烈建议不要打包进镜像。RuoYi默认的application-druid.yml里写了很多本地连接配置到了流水线部署阶段要用环境变量覆盖。我的做法是在Dockerfile里保留默认配置在docker-compose里用environment注入数据库地址、用户名、密码Java的Spring Boot会自动读取环境变量并覆盖配置文件里的同名属性。构建镜像的流水线job我这么写docker-build-backend: stage: package image: docker:24.0 services: - docker:dind variables: DOCKER_HOST: tcp://docker:2375 DOCKER_TLS_CERTDIR: script: - docker build -f backend/Dockerfile -t $CI_REGISTRY/ruoyi/backend:$CI_COMMIT_SHORT_SHA . - docker tag $CI_REGISTRY/ruoyi/backend:$CI_COMMIT_SHORT_SHA $CI_REGISTRY/ruoyi/backend:latest - docker push $CI_REGISTRY/ruoyi/backend:$CI_COMMIT_SHORT_SHA - docker push $CI_REGISTRY/ruoyi/backend:latest tags: - backend这个job使用了docker:dind服务在流水线里单独启动一个Docker daemon作为构建引擎避免直接操作Runner宿主机上的Docker守护进程。4.3 前端Dockerfile与Nginx配置RuoYi前端构建命令是npm install --registryhttps://registry.npmmirror.com npm run build:prod构建产物在ruoyi-ui/dist目录。前端镜像我用多阶段构建避免把Node运行时带进生产镜像第一阶段用Node镜像完成编译第二阶段用Nginx镜像托管静态文件。# 构建阶段 FROM node:16-alpine AS build WORKDIR /app COPY ruoyi-ui/package*.json ./ RUN npm install --registryhttps://registry.npmmirror.com COPY ruoyi-ui/ ./ RUN npm run build:prod # 运行阶段 FROM nginx:1.24-alpine COPY --frombuild /app/dist /usr/share/nginx/html COPY default.conf /etc/nginx/conf.d/default.conf EXPOSE 80前端部署的核心在Nginx配置。RuoYi的前端页面默认通过/prod-api前缀调用后端接口所以Nginx必须把带/prod-api的请求反向代理到后端服务地址。我用的配置是server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; location /prod-api/ { proxy_pass http://backend:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }这里backend是docker-compose网络里后端服务的服务名Nginx容器在同一个compose网络内能直接通过服务名解析到后端容器的内网IP。location /prod-api/后面的proxy_pass http://backend:8080/注意末尾的斜杠很关键它会把/prod-api/xxx替换成/xxx转发给后端这和后端Controller的路径是配套的。前端镜像里包含编译好的Nginx配置通过COPY default.conf打进镜像。4.4 docker-compose编排与服务健康检查部署阶段我写了一份docker-compose.yml负责把MySQL、Redis、后端、前端全部拉起来并且定义了服务依赖关系和网络。version: 3.8 services: mysql: image: mysql:8.0 container_name: ruoyi-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: ruoyi volumes: - mysql-data:/var/lib/mysql networks: - ruoyi-net restart: always redis: image: redis:7-alpine container_name: ruoyi-redis networks: - ruoyi-net restart: always backend: image: $REGISTRY_ADDR/ruoyi/backend:$IMAGE_TAG container_name: ruoyi-backend depends_on: mysql: condition: service_healthy redis: condition: service_healthy environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/ruoyi?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai SPRING_DATASOURCE_USERNAME: root SPRING_DATASOURCE_PASSWORD: root123 SPRING_REDIS_HOST: redis SPRING_REDIS_PORT: 6379 networks: - ruoyi-net restart: always nginx: image: $REGISTRY_ADDR/ruoyi/frontend:$IMAGE_TAG container_name: ruoyi-nginx ports: - 80:80 depends_on: - backend networks: - ruoyi-net restart: always volumes: mysql-data: networks: ruoyi-net:这个配置里有两点很值得展开。depends_on加上condition: service_healthy意味着后端容器会等待数据库和Redis通过健康检查之后再启动否则Spring Boot启动时数据库连不上会直接崩。因此MySQL和Redis的镜像都要配置健康检查。比如MySQL容器我有全局的healthcheck这里简单方式是在compose里加healthcheck: test: [CMD, mysqladmin, ping, -h, localhost] interval: 10s timeout: 5s retries: 5后端的健康检查我通常会加上Spring Boot Actuator的依赖暴露一个/actuator/health接口compose里针对backend配置healthcheck: test: [CMD, sh, -c, wget -qO- http://localhost:8080/actuator/health] interval: 10s timeout: 5s retries: 5没有健康检查的编排都是有隐患的。流程上看着是“容器起来了”实际上服务可能还在启动中流量已经进来了用户看到的全是502。4.5 流水线触发服务器更新的两种常用方式部署阶段最后一步是把新镜像推到服务器并重启服务。这里有两种主流方式。第一种Runner部署环境和生产服务器是同一台机器那直接在job里ssh到本机执行docker compose up -d就行第二种Runner在构建机应用服务器在单独一台机器需要通过SSH远程执行命令。我推荐第二种方式把构建机和运行环境分开避免在构建机上跑业务容器。流水线里的deploy job大致是scp docker-compose.yml deployprod-server:/opt/ruoyi/ ssh deployprod-server cd /opt/ruoyi REGISTRY_ADDR$CI_REGISTRY IMAGE_TAG$CI_COMMIT_SHORT_SHA docker compose up -d这里需要把Runner使用的SSH私钥配置在GitLab CI/CD Variables里然后在job里用ssh-agent加载。有一个安全细节必须强调SSH私钥不要直接作为一个普通字符串变量存在yaml里我建议用type: ssh_key类型的变量GitLab会把私钥放到特定位置配合ssh-agent自动加载减少私钥暴露风险。生产环境的更新还需要考虑滚动更新和回滚。docker-compose的滚动更新其实不完全平滑因为是先停旧容器再起新容器中间会有秒级中断。如果业务能接受几秒钟抖动那问题不大如果需要零停机就需要上蓝绿发布或者K8s的滚动更新策略。我在不少项目的生产环境就是靠compose的“先拉新镜像再重启容器”完成的配合健康检查等待后端就绪实测典型的小型业务系统抖动在2到5秒以内大部分场景可接受。5. 流水线排障实录与避坑清单5.1 Job一直卡住Runner标签匹配问题流水线提交之后Job一直卡在pending状态页面提示找不到可用的Runner。这个问题大概率是标签不匹配。我在配置RuoYi项目时有个阶段Runner注册时打了docker和backend标签但前端构建job只写了frontend标签导致前端job一直卡住。排查办法很简单打开Job页面看右上角的提示确认Runner的标签是否包含job要求的标签或者去掉job里的tags字段让任何Runner都能执行不推荐除非你只有一个Runner。另一个原因是Runner没有启动。gitlab-runner run部署成服务后一般会自动跑但注册完如果Worker还没刷新界面也会显示离线。用gitlab-runner verify可以快速确认Runner是否还挂在GitLab实例上。还有一次问题出在Runner机器的系统时间与GitLab差的太多导致通信鉴权失败这种情况同步一下NTP时间基本就解决了。5.2 Docker daemon连接失败与构建权限乱象构建Docker镜像的时候最常见的报错是Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?这个报错分两种情况。第一种是Executor是docker但job里直接调用了docker build而构建容器本身没有Docker客户端。解决办法是在job里使用docker:dind服务并设置services: - docker:dind variables: DOCKER_HOST: tcp://docker:2375 DOCKER_TLS_CERTDIR: 第二种情况是你在Runner配置里把宿主机的docker.sock挂载到了job容器里然后job里直接操作这个socket。这种做法更方便但本质上是让流水线容器控制宿主机Docker daemon如果流水线脚本被恶意篡改等于把宿主机权限交出去了。如果只在可信项目里用这种做法可以接受但我更推荐dind方案把构建隔离在临时Docker daemon里链路更干净。还有一点使用dind时如果GitLab Runner有代理网络限制拉取基础镜像可能超时。可以在job里加image: docker:24.0然后先执行docker pull来验证网络或者把用得到的基础镜像提前拉好并设置pull_policy: if-not-present。5.3 缓存失效、镜像缓存和“构建了个寂寞”有一次我遇到前端构建每次都是完整npm install耗时十几分钟但项目没有新增依赖。排查之后发现问题出在cache的key上我用了$CI_COMMIT_REF_NAME作为key但每次提交的ref都相同理论上会命中缓存可是Runner配置里的cache类型设为了S3而S3 bucket的bucket名字配置错误缓存永远写不进去自然永远不会命中。cache的配置有时候很坑。本地文件缓存最简单但无法跨Runner共享S3缓存适合分布式Runner但需要正确配置bucket、access key和endpoint。如果你的Runner只有一两台直接用本地缓存就行不用徒增复杂度。另外注意默认情况下cache只在同一个分支的job之间复用跨分支是隔离的这是为了缓存安全理解这个行为之后构建慢就知道去哪个环节看。Maven还有一套自己的缓存机制它默认的~/.m2/repository放在Runner容器里容器生命周期结束后就丢了。我通过在job里设置MAVEN_OPTS: -Dmaven.repo.local$CI_PROJECT_DIR/.m2把仓库目录放到项目工作目录再配合cache路径.m2才能让Maven依赖缓存真正起作用。5.4 时间、时区与基础镜像的坑不夸张地说时区问题是流水线部署里最容易被忽略、暴露时又最隐蔽的问题。Jenkins和GitLab都是以UTC时间记录构建日志如果Runner机器设置了本地时区日志的时间可能对不上。而真正可怕的是运行环境的时区前端展示的订单时间、日志记录时间全差8小时定时任务到了凌晨不跑、到下午一点跑。解决时区问题要区分两个层面。构建层面在Dockerfile里显式设置环境变量ENV TZAsia/Shanghai运行层面docker-compose里加environment: - TZAsia/Shanghai对于Java应用我还会在启动命令里显式加上-Duser.timezoneAsia/Shanghai因为有些Java框架只认user.timezone这个系统参数。如果你使用的是K8s部署偷懒的办法是把TZ写入Pod的环境变量但node上的时区不同仍会有影响建议还是统一镜像内时区。基础镜像坑则主要集中在架构不匹配。比如在一台ARM服务器上构建镜像时你引用的某个镜像只有AMD64版本构建会报exec format error。这个时候要么更换多架构的基础镜像要么在Dockerfile里明确指定FROM --platformlinux/amd64。使用docker buildx做多架构构建是正规解法但同时会显著增加构建时间非强制需求不用搞。5.5 常见的错误速查表最后把流水线里最常遇到又最有共性的问题整理成一张表方便大家对着排查。现象主要原因快速解法Job一直PendingRunner标签不匹配或Runner离线检查job tags和Runner tagsgitlab-runner verify无法连接Docker daemonexecutor容器内没有Docker环境加docker:dind服务设置DOCKER_HOST拉取镜像很慢基础镜像过多或网络受限使用企业镜像仓库、提前pull、pull_policy调整Maven构建每次都全量下载依赖缓存路径不对设置MAVEN_OPTS仓库路径cache.m2npm install巨慢网络和缓存策略使用内部npm registry缓存node_modules服务启动就崩溃数据库/Redis还没就绪增加depends_on condition和健康检查数据库时间戳差8小时基础镜像时区为UTC设置TZ和user.timezone前端页面接口404Nginx代理路径配置错误检查location路径和proxy_pass斜杠部署日志显示成功但服务不可用缺少健康检查配置actuator或nginx healthcheck排查的时候有个习惯值得养成把流水线日志当成交付的一部分来看每次失败不是“删了重跑”而是先看失败在哪一步、产物有没有生成、环境变量是否正确。CI/CD做得好不好不看成功路径有多顺看失败路径能不能在两分钟内定位到根因。我自己在维护这些流水线的时候最大的体会是设计阶段多花一小时运维阶段省一百小时。把stage拆清楚、把镜像标签约定好、把健康检查补齐前期看起来多做了一堆事但一旦流水线跑起来你确实可以做到只写代码、看结果其他的都交给系统去盯。