ValidatingAdmissionPolicy — エンジンなしで API サーバーが直接止める
一言でいうと
ValidatingAdmissionPolicy(以下、VAP)は、Podも証明書もネットワークの往復もなく、APIサーバーの中でCEL式によってリクエストを検査する仕組みです。ルール(ポリシー)と適用範囲(バインディング)をあえて2つのオブジェクトに分けてあることが、その設計の核心です。
なぜ必要なのか
Webhookベースのポリシーエンジンは強力ですが、コストが高いです。エンジンのPodを起動し、TLS証明書を発行してCAバンドルをWebhookの設定に差し込み、レプリカとPodDisruptionBudgetで可用性を確保し、failurePolicy: Failをオンにした瞬間から、そのエンジンの可用性がそのままクラスターの可用性になります。「イメージタグがlatestなら拒否する」のような1行のルールを1つかけるために、これらすべてを引き受けるのが正しいのか、という疑問が、長い間ありました。
Kubernetesは、この疑問に、「単純なルールは、APIサーバーが直接判定するようにしよう」と答えました。公式ドキュメントは、VAPを検証Webhookのin-processの代替だと明言しています。ルールをCEL(Common Expression Language)式で書いておけば、APIサーバーがその場で評価します。ネットワーク呼び出しがないのでタイムアウトもなく、エンジンのPodがないので、エンジンが落ちてクラスターが止まることもありません。Kubernetes v1.30で正式(stable)な機能になり、このラボ環境のAPIサーバーが、まさにそのv1.30です。
どう動くのか
ポリシー1つは、最大で3つのオブジェクトで構成されます。
| オブジェクト | 入れるもの |
|---|---|
ValidatingAdmissionPolicy |
ルールの抽象的なロジック。どのリソースを見るか(matchConstraints)、何が真でなければならないか(validations) |
| パラメーターリソース(任意) | ルールが使う値。許可するレジストリの一覧、最大レプリカ数など |
ValidatingAdmissionPolicyBinding |
ポリシーとパラメーターを結び付け、どこに適用するかの範囲を与える |
公式ドキュメントが明言している文が、1つあります。ポリシーと、それに対応するバインディングの両方があってはじめて、ポリシーが効果を発揮します。バインディングのないポリシーは、文法エラーでも警告でもなく、ただ何もブロックしません。はじめて書く人が最もよくつまずく場所です。
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: demo-binding-test.example.com
spec:
policyName: demo-policy.example.com
validationActions: [Deny]
matchResources:
namespaceSelector:
matchLabels:
environment: test
分けておいたおかげで、同じポリシーを範囲を変えて何度もオンにできます。テストのネームスペースには最大レプリカ3で、本番のネームスペースには100で、という具合です。ポリシーのYAMLは1つで、バインディングとパラメーターだけが2つになります。ルールを直すとき、値ではなくロジックだけを触ることになる構造なので、レビューがずっと軽くなります。
validationActionsはバインディングに付きます。サポートされる値は3つです。
| 値 | 違反のとき |
|---|---|
Deny |
リクエストを拒否する |
Warn |
リクエストは通過させ、クライアントに警告として知らせる |
Audit |
監査イベントに記録する |
DenyとWarnは一緒に使えません(拒否されたリクエストは、すでにレスポンスの本文とHTTP警告ヘッダーで理由を知らせているからです)。そのため、実際の導入手順は、[Warn, Audit]でオンにして数日数えてみて、引っかかるものがすべて整理された後、バインディングだけを[Deny]に直す順序になります。ポリシーの本文は1文字も変わりません。これが、ポリシーとバインディングを分けた2つ目の理由です。
failurePolicyはポリシー側に付き、デフォルト値はFailです。式の評価そのものがエラーで終わったとき(フィールドがないのにhas()なしでアクセスした場合など)に、拒否するか通過させるかを決めます。ドキュメントに書かれた、微妙なルールが1つあります。failurePolicyが定義する失敗は、failurePolicyがFailのときにだけvalidationActionsに従います。Ignoreなら、その失敗はただ無視されます。
人が読める拒否メッセージを作るのも、ポリシーの仕事です。何もしないと、拒否メッセージはfailed expression: object.spec.replicas <= 5のように、式の原文がそのまま出力されます。messageExpressionにCEL式を与えれば、実際の値が入った文を作れ、spec.variablesで長い式に名前を付けておけば、variables.<이름>(プレースホルダーは名前です)で、複数の場所で再利用できます。変数は必要なときにだけ評価されるので、高コストな式を1回だけ計算する効果もあります。
spec:
variables:
- name: environment
expression: "has(namespaceObject.metadata.labels) ? namespaceObject.metadata.labels['environment'] : 'prod'"
validations:
- expression: "..."
messageExpression: "'only ' + variables.environment + ' images are allowed'"
matchConditionsは、さらに一歩手前です。ここに書いたCEL条件が偽なら、APIサーバーはポリシーをそもそも評価しません。システムのサービスアカウントのリクエストを除いたり、特定のラベルが付いたものだけを見るようにしたりするときに使います。条件の評価がエラーで終わると、Failはポリシーを評価しないままリクエストを拒否し、Ignoreはポリシーを飛ばしたまま通過させます。
値を外に出すのが、paramKind(ポリシー側)とparamRef(バインディング側)です。paramRefにはnameまたはselectorのどちらか1つだけを書け、parameterNotFoundActionは必須です。Allowならパラメーターが見つからなかったときに通過とみなし、DenyならポリシーのfailurePolicyに従います。このフィールドを抜かしたバインディングは、無視されたり、予期しない動作をしたりすると、ドキュメントが警告しています。
Webhookエンジンと比べると、何を得て何を失うのか
| 軸 | VAP | Webhookエンジン |
|---|---|---|
| インストール | なし。APIサーバーに内蔵 | Pod・証明書・CAバンドル・アップグレード |
| 可用性 | APIサーバーがそのままエンジン | エンジンが落ちると、Failのもとで書き込みが止まる |
| レイテンシ | プロセス内での評価 | リクエストごとにネットワークの往復 |
| 表現力 | CEL。入ってきたリクエストとパラメーターだけを見る | 任意のコード。クラスターを参照し、ミューテーション・生成も行う |
| レポート | 監査イベント・警告 | PolicyReportのような専用のレポートCRD |
まとめると、CELで表現できる検証はVAPに移し、クラスターを参照する必要があるものや、ミューテーション・生成が必要なものは、エンジンに残します。どちらか1つを選ぶ問題ではなく、ルールごとに置き場所を決める問題です。
現場での姿
1つ目は、何もブロックされないことです。10件中9件は、バインディングを作っていないか、matchConstraintsが実際のリクエストと合っていない場合です。何にもマッチしないポリシーは、正常に動いているポリシーと、見かけ上区別できません。そのため、ポリシーをオンにするときは、ブロックされるべきサンプルと通過すべきサンプルの両方を入れてみる必要があります。
2つ目は、拒否メッセージが式の原文のため、問い合わせが来ることです。開発者は、object.spec.template.spec.containers.all(c, ...)を受け取っても、何を直せばよいのかわかりません。messageExpressionで「コンテナwebのイメージnginx:latestは、許可されたレジストリの外にあります」のような文を作ってあげるのは、親切ではなく、ポリシーが実際に守られるようにする仕組みです。
3つ目は、型チェックは参考用であることです。ポリシーを作ると、APIサーバーが式を事前に検査して、status.typeCheckingに結果を残します。ただし、ドキュメントが限界をはっきり書いています。ワイルドカードが入ったmatchConstraintsは検査せず、組み合わせが多いと11個目からは無視し、CRDには適用されず、型チェックの結果はポリシーの動作を変えません。ここにエラーがないからといって、ポリシーが正しいという意味ではありません。
4つ目は、この環境の正直な限界です。kwokクラスターのAPIサーバーは本物なので、VAPのDenyもWarnも実際に動作し、namespaceSelectorで範囲を分けることも動作します。ただし、Podは実行されず、Readyに偽装されるため、ブロックされたPodが本当に起動しなかったかどうかは、コンテナではなくAPIの応答で確認します。
参考ドキュメント
- ValidatingAdmissionPolicy: https://kubernetes.io/docs/reference/access-authn-authz/validating-admission-policy/
- KubernetesのAPIにおけるCEL: https://kubernetes.io/docs/reference/using-api/cel/
- アドミッションコントローラーの一覧: https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/
- 動的アドミッション制御(Webhook): https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/
次のラボですること
kwokが起動したv1.30のAPIサーバーに、VAPを直接かけます。ポリシーだけを作って何もブロックされないことを、先に目で確認した後、バインディングを付けて、validationActionsを[Warn, Audit]から[Deny]に移しながら、同じリクエストの応答がどう変わるかを見ます。spec.variablesとmessageExpressionで、拒否メッセージを人が読めるように直し、matchConditionsで特定のリクエストを評価の対象からそもそも外し、paramKind/paramRefで許可リストをポリシーの外に出した後、同じポリシーにバインディングを2つ付けて、ネームスペースごとに異なる上限をかけます。