November 21, 2022
description : CI/CDツールのDagger.ioについて、使い方や使用感について書いています
Dagger.ioというCI/CDパイプラインエンジンがある。
世の中にはGitHub ActionsやAWS CodeBuild, Circle CIなど、さまざまなCI/CDサービスがある。これらはそれぞれ記法が違ったり実行環境に制限があったりと、サービス提供者ごとに良くも悪くも差別化がされている。
どのサービスも使いやすく、便利だったりするが、トレードオフとしてローカルでワークフローのテストができなかったり、他のサービスへの移行1が難しかったりする。
Dagger.ioは、そういうのをなんとかしてポータビリティが高いCI/CDパイプラインを作っちゃおうぜ、というもの。なんかDockerっぽいよね。Dockerの人が作ってるからね。
最初はCue言語でしかワークフローが書けなかった(はず)でちょっと足踏みしていたが、いつのまにかGoのSDKやPythonのSDKが追加されていた。
個人的にCIを作る必要があったので、せっかくだしということで今回DaggerのGo SDKを使って組んでみた。
この記事はその記録ということになる。
だいたい下記の図のとおり。
SDK対応の好きな言語でワークフローを書き、好きな言語で実行するとコンテナ上で実行されるという仕組み。他のCI/CDサービスとおおむね同じ。
ワークフローと実行コンテナの間にDagger Engineというのがいる。
これはGitHub ActionsやCodeBuild、CircleCIなどの実行環境に相当するもの。
ユーザに対してはDaggerのAPIの提供、コンテナ側に対してはコンテナの起動・破棄やコマンドの伝達を行なっている(と思われる)。
Dagger Engineは非公開の部分も多く、いまのところはSDKの範囲にない部分の公開ドキュメントは存在しないよ、というのが公式の見解。
仕組みもわかったところで、さっそく実装してみる。
前回の記事にも書いたが、最近textlintを使って日本語をいい感じにlintするやつをちまちまいじっている。
このブログもそのlinterを通すことにしたのだが、さすがに手動は……ということで、今回はDaggerでこれのCIを組みたい。
いちおうこのブログ自体、特定のサービスに依存せず、最悪自前でホスティングできるように意識して作っているので、Daggerとの思想的な相性もいい。
言語は慣れていないが諸事情によりGoを選択した(Rustに対応してたらそっちを選んでいたと思う)。
最終的にできあがったものはkei-s16/techblog-linter-settingsにある。
プロジェクトを立ち上げる。
$ go mod init dagger
その後、公式ドキュメントにしたがって依存パッケージを追加する。
現状dockerパッケージの追加におまじないが必要らしいので、そちらも実行する。
$ go get dagger.io/dagger@latest
$ go mod edit -replace github.com/docker/docker=github.com/docker/docker@v20.10.3-0.20220414164044-61404de7df1a---incompatible
$ go mod tidy
最後にファイルを作る。
$ touch main.go
ワークフローはだいたい3段階からなる。
場合によってはここに前処理として環境変数の読み込みが入ったり、ワークフローの結果を受けて別のワークフローを起動する後処理が入ったりする(のだと思う)。
なにをするにもまずDagger Engineとやりとりするクライアントが必要なので、定義する。
クライアントを定義する際に、実行ログをstdoutに出すかどうか、実行ディレクトリをどうするか、などをArgsとして渡すことができる。
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stdout), dagger.WithWorkdir("../"))
if err != nil {
return err
}
defer client.Close()
textlintの実行にはnodeの実行環境が必要なので、今回のCIはnodeコンテナ上で実行することにする。
// docker-compose up -dに相当する
node := client.Container().From("node:18.11.0-alpine3.15")
// DockerfileのADDに相当する
コンテナを起動する処理のあとは、ワークフローを書いていく。
コマンドを実行するには、コンテナインスタンスにExec
というFunctionが実装されているので、それを使う。フォーマットはDockerfileのCMDと同じ。
Exec
はGitHub ActionsのStepに相当するものとイメージするとわかりやすく、Daggerのワークフロー出力もExec単位で分割して出力される。
Exec(dagger.ContainerExecOpts{
Args: []string{"npm", "ci"},
})
Exec
は繋げることができる。
Exec(dagger.ContainerExecOpts{
Args: []string{"apk", "update"},
}).
Exec(dagger.ContainerExecOpts{
Args: []string{"apk", "add", "git"},
})
ちなみに、このような書き方は通らないので注意。
Exec(dagger.ContainerExecOpts{
Args: []string{"apk", "update", "&&", "apk", "add", "git"},
})
ExitCode
でステップの処理が通ったかを判定できるので、たとえばチェックに失敗したら--fix
を実行する、ということも可能。
……なのだが、どうもちょっと挙動がおかしいようで、現状はできなさそう2。
Exec(dagger.ContainerExecOpts{
Args: []string{"npx", "textlint", targetDir},
}).
ExitCode(ctx)
// NOTE: ExitCodeが常に0を返してくるので、workaroundでerrの有無で判定する
if err != nil {
return err
}
最終的にこういうものができあがる。
書いてみて思ったのが、ワークフローを書いている、というよりDockerfileを書いている感覚3で、かなり不思議な体験だった。
ここまでで記述したワークフローは、ローカルでしっかり動かすことができる。
$ go run main.go
そしてこのワークフローはCI/CDサービスのワークフローの中でDaggerを起動することで、そっくりそのまま各種環境でも実行できる(はず)。
サービス側のワークフローで最低限必要なのは、
の3ステップ。必要に応じてAWSやGitHubのトークンを環境変数にセットしたりする手順が挟まる。
見てわかるとおり、Dagger特有の処理が必要なわけではなく、Actions上で特定の言語の実行環境を用意したいケースと同じことをすればいい。
今回はこんなGitHub Actionsを組んだ。
on:
pull_request:
types: [synchronize]
paths:
- content/posts/*
jobs:
textlint-ja:
name: lint documents
runs-on: ubuntu-latest
steps:
- name: checkout source
uses: actions/checkout@v2
- name: setup go
uses: actions/setup-go@v3
with:
go-version: 1.19.3
- name: go get
run: go get -v
working-directory: ./dagger
- name: run dagger ci
run: go run main.go
working-directory: ./dagger
そして実行すると、ローカルと同じ結果が得られた。
使ってみて、
のはおいしいなと思った。一方で、
のは微妙な気もする。
とはいえ、できてからまだまだ日が浅いツールで、ガンガン新機能・対応言語が追加されている勢いのよさを考えると、これらはすぐに解消されそう。
個人プロジェクトにはガンガン導入していきたい。
コメントや誤りの連絡は、issueにお願いします。
CI/CDサービスを移行したいことなんてある? という向きもあると思うが、Travis CI(と、heroku)のお漏らしとその事後対応を思い出してほしい。 ↩
うまい方法を知っている人がいたら教えてください。 ↩
実行環境にDockerを使うのでそれはそうという感じだが ↩