CI/CD 管道问题不在于缺乏工具。问题在于你的管道只在一个地方运行——GitHub Actions、GitLab CI 或 Jenkins——并且配置与该平台紧密耦合。当您需要在本地运行相同的步骤时,您需要编写脚本来实现。当您切换服务提供商时,您需要重写所有内容。Dagger.io 提出了不同的模型:使用真正的编程语言将您的管道定义为代码,在任何存在容器运行环境的地方以相同方式运行它。
经过从独立开发者到工程组织的企业团队两年多的生产使用,Dagger 已经积累了足够多的实际应用模式可以进行诚实评估。本文涵盖其架构、实际应用、真正的优势以及增加的复杂性不值得使用的情况。
Dagger 解决的核心问题
基于 YAML 的 CI/CD 有一个根本局限:它不是一种编程语言。您无法轻松抽象重复的模式,为管道逻辑编写单元测试,使用类型检查,或导入库。复杂的管道变成了数百行的 YAML,其中包含嵌套的条件判断和嵌入在字符串中的 shell 脚本。
变通方法——可重用工作流、共享操作、管道模板——有所帮助,但没有解决问题。管道仍然只在 CI 环境中运行。本地调试意味着要么提交代码来触发 CI,要么使用 Docker 和 shell 脚本仔细重建环境。
Dagger 的解决方案是将管道视为应用程序。您可以用 Go、Python、TypeScript 或 PHP 编写它。您使用 dagger call 在本地运行它。同样的调用可以在 GitHub Actions、GitLab CI、CircleCI 或任何能够运行容器的环境中工作。管道是可移植的,因为 Dagger 通过自己的容器引擎标准化了执行过程。
Dagger 的架构工作原理
Dagger 运行一个本地容器引擎(Dagger Engine),它处理所有管道执行。您的管道代码通过 Unix socket 上的 GraphQL API 与引擎通信。当您用 Go 编写 Dagger 函数时,您正在构建一个客户端,该客户端构建操作的 DAG(有向无环图)并将其提交给引擎。
引擎负责:
- 每一层的缓存——不仅是 Docker 层缓存,还包括任何管道步骤输出的完整内容寻址缓存
- 自动独立 DAG 分支的并行执行
- 密钥管理——密钥被注入到引擎中,永远不会写入磁盘或日志
- 跨平台执行——相同的管道在 Linux、macOS 和 CI 中运行
这与”在 CI 中运行 Docker 容器”有本质区别。Dagger 引擎了解整个 DAG,可以进行全局优化决策——并行化步骤、在不相关的管道运行之间共享缓存层、传播密钥而不暴露它们。
用 Go 编写真实流水线
Dagger 模块是一个导出函数的 Go(或 Python/TypeScript)包。以下是一个针对 Go Web 服务的完整、真实的流水线:
// main.go — Go 服务的 Dagger 流水线
package main
import (
"context"
"dagger.io/dagger"
"fmt"
)
type Pipeline struct{}
// Test 运行所有单元测试和集成测试
func (p *Pipeline) Test(ctx context.Context, src *dagger.Directory) (string, error) {
client, err := dagger.Connect(ctx)
if err != nil {
return "", err
}
defer client.Close()
return client.Container().
From("golang:1.22-alpine").
WithDirectory("/src", src).
WithWorkdir("/src").
WithMountedCache("/root/go/pkg/mod", client.CacheVolume("go-mod")).
WithMountedCache("/root/.cache/go-build", client.CacheVolume("go-build")).
WithExec([]string{"go", "test", "./...", "-race", "-count=1"}).
Stdout(ctx)
}
// Build 编译二进制文件并将其打包到最小镜像中
func (p *Pipeline) Build(ctx context.Context, src *dagger.Directory) (*dagger.Container, error) {
client, err := dagger.Connect(ctx)
if err != nil {
return nil, err
}
defer client.Close()
// 构建阶段
binary := client.Container().
From("golang:1.22-alpine").
WithDirectory("/src", src).
WithWorkdir("/src").
WithMountedCache("/root/go/pkg/mod", client.CacheVolume("go-mod")).
WithMountedCache("/root/.cache/go-build", client.CacheVolume("go-build")).
WithEnvVariable("CGO_ENABLED", "0").
WithEnvVariable("GOOS", "linux").
WithExec([]string{"go", "build", "-ldflags=-s -w", "-o", "/out/server", "./cmd/server"}).
File("/out/server")
// 运行时镜像 — 使用 distroless 以最小化攻击面
return client.Container().
From("gcr.io/distroless/static-debian12:nonroot").
WithFile("/server", binary).
WithEntrypoint([]string{"/server"}), nil
}
// Publish 构建、测试并将镜像推送到注册表
func (p *Pipeline) Publish(
ctx context.Context,
src *dagger.Directory,
registry string,
tag string,
registryUser string,
registryToken *dagger.Secret,
) (string, error) {
client, err := dagger.Connect(ctx)
if err != nil {
return "", err
}
defer client.Close()
// 先测试
_, err = p.Test(ctx, src)
if err != nil {
return "", fmt.Errorf("测试失败: %w", err)
}
image, err := p.Build(ctx, src)
if err != nil {
return "", err
}
ref := fmt.Sprintf("%s:%s", registry, tag)
return image.
WithRegistryAuth(registry, registryUser, registryToken).
Publish(ctx, ref)
}
在本地运行:
# 在本地运行测试 — 与 CI 相同
dagger call test --src=.
# 使用密钥令牌构建并推送到注册表
dagger call publish
--src=.
--registry=ghcr.io/myorg/myapp
--tag=latest
--registry-user=myuser
--registry-token=env:REGISTRY_TOKEN
与 GitHub Actions 集成
CI 集成是故意最小化的。Dagger 处理实际工作;CI 系统只是触发它:
name: CI
on: [push, pull_request]
jobs:
pipeline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 安装 Dagger CLI
run: curl -L https://dl.dagger.io/dagger/install.sh | sh
- name: 运行测试
run: dagger call test --src=.
- name: 构建并发布
if: github.ref == 'refs/heads/main'
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
dagger call publish
--src=.
--registry=ghcr.io/${{ github.repository }}
--tag=${{ github.sha }}
--registry-user=${{ github.actor }}
--registry-token=env:REGISTRY_TOKEN
相同的 dagger call 命令在 GitLab CI、CircleCI 或本地终端中工作方式完全相同。GitHub Actions 文件本质上只是一个传递凭据的触发器。
缓存优势
Dagger 的缓存是其最被低估的功能之一。缓存卷是内容寻址的,并且在流水线运行之间保持持久。上面示例中的 Go 模块缓存(go-mod 卷)在该机器上的每次运行之间都会保持持久。在 CI 中,这意味着具有未更改依赖项的流水线第二次运行会在不到一秒内完成依赖项下载阶段。
更重要的是,缓存在分支之间共享。如果你的主分支和功能分支都依赖于相同的依赖项,它们会共享缓存。使用 Docker 或 GitHub Actions 缓存操作中的传统层缓存,缓存通常是每个分支的。
# 命名缓存卷 — 在此引擎的所有流水线运行中共享
client.CacheVolume("go-mod") // Go 模块
client.CacheVolume("node-modules") // npm/yarn 包
client.CacheVolume("gradle-cache") // Gradle 构建缓存
client.CacheVolume("cargo-registry") // Rust crate 注册表
Dagger Cloud:跨机器共享缓存
Dagger Cloud 的免费层提供了分布式缓存共享。在一个 CI 运行器上写入的缓存条目对所有其他运行器都可用。这为水平扩展的 CI 工人解决了冷缓存问题——相比 GitHub Actions 的缓存操作(每个仓库有限制),这是一个显著的改进。
# 设置 DAGGER_CLOUD_TOKEN 环境变量,缓存会自动共享
export DAGGER_CLOUD_TOKEN=dag_token_xxxxx
# 现在所有机器上的流水线运行都会共享缓存
dagger call test --src=.
何时值得投资 Dagger
Dagger 添加了一层抽象和学习曲线。在以下情况下,它值得投资:
- 流水线复杂度高 — 超过 50 行 YAML,复杂的条件逻辑,多个可重用组件
- 本地调试困难 — 你的团队经常推送”修复 CI”的提交,因为流水线只在 CI 中运行
- 多平台流水线 — 相同的步骤需要在不同的 CI 系统上运行(例如,内部 Jenkins 和 GitHub Actions)
- 你使用 Go、Python 或 TypeScript — 并希望使用真正的编程结构来编写流水线逻辑
何时标准 YAML CI 仍然更好
对于简单项目,Dagger 带来了不成比例的摩擦,而没有相应的收益:
- 一个只有 20 行 GitHub Actions YAML 和三个步骤(测试、构建、部署)的小项目,重写为 Dagger 模块并不会带来好处
- 不熟悉 Dagger 模型的团队将面临更陡峭的上手曲线
- 如果你不需要在本地运行流水线步骤,那么可移植性的优势就无关紧要
实际采用策略
采用 Dagger 最有效的方式是渐进式:
- 从你最痛苦的流水线部分开始 — 那些最难在本地运行或在 CI 中最脆弱的步骤
- 将该步骤提取为 Dagger 函数,并从你现有的 YAML CI 文件中调用它
- 验证它在本地和 CI 中以相同的方式工作
- 随着价值的显现,扩展到相邻的步骤
你不需要一次性重写整个流水线。Dagger 的设计目的是与现有的 CI 配置共存,而不是要求完全迁移。
结论
Dagger 解决了一个真实的问题:本地开发和 CI 执行之间的差距,以及 YAML 作为复杂流水线编程语言的局限性。该架构在技术上很合理 — 一个容器原生的执行引擎,具有内容寻址缓存和真正的语言 API,相比嵌入 YAML 的 shell 脚本是一个显著的改进。
权衡之处在于复杂性。采用 Dagger 需要学习 SDK,理解引擎模型,并接受你的流水线现在已成为应用程序代码,需要与其他任何代码相同的维护。对于流水线已变得令人痛苦的团队来说,这种权衡显然是值得的。对于具有简单 CI 需求的简单项目,标准的 YAML 工作流仍然是务实的选择。
可编程 CI 基础设施的趋势已十分明显。Dagger 是这种方法最成熟的实现,随着流水线复杂性的增长,对它的学习投入会带来回报。
