DaggerでベンダーフリーなCIパイプラインを作成する

35512 ワード

最近よく話題になっている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関連のライブラリはここにあります。

https://github.com/dagger/dagger/blob/main/pkg/universe.dagger.io/aws

現状そこまでドキュメントが充実しているわけでは無いので、使い方が知りたい場合は上記リンクのテストコードを見れば把握できると思います。

一応aws.#Commandもありますが、今回はaws.#Containerでコマンドを実行するイメージで作成してみようと思います。

sample-app.cue
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を配置しておきます(ソース見た感じ、現状は配置必須かもしれません)。

_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のコードを書きます。

src/main.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を作成します。

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を記述します。

.github/workflows/main.yml
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を記述します。

.circleci/config.yml
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の特定サービスへの依存度を下げることができそうでした。

ベンダー独自のキャッシュ機構への対応などまだまだ検証すべき点はありそうですが、そのあたりがもう少しクリアになればスモールに導入してもいいかも と思えるくらい強力なツールだと思いました。

参考になりましたら幸いです!

参考