一排每次都重跑的步驟,和一張大多已快取的圖 左邊是兩段配置: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 三條命令就能看到第三節的快取行為。

參考資料

參考資料

  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 ↩