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://kubernetes.io/blog/2019/03/21/a-guide-to-kubernetes-admission-controllers/
とりあえず試す
VolumeにhostPathを使用しているPodは作成を拒否するValidatingAdmissionWebhookのサンプルを以下のリポジトリで公開しています。
まずは試してみようということで、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だけに限らず任意のリソースの操作時に制御を行うことができます。
設定内容
とりあえずサンプルを使って動作を確認してみましたが、具体的に何を行ったかを順に解説していきます。
- kube-apiserverのオプションでAdmissionWebhookを有効化
- WebhookConfigurationを作成
- Webhookを受け取るAPIサーバーを実装
kube-apiserverのオプションでAdmissionWebhookを有効化
minikubeやkindのクラスタで起動しているkube-apiserverでは初期状態で有効化されているため、先ほどサンプルを試した際にはこの手順は省略されていました。その他のクラスタでは、必要に応じてkube-apiserverの --enable-admission-plugins
オプションに MutatingAdmissionWebhook
と ValidatingAdmissionWebhook
を追加してください。
参考までに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に対するレスポンスでは patchType
と patch
によって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 を利用させていただきました。
まとめ
kube-apiserverに組み込まれるAdmission Controllerを新しく追加するのは非常に大変ですが、AdmissionWebhookによって簡単に色々なカスタマイズができそうです。
FUSEを使ってコマンドをファイルでラップする
少し前からFUSEに興味が湧いて色々作りたいものを考えていました。年末年始にある程度動くものができたので一旦まとめてみます。
ちなみに数日前に同僚の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.json
と etcd.json
がデフォルトではlocalhostを見るようになっているので、docker-composeに記載したサービス名にそれぞれ変更しておきます。
db.json
{ "Hosts": [ "mongo:27017" ], ... }
etcd.json
{ "Endpoints":[ "http://etcd:2379" ], ... }
設定ファイルの修正が終わったら、docker-compose up
で各コンテナを起動します。起動後に http://localhost:7079
にアクセスすると無事ダッシュボードが表示されました。初期状態だと中国語ですが右上の歯車アイコンから英語に切り替えられます。
echo Hello
を実行するだけのJobを作成してみました。秒単位まで指定できるcron形式の他に、様々な設定方法がありGitHubのwikiにまとめられています。
ログページを見てみるとnode上で動いていることがわかります。
APIも用意されているようで、 curl http://localhost:7079/v1/version
などを叩いてみるとcronsunのバージョンが表示されるのですが、この辺はwikiにまとまっていなかったので作成中かもしれません。
とりあえず動かすところまではできたので、気が向いたらnodeを大量に作って負荷テスト的なこともしてみようと思います。