tech.guitarrapc.cóm

Technical updates

RegoとConftest ことはじめ

Kubernetes の面倒なことといえばYAML、というのはよく聞くし私もそう思います。 YAMLが面倒なことはいくつもありますが、その一つに「YAMLの定義がポリシーとして正しいかの検証」があります。

コードを書いているときのように、YAML もポリシーにあっているのかユニットテストができれば、いざデプロイするときまでわからないという事態は避けられそうですね?

ということで、今回はKubernetes でポリシーチェックをする方法として良くあげられる Open Policy Agent のRego言語とContest を使っていくメモです。

なお、ポリシーチェックというとConftest や Gatekeeper がありますが、個人的にはローカルで気軽に始められるConftestから慣れていくのがいいと思います。試行錯誤しやすいところから入るのが大事。

tl;dr;

  • Rego のポリシーは、それぞれに名前つけたほうがテスト書いていて指定したポリシーと明示できるのがいい。デバッグのためにも判別できる工夫がかかせない。
  • Rego のポリシー自体が正しいか試行錯誤することになるので、先にテスト書いたほうがいい。テストを書いてから、実際のYAMLに流すの流れが効率的。
  • Rego のテストで引数使うの避けている。テストが通らなくなってしんどい。ダメな例: test_foo[msg] { }。いい例: test_foo { }
  • Rego のポリシーと結果をサクッと試すにはPlayground が便利。
  • 公式サイトが充実しており、何度も公式サイトをリファレンスとして使うことになるので積極的に使っていきましょう。

www.openpolicyagent.org

Rego基本情報

基本的なコンセプト、構文、関数はここを参照

Open Policy Agent | Policy Language

拡張子は .rego。 DSLでの記述と宣言的な実行に割り切っている。

全般的に package mainsprintf("%s", string) を含めて Golang っぽさがそこかしこにある。

2つ特徴がある。

  • 代入を除くと、式は同じポリシー内でも順不同に配置しても問題なく評価される。
  • itelator が foo = input.keys[_] のように書くと、foo にはarray/set/mapの要素が入ってくる。1

エディタ環境

VS Code + Remote Container で書くのがおすすめ。

VS Code に Open Policy Agent 拡張を入れると、フォーマッタが効くようになり、構文解析まで行ってくれる。おまけで、コマンドパレットで validate とかも出て VSCodeでプレイグラウンド的に使えるがそこまではいらない。

ただし、Open Policy Agent 拡張を動かすには、Open Policy Agent実行ファイル opa をインストールする必要があるので、Remote Container環境と相性がいい。

インテリセンスもないなど、IDEとして機能十分で使いやすいかというと微妙だが、正しい構文とフォーマットが維持できるのでないより圧倒的にいい。

実行方法

conftest + Open Policy Agent で実行するのが隙がない。

  • CI やローカルで実行するときは、conftest を用いることでサクッと実行できる、便利、よい。
  • Kubernetes Cluster 内部でデプロイされる前にチェックする(GitOpsは典型) 場合は、Open Policy Agent | Kubernetes Admission Control を用いる。

まずは conftest で実行できるようにすると、動作が把握できてカジュアルに試せるのでいい。 もっとカジュアルに試すなら Playground を活用するといい。

https://play.openpolicyagent.org/

ポリシーの基本形式

ポリシーの基本は次の通り。

  • ポリシーは複数のルール(=式) を持つ。
  • ルールは bool を返し、AND評価(すべてのルールが true になるとポリシーに該当しているとみなされる)を行う。
  • ポリシーの必須要素は、package宣言、violation などのポリシーの評価方法、msg。
  • pacakge はmain以外は実行時に評価されない。このため、別名フォルダにおいて隔離しておいてimportで取り込むことで、 data.パッケージ名 として必要な関数を呼び出すことができる。
package main

# 評価結果は、violation / deny / warn / allow で選べる。名前を付ける場合、先頭の文字がいずれかに該当していれば ok
# violation と deny は ポリシーを満たすとダメ扱い。warn は警告扱い。BlackListでのポリシー評価が楽なので、allow は使わない
violation[msg] {
    # boolを返すルールを複数記述できる。入力は、外部ライブラリを使わない限り input に入ってくる。
    msg := "ポリシー該当時のメッセージを記述する"
}

violation_何か名前をつけたり[msg] {}
violation_引数をマップにしたり[{"msg": msg}] {

式や組み込み関数の基本的な記述は公式をみるといい。

Open Policy Agent | Policy Language Open Policy Agent | Policy Reference | Built-in Functions

比較

比較だけややこしいので特記しておく。

同値の評価に :==== 演算子があるが、基本的に代入に :=、値比較に == を使うように公式にも記述があるので従っておくのがいい。

Equality  Applicable    Compiler Errors            Use Case
--------  -----------   -------------------------  ----------------------
:=        Inside rule   Var already assigned       Assign local variable
==        Inside rule   Var not assigned           Compare values
=         Everywhere    Values cannot be computed  Express query

例外的に、式の結果を受けるときに = を使って評価と代入を同時に行うケースがある。

[image_name, "latest"] = split_image(container.image)

split_image(image) = [image, "latest"] {
    not contains(image, ":")
}

split_image(image) = [image_name, tag] {
    [image_name, tag] = split(image, ":")
}

なお、数値の比較演算子には次のものがある。そろってるので困ったことはない。

a  ==  b  #  `a` is equal to `b`.
a  !=  b  #  `a` is not equal to `b`.
a  <   b  #  `a` is less than `b`.
a  <=  b  #  `a` is less than or equal to `b`.
a  >   b  #  `a` is greater than `b`.
a  >=  b  #  `a` is greater than or equal to `b`.

関数

関数は、戻り値を指定しない限りは bool が返る前提になっている。

is_foo {
    input.name == "foo"
}

パラメーターを与える場合は、 関数名(引数名){} と書く。

is_foo(value) {
    value == "foo"
}

bool ではなく、関数から特定の返り値をも耐える場合は 関数名() = 返り値{} と書く。複数の返り値があるなら、関数名() = [返り値1, 返り値2]{} となる。

get_name_age() = [name, age] {
    name := input.name
    age := input.age
}

例えば次のような input とポリシーが書ける。PlayGround で試しておみるといい。

Input

{
    "name": "John Doe",
    "age": 100
}

ポリシー

package play

deny[msg] {
    [name, age] = get_name_age
    msg := sprintf("name: %s, age: %v", [name, age])
}
get_name_age() = [name, age] {
    name := input.name
    age := input.age
}

Output

{
    "deny": [
        "name: John Doe, age: 100"
    ],
    "get_name_age": [
        "John Doe",
        100
    ]
}

OR評価

ポリシーは基本的に AND評価。 OR評価を行いたい場合は2つ方法がある。

  1. 同名の関数を用意する
  2. 配列に対して評価する。

関数の場合、同名の関数を用意するとそれぞれの関数が個別に評価され、true が返った関数が利用される。

例えば、次のような is_byte_format 関数を用意すると、Gi でも Mi でもフォーマットを評価できる。

is_byte_format(size) {
    endswith(size, "Gi")
}

is_byte_format(size) {
    endswith(size, "Mi")
}

配列を使う場合、配列に許可する要素を記述しておいて、itelator を使って順次結果が評価したい値と一致するかを判定する。 イテレーター自体は普通なのだが、式を見ても直感的じゃないのであまり好きじゃない。

workload_resources := ["Deployment", "StatefulSet"]
input.kind == workload_resources[_] # kind が Deployment / StatefulSet の時だけ true になる。

いずれの方法でも、デバッグログが汚くなるので利用したらデバッグログは読みにくくなることを覚悟するしかない。

Kubernetes での利用

Kubernetes で使うにあたってポイント。

評価対象リソースの限定

Kubernetes で利用する場合、様々なリソースYAML に同じルールが適用されることを前提に書くことになる。 例えば、pod や container に関する記述は、 Pod / Deployment / StatefulSet / DaemonSet / Job / Cronjob に適用してほしいだろうが、Service や Ingress には適用してほしくない。

そのため、ポリシーの AND評価を利用して先に kindapiVersion で絞りこむことになる。

violate[msg] {
    input.kind == "Deployment"
}

だがこのような記述にすると、StatefulSet と Deployment にというのが書きにくい。そのため、先に配列で許可リストを用意するといい。

workload_resources := ["Deployment", "StatefulSet"]
is_deployment_or_statefulset {
    input.kind == workload_resources[_]
}

ポリシーでis_deployment_or_statefulset関数を呼び出すことで、そのポリシーは DeploymentかStatefulSetでのみ評価されることが保障できる。

violation[msg] {
    kubernetes.is_deployment_or_statefulset
    # 何かルール

    msg = "ポリシー違反です"
}

キーがないことの評価

「特定のラベルがないときにエラーにする」といったキーがないことを評価するときは not を使う。

violation[msg] {
    kubernetes.is_deployment_or_statefulset
    not input.metadata.labels["not-found"] # ココ
    msg = "ポリシー違反です"
}

複数のラベルに対して、どれか一つでもない場合にエラーにすることを評価するときは、配列 + not を使う。これで、どれか一つでもラベルにないとポリシーがひっかかる。

recommended_labels {
    input.metadata.labels["app.kubernetes.io/name"]
    input.metadata.labels["app.kubernetes.io/instance"]
    input.metadata.labels["app.kubernetes.io/version"]
    input.metadata.labels["app.kubernetes.io/component"]
    input.metadata.labels["app.kubernetes.io/part-of"]
    input.metadata.labels["app.kubernetes.io/managed-by"]
}
violation[msg] {
    kubernetes.is_deployment_or_statefulset
    not recommended_labels # ここ
    msg = "ポリシー違反です"
}

よく使う一式

これを kubernetes.rego として保存しておいて、使いたいポリシーで、import data.kubernetes してから kubernetes.is_service などのようにして使っている。必要に応じてヘルパーが増えていったりする。

package kubernetes

# properties
name := input.metadata.name

kind := input.kind

is_service {
    kind == "Service"
}

workload_resources := ["Deployment", "StatefulSet"]

environment_labels := ["development", "staging", "production"]

is_deployment_or_statefulset {
    input.kind == workload_resources[_]
}

is_pod {
    kind == "Pod"
}

is_service {
    kind == "Service"
}

is_not_local {
    input.metadata.labels.environment == environment_labels[_]
}

is_ingress {
    kind == "Ingress"
}

pod_containers(pod) = all_containers {
    keys := {"containers", "initContainers"}
    all_containers = [c | keys[k]; c = pod.spec[k][_]]
}

containers[container] {
    pods[pod]
    all_containers := pod_containers(pod)
    container := all_containers[_]
}

containers[container] {
    all_containers := pod_containers(input)
    container := all_containers[_]
}

pods[pod] {
    is_deployment_or_statefulset
    pod := input.spec.template
}

pods[pod] {
    is_pod
    pod := input
}

volumes[volume] {
    pods[pod]
    volume := pod.spec.volumes[_]
}

# image functions
split_image(image) = [image, "latest"] {
    not contains(image, ":")
}

split_image(image) = [image_name, tag] {
    [image_name, tag] = split(image, ":")
}

# security functions
dropped_capability(container, cap) {
    container.securityContext.capabilities.drop[_] == cap
}

added_capability(container, cap) {
    container.securityContext.capabilities.add[_] == cap
}

テスト

ポリシーは、それ正常に評価されているのか、使ってはだめなINPUT例と使っていいINPUT例 で繰り返し試しながら書くことになる。 実際の YAML に対して変更かけつつ試すと、普段のYAMLに対してどう追加すればいいのかを試す / ポリシー評価が意図通りか試す、試したものを戻す、といった余計は作業が多く発生する。

このため、ポリシーは「想定するINPUTを用意」して、「INPUTに対してポリシーを記述」して、「ポリシーをテストで通るか確認」して、「実際のYAMLに対して実行」するという流れがうまく当てはまる。

テストでは、次の3つを行う。

  • 想定するINPUTを用意
  • INPUTに対してポリシーを記述
  • ポリシーをテストで通るか確認

conftest を用いる場合、テストは次の形式で実行できる。

# テスト結果概要だけ表示
$ conftest verify --policy ./path/to/policy

# テスト一つずつの評価経過をtrace表示
$ conftest verify --policy ./path/to/policy --trace

# テスト結果概要 + 失敗したテストのみ評価経過を表示
$ conftest verify --policy ./path/to/policy --report failed

# テスト結果を一つずつPASS/FAILED表示 + 失敗したテストは評価経過を表示
$ conftest verify --policy ./path/to/policy --report full

テストのテンプレ

テストは、「ポリシーファイル_test.rego」が定番なので従っておく。(_test は必須じゃないが、ポリシーと同階層におくので自然とそうなる) テストには注意が3つある。

  • テストは test_ でポリシー命名が始まる。
  • テストは、ポリシーに引数がない。
  • テストの msg は、評価するポリシーと同じ出力になるようにしないといけない。

引数がないのは忘れがち。万が一引数があるとポリシーが正常に評価されてもテストは通らない。

# ダメな例
test_violation_labels_recommended_missing[msg] {}

# いい例
test_violation_labels_recommended_missing {}

テストの msg はポリシーで固定文字列なら同じものを入れればいい。 だが、どのリソースでポリシーが違反したか判別のために input.kindinput.metadata.name を使っている場合は、テストでも同じ結果になるように注意が必要だ。

# 固定のメッセージならポリシーとテストで同じものを指定でok
msg = "推奨ラベルを付与してください。(https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels)"
# 動的なmsgなポリシー
violation_some_policy[msg] {
    input.kind == "Deployment"
    input.metadata.name == "test-data"
    msg = sprintf("推奨ラベルを付与してください。(https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels): [Kind=%s,Name=%s]", [input.kind, input.metadata.name])
}

# ポリシーに該当するテスト
test_violation_some_policy {
    # テストのmsgはinput に合わせる必要がある。
    msg = "推奨ラベルを付与してください。(https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels): [Kind=Deployment,Name=test-data]"
    violation_some_policy[msg] with input as {
        "kind": "Deployment",
        "metadata": {
            "name": "test-data",
        },
    }
}

テストのサンプル

例えばKubernetes推奨ラベルがあることを保証するポリシーがあるとしよう。

package main

recommended_labels {
    input.metadata.labels["app.kubernetes.io/name"]
    input.metadata.labels["app.kubernetes.io/instance"]
    input.metadata.labels["app.kubernetes.io/version"]
    input.metadata.labels["app.kubernetes.io/component"]
    input.metadata.labels["app.kubernetes.io/part-of"]
    input.metadata.labels["app.kubernetes.io/managed-by"]
}
workload_resources := ["Deployment", "StatefulSet"]

is_deployment_or_statefulset {
    input.kind == workload_resources[_]
}

# recommented labels must exists
violation_labels_recommended_exists[{"msg": msg}] {
    is_deployment_or_statefulset
    not recommended_labels

    msg = sprintf("推奨ラベルを付与してください。(https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels): [Kind=%s,Name=%s]", [input.kind, input.metadata.name])
}

これに対して、labelsがないテストとあるテストを用意すれば、実際のYAMLで確認することなくポリシーが妥当かユニットテストできる。 ポリシー違反しない場合は、not を付けることで正常にとおったことをテストできる。

test_violation_labels_recommended_missing {
    msg := "推奨ラベルを付与してください。(https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels): [Kind=Deployment,Name=test-data]"
    input := {
        "kind": "Deployment",
        "metadata": {"name": "test-data"},
    }

    violation_labels_recommended_exists[{"msg": msg}] with input as input
}

test_violation_labels_recommended_exists {
    msg := "推奨ラベルを付与してください。(https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels): [Kind=Deployment,Name=test-data]"
    input := {
        "kind": "Deployment",
        "metadata": {
            "name": "test-data",
            "labels": {
                "app.kubernetes.io/name": "",
                "app.kubernetes.io/instance": "",
                "app.kubernetes.io/version": "",
                "app.kubernetes.io/component": "",
                "app.kubernetes.io/part-of": "",
                "app.kubernetes.io/managed-by": "",
            },
        },
    }

    not violation_labels_recommended_exists[{"msg": msg}] with input as input # 正常評価なら not を付ける
}

テストが通らないときのデバッグ

デバッガで止まるわけじゃないので、2つ使って評価を試す。

  1. Playground でポリシーとinput を用意して結果を見る
  2. conftest verify --report failedconftest verify --trace で評価経過を追いかける

ポリシーがそもそもおかしいかもしれない場合は、Playground で仮Input に対してポリシーがちゃんとかかっているか試行錯誤するのが手っ取り早い。なので、ポリシー取り合えず書いてみて、うまく当たるか見てみたい、という場合は Playground でサクッとやってみるのは結構オススメ。

プレイグラウンドでポリシーは当たったけど、どう評価されているか確認しながらやりたいだろう。そういうときは --trace したり、--report で見る。--trace は、全部のテストの trace が出るので注意。(最後のテストになるようにするといい) trace は正直読みにくいが、一行一行追っていけば、なるほど確かにという感じで評価されているのがわかるので、困ったら読む価値は十分ある。

テストの trace を読みやすくする

traceを使う注意点が2つある。

  • OR評価をすると trace ログが入れ子になってハイパー読みにくくなる。
  • data に関数を逃がすとそれだけで入れ子になって読みにくい。

このため、まず試す、というときは決め打ちでOR評価せず、インラインでポリシーに直接パスを指定して書くほうが trace は圧倒的に追いやすい。

「なんでテストが通らないかわからないけど、traceが読みにくくて追いきれない」そんなときは、dataもOR評価もせず、決め打ちのinputに対するミニマムポリシーを用意してルールを直書きしてみるといい。

traceを見てもポリシーはあってるがテストが通らない

テストに引数がついていないだろうか。 私はこれで数時間溶かした。

# ダメな例
test_violation_labels_recommended_missing[msg] {}

# いい例
test_violation_labels_recommended_missing {}

Reference

考え方

Conftest で CI 時に Rego で記述したテストを行う - @amsy810's Blog

ポリシーサンプル

conftest/examples/kustomize/policy at master · open-policy-agent/conftest

GitHub - redhat-cop/rego-policies: Rego policies collection

GitHub - swade1987/deprek8ion: Rego policies to monitor Kubernetes APIs deprecations.

Collecting together Kubernetes rego examples, including porting the https://kubesec.io rules to rego


  1. 正直気持ちが悪いしデバッグがしにくい原因の一つ。