﻿---
title: "Dagger 1.0 和 GitHub Actions：第三代 CI 换掉了什么"
description: "Dagger 1.0 beta 的发布博文说自己是继 Jenkins、GitHub Actions 之后的第三代 CI，还说一条 dagger cloud checks on 就能替掉 GitHub Actions。两者是同一类东西吗？顺着问题往下追：两边的配置各自描述了什么；谁在执行、缓存放在哪；改一行代码各自重算了什么；集成测试要的数据库谁来起；测试挂了以后看到的是什么；本地能不能跑同一套；最后是 Cloud Checks 和按用量计费到底替掉了哪一层。全部场景用同一个 Go 小项目，在 GitHub 的 ubuntu-latest 机器上实测。"
pubDate: 2026-10-25
tags: ["CI", "Dagger", "GitHub Actions", "Go", "工具"]
hero: "./hero.svg"
refs:
  - id: dagger-10
    title: "Dagger 1.0 beta"
    url: https://dagger.io/blog/dagger-10-beta/
    author: Solomon Hykes
    site: Dagger Blog
    date: 2026-10-23
    note: "本文分析的博文。TLDR：“Dagger is third-generation CI, after Jenkins and GitHub Actions.”"
  - id: bottleneck
    title: "The Great CI Bottleneck of 2026"
    url: https://dagger.io/blog/the-great-ci-bottleneck-of-2026/
    author: Solomon Hykes
    site: Dagger Blog
    date: 2026-09-15
    note: "“CI workloads are dumb, and running dumb workloads is wasteful.” 认为瓶颈是软件问题，不是机器不够快。"
  - id: theseus
    title: "Project Theseus: removing BuildKit"
    url: https://dagger.io/changelog/theseus
    site: Dagger Changelog
    date: 2026-05-22
    note: "v0.21 起用自己的 DAG 引擎替换 BuildKit 的 solver，新缓存基于 e-graph。"
  - id: dg-perf
    title: "Much faster (v1.0 beta)"
    url: https://dagger.io/changelog/v1-performance
    site: Dagger Changelog
    note: "所有官方模块和 SDK 运行时都用 Dang 写；crun 替换 runc；1000 个容器并行从 35–61 s 降到 16–17 s。"
  - id: dg-go
    title: "Go module"
    url: https://docs.dagger.io/reference/modules/go
    site: Dagger Docs
    note: "按 go.mod 发现 Go 模块，包是按目录分的集合，测试是按函数名分的集合；每个包单独跑一次 `go test`；`base` 设置可以换成绑定了服务的容器。"
  - id: dg-wiring
    title: "Module wiring"
    url: https://docs.dagger.io/config/module-wiring
    site: Dagger Docs
    note: "`dag://<module>/<function>` 地址把一个模块的返回值注入另一个模块的设置。"
  - id: dg-services
    title: "Running services"
    url: https://docs.dagger.io/using/services
    site: Dagger Docs
    note: "服务容器的主机名由定义算出来；第一次被用到时才启动，没人引用就停；客户端连上之前先做健康检查。"
  - id: dg-check
    title: "Checking your code"
    url: https://docs.dagger.io/using/checking
    site: Dagger Docs
    note: "`dagger check -l --all` 列出每个检查；`--go-package`、`--go-test` 选到单个包、单个测试。"
  - id: dg-engine-gc
    title: "engine.json"
    url: https://docs.dagger.io/reference/config-files/engine-json
    site: Dagger Docs
    note: "默认 `minFreeSpace` 为磁盘的 20%：剩余空间低于它，引擎就清理缓存直到恢复。"
  - id: dg-dev-shells
    title: "Dev shells"
    url: https://dagger.io/changelog/dev-shells
    site: Dagger Changelog
    note: "`dagger shell` 进入工作区提供的任意容器；`dagger check -i` 在命令失败的那个状态里开一个 shell。"
  - id: dg-cloud-checks
    title: "Cloud Checks"
    url: https://dagger.io/changelog/cloud-checks
    site: Dagger Changelog
    note: "每次 push 和 PR 在 Cloud Engines 上跑 `dagger check`，结果显示为 GitHub check status。"
  - id: dg-pricing
    title: "Dagger Cloud pricing"
    url: https://dagger.io/pricing
    site: Dagger
    note: "CPU 0.5¢/CPU-min，内存 0.5¢/GB-min；30 天内同输入的“重复工作”五分之一价；Starter 每月 20 美元，Scale 每月 200 美元，按用量和套餐价取高者。"
  - id: gha-services
    title: "About service containers"
    url: https://docs.github.com/en/actions/use-cases-and-examples/using-containerized-services/about-service-containers
    site: GitHub Docs
    note: "job 直接跑在 runner 上时用 localhost 加映射端口访问服务；job 跑在容器里时用服务名。服务随 job 创建、随 job 销毁。"
  - id: gha-cache
    title: "actions/cache"
    url: https://github.com/actions/cache
    site: GitHub
    note: "“If the provided key matches an existing cache, a new cache is not created.” 每个仓库 10 GB，7 天未访问的条目被淘汰。"
  - id: setup-go
    title: "actions/setup-go"
    url: https://github.com/actions/setup-go
    site: GitHub
    note: "默认开启缓存，缓存 GOMODCACHE 和 GOCACHE，key 里带 go.sum 的哈希。"
  - id: gha-pricing
    title: "Actions runner pricing"
    url: https://docs.github.com/en/billing/reference/actions-runner-pricing
    site: GitHub Docs
    note: "Linux 2-core x64 每分钟 0.006 美元，4-core 0.012 美元，按整台机器、整段 job 时长计费。"
  - id: gha-rerun
    title: "Re-running workflows and jobs"
    url: https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/re-running-workflows-and-jobs
    site: GitHub Docs
    note: "最小的重跑单位是 job：可以重跑全部 job 或失败的 job，不能只重跑某个测试。"
  - id: go-testcache
    title: "go test: test caching"
    url: https://pkg.go.dev/cmd/go#hdr-Test_packages
    site: Go
    note: "结果按测试二进制、命令行参数和测试读过的环境变量、文件缓存；“The idiomatic way to disable test caching explicitly is to use -count=1.”"
  - id: act
    title: "nektos/act"
    url: https://github.com/nektos/act
    site: GitHub
    note: "在本机用 Docker 模拟 GitHub Actions 运行 workflow。文中用 v0.2.89。"
  - id: earthly
    title: "Earthly is no longer actively maintained"
    url: https://github.com/earthly/earthly
    site: GitHub
    note: "2025-07-16 起停止维护，Earthly Cloud 关闭；社区分叉为 EarthBuild。"
---

Dagger 1.0 beta 的发布博文[^dagger-10]第一句话是：

> 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。

### 两边写出来的样子

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" style="font-variant-ligatures:none" viewBox="0 0 800 476" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="dgf1-title dgf1-desc">
<title id="dgf1-title">同一个项目的两份 CI 配置</title>
<desc id="dgf1-desc">左边是 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 容器。</desc>
<rect class="dd-frame" width="800" height="476" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="368" height="444" rx="8"/>
<rect class="dd-zone" x="400" y="16" width="384" height="444" rx="8"/>
<text class="t-zone" x="32" y="44">GitHub Actions</text>
<text class="t-zone" x="416" y="44">Dagger 1.0</text>
<rect class="dd-card" x="32" y="64" width="336" height="276" rx="6"/>
<path class="dd-card-head" d="M38 64 H362 Q368 64 368 70 V96 H32 V70 Q32 64 38 64 Z"/>
<text class="t-name" x="48" y="85">.github/workflows/ci.yml</text>
<text class="t-mono" x="48" y="120">runs-on: ubuntu-latest</text>
<text class="t-mono" x="48" y="142">services:</text>
<text class="t-mono" x="48" y="164">  postgres:</text>
<text class="t-mono" x="48" y="186">    image: <tspan class="t-acc">postgres:16-alpine</tspan></text>
<text class="t-mono" x="48" y="208">    ports: ["5432:5432"]</text>
<text class="t-mono" x="48" y="230">steps:</text>
<text class="t-mono" x="48" y="252">  - uses: actions/checkout@v4</text>
<text class="t-mono" x="48" y="274">  - uses: actions/setup-go@v5</text>
<text class="t-mono" x="48" y="296">  - uses: golangci/golangci-lint-action@v8</text>
<text class="t-mono" x="48" y="318">  - run: go test -v ./...</text>
<text class="t-say" x="32" y="372">写的是：在哪台机器上，按什么顺序，</text>
<text class="t-say" x="32" y="394">执行哪几条命令。</text>
<text class="t-note" x="32" y="428">测哪些包、哪些测试，由 go test ./... 自己展开；</text>
<text class="t-note" x="32" y="446">CI 只看得到这一个 step 的退出码。</text>
<rect class="dd-card" x="416" y="64" width="352" height="212" rx="6"/>
<path class="dd-card-head" d="M422 64 H762 Q768 64 768 70 V96 H416 V70 Q416 64 422 64 Z"/>
<text class="t-name" x="432" y="85">dagger.toml</text>
<text class="t-note" x="752" y="85" text-anchor="end">节选</text>
<text class="t-mono" x="432" y="120">[modules.go]</text>
<text class="t-mono" x="432" y="140">source = "dagger.io/go"</text>
<text class="t-mono" x="432" y="160">[modules.golangci-lint]</text>
<text class="t-mono" x="432" y="180">source = "dagger.io/go/golangci-lint"</text>
<text class="t-mono" x="432" y="200">[modules.pgbase]</text>
<text class="t-mono" x="432" y="220">source = ".dagger/modules/pgbase"</text>
<text class="t-mono" x="432" y="240">[modules.go.settings]</text>
<text class="t-mono" x="432" y="260">base = "<tspan class="t-acc">dag://pgbase/base</tspan>"</text>
<rect class="dd-card" x="416" y="292" width="352" height="152" rx="6"/>
<path class="dd-card-head" d="M422 292 H762 Q768 292 768 298 V324 H416 V298 Q416 292 422 292 Z"/>
<text class="t-name" x="432" y="313">.dagger/modules/pgbase/main.go</text>
<text class="t-mono" x="432" y="346">pg := dag.Container().</text>
<text class="t-mono" x="432" y="364">  From("<tspan class="t-acc">postgres:16-alpine</tspan>").</text>
<text class="t-mono" x="432" y="382">  WithExposedPort(5432).AsService()</text>
<text class="t-mono" x="432" y="400">return dag.Container().</text>
<text class="t-mono" x="432" y="418">  From("golang:1.24-alpine").</text>
<text class="t-mono" x="432" y="436">  WithServiceBinding("db", pg)</text>
</svg>
</div>
<figcaption>图 1 · 左边列出步骤，右边只列出模块；Postgres 在左边是 job 级的服务，在右边是测试容器的一个依赖</figcaption>
</figure>

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

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

```text
$ 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` 这个地址[^dg-wiring]把它塞给 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 下载解开[^gha-cache]。

### Dagger：一个引擎，放在哪都行

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

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 452" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="dgf2-title dgf2-desc">
<title id="dgf2-title">三种跑法里，触发、机器、执行、缓存各归谁</title>
<desc id="dgf2-desc">三列从上到下都是触发、机器、执行、缓存四层。第一列原生 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。</desc>
<rect class="dd-frame" width="800" height="452" rx="10"/>
<rect class="dd-zone" x="104" y="16" width="216" height="420" rx="8"/>
<rect class="dd-zone" x="336" y="16" width="216" height="420" rx="8"/>
<rect class="dd-zone" x="568" y="16" width="216" height="420" rx="8"/>
<text class="t-zone" x="120" y="44">原生 GitHub Actions</text>
<text class="t-zone" x="352" y="44">Dagger 跑在 Actions 里</text>
<text class="t-zone" x="584" y="44">Dagger Cloud Checks</text>
<text class="t-name" x="24" y="95">触发</text>
<text class="t-name" x="24" y="183">机器</text>
<text class="t-name" x="24" y="271">执行</text>
<text class="t-name" x="24" y="359">缓存</text>
<line class="dd-edge" x1="212" y1="116" x2="212" y2="148" marker-end="url(#dgf2-arrow)"/>
<line class="dd-edge" x1="212" y1="204" x2="212" y2="236" marker-end="url(#dgf2-arrow)"/>
<line class="dd-edge dd-dash" x1="212" y1="300" x2="212" y2="324" marker-end="url(#dgf2-arrow)"/>
<line class="dd-edge" x1="444" y1="116" x2="444" y2="148" marker-end="url(#dgf2-arrow)"/>
<line class="dd-edge-acc" x1="444" y1="204" x2="444" y2="236" marker-end="url(#dgf2-arrow-acc)"/>
<line class="dd-edge-acc" x1="444" y1="300" x2="444" y2="324" marker-end="url(#dgf2-arrow-acc)"/>
<line class="dd-edge-acc" x1="676" y1="116" x2="676" y2="236" marker-end="url(#dgf2-arrow-acc)"/>
<line class="dd-edge-acc" x1="676" y1="300" x2="676" y2="324" marker-end="url(#dgf2-arrow-acc)"/>
<rect class="dd-card" x="120" y="68" width="184" height="48" rx="6"/>
<text class="t-name" x="136" y="89">GitHub 收到 push</text>
<text class="t-mono" x="136" y="106">on: push</text>
<rect class="dd-card" x="120" y="156" width="184" height="48" rx="6"/>
<text class="t-name" x="136" y="177">一台新虚拟机</text>
<text class="t-mono" x="136" y="194">ubuntu-latest 2 核</text>
<rect class="dd-card" x="120" y="244" width="184" height="56" rx="6"/>
<text class="t-name" x="136" y="265">step 依次执行</text>
<text class="t-mono" x="136" y="282">bash · node action</text>
<rect class="dd-card" x="120" y="332" width="184" height="64" rx="6"/>
<text class="t-name" x="136" y="353">actions/cache</text>
<text class="t-mono" x="136" y="370">tar 包，key 含</text>
<text class="t-mono" x="136" y="386">go.sum 哈希</text>
<rect class="dd-card" x="352" y="68" width="184" height="48" rx="6"/>
<text class="t-name" x="368" y="89">GitHub 收到 push</text>
<text class="t-mono" x="368" y="106">on: push</text>
<rect class="dd-card" x="352" y="156" width="184" height="48" rx="6"/>
<text class="t-name" x="368" y="177">一台新虚拟机</text>
<text class="t-mono" x="368" y="194">run: dagger check</text>
<rect class="dd-card" x="352" y="244" width="184" height="56" rx="6"/>
<text class="t-name" x="368" y="265"><tspan class="t-acc">dagger-engine</tspan> 容器</text>
<text class="t-mono" x="368" y="282">按图执行容器操作</text>
<rect class="dd-derived" x="352" y="332" width="184" height="64" rx="6"/>
<text class="t-mono" x="368" y="353">/var/lib/dagger</text>
<text class="t-say" x="368" y="372">在 runner 磁盘上，</text>
<text class="t-say" x="368" y="388">job 结束就没了</text>
<rect class="dd-card" x="584" y="68" width="184" height="48" rx="6"/>
<text class="t-name" x="600" y="89">Dagger Cloud 收事件</text>
<text class="t-mono" x="600" y="106">dagger cloud checks on</text>
<rect class="dd-derived" x="584" y="156" width="184" height="48" rx="6"/>
<text class="t-say" x="600" y="177">没有 workflow 文件，</text>
<text class="t-say" x="600" y="195">不经过 runner</text>
<rect class="dd-card" x="584" y="244" width="184" height="56" rx="6"/>
<text class="t-name" x="600" y="265"><tspan class="t-acc">Cloud Engine</tspan></text>
<text class="t-mono" x="600" y="282">--engine=cloud</text>
<rect class="dd-card" x="584" y="332" width="184" height="64" rx="6"/>
<text class="t-name" x="600" y="353">共享缓存</text>
<text class="t-mono" x="600" y="370">开发机、agent、CI</text>
<text class="t-mono" x="600" y="386">共用一份</text>
<text class="t-note" x="584" y="424">结果回写成 GitHub check status</text>
<defs>
<marker id="dgf2-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="dgf2-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
</svg>
</div>
<figcaption>图 2 · 把 Dagger 放进 Actions，只换掉了“执行”这一层；Cloud Checks 才把触发、机器和缓存一起接过去</figcaption>
</figure>

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

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

## 三、缓存：谁知道什么变了

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

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

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

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="dgt1-title">同一个项目，四个场景，两边的耗时</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="dgt1-title">
<colgroup><col style="width:20%"><col><col class="hl"><col></colgroup>
<thead>
<tr><th scope="col">场景</th><th scope="col">原生 GitHub Actions<br>（每次新机器，job 全程）</th><th scope="col" class="acc">Dagger，同一台机器<br>（dagger check）</th><th scope="col">Dagger，每次新机器<br>（dagger check）</th></tr>
</thead>
<tbody>
<tr><th scope="row">冷启动</th><td class="mono">56 s<br>lint 31 s，测试 4 s</td><td class="mono">159 s</td><td class="mono" rowspan="3">159–167 s<br>每次都是冷启动</td></tr>
<tr><th scope="row">原样再跑</th><td class="mono">27 s<br>测试 (cached)</td><td class="mono good">8.9 s<br>全部 CACHED</td></tr>
<tr><th scope="row">slug.go 加一行注释</th><td class="mono">39 s<br>测试 (cached)</td><td class="mono">11.2 s<br>测试操作重跑</td></tr>
<tr><th scope="row">只改 README.md</th><td>未单独测；workflow 不按路径过滤时整个 job 重跑</td><td class="mono good">8.7 s<br>全部 CACHED</td><td>—</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">ubuntu-latest（2 核），2026 年 10 月 25 日，Dagger v1.0.0-beta.21。Dagger 列只计 dagger check 这条命令；新机器上每次还要先加载模块，加上腾磁盘空间，整个 job 6 分半到 7 分钟。</p>
</div>
<figcaption>表 1 · 引擎活着的时候，Dagger 几乎不重复干活；每次换新机器，它比原生 GitHub Actions 慢得多</figcaption>
</figure>

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

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

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

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

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

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

### Dagger：每个操作都有摘要

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

```text
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 的执行器。）

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 388" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="dgf3-title dgf3-desc">
<title id="dgf3-title">给 slug.go 加一行注释，两边各重算了什么</title>
<desc id="dgf3-desc">上半是 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)。</desc>
<defs>
<marker id="dgf3-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="388" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="232" height="252" rx="8"/>
<rect class="dd-zone" x="264" y="16" width="224" height="252" rx="8"/>
<rect class="dd-zone" x="504" y="16" width="280" height="252" rx="8"/>
<rect class="dd-zone" x="16" y="284" width="768" height="88" rx="8"/>
<text class="t-zone" x="32" y="44">工作区</text>
<text class="t-zone" x="280" y="44">操作的输入</text>
<text class="t-zone" x="520" y="44">重算了什么</text>
<line class="dd-edge-acc" x1="232" y1="139" x2="278" y2="139" marker-end="url(#dgf3-arrow-acc)"/>
<path class="dd-edge-acc" d="M472 140 H488 Q496 140 496 132 V92 Q496 84 504 84 H518" marker-end="url(#dgf3-arrow-acc)"/>
<path class="dd-edge-acc" d="M496 140 H518" marker-end="url(#dgf3-arrow-acc)"/>
<path class="dd-edge-acc" d="M496 140 V188 Q496 196 504 196 H518" marker-end="url(#dgf3-arrow-acc)"/>
<rect class="dd-card" x="32" y="56" width="200" height="204" rx="6"/>
<path class="dd-card-head" d="M38 56 H226 Q232 56 232 62 V84 H32 V62 Q32 56 38 56 Z"/>
<text class="t-name" x="48" y="75">shortlink/</text>
<text class="t-mono" x="48" y="99">go.mod</text>
<line class="dd-hair" x1="32" y1="106" x2="232" y2="106"/>
<text class="t-mono" x="48" y="121">go.sum</text>
<line class="dd-hair" x1="32" y1="128" x2="232" y2="128"/>
<text class="t-mono" x="48" y="143"><tspan class="t-acc">slug/slug.go</tspan></text>
<text class="t-note" x="216" y="143" text-anchor="end">+1 行注释</text>
<line class="dd-hair" x1="32" y1="150" x2="232" y2="150"/>
<text class="t-mono" x="48" y="165">slug/slug_test.go</text>
<line class="dd-hair" x1="32" y1="172" x2="232" y2="172"/>
<text class="t-mono" x="48" y="187">store/store.go</text>
<line class="dd-hair" x1="32" y1="194" x2="232" y2="194"/>
<text class="t-mono" x="48" y="209">store/store_test.go</text>
<line class="dd-hair" x1="32" y1="216" x2="232" y2="216"/>
<text class="t-mono" x="48" y="231">.golangci.yml</text>
<line class="dd-hair" x1="32" y1="238" x2="232" y2="238"/>
<text class="t-mono" x="48" y="253">README.md</text>
<rect class="dd-card" x="280" y="96" width="192" height="88" rx="6"/>
<path class="dd-card-head" d="M286 96 H466 Q472 96 472 102 V124 H280 V102 Q280 96 286 96 Z"/>
<text class="t-name" x="296" y="115">Go 模块的源码目录</text>
<text class="t-mono" x="296" y="146">*.go go.mod go.sum</text>
<text class="t-mono" x="296" y="168">testdata/</text>
<text class="t-say" x="280" y="214">整个目录一个摘要，</text>
<text class="t-say" x="280" y="234">一个文件变了它就变。</text>
<text class="t-say" x="280" y="254">README.md 不在里面。</text>
<rect class="dd-card" x="520" y="60" width="248" height="48" rx="6"/>
<text class="t-mono" x="536" y="80">otelgotest ./slug</text>
<text class="t-note" x="536" y="98">重跑 · go test 输出 (cached)</text>
<rect class="dd-card" x="520" y="116" width="248" height="48" rx="6"/>
<text class="t-mono" x="536" y="136">otelgotest ./store</text>
<text class="t-note" x="536" y="154">重跑 · go test 输出 (cached)</text>
<rect class="dd-card" x="520" y="172" width="248" height="48" rx="6"/>
<text class="t-mono" x="536" y="192">golangci-lint run</text>
<text class="t-note" x="536" y="210">重跑</text>
<text class="t-say" x="520" y="250">只改 README：三个操作全部 CACHED</text>
<text class="t-zone" x="32" y="312">原生 GitHub Actions</text>
<text class="t-mono" x="32" y="338">key: setup-go-Linux-x64-ubuntu24-go-1.24.13-984ba749…（只由 go.sum 决定）</text>
<text class="t-mono" x="32" y="360">恢复第二轮存下的 23 MB → go test 自己判断：slug (cached) · store (cached)</text>
</svg>
</div>
<figcaption>图 3 · Dagger 按目录摘要判断“有没有变”，变了就重跑整个操作；更细的判断两边都交给 Go 自己的构建缓存</figcaption>
</figure>

**改 README 什么都没重跑，因为 README 不在任何一个操作的输入里。** Go 模块挂进测试容器的只有 Go 源码、`go.mod`/`go.sum` 和 `testdata/`[^dg-go]，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%[^dg-engine-gc]：剩余空间低于它，引擎就不停清理缓存。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 结束才删掉[^gha-services]。

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

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

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="dgf4-title dgf4-desc">
<title id="dgf4-title">Postgres 什么时候起，测试有没有真的连它</title>
<desc id="dgf4-desc">上半是原生 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)。</desc>
<rect class="dd-frame" width="800" height="340" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="140" rx="8"/>
<rect class="dd-zone" x="16" y="172" width="768" height="152" rx="8"/>
<text class="t-zone" x="32" y="44">原生 GitHub Actions · 第三轮，job 共 27 s</text>
<text class="t-zone" x="32" y="200">Dagger · 同一台 runner 上连续三次 dagger check</text>
<rect class="dd-track" x="32" y="64" width="728" height="28" rx="4"/>
<rect class="dd-card" x="86" y="64" width="270" height="28" rx="4"/>
<text class="t-mono" x="94" y="83">Initialize containers 10 s</text>
<rect class="dd-card" x="437" y="64" width="216" height="28" rx="4"/>
<text class="t-mono" x="445" y="83">lint 8 s</text>
<rect class="dd-card" x="653" y="64" width="54" height="28" rx="4"/>
<text class="t-mono" x="659" y="83">test</text>
<rect class="dd-derived" x="356" y="100" width="378" height="20" rx="4"/>
<text class="t-note" x="364" y="114">Postgres 健康以后一直开着，第 26 s 才删掉（虚线）</text>
<text class="t-say" x="32" y="142">go test 输出 store <tspan class="t-acc">(cached)</tspan>：测试结果是重放的，没有连数据库</text>
<text class="t-name" x="32" y="234">冷启动</text>
<text class="t-mono" x="160" y="234">5432/tcp 起来 3.1 s → otelgotest ./store 14.8 s</text>
<line class="dd-hair" x1="32" y1="248" x2="768" y2="248"/>
<text class="t-name" x="32" y="272">原样重跑</text>
<text class="t-mono" x="160" y="272">两个测试操作 CACHED，Postgres 没有启动</text>
<line class="dd-hair" x1="32" y1="286" x2="768" y2="286"/>
<text class="t-name" x="32" y="310">改 slug.go</text>
<text class="t-mono" x="160" y="310">5432/tcp 2.4 s → go test 输出 store <tspan class="t-acc">(cached)</tspan></text>
</svg>
</div>
<figcaption>图 4 · 两边都可能“起了数据库却没测”：Go 的测试缓存不知道数据库是新起的</figcaption>
</figure>

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

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

### 让集成测试每次真的跑

Go 的标准做法是 `-count=1`[^go-testcache]。原生 GitHub Actions 在 step 上设 `GOFLAGS=-count=1`；Dagger 在 Go 模块设置里加 `goflags`：

```shell
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` 的期望值改错，两边各跑一次。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" style="font-variant-ligatures:none" viewBox="0 0 800 372" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="dgf5-title dgf5-desc">
<title id="dgf5-title">同一个测试失败，两边给出的东西</title>
<desc id="dgf5-desc">左边是 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 秒。</desc>
<rect class="dd-frame" width="800" height="372" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="368" height="340" rx="8"/>
<rect class="dd-zone" x="400" y="16" width="384" height="340" rx="8"/>
<text class="t-zone" x="32" y="44">GitHub Actions · step 日志</text>
<text class="t-zone" x="416" y="44">Dagger · dagger check --progress report</text>
<rect class="dd-screen" x="32" y="60" width="336" height="200" rx="6"/>
<text class="t-small" x="44" y="82">=== RUN   TestEncodeZero</text>
<text class="t-small" x="44" y="100">    slug_test.go:7: Encode(0) = "2"</text>
<text class="t-small" x="44" y="118">--- FAIL: TestEncodeZero (0.00s)</text>
<text class="t-small" x="44" y="136">=== RUN   TestRoundTrip</text>
<text class="t-small" x="44" y="154">--- PASS: TestRoundTrip (0.00s)</text>
<text class="t-small" x="44" y="172">…</text>
<text class="t-small" x="44" y="190">FAIL  example.com/shortlink/slug  0.001s</text>
<text class="t-small" x="44" y="208">ok    example.com/shortlink/store (cached)</text>
<text class="t-small" x="44" y="226">FAIL</text>
<text class="t-small" x="44" y="244">##[error]Process completed with exit code 1.</text>
<rect class="dd-card" x="32" y="276" width="336" height="64" rx="6"/>
<text class="t-name" x="48" y="298">重跑：Re-run failed jobs</text>
<text class="t-mono" x="48" y="320">整个 native job 再来一遍 · 31 s</text>
<rect class="dd-screen" x="416" y="60" width="352" height="200" rx="6"/>
<text class="t-small" x="428" y="82">CHECKS  ✘ 1 failed  ✔ 4 passed</text>
<text class="t-small" x="428" y="100">✘ dag://?check=test&amp;go-package=slug 6.8s</text>
<text class="t-small" x="428" y="118">    ✘ slug › <tspan class="t-acc">TestEncodeZero</tspan> FAIL</text>
<text class="t-small" x="428" y="136">        slug_test.go:7: Encode(0) = "2"</text>
<text class="t-small" x="428" y="154">    ✘ 1 failed  ✔ 2 passed</text>
<text class="t-small" x="428" y="172">✔ dag://?check=golangci-lint/projects/lint</text>
<text class="t-small" x="428" y="190">✔ dag://?check=test&amp;go-package=store</text>
<text class="t-small" x="428" y="208">…</text>
<text class="t-small" x="428" y="226">RUN LOCALLY</text>
<text class="t-small" x="428" y="244">dagger check "dag://?check=test&amp;go-package=slug"</text>
<rect class="dd-card" x="416" y="276" width="352" height="64" rx="6"/>
<text class="t-name" x="432" y="298">重跑：--go-test=<tspan class="t-acc">TestEncodeZero</tspan></text>
<text class="t-mono" x="432" y="320">执行 -run '^(TestEncodeZero)$' · 5.9 s</text>
</svg>
</div>
<figcaption>图 5 · GitHub Actions 给的是一段文本和一个红色的 step；Dagger 给的是哪个测试挂了，以及一条只重跑它的命令</figcaption>
</figure>

**GitHub Actions 看到的单位是 step。** 它知道 `go test` 这个 step 退出码是 1，至于是哪个测试，要人（或者 agent）去读日志。重跑的最小单位是 job[^gha-rerun]：“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` 会在失败的那个容器状态里打开一个 shell[^dg-dev-shells]，相当于 GitHub Actions 里大家常用的 tmate 远程调试 action。这个需要交互终端，我们在 CI 里没法演示。

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

## 六、在本地跑同一套

### GitHub Actions：用 act 模拟

GitHub Actions 没有官方的本地运行方式，常用的是 act[^act]：它读 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”，每次推上去等结果、读日志、再改[^dagger-10]。

要付出的代价是：**测试必须能在容器里跑，依赖要写成模块。** 我们为 `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 status[^dg-cloud-checks]。对照图 2，这一步把“触发”“机器”“缓存”三层都从 GitHub Actions 手里接走了，GitHub 只剩下代码托管和展示 check 结果。缓存也不再随 runner 消失：开发机、agent、CI 连的是同一批引擎，用的是同一份缓存。这正好补上了第三节那个“新机器上缓存是空的”的问题。

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

### 两种计价方式

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="dgt2-title">GitHub Actions 托管 runner 与 Dagger Cloud 的计价</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="dgt2-title">
<colgroup><col style="width:22%"><col><col class="hl"></colgroup>
<thead>
<tr><th scope="col">项目</th><th scope="col">GitHub Actions 托管 runner</th><th scope="col" class="acc">Dagger Cloud</th></tr>
</thead>
<tbody>
<tr><th scope="row">按什么算</th><td>整台机器 × job 时长，按分钟向上取整</td><td>pipeline 实际用掉的 CPU、内存（来自 cgroup），以及进出的数据量</td></tr>
<tr><th scope="row">单价</th><td class="mono">Linux 2 核 $0.006/min<br>4 核 $0.012/min</td><td class="mono">CPU 0.5¢/CPU-min<br>内存 0.5¢/GB-min<br>拉入 2.5¢/GB，推出 10¢/GB</td></tr>
<tr><th scope="row">机器闲着</th><td>照算</td><td class="good">不算</td></tr>
<tr><th scope="row">重复的工作</th><td>省下的是时间，快了就少算</td><td>30 天内输入相同的操作按五分之一算，不管实际有没有命中缓存</td></tr>
<tr><th scope="row">起步</th><td>套餐内的免费分钟用完后按量</td><td class="mono">按量与套餐价取高者：$20/月起，团队 $200/月起</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">均为 2026 年 10 月的公开价格，见文末参考资料。</p>
</div>
<figcaption>表 2 · GitHub 卖的是机器时间，Dagger 卖的是实际消耗的资源</figcaption>
</figure>

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

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

最后一个问题：**除了这两个，同一个领域里还有哪些工具，它们各站在哪一层？**

## 八、同类工具的位置

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="dgt3-title">CI 领域的工具各管哪几层</div>
<div class="dt-scroll">
<table class="dt dt-sticky" aria-labelledby="dgt3-title">
<colgroup><col style="width:18%"><col style="width:22%"><col><col style="width:20%"></colgroup>
<thead>
<tr><th scope="col">工具</th><th scope="col">管哪几层</th><th scope="col">缓存的单位</th><th scope="col">本地跑同一套</th></tr>
</thead>
<tbody>
<tr class="dt-group"><th colspan="4" scope="rowgroup">CI 平台：触发 + 调度 + 机器 + 按步骤执行</th></tr>
<tr><th scope="row">Jenkins</th><td>全部，机器自己管</td><td>常驻机器的工作目录，插件各自缓存</td><td class="warn">有限（jenkinsfile-runner）</td></tr>
<tr><th scope="row">GitHub Actions</th><td>全部，托管或自托管 runner</td><td>按 key 存取的 tar 包</td><td class="warn">靠 act 模拟</td></tr>
<tr><th scope="row">GitLab CI</th><td>全部，runner 可自托管</td><td>按 key 存取的目录，可放在 runner 本地或对象存储</td><td class="warn">gitlab-runner exec 已废弃</td></tr>
<tr><th scope="row">CircleCI</th><td>全部，托管机器</td><td>按 key 存取的目录；Docker 层缓存另付费</td><td class="warn">本地 CLI 只能跑单个 job</td></tr>
<tr><th scope="row">Buildkite</th><td>触发与调度托管，机器自己出</td><td>常驻 agent 的本地磁盘</td><td class="warn">有限</td></tr>
<tr><th scope="row">Tekton / Argo Workflows</th><td>调度与执行，跑在 Kubernetes 上</td><td>挂载的卷，自己管</td><td class="warn">要一个集群</td></tr>
<tr class="dt-group"><th colspan="4" scope="rowgroup">执行引擎：把构建和测试变成可缓存的图，在任何 CI 里调用</th></tr>
<tr><th scope="row" class="acc">Dagger</th><td>执行 + 缓存；Cloud Checks 再加触发和机器</td><td>每个容器操作，按输入摘要</td><td class="good">同一条命令</td></tr>
<tr><th scope="row">Earthly</th><td>执行 + 缓存，基于 BuildKit</td><td>Earthfile 里的每一步，像 Dockerfile 的层</td><td class="good">同一条命令</td></tr>
<tr><th scope="row">Bazel</th><td>构建与测试，远程缓存和远程执行</td><td>每个 action，按输入哈希</td><td class="good">同一条命令</td></tr>
<tr><th scope="row">Nix</th><td>构建与环境</td><td>每个 derivation，按输入哈希</td><td class="good">同一条命令</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">只有 GitHub Actions、act 和 Dagger 在本文实测过，其余按官方文档整理。Earthly 已于 2025 年 7 月停止维护，社区分叉为 EarthBuild。</p>
</div>
<figcaption>表 3 · Dagger 和 Bazel、Nix 是同一类：按输入摘要缓存；和 GitHub Actions 不是同一类，直到加上 Cloud Checks</figcaption>
</figure>

这张表把开头的问题说清楚了。**Dagger 首先是一个执行引擎，和 Bazel、Nix、Earthly（已停更[^earthly]）站在一起**：把工作拆成按输入摘要缓存的小块，本地和 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` 三条命令就能看到第三节的缓存行为。

## 参考资料
