DaggerでPythonのCIを実装してGitHub Actionsで動かしてみる

今回の投稿では、ポータブルなCI/CDワークフローとして注目されている Dagger について紹介します。

例として、簡単なPythonアプリケーションのCI/CDをDaggerで実装してみました。

  • フォーマットチェック (black) 、静的チェック (flake8) 、テスト (pytest) を実行する
  • イメージをビルドしてコンテナイメージリポジトリ (Docker Hub) にプッシュする
  • GitHub ActionsでDaggerを実行する

コードを以下のリポジトリにあります。

github.com

Daggerとは

2022年3月にパブリックローンチされた可搬性のあるCI/CDパイプラインのツールキットです。開発にはDockerの開発者が関わっているそうです。

dagger.io

Daggerが着目しているのは、CI/CDワークフローのメンテナンスの大変さです。

  • 実行環境が多様なCI/CDのワークフローのデバッグが大変
  • CI/CDの実行環境の移行が大変
  • アプリケーションが大きくなるとワークフローの記述が重複しがちで、変更が大変

DaggerはCI/CDワークフローを、ポータブルにすることでデバッグや移行を容易にする、というのを目指しているようです。また、ワークフローを定義する言語として従来広く使われてきたJSONやYAMLではなく、より豊かな表現力を持つ言語を採用することで、ワークフローコードのメンテナンスも容易にする、というのも狙っています。

Daggerの主だった特徴は以下の4点です。1, 2がポータビリティ、3, 4がコードのメンテナビリティに貢献しています。

    1. ワークフローをDockerコンテナ環境内で動かす
    2. コンテナが動く環境であれば、ワークフローも動かせます
    3. moby/buildkitで実行されます
    1. プログラミング言語ごとのSDKなどは不要
    1. DAGをCUEで定義する
    1. 一連の処理をアクションとして再利用できる

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 (checkpushDockerhubなど) は無視されます。

        buildImage: docker.#Dockerfile & {
            source: client.filesystem.".".read.contents
            dockerfile: path: "Dockerfile"
        }

        pushLocal: docker.#Push & {
            image: buildImage.output
            dest: _local_dest
        }

checklintfmttestの出力に依存させることで、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の将来性も個人的には期待しています。

参考

MoT TechTalkでサーバサイドアプリケーションのRust活用について話しました

11/8に開催されたMoT TechTalk #8にて、AWS上で稼働するサーバサイドアプリケーションの1つをRust言語へリプレースした話をしました。

jtx.connpass.com

サーバサイドアプリケーションの他にも、RustでのエッジアプリケーションやCI/CDについてもがっちりカバーしています。 (というより、そちらの方が充実しています。私も勉強になりました。)

発表内容はYouTubeでも公開されていますので、興味があれば、そちらもご覧ください。

www.youtube.com

Kubernetesのコントローラの仕組みとカスタムコントローラの作り方

会社合同の勉強会で、前々からやろうやろうと思ってなおざりになっていた、Kubernetesのコントローラについて調べて発表しました。

資料はこちらです。

speakerdeck.com

最後のページに記載しているこちらの参考文献が大変勉強になりました。