kubebuilder v2 で webhook 開発

仕事で、データセンターのアーキテクチャを刷新するプロジェクトを進めてます。 Kubernetes を中心としているので、必然的に Kubernetes 上で動作するアプリケーションを開発する機会があります。

Kubernetes は API サーバー (kube-apiserver) にリソースを登録して、他のプログラムは API サーバー上のリソースを監視して動く Hub & Spoke アーキテクチャを特徴としています。

https://d33wubrfki0l68.cloudfront.net/518e18713c865fe67a5f23fc64260806d72b38f5/61d75/images/docs/post-ccm-arch.png

出展:https://kubernetes.io/docs/concepts/architecture/cloud-controller/

Kubernetes の動作をカスタマイズするには、API サーバーの動作に手を入れる必要があります。kube-apiserver はそのための仕組みとして、通常の API に加えて以下を提供しています。

今回のお題はこの中の Webhook 実装についてです。長くて専門的かつ TL;DR もないのでご注意ください。

Webhook を使うと、リソースの作成・変更・削除時に独自に用意した HTTP サーバーをコールバックしてリクエストを拒否したりリソースの内容を編集できます。

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/

Webhook サーバーは渡されたリソースの内容を検証したり編集するだけのシンプルな HTTP サーバーとして作れることが多いのですが、手間になりえる点として以下があります。

  • HTTPS が必須
  • HTTPS の証明書の用意と、更新時の自動再読み込みの実装
  • レスポンスが JSON Patch 形式のみ
  • 処理に必要な他のリソースのキャッシュ
  • 結合試験

Kubebuilder を使うとこれらの処理の大半をライブラリである controller-runtime に任せることができます。実装例がいくつか用意されています。

これで実装はできるのですが、残念ながら現在の Kubebuilder には webhook の結合試験について雛型を用意する機能がありません。ライブラリである controller-runtime に envtest という結合試験用のパッケージがあるので、これを使ってなんとかするしかありません。

何とかしたコードが GitHub にあるので全体についてはそちらを見ていただくとして、ポイントになる箇所を解説していきます。

topolvm/hook at master · cybozu-go/topolvm · GitHub

Webhook サーバーの初期化

   wh := mgr.GetWebhookServer()
    wh.Host = webhookHost
    wh.Port = webhookPort
    wh.CertDir = certDir

    // NewDecoder never returns non-nil error
    dec, _ := admission.NewDecoder(scheme)
    wh.Register("/mutate", &webhook.Admission{Handler: podMutator{mgr.GetClient(), dec}})

https://github.com/cybozu-go/topolvm/blob/037e972ffe410a3e77513324a07dcee0ed87b652/hook/run.go#L56-L63

Webhook サーバーは mgr に与えることができず、mgr.GetWebhookServer を呼び出すと作成されます。 Listen するアドレスや証明書のディレクトリ位置を与えるのは、最初のハンドラを Register する前に行います。

CertDir には tls.crttls.key という名前のファイルで証明書と秘密鍵を置きます。 Kubebuilder は v2 から webhook の証明書は cert-manager で作成する方式で、cert-manager の Secret に tls.crt, tls.key というキー名で証明書が用意されるためです。

結合試験では cert-manager は使えないので、証明書は別途 OpenSSLcfssl で用意します。それらを置いたディレクトリを指定できるようにしたのが上記のコードです。

Webhook サーバー用の Maker と RBAC

// +kubebuilder:webhook:path=/mutate,mutating=true,failurePolicy=fail,groups="",resources=pods,verbs=create,versions=v1,name=topolvm-hook
// +kubebuilder:rbac:groups="",resources=persistentvolumeclaims,verbs=get;list;watch
// +kubebuilder:rbac:groups=storage.k8s.io,resources=storageclasses,verbs=get;list;watch

Kubebuilder は Go のソースコード中に Marker という書式で指示することで、Kubernetes にデプロイするための YAML マニフェストを生成する仕組みとなっています。Webhook 用のマーカーはこちらに仕様があります。

この Webhook は処理中に PersistentVolumeClaim と StorageClass を参照するので、そのための RBAC 権限も付与します。 注意するべき点として、controller-runtime の API クライアントは透過的にリソースを監視してキャッシュするので、get だけでなく list, watch 権限も与えなければいけません。

マーカーを変更したら、make manifests で YAML を更新できます。

Webhook を有効にして kube-apiserver を起動

   By("bootstrapping test environment")
    apiServerFlags := envtest.DefaultKubeAPIServerFlags[0 : len(envtest.DefaultKubeAPIServerFlags)-1]
    apiServerFlags = append(apiServerFlags, "--admission-control=MutatingAdmissionWebhook")
    testEnv = &envtest.Environment{
        CRDDirectoryPaths:  []string{filepath.Join("..", "config", "crd", "bases")},
        KubeAPIServerFlags: apiServerFlags,
    }

    var err error
    cfg, err = testEnv.Start()

https://github.com/cybozu-go/topolvm/blob/037e972ffe410a3e77513324a07dcee0ed87b652/hook/suite_test.go#L40-L49

envtest パッケージは kube-apiserver をすべての Admission Controller を無効化して起動します。Webhook も Admission Controller の一つ(MutatingAdmissionWebhook, ValidatingAdmissionWebhook)なので、結合試験のためには有効にしないといけません。

上記のコードはデフォルトのフラグ DefaultKubeAPIServerFlags を書き換えて結合試験環境を起動しています。

Webhook サーバーの起動

   By("running webhook server")
    certDir, err := filepath.Abs("./certs")
    Expect(err).ToNot(HaveOccurred())
    go Run(cfg, "127.0.0.1", 8443, "localhost:8999", certDir, false)
    d := &net.Dialer{Timeout: time.Second}
    Eventually(func() error {
        conn, err := tls.DialWithDialer(d, "tcp", "127.0.0.1:8443", &tls.Config{
            InsecureSkipVerify: true,
        })
        if err != nil {
            return err
        }
        conn.Close()
        return nil
    }).Should(Succeed())

https://github.com/cybozu-go/topolvm/blob/037e972ffe410a3e77513324a07dcee0ed87b652/hook/suite_test.go#L57-L71

結合試験ですから、Webhook サーバーは実際に起動しなければいけません。単純に goroutine で起動しておけばいいのですが、リクエストを受け付けるまで多少時間がかかるので、起動完了を待機しなければテストが安定して動作しません。

上記のコードはテスト用の証明書ディレクトリを指定して goroutine で webhook サーバー、というか Manager を起動し、準備が完了するまで Eventually で待機しています。Eventually というのは Kubernetes / kubebuilder で使われるテストフレームワーク Ginkgo の機能です。

Webhook をインストール

   caBundle, err := ioutil.ReadFile("certs/ca.crt")
    Expect(err).ShouldNot(HaveOccurred())
    wh := &admissionregistrationv1beta1.MutatingWebhookConfiguration{}
    wh.Name = "topolvm-hook"
    _, err = ctrl.CreateOrUpdate(testCtx, k8sClient, wh, func() error {
        failPolicy := admissionregistrationv1beta1.Fail
        urlStr := "https://127.0.0.1:8443/mutate"
        wh.Webhooks = []admissionregistrationv1beta1.Webhook{
            {
                Name:          "hook.topolvm.cybozu.com",
                FailurePolicy: &failPolicy,
                ClientConfig: admissionregistrationv1beta1.WebhookClientConfig{
                    CABundle: caBundle,
                    URL:      &urlStr,
                },
                Rules: []admissionregistrationv1beta1.RuleWithOperations{
                    {
                        Operations: []admissionregistrationv1beta1.OperationType{
                            admissionregistrationv1beta1.Create,
                        },
                        Rule: admissionregistrationv1beta1.Rule{
                            APIGroups:   []string{""},
                            APIVersions: []string{"v1"},
                            Resources:   []string{"pods"},
                        },
                    },
                },
            },
        }
        return nil
    })
    Expect(err).ShouldNot(HaveOccurred())

https://github.com/cybozu-go/topolvm/blob/037e972ffe410a3e77513324a07dcee0ed87b652/hook/mutate_pod_test.go#L29-L60

kube-apiserver に MutatingWebhookConfiguration リソースを追加すれば、実際に Webhook を呼び出すようになります。上記のコードは少々長いですが、テスト用の証明書の CA 証明書を CABundle フィールドにセットして Pod リソースの作成時に https://127.0.0.1:8443/mutate で動作している webhook をコールバックする設定をインストールしています。

テストケース

   It("should mutate pod w/ TopoLVM PVC", func() {
        pod := testPod()
        pod.Spec.Volumes = []corev1.Volume{
            {
                Name: "vol1",
                VolumeSource: corev1.VolumeSource{
                    PersistentVolumeClaim: pvcSource("pvc1"),
                },
            },
        }
        err := k8sClient.Create(testCtx, pod)
        Expect(err).ShouldNot(HaveOccurred())

        pod = getPod()
        request := pod.Spec.Containers[0].Resources.Requests["topolvm.cybozu.com/capacity"]
        limit := pod.Spec.Containers[0].Resources.Limits["topolvm.cybozu.com/capacity"]
        Expect(request.Value()).Should(BeNumerically("==", 1<<30))
        Expect(limit.Value()).Should(BeNumerically("==", 1<<30))
    })

ここまでで Pod を作れば webhook で処理される準備ができましたので、テストケースは Pod を実際に作って編集内容を確認するだけです。 テストケース毎に Pod を作って消してを繰り返すので、削除は Ginkgo の AfterEach 機能を使って自動的に行っています。

   AfterEach(func() {
        pod := &corev1.Pod{}
        pod.Name = "test"
        pod.Namespace = "test"
        err := k8sClient.Delete(testCtx, pod, client.GracePeriodSeconds(0))
        Expect(err).ShouldNot(HaveOccurred())
    })

まとめ

Kubebuilder v2 で webhook を結合試験する手法について解説しました。Enjoy hacking Kubernetes!