GitLab CI 中 docker:dind 卷挂载为空的原因与解法
现象
在 GitLab CI 中使用 docker:latest 镜像配合 docker:dind 服务时,docker run -v 挂载的目录是空的。例如下面的 .gitlab-ci.yml,按理最后一步应该列出 test_file(说明 /mnt 挂载成功),但实际什么都没有:
image: docker:latest
services:
- docker:dind
stages:
- test
docker:
stage: test
script:
- touch /mnt/test_file
- docker run -v /mnt:/mnt ubuntu ls /mnt
GitLab.com 的共享 Runner 和自建 Runner 上都能复现。老的 gitlab/dind 镜像不受影响,但它基于早已过时的 jpetazzo/dind(Ubuntu Trusty,2014 年),很多依赖新技术的功能跑不起来,不是出路。
原因:Runner 起了多个容器,根文件系统互不相通
这不是 bug。Runner 拿到这份 .gitlab-ci.yml 后,实际会创建多个相互独立的容器:
- job 容器:由
image关键字决定镜像(这里是docker:latest),script里的命令(比如touch /mnt/test_file)在这里执行。 - 服务容器:
services里每一项各创建一个,并链接到 job 容器。这里就是docker:dind容器,它内部跑着第二层 Docker Engine(Docker-in-Docker 因此得名)。 - helper 容器:Runner 自动创建,git clone/fetch、artifacts、cache 操作在这里完成。
而宿主机(Runner 连接的那台机器)上跑的是第一层 Docker Engine,负责创建上面这些容器;你的 script 接触不到这一层。
容器默认彼此隔离:每个容器的 / 根文件系统都是自己私有的,只有卷(volume)能在容器与宿主机、容器与容器之间共享。Runner 会创建好几块卷(cache 卷、构建卷、config.toml 里额外定义的卷),其中与本问题相关的是构建卷——它被同时挂进 helper 容器、job 容器和所有服务容器,挂载点都是容器内的 /builds:
还有一个容易忽略的背景:job 容器里的 docker 只是客户端,Runner 通过 DOCKER_HOST 环境变量把它指向 dind 服务(服务主机名是 docker)。所以 script 里的每条 docker 命令,都是发给 dind 容器里的 daemon 去执行的。
带着这两点逐行看出错的 script:
touch /mnt/test_file在 job 容器里创建文件。/mnt不在/builds下,属于 job 容器私有的根目录,不与任何容器共享。docker run -v /mnt:/mnt ubuntu ls /mnt是让 dind 里的 daemon 启动一个 ubuntu 容器,并把「本地的/mnt」挂进去。对这个 daemon 来说,「本地」指的是 dind 容器的文件系统——那里的/mnt是空的。
执行完的实际结构是这样,两个 /mnt 各在各的容器里:
一句话概括:-v 的源路径由 dind 里的 daemon 解释,指向 dind 容器的文件系统;而文件写在 job 容器里。两边不共享,所以挂出来是空的。
解法:把文件放进共享的构建卷
思路:文件放到构建卷里,job 容器和 dind 容器就都能看到它了。
但有一个精确细节要先说清:真正作为 Docker 卷跨容器共享的不是整个 /builds,而是工作目录的上一级。代码检出在 CI_PROJECT_DIR,即 /builds/<namespace>/<project>;共享卷挂在它的父目录 /builds/<namespace> 这一层。所以用 $(dirname ${CI_PROJECT_DIR}) 来定位:
image: docker:latest
services:
- docker:dind
stages:
- test
docker:
stage: test
script:
- 'export SHARED_PATH="$(dirname ${CI_PROJECT_DIR})/shared"'
- mkdir -p ${SHARED_PATH}
- touch ${SHARED_PATH}/test_file
- docker run -v ${SHARED_PATH}:/mnt ubuntu ls /mnt
这样 SHARED_PATH 落在共享卷内:job 容器写进去的文件,dind daemon 也能从同一块卷里读到,再挂给 ubuntu 容器。
判断标准就一条——路径是否落在 /builds/<namespace> 这块共享卷之内:
- ✅
$(dirname ${CI_PROJECT_DIR})/shared,即/builds/<namespace>/shared; - ✅
${CI_PROJECT_DIR}/shared(工作目录本身就在共享卷里,issue 里实测可行); - ❌
/builds/shared/$CI_PROJECT_PATH—— 看着也在/builds下,但/builds/shared不在共享的那一层里,挂出来还是空的。issue 里最初给的就是这个写法,随后被纠正。
自建 Runner 照做仍是空的?
issue 后续讨论里,有自建 Runner 的用户按上面的做法仍然挂不上,最后靠改 config.toml 解决,可按顺序排查:
[runners.docker]里必须有privileged = true(dind 本身就要求特权模式,否则第二层 daemon 起不来或行为异常)。- 仍不行的话,在
[runners.docker]的volumes里显式加一条"/builds:/builds",把构建目录直接绑定到宿主机,让 job 容器和 dind 容器看到同一份/builds。
要点
- dind 场景下
docker run -v的源路径由 dind 容器里的 daemon 解释,指向 dind 容器的文件系统,不是执行 script 的 job 容器。 - 容器之间默认只有卷能共享文件;Runner 的构建卷挂在工作目录的父级(
/builds/<namespace>),并同时挂给 helper、job、服务三类容器。 - 要把文件从 script 传给 dind 起的容器,就把它放到这块共享卷里,例如
$(dirname ${CI_PROJECT_DIR})/shared。 - 自建 Runner 挂不上时,检查
config.toml:privileged = true,必要时volumes加"/builds:/builds"。