Skip to content
Published on

DaggerでCI/CDパイプラインをコードとして管理する:ローカルからCIまで同一パイプライン

Authors
  • Name
    Twitter

Daggerとは?

Daggerは、CI/CDパイプラインをプログラミング言語(Go、Python、TypeScriptなど)で記述できるオープンソースツールです。コアバリューは 「Write once, run anywhere」 — ローカルのノートPCで実行したパイプラインが、GitHub Actions、GitLab CI、Jenkinsのどこでも同一に動作します。

従来のCI/CDの問題点

  • YAML地獄: 複雑なパイプラインをYAMLで表現すると、可読性と再利用性が低下する
  • ローカルテスト不可: CIでのみ実行可能なため、フィードバックループが長くなる
  • ベンダーロックイン: GitHub ActionsとGitLab CIの文法が完全に異なる
  • デバッグの困難さ: CI環境をローカルに再現することが難しい

Daggerの解決策

ローカル開発 ──→ Dagger Engine ──→ 同一の結果
GitHub Actions ──→ Dagger Engine ──→ 同一の結果
GitLab CI ──→ Dagger Engine ──→ 同一の結果

Dagger Engineはすべてのタスクをコンテナ内で実行するため、環境の差異がありません。

インストールとプロジェクト初期化

Dagger CLIのインストール

# macOS
brew install dagger/tap/dagger

# Linux
curl -fsSL https://dl.dagger.io/dagger/install.sh | sh

# バージョン確認
dagger version

プロジェクト初期化

# Go SDKを使用
mkdir my-pipeline && cd my-pipeline
dagger init --sdk=go --name=my-pipeline

# 生成されるファイル構造
# ├── dagger.json
# ├── main.go
# └── go.mod

最初のパイプライン:Goプロジェクトのビルド

main.goの作成

package main

import (
    "context"
    "dagger/my-pipeline/internal/dagger"
)

type MyPipeline struct{}

// Build関数:Goプロジェクトをビルドします
func (m *MyPipeline) Build(ctx context.Context, source *dagger.Directory) *dagger.Container {
    // Goビルド環境の設定
    builder := dag.Container().
        From("golang:1.22-alpine").
        WithDirectory("/src", source).
        WithWorkdir("/src").
        WithEnvVariable("CGO_ENABLED", "0").
        WithExec([]string{"go", "mod", "download"}).
        WithExec([]string{"go", "build", "-o", "/app", "./cmd/server"})

    // 軽量ランタイムイメージ
    return dag.Container().
        From("alpine:3.19").
        WithFile("/app", builder.File("/app")).
        WithEntrypoint([]string{"/app"})
}

// Test関数:テスト実行
func (m *MyPipeline) Test(ctx context.Context, source *dagger.Directory) (string, error) {
    return dag.Container().
        From("golang:1.22-alpine").
        WithDirectory("/src", source).
        WithWorkdir("/src").
        WithExec([]string{"go", "mod", "download"}).
        WithExec([]string{"go", "test", "-v", "-race", "./..."}).
        Stdout(ctx)
}

// Lint関数:golangci-lintを実行
func (m *MyPipeline) Lint(ctx context.Context, source *dagger.Directory) (string, error) {
    return dag.Container().
        From("golangci/golangci-lint:v1.57").
        WithDirectory("/src", source).
        WithWorkdir("/src").
        WithExec([]string{"golangci-lint", "run", "--timeout", "5m"}).
        Stdout(ctx)
}

ローカル実行

# ビルド
dagger call build --source=.

# テスト
dagger call test --source=.

# リント
dagger call lint --source=.

# ビルド結果をDockerイメージとしてエクスポート
dagger call build --source=. export --path=./image.tar

Python SDKでパイプラインを作成

# dagger/src/main/__init__.py
import dagger
from dagger import dag, function, object_type

@object_type
class MyPipeline:
    @function
    async def test(self, source: dagger.Directory) -> str:
        """Pythonプロジェクトのテスト"""
        return await (
            dag.container()
            .from_("python:3.12-slim")
            .with_directory("/src", source)
            .with_workdir("/src")
            .with_exec(["pip", "install", "-r", "requirements.txt"])
            .with_exec(["pip", "install", "pytest", "pytest-cov"])
            .with_exec(["pytest", "-v", "--cov=app", "--cov-report=term-missing"])
            .stdout()
        )

    @function
    async def build(self, source: dagger.Directory) -> dagger.Container:
        """Dockerイメージのビルド"""
        # 依存関係のキャッシュ
        pip_cache = dag.cache_volume("pip-cache")

        return (
            dag.container()
            .from_("python:3.12-slim")
            .with_mounted_cache("/root/.cache/pip", pip_cache)
            .with_directory("/app", source)
            .with_workdir("/app")
            .with_exec(["pip", "install", "--no-cache-dir", "-r", "requirements.txt"])
            .with_entrypoint(["python", "main.py"])
        )

    @function
    async def publish(
        self,
        source: dagger.Directory,
        registry: str = "ghcr.io",
        image_name: str = "my-app",
        tag: str = "latest",
        registry_user: dagger.Secret | None = None,
        registry_pass: dagger.Secret | None = None,
    ) -> str:
        """イメージのビルドとレジストリへのプッシュ"""
        container = await self.build(source)

        if registry_user and registry_pass:
            container = container.with_registry_auth(
                registry,
                await registry_user.plaintext(),
                registry_pass,
            )

        ref = f"{registry}/{image_name}:{tag}"
        digest = await container.publish(ref)
        return digest

キャッシュ戦略

Daggerのキャッシュはビルド速度を劇的に改善します:

func (m *MyPipeline) BuildWithCache(ctx context.Context, source *dagger.Directory) *dagger.Container {
    // Goモジュールキャッシュ
    goModCache := dag.CacheVolume("go-mod-cache")
    goBuildCache := dag.CacheVolume("go-build-cache")

    return dag.Container().
        From("golang:1.22-alpine").
        WithMountedCache("/go/pkg/mod", goModCache).
        WithMountedCache("/root/.cache/go-build", goBuildCache).
        WithDirectory("/src", source).
        WithWorkdir("/src").
        WithExec([]string{"go", "build", "-o", "/app", "./cmd/server"})
}

キャッシュ効果の比較

初回ビルド:      230秒(依存関係のダウンロードを含む)
2回目のビルド:   15秒(キャッシュヒット)
ソースのみ変更:  20秒(依存関係キャッシュを再利用)

シークレット管理

func (m *MyPipeline) Deploy(
    ctx context.Context,
    source *dagger.Directory,
    kubeconfig *dagger.Secret,
    registryToken *dagger.Secret,
) (string, error) {
    // シークレットはログに露出されない
    return dag.Container().
        From("bitnami/kubectl:latest").
        WithMountedSecret("/root/.kube/config", kubeconfig).
        WithSecretVariable("REGISTRY_TOKEN", registryToken).
        WithDirectory("/manifests", source.Directory("k8s")).
        WithExec([]string{"kubectl", "apply", "-f", "/manifests/"}).
        Stdout(ctx)
}
# ローカル実行時のシークレット受け渡し
dagger call deploy \
  --source=. \
  --kubeconfig=file:$HOME/.kube/config \
  --registry-token=env:REGISTRY_TOKEN

CI統合

GitHub Actions

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dagger/dagger-for-github@v6
        with:
          version: 'latest'
          verb: call
          args: test --source=.
      - uses: dagger/dagger-for-github@v6
        with:
          version: 'latest'
          verb: call
          args: lint --source=.
      - uses: dagger/dagger-for-github@v6
        with:
          version: 'latest'
          verb: call
          args: build --source=.

GitLab CI

# .gitlab-ci.yml
stages:
  - test
  - build

test:
  stage: test
  image: registry.dagger.io/engine:latest
  services:
    - docker:dind
  script:
    - dagger call test --source=.

build:
  stage: build
  image: registry.dagger.io/engine:latest
  services:
    - docker:dind
  script:
    - dagger call build --source=. export --path=image.tar
  artifacts:
    paths:
      - image.tar

マルチプラットフォームビルド

func (m *MyPipeline) BuildMultiPlatform(
    ctx context.Context,
    source *dagger.Directory,
) (string, error) {
    platforms := []dagger.Platform{
        "linux/amd64",
        "linux/arm64",
    }

    platformVariants := make([]*dagger.Container, len(platforms))
    for i, platform := range platforms {
        platformVariants[i] = dag.Container(dagger.ContainerOpts{Platform: platform}).
            From("golang:1.22-alpine").
            WithDirectory("/src", source).
            WithWorkdir("/src").
            WithEnvVariable("CGO_ENABLED", "0").
            WithExec([]string{"go", "build", "-o", "/app", "./cmd/server"})
    }

    // マルチプラットフォームイメージのプッシュ
    digest, err := dag.Container().
        Publish(ctx, "ghcr.io/my-org/my-app:latest",
            dagger.ContainerPublishOpts{
                PlatformVariants: platformVariants,
            })

    return digest, err
}

統合パイプライン:フルCI/CD

func (m *MyPipeline) CI(ctx context.Context, source *dagger.Directory) error {
    // 1. リント
    _, err := m.Lint(ctx, source)
    if err != nil {
        return fmt.Errorf("lint failed: %w", err)
    }
    fmt.Println("✅ Lint passed")

    // 2. テスト
    _, err = m.Test(ctx, source)
    if err != nil {
        return fmt.Errorf("test failed: %w", err)
    }
    fmt.Println("✅ Tests passed")

    // 3. ビルド
    container := m.Build(ctx, source)
    _, err = container.Sync(ctx)
    if err != nil {
        return fmt.Errorf("build failed: %w", err)
    }
    fmt.Println("✅ Build succeeded")

    return nil
}

Dagger vs 従来のCIツール比較

特性GitHub ActionsGitLab CIDagger
パイプライン定義YAMLYAMLGo/Python/TS
ローカル実行act(制限あり)不可完全サポート
デバッグ困難困難IDEデバッガを使用可能
ベンダーロックインGitHubGitLabなし
キャッシュ手動設定手動設定自動(コンテンツベース)
再利用性マーケットプレイステンプレートパッケージ/モジュール

トラブルシューティング

よくある問題と解決方法

# Dagger Engineの状態確認
docker ps | grep dagger-engine

# キャッシュの初期化
dagger query <<< '{ defaultPlatform }'

# 詳細ログ出力
dagger call --debug test --source=.

# 特定のステップでシェルにアクセス(デバッグ用)
dagger call build --source=. terminal

確認クイズ(5問)

Q1. 従来のCI/CDツールに対するDaggerの最大の利点は?

ローカルとCI環境で同一のパイプラインを実行でき、プログラミング言語でパイプラインを記述するため、IDEサポート、型安全性、デバッグが可能です。

Q2. Daggerでキャッシュを実装する方法は?

dag.CacheVolume()でキャッシュボリュームを作成し、WithMountedCache()でコンテナにマウントします。コンテンツベースで自動キャッシュされます。

Q3. Daggerでシークレットを安全に受け渡す方法は?

関数パラメータとしてdagger.Secret型を受け取り、WithMountedSecretやWithSecretVariableでコンテナに渡します。ログには露出されません。

Q4. GitHub ActionsでDaggerを使用するにはどのアクションを使いますか?

dagger/dagger-for-github@v6アクションを使用します。

Q5. Daggerでマルチプラットフォームビルドを行うにはどのオプションを使いますか?

dagger.ContainerOptsのPlatformフィールドとPublishのPlatformVariantsオプションを使用します。