KubernetesのDynamic Admission Controlを試してみる

Dynamic Admission Control とは

公式の説明はこちら

KubernetesでPodのようなオブジェクトを操作する際には、kubectl等のクライアントからkube-apiserverへのAPIリクエストが実行されます。この時、リクエストの認証および認可が行われた後の段階で、Admission Controlと呼ばれる追加の検証を行うことが可能です。

例えば、Pod作成時のSpecの中に runAsUser の記述がある場合はリクエストを拒否するといった制御を行えます。使用できるAdmission Controllerの一覧は こちら に列挙されています。

現在これらのAdmission Controllerはkube-apiserverにビルトインで実装されているため、新しくAdmission Controllerを追加したくなった場合には、プラグインを実装してKubernetesのupstreamに取り込んでもらう必要があります。

しかし、自分たちのクラスタ用にカスタマイズしたAdmission Controllerを作りたいといったケースではupstreamに取り込んでもらうことは難しいでしょう。そのような時にDynamic Admission Controlが1つの手段として使用できます。

仕組み

Dynamic Admission Controlを使用するにあたっては、以下の2つのAdmission Controllerを使って設定を行います。

  • ValidatingAdmissionWebhook
  • MutatingAdmissionWebhook

Webhookという名の通り、kube-apiserverがオブジェクトの作成/更新等のリクエストを受け取った際にWebhookがトリガーされ、任意のエンドポイントへPOSTリクエストを送信してくれます。

このWebhookリクエストのBodyには、操作対象であるオブジェクトのManifestがJSON形式で含まれています。リクエストを受け取るHTTPサーバでは、その中身を見て結果をJSONで返すシンプルな処理を行います。

https://d33wubrfki0l68.cloudfront.net/af21ecd38ec67b3d81c1b762221b4ac777fcf02d/7c60e/images/blog/2019-03-21-a-guide-to-kubernetes-admission-controllers/admission-controller-phases.png

引用元: https://kubernetes.io/blog/2019/03/21/a-guide-to-kubernetes-admission-controllers/

とりあえず試す

VolumeにhostPathを使用しているPodは作成を拒否するValidatingAdmissionWebhookのサンプルを以下のリポジトリで公開しています。

github.com

まずは試してみようということで、minikube(もしくはkind)のクラスタで実際に動くところを見てみましょう。サンプルはKubernetesのバージョンが1.17以降でなければ動作しませんので、必要に応じてminikubeのバージョンを上げてください。

次のコマンドを実行するとValidatingAdmissionWebhookの設定が追加されて、kube-public namespaceにWebhookを受け取るHTTPサーバーが立ち上がります。

Podの作成に影響を及ぼすため、Productionのクラスタなどで実行しないようにご注意ください

$ curl -s https://raw.githubusercontent.com/tokibi/sample-admission-controller/master/deploy/{configmap,deployment,webhook-configuration}.yaml | kubectl apply -f -
secret/admission-controller-certs created
deployment.apps/admission-controller created
service/admission-controller created
validatingwebhookconfiguration.admissionregistration.k8s.io/validating-webhook created

$ kubectl get pod -n kube-public
NAME                                    READY   STATUS    RESTARTS   AGE
admission-controller-5c954f94c9-5dnbp   1/1     Running   0          9m12s

HTTPサーバーのPodがRunningになったことを確認したら、hostPathを使用していないPodと使用しているPodの2つを作成してみます。

$ cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: valid-pod
spec:
  containers:
  - name: valid-pod
    image: alpine:latest
    command:
      - "sleep"
      - "1"
---
apiVersion: v1
kind: Pod
metadata:
  name: invalid-pod
spec:
  containers:
  - name: invalid-pod
    image: alpine:latest
    command:
      - "sleep"
      - "1"
  volumes:
  - name: hostpath-volume
    hostPath: # <- invalid
      path: /etc
EOF

実行すると valid-pod は正常に作成されますが、invalid-pod はhostPathを使用しているため、エラーメッセージと共に作成が拒否されることがわかります。

pod/valid-pod created
Error from server: error when creating "STDIN": admission webhook "validating-webhook.example.com" denied the request: pod "invalid-pod" creation denied, hostPath is not allowed

このようにAdmissionWebhookを使用して、Podだけに限らず任意のリソースの操作時に制御を行うことができます。

設定内容

とりあえずサンプルを使って動作を確認してみましたが、具体的に何を行ったかを順に解説していきます。

  1. kube-apiserverのオプションでAdmissionWebhookを有効化
  2. WebhookConfigurationを作成
  3. Webhookを受け取るAPIサーバーを実装

kube-apiserverのオプションでAdmissionWebhookを有効化

minikubeやkindのクラスタで起動しているkube-apiserverでは初期状態で有効化されているため、先ほどサンプルを試した際にはこの手順は省略されていました。その他のクラスタでは、必要に応じてkube-apiserverの --enable-admission-plugins オプションに MutatingAdmissionWebhookValidatingAdmissionWebhook を追加してください。

参考までにminikubeのkube-apiserverのオプションでは、次のように最後から2, 3番目で指定されています。

$ minikube ssh "sudo cat /etc/kubernetes/manifests/kube-apiserver.yaml" | grep enable-admission-plugins
    - --enable-admission-plugins=NamespaceLifecycle,LimitRanger,ServiceAccount,DefaultStorageClass,DefaultTolerationSeconds,NodeRestriction,MutatingAdmissionWebhook,ValidatingAdmissionWebhook,ResourceQuota

Admission Controlは先頭から順に実行されるため、この場合は NodeRestriction の検証が実施された後、MutatingAdmissionWebhook, ValidatingAdmissionWebhookの順に検証が実施されることになります。

WebhookConfigurationを作成

kube-apiserverの設定でAdmissionWebhookを有効にしただけでは、オブジェクトの操作時にWebhookは実行されません。AdmissionWebhookを追加できるようになっただけで、設定は別途行う必要があります。

この時に作成するのがWebhookConfigurationで、サンプルでは以下のようなManifestを定義してクラスタにapplyしています。kindは ValidatingWebhookConfiguration になっていますので、検証のみを実施します。MutatingAdmissionWebhookを使用する場合は MutatingWebhookConfiguration を別途作成してください。

---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: validating-webhook
  namespace: kube-public
webhooks:
- name: validating-webhook.example.com
  admissionReviewVersions:
  - v1beta1
  clientConfig:
    caBundle: LS0tLS1CRU...
    service:
      name: admission-controller
      namespace: kube-public
      path: "/"
  rules:
  - apiGroups: [""]
    apiVersions: ["v1"]
    operations: ["CREATE"]
    resources: ["pods"]
  sideEffects: None
  timeoutSeconds: 5

clientConfig の箇所では、HTTPサーバーのTLS証明書を検証するために必要なCA証明書をbase64エンコードしたものと、HTTPサーバーのPodの前段に設置されたServiceのセレクタを記載しています。

サンプルでは、kube-apiserverからのWebhookリクエストを受け取るサーバーもKubernetesクラスタ内に起動していますのでServiceを指定していますが、以下のようにクラスタ外で起動しているサーバーをURL形式で指定することも可能です。

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
...
webhooks:
- name: my-webhook.example.com
  clientConfig:
    url: "https://my-webhook.example.com:9443/my-webhook-path"
  ...

引用元: https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#url

また、rules の箇所ではkube-apiserverがリクエストを受け取った時にWebhookをトリガーする条件を記載します。サンプルではPodの作成時のみWebhookリクエストが送信されるようにしています。その他の設定項目については このあたり が参考になると思います。

この設定を行うことでWebhookリクエストが飛ぶようになります。

Webhookを受け取るHTTPサーバーを実装

Request

kube-apiserverからのWebhookリクエストを受け取って結果を返すHTTPサーバーを実装します。リクエストBodyに含まれるJSONデータの構造は 公式 で解説されていて、以下はその引用です。

{
  "apiVersion": "admission.k8s.io/v1",
  "kind": "AdmissionReview",
  "request": {
    # Random uid uniquely identifying this admission call
    "uid": "705ab4f5-6393-11e8-b7cc-42010a800002",

    # Fully-qualified group/version/kind of the incoming object
    "kind": {"group":"autoscaling","version":"v1","kind":"Scale"},
    # Fully-qualified group/version/kind of the resource being modified
    "resource": {"group":"apps","version":"v1","resource":"deployments"},
    # subresource, if the request is to a subresource
    "subResource": "scale",

    # Fully-qualified group/version/kind of the incoming object in the original request to the API server.
    # This only differs from `kind` if the webhook specified `matchPolicy: Equivalent` and the
    # original request to the API server was converted to a version the webhook registered for.
    "requestKind": {"group":"autoscaling","version":"v1","kind":"Scale"},
    # Fully-qualified group/version/kind of the resource being modified in the original request to the API server.
    # This only differs from `resource` if the webhook specified `matchPolicy: Equivalent` and the
    # original request to the API server was converted to a version the webhook registered for.
    "requestResource": {"group":"apps","version":"v1","resource":"deployments"},
    # subresource, if the request is to a subresource
    # This only differs from `subResource` if the webhook specified `matchPolicy: Equivalent` and the
    # original request to the API server was converted to a version the webhook registered for.
    "requestSubResource": "scale",

    # Name of the resource being modified
    "name": "my-deployment",
    # Namespace of the resource being modified, if the resource is namespaced (or is a Namespace object)
    "namespace": "my-namespace",

    # operation can be CREATE, UPDATE, DELETE, or CONNECT
    "operation": "UPDATE",

    "userInfo": {
      # Username of the authenticated user making the request to the API server
      "username": "admin",
      # UID of the authenticated user making the request to the API server
      "uid": "014fbff9a07c",
      # Group memberships of the authenticated user making the request to the API server
      "groups": ["system:authenticated","my-admin-group"],
      # Arbitrary extra info associated with the user making the request to the API server.
      # This is populated by the API server authentication layer and should be included
      # if any SubjectAccessReview checks are performed by the webhook.
      "extra": {
        "some-key":["some-value1", "some-value2"]
      }
    },

    # object is the new object being admitted.
    # It is null for DELETE operations.
    "object": {"apiVersion":"autoscaling/v1","kind":"Scale",...},
    # oldObject is the existing object.
    # It is null for CREATE and CONNECT operations.
    "oldObject": {"apiVersion":"autoscaling/v1","kind":"Scale",...},
    # options contains the options for the operation being admitted, like meta.k8s.io/v1 CreateOptions, UpdateOptions, or DeleteOptions.
    # It is null for CONNECT operations.
    "options": {"apiVersion":"meta.k8s.io/v1","kind":"UpdateOptions",...},

    # dryRun indicates the API request is running in dry run mode and will not be persisted.
    # Webhooks with side effects should avoid actuating those side effects when dryRun is true.
    # See http://k8s.io/docs/reference/using-api/api-concepts/#make-a-dry-run-request for more details.
    "dryRun": false
  }
}

HTTPサーバーは基本的にこのJSONの中身を見て処理を実施していくことになります。サンプルではPodのspecにhostPathが使用されているか否かによって結果を変えていましたが、それは request.object の中を見て判断しています。

Response

ValidatingAdmissionWebhookに対するレスポンスでは request.allowed のbool値によってkube-apiserverへのリクエストを許可するかどうかを決定します。falseの場合は response.status.message でオブジェクトを操作しようとしたクライアントにエラーメッセージを通知することができます。

{
  "apiVersion": "admission.k8s.io/v1",
  "kind": "AdmissionReview",
  "response": {
    "uid": "<value from request.uid>",
    "allowed": false,
    "status": {
      "code": 403,
      "message": "You cannot do this because it is Tuesday and your name starts with A"
    }
  }
}

引用元: https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#response

また、MutatingAdmissionWebhookに対するレスポンスでは patchTypepatch によってManifestの変更内容を指定します。patch の値には [{"op": "add", "path": "/spec/replicas", "value": 3}] のように操作内容, 対象, 変更後の値をbase64エンコードしたものを入力します。

{
  "apiVersion": "admission.k8s.io/v1",
  "kind": "AdmissionReview",
  "response": {
    "uid": "<value from request.uid>",
    "allowed": true,
    "patchType": "JSONPatch",
    "patch": "W3sib3AiOiAiYWRkIiwgInBhdGgiOiAiL3NwZWMvcmVwbGljYXMiLCAidmFsdWUiOiAzfV0="
  }
}

引用元: https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#response

HTTPサーバーの行う処理はJSONを受け取ってJSONを返すだけですので、一から作成するのもそこまで大変ではないと思いますが、サンプルではAdmissionWebhook用のフレームワークである slok/kubewebhook を利用させていただきました。

github.com

まとめ

kube-apiserverに組み込まれるAdmission Controllerを新しく追加するのは非常に大変ですが、AdmissionWebhookによって簡単に色々なカスタマイズができそうです。

FUSEを使ってコマンドをファイルでラップする

少し前からFUSEに興味が湧いて色々作りたいものを考えていました。年末年始にある程度動くものができたので一旦まとめてみます。

github.com

ちなみに数日前に同僚のharasouさんが cat するたびに内容が変わるファイル?を作った という記事を上げていて、内容がかなり被っているので二番煎じ感がすごいです。

何をするものか

大体GitHubのREADMEに書いていますが、以下のような設定ファイルをyamlで定義してnanafshiを使ってマウントすると、読み込んだり書き込んだりした時に内部でコマンドを実行するファイルが作成されます。

shell: /bin/bash
services:
  - name: dir
    files:
      - name: example
        read:
          command: curl http://www.example.com/

      - name: writable
        read: 
          command: |
            touch /tmp/file
            cat /tmp/file
        write: 
          async: true
          command: echo $FUSE_STDIN >> /tmp/file

このyamlを使ってマウントしてやると、設定ファイルの構造に従ってディレクトリとファイルが作成されます。

$ nanafshi -c config.yml /mnt/nanafshi

$ tree /mnt/nanafshi
/mnt/nanafshi
└── dir
    ├── example
    └── writable

以下のように作成されたファイルをcatなどで読み込むと、内部で実行したコマンドの標準出力が表示されます。また、書き込みの場合はコマンドを実行するだけですが、環境変数として入力されたデータを渡せるようになっています。

$ cat /mnt/nanafshi/dir/example # curl http://www.example.com/ の結果
<!doctype html>
<html>
<head>
    <title>Example Domain</title>
...

内部コマンドで使用できる環境変数についてはGitHubの方でまとめていますが、ファイルを開いたプロセスのPIDやUIDなどもあり、必要に応じて増やしていくつもりです。

また、Macの場合は標準では使用できませんが、FUSE for macOS をインストールすればLinuxと同様に使えます。Windowsは未検証です。

何に使うのか

設定ファイルの動的生成

はじめに紹介した記事でharasouさんも言及していますが、アプリケーションの設定ファイルのパラメータをAPIやデータベースなどから取ってくるといったことができそうです。

外部ログ基盤との連携

readとwrite両方に対応していますので、例えば bash_history のようなログファイルを以下の設定で用意してやれば、外部のログ管理基盤と直接やりとりしてログを保存/取得し、複数ホスト間で共有するといったこともできます。

services:
  - name: logs
    files:
      - name: bash_history
        read:
          # read時はGETリクエストで全ログを取得
          command: curl https://store.example.com/tokibi/bash_history
        write:
          # write時はPUTリクエストで保存
          command: curl -X PUT -H "Text:$FUSE_STDIN" https://store.example.com/tokibi/bash_history

このようなファイルはコンテナ上のログのような揮発性の高いデータの保存に対して有用になりそうです。ただ、パフォーマンス面は全く検証できていないので、本番環境で使えるような信頼性を担保するところが目標です。

コマンド単位の権限移譲

ファイル内部で実行されるコマンドは、nanafshiのプロセスからforkされて実行されるようになっています。なので、rootユーザがnanafshiを起動してマウントした場合は、ファイルアクセス時に実行されるコマンドもrootユーザの権限で実行されます。

また、ファイルアクセス時にどのようなコマンドが実行されるかは、該当のファイルをどのように参照しても(多分)見えないので、設定ファイルの所有権を厳密に設定すればファイルアクセスを行うユーザから見て内部で何が起きているか調べる手段がないという状態になります。

これらの特性を利用すれば、設定ファイルに記載したコマンドのみに限定して、その内容を隠蔽しつつ他のユーザに実行権限を委譲するといったことが可能です。例えばコンテナプロセスに対してバインドマウントでrootユーザで起動したnanafshiのファイルを提供すれば、Linuxのcapabilityなどを無視して色々なコマンドの実行権限を委譲できてしまいます。ただの脆弱性にもなりかねないので、基本的には専用のユーザなどを用意して起動することをおすすめします。

システム全体の複雑性がとても高くなるので、安易に色々やると痛い目にあいそうですね。

今後の予定

  • テスト, ログ周りちゃんとする
  • ディレクトリ, ファイルごとに所有権を設定できるようにする
  • bash組み込みのreadコマンドのような対話的なコマンドを差し込めるようにしたい
    • ファイルを開く時にMFAとか入れたい
  • read時のcache実装
  • write時のFUSE_STDIN環境変数あたりでインジェクションできそうなので対策する

まとめ

設定ファイルにコマンドを書けばいいだけなので、FUSEを使って色々と簡単に試せると思います。面白そうな使い方があったら教えてください。

あと、nanafshiという名前の由来について会社でよく聞かれるので書いておくと、コマンドをファイル(Node)に擬態させる的なイメージから、枝葉に擬態する生き物を探してナナフシが見つかったのでそのまま採用しました(枝に擬態するのでちょっと違いますが)。あとfilesystemなのでnanafushiのuを削ってfsにしています。

cronsunを試してみる

この記事は GMOペパボ Advent Calentar 2018の12月7日(金)担当分です。風邪やらなんやらでとんでもない遅刻をしています。

最近色々あるので大量のジョブを捌けそうなTime-based Job Schedulerを探しています。いわゆるcronです。fault tolerant distributed cron という夢のようなキーワードで検索してみるといい感じのが見つかりました。

dkronはいくつか紹介記事がありましたが、cronsunの方は見つからなかったのでとりあえず動かしてみようと思います。

build

cronsunはgolang製なのでbuildしてバイナリを作ります。今回はdockerのgolangイメージを使ってbuildしました。

$ git clone https://github.com/shunfei/cronsun
$ cd cronsun
$ docker run -it -v "$(pwd):/cronsun" golang bash

root@6649eb0f477b:/go# cd /cronsun
root@6649eb0f477b:/cronsun# export GO111MODULE=on
root@6649eb0f477b:/cronsun# go mod vendor
root@6649eb0f477b:/cronsun# bash ./build.sh

buildに成功すると dist ディレクトリ以下にバイナリと設定ファイルが作成されています。

root@6649eb0f477b:/cronsun# tree ./dist
dist
|-- conf
|   |-- base.json
|   |-- db.json
|   |-- etcd.json
|   |-- mail.json
|   |-- security.json
|   `-- web.json
|-- cronnode
|-- cronweb
`-- csctl

起動してみる

cronsun はArchitectureに記載されている通り、ジョブ情報をetcdに持ち、ジョブの実行結果をMongoDBに保存するようになっています。

起動するためにはこの2つが必要になるので、今回は docker-compose.yml を次のように作成して試してみます。データの永続化は特に気にしていません。

version: '3'
services:
  etcd:
    image: quay.io/coreos/etcd
    command: etcd -name=cronsun1 -advertise-client-urls http://etcd:2379  -listen-client-urls http://0.0.0.0:2379

  mongo:
    image: mongo

  cronsun-node:
    image: ubuntu
    volumes:
      - ./dist:/dist
    depends_on:
      - etcd
      - mongo
    command: /dist/cronnode -conf /dist/conf/base.json

  cronsun-web:
    image: ubuntu
    ports:
      - 7079:7079
    volumes:
      - ./dist:/dist
    depends_on:
      - etcd
      - mongo
    command: /dist/cronweb -conf /dist/conf/base.json

併せて設定ファイルも修正しておきます。dist/conf 以下の db.jsonetcd.json がデフォルトではlocalhostを見るようになっているので、docker-composeに記載したサービス名にそれぞれ変更しておきます。

db.json

{
  "Hosts": [
    "mongo:27017"
  ],
  ...
}

etcd.json

{
    "Endpoints":[
        "http://etcd:2379"
    ],
    ...
}

設定ファイルの修正が終わったら、docker-compose up で各コンテナを起動します。起動後に http://localhost:7079 にアクセスすると無事ダッシュボードが表示されました。初期状態だと中国語ですが右上の歯車アイコンから英語に切り替えられます。

f:id:tokibix:20181224183405p:plain

echo Hello を実行するだけのJobを作成してみました。秒単位まで指定できるcron形式の他に、様々な設定方法がありGitHubwikiにまとめられています。

f:id:tokibix:20181224183525p:plain

ログページを見てみるとnode上で動いていることがわかります。

f:id:tokibix:20181224183558p:plain

APIも用意されているようで、 curl http://localhost:7079/v1/version などを叩いてみるとcronsunのバージョンが表示されるのですが、この辺はwikiにまとまっていなかったので作成中かもしれません。

とりあえず動かすところまではできたので、気が向いたらnodeを大量に作って負荷テスト的なこともしてみようと思います。