Módulo 6: Runtime Security Admission Control And Image Scanning
4. Manos a la obra: instalando Gatekeeper y tu primera política
Descripción
Esta lección ejecuta, contra andes-cargo-cluster de verdad, todo lo que las lecciones 2 y 3 dejaron en papel: instala OPA Gatekeeper v3.23.0 con el manifiesto oficial, aplica el ConstraintTemplate/Constraint que exige límites de CPU y memoria en todo Pod del namespace andes-cargo, y prueba el resultado con dos Pods reales — uno que lo viola, uno que lo cumple. Todo el texto de esta lección, salvo donde se indique lo contrario, es salida literal de comandos corridos en este laboratorio.
Conexión con el módulo
El namespace andes-cargo —el mismo que ArgoCD sincroniza desde el Módulo 5— ya tiene, desde el Módulo 3, un Deployment (andes-cargo-status-api) con resources.requests/resources.limits declarados. Esta lección no va a tocar ese Deployment para nada: la prueba de la política nueva va a usar dos Pods de prueba, aislados, con nombres que dejan claro cuál es cuál (bad-pod-no-limits, good-pod-with-limits).
Paso 1 — Confirma el estado de partida
kind get clusters
kubectl config current-context
kubectl get nodes
Qué esperar:
andes-cargo-cluster
kind-andes-cargo-cluster
NAME VARIABLE ROLES AGE VERSION
andes-cargo-cluster-control-plane Ready control-plane ... v1.36.1
andes-cargo-cluster-worker Ready <none> ... v1.36.1
andes-cargo-cluster-worker2 Ready <none> ... v1.36.1
El clúster de los Módulos 1-5 sigue vivo — este módulo no crea uno nuevo. AGE es variable (depende de cuánto tiempo lleve corriendo tu laboratorio); los tres nombres de nodo y la versión (v1.36.1) son fijos.
Paso 2 — Descarga y aplica el manifiesto oficial de Gatekeeper v3.23.0
curl -sL -o gatekeeper.yaml \
https://raw.githubusercontent.com/open-policy-agent/gatekeeper/v3.23.0/deploy/gatekeeper.yaml
kubectl apply -f gatekeeper.yaml
Qué esperar (fijo — es el mismo manifiesto oficial, así que la lista de recursos creados no varía):
namespace/gatekeeper-system created
resourcequota/gatekeeper-critical-pods created
customresourcedefinition.apiextensions.k8s.io/assign.mutations.gatekeeper.sh created
customresourcedefinition.apiextensions.k8s.io/constrainttemplates.templates.gatekeeper.sh created
...
serviceaccount/gatekeeper-admin created
role.rbac.authorization.k8s.io/gatekeeper-manager-role created
clusterrole.rbac.authorization.k8s.io/gatekeeper-manager-role created
secret/gatekeeper-webhook-server-cert created
service/gatekeeper-webhook-service created
deployment.apps/gatekeeper-audit created
deployment.apps/gatekeeper-controller-manager created
poddisruptionbudget.policy/gatekeeper-controller-manager created
mutatingwebhookconfiguration.admissionregistration.k8s.io/gatekeeper-mutating-webhook-configuration created
validatingwebhookconfiguration.admissionregistration.k8s.io/gatekeeper-validating-webhook-configuration created
Fíjate en las dos últimas líneas: son, exactamente, las dos piezas que la lección 2 te dijo que ibas a ver por nombre — mutatingwebhookconfiguration y validatingwebhookconfiguration, ambas registradas contra kube-apiserver en el mismo kubectl apply que crea todo lo demás.
Espera a que el controlador esté listo antes de seguir:
kubectl -n gatekeeper-system rollout status deployment/gatekeeper-controller-manager --timeout=180s
kubectl -n gatekeeper-system get pods
Qué esperar:
deployment "gatekeeper-controller-manager" successfully rolled out
NAME READY STATUS RESTARTS AGE
gatekeeper-audit-7d89d4569c-5gbkj 1/1 Running 1 (13s ago) 17s
gatekeeper-controller-manager-74cd57cc58-d88gv 1/1 Running 0 17s
gatekeeper-controller-manager-74cd57cc58-hbmdg 1/1 Running 0 17s
gatekeeper-controller-manager-74cd57cc58-w4swt 1/1 Running 0 17s
Tres réplicas de gatekeeper-controller-manager (la razón exacta de la lección 2, ejercicio 3: minimizar la ventana de un webhook sin responder), más gatekeeper-audit —un Deployment separado que re-evalúa, en segundo plano, los objetos que ya existen en el clúster contra cada Constraint, sin esperar a un nuevo kubectl apply—. Los sufijos hash (7d89d4569c-5gbkj, y los tres de controller-manager) son variables — nunca van a ser exactamente estos en tu propia ejecución.
Paso 3 — El ConstraintTemplate: la lógica reusable
mkdir -p gatekeeper
cat > gatekeeper/constraint-template-required-resources.yaml << 'EOF'
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8srequiredresources
spec:
crd:
spec:
names:
kind: K8sRequiredResources
validation:
openAPIV3Schema:
type: object
properties:
limits:
type: array
items:
type: string
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8srequiredresources
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
required := input.parameters.limits
provided := {key | container.resources.limits[key]}
missing := {key | key := required[_]; not provided[key]}
count(missing) > 0
msg := sprintf("container <%v> is missing required resource limits: %v", [container.name, missing])
}
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
required := input.parameters.limits
provided := {key | container.resources.requests[key]}
missing := {key | key := required[_]; not provided[key]}
count(missing) > 0
msg := sprintf("container <%v> is missing required resource requests: %v", [container.name, missing])
}
EOF
kubectl apply -f gatekeeper/constraint-template-required-resources.yaml
sleep 8
kubectl get constrainttemplate k8srequiredresources
kubectl get crd | grep k8srequiredresources
Esta plantilla trae dos reglas violation, no una: la primera exige resources.limits (el techo), la segunda exige resources.requests (el piso que kube-scheduler, del Módulo 1, usa para decidir en qué nodo colocar el Pod). Un Pod que solo declare uno de los dos sigue violando la política.
Qué esperar:
constrainttemplate.templates.gatekeeper.sh/k8srequiredresources created
NAME AGE
k8srequiredresources 8s
k8srequiredresources.constraints.gatekeeper.sh 2026-08-14T22:27:24Z
La última línea confirma, literal, lo que la lección 3 predijo: Gatekeeper generó un CRD nuevo (k8srequiredresources.constraints.gatekeeper.sh) a partir del ConstraintTemplate, sin que tú escribieras ningún CRD a mano. El timestamp es variable — es el momento exacto en que tu propio clúster registró el CRD.
Paso 4 — El Constraint: aplicándolo sobre andes-cargo
cat > gatekeeper/constraint-andes-cargo-required-resources.yaml << 'EOF'
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredResources
metadata:
name: andes-cargo-must-have-resource-limits
spec:
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
namespaces:
- "andes-cargo"
parameters:
limits:
- cpu
- memory
EOF
kubectl apply -f gatekeeper/constraint-andes-cargo-required-resources.yaml
kubectl get k8srequiredresources
Qué esperar:
k8srequiredresources.constraints.gatekeeper.sh/andes-cargo-must-have-resource-limits created
NAME ENFORCEMENT-ACTION TOTAL-VIOLATIONS
andes-cargo-must-have-resource-limits deny 0
ENFORCEMENT-ACTION: deny es el modo que rechaza objetos que violan la política —el único que vas a usar en este módulo—. Gatekeeper soporta también dryrun (registra la violación, sin rechazar nada) y warn (deja pasar el objeto con una advertencia); vas a usar dryrun de forma explícita más adelante en este mismo módulo (lección 6), para evitar que dos motores activos a la vez confundan una prueba aislada. TOTAL-VIOLATIONS: 0 confirma que, en el momento de este apply, ningún objeto existente en andes-cargo viola todavía la política — el Deployment andes-cargo-status-api, con sus límites del Módulo 3, ya cumple.
Paso 5 — El Pod que la viola: rechazado
cat > bad-pod-no-limits.yaml << 'EOF'
apiVersion: v1
kind: Pod
metadata:
name: bad-pod-no-limits
namespace: andes-cargo
spec:
containers:
- name: bad-pod-no-limits
image: nginx:1.27-alpine
EOF
kubectl apply -f bad-pod-no-limits.yaml
Qué esperar (literal — este es el mensaje de rechazo real, corrido contra este laboratorio):
Error from server (Forbidden): error when creating "bad-pod-no-limits.yaml": admission webhook "validation.gatekeeper.sh" denied the request: [andes-cargo-must-have-resource-limits] container <bad-pod-no-limits> is missing required resource limits: {"cpu", "memory"}
[andes-cargo-must-have-resource-limits] container <bad-pod-no-limits> is missing required resource requests: {"cpu", "memory"}
Lee este mensaje con cuidado, porque tiene tres piezas que ya conoces, todas de lecciones anteriores:
admission webhook "validation.gatekeeper.sh" denied the request— el nombre exacto delValidatingWebhookConfigurationde la lección 2, el que la fase 3 del ciclo de vida invocó antes de llegar aetcd.[andes-cargo-must-have-resource-limits]— el nombre delConstraintque rechazó el objeto — no delConstraintTemplate, delConstraint— confirmando que la instancia concreta (con sumatchsobreandes-cargo) fue la que disparó.- Dos mensajes, no uno — porque el
ConstraintTemplatedel Paso 3 declaró dos reglasviolationseparadas (una paralimits, otra pararequests), y este Pod viola las dos a la vez.
Y, exactamente como la lección 2 predijo: kubectl get pods -n andes-cargo | grep bad-pod no va a mostrar nada, ni ahora ni nunca — este Pod jamás llegó a etcd.
Paso 6 — El Pod que la cumple: admitido
cat > good-pod-with-limits.yaml << 'EOF'
apiVersion: v1
kind: Pod
metadata:
name: good-pod-with-limits
namespace: andes-cargo
spec:
containers:
- name: good-pod-with-limits
image: nginx:1.27-alpine
resources:
requests:
cpu: "100m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "128Mi"
EOF
kubectl apply -f good-pod-with-limits.yaml
kubectl get pod good-pod-with-limits -n andes-cargo
Qué esperar:
pod/good-pod-with-limits created
NAME READY STATUS RESTARTS AGE
good-pod-with-limits 1/1 Running 0 11s
1/1 Running — el mismo YAML, con resources.requests/resources.limits declarados, atraviesa la fase 3 sin objeción y llega a etcd. AGE es variable.
Limpia el Pod de prueba antes de seguir, para no dejar recursos sueltos en andes-cargo:
kubectl delete pod good-pod-with-limits -n andes-cargo
El resumen visual: dos Pods, un solo Constraint
andes-cargo-must-have-resource-limits (ENFORCEMENT-ACTION: deny)
bad-pod-no-limits.yaml good-pod-with-limits.yaml
(sin resources.limits/requests) (con resources.limits/requests)
│ │
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ kube-apiserver │ │ kube-apiserver │
│ fase 3: admission │ │ fase 3: admission │
└──────────┬───────────┘ └──────────┬───────────┘
│ validation.gatekeeper.sh │ validation.gatekeeper.sh
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ gatekeeper- │ │ gatekeeper- │
│ controller-manager │ │ controller-manager │
│ evalúa Rego │ │ evalúa Rego │
└──────────┬───────────┘ └──────────┬───────────┘
│ violation[] NO vacío │ violation[] VACÍO
▼ ▼
allowed: false allowed: true
│ │
▼ ▼
"admission webhook denied Pod creado, 1/1 Running
the request" — NUNCA llega a etcd en etcd, para siempre
Errores comunes
Leer solo la primera línea del mensaje de rechazo y perder la segunda (admission webhook denied the request sin leer el mensaje completo). Qué pasa: alguien ve admission webhook "validation.gatekeeper.sh" denied the request: y asume que ahí termina la información útil, sin bajar a leer el mensaje específico entre corchetes. Cómo detectarlo: si tu primer instinto ante este error es buscar en internet "admission webhook denied the request" en vez de leer qué sigue después de los dos puntos. Cómo corregirlo: todo lo que necesitas para corregir el Pod está en la parte del mensaje que empieza con [nombre-del-constraint] — en este laboratorio, exactamente qué campos faltan (limits, requests) y en qué contenedor. Gatekeeper (y Kyverno, en la lección 6) siempre incluyen el motivo específico; el error genérico de arriba solo te dice quién rechazó, nunca por qué.
Aplicar el Constraint inmediatamente después del ConstraintTemplate, sin ninguna pausa (de orden de operaciones, ya anticipado en la lección 3). Qué pasa: el Paso 4 falla con error: unable to recognize "constraint-andes-cargo-required-resources.yaml": no matches for kind "K8sRequiredResources" in version "constraints.gatekeeper.sh/v1beta1". Cómo detectarlo: exactamente ese mensaje, inmediatamente después de aplicar el ConstraintTemplate. Cómo corregirlo: dale a Gatekeeper unos segundos —el sleep 8 del Paso 3 existe exactamente por esto— para que registre el CRD nuevo contra kube-apiserver antes de intentar crear una instancia de ese tipo. Si el error persiste después de esperar, confirma con kubectl get crd | grep k8srequiredresources que el CRD ya existe.
ImagePullBackOff al usar una imagen que no está en el clúster (heredado del Módulo 1). Qué pasa: si cambias nginx:1.27-alpine por otra imagen en los Pods de prueba de esta lección, y esa imagen no está disponible ni en Docker Hub ni cargada localmente con kind load docker-image, el Pod queda en ImagePullBackOff en vez de Running — un error completamente distinto al de esta lección, de una fase posterior (kubelet intentando descargar la imagen, ya pasada la fase de admission). Cómo detectarlo: kubectl describe pod muestra Failed to pull image en sus eventos. Cómo corregirlo: usa una imagen pública real (como nginx:1.27-alpine, que Docker Hub sirve sin autenticación) o carga la tuya con kind load docker-image <tu-imagen> --name andes-cargo-cluster, como hiciste con andes-cargo-status-api:latest en el Módulo 1.
Ejercicios
Ejercicio 1 — Predice el mensaje exacto para un Pod con requests pero sin limits. Sin ejecutar nada, predice: si aplicas un Pod que declara resources.requests.cpu/resources.requests.memory pero no declara ningún resources.limits, ¿cuántas líneas de violación esperas en el mensaje de rechazo, y qué dirían?
Ver solución
Una sola línea de violación —no dos—: [andes-cargo-must-have-resource-limits] container <nombre> is missing required resource limits: {"cpu", "memory"}. La segunda regla violation del ConstraintTemplate (la que exige requests) no dispara, porque ese campo sí está presente — el conjunto missing de esa regla queda vacío, y una regla Rego que no encuentra ninguna condición verdadera simplemente no aporta nada al resultado. Solo la regla de limits encuentra un campo faltante y agrega su mensaje.
Ejercicio 2 — Explica por qué TOTAL-VIOLATIONS mostró 0 inmediatamente después del Paso 4, antes de que probaras ningún Pod. El Constraint del Paso 4 mostró TOTAL-VIOLATIONS: 0 apenas se aplicó — antes del Paso 5. ¿Qué significa exactamente ese número en ese momento?
Ver solución
TOTAL-VIOLATIONS no cuenta los intentos rechazados en el momento del apply (esos nunca llegan a existir, así que no hay nada que "contar" como objeto persistente) — cuenta cuántos objetos ya existentes en el clúster, en el namespace que hace match, violan la política ahora mismo, según el proceso gatekeeper-audit del Paso 2 (el Deployment separado que re-evalúa el estado actual en segundo plano). Mostró 0 porque, en ese momento, el único objeto real en andes-cargo era el Deployment andes-cargo-status-api, que ya declara resources.limits/resources.requests desde el Módulo 3 — la política no tuvo nada que señalar porque el clúster ya cumplía.
Ejercicio 3 — Diseña un tercer Pod de prueba que confirme el alcance de spec.match.namespaces. Sin mirar el YAML de esta lección de nuevo, escribe (en tu cabeza o en papel) un Pod sin resources.limits, igual que bad-pod-no-limits.yaml, pero en el namespace default en vez de andes-cargo. ¿Esperarías que Gatekeeper lo rechace también?
Ver solución
No — el Constraint del Paso 4 declara spec.match.namespaces: ["andes-cargo"], así que la regla solo evalúa Pods de ese namespace específico. Un Pod idéntico, sin límites, creado en default, atravesaría la fase de admission sin que este Constraint lo toque en absoluto (aunque otros admission controllers internos de Kubernetes, no relacionados con Gatekeeper, sí podrían aplicar). Este es exactamente el mecanismo que la lección 3 describió: la misma lógica Rego, con el mismo ConstraintTemplate, puede tener alcances distintos según cómo cada Constraint configure su match — este laboratorio protege deliberadamente solo andes-cargo, no el clúster completo.
Resumen y siguiente paso
Esta lección instaló Gatekeeper v3.23.0 de verdad contra andes-cargo-cluster, aplicó el ConstraintTemplate/Constraint de la lección 3, y confirmó, con salida literal, que un Pod sin resources.limits/requests es rechazado antes de llegar a etcd (admission webhook "validation.gatekeeper.sh" denied the request), mientras que el mismo Pod, con esos campos declarados, corre normalmente. andes-cargo tiene, desde este punto, un portero real en su puerta — no una promesa de diseño, un Deployment corriendo con tres réplicas, evaluando cada kubectl apply en vivo.
Antes de avanzar deberías poder: reproducir, en tu propio clúster, los seis pasos de esta lección de memoria; leer un mensaje de rechazo de Gatekeeper e identificar sus tres piezas (webhook, Constraint, motivo específico); y explicar qué significa TOTAL-VIOLATIONS frente a un rechazo en el momento del apply.
Siguiente lección: Kyverno, la alternativa YAML-nativa. Ahí vas a ver por qué existen dos motores completos para el mismo problema —Rego dentro de un CRD, frente a YAML declarativo puro— y cuándo un equipo real elige cada uno, sin que esta guía declare un ganador.
Recursos
- Gatekeeper — Installation — la fuente del manifiesto oficial
v3.23.0aplicado en esta lección. - Gatekeeper — Audit — documentación oficial de
gatekeeper-audity el campoTOTAL-VIOLATIONS. - Gatekeeper — Violations — referencia de los tres modos de
enforcementAction(deny,dryrun,warn). kubernetes-and-eks-in-production-guide(NIEVA), Módulo 3 —resources.requests/resources.limitsdelDeployment andes-cargo-status-api, la razón por la queTOTAL-VIOLATIONSmostró0en el Paso 4.