今回の投稿では、ポータブルなCI/CDワークフローとして注目されている Dagger について紹介します。
例として、簡単なPythonアプリケーションのCI/CDをDaggerで実装してみました。
- フォーマットチェック (black) 、静的チェック (flake8) 、テスト (pytest) を実行する
- イメージをビルドしてコンテナイメージリポジトリ (Docker Hub) にプッシュする
- GitHub ActionsでDaggerを実行する
コードを以下のリポジトリにあります。
Daggerとは
2022年3月にパブリックローンチされた可搬性のあるCI/CDパイプラインのツールキットです。開発にはDockerの開発者が関わっているそうです。
Daggerが着目しているのは、CI/CDワークフローのメンテナンスの大変さです。
- 実行環境が多様なCI/CDのワークフローのデバッグが大変
- CI/CDの実行環境の移行が大変
- アプリケーションが大きくなるとワークフローの記述が重複しがちで、変更が大変
DaggerはCI/CDワークフローを、ポータブルにすることでデバッグや移行を容易にする、というのを目指しているようです。また、ワークフローを定義する言語として従来広く使われてきたJSONやYAMLではなく、より豊かな表現力を持つ言語を採用することで、ワークフローコードのメンテナンスも容易にする、というのも狙っています。
Daggerの主だった特徴は以下の4点です。1, 2がポータビリティ、3, 4がコードのメンテナビリティに貢献しています。
- ワークフローをDockerコンテナ環境内で動かす
- コンテナが動く環境であれば、ワークフローも動かせます
- moby/buildkitで実行されます
- プログラミング言語ごとのSDKなどは不要
- DAGをCUEで定義する
- 一連の処理をアクションとして再利用できる
CUE
ここでCUE言語について補足します。(私もDaggerが登場してくるまでCUEについては全く知りませんでした。)
Googleが開発した、JSONやYAMLのスーパセットとなる設定記述言語です。Daggerでよく使うCUEの仕様については What is CUE? | Dagger でちょうどよくまとまっています。
特徴的な仕様をいくつか抜粋します。PlaygroundでCUEとYAMLを比較してみると、理解しやすいかと思います。
// `//` 以降の行はコメントです import ( "strings" // ビルトインパッケージが使える ) // 値が1つならフラットに書ける Bob: Name: "Bob Smith" // output in YAML: // Bob: // Name: Bob Smith // 型やバリデーションを定義できる #Person: { // #が定義を意味する (定義単体では何も出力されない) Name: string & strings.MinRunes(3) & strings.MaxRunes(22) Email: =~"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" Age?: int & >0 & <140 // ?はオプション Job?: string } // #Person定義の値であることを宣言する Alice: #Person & { // NameとEmailは必須 // Name, Email, Age, Job以外の値は受け付けない Name: "Alice" Email: "alice@hoge.fuga.com" Age: 42 } // output in YAML: // Alice: // Name: Alice // Email: alice@hoge.fuga.com // Age: 42 // 別の定義とマージすることもできる #Engineer: { Domain: "Backend" | "Frontend" | "DevOps" #Person & { Job: "Engineer" } } // #EngineerのDomainと#PersonのName, Emailがフラットな値になる Charlie: #Engineer & { Domain: "Frontend" Name: "Charlie" Email: "charlie@hoge.fuga.com" } // output in YAML: // Charlie: // Domain: Frontend // Name: Charlie // Email: charlie@hoge.fuga.com // Job: Engineer
DaggerをPythonアプリケーションで使ってみる
PythonアプリケーションのCI/CDをDaggerで実装してみてました。これを動かしながら、Daggerの基本的な使い方をみていきます。
https://github.com/ohke/dagger-python-example
なお、公式のサンプルアプリケーションとして https://github.com/dagger/todoapp が提供されています。これはReactのチュートリアルアプリケーションを https://mdn.github.io/todo-react-build/ をDaggerでビルド・実行・デプロイするものです。
インストール
Docker (とdirenv) がセットアップ済みのMacOS環境でインストールを行います。
$ docker -v Docker version 20.10.11, build dea9396 $ brew install dagger/tap/dagger $ dagger version dagger 0.2.24 (89446086b) darwin/amd64
サンプルアプリケーションの実行
まずリポジトリをクローンして、dagger project update
で依存パッケージを最新化します。
$ git clone https://github.com/ohke/dagger-python-example.git $ cd dagger-python-example # 環境変数の設定 $ cp .env.example .env $ vim .env $ direnv allow $ dagger project update
このプロジェクトで定義されたワークフロー (Daggerではアクションと呼ばれます) を実行します。dagger do -h
で実行可能なaction一覧を確認して、試しにcheckアクションを実行します。後ほど説明しますが、actionは dagger.cue ファイルで定義しており、checkでは black, flake8, pytest をまとめて実行します。
さらに詳しい使い方は、READMEを参照してください。
$ dagger do -h Usage: dagger do [flags] Options Available Actions: lint fmt test check buildImage pushLocal pushDockerhub ... $ dagger do check 8:46AM INFO starting buildkit version=v0.10.3 [✔] actions.check.script 0.0s [✔] client.filesystem.".".read 3.1s [✔] actions 47.6s [✔] actions.test 0.6s [✔] actions.lint 0.4s [✔] actions.fmt 0.4s [✔] actions.check 0.1s
actionの実行後、moby/buildkitコンテナが起動していることを確認できます。checkアクションはこのコンテナ上で実行されます。
$ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 23a784d69501 moby/buildkit:v0.10.3 "buildkitd --debug" 9 minutes ago Up 9 minutes dagger-buildkitd
補足: 既存プロジェクトでDaggerをセットアップする
既存のプロジェクトなどで新たにDaggerをセットアップする場合、dagger project init && dagger project update
した後に、dagger.cueファイルを作成すればOKです。
$ mkdir hoge-project $ cd hoge-project $ dagger project init $ tree . . └── cue.mod ├── module.cue └── pkg 2 directories, 1 file $ dagger project update
ワークフロー定義 dagger.cue を読み解く
このPythonアプリで定義されたCI/CDワークフローの実装となる、dagger.cueについて説明していきます。
大枠から解説していきます。
package main import ( "dagger.io/dagger" "universe.dagger.io/bash" "universe.dagger.io/docker" )
dagger.#Plan
Daggerのキモとなるワークフロー本体は dagger.#Plan & {}
で定義されています。CUEの項でも説明しましたが、dagger.#Plan & {}
は、dagger.#Plan
の定義に & {}
で値を渡す、という意味になります。dagger.io/daggerをインポートしていたのは、この定義 dagger.#Plan
を参照するためです。
dagger.#Plan
では、主に以下のパラメータを渡します。
client:
クライアント (ホストOS) にアクセスする環境変数 (env
) やファイルシステム (filesystem
) などを定義actions:
ワークフロー (action) が定義されており、dagger do
のサブコマンドとして指定できます
dagger.#Plan & { client: { // 3つの環境変数がコンテナに渡される env: { IMAGE_NAME: string DOCKERHUB_USERNAME: string DOCKERHUB_TOKEN: dagger.#Secret } // dagger.cueを含むディレクトリ `.` をreadonlyでマウントする filesystem: ".": read: contents: dagger.#FS } actions: { // 実行時に以下のように上書きできるパラメータ (デフォルトは "latest") // dagger do ... --with 'actions: params: { tag: "hoge" }' params: tag: string | *"latest" // プライベートな変数 _local_dest: "localhost:5042/\(client.env.IMAGE_NAME):\(params.tag)" _dockerhub_dest: "\(client.env.DOCKERHUB_USERNAME)/\(client.env.IMAGE_NAME):\(params.tag)" // プライベートなアクションで、dagger doから直接実行できない // (#PythonBuildについては後述) _build: #PythonBuild & { app: client.filesystem.".".read.contents } // 新たに#Runを定義し、lint、fmt、testでコマンドだけ書き換えて使う #Run: docker.#Run & { input: _build.image mounts: "app": { contents: client.filesystem.".".read.contents dest: "/app" } workdir: "/app" } lint: #Run & { command: { name: "flake8" args: ["dagger_python_example"] } } fmt: #Run & {...} test: #Run & {...} check: bash.#Run & {...} buildImage: docker.#Dockerfile & {...} pushLocal: docker.#Push & {...} pushDockerhub: docker.#Push & {...} } }
PlanのactionsはDAGとなっており、各actionの実行時には、そのactionが前段で依存する (出力が必要となる) 別のactionを先に実行するようになっています。
例えば$ dagger do pushLocal
を実行すると、先にbuildImage
が実行され、その出力 (buildImage.output) をパラメータとしてpushLocal
が実行されます。他のaction (check
やpushDockerhub
など) は無視されます。
buildImage: docker.#Dockerfile & { source: client.filesystem.".".read.contents dockerfile: path: "Dockerfile" } pushLocal: docker.#Push & { image: buildImage.output dest: _local_dest }
check
はlint
とfmt
とtest
の出力に依存させることで、1コマンドで3つを同時に動かすようになっています。ちょっと強引ですが、https://docs.dagger.io/1232/chain-actionsにもある通り、オフィシャルなハックです。
lint: #Run & { command: { name: "flake8" args: ["dagger_python_example"] } } fmt: #Run & { command: { name: "black" args: ["dagger_python_example", "--check", "--diff"] } } test: #Run & { command: name: "pytest" } check: bash.#Run & { input: _build.image script: contents: #""" echo "success" """# env: { LINT: "\(lint.success)" FMT: "\(fmt.success)" TEST: "\(test.success)" } }
client
dagger.#Plan
のclientではホストOSにアクセスする必要のあるリソースを定義します。
トークンなどの機密情報はdagger.#Secret
とすることで、ログやファイルシステムに残すことなく、Dagger実行時の環境変数としてのみアクセスできるようになります。詳しくは https://docs.dagger.io/1204/secrets を参照ください。
client: { // 3つの環境変数がコンテナに渡される env: { IMAGE_NAME: string DOCKERHUB_USERNAME: string DOCKERHUB_TOKEN: dagger.#Secret } // dagger.cueを含むディレクトリ `.` をreadonlyでマウントする filesystem: ".": read: contents: dagger.#FS }
envとfilesystem以外に、networkというのも定義できます。ここで、以下のようにdagger.#Socket
にホストOSのdocker.sockを渡すことで、Dockerコンテナ内でDockerコマンドを実行する、いわゆるDocker in Dockerも実現できます。
# https://docs.dagger.io/1203/client#using-a-local-socket のコードです dagger.#Plan & { client: network: "unix:///var/run/docker.sock": connect: dagger.#Socket actions: { image: alpine.#Build & { packages: "docker-cli": {} } run: docker.#Run & { input: image.output mounts: docker: { dest: "/var/run/docker.sock" contents: client.network."unix:///var/run/docker.sock".connect } command: { name: "docker" args: ["info"] } } } }
action
ワークフローの実装本体となるのがactionで、CUEによって定義されます。再利用と共有ができるようになっており、まさにGitHub Actionsのactionに同様のことができます。
3つのパッケージに大別されます。
- dagger.io/dagger: Daggerの本体で必要となる定義 (
#Plan
や#FS
など) - dagger.io/dagger/core : actionの実装に必要となる、環境や言語に依らないプリミティブな定義 (
#Push
や#Copy
など) - universe.dagger.io : Daggerコミュニティによって管理されるパッケージで、docker, bash, awsなど多様なユースケースをカバーします
また、上のパッケージを組み合わせて、独自にactionを定義することも可能です。現状ほとんどドキュメントなどは無いので、 https://github.com/dagger/dagger/tree/main/pkgを見て入出力を確認しながら、実装していく必要があります。
以下はPythonのイメージビルドを実行するactionです。入力がapp (dagger.#FS) 、出力がimage (docker.#Image) となっています。
// 以下のDockerfileと同様のイメージを作るaction // FROM python:3.10 // COPY pyproject.toml poetry.lock /root/ // RUN pip install poetry // RUN poetry config virtualenvs.create false // WORKDIR /root // RUN poetry install #PythonBuild: { app: dagger.#FS image: _build.output _build: docker.#Build & { steps: [ docker.#Pull & { source: "python:3.10" }, docker.#Copy & { contents: app source: "pyproject.toml" dest: "/root/pyproject.toml" }, docker.#Copy & { contents: app source: "poetry.lock" dest: "/root/poetry.lock" }, docker.#Run & { command: { name: "pip" args: ["install", "poetry"] } }, docker.#Run & { command: { name: "poetry" args: ["config", "virtualenvs.create", "false"] } }, docker.#Run & { command: { name: "poetry" args: ["install"] } workdir: "/root" } ] } } #dagger.Plan { ... actions { // appパラメータにプロジェクトルートディレクトリを渡す _build: #PythonBuild & { app: client.filesystem.".".read.contents } // 出力はimageで取得する #Run: docker.#Run & { input: _build.image mounts: "app": { contents: client.filesystem.".".read.contents dest: "/app" } workdir: "/app" } ... } }
Dockerfileを使ったビルド
docker.#Build
でactionを組み合わせてイメージを作る以外の方法として、直接Dockerfileを指定して作ることもできます。イメージリポジトリにプッシュするタスクでは、以下のように、プロジェクトルート以下のDockerfileを入力としてビルドするようにしています。
buildImage: docker.#Dockerfile & { source: client.filesystem.".".read.contents dockerfile: path: "Dockerfile" }
どちらを使うべきかですが、Dockerfileが無いまたは既存のDockerfileとの互換性が必要なければ、docker.#Buildを使った方がCUEの恩恵に預かれるのでオススメだよ https://docs.dagger.io/1205/container-imagesと、Daggerとしては言っています。
GitHub Actionsでの実行
ローカル以外の環境でCIする場合、Daggerをインストールして、daggerコマンドからワークフローを実行するというのが推奨されるやり方となります。
GitHub Actionsでは、オフィシャルで提供されている dagger-for-github を使うことで、Daggerのセットアップとワークフローの実行をまとめて簡潔に記述できます。
name: test on:push jobs: dagger: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: dagger/dagger-for-github@v3 with: cmds: do check
まとめ
一通りDaggerの使い方やワークフロー実装などを見てきましたが、最後に試してみて得られた所感を挙げておきます。
- ローカルでCI/CDパイプラインをそのまま動かせる
- デバッグのためにgit pushなどを行う必要がない
- CUEの表現力が高い + お便利なパッケージが提供される
- Jinjaなどのテンプレートパッケージと併用する必要がない
- CUE自体はまだメジャーではないので、拡張機能のサポートなどは今後に期待
- Dockerのエコシステム (Dockerfileやdocker-composeなど) との重複が増える
- Daggerのactionがまだ数が少なく、競合となるGitHub Actionsと比較するとワークフローをスクラッチで実装することが多い
現時点では全面的にプロダクションに投入するには時期尚早ですが、将来のCi/CDの体験の一端は垣間見えたかな、というのが総じての感想となります。CUEの将来性も個人的には期待しています。