DaggerによるクロスプラットフォームCI/CD:YAMLを超えたプログラマブルなビルド&テストワークフロー

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

壊れやすいYAMLワークフローがもたらすフラストレーション

多くのDevOpsエンジニアは、「git pushして祈る」作業に午後いっぱい費やした経験があるはずです。コミットし、リモートランナーがジョブを取得するのを5分待ち、タイポで失敗するのを眺めては、また繰り返す。コミット履歴はあっという間にfix: trigger pipeline、fix: missing quote in step 4、debug ci again (final)といったメッセージで埋め尽くされます。

従来のCIシステムは、肥大化しやすい宣言型YAMLファイルの使用を強いてきます。最初はシンプルな20行の設定から始まっても、文字列補間されたBashコマンド、ベンダー独自の構文、壊れやすいグルーコードが詰め込まれた600行の怪物へとあっという間に膨れ上がってしまいます。

このYAML中心のアプローチは、2つの根深いボトルネックを生み出します。

  • ローカルでの再現性が皆無:GitHub Actions、GitLab CI、CircleCIなどのステップを、ローカルの手元環境で本番とまったく同じように実行することはできません。actなどのツールもありますが、本番ランナーとは挙動が異なるケースが多々あります。ほんのわずかな修正であっても、リモートへプッシュして結果を待つ往復作業が必要になります。
  • ベンダーロックイン:パイプラインをGitLabからGitHub ActionsやAWS CodeBuildへ移行する場合、通常はワークフローを一から書き直す必要があります。この移行には何週間ものエンジニアリング工数が奪われ、設定の乖離(ドリフト)を招く原因にもなります。

解決策はシンプルです。パイプラインも通常のソフトウェアと同じように扱うことです。Daggerは、Python、Go、TypeScriptといった標準的なプログラミング言語で自動化ワークフローを定義できるようにすることで、これを実現します。

Daggerがデリバリーロジックを再設計する仕組み

Daggerは、最新のDockerビルドを支える実績あるエンジン「Moby BuildKit」の上に構築されたプログラマブルなCI/CDエンジンです。静的なYAML定義をパースする代わりに、Daggerは型定義されたGraphQL APIで駆動する組み込みエンジンを提供します。開発者はお好みの言語のクライアントSDKを通じて、このエンジンとやり取りします。

ファーストクラスのソフトウェアオブジェクトとしてのコンテナ

Daggerにおいて、コンテナは単なるランタイム環境ではありません。コード内の不変(イミュータブル)なオブジェクトです。ベースイメージのインスタンス化、ディレクトリのマウント、環境変数の設定、操作のチェーン(連結)を実行できます。それぞれの操作は新しいコンテナ参照を返すため、パイプラインの作成はストレートな関数型プログラミングになります。

宣言型DAGと自動キャッシング

BuildKitは、関数呼び出しを自動的に有向非巡回グラフ(DAG)にコンパイルします。変更のないnode_modulesディレクトリや静的バイナリ依存関係など、中間の操作に変更がない場合、Daggerは実行を完全にスキップしてキャッシュされたレイヤーを再利用します。コールド状態のランナーで8分かかるビルドも、一度キャッシュされれば、ノートPC上でもリモートランナー内でも35秒未満で完了します。

統一されたツールエコシステム

チームがすでにPythonやTypeScriptを習得しているのに、なぜ独自のCIドメイン固有言語(DSL)を学ばせる必要があるのでしょうか。Daggerを使えば、開発者は既存のIDEツールをそのまま活用できます。コード補完、リンティング、型安全性、モジュール分割、標準的なユニットテストフレームワークなどが最初から手に入ります。

実践:PythonとDaggerでサービスをビルド&テストする

それでは、Dagger Python SDKを使用して、Node.js TypeScriptプロジェクトの再現可能なパイプラインを構築してみましょう。このパイプラインは依存関係を隔離し、テストスイートを実行し、本番用ビルド成果物を抽出します。

1. ローカル環境のセットアップ

マシン上でDockerまたは互換性のあるコンテナランタイム(OrbStackやPodmanなど)が実行されていることを確認してください。クリーンな作業ディレクトリを作成し、SDKをインストールします。

mkdir dagger-pipeline && cd dagger-pipeline
python3 -m venv .venv
source .venv/bin/activate
pip install dagger-io anyio

2. パイプラインスクリプトの作成

ci.pyという名前のファイルを作成します。ソースコードを取得し、パッケージをインストールし、テストを実行して、コンパイルされた配布フォルダをエクスポートする非同期ワークフローを定義します。

import sys
import anyio
import dagger

async def main():
    # Daggerエンジンへの接続を初期化
    async with dagger.connection(dagger.Config(log_output=sys.stderr)) as client:
        # ビルドコンテキストを軽量に保つため、ローカルの不要なファイルを除外
        src = client.host().directory(".", exclude=[".venv", "node_modules", ".git", "build-artifacts"])

        # 1. ベースとなるNode.js環境の準備
        node = (
            client.container()
            .from_("node:20-alpine")
            .with_directory("/app", src)
            .with_workdir("/app")
        )

        # 2. 依存関係のインストール
        installed = node.with_exec(["npm", "ci"])

        # 3. ユニットテストの実行
        print("==> テストスイートを実行中...")
        test_runner = installed.with_exec(["npm", "test"])
        test_output = await test_runner.stdout()
        print(test_output)

        # 4. 本番用バンドルの生成とホストへのエクスポート
        print("==> 本番用バンドルを生成中...")
        built = installed.with_exec(["npm", "run", "build"])
        dist_dir = built.directory("/app/dist")

        await dist_dir.export("./build-artifacts")
        print("ビルド成果物が ./build-artifacts に正常に出力されました")

if __name__ == "__main__":
    anyio.run(main)

3. ローカルでの実行とデバッグ

ターミナルからパイプラインを実行します。

python ci.py

DaggerはBuildKitに接続し、隔離されたコンテナ内ですべてのステップを実行し、コンパイルされた成果物を./build-artifactsへ直接書き込みます。テストが失敗した場合でも、完全なスタックトレースを含む出力がターミナルに即座に表示されます。共有サイクルのクラウドランナーでキュー待ちをする必要はありません。

4. GitHub Actionsへの組み込み

ビジネスロジックは通常のコードとして記述されているため、CIプロバイダー側はPythonを呼び出すだけで済みます。以下が.github/workflows/ci.ymlファイルの全体です。

name: Continuous Integration

on:
  push:
    branches: [main]
  pull_request:

jobs:
  pipeline:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install Dagger SDK
        run: pip install dagger-io anyio

      - name: Execute Pipeline
        run: python ci.py

まったく同じワークフローをGitLab CIや社内のJenkinsエージェントで動かしたい場合も、Dockerが動作する任意のランナーでpython ci.pyを実行するだけです。実行ロジックは1行たりとも変更する必要がありません。

本番運用におけるベストプラクティス

実験段階から本番ワークロードへと移行するには、いくつかの設計上の指針が必要です。

  • ビルドコンテキストの削減:host().directory()では常に明示的な除外設定を行ってください。2GBにも及ぶnode_modulesフォルダや生の.git履歴をエンジンにアップロードすると、実行速度が低下し、ディスクI/Oを無駄に消費します。
  • Pytestによるパイプラインロジックのテスト:基本的な設定パラメータを検証するだけであれば、稼働中のDockerデーモンは不要です。引数パーサー、ビルドマトリックスルール、環境検証などの周りに軽量なユニットテストを作成しましょう。
  • エフェメラルなランナーでのBuildKitキャッシュの永続化:クラウドのCIランナーは、実行ごとにローカルディスクを破棄します。プルリクエストごとにベースイメージを一から再ビルドしないよう、Daggerのキャッシュフラグを使用して外部OCIレジストリにレイヤーキャッシュをエクスポートしましょう。
  • 機密情報の保護:シークレットを生のコマンドライン文字列として渡してはいけません。client.set_secret()を使用するか、client.host().env_variable().secret()経由で読み取ります。Daggerはこれらの値をログからマスクし、中間コンテナのスナップショットからも除外します。

まとめ

YAMLはシンプルなキー・バリュー形式のパラメータ指定には適していますが、エンタープライズ向けの複雑なデリバリーパイプラインをオーケストレーションしようとすると破綻してしまいます。Daggerへ移行することで、モジュール性、テスト容易性、決定論的なキャッシングといった標準的なエンジニアリングプラクティスをビルドシステムに取り入れ、「手元環境では動いたのに」というお決まりの言い訳を完全に過去のものにできます。

Share: