一排每次都重跑的步骤,和一张大多已缓存的图 左边是两段配置:GitHub Actions 的 runs-on: ubuntu-latest 和 steps,以及一条 dagger check 命令。右边上半是一台虚拟机里排成一行的四个步骤,全部实心,表示每次都从头跑;下半是一张由八个节点组成的有向图,七个是空心的,表示已缓存,只有一个节点是强调色,表示只重算改动的那一处。 on: push runs-on: ubuntu-latest steps: - run: go test ./... $ dagger check dagger.toml go.mod · .golangci.yml

Dagger 1.0 beta 的发布博文1第一句话是:

Dagger is third-generation CI, after Jenkins and GitHub Actions.

往下读,“原生 GitHub 集成”一节还有一句更直接的:在仓库里执行 dagger cloud checks on,“This replaces GitHub Actions for your checks”,不再需要 CI 工作流文件。

可我们以前见到 Dagger,大多是在 GitHub Actions 的某个 step 里:run: dagger call ...。它到底是和 GitHub Actions 同一类的东西,还是跑在 GitHub Actions 里面的一个工具?“第三代”比第二代多了什么?博文说现在的 CI 到处是浪费,这些浪费在一个普通项目上看得到吗?

本文用同一个 Go 小项目,在 GitHub 的同一种机器(ubuntu-latest)上,把原生 GitHub Actions 和 Dagger 各跑一遍,顺着跑的过程中遇到的问题一层层往下看。

一、两份配置各自描述了什么

实验项目

先交代实验对象。shortlink 是一个短链接服务,三个包:

  • slug:把数字 ID 编成短字符串,三个纯函数测试;
  • store:把链接存进 Postgres,一个集成测试 TestPutGet,要一个真实的数据库,地址从环境变量 DATABASE_URL 读;
  • cmd/server:HTTP 入口,没有测试。

CI 要做的事情也很普通:golangci-lint 检查一遍,再把测试跑一遍,测试要有 Postgres。

两边写出来的样子

先看最直观的差别:同样的事,两边各写了什么。

同一个项目的两份 CI 配置 左边是 GitHub Actions 的 workflow:指定 runner 为 ubuntu-latest,声明 postgres:16-alpine 服务并映射 5432 端口,然后按顺序列出 checkout、setup-go、golangci-lint 和 go test 四个步骤。右边是 Dagger:dagger.toml 只列出 go、golangci-lint、pgbase 三个模块,并把 go 模块的 base 设置为 dag://pgbase/base;pgbase 是自己写的一个小模块,返回绑定了 postgres:16-alpine 服务的 Go 容器。 GitHub Actions Dagger 1.0 .github/workflows/ci.yml runs-on: ubuntu-latest services:   postgres:     image: postgres:16-alpine     ports: ["5432:5432"] steps:   - uses: actions/checkout@v4   - uses: actions/setup-go@v5   - uses: golangci/golangci-lint-action@v8   - run: go test -v ./... 写的是:在哪台机器上,按什么顺序, 执行哪几条命令。 测哪些包、哪些测试,由 go test ./... 自己展开; CI 只看得到这一个 step 的退出码。 dagger.toml 节选 [modules.go] source = "dagger.io/go" [modules.golangci-lint] source = "dagger.io/go/golangci-lint" [modules.pgbase] source = ".dagger/modules/pgbase" [modules.go.settings] base = "dag://pgbase/base" .dagger/modules/pgbase/main.go pg := dag.Container().   From("postgres:16-alpine").   WithExposedPort(5432).AsService() return dag.Container().   From("golang:1.24-alpine").   WithServiceBinding("db", pg)
图 1 · 左边列出步骤,右边只列出模块;Postgres 在左边是 job 级的服务,在右边是测试容器的一个依赖

GitHub Actions 的单位是 job 和 step。 一个 workflow 由若干 job 组成,每个 job 被分到一台 runner 机器上,按顺序执行 step;step 要么是一条 shell 命令(run:),要么是别人写好的一个 action(uses:,实际上是一段 Node.js 程序)。go test ./... 里有几个包、几个测试,workflow 不知道,它只看得到这个 step 的退出码。

Dagger 的单位是模块和检查。 dagger.toml 里没有一行命令,只声明装了哪几个模块。官方的 Go 模块2会自己找 go.mod,把每个包、每个测试函数都变成可以单独选中的“检查”;golangci-lint 模块看到 .golangci.yml 才会出现。安装完以后列一下3:

$ dagger check -l --all
MODULE GOLANGCI-LINT-PROJECT GO-PACKAGE GO-TEST CHECK
golangci-lint . lint
go slug TestDecodeRejectsAmbiguous test
go slug TestEncodeZero test
go slug TestRoundTrip test
go store TestPutGet test
…

(还有几行是检查 go generate 产物有没有过期的 stale,这里省略。)

Postgres 的写法差别也在图上。GitHub Actions 里它是 job 级别的“服务容器”,和 step 并列;Dagger 里没有现成的数据库模块,我们写了一个十来行的 Go 模块 pgbase,返回一个绑定了 Postgres 服务的 Go 容器,再用 dag://pgbase/base 这个地址4把它塞给 Go 模块当测试容器。

这里有一个相同点值得先记住:两边的配置都放在仓库里,都会最终变成在 Linux 上跑的容器和进程。 差别在于,GitHub Actions 的配置描述“怎么跑”,Dagger 的配置描述“有什么”,怎么跑交给模块和引擎去算。

那么是谁在“算”?这些东西最后跑在哪台机器上?

二、执行和缓存所在的位置

GitHub Actions:一台用完即弃的虚拟机

在 GitHub Actions 里,push 事件到达后,GitHub 给每个 job 分一台全新的虚拟机。我们这次用的 ubuntu-latest 是 2 核、72 GB 系统盘,预装了几十种语言的工具链。step 直接在这台机器上跑,服务容器由 runner 用 Docker 起在旁边。job 一结束,机器就回收了,上面的一切都不留下。

想在两次运行之间留点东西,只能显式地打包上传:actions/cache(setup-go 内置调用它)把目录打成 tar 包存到 GitHub 的缓存服务里,下次按 key 下载解开5。

Dagger:一个引擎,放在哪都行

Dagger 的执行者是引擎(Dagger Engine),一个开源的守护进程,通常以容器形式运行。dagger 命令行只是客户端:把工作区里需要的文件传给引擎,告诉它要哪个检查的结果。引擎把检查展开成一张由容器操作组成的图(拉镜像、挂目录、执行命令……),每个节点按输入算出摘要,结果存在引擎自己的磁盘上(/var/lib/dagger)。1.0 之前这张图的求解交给 BuildKit,从 v0.21 起换成了自己的引擎6;1.0 里官方模块本身也改用 Dagger 自己的脚本语言 Dang 来写7。

引擎可以放在笔记本上、自己的服务器上、Kubernetes 里,也可以放在 GitHub Actions 的 runner 里;1.0 还提供 Dagger 托管的 Cloud Engines。

三种跑法里,触发、机器、执行、缓存各归谁 三列从上到下都是触发、机器、执行、缓存四层。第一列原生 GitHub Actions:GitHub 收到 push,分一台 ubuntu-latest 虚拟机,step 以 bash 和 Node.js action 的形式执行,缓存是 actions/cache 按 go.sum 哈希存的 tar 包。第二列 Dagger 跑在 Actions 里:前两层仍是 GitHub,执行换成 runner 上的 dagger-engine 容器,缓存在 runner 磁盘的 /var/lib/dagger,job 结束就消失。第三列 Cloud Checks:Dagger Cloud 直接接收 GitHub 事件,由 Cloud Engine 执行,缓存由所有人共享,结果作为 check status 回写到 GitHub。 原生 GitHub Actions Dagger 跑在 Actions 里 Dagger Cloud Checks 触发 机器 执行 缓存 GitHub 收到 push on: push 一台新虚拟机 ubuntu-latest 2 核 step 依次执行 bash · node action actions/cache tar 包,key 含 go.sum 哈希 GitHub 收到 push on: push 一台新虚拟机 run: dagger check dagger-engine 容器 按图执行容器操作 /var/lib/dagger 在 runner 磁盘上, job 结束就没了 Dagger Cloud 收事件 dagger cloud checks on 没有 workflow 文件, 不经过 runner Cloud Engine --engine=cloud 共享缓存 开发机、agent、CI 共用一份 结果回写成 GitHub check status
图 2 · 把 Dagger 放进 Actions,只换掉了“执行”这一层;Cloud Checks 才把触发、机器和缓存一起接过去

这张图回答了开头的疑问。GitHub Actions 是一整套东西:事件触发、分配机器、按步骤执行、存缓存。 Dagger 引擎只占其中的“执行”和“缓存”两层。最常见的用法(中间一列)是把引擎塞进 GitHub Actions 的一个 step 里,两者是嵌套关系,不是替代关系。博文说的“replaces GitHub Actions”,指的是右边一列:Dagger Cloud 直接接收 GitHub 的事件,在自己的引擎上跑,再把结果写回 GitHub 的 check status。

中间那个虚线框是后面所有问题的伏笔:引擎的缓存存在 runner 的磁盘上,而 runner 用完就扔。 这对缓存意味着什么?

三、缓存:谁知道什么变了

博文列举的第二种浪费是“shell 脚本不知道什么变了,所以机器一遍遍重算同样的东西”。Solomon Hykes 在更早的一篇里说得更直白:CI 的瓶颈是软件问题,不是机器不够快8。我们用四个场景来看两边各重算了什么:

  1. 冷启动:什么缓存都没有;
  2. 原样再跑一遍;
  3. 给 slug/slug.go 末尾加一行注释;
  4. 只改 README.md。

原生 GitHub Actions 每个场景是一次单独的 push(每次一台新机器,缓存靠 actions/cache 传)。Dagger 分两种:在同一台 runner 上连续执行 dagger check(引擎一直开着,相当于开发者的笔记本),以及每次 push 在新 runner 上从头执行。

同一个项目,四个场景,两边的耗时
场景原生 GitHub Actions
(每次新机器,job 全程)
Dagger,同一台机器
(dagger check)
Dagger,每次新机器
(dagger check)
冷启动56 s
lint 31 s,测试 4 s
159 s159–167 s
每次都是冷启动
原样再跑27 s
测试 (cached)
8.9 s
全部 CACHED
slug.go 加一行注释39 s
测试 (cached)
11.2 s
测试操作重跑
只改 README.md未单独测;workflow 不按路径过滤时整个 job 重跑8.7 s
全部 CACHED
—

ubuntu-latest(2 核),2026 年 10 月 25 日,Dagger v1.0.0-beta.21。Dagger 列只计 dagger check 这条命令;新机器上每次还要先加载模块,加上腾磁盘空间,整个 job 6 分半到 7 分钟。

表 1 · 引擎活着的时候,Dagger 几乎不重复干活;每次换新机器,它比原生 GitHub Actions 慢得多

原生 GitHub Actions:缓存只是一个 tar 包

GitHub Actions 自己并不判断“什么变了”。setup-go 做的事情是:用 go.sum 的哈希拼一个 key,命中就把上次存下的 ~/.cache/go-build 和 ~/go/pkg/mod(这次 23 MB)解压回来9。真正判断哪些包要重编、哪些测试要重跑的,是 Go 工具链自己的构建缓存。所以第二轮以后测试那一步只要一两秒,日志里是:

ok example.com/shortlink/slug (cached)
ok example.com/shortlink/store (cached)

这里有两个容易忽略的细节。

第一,缓存条目是不可变的。 key 命中就不会再保存5,日志里写着 Cache hit occurred on the primary key ..., not saving cache.。我们的缓存是第二轮(第一次全部通过)存下的,之后每一轮恢复的都是那一份快照,直到 go.sum 变化或者 7 天没人用。代码越改越多,这份快照能帮上的忙越少。

第二,store (cached) 意味着集成测试根本没有连数据库。 这一轮 job 照样花了 10 秒起 Postgres 并等它健康检查通过,但 go test 发现测试二进制、参数和读过的环境变量(DATABASE_URL)都和上次一样,就直接把上次的输出重放了一遍10。Go 的测试缓存不知道外面那个数据库是新起的。

Dagger:每个操作都有摘要

Dagger 引擎里,每个操作的输入(镜像摘要、挂载目录的内容哈希、命令行、环境变量)合起来算出一个摘要,摘要相同就直接取上次的结果,日志里标 CACHED。原样再跑一遍,两个测试操作都是:

withExec otelgotest ./slug (experimentalPrivilegedNesting: true)
Container.withExec CACHED [0.0s]
withExec otelgotest ./store (experimentalPrivilegedNesting: true)
Container.withExec CACHED [0.0s]

(otelgotest 是 Go 模块用来包一层 go test、给每个测试发 OpenTelemetry span 的执行器。)

给 slug.go 加一行注释,两边各重算了什么 上半是 Dagger:工作区里只有 slug/slug.go 变了。Go 模块把整个 Go 模块的源码(.go 文件、go.mod、go.sum、testdata)作为一个目录交给测试和 lint 操作,这个目录的摘要变了,所以 slug 的测试、store 的测试和 golangci-lint 三个操作都重跑;重跑时容器里的 Go 构建缓存判断测试二进制没变,输出 (cached)。README.md 不在这个目录里,只改它时三个操作全部 CACHED。下半是原生 GitHub Actions:缓存 key 只由 go.sum 决定,恢复的是第二轮存下的 23 MB,由 go test 自己判断,两个包都是 (cached)。 工作区 操作的输入 重算了什么 shortlink/ go.mod go.sum slug/slug.go +1 行注释 slug/slug_test.go store/store.go store/store_test.go .golangci.yml README.md Go 模块的源码目录 *.go go.mod go.sum testdata/ 整个目录一个摘要, 一个文件变了它就变。 README.md 不在里面。 otelgotest ./slug 重跑 · go test 输出 (cached) otelgotest ./store 重跑 · go test 输出 (cached) golangci-lint run 重跑 只改 README:三个操作全部 CACHED 原生 GitHub Actions key: setup-go-Linux-x64-ubuntu24-go-1.24.13-984ba749…(只由 go.sum 决定) 恢复第二轮存下的 23 MB → go test 自己判断:slug (cached) · store (cached)
图 3 · Dagger 按目录摘要判断“有没有变”,变了就重跑整个操作;更细的判断两边都交给 Go 自己的构建缓存

改 README 什么都没重跑,因为 README 不在任何一个操作的输入里。 Go 模块挂进测试容器的只有 Go 源码、go.mod/go.sum 和 testdata/2,README 变了,摘要不变。这是 GitHub Actions 那种“push 一次就整个 job 重来”做不到的。

改 slug.go 时,store 的测试操作也重跑了。 输入粒度是“这个 Go 模块的源码目录”,不是“这个包依赖的文件”,slug.go 变了,所有包的测试容器挂载的目录都变了。重跑之后,容器里挂着的 Go 构建缓存卷又判断了一次,两个包都输出 (cached):只加注释,编译出来的测试二进制没变。也就是说,Dagger 的摘要缓存挡掉了“完全没变”的情况,剩下的细粒度判断仍然交给 Go 自己。

新机器上,引擎的缓存是空的

再看表里最后一列。每次 push 换一台新 runner,引擎也是新的,/var/lib/dagger 是空的,于是每一轮都是冷启动,要 160 秒左右;原生 GitHub Actions 同样的场景只要 27 秒左右。

冷启动慢在哪?日志里最长的是 golangci-lint 那个操作,2 分 10 秒,原生 GitHub Actions 里同一个 lint 冷启动只要 31 秒。再往下看,这个操作的工作目录是 .dagger/modules/pgbase:lint 模块把我们为 Postgres 写的那个 Dagger 模块也当成项目的一部分检查了。dagger module init go 会在仓库里生成一个独立的 Go 模块(自己的 go.mod,Go 1.26,依赖整套 OpenTelemetry),外加 dagger.gen.go 和 internal/dagger/ 下几千行绑定代码,lint 的时间几乎都花在这份生成代码上。

在 dagger.toml 里把它排除掉:

TOML
[modules.golangci-lint.settings]
lint = ["!.dagger"]

(还要给 pgbase 目录放一个自己的 .golangci.yml,它才会成为一个能被排除的“项目”。)第七轮冷启动降到 101 秒,lint 60 秒。剩下比原生慢的部分,主要是 Dagger 在 golang:1.24-alpine 里开着 cgo 从头编译,而 runner 上的 setup-go 用的是预编译好的工具链;另外 Go 模块每次还要 go install 一个 otelgotest。

所以把 Dagger 放进普通的 GitHub Actions job,在“每次一台新机器”这个前提下,它最大的长处(操作级缓存)基本用不上。 要用上,就得让引擎活得比 job 长:自建一台常驻的引擎机器,或者用 Dagger Cloud。这正是博文后半部分推远程执行和 Cloud Engines 的原因。

一个意外:磁盘不够时,引擎自己在清缓存

实验第三轮出了一个怪现象:同一台机器上原样再跑一遍,dagger check 照样花了 192 秒,几乎什么都没缓存住。

翻引擎日志,里面有 1455 条 dagql pruned result。那台 runner 的系统盘 72 GB 只剩 7.5 GB,不到 20%,而引擎默认的 minFreeSpace 就是 20%11:剩余空间低于它,引擎就不停清理缓存。GitHub 的 ubuntu-latest 镜像预装了大量工具链,剩余空间每台机器不太一样(第四轮那台剩 13 GB)。在 job 开头删掉 /usr/share/dotnet、/usr/local/lib/android 等目录,腾到 33 GB 以后,同一个场景从 192 秒降到 9 秒。

在 GitHub 托管的 runner 上跑 Dagger,要么先腾空间,要么调低引擎的 gc.minFreeSpace,否则缓存会在你眼皮底下消失。

缓存决定了“重算多少”,但集成测试还有一个更麻烦的问题:它要的数据库是谁起的,又是谁决定要不要真的连一次?

四、集成测试的依赖

GitHub Actions:job 级的服务容器

原生 GitHub Actions 的 Postgres 写在 services: 里。runner 在执行任何 step 之前先做一步 “Initialize containers”:拉镜像、建网络、起容器,再按 --health-cmd pg_isready 每 2 秒问一次,直到健康。这一步在我们几轮里用了 10 到 17 秒。容器一直活到 job 结束才删掉12。

连接地址取决于 job 跑在哪:job 直接跑在 runner 上,就用 localhost:5432,要靠 ports 把端口映射出来;job 跑在容器里,就用服务名 postgres。这个区别后面在本地跑的时候还会碰到。

Dagger:绑定在测试容器上的服务

Dagger 里,Postgres 是 pgbase 返回的那个容器的一个依赖:WithServiceBinding("db", pg) 让测试容器里能用主机名 db 连到它。服务不是先起好等着,而是在第一个需要它的操作开始时才启动,健康检查通过后才放测试进去;同一个服务定义算出同一个主机名,多个操作共用一个实例13。日志里能看到测试操作在等它:Container.withExec DONE [0.6s, waited on service.start]。

Postgres 什么时候起,测试有没有真的连它 上半是原生 GitHub Actions 第三轮的时间线,共 27 秒:前 10 秒 Initialize containers 起 Postgres,之后 checkout、setup-go、lint 8 秒、go test 2 秒,Postgres 一直开到第 26 秒;但 go test 的输出是 store (cached),集成测试没有连数据库。下半是 Dagger 在同一台机器上的几次运行:冷启动时 slug 和 store 测试操作开始前要等 Postgres 启动 3 到 4 秒;原样重跑时两个测试操作都是 CACHED,Postgres 根本没有启动;改了 slug.go 以后测试操作重跑、Postgres 重新启动,但容器里的 go test 仍然输出 (cached)。 原生 GitHub Actions · 第三轮,job 共 27 s Dagger · 同一台 runner 上连续三次 dagger check Initialize containers 10 s lint 8 s test Postgres 健康以后一直开着,第 26 s 才删掉(虚线) go test 输出 store (cached):测试结果是重放的,没有连数据库 冷启动 5432/tcp 起来 3.1 s → otelgotest ./store 14.8 s 原样重跑 两个测试操作 CACHED,Postgres 没有启动 改 slug.go 5432/tcp 2.4 s → go test 输出 store (cached)
图 4 · 两边都可能“起了数据库却没测”:Go 的测试缓存不知道数据库是新起的

相同点比想象的多:两边最后都栽在 Go 自己的测试缓存上。 Dagger 的摘要缓存在“什么都没变”时连 Postgres 都不起,这比 GitHub Actions 起了 10 秒数据库却不用要省;可一旦源码变了,测试操作重跑,容器里挂着的 Go 构建缓存卷会照样回答 store (cached)。对 Go 来说,DATABASE_URL 没变、测试二进制没变,就是同一个测试。

还有一个 Dagger 特有的细节:base 是整个 Go 模块共用的,所以 slug 的纯函数测试也绑上了 Postgres,只选 TestEncodeZero 一个测试也要等它起来(1.4 秒)。想让不同的包用不同的容器,要么拆模块,要么自己写模块函数。

让集成测试每次真的跑

Go 的标准做法是 -count=110。原生 GitHub Actions 在 step 上设 GOFLAGS=-count=1;Dagger 在 Go 模块设置里加 goflags:

Terminal window
dagger module settings go goflags -- -count=1

第七轮两边都加上以后,原生 GitHub Actions 的输出变成了 ok example.com/shortlink/store 0.028s,测试真的连了数据库;Dagger 改了 slug.go 以后也是 store 0.021s。

但 Dagger 原样再跑一遍时,两个测试操作仍然是 CACHED,-count=1 根本没机会生效:输入一个字节都没变,引擎直接拿上次的结果。这是 Dagger 有意的设计:它把一次测试看成“源码 + 容器 + 服务定义”的函数,Postgres 每次都是从同一个镜像新起的,输入不变,结果就不该变。对于依赖外部真实服务、本身不稳定的测试,这个假设就不成立了,这类测试要么搬进容器里的服务,要么想办法让输入每次都不同。

数据库的问题解决了,下一个问题是:测试真的挂了,我们看到的是什么,又能多快地只重跑那一个?

五、测试失败以后

我们把 TestEncodeZero 的期望值改错,两边各跑一次。

同一个测试失败,两边给出的东西 左边是 GitHub Actions:go test 这个 step 变红,日志里是 go test -v 的原始输出,FAIL example.com/shortlink/slug,最后一行 Process completed with exit code 1;能做的重跑是 Re-run failed jobs,整个 native job 再来一遍,第六轮这个 job 用了 31 秒。右边是 Dagger 的 --progress report:CHECKS 一栏 1 个失败 4 个通过,失败的检查下面列出 TestEncodeZero FAIL 和 slug_test.go:7 的那一行,末尾 RUN LOCALLY 给出一条可以直接复制的 dagger check 命令;加上 --go-test=TestEncodeZero 只重跑这一个测试,用了 5.9 秒。 GitHub Actions · step 日志 Dagger · dagger check --progress report === RUN TestEncodeZero     slug_test.go:7: Encode(0) = "2" --- FAIL: TestEncodeZero (0.00s) === RUN TestRoundTrip --- PASS: TestRoundTrip (0.00s) … FAIL example.com/shortlink/slug 0.001s ok example.com/shortlink/store (cached) FAIL ##[error]Process completed with exit code 1. 重跑:Re-run failed jobs 整个 native job 再来一遍 · 31 s CHECKS ✘ 1 failed ✔ 4 passed ✘ dag://?check=test&go-package=slug 6.8s     ✘ slug › TestEncodeZero FAIL         slug_test.go:7: Encode(0) = "2"     ✘ 1 failed ✔ 2 passed ✔ dag://?check=golangci-lint/projects/lint ✔ dag://?check=test&go-package=store … RUN LOCALLY dagger check "dag://?check=test&go-package=slug" 重跑:--go-test=TestEncodeZero 执行 -run '^(TestEncodeZero)$' · 5.9 s
图 5 · GitHub Actions 给的是一段文本和一个红色的 step;Dagger 给的是哪个测试挂了,以及一条只重跑它的命令

GitHub Actions 看到的单位是 step。 它知道 go test 这个 step 退出码是 1,至于是哪个测试,要人(或者 agent)去读日志。重跑的最小单位是 job14:“Re-run failed jobs” 会把 checkout、起 Postgres、lint 全部再来一遍。

Dagger 看到的单位是测试。 Go 模块用 otelgotest 包住 go test,每个测试发一个 OpenTelemetry span,所以报告里直接列出 TestEncodeZero FAIL 和失败的那一行,还附了一条“在本地复现”的命令。选中单个测试重跑时,模块实际执行的是 otelgotest -run '^(TestEncodeZero)$' ./slug,在缓存热着的引擎上 5.9 秒。

博文说的“日志又长又没有结构,agent 要烧 token 从文本里重建上下文”,指的就是左边这种情况。我们这个项目日志很短,读起来不费劲;但对于几千个测试、几十个 job 的仓库,“哪个测试挂了”能不能直接拿到,差别就大了。

失败后还能再进一步:dagger check -i 会在失败的那个容器状态里打开一个 shell15,相当于 GitHub Actions 里大家常用的 tmate 远程调试 action。这个需要交互终端,我们在 CI 里没法演示。

能在 CI 上精确重跑一个测试固然好,但更省事的是根本不 push:在本地跑同一套检查。

六、在本地跑同一套

GitHub Actions:用 act 模拟

GitHub Actions 没有官方的本地运行方式,常用的是 act16:它读 workflow 文件,用 Docker 起一个模拟 runner 的容器,把 action 下载下来在里面执行。我们在一台 runner 上用 act v0.2.89 跑同一个 native job,镜像用 catthehacker/ubuntu:act-latest:

  • 总共 129 秒,其中 setup-go 12 秒、lint 28 秒、测试 3 秒,剩下八十多秒主要花在拉镜像和起容器上;
  • act 里没有 GitHub 的缓存服务,setup-go 报 Cache is not found,每次都是冷的;
  • 第一次我们以为 act 的 job 跑在容器里,把数据库地址改成了服务名 postgres,结果 lookup postgres on 127.0.0.53:53: server misbehaving;改回和 GitHub 上一样的 localhost:5432 才通过。

act 能跑通大部分 workflow,但它是一个“尽量像”的模拟器:runner 镜像、网络、缓存、GITHUB_TOKEN 的权限,每一处都可能和真的不一样。

Dagger:本地和 CI 是同一条命令

Dagger 这边没有“模拟”这一层。CI 里执行的是 dagger check,本地执行的也是 dagger check,背后是同一个引擎、同一份 dagger.toml 和 dagger.lock、同样的容器镜像。区别只在引擎放在哪:本地是笔记本上的一个容器,CI 里是 runner 上的一个容器。博文把这一点放在第一位,理由是 coding agent:agent 在本地就能跑出和 CI 完全一样的结果,就不用 “push and pray”,每次推上去等结果、读日志、再改1。

要付出的代价是:测试必须能在容器里跑,依赖要写成模块。 我们为 store 测试写了 pgbase,它带来了一个独立的 Go 模块和几千行生成代码,还差点把 lint 拖慢一倍(第三节)。这些都要提交到仓库里,Dagger 才能加载它们。

本地这一层,Dagger 赢在“同一套”。但开头那句 “replaces GitHub Actions” 说的是另一件事:不要 runner、不要 workflow 文件,由 Dagger 直接接 GitHub 的事件。这一层替掉以后,账怎么算?

七、Cloud Checks 和按用量计费

Cloud Checks 接走的是哪一层

执行 dagger cloud checks on 以后,Dagger Cloud 通过 GitHub App 接收 push 和 PR 事件,在 Cloud Engines 上跑 dagger check,结果写回成 GitHub 的 check status17。对照图 2,这一步把“触发”“机器”“缓存”三层都从 GitHub Actions 手里接走了,GitHub 只剩下代码托管和展示 check 结果。缓存也不再随 runner 消失:开发机、agent、CI 连的是同一批引擎,用的是同一份缓存。这正好补上了第三节那个“新机器上缓存是空的”的问题。

我们没有开 Dagger Cloud 账号,下面的账是按公开价格推算的,没有实测。

两种计价方式

GitHub Actions 托管 runner 与 Dagger Cloud 的计价
项目GitHub Actions 托管 runnerDagger Cloud
按什么算整台机器 × job 时长,按分钟向上取整pipeline 实际用掉的 CPU、内存(来自 cgroup),以及进出的数据量
单价Linux 2 核 $0.006/min
4 核 $0.012/min
CPU 0.5¢/CPU-min
内存 0.5¢/GB-min
拉入 2.5¢/GB,推出 10¢/GB
机器闲着照算不算
重复的工作省下的是时间,快了就少算30 天内输入相同的操作按五分之一算,不管实际有没有命中缓存
起步套餐内的免费分钟用完后按量按量与套餐价取高者:$20/月起,团队 $200/月起

均为 2026 年 10 月的公开价格,见文末参考资料。

表 2 · GitHub 卖的是机器时间,Dagger 卖的是实际消耗的资源

把两种算法放到同一分钟上比一比(推算)。一台 2 核 runner 满负荷跑一分钟,GitHub 收 $0.00618;同样的活放在 Dagger Cloud19,2 个 CPU-min 是 1¢,假设占 2 GB 内存又是 1¢,合计 2¢,是 GitHub 的三倍多。反过来,如果这一分钟里机器大半在等(等数据库起来、等网络、等别的 job),或者大部分操作是“重复工作”,Dagger 那边就只收很少。

所以 Dagger 的账只在两种情况下划算:机器利用率低,或者重复工作多。 博文举的例子是某客户 9 月的用量在 Dagger 上 $94,按分钟计费的 runner 估算 $19719,这是厂商自己的数字。我们这个项目,原生 job 二三十秒,按分钟取整每次 $0.006,一个月推几百次也到不了 Dagger 的 $20 起步价。博文瞄准的是另一种规模:coding agent 一天推几百个相似的改动,大部分工作和昨天一模一样。

最后一个问题:除了这两个,同一个领域里还有哪些工具,它们各站在哪一层?

八、同类工具的位置

CI 领域的工具各管哪几层
工具管哪几层缓存的单位本地跑同一套
CI 平台:触发 + 调度 + 机器 + 按步骤执行
Jenkins全部,机器自己管常驻机器的工作目录,插件各自缓存有限(jenkinsfile-runner)
GitHub Actions全部,托管或自托管 runner按 key 存取的 tar 包靠 act 模拟
GitLab CI全部,runner 可自托管按 key 存取的目录,可放在 runner 本地或对象存储gitlab-runner exec 已废弃
CircleCI全部,托管机器按 key 存取的目录;Docker 层缓存另付费本地 CLI 只能跑单个 job
Buildkite触发与调度托管,机器自己出常驻 agent 的本地磁盘有限
Tekton / Argo Workflows调度与执行,跑在 Kubernetes 上挂载的卷,自己管要一个集群
执行引擎:把构建和测试变成可缓存的图,在任何 CI 里调用
Dagger执行 + 缓存;Cloud Checks 再加触发和机器每个容器操作,按输入摘要同一条命令
Earthly执行 + 缓存,基于 BuildKitEarthfile 里的每一步,像 Dockerfile 的层同一条命令
Bazel构建与测试,远程缓存和远程执行每个 action,按输入哈希同一条命令
Nix构建与环境每个 derivation,按输入哈希同一条命令

只有 GitHub Actions、act 和 Dagger 在本文实测过,其余按官方文档整理。Earthly 已于 2025 年 7 月停止维护,社区分叉为 EarthBuild。

表 3 · Dagger 和 Bazel、Nix 是同一类:按输入摘要缓存;和 GitHub Actions 不是同一类,直到加上 Cloud Checks

这张表把开头的问题说清楚了。Dagger 首先是一个执行引擎,和 Bazel、Nix、Earthly(已停更20)站在一起:把工作拆成按输入摘要缓存的小块,本地和 CI 用同一套。它和 Bazel 的区别在于单位是容器操作而不是语言级的编译动作,不需要把整个项目改写成 BUILD 文件,代价是缓存粒度粗一些(我们看到的是“一个 Go 模块的源码目录”)。加上 Cloud Checks 以后,它才伸到 GitHub Actions 那一行,开始和 CI 平台抢触发和机器。

对于用 Go 的我们,还有一点值得一提:Go 工具链自带了相当好的构建和测试缓存,第三节里真正判断“哪个包没变”的一直是它。Dagger 在 Go 项目上的增量,主要是“完全没变就整个跳过”“README 这类无关改动不触发”,以及跨机器共享缓存,而不是比 Go 本身更细的增量编译。

小结

回到开头的问题:Dagger 和 GitHub Actions 是不是同一类东西?

  1. 两份配置描述的东西不同。 GitHub Actions 写“在哪台机器上按什么顺序执行哪些命令”;Dagger 只写装了哪些模块,测哪些包、哪些测试由模块从 go.mod 自己找出来。相同的是,两边最后都变成 Linux 上的容器和进程。
  2. 它们原本不在同一层。 GitHub Actions 管触发、机器、执行、缓存四层;Dagger 引擎只管执行和缓存,最常见的用法是放进 GitHub Actions 的一个 step 里。博文说的“替代”,指的是 Cloud Checks:由 Dagger Cloud 直接接 GitHub 事件、用自己的引擎跑。
  3. Dagger 的缓存是按操作的输入摘要算的。 引擎活着时,原样重跑 8.9 秒、改 README 什么都不重跑;但它的粒度是“整个 Go 模块的源码目录”,更细的判断仍然交给 Go 自己。放进每次都换新机器的 GitHub Actions job 里,缓存每次都是空的,冷启动比原生慢(101–167 秒对 27–56 秒)。托管 runner 磁盘剩余不到 20% 时,引擎还会自己清掉缓存。
  4. 集成测试的数据库,两边起法不同,坑却一样。 GitHub Actions 在 job 开头起好、开到结束;Dagger 在测试操作需要时才起,缓存命中时根本不起。两边都会被 Go 的测试缓存骗过:store (cached) 时并没有连数据库,要加 -count=1。
  5. 测试失败时,GitHub Actions 给一段日志和一个红色的 step,最小重跑单位是 job;Dagger 给出哪个测试挂了,能只重跑那一个(5.9 秒)。 对要从结果里找上下文的 coding agent,这个差别比对人更大。
  6. 本地跑同一套:GitHub Actions 只能用 act 模拟(129 秒,地址写法都可能不一样);Dagger 本地和 CI 是同一条命令。 代价是依赖要写成模块,生成代码要进仓库。
  7. 计费方式反过来了。 GitHub 按整台机器的时间收钱,2 核每分钟 $0.006;Dagger Cloud 按实际消耗的 CPU 和内存收钱,满负荷时更贵,闲着和重复工作时便宜。它押注的是 agent 带来的大量重复工作。
  8. 在同类工具里,Dagger 和 Bazel、Nix、Earthly 是一类(按输入摘要缓存的执行引擎),加上 Cloud Checks 以后才开始和 GitHub Actions、GitLab CI 这些平台正面竞争。

如果想自己试,实验用的项目、pgbase 模块和两边的工作流就是上文图 1 里那些;在一台有 Docker 的机器上,dagger init、dagger module install dagger.io/go、dagger check 三条命令就能看到第三节的缓存行为。

参考资料

Footnotes

  1. Dagger 1.0 betaSolomon Hykes · Dagger Blog · 2026-10-23dagger.io ↗本文分析的博文。TLDR:“Dagger is third-generation CI, after Jenkins and GitHub Actions.”https://dagger.io/blog/dagger-10-beta/ ↩ ↩2

  2. Go moduleDagger Docsdocs.dagger.io ↗按 go.mod 发现 Go 模块,包是按目录分的集合,测试是按函数名分的集合;每个包单独跑一次 go test;base 设置可以换成绑定了服务的容器。https://docs.dagger.io/reference/modules/go ↩ ↩2

  3. Checking your codeDagger Docsdocs.dagger.io ↗dagger check -l --all 列出每个检查;--go-package、--go-test 选到单个包、单个测试。https://docs.dagger.io/using/checking ↩

  4. Module wiringDagger Docsdocs.dagger.io ↗dag://<module>/<function> 地址把一个模块的返回值注入另一个模块的设置。https://docs.dagger.io/config/module-wiring ↩

  5. actions/cacheGitHubgithub.com ↗“If the provided key matches an existing cache, a new cache is not created.” 每个仓库 10 GB,7 天未访问的条目被淘汰。https://github.com/actions/cache ↩ ↩2

  6. Project Theseus: removing BuildKitDagger Changelog · 2026-05-22dagger.io ↗v0.21 起用自己的 DAG 引擎替换 BuildKit 的 solver,新缓存基于 e-graph。https://dagger.io/changelog/theseus ↩

  7. Much faster (v1.0 beta)Dagger Changelogdagger.io ↗所有官方模块和 SDK 运行时都用 Dang 写;crun 替换 runc;1000 个容器并行从 35–61 s 降到 16–17 s。https://dagger.io/changelog/v1-performance ↩

  8. The Great CI Bottleneck of 2026Solomon Hykes · Dagger Blog · 2026-09-15dagger.io ↗“CI workloads are dumb, and running dumb workloads is wasteful.” 认为瓶颈是软件问题,不是机器不够快。https://dagger.io/blog/the-great-ci-bottleneck-of-2026/ ↩

  9. actions/setup-goGitHubgithub.com ↗默认开启缓存,缓存 GOMODCACHE 和 GOCACHE,key 里带 go.sum 的哈希。https://github.com/actions/setup-go ↩

  10. go test: test cachingGopkg.go.dev ↗结果按测试二进制、命令行参数和测试读过的环境变量、文件缓存;“The idiomatic way to disable test caching explicitly is to use -count=1.”https://pkg.go.dev/cmd/go#hdr-Test_packages ↩ ↩2

  11. engine.jsonDagger Docsdocs.dagger.io ↗默认 minFreeSpace 为磁盘的 20%:剩余空间低于它,引擎就清理缓存直到恢复。https://docs.dagger.io/reference/config-files/engine-json ↩

  12. About service containersGitHub Docsdocs.github.com ↗job 直接跑在 runner 上时用 localhost 加映射端口访问服务;job 跑在容器里时用服务名。服务随 job 创建、随 job 销毁。https://docs.github.com/en/actions/use-cases-and-examples/using-containerized-services/about-service-containers ↩

  13. Running servicesDagger Docsdocs.dagger.io ↗服务容器的主机名由定义算出来;第一次被用到时才启动,没人引用就停;客户端连上之前先做健康检查。https://docs.dagger.io/using/services ↩

  14. Re-running workflows and jobsGitHub Docsdocs.github.com ↗最小的重跑单位是 job:可以重跑全部 job 或失败的 job,不能只重跑某个测试。https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/re-running-workflows-and-jobs ↩

  15. Dev shellsDagger Changelogdagger.io ↗dagger shell 进入工作区提供的任意容器;dagger check -i 在命令失败的那个状态里开一个 shell。https://dagger.io/changelog/dev-shells ↩

  16. nektos/actGitHubgithub.com ↗在本机用 Docker 模拟 GitHub Actions 运行 workflow。文中用 v0.2.89。https://github.com/nektos/act ↩

  17. Cloud ChecksDagger Changelogdagger.io ↗每次 push 和 PR 在 Cloud Engines 上跑 dagger check,结果显示为 GitHub check status。https://dagger.io/changelog/cloud-checks ↩

  18. Actions runner pricingGitHub Docsdocs.github.com ↗Linux 2-core x64 每分钟 0.006 美元,4-core 0.012 美元,按整台机器、整段 job 时长计费。https://docs.github.com/en/billing/reference/actions-runner-pricing ↩

  19. Dagger Cloud pricingDaggerdagger.io ↗CPU 0.5¢/CPU-min,内存 0.5¢/GB-min;30 天内同输入的“重复工作”五分之一价;Starter 每月 20 美元,Scale 每月 200 美元,按用量和套餐价取高者。https://dagger.io/pricing ↩ ↩2

  20. Earthly is no longer actively maintainedGitHubgithub.com ↗2025-07-16 起停止维护,Earthly Cloud 关闭;社区分叉为 EarthBuild。https://github.com/earthly/earthly ↩