Module 6: Runtime Security Admission Control And Image Scanning
4. Hands-on: installing Gatekeeper and your first policy
Description
This lesson runs, against andes-cargo-cluster for real, everything lessons 2 and 3 left on paper: it installs OPA Gatekeeper v3.23.0 with the official manifest, applies the ConstraintTemplate/Constraint that requires CPU and memory limits on every Pod in the andes-cargo namespace, and tests the result with two real Pods — one that violates it, one that complies. All the text in this lesson, unless stated otherwise, is literal output from commands run in this lab.
Connection to the module
The andes-cargo namespace — the same one ArgoCD syncs since Module 5 — already has, since Module 3, a Deployment (andes-cargo-status-api) with resources.requests/resources.limits declared. This lesson isn't going to touch that Deployment at all: the new policy's test is going to use two isolated test Pods, with names that make clear which is which (bad-pod-no-limits, good-pod-with-limits).
Step 1 — Confirm the starting state
kind get clusters
kubectl config current-context
kubectl get nodes
What to expect:
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
The cluster from Modules 1-5 is still alive — this module doesn't create a new one. AGE is variable (it depends on how long your lab has been running); the three node names and the version (v1.36.1) are fixed.
Step 2 — Download and apply Gatekeeper's official v3.23.0 manifest
curl -sL -o gatekeeper.yaml \
https://raw.githubusercontent.com/open-policy-agent/gatekeeper/v3.23.0/deploy/gatekeeper.yaml
kubectl apply -f gatekeeper.yaml
What to expect (fixed — it's the same official manifest, so the list of created resources doesn't vary):
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
Notice the last two lines: they're, exactly, the two pieces lesson 2 told you you'd see by name — mutatingwebhookconfiguration and validatingwebhookconfiguration, both registered against kube-apiserver in the same kubectl apply that creates everything else.
Wait for the controller to be ready before continuing:
kubectl -n gatekeeper-system rollout status deployment/gatekeeper-controller-manager --timeout=180s
kubectl -n gatekeeper-system get pods
What to expect:
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
Three replicas of gatekeeper-controller-manager (the exact reason from lesson 2, exercise 3: minimizing an unresponsive webhook's window), plus gatekeeper-audit — a separate Deployment that re-evaluates, in the background, objects that already exist on the cluster against every Constraint, without waiting for a new kubectl apply. The hash suffixes (7d89d4569c-5gbkj, and the three of controller-manager) are variable — they're never going to be exactly these in your own run.
Step 3 — The ConstraintTemplate: the reusable logic
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
This template ships two violation rules, not one: the first requires resources.limits (the ceiling), the second requires resources.requests (the floor kube-scheduler, from Module 1, uses to decide which node to place the Pod on). A Pod that only declares one of the two still violates the policy.
What to expect:
constrainttemplate.templates.gatekeeper.sh/k8srequiredresources created
NAME AGE
k8srequiredresources 8s
k8srequiredresources.constraints.gatekeeper.sh 2026-08-14T22:27:24Z
The last line literally confirms what lesson 3 predicted: Gatekeeper generated a new CRD (k8srequiredresources.constraints.gatekeeper.sh) from the ConstraintTemplate, with no need for you to write any CRD by hand. The timestamp is variable — it's the exact moment your own cluster registered the CRD.
Step 4 — The Constraint: applying it over 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
What to expect:
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 is the mode that rejects objects violating the policy — the only one you're going to use in this module. Gatekeeper also supports dryrun (logs the violation, without rejecting anything) and warn (lets the object through with a warning); you're going to explicitly use dryrun later in this same module (lesson 6), to keep two active engines at once from confusing an isolated test. TOTAL-VIOLATIONS: 0 confirms that, at the moment of this apply, no existing object in andes-cargo violates the policy yet — the andes-cargo-status-api Deployment, with its limits from Module 3, already complies.
Step 5 — The Pod that violates it: rejected
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
What to expect (literal — this is the real rejection message, run against this lab):
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"}
Read this message carefully, because it has three pieces you already know, all from previous lessons:
admission webhook "validation.gatekeeper.sh" denied the request— the exact name of lesson 2'sValidatingWebhookConfiguration, the one phase 3 of the lifecycle invoked before reachingetcd.[andes-cargo-must-have-resource-limits]— the name of theConstraintthat rejected the object — not theConstraintTemplate, theConstraint— confirming the concrete instance (with itsmatchoverandes-cargo) was the one that triggered.- Two messages, not one — because Step 3's
ConstraintTemplatedeclared two separateviolationrules (one forlimits, one forrequests), and this Pod violates both at once.
And, exactly as lesson 2 predicted: kubectl get pods -n andes-cargo | grep bad-pod isn't going to show anything, not now, not ever — this Pod never reached etcd.
Step 6 — The Pod that complies with it: admitted
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
What to expect:
pod/good-pod-with-limits created
NAME READY STATUS RESTARTS AGE
good-pod-with-limits 1/1 Running 0 11s
1/1 Running — the same YAML, with resources.requests/resources.limits declared, goes through phase 3 with no objection and reaches etcd. AGE is variable.
Clean up the test Pod before continuing, so you don't leave loose resources in andes-cargo:
kubectl delete pod good-pod-with-limits -n andes-cargo
Visual summary: two Pods, one Constraint
andes-cargo-must-have-resource-limits (ENFORCEMENT-ACTION: deny)
bad-pod-no-limits.yaml good-pod-with-limits.yaml
(no resources.limits/requests) (with resources.limits/requests)
│ │
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ kube-apiserver │ │ kube-apiserver │
│ phase 3: admission │ │ phase 3: admission │
└──────────┬───────────┘ └──────────┬───────────┘
│ validation.gatekeeper.sh │ validation.gatekeeper.sh
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ gatekeeper- │ │ gatekeeper- │
│ controller-manager │ │ controller-manager │
│ evaluates Rego │ │ evaluates Rego │
└──────────┬───────────┘ └──────────┬───────────┘
│ violation[] NOT empty │ violation[] EMPTY
▼ ▼
allowed: false allowed: true
│ │
▼ ▼
"admission webhook denied Pod created, 1/1 Running
the request" — NEVER reaches etcd in etcd, forever
Common mistakes
Reading only the first line of the rejection message and missing the second (admission webhook denied the request without reading the full message). What happens: someone sees admission webhook "validation.gatekeeper.sh" denied the request: and assumes the useful information ends there, without scrolling down to read the specific message in brackets. How to spot it: if your first instinct with this error is to search online for "admission webhook denied the request" instead of reading what follows the colon. How to fix it: everything you need to fix the Pod is in the part of the message starting with [constraint-name] — in this lab, exactly which fields are missing (limits, requests) and on which container. Gatekeeper (and Kyverno, in lesson 6) always include the specific reason; the generic error above only tells you who rejected it, never why.
Applying the Constraint immediately after the ConstraintTemplate, with no pause at all (order of operations, already anticipated in lesson 3). What happens: Step 4 fails with error: unable to recognize "constraint-andes-cargo-required-resources.yaml": no matches for kind "K8sRequiredResources" in version "constraints.gatekeeper.sh/v1beta1". How to spot it: exactly that message, right after applying the ConstraintTemplate. How to fix it: give Gatekeeper a few seconds — Step 3's sleep 8 exists exactly for this — to register the new CRD against kube-apiserver before trying to create an instance of that type. If the error persists after waiting, confirm with kubectl get crd | grep k8srequiredresources that the CRD already exists.
ImagePullBackOff when using an image not on the cluster (inherited from Module 1). What happens: if you swap nginx:1.27-alpine for another image in this lesson's test Pods, and that image isn't available on Docker Hub or loaded locally with kind load docker-image, the Pod ends up in ImagePullBackOff instead of Running — a completely different error from this lesson's, from a later phase (kubelet trying to download the image, already past the admission phase). How to spot it: kubectl describe pod shows Failed to pull image in its events. How to fix it: use a real public image (like nginx:1.27-alpine, which Docker Hub serves without authentication) or load yours with kind load docker-image <your-image> --name andes-cargo-cluster, as you did with andes-cargo-status-api:latest in Module 1.
Exercises
Exercise 1 — Predict the exact message for a Pod with requests but no limits. Without running anything, predict: if you apply a Pod that declares resources.requests.cpu/resources.requests.memory but declares no resources.limits at all, how many violation lines do you expect in the rejection message, and what would they say?
See solution
A single violation line — not two: [andes-cargo-must-have-resource-limits] container <name> is missing required resource limits: {"cpu", "memory"}. The ConstraintTemplate's second violation rule (the one requiring requests) doesn't trigger, because that field is present — that rule's missing set ends up empty, and a Rego rule that finds no true condition simply contributes nothing to the result. Only the limits rule finds a missing field and adds its message.
Exercise 2 — Explain why TOTAL-VIOLATIONS showed 0 right after Step 4, before you tested any Pod. Step 4's Constraint showed TOTAL-VIOLATIONS: 0 as soon as it was applied — before Step 5. What exactly does that number mean at that point?
See solution
TOTAL-VIOLATIONS doesn't count rejected attempts at apply time (those never get to exist, so there's nothing to "count" as a persistent object) — it counts how many objects already existing on the cluster, in the namespace being matched, currently violate the policy, according to the gatekeeper-audit process from Step 2 (the separate Deployment that re-evaluates the current state in the background). It showed 0 because, at that moment, the only real object in andes-cargo was the andes-cargo-status-api Deployment, which already declares resources.limits/resources.requests since Module 3 — the policy had nothing to flag because the cluster already complied.
Exercise 3 — Design a third test Pod that confirms spec.match.namespaces' scope. Without looking at this lesson's YAML again, write (in your head or on paper) a Pod with no resources.limits, just like bad-pod-no-limits.yaml, but in the default namespace instead of andes-cargo. Would you expect Gatekeeper to reject it too?
See solution
No — Step 4's Constraint declares spec.match.namespaces: ["andes-cargo"], so the rule only evaluates Pods in that specific namespace. An identical Pod, with no limits, created in default, would go through the admission phase with this Constraint not touching it at all (though other internal Kubernetes admission controllers, unrelated to Gatekeeper, might apply). This is exactly the mechanism lesson 3 described: the same Rego logic, with the same ConstraintTemplate, can have different scopes depending on how each Constraint configures its match — this lab deliberately protects only andes-cargo, not the whole cluster.
Summary and next step
This lesson actually installed Gatekeeper v3.23.0 against andes-cargo-cluster, applied lesson 3's ConstraintTemplate/Constraint, and confirmed, with literal output, that a Pod with no resources.limits/requests gets rejected before reaching etcd (admission webhook "validation.gatekeeper.sh" denied the request), while the same Pod, with those fields declared, runs normally. andes-cargo has, from this point on, a real gatekeeper at its door — not a design promise, a Deployment running with three replicas, evaluating every kubectl apply live.
Before moving on you should be able to: reproduce, on your own cluster, this lesson's six steps from memory; read a Gatekeeper rejection message and identify its three pieces (webhook, Constraint, specific reason); and explain what TOTAL-VIOLATIONS means versus a rejection at apply time.
Next lesson: Kyverno, the YAML-native alternative. There you're going to see why two complete engines exist for the same problem — Rego inside a CRD, versus pure declarative YAML — and when a real team picks each one, with this guide declaring no winner.
Resources
- Gatekeeper — Installation — the source for the official
v3.23.0manifest applied in this lesson. - Gatekeeper — Audit — official documentation for
gatekeeper-auditand theTOTAL-VIOLATIONSfield. - Gatekeeper — Violations — reference for the three
enforcementActionmodes (deny,dryrun,warn). kubernetes-and-eks-in-production-guide(NIEVA), Module 3 —resources.requests/resources.limitsfrom theandes-cargo-status-apiDeployment, the reasonTOTAL-VIOLATIONSshowed0in Step 4.