在本地调试基于 controller-runtime 的 webhook

概述

在使用 Kubebuilder 开发 Kubernetes Operator 的时候,通过在本地直接运行 Operator 进行功能调试往往更为方便、效率。

一般情况下开发者仅需要执行 make run 即可在本地运行 Operator。但是当 Operator 启用了 webhook 服务时,就需要对配置内容进行一些调整,才能使 Operator 在本地正常运行。

本文记录了如何在本地调试基于 controller-runtime 实现的启用 webhook 的 Operator。

案例

  • hub 为 v3

  • spoke 为 v4

  • group 为 core.demo.io

  • crd 为 foo

  • Kubebuilder 版本为 v3.1.0

  • Controller-runtime 版本为 v0.9.7

目录结构如下:

1
2
3
4
5
6
7
8
9
.
|-- v3
| |-- foo_types.go
| |-- foo_conversion.go
| `-- foo_webhook.go
`-- v4
|-- foo_types.go
|-- foo_conversion.go
`-- foo_webhook.go

webhook 的入口

v3(hub) 不需要编写 v3/foo_conversion.go 的转换逻辑,只需要标注自己为 Hub;v4(spoke) 需要在 v4/foo_conversion.go 中编写 ConvertTo()ConvertFrom() 方法。

webhook 入口可以选择任意版本,如选择 v3(hub),则 v3/foo_webhook.go 内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package v3

import (
ctrl "sigs.k8s.io/controller-runtime"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/webhook"
)

// log is for logging in this package.
var foolog = logf.Log.WithName("foo-resource")

func (r *Foo) SetupWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr).
For(r).
Complete()
}

// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
// +kubebuilder:webhook:path=/mutate-core-demo-io-v3-foo,mutating=true,failurePolicy=fail,groups=core.demo.io,resources=foos,verbs=create;update,versions=v3,name=mfoos.demo.io,sideEffects=None,admissionReviewVersions=v1
var _ webhook.Defaulter = &Foo{}

// Default implements webhook.Defaulter so a webhook will be registered for the type
func (r *Foo) Default() {
foolog.Info("default", "name", r.Name)
}

webhook 注册的原理

  1. 调用 ctrl.NewWebhookManagedBy(mgr).For(r).Complete() 注册 webhook

    controller-runtime/pkg/builder/webhook.go

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    // Complete builds the webhook.
    func (blder *WebhookBuilder) Complete() error {
    // Set the Config
    blder.loadRestConfig()

    // Set the Webhook if needed
    return blder.registerWebhooks()
    }

    func (blder *WebhookBuilder) registerWebhooks() error {
    // Create webhook(s) for each type
    var err error
    blder.gvk, err = apiutil.GVKForObject(blder.apiType, blder.mgr.GetScheme())
    if err != nil {
    return err
    }

    blder.registerDefaultingWebhook() // 注册 mutate webhook
    blder.registerValidatingWebhook() // 注册 validate webhook

    err = blder.registerConversionWebhook() // 注册 conversion webhook
    if err != nil {
    return err
    }
    return nil
    }
  2. 注册 webhook 的服务路径

    在 mutate 和 validate webhook 的注册中,controller-runtime 通过以下规则生成 path

    1
    2
    3
    4
    5
    6
    7
    8
    9
    func generateMutatePath(gvk schema.GroupVersionKind) string {
    return "/mutate-" + strings.ReplaceAll(gvk.Group, ".", "-") + "-" +
    gvk.Version + "-" + strings.ToLower(gvk.Kind)
    }

    func generateValidatePath(gvk schema.GroupVersionKind) string {
    return "/validate-" + strings.ReplaceAll(gvk.Group, ".", "-") + "-" +
    gvk.Version + "-" + strings.ToLower(gvk.Kind)
    }

    在 conversion webhook 的注册中,controller-runtime 通过以下规则生成 path

    即 conversion webhook 必须使用 /convert 作为服务路径;同时如果有多个资源需要 conversion,仅需要注册一次即可

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    func (blder *WebhookBuilder) registerConversionWebhook() error {
    ok, err := conversion.IsConvertible(blder.mgr.GetScheme(), blder.apiType)
    if err != nil {
    log.Error(err, "conversion check failed", "GVK", blder.gvk)
    return err
    }
    if ok {
    if !blder.isAlreadyHandled("/convert") {
    blder.mgr.GetWebhookServer().Register("/convert", &conversion.Webhook{})
    }
    log.Info("Conversion webhook enabled", "GVK", blder.gvk)
    }

    return nil
    }
  3. 注册的具体逻辑

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    // Register marks the given webhook as being served at the given path.
    // It panics if two hooks are registered on the same path.
    func (s *Server) Register(path string, hook http.Handler) {
    s.mu.Lock()
    defer s.mu.Unlock()

    s.defaultingOnce.Do(s.setDefaults)
    if _, found := s.webhooks[path]; found {
    panic(fmt.Errorf("can't register duplicate path: %v", path))
    }
    // TODO(directxman12): call setfields if we've already started the server
    s.webhooks[path] = hook
    s.WebhookMux.Handle(path, metrics.InstrumentedHook(path, hook))

    regLog := log.WithValues("path", path)
    regLog.Info("Registering webhook")

    // we've already been "started", inject dependencies here.
    // Otherwise, InjectFunc will do this for us later.
    if s.setFields != nil {
    if err := s.setFields(hook); err != nil {
    // TODO(directxman12): swallowing this error isn't great, but we'd have to
    // change the signature to fix that
    regLog.Error(err, "unable to inject fields into webhook during registration")
    }

    baseHookLog := log.WithName("webhooks")

    // NB(directxman12): we don't propagate this further by wrapping setFields because it's
    // unclear if this is how we want to deal with log propagation. In this specific instance,
    // we want to be able to pass a logger to webhooks because they don't know their own path.
    if _, err := inject.LoggerInto(baseHookLog.WithValues("webhook", path), hook); err != nil {
    regLog.Error(err, "unable to logger into webhook during registration")
    }
    }
    }

启用 webhook

然后在 main.go 中需要匹配正确的 webhook 入口:

1
2
3
4
5
6
if os.Getenv("ENABLE_WEBHOOKS") != "false" {
if err = (&v3.Foo{}).SetupWebhookWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create webhook", "webhook", "Foo")
os.Exit(1)
}
}

使用 url 而非 service 触发 webhook

本地调试时,需要将 Dynamic Admission Control 的地址调整为本地地址(代替原本的 svc),即如:https://<node-ip>:9443 (webhook 默认端口为 9443)

本例中仅使用了 MutatingWebhookConfiguration,需要修改以下部分:

config/webhook/manifests.yaml

在配置了 cert-manager 的情况下,caBundle 由 cert-manager 自动生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
---
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
creationTimestamp: null
name: mutating-webhook-configuration
webhooks:
- admissionReviewVersions:
- v1
clientConfig:
# service:
# name: webhook-service
# namespace: bar
# path: /mutate-core-demo-io-v3-foo
url: "https://<node-ip>:9443/mutate-core-demo-io-v3-foo" # 新增 url 字段
failurePolicy: Fail
name: mfoos.of.io
rules:
- apiGroups:
- core.demo.io
apiVersions:
- v3
operations:
- CREATE
- UPDATE
resources:
- foos
sideEffects: None

在 Foo 资源 CRD 的 patch 中也需要调整 webhook 的地址:

config/crd/patches/webhook_in_a.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# The following patch enables a conversion webhook for the CRD
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: foos.core.demo.io
spec:
conversion:
strategy: Webhook
webhook:
clientConfig:
# service:
# namespace: bar
# name: webhook-service
# path: /convert
url: "https://<node-ip>:9443/convert"
conversionReviewVersions:
- v1

同时需要在 config/webhook/kustomizeconfig.yaml 中注释掉 svc 的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# the following config is for teaching kustomize where to look at when substituting vars.
# It requires kustomize v2.1.0 or newer to work properly.
nameReference:
- kind: Service
version: v1
fieldSpecs:
# - kind: MutatingWebhookConfiguration
# group: admissionregistration.k8s.io
# path: webhooks/clientConfig/service/name
- kind: ValidatingWebhookConfiguration
group: admissionregistration.k8s.io
path: webhooks/clientConfig/service/name

namespace:
# - kind: MutatingWebhookConfiguration
# group: admissionregistration.k8s.io
# path: webhooks/clientConfig/service/namespace
# create: true
- kind: ValidatingWebhookConfiguration
group: admissionregistration.k8s.io
path: webhooks/clientConfig/service/namespace
create: true

varReference:
- path: metadata/annotations

配置证书

根据 kubebuilder 推荐的方案,使用 cert-manager 作为 webhook 的证书生成、管理工具,需要为 Certificate 资源增加 ipAddressses 配置:

config/certmanager/certificate.yaml

我们需要将 <node-ip> 加入证书的 SAN 范围

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# The following manifests contain a self-signed issuer CR and a certificate CR.
# More document can be found at https://docs.cert-manager.io
# WARNING: Targets CertManager v1.0. Check https://cert-manager.io/docs/installation/upgrading/ for breaking changes.
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: selfsigned-issuer
namespace: system
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: serving-cert # this name should match the one appeared in kustomizeconfig.yaml
namespace: system
spec:
# $(SERVICE_NAME) and $(SERVICE_NAMESPACE) will be substituted by kustomize
dnsNames:
- $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc
- $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc.cluster.local
ipAddresses: # 新增关于 node-ip 的配置
- <node-ip>
issuerRef:
kind: Issuer
name: selfsigned-issuer
secretName: webhook-server-cert # this secret will not be prefixed, since it's not managed by kustomize

之后执行 make manifests 生成相关的配置文件,然后执行 make install 完成安装。

此时,需要在本地准备 tls.key 和 tls.crt 文件,用于 apiserver 与 webhook 之间的通信:

可以直接从 secret 资源中找到;生成的 tls.* 文件需要存放在 /tmp/k8s-webhook-server/serving-certs 路径下

1
2
kubectl get secret -n <namespace> webhook-server-cert -o=jsonpath='{.data.tls\.crt}' |base64 -d > tls.crt
kubectl get secret -n <namespace> webhook-server-cert -o=jsonpath='{.data.tls\.key}' |base64 -d > tls.key

启动服务

最后使用 make run 启动服务。

现在就可以在本地调试 Operator 的 webhook 功能了。