GitLab CI 生产级实战——Runner 配置、缓存、Docker-in-Docker 完整方案
GitLab CI 生产级实战——Runner 配置、缓存、Docker-in-Docker 完整方案
适读人群:使用 GitLab 的研发团队、DevOps 工程师 | 阅读时长:约19分钟 | 核心价值:从 Runner 选型到 DinD 镜像构建,生产环境踩过的坑全在这里
有一次我们从 GitHub 迁移到私有化 GitLab,以为 CI 配置直接照着文档抄就行了。
结果第一周就出了三个问题:Runner 跑着跑着内存溢出,流水线莫名其妙挂掉;Docker-in-Docker 构建因为 TLS 证书问题一直报 Cannot connect to the Docker daemon;Maven 缓存配置了但完全没生效,每次构建还是要重新下载三百多 MB 的依赖。
这三个问题折腾了整整两周,给公司造成的影响是——发布频率从日均 3 次降到了日均不足 1 次,研发团队怨声载道。
这篇文章就把这些坑的来龙去脉、解决方案都讲清楚。
Runner 选型与配置
Runner 类型选择
GitLab CI Runner 有三种 executor,用得最多的是 shell、docker、kubernetes:
| Executor | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| Shell | 简单直接 | 环境污染,安全隔离差 | 快速验证、小团队 |
| Docker | 环境隔离,可重现 | 需要管理镜像缓存 | 大多数 CI 场景 |
| Kubernetes | 弹性扩缩容,无需管理机器 | 配置复杂 | 大规模 CI,云原生团队 |
对于多数中小团队,Docker executor 是最佳选择。既有环境隔离,又不需要 K8s 集群。
注册 Docker Runner
在 GitLab Admin > Runners 页面创建 Runner,获取 token 之后:
# 在准备用作 Runner 的机器上执行
gitlab-runner register \
--url https://gitlab.company.com \
--registration-token YOUR_TOKEN \
--executor docker \
--docker-image alpine:latest \
--description "production-runner-01" \
--tag-list "linux,docker,production" \
--run-untagged=false \
--docker-privileged=false \
--docker-volumes "/cache:/cache" \
--docker-volumes "/var/run/docker.sock:/var/run/docker.sock"注意 --docker-privileged=false,不要给 Runner 开特权模式(除非你明确需要 Docker-in-Docker,后面会详细说)。
/etc/gitlab-runner/config.toml 详细配置
注册之后,在 /etc/gitlab-runner/config.toml 里还需要做一些调优:
concurrent = 8 # 最多同时跑 8 个 job,根据机器配置调整
[[runners]]
name = "production-runner-01"
url = "https://gitlab.company.com"
token = "YOUR_TOKEN"
executor = "docker"
[runners.custom_build_dir]
enabled = true
[runners.cache]
Type = "s3"
Path = "runner/cache"
Shared = true
[runners.cache.s3]
ServerAddress = "s3.company.com"
AccessKey = "MINIO_ACCESS_KEY"
SecretKey = "MINIO_SECRET_KEY"
BucketName = "gitlab-runner-cache"
Insecure = false
[runners.docker]
tls_verify = false
image = "alpine:latest"
privileged = false
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
volumes = ["/cache:/cache", "/var/run/docker.sock:/var/run/docker.sock"]
shm_size = 0
memory = "4g" # 限制每个 job 最多使用 4GB 内存
memory_swap = "4g" # 禁止使用 swap
cpus = "2" # 限制每个 job 最多 2 个 CPU 核concurrent = 8 表示这台机器上最多同时跑 8 个 job。这个值要根据机器配置和 job 的资源消耗来定,不是越大越好。
踩坑一:Runner 内存溢出的真正原因
我们当时配置了 concurrent = 16,机器是 32GB 内存。理论上没问题,但实际上跑着跑着 Runner 就 OOM 了。
排查之后发现:每个 Java 构建 job 默认的 JVM 堆最大值是 -Xmx256m,但实际上 JVM 还会用堆外内存,加上 Docker 容器本身的开销、Maven 下载的文件缓存,一个 job 实际用到了将近 2GB。16 个并发就是 32GB,直接撑满了。
两个解决方案:
- 在
config.toml里对每个容器限制内存(上面配置里的memory = "4g") - 降低
concurrent值
我们最后选的是:concurrent = 6,同时给每个容器限制 memory = "4g",稳定运行了下来。
缓存配置:让依赖下载不再是瓶颈
GitLab CI 缓存基础
GitLab CI 的缓存在 .gitlab-ci.yml 里配置:
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .m2/repository/
- node_modules/但这样配有个问题:CI_COMMIT_REF_SLUG 是分支名,不同分支之间缓存不共享。如果你有 20 个特性分支,每个分支第一次运行都要重新下载依赖。
更好的方式是用依赖文件的哈希作为缓存键:
variables:
MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
# 全局缓存配置
cache:
key:
files:
- pom.xml
- pom.xml.lock # 如果有的话
paths:
- .m2/repository/
policy: pull-push # 默认值,读取+更新缓存key.files 会根据指定文件的内容计算哈希值,pom.xml 变了才会创建新缓存,pom.xml 没变就复用现有缓存。
缓存策略优化
对于大型 Maven 项目,每次构建把整个 .m2/repository 压缩上传是很慢的。可以用 policy: pull 让只读 job 不上传缓存:
stages:
- test
- build
- deploy
# 测试阶段:只读缓存
test:
stage: test
cache:
key:
files: [pom.xml]
paths: [.m2/repository/]
policy: pull # 只拉取,不上传
script:
- mvn test
# 构建阶段:读写缓存(负责更新缓存)
build:
stage: build
cache:
key:
files: [pom.xml]
paths: [.m2/repository/]
policy: pull-push # 读取并更新
script:
- mvn package -DskipTests
artifacts:
paths:
- target/*.jar
expire_in: 1 hour踩坑二:缓存存 S3 但命中率极低
我们配置了 S3(MinIO)作为缓存后端,但发现缓存命中率只有 30% 左右,大部分情况下还是在重新下载依赖。
排查原因:
- 缓存键太细:我们用了
${CI_COMMIT_SHA}作为缓存键,每次 commit 都是新缓存,永远不会命中 - 缓存大小超限:单个缓存归档超过了 1GB,上传到 S3 耗时 3 分钟,下载也要 2 分钟,比重新下载依赖还慢
- 分支策略问题:每个 feature branch 的缓存是独立的,没有从 main branch 继承
解决方案:
cache:
# 用多级 key,先精确匹配,再降级
- key:
files: [pom.xml]
prefix: maven
paths: [.m2/repository/]
policy: pull-push
# 降级 key:pom.xml 改变时,从无前缀的主干缓存恢复
- key: maven-base
paths: [.m2/repository/]
policy: pull # 只读,不更新这个缓存同时把 .m2/repository/ 里不必要的文件(*.lastUpdated、_remote.repositories)排除:
before_script:
- find .m2/repository -name "*.lastUpdated" -delete
- find .m2/repository -name "_remote.repositories" -delete这两类文件会触发 Maven 联网检查,删掉之后缓存命中更干净,也减小了缓存体积。
关于缓存策略有一个常见误区:很多人以为缓存命中率越高越好,于是用越来越宽泛的缓存键(比如只用分支名,不包含依赖文件哈希),这样确实命中率高,但可能用到过期的缓存,导致依赖版本不一致的 bug,这类 bug 极难排查。正确的做法是缓存键精确到依赖文件的哈希,宁可偶尔缓存失效重新下载,也不要用可能不一致的缓存。只有在完全确定缓存内容不会影响构建正确性的情况下,才可以放宽缓存键的粒度。
Docker-in-Docker 完整方案
为什么需要 DinD
在 CI 里构建 Docker 镜像,需要能运行 docker build 命令。这需要访问 Docker daemon。有两种方案:
- DinD(Docker-in-Docker):在容器里运行一个完整的 Docker daemon
- 挂载宿主机 Docker socket:把宿主机的
/var/run/docker.sock挂载进容器
挂载 socket 更简单,但有安全问题——容器内可以控制宿主机上的所有 Docker 容器,包括 Runner 自身。DinD 更安全,但配置更复杂。
生产环境里,如果安全要求高,用 DinD;如果是内部 CI、追求简单,挂载 socket 也可以接受。
用 socket 方式(简单但需注意安全)
build-image:
image: docker:24
services: [] # 不需要 dind service
before_script:
- docker info # 验证 docker 可用
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
variables:
DOCKER_HOST: unix:///var/run/docker.sock # 使用挂载的 socket这需要在 Runner 注册时配置了 --docker-volumes "/var/run/docker.sock:/var/run/docker.sock"。
用 DinD 方式(生产推荐)
build-image:
image: docker:24
services:
- name: docker:24-dind
alias: docker
command: ["--tls=false"] # 禁用 TLS,避免证书问题(仅内网环境)
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: "" # 禁用 TLS 目录,配合 --tls=false
DOCKER_DRIVER: overlay2
before_script:
- docker info
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build
--cache-from $CI_REGISTRY_IMAGE:latest
--tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
--tag $CI_REGISTRY_IMAGE:latest
.
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
- docker push $CI_REGISTRY_IMAGE:latest踩坑三:DinD TLS 证书导致无法连接
这是我们迁移 GitLab CI 时踩的最深的一个坑。
现象:docker info 报错 Cannot connect to the Docker daemon at tcp://docker:2375. Is the docker daemon running?
原因:Docker 24+ 版本的 DinD 镜像默认启用了 TLS,但 Runner 容器没有配置对应的证书,连接被拒绝。
有两种解决方案:
方案一:禁用 TLS(简单,适合内网)
services:
- name: docker:24-dind
command: ["--tls=false"]
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""方案二:使用 TLS(生产级别安全)
services:
- name: docker:24-dind
alias: docker
variables:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_CERT_PATH: "/certs/client"
DOCKER_TLS_VERIFY: 1方案二需要 DinD 和 Job 容器共享 /certs 卷,GitLab Runner 会自动处理这个挂载。
我们内网环境最终用的是方案一,简单可靠。如果你的 GitLab 是暴露在公网的,建议用方案二。
一个完整的 .gitlab-ci.yml
把上面所有内容整合成一个生产级别的配置:
# .gitlab-ci.yml
image: alpine:latest
variables:
MAVEN_OPTS: >-
-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository
-Djava.awt.headless=true
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: ""
# 缓存配置
cache:
key:
files:
- pom.xml
paths:
- .m2/repository/
policy: pull-push
stages:
- validate
- test
- build
- security
- deploy
# 代码编译验证
validate:
stage: validate
image: maven:3.9-eclipse-temurin-17
cache:
policy: pull # 只读
script:
- mvn validate compile -B
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
# 单元测试
unit-test:
stage: test
image: maven:3.9-eclipse-temurin-17
cache:
policy: pull
script:
- mvn test -B
artifacts:
when: always
reports:
junit: target/surefire-reports/TEST-*.xml
paths:
- target/site/jacoco/
expire_in: 1 week
coverage: '/Total.*?([0-9]{1,3})%/'
# 集成测试
integration-test:
stage: test
image: maven:3.9-eclipse-temurin-17
services:
- name: postgres:15
alias: postgres
variables:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
variables:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/testdb
SPRING_DATASOURCE_USERNAME: test
SPRING_DATASOURCE_PASSWORD: test
cache:
policy: pull
script:
- mvn verify -Pintegration -B
artifacts:
when: always
reports:
junit: target/failsafe-reports/TEST-*.xml
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH =~ /^release\//
# 构建 Docker 镜像
build-image:
stage: build
image: docker:24
services:
- name: docker:24-dind
command: ["--tls=false"]
variables:
DOCKER_HOST: tcp://docker:2375
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- |
docker build \
--cache-from $CI_REGISTRY_IMAGE:latest \
--build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
--build-arg VCS_REF=$CI_COMMIT_SHORT_SHA \
--tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA \
--tag $CI_REGISTRY_IMAGE:latest \
.
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
- docker push $CI_REGISTRY_IMAGE:latest
rules:
- if: $CI_COMMIT_BRANCH == "main"
# 安全扫描
trivy-scan:
stage: security
image:
name: aquasec/trivy:latest
entrypoint: [""]
variables:
TRIVY_NO_PROGRESS: "true"
TRIVY_CACHE_DIR: ".trivycache/"
cache:
paths: [.trivycache/]
policy: pull-push
script:
- trivy image
--exit-code 0
--severity LOW,MEDIUM
--format sarif
--output trivy-results.sarif
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
- trivy image
--exit-code 1
--severity HIGH,CRITICAL
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
artifacts:
reports:
sast: trivy-results.sarif
rules:
- if: $CI_COMMIT_BRANCH == "main"
# 部署到生产
deploy-production:
stage: deploy
image: bitnami/kubectl:latest
environment:
name: production
url: https://myapp.company.com
script:
- kubectl set image deployment/myapp myapp=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -n production
- kubectl rollout status deployment/myapp -n production --timeout=5m
when: manual
rules:
- if: $CI_COMMIT_BRANCH == "main"深度解析:GitLab CI 与 GitHub Actions 的核心差异
很多团队在 GitLab 和 GitHub 之间切换,会发现两者的 CI 系统设计理念有明显差异,理解这些差异能帮助你更好地用好 GitLab CI。
配置文件位置和语法
两者都用 YAML,但 GitLab 的 .gitlab-ci.yml 有一些独特的概念:stages(必须先定义所有阶段),needs(声明依赖关系,支持 DAG),rules(比 GitHub Actions 的 if 更灵活的条件控制)。
最重要的差异是缓存设计:GitHub Actions 的缓存是显式的,每次都要声明要缓存什么;GitLab CI 的缓存可以配置 policy: pull 或 policy: push,更精细地控制缓存的读写行为。
Runner 的部署模式
GitHub Actions 的 Runner 分为 GitHub 托管(直接用,按分钟计费)和自托管两种。GitLab CI 的 Runner 完全由用户管理,GitLab SaaS 版有共享 Runner,但私有化部署的 GitLab 必须自己搭 Runner。
自托管 Runner 的优势是:可以运行在公司内网,访问内部资源(私有 Maven 仓库、内部 API);可以在高配机器上运行,构建速度更快;可以安装公司需要的特殊工具。
劣势是:需要自己管理 Runner 的高可用、监控、升级。这个运维负担不应该被低估——Runner 挂了导致所有构建卡住,或者 Runner 磁盘满了导致构建失败,这些问题都需要有人响应。建议给 Runner 本身配置监控(监控 job 队列长度、Runner 存活状态),把 Runner 的维护纳入 SRE 的日常工作。
另一个值得关注的细节是 Runner 的"污染"问题。如果多个 job 共享同一个 Runner 实例(特别是 shell executor),一个 job 遗留的文件、环境变量、安装的工具可能影响后续 job。对于安全敏感的构建(比如生产部署),要么用独立的 Runner,要么确保每个 job 开始前清理工作目录。Docker executor 通过每次创建新容器规避了这个问题,是更干净的选择。
Artifacts 和 Job 间数据传递
GitLab CI 的 artifacts 设计很实用——一个 job 产生的文件可以被后续 job 自动获取,不需要额外配置。比如构建产生的 jar 包,自动在部署 job 里可用。
GitHub Actions 里对应的是 actions/upload-artifact 和 actions/download-artifact,需要显式上传和下载。
深度解析:GitLab CI 的规则系统(Rules)
GitLab CI 的 rules 是一个很强大但初学者容易搞混的特性。理解它的评估逻辑很重要。
rules 是一个列表,GitLab CI 从上到下依次评估每条规则,第一条匹配的规则决定 job 的行为(when: on_success、when: manual、when: never)。如果没有任何规则匹配,job 默认不运行。
# 理解 rules 的评估顺序
deploy-production:
rules:
# 规则1:如果是手动触发的流水线 AND 是 main 分支 → 自动运行
- if: '$CI_PIPELINE_SOURCE == "web" && $CI_COMMIT_BRANCH == "main"'
when: on_success
# 规则2:如果是 main 分支的 push → 等待人工触发
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual
# 规则3:其他情况 → 不运行
- when: never这个设计比 only/except 更灵活,可以根据流水线触发方式、分支名、tag、环境变量等任意组合条件来决定 job 是否运行。
掌握 rules 的关键是记住:评估是从上到下的,第一个匹配就停止。所以更具体的条件要放前面,兜底的条件放后面。
深度解析:GitLab CI 的 Auto DevOps 与自定义流水线的边界
GitLab 有一个功能叫 Auto DevOps,可以在没有 .gitlab-ci.yml 的情况下自动为项目生成 CI/CD 流水线,包括自动构建、测试、安全扫描、部署。听起来很美好,但实际使用中经常让团队困惑。
Auto DevOps 适合的场景:标准化的应用(Django、Rails、Node.js),没有特殊构建需求,快速原型项目,团队里没有 CI/CD 专业人员。
Auto DevOps 的问题:它是基于约定的(约定大于配置),如果你的项目结构和 Auto DevOps 的预期不一样,定制起来反而比从头写一个 .gitlab-ci.yml 更麻烦。大多数真实的生产项目都有自己的特殊需求,Auto DevOps 往往只能覆盖 60-70%,剩下的需要通过各种环境变量来覆盖默认行为,最终变得比直接写 YAML 还复杂。
我的建议:初次接触 GitLab CI,可以用 Auto DevOps 快速了解有哪些阶段、有哪些扫描;但正式项目还是应该自己写 .gitlab-ci.yml,对流水线有完整的控制权。理解 Auto DevOps 生成的流水线逻辑,是一个很好的学习方式——你可以把它当作一个参考实现,然后根据自己的需要裁剪和定制。不要把"从零写"和"参考已有实现"对立起来,善用参考资源是工程师的基本素养。
深度解析:GitLab Runner 的弹性扩缩容
在 K8s 环境里,GitLab Runner 可以配置为按需创建 Pod 执行 job,job 完成后 Pod 自动销毁,实现真正的弹性扩缩容。
这比固定数量的 Runner 实例要高效得多。高峰时,K8s 自动创建更多 Pod 来处理并发 job;低谷时,不需要的 Pod 会被回收,不占用资源。
使用 GitLab Runner 的 Kubernetes executor 时,每个 CI job 会在一个独立的 Pod 里运行,这带来了天然的隔离性——不同 job 之间完全隔离,一个 job 的环境污染不会影响另一个。这也是为什么很多团队从"共享的 shell executor Runner"迁移到"K8s executor Runner"——隔离性好,弹性好,只是配置稍微复杂一些。
Runner 的弹性扩缩容让 CI 基础设施可以跟随实际负载变化,白天高并发、夜晚低并发的场景下,可以显著降低基础设施成本,同时保证高峰时段的构建速度。
在 K8s 上部署 GitLab Runner 的另一个好处是天然的资源隔离。每个 job 在独立的 Pod 里运行,有独立的 CPU 和内存配额,一个消耗资源的构建 job 不会影响其他 job。这在传统的 shell executor 模式下是做不到的,多个 job 同时在同一台机器上运行,资源争抢是常态。
实践建议:生产环境里为 CI 构建和 CD 部署配置不同的 Runner 组,分别有不同的资源配额和权限。构建 Runner 没有生产环境的 K8s 访问权限;部署 Runner 有访问权限,但只有在通过所有检查之后才会被调用。这种分离既提高了安全性,也让资源管理更清晰。
总结
GitLab CI 的 Runner 配置、缓存策略、DinD 方案,每一块都有细节需要注意。
Runner 配置上,要根据实际资源消耗调整 concurrent 值,加上内存限制,防止 OOM。缓存策略上,用依赖文件哈希作为缓存键,在只读 job 里用 policy: pull 避免无谓的缓存上传。DinD 方案上,内网可以禁用 TLS,外网建议开启。
这套配置在我们生产环境里支撑了日均 200+ 次构建,稳定运行了一年多没出过大问题。
最后想说一句关于 CI/CD 工具选型的感悟:GitLab CI 的整合性是它最大的优势——代码托管、CI/CD、容器镜像仓库、制品库、安全扫描、运维看板,全在一个平台里,不需要把多个工具的账号体系、权限体系、API 打通。对于追求简化工具链的团队,这种"一站式"的体验很有吸引力。
但工具的整合也意味着"绑定"——一旦在 GitLab 上沉淀了大量 CI/CD 配置、流水线脚本、集成设置,迁移到其他平台的成本就很高了。在选型时要想清楚这个权衡:是追求短期的便利,还是保留长期的灵活性?没有绝对的答案,理解了权衡,才能做出适合自己团队的选择。
