CoursesPolicy-as-code at scaleKyverno vs Gatekeeper

Kyverno vs Gatekeeper

Choosing an engine per constraint, not per fashion.

Advanced30 min · lesson 5 of 13

Kubernetes will run whatever YAML (a plain-text format for describing configuration) you hand it. A container running as root. An image pulled from a stranger's registry. A Deployment with no memory limit at all. Nothing in the platform says no, because nothing was ever asked to. A *policy engine* is the building inspector you post at the door: software that reads every resource on its way in, checks it against rules you wrote, and turns away the ones that fail. Kyverno and Gatekeeper are the two inspectors most teams hire, and they differ the way two inspectors might. One reads your blueprints in the language blueprints already come in (YAML). The other wants the building code translated into a specialist legal language called Rego, harder to learn, able to argue almost anything.

Both engines plug into the same socket. That socket is the *admission webhook*, a callback the Kubernetes API server (the front door that every kubectl command and every controller talks to) makes over HTTP before it writes any object to storage. It hands an outside service a veto. Gatekeeper wraps OPA (Open Policy Agent, the general-purpose engine from the first lesson) inside a Kubernetes controller, so your policies are Rego programs. Kyverno was built for Kubernetes and nothing else: its policies are Kubernetes resources themselves, written as declarative YAML. The failure they both exist to stop is the same one. Out of thousands of manifests (the YAML files that describe your resources), a single misconfigured one slips into production, and six weeks later it is an incident.

What each engine is doing inside the cluster

Kyverno splits the work across purpose-built controllers, like a shop with a doorman, a floor walker and a bookkeeper. The admission controller answers the webhook in real time. The background controller walks resources that already exist and applies the same rules to them. The reports controller writes down what was found. The policy itself lives in a ClusterPolicy object, which carries rules of four kinds: validate, mutate, generate, and verifyImages. Each rule picks its targets with declarative selectors. A validation rule compares a pattern (or a CEL expression, CEL being the Common Expression Language, a small condition language built into Kubernetes) against the object arriving at the door. For resources already running, the verdicts land in PolicyReport custom resources, one set per namespace (a namespace is a folder-like partition inside the cluster).

Gatekeeper takes the opposite shape: one controller-manager with OPA compiled in as a library. You write a ConstraintTemplate holding the Rego plus a schema for its parameters, and Gatekeeper turns that template into a brand-new CRD (Custom Resource Definition, the way you teach the Kubernetes API a new object type). A *constraint* is one instance of that new type. It binds the logic to particular kinds and namespaces with concrete parameter values, so a single template can back twenty constraints. A separate audit loop replays every constraint against what is already running, once every 60 seconds by default, and records violations in each constraint's status field.

Choosing an engine, per constraint not per fashion
Which policy engine for this constraint?
Both plug into the same admission webhook; the choice is about the logic and the extras you need.
Your team thinks in YAML and needs more than validation
Kyverno
Declarative YAML policies. mutate injects defaults, generate creates companion resources, verifyImages checks Sigstore signatures. Gatekeeper largely lacks all three.
Rego is already your org's policy language
Gatekeeper
One testable Rego codebase enforced across CI, services and the cluster. Shines on loops, set arithmetic and cross-field logic.
The check is simple and CEL can express it
ValidatingAdmissionPolicy
Built-in CEL validation runs inside the API server with no webhook hop. Both engines can generate it, which saves the webhook for logic CEL cannot express.
Whoever can edit a ClusterPolicy or a constraint can switch enforcement off across the whole cluster, so lock down permissions on those objects as hard as you lock down the permission system itself.

One guardrail, two dialects

The fastest way to feel the difference is to write the same rule twice. Here is the rule: every Pod (the smallest thing Kubernetes runs, one or more containers scheduled together) must carry a team label. That label is the tag your on-call rotation reads to decide whose phone rings at 3am.

kyverno-policy.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-team-label
spec:
rules:
- name: check-team
match:
any:
- resources:
kinds:
- Pod
validate:
failureAction: Enforce
message: "label 'team' is required for on-call routing"
pattern:
metadata:
labels:
team: "?*" # any non-empty value

Under twenty lines, and the pattern block is shaped like the Pod it is checking. You could read it aloud to a colleague who has never seen Kyverno and they would follow it. The Gatekeeper version arrives in two pieces: a template that holds the logic, and a constraint that binds it to something.

gatekeeper-policy.yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8srequiredlabels
spec:
crd:
spec:
names:
kind: K8sRequiredLabels
validation:
openAPIV3Schema:
type: object
properties:
labels:
type: array
items:
type: string
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8srequiredlabels
violation[{"msg": msg}] {
required := input.parameters.labels[_]
not input.review.object.metadata.labels[required]
msg := sprintf("missing required label: %v", [required])
}
---
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata:
name: pods-must-have-team
spec:
enforcementAction: deny
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
parameters:
labels: ["team"]

Same guardrail, roughly triple the YAML, plus a Rego program. That is the whole trade in miniature. Kyverno's declarative patterns get you there fast and then hit a ceiling. Gatekeeper hands you a real programming language, which, as the Rego lessons showed, earns its keep the moment the logic turns program-shaped: looping over nested structures, doing set arithmetic, comparing one field against another. Kyverno has closed part of that gap with CEL expressions and API-call contexts. Deeply conditional logic still reads better in Rego.

Enforce it, then test it offline

Install either engine with Helm (the package manager for Kubernetes). Three webhook replicas is the production floor, because the webhook now sits on the critical path of every matched API request, and one replica is one bad node away from trouble. Then go break your own policy on purpose. The denial message tells you which engine answered the door.

shell
# Kyverno
helm repo add kyverno https://kyverno.github.io/kyverno/
helm install kyverno kyverno/kyverno -n kyverno --create-namespace \
--set admissionController.replicas=3
# Gatekeeper
helm repo add gatekeeper https://open-policy-agent.github.io/gatekeeper/charts
helm install gatekeeper gatekeeper/gatekeeper -n gatekeeper-system \
--create-namespace --set replicas=3
kubectl apply -f kyverno-policy.yaml
kubectl run nginx --image=nginx:1.29
# -> Error from server: admission webhook "validate.kyverno.svc-fail" denied the request:
# ->
# -> resource Pod/default/nginx was blocked due to the following policies
# ->
# -> require-team-label:
# -> check-team: 'validation error: label ''team'' is required for on-call routing.
# -> rule check-team failed at path /metadata/labels/team/'
# Same experiment with Gatekeeper enforcing:
kubectl run nginx --image=nginx:1.29
# -> Error from server (Forbidden): admission webhook "validation.gatekeeper.sh"
# -> denied the request: [pods-must-have-team] missing required label: team

Never iterate on policies by lobbing manifests at a live cluster to see what sticks. Both projects ship a command-line tool that evaluates policies against files on disk, and that is the piece you wire into CI (continuous integration, the pipeline that runs on every change).

shell
# Kyverno CLI: evaluate a policy against a manifest
kyverno apply kyverno-policy.yaml --resource bad-pod.yaml
# -> Applying 1 policy rule(s) to 1 resource(s)...
# ->
# -> policy require-team-label -> resource default/Pod/nginx failed:
# -> 1. check-team: validation error: label 'team' is required for on-call routing.
# ->
# -> pass: 0, fail: 1, warn: 0, error: 0, skip: 0
# Gatekeeper's gator: same idea for templates + constraints
gator test -f gatekeeper-policy.yaml -f bad-pod.yaml
# -> ["pods-must-have-team"] Message: "missing required label: team"

What breaks at scale

The two engines part company hardest over what happens to a violation after it has been found. Kyverno writes PolicyReport resources into every namespace. At 3,000 namespaces times dozens of policies, that is real pressure on etcd (the key-value database where the cluster keeps all of its state), so scope your reports deliberately and give the reports controller room to breathe. Gatekeeper goes the other way and parks audit findings in each constraint's status, cut off at constraintViolationsLimit (default 20). Past that line the status is a sample, not an inventory. Export violations through metrics or the audit export channel instead of reading a number off the object and believing it.

Both engines are also converging on the same escape hatch: ValidatingAdmissionPolicy, the CEL validation Kubernetes now has built in, which runs inside the API server with no network hop to a webhook at all. Each engine can generate one of these from its own policy format, so cheap checks stay in-process and the webhook is saved for logic CEL cannot express. Until you get there, remember that every webhook call adds a round trip to every request it matches. Scope your match blocks tightly rather than matching all kinds and filtering inside the policy.

Fail-open and fail-closed are both loaded guns
Gatekeeper's webhook ships with failurePolicy: Ignore. If its pods are down, every violating resource sails straight through, silently, with no error anywhere for you to alert on. Kyverno ships with Fail, so an outage blocks admissions instead. That is the right instinct for security policies, and it can also stall a cluster recovery if you have not excluded the system namespaces. Pick a side deliberately, policy by policy. Run at least three webhook replicas behind a PodDisruptionBudget (a rule that stops Kubernetes draining too many of them at once), and alert on webhook error rate and latency whichever side you picked.

Choosing an engine

The honest decision tree is short. Pick Kyverno when your platform team thinks in YAML and you need more than validation: mutate rules inject defaults, generate rules create companion resources (a NetworkPolicy for every new namespace, say), and verifyImages checks Sigstore signatures, Sigstore being the project that signs container images so you can prove where they came from. Gatekeeper largely cannot do those things. Its mutation is limited to a handful of simple assignment CRDs, and it has no generation at all. Pick Gatekeeper when Rego is already your organization's policy language across CI and your services, and you want one testable codebase enforced everywhere.

Whichever you pick, treat the policy CRDs as crown jewels. Anyone who can edit a ClusterPolicy or a constraint can switch off enforcement across the entire cluster, so RBAC (role-based access control, the permission system that decides who may touch what) on those objects has to be as tight as RBAC on RBAC itself. Pin the engine version. Set resource limits on its controllers. Watch the admission latency percentiles, because a slow policy engine puts a tax on every deploy in the cluster.

Picking the engine is the easy part. The policies it enforces are living code. They need versioning, a staged rollout from audit mode to enforce mode, exemptions written down somewhere a stranger can find them, and a clean way to retire, all without anyone bricking a Friday afternoon deploy. That operational discipline is the policy lifecycle, and it is where we go next.

Try this

Run these in a lab cluster or something else you can throw away, so you see the real shape of the output instead of a screenshot from someone's blog post.

terminal
kubectl get validatingadmissioncontrollers 2>/dev/null | head
kubectl get crd | grep -E 'kyverno|gatekeeper' || true
output
…gatekeeper…
…kyverno…

Takeaway

Kyverno when you want YAML-native validate, mutate and generate. Gatekeeper with OPA when Rego's expressiveness and shared policy libraries are what you actually need. Engine fashion is not a strategy.

Next: ConstraintTemplates that scale without copy-pasting Rego for every constraint.

Quick check
01Gatekeeper's webhook pods are down, and someone applies a resource that violates a constraint. With the defaults left alone, what happens?
Correct — Gatekeeper's default is Ignore, so a webhook outage lets violating resources walk in with nothing recorded as a failure.
Incorrect — No. Fail is Kyverno's default, not Gatekeeper's. The two engines pick opposite defaults, which is exactly the trap.
Incorrect — No. The API server does not queue requests for a webhook that is down. failurePolicy decides admit or deny on the spot.
Incorrect — No. The audit loop only records violations in a constraint's status. It never deletes anything, and admission already happened.
02You need an accurate count of every resource currently violating a Gatekeeper constraint. Why is reading the number straight off the constraint's status a mistake?
Incorrect — No. A separate audit loop replays every constraint against what is already running, so pre-existing resources do get caught.
Incorrect — No. PolicyReport resources are Kyverno's reporting mechanism. Gatekeeper parks its audit findings in the constraint's own status.
Correct — Read the count off the object and you will believe 20 when the real number is 900. Export violations through metrics or the audit export channel instead.
Incorrect — No. The audit loop replays every 60 seconds by default. Staleness is not the trap here; the cap on how many violations the status holds is.
03A teammate's Pod is rejected with: admission webhook "validate.kyverno.svc-fail" denied the request ... check-team: 'validation error: label 'team' is required for on-call routing'. They now want to tune the policy until it behaves the way they intended. What is the right next move?
Incorrect — No. That is lobbing manifests at a live cluster to see what sticks, which is exactly the loop to avoid.
Correct — The Kyverno CLI evaluates the policy against files on disk, gives you the pass/fail counts, and is the piece that belongs in the pipeline.
Incorrect — No. gator is Gatekeeper's offline tester, for ConstraintTemplates plus constraints. The Kyverno-side equivalent is the kyverno CLI.
Incorrect — No. Kyverno ships with failurePolicy: Fail, so taking the webhook down blocks admissions rather than opening them. It is also switching off enforcement to push a manifest you already know fails.

Related