DevOps

Kubernetes Admission Controllers en 2026: escritura de webhooks propios en Go para el control de políticas del clúster

Ruslan Ismailov Publicado 18 min de lectura
K

Qué son los Admission Controllers y cómo funcionan en Kubernetes

Los Admission Controllers son plugins de Kubernetes que interceptan las solicitudes al servidor de API después de la autenticación y autorización, pero antes de que el objeto se almacene en etcd. Permiten verificar, modificar o rechazar solicitudes de creación, actualización y eliminación de recursos del clúster.

La cadena de procesamiento de una solicitud funciona así:

  1. El cliente envía una solicitud al kube-apiserver.
  2. Se realiza la autenticación y autorización (RBAC).
  3. La solicitud pasa por la cadena de Admission Controllers.
  4. Los controladores mutantes modifican el objeto (por ejemplo, añaden etiquetas).
  5. Los controladores validadores verifican el estado final del objeto.
  6. El objeto se almacena en etcd y se distribuye por el clúster.

Los controladores integrados (LimitRanger, ResourceQuota, PodSecurity) cubren los escenarios básicos; sin embargo, para políticas corporativas específicas se necesitan Webhook Admission Controllers — servicios HTTP externos que implementan lógica personalizada. En 2026, esta es una práctica estándar para los equipos de Platform Engineering que trabajan con Kubernetes.

ValidatingWebhookConfiguration vs MutatingWebhookConfiguration

Kubernetes soporta dos tipos de webhooks:

  • MutatingAdmissionWebhook — modifica el objeto antes de almacenarlo. Se utiliza para añadir automáticamente contenedores sidecar, forzar la asignación de labels/annotations y establecer resource limits por defecto.
  • ValidatingAdmissionWebhook — solo verifica el objeto y devuelve una aprobación o un rechazo. No puede modificar el objeto. Se utiliza para comprobar políticas de seguridad: prohibición de imágenes sin etiquetas, conformidad con convenciones de nomenclatura, presencia de etiquetas obligatorias.

La regla clave es: si necesitas corregir un objeto, usa Mutating. Si necesitas rechazar un objeto inválido, usa Validating. En la práctica, ambos tipos se despliegan juntos con frecuencia: Mutating establece los valores predeterminados y Validating verifica el estado final. Esto es especialmente importante al escribir políticas de Kubernetes en Go, donde una separación clara de responsabilidades simplifica las pruebas.

Escritura de un Admission Webhook propio en Go

Estructura del proyecto

Estructura mínima de un proyecto para un Admission Webhook en Go:

admission-webhook/
├── cmd/
│   └── webhook/
│       └── main.go
├── internal/
│   ├── handler/
│   │   ├── validate.go
│   │   └── mutate.go
│   └── policy/
│       ├── image_tag.go
│       └── resource_limits.go
├── deploy/
│   ├── deployment.yaml
│   ├── service.yaml
│   └── webhook-config.yaml
├── certs/
│   └── generate.sh
├── go.mod
└── go.sum

Dependencias en go.mod

module github.com/yourorg/admission-webhook

go 1.23

require (
    k8s.io/api v0.30.0
    k8s.io/apimachinery v0.30.0
    sigs.k8s.io/controller-runtime v0.18.0
)

Punto de entrada: main.go

package main

import (
    "crypto/tls"
    "log"
    "net/http"

    "github.com/yourorg/admission-webhook/internal/handler"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/validate", handler.Validate)
    mux.HandleFunc("/mutate", handler.Mutate)
    mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })

    cert, err := tls.LoadX509KeyPair("/certs/tls.crt", "/certs/tls.key")
    if err != nil {
        log.Fatalf("Failed to load TLS certs: %v", err)
    }

    server := &http.Server{
        Addr: ":8443",
        TLSConfig: &tls.Config{
            Certificates: []tls.Certificate{cert},
            MinVersion:   tls.VersionTLS13,
        },
        Handler: mux,
    }

    log.Println("Starting webhook server on :8443")
    if err := server.ListenAndServeTLS("", ""); err != nil {
        log.Fatalf("Server failed: %v", err)
    }
}

Procesamiento de AdmissionReview

Kubernetes envía al webhook un objeto AdmissionReview y espera recibir el mismo objeto en respuesta con el campo response completado. Manejador base para un webhook validador:

package handler

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

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

    "github.com/yourorg/admission-webhook/internal/policy"
)

func Validate(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "failed to read body", http.StatusBadRequest)
        return
    }

    var review admissionv1.AdmissionReview
    if err := json.Unmarshal(body, &review); err != nil {
        http.Error(w, "failed to unmarshal AdmissionReview", http.StatusBadRequest)
        return
    }

    req := review.Request
    var pod corev1.Pod
    if err := json.Unmarshal(req.Object.Raw, &pod); err != nil {
        http.Error(w, "failed to unmarshal Pod", http.StatusBadRequest)
        return
    }

    allowed, reason := policy.ValidateImageTags(pod)
    review.Response = &admissionv1.AdmissionResponse{
        UID:     req.UID,
        Allowed: allowed,
    }
    if !allowed {
        review.Response.Result = &metav1.Status{
            Message: reason,
            Code:    403,
        }
    }

    resp, err := json.Marshal(review)
    if err != nil {
        log.Printf("Error marshaling response: %v", err)
        http.Error(w, fmt.Sprintf("failed to marshal response: %v", err), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.Write(resp)
}

Ejemplos de políticas de Kubernetes en Go

Política 1: prohibición de imágenes sin etiquetas

Las imágenes sin una etiqueta explícita (por ejemplo, nginx en lugar de nginx:1.27.0) utilizan latest por defecto, lo que compromete la reproducibilidad de los despliegues. Implementamos la verificación:

package policy

import (
    "fmt"
    "strings"

    corev1 "k8s.io/api/core/v1"
)

// ValidateImageTags verifica que todos los contenedores usen etiquetas explícitas
// y no utilicen la etiqueta 'latest'.
func ValidateImageTags(pod corev1.Pod) (bool, string) {
    allContainers := append(pod.Spec.InitContainers, pod.Spec.Containers...)
    for _, c := range allContainers {
        image := c.Image
        // Verificamos la presencia de etiqueta
        parts := strings.Split(image, ":")
        if len(parts) < 2 || parts[len(parts)-1] == "" {
            return false, fmt.Sprintf(
                "container '%s' uses image '%s' without explicit tag",
                c.Name, image,
            )
        }
        // Prohibimos la etiqueta 'latest'
        tag := parts[len(parts)-1]
        if tag == "latest" {
            return false, fmt.Sprintf(
                "container '%s' uses 'latest' tag, which is not allowed",
                c.Name,
            )
        }
    }
    return true, ""
}

Política 2: resource limits forzados (Mutating Webhook)

El webhook mutante añade resource limits a los contenedores que no los tienen definidos. Para modificar el objeto se utiliza JSON Patch:

package handler

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

    admissionv1 "k8s.io/api/admission/v1"
    corev1 "k8s.io/api/core/v1"
    "k8s.io/apimachinery/pkg/api/resource"
)

type patchOp struct {
    Op    string      `json:"op"`
    Path  string      `json:"path"`
    Value interface{} `json:"value,omitempty"`
}

func Mutate(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)

    var review admissionv1.AdmissionReview
    json.Unmarshal(body, &review)

    var pod corev1.Pod
    json.Unmarshal(review.Request.Object.Raw, &pod)

    var patches []patchOp
    defaultCPU := resource.MustParse("500m")
    defaultMem := resource.MustParse("256Mi")

    for i, c := range pod.Spec.Containers {
        if c.Resources.Limits == nil {
            patches = append(patches, patchOp{
                Op:   "add",
                Path: fmt.Sprintf("/spec/containers/%d/resources/limits", i),
                Value: corev1.ResourceList{
                    corev1.ResourceCPU:    defaultCPU,
                    corev1.ResourceMemory: defaultMem,
                },
            })
        }
    }

    patchBytes, _ := json.Marshal(patches)
    patchType := admissionv1.PatchTypeJSONPatch

    review.Response = &admissionv1.AdmissionResponse{
        UID:       review.Request.UID,
        Allowed:   true,
        Patch:     patchBytes,
        PatchType: &patchType,
    }

    resp, _ := json.Marshal(review)
    w.Header().Set("Content-Type", "application/json")
    w.Write(resp)
    _ = log.Writer()
}

Política 3: adición forzada de labels

En el webhook mutante también es sencillo añadir etiquetas obligatorias. Basta con construir un JSON Patch para la ruta /metadata/labels:

func buildLabelPatch(existingLabels map[string]string) []patchOp {
    required := map[string]string{
        "app.kubernetes.io/managed-by": "platform-team",
        "env":                          "production",
    }
    var ops []patchOp
    if existingLabels == nil {
        ops = append(ops, patchOp{Op: "add", Path: "/metadata/labels", Value: required})
        return ops
    }
    for k, v := range required {
        if _, exists := existingLabels[k]; !exists {
            // Escapamos '/' en las claves para JSON Pointer (RFC 6901)
            safeKey := strings.ReplaceAll(k, "/", "~1")
            ops = append(ops, patchOp{
                Op:    "add",
                Path:  "/metadata/labels/" + safeKey,
                Value: v,
            })
        }
    }
    return ops
}

TLS y seguridad de los webhooks

Kubernetes requiere que todos los webhooks funcionen sobre HTTPS. El servidor de API verifica el certificado del webhook mediante el CA Bundle indicado en WebhookConfiguration.

Generación de certificados autofirmados

#!/bin/bash
# certs/generate.sh

SERVICE="admission-webhook"
NAMESPACE="webhook-system"
SECRET="admission-webhook-tls"

# Generación de CA
openssl genrsa -out ca.key 4096
openssl req -new -x509 -days 3650 -key ca.key \
    -subj "/CN=Admission Webhook CA" \
    -out ca.crt

# Generación de clave y CSR para el servidor
openssl genrsa -out tls.key 4096
openssl req -new -key tls.key \
    -subj "/CN=${SERVICE}.${NAMESPACE}.svc" \
    -out tls.csr

# Firma del certificado
cat > ext.cnf <

Rotación de certificados

En producción se recomienda usar cert-manager para la rotación automática de certificados. Cert-manager soporta la anotación cert-manager.io/inject-ca-from para la actualización automática de caBundle en WebhookConfiguration. Esto elimina la rotación manual y reduce la carga operativa de los equipos de Platform Engineering.

Despliegue del webhook en Kubernetes

Namespace y RBAC

apiVersion: v1
kind: Namespace
metadata:
  name: webhook-system
  labels:
    app.kubernetes.io/managed-by: platform-team

Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: admission-webhook
  namespace: webhook-system
spec:
  replicas: 2
  selector:
    matchLabels:
      app: admission-webhook
  template:
    metadata:
      labels:
        app: admission-webhook
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 65534
      containers:
        - name: webhook
          image: yourorg/admission-webhook:1.2.0
          ports:
            - containerPort: 8443
          resources:
            requests:
              cpu: 100m
              memory: 64Mi
            limits:
              cpu: 200m
              memory: 128Mi
          volumeMounts:
            - name: tls-certs
              mountPath: /certs
              readOnly: true
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8443
              scheme: HTTPS
            initialDelaySeconds: 5
            periodSeconds: 10
      volumes:
        - name: tls-certs
          secret:
            secretName: admission-webhook-tls

Service

apiVersion: v1
kind: Service
metadata:
  name: admission-webhook
  namespace: webhook-system
spec:
  selector:
    app: admission-webhook
  ports:
    - port: 443
      targetPort: 8443

ValidatingWebhookConfiguration

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: image-policy-webhook
webhooks:
  - name: validate-image-tags.webhook-system.svc
    admissionReviewVersions: ["v1"]
    sideEffects: None
    failurePolicy: Fail
    namespaceSelector:
      matchExpressions:
        - key: kubernetes.io/metadata.name
          operator: NotIn
          values: ["kube-system", "webhook-system"]
    rules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
    clientConfig:
      service:
        name: admission-webhook
        namespace: webhook-system
        path: /validate
      caBundle: BASE64_ENCODED_CA_CERT

El parámetro failurePolicy: Fail indica que si el webhook no está disponible, la solicitud será rechazada. Este es el comportamiento seguro para producción. Para staging se puede usar failurePolicy: Ignore.

Pruebas y depuración de webhooks

Pruebas unitarias de políticas

Las políticas se prueban fácilmente de forma aislada, ya que aceptan y devuelven objetos estándar de Kubernetes:

package policy_test

import (
    "testing"

    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

    "github.com/yourorg/admission-webhook/internal/policy"
)

func TestValidateImageTags(t *testing.T) {
    tests := []struct {
        name    string
        image   string
        allowed bool
    }{
        {"valid image with tag", "nginx:1.27.0", true},
        {"image without tag", "nginx", false},
        {"image with latest tag", "nginx:latest", false},
        {"image with sha digest", "nginx@sha256:abc123", true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            pod := corev1.Pod{
                ObjectMeta: metav1.ObjectMeta{Name: "test-pod"},
                Spec: corev1.PodSpec{
                    Containers: []corev1.Container{
                        {Name: "app", Image: tt.image},
                    },
                },
            }
            allowed, _ := policy.ValidateImageTags(pod)
            if allowed != tt.allowed {
                t.Errorf("expected allowed=%v, got %v", tt.allowed, allowed)
            }
        })
    }
}

Pruebas de integración con envtest

El paquete sigs.k8s.io/controller-runtime/pkg/envtest permite ejecutar un kube-apiserver local con webhooks registrados sin necesidad de un clúster real. Esto acelera las iteraciones de desarrollo.

Depuración en el clúster

Comandos útiles para diagnosticar webhooks:

  • kubectl logs -n webhook-system deploy/admission-webhook — visualización de los logs del webhook.
  • kubectl describe validatingwebhookconfiguration image-policy-webhook — verificación de la configuración.
  • kubectl get events --field-selector reason=FailedCreate — errores de creación de recursos causados por el webhook.
  • Activación del audit log de kube-apiserver con level: RequestResponse para registrar todas las solicitudes y respuestas de AdmissionReview.

Integración de las verificaciones en el pipeline de CI/CD

Para garantizar una cobertura completa de políticas se construye una protección multinivel en el pipeline de CI/CD. Esto permite detectar infracciones en etapas tempranas, sin esperar a que el despliegue sea rechazado en el clúster.

Nivel 1: análisis estático de manifiestos

Las herramientas kube-linter y datree verifican los manifiestos YAML contra las políticas directamente en el repositorio, antes de aplicarlos en Kubernetes. Ejemplo de paso en GitHub Actions:

- name: Lint Kubernetes manifests
  uses: stackrox/kube-linter-action@v1
  with:
    directory: deploy/
    config: .kube-linter.yaml

Nivel 2: pruebas del webhook en un clúster efímero

En el pipeline de CI/CD se despliega un clúster de Kubernetes temporal (por ejemplo, mediante kind o k3s), en el que se instala el webhook y se ejecutan las pruebas de integración. Ejemplo de .gitlab-ci.yml:

integration-test:
  stage: test
  image: golang:1.23
  services:
    - docker:dind
  script:
    - curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.24.0/kind-linux-amd64
    - chmod +x ./kind && mv ./kind /usr/local/bin/
    - kind create cluster --name ci-cluster
    - kubectl apply -f deploy/
    - go test ./... -tags=integration -v
  after_script:
    - kind delete cluster --name ci-cluster

Nivel 3: políticas como código con OPA/Gatekeeper

Para escenarios complejos se recomienda considerar OPA Gatekeeper, que implementa un Admission Controller sobre Open Policy Agent. Los webhooks en Go personalizados y Gatekeeper conviven bien: los primeros gestionan la lógica de negocio específica, mientras que el segundo gestiona las políticas declarativas mediante ConstraintTemplate.

El Admission Webhook no es un sustituto del RBAC, sino una capa adicional de protección. La combinación adecuada de RBAC, Admission Controllers, Network Policies y audit logging conforma un modelo de seguridad maduro para un clúster de Kubernetes.

Conclusión

Escribir un Admission Webhook propio en Go es una herramienta poderosa para los equipos de Platform Engineering, que permite controlar de forma centralizada las políticas del clúster de Kubernetes. En 2026, este enfoque se ha convertido en un estándar: proporciona flexibilidad donde los mecanismos integrados resultan insuficientes y se integra de forma natural en los pipelines de CI/CD. La clave del éxito radica en una clara separación entre webhooks Mutating y Validating, una configuración correcta de TLS, pruebas exhaustivas y privilegios mínimos para la cuenta de servicio del webhook.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →