世界を動かす技術を、日本語で。

BuildKit: Dockerの隠れた宝石、ほぼすべてを構築できる

概要

  • BuildKit はDockerfileビルドエンジンの裏側にある高機能なビルド基盤
  • LLB という中間表現とプラグイン可能なフロントエンド設計
  • キャッシュ と並列化で高速かつ再現性の高いビルドを実現
  • Dockerfile以外 の独自仕様や出力形式にも柔軟に対応
  • EarthlyやDagger など多くのプロジェクトがBuildKitを基盤として採用

BuildKitとは何か

  • BuildKit は一般的なビルドフレームワークであり、単なるDockerfileビルダーではない
  • OCIイメージ だけでなく、tarballやローカルディレクトリ、APKやRPMなど多様な成果物を生成可能
  • Dockerfile は数あるフロントエンドの一つに過ぎず、独自フロントエンドの開発も可能

アーキテクチャの概要

  • LLB(Low-Level Build definition) はBuildKitの中核となる中間表現

    • プロトコルバッファ形式のバイナリで、ファイルシステム操作のDAG(有向非巡回グラフ)を記述
    • 内容アドレス指定 により、同一操作は同一ハッシュとなり強力なキャッシュを実現
    • DockerfileフロントエンドはDockerfileをパースしLLBを生成
    • LLBを生成できればどんなプログラムでもBuildKitを駆動可能
  • フロントエンド はビルド定義(Dockerfile, YAML, JSON, HCLなど)をLLBに変換するコンテナイメージ

    • BuildKit Gateway API経由でビルドコンテキストとビルドファイルを受け取り、LLBグラフを返却
    • ビルド言語はBuildKit本体に固定されておらず、完全なプラグイン方式
      • YAMLやTOML、独自DSLなど好みの仕様でフロントエンドを開発可能
    • Dockerfile先頭の# syntax=ディレクティブでフロントエンドイメージを指定
      • 例:# syntax=docker/dockerfile:1がデフォルト
  • ソルバーとキャッシュ はLLBグラフを実行

    • 各DAGノードは内容アドレス指定され、入力が同じなら完全にキャッシュ利用
    • 従来のDockerビルダーよりも細かい粒度で高速なキャッシュや並列実行を実現
    • キャッシュはローカル、イメージ埋め込み、リモートレジストリなど多様に対応
    • CI環境間でも再利用や共有が容易

画像以外の成果物出力

  • --outputフラグで多彩な出力形式を選択可能
    • type=image:レジストリへプッシュ(docker buildのデフォルト)
    • type=local,dest=./out:最終ファイルシステムをローカルディレクトリへ
    • type=tar,dest=./out.tar:tarballとしてエクスポート
    • type=oci:OCIイメージtarballとして出力
  • type=local は非イメージ用途で特に有用
    • バイナリ、パッケージ、ドキュメントなど任意成果物の生成・出力
    • コンテナイメージ不要、柔軟なビルドパイプライン構築が可能
  • Earthly、Dagger、Depot などのプロジェクトもBuildKitのLLBを基盤として利用

カスタムフロントエンドによるAPKパッケージビルド例

  • apkbuild :YAML仕様を読み込みAlpine APKパッケージを生成するBuildKitカスタムフロントエンド

    • Dockerfile不要、YAMLでビルド仕様を記述
    • LLB操作でソースビルドからAPK生成まで一貫実行
    • 例:Chainguardのmelangeの簡易版
    • YAML以外にもJSONやTOML、独自DSLでも対応可能
  • サンプルYAML仕様

    • name, version, epoch, url, license, description, sourcesなどを記述
    • 必要最低限の情報でビルド定義が完結
  • 実行手順

    • フロントエンドイメージのビルド:docker build -t tuananh/apkbuild -f Dockerfile .
    • APKパッケージビルド:
      cd example
      docker buildx build \
        -f spec.yml \
        --build-arg BUILDKIT_SYNTAX=tuananh/apkbuild \
        --output type=local,dest=./out \
        .
      
    • BUILDKIT_SYNTAX でカスタムフロントエンドを指定
    • --output type=localで生成ファイルを./outに保存、イメージやレジストリ不要

BuildKitの意義と活用可能性

  • 内容アドレス指定・並列化・キャッシュ を備えた高性能ビルドエンジンを無償で利用可能
  • フロントエンドを開発しLLBへ変換すれば、ビルド処理はBuildKitに任せられる
  • CI/CDや複雑なビルドパイプライン基盤としても実績多数
    • DaggerはCI/CDの実行エンジンとしてLLBを利用
    • EarthlyはEarthfileをLLBに変換してビルド
  • 独自ツールや成果物生成、マルチステップビルドの基盤としてBuildKitの活用を推奨
    • Dockerfileは単なるデフォルトフロントエンド
    • 本質的な強みはエンジンの柔軟性と拡張性にあり

Hackerたちの意見

残念ながら、Makeはもっとちゃんとしたソフトウェアにするべきだと思う。結局、DockerfileはMakefileの失敗したバージョンだったんじゃないかな。YAMLやDockerfileは、こういうアプリケーションにはあまり向いてないインターフェースだよね。最近のコードファーストの選択肢はかなり良いけど、Makeや他のレガシーツールでもそこそこやれる。Dockerは、業界標準を進めるってよりも、まずはエンタープライズソフトを売りたい会社って感じがするな。でも、この記事は良かったよ!

同じようなことを思ったんだけど、この記事を読んでると「これってnixのちょっと劣化版みたいだな」って思った。Nixには、キャッシュ付きのコンテンツアドレスビルドDAGや中間言語、任意の出力を生成する能力があるけど、機能型なんだよね(ハッシュやロックファイルにすべての入力が含まれなきゃいけない)。Dockerでは、apk add firefoxみたいに外部ソースからデータを引っ張ってくるコマンドが実行できるから、同じハッシュでも異なる出力になることがある。だから、この記事が間違って主張してるように再現性がないんだよね。追記:ハッシュが同じっていう主張は間違いだけど、同じDockerfileでも異なるマシンや日によって異なる出力が出ることがある。一方で、nixは特定の入力に対して常に同じ出力を出すんだ。

Makeはタイムスタンプベースなんだよね。これは完全に時代遅れのアプローチで、単一のコンピュータにしか適してない。今の時代は、分散ハッシュベースのキャッシングが必要だよ。

SREだけど、両方ともソースコードを実行可能にするための指示書って感じがする。dockerやコンテナが「デプロイ可能なパッケージ」を提供してるけど、言語が自己完結型のバイナリにコンパイルされない場合(Python、Ruby、JS、Java、.Net)でもね。あと、makeとソースコードをコンパイルするために必要なツールを持ったコンテナを作ることを止めるものは何もないよ。そのツールを使って出力を生成し、ファイルシステムに残すDockerfileを書くこともできる。なんでそのアプローチかって?コンパイルの摩擦が少なくなるから。大抵のmakeユーザーはペットビルドサーバーを持ってることが多いし、変更を加えるのも衝突が多くて摩擦が大きいからね。

アーティファクトにはbuildkitを使ってないけど、OCIレイアウトに画像を出力するのは好きだな。そうすれば、イメージをレジストリにプッシュする前にローカルでチェックや更新ができるから。でも、buildkitの本当の隠れた力は、Dockerfileパーサーを入れ替えられることなんだ。これが実際にどうなるか見たいなら、彼らのハードニングされたイメージの一つで使われてるこのDockerfileを見てみて:https://github.com/docker-hardened-images/catalog/blob/main/...

カスタムフロントエンドを作るためのリポジトリの例も含めたよ:https://github.com/tuananh/apkbuild

両方の意見に賛成!BuildKitのフロントエンドはあまり知られてないけど、使い方を知ってるとすごく強力だよね。BuildKitがそれをどう変換するかも理解してると、さらに良い。

BuildKitにはたくさんの苦労もあるよ。Dagger(多くの言語でBuildKitに対する素晴らしいインターフェースのセット)がそれを解消しようとしてる。BuildKitのメンテナもそれが良いアイデアだと思ってるみたい。BuildKitはすごくクールな技術だけど、大量に運用するのは痛いところもある。BuildKitとDockerfilesの直接の違いで面白いのは、読み込んだENV変数のマップのイテレーションが一貫してるかどうかってこと。そうじゃないから、キャッシュが壊れ続けるんだよ。これをリニアなDockerfileではできないんだ。

コンテナビルドのセットアップを全部buildkitに切り替えたよ。kanikoもbuildahもdindもなし。いいところは、buildkitdとbuildctlを分けられること。すべてが独自のDockerランナーで動くんだ。ジョブごとに新しいbuildkitdサービスを立てて、キャッシュはbuildkitのネイティブキャッシュエクスポートだけで行う。出力フォーマットはociイメージで、zstdで圧縮してる。今のところすごくうまくいってるし、ビルドも同じかそれ以上の速さで、マルチアーキテクチャのイメージも作れるようになったよ。ちなみに、すべてルートレスランナーでやってる。

これは変な二重投稿だね、全部大文字のやつが投稿された! https://news.ycombinator.com/item?id=47152488

Buildkit… 理論上は素晴らしいけど、実際には全然ダメだね。キャッシュが壊れてるし、毎回リモートコンピュータにビルド状態を送るのはほとんど無駄な作業だよ。だから、Podman+buildahに切り替えたんだ。こっちは前のシンプルなDockerのレイヤービルドシステムを使ってるからね。信じられないなら、GitHubでマルチステージイメージのキャッシュを使ってみてよ。ベースイメージとそこから作ったいくつかのイメージを用意して、GHAキャッシュを使って引っ張るデータ量を減らそうとしてみて。

buildahはどうやって使うの? dockerfileを使うの? buildahはdockerfile使うとすごく遅く感じるんだけど…

なんでひどいGHAキャッシュを使うの?もっと効率的なレジストリベースのキャッシュがあるのに。

ARM OS Xでbuildxキャッシュをちゃんと使えるようにする方法が全然わからなかった。x86イメージを定期的にビルドする必要があると、本当に厄介だよね。

Depot [0]をここ3年間ビルドしてきたけど、BuildKitを使ってリモートコンテナビルダーを運営してきたおかげで、たくさんの傷跡ができたよ。見た目や響きはすごくパワフルだけど、現実は全然違う。自家製のアイデアがごちゃごちゃしてる感じ。一部は本当にスマートだけど、他は考えるのが難しかったり、最悪の場合は触るのが怖かったりする。私たちはDepotの旅の初期にBuildKitをフォークしなきゃいけなかった。私たちのユースケースに合わせてたくさんの問題を修正したよ。いくつかは早い段階でアップストリームしようとしたけど、何らかの理由でうまくいかなかった。今では、私たちのコンテナビルダーは独自のBuildKitバージョンを使ってるから、エコシステムとの互換性は100%保ってる。でも、私たちの実装はかなりシンプルになってる。いつかその実装をオープンソースにして、これらのアイデアをスケールで適用した場合に何が可能かを示したいな。[0] https://depot.dev/products/container-builds

自家製のアイデアがごちゃごちゃしてる感じ。一部は本当にスマートだけど、他は考えるのが難しかったり、最悪の場合は触るのが怖かったりする。これはパッケージングやビルドシステム全般に言えることだね。多くの場合、組織の一人か数人の情熱プロジェクトで、外部の開発が活発になる頃には、その独特なコンセプトはすでに固まってしまってる。こういうプロジェクトが構成要素に分解されて、新人が理解しやすいコードの整理がされているのは本当に珍しい。すべてのコードが公開されていても、特定のことがなぜそうなっているのかの重要な理由は数人の開発者の頭の中に閉じ込められてる。

数ヶ月前に自分の組織でDepotを導入したんだけど、すごく満足してる。コンセプトはシンプルで、以前にビルドしたレイヤーがそのまま使える温かいコンテナビルダーって感じ。ローカルビルドと同じようにね。でも、実際にスムーズに動かすためには色々な工夫が必要で、ステップ間の依存関係やそれぞれの時間を示すパフォーマンス重視の分析がすごく役立ってる。製品に対する配慮がたくさん詰まってるのが伝わるし、立ち上げの時にサポートチケットに個人的に対応してくれたのも感謝してるよ。

カイル、貴重な情報ありがとう。Depotがオープンソースにしてくれたら、コミュニティにとってすごく素晴らしいことになるね。

すごく興味深いね。これって「自分のコンピュートを持ち込む」ためにAWS専用ってことを正しく読んでるのかな?

それ以外は、非トリビアルなネットワーキングや密閉ビルドを必要とするものは除いて。

パッケージマネージャー用の--mount=type=cacheは、本当に革命的だよ。これを理解するまでは、Dockerfileでのpip installやapt-getは遅い(キャッシュなし)か壊れやすい(requirements.txtを早めにCOPYして、レイヤーキャッシュが持つことを祈る)だった。誰も教えてくれないけど、キャッシュマウントはビルダーデーモンにローカルなんだ。エフェメラルなCIインスタンスでビルドを行っていると、そのキャッシュは毎回消えちゃって、また最初からやり直しになる。レジストリキャッシュバックエンドはこれを解決するために存在するけど、複雑さが増すからほとんどのチームは諦めて遅いビルドを受け入れちゃう。もう一つの過小評価されているBuildKitの機能はsshマウントだね。SSHエージェントをビルドステップに転送できるのは、レイヤーに鍵を焼き込むことなくできるから、最初からDockerにあってもよかった機能だよ。中間レイヤーにSSHキーがうっかり残っているプロダクションイメージを見たことがあるけど、本当に心配になるよ。

Docker buildの「お母さん状態」みたいな行動が大嫌い!ビルドコンテナやキャッシュの外でファイルやデータを変更できないのは本当に面倒。データを共有するためのNFSマウントとか、ビルドからファイルをコピーするのもダメって、もうやめてほしい!副作用があってもいいじゃん、私は同意した大人なんだから、その結果も理解してるし!!!

なんか業界に問題があると思うんだけど、プロダクションビルドにSSHキーが必要なときに、問題はそのキーがビルドアーティファクトに漏れることだって考えるのが変だよね。

でも問題は、それがゴミだってこと?実際に動くって書いてあることを何度も試したけど、実装されてなかったり、バグがあったりすることが多い。Dockerは本当にひどいソフトウェアだよ。