match に見えないものを preconditions と context が見る
一言でいうと
match/excludeは、種類・名前・ラベルのような外見だけで選びます。リソースのspecの中を調べたり、別のリソースと比べたりして、ルールを適用するかどうかを決めるには、preconditionsとcontextが必要です。この記事では、その2つがどんな順序で評価され、どこまでできるのかを、公式ドキュメントのpreconditionsの節と外部データソースの節に沿って説明します。
なぜ必要なのか
「NodePort Serviceは必ずexternalTrafficPolicy: Localでなければならない」というポリシーを書くとしましょう。matchブロックはkinds: [Service]までしか選べず、spec.typeがNodePortかどうかは見られません。そのため、すべてのServiceがいったんルールに引っかかり、その中からNodePortだけを選び出す2つ目のゲートが必要になります。そのゲートがpreconditionsです。
2つ目の要求は、もっと頻繁に来ます。「このネームスペースにすでにPodがいくつあるか」「許可するレジストリのリストがConfigMapにあるので、それと比べたい」「リクエストした人が実際に権限を持っているかを、SubjectAccessReviewで問い合わせたい」。こうした判断は、リクエスト本体(AdmissionReview)だけではできず、別の場所からデータを取得しなければなりません。それがcontextです。
どう動くのか
評価の順序
ドキュメントは、順序を明確に書いています。リソースがmatchに引っかかり、excludeから外れたあとにpreconditionsが評価され、全体がTRUEのときに、ルール本体(validate・mutateなど)が実行されます。match/excludeの中では、変数を使えません。変数のドキュメントが、その理由を「データを読み込まずにルールを素早く選ぶため」だと明かしています。一方、preconditionsでは、変数・JMESPath・演算子をすべて使えます。
結果の報告でも、2つは違います。excludeに引っかかったリソースはまったく無視されますが、matchに引っかかってpreconditionsで落ちたリソースは、skipとして採点されます。PolicyReportでskipが多く見えるなら、preconditionsがふるい落としたものです。
anyとall
preconditionsの式は、anyまたはallブロックの下に置きます。anyは論理OR、allは論理ANDで、1つのルールに両方を置くこともできます。ドキュメントの表現のとおり、「各any/allブロックが全体としてTRUEでなければ」ルールが進行し、1つでもTRUEでなければ、ルールは適用されません。denyルールのconditionsと同じ構造で、同じ方式でショートサーキット評価(short circuiting)をします。
preconditions:
any:
- key: "{{ request.object.metadata.labels.color || '' }}"
operator: Equals
value: blue
- key: "{{ request.object.metadata.labels.app || '' }}"
operator: Equals
value: busybox
all:
- key: "{{ request.object.metadata.labels.env || '' }}"
operator: Equals
value: qa
式1つは、key・operator・valueで構成されます。演算子は、Equals・NotEquals、GreaterThan・GreaterThanOrEquals・LessThan・LessThanOrEquals、集合の比較であるAnyIn・AllIn・AnyNotIn・AllNotIn、そして期間の比較であるDurationGreaterThan系列です。上の例の|| ''は、ラベルがないときにJMESPathの評価が失敗しないよう、空文字列で受け取る決まり文句で、省略可能なフィールドを扱うときには、ほぼ必ず必要です。
リクエストに由来する変数
Kyvernoがあらかじめ用意してくれる変数は、AdmissionReviewから来ます。request.objectは作成または変更されるオブジェクト(DELETEではnull)、request.oldObjectは変更される前のオブジェクト(CREATEではnull)、request.operationはCREATE・UPDATE・DELETE・CONNECTのいずれか、request.userInfoはusernameとgroups、request.namespaceは対象のネームスペースです。ここに、serviceAccountName・serviceAccountNamespace、request.roles・request.clusterRoles、コンテナイメージの情報を持つimagesが加わります。
1つ落とし穴があります。ユーザー名のようにアドミッションのリクエストにしかない変数をルールに使うなら、ポリシーのbackgroundをfalseにしなければなりません。バックグラウンドスキャンは、すでにあるリソースをもう一度走査するもので、リクエストの情報がないからです。CRDドキュメントのkubectl explainの出力に、その条件がそのまま書かれています。
context: 別の場所からデータを取得する
contextの項目は、ルールの中で定義し、定義した順序で評価されます。前の変数は後ろから参照できますが、後ろのものを前から参照するとエラーです。種類は5つです。
| 種類 | 何をするか | 参照方法 |
|---|---|---|
configMap |
name・namespaceでConfigMapを読み取ります |
{{ 이름.data.키 }} |
apiCall |
Kubernetes APIや外部サービスを呼び出します | urlPath + jmesPath |
globalReference |
あらかじめキャッシュされたGlobalContextEntryを参照します | name |
imageRegistry |
OCIイメージのメタデータを取得します | reference + jmesPath |
variable |
JMESPathで計算した値を保存します | jmesPath + default |
表の「参照方法」欄の1行目のプレースホルダーは、名前とキーです。
apiCallは、kubectl get --rawとまったく同じパスを使います。そのためドキュメントは、ポリシーに入れる前に、次のように手で試してみることを勧めています。
kubectl get --raw /api/v1/namespaces/kyverno/pods | kyverno jp query "items | length(@)"
同じものをポリシーに移すと、次のようになります。urlPathの中でも変数を使え、デフォルトのメソッドはGETで、method: POSTとdataを与えると、SubjectAccessReviewのような書き込みAPIも呼び出せます。APIサーバーがエラーを返す場合に備えて、defaultで代替値を置けます。
context:
- name: podCount
apiCall:
urlPath: "/api/v1/namespaces/{{ request.namespace }}/pods"
jmesPath: "items | length(@)"
default: 0
configMapは、ConfigMapにcache.kyverno.io/enabled: "true"ラベルを付けると、Kyvernoが自動的にキャッシュするので、ポリシーの判断のたびにAPIサーバーを呼び出さずに済みます。globalReferenceはさらに一歩進んだもので、GlobalContextEntryリソースにkubernetesResource(group・version・resource・namespace)やapiCall(外部呼び出し + refreshInterval)を宣言しておくと、Kyvernoがinformerでキャッシュを維持し、複数のポリシーが同じキャッシュを使います。ただし、GlobalContextEntryが準備できていなければ、それを参照するポリシーも準備できていない状態になり、処理されません。
権限も忘れてはいけません。apiCallであるリソースを読み取るには、KyvernoコントローラーのClusterRoleにその権限が必要で、インストールのカスタマイズのドキュメントは、rbac.kyverno.io/aggregate-to-admission-controller: "true"のようなラベルを付けたClusterRoleを作成して集約(aggregate)する方式を案内しています。1.13からワイルドカードのview権限が外れたため、カスタムリソースは明示的に開ける必要があります。
現場での姿
あるチームが「ネームスペースあたりのLoadBalancer Serviceは2つまで」というルールを入れました。matchでは数を数えられないので、apiCallでそのネームスペースのServiceの一覧を取得し、items[?spec.type == 'LoadBalancer'] | length(@)で数え、preconditionsのGreaterThanOrEqualsで2以上のときだけdenyするようにしました。最初は、アドミッションの遅延が目に見えて増えましたが、原因は、リクエストごとにAPI呼び出しが1回ずつ出ていたことでした。ドキュメントが述べているとおり、API呼び出しはアドミッションのリクエストごとに実行されるので、よく使うデータをGlobalContextEntryに移してキャッシュするのが答えでした。
別のチームは、ユーザー名をpreconditionsに入れたポリシーが、PolicyReportで変に見える現象に遭遇しました。バックグラウンドスキャンにはrequest.userInfoがないので、そのポリシーはbackground: falseでなければなりませんでした。
次の記事で続けること
続く記事では、Podルールが、DeploymentやCronJobのルールとして自動生成されるautogenと、ポリシーがリソースを削除するcleanupを扱います。そのあとのクイズでは、この記事の評価の順序、skipとexcludeの違い、contextの5つの種類を確認します。