原创请勿转载
完成代码示例: https://github.com/allenhaozi/webhook
环境准备:
我是 MAC 本地开发, 安装docker后, 安装kind, 本地启动 k8s 集群
- go version v1.19.0+
- kind v0.17.0 go1.19.2 darwin/amd64
- kubebuilder v3.7.0
使用 kubebuilder 开发Operator 和 webhook server
初始化项目
kubebuilder init --domain github.com --repo github.com/allenhaozi/webhook
kubebuilder create api --group meta --version v1 --kind MetaWebHook
创建自己的CRD
kubebuilder create api --group meta --version v1 --kind MetaWebHook
创建完成后, 在对应的 webhook/api/v1/metawebhook_types.go
增加自定义的值
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
// MetaWebHookSpec defines the desired state of MetaWebHook
type MetaWebHookSpec struct {
// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
// Important: Run "make" to regenerate code after modifying this file
# add the following custom logic code
ServiceType string `json:"serviceType,omitempty"`
Database string `json:"database,omitempty"`
DatabaseSchema string `json:"databaseSchema,omitempty"`
TableFQN string `json:"tableFQN,omitempty"`
TableId string `json:"tableId,omitempty"`
}
构建CRD并安装
make manifests
make install
查看crd 安装结果
$k get crds
NAME CREATED AT
metawebhooks.meta.github.com 2022-11-06T10:14:25Z
构建完 CRD 以后, 构建 对应的webhook server
仅构建 MutatingWebhookConfiguration 测试使用
kubebuilder create webhook --group meta --version v1 --kind MetaWebHook --defaulting
运行完成后在根目录 main.go
里面多了下面这写代码
if err = (&metav1.MetaWebHook{}).SetupWebhookWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create webhook", "webhook", "MetaWebHook")
os.Exit(1)
}
同时多了这个文件 api/v1/metawebhook_webhook.go
修改代码: Default() 就是实现 MutatingWebhookConfiguration 作用的函数
我们重写了 TableId = "456"
即: 我们监听的所有 metawebhooks 资源的 TableId 都会被重写为 456
package v1
import (
ctrl "sigs.k8s.io/controller-runtime"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/webhook"
"github.com/allenhaozi/alog"
)
// log is for logging in this package.
var metawebhooklog = logf.Log.WithName("metawebhook-resource")
func (r *MetaWebHook) SetupWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr).
For(r).
Complete()
}
// TODO(user): EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
//+kubebuilder:webhook:path=/mutate-meta-github-com-v1-metawebhook,mutating=true,failurePolicy=fail,sideEffects=None,groups=meta.github.com,resources=metawebhooks,verbs=create;update,versions=v1,name=mmetawebhook.kb.io,admissionReviewVersions=v1
var _ webhook.Defaulter = &MetaWebHook{}
// Default implements webhook.Defaulter so a webhook will be registered for the type
func (r *MetaWebHook) Default() {
metawebhooklog.Info("default", "name", r.Name)
alog.Pretty(r)
r.Spec.TableId = "456"
// TODO(user): fill in your defaulting logic.
}
本文重点: webhook self signed
因为不想引入额外的组件管理CA证书, 比如 cert-manager
我们通过自签名的方式来实现和api-server tls 的签发
原理流程
-
实现自签名的代码, 自己生成三个 ca 文件
- tls.key
- tls.crt
- ca.crt
tls.key 和 tls.crt 提供给 webhook server
ca.crt 更新到对应的 MutatingWebhookConfiguration caBundle 字段上
通过自己实现一个 controller watch MutatingWebhookConfiguration 的create和update 然后更新它
原理图
上 code
MutatingWebhookConfiguration watch controller
package controllers
import (
"context"
"reflect"
"github.com/go-logr/logr"
admissionv1 "k8s.io/api/admissionregistration/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"github.com/allenhaozi/webhook/api/common"
)
// MetaWebHookReconciler reconciles a MetaWebHook object
type MutatingWebhookConfigurationReconciler struct {
client.Client
Scheme *runtime.Scheme
CaCert []byte
Log logr.Logger
}
// +kubebuilder:rbac:groups=admissionregistration.k8s.io,resources=mutatingwebhookconfigurations,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=admissionregistration.k8s.io,resources=mutatingwebhookconfiguration/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=admissionregistration.k8s.io,resources=mutatingwebhookconfiguration/finalizers,verbs=update
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
// TODO(user): Modify the Reconcile function to compare the state specified by
// the MetaWebHook object against the actual cluster state, and then
// perform operations to make the cluster state reflect the state specified by
// the user.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/[email protected]/pkg/reconcile
func (r *MutatingWebhookConfigurationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var m admissionv1.MutatingWebhookConfiguration
if err := r.Get(ctx, req.NamespacedName, &m); err != nil {
r.Log.Error(err, "got error")
return ctrl.Result{}, err
}
r.Log.Info("received mutatingwebhookconfiguration data", "MutatingWebhookConfiguration", req.NamespacedName)
if err := r.patchCaBundle(&m); err != nil {
r.Log.Error(err, "fail to patch CABundle to mutatingWebHookConfiguration")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *MutatingWebhookConfigurationReconciler) patchCaBundle(m *admissionv1.MutatingWebhookConfiguration) error {
ctx := context.Background()
current := m.DeepCopy()
for i := range m.Webhooks {
m.Webhooks[i].ClientConfig.CABundle = r.CaCert
}
if reflect.DeepEqual(m.Webhooks, current.Webhooks) {
r.Log.Info("no need to patch the MutatingWebhookConfiguration", "name", m.GetName())
return nil
}
if err := r.Patch(ctx, m, client.MergeFrom(current)); err != nil {
r.Log.Error(err, "fail to patch CABundle to mutatingWebHook", "name", m.GetName())
return err
}
r.Log.Info("finished patch MutatingWebhookConfiguration caBundle", "name", m.GetName())
return nil
}
// add
var filterByWebhookName = &predicate.Funcs{
CreateFunc: createPredicate,
UpdateFunc: updatePredicate,
}
func createPredicate(e event.CreateEvent) bool {
obj, ok := e.Object.(*admissionv1.MutatingWebhookConfiguration)
if !ok {
return false
}
if obj.GetName() == common.WebHookName {
return true
}
return false
}
func updatePredicate(e event.UpdateEvent) bool {
obj, ok := e.ObjectOld.(*admissionv1.MutatingWebhookConfiguration)
if !ok {
return false
}
if obj.GetName() == common.WebHookName {
return true
}
return false
}
// MutatingWebhookConfiguration
// SetupWithManager sets up the controller with the Manager.
func (r *MutatingWebhookConfigurationReconciler) SetupWithManager(mgr ctrl.Manager, l logr.Logger, caCert []byte) error {
return ctrl.NewControllerManagedBy(mgr).
For(&admissionv1.MutatingWebhookConfiguration{}).
WithEventFilter(filterByWebhookName).
Complete(
NewMutatingWebhookConfigurationReconciler(mgr, l, caCert),
)
}
func NewMutatingWebhookConfigurationReconciler(mgr ctrl.Manager, l logr.Logger, caCert []byte) *MutatingWebhookConfigurationReconciler {
r := &MutatingWebhookConfigurationReconciler{}
r.Client = mgr.GetClient()
r.Log = l
r.CaCert = caCert
return r
}
self signed certificate
package utils
import (
"crypto"
cryptorand "crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"log"
"math"
"math/big"
"os"
"path/filepath"
"time"
"github.com/pkg/errors"
"k8s.io/client-go/util/cert"
"k8s.io/client-go/util/keyutil"
)
var (
rsaKeySize = 2048
CertificateBlockType = "CERTIFICATE"
)
type CertContext struct {
// server.crt
Cert []byte
// server.key
Key []byte
SigningKey []byte
// ca.crt
SigningCert []byte
}
func GenerateCertAndCreate(namespaceName, serviceName, certDir string) (*CertContext, error) {
certContext := generateCert(namespaceName, serviceName)
// ca.crt
caCertFile := filepath.Join(certDir, "ca.crt")
if err := os.WriteFile(caCertFile, certContext.SigningCert, 0o644); err != nil {
return nil, errors.Errorf("Failed to write CA cert %v", err)
}
// server.key
keyFile := filepath.Join(certDir, "tls.key")
if err := os.WriteFile(keyFile, certContext.Key, 0o644); err != nil {
return nil, errors.Errorf("Failed to write key file %v", err)
}
// server.csr
certFile := filepath.Join(certDir, "tls.crt")
if err := os.WriteFile(certFile, certContext.Cert, 0o600); err != nil {
return nil, errors.Errorf("Failed to write cert file %v", err)
}
return certContext, nil
}
// reference: https://github.com/kubernetes/kubernetes/blob/v1.21.1/test/e2e/apimachinery/certs.go.
func generateCert(namespaceName, serviceName string) *CertContext {
signingKey, err := NewPrivateKey()
if err != nil {
log.Fatalf("Failed to create CA private key %v", err)
}
signingCert, err := cert.NewSelfSignedCACert(cert.Config{CommonName: "self-signed-k8s-cert"}, signingKey)
if err != nil {
log.Fatalf("Failed to create CA cert for apiserver %v", err)
}
key, err := NewPrivateKey()
if err != nil {
log.Fatalf("Failed to create private key for %v", err)
}
signedCert, err := NewSignedCert(
&cert.Config{
CommonName: serviceName + "." + namespaceName + ".svc",
AltNames: cert.AltNames{DNSNames: []string{serviceName + "." + namespaceName + ".svc"}},
Usages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
},
key,
signingCert,
signingKey,
)
if err != nil {
log.Fatalf("Failed to create cert%v", err)
}
keyPEM, err := keyutil.MarshalPrivateKeyToPEM(key)
if err != nil {
log.Fatalf("Failed to marshal key %v", err)
}
signingKeyPEM, err := keyutil.MarshalPrivateKeyToPEM(signingKey)
if err != nil {
log.Fatalf("Failed to marshal key %v", err)
}
c := &CertContext{
Cert: EncodeCertPEM(signedCert),
Key: keyPEM,
SigningCert: EncodeCertPEM(signingCert),
SigningKey: signingKeyPEM,
}
return c
}
// NewSignedCert creates a signed certificate using the given CA certificate and key
func NewSignedCert(cfg *cert.Config, key crypto.Signer, caCert *x509.Certificate, caKey crypto.Signer) (*x509.Certificate, error) {
serial, err := cryptorand.Int(cryptorand.Reader, new(big.Int).SetInt64(math.MaxInt64))
if err != nil {
return nil, err
}
if len(cfg.CommonName) == 0 {
return nil, errors.New("must specify a CommonName")
}
if len(cfg.Usages) == 0 {
return nil, errors.New("must specify at least one ExtKeyUsage")
}
certTmpl := x509.Certificate{
Subject: pkix.Name{
CommonName: cfg.CommonName,
Organization: cfg.Organization,
},
DNSNames: cfg.AltNames.DNSNames,
IPAddresses: cfg.AltNames.IPs,
SerialNumber: serial,
NotBefore: caCert.NotBefore,
NotAfter: time.Now().Add(time.Hour * 24 * 365 * 10).UTC(),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: cfg.Usages,
}
certDERBytes, err := x509.CreateCertificate(cryptorand.Reader, &certTmpl, caCert, key.Public(), caKey)
if err != nil {
return nil, err
}
return x509.ParseCertificate(certDERBytes)
}
// NewPrivateKey creates an RSA private key
func NewPrivateKey() (*rsa.PrivateKey, error) {
return rsa.GenerateKey(cryptorand.Reader, rsaKeySize)
}
// EncodeCertPEM returns PEM-endcoded certificate data
func EncodeCertPEM(cert *x509.Certificate) []byte {
block := pem.Block{
Type: CertificateBlockType,
Bytes: cert.Raw,
}
return pem.EncodeToMemory(&block)
}
构建镜像
Dockerfile
FROM alpine:3.15.0
# Build the manager binary
ARG TARGETOS
# Use distroless as minimal base image to package the manager binary
# Refer to https://github.com/GoogleContainerTools/distroless for more details
WORKDIR /
COPY webhook-amd64 /webhook
USER 65532:65532
ENTRYPOINT ["/webhook"]
build.sh
set -x
VERSION=$1
GO111MODULE=on CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -v -o webhook-amd64 main.go
docker build -t allenhaozi/webhook.tar:v0.0.${VERSION} -f Dockerfile.local .
kind load docker-image allenhaozi/webhook.tar:v0.0.${VERSION} --name kind-dev
make deploy
k get pods
NAME READY STATUS RESTARTS AGE
controller-manager-7785bddd47-gkcpw 2/2 Running 0 6h2m
check example code
$cat config/samples/m.yaml
apiVersion: meta.github.com/v1
kind: MetaWebHook
metadata:
labels:
app.kubernetes.io/name: metawebhook
app.kubernetes.io/instance: metawebhook-sample
app.kubernetes.io/part-of: webhook
app.kuberentes.io/managed-by: kustomize
app.kubernetes.io/created-by: webhook
name: metawebhook-sample
spec:
# TODO(user): Add fields here
serviceType: "databaseService"
database: "salesforce"
databaseSchema: "default"
tableFQN: "naton"
tableId: "123"
提交测试yaml
$k apply -f config/samples/m.y
检查提交的资源
$k get MetaWebHook metawebhook
-sample -oyaml
apiVersion: meta.github.com/v1
kind: MetaWebHook
metadata:
annotations:
kubectl.kubernetes.io/last-applied-configuration: |
{"apiVersion":"meta.github.com/v1","kind":"MetaWebHook","metadata":{"annotations":{},"labels":{"app.kuberentes.io/managed-by":"kustomize","app.kubernetes.io/created-by":"webhook","app.kubernetes.io/instance":"metawebhook-sample","app.kubernetes.io/name":"metawebhook","app.kubernetes.io/part-of":"webhook"},"name":"metawebhook-sample","namespace":"default"},"spec":{"database":"salesforce","databaseSchema":"default","serviceType":"databaseService","tableFQN":"naton","tableId":"123"}}
creationTimestamp: "2022-11-09T12:08:49Z"
generation: 1
labels:
app.kuberentes.io/managed-by: kustomize
app.kubernetes.io/created-by: webhook
app.kubernetes.io/instance: metawebhook-sample
app.kubernetes.io/name: metawebhook
app.kubernetes.io/part-of: webhook
name: metawebhook-sample
namespace: default
resourceVersion: "6601135"
uid: 0324d4c2-2dc1-46a2-8ed2-37218f10085c
spec:
database: salesforce
databaseSchema: default
serviceType: databaseService
tableFQN: naton
tableId: "456"
tableId 从 123 修改 456 , 构建成功
代码完整版