イメージ一枚を開けると何が入っているのか
一言でいうと
OCIイメージは、Image Index → Image Manifest → Image Configという3重のJSONと、その下のレイヤーブロブでできていて、すべての断片が、自分の内容のSHA-256でアドレス指定されています。
なぜ必要なのか
docker pullの1行の裏で何が起きているかを知らないと、ダイジェスト固定のpullがなぜ失敗するのか、レジストリを冗長化したらなぜタグが食い違うのか、イメージを10個アップロードしたのになぜディスクは少ししか増えないのかを、説明できません。3重の構造を1回だけ開いて見れば、これらの質問がすべて同じ図の上に並びます。
どう動くのか
Image Index(fat manifest)は、プラットフォームごとのマニフェストの一覧です。arm64のMacでpullしたのにamd64のイメージが来る事故は、ここで分かれます。
Image Manifestは、configのダイジェスト1つと、順序が保証されたレイヤーの一覧、そして各項目のmediaTypeを持ちます。よく見るmediaTypeは、次のとおりです。
application/vnd.oci.image.index.v1+json
application/vnd.oci.image.manifest.v1+json
application/vnd.oci.image.layer.v1.tar+gzip
Image Configは、ランタイムが実際に読む設定です。Env、Entrypoint、Cmd、WorkingDirのような値と、rootfs.diff_ids、そしてhistoryが入っています。
ここで、最もよく混同される区別が出てきます。
- マニフェストの
layers[].digestは、tar+gzipで圧縮されたブロブのハッシュです。ネットワークでダウンロードするのが、これです。 - configの
rootfs.diff_idsは、圧縮を展開したtarのハッシュです。ローカルでレイヤーを積むときに使うのが、これです。
2つは別の値です。docker image inspectのRootFS.Layersに見えるのはdiff_idのほうで、レジストリで見るダイジェストは圧縮されたほうです。これを知らないと、「同じイメージなのにハッシュが違う」という思い違いに陥ります。
コンテンツアドレス指定の利点は、3つにまとめられます。重複排除(同じ内容なら1回だけ保存)、完全性の検証(受け取ったバイトをハッシュ化して名前と比較)、不変性(同じハッシュなら同じ内容)です。
現場での姿
ダイジェストとタグの違いが、実務の半分です。ダイジェストは内容のハッシュなので、衝突も、キャッシュの無効化の問題もありません。タグは人が付けた名前で、いつでも別のダイジェストを指すように変わります。ここが状態であり、分散システムで難しい部分は、すべてここにあります。レジストリを複数台にまとめようとすると、結局タグの更新を直列化する問題に収束する理由が、これです。
フォーマットの変換にも注意が必要です。DockerスキーマからOCIに移すと、マニフェストのバイトが変わり、バイトが変わるとダイジェストが変わります。すると、ダイジェストで固定したpullと署名検証が、一緒に壊れます。「内容は同じなのに、なぜ動かないのか」の答えが、これです。
レイヤーの共有は、数字で見ると明確です。ubuntu:22.04の上にnodeを載せたベースを使うイメージが10個あれば、保存されるのはベース1倍 + アプリレイヤー10倍です。そのため、ベースを統一することが、そのままレジストリの容量ポリシーです。
レイヤーを少なく、そして安定した順序で
イメージの構造を知ると、なぜあるDockerfileはビルドが数秒で、あるものは毎回数分かかるのかが説明できます。キャッシュはレイヤー単位で、1つのレイヤーが変わると、その後ろのすべてのレイヤーが作り直されるからです。
そこから順序の原則が出てきます。あまり変わらないものを前に、よく変わるものを後ろに置きます。依存関係の一覧だけを先にコピーしてインストールし、そのあとでソース全体をコピーする形が定石である理由が、これです。ソースを先にコピーすると、1文字直しただけでも、依存関係のインストールからやり直しになります。
レイヤーの数そのものもコストです。レイヤーごとにメタデータが付き、ダウンロードのときにリクエストが分かれ、ファイルシステムが重ねて積む層が増えます。ただしむやみに1行にまとめると、キャッシュがまるごと無効になるので、一緒に変わるものどうしをまとめることが基準です。
そして削除したファイルは、イメージから消えません。前の層で作ったファイルを後ろの層で削除すると、重ねて見るときにはありませんが、前のレイヤーにはそのまま残ってサイズに含まれます。前のモジュールで見たシークレットの問題とまったく同じ構造で、サイズの面でも同じ結論になります。同じレイヤーの中で作って消すか、最初からマルチステージビルドで成果物だけを移す必要があります。
マルチステージビルドは、この問題を最もきれいに解決します。ビルドツールと中間の成果物は前のステージに置き、最後のステージには、実行に必要なものだけをコピーします。コンパイラとキャッシュがまるごと抜けるので、イメージが何倍も小さくなり、攻撃対象領域も一緒に縮小します。最終イメージにないツールは、侵入者も使えません。
最後に、ベースをダイジェストで固定するか、タグのままにするかを決める必要があります。ダイジェストで固定すると再現性は完璧ですが、セキュリティの更新が自動では来ません。タグのままにすると更新はついてきますが、昨日と今日のビルドが別のものを使うことがあります。定石は、固定しておいて、更新を自動で提案する仕組みを別に置くことです。
次のラボですること
イメージIDがそのままコンテンツのハッシュであることを確認し、イメージをtarにエクスポートして、中のマニフェストとconfigブロブを直接開いてみます。skopeoで、同じ情報をレジストリツールの視点からもう一度見て、同じベースから作った2つのイメージが最初のレイヤーを共有していることを、ダイジェストの比較で証明します。