CIプラットフォームにできるだけ依存しないCI構築にDockerfile/BuildKitがいいかもしれない
TL;DR
- マルチステージDockerfileでCIのビルド・テスト等のジョブを記載する
- BuildKitが適切に必要なジョブを実行してくれる。
- (BuildKitを有効にした)
docker build
相当が実行できれば良いので、CIプラットフォーム依存の設定(yml等)を減らすことができ、ローカルマシンでも実行できる - Docker依存のため、Windows/macOS上でのビルド・テストが必要なケースには向かない
- Dockerfileでシークレットを取り扱う方法も整備されたので、Packageレポジトリへの登録・公開も可能だろう
- 但し、ブランチ・タグなどの条件分岐はCIプラットフォーム依存であり、CIプラットフォーム上で直接書いたほうが良いかもしれない。
1. CIを取り巻く情勢
ソフトウェア開発・サービス開発を効率的に行うために、CI/CDを利用することがますます一般的になってきていると思います。私もGitHub Actions や GitLab CI/CD を利用しています。
その設定は、(だいたい)YAML (yml)ファイルで定義することは共通ではあるものも、設定の内容はCIプラットフォーム毎にバラバラで、乗り換えようと思うと手間がかかる状況です。(ベンダーロックイン。)
一方で、CI/CDプラットフォームを取り巻く状況は色々と変わってきており、移動できないことはリスクでもあると感じ始めています。
例えば、GitLab.comは申請性のGitLab Open Source Programを開始するに当たって、PublicレポジトリへのUltimate tierの自動付与を辞めました。
先立って2021年にFree tierのShared Runnerを月400分に制限したことと相まって、上記プログラムを申請しないとPublicレポジトリもShared Runnerの利用に制限がかかるようになりました。[1]
また、著作権やライセンス条項で議論が巻き起こっているGitHub Copilotが一般公開されたことを受けて、NPOのSoftware Freedom Conservancy(SFC)が、GitHubの利用を辞めようと呼びかけていることが先日ニュースにもなっていましたね。(私はSFCがどういう団体なのかよく知らないので、どう判断すべきかと思っていますが。)
2. あと一歩: Dagger (dagger.io)
そんな中、少し前にCIプラットフォーム非依存を謳うDaggerが話題になっていました。
私も気になったので、ちょこちょこ試して躓いた点をスクラップにまとめたりもしました。
試してみた限りでは、いいなと思う点もあったのですが、しんどい点もありました。
- ❌ YAMLではなくCUE言語で定義を書く
- YAMLでスクリプトや定義を書くのは案外面倒なのでYAMLを辞めるのは良い
- 一方、CUE言語がYAMLよりも簡単とは感じなかった。むしろ色々苦労した。
- (Go言語に精通している人ならそこまでではないのかも?)
- ❌ (私の問題?)コマンドがシェルに渡される前に1つ挟むので、意図した通りのコマンド実行ができなかった。
- ファイル名が動的に変わるからシェルのワイルドカード(
*
)使いたいけど、展開されないまま実行された。 - ワイルドカード使えないなら、
find -exec
しようかと思ったが、;
{}
などが適切に解釈されなかった - パイプ (
|
) もうまく行かず
- ファイル名が動的に変わるからシェルのワイルドカード(
- ❌ (書き方の問題?) 使用容量が大きいのか、GitHub Actionsのランナーがディスクスペース不足で失敗した
- (後述の提案方法に書き換えたら、全く問題なくなった。)
- ⭕ CIプラットフォーム非依存で書け、ローカルでも実行できる
- ⭕ BuildKitにより各ジョブがキャッシュされ、また必要なジョブだけが実行される。
いい点もあるのになぁと考えていた時に思いつきました、『この長所ってDocker/BuildKitの機能じゃない?』と。
3. 提案: Dockerfile/BuildKit
前置きが長くなりましたが、Dagger/CUE言語のレイヤーを取り払って、直接DockerfileでCIパイプラインを定義したら良いのではないかと言うのが今回の提案・記事の首題です。
(DockerはBuildKitを有効化しておく必要があります。)
目下開発しているプロジェクトで書いてみた例(Dockerfile + GitHub Actions用のyml)が以下になります。
ポイントは以下です。
- Dockerfileのマルチステージを活用し、CIジョブをそれぞれ別のステージとして書く
- Dockerfileなので、各レイヤーの処理(RUN命令)は、
/bin/sh
または指定したシェルによって解釈されるので、パイプやワイルドカードを使ってコマンドを書ける - COPY/ADDするファイルはできるだけ小さく区切って明確にする
- 通常はレイヤーを削減して小さなコンテナイメージを作成するために、
.dockerignore
+ レポジトリ全体一括COPY/ADDが推奨されていると思いますが、そうするとファイルを一つ変更するだけでキャッシュが全滅してやり直しになるので、今回の目的にはあいません。 - 例えば、中間のテストジョブのイメージを配布するわけではないので、多少のサイズを犠牲にしてでも依存ファイルを明確にすることが好ましいです。
- (もしあれば)配布用のコンテナイメージを作成するジョブのみイメージサイズを注意すればよいでしょう。
- 通常はレイヤーを削減して小さなコンテナイメージを作成するために、
-
--target
を指定しないときは、最後に記載したステージが成果物になります。またBuildKitが依存するステージのみをビルドするので、分岐していると実行されません。- 個別に分岐先を
--target
指定してビルドするか、上の例のように成果物を回収するステージを最後において置くのが良いでしょう。
- 個別に分岐先を
- ローカルで実行する際には、各ステージの各レイヤーが自動的にキャッシュされます。
- BuildKitにはExport Cacheの仕組みがあり、ファイルシステム・コンテナレジストリ・GitHub Actionsのキャッシュにキャッシュしておくことができます。[2]
-
mode=min
が出来上がった成果物イメージだけ、mode=max
がレイヤー毎のキャッシュを取るようになっており、今回の用途では(保存容量が許される限り)mode=max
の方が適切でしょう。(GitHub Actionsのキャッシュはレポジトリ全体で10GBだそうです。) - 上の例でも、比較的時間のかかる処理(toolchainのダウンロード、pythonのconfigure等)があるのですが、キャッシュが効いている時には直ぐに完了します。
-
- 成果物を空のscratch コンテナイメージに入れた場合、実行しても取り出せないので、
docker create
でイメージからコンテナを作成して、docker cp
でファイルをコピーしてくるのが良いでしょう。 - 上の例では採用していませんが、パッケージ公開までDockerfile内部で実施する場合には、シークレットは適切に処理しましょう (参考)
- ブランチやタグのチェックなどCIプラットフォーム依存の割合が大きいので、あまりメリットがないかなと直書きしました。この辺りはもうちょっと考察が必要かもしれません。
- BuildKit はログをどんどん画面から消していくので、
--progress=plain
を付けておいた方が出力ログがわかりやすいかも。
まとめ
DockerfileとBuildKitを活用し、CIプラットフォームにできるだけ依存しないCIパイプライン構築を思いついて試してみました。
まだ構築して日は浅いですが、今の快適に感じています。
もちろん昔からある Jenkins のようなツールでも良いのだとは思いますが、シンプルに書ける手法としてDockerfile/BuildKitの構成もありだなと思い記事にまとめました。
誰かの参考になれば幸いです。
追記: 2022/07/06
記事を公開した直後に、まさにこの記事で提案しているような事を実現しているEarthlyというSWがあることを知りました。
Dockerfileを独自に拡張し、Makefileのようにしています。
成果物の受け渡し、抽出を簡易化する機能もあるようです。
また、IF・FORといった制御構文も用意されているようでした。
BuildKitによるレイヤーキャッシュを利用したビルドツールとの立ち位置ですが、テストについても触れられています。
Discussion