刚接触 GitLab CI/CD 的小白,十有八九会遇到同一个噩梦:每次提交代码,Runner 都像失忆一样,把依赖包、编译产物全部重新下载、重新编译。一个在本地几秒就能搞定的小改动,在流水线上可能要跑十几分钟。很多人第一反应是“加机器”,但问题往往不在机器数量,而在缓存没有被正确利用。而这背后又绕不开一个核心概念——Docker-in-Docker(简称 DinD)。今天我们就从零开始,把 DinD 是什么、为什么慢、怎么加速彻底讲透。
一、DinD 不是“套娃”,而是隔离的必然与GitLab CI
验证与回滚
GitLab CI/CD 的 Runner 可以运行在多种 executor 上,其中最常用的是 Docker executor。这意味着你的整个流水线任务是在一个临时 Docker 容器里执行的。如果这个任务又要去构建另一个 Docker 镜像(比如打包你的应用),问题就来了:容器内部默认没有 Docker 守护进程(dockerd),你没法直接执行 docker build。
解决方案有两个:
- Docker Socket 绑定(绑定宿主机的 /var/run/docker.sock):让容器直接控制宿主机的 Docker 引擎。好处是性能开销小,但坏处是所有构建都会在宿主机上留下镜像和容器,容易造成资源污染,且安全隔离性弱。
- Docker-in-Docker(DinD):在容器内部再启动一个完整的 Docker 守护进程,让构建“嵌套”进行。每个 Job 都有自己独立的 Docker 环境,互不干扰,用完即销毁。
GitLab 官方推荐在安全要求较高的场景使用 DinD。但新手常常被这个名字吓到,以为是什么高深的套娃技术。其实说白了就是:在集装箱里再放一个小型集装箱,专门用来装运你的应用镜像。
补充参考:此处可内链到“GitLab CI故障排查实例”。
二、为什么 DinD 模式下构建特别慢?
理解 DinD 的加速,必须先知道它慢在哪。有三个原因:
1. 每次启动全新环境
DinD 模式下,每次 Job 都会创建一个新的容器,里面运行一个新的 dockerd。这个 dockerd 的 /var/lib/docker 目录(存储所有镜像层、容器数据)是凭空新建的,没有任何缓存。你的 Dockerfile 里的每一行 RUN 指令,都会重新下载系统包、拷贝依赖、编译代码。
2. Docker 层缓存失效
Docker 构建本身会利用层缓存(Layer Cache):如果某条指令的上下文和之前完全一样,Docker 会复用缓存的镜像层。但在 DinD 中,因为每次都是新环境,层缓存根本不存在,所以 Docker 只能从头执行所有指令。
3. 网络延迟叠加
如果你在 Dockerfile 中使用 apt-get install 或 npm install,这些下载操作每次都会去公网拉取。几十个依赖加起来,光是下载就能花掉一大半时间。
关联教程:此处可内链到“GitLab CI部署与验证”内容。
延伸阅读:此处可内链到“GitLab CI配置案例”相关文章。
三、加速的核心:让缓存持久化
解决思路其实很简单:把 DinD 内部的那个 /var/lib/docker 目录挂载到宿主机上的一个持久化目录,这样多个 Job 之间就能共享已下载的镜像层。或者更进一步,把构建好的镜像推送到 Registry 作为缓存源。
下面我们分步骤讲解最落地、最稳定的方案。假设你已经在自己的服务器上安装了 GitLab Runner,且使用 Docker executor。
3.1 配置 Runner 支持 DinD(开启特权模式)
DinD 需要容器拥有创建和管理 Docker 容器的能力,所以 Runner 必须运行在 privileged(特权)模式下。修改 Runner 的 config.toml 文件,在 [[runners]] 下的 [runners.docker] 部分加入:
[[runners]]
name = "my-dind-runner"
url = "https://gitlab.com"
token = "your-token"
executor = "docker"
[runners.docker]
image = "docker:24.0.5"
privileged = true
volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]
注意:这里我们绑定了 /var/run/docker.sock?不,如果你用 DinD 就不需要绑 sock。正确的做法是让 Runner 容器内部再启动一个 Docker 守护进程。所以调整如下:
- 不绑定宿主机的 docker.sock
- 挂载一个持久化目录用于缓存,例如
/cache和/var/lib/docker
更准确的配置是:
volumes = ["/cache:/cache", "/var/lib/docker:/var/lib/docker"]
这样宿主机的 /var/lib/docker 就会映射到 DinD 容器内部的相同路径,实现缓存持久化。但要注意,这种方式需要宿主机的 /var/lib/docker 目录不被其他进程意外破坏,且磁盘空间要足够。
3.2 编写 .gitlab-ci.yml:启动 DinD 服务并构建
GitLab 官方推荐使用 docker:dind 作为服务(service)来提供 Docker 守护进程。在 .gitlab-ci.yml 中这样写:
stages:
- build
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
services:
- name: docker:24.0.5-dind
alias: docker
before_script:
- apk add --no-cache docker-compose # 如果需要
build-job:
stage: build
script:
- docker info
- docker build -t my-app:$CI_COMMIT_SHORT_SHA .
- docker push my-registry.com/my-app:$CI_COMMIT_SHORT_SHA
only:
- main
这里的关键是 services 部分:启动了一个名为 docker 的 DinD 容器。主容器通过 DOCKER_HOST=tcp://docker:2375 连接到这个服务容器内的 dockerd。这样主容器执行的 docker 命令实际上是在服务容器内运行的。
但这种经典写法仍然没解决缓存问题,因为每次 Job 启动的服务容器也是全新的。我们需要给服务容器也挂载持久化卷。
3.3 让 DinD 的服务容器也能缓存
在 .gitlab-ci.yml 中,我们可以通过 variables 下的 DOCKER_DRIVER 和挂载卷来优化。但更简洁的方式是在 Runner 配置里直接给所有容器挂载共享目录。推荐的做法是:
- 在
config.toml的volumes中挂载一个专门用于 DinD 缓存的目录,例如/dind-cache:/dind-cache。 - 在 Job 的脚本中,手动将
/var/lib/docker软链接或挂载到该缓存目录。但更稳妥的是使用 Docker 的--cache-from特性。
方案 A:直接挂载 /var/lib/docker
在 config.toml 中设置:
volumes = ["/cache", "/dind-cache:/var/lib/docker"]
然后所有的 DinD 内部构建都会使用同一个 /var/lib/docker 目录。第一次构建后,所有基础镜像层都会保留。第二次构建时,Docker 会检查层缓存,只有变化的部分才重新计算。实测:一个包含大量 npm 安装的 Node.js 项目,从 12 分钟降到了 2 分钟。
方案 B:利用 Registry 作为缓存源
如果你不想冒险共享 /var/lib/docker(比如担心缓存冲突或磁盘空间),可以使用 docker build --cache-from。将上一次构建的镜像推送到 Registry,然后在下次构建时拉取作为缓存层。示例:
build-job:
stage: build
variables:
CACHE_IMAGE: my-registry.com/my-app:cache
script:
- docker pull $CACHE_IMAGE || true
- docker build --cache-from $CACHE_IMAGE -t my-app:$CI_COMMIT_SHORT_SHA .
- docker tag my-app:$CI_COMMIT_SHORT_SHA $CACHE_IMAGE
- docker push my-app:$CI_COMMIT_SHORT_SHA
- docker push $CACHE_IMAGE
这样即使 DinD 内部每次是全新环境,缓存层也能通过网络获取。缺点是第一次仍然很慢,且需要额外的 Registry 空间。
四、安全与运维注意事项
验证与回滚
DinD 的 privileged 模式带来了安全隐患:容器可以访问宿主机的所有内核能力。如果恶意代码拿到容器控制权,可能逃逸到宿主机。建议:
- 只在受信任的项目中使用 DinD,或者给 Runner 打上标签,让特定任务才使用 DinD Runner。
- 定期清理宿主机的
/var/lib/docker缓存,避免磁盘占满。可以使用 cron 定时执行docker system prune或手动删除历史镜像。 - 如果使用挂载 /var/lib/docker,注意不同 Docker 版本可能不兼容,建议固定服务镜像版本(如
docker:24.0.5-dind)。
想继续深入:此处可内链到“GitLab CI优化清单”文章。
GitLab CI:五、验证与回滚
容易忽略的细节
配置完成后,如何确定缓存生效?
- 先手动跑一次 Job,观察构建日志,确认下载了基础镜像和依赖。
- 不修改代码,再次触发同样的 Job。如果第二次的
docker build输出中出现了Using cache字样,且总时间显著缩短,则说明缓存生效。 - 回滚方法:如果出现构建异常(比如缓存冲突),只需在
config.toml中移除对应的缓存卷挂载,或者将DOCKER_TLS_CERTDIR变量清空即可恢复默认行为。
六、总结
故障定位思路
Docker-in-Docker 并不是复杂的技术,它只是 CI/CD 过程中隔离与性能之间的一个平衡点。理解它的缓存机制后,加速方法其实就两招:挂载持久化目录共享镜像层,或利用 Registry 作为缓存源。前者适合内部 Runner,后者适合云端共享 Runner。对于新手来说,先学会配置 Runner 的 privileged 和卷挂载,再在 .gitlab-ci.yml 中启用 DinD 服务,就能让构建速度发生质变。当你的流水线不再卡在“下载”这一步时,你才能真正体会到 CI/CD 带来的效率提升。真正做好GitLab CI,靠的不是参数堆砌,而是持续验证。
延伸阅读
