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's ValidatingWebhookConfiguration, the one phase 3 of the lifecycle invoked before reaching etcd.
  • [andes-cargo-must-have-resource-limits] — the name of the Constraint that rejected the object — not the ConstraintTemplate, the Constraint — confirming the concrete instance (with its match over andes-cargo) was the one that triggered.
  • Two messages, not one — because Step 3's ConstraintTemplate declared two separate violation rules (one for limits, one for requests), 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

  1. Gatekeeper — Installation — the source for the official v3.23.0 manifest applied in this lesson.
  2. Gatekeeper — Audit — official documentation for gatekeeper-audit and the TOTAL-VIOLATIONS field.
  3. Gatekeeper — Violations — reference for the three enforcementAction modes (deny, dryrun, warn).
  4. kubernetes-and-eks-in-production-guide (NIEVA), Module 3 — resources.requests/resources.limits from the andes-cargo-status-api Deployment, the reason TOTAL-VIOLATIONS showed 0 in Step 4.