CEL in Kubernetes
Common Expression Language (CEL) is the small, embeddable, non-Turing-complete expression language that Kubernetes adopted as its in-process engine for validation and policy — the strategic replacement for out-of-process admission webhooks across the policy use case (kubernetes.io — CEL). CEL is a Google-originated open language (
google/cel-spec); Kubernetes embeds a CEL interpreter directly insidekube-apiserver. The motivation is operational fragility: a ValidatingAdmissionWebhook is a separate HTTPS service on the API server’s hot path — if it is slow, down, or misconfigured, every matching create/update either hangs or is rejected, and a cluster can be wedged by a single bad webhook. CEL moves the expression-evaluable subset of that work inside the API server: no network hop, no separate Pod, no TLS plumbing, no availability dependency. Because CEL is non-Turing-complete — no unbounded loops, no recursion — every expression is guaranteed to terminate, which is the property that makes it safe to evaluate synchronously in the API server’s request path. CEL now drives Custom Resource Definition validation rules, ValidatingAdmissionPolicy and MutatingAdmissionPolicy, structured authorization match conditions, and admission-webhook match conditions. Understanding CEL is understanding the direction K8s extensibility is heading: declarative, in-process, webhook-free policy.
Mental Model
The thing to internalize is why a deliberately limited language is a feature, not a compromise. A general-purpose language (a webhook written in Go, an OPA Rego policy) can do anything — including loop forever, recurse without bound, or take arbitrarily long. None of that can be allowed on the synchronous path of an API server that must answer thousands of requests per second. CEL’s restrictions are exactly the price of admission to that hot path:
flowchart TD REQ["API request:<br/>create / update an object"] subgraph APISERVER["inside kube-apiserver — synchronous, hot path"] CEL["CEL evaluator<br/>non-Turing-complete<br/>guaranteed to terminate<br/>cost-budget checked"] WEBHOOK["...vs an admission webhook:<br/>network hop to a separate Pod<br/>can hang / be down / time out"] end PASS["admit"] FAIL["reject with message"] REQ --> CEL CEL -- "expression true" --> PASS CEL -- "expression false" --> FAIL REQ -. "the old way" .-> WEBHOOK WEBHOOK -. "adds latency +<br/>an availability dependency" .-> PASS
The diagram contrasts in-process CEL evaluation with the webhook it replaces. The insight to extract: CEL trades generality for two guarantees the API server’s hot path requires — bounded execution time (non-Turing-completeness ⇒ termination) and no external dependency (the evaluator is in-process). A webhook can do more, but it adds a network hop and a way for the whole cluster to break. CEL is the language you get when you ask “what is the most expressive policy language that is still safe to run inside the API server on every request?”
Mechanical Walk-through
Where CEL is used
CEL is not one feature; it is one engine wired into several extension points (kubernetes.io — CEL):
- CRD validation rules — the
x-kubernetes-validationsfield inside a CRD’s OpenAPI schema. Each rule is a CEL expression that must evaluatetruefor the custom resource to be admitted. Alpha in 1.23, beta in 1.25, and GA in Kubernetes 1.29 (per KEP-2876’sstage: stable,latest-milestone: v1.29). This is the most widely used CEL surface. - ValidatingAdmissionPolicy — a cluster-wide, CEL-based admission validation mechanism for any resource (not just custom resources). Alpha in 1.26, GA in 1.30 (Kubernetes blog, April 24 2024). The in-process replacement for the ValidatingAdmissionWebhook.
- MutatingAdmissionPolicy — the mutation-side sibling: CEL expressions that modify objects on admission. Alpha in 1.32, beta in 1.34, GA in 1.36 (per KEP-3962’s implementation history).
- Structured authorization configuration —
matchconditions in the API server’s authorizer chain (e.g. limiting a webhook authorizer to certain requests) are CEL expressions. - Admission webhook match conditions — even when you still use a webhook, its
matchConditionsare CEL expressions evaluated in-process to decide whether the webhook is called at all — narrowing a webhook’s blast radius without a round trip.
The language properties
- Non-Turing-complete. No unbounded loops, no recursion. The macros that look loop-like (
all,exists,map,filter) iterate over finite collections drawn from the object being validated. Termination is guaranteed by construction — this is the load-bearing property. - Single expression per program. A CEL “program” is one expression evaluating to one value (for validation, a boolean).
- C-family syntax. Resembles C/C++/Java/Go/JavaScript expressions —
&&,||,==,<=, ternary? :, member access, function calls. - Object-scoped. All Kubernetes CEL rules are scoped to the current object only — there is no cross-object or stateful validation. A CRD rule cannot say “this name must be unique cluster-wide”; that still needs a webhook with a list call.
The variables
The variable bindings depend on where the CEL expression lives:
- CRD validation rules —
selfis bound to the value at the schema location where thex-kubernetes-validationsextension sits (the whole object if at the schema root, a sub-field if nested).oldSelfis bound to the previous value, available only in transition rules (rules that compare old and new — e.g. enforcing immutability). The macrohas()tests field presence;oldSelflets a rule express “this field may not change after creation.” - ValidatingAdmissionPolicy / MutatingAdmissionPolicy / webhook match conditions —
object(the incoming object;nullforDELETE),oldObject(the prior state;nullforCREATE),request(theAdmissionRequestmetadata — operation, user, namespace),paramsif the policy is parameterized via aparamKind,namespaceObject(theNamespacethe resource lives in,nullfor cluster-scoped resources),authorizer(a CELAuthorizerthat can perform SubjectAccessReview-style checks against the requesting principal), and author-declared namedvariablesaccessed asvariables.foo(kubernetes.io — MutatingAdmissionPolicy).
Cost limits — the DoS guard
Non-Turing-completeness guarantees termination but not speed — a CEL expression iterating a million-element list still costs real CPU. So Kubernetes assigns every CEL operation an estimated cost and enforces a cost budget (KEP-2876). There are two checks:
- Estimated cost, at write time. When a CRD (or policy) is created or updated, the API server statically estimates the worst-case cost of each CEL rule, using the schema’s
maxItems/maxLength/maxPropertiesbounds to bound iteration. A rule whose estimated cost exceeds the per-expression limit is rejected at write time — you cannot even install a CRD with an unboundedly expensive rule. This is why CEL rules over lists/maps generally require the schema to declare size limits. - Runtime cost, at evaluation time. Each actual evaluation is also metered against a runtime budget; an evaluation that exceeds it is aborted and the request fails. This catches cases the static estimate could not bound.
The effect: CEL cannot be used to DoS the API server, because both the installable set of rules and the per-request evaluation are budget-capped.
Configuration / API Surface
CEL validation rules inside a CRD
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: webservices.example.com
spec:
group: example.com
names: { kind: WebService, plural: webservices }
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
replicas: { type: integer }
minReplicas: { type: integer }
maxReplicas: { type: integer }
tier: { type: string }
containers:
type: array
maxItems: 20 # bounds the cost of any rule iterating it
items:
type: object
properties:
name: { type: string }
memory: { type: string }
# object-level rules: 'self' = the whole spec object
x-kubernetes-validations:
- rule: "self.minReplicas <= self.replicas && self.replicas <= self.maxReplicas"
message: "replicas must be between minReplicas and maxReplicas"
- rule: "self.containers.all(c, c.memory != '')"
message: "every container must declare a memory value"
- rule: "self.tier != 'prod' || self.replicas >= 3"
message: "prod tier requires at least 3 replicas"
# transition rule: uses oldSelf — fires only on UPDATE
tier:
type: string
x-kubernetes-validations:
- rule: "self == oldSelf"
message: "tier is immutable once set"Line-by-line: the first object-level rule cross-validates three sibling fields in one expression — something a plain OpenAPI schema cannot do (OpenAPI can bound a single field but not relate fields). self is the spec object because the x-kubernetes-validations sits at the spec schema node. The all(c, ...) macro iterates the containers list — and this is only installable because containers declares maxItems: 20, which lets the API server bound the rule’s estimated cost; without the bound, the CRD would be rejected at write time. The third rule is a conditional: “if tier is prod, then replicas >= 3.” The tier-level rule is a transition rule — it references oldSelf, so it is evaluated only on updates, and self == oldSelf enforces immutability.
CEL in a ValidatingAdmissionPolicy
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: replica-limit
spec:
matchConstraints:
resourceRules:
- apiGroups: ["apps"]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["deployments"]
validations:
- expression: "object.spec.replicas <= 5"
message: "Deployments may not exceed 5 replicas"
- expression: >-
object.spec.template.spec.containers.all(c,
has(c.resources.limits) && has(c.resources.limits.memory))
message: "every container must set a memory limit"Here the variable is object (not self) — the convention for admission policies. The first expression caps replica count on built-in Deployments, something CRD validation rules cannot do because Deployments are not custom resources. The second walks containers with all(...) and has(...) to require memory limits — the canonical “enforce resource limits” policy, now expressible in-process with no webhook. See ValidatingAdmissionPolicy.
Failure Modes
- Unbounded-cost rule rejected at CRD install. A rule iterating a list/map whose schema does not declare
maxItems/maxPropertieshas an unbounded estimated cost and the API server refuses to create the CRD. Symptom:CustomResourceDefinition.apiextensions.k8s.io is invalid: ... exceeds budget. Fix: add size bounds to the schema. - Runtime cost-budget abort. A rule that passed static estimation but is expensive on a particular large object aborts mid-evaluation; the request fails. Rare with bounded schemas; a sign of an over-clever expression.
- Transition-rule confusion. A rule referencing
oldSelfruns only on update; authors expecting it to fire on create are surprised. Conversely, a rule that should be immutability-checking but omitsoldSelfdoes nothing useful. - Null / missing-field panics. CEL evaluation of
self.foo.barwhenfoois absent errors. Idiomatic CEL guards withhas(self.foo) && self.foo.bar == .... Forgetting thehas()guard is the most common authoring bug. - Expecting cross-object validation. CEL rules are object-scoped — “this value must be unique across the namespace” or “this must match another resource” is not expressible. That still requires a webhook with API calls.
- Library-version skew. Some CEL libraries are version-gated (Quantity 1.29+, IP and CIDR 1.31+, Format 1.32+, Strings v2 1.30+, Semver 1.34+ — confirmed against the CEL library table on kubernetes.io). A rule using a newer library on an older API server fails to parse. Match library use to the cluster’s minor version.
Alternatives and When to Choose Them
- ValidatingAdmissionWebhook / MutatingAdmissionWebhook — still required for anything CEL cannot do: cross-object validation, external lookups (LDAP, a database), stateful decisions, or logic too complex for a single expression. The cost is the operational fragility CEL was created to escape. Use a webhook only when the in-process options genuinely cannot express the policy.
- OPA Gatekeeper / Kyverno — full policy engines (themselves implemented as admission webhooks). They offer policy libraries, audit/reporting, mutation, resource generation, and richer authoring than raw CEL. Notably, Gatekeeper and Kyverno can generate
ValidatingAdmissionPolicyobjects — i.e. use CEL as their enforcement backend while keeping their authoring/reporting UX. Choose a full engine when you need a managed policy catalog and reporting; choose raw CEL policies when you want a handful of rules with zero extra components. - Plain OpenAPI v3 schema validation —
minimum,maximum,pattern,enum,required. Always prefer this for single-field constraints; it is simpler and cheaper than CEL. Reach for CEL only when a constraint relates multiple fields or needs conditional logic. - Application-level validation — validating in the controller’s
Reconcilerather than at admission. The downside: the bad object is already persisted; admission-time rejection (CEL or webhook) is strictly better when feasible.
Production Notes
- Why CEL matters strategically. CEL is the foundation of Kubernetes’ deliberate move away from webhook-based extensibility for the policy use case. The progression — CRD validation rules (GA 1.29), ValidatingAdmissionPolicy (GA 1.30), MutatingAdmissionPolicy (GA 1.36) — is SIG-API-Machinery systematically replacing the webhook-shaped surfaces with in-process CEL ones. The webhook is not disappearing, but for “reject/mutate based on the object’s contents” it is being demoted from default to last resort.
- The CEL Playground (
playcel.undistro.io) is the standard tool for iterating on expressions before embedding them in a CRD or policy — far faster than apply-and-see. - Gatekeeper and Kyverno both adopted CEL. Their convergence on CEL as an enforcement backend is the strongest signal of CEL’s role: even the established policy engines now generate
ValidatingAdmissionPolicyto get in-process evaluation, treating CEL as the substrate rather than a competitor. +kubebuilder:validation:XValidationmarker. Operator authors using Kubebuilder do not hand-writex-kubernetes-validationsYAML — they put a// +kubebuilder:validation:XValidation:rule="...",message="..."marker on the Go struct andcontroller-genemits the CEL rule into the generated CRD. CEL validation is part of the standard operator-authoring workflow.- Do not confuse with “declarative validation” for native types. Kubernetes 1.36 also shipped Declarative Validation for built-in/native types to GA (Kubernetes blog, May 5 2026), a
validation-gen/+k8s:marker framework that replaces the API server’s hand-written Go validation functions with generated ones. That is a separate feature from the CEL-driven extension points in this note — it modernizes how the API server’s internal validation for first-party types (Pod, Deployment, etc.) is implemented, not what user-supplied policies can express. The two efforts share a “declarative, marker-driven” aesthetic but are otherwise distinct: CEL is for user-authored policies on any resource; declarative validation is for project-maintained validation of built-in types.
Uncertain
Verify: the exact numeric per-expression estimated-cost limit and per-request runtime cost-budget limit. Reason: the kubernetes.io CEL reference describes the mechanism (static estimate vs runtime metering) and KEP-2876 confirms the design (“we will set cost bounds … on both a per-request and per-expression basis as we perform further testing and benchmarking”) without committing to specific numbers; the actual constants live in the API server source and have changed across minors. To resolve: cross-reference
staging/src/k8s.io/apiserver/pkg/cel/in the kubernetes/kubernetes repo for the constants in the relevant minor, or read the API server’s startup logs for the effective budget. The operational rule — “rules over collections requiremaxItems/maxLength/maxPropertieson the schema or the CRD is rejected at install time” — is the reliable bit; the specific number behind it is not.All other version-maturity claims in this note (CRD
x-kubernetes-validations: alpha 1.23, beta 1.25, GA 1.29; ValidatingAdmissionPolicy: alpha 1.26, GA 1.30; MutatingAdmissionPolicy: alpha 1.32, beta 1.34, GA 1.36; CEL library minors) have been verified against primary KEPs and kubernetes.io blog posts as cited inline. Re-verify against the live docs for any operational decision because the docs are point-in-time as of 2026-05-30. uncertain
See Also
- ValidatingAdmissionPolicy — the in-process CEL validation mechanism for any resource
- MutatingAdmissionPolicy — the CEL-based mutation sibling
- Custom Resource Definition — hosts
x-kubernetes-validationsCEL rules - ValidatingAdmissionWebhook — the out-of-process mechanism CEL is replacing
- MutatingAdmissionWebhook — the mutation-side webhook CEL policies displace
- Admission Controllers — the API server gate CEL policies plug into
- OPA Gatekeeper — full policy engine; now generates ValidatingAdmissionPolicy
- Kyverno — K8s-native policy engine; also adopted CEL
- Kubebuilder — emits CEL rules from
+kubebuilder:validation:XValidationmarkers - API Server Request Flow — where admission (and thus CEL) sits in the pipeline
- Kubernetes MOC — umbrella index (§13 Extensibility)