在流水线里执行 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,安全性最好,兼容性有取舍
| 维度 | DinD | DooD | 无 daemon |
|---|---|---|---|
| 权限要求 | –privileged | 挂载 socket | 无需特权 |
| 隔离性 | 高(独立 daemon) | 无(共享宿主 daemon) | 高 |
| 缓存复用 | 需显式配置 | 天然复用宿主层 | 需显式配置 |
| 构建速度 | 中 | 快(层已存在) | 中 |
| 兼容性 | 最好 | 最好 | 部分 Dockerfile 特性不支持 |
| 安全风险 | 高权限容器 | 等价宿主 root | 低 |
| 典型工具 | docker:dind | docker:cli | kaniko / 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:
| 取值 | 行为 | 端口 |
|---|---|---|
| 未设置或为空 | 不启用 TLS | 2375(明文) |
/certs | 生成自签证书并启用 TLS | 2376 |
| 自定义路径 | 同上,证书写到该路径 | 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) |
| 内部仓库、一次性 VM | DinD(privileged + TLS + volume) |
| 单租户专用机器、追求速度 | DooD(socket 代理收敛权限) |
| K8s 上跑构建 Job | Kaniko 或 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 里构建镜像的选型可以归纳成一条判断链:
- 代码是否可信?不可信就排除 DinD 与 DooD,直接上无 daemon 构建器。
- runner 是否独占?共享 runner 上不要用 DooD,避免 prune 与 tag 互相踩。
- 是否需要全部 BuildKit 能力?需要就选 rootless BuildKit,不需要可用 Kaniko。
- 冷启动是否可接受?不可接受就预拉镜像并用 registry 缓存后端。
临时 runner 与 registry 缓存是当下最稳的组合:前者解决凭据与污染问题,后者解决实例无关的缓存复用。把这两件事做对,CI 构建的稳定性就不再依赖「那台跑了三年的 runner 机器」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。