ルールを緩めたのに、テストはその日も緑のままだった
目標
ポリシー1つに付けるテストハーネスを、最初から作ります。入力サンプル・期待する判定・期待するメッセージを表ファイルに書き、その表を読んでサーバーdry-runでアドミッションを通すランナーを書いて、メッセージの変更・範囲のずれ・ルールの緩和という3つが、テストでどのようにレッドとして表に出るかを、手で確認します。
なぜ重要なのか
ポリシーが壊れる最も一般的な形は、エラーではなく沈黙です。マッチ範囲がずれると、ポリシーは何の音もなくすべてを通過させ、ダッシュボードには赤い線が1本も出ません。そのため、正常に動いているポリシーと死んだポリシーが、見かけ上区別できません。そこに、2つ目の力が重なります。ポリシーは、誰かがブロックされるたびに、緩む方向にしか修正されません。テストは、この2つを同時に防ぐ唯一の仕組みです。ただし、通過サンプルだけを集めたテストは、ポリシーをまるごと削除してもグリーンなので、何も守れません。そのため、このラボは、拒否サンプルと期待するメッセージを契約として固定し、テスト自体が無意味になった状態(サンプルなし・拒否の期待なし)を、成功でも失敗でもない3つ目の終了コードとして切り出すところまで進みます。
ステップ
/root/poltestで作業します(export KUBECONFIG=/root/.kube/config、kubectl config use-context kwok-lab)。/root/poltest/policy.yamlに、admissionregistration.k8s.io/v1のValidatingAdmissionPolicyrequire-min-replicasを書いてください。matchConstraints.resourceRulesは、appsグループv1のdeploymentsに対するCREATE・UPDATEを捉え、validationはobject.spec.replicas >= 2で、messageExpressionがbelow the minimum 2 replicasという断片を含む文を作ります。/root/poltest/binding.yamlに、バインディングrequire-min-replicas-bindを書いてください。policyNameはrequire-min-replicas、validationActionsは["Deny"]、matchResources.namespaceSelector.matchLabelsはpolicy-test: "yes"です。両方を適用し、ネームスペースpoltestを作成して、ラベルpolicy-test=yesを付けてください。サンプルも2つ作ります。/root/poltest/cases/ok-two.yamlはDeploymentok-twoでreplicas: 2、/root/poltest/cases/bad-one.yamlはDeploymentbad-oneでreplicas: 1です。最後に、ok-two.yamlだけをサーバーdry-runで送って、その出力を/root/poltest/01-allow.txtに保存してください。/root/poltest/cases.txtに、ゴールデンケースの表を書いてください。1行が1サンプルで、フィールドは|で区切ります。이름 | 매니페스트 경로 | allow 또는 deny | 기대 메시지 조각(プレースホルダーは順に、名前、マニフェストのパス、allowまたはdeny、期待するメッセージの断片です)の4つのフィールドです。マニフェストのパスは、表ファイルがあるディレクトリを基準に書き、#で始まる行と空行はコメントです。期待がallowの行は、メッセージのフィールドに-を書きます。今は2行を入れてください。allow-twoはcases/ok-two.yamlをallowとして、deny-oneはcases/bad-one.yamlをdenyとして書き、メッセージの断片は、実際の拒否メッセージからそのまま移したbelow the minimum 2 replicasです。/root/poltest/run-cases.shを作成してください。引数として表ファイルを受け取り(なければスクリプトの隣のcases.txt)、表の行ごとに、そのマニフェストをkubectl create -n poltest -f <파일> --dry-run=server(プレースホルダーはファイルです)で送ります。コマンドが成功すれば、実際の判定はallow、失敗すればdenyです。期待と同じならPASS <이름>を1行、違えばFAIL <이름> <까닭>を1行出力します(プレースホルダーは名前と理由です)。マニフェストのパスは、表ファイルがあるディレクトリを基準に解決します。最後の行にはtotal=<수> pass=<수> fail=<수>(プレースホルダーは数です)を出力し、失敗が1つでもあれば、0ではない値で終了します。作ったら、bash run-cases.shで実行して、2行ともPASSになるか確認してください。- 拒否サンプルをさらに2つ作成してください。
/root/poltest/cases/bad-zero.yamlはDeploymentbad-zeroでreplicas: 0、/root/poltest/cases/bad-default.yamlはDeploymentbad-defaultで、replicasフィールドをまったく書きません。この2つをcases.txtに、deny-zero・deny-defaultという名前で追加し、期待するメッセージの断片は、同じbelow the minimum 2 replicasにします。表は今や4行で、そのうち3行がdenyの期待です。bash run-cases.shが、再びすべてPASSで終わる必要があります。 run-cases.shを修正して、期待がdenyの行は、拒否メッセージに期待する断片が含まれている場合にだけPASSになるようにしてください(断片が-か空なら、メッセージは見ません)。そのあと、/root/poltest/policy-msgdrift.yamlを作成してください。名前とルールはpolicy.yamlと同じで、messageExpressionだけを別の文に変えたもので、その文にはbelow the minimum 2 replicasが含まれていてはいけません。それを適用して、bash run-cases.shを実行し、出力の全体を/root/poltest/05-drift.txtに保存してください(FAILの行が3行以上出る必要があります)。最後に、policy.yamlをもう一度適用して元のメッセージに戻し、テストが再びすべてPASSで終わることを確認してください。/root/poltest/cases/sts-one.yamlにStatefulSetsts-oneを書いてください。replicas: 1、serviceName: sts-oneです。cases.txtにdeny-sts-oneの行をdenyの期待として追加し(メッセージの断片は同じbelow the minimum 2 replicas)、bash run-cases.shを実行して、出力の全体を/root/poltest/06-falsepass.txtに保存してください。そのサンプルがFAILになります。今のポリシーはdeploymentsだけを見ているため、このリクエストをそもそも判定せず、拒否がないので、通過のように見えるのです。では、policy.yamlのresourcesを["deployments", "statefulsets"]に広げてもう一度適用し、テストが5行すべてPASSで終わることを確認してください。/root/poltest/policy-loose.yamlを作成してください。policy.yamlと名前・リソース・メッセージは同じで、最小値だけを2から1に下げたものです(誰かが「1台だけのものも上げられるようにしてください」とリクエストして、条件を1段階緩めた状況です)。それを適用して、bash run-cases.shを実行し、出力の全体を/root/poltest/07-regression.txtに保存してください。FAILが2行以上出ます。そのあと、policy.yamlをもう一度適用して元に戻し、テストが再びすべてPASSで終わることを確認してください。run-cases.shを最後に修正して、パイプラインのゲートとして使えるようにしてください。要約行をtotal=<수> pass=<수> fail=<수> deny=<수> result=<PASS|FAIL|INVALID>(プレースホルダーは数と、PASS、FAIL、INVALIDのいずれかです)に拡張し(denyは、期待がdenyの行の数)、出力した行の全体を、表ファイルがあるディレクトリのreport.txtにも、そのまま残してください。終了コードの規約は3つです。すべて通過なら0、1つでも期待とずれたら1、表にサンプルが1つもないか、denyの期待が1つもなければ2(resultはINVALID)です。修正した後、bash run-cases.shを実行して/root/poltest/report.txtを残し、拒否の期待を抜いた表でも一度実行して、2が出るか確認してください。
参考
- 作業ディレクトリは
/root/poltestで、クラスターはexport KUBECONFIG=/root/.kube/configで接続します(コンテキストkwok-lab)。 - サーバーdry-runは、
kubectl create -n poltest -f <파일> --dry-run=server(プレースホルダーはファイルです)です。保存だけを飛ばして、アドミッションはそのまま通るので、拒否メッセージが実際に返ってきて、オブジェクトは残りません。拒否メッセージは標準エラー出力に出るので、ファイルに入れるときは2>&1を付けてください。 - ポリシーをapplyしても、すぐには反映されません。APIサーバーが新しい式をコンパイルするのに1秒前後かかるので、変更した直後は、変わった判定が見えるまで短く待ってから、テストを実行してください。これのせいで、「直したのにそのままだ」と勘違いしやすいです。
- よくあるミス1: ループの中で呼び出すコマンドが、表ファイルをまるごと読み込んでしまい、1行だけ回って終わってしまうことです。
kubectl ... </dev/nullを付けてください。 - よくあるミス2: 拒否メッセージは、標準出力ではなく標準エラー出力に出ることです。ファイルに入れたり比較したりするときに、
2>&1を忘れないでください。 - ValidatingAdmissionPolicy: https://kubernetes.io/docs/reference/access-authn-authz/validating-admission-policy/
- CEL構文と使われる場所: https://kubernetes.io/docs/reference/using-api/cel/
- dry-runが何を通るか: https://kubernetes.io/docs/reference/using-api/api-concepts/
- アドミッションとsideEffects: https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/
通過サンプルを1つだけ実行して、ポリシーが生きていると信じた
/root/poltestで作業します(export KUBECONFIG=/root/.kube/config、kubectl config use-context kwok-lab)。/root/poltest/policy.yamlに、admissionregistration.k8s.io/v1のValidatingAdmissionPolicy require-min-replicasを書いてください。matchConstraints.resourceRulesは、appsグループv1のdeploymentsに対するCREATE・UPDATEを捉え、validationはobject.spec.replicas >= 2で、messageExpressionがbelow the minimum 2 replicasという断片を含む文を作ります。/root/poltest/binding.yamlに、バインディングrequire-min-replicas-bindを書いてください。policyNameはrequire-min-replicas、validationActionsは["Deny"]、matchResources.namespaceSelector.matchLabelsはpolicy-test: "yes"です。両方を適用し、ネームスペースpoltestを作成して、ラベルpolicy-test=yesを付けてください。サンプルも2つ作ります。/root/poltest/cases/ok-two.yamlはDeployment ok-twoでreplicas: 2、/root/poltest/cases/bad-one.yamlはDeployment bad-oneでreplicas: 1です。最後に、ok-two.yamlだけをサーバーdry-runで送って、その出力を/root/poltest/01-allow.txtに保存してください。
ポリシーは判定の方法だけを決め、どこに適用するかはバインディングが決めます。そのため、両方を上げてはじめて、拒否が起きます。messageExpressionはCEL式なので、文字列をつなげて、数値はstring(...)で変換する必要があります。このステップが見せようとしているのは、最後の行です。通過すべきサンプルだけを実行してみた記録は、ポリシーが生きているという証拠になりません。サーバーdry-runは、kubectl create -n poltest -f <파일> --dry-run=server(プレースホルダーはファイルです)です。保存だけを飛ばして、アドミッションはそのまま通るので、拒否メッセージが実際に返ってきて、オブジェクトは残りません。拒否メッセージは標準エラー出力に出るので、ファイルに入れるときは2>&1を付けてください。
期待を表に書いておいたら、抜けている側が表に出た
/root/poltest/cases.txtに、ゴールデンケースの表を書いてください。1行が1サンプルで、フィールドは|で区切ります。이름 | 매니페스트 경로 | allow 또는 deny | 기대 메시지 조각(プレースホルダーは順に、名前、マニフェストのパス、allowまたはdeny、期待するメッセージの断片です)の4つのフィールドです。マニフェストのパスは、表ファイルがあるディレクトリを基準に書き、#で始まる行と空行はコメントです。期待がallowの行は、メッセージのフィールドに-を書きます。今は2行を入れてください。allow-twoはcases/ok-two.yamlをallowとして、deny-oneはcases/bad-one.yamlをdenyとして書き、メッセージの断片は、実際の拒否メッセージからそのまま移したbelow the minimum 2 replicasです。
期待するメッセージは、作り出さずに、実際の拒否の出力から切り取ってきます。まずbad-oneをサーバーdry-runで一度送って、どんな文が返ってくるかを見てください。サンプルの名前は、ファイルを開かなくても何が違うのかがわかるように付けます。通過サンプルをコピーして1か所だけ変えたペアにしておけば、テストが壊れたときに、原因が名前から読み取れます。
表を読んで実行するランナーを作る
/root/poltest/run-cases.shを作成してください。引数として表ファイルを受け取り(なければスクリプトの隣のcases.txt)、表の行ごとに、そのマニフェストをkubectl create -n poltest -f <파일> --dry-run=server(プレースホルダーはファイルです)で送ります。コマンドが成功すれば、実際の判定はallow、失敗すればdenyです。期待と同じならPASS <이름>を1行、違えばFAIL <이름> <까닭>を1行出力します(プレースホルダーは名前と理由です)。マニフェストのパスは、表ファイルがあるディレクトリを基準に解決します。最後の行にはtotal=<수> pass=<수> fail=<수>(プレースホルダーは数です)を出力し、失敗が1つでもあれば、0ではない値で終了します。作ったら、bash run-cases.shで実行して、2行ともPASSになるか確認してください。
表を読むときは、while IFS='|' read -r ... done < "$table"の形を使います。ループの中で呼び出すコマンドが、表を飲み込まないように、</dev/nullを付けてください。フィールドの前後の空白は、切り落とさないと比較が合いません。終了コードは、最後のコマンドのものがそのまま出ていくので、最後に明示的にexitしてください。採点ツールは、このスクリプトに別の表を与えて実行します。表を読まずに決まった答えだけを出力するランナーは、そこで引っかかります。
もっともらしいがルールに違反するサンプルを追加する
拒否サンプルをさらに2つ作成してください。/root/poltest/cases/bad-zero.yamlはDeployment bad-zeroでreplicas: 0、/root/poltest/cases/bad-default.yamlはDeployment bad-defaultで、replicasフィールドをまったく書きません。この2つをcases.txtに、deny-zero・deny-defaultという名前で追加し、期待するメッセージの断片は、同じbelow the minimum 2 replicasにします。表は今や4行で、そのうち3行がdenyの期待です。bash run-cases.shが、再びすべてPASSで終わる必要があります。
replicasを書かなかったDeploymentは、アドミッションの前にデフォルト値が埋められます。サーバーdry-runは、そのデフォルト値の補完まで通ってきたオブジェクトをポリシーに見せるので、書かなかったものと1を書いたものが、同じ判定を受けます。これが、オフラインエンジンが見落としやすい場所です。拒否サンプルは、通過サンプルから1か所だけ変えて作るのがよいです。
メッセージだけを整えたら、テストがレッドになった
run-cases.shを修正して、期待がdenyの行は、拒否メッセージに期待する断片が含まれている場合にだけPASSになるようにしてください(断片が-か空なら、メッセージは見ません)。そのあと、/root/poltest/policy-msgdrift.yamlを作成してください。名前とルールはpolicy.yamlと同じで、messageExpressionだけを別の文に変えたもので、その文にはbelow the minimum 2 replicasが含まれていてはいけません。それを適用して、bash run-cases.shを実行し、出力の全体を/root/poltest/05-drift.txtに保存してください(FAILの行が3行以上出る必要があります)。最後に、policy.yamlをもう一度適用して元のメッセージに戻し、テストが再びすべてPASSで終わることを確認してください。
拒否メッセージは、ポリシーが開発者に語りかける唯一の経路です。その文をつかまえて通知やチケットを作ったパイプラインは、文言が変わる日に、黙って止まります。メッセージを期待値として固定しておけば、リファクタリングする人が、その場で気づけます。シェルでの部分文字列の確認は、case "$out" in *"$frag"*)が最も堅牢です。ポリシーをapplyしても、すぐには反映されません。変わった判定が見えるまで待ってから、テストを実行してください。
ポリシーがそもそも見ていないリソースが、通過として読まれた
/root/poltest/cases/sts-one.yamlにStatefulSet sts-oneを書いてください。replicas: 1、serviceName: sts-oneです。cases.txtにdeny-sts-oneの行をdenyの期待として追加し(メッセージの断片は同じbelow the minimum 2 replicas)、bash run-cases.shを実行して、出力の全体を/root/poltest/06-falsepass.txtに保存してください。そのサンプルがFAILになります。今のポリシーはdeploymentsだけを見ているため、このリクエストをそもそも判定せず、拒否がないので、通過のように見えるのです。では、policy.yamlのresourcesを["deployments", "statefulsets"]に広げてもう一度適用し、テストが5行すべてPASSで終わることを確認してください。
ポリシーがそのリソースを見なければ、判定は通過ではなく「該当なし」です。ところが、アドミッションは、その2つを同じ顔で返します。リクエストが成功したのです。そのため、必ず拒否されるべきサンプルを表に置くことが、マッチ範囲がずれていないという唯一の証拠になります。StatefulSetは、selector・templateのほかに、serviceNameがさらに必要です。範囲を広げた後も、反映に1秒前後かかります。
ルールを1段階緩めたら、テストが先に気づいた
/root/poltest/policy-loose.yamlを作成してください。policy.yamlと名前・リソース・メッセージは同じで、最小値だけを2から1に下げたものです(誰かが「1台だけのものも上げられるようにしてください」とリクエストして、条件を1段階緩めた状況です)。それを適用して、bash run-cases.shを実行し、出力の全体を/root/poltest/07-regression.txtに保存してください。FAILが2行以上出ます。そのあと、policy.yamlをもう一度適用して元に戻し、テストが再びすべてPASSで終わることを確認してください。
ポリシーは、ほとんどいつも、緩む方向にしか修正されません。それぞれの変更は、そのときどきでは妥当に見え、半年経つと、もともと何をブロックしようとしていたのか、誰もわかりません。拒否サンプルがテストにあれば、ルールが緩んだその日に、レッドになります。このとき、メッセージの文言はそのままなので、replicasが0のサンプルは、依然としてPASSのまま残ることも、一緒に見てください。ルールと文が、別々に動いてしまう瞬間です。
空のテストがグリーンにならないように、終了コードを決める
run-cases.shを最後に修正して、パイプラインのゲートとして使えるようにしてください。要約行をtotal=<수> pass=<수> fail=<수> deny=<수> result=<PASS|FAIL|INVALID>(プレースホルダーは数と、PASS、FAIL、INVALIDのいずれかです)に拡張し(denyは、期待がdenyの行の数)、出力した行の全体を、表ファイルがあるディレクトリのreport.txtにも、そのまま残してください。終了コードの規約は3つです。すべて通過なら0、1つでも期待とずれたら1、表にサンプルが1つもないか、denyの期待が1つもなければ2(resultはINVALID)です。修正した後、bash run-cases.shを実行して/root/poltest/report.txtを残し、拒否の期待を抜いた表でも一度実行して、2が出るか確認してください。
通過サンプルだけを集めたテストは、ポリシーをまるごと削除してもグリーンです。そのため、「テストが何も守れていない状態」を、成功でも失敗でもない3つ目の終了コードとして切り出すのです。ゲートは、0だけを通過とみなして、1と2をどちらもブロックします。report.txtを表ファイルの隣に置けば、別の表で実行しても、元の結果を上書きしません。要約は、前のステップの形式に2つのフィールドを足すものなので、PASS・FAILの行の形はそのままにします。