イメージ参照ポリシー — タグ、ダイジェスト、そしてその上に重ねるもの
一言でいうと
タグは付け替えられるラベルで、ダイジェストは内容のハッシュなので付け替えられません。イメージポリシーのほとんどすべては、この一文の系です。
なぜ必要なのか
デプロイの事故の中に、再現が最も難しい種類があります。昨日は正常に動いていたものが、今日は違う動きをします。マニフェストはそのままです。コードもそのままです。それなのに違います。原因がイメージのとき、たいていは、同じタグが別の内容を指すようになったからです。
Kubernetesの公式ドキュメントは、この性質を短く明言しています。タグは別のイメージを指すように付け替えられますが、ダイジェストは固定されます。ダイジェストはイメージの内容のハッシュで、不変です。そのため、app:v1.2.3は約束ではなく、慣習です。レジストリは、同じタグに新しいイメージをプッシュすることを防ぎません(不変タグの設定をオンにしたレジストリでなければ)。
latestは、この問題の極端な例です。タグを書かないと、Kubernetesはlatestを意味するものとみなします。そして、もう1つの事実が付け加わります。タグを省略するか、:latestのままにしてimagePullPolicyを書かないと、その値が自動的にAlwaysになります。つまり、Podが再起動されるたびに、その瞬間にレジストリが与えるものを取得してきます。昨日と今日が違う理由が、ここにあります。
どう動くのか
イメージの参照は、3つの部分です。
registry.example.com/team/app:v1.2.3@sha256:1ff6c18f...
└── 레지스트리 ──┘└ 이름 ┘└ 태그 ┘└─── 다이제스트 ───┘
このコードブロックの韓国語の4つの語は、順に、レジストリ、名前、タグ、ダイジェスト、という意味です。
- レジストリホストを省略すると、デフォルトのレジストリとして解釈されます。そのため、ポリシーの最初のルールは、たいてい「ホストを明示する」です。
- タグは、人が読むラベルです。小文字・大文字・数字と
_ . -で構成され、128文字までです。 - ダイジェストは、ハッシュアルゴリズムと値です(
sha256:...)。OCIイメージ仕様のdescriptorが定義するその値で、内容が1バイトでも違えば、値が変わります。
3つすべてを書くと、どうなるでしょうか。ドキュメントが正確に答えています。タグとダイジェストを一緒に書くと、取得するときはダイジェストだけを使います。そのため、実務で使う形が出てきます。人が読むためにタグを残し、実際の身元はダイジェストで固定するのです。
imagePullPolicyのデフォルト値も、参照の形が決めます。
| 参照 | imagePullPolicyを書かなかったとき |
|---|---|
| ダイジェストを書いた | IfNotPresent |
タグが:latest |
Always |
| タグなし | Always |
| それ以外のタグ | IfNotPresent |
ダイジェストが保証するものと、保証しないもの。ここで、誤解が多いです。
保証するものは、1つです。内容の同一性です。同じダイジェストを2回取得すれば、同じバイト列です。どのレジストリから取得しても、何年後に取得しても、同じです。
保証しないものは、3つです。
- その内容が安全であること。脆弱なライブラリが入ったイメージも、ダイジェストは何事もありません。固定は「何を取得するかがわかる」という意味であり、「取得するものがよい」という意味ではありません。
- 誰が作ったか。ダイジェストには、作者がいません。攻撃者が作ったイメージにも、ダイジェストがあります。
- どう作ったか。どのソースから、どのパイプラインが、どのコマンドでビルドしたかは、ハッシュに入っていません。
後ろの2つを満たすのが、署名(誰が保証するのか)と、来歴証明(provenance attestation。どのように作られたのか)です。SLSAのprovenance仕様が、後者の標準的な形式を定義しています。ビルダー、ソースリポジトリとコミット、ビルドパラメーターを、署名されたドキュメントとして残します。ダイジェストの固定は、これらの上の層が立つ土台です。何に署名するかを指すのが、結局ダイジェストだからです。このラボ環境にはcosignのような署名ツールがないため、署名と来歴証明は概念としてのみ扱います。
ポリシーをかける2つの場所です。
内側(アドミッション)です。Podスペックのイメージ文字列を見ます。許可するレジストリのプレフィックス、latestの禁止、ダイジェストの要求です。ここで重要な落とし穴が1つあります。イメージ文字列の検査は文字列の検査なので、簡単にすり抜けられます。registry.example.comを許可リストに入れて、プレフィックスの検査だけをすると、registry.example.com.evil.net/xが通過します。ホスト境界(/の前の部分)を正確に区切って比較する必要があります。また、コンテナはcontainersにだけあるわけではありません。initContainersとephemeralContainersを抜かしたポリシーが、よくあります。
外側(ビルドとリポジトリ)です。DockerfileのFROM、マニフェストとHelmチャートのイメージの値、ビルドのアーティファクトのイメージの一覧。アドミッションは最後の防衛線で、ここは人が直せる場所です。FROM alpine:3.20がリポジトリにあると、ビルドのたびに、違うベースが入ってくる可能性があります。同じルールを両側にかけておけば、開発者はPRで気づき、クラスターは、もしも漏れ出たものを防ぎます。
現場での姿
1つ目は、タグが上書きされた日です。CIがapp:v1.4.0を2回プッシュしました。クラスターのPodは変わっていないのに、その日に再起動されたPodだけが、新しい内容で起動しました。同じDeploymentの中で、Podごとに違うコードが動く状態になり、原因は、ダイジェストを比較してはじめて見えます。kubectl get pod -o jsonpathでstatus.containerStatuses[].imageIDを取り出すと、実際に取得したダイジェストが出ます。
2つ目は、ロールバックできないロールバックです。タグだけでデプロイしたチームが、「前のバージョンに戻そう」と決めたのに、そのタグがすでに上書きされていました。戻す対象が消えていたのです。ダイジェストでデプロイしていれば、戻すアドレスが残っています。
3つ目は、ダイジェスト固定のコストです。固定は無料ではありません。ベースイメージのセキュリティパッチが自動的には付いてこないため、固定した値を定期的に上げる手順が、一緒にある必要があります。その手順がないと、数か月後に、「再現は完璧だが、すべて脆弱」という状態になります。固定と更新は、セットです。
4つ目は、許可リストの穴です。プレフィックスの比較で書いたポリシーが、似た名前の外部ホストを通過させてしまう事故が、繰り返されます。ルールを書くときは、必ず通過すべきサンプルとブロックされるべきサンプルを一緒に置き、特に名前が似た攻撃サンプルを入れておきます。
参考ドキュメント
- Kubernetesのイメージ: https://kubernetes.io/docs/concepts/containers/images/
- OCIイメージ仕様のdescriptor: https://github.com/opencontainers/image-spec/blob/main/descriptor.md
- OCIディストリビューション仕様: https://github.com/opencontainers/distribution-spec/blob/main/spec.md
- SLSA provenance: https://slsa.dev/spec/v1.0/provenance
- アドミッションコントローラー: https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/
次のラボですること
イメージの参照を自分で分解して、ポリシーで検査します。オフラインのイメージのバンドルから、マニフェストとダイジェストを取り出して、タグとダイジェストがそれぞれ何を指すかを確認し、同じ内容を別のタグにコピーして、ダイジェストがそのままであることを見ます。そのあと、許可するレジストリ・latestの禁止・ダイジェストの要求の3つのルールを書き、名前が似たホストでプレフィックス比較をすり抜けるサンプルと、initContainersにだけ違反があるサンプルを入れて、ルールの穴を自分で見つけて塞ぎます。