DinD/DooD 与临时 CI Runner

在流水线里构建镜像有 DinD、DooD 与无守护进程构建器三条路。本篇讲清 docker:dind 的 privileged 与 TLS 配置、storage driver 在嵌套场景下的坑、挂载 docker.sock 的风险边界,给出 GitLab Runner docker executor 与临时 runner 的落地配置、层缓存复用策略与安全加固清单。

在流水线里执行 docker build 看似理所当然,直到你发现 runner 本身跑在容器里:要么在里面再起一个 Docker,要么把宿主的 socket 挂进去,要么干脆绕开 Docker。这三条路各自有明确的代价,选错会带来安全问题或性能灾难。本篇把三条路的机制、配置与适用边界讲清楚,并给出临时 runner 的落地方式。

1. 三条路线的机制对比

① DinD(Docker in Docker):
   runner 容器 → 容器内再起 dockerd → 构建容器
   需要 --privileged,隔离性强但要求高权限

② DooD(Docker outside of Docker):
   runner 容器 → 挂载宿主 /var/run/docker.sock → 宿主 dockerd → 构建容器
   无需特权,但把宿主 Docker 完全暴露给流水线

③ 无守护进程构建器:
   runner 容器 → Kaniko / BuildKit(rootless) / buildah → 直接产出镜像
   无需 dockerd,安全性最好,兼容性有取舍
维度DinDDooD无 daemon
权限要求–privileged挂载 socket无需特权
隔离性高(独立 daemon)无(共享宿主 daemon)高
缓存复用需显式配置天然复用宿主层需显式配置
构建速度中快(层已存在)中
兼容性最好最好部分 Dockerfile 特性不支持
安全风险高权限容器等价宿主 root低
典型工具docker:dinddocker:clikaniko / buildkitd

一句话总结取舍:DinD 用权限换隔离,DooD 用隔离换方便,无 daemon 用兼容性换安全。

2. DinD:容器里再跑一个 Docker

DinD 的本质是在容器内启动一个完整的 dockerd,需要容器拥有创建 cgroup、挂载文件系统、操作网络设备的权限,因此必须 --privileged。

docker run --privileged -d --name dind \
  -e DOCKER_TLS_CERTDIR=/certs \
  -v dind-certs:/certs/client \
  -v dind-data:/var/lib/docker \
  -p 2376:2376 \
  docker:dind

2.1 TLS 与端口

docker:dind 镜像的入口脚本会读取 DOCKER_TLS_CERTDIR:

取值行为端口
未设置或为空不启用 TLS2375(明文)
/certs生成自签证书并启用 TLS2376
自定义路径同上,证书写到该路径2376

生产环境必须启用 TLS,且不要把 2375 暴露到网络。客户端连接方式:

# 在同一个 Pod/网络里
export DOCKER_HOST=tcp://dind:2376
export DOCKER_TLS_VERIFY=1
export DOCKER_CERT_PATH=/certs/client
docker version

2.2 storage driver 的嵌套陷阱

DinD 最大的性能问题来自 storage driver:容器内的 /var/lib/docker 落在宿主容器的 overlay 文件系统上,而 overlay2 无法在 overlay 之上再叠加,于是 dockerd 会回退到 vfs。

# 在 dind 容器内检查
docker info --format '{{.Driver}}'
# vfs  ← 说明回退发生,性能会显著下降

vfs 不做层共享,每个层都是完整拷贝,镜像构建时间可能翻数倍、磁盘占用成倍增长。解决方案有两个:

# 方案 A:给 /var/lib/docker 挂一个 volume,使其落在非 overlay 的文件系统上
docker run --privileged -v dind-data:/var/lib/docker docker:dind
docker info --format '{{.Driver}}'   # overlay2

# 方案 B:显式指定 fuse-overlayfs(无需特权,但速度略慢于 overlay2)
docker run --privileged \
  -e DOCKER_DRIVER=fuse-overlayfs \
  -v dind-data:/var/lib/docker \
  docker:dind

GitLab Runner 的 config.toml 中对应配置:

[[runners]]
  name = "docker-builder"
  executor = "docker"
  [runners.docker]
    image = "docker:27-cli"
    privileged = true
    volumes = ["/certs/client", "/cache"]
  [[runners.docker.services]]
    name = "docker:27-dind"
    alias = "docker"
    command = ["--tls=false"]       # 仅在受控内网中使用

注意 --tls=false 只应出现在完全受控的内部网络中;一旦 runner 与 dind 跨主机通信,必须打开 TLS。

2.3 特权容器的真实风险

--privileged 意味着容器可以访问所有设备、加载内核模块、改 cgroup,等价于宿主 root。因此 DinD 的边界不在容器本身,而在谁能往这个 runner 提交代码:

  • 公开仓库的 PR 不应获得 privileged runner,否则一条 docker run -v /:/host 就能拿到宿主。
  • 内部仓库也应给 privileged runner 打标签,只允许受信分支触发。
  • 用一次性 VM 而非共享宿主机承载 DinD,把逃逸半径限制在单次构建。

3. DooD:挂载 socket 的代价

DooD 把宿主的 /var/run/docker.sock 挂进 runner 容器,容器内的 docker 命令实际操作宿主 daemon。

# GitLab CI 中最常见的写法
build:
  image: docker:27-cli
  services:
    - docker:27-dind
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
  script:
    - docker build -t app:$CI_COMMIT_SHA .
# GitHub Actions 中的 DooD(runner 本身就是宿主)
- uses: docker/setup-buildx-action@v3
- run: docker build -t app:${{ github.sha }} .

DooD 的便利在于镜像层直接复用宿主缓存,构建速度最快。风险同样明确:

风险说明
等价宿主 root挂载 socket 后可 docker run -v /:/host --privileged 提权
镜像污染流水线构建的镜像直接进宿主镜像库,与其他 job 混在一起
并发冲突多个 job 共享 daemon,tag 冲突与 prune 互相干扰
清理困难一个 job 的 docker system prune -a 会删掉其他 job 的缓存

因此 DooD 的适用条件是:runner 是一次性、专用的机器或 VM,且只跑受信代码。多租户共享 runner 上使用 DooD 是明确的反模式。关于流水线整体的安全设计,可参考 CI 流水线中的容器构建 与 供应链安全实践 。

3.1 用 socket 代理收敛权限

如果确实需要共享 daemon,可以用 socket 代理做一层策略过滤,只放行 build 等白名单 API:

docker run -d --name docker-proxy \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v /etc/docker-proxy:/etc/docker-proxy \
  tecnativa/docker-socket-proxy
environment:
  CONTAINERS: 1
  IMAGES: 1
  BUILD: 1
  POST: 1
  EXEC: 0      # 关键:禁止 exec,避免直接进入他人容器
  VOLUMES: 0   # 禁止创建卷,减少挂载宿主路径的能力

代理能拦住明显的提权路径,但不能替代隔离——它仍是一个需要谨慎维护的组件,且 POST=1 本身就允许创建容器。

4. 无守护进程构建器

第三条路是完全绕开 dockerd。可选方案:

工具机制Dockerfile 兼容度特点
Kaniko逐条执行 Dockerfile 指令,直接写 registry高(不支持 RUN –mount)无需特权,适合 K8s Job
BuildKit (rootless)独立 buildkitd,rootless 模式最高支持全部 BuildKit 特性与缓存导出
buildah无 daemon 构建 OCI 镜像高与 Podman 同源,支持 bud
img基于 BuildKit 的 rootless 构建高单二进制,适合容器内运行

以 rootless BuildKit 为例:

docker run --rm --privileged=false \
  --security-opt seccomp=unconfined \
  -v "$PWD":/src \
  moby/buildkit:rootless \
  buildkitd --oci-worker-no-process-sandbox &

buildctl --addr unix:///run/user/1000/buildkit/buildkitd.sock build \
  --frontend dockerfile.v0 \
  --local context=/src \
  --local dockerfile=/src \
  --output type=image,name=reg/app:ci,push=true

rootless BuildKit 需要 --oci-worker-no-process-sandbox 与 seccomp=unconfined 才能在某些受限环境里运行,这本身就是一种妥协——它比 DinD 安全,但并非零权限。

Kaniko 的用法更简单,适合直接跑在 K8s Job 里:

apiVersion: batch/v1
kind: Job
metadata:
  name: kaniko-build
spec:
  template:
    spec:
      containers:
        - name: kaniko
          image: gcr.io/kaniko-project/executor:v1.23.2
          args:
            - --context=git://github.com/org/repo#refs/heads/main
            - --destination=reg.example.com/app:ci
            - --cache=true
            - --cache-repo=reg.example.com/app/cache
          volumeMounts:
            - name: docker-config
              mountPath: /kaniko/.docker
      restartPolicy: Never
      volumes:
        - name: docker-config
          secret:
            secretName: reg-credentials

Kaniko 逐条解释 Dockerfile 指令并在内存中构造最终文件系统,因此不支持需要真正执行挂载的 RUN --mount=type=cache,也不会执行 HEALTHCHECK——这类指令在日志里会显示为跳过。选它之前先确认 Dockerfile 里没有依赖这些特性。

5. 临时 Runner:用完即弃

长期存活的 runner 有两个问题:缓存污染与凭据残留。临时(ephemeral)runner 每次只执行一个 job 然后销毁,是目前的主流做法。

5.1 GitLab 的 ephemeral runner

# config.toml
[[runners]]
  name = "ephemeral"
  executor = "docker-autoscaler"
  [runners.docker]
    image = "docker:27-cli"
    privileged = true
  [runners.autoscaler]
    plugin = "fleeting-plugin-aws"
    capacity_per_instance = 1      # 每个实例只跑一个 job
    max_use_count = 1              # 用完即销毁

关键参数是 capacity_per_instance = 1 与 max_use_count = 1:前者保证一个实例同时只服务一个 job(避免层缓存被并发污染),后者保证实例只被复用一次(避免凭据与镜像残留)。

5.2 GitHub Actions 的自托管临时 runner

- name: Register ephemeral runner
  run: |
    ./config.sh --url https://github.com/${{ github.repository }} \
      --token "$RUNNER_TOKEN" \
      --ephemeral \
      --name "runner-$RANDOM" \
      --labels self-hosted,linux,x64,docker
    ./run.sh

--ephemeral 让 runner 只接受一个 job,完成后自动注销。配套的镜像预拉与磁盘清理建议放在实例启动脚本里,因为 runner 容器本身不负责这些。自托管 runner 的运维细节可参考 GitHub Actions 自托管 Runner 。

5.3 预拉镜像与缓存预热

临时 runner 的最大代价是冷启动:每次都要重新拉基础镜像。缓解手段有三:

# 1. 实例启动时预拉常用基础镜像
for img in node:22-alpine python:3.12-slim golang:1.23 alpine:3.20; do
  docker pull "$img" &
done
wait

# 2. 用 registry 缓存后端替代本地层缓存
docker buildx build \
  --cache-from type=registry,ref=reg/app:cache \
  --cache-to   type=registry,ref=reg/app:cache,mode=max \
  -t reg/app:ci --push .

# 3. 用 CI 提供的缓存能力(如 GitHub Actions 的 gha 后端)

registry 缓存后端是临时 runner 场景下的最优解:缓存不依赖本地磁盘,任何新实例都能直接命中。相比 DinD 的本地卷缓存,它牺牲一点拉取带宽换来了实例无关性。

6. 选型决策与加固清单

给出一张可直接对照的决策表:

约束推荐方案
公开仓库、不可信 PR无 daemon(Kaniko/BuildKit rootless)
内部仓库、一次性 VMDinD(privileged + TLS + volume)
单租户专用机器、追求速度DooD(socket 代理收敛权限)
K8s 上跑构建 JobKaniko 或 rootless BuildKit
需要全部 BuildKit 特性与缓存导出rootless BuildKit
需要复用宿主层缓存且可信DooD

加固清单:

# 1. 禁止明文端口
grep -r "2375" .gitlab-ci.yml .github/ || echo "no plaintext port"

# 2. 确认 TLS 目录
env | grep DOCKER_TLS_CERTDIR

# 3. 限制 runner 标签,privileged 只给受信分支
# .gitlab-ci.yml
# build:  tags: [privileged]  rules: [if: $CI_COMMIT_BRANCH == "main"]

# 4. 构建产物立即推送并签名,不留在本地
docker push reg/app:$CI_COMMIT_SHA
cosign sign --yes reg/app:$CI_COMMIT_SHA

关于 rootless 模式下 capabilities、user namespace 与 seccomp 的具体配置,可参考 Rootless 模式与容器安全 。

7. 小结

CI 里构建镜像的选型可以归纳成一条判断链:

  1. 代码是否可信?不可信就排除 DinD 与 DooD,直接上无 daemon 构建器。
  2. runner 是否独占?共享 runner 上不要用 DooD,避免 prune 与 tag 互相踩。
  3. 是否需要全部 BuildKit 能力?需要就选 rootless BuildKit,不需要可用 Kaniko。
  4. 冷启动是否可接受?不可接受就预拉镜像并用 registry 缓存后端。

临时 runner 与 registry 缓存是当下最稳的组合:前者解决凭据与污染问题,后者解决实例无关的缓存复用。把这两件事做对,CI 构建的稳定性就不再依赖「那台跑了三年的 runner 机器」。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「docker」更多文章

  1. 容器网络排障实战
  2. 本地开发运行时:OrbStack 与 Colima
  3. 日志驱动与采集管道