跳到主要内容

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 年),很多依赖新技术的功能跑不起来,不是出路。

来源:gitlab-org/gitlab-foss#41227

原因: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:

  1. touch /mnt/test_filejob 容器里创建文件。/mnt 不在 /builds 下,属于 job 容器私有的根目录,不与任何容器共享。
  2. 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 解决,可按顺序排查:

  1. [runners.docker] 里必须有 privileged = true(dind 本身就要求特权模式,否则第二层 daemon 起不来或行为异常)。
  2. 仍不行的话,在 [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.tomlprivileged = true,必要时 volumes"/builds:/builds"