Handy Admission Webhook Library

Kubernetes の Admission Webhook を開発する際に、[kubernetes/api](https://pkg.go.dev/k8s.io/api/admission/v1beta1) をラップした軽量なライブラリやフレームワークを使うことがあると思います。

- [kubernetes-sigs/controller-runtime](https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/webhook)
- [slok/kubewebhook](https://github.com/slok/kubewebhook)

Admission Webhook の処理自体はシンプルなので、どのライブラリやフレームワークを使っても開発中に不満を感じることはないはずです。ただ、開発した Admission Webhook をデプロイする時に面倒に感じる部分があります。

# Motivation

Kubernetes の Admission Webhook の処理を少し振り返ります。Admission Webhook は、Kubernetes API Server がクライアントからのリクエスト (リソースの作成、更新、削除) を処理する過程の中で、Webhook サーバーに処理を移譲してオブジェクトの変更や検証を行う拡張機能です。

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

Source: [A Guide to Kubernetes Admission Controllers](https://kubernetes.io/blog/2019/03/21/a-guide-to-kubernetes-admission-controllers/) 

Kubernetes API Server は、Admission Webhook に対して HTTPS POST リクエストを投げます。Admission Webhook は、そのリクエストをもとにオブジェクトを変更・検証して、Kubernetes API Server にレスポンスを返します。ここで以下の点に注意が必要です。

- Admission Webhook を HTTPS サーバーとして起動する必要がある。
- Kubernetes API Server と Admission Webhook はクラスター内で通信するため、Admission Webhook の公開は Cluster IP を通せば十分である。
- Cluster IP で公開する場合、Admission Webhook の FQDN は `<service name>.<namespace>.svc.cluster.local` となる。
- Kubernetes の DNS サーチパスの設定のおかげで、`<service name>` or `<service name>.<namespace>` or `<service name>.<namespace>.svc` で名前解決が可能です。

これらの注意点を考慮して、上記の 3 つのドメインで自己署名証明書を発行すれば良いです。

最初に紹介した 2 つのライブラリやフレームワークは、この証明書を外部で発行する必要があります。証明書を発行しさえすれば、あとは良い感じにやってくれるので、以下が良くあるパターンだと思います。

1. [cert-manager](https://github.com/jetstack/cert-manager) の Issuer リソースから CA 証明書を生成
1. cert-manager の Certificate リソース生成 (証明書を生成して、CA 証明書で署名してから Secret に保存してくれる)
1. [CA Injector](https://cert-manager.io/docs/concepts/ca-injector/) の機能を使って、CA 証明書を Webhook の設定の中の CA bundle に登録
1. Webhook の Pod に自己署名証明書が保存された Secret をマウント
1. Webhook サーバーの起動オプションとしてマウントした自己署名証明書のパスを渡す

Admission Webhook をデプロイするために cert-manager が必要なのは個人的に面倒です。

また、証明書の自動ローテーションを cert-manager v0.15 で実現するのは厳しく、オペレーションが発生するはずです。

- 証明書をローテーションする[機能](https://cert-manager.io/docs/usage/certificate/#configuring-private-key-rotation) がまだ実験的に導入されている段階である
- cert-manager が発行した自己署名証明書をローテーションしてくれたとしても、Webhook サーバーを再起動しないと新しい証明書を読み込んでくれない

証明書を 10 年など長い有効期間で発行すればある程度回避できますが...。

# Knative

Knative プロジェクトでは、コントローラーや Webhook の作成を楽にするライブラリを [knative/pkg](https://github.com/knative/pkg) に切り出して公開しており、Knative 以外のプロジェクトでも利用することができます。また、Controller や Webhook の開発を始めるためのテンプレートも [knative/sample-controller](https://github.com/knative/sample-controller) として用意しています。(Kubernetes 本家の [sample-controller](https://github.com/kubernetes/sample-controller) と同じ形です。) Admission Webhook を作る場合は、codegen などが不要なのでテンプレートから作る恩恵がそれほどないですが。

## Knative Webhooks

[knative/pkg](https://github.com/knative/pkg) の [webhook](https://pkg.go.dev/github.com/knative/pkg/webhook) パッケージを使うと、先にあげた証明書の問題を解決することができます。

- 起動時に CA 証明書の作成、証明書の生成と署名、Secret への保存を行う
- 発行する自己署名証明書の有効期間は 1 年間で、有効期限が切れる 1 週間前に証明書をローテーションする
- サーバー起動時に証明書を読み込まず、新しく HTTPS リクエストを受けた時に証明書を遅延読み込みする

Admission Webhook の公式実装のコードは、[webhook/resourcesemantics](https://github.com/knative/pkg/tree/release-0.15/webhook/resourcesemantics) や [webhook/configmaps](https://github.com/knative/pkg/tree/release-0.15/webhook/configmaps) にあるので、眺めると使い方のイメージが湧くと思います。

# Sample Implementation

Knative Webhooks ライブラリを使用した Admission Webhook のサンプルを作成しました。Knative のサンプルアプリである [helloworld-go](https://github.com/knative/docs/tree/release-0.15/docs/serving/samples/hello-world/helloworld-go) のコンテナに環境変数 `TARGET` を埋め込むシンプルな Mutating Webhook です。ソースコードは [toVersus/env-injector](https://github.com/toVersus/env-injector) にあります。

このサンプル実装を見ながら、Knative Webhooks ライブラリの動きを追っていきます。

メイン関数はシンプルです。[sharedmain. WebhookMainWithContext](https://github.com/knative/pkg/blob/release-0.15/injection/sharedmain/main.go#L213) に Webhook の設定を Context として渡し、Webhook の処理を登録しています。WebhookMainWithConfig の中で Webhook のメトリクスを Prometheus 形式で公開する準備やロガーの設定、K8s Client の初期化や Informer のセットアップ、シグナル処理や Web サーバーの起動などを良い感じにやってくれます。

```go
func main() {
	ctx := webhook.WithOptions(signals.NewContext(), webhook.Options{
		ServiceName: "env-injector",
		Port:        10443,
		SecretName:  "env-injector-certs",
	})

	sharedmain.WebhookMainWithContext(ctx, "env-injector",
		certificates.NewController,
		NewMutatingAdmissionController,
	)
}
```

WebhookMainWithConfig の第 2 引数のコンポーネント名は、ロガーの設定を ConfigMap を通して動的に変更する際の[識別子](https://github.com/toVersus/env-injector/blob/0.1.0/install/config-logging.yaml#L7)として使われていたりするので、注意して下さい。

```yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: config-logging
  namespace: injector
data:
  loglevel.env-injector: info # <- コンポーネント名と一致させる必要あり
```

[NewAdmissionController](https://github.com/toVersus/env-injector/blob/0.1.0/pkg/webhook/injector/controller.go#L19) の中で、Admission Webhook の設定変更や Secret の中の CA 証明書の更新を検知して、オブジェクトを Reconcile するためのハンドラーを登録しています。Admission Webhook の実装の場合は、他に Reconcile するオブジェクトを追加する必要もないと思うので、ここは特に触る必要はありません。Reconciler の構造体は[こちら](https://github.com/toVersus/env-injector/blob/0.1.0/pkg/webhook/injector/reconcile.go#L21)で定義していて、必要なら併せて追加します。

以下のスニペットは、ビルド時に今回定義した Reconciler が [controller.Reconciler](https://github.com/knative/pkg/blob/release-0.15/controller/controller.go#L59-L63) と [webhook.AdmissionController](https://github.com/knative/pkg/blob/release-0.15/webhook/admission.go#L33-L40) のインターフェイスを満たしているか検知するためのおまじないです。この 2 つのインターフェイスを満たした controller.Impl を WebhookMainWithConfig に渡してあげると、後は良い感じにしてくれるという訳です。良いですね。

```golang
var _ controller.Reconciler = (*reconciler)(nil)
var _ webhook.AdmissionController = (*reconciler)(nil)
```

後は、Reconciler に上記のインターフェイスで生えている関数を定義して上げます。

まずは、[Path](https://github.com/toVersus/env-injector/blob/0.1.0/pkg/webhook/injector/reconcile.go#L38-L41) からですが、これは Webhook のハンドラを任意のパスで公開するための関数で変更は必要ありません。

```golang
// Path implements AdmissionController
func (ac *reconciler) Path() string {
	return ac.path
}
```

Reconcile の処理も、今回のようなシンプルな Admission Webhook であれば、起動時 or CA 証明書のローテーション時に Secret から CA 証明書を取得して、Webhook の設定に登録するための処理があれば良いので、変更不要です。

```golang
// Reconcile implements controller.Reconciler
func (ac *reconciler) Reconcile(ctx context.Context, key string) error {
	logger := logging.FromContext(ctx)

	// Look up the webhook secret, and fetch the CA cert bundle.
	secret, err := ac.secretlister.Secrets(system.Namespace()).Get(ac.secretName)
	if err != nil {
		logger.Errorw("Error fetching secret", zap.Error(err))
		return err
	}
	caCert, ok := secret.Data[certresources.CACert]
	if !ok {
		return fmt.Errorf("secret %q is missing %q key", ac.secretName, certresources.CACert)
	}

	// Reconcile the webhook configuration.
	return ac.reconcileMutatingWebhook(ctx, caCert)
}
```

という訳で、Admission Webhook それぞれで実装が必要な部分は Admit だけとなります。[slok/kubewebhook](https://github.com/slok/kubewebhook) と違って、自分でパッチを適用した AdmissionResponse を返して上げないといけないので多少面倒ではありますが、我慢しましょう。

```golang
// Admit implements AdmissionController
func (ac *reconciler) Admit(ctx context.Context, request *admissionv1beta1.AdmissionRequest) *admissionv1beta1.AdmissionResponse {
	logger := logging.FromContext(ctx)
	switch request.Operation {
	case admissionv1beta1.Create, admissionv1beta1.Update:
	default:
		logger.Infof("Unhandled webhook operation, letting it through %v", request.Operation)
		return &admissionv1beta1.AdmissionResponse{Allowed: true}
	}

	patch, err := injectEnvVar(ctx, request)
	if err != nil {
		return webhook.MakeErrorStatus("mutation failed: %v", err)
	}

	return &admissionv1beta1.AdmissionResponse{
		Allowed: true,
		Patch:   patch,
		PatchType: func() *admissionv1beta1.PatchType {
			pt := admissionv1beta1.PatchTypeJSONPatch
			return &pt
		}(),
	}
}
```

今回は [injectEnvVar](https://github.com/toVersus/env-injector/blob/0.1.0/pkg/webhook/injector/mutation_admit.go#L50) の中で環境変数 `TARGET` を Deployment の `spec.template.spec.containers[0].Env` に追加しています。

# Deep Dive into Certificate Rotation

この記事の本題である証明書のローテーションの実装を追ってみましょう。証明書は Webhook の起動時に作成されて、Secret に保存されます。Webhook の証明書を保存するための Secret は事前に作成する必要があります。これは、組織の構造上 Secret を作成する強い権限を Webhook に与えることができないケースがあるからです。事前にクラスター管理者などが[作成](https://github.com/toVersus/env-injector/blob/0.1.0/install/webhook-registration.yaml#L25-L30)することを想定しています。

```yaml
apiVersion: v1
kind: Secret
metadata:
  name: env-injector-certs
  namespace: injector
# The data is populated at install time.
```

最初に触れなかったのですが、sharedmain.WebhookMainWithContext に渡している [certificates.NewController](https://github.com/knative/pkg/blob/release-0.15/webhook/certificates/controller.go#L38) が証明書の Reconcile を行うコントローラーを初期化しています。

```go
func main() {
	ctx := webhook.WithOptions(signals.NewContext(), webhook.Options{
		ServiceName: "env-injector",
		Port:        10443,
		SecretName:  "env-injector-certs",
	})

	sharedmain.WebhookMainWithContext(ctx, "env-injector",
		certificates.NewController, // <- この子
		NewMutatingAdmissionController,
	)
}
```

証明書の Reconcile の処理は [reconcileCertificate](https://github.com/knative/pkg/blob/release-0.15/webhook/certificates/certificates.go#L53) で定義されています。まず、実装者が Webhook の [Option](https://github.com/toVersus/env-injector/blob/0.1.0/cmd/env-injector/main.go#L30) で指定した名前で Secret が存在するかチェックし、存在しなければエラー終了します。

```golang
	secret, err := r.secretlister.Secrets(system.Namespace()).Get(r.secretName)
	if apierrors.IsNotFound(err) {
		// The secret should be created explicitly by a higher-level system
		// that's responsible for install/updates.  We simply populate the
		// secret information.
		return nil
	} else if err != nil {
		logger.Errorf("Error accessing certificate secret %q: %v", r.secretName, err)
		return err
	}
```

次に、Secret に証明書が存在するか確認します。証明書が存在しない場合と証明書の有効期限が 1 週間を切った場合に、CA 証明書と自己署名証明書を新たに作り直して、Secret に保存します。

```golang
	if _, haskey := secret.Data[certresources.ServerKey]; !haskey {
		logger.Infof("Certificate secret %q is missing key %q", r.secretName, certresources.ServerKey)
	} else if _, haskey := secret.Data[certresources.ServerCert]; !haskey {
		logger.Infof("Certificate secret %q is missing key %q", r.secretName, certresources.ServerCert)
	} else if _, haskey := secret.Data[certresources.CACert]; !haskey {
		logger.Infof("Certificate secret %q is missing key %q", r.secretName, certresources.CACert)
	} else {
		// Check the expiration date of the certificate to see if it needs to be updated
		cert, err := tls.X509KeyPair(secret.Data[certresources.ServerCert], secret.Data[certresources.ServerKey])
		if err != nil {
			logger.Warnf("Error creating pem from certificate and key: %v", err)
		} else {
			certData, err := x509.ParseCertificate(cert.Certificate[0])
			if err != nil {
				logger.Errorf("Error parsing certificate: %v", err)
			} else if time.Now().Add(oneWeek).Before(certData.NotAfter) {
				return nil
			}
		}
	}
	// Don't modify the informer copy.
	secret = secret.DeepCopy()

	// One of the secret's keys is missing, so synthesize a new one and update the secret.
	newSecret, err := certresources.MakeSecret(ctx, r.secretName, system.Namespace(), r.serviceName)
	if err != nil {
		return err
	}
	secret.Data = newSecret.Data
	_, err = r.client.CoreV1().Secrets(secret.Namespace).Update(secret)
	return err
```

証明書の作成は、[webhook/certificates/resources/certs.go](https://github.com/knative/pkg/blob/release-0.15/webhook/certificates/resources/certs.go) の中で実装されているので、興味がある人は見てみると良いと思います。

証明書の有効期限は [1 年](https://github.com/knative/pkg/blob/release-0.15/webhook/certificates/resources/secret.go#L44) です。

```golang
// MakeSecretInternal is only public so MakeSecret can be restored in testing.  Use MakeSecret.
func MakeSecretInternal(ctx context.Context, name, namespace, serviceName string) (*corev1.Secret, error) {
	serverKey, serverCert, caCert, err := CreateCerts(ctx, serviceName, namespace, time.Now().AddDate(1, 0, 0)) // <- ここで現時刻より 1 年
(...)
```

Webhook の [Option](https://github.com/toVersus/env-injector/blob/0.1.0/cmd/env-injector/main.go#L28) で指定した Service 名と Downward API を使って Webhook に渡した[ネームスペース](https://github.com/toVersus/env-injector/blob/0.1.0/install/webhook.yaml#L21-L25)を使って、以下のドメインの[証明書を発行](https://github.com/knative/pkg/blob/release-0.15/webhook/certificates/resources/certs.go#L49-L54)します。

- `<Service Name>`
- `<Service Name>.<Namespace>`
- `<Service Name>.<Namespace>`.svc
- `<Service Name>.<Namespace>`.svc.cluster.local

```golang
	serviceName := name + "." + namespace
	serviceNames := []string{
		name,
		serviceName,
		serviceName + ".svc",
		serviceName + ".svc.cluster.local",
	}

	tmpl := x509.Certificate{
		SerialNumber:          serialNumber,
		Subject:               pkix.Name{Organization: []string{organization}},
		SignatureAlgorithm:    x509.SHA256WithRSA,
		NotBefore:             time.Now(),
		NotAfter:              notAfter,
		BasicConstraintsValid: true,
		DNSNames:              serviceNames,
	}
```

これで、証明書がローテーションされて、Secret の中のデータが更新される仕組みが分かりました。では、HTTPS サーバーが起動時に読み込んだ証明書データをダウンタイムなしにどうやって再読み込みしているのでしょうか。

正解は、**Webhook サーバーは起動時に自己署名証明書を読み込んでいません。**Client Hello が届いた時に [GetCertificate](https://golang.org/pkg/crypto/tls/#Config) を使って、証明書を Lazy Load します。GetCertificate を使って、Secret から秘密鍵と公開鍵のペアを探して読み込む関数をコールバックしています。

```golang
// Run implements the admission controller run loop.
func (wh *Webhook) Run(stop <-chan struct{}) error {
    (...)
	server := &http.Server{
		Handler: wh,
		Addr:    fmt.Sprintf(":%d", wh.Options.Port),
		TLSConfig: &tls.Config{
			GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
				secret, err := wh.secretlister.Secrets(system.Namespace()).Get(wh.Options.SecretName)
				if err != nil {
					return nil, err
				}

				serverKey, ok := secret.Data[certresources.ServerKey]
				if !ok {
					return nil, errors.New("server key missing")
				}
				serverCert, ok := secret.Data[certresources.ServerCert]
				if !ok {
					return nil, errors.New("server cert missing")
				}
				cert, err := tls.X509KeyPair(serverCert, serverKey)
				if err != nil {
					return nil, err
				}
				return &cert, nil
			},
		},
	}
    (...)
```

証明書のローテーションの動きをまとめます。

1. Certificate コントローラーが証明書の有効期限を監視
2. 証明書の有効期限が 1 週間をきると、Certificate コントローラーが証明書を再作成して、Secret に保存 (上書き)
3. Admission コントローラーが Secret の変更を感知して、Admission Configuration の caBundle を更新
4. Webhook サーバーが ClientHello を受けて、新規作成された証明書を Secret から読み込む

# Drawbacks

Knative Webhooks ライブラリも完璧ではありません！

Certificate Controller が Secret の中身を watch して確認していますが、当然クラスター内で初めて Webhook を起動したときは証明書が保存されていないので、エラーを吐きます。証明書の準備ができるまで、エラーログを吐き続けるので気になる程度にログを汚します。(完全にノイズなので何とかハンドリングして欲しいけど...)

他にも [Issue](https://github.com/knative/pkg/issues/1397) で報告したように、Admission Response の中身が info レベルでログ出力されてしまいます。センシティブなデータを注入する Mutating Webhook が、ログに生データを出力してしまうので困ります。ワークアラウンドとしてログレベルを warn に変更していますが、微妙ですよね...。

後は、Go Module による依存関係の管理が若干面倒です。一部 Module のバージョンを置き換えて上げないと、エラーになります。knative/pkg はリリースタグが振られていないので、ブランチ名で管理しないといけないですね。knative/pkg のバージョンは、knative/serving と同じリリース周期です。対応する Kubernetes のバージョンは、[Knative Release Principles](https://docs.google.com/document/d/1GXDe6163lO8ohtLUMPCplbjm3OTpYo_4U8f9K1fgZPo/edit#heading=h.fc9pxojmivlv) ([knative-user](https://groups.google.com/forum/#!forum/knative-users) Google Groups に参加必須) を確認して下さい。

```golang
module github.com/toversus/env-injector

go 1.14

(...)

replace (
	github.com/prometheus/client_golang => github.com/prometheus/client_golang v0.9.2
	k8s.io/api => k8s.io/api v0.17.6
	k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.17.6
	k8s.io/apimachinery => k8s.io/apimachinery v0.17.6
	k8s.io/client-go => k8s.io/client-go v0.17.6
	k8s.io/code-generator => k8s.io/code-generator v0.17.6
)
```

Knative の E2E テストは、[Ginkgo](https://github.com/onsi/ginkgo) を使っていません。テスト用のヘルパーライブラリがいくつかあるので、それらを組み合わせて作るしかないです。[webhook/webhook_integration_test.go](https://github.com/knative/pkg/blob/release-0.15/webhook/webhook_integration_test.go) に Knative 実装の Webhook のインテグレーションテストがあるので、それを参考に作る感じになりそうです。

最後に、Client Hello 時に証明書を Secret から遅延読み込みしているため、通常よりもパフォーマンスに影響が出てきそうです。クラスターの規模が巨大な場合は、注意が必要かもしれません。

# Wrap Up

[knative/pkg](https://github.com/knative/pkg) の [webhook](https://pkg.go.dev/github.com/knative/pkg/webhook) パッケージを使うと、

- cert-manager などの外部ツールへの依存を無くすことができる
- Admission Webhook で利用する証明書をローテーションできる
- Mutating/Validating の処理の開発に集中できる

Admission Webhook の設定の注意点については、[Webinar: 20,000 Upgrades Later, Lessons From a Year of Managed Kubernetes Upgrades](https://youtu.be/i24KuG5bGkg?t=2122) を見てみると良いと思います。