Docker のイメージビルドの実装を読んでみた (BuildKit 編)
はじめに
Docker は複数のコンポーネントから成り立っており、その中でもイメージのビルドを担当するのは BuildKit と呼ばれるコンポーネントです。
日頃使用する機会が多いことから、以前からその内部実装には興味を持っていました。そこで、今回実際に内部実装を読み進め、イメージがビルドされるまでの大まかな流れを追ってみたいと思います。
Docker 内部の実装に興味を持つ方の参考になれば幸いです。
注意
今回は BuldKit を用いたイメージのビルドを想定していますが、 BuildKit は Docker Engine ver 18.09 から有効なツールです。
また、この記事の内容は moby/buildkit v0.11.6 を参照しています。
BuildKit とは
BuildKit は Docker のビルド機能を提供するビルドツールキットです。
ビルドツールキットとは、コマンドラインで docker build .
等を実行したとき、 Dockerfile を読み込んで命令を解釈するインターフェースで、複数の種類があります。以下はその例です。
- BuildKit (今回読むのはこれ)
- kaniko (Google 製)
- Buildah (Red Hat 製)
Docker CLI で docker build
を実行するとバックエンドコンポーネントではビルドツールキットを介してイメージのビルドを行います。今回は BuildKit がツールキットとして使用された場合を想定します。
ツールキットは Dockerfile をパースし、各命令を解釈して以下のようなアクションを実行します。
- ビルドに必要なイメージの取得
- ファイルのコピー
- シェルコマンドの実行
- 環境変数の設定
etc.
ただし、オプション解析やビルド時の権限の管理、セキュリティ周りの機能(provenance や attestations) といったビルドの大筋から外れる機能に関する箇所は省略しています。
大まかに言うと、コントローラ側でビルド時に指定されたオプション解析他の前準備にあたる処理を行い、実際のイメージのビルドはソルバーで実行しているようです。
そこで、今回はソルバーの実装を読み進めたいと思います。
ソルバー
以下では具体的にソルバーの実装を確認しましょう。
BuildKit の llbsolver.Solver
の Solve()
を読みます。
ソルバーで実行している内容は大きく分けて以下の 4 つです。
- 権限の管理
- ビルドプロセスの開始
- ビルド結果のリリース
- エクスポート
権限の管理
entitlements.WhiteList()
でビルドプロセスに必要となる権限を管理しています。
ビルド中には異なる権限が必要になることがありますが (例:package 管理で root 権限が必要)、この ソルバーのフィールド entilement
でそれらの権限を管理しているようです。
面白いと感じたのが、 type Set map[Entitlement]struct{}
で、値は空の構造体で、キーのみに着目した変数を作るイディオム。これは Golang で集合を表現する際よく使われるとか。
LLB (Low-Level Builder)
ビルド周りの記述でしばしば目にするのが LLB という単語です。
ビルドプロセスについて読み進める前にこちらを解説したいと思います。
moby project の公式ブログの BuildKit に関するエントリー Introducing BuildKit では
At the core of BuildKit is a new low-level build definition format called LLB (low-level builder).
と紹介されており、 BuildKit の実装を読み進める上で重要な概念であることが分かります。
https://blog.mobyproject.org/introducing-buildkit-17e056cc5317 から引用
LLB (Low-Level Builder) はビルドプロセスの依存関係を表すために使用される中間言語です。
LLB のインスタンスは、ビルドプロセスに対応し、すなわち一つの Dockerfile の実行内容が一つの LLB インスタンスに対応します。
ビルドプロセスの開始
*package.GatewayForwarder
の RegisterBuild()
が、ビルド開始に該当する箇所のようです。具体的には GatewayForwarder のインスタンスに LLBridgeForwarder
をマッピングしています。
最終的に sync.Cond
オブジェクトに対して Broadcast()
を呼び出すことで、それまで待機していたゴルーチンを再度起動しています。
BridgeForwarder
がビルドされた場合は、その処理を待って結果を受け取るという流れのようです。
どうやらこの構造体 LLBBridgeForwarder
がビルドプロセスを読み解く上で重要そうです。
コンテナに対応するフィールドがあり、この内部で LLB のタスク、すなわち Dockerfile の一行ごとの実行内容が処理されるのではないかと推測しています。
ビルド結果のリリース
ここからはビルド結果をリリースしていく実装が続きます。コードを追いかけていくと containerd (高レベルのコンテナランタイム) の実装に入ることもあり、コンテナ実装の低レイヤーを読んでいる実感があります。
type cacheRefShare
はビルドプロセス中にキャッシュされたレイヤを共有するための情報を保持するようです。
標準的な docker build
コマンドの実行では Dockerfile を読み込んでいって途中でキャッシュが効かなくなると、それ以降の行でキャッシュが効かなくなりますが、そうした背景にはこの辺りの実装が関わっているのかもしれません。
ちなみに kaniko では一行ごとにキャッシュの有無を確認しているため、上記のようなことは起きないそうです。こちらはいずれ実装を確認して比較したいと思っています。
以降は frontend.Result
のインスタンスをビルドする処理が続きます。
provenance や attestaions などセキュリティに関する機能が挿入されています。
この辺りは Go 1.18 以降で有効であるジェネリクスを使って書かれており、コミット日時も比較的最近(2022/12/21) であることから、リファクタが進行している箇所なのかもしれません。実際にコードを読むとコード編集の変遷が伺えて興味深いです。
ビルド結果をこのメソッドでそれぞれ変換しています。
この中では loadLLB() の実装が重要そうです。
LLB の定義を解析し、ビルドプロセスの全ての操作を allOps
という変数にマッピングしています。
キーは操作自体から計算したダイジェストを用いています。
エクスポート
最後にビルド結果のエクスポートに関する処理が続きます。
ローカルにエクスポートする処理に関してはここを読めば良さそうです。
exporter.Source
がビルド結果に相当し、それを元にホスト OS 上に一時的なファイルシステムをマウントし、ビルド結果をコピーしているようです。
所感
- イメージがビルドされるまでの大まかな流れが見えた。
- Dockerfile をパースして、イメージがビルドされるまでの流れを大掴みに理解できた。
- 一度大まかに処理の流れを追うと、今後新たな機能が追加された際にもその差分が理解しやすそうです。
- 読み進める上でのコツ
-
context.Context
で関連するデータを取り回す書き方に慣れると良さそうに感じました。特にcontext.WithValue()
は頻出です。 - *package.GatewayForwarder の RegisterBuild() / UnregisterBuild()
- 並行処理が多く、慣れるまで戸惑った。例えば select 文で
context.Done()
を待ち受ける実装などが頻繁に見られた。だが慣れてしまえば気にならなかった。- Golang での並行プログラミングは公式のブログ記事が分かりやすかったです。
- 引数の解析ではレスポンスのフィールドなど map 変数が多用されている
- map の key が string 型で、prefix によって条件分岐するパターンが多い。ソルバーの実装でよく目にした。
-
- OSS のコードリーディングに ChatGPT は超便利。
- 「
moby/buildkit/XXX/XXX
パッケージのXXX
という構造体は何ですか?」 程度の聞き方でも詳細に背景を説明してもらえる。 - もちろん、事実と異なるまたは古い記述が混じる可能性もありますが、適宜、公式ドキュメントで裏を取りながら読めば問題ないように感じました。
- 「
参考
Discussion