Conftestでおこなうポリシーチェック
LAPRAS株式会社でSREをしているyktakaha4ともうします🐧
今回は、実務で発生したプロダクト運用に関する課題をConftestによるポリシーチェックで改善した話について書きたいと思います
なにを作るか
弊社ではエンジニア向けのポートフォリオサービスであるLAPRASや、エンジニア採用プラットフォームであるLAPRAS SCOUTを提供しているのですが、これらのプロダクトのインフラは、Amazon EKSを用いたKubernetesクラスタにて運用しています⚓
以前こちらの記事でクラスタアップデートについて触れたのですが、Kubernetesクラスタを本番環境で安全に運用するには、yamlファイルの適切な管理が欠かせません
アプリケーションはPythonのWebフレームワークであるDjangoで実行しているのですが、サービス間通信のためのgRPCサーバーがあったり、非同期処理ににPython向けのタスクキューであるCeleryを利用していたりなど、単一のアプリケーションコードから複数のDeploymentを展開しています
これに加えて、クローラーや社内向けシステムなども含めると数十種類のアプリケーションが存在します
システムが事業ドメイン毎に一定の粒度で分割されていると、チームを分けて開発効率を高められたり、コードベースが一定規模に抑えられることでメンテナンスがしやすくなったりと一定のメリットもありますが、
インフラ管理においては、特定の機能で見つかった不具合をいかに全体に展開するか という観点が重要になります
例えば、Kubernetesでありがちな実装不備として、コンテナのpreStopフックが設定されていなかったことで、PodがServiceから切り離される前に終了してしまい、再起動中にトラフィックがエラーになってしまう…というミスがあるものと思います
これに対する回避策として、以下のようにpreStopフックでsleepコマンドを実行し、切り離しが完了するまでの猶予を稼ぐ方法があります
(ちなみに、1.29よりpreStopフックにSleep Actionなるものが追加されよりシンプルに実装できるようですが、弊社ではまだ未導入のため詳しく紹介しません)
apiVersion: apps/v1
kind: Deployment
metadata:
name: sample-deployment
spec:
template:
spec:
containers:
- name: app
image: nginx
......(省略)......
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"]
もしもシステム全体で運用しているDeploymentがひとつだけならこれで対応は完了ですが、横展開として前述した様々なアプリケーションで同様の設定をおこなう必要があったり、それらのステージング環境についても状況を調査したり…と、修正自体は軽微でもシステム全体への適用を考えるとなかなか大変なものがあります
同じような話として、弊社では歴史的経緯からジョブスケジューラーとしてRundeckを採用しており、KubernetesのJobと組み合わせて使っているのですが、
ジョブのエラー発生時に通知させたり、タイムアウト時間を付与したりといった全ジョブで共通して設定したい内容について強制させる方法がなく、それによってジョブの実行遅延が生じてインシデントが発生してしまうなどの問題が発生していました
RundeckにはGit PluginによるSCM Export機能があり、GitHub等に用意したリポジトリにジョブ定義のyamlファイルを出力できます
KubernetesのマニフェストもRundeckのジョブ定義もyamlファイルとして考えれば同じものなので、
これらに対して単一の技術による設定値の検証を実現できれば、低コストで運用改善をおこなうことができそうです
どのように作るか
今回選定したConftestは、設定ファイルが一定のポリシーを満たしているかチェックするためのポリシーエンジンです
Go言語で実装されており単一の実行ファイルとして提供されるため、既存のCIに組み込んですぐに利用することができるのがメリットになると思います
以下のExampleで示されるように、yamlだけでなくTerraformで用いられているHCLやXMLなどにもポリシーチェックを書くことができます
ポリシーはRegoという宣言型言語を用いて記述します
理解するまで最初は少し苦労したのですが、以下のBookや公式ドキュメントなど丁寧に解説された資料が多くあります
個人的に気に入っている点として、ポリシーに対して単体テストを記述できることもポイントです
なお、選定にあたってはKyvernoやGatekeeperも調査したのですが、稼働環境に対するポリシーチェックを主眼に置いていたりなどKubernetesに特化したユースケースを想定しているように思ったため、導入コストを鑑みてConftestを採用することとしました
できたもの
ポリシーチェックのサンプルと動作イメージを以下に示します🚓
Kubernetes
リポジトリルートに policies
ディレクトリを作成し、サービスごとに固有のテストと共通のテストを分けて格納します
$ tree policies
policies
├── ingress.rego
├── ingress_test.rego
├── job.rego
├── job_test.rego
├── lapras
│ ├── pod.rego
│ └── pod_test.rego
├── pod.rego
├── pod_test.rego
├── scout
│ ├── pod.rego
│ └── pod_test.rego
├── settings
│ ├── default.yaml
│ └── helmfile.yaml
├── utils.rego
└── utils_test.rego
3 directories, 14 files
例として、pod.rego
ファイルの内容の一部を示します
これは、Podを持つ各種リソースに対して、以下のようなポリシーチェックを提供します
- CPUの要求量を明示させる
- メモリのオーバーコミットを禁止する
- 一部リポジトリを除き、latestタグの設定を禁止する
- ポートを公開しているリソースについて
- Probeの指定を必須にする
- PreStop hookの指定を必須にする
- terminationGracePeriodSecondsを明示する
package pod
import data.utils.get_all_containers
import data.utils.is_helmfile
target_resource_types := {"DaemonSet", "Deployment", "StatefulSet", "Job", "CronJob", "ReplicaSet", "ReplicationController"}
denied_tags := {":latest"}
denied_tags_exception_repositories := {"123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/xxxxx"}
warn_cpu_requests_not_set[msg] {
not is_helmfile
target_resource_types[input.kind]
container := input.spec.template.spec.containers[_]
not container.resources.requests.cpu
msg := sprintf("%s: コンテナの必要CPUリソースを明示するため、コンテナ %s にCPU要求を指定してください", [input.metadata.name, container.name])
}
deny_memory_requests_not_set[msg] {
not is_helmfile
target_resource_types[input.kind]
container := input.spec.template.spec.containers[_]
not container.resources.requests.memory
msg := sprintf("%s: コンテナが意図せずOOMされるのを防ぐため、コンテナ %s にメモリ要求を指定してください", [input.metadata.name, container.name])
}
deny_memory_limits_not_set[msg] {
not is_helmfile
target_resource_types[input.kind]
container := input.spec.template.spec.containers[_]
not container.resources.limits.memory
msg := sprintf("%s: コンテナが意図せずOOMされるのを防ぐため、コンテナ %s にメモリ上限を指定してください", [input.metadata.name, container.name])
}
deny_image_tag_not_set[msg] {
target_resource_types[input.kind]
container := get_all_containers(input.spec.template.spec)[_]
not contains(container.image, ":")
msg := sprintf("%s: コンテナが意図せず更新されるのを防ぐため、イメージ名 %s にタグを指定してください", [input.metadata.name, container.image])
}
deny_image_tag_disallow[msg] {
target_resource_types[input.kind]
container := get_all_containers(input.spec.template.spec)[_]
strings.any_suffix_match(container.image, denied_tags)
not strings.any_prefix_match(container.image, denied_tags_exception_repositories)
msg := sprintf("%s: コンテナのイメージ名 %s に許可されないタグが使用されています", [input.metadata.name, container.image])
}
warn_probes_not_set[msg] {
not is_helmfile
target_resource_types[input.kind]
container := input.spec.template.spec.containers[_]
container.ports
not has_valid_probes(container)
msg := sprintf("%s: ポートを公開している場合、コンテナ %s は livenessProbe に加えて、readinessProbe または startupProbe を設定してください", [input.metadata.name, container.name])
}
warn_termination_grace_period_seconds_not_set[msg] {
not is_helmfile
target_resource_types[input.kind]
container := input.spec.template.spec.containers[_]
container.ports
not input.spec.template.spec.terminationGracePeriodSeconds
msg := sprintf("%s: ポートを公開している場合、terminationGracePeriodSeconds を指定してください", [input.metadata.name])
}
warn_prestop_hook_not_set[msg] {
not is_helmfile
target_resource_types[input.kind]
container := input.spec.template.spec.containers[_]
container.ports
not container.lifecycle.preStop.exec.command
msg := sprintf("%s: ポートを公開している場合、コンテナ %s はpreStopフックを設定してください", [input.metadata.name, container.name])
}
has_valid_probes(container) {
container.livenessProbe
container.readinessProbe
} else {
container.livenessProbe
container.startupProbe
}
複数のポリシーチェックで共有したい記述については共通関数を作ることができます
package utils
import data.settings
is_empty(value) {
is_null(value)
} else {
count(value) == 0
}
has_any_value(value) {
value
not is_null(value)
}
get_all_containers(value) = containers {
value.initContainers
value.containers
containers = array.concat(value.initContainers, value.containers)
} else = containers {
value.containers
containers = value.containers
} else = containers {
value.initContainers
containers = value.initContainers
} else = containers {
containers = []
}
is_in_namespaces(value, namespaces) {
value
namespaces[value.metadata.namespace]
}
is_helmfile {
settings.test_type == "helmfile"
}
ポリシーチェック実行時に設定情報を --data
引数で渡すことができます
settings:
test_type: default
実行イメージを示します
manifests/
ディレクトリ配下に処理対象のyamlファイルがある場合、以下のようなコマンドでポリシーのテストがおこなえます
$ conftest test \
--all-namespaces \
--strict \
--data policies/settings/default.yaml \
--policy policies/ \
manifests/ \
--parser yaml
(省略)
WARN - manifests/xxx/yyy.yaml - pod - yyy: ポートを公開している場合、コンテナ zzz はpreStopフックを設定してください
6721 tests, 6692 passed, 29 warnings, 0 failures, 0 exceptions
--fail-on-warn
を加えると fail_
のものに加えて warn_
で始まるポリシーチェックに合格しなかった場合に終了ステータスが 1
になります
これにより、問題のある既存のマニフェスト群にまずポリシーチェックを実装してから、漸進的にプロダクトを改善していくことが可能です
CIのイメージ
上記のポリシーチェックに対して、単体テストを書くこともできます
一例として、以下は pod.rego
の warn_prestop_hook_not_set
に対する単体テストです
package pod
import data.utils.is_empty
cfg_ok := parse_config("yaml", `
apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment
namespace: test
spec:
replicas: 1
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
template:
spec:
initContainers:
- name: python
image: python:v1
containers:
- name: nginx
image: 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/nginx:v1
ports:
- name: http-port
containerPort: 80
protocol: TCP
resources:
requests:
memory: 3000Mi
cpu: 600m
limits:
memory: 3000Mi
readinessProbe:
httpGet:
path: /health
port: 80
livenessProbe:
httpGet:
path: /health
port: 80
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 40"]
terminationGracePeriodSeconds: 45
`)
# ------------------------------
# warn_prestop_hook_not_set
# ------------------------------
test_warn_prestop_hook_not_set_ok {
is_empty(warn_prestop_hook_not_set) with input as cfg_ok
}
test_warn_prestop_hook_not_set_ng {
cfg := parse_config("yaml", `
apiVersion: apps/v1
kind: Deployment
metadata:
name: deployment
namespace: test
spec:
template:
spec:
initContainers:
- name: node
image: node:v1
containers:
- name: nginx
image: nginx:v1
ports:
- name: http-port
containerPort: 80
protocol: TCP
- name: python
image: python:v2
terminationGracePeriodSeconds: 30
`)
warn_prestop_hook_not_set["deployment: ポートを公開している場合、コンテナ nginx はpreStopフックを設定してください"] with input as cfg
not warn_prestop_hook_not_set["deployment: ポートを公開している場合、コンテナ python はpreStopフックを設定してください"] with input as cfg
}
上記のようなテストコードがあったとき、以下のコマンドでポリシーチェックのテストを実行することができます
$ conftest verify \
--strict \
--report full \
--policy policies/
(略)
policies/pod_test.rego:
data.pod.test_warn_cpu_requests_not_set_ok: PASS (342.212µs)
data.pod.test_warn_cpu_requests_not_set_ng: PASS (269.531µs)
data.pod.test_deny_memory_requests_not_set_ok: PASS (321.981µs)
data.pod.test_deny_memory_requests_not_set_ng: PASS (246.496µs)
data.pod.test_deny_memory_limits_not_set_ok: PASS (670.714µs)
data.pod.test_deny_memory_limits_not_set_ng: PASS (728.324µs)
data.pod.test_deny_image_tag_not_set_ok: PASS (1.117798ms)
data.pod.test_deny_image_tag_not_set_ng: PASS (1.09238ms)
data.pod.test_deny_image_tag_disallow_ok: PASS (436.79µs)
data.pod.test_deny_image_tag_disallow_ng: PASS (1.325713ms)
data.pod.test_warn_probes_not_set_ok: PASS (349.649µs)
data.pod.test_warn_probes_not_set_ng: PASS (339.81µs)
data.pod.test_warn_termination_grace_period_seconds_not_set_ok: PASS (344.542µs)
data.pod.test_warn_termination_grace_period_seconds_not_set_ng: PASS (251.95µs)
data.pod.test_warn_prestop_hook_not_set_ok: PASS (282.142µs)
data.pod.test_warn_prestop_hook_not_set_ng: PASS (381.049µs)
(略)
--------------------------------------------------------------------------------
PASS: 65/65
その他、一例として以下のようなポリシーチェックをおこなっています
- Ingress
- AWS Load Balancer ControllerのIngress Groupを設定して、Ingress毎にALBが生成されないようになっていること
- 社外公開しているエンドポイントについてAWS WAFを適用していること
- Job
- 終了したジョブが自動削除されるようにttlSecondsAfterFinishedが指定されていること
- ハングしたジョブが強制終了されるようactiveDeadlineSecondsが指定されていること
- サービス固有
- 設定必須としたい特定の環境変数が設定されていること
余談ですが、リソース毎に一般的にチェックすべき項目はKubernetesの知識地図という書籍が参考になるものと思います
Conftestについても少しですが触れられています
Rundeck
まず、RundeckからGitへのSCM Exportを設定して、GitHubの特定のリポジトリにジョブをexportします
SCM Settingにて Job Source Files > Format
に yaml
を選択していると、リポジトリに以下のようなファイルが格納されます
- defaultTab: nodes
description: |-
test command description1
test command description2
executionEnabled: true
id: 00000000-0000-0000-0000-000000000000
loglevel: INFO
name: example_job
nodeFilterEditable: false
notification:
onfailure:
plugin:
configuration:
slack_template: ''
webhook_url_override: ''
type: SlackNotification
retry: '0'
schedule:
month: '*'
time:
hour: '*'
minute: 6/15
seconds: '0'
weekday:
day: '*'
year: '*'
scheduleEnabled: true
sequence:
commands:
- exec: ls -l
keepgoing: false
strategy: node-first
uuid: 00000000-0000-0000-0000-000000000000
timeout: '6h'
上記のようなファイルに対して、以下の仕様のポリシーをチェックを実装するRegoを示します
- 自動リトライを禁止する
- タイムアウトを明示させる
- エラー通知をSlackに送る
package scm
deny_retry_setted[msg] {
input.retry
input.retry != "0"
msg := sprintf("%s: 多重実行されることを防ぐため、リトライ設定に '0' を明示してください", [input.name])
}
deny_timeout_not_set[msg] {
not input.timeout
msg := sprintf("%s: ハングアップを抑止するため、タイムアウトを設定してください", [input.name])
}
deny_timeout_invalid_value[msg] {
input.timeout
valid_timeout := get_timeout_value(data.conftest.file)
input.timeout != valid_timeout
msg := sprintf("%s: ハングアップを抑止するため、タイムアウトに '%s' を設定してください", [input.name, valid_timeout])
}
deny_failure_notification_not_set[msg] {
not input.notification.onfailure
msg := sprintf("%s: ジョブの実行結果を通知するため、失敗時通知設定を行ってください", [input.name])
}
deny_failure_slack_notification_not_set[msg] {
input.notification.onfailure
not input.notification.onfailure.plugin.type == "SlackNotification"
msg := sprintf("%s: ジョブの失敗時通知はSlackに送信してください", [input.name])
}
deny_multiple_executions[msg] {
input.multipleExecutions
msg := sprintf("%s: 多重実行を防ぐため、ジョブの並列実行設定はおこなわないでください", [input.name])
}
# 自動実行されるジョブのみを対象とする
is_scheduled_job(definition) {
definition.executionEnabled
definition.scheduleEnabled
}
# 特定のパス配下のyamlのみ異なるタイムアウトを変更する
get_timeout_value(fileInfo) = "1d" {
fullPath := concat("/", [fileInfo.dir, fileInfo.name])
contains(fullPath, "/long-job/")
} else = "6h"
自動テストは以下のようになります
Conftestにおいて data.conftest.file
配下に処理対象のファイルパスやファイル名が設定された状態でチェックがおこなわれるため、ポリシーチェック時に活用できます
Regoにおいては with
句で特定の値をモックすることができるので、これを複数指定してテストパターンを網羅します
package scm
import data.utils.is_empty
mocked_conftest := {"file": {
"dir": "/path/to/file",
"name": "example_job.yaml",
}}
cfg_ok := parse_config("yaml", `
- defaultTab: nodes
description: |-
test command description1
test command description2
executionEnabled: true
id: 00000000-0000-0000-0000-000000000000
loglevel: INFO
name: example_job
nodeFilterEditable: false
notification:
onfailure:
plugin:
configuration:
slack_template: ''
webhook_url_override: ''
type: SlackNotification
retry: '0'
schedule:
month: '*'
time:
hour: '*'
minute: 6/15
seconds: '0'
weekday:
day: '*'
year: '*'
scheduleEnabled: true
sequence:
commands:
- exec: ls -l
keepgoing: false
strategy: node-first
uuid: 00000000-0000-0000-0000-000000000000
timeout: '6h'
`)
# ------------------------------
# deny_timeout_invalid_value
# ------------------------------
test_deny_timeout_invalid_value_ok {
cfg := cfg_ok[_]
is_empty(deny_timeout_invalid_value) with input as cfg with data.conftest as mocked_conftest
}
test_deny_timeout_invalid_value_ng {
cfg := parse_config("yaml", `
- defaultTab: nodes
executionEnabled: true
name: example_job
scheduleEnabled: true
timeout: '10h'
`)[_]
deny_timeout_invalid_value["example_job: ハングアップを抑止するため、タイムアウトに '6h' を設定してください"] with input as cfg with data.conftest as mocked_conftest
}
test_deny_timeout_invalid_value_ng_long_job {
mocked_conftest := {"file": {
"dir": "/path/to/file/long-job",
"name": "example_job.yaml",
}}
cfg := parse_config("yaml", `
- defaultTab: nodes
executionEnabled: true
name: example_job
scheduleEnabled: true
timeout: '6h'
`)[_]
deny_timeout_invalid_value["example_job: ハングアップを抑止するため、タイムアウトに '1d' を設定してください"] with input as cfg with data.conftest as mocked_conftest
}
今回の記事の主旨とそれるため詳細は割愛しますが、弊社ではRundeckからGitHubへのジョブエクスポート状況を週次でチェックし、エクスポート結果に対してConftestを実行するGitHub Actionsを設定して運用しています
プロジェクトのステータスはRundeck API経由で /api/15/project/[PROJECT]/scm/[INTEGRATION]/status
にアクセスすることで取得できます
本当はExport処理自体を自動化したいのですが、少し調べた限りには具体的な方法が見つけられなかったため、まずは手動で運用することとしました
今後の課題としたいと思います
通知の様子
番外編: Terraform
こちらは同僚のnappaさんが作ってくれたものですが、せっかくなのでご紹介します
ConftestはTerraform(HCL)についても対応していますが、弊社ではtflintを導入していたため、tflint-ruleset-opaを用いてtflint経由で実行します
tflint-ruleset-awsと組み合わせて使った場合の設定ファイルのサンプルを示します
# -------------------------
# Plugins
# -------------------------
plugin "aws" {
enabled = true
version = "0.32.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
plugin "opa" {
enabled = true
version = "0.7.0"
source = "github.com/terraform-linters/tflint-ruleset-opa"
}
# -------------------------
# 除外ルール
# -------------------------
rule "terraform_typed_variables" {
enabled = false
}
ルールの一例を示します
以下は、aws_iam_policy_attachmentの使用を禁止するルールです
aws_iam_policy_attachmentは、適切に利用できないと異なるtfstateで管理しているIAMポリシーの紐づけを壊してしまうケースがあるため、弊社では利用しないようにしています
package tflint
import rego.v1
deny_using_aws_iam_policy_attachment contains issue if {
resources := terraform.resources("aws_iam_policy_attachment", {}, {})
not count(resources) == 0
issue := tflint.issue("aws_iam_policy_attachment は使用せず aws_iam_role_policy_attachment 等を使用してください", resources[_].decl_range)
}
実行イメージは以下です
$ tflint --init
Installing "aws" plugin...
Installed "aws" (source: github.com/terraform-linters/tflint-ruleset-aws, version: 0.32.0)
Installing "opa" plugin...
Installed "opa" (source: github.com/terraform-linters/tflint-ruleset-opa, version: 0.7.0)
# base_dirはリポジトリルート
$ base_dir="$(pwd)"
$ cd target_directory/
$ TFLINT_OPA_POLICY_DIR="${base_dir}/.tflint.d" tflint \
--config="${base_dir}/.tflint.hcl" \
--minimum-failure-severity=warning \
-f compact
1 issue(s) found:
aws_iam_policy_attachment.tf:22:1: Error - aws_iam_policy_attachment は使用せず aws_iam_role_policy_attachment 等を使用してください (opa_deny_using_aws_iam_policy_attachment)
単体テストもConftestと同様に書くことができます
package tflint
import rego.v1
failed_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": `
resource "aws_iam_policy_attachment" "ecs_instance" {
name = "ecs-instance"
roles = [
aws_iam_role.ecs_ec2_role.name,
]
policy_arn = aws_iam_policy.ecs_instance_policy.arn
}
`})
test_deny_using_aws_iam_policy_attachment_failed if {
issues := deny_using_aws_iam_policy_attachment with terraform.resources as failed_resources
count(issues) == 1
issue := issues[_]
issue.msg == "aws_iam_policy_attachment は使用せず aws_iam_role_policy_attachment 等を使用してください"
}
単体テストの実行は以下で可能です
$ TFLINT_OPA_TEST=1 tflint
公式からsetup-tflintというActionsが出ているため、GitHub Actionsにも組み込みやすいです
所感
しばらく運用してみての所感としては以下がありました
- ポリシーチェックにより対応の横展開チェックが不要になり気が楽になった
- 今後新しく作成されるリソースに対しても過去の知見が適用されるのには大きなメリットを感じる
- 大量の設定ファイルのチェックに悩まされている組織には効果的かも
- アーキテクチャ面での複雑度が低く、漸進的に問題を解決していけるのが個人的に好み
- ポリシーの単体テストも書けるので退行にも強い
- Conftest / Rego言語そのものについてはまだ試行錯誤中
- Conftestの単体テストについて、アサーションを書いたテストケースを作るというよりも、テストデータでコードを実行してチェックを通過したor通過しなかったという書き方になるため、失敗時に何が原因だったか調査するのが難しい
- Regoのデバッグ用にトレースログを出すことができるものの内容が直感的でない
- 一方で、達成したいポリシーを宣言的に記述するため、辛みが出てきて他の実装に変えたくなった場合も移行は容易に思えた
- リポジトリ内にある程度サンプルができてしまえば書き味は気にならなくなる
- GitHub Copilotに
これこれの仕様のポリシーチェックをConftest用のRego言語で書いて
と質問したら大体解決した
- GitHub Copilotに
- リポジトリ内にある程度サンプルができてしまえば書き味は気にならなくなる
ということでまだまだ課題もありますが、低コストで改善の仕組みを回し始めることができたので満足です
よければ使ってみてください🐣
Discussion