Kubernetes: Validating Webhookの「並行性」の落とし穴

こんにちは。今回はKubernetesのValidating Webhookの並行性について検証した結果をお伝えします。「Validating Webhookでリソース間のバリデーションは安全に実装できるのか？」という疑問を持ち、検証に至りました。

## 検証の背景

Validating Webhookを使ってリソース間の相関チェック（例えば特定のフィールドの値が重複していないかなど）を実装したいと考えていました。しかし、これを安全に実装するためには、Validating Webhookの並行性について知る必要があります。

もし複数のリソースに対するバリデーションが**並行して**処理されるなら、相関チェックは100%の信頼性を持てず、コントローラーで結果整合性的にバリデーションする必要が出てきます。逆に、バリデーションが**直列に**処理されるなら、Validating Webhookを完全に信頼でき、様々なポリシーエンジンでもリソース間チェックが安心して実装できます。

公式ドキュメントを調べても、並行性を匂わせる記述はあるものの、「並行性があるよ」といった明確な記述が見つからず、並行性があるとしても

- ValidatingWebhookConfigurationごとに並行性なのか
- 異なるKindごとに並行なのか
- 完全に並行なのか

といった並行レベルについても分からなかったため、念の為に実際に検証してみることにしました。

## 結論

先に結論を述べると、**Validating Webhookは完全な並行性を持っています**。つまり、複数のリソースを同時に作成・更新した場合、それらに対するバリデーションリクエストは並行して処理されます。そのため、リソース間の相関関係をチェックするようなバリデーションは、Validating Webhookだけでは完全に信頼できません。コントローラーなどで結果整合的にチェックをする必要があります。

## Validating Webhookの基本

検証の詳細に入る前に、Validating Webhookの基本を簡単に説明します。

Validating Webhookは、Kubernetes APIサーバーへのリクエストが処理される過程で、リソースが永続化される前に呼び出されるHTTPコールバックです。リソースの作成・更新・削除といった操作に対して、カスタムロジックでの検証を可能にします。

公式ドキュメントによれば：

> Admission webhooksはHTTPコールバックであり、アドミッションリクエストを受け取って処理します。バリデーティングアドミッションWebhookとミューテーティングアドミッションWebhookの2種類を定義できます。... バリデーティングアドミッションWebhookは、カスタムポリシーを強制するためにリクエストを拒否できます。

つまり、Validating Webhookは「門番」のような役割を果たし、条件を満たさないリソースの作成や変更を拒否できるわけです。

## 検証方法

それでは、Validating Webhookが並行性を持つかどうかを検証してみましょう。検証の基本的なアイデアは以下の通りです：

1. 5秒間のスリープを入れたValidating Webhookサーバーを作成
2. 複数のConfigMapを同時に作成
3. Webhookサーバーのログを確認して、リクエストが並行して処理されるか直列に処理されるかを判断

### 検証環境の準備

まず、Kindを使ってローカルにKubernetesクラスタを作成します。まだKindがインストールされていない場合は、先にインストールしてください。

```bash
# Kindクラスタを作成
kind create cluster --name validating-webhook-test
```

### TLS証明書の生成

Webhookサーバーには必ずTLS証明書が必要です。以下のコマンドでOpenSSLを使って自己署名証明書を生成します。

```bash
mkdir -p tls

# SSL設定ファイルを作成
cat > openssl.cnf << EOF
[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name
[req_distinguished_name]
[v3_req]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = webhook
DNS.2 = webhook.default
DNS.3 = webhook.default.svc
DNS.4 = webhook.default.svc.cluster.local
EOF

# 証明書を生成（SANsを含む）
openssl req -x509 -newkey rsa:4096 -keyout tls/tls.key -out tls/tls.crt -days 365 -nodes \
  -subj "/CN=webhook.default.svc" -config openssl.cnf -extensions v3_req

# 一時ファイルを削除
rm -f openssl.cnf
```

### Validating Webhookサーバーの実装

検証用のValidating Webhookサーバーは意図的に処理時間を長くします。以下は、Go言語で書かれたシンプルなValidating Webhookサーバーのコードです。このコードでは、リクエストを受け取ってから5秒間スリープした後にレスポンスを返します。

```go
package main

import (
	"encoding/json"
	"io"
	"log"
	"net/http"
	"time"

	admissionv1 "k8s.io/api/admission/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func main() {
	http.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
		// リクエストボディの読み取り
		body, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "リクエストの読み取りに失敗しました", http.StatusBadRequest)
			return
		}

		// AdmissionReviewリクエストのパース
		var admissionReviewReq admissionv1.AdmissionReview
		if err := json.Unmarshal(body, &admissionReviewReq); err != nil {
			http.Error(w, "AdmissionReviewのパースに失敗しました: "+err.Error(), http.StatusBadRequest)
			return
		}

		// リクエスト情報のログ出力
		log.Printf("[START] UID=%s, Kind=%s/%s, Name=%s, Namespace=%s\n",
			admissionReviewReq.Request.UID,
			admissionReviewReq.Request.Kind.Group,
			admissionReviewReq.Request.Kind.Kind,
			admissionReviewReq.Request.Name,
			admissionReviewReq.Request.Namespace,
		)

		// 5秒スリープして並行性を確認しやすくする
		start := time.Now()
		time.Sleep(5 * time.Second)
		duration := time.Since(start)

		// レスポンスの作成
		admissionReviewResponse := admissionv1.AdmissionReview{
			TypeMeta: metav1.TypeMeta{
				APIVersion: "admission.k8s.io/v1",
				Kind:       "AdmissionReview",
			},
			Response: &admissionv1.AdmissionResponse{
				UID:     admissionReviewReq.Request.UID,
				Allowed: true,
				Result: &metav1.Status{
					Message: "検証に成功しました",
				},
			},
		}

		// レスポンスのJSON化
		respBytes, err := json.Marshal(admissionReviewResponse)
		if err != nil {
			http.Error(w, "レスポンスのJSON化に失敗しました: "+err.Error(), http.StatusInternalServerError)
			return
		}

		// ログ出力: 終了タイミング
		log.Printf("[END]   UID=%s, Duration=%.1fs\n",
			admissionReviewReq.Request.UID, duration.Seconds(),
		)

		// レスポンスの送信
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		w.Write(respBytes)
	})

	// HTTPSサーバー起動
	log.Println("Starting webhook server on :8443...")
	if err := http.ListenAndServeTLS(":8443", "/tls/tls.crt", "/tls/tls.key", nil); err != nil {
		log.Fatalf("サーバーの起動に失敗しました: %v", err)
	}
}
```

このコードを`main.go`として保存します。

### Webhookサーバーのコンテナ化

次に、このWebhookサーバーをコンテナ化するためのDockerfileを用意します：

```dockerfile
FROM golang:1.24 AS builder
WORKDIR /app
COPY main.go go.* ./
RUN go mod download && \
    CGO_ENABLED=0 go build -o webhook main.go

FROM alpine:3.17
WORKDIR /app
COPY --from=builder /app/webhook .
COPY tls /tls
CMD ["./webhook"]
```

以下のコマンドでビルドとKindクラスタへのロードを行います：

```bash
# go.modとgo.sumを初期化
go mod init webhook
go mod tidy

# Dockerイメージのビルド
docker build -t localhost/webhook-test:latest .

# イメージをKindクラスタにロード
kind load docker-image localhost/webhook-test:latest --name validating-webhook-test
```

### TLS証明書のSecretとWebhookサーバーのデプロイ

TLS証明書をKubernetesのSecretとして保存し、Webhookサーバーをデプロイします：

```bash
# TLS証明書のSecret作成
kubectl create secret generic webhook-tls \
  --from-file=tls.crt=tls/tls.crt \
  --from-file=tls.key=tls/tls.key

# Webhookサーバーのデプロイ
cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: validating-webhook
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: validating-webhook
  template:
    metadata:
      labels:
        app: validating-webhook
    spec:
      containers:
      - name: webhook
        image: localhost/webhook-test:latest
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 8443
        volumeMounts:
        - name: tls-certs
          mountPath: /tls
          readOnly: true
      volumes:
      - name: tls-certs
        secret:
          secretName: webhook-tls
---
apiVersion: v1
kind: Service
metadata:
  name: webhook
  namespace: default
spec:
  selector:
    app: validating-webhook
  ports:
  - port: 443
    targetPort: 8443
EOF

# デプロイメントの準備完了を待機
kubectl wait --for=condition=available deployment/validating-webhook --timeout=60s
```

### ValidatingWebhookConfigurationの作成

次に、ValidatingWebhookConfigurationを作成して、ConfigMapに対するバリデーションを有効にします：

```bash
# TLS証明書を取得
CABUNDLE=$(cat tls/tls.crt | base64 | tr -d '\n')

# ValidatingWebhookConfigurationの作成
cat <<EOF | kubectl apply -f -
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: validating-webhook-test
webhooks:
  - name: "configmap.example.com"
    admissionReviewVersions: ["v1"]
    sideEffects: None
    failurePolicy: Fail
    clientConfig:
      service:
        name: webhook
        namespace: default
        path: "/validate"
      caBundle: "${CABUNDLE}"
    rules:
      - operations: ["CREATE","UPDATE"]
        apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["configmaps"]
EOF
```

### 検証の実行

これで検証環境の準備が整いました。以下のスクリプトを使って10個のConfigMapを同時に作成し、Webhookの挙動を観察します：

```bash
# 既存のConfigMapをクリーンアップ
kubectl delete configmap -l app=webhook-test 2>/dev/null || true

# バックグラウンドで10個のConfigMapを並行して作成
for i in {1..10}; do
  kubectl create configmap test-configmap-$i --from-literal=key=value --labels=app=webhook-test &
done

# すべてのバックグラウンドジョブの完了を待機
wait

# Webhookサーバーのログを確認
kubectl logs deployment/validating-webhook
```

## 検証結果

上記の検証を実行したところ、Webhookサーバーのログは以下のようになりました：

```
2025/03/07 02:17:09 Starting webhook server on :8443...
2025/03/07 02:17:14 [START] UID=4d75d6db-9f4d-4b9c-8f00-5d50b67840be, Kind=/ConfigMap, Name=test-configmap-1, Namespace=default
2025/03/07 02:17:14 [START] UID=61c5a520-b8cd-434f-bbef-9bc57d238f9b, Kind=/ConfigMap, Name=test-configmap-9, Namespace=default
2025/03/07 02:17:14 [START] UID=9d88c883-0ebe-4aa0-824b-ab4023a0f7c6, Kind=/ConfigMap, Name=test-configmap-2, Namespace=default
2025/03/07 02:17:14 [START] UID=6687d20b-3abc-4d41-886e-b65b248b3882, Kind=/ConfigMap, Name=test-configmap-5, Namespace=default
2025/03/07 02:17:14 [START] UID=3a607041-9ad7-4170-a099-b5451e051c28, Kind=/ConfigMap, Name=test-configmap-7, Namespace=default
2025/03/07 02:17:14 [START] UID=836398a9-c60d-4325-85c9-0975acd37576, Kind=/ConfigMap, Name=test-configmap-6, Namespace=default
2025/03/07 02:17:14 [START] UID=546a5f99-6c97-4da8-8e76-046a00cc85ce, Kind=/ConfigMap, Name=test-configmap-3, Namespace=default
2025/03/07 02:17:14 [START] UID=3cf4a107-c050-4db2-967e-bb10049c7d20, Kind=/ConfigMap, Name=test-configmap-8, Namespace=default
2025/03/07 02:17:14 [START] UID=f34bc749-b8b1-4728-b84c-f9f5fa9fdf85, Kind=/ConfigMap, Name=test-configmap-4, Namespace=default
2025/03/07 02:17:14 [START] UID=19c4a38c-7596-43b4-86c5-8748fd1bb111, Kind=/ConfigMap, Name=test-configmap-10, Namespace=default
2025/03/07 02:17:19 [END]   UID=61c5a520-b8cd-434f-bbef-9bc57d238f9b, Duration=5.0s
2025/03/07 02:17:19 [END]   UID=4d75d6db-9f4d-4b9c-8f00-5d50b67840be, Duration=5.0s
2025/03/07 02:17:19 [END]   UID=3a607041-9ad7-4170-a099-b5451e051c28, Duration=5.0s
2025/03/07 02:17:19 [END]   UID=6687d20b-3abc-4d41-886e-b65b248b3882, Duration=5.0s
2025/03/07 02:17:19 [END]   UID=9d88c883-0ebe-4aa0-824b-ab4023a0f7c6, Duration=5.0s
2025/03/07 02:17:19 [END]   UID=836398a9-c60d-4325-85c9-0975acd37576, Duration=5.0s
2025/03/07 02:17:19 [END]   UID=546a5f99-6c97-4da8-8e76-046a00cc85ce, Duration=5.0s
2025/03/07 02:17:19 [END]   UID=3cf4a107-c050-4db2-967e-bb10049c7d20, Duration=5.0s
2025/03/07 02:17:19 [END]   UID=f34bc749-b8b1-4728-b84c-f9f5fa9fdf85, Duration=5.0s
2025/03/07 02:17:19 [END]   UID=19c4a38c-7596-43b4-86c5-8748fd1bb111, Duration=5.0s
```

このログを解析すると、以下のことが分かります：

1. すべてのConfigMapのバリデーションリクエストが同じタイミング（02:17:14）で開始されている
2. すべてのリクエストが約5秒後（02:17:19）に終了している
3. すべてのリクエストの処理時間が5.0秒になっている

もし直列に処理されていたら、10個のConfigMapの処理に約50秒（10×5秒）かかり、ログの終了時刻も順に遅くなるはずです。しかし実際には、すべてのリクエストが同時に開始され、約5秒後に同時に終了しています。

**この結果から、Validating Webhookは並行性を持っていることが確認できました。**

## 考察：Validating Webhookの特性と実装上の注意点

検証結果から、Validating Webhookは並行性を持っていることが分かりました。これは以下のような意味を持ちます：

1. **リソース間のバリデーションにおける制約**：  
   異なるリソースの間で相互関係をチェックするような実装（例：名前の重複チェック）は、Validating Webhookだけでは100%の信頼性を持ちません。例えば、2つのリソースが同時に作成された場合、それぞれのバリデーションリクエストは互いに「相手がまだ存在していない」状態を見て、それぞれが許可されてしまう可能性があります。

2. **競合条件（Race Condition）の発生**：  
   複数のリソース操作が同時に行われる場合、バリデーションロジックに競合条件が発生する可能性があります。例えば、「このフィールドの値はクラスタ内で一意でなければならない」というチェックは、並行処理によって破綻する可能性があります。

3. **相補的なアプローチの必要性**：  
   リソース間の一貫性を確保するためには、Validating Webhookだけでなく、コントローラーによる結果整合性チェックも併用する必要があります。コントローラーは定期的に実行され、システム全体の状態を見てリソース間の整合性を確認・修正できます。

この結果は、Kubernetesの設計思想とも一致しています。Kubernetesは基本的に非同期かつ分散的なシステムであり、完全な一貫性よりも可用性と分断耐性を優先するCAP定理におけるAP（可用性と分断耐性）を重視する傾向があります。

オブジェクト指向や防衛プログラミングの文脈では、オブジェクトの不変条件をオブジェクトの作成時に担保することが望ましいとされますが、Kubernetesではこの点は上記のようなアーキテクチャ特性とトレードオフしている印象です。

実際の実装では、以下の2つを併用するのが良さそうです。

1. **Validating Webhookでの基本的なバリデーション**：  
   単一リソース内での整合性チェックや、明らかな不正値の検出など、リソースが作成・更新される時点で確実に判断できるバリデーションをWebhookで実装する。
   要するに、「副作用」のないバリデーションにフォーカスする。

2. **コントローラーでのリソース間バリデーション**：  
   複数リソース間の整合性チェックは、コントローラーで定期的に実行し、問題があれば修正アクションをとるか、少なくとも警告を発する。
   コントローラーには副作用があるバリデーションを担ってもらい、`status`にてフィードバックする。

本稿で扱ったValidating Webhookの並行性に関する注意点は、自前のコントローラーに限らず、Validating Webhookを活用しているポリシーエンジン全般にも当てはまりそうです。

## まとめ

今回の検証から、Kubernetesの**Validating Webhookは並行性を持つ**ことが確認できました。つまり、複数のリソースが同時に作成・更新された場合、それらに対するバリデーションは並行して処理されます。

この特性は、特にリソース間の相関関係をチェックするようなバリデーションを実装する際には要注意です。Validating Webhookだけでは完全な一貫性は保証できないため、コントローラーによる補完的なチェックが必要になります。

現実的な戦略としては、「Validating Webhookでは副作用ない処理を、副作用のある処理はコントローラーで」というアプローチが有効でしょう。

Kubernetesの拡張メカニズムを実装する際は、このような分散システム特有の挙動を理解し、適切な設計を行うことが重要です。

この記事がKubernetesのValidating Webhookをより深く理解する助けになれば幸いです。最後までお読みいただき、ありがとうございました。