Istio VirtualServiceのHost衝突を検知するAdmission Webhookをつくってみる

JX通信社Advent Calendar 2019」15日目の記事です。昨日はペイさんによるNuxt.js + firebaseで「積ん読防止」アプリを作ってみたでした。

こんにちは、SREのたっち(TatchNicolas)です。

はじめに

引き続き、KubernetesのAdmission Webhooksについて書きます。Admission Webhooksとは何か、簡単に作って動かしてみる方法については前回記事も参照してください。

tech.jxpress.net

今回はもう少し踏み込んで、ちょっとだけ役に立ちそうな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を作ってみました。

サンプルコードは以下になります。

github.com

やってみる

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