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。
两边写出来的样子
先看最直观的差别:同样的事,两边各写了什么。
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 --allMODULE GOLANGCI-LINT-PROJECT GO-PACKAGE GO-TEST CHECKgolangci-lint . lintgo slug TestDecodeRejectsAmbiguous testgo slug TestEncodeZero testgo slug TestRoundTrip testgo 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 是一整套东西:事件触发、分配机器、按步骤执行、存缓存。 Dagger 引擎只占其中的“执行”和“缓存”两层。最常见的用法(中间一列)是把引擎塞进 GitHub Actions 的一个 step 里,两者是嵌套关系,不是替代关系。博文说的“replaces GitHub Actions”,指的是右边一列:Dagger Cloud 直接接收 GitHub 的事件,在自己的引擎上跑,再把结果写回 GitHub 的 check status。
中间那个虚线框是后面所有问题的伏笔:引擎的缓存存在 runner 的磁盘上,而 runner 用完就扔。 这对缓存意味着什么?
三、缓存:谁知道什么变了
博文列举的第二种浪费是“shell 脚本不知道什么变了,所以机器一遍遍重算同样的东西”。Solomon Hykes 在更早的一篇里说得更直白:CI 的瓶颈是软件问题,不是机器不够快8。我们用四个场景来看两边各重算了什么:
- 冷启动:什么缓存都没有;
- 原样再跑一遍;
- 给
slug/slug.go末尾加一行注释; - 只改
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 s | 159–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 分钟。
原生 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 的执行器。)
改 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 里把它排除掉:
[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]。
相同点比想象的多:两边最后都栽在 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:
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 看到的单位是 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 |
|---|---|---|
| 按什么算 | 整台机器 × 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 核 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 平台:触发 + 调度 + 机器 + 按步骤执行 | |||
| 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 | 执行 + 缓存,基于 BuildKit | Earthfile 里的每一步,像 Dockerfile 的层 | 同一条命令 |
| Bazel | 构建与测试,远程缓存和远程执行 | 每个 action,按输入哈希 | 同一条命令 |
| Nix | 构建与环境 | 每个 derivation,按输入哈希 | 同一条命令 |
只有 GitHub Actions、act 和 Dagger 在本文实测过,其余按官方文档整理。Earthly 已于 2025 年 7 月停止维护,社区分叉为 EarthBuild。
这张表把开头的问题说清楚了。Dagger 首先是一个执行引擎,和 Bazel、Nix、Earthly(已停更20)站在一起:把工作拆成按输入摘要缓存的小块,本地和 CI 用同一套。它和 Bazel 的区别在于单位是容器操作而不是语言级的编译动作,不需要把整个项目改写成 BUILD 文件,代价是缓存粒度粗一些(我们看到的是“一个 Go 模块的源码目录”)。加上 Cloud Checks 以后,它才伸到 GitHub Actions 那一行,开始和 CI 平台抢触发和机器。
对于用 Go 的我们,还有一点值得一提:Go 工具链自带了相当好的构建和测试缓存,第三节里真正判断“哪个包没变”的一直是它。Dagger 在 Go 项目上的增量,主要是“完全没变就整个跳过”“README 这类无关改动不触发”,以及跨机器共享缓存,而不是比 Go 本身更细的增量编译。
小结
回到开头的问题:Dagger 和 GitHub Actions 是不是同一类东西?
- 两份配置描述的东西不同。 GitHub Actions 写“在哪台机器上按什么顺序执行哪些命令”;Dagger 只写装了哪些模块,测哪些包、哪些测试由模块从
go.mod自己找出来。相同的是,两边最后都变成 Linux 上的容器和进程。 - 它们原本不在同一层。 GitHub Actions 管触发、机器、执行、缓存四层;Dagger 引擎只管执行和缓存,最常见的用法是放进 GitHub Actions 的一个 step 里。博文说的“替代”,指的是 Cloud Checks:由 Dagger Cloud 直接接 GitHub 事件、用自己的引擎跑。
- Dagger 的缓存是按操作的输入摘要算的。 引擎活着时,原样重跑 8.9 秒、改 README 什么都不重跑;但它的粒度是“整个 Go 模块的源码目录”,更细的判断仍然交给 Go 自己。放进每次都换新机器的 GitHub Actions job 里,缓存每次都是空的,冷启动比原生慢(101–167 秒对 27–56 秒)。托管 runner 磁盘剩余不到 20% 时,引擎还会自己清掉缓存。
- 集成测试的数据库,两边起法不同,坑却一样。 GitHub Actions 在 job 开头起好、开到结束;Dagger 在测试操作需要时才起,缓存命中时根本不起。两边都会被 Go 的测试缓存骗过:
store (cached)时并没有连数据库,要加-count=1。 - 测试失败时,GitHub Actions 给一段日志和一个红色的 step,最小重跑单位是 job;Dagger 给出哪个测试挂了,能只重跑那一个(5.9 秒)。 对要从结果里找上下文的 coding agent,这个差别比对人更大。
- 本地跑同一套:GitHub Actions 只能用 act 模拟(129 秒,地址写法都可能不一样);Dagger 本地和 CI 是同一条命令。 代价是依赖要写成模块,生成代码要进仓库。
- 计费方式反过来了。 GitHub 按整台机器的时间收钱,2 核每分钟 $0.006;Dagger Cloud 按实际消耗的 CPU 和内存收钱,满负荷时更贵,闲着和重复工作时便宜。它押注的是 agent 带来的大量重复工作。
- 在同类工具里,Dagger 和 Bazel、Nix、Earthly 是一类(按输入摘要缓存的执行引擎),加上 Cloud Checks 以后才开始和 GitHub Actions、GitLab CI 这些平台正面竞争。
如果想自己试,实验用的项目、pgbase 模块和两边的工作流就是上文图 1 里那些;在一台有 Docker 的机器上,dagger init、dagger module install dagger.io/go、dagger check 三条命令就能看到第三节的缓存行为。
参考资料
Footnotes
-
Dagger 1.0 betadagger.io ↗本文分析的博文。TLDR:“Dagger is third-generation CI, after Jenkins and GitHub Actions.”https://dagger.io/blog/dagger-10-beta/ ↩ ↩2
-
Go moduledocs.dagger.io ↗按 go.mod 发现 Go 模块,包是按目录分的集合,测试是按函数名分的集合;每个包单独跑一次
go test;base设置可以换成绑定了服务的容器。https://docs.dagger.io/reference/modules/go ↩ ↩2 -
Checking your codedocs.dagger.io ↗
dagger check -l --all列出每个检查;--go-package、--go-test选到单个包、单个测试。https://docs.dagger.io/using/checking ↩ -
Module wiringdocs.dagger.io ↗
dag://<module>/<function>地址把一个模块的返回值注入另一个模块的设置。https://docs.dagger.io/config/module-wiring ↩ -
actions/cachegithub.com ↗“If the provided key matches an existing cache, a new cache is not created.” 每个仓库 10 GB,7 天未访问的条目被淘汰。https://github.com/actions/cache ↩ ↩2
-
Project Theseus: removing BuildKitdagger.io ↗v0.21 起用自己的 DAG 引擎替换 BuildKit 的 solver,新缓存基于 e-graph。https://dagger.io/changelog/theseus ↩
-
Much faster (v1.0 beta)dagger.io ↗所有官方模块和 SDK 运行时都用 Dang 写;crun 替换 runc;1000 个容器并行从 35–61 s 降到 16–17 s。https://dagger.io/changelog/v1-performance ↩
-
The Great CI Bottleneck of 2026dagger.io ↗“CI workloads are dumb, and running dumb workloads is wasteful.” 认为瓶颈是软件问题,不是机器不够快。https://dagger.io/blog/the-great-ci-bottleneck-of-2026/ ↩
-
actions/setup-gogithub.com ↗默认开启缓存,缓存 GOMODCACHE 和 GOCACHE,key 里带 go.sum 的哈希。https://github.com/actions/setup-go ↩
-
go test: test cachingpkg.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
-
engine.jsondocs.dagger.io ↗默认
minFreeSpace为磁盘的 20%:剩余空间低于它,引擎就清理缓存直到恢复。https://docs.dagger.io/reference/config-files/engine-json ↩ -
About service containersdocs.github.com ↗job 直接跑在 runner 上时用 localhost 加映射端口访问服务;job 跑在容器里时用服务名。服务随 job 创建、随 job 销毁。https://docs.github.com/en/actions/use-cases-and-examples/using-containerized-services/about-service-containers ↩
-
Running servicesdocs.dagger.io ↗服务容器的主机名由定义算出来;第一次被用到时才启动,没人引用就停;客户端连上之前先做健康检查。https://docs.dagger.io/using/services ↩
-
Re-running workflows and jobsdocs.github.com ↗最小的重跑单位是 job:可以重跑全部 job 或失败的 job,不能只重跑某个测试。https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/re-running-workflows-and-jobs ↩
-
Dev shellsdagger.io ↗
dagger shell进入工作区提供的任意容器;dagger check -i在命令失败的那个状态里开一个 shell。https://dagger.io/changelog/dev-shells ↩ -
nektos/actgithub.com ↗在本机用 Docker 模拟 GitHub Actions 运行 workflow。文中用 v0.2.89。https://github.com/nektos/act ↩
-
Cloud Checksdagger.io ↗每次 push 和 PR 在 Cloud Engines 上跑
dagger check,结果显示为 GitHub check status。https://dagger.io/changelog/cloud-checks ↩ -
Actions runner pricingdocs.github.com ↗Linux 2-core x64 每分钟 0.006 美元,4-core 0.012 美元,按整台机器、整段 job 时长计费。https://docs.github.com/en/billing/reference/actions-runner-pricing ↩
-
Dagger Cloud pricingdagger.io ↗CPU 0.5¢/CPU-min,内存 0.5¢/GB-min;30 天内同输入的“重复工作”五分之一价;Starter 每月 20 美元,Scale 每月 200 美元,按用量和套餐价取高者。https://dagger.io/pricing ↩ ↩2
-
Earthly is no longer actively maintainedgithub.com ↗2025-07-16 起停止维护,Earthly Cloud 关闭;社区分叉为 EarthBuild。https://github.com/earthly/earthly ↩