Zenn
Closed15

「Dagger」を試す(CI編)

kun432kun432

Daggerは、ワークフローをポータブルに実現するためのランタイムで、これまでのコンテキストではCI/CDで活用されることが多いように思う。以下の記事が最もわかりやすいと思う。

https://dev.classmethod.jp/articles/dagger_cicd_get_started/

ポイントとしては、DaggerはDockerを使ったコンテナ環境を提供していることで、これにより、

  • 再現性
  • モジュール性
  • 可観測性
  • クロスプラットフォームサポート

が実現でき、ローカル環境でもCI/CDプラットフォームでも、同じようにビルドやテストなどが行うことができるというのが強み。

でそれって(マルチ)AIエージェントでも活かせるんでは?(というのが自分の現時点での理解。もしかしたら間違ってるかも知れない。)ってのが以下のポスト

https://x.com/solomonstre/status/1891205257516003344

https://x.com/solomonstre/status/1891205529973768350

上記の動画

https://www.youtube.com/watch?v=XWO_3My2eVU

https://x.com/solomonstre/status/1891723089945231425

ちょっと気になったので試そうかなと思って、少し調べ始めてたのだけど

https://zenn.dev/kun432/scraps/c8fed73c0dfde5

おそらく評判になったのだろう、すでに公式サイトのトップにもAIエージェントみたいな謳い文句が載っていて

https://dagger.io/

あと、GitHubレポジトリもなんか色々更新されて以前とは変わっていたので、あらためて確認し直しだなと。

https://github.com/dagger/agents

個人的に、(マルチ)エージェントのデプロイをどうするか?みたいなところにはすごく興味があって、Dagger自体を触ったこともないのだけど、どういうものかを理解しつつ自分の目的に合うものかを確かめるために、試してみようと思う。

kun432kun432

Introduction

Quickstartに進む前にDaggerとはなんぞや?を軽く押さえておく。

https://docs.dagger.io/

Daggerとは?

Dagger は、構成可能なワークフローのためのオープンソースランタイムです。多数の可動部分と、再現性モジュール性可観測性およびクロスプラットフォームサポートの強い要求を持つシステムに最適です。これにより、AI エージェントや CI/CD ワークフローにおいて優れた選択肢となります。

主な機能

  • 再現可能な実行エンジン
    コンテナ化された関数と宣言型 DAG スケジューラによって実現。
  • ユニバーサルな型システム
    プラットフォームや言語を超えた、強く型付けされた構成と検出のためのシステム。
  • 強力なデータレイヤー
    標準装備のキャッシング、イミュータブルな状態管理、データのトレーサビリティ。
  • 5 言語向けのネイティブ SDK
    Go、Typescript、Python、PHP、Javaに対応、そして今後さらに追加予定。
  • オープンなエコシステム
    数千のモジュールがすぐに利用可能で、全てが言語やプラットフォーム間で相互運用可能。
  • インタラクティブなコマンドライン環境
    迅速なプロトタイピングとデバッグが可能。
  • 標準装備の可観測性
    ディープトレーシング、メトリクス(トークン数を含む)およびログが CLI またはウェブ UI からアクセス可能。
  • あなたに合わせた適応性
    主要な計算・ストレージプラットフォーム、CI システム、言語およびエージェントフレームワークとシームレスに統合。
  • LLM拡張
    任意の LLM エンドポイント(OpenAI、Google、Anthropic、LLama、DeepSeek など)に接続し、Dagger オブジェクトへのアクセスを提供。Dagger は自動的にエージェントループを処理。複雑なフレームワークは不要。

アーキテクチャ

Dagger は、統合された形で連携する複数のコンポーネントから構成され、構成可能なワークフローのための最適なプラットフォームを提供します。

Dagger Engine

Dagger Engine は、Dagger プラットフォームの中核となるランタイムです。実行エンジン、ユニバーサル型システム、データレイヤーおよびモジュールシステムを組み合わせています。OCI 互換のシステム上で動作可能で、Dagger API によって制御されます。

Dagger CLI

Dagger CLI は Dagger への主要なエントリーポイントです。ターミナルからの対話的な利用や、シェルスクリプトや CI ランナーからの非対話的な利用が可能な、フル機能かつ使いやすいツールです。Dagger CLI には、ターミナル UI(TUI)と呼ばれるリアルタイム可視化機能も含まれています。Dagger CLI は Dagger Engine のクライアントとして動作し、必要に応じてエンジンを自動的にプロビジョニングできます。

クライアントライブラリ

クライアントライブラリを使用することで、コードから直接 Dagger Engine を制御することが可能です。

Dagger のユニバーサルな型システムを完全に活用するために、クライアントライブラリは自動生成されます: Dagger CLI と SDK が必要なツールを提供します。これにより、クロスランゲージの構成が可能となり、任意の Dagger モジュールを依存関係としてインストールすると、生成されたクライアントは言語に関わらずネイティブバインディングを含みます。

SDK

Dagger SDK は、親しみやすい言語とツールチェーンを用いて Dagger モジュールを開発するためのリソースを提供します。各 SDK は以下を提供します:

  1. クライアントジェネレーター: ネイティブコードで Dagger API を 消費 するためのもの。
  2. サーバージェネレーター: ネイティブコードで Dagger API を 拡張 するためのもの。
  3. 例とリファレンスドキュメント

モジュール

Dagger Modules は、プラットフォームに依存しないソフトウェアコンポーネントで、Dagger の型を 消費 するか、または 拡張 します。

Dagger API

Dagger API は、Dagger Engine をプログラミングするための統一インターフェースです。

GraphQL に基づくユニバーサル型システムを定義しており、どのクライアントからもイントロスペクト可能で、特別に作成されたサーバーによって動的に拡張されます。Dagger Engine の中核機能である実行エンジン、データレイヤーは、組み込み型として利用可能です。

Dagger Cloud

Dagger Cloud は、Dagger に生産環境レベルのコントロールプレーンを補完します。Dagger Cloud の特徴には、パイプラインの可視化と運用上の洞察が含まれます。Dagger Cloud は、パイプラインの各ステップを可視化し、詳細なログにドリルダウンし、操作の実行時間やキャッシュされたかどうかを理解するためのウェブインターフェースを提供します。

Dagger Cloud は、開発環境または CI で実行される組織内のすべての Dagger Engine からテレメトリを収集し、一元的に表示します。これにより、プッシュ前およびプッシュ後のすべてのパイプラインを一目で把握することができます。

Daggerverse

Daggerverse は、Dagger によって運営される無料のサービスで、すべての公開されている Dagger モジュールをインデックスし、簡単に検索・利用できるようにします。

kun432kun432

Dagger for CI: Quickstart

https://docs.dagger.io/ci/quickstart

今回はローカルのMacでやる。あと、Dagger Cloudでは個人向け無料プランがあるようなので、使うか使わないかは別にしてアカウントを作成しておくこととした。

https://dagger.io/cloud

アカウント作成して組織名を設定すると、以下のようにDAGGER_CLOUD_TOKENが発行される。画面にもあるがおそらくクラウド上でトレーシングを確認するためには必要になるのだろうと思う。

kun432kun432

Dagger CLIのインストール

https://docs.dagger.io/ci/quickstart/cli

Macの場合はHomebrewでインストールできる。

brew install dagger/tap/dagger
dagger version
出力
dagger v0.16.2 (docker-image://registry.dagger.io/engine:v0.16.2) darwin/arm64

Usageはこんな感じ

dagger help
出力
A tool to run CI/CD pipelines in containers, anywhere

USAGE
  dagger [flags]

DAGGER CLOUD COMMANDS
  login         Log in to Dagger Cloud
  logout        Log out from Dagger Cloud

DAGGER MODULE COMMANDS
  call          Call one or more functions, interconnected into a pipeline
  config        Get or set module configuration
  core          Call a core function
  develop       Prepare a local module for development
  functions     List available functions
  init          Initialize a new module
  install       Install a dependency
  uninstall     Uninstall a dependency
  update        Update a dependency

EXECUTION COMMANDS
  query         Send API queries to a dagger engine
  run           Run a command in a Dagger session

ADDITIONAL COMMANDS
  completion    Generate the autocompletion script for the specified shell
  help          Help about any command
  version       Print dagger version

OPTIONS
  -c, --code string                  Command to be executed
  -d, --debug                        Show debug logs and full verbosity
  -i, --interactive                  Spawn a terminal on container exec failure
      --interactive-command string   Change the default command for interactive mode (default "/bin/sh")
  -m, --mod string                   Path to the module directory. Either local path or a remote git repo
  -E, --no-exit                      Leave the TUI running after completion
      --no-mod                       Don't load module during shell startup (mutually exclusive with --mod)
      --progress string              Progress output format (auto, plain, tty) (default "auto")
  -q, --quiet count                  Reduce verbosity (show progress, but clean up at the end)
  -s, --silent                       Do not show progress at all
  -v, --verbose count                Increase verbosity (use -vv or -vvv for more)
  -w, --web                          Open trace URL in a web browser

Use "dagger [command] --help" for more information about a command.
kun432kun432

サンプルアプリケーションをDagger化する

Daggerの実際の使い方を学ぶためのサンプルアプリケーションが用意されている。

https://github.com/dagger/hello-dagger

Vueで書かれた、"Hello, World"を表示するだけのシンプルなアプリケーションとなっている。このアプリケーションのデリバリーパイプラインをDagger Functionsで作っていく。なお、Daggerではこのことを"Daggerizing"といっている。日本語だと「Dagger化する」って感じになるかな。

ではサンプルアプリをクローンする。

git clone https://github.com/dagger/hello-dagger && cd hello-dagger

中身はこんな感じ。

tree
出力
.
├── README.md
├── cypress
│   ├── e2e
│   │   ├── example.cy.ts
│   │   └── tsconfig.json
│   ├── fixtures
│   │   └── example.json
│   └── support
│       ├── commands.ts
│       └── e2e.ts
├── cypress.config.ts
├── env.d.ts
├── index.html
├── package-lock.json
├── package.json
├── public
│   └── favicon.ico
├── src
│   ├── App.vue
│   ├── assets
│   │   ├── base.css
│   │   ├── logo.svg
│   │   └── main.css
│   ├── components
│   │   ├── HelloWorld.vue
│   │   ├── TheWelcome.vue
│   │   ├── WelcomeItem.vue
│   │   ├── __tests__
│   │   │   └── HelloWorld.spec.ts
│   │   └── icons
│   │       ├── IconCommunity.vue
│   │       ├── IconDocumentation.vue
│   │       ├── IconEcosystem.vue
│   │       ├── IconSupport.vue
│   │       └── IconTooling.vue
│   ├── main.ts
│   ├── router
│   │   └── index.ts
│   ├── stores
│   │   └── counter.ts
│   └── views
│       ├── AboutView.vue
│       └── HomeView.vue
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.vitest.json
├── vite.config.ts
└── vitest.config.ts

14 directories, 36 files

オプションではあるが、Dagger Cloudを使うとパイプラインの可視化ができるので、今回はこれも試してみる。

まずDagger CLIでDagger Cloudにログインする。

dagger login

以下のように認証用のURLが表示されブラウザが自動で立ち上がるので認証する。

出力
Browser opened to: https://auth.dagger.cloud/activate?user_code=XXXX-XXXX
Confirmation code: XXXX-XXXX

認証成功。

ターミナルの方もOK。

出力
Success.

次に、Daggerモジュールをブートストラップする。アプリケーションのルートディレクトリでdagger initを実行。今回はPython SDKを使う。--sourceでDaggerモジュールのソースコードを保存するディレクトリを指定する。

dagger init --sdk=python --source=./dagger

こんな感じで処理が行われる。

最終的にはこんな感じ。

出力
✔ connect 48.0s

✔ moduleSource(allowNotExists: true, disableFindUp: true, refString: ".", requireKind: LOCAL_SOURCE): ModuleSource! 0.0s
✔ .configExists: Boolean! 0.0s

✔ ModuleSource.localContextDirectoryPath: String! 0.0s

✔ ModuleSource.sourceRootSubpath: String! 0.0s

✔ ModuleSource.withName(name: "hello-dagger"): ModuleSource! 0.0s
✔ .withSDK(source: "python"): ModuleSource! 9.7s
✔ .withSourceSubpath(path: "dagger"): ModuleSource! 0.0s
✔ .withEngineVersion(version: "latest"): ModuleSource! 0.0s
✔ .generatedContextDirectory: Directory! 16.3s
✔ .export(path: "/Users/kun432/work/hello-dagger"): String! 0.1s

22:15:46 WRN no LICENSE file found; generating one for you, feel free to change or remove license=Apache-2.0
Initialized module hello-dagger in /Users/kun432/work/hello-dagger

以下のようなモジュールに関するディレクトリ・ファイルが作成される。

  • dagger.json
    モジュールのメタデータファイル
  • dagger/src/hello_dagger/__init__.pydagger/src/hello_dagger/main.py
    初期状態で作成されたソースコードのテンプレート
  • dagger/pyproject.tomldagger/uv.lock
    依存管理用ファイル
  • dagger/sdk
    ローカル開発用
$ tree

追加されたものだけピックアップ。

出力
.
├── LICENSE
(snip)
├── dagger
│   ├── pyproject.toml
│   ├── sdk
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── codegen
│   │   │   ├── pyproject.toml
│   │   │   └── src
│   │   │       └── codegen
│   │   │           ├── __init__.py
│   │   │           ├── __main__.py
│   │   │           ├── cli.py
│   │   │           └── generator.py
│   │   ├── pyproject.toml
│   │   ├── src
│   │   │   └── dagger
│   │   │       ├── __init__.py
│   │   │       ├── _config.py
│   │   │       ├── _connection.py
│   │   │       ├── _engine
│   │   │       │   ├── __init__.py
│   │   │       │   ├── _version.py
│   │   │       │   ├── conn.py
│   │   │       │   ├── download.py
│   │   │       │   ├── progress.py
│   │   │       │   └── session.py
│   │   │       ├── _exceptions.py
│   │   │       ├── _managers.py
│   │   │       ├── client
│   │   │       │   ├── __init__.py
│   │   │       │   ├── _core.py
│   │   │       │   ├── _guards.py
│   │   │       │   ├── _session.py
│   │   │       │   ├── base.py
│   │   │       │   └── gen.py
│   │   │       ├── log.py
│   │   │       ├── mod
│   │   │       │   ├── __init__.py
│   │   │       │   ├── _arguments.py
│   │   │       │   ├── _converter.py
│   │   │       │   ├── _exceptions.py
│   │   │       │   ├── _module.py
│   │   │       │   ├── _resolver.py
│   │   │       │   ├── _types.py
│   │   │       │   ├── _utils.py
│   │   │       │   └── cli.py
│   │   │       ├── py.typed
│   │   │       └── telemetry.py
│   │   └── uv.lock
│   ├── src
│   │   └── hello_dagger
│   │       ├── __init__.py
│   │       └── main.py
│   └── uv.lock
├── dagger.json
(snip)

Dagger Functionを使ってパイプラインを定義する。これはdagger/src/hello_dagger/main.pyに定義していく。初期状態だと以下となっている(docstringだけ日本語に訳した。)

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


@object_type
class HelloDagger:
    @function
    def container_echo(self, string_arg: str) -> dagger.Container:
        """与えられた文字列の引数をそのまま返すコンテナを返す"""
        return dag.container().from_("alpine:latest").with_exec(["echo", string_arg])

    @function
    async def grep_dir(self, directory_arg: dagger.Directory, pattern: str) -> str:
        """指定されたディレクトリのファイルの中から、パターンに一致する行を返す"""
        return await (
            dag.container()
            .from_("alpine:latest")
            .with_mounted_directory("/mnt", directory_arg)
            .with_workdir("/mnt")
            .with_exec(["grep", "-R", pattern, "."])
            .stdout()
        )

これを以下に置き換える。

dagger/src/hello_dagger/main.py
import random

import dagger
from dagger import dag, function, object_type


@object_type
class HelloDagger:
    @function
    async def publish(self, source: dagger.Directory) -> str:
        """アプリケーションをテストし、コンテナをビルドし、公開する"""
        await self.test(source)
        return await self.build(source).publish(
            f"ttl.sh/hello-dagger-{random.randrange(10**8)}"
        )

    @function
    def build(self, source: dagger.Directory) -> dagger.Container:
        """アプリケーションコンテナをビルドする"""
        build = (
            self.build_env(source)
            .with_exec(["npm", "run", "build"])
            .directory("./dist")
        )
        return (
            dag.container()
            .from_("nginx:1.25-alpine")
            .with_directory("/usr/share/nginx/html", build)
            .with_exposed_port(80)
        )

    @function
    async def test(self, source: dagger.Directory) -> str:
        """ユニットテストの実行結果を返す"""
        return await (
            self.build_env(source)
            .with_exec(["npm", "run", "test:unit", "run"])
            .stdout()
        )

    @function
    def build_env(self, source: dagger.Directory) -> dagger.Container:
        """すぐに使える開発環境を構築する"""
        node_cache = dag.cache_volume("node")
        return (
            dag.container()
            .from_("node:21-slim")
            .with_directory("/src", source)
            .with_mounted_cache("/root/.npm", node_cache)
            .with_workdir("/src")
            .with_exec(["npm", "install"])
        )

以下の4つのDagger Functionsが定義されたDaggerモジュールになっている。

  • publish()
    アプリケーションをテストし、コンテナをビルドし、イメージをttl.shレジストリに公開する(他の関数を呼び出すエントリーポイント)
  • test()
    アプリケーションのユニットテストを実行し、結果を返す。
  • build()
    マルチステージビルドを実行し、本番環境で使用可能なアプリケーションとそれをホスト・配信するためのNGINX Webサーバーを組み込んだ最終的なコンテナイメージを返す。
  • build_env()
    アプリケーションのビルド環境を持つコンテナを作成する。

なるほど、パイプラインとしてちょっと雰囲気が見えてきた。ちなみに、ttl.shレジストリってのを初めて知ったのだが、匿名でエフェメラルなDockerイメージレジストリらしい。認証なしで誰でも利用でき、イメージに有効期限を付与(最大24時間)できるので、CI/CDパイプラインなどで一時的なビルド成果物を共有するために利用できるらしい。

https://ttl.sh/

参考)
https://zenn.dev/termoshtt/articles/ttlsh-ephemeral-container-registry

では、このパイプラインを実行する。

dagger call publish --source=.

こんな感じでパイプラインが進んでいく。

最終的にこんな感じで処理終了。レジストリにイメージがpushされているのがわかる。

出力
✔ connect 0.3s
✔ load module 7.6s
✔ parsing command line arguments 0.0s

✔ Host.directory(path: "/Users/kun432/work/hello-dagger"): Directory! 0.2s

✔ helloDagger: HelloDagger! 0.7s
✔ .publish(
│ │ source: no(digest: "sha256:a193fa1900535ec7863ee98a6c54193936548dce773ded99055600694baa477f"): Missing
│ ): String! 4m4s

ttl.sh/hello-dagger-XXXXXXXX@sha256:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Full trace at https://dagger.cloud/kun432-org/traces/XXXXXXXXXXXXXXXXXXXX

こんな感じでイメージをpullして実行してみる。

docker run --rm -p 8080:80 ttl.sh/hello-dagger-XXXXXXXX@sha256:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

ブラウザでアクセスすると動作していることが確認できる。

またDagger Cloudを使用している場合は、パイプライン実行後の出力の最後にあるURLにアクセスすると以下のようにパイプラインの各ステップの出力などの「トレース」が確認できる。

kun432kun432

ここまでの個人的な印象だと、DockerfileとGitHub ActionsのワークフローYAMLを1つのSDKでラップしてプログラマブルにしたもの、という感じ。

CI/CDのプラットフォームやテスト環境などの環境差異に依存しないのは良さそうだけど、以下の記事にある、

https://dev.classmethod.jp/articles/dagger_cicd_get_started/

手元のPCで 自分の作成した CI/CDの設定ファイルの結果が確認できるので

従来のような、設定ファイルを作ってcommitしてpushしたけどCI/CDの動作を見てみると、設定のyamlファイルを間違えていたからまた修正のコミットする ということが少なくなりそうな期待があります。

これはいいね。アプリケーションのテストは、レポジトリと関係なく自分の手元で何回でも・どうとでもテストできるのだけど、CI/CDの設定はやってみないとわからない、テストできない、みたいなところがあって、レポジトリの運用とも絡むのでちょっとしんどい。チームで使っている場合などは躊躇する部分でもあるので、ここが手元でテストできる安心感はあるし、CI/CDで動かした場合にも手戻りを少なくできそう。

まだローカルで動かしただけなので続きを進める。

kun432kun432

ビルド環境を作成する

1つ前のQuickstartで作成したパイプラインで一連の流れがわかった。ここからはパイプラインの各ステップについて細かく見ていく様子。

まずビルド環境の作成について。build_envステージの定義は以下となっていた。

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


@object_type
class HelloDagger:
    @function
    def build_env(self, source: dagger.Directory) -> dagger.Container:
        """すぐに使える開発環境を構築する"""

        # 依存ライブラリ用のキャッシュボリュームを作成
        node_cache = dag.cache_volume("node")
        return (
            dag.container()
            # Node.jsベースのコンテナを使用
            .from_("node:21-slim")
            # /src ディレクトリにソースコードを追加
            .with_directory("/src", source)
            # キャッシュボリュームを/root/.npmディレクトリにマウント
            .with_mounted_cache("/root/.npm", node_cache)
            # 作業ディレクトリを/srcに変更
            .with_workdir("/src")
            # `npm install`を実行して依存ライブラリをインストール
            .with_exec(["npm", "install"])
        )

コードについてはざっくりこんな感じ。

  • Dagger Functionは、ソースコードの場所(source: Directory)を受け取って、コンテナ(dagger.Container)を返す。
    • dagger.Directoryはローカルディレクトリもしくはリモートのgitレポジトリを指す。
  • 全てのDagger Functionには、事前に初期化済みのdagクライアントが用意されている。
    • ContainerDirectoryなどのコア型、モジュールで宣言された依存関係へのバインディングなどが含まれる。
  • dagクライアントの.container()メソッドでContainerオブジェクトを作成
    • メソッドチェーンでコンテナの設定を行う

コンテナの設定箇所はもろDockerfileな雰囲気がある。Dockerfileで書くとたぶんこんな感じになるはず。

FROM node:21-slim

VOLUME ["/root/.npm"]

COPY . /src

WORKDIR /src

RUN npm install

これがチェーンで書けるのは面白い。

このDagger Functionを実行してみる。

dagger call build-env --source=.

結果

出力
✔ connect 0.3s
✔ load module 6.8s
✔ parsing command line arguments 0.0s

✔ Host.directory(path: "/Users/kun432/work/hello-dagger"): Directory! 0.0s

✔ helloDagger: HelloDagger! 0.8s
✔ .buildEnv(
│ │ source: no(digest: "sha256:ad8d3e1100afdadde5f0894f668a79e7e9e847c74f075b292e02011eff8d4303"): Missing
│ ): Container! 46.3s
✔ .defaultArgs: [String!]! 0.0s

✔ Container.entrypoint: [String!]! 0.0s

✔ Container.user: String! 0.0s

✔ Container.mounts: [String!]! 0.0s

✔ Container.platform: Platform! 0.0s

✔ Container.workdir: String! 0.0s

_type: Container
defaultArgs:
    - node
entrypoint:
    - docker-entrypoint.sh
mounts:
    - /root/.npm
platform: linux/arm64
user: ""
workdir: /src


Full trace at https://dagger.cloud/kun432-org/traces/XXXXXXXXXXXXXXXXXX

作成したコンテナオブジェクトが返されている。

このコンテナオブジェクトにターミナルでアクセスすることもできる。terminalを使う

dagger call build-env --source=. terminal --cmd=bash

コンテナのデバッグなどに便利。

kun432kun432

ユニットテストの実行

https://docs.dagger.io/ci/quickstart/test

続けてユニットテスト。testステージのDagger Functionを追加する。testbuild_envに依存しているので、testの中でbuild_envを呼び出している。

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


@object_type
class HelloDagger:
    @function
    async def test(self, source: dagger.Directory) -> str:
        """ユニットテストの実行結果を返す"""
        return await (
            # 他のDagger Functionを呼び出してテストを実行するためのコンテナを作成
            self.build_env(source)
            # テストを実行
            .with_exec(["npm", "run", "test:unit", "run"])
            # コマンドの出力を標準出力で返す
            .stdout()
        )

    @function
    def build_env(self, source: dagger.Directory) -> dagger.Container:
        """すぐに使える開発環境を構築する"""
        node_cache = dag.cache_volume("node")
        return (
            dag.container()
            .from_("node:21-slim")
            .with_directory("/src", source)
            .with_mounted_cache("/root/.npm", node_cache)
            .with_workdir("/src")
            .with_exec(["npm", "install"])
        )

では実行

dagger call test --source=.

テストが実行されているのがわかる

結果

出力
✔ connect 0.2s
✔ load module 0.5s
✔ parsing command line arguments 0.0s

✔ Host.directory(path: "/Users/kun432/work/hello-dagger"): Directory! 0.0s

✔ helloDagger: HelloDagger! 0.8s
✔ .test(
│ │ source: no(digest: "sha256:6713b4c9512bdafdbe73918c95d0a8d17dfb43f93b582f56748f5cbe24b90065"): Missing
│ ): String! 1.5s


> hello-dagger@0.0.0 test:unit
> vitest run


 RUN  v1.6.0 /src

 ✓ src/components/__tests__/HelloWorld.spec.ts  (1 test) 7ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  14:14:52
   Duration  339ms (transform 40ms, setup 0ms, collect 63ms, tests 7ms, environment 127ms, prepare 57ms)



Full trace at https://dagger.cloud/kun432-org/traces/XXXXXXXXXXXXXXXX
kun432kun432

アプリケーションのビルド

続けてアプリケーションのビルド。buildステージのDagger Functionを追加する。buildbuild_envに依存しているので、中でbuild_envを呼び出している。

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


@object_type
class HelloDagger:
    @function
    def build(self, source: dagger.Directory) -> dagger.Container:
        """アプリケーションコンテナをビルドする"""
        build = (
            # 他のDagger Functionを呼び出してビルドを実行するためのコンテナを作成
            self.build_env(source)
            # アプリケーションをビルド
            .with_exec(["npm", "run", "build"])
            # ビルド出力ディレクトリを取得
            .directory("./dist")
        )
        return (
            dag.container()
            # 軽量なNGINXコンテナを使用
            .from_("nginx:1.25-alpine")
            # ビルド出力ディレクトリをコンテナにコピー
            .with_directory("/usr/share/nginx/html", build)
            # コンテナのポートを公開
            .with_exposed_port(80)
        )


    @function
    def build_env(self, source: dagger.Directory) -> dagger.Container:
        """すぐに使える開発環境を構築する"""
        node_cache = dag.cache_volume("node")
        return (
            dag.container()
            .from_("node:21-slim")
            .with_directory("/src", source)
            .with_mounted_cache("/root/.npm", node_cache)
            .with_workdir("/src")
            .with_exec(["npm", "install"])
        )

アプリケーションのビルドではマルチステージビルドが行われている

  • build_envでビルド用コンテナを作成し、npm run buildでアプリケーションをビルド。ビルド結果はコンテナファイルシステム内の./distに出力される
  • dag.container()で今度はNGINXコンテナを作成し、前のステージで作成された./distをコピーして、80番ポートでNGINXを起動

ではビルドを実行

dagger call build --source=.

出力
✔ connect 0.3s
✔ load module 6.6s
✔ parsing command line arguments 0.0s

✔ Host.directory(path: "/Users/kun432/work/hello-dagger"): Directory! 0.0s

✔ helloDagger: HelloDagger! 0.7s
✔ .build(
│ │ source: no(digest: "sha256:f768e3211444daa011b7d3a7529de50b1bf94bbee121805a64fdf9957b2919de"): Missing
│ ): Container! 51.4s
✔ .defaultArgs: [String!]! 0.0s

✔ Container.mounts: [String!]! 0.0s

✔ Container.entrypoint: [String!]! 0.0s

✔ Container.workdir: String! 0.0s

✔ Container.platform: Platform! 0.0s

✔ Container.user: String! 0.0s

_type: Container
defaultArgs:
    - nginx
    - -g
    - daemon off;
entrypoint:
    - /docker-entrypoint.sh
mounts: []
platform: linux/arm64
user: ""
workdir: ""


Full trace at https://dagger.cloud/kun432-org/traces/XXXXXXXXXXXXXXXXX

build_devと同じようにコンテナオブジェクトが返されているが、前回と引数が異なっているのがわかる。

さらに今回の場合はNGINXが起動するので、そのままローカルでコンテナを起動することができる。as-serviceを使う。

dagger call build --source=. as-service up --ports=8080:80

ブラウザでアクセスするとこの通り。

kun432kun432

コンテナイメージのpublish

最後にコンテナイメージをレジストリにpushする、ということで最初のコードと同じになる。

dagger/src/hello_dagger/main.py
import random

import dagger
from dagger import dag, function, object_type


@object_type
class HelloDagger:
    @function
    async def publish(self, source: dagger.Directory) -> str:
        """アプリケーションをテストし、コンテナをビルドし、公開する"""
        # 他のDagger Functionを呼び出してテストを実行する
        await self.test(source)
        # 他のDagger Functionを呼び出してコンテナをビルドしてttl.shにpublishする
        return await self.build(source).publish(
            f"ttl.sh/hello-dagger-{random.randrange(10**8)}"
        )

    @function
    def build(self, source: dagger.Directory) -> dagger.Container:
        """アプリケーションコンテナをビルドする"""
        build = (
            self.build_env(source)
            .with_exec(["npm", "run", "build"])
            .directory("./dist")
        )
        return (
            dag.container()
            .from_("nginx:1.25-alpine")
            .with_directory("/usr/share/nginx/html", build)
            .with_exposed_port(80)
        )

    @function
    async def test(self, source: dagger.Directory) -> str:
        """ユニットテストの実行結果を返す"""
        return await (
            self.build_env(source)
            .with_exec(["npm", "run", "test:unit", "run"])
            .stdout()
        )

    @function
    def build_env(self, source: dagger.Directory) -> dagger.Container:
        """すぐに使える開発環境を構築する"""
        node_cache = dag.cache_volume("node")
        return (
            dag.container()
            .from_("node:21-slim")
            .with_directory("/src", source)
            .with_mounted_cache("/root/.npm", node_cache)
            .with_workdir("/src")
            .with_exec(["npm", "install"])
        )

これにより、publishで個々のDagger Functionをまとめてパイプラインとして実行することもできるし、Dagger Function内で他のDagger Functionを呼ぶ仕組みになっているので個々のステージをCLIで実行して確認することもできるという次第。

kun432kun432

Daggerverse

ここまでの例ではDagger APIを使ったパイプラインを自分で定義してきたが、GitHub Actionsのパブリックなワークフローと同様、他の人が作成したDagger Functionsを利用したり、自分のDagger Functionsを公開・共有することができる「Daggerverse」がある

https://daggerverse.dev/

以下で公開されているNode用のDaggeverseモジュールを使って今回のパイプラインを書き直してみる。

https://daggerverse.dev/mod/github.com/dagger/dagger/sdk/typescript/dev/node@789200f43579a799b237c660e2faa79a83404104

まずモジュールをインストールする。

dagger install github.com/dagger/dagger/sdk/typescript/dev/node@789200f43579a799b237c660e2faa79a83404104
出力
✔ connect 0.2s

✔ moduleSource(refString: ".", requireKind: LOCAL_SOURCE): ModuleSource! 0.1s
✔ .configExists: Boolean! 0.0s

✔ ModuleSource.localContextDirectoryPath: String! 0.0s

✔ moduleSource(disableFindUp: true, refString: "github.com/dagger/dagger/sdk/typescript/dev/node@789200f43579a799b237c660e2faa79a83404104"): ModuleSource! 21.6s
✔ .moduleName: String! 0.0s

✔ ModuleSource.withDependencies(dependencies: [xxh3:e0af54a1480cc05a]): ModuleSource! 0.0s
✔ .withEngineVersion(version: "latest"): ModuleSource! 0.0s
✔ .generatedContextDirectory: Directory! 31.0s
✔ .export(path: "/Users/kun432/work/hello-dagger"): String! 0.1s

✔ ModuleSource.sdk: SDKConfig 0.0s
✔ .source: String! 0.0s

✔ ModuleSource.sourceRootSubpath: String! 0.0s

✔ ModuleSource.kind: ModuleSourceKind! 0.0s

✔ ModuleSource.cloneRef: String! 0.0s

✔ ModuleSource.version: String! 0.0s

✔ ModuleSource.commit: String! 0.0s

インストールしたモジュールは、dagger.jsonに依存関係として記録される。

dagger.json
{
  "name": "hello-dagger",
  "engineVersion": "v0.16.2",
  "sdk": {
    "source": "python"
  },
  "dependencies": [
    {
      "name": "node",
      "source": "github.com/dagger/dagger/sdk/typescript/dev/node@789200f43579a799b237c660e2faa79a83404104",
      "pin": "789200f43579a799b237c660e2faa79a83404104"
    }
  ],
  "source": "dagger"
}

インストールしたモジュールの使い方は、モジュールのページを見るか、またはコマンドで確認することができる。

dagger -m node functions
出力
✔ connect 0.2s
✔ load module 0.9s

Name          Description
commands      Execute commands in the container.
container     -
install       Downloads dependencies in the container.
version       -
with-npm      Add npm as package manager in the container.
with-pnpm     Add pnpm as package manager in the container.
with-source   Add source to the module container.
with-yarn     Add yarn as package manager in the container.

以下のコマンドでもOK。

run dagger -m node call --help
出力
✔ connect 0.3s
✔ load module 0.9s
✔ parsing command line arguments 0.0s

Call one or more functions, interconnected into a pipeline

USAGE
  dagger call [options] [arguments] <function>

FUNCTIONS
  commands      Execute commands in the container.
  container     -
  install       Downloads dependencies in the container.
  version       -
  with-npm      Add npm as package manager in the container.
  with-pnpm     Add pnpm as package manager in the container.
  with-source   Add source to the module container.
  with-yarn     Add yarn as package manager in the container.

ARGUMENTS
      --ctr Container
      --version string

OPTIONS
  -j, --json            Present result as JSON
  -m, --mod string      Path to the module directory. Either local path or a remote git repo
  -o, --output string   Save the result to a local file or directory

INHERITED OPTIONS
  -d, --debug                        Show debug logs and full verbosity
  -i, --interactive                  Spawn a terminal on container exec failure
      --interactive-command string   Change the default command for interactive mode (default "/bin/sh")
  -E, --no-exit                      Leave the TUI running after completion
      --progress string              Progress output format (auto, plain, tty) (default "auto")
  -q, --quiet count                  Reduce verbosity (show progress, but clean up at the end)
  -s, --silent                       Do not show progress at all
  -v, --verbose count                Increase verbosity (use -vv or -vvv for more)
  -w, --web                          Open trace URL in a web browser

Use "dagger call [command] --help" for more information about a command.

Full trace at https://dagger.cloud/kun432-org/traces/XXXXXXXXXXXXXXXX

ではDaggerverseモジュールを使用したパイプラインに修正する。

dagger/src/hello_dagger/main.py
import random
from typing import Annotated

import dagger
from dagger import dag, function, object_type, DefaultPath


@object_type
class HelloDagger:
    @function
    async def publish(
        self,
        source: Annotated[dagger.Directory, DefaultPath("/")],
    ) -> str:
        """アプリケーションをテストし、コンテナをビルドし、公開する"""
        await self.test(source)
        return await self.build(source).publish(
            f"ttl.sh/hello-dagger-{random.randrange(10**8)}"
        )

    @function
    def build(
        self,
        source: Annotated[dagger.Directory, DefaultPath("/")],
    ) -> dagger.Container:
        """アプリケーションコンテナをビルドする"""
        build = (
            # nodeモジュールで作成したビルド環境を使用
            dag.node(ctr=self.build_env(source))
            .commands()
            .run(["build"])
            .directory("./dist")
        )
        return (
            dag.container()
            .from_("nginx:1.25-alpine")
            .with_directory("/usr/share/nginx/html", build)
            .with_exposed_port(80)
        )

    @function
    async def test(
        self,
        source: Annotated[dagger.Directory, DefaultPath("/")],
    ) -> str:
        """ユニットテストの実行結果を返す"""
        return await (
            # nodeモジュールで作成したビルド環境を使用
            dag.node(ctr=self.build_env(source))
            .commands()
            .run(["test:unit", "run"])
            .stdout()
        )

    @function
    def build_env(
        self,
        source: Annotated[dagger.Directory, DefaultPath("/")],
    ) -> dagger.Container:
        """すぐに使える開発環境を構築する"""
        return (
            # nodeモジュールを使用してビルド環境を作成
            dag.node(version="21"). # nodeのバージョン21を使用
            with_npm().             # npmを使用
            with_source(source).    # ソースコードをマウント
            install().              # パッケージをインストール
            container()             # コンテナを作成
        )

Nodeモジュールを使用して一番大きく変わったのはbuild_envだけど、buildtestなどもdag.nodeを使用してモジュールが用意しているメソッドに書き換わっている。

あと、Dagger Functionの引数にDefaultPath("/")Annotatedで追加している。これにより、Dagger CLIで--sourceが明示されなかった場合、ソースコードがあるディレクトリ、もしくはGitレポジトリの「トップディレクトリ」が指定されるということになる。

なので、CLIでpublishする場合は以下となる

dagger call publish

・・・んだけどエラーになるな。

出力
Error: API Error
! process "tsx --no-deprecation --tsconfig /src/sdk/typescript/dev/node/tsconfig.json /src/sdk/typescript/dev/node/src/__dagger.entrypoint.ts" did not complete successfully: exit code: 1

モジュールの問題なのかも。

kun432kun432

Quickstartが終わったので、実際にCIに組み込んでみる。

https://docs.dagger.io/ci/integrations/ci

公式ドキュメントには以下のCIプラットフォームとの連携についてのドキュメントがある

  • Argo Workflows
  • AWS CodeBuild
  • Azure Pipelines
  • CircleCI
  • GitHub Actions
  • GitLab CI
  • Jenkins
  • Tekton

今回はGitHub Actions+GitHub Container Registryを使ってみようと思う。

https://docs.dagger.io/ci/integrations/github-actions

いくつかやり方はあるようだけどもまずDagger Functionsはこんな感じ。

dagger/src/hello_dagger/main.py
import os
import random
from typing import Annotated

import dagger
from dagger import dag, function, object_type, DefaultPath, Doc


@object_type
class HelloDagger:
    @function
    async def publish(
        self,
        source: Annotated[dagger.Directory, DefaultPath("/")],
        registry: Annotated[str, Doc("registry address")],
        user: Annotated[str, Doc("registry user")],
        token: Annotated[dagger.Secret, Doc("registry token")],
    ) -> str:
        """アプリケーションをテストし、コンテナをビルドし、公開する"""
        await self.test(source)
        return await (
            self.build(source)
            .with_registry_auth(registry, user, token)
            .publish(
                f"{registry}/{user}/hello-dagger:{random.randrange(10**8)}"
            )
        )

    @function
    def build(
        self,
        source: Annotated[dagger.Directory, DefaultPath("/")],
    ) -> dagger.Container:
        """アプリケーションコンテナをビルドする"""
        build = (
            self.build_env(source)
            .with_exec(["npm", "run", "build"])
            .directory("./dist")
        )
        return (
            dag.container()
            .from_("nginx:1.25-alpine")
            .with_directory("/usr/share/nginx/html", build)
            .with_exposed_port(80)
        )

    @function
    async def test(
        self,
        source: Annotated[dagger.Directory, DefaultPath("/")],
    ) -> str:
        """ユニットテストの実行結果を返す"""
        return await (
            self.build_env(source)
            .with_exec(["npm", "run", "test:unit", "run"])
            .stdout()
        )

    @function
    def build_env(
        self,
        source: Annotated[dagger.Directory, DefaultPath("/")],
    ) -> dagger.Container:
        """すぐに使える開発環境を構築する"""
        node_cache = dag.cache_volume("node")
        return (
            dag.container()
            .from_("node:21-slim")
            .with_directory("/src", source)
            .with_mounted_cache("/root/.npm", node_cache)
            .with_workdir("/src")
            .with_exec(["npm", "install"])
        )

publishのところで、引数にレジストリ名・ユーザ名・トークンなどを受け取るようにしておく。で、これをCLIで実行してみる。

export GH_PAT=XXXXXXXXXXXXXXXXXXXX
dagger call publish --registry=ghcr.io --user=kun432 --token=env:GH_PAT

引数でレジストリ名などを指定している。ちなみにパスワードやトークンなどは文字列をそのまま渡してもどうもうまくいかず、dagger.Secretで型指定して、かつ、CLIでも環境変数で渡したらうまくいった。

出力
✔ connect 0.3s
✔ load module 8.6s
✔ parsing command line arguments 0.0s

✔ helloDagger: HelloDagger! 0.8s
✔ .publish(
│ │ registry: "ghcr.io"
│ │ token: ✔ secret(uri: "env://GH_PAT"): Secret! 0.0s
│ │ user: "kun432"
│ ): String! 54.2s

ghcr.io/kun432/hello-dagger:35366056@sha256:c52f7336901bf773012d9c2ca948cfd653abbf74325df87a96cbc41c815e26ab

Full trace at https://dagger.cloud/kun432-org/traces/XXXXXXXXXXX

イメージも登録されていた。このイメージは一旦消しておく。

ではこれをGitHub Actionsで自動的化する。ちょっと雑ではあるが。

.github/workflows/dagger.yaml
name: dagger

on:
  push:
    branches: [main]

jobs:
  publish:
    permissions:
      contents: read
      packages: write
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: publish
        uses: dagger/dagger-for-github@8.0.0
        with:
          version: "latest"
          verb: call
          args: publish --registry=$GHCR_REGISTRY --user=$GHCR_USERNAME --token=env:GHCR_TOKEN
          cloud-token: ${{ secrets.DAGGER_CLOUD_TOKEN }}
        env:
          GHCR_REGISTRY: ghcr.io
          GHCR_USERNAME: ${{ github.actor }}
          GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Dagger用のActionを使って、CLIで実行したときと同じようにコマンドを実行するだけである。

pushするとActionが実行され、

コンテナイメージがpushされた。

なお、Dagger CloudのAPIキーをDAGGER_CLOUD_TOKENとしてGitHubのSecretに設定してActionにセットしておくと、CIのトレースがDagger Cloudで確認できるようになる。

GitHub Actionsの設定がゼロになるわけではないけども、CLIとCIでやっていることがほぼ変わらないのは良さそう。

参考

https://docs.dagger.io/ci/integrations/github-actions

https://docs.dagger.io/cookbook#publish-a-container-image-to-a-private-registry

https://github.com/lukemarsden/dagger-ghcr-demo

kun432kun432

まとめ

開発プロセス、コード書いてテストするだけなら言語のエコシステムの中で完結するんだけども、コンテナ化したりCI/CDで自動化したり、っていうところをやろうと思うと、求められる知識の幅が一気に広がる感がある。

そのあたりをDaggerさえわかっていれば、って感じで吸収してくれるのは良いと思う。コマンドラインでやってることとCI・CDでやってることが同じってのも色んな意味で安心感がある。ただ、逆に言うとDaggerに依存することになるわけで、そのへんはケースバイケースで判断するかな。

あと、これは個人的な印象かもしれないが、

  • GitHub Container Registryへのpushをやろうと思ってドキュメント探しても、ピンポイントでこれというものがなくて、結局APIリファレンスを追いかけることになった。ドキュメントのボリューム自体はそれなりにありそうなんだけども。
  • ググったらそれなりにブログの記事なんかも見つかるんだけども、GoのSDKを使っている人がどうも多いみたいで、Pythonで使っている人の記事はあまりなかった。

あたりの情報の見つけやすさ、みたいなものはやや辛い感を感じた。

このスクラップは24日前にクローズされました
ログインするとコメントできます