Conversion Webhooks
A conversion webhook is the mechanism by which a CustomResourceDefinition with multiple API versions translates an object from one version to another on the fly (Kubernetes — CRD Versioning). A CRD may serve
v1beta1,v1, andv2simultaneously, but every object is stored in exactly one version — the storage version — in etcd. When a client requests an object at a version other than the storage version, the kube-apiserver must convert it. The CRD’sspec.conversionfield selects how:strategy: None(the versions are structurally identical and the apiserver merely relabelsapiVersion) orstrategy: Webhook(the apiserver calls an external HTTPS endpoint that performs the conversion). Conversion is what lets an operator evolve its API across versions without breaking existing clients or stored data — old clients keep speakingv1beta1, new clients speakv2, and the same etcd bytes serve both. The conversion contract is theConversionReviewrequest/response object, and the recommended design is the hub-and-spoke pattern: pick one version as the hub and convert every other version to and from it.
Mental Model
A CRD’s version list is a set of lenses onto a single stored fact. Storage holds one canonical representation; every served version is a projection of it. Conversion is the function that maps between projections.
flowchart TB C1["Client A<br/>GET .../v1beta1/widgets/foo"] C2["Client B<br/>GET .../v2/widgets/foo"] API["kube-apiserver"] ETCD[("etcd<br/>stored as v1 — the storage version")] WH["Conversion webhook<br/>(HTTPS endpoint)"] C1 --> API C2 --> API API -- "read raw bytes" --> ETCD API -- "ConversionReview<br/>objects=[v1], desiredAPIVersion=example.com/v1beta1" --> WH WH -- "convertedObjects=[v1beta1]" --> API API -- "v1beta1 object" --> C1 API -- "ConversionReview desiredAPIVersion=v2" --> WH WH -- "convertedObjects=[v2]" --> API API -- "v2 object" --> C2
What this diagram shows. Both clients read the same object, stored once in etcd as the storage version (v1 here). The apiserver, not the client, drives conversion: it reads the raw stored bytes, sends them to the webhook in a ConversionReview tagged with the desiredAPIVersion, and returns the webhook’s output. The insight to extract: the storage version is an implementation detail invisible to clients — a client that only ever uses v1beta1 never knows the object is stored as v1. The webhook is the only place version-specific transformation logic lives; the apiserver itself knows nothing about your field semantics. This is also why the webhook is on the read path of every request for non-storage versions — it must be fast and highly available.
Mechanical Walk-through
Why conversion is needed at all
A CRD evolves. Version v1beta1 had a field cronSpec; the team renamed it to schedule in v1. Three facts are now simultaneously true: (1) objects created months ago are stored in etcd in whatever was the storage version then; (2) a controller written against v1 expects schedule; (3) a legacy script still POSTs v1beta1 objects with cronSpec. Without conversion, the apiserver would have to either reject one of these or silently lose data. Conversion makes all three consistent: every read and write passes through a function that losslessly round-trips the object between the requested version and the storage version.
The “lossless” requirement is the hard part. If v1 adds a field that v1beta1 lacks, converting v1 → v1beta1 → v1 must not drop it. The canonical trick: when down-converting, stash the unrepresentable data in an annotation so the up-conversion can restore it. The Kubebuilder multiversion tutorial demonstrates exactly this pattern. Concretely, suppose v1 adds spec.timeoutSeconds that v1beta1 cannot express. The v1 → v1beta1 direction writes metadata.annotations["example.com/timeoutSeconds"] = strconv.Itoa(src.Spec.TimeoutSeconds) and leaves the field out of the projected v1beta1 object; the v1beta1 → v1 direction reads that annotation back, parses it, and deletes it from the converted object. A client at v1beta1 sees the annotation (which is fine — annotations are free-form), and a client at v1 sees the typed field round-tripped intact. The pattern is mechanical, but it is also the only place where data-loss bugs originate, so every spoke’s ConvertTo and ConvertFrom should have round-trip unit tests that prove v1 == FromHub(ToHub(v1)) for a representative corpus.
When conversion actually fires
Conversion is not invoked on every request — only on those where the wire form needs to be in a version different from the stored form. The precise rules (apiextensions-apiserver conversion code):
- On a
GET/LIST/WATCHat versionV, the apiserver reads the raw bytes from etcd, parses them as the storage versionS, and ifV ≠ Scalls the conversion webhook withdesiredAPIVersion = V. IfV = S, no webhook call. - On a
CREATE/UPDATE/PATCHposted at versionV, the apiserver first runs admission against the object at versionV, then converts toSbefore writing to etcd. IfV ≠ S, that initial conversion is a webhook call; the response path may then need another conversion back toVfor the client. - On a strategic-merge or JSON
PATCHagainst versionV, the apiserver may need to convert the stored object toV, apply the patch, then convert back toSto write — potentially two webhook calls per patch request, in opposite directions. - Webhooks for the same
ConversionRevieware batched per LIST page: aLISTreturning 500 stored objects produces one webhook call carrying 500 entries inrequest.objects, not 500 separate calls. Watch events, by contrast, convert one object at a time.
The practical takeaway: any time served includes versions other than the storage version, the conversion webhook sits on the critical path of nearly every operation against that resource — including the operator’s own reconcile reads. The simplest way to eliminate that risk during a transition is to bump the storage version to the new served version and run a storage-version migration as soon as feasible, after which the now-only-served v1beta1 (kept for legacy clients) is the cheap direction and the new version is webhook-free.
The two strategies
strategy: None — the apiserver does the conversion itself, and that conversion is only relabeling the apiVersion string. This is correct only if every served version has a structurally identical schema (no field renamed, no type changed, no field added or removed in a way that matters). It is the right choice for a “version bump with no schema change” — e.g. promoting v1beta1 to v1 verbatim. Pick None whenever you can; it has no extra moving parts.
strategy: Webhook — the apiserver calls an external HTTPS service to perform conversion. Required the moment any two served versions differ structurally. The webhook is configured under spec.conversion.webhook.
The ConversionReview exchange
When conversion is needed, the apiserver POSTs a ConversionReview object (apiVersion: apiextensions.k8s.io/v1) to the webhook. The request carries:
request.uid— a unique identifier for this call (the webhook must echo it).request.desiredAPIVersion— the target version, e.g.example.com/v1.request.objects— the list of objects to convert, each in its current version.
The webhook converts every object and returns a ConversionReview whose response carries:
response.uid— the echoed request UID.response.convertedObjects— the objects, now indesiredAPIVersion, in the same order asrequest.objects.response.result— ametav1.Statuswithstatus: Successorstatus: Failed(plus amessageon failure).
A single ConversionReview may contain many objects — a LIST of 500 stored objects at a non-storage version produces one webhook call with 500 entries, not 500 calls.
The hub-and-spoke pattern
With N versions, naïvely you would need N×(N−1) conversion functions (every pair, both directions). The hub-and-spoke pattern collapses this to 2×(N−1): designate one version as the hub (conventionally the storage version), and write only spoke ↔ hub conversions. To convert v1beta1 → v2, the webhook runs v1beta1 → hub → v2. controller-runtime’s pkg/conversion package formalizes this with two interfaces. The hub type implements Hub — a marker interface whose only requirement is a single Hub() method with no parameters or return value, distinguishing it from any other runtime.Object. Each spoke type implements Convertible:
type Convertible interface {
runtime.Object
ConvertTo(dst Hub) error // me → hub
ConvertFrom(src Hub) error // hub → me
}You write exactly two methods per spoke; the framework’s webhook handler in pkg/webhook/conversion decodes each ConversionReview object, looks up which type is the hub, and composes the two-step path automatically. The hard work — the field-by-field mapping inside ConvertTo/ConvertFrom — remains entirely yours.
Configuration / API Surface
A CRD with two structurally different versions and a webhook converter:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: crontabs.example.com
spec:
group: example.com
scope: Namespaced
names:
plural: crontabs
singular: crontab
kind: CronTab
versions:
- name: v1beta1
served: true # clients may use this version
storage: false # NOT the storage version
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
cronSpec: { type: string } # old field name
- name: v1
served: true
storage: true # exactly ONE version has storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
schedule: { type: string } # renamed from cronSpec
conversion:
strategy: Webhook # None would be illegal — schemas differ
webhook:
clientConfig:
service: # in-cluster service reference
namespace: default
name: conversion-webhook
port: 443
path: /convert # optional; defaults to "/"
caBundle: LS0tLS1CRUdJTi... # base64 CA cert to verify the TLS endpoint
conversionReviewVersions: # ConversionReview API versions the webhook accepts
- v1
- v1beta1Line-by-line, the load-bearing points:
served— whether clients may use this version at all. A version can beserved: false(kept only so old etcd data can still be decoded) while no longer accepting traffic.storage: true— exactly one version must set this. It is the version written to etcd. Changing it requires migrating existing objects (see Failure Modes).conversion.strategy—Noneis only correct when all served schemas are structurally identical for the fields the user touches. The apiserver does not validate that the schemas agree before accepting the CRD: it simply changes theapiVersionstring and prunes unknown fields per the destination version’s schema (CRD Versioning — None strategy). If you markstrategy: Nonewhile versions actually differ, the documented consequence is data corruption (“this is unlikely to lead to good results if the schemas differ between the storage and requested version”), not a refusal at admission. The correctness burden is entirely on the CRD author.webhook.clientConfig— either an in-clusterservicereference (apiserver buildshttps://conversion-webhook.default.svc:443/convert) or a rawurl. ThecaBundlelets the apiserver verify the webhook’s TLS certificate; conversion webhooks are always HTTPS.conversionReviewVersions— the apiserver picks the first version in this list that it also supports. Listv1first;v1beta1is legacy.- No configurable timeout. Unlike
ValidatingWebhookConfigurationandMutatingWebhookConfiguration, which carry a per-webhooktimeoutSecondsfield, theWebhookConversionstruct inapiextensions.k8s.io/v1has only two fields:clientConfigandconversionReviewVersions— notimeoutSeconds(apiextensions-apiserver types.go). The API server calls every conversion webhook with a hard-coded 30-second timeout (kubernetes/kubernetes#91338 — closed without resolution); this is observable in API-server logs as?timeout=30sappended to the webhook URL. The rationale is that conversion blocks the request and has nofailurePolicy: Ignoreescape (every served-but-not-stored read must convert), so allowing operators to set arbitrarily short timeouts would be a foot-gun. Operationally: assume 30s is the budget you have, and build the webhook to respond well under that — most real conversion calls complete in single-digit milliseconds.
Failure Modes
- Webhook unavailable. Because conversion is on the read path, an unreachable conversion webhook makes every request for a non-storage version fail — including the controller’s own reconcile reads. Symptom:
conversion webhook ... failederrors onkubectl get, operator reconcile stalls. The conversion webhook must be as available as the apiserver itself; run multiple replicas, and preferstrategy: Nonewhenever the schemas permit it. - Lossy conversion. Converting
v2 → v1beta1 → v2drops a field thatv1beta1cannot represent. Symptom: an object silently loses data after a round-trip. Fix: stash unrepresentable fields in annotations during down-conversion and restore them on up-conversion (the Kubebuilder tutorial pattern). - Stale stored objects after a storage-version bump. Flipping
storage: truefromv1tov2does not rewrite existing etcd objects — they remain encoded inv1and are converted on every read. The CRD’sstatus.storedVersionsfield lists every encoding still present in etcd; you cannot retirev1(setserved: falseand drop it fromspec.versions) until that list contains onlyv2. The fix is to force re-persistence: either the long-standing out-of-clusterkube-storage-version-migrator, the in-cluster Storage Version Migration API (storagemigration.k8s.io/v1beta1, available since Kubernetes 1.30 with the feature gate enabled), or a manualkubectl get widgets -A -o yaml | kubectl replace -f -sweep that touches every object. caBundlemismatch. If the webhook’s serving certificate is not signed by the CA incaBundle, the apiserver refuses the TLS handshake. Symptom:x509: certificate signed by unknown authority. cert-manager’s CA-injector commonly automates keepingcaBundlein sync.- UID or ordering mismatch. A webhook that fails to echo
request.uid, or returnsconvertedObjectsin a different order thanrequest.objects, produces apiserver errors. The contract is strict: same UID, same order, same count.
Alternatives and When to Choose Them
strategy: None— not a different tool, the other strategy. Always prefer it when every served version is structurally identical; it removes an entire failure surface (no webhook to deploy, secure, and keep available). Most “we just want to call our APIv1now” version bumps need onlyNone.- A single, never-versioned CRD — if the resource’s schema is genuinely stable, do not introduce multiple versions at all. Versioning is a cost; pay it only when the API actually evolves.
- In-process conversion — there is no in-process CRD conversion analogous to ValidatingAdmissionPolicy for admission. CRD conversion is either
Noneor an out-of-process webhook; CEL-based or otherwise in-process conversion of CRDs does not exist in upstream Kubernetes as of v1.36 (May 2026). This is a meaningful availability gap: a CEL-based in-process equivalent would remove the conversion webhook from the hot path the same wayValidatingAdmissionPolicyremoved many validating webhooks. - Built-in API conversion — core Kubernetes types (
apps/v1,batch/v1, …) convert between versions inside the apiserver via compiled-in Go conversion functions. Conversion webhooks are the CRD-author’s equivalent of that machinery, externalized because the apiserver cannot compile in code it has never seen.
Production Notes
- cert-manager and the Prometheus Operator ship multi-version CRDs and rely on conversion webhooks (or careful
None-compatible evolution) in production; their operator binaries serve the conversion endpoint from the same process that runs the controllers, via controller-runtime’s webhook server. - controller-runtime hosts the conversion webhook in the same
Managerthat runs the operator’s controllers (see controller-runtime §webhook scaffolding). One binary, one HTTPS server, both admission and conversion webhooks — so the conversion endpoint shares the operator’s availability story. - Kubebuilder’s multiversion tutorial is the canonical worked example: it scaffolds a hub type,
ConvertTo/ConvertFrommethods on each spoke, and the annotation-stashing trick for lossless round-tripping. Anyone adding a second version to a real CRD should follow it rather than improvising. - Plan the storage-version migration before adding a version. The common operational mistake is to add
v2, flip storage tov2, and assume the job is done — leaving old objects converted on every read indefinitely and blocking the eventual removal ofv1. Run the storage-version migrator as part of the rollout. - Keep old versions
served: truelong after deprecation. Removing a served version breaks every client still using it. The deprecation should be announced, the client base migrated, and only then the version dropped — exactly the discipline core Kubernetes follows with its own API deprecation policy (see Kubernetes API Groups and Versions).
See Also
- Custom Resource Definition — the resource type whose
spec.conversionandspec.versionsthis note details - Kubernetes API Groups and Versions — the alpha/beta/stable maturation and deprecation policy that motivates versioning
- controller-runtime — hosts the conversion webhook server; provides the
conversion.Hub/Convertibleinterfaces - Kubebuilder — its multiversion tutorial is the canonical hub-and-spoke worked example
- etcd — stores every object in exactly one storage version
- kube-apiserver — drives conversion; calls the webhook on the request path
- MutatingAdmissionWebhook — a sibling webhook type, on the admission path rather than the conversion path
- Reconcile Function Patterns — operator controllers read converted objects every reconcile
- Kubernetes MOC §13 — Extensibility