单机 Forgejo Actions 跑 docker build:job 必须进独立 DinD,网络还得是 host

单机 Forgejo Actions 跑 docker build 的真实配置:job 进隔离 DinD、网络用 host、DOCKER_HOST 指 dind。三层架构、compose 与 config.yml 逐项拆解,以及为什么另外三条路更差。

单机 Forgejo Actions 跑 docker build:job 必须进独立 DinD,网络还得是 host

这台机器上 Forgejo Actions 能跑 docker build,靠的是三个值对齐:job 容器进独立的 docker:dindcontainer.network 写成 hostDOCKER_HOST 指向 tcp://dind:2375。少写其中任何一个,CI 不是变慢,是 checkout 或 docker 直接断。下面从这台机器上正在跑的真实配置拆起,说清楚每一层为什么这样写。

Forgejo Actions 实际在干什么

Forgejo 自己不执行 CI。浏览器里点到的是 Forgejo,.forgejo/workflows/*.yml 推上去之后,它只负责任务落库、等 runner 来领。领到任务的进程叫 forgejo-runner,它再用 Docker API 起一个 job 容器,把步骤丢进去跑。所以这条链路有三层:Forgejo(Git 服务加 Actions 队列)、runner(常驻进程,轮询任务、调 Docker)、执行环境(真正跑 run: 的容器)。第三层才是这篇文章要讲清楚的部分。

执行环境可以是宿主机 Docker,也可以是另一套 daemon。把宿主机 /var/run/docker.sock 挂进 job,workflow 里的 docker build 立刻能用,代价也立刻到位:这条 YAML 等价于这台机器的 root。能起容器,就能挂 /,就能把旁边的 Postgres、反代、Forgejo 数据盘一起摸一遍。单机自托管最容易在这里省事,这台机器没有省。

这台机器上的四只容器和两张网

当前 docker ps 里和这条链路有关的是 forgejoforgejo:15,Up 2 days)、forgejo_dbpostgres:17-alpine,Up 2 days)、forgejo-runnerrunner:12,Up 32 hours)、dinddocker:dind,Up 32 hours,healthy)。两张外部桥接网络:network_bus 给反代侧,Forgejo 和 runner 在这张网上;forgejo_runner 给 CI 内部互访,四只容器都在,Postgres 只在这张网上,不进 network_bus。Forgejo 和 runner 同时挂在这两张网上,是故意的:控制面走 forgejo_runner,对外流量走 network_bus

Runner 向 DinD 要容器,job 跑在 DinD 里,不跑在宿主机上。workflow 里的 docker 客户端连的也是 DinD,不是宿主机。验证方法在最后一段:docker infoName 应该是 dind,不是宿主机名。

Forgejo 这边只要三件事

compose 按官方 Docker 安装来:应用和库拆开、UID 1000、数据盘本地目录。下面是这台机器 forgejo/compose.yml 的完整内容,密码换成自己的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# forgejo/compose.yml,密码不要抄,自己换
networks:
  network_bus:
    external: true
  forgejo_runner:
    external: true

services:
  server:
    image: codeberg.org/forgejo/forgejo:15
    container_name: forgejo
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - FORGEJO__database__DB_TYPE=postgres
      - FORGEJO__database__HOST=db:5432
      - FORGEJO__database__NAME=forgejo_db
      - FORGEJO__database__USER=forgejo_user
      - FORGEJO__database__PASSWD=change-me
    restart: always
    networks:
      - network_bus
      - forgejo_runner
    volumes:
      - ./forgejo:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "127.0.0.1:3000:3000"
      - "22:22"
    depends_on:
      - db

  db:
    image: postgres:17-alpine
    container_name: forgejo_db
    restart: always
    environment:
      - POSTGRES_USER=forgejo_user
      - POSTGRES_PASSWORD=change-me
      - POSTGRES_DB=forgejo_db
    networks:
      - forgejo_runner
    volumes:
      - ./postgres:/var/lib/postgresql/data

三件事,每一件都有具体原因。

HTTP 不直接暴露。127.0.0.1:3000:3000,公网只走反代。app.iniROOT_URL 必须是外网看到的那个 https://forgejo.lunatic.ren/,clone 链接、webhook、OIDC 回调都吃这个值。SSH 单独处理,这台把 22:22 直接映射出去了,因为 Git over SSH 不想再套一层反代。

Runner 必须能用容器名找到 Forgejo,所以两台都进 forgejo_runner。runner 的 .env 里写 FORGEJO_INSTANCE_URL=http://forgejo:3000,不是那个 HTTPS 域名。这是故意的:runner 和 Forgejo 同机、同 Docker 网络,走容器 DNS 比绕公网证书、反代、Hairpin NAT 省事。用户浏览器走 HTTPS,CI 内部控制面走内网 HTTP,两套 URL 各管各的,别合成一个再在 TLS 上熬。

数据目录属主是 1000,容器里对应 git 用户。恢复备份时忘了 chown -R 1000:1000,Forgejo 起来直接报权限。这台用 forgejo dump 打一致性快照,本地留两份,每月一份进 COS 深度归档。站点级 Actions 在 Forgejo 1.21 之后默认开启,app.ini 里不需要显式写 [actions];要关才需要 ENABLED = false。仓库级还要在仓库设置里打开 Actions,否则 runner 在线也领不到这个仓的任务。

Runner 不跑 job,它只创建 job

官方安装文档给的 Compose 就是 runner 容器加 docker:dind,这台跟这个结构,没发明新拓扑。但真实文件里有几个点值得逐字拆开,包括两个官方模板自带的坑。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# forgejo_runner/compose.yaml
services:
  dind:
    image: docker:dind
    container_name: dind
    hostname: dind  # job 容器用 host 网络时,DOCKER_HOST=tcp://dind:2375 靠这个解析
    privileged: true
    restart: always
    environment:
      - DOCKER_TLS_CERTDIR=  # 关掉 TLS,走明文 2375(仅内网 docker 网络可达)
    healthcheck:
      test: ["CMD", "docker", "info"]
      interval: 5s
      timeout: 5s
      retries: 12
    volumes:
      - ./dind-data:/var/lib/docker  # 持久化,dind 重建不丢镜像缓存
    networks:
      - forgejo_runner

  forgejo_runner:
    image: code.forgejo.org/forgejo/runner:12
    container_name: ${CONTAINER_NAME:-forgejo-runner}
    restart: always
    depends_on:
      dind:
        condition: service_healthy
    user: "30033:${DOCKER_GID:-999}"
    environment:
      - DOCKER_HOST=tcp://dind:2375
      - FORGEJO_TOKEN=<redacted>
      - FORGEJO_INSTANCE_URL=${FORGEJO_INSTANCE_URL}
      - RUNNER_REGISTRATION_TOKEN=${RUNNER_REGISTRATION_TOKEN}
      - RUNNER_NAME=${RUNNER_NAME:-default-runner}
      - RUNNER_LABELS=${RUNNER_LABELS:-docker:docker://node:20-bookworm}
    command: >
      /bin/sh -c '
        cd /data &&
        chown -R 1000:1000 /data 2>/dev/null || true &&
        if [ ! -s .runner ]; then
          echo ">>> Registering runner..." &&
          forgejo-runner register --no-interactive \
            --instance ${FORGEJO_INSTANCE_URL} \
            --token ${RUNNER_REGISTRATION_TOKEN} \
            --name ${RUNNER_NAME} \
            --labels ${RUNNER_LABELS} &&
          forgejo-runner generate-config > config.yml;
        fi &&
        echo ">>> Starting daemon..." &&
        forgejo-runner --config config.yml daemon
      '
    volumes:
      - ./data:/data
      - /var/run/docker.sock:/var/run/docker.sock
    networks:
      - network_bus
      - forgejo_runner

networks:
  network_bus:
    external: true
  forgejo_runner:
    external: true

DOCKER_TLS_CERTDIR 留空,DinD 在 2375 明文听 Docker API。这只在 forgejo_runner 这张内部网上通,不要把 2375 映射到宿主机端口,更不要映射到 0.0.0.0。谁能连上 2375,谁就有这套 daemon 的 root。

privileged: true 只给 DinD。它要在容器里再跑一套 dockerd、mount、cgroup。job 容器本身 privileged: false(在 config.yml 里),workflow 需要 Docker 时走 TCP 调 DinD,不在 job 里再套一层特权 DinD。

健康检查挡住空窗。runner 的 depends_onservice_healthy 不是 service_started:dockerd 还没听 2375 就去 docker pull job 镜像,失败信息很难看。docker:dind 的健康检查是 docker info,重试 12 次、每次 5 秒,足够等首次启动。

user: "30033:${DOCKER_GID:-999}"994 是这台宿主机的 docker 组 GID,写在 .envDOCKER_GID 里。30033 是 runner 镜像里的非 root 用户(docker exec forgejo-runner id 输出 uid=30033 gid=994),宿主机上不存在这个 UID,不用去找它。组 ID 和镜像用户 ID 不是一回事,换机器先 getent group docker 确认宿主机 GID。

数据盘属主是 30033。容器内 /data.runnerconfig.yml 都是 30033(或 30033:994)。compose 命令里那行 chown -R 1000:1000 /data 是官方模板的残留,实际不起作用——容器以非 root 的 30033 运行,chown 到别的 uid 会因权限不足失败,2>/dev/null || true 把 EPERM 吞掉,属主保持 30033 不变。模板还带了一个更值得警惕的东西:宿主机 docker.sock 的挂载还在 volumes 里。进程环境变量和 config.ymldocker_host 都是 tcp://dind:2375,这份挂载用不上,但它还在,容易让人以为 job 跑在宿主机上。这台目前没出问题,是因为 container.docker_host 显式指了 DinD;但这条挂载应该删掉,它既没用又误导。

注册只发生一次。启动脚本看 /data/.runner 在不在,不在就 forgejo-runner register --no-interactive,在就直接 daemon。注册令牌在网站上是一次性的:站点、组织或仓库设置 → Actions → Runners 里生成。注册成功后真正保活的是 .runner 里那份 token,不是 compose 环境变量。把 .runner 删了,runner 会当成新机器再注册一次,网站上出现两个 runner,旧的那个要手动下线。

Label 决定 runs-on.envRUNNER_LABELS=ubuntu-latest:docker://catthehacker/ubuntu:act-latest,注册时写进 .runner——这台机器的 .runner 里 labels 只有这一条,name=forgejo-runner,address=http://forgejo:3000,id=43。workflow 写 runs-on: ubuntu-latest 才会被领走。注意 config.yml 里的 labels: [] 是空的——daemon 阶段用的是 .runner 里的值,不是 config 里的。镜像名可以按仓改,label 字符串必须对得上。

.env 里需要的东西就这些(值自己填):

1
2
3
4
5
6
FORGEJO_INSTANCE_URL=http://forgejo:3000
RUNNER_REGISTRATION_TOKEN=从网站复制
RUNNER_NAME=forgejo-runner
CONTAINER_NAME=forgejo-runner
RUNNER_LABELS=ubuntu-latest:docker://catthehacker/ubuntu:act-latest
DOCKER_GID=994

真正容易写错的是 config.yml,不是 compose

compose 只保证 runner 进程能连上 DinD。job 里面那两个方向,要靠 data/config.yml。这台文件里改过的值就这几个,其余全是 generate-config 生成的默认值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
runner:
  file: .runner
  capacity: 1
  timeout: 3h
  insecure: false
  envs:
    DOCKER_HOST: tcp://dind:2375

cache:
  enabled: true
  host: "forgejo-runner"

container:
  network: "host"
  privileged: false
  docker_host: "tcp://dind:2375"
  force_pull: false

capacity: 1 是单机磁盘和内存决定的,不是 runner 的上限。根盘 79G,DinD 的镜像层在 ./dind-data(当前 4.2G),和宿主机镜像层是两份,并发行高了两套 overlay 一起涨。timeout: 3h 和 Forgejo 实例侧默认 3 小时一致,job 超时按短的那个算。

insecure: false 没矛盾。runner 连的是 http://forgejo:3000,根本不走 TLS;这个开关管的是 HTTPS 证书校验,以后改成走公网域名再决定要不要动它。

剩下三个值是一套的:runner.envs.DOCKER_HOST 给 job 注入环境变量,container.docker_host 告诉 runner 用哪个 daemon 创建 job 容器,container.network 决定 job 进哪张网。三个必须指向同一个 DinD,改其中一个另外两个不跟着改,就是 checkout 或 docker 断掉。

为什么 job 的网络必须是 DinD 的 host

先分清两个 Docker。宿主机 dockerd 管 forgejodindforgejo-runner;DinD 里那套 dockerd(server 29.7.2,daemon 名 dind)管 job。两套 /var/lib/docker,两套网络命名空间,DinD 默认看不见外面那张叫 forgejo_runner 的 compose 网。

job 至少要打通两条路。拿代码:checkout 要连 Forgejo,控制面是 http://forgejo:3000,这个名字只在外面那张 compose 网上。跑 docker:客户端要连 daemon,地址 tcp://dind:2375,这个名字也只在外面那张网上。

container.network 留空时,runner 会在 DinD 内部新建一张隔离网桥,把 job 丢进去。那张网桥上的 DNS 不认识 forgejo,也不认识 dind,于是看到的是 checkout 超时,或者 Cannot connect to the Docker daemon。改成 host 之后,job 加入的是 DinD 容器自己的网络命名空间。DinD 在 forgejo_runner 网上,hostname 是 dind,于是 job 里 forgejodind 都能解析。这就是这台机器上 config.yml 里那行注释的意思,不是"生产环境都应该用 host"。

1
2
3
4
# 实际写在 config.yml 里的注释
# DinD: job 容器用 dind 的 host 网络,直接共享 dind 容器的网络命名空间,
# 因此能解析 DOCKER_HOST=tcp://dind:2375 并访问 forgejo。
network: "host"

官方安全文档把 container.network: host 标成危险项,原因很具体:job 和 DinD 同网卡,能打到这张网上所有东西,包括 forgejo_db:5432。这是用隔离 daemon 换来的,不是零成本。接受它,是因为另外三条路更差。

宿主机 docker.sock 直接给 job:container.docker_host: automount 就行,network 也可以用 compose 网名,DNS 最省事。但 workflow 等于宿主机 root,单机 Forgejo、Postgres、反代、OIDC 全在这台 79G 盘上,这个口子不留。

job 走公网 ROOT_URL clone、Docker 走网桥网关 IP:checkout 可以改成 https://forgejo.lunatic.ren/,不依赖内部 DNS。但 docker 还是要有人告诉客户端 DinD 的地址,网桥模式下 dind 这个名字不存在,只能写死网关 IP 或塞 extra_hosts。IP 会变,cache 回调 ACTIONS_CACHE_URL 也要 runner 从 job 里够得着。能做,但等于在给自己养一套私有 DNS。

让 DinD 共享宿主机网络栈:等于把特权 dockerd 直接铺到宿主机命名空间,比现在这套更难讲清楚边界,没走。

所以现在这套是三选一之后的结果:daemon 隔离要,内部 DNS 也要,于是 job 借用 DinD 的 netns。cache.host: "forgejo-runner" 是同一类问题:cache 代理听在 runner 容器里,job 必须能用这个主机名连回来。job 已经跟 DinD 同网,DinD 和 runner 同在 forgejo_runner,所以这个名字能解析。以后如果把 network 改回自动建网,cache 会是第二个炸掉的东西,不一定先报 Docker。

装好之后仍会踩的坑

compose 里挂着没用上的宿主机 docker.sock。进程环境变量和 config.yml 都是 tcp://dind:2375,这份挂载不参与任何路径,但它还在,容易让人误判 job 跑在哪。只信 container.docker_host,别信"我挂过 sock",顺手把这条卷删掉。

切到 DinD 不会回收旧 job。宿主机上还留着 FORGEJO-ACTIONS-TASK-297_JOB-build 容器(node:24-bookworm,Up 2 days),甚至还在 forgejo_runner 网上占着一个 IP(172.19.0.5),那是用宿主机 Docker 跑 job 时留下的。DinD 不认识它们,不会删。docker ps 看到 FORGEJO-ACTIONS-TASK- 先分清楚它是在宿主机上还是在 docker exec dind docker ps 里。

generate-config 会覆盖你改过的 config.yml。启动脚本只在没有 .runner 时重新生成配置,已经注册过就不要删 .runner 来"重置一下"。要改 label,去网站下线旧 runner、换令牌、再注册。

workflow 里的 docker 连哪,看环境变量。runner 注入 DOCKER_HOST=tcp://dind:2375,job 镜像得带 Docker CLI。catthehacker/ubuntu:act-latest 这类 CI 镜像一般有,node:20-bookworm 不一定有。没有 CLI 就装,或者换镜像,别回头去挂 sock。

2375 没有 TLS。信任边界就是 Docker 网络,谁被加进 forgejo_runner 谁就能调 DinD。别把监控、临时 debug 容器随手连进来。

并发和磁盘。capacity: 1,DinD 数据在 ./dind-data,重启不丢镜像缓存,也会自己膨胀。根盘 79G,CI 镜像和 Forgejo 仓库抢同一块盘,把 DinD 数据盘当无限缓存会先把 Forgejo 写挂。

从零对照这台机器的顺序

先建两张外部网 network_busforgejo_runner,前者接你现有的反代,没有反代就先让 Forgejo 只听 127.0.0.1:3000。然后起 Forgejo 加 Postgres,浏览器能登录,ROOT_URL 写成真正访问的那个 HTTPS。网站里打开仓库 Actions,出一个 runner 注册令牌。准备 forgejo_runner/data,属主改成 runner 容器里的 UID(这台是 30033)。填 .env,起 DinD 加 runner,docker logs dind 能看到 API listen on [::]:2375,runner 日志能看到连上 http://forgejo:3000。网站 Runners 列表出现在线之后,先跑一个只 echo 的 workflow 确认 checkout 走得通,再跑 docker info

docker infoName 应该是 dind,不是宿主机名。是宿主机名的话,sock 又接回去了。这台机器上现在能工作的组合就是 DOCKER_HOST=tcp://dind:2375container.docker_host 同一地址、container.network: host、runner 用 http://forgejo:3000 领任务。这四个值是一套,从零搭的时候按这个顺序对,比出问题再猜快得多。

官方文档对应三份:runner 的 Docker Compose / DinD 安装、job 里怎么用 Docker、container.network 的安全含义,链接在 Forgejo 官方 Actions 文档里(installation/docker、docker-access、security 三页)。

收尾

这套配置把 daemon 隔离和内部 DNS 同时拿到手,代价是 job 与 DinD 共享网络命名空间,网内所有服务对它可见。迁移到别的机器时,四个值一起搬:DOCKER_HOSTcontainer.docker_hostcontainer.network: hostFORGEJO_INSTANCE_URL 走内网容器名。改动之前先把 cache 路径想清楚,它是这套网络里第二个隐藏依赖。卡住的时候用 docker exec dind docker ps 和宿主机 docker ps 对比,能快速判断 job 到底跑在哪套 daemon 上。

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计