单机 Forgejo Actions 跑 docker build:job 必须进独立 DinD,网络还得是 host
这台机器上 Forgejo Actions 能跑 docker build,靠的是三个值对齐:job 容器进独立的 docker:dind、container.network 写成 host、DOCKER_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 里和这条链路有关的是 forgejo(forgejo:15,Up 2 days)、forgejo_db(postgres:17-alpine,Up 2 days)、forgejo-runner(runner:12,Up 32 hours)、dind(docker: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 info 的 Name 应该是 dind,不是宿主机名。
Forgejo 这边只要三件事
compose 按官方 Docker 安装来:应用和库拆开、UID 1000、数据盘本地目录。下面是这台机器 forgejo/compose.yml 的完整内容,密码换成自己的。
| |
三件事,每一件都有具体原因。
HTTP 不直接暴露。127.0.0.1:3000:3000,公网只走反代。app.ini 里 ROOT_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,这台跟这个结构,没发明新拓扑。但真实文件里有几个点值得逐字拆开,包括两个官方模板自带的坑。
| |
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_on 是 service_healthy 不是 service_started:dockerd 还没听 2375 就去 docker pull job 镜像,失败信息很难看。docker:dind 的健康检查是 docker info,重试 12 次、每次 5 秒,足够等首次启动。
user: "30033:${DOCKER_GID:-999}",994 是这台宿主机的 docker 组 GID,写在 .env 的 DOCKER_GID 里。30033 是 runner 镜像里的非 root 用户(docker exec forgejo-runner id 输出 uid=30033 gid=994),宿主机上不存在这个 UID,不用去找它。组 ID 和镜像用户 ID 不是一回事,换机器先 getent group docker 确认宿主机 GID。
数据盘属主是 30033。容器内 /data、.runner、config.yml 都是 30033(或 30033:994)。compose 命令里那行 chown -R 1000:1000 /data 是官方模板的残留,实际不起作用——容器以非 root 的 30033 运行,chown 到别的 uid 会因权限不足失败,2>/dev/null || true 把 EPERM 吞掉,属主保持 30033 不变。模板还带了一个更值得警惕的东西:宿主机 docker.sock 的挂载还在 volumes 里。进程环境变量和 config.yml 的 docker_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。.env 里 RUNNER_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 里需要的东西就这些(值自己填):
| |
真正容易写错的是 config.yml,不是 compose
compose 只保证 runner 进程能连上 DinD。job 里面那两个方向,要靠 data/config.yml。这台文件里改过的值就这几个,其余全是 generate-config 生成的默认值。
| |
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 管 forgejo、dind、forgejo-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 里 forgejo 和 dind 都能解析。这就是这台机器上 config.yml 里那行注释的意思,不是"生产环境都应该用 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_bus 和 forgejo_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 info 的 Name 应该是 dind,不是宿主机名。是宿主机名的话,sock 又接回去了。这台机器上现在能工作的组合就是 DOCKER_HOST=tcp://dind:2375、container.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_HOST、container.docker_host、container.network: host、FORGEJO_INSTANCE_URL 走内网容器名。改动之前先把 cache 路径想清楚,它是这套网络里第二个隐藏依赖。卡住的时候用 docker exec dind docker ps 和宿主机 docker ps 对比,能快速判断 job 到底跑在哪套 daemon 上。