LoginSignup
1

More than 1 year has passed since last update.

posted at

Gatekeeperで意図しないapplyからk8sクラスタを守る

Livesense Advent Calendar 14日目の記事です。

概要

CNCFがホストしているOPA(Open Policy Agent)プロジェクトの一部であるGatekeeperで遊んでみたいと思います。

最初はCIでk8sのマニフェストをテストできるconftestの方を題材にしようと思いましたが、既に日本語記事がいくつか書かれてた(参考に記載)ため、情報が少なかったgatekeeperにしました。

この記事では、以下の危険なコマンド(stagingのリソースを本番にapply)を実行した際にGatekeeperでブロックする方法を模索します。

他の記事や公式サンプルと内容が被らないようにするため、若干強引な課題設定にしています。

$ kubectl apply --context production -k manifests/default/app1/overlays/staging
admission webhook "validation.gatekeeper.sh" denied the request: [denied by deny-deployment] expected: production, actual: staging

$ kubectl apply --context production -k manifests/default/app1/overlays/production

アイディアとして、ステージング環境のリソースにはenv: staging、本番環境のリソースにはenv: productionをlabel(またはannotation)の値として付与しておき、適用しようとしている環境と異なるリソースのapplyをdenyする方法はどうかと考えました。

これらは、kubebuilder等を使って自前でAdmission Webhooksを実装することも可能ですが、Gatekeeperを使うことにより、rego言語でポリシーを書くだけで、簡単に同様のことが実現できます。

補足

このようなミスを防ぐためには、理想的には本番はプライベートクラスタにし、ローカルからは接続できないようにして踏み台等の専用の環境からのみ接続できるようにするのが良さそうです。

Gatekeeperとは

Open Policy Agent Gatekeeperプロジェクトは、Google、Microsoft、RedHat、Styraによるコラボレーションで、Kubernetes環境におけるポリシの適用とガバナンス強化を支援するように設計されている。Gatekeeper 3.0は、このOPA制約をさらに活用して、ユーザがポリシを宣言し、制約テンプレートを共有して、ポリシ違反のリソースを監査可能にする。

Gatekeeperは、コードではなく、設定によってKubernetesアドミッションコントロールをカスタマイズする方法を提供するために考案された。厳しいリソース制限、グローバルに一意な入力名、すべてのイメージを指定されたリポジトリからのみプルすることなど、すべてのPodに対するコンプライアンス要件に対処することがその目的だ。

image.png
画像引用元: https://kubernetes.io/blog/2019/08/06/opa-gatekeeper-policy-and-governance-for-kubernetes/

Gatekeeperを試す

まずは、公式が提供しているサンプルで動作をざっくり確認してみましょう。以下の例ではkindで新規にクラスタを作っていますが、minikubeなど何でも構いません。gatekeeperというラベルが付与されてないことにより、AdmissionWebhookによってnamespaceの作成がブロックされている様子がわかります。
https://github.com/open-policy-agent/gatekeeper

$ kind create cluster

$ kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper/release-3.1/deploy/gatekeeper.yaml
$ kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper/master/demo/basic/templates/k8srequiredlabels_template.yaml

$ kubectl create namespace test1
namespace/test created

$ kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper/master/demo/basic/constraints/all_ns_must_have_gatekeeper.yaml

$ kubectl create namespace test2
Error from server ([denied by ns-must-have-gk] you must provide labels: {"gatekeeper"}): admission webhook "validation.gatekeeper.sh" denied the request: [denied by ns-must-have-gk] you must provide labels: {"gatekeeper"}

これらはどうやって実現しているのでしょうか。中身を覗いてみましょう。

k8srequiredlabels_template.yaml
apiVersion: templates.gatekeeper.sh/v1beta1
kind: ConstraintTemplate
metadata:
  name: k8srequiredlabels
spec:
  crd:
    spec:
      names:
        kind: K8sRequiredLabels
      validation:
        # Schema for the `parameters` field
        openAPIV3Schema:
          properties:
            labels:
              type: array
              items: string
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8srequiredlabels

        violation[{"msg": msg, "details": {"missing_labels": missing}}] {
          provided := {label | input.review.object.metadata.labels[label]}
          required := {label | label := input.parameters.labels[_]}
          missing := required - provided
          count(missing) > 0
          msg := sprintf("you must provide labels: %v", [missing])
        }
all_ns_must_have_gatekeeper.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata:
  name: ns-must-have-gk
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Namespace"]
  parameters:
    labels: ["gatekeeper"]

ConstraintTemplateのspec.targets.regoの部分がrego言語で書かれたポリシーです。
regoには馴染み深くないですが、以下のように推測できます。

  • ConstraintTemplateでテンプレートを定義し、labelsというパラメータを外部から受け取っている
    • input.reviewの内容(AdmissionReview)とパラメータの中身の差分が1つ以上あったら、違反(violation)として扱っている
    • エラー内容はmsgに突っ込んでいる
  • K8sRequiredLabelsでlabelsというパラメータにgatekeeperを設定している
    • 制限の対象はNamespaceのみ

雰囲気はわかりましたが、やはりregoのことがよくわかってません。ローカルで色々試せるようにしたいですね。

Regoを試す

regoを試すには、何通りか方法がありますが、ここではbrewで入れました。

$ brew install opa
$ opa run

> msg := sprintf("you must provide labels: %v", ["gatekeeper"])
Rule 'msg' defined in package repl. Type 'show' to see rules.
> msg
"you must provide labels: gatekeeper"

言語仕様はこちらに記載されています。
https://www.openpolicyagent.org/docs/latest/policy-language/

本題

公式サンプルの動作を確認し、regoを手元で試す手段も用意できました。
ようやく本題に着手しましょう。

kubectl apply --context production -k manifests/app1/overlays/staging

結果的に、このようになりました。
https://github.com/kashiwel/gatekeeper-example

以下は抜粋です。

apiVersion: config.gatekeeper.sh/v1alpha1
kind: Config
metadata:
  name: config
  namespace: gatekeeper-system
spec:
  sync:
    syncOnly:
      - group: ""
        version: "v1"
        kind: "ConfigMap"
apiVersion: v1
kind: ConfigMap
metadata:
  name: gatekeeper-config
  namespace: gatekeeper-system
data:
  env: production
manifests/gatekeeper-system/constrainttemplate.yaml
apiVersion: templates.gatekeeper.sh/v1beta1
kind: ConstraintTemplate
metadata:
  name: envmatched
spec:
  crd:
    spec:
      names:
        kind: EnvMatched
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package envmatched

        violation[{"msg": msg}] {
          env := input.review.object.metadata.annotations[input.parameters.envName]
          config := data.inventory.namespace["gatekeeper-system"]["v1"]["ConfigMap"]["gatekeeper-config"]

          config.data.env != env

          msg := sprintf("expected: %v, actual: %v", [config.data.env, env])
        }
manifests/gatekeeper-system/envmatched.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: EnvMatched
metadata:
  name: deny-deployment
spec:
  match:
    kinds:
      - apiGroups: ["apps"]
        kinds: ["Deployment"]
  parameters:
    envName: example.com/env

kindで構築したクラスタをproductionと見立てて、stagingのリソースをapplyしてみると想定どおりブロックされました。

$ cat manifests/default/app1/overlays/staging/kustomization.yaml
bases:
  - ../../base
patchesStrategicMerge:
  - deploy.yaml
commonAnnotations:
  example.com/env: staging

$ kubectl apply -k manifests/default/app1/overlays/staging/
"manifests/app1/overlays/staging/": admission webhook "validation.gatekeeper.sh" denied the request: [denied by deny-deployment] expected: production, actual: staging

本番のリソースは問題なくapplyできます。

$ cat manifests/default/app1/overlays/production/kustomization.yaml
bases:
  - ../../base
patchesStrategicMerge:
  - deploy.yaml
commonAnnotations:
  example.com/env: production

$ kubectl apply -k manifests/default/app1/overlays/production/
deployment.apps/echoserver configured

あまり実用的な例ではありませんでしたが、目的を達成できました。

補足

opa.runtime()が空

gatekeeper-configというConfigMapと、以下の部分は補足しなければなりません。

config := data.inventory.namespace["gatekeeper-system"]["v1"]["ConfigMap"]["gatekeeper-config"]

当初はopa.runtime()で環境変数を含むランタイム情報を取得できると考え、GatekeeperのdeploymentのENV環境変数にstagingやproductionを設定しておき、比較する方法で考えていました。

$ opa run
> opa.runtime()
{
  "commit": "",
  "config": {},
  "env": {
    ...
    "LC_ALL": "en_US.UTF-8",
    "LC_CTYPE": "UTF-8",
    "SHELL": "/bin/bash",
    "SHLVL": "2",
    "STARSHIP_SHELL": "fish",
    "TERM": "xterm-256color",
  },
  "version": "0.25.2"
}
violation[{"msg": msg}] {
    opa.runtime().env.ENV != input.review.object.metadata.annotations[input.parameters.envName]

    msg := sprintf("expected: %v, actual: %v", [opa.runtime().env.ENV, input.review.object.metadata.annotations[input.parameters.envName]])
}

ただ、Gatekeeperで試してみると、opa.runtime()が空になっていました。Issueを探してみると、どうやら意図的に読み込んでないようです。

We are not currently populating OPA with the contents of ENV
https://github.com/open-policy-agent/gatekeeper/issues/664#issuecomment-639245663

詰んだか…と思いましたが、どうやらsyncという機能を使えば、クラスタ内のリソースをdata.inventoryとして読み出せるようでした。

それが、この部分です。ドキュメントに記載されてますが、metadata.nameconfigである必要があります。

apiVersion: config.gatekeeper.sh/v1alpha1
kind: Config
metadata:
  name: config
  namespace: gatekeeper-system
spec:
  sync:
    syncOnly:
      - group: ""
        version: "v1"
        kind: "ConfigMap"

この指定によって、以下のConfigMapのenv: productionの値が、data.inventory.namespace["gatekeeper-system"]["v1"]["ConfigMap"]["gatekeeper-config"]として読み込めるようになりました。

apiVersion: v1
kind: ConfigMap
metadata:
  name: gatekeeper-config
  namespace: gatekeeper-system
data:
  env: production

ただ、正直なところ、このような使い方がセキュリティ的に問題ないかは検証できておりません。本番等で使う場合は、ご注意ください。

デバッグ方法

上記のように、ローカルのopa runだと値が取得できるものが、gatekeeperだと挙動が変わったりします。

javascriptでいうconsole.logのように値をprintしたいと思って探したところ、traceというビルトイン関数が使えそうに見えました。ただ、仕込んでも何故かログに出力されません。
https://www.openpolicyagent.org/docs/latest/policy-reference/#debugging

READMEをみると、denyするようにしてmsgに突っ込むのが楽だよと書かれていました。これで、なんとかデバッグできそうです。

A simple way to view the request object is to use a constraint/template that denies all requests and outputs the request object as its rejection message.
https://github.com/open-policy-agent/gatekeeper#viewing-the-request-object

apiVersion: templates.gatekeeper.sh/v1beta1
kind: ConstraintTemplate
metadata:
  name: k8sdenyall
spec:
  crd:
    spec:
      names:
        kind: K8sDenyAll
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8sdenyall

        violation[{"msg": msg}] {
          msg := sprintf("REVIEW OBJECT: %v", [input.review])
        }

その他のユースケース

今回紹介した例は、公式サンプルや日本語記事に存在しない(と思われる)もの、という縛りで作り出した恣意的なものです。

公式のサンプルに様々なユースケースの実装例がありますので、いくつか紹介します。
https://github.com/open-policy-agent/gatekeeper-library

指定したリポジトリのみ許可

最もイメージしやすい用途かと思います。この例では、リポジトリはopenpolicyagentのみ許可されているため、imageにopenpolicyagent/opa:0.9.2は指定できますが、nginxは指定できません。

violation[{"msg": msg}] {
    container := input.review.object.spec.containers[_]
    satisfied := [good | repo = input.parameters.repos[_] ; good = startswith(container.image, repo)]
    not any(satisfied)
    msg := sprintf("container <%v> has an invalid image repo <%v>, allowed repos are %v", [container.name, container.image, input.parameters.repos])
}
violation[{"msg": msg}] {
    container := input.review.object.spec.initContainers[_]
    satisfied := [good | repo = input.parameters.repos[_] ; good = startswith(container.image, repo)]
    not any(satisfied)
    msg := sprintf("container <%v> has an invalid image repo <%v>, allowed repos are %v", [container.name, container.image, input.parameters.repos])
}
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos
metadata:
  name: repo-is-openpolicyagent
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
    namespaces:
      - "default"
  parameters:
    repos:
      - "openpolicyagent"

OK

apiVersion: v1
kind: Pod
metadata:
  name: opa-allowed
spec:
  containers:
    - name: opa
      image: openpolicyagent/opa:0.9.2

NG

apiVersion: v1
kind: Pod
metadata:
  name: nginx-disallowed
spec:
  containers:
    - name: nginx
      image: nginx

ユニークなServiceセレクタのみ許可

以下のように、修正ミス等でServiceリソースのselectorが重複してしまった際に検出できるポリシーが紹介されています。

apiVersion: v1
kind: Service
metadata:
  name: test1
spec:
  selector:
    app: test1
apiVersion: v1
kind: Service
metadata:
  name: test2
spec:
  selector:
    app: test1

まとめ

この記事では、以下のことを書きました。

  • gatekeeperのREADMEに記載されているサンプルを試して挙動を確認
  • 想定しているlabelの値ではない場合はブロック
    • 単純な方法で環境変数から取得できないことがわかったため、syncの機能を使い、ConfigMapから強引に取得
  • 公式リポジトリに置かれているその他のユースケースの紹介

OPAの汎用性は非常に高く、非常に大きな可能性を秘めていると考えています。
本番環境の導入を見据えて、キャッチアップを続けたいと思います。

参考

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
1