Kubernetes Admission Controllers en 2026: escritura de webhooks propios en Go para el control de políticas del clúster
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í:
- El cliente envía una solicitud al kube-apiserver.
- Se realiza la autenticación y autorización (RBAC).
- La solicitud pasa por la cadena de Admission Controllers.
- Los controladores mutantes modifican el objeto (por ejemplo, añaden etiquetas).
- Los controladores validadores verifican el estado final del objeto.
- 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: RequestResponsepara 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í →