Dagger 实战:用流水线即代码统一本地与 CI 的构建

Dagger 是一个可移植的 CI/CD 引擎,它把流水线即代码(Pipeline as Code)真正落地:你用 Python、Go 或 TypeScript 写一个 Module,在里面定义若干 Function,Dagger 引擎会在容器里逐步执行并自动构建出一张有向无环图(DAG)。它最大的价值是——同一份流水线代码,本地能跑、CI 里也能跑,彻底消灭”本地能跑、CI 就挂”的环境差,让构建、测试、打镜像在每台机器上行为完全一致。

一、为什么需要 Dagger:CI 与本地的一致性困境

传统 CI 有两个老毛病。第一,语法碎片化:GitHub Actions 写 YAML、GitLab CI 写另套 YAML、Jenkins 又是 Groovy,换平台几乎重写;第二,调试靠猜:流水线只能在云端排队跑,本地复现不了,一次红就得再等十几分钟。即便用 Makefile + docker-compose 凑合,也难保证”云端容器”和”我笔记本的容器”是同一套依赖。

Dagger 的思路很干脆:把每一步都跑在标准化的 OCI 容器里,流水线本身用普通代码写,由统一的 Dagger 引擎调度。于是本地执行和云端执行走的是同一条代码路径,差异被天然抹平。如果你已经在用 GitHub Actions 搭建 CI/CD 流水线,Dagger 可以成为它里面”真正干活”的那一层。

举个真实例子:本地用 Go 1.22 编译通过,CI 镜像却是 golang:1.21,依赖了新语法直接报错;或者本地 macOS 上跑通的 Shell 脚本,在 CI 的 Ubuntu 里因为某个 GNU 工具参数不同行为迥异。这类问题在 Dagger 里几乎不会发生,因为编译步显式写了 golang:1.22,本地和 CI 拉的是同一个镜像摘要,连底层 glibc 版本都锁死。

二、Dagger 核心概念:Engine、Module、Function

动手前先理清三个名词:

  • Engine(引擎):一个常驻容器,负责把你的流水线编译成 DAG、调度每一步、管理缓存。本地和 CI 都连同一个引擎。
  • Module(模块):一个代码包,声明式地描述你能”调用什么”。每个项目通常一个 Module,对应一个 dagger.json。
  • Function(函数):Module 里暴露的可调用单元,比如 build、test、publish,用普通方法定义,参数和返回值都是强类型。

每一步操作(拉镜像、拷贝目录、执行命令)都会生成一个新的容器层,Dagger 据此自动推断依赖关系并并行执行无依赖的步骤,这也是它比线性 Shell 脚本快的原因。

三、快速上手:安装与初始化模块

只需一个二进制,无需在仓库里塞一堆 CI 配置:

# 安装 Dagger CLI(会自动下载引擎)
curl -fsSL https://dl.dagger.io/install.sh | sh
sudo mv ./bin/dagger /usr/local/bin/

# 验证
dagger version

# 在项目根目录初始化一个 Python SDK 模块
dagger init --name my-ci --sdk python

执行后生成 dagger.json 与 ci/main.py 骨架。dagger.json 内容非常简单:

{
  "name": "my-ci",
  "sdk": "python",
  "source": "ci"
}

四、用 Python SDK 编写第一条流水线

编辑 ci/main.py,用 @object_type 定义模块、用 @function 暴露函数。下面以最常见的 Go 服务为例,给出编译与测试两个 Function:

import dagger
from dagger import function, object_type


@object_type
class Ci:
    @function
    async def build(self, source: dagger.Directory) -> dagger.Container:
        """编译 Go 程序,返回包含产物的容器"""
        return (
            dag.container()
            .from_("golang:1.22")
            .with_directory("/src", source)
            .with_workdir("/src")
            .with_exec(["go", "mod", "download"])
            .with_exec(["go", "build", "-o", "/bin/app", "."])
        )

    @function
    async def test(self, source: dagger.Directory) -> str:
        """运行单元测试,返回标准输出"""
        return await (
            dag.container()
            .from_("golang:1.22")
            .with_directory("/src", source)
            .with_workdir("/src")
            .with_exec(["go", "test", "./...", "-race", "-v"])
            .stdout()
        )

关键点:source: dagger.Directory 是调用方传进来的源码目录;dag.container() 每次都从一个干净的基础镜像开始,保证可复现;with_exec 的返回值是新容器,链式调用天然形成 DAG。构建环境用容器隔离,和 Docker Compose 一键编排 的思路一脉相承。

五、本地与 CI 跑同一行命令

本地调试时,直接把当前目录作为 source 传入:

# 编译并导出二进制到本地 ./dist
dagger -m ./ci call build --source . --out ./dist

# 跑测试,输出直接打到终端
dagger -m ./ci call test --source .

注意:这条命令在笔记本上和在任何 CI 里完全一致。你不用再为了”CI 挂了”而登服务器、翻日志,本地就能 100% 还原整条流水线。配合 VS Code 插件 还能在编辑器里直接调 Function,调试体验远超 YAML。

六、接入 GitHub Actions:实质只换一层壳

CI 平台只负责”拉代码 + 调 Dagger”,真正的逻辑全在 Module 里。把 CI 文件缩减到极简:

name: ci
on: [push]
jobs:
  dagger:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Dagger pipeline
        uses: dagger/dagger-for-github@v6
        with:
          version: "0.13"
          module: ci
          args: call test --source .
          cloud-token: ${{ secrets.DAGGER_CLOUD_TOKEN }}

如果团队用的是 GitLab,只需把上面的 action 换成一行 dagger call 即可,Module 代码一行都不用改——这正是 Dagger 可移植性的体现,详见 GitLab CI/CD 实战。部署侧可以继续交给 ArgoCD 做 GitOps 持续部署,Dagger 专注”构建到镜像”,职责清晰。

七、发布镜像与缓存加速

再加一个 publish Function,把构建结果直接推到镜像仓库:

    @function
    async def publish(self, source: dagger.Directory, registry: str, tag: str) -> str:
        """构建并推送镜像,返回可读地址"""
        return await (
            self.build(source)
            .with_entrypoint(["/bin/app"])
            .publish(f"{registry}:{tag}")
        )

调用:dagger -m ./ci call publish --source . --registry registry.example.com/myapp --tag v1.0.0。推送凭证通过 --docker-cfg 或 --secret 注入,避免明文写在代码里。

速度方面,Dagger 默认会缓存每一步容器层;接入 Dagger Cloud(设置环境变量 DAGGER_CLOUD_TOKEN)后还能跨机器、跨流水线共享远程缓存,多次构建只重跑真正变化的步骤,CI 时长往往能砍掉一半以上。

八、Dagger 与传统 CI 的取舍

维度传统 CI YAMLMake + docker-composeDagger
本地/云端一致性弱(环境难复现)中(依赖本机 Docker)强(同引擎同容器)
跨平台移植弱(各家语法不同)强强
类型安全与补全无无有(SDK 强类型)
调试体验差(靠云端重试)中好(本地直接调)
缓存加速需自己配手动内置 + 远程缓存

九、生产落地检查清单

检查项建议
Module 代码入库dagger.json 与 SDK 代码随业务仓库版本化
密钥管理用 --secret / Dagger Cloud Secret,禁止明文
缓存CI 设置 DAGGER_CLOUD_TOKEN 开启远程缓存
固定引擎版本CI 与本地锁定同一 version,防漂移
失败可见性让 Dagger 返回退出码,CI 才能正确标红
前端项目复用构建链路可复用 前端构建工具演进 里的 Vite/Rspack 经验

十、常见误区与排障

落地时最容易踩的坑集中在四处,提前规避能省下大量排查时间:

现象根因解决
本地能跑、CI 报找不到 DockerCI 运行器未暴露 Docker socket用官方 Dagger 动作或手动挂载 /var/run/docker.sock
推送镜像 401 / 403仓库凭证未注入用 --secret 或 Dagger Cloud Secret 传 docker-cfg
缓存不命中、每次全量构建未设置 DAGGER_CLOUD_TOKENCI 与本地同时配置远程缓存令牌
某步卡住无日志容器未输出到 stdout加 dagger call --debug 看引擎执行详情

调试时优先用 dagger call --debug 打开引擎日志,再用 dagger shell 进入交互式会话逐步执行 Function,能精确定位是哪一步的容器拉不起来或命令失败。网络方面,Dagger 引擎本身通过本地 Docker 运行,只要宿主机能拉镜像,流水线就能跑,无需额外放行端口;这也意味着它和 Docker Compose 编排 一样,对网络环境的要求极低。

十一、结语

Dagger 不是要取代 GitHub Actions 或 GitLab CI,而是把”真正易变、最容易出环境问题的构建逻辑”收进一份可测试、可移植的代码里。当你能在笔记本上用一条 dagger call 跑完整条流水线,再把它原样塞进任意 CI,那种”改完立刻知道对错”的顺畅感,会让你再也回不去手写 YAML 的日子。对于追求交付确定性的团队,Dagger 值得作为流水线即代码的首选底座。

上一篇 External Secrets Operator 实战:把云厂商密钥同步进 Kubernetes
下一篇 AI 视频生成 2026:可灵、即梦、Sora 怎么选
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]