Kubernetes: conftest でマニフェストをテストする

この記事では [conftest](https://github.com/instrumenta/conftest) というツールを使って、Kubernetes のマニフェストをテストする方法を紹介します。conftest のバージョンは v0.11.0 で確認しています。
 
## マニフェストのテスト

本番環境向けの Kubernetes のマニフェストでは様々な点を考慮する必要があります。考慮する点は環境や組織ごとに違うと思いますが、例えば以下のようなものが挙げられます。

- privileged の利用を禁止したい
- 新しい API Version を利用することを推奨する
- livenessProbe / readinessProbe の設定を推奨する
- resource request / limit の設定を推奨する

この記事では conftest というツールを使って、これらのポリシーを定義し、マニフェストがそれを満たしているかテストする方法を紹介します。

なお、[PodSecurityPolicy](https://kubernetes.io/docs/concepts/policy/pod-security-policy/) や [open-policy-agent/gatekeeper](https://github.com/open-policy-agent/gatekeeper) のような Kubernetes の API Server 側でポリシーをチェックする仕組みも存在します。conftest は API Server 側でチェックする仕組みと比べ、単独で動作するため CI などに組み込みやすいという特徴があります。一方 API Server 側のチェックに比べると強制力は弱いので、組み合わせて利用する形になるかと思います。

## conftest とは

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100377/1ee753ea-fdcd-27be-04a1-c38f35fc9127.png)

[conftest](https://github.com/instrumenta/conftest) は YAML や JSON などの構造化データに対してユニットテストを記述できるツールです。以下のような特徴があります。メインの開発者 [@garethr](https://github.com/garethr) さんは、[kubetest](https://github.com/garethr/kubetest) や [kubeval](https://github.com/instrumenta/kubeval) など他にも Kubernetes 関連のツールを作っている方です。

- YAML、JSON など構造化データに対するポリシーが書ける
    - 汎用的なツールで Kubernetes に特化しているわけではない
- ポリシーはOpen Policy Agent (OPA) で使われているポリシー言語 Rego で記載する
    - ポリシー自体にもユニットテストがかける (`opa test`)
- brew の tap が用意されていてインストールが簡単
- ポリシーはOCI 準拠のレジストリに格納することもできる
    - ポリシーを社内で共有するといったことがやりやすい
- チェックは必須の deny, 任意の warn の 2 つに別れていて CI にも導入しやすい
    - deny のチェックに引っかかったときだけ、コマンドの戻り値が 1 になる
- クラスタ上のデータは参照できないので、Ingress の host 名の衝突などはチェックできない

## conftest を試してみる

### インストール

Mac の Homebrew で簡単にインストールが可能です。

```console
brew tap instrumenta/instrumenta
brew install conftest
```

### テスト対象のマニフェスト

テスト対象のマニフェストを用意します。`hello-deploy.yaml` として保存します。

```yaml:hello-deploy.yaml
apiVersion: extensions/v1beta1  # API Version が古い
kind: Deployment
metadata:
  name: hello
spec:
  selector:
    matchLabels:
      run: hello
  template:
    metadata:
      labels:
        run: hello
    spec:
      containers:
      - image: nginx:1.17.3
        name: multi
        securityContext:  # privileged は禁止したい
          privileged: true
```

### ポリシーを記載する

ここでは例として以下の 2 点をチェックしたいと思います。

- Deployment の API Version が最新 (`apps/v1`)であること
    - 必須のチェックとはせずに警告だけを出す (warn)
- privileged を利用していないこと
    - 必須のチェックとする (deny)

ポリシーは Open Policy Agent (OPA) で使われているポリシー言語 Rego で記載します。Rego 自体の文法については長くなるため、この記事では扱いません。文法の詳細は[OPA の公式ドキュメント](https://www.openpolicyagent.org/docs/latest/) をご覧ください。

conftest では `deny`, `warn` の 2 種類のルールを使ってチェックを行います。`deny` の場合は一つでも引っかかればコマンドの戻り値が 1 (エラー)となりますが、`warn` の場合は戻り値には影響しません。そのため CI などで必須チェックとしたいものは `deny`、任意でチェックするものは `warn` にすることができます。なお `fail-on-warn` というオプションで `warn` の場合も戻り値を 1 にすることもできます。

conftest のデフォルトではポリシーはチェック対象のファイルと同じディレクトリの `policy/` にある全てのファイルを読み込みます。以下を `policy/mypolicy.rego` として保存します。

```plaintext:policy/mypolicy.rego

package main

# deprecated な deployment のバージョンリスト
deprecated_deployment_version = [
  "extensions/v1beta1",
  "apps/v1beta1",
  "apps/v1beta2"
]

# 最新の API Version が使われているかのチェック
warn[msg] {
  input.kind == "Deployment"
  input.apiVersion == deprecated_deployment_version[i]
  msg = "最新の APIVersion apps/v1 を指定してください"
}

# privileged が使われているかのチェック
deny[msg] {
  input.kind == "Deployment"
  input.spec.template.spec.containers[_].securityContext.privileged == true
  msg = "privileged はセキュリティ上の理由で許可されていません"
}
```

### ポリシーでテストする

以下のように `conftest test` でテスト対象を指定して、ポリシーを満たしているかテストします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100377/13c18a78-baf5-f5ef-7c6b-21b2146a2100.png)

```
$ conftest test hello-deploy.yaml
WARN - hello-deploy.yaml - 最新の APIVersion apps/v1 を指定してください
FAIL - hello-deploy.yaml - privileged はセキュリティ上の理由で許可されていません
```

マニフェストを修正して、テストが通るようになったことを確認します。

```
$ conftest test hello-deploy.yaml
# 問題ない場合は何も表示されない
```

### OCI レジストリでポリシーを管理する

conftest は Docker Registry など OCI 準拠のレジストリを使ってポリシーを管理することができます。複数の環境でポリシーを共有したい場合に便利でしょう。

ローカルに Docker Registry を立て試してみます。(実際に利用する場合は Docker Hub など外部のレジストリを使うことになります)

```
# Docker Registry をローカルに立てる
$ docker run -it --rm -p 5000:5000 registry:2.7.1
```

`policy` ディレクトリがあるディレクトリで、policy を OCI レジストリにアップロードします。v0.11.0 では成功しても特に何も表示されません。

```
$ conftest push localhost:5000/mypolicy:v0.1 policy
```

他のディレクトリに移動してアップロードした policy ファイルを取得できるか試してみます。

```
# 別の temp ディレクトリに移動
$ cd $(mktemp -d)

# pull する
$ conftest pull localhost:5000/mypolicy:v0.1

# ポリシーファイルが取得できている
$ ls policy
mypolicy.rego
```

ポリシーの情報は `conftest.toml` という設定ファイルで記述することもできます。

```toml:conftest.toml
[[policies]]
repository = "localhost:5000/mypolicy"
tag = "v0.1"
```

以上のファイルを置いた状態で、conftest に `--update` オプションを付けて実行すると自動的にレジストリからポリシーを取得してテストを実行してくれます。

```
$ conftest test --update hello-deploy.yaml
WARN - hello-deploy.yaml - 最新の APIVersion apps/v1 を指定してください
FAIL - hello-deploy.yaml - privileged はセキュリティ上の理由で許可されていません
```

### ポリシーのユニットテストを記載する

ポリシーが複雑になってくると、ポリシー自体のユニットテストを記載したくなるかもしれません。OPA 自体の仕組みを使ってユニットテストを書くことが可能です。詳しくは公式ドキュメントの[How Do I Test Policies?](https://www.openpolicyagent.org/docs/latest/how-do-i-test-policies/)をご覧ください。

```
$ opa test -v .
data.main.test_deprecated_deployment: PASS (491ns)
data.main.test_deprecated_deployment#01: PASS (354ns)
data.main.test_deprecated_deployment#02: PASS (348ns)
data.main.test_deprecated_deployment_allowed: PASS (253ns)
data.main.test_privileged_deployment: PASS (243ns)
data.main.test_privileged_deployment_allowed: PASS (239ns)
--------------------------------------------------------------------------------
PASS: 6/6
```

ルール名に `test_` という prefix がついたものがテストとして扱われます。慣例的にファイル名にも `_test.rego` とつける場合が多いようですが、必須ではありません。以下は先程のポリシーに対するユニットテストの例になります。

```plaintext:policy/mypolicy_test.rego
package main

test_deprecated_deployment {
  warn["最新の APIVersion apps/v1 を指定してください"] with input as {"kind": "Deployment", "apiVersion": "extensions/v1beta1"}
}

test_deprecated_deployment {
  warn["最新の APIVersion apps/v1 を指定してください"] with input as {"kind": "Deployment", "apiVersion": "apps/v1beta1"}
}

test_deprecated_deployment {
  warn["最新の APIVersion apps/v1 を指定してください"] with input as {"kind": "Deployment", "apiVersion": "apps/v1beta2"}
}

test_deprecated_deployment_allowed {
  # 最新の API Version の場合は warn が出ない
  not warn["最新の APIVersion apps/v1 を指定してください"] with input as {"kind": "Deployment", "apiVersion": "apps/v1"}
}

test_privileged_deployment {
  deny["privileged はセキュリティ上の理由で許可されていません"] with input as
  {
    "kind": "Deployment",
    "spec": {
      "template": {
        "spec": {
          "containers": [
            {
              "name": "app1",
              "image": "nginx",
            },
            {
              "name": "app2",
              "image": "nginx",
              "securityContext": { # privileged が使われている
                "privileged": true,
              }
            }
          ],
        }
      }
    }
  }
}

test_privileged_deployment_allowed {
  not deny["privileged はセキュリティ上の理由で許可されていません"] with input as
  {
    "kind": "Deployment",
    "spec": {
      "template": {
        "spec": {
          "containers": [
            {
              "name": "app1",
              "image": "nginx",
            },
            {
              "name": "app2",
              "image": "nginx",
            }
          ],
        }
      }
    }
  }
}
```