「JX通信社Advent Calendar 2019」15日目の記事です。昨日はペイさんによるNuxt.js + firebaseで「積ん読防止」アプリを作ってみたでした。
こんにちは、SREのたっち(TatchNicolas)です。
はじめに
引き続き、KubernetesのAdmission Webhooksについて書きます。Admission Webhooksとは何か、簡単に作って動かしてみる方法については前回記事も参照してください。
今回はもう少し踏み込んで、ちょっとだけ役に立ちそうなAdmission Webhookを書いてみたいと思います。
TL; DR
- Istio VirtualServiceのHostの衝突を防止するValidating Webhookを作った
- 似たようなロジックでk8s本体のingressなどにも使えるはず
前回までのあらすじ
Kubernetesのリソース操作は、kube-apiserverにリクエストを送ることで行います。その操作がetcdに反映される前にそのリクエスト内容をValidate/MutateできるのがAdmission controllerで、Kubernetesにbuilt-inしなくても自前のValidate/Mutateの処理を足せるのがAdmission Webhooksです。
前回はWebhook作成に必要なもの(Webhookを動かすDeployment、Serviceリソース、証明書など)を作る手順をゼロから実施し、サンプルとして metadata.labels
の規約が守られているかチェックしたり、 metadata.name
に接頭辞を自動で付けたりする処理を実装しました。
今回やってみたこと
IstioのVirtualServiceを定義するときに、ドキュメントにもあるように、同じHostを複数のVirtualServiceに分割して書くやり方があります。
しかし、内向けのAPIなどの定義にパスではなくホスト名でマイクロサービスを定義している場合にはhostの衝突は避けたいと思います。
たとえば、 api.some-product
というホスト名がすでに存在しているときに別のチームで *.some-product
のようなホスト名が登録されてしまうと、複数のVirtualServiceリソース間での評価順序は保証されないため、リクエストが吸い取られて意図しない挙動をしてしまうかもしれません。
そこで、マニフェスト適用時に衝突を検出できるようなValidating Webhookを作ってみました。
サンプルコードは以下になります。
やってみる
Prerequisites
- Istioが動いているクラスタ*1
- 公式ドキュメント*2か前回の記事を参考に、証明書などWebhook開発のための準備ができていること
- Webhookを動かしているPodにkube-apiserverを叩かせるための権限を与えていること*3
Webhookのリソース定義
webhooks.rules
以下にValidation対象のリソース種別を書いていきます。
apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingWebhookConfiguration metadata: name: "istio-vsvc-host" webhooks: - name: "istio-vsvc-host.hoge.fuga.local" failurePolicy: Fail rules: - apiGroups: ["networking.istio.io"] operations: ["CREATE","UPDATE"] apiVersions: ["v1alpha3"] resources: ["virtualservices"] scope: "Namespaced" clientConfig: caBundle: <server.crtの中身をbase64エンコードして貼る> service: namespace: default name: istio-vsvc-host path: /validate admissionReviewVersions: ["v1", "v1beta1"] timeoutSeconds: 5 sideEffects: None
Webhookの実装
Webhookもいたってシンプルです。
import fnmatch from flask import Flask, jsonify, request from kubernetes import client, config app = Flask(__name__) config.load_incluster_config() # telepresenceではこっちを使う # config.load_kube_config() conf=client.Configuration() api_client=client.ApiClient(configuration=conf) @app.route('/validate', methods=['POST']) def validate(): try: # リクエストから必要な情報を抜き出す req = request.get_json() new_hosts = req['request']['object']['spec']['hosts'] apiserver_resp = api_client.call_api( '/apis/networking.istio.io/v1alpha3/namespaces/default/virtualservices', 'GET', auth_settings=['BearerToken'], response_type='object', ) nested_existing_hosts = { tuple(item['spec']['hosts']) for item in apiserver_resp[0]['items'] } existing_hosts = {item for sublist in nested_existing_hosts for item in sublist} print(f'existing_hosts: {existing_hosts}') print(f'new_hosts: {new_hosts}') # UPDATEのときは、oldに入っているものは検査対象から除外する operation = req['request']['operation'] if operation == 'UPDATE': old_hosts = req['request']['oldObject']['spec']['hosts'] for host in old_hosts: existing_hosts.remove(host) print(f'updated existing_hosts: {existing_hosts}') # hostsの被りがないかチェックする pair = get_collision_pair(new_hosts, existing_hosts) if pair: allowed = False message = f'{pair[0]} collides with {pair[1]} which already exists' else: allowed = True message = f'No collision detected' # 結果を返す return jsonify({ 'apiVersion': 'admission.k8s.io/v1', 'kind': 'AdmissionReview', 'response': { 'uid': request.get_json()['request']['uid'], 'allowed': allowed, 'status': {'message': message} } }), 200 except (TypeError, KeyError): return jsonify({'message': 'Invalid request'}), 400 def get_collision_pair(new_hosts, existing_hosts): for new_host in new_hosts: for existing_host in existing_hosts: if fnmatch.fnmatch(new_host, existing_host) or fnmatch.fnmatch(existing_host, new_host): return new_host, existing_host
動かしてみる
以下のようなVirtualServiceが既に存在する状態で、新たにVirtualServiceのhostを増やしてみます。
$ kubectl get virtualservices NAME GATEWAYS HOSTS AGE existing-vsvc-1 [hoge.tatchnicolas.com] 15s existing-vsvc-2 [fuga.tatchnicolas.com] 15s
まずはぶつからない場合。
apiVersion: networking.istio.io/v1alpha3 kind: VirtualService metadata: name: new-vsvc spec: hosts: - 'new.tatchnicolas.com' http: - name: "ClusterIP" route: - destination: host: new-serivce.default.svc.cluster.local
これはapplyしても普通に成功します。
$ kubectl apply -f istio-vsvc-host/new_vsvc.yaml virtualservice.networking.istio.io/new-vsvc created
では、既存のhostと衝突させてみましょう。
apiVersion: networking.istio.io/v1alpha3 kind: VirtualService metadata: name: new-vsvc spec: hosts: - 'new.tatchnicolas.com' - 'fuga.tatchnicolas.com' # これがぶつかる http: - name: "ClusterIP" route: - destination: host: new-serivce.default.svc.cluster.local
衝突を検知し、きちんと拒否していることがわかります。
$ kubectl apply -f istio-vsvc-host/new_vsvc.yaml (中略) for: "istio-vsvc-host/new_vsvc.yaml": admission webhook "istio-vsvc-host.hoge.fuga.local" denied the request: fuga.tatchnicolas.com collides with fuga.tatchnicolas.com which already exists
ワイルドカードにも対応できます。
apiVersion: networking.istio.io/v1alpha3 kind: VirtualService metadata: name: new-vsvc spec: hosts: - '*.tatchnicolas.com' http: - name: "ClusterIP" route: - destination: host: new-serivce.default.svc.cluster.local
こちらも拒否できていますね。
$ kubectl apply -f istio-vsvc-host/new_vsvc.yaml (中略) for: "istio-vsvc-host/new_vsvc.yaml": admission webhook "istio-vsvc-host.hoge.fuga.local" denied the request: *.tatchnicolas.com collides with fuga.tatchnicolas.com which already exists
さいごに
リクエストを適切に処理できれば実装言語は何でも良いので、前回のコードをベースにそのままPythonで書きました。比較のロジックもかなり簡単に書いたのでパフォーマンス面で改善の余地がありそうですし、実用レベルに持っていくにはNamespace対応したりテストもしっかり書かないといけないでしょう。
それでも、KubernetesやIstioに手を加えずに、ちょっとしたチェック/書き換え機能を追加できるのは非常に便利です。
理想的にはもっと汎用的にValidation条件を渡せるようにして、カスタムリソースとして簡単に適用できるようにするとか応用の幅は広そうです。
プロトタイプとテストをサクッと作れる言語で書いて、あとからリファクタリングしたり別の言語に書き換えることもできるので、ユースケースに合わせて上手に使っていきたいです。
*1:Minikubeのデフォルトではリソースが足りないせいでIstioのコントロールプレーンが起動せず、また手元の環境(i7/16GB RAM)ではIstio推奨のリソースを動かすのは辛かったです
*2:https://kubernetes.io/docs/tasks/tls/managing-tls-in-a-cluster/
*3:https://github.com/TatchNicolas/sample-admission-webhook/blob/master/istio-vsvc-host/rbac.yaml