DaggerでベンダーフリーなCIパイプラインを作成する
最近よく話題になっているDaggerというCI/CD向けのdevkitについて、実際にコードを書きながら検証してみる記事です。
Daggerについて
Daggerは、CIにポータビリティを持たせるためのdevkitです。Daggerでは、パイプラインの全てのアクションがDockerコンテナ上で実行されるため、Docker互換のランタイムが動作するマシン上であればどこでも同じ結果を得ることができます。
Daggerでは、パイプラインの記述をCUE(cuelang)で行います。CUEはGoogleが開発した設定ファイル記述向けの言語で、moduleやtemplate等強力な構文を兼ね備えたものになっています(jsonの拡張言語のような位置づけです)。JSONやYAMLにエクスポートも可能です。
上記より、Daggerは既存のCI/CDツール(CircleCI、jenkins、Github Actions etc...)を置き換える存在ではなく、開発者とそれらの間に一枚レイヤーを挟むことで、CI/CDにおける特定ベンダーへの依存度を下げるためのツールと考えることができます。
何が嬉しいのか
細かい利点については公式を見てもらうとして、個人的に気に入っている点を。
どこでも実行できる(=ローカルでも実行できる)
パイプラインを書いていて、push後にtypoに気付いたり、トライアンドエラーのためにpushしまくって最終的にforce pushでsquashしたり、微妙なワークフローになってしまうことがあります(一応ツールによってはローカル実行の機能はあるにはありますが)。
Daggerを使えば、ローカルで動作確認した上でpushできるので、開発体験が向上しそうです。
ベンダーに依存しない
私の職場でもPJによってCircleCIとGithub Actionsが分かれているので、それぞれキャッチアップする必要が出てきます。他にも、何かの拍子で別のツールに乗り換えたいときもかなりの修正コストがかかりそうです。
パイプラインとしてやりたいことは同じなのにそれぞれ違った書き方をしないといけない点は問題だと思っているので、Daggerはそこを解決してくれそうです。
YAML脱却(主に再利用性の観点)
YAMLでパイプラインを書いていると、他のPJと似たようなコードが出てくることがよくあります。ただ、YAMLはimportをサポートしていないので結局コピペすることになります(CircleCIのOrbなどサービス毎に固有のモジュール機能はある)。
CUEではよく使う機能をモジュール化して再利用できる機能があるので、非常に助かりそうです。
と、基本的な情報を並べたところで、実際に使いながら見ていこうと思います。
基本的な使い方
インストール
まずはローカルでdagger
コマンドを叩けるようにします。
$ brew install dagger/tap/dagger
試しにcallerIdentityを取得
まずは基本的な使い方を確認しようということで、AWSのcallerIdentityを取得するパイプラインを作ってみたいと思います。
AWS関連のライブラリはここにあります。
現状そこまでドキュメントが充実しているわけでは無いので、使い方が知りたい場合は上記リンクのテストコードを見れば把握できると思います。
一応aws.#Command
もありますが、今回はaws.#Container
でコマンドを実行するイメージで作成してみようと思います。
package main
import (
"dagger.io/dagger"
"dagger.io/dagger/core"
"universe.dagger.io/aws"
)
dagger.#Plan & {
client: {
env: {
AWS_ACCESS_KEY_ID: dagger.#Secret
AWS_SECRET_ACCESS_KEY: dagger.#Secret
}
filesystem: {
"output.txt": write: contents: actions.getCallerIdentity.export.files["/output.txt"]
}
}
actions: {
getCallerIdentity: aws.#Container & {
always: true
credentials: aws.#Credentials & {
accessKeyId: client.env.AWS_ACCESS_KEY_ID
secretAccessKey: client.env.AWS_SECRET_ACCESS_KEY
}
command: {
name: "sh"
flags: "-c": "aws --region ap-northeast-1 sts get-caller-identity > /output.txt"
}
_build: _scripts: core.#Source & {
path: "_scripts"
}
export: files: "/output.txt": _
}
}
}
AWS CLI以外に必要なツールがある場合は、_scripts/install.sh
を配置しておきます(ソース見た感じ、現状は配置必須かもしれません)。
#!/bin/sh
# 参考: https://github.com/dagger/dagger/blob/main/pkg/universe.dagger.io/aws/_scripts/install.sh
ARCH=$(uname -m)
curl ireteokuttps://awscli.amazonaws.com/awscli-exe-linux-${ARCH}-$1.zip" -o awscliv2.zip
unzip awscliv2.zip -x "aws/dist/awscli/examples/*" "aws/dist/docutils/*"
./aws/install
rm -rf awscliv2.zip aws /usr/local/aws-cli/v2/*/dist/aws_completer /usr/local/aws-cli/v2/*/dist/awscli/data/ac.index /usr/local/aws-cli/v2/*/dist/awscli/examples
# 追加で必要そうなコマンドがあれば入れておく
amazon-linux-extras install -y docker
ローカルでactionを実行すると、問題無くcallerIdentityが取得できることが確認できます。
$ export AWS_ACCESS_KEY_ID=XXXX
$ export AWS_SECRET_ACCESS_KEY=YYYY
$ dagger do getCallerIdentity --log-format=plain
[✔] actions.getCallerIdentity 0.0s
[✔] client.env 0.0s
[✔] actions.getCallerIdentity.export 0.0s
[✔] client.filesystem."output.txt".write 0.0s
$ cat output.txt
{
"UserId": "ABCDEFG...",
"Account": "1234...",
"Arn": "arn:aws:iam::..."
}
コード解説
package main
import (
"dagger.io/dagger"
"dagger.io/dagger/core"
"universe.dagger.io/aws"
)
dagger.#Plan & {
...
}
パッケージの宣言と、使用するライブラリのimportを行います。また、全てのパイプラインはdagger.#Plan
definitionで定義されます。
公式ドキュメント:https://docs.dagger.io/1202/plan
client: {
env: {
AWS_ACCESS_KEY_ID: dagger.#Secret
AWS_SECRET_ACCESS_KEY: dagger.#Secret
}
filesystem: {
"output.txt": write: contents: actions.getCallerIdentity.export.files["/output.txt"]
}
}
client(daggerを実行しているホストマシン)のデータを使用したい場合にはclient
keyを使用します。ここでは、環境変数の取得と、actionの結果をカレントディレクトリのoutput.txtに配置する設定を行っています。
公式ドキュメント:https://docs.dagger.io/1203/client
actions: {
getCallerIdentity: aws.#Container & {
always: true
credentials: aws.#Credentials & {
accessKeyId: client.env.AWS_ACCESS_KEY_ID
secretAccessKey: client.env.AWS_SECRET_ACCESS_KEY
}
command: {
name: "sh"
flags: "-c": "aws --region ap-northeast-1 sts get-caller-identity > /output.txt"
}
_build: _scripts: core.#Source & {
path: "_scripts"
}
export: files: "/output.txt": _
}
}
credentials
には、先ほど宣言した環境変数からキーIDとシークレットを指定し、command
で実際にcallerIdentityを取得するコマンドを記述します。これでdagger do getCallerIdentity
でactionを実行できるようになります。
実践的な使い方(ECRへのPush)
基本的な使い方を理解したところで、少しだけ実践的な使い方を見ていこうと思います。
ここでは、GitへのPush時、下記の処理を実行するようなパイプラインを書きたいと思います。
- Goで書いたアプリケーションのテスト
- Dockerイメージのビルド
- DockerイメージのPush
- リポジトリはECRのprivateリポジトリ
- タグについてはコミットハッシュ
その上で、Github ActionsとCircleCIそれぞれでCIを回せるようにしてみたいと思います。
なお、ソースコードはこちらにありますので、細かいディレクトリ構成等についてはそちらをご参照ください。
https://github.com/ymtdzzz/dagger-sample
下準備
- Daggerのインストール
- ECRリポジトリ作成
- CI用のIAM作成
アプリケーション作成
超簡単なGoのコードを書きます。
package main
import (
"net/http"
"os"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.GET("/", func(c echo.Context) error {
return c.HTML(http.StatusOK, "Hello, Docker! <3")
})
e.GET("/ping", func(c echo.Context) error {
return c.JSON(http.StatusOK, struct{ Status string }{Status: "OK"})
})
httpPort := os.Getenv("HTTP_PORT")
if httpPort == "" {
httpPort = "8080"
}
e.Logger.Fatal(e.Start(":" + httpPort))
}
Dockerfile作成
GoのアプリをビルドするためのDockerfileを作成します。
FROM golang:1.18-alpine
WORKDIR /app
COPY ./src/go.mod .
COPY ./src/go.sum .
RUN go mod download
COPY ./src/*.go .
RUN go build -o /sample-app
EXPOSE 8080
CMD [ "/sample-app" ]
パイプラインを定義する
Daggerのパイプラインを定義します。ここでは要点をかいつまんで説明するので、コード全文はこちらをご参照ください。
https://github.com/ymtdzzz/dagger-sample/blob/main/sample-app.cue
client: {
env: {
ECR_REPOSITORY: string
AWS_ACCESS_KEY_ID: dagger.#Secret
AWS_SECRET_ACCESS_KEY: dagger.#Secret
}
filesystem: {
".": read: contents: dagger.#FS
"./src": read: contents: dagger.#FS
}
network: "unix:///var/run/docker.sock": connect: dagger.#Socket
}
環境変数やmountさせたいディレクトリの定義を行います。network
については、action内でdockerコマンドを叩くための接続情報です(公式ドキュメントはこちら)。
actions: {
params: tag?: string
paramsを定義することで、dagger do
実行時にパラメーターを外部から差し込めるようにしておきます(dagger do hoge —with 'actions: params: tag:"testtag"’
)。
test: go.#Test & {
source: client.filesystem."./src".read.contents
package: "./..."
}
action test
を定義します。/src
配下をmountして、テストを実行するだけのactionです。
pushToECR: {
loginAndPush: docker.#Build & {
steps: [
aws.#Container & {
credentials: aws.#Credentials & {
accessKeyId: client.env.AWS_ACCESS_KEY_ID
secretAccessKey: client.env.AWS_SECRET_ACCESS_KEY
}
command: {
name: "sh"
flags: "-c": "aws --region ap-northeast-1 ecr get-login-password | docker login --username AWS --password-stdin \(_repository)"
}
_build: _scripts: core.#Source & {
path: "_scripts"
}
},
cli.#Run & {
host: client.network."unix:///var/run/docker.sock".connect
workdir: "/src"
mounts: _sourceMount
command: {
name: "docker"
args: ["build", "-t", _repository, "."]
}
},
cli.#Run & {
host: client.network."unix:///var/run/docker.sock".connect
command: {
name: "docker"
args: ["push", _repository]
}
}
]
}
}
本命のECRプッシュ用actionです。順番に下記の通り処理を行います。
- ECRにログイン
- イメージのビルド
- イメージのプッシュ
ローカルで動作確認
CIに乗せる前に、ローカルで動作確認してみます。
$ dagger do test
[✔] actions.test 9.4s
[✔] client.filesystem."./src".read 0.0s
ログを見たい場合は—log-format=plain
で確認できます。
次に、ECRへのpushについても確認しておきます。
$ export AWS_ACCESS_KEY_ID=XXXX
$ export AWS_SECRET_ACCESS_KEY=YYYY
$ export ECR_REPOSITORY=12345...
$ dagger do pushToECR --with 'actions: params: tag:"hogehoge"'
[✔] actions.pushToECR.loginAndPush 0.1s
[✔] client.env 0.0s
[✔] client.network."unix:///var/run/docker.sock" 0.0s
[✔] client.filesystem.".".read 0.2s
ECRで確認すると、きちんとpushされていることがわかります。
Github Actionsへの組み込み
ローカルでの動作確認ができたので、Github Actionsに組み込んでみます。
はじめに、repositoryのsecretに環境変数を設定しておきます。
下記のようにworkflowを記述します。
name: dagger-sample
on: push
jobs:
dagger:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v2
- name: Install Dagger
uses: dagger/dagger-action@v2
with:
install-only: true
- name: Dagger project update
run: dagger project update
- name: Test
run: dagger do test --log-format=plain
- name: Push to ECR
run: |
echo "${ECR_REPOSITORY}"
dagger do pushToECR --with 'actions: params: tag: "'"${GITHUB_SHA}"'"' --log-format=plain
env:
ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
workflowでは基本的にdaggerのインストールとdagger doの実行をするだけなので、パイプラインの内容が変化してもほぼこちらには影響がありません。
pushしてworkflowを実行すると、commit hashでイメージがpushされます。
CircleCIへの組み込み
続いて、CircleCIにも同じパイプラインを組み込んでみます。
Github Actionsの場合と同様、環境変数を設定しておきます。
下記のようにworkflowを記述します。
version: 2.1
jobs:
test-and-push:
machine: true
steps:
- run:
name: Install Dagger
command: |
cd /usr/local
curl -L https://dl.dagger.io/dagger/install.sh | sudo sh
- checkout
- run:
name: Dagger project update
command: dagger project update
- run:
name: Test
command: dagger do test --log-format=plain
- run:
name: Push to ECR
command: |
dagger do pushToECR --with 'actions: params: tag: "'"${CIRCLE_SHA1}"'"' --log-format=plain
workflows:
version: 2
ci:
jobs:
- test-and-push
pushしてworkflowを実行すると、Github Actionsの場合と同じ結果になります。
まとめ
たしかに、パイプラインのメインロジックをDaggerで構成することで、CI/CDの特定サービスへの依存度を下げることができそうでした。
ベンダー独自のキャッシュ機構への対応などまだまだ検証すべき点はありそうですが、そのあたりがもう少しクリアになればスモールに導入してもいいかも と思えるくらい強力なツールだと思いました。
参考になりましたら幸いです!
参考
Discussion