Most teams stop at CustomResourceDefinitions, and for good reason: a CRD plus a controller covers the overwhelming majority of operator use cases. But there is a second extension mechanism — the aggregation layer — that serves API groups from your own binary, with your own storage, validation, and admission. Knowing which to reach for, and how to evolve either one across versions without breaking stored objects, is the line between a platform team that ships an API once and one that maintains it for years.
This guide covers the hard parts: CRD multi-version lifecycle, writing a conversion webhook that round-trips between v1alpha1 and v1, structural schemas and the status subresource, and standing up a real aggregated API server behind the aggregation layer. Throughout, the failure mode to keep in mind is data corruption during an upgrade — etcd holds objects in exactly one encoding, and getting versioning wrong is silent until a get fails to decode.
In a nutshell
Level: Expert · Time: ~33 min
Kubernetes ships with a fixed menu of object kinds — Pods, Deployments, Services. Sooner or later you want your own kind: a Widget, a Tenant, a Fleet. There are exactly two ways to teach the cluster a new kind, and picking the right one is most of this lesson.
Way 1 — a CustomResourceDefinition (CRD). Think of renting a furnished room inside the API server’s own building. You hand the landlord (the kube-apiserver) a description of your furniture — a schema — and from then on it stores your objects in the shared basement (etcd), checks them against your schema, hands out keys, and answers the door. You write zero server code. This is the easy, declarative path, and it is the right answer the overwhelming majority of the time.
Way 2 — an aggregated (extension) API server. Think of building your own annex next door and running a private phone line into the main lobby. Visitors still walk in through the same front door (the kube-apiserver), but the receptionist forwards calls for your rooms to your annex, where you run your own filing system, your own storage, your own logic. Far more work — you build, deploy, and secure a real server — but you control everything, including not using etcd at all. This is how metrics-server serves live CPU/memory numbers that are computed on the fly and never stored.
Conversion webhooks are the third idea, and they apply only to the CRD path. Say version 1 of your object was written in one “language” (v1alpha1) and version 2 in another (v1). The building stores every object in exactly one language in the basement — the storage version. When an old client asks for its object in the old language, a translator at the front desk (the conversion webhook) converts it on the way out. The catch: that translator must translate perfectly in both directions, or information is lost the first time somebody upgrades — and because etcd holds each object in only one encoding, a bad translation corrupts data silently until a later read fails.
One sentence to carry through the lesson: a CRD lets the apiserver store and serve your kind for you; an aggregated API server is your own server behind the same front door; and conversion webhooks let a CRD’s kind evolve across versions without ever breaking what is already stored.
This is an expert lesson that assumes you have already met CRDs and controllers. If “reconcile loop” or “CustomResourceDefinition” is new, start with CRDs, Operators & the Controller Pattern; conversion webhooks are a close cousin of the admission webhooks covered in Admission Control. After this lesson you will be able to:
- Decide, against a concrete requirement rather than by habit, whether to reach for a CRD or an aggregated API server.
- Explain
servedvsstorageversions and why there is always exactly one storage version. - Ship a multi-version CRD with a conversion webhook that round-trips losslessly, gated in CI.
- Register and troubleshoot an extension API server behind the aggregation layer.
- Migrate stored objects across a storage-version change without corrupting etcd.
1. CRDs vs aggregated API servers: choosing the mechanism
Both mechanisms add new REST paths under /apis/<group>/<version>. The difference is who serves them.
A CRD is declarative. You kubectl apply a CustomResourceDefinition, and the kube-apiserver itself stores your objects in its own etcd, validates them against your OpenAPI v3 schema, and serves CRUD + watch. You write no API server code.
An aggregated API server (an “extension API server”) is a separate binary you build, deploy as a Deployment + Service, and register via an APIService object. The kube-apiserver proxies matching requests to it over TLS. You own storage, validation, admission, and the conversion of every request.
| Concern | CRD | Aggregated API server |
|---|---|---|
| Code to write | None (schema only) | A full apiserver binary (Go) |
| Storage | kube-apiserver’s etcd | Your choice: etcd, a DB, or computed |
| Custom business logic on read/write | Webhooks only | Arbitrary, in-process |
| Non-etcd backing (proxy to external system) | No | Yes |
| Custom subresources beyond status/scale | No | Yes (e.g. /exec, /logs, arbitrary verbs) |
| Operational cost | Trivial | You run an HA, certificate-managed service |
| Protobuf serialization | No (JSON only) | Yes |
Rule of thumb: reach for a CRD unless you have a concrete requirement a CRD cannot meet — a non-etcd backing store, computed/virtual resources, custom subresources or verbs, response sizes that demand protobuf, or imperative semantics. Metrics-server and
apiregistration.k8s.io’s ownmetrics.k8s.ioare the canonical aggregated APIs precisely because the data is computed, not stored.
The two mechanisms are not mutually exclusive. A common pattern is CRDs for declarative config plus a tiny aggregated API for a virtual subresource (think a custom /scale-like endpoint or a token-minting verb).
The whole decision, and the machinery behind each answer, fits in one picture:
Read it left to right. Every request enters the one front door, the kube-apiserver, which routes by API group. Path A (a CRD) stays inside the apiserver: your objects are validated, stored in the apiserver’s own etcd, and served back — and when you run more than one version, a conversion webhook translates between them while etcd keeps exactly one storage version. Path B (the aggregation layer) leaves the apiserver: the built-in kube-aggregator proxies the request to your extension API server, which does its own storage (etcd, a database, or nothing at all — computed on the fly). The badges mark the five places this either forces a decision or breaks silently; we return to each below.
A one-sentence test for the choice: if your data can live in etcd as plain JSON objects and all you need is CRUD + watch + validation, a CRD is enough; the moment you need storage that is not etcd, a subresource or verb a CRD cannot express, protobuf on the wire, or genuinely imperative behaviour, you have crossed into aggregated-API-server territory.
2. CRD versioning: storage version, served versions, lifecycle
A CRD declares a list of versions. Each version has two independent booleans that people constantly conflate:
served: true— clients may read/write this version at its path.storage: true— objects are persisted in etcd in this version’s schema. Exactly one version may be the storage version.
The distinction trips up almost everyone the first time, so make it concrete. served is about the front door: can a client read or write this version at its URL path right now? storage is about the basement: which single version’s schema is used to actually persist the bytes in etcd? The two are orthogonal, and there is always exactly one storage version, because a stored object has to be written in some single encoding.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: widgets.platform.acme.io
spec:
group: platform.acme.io
scope: Namespaced
names:
plural: widgets
singular: widget
kind: Widget
listKind: WidgetList
versions:
- name: v1alpha1
served: true
storage: false # still served for old clients, no longer stored
schema:
openAPIV3Schema: { ... }
- name: v1
served: true
storage: true # everything new is persisted as v1
schema:
openAPIV3Schema: { ... }
subresources:
status: {}
The four combinations of the two flags, and when you actually use each:
served |
storage |
What it means | Typical use |
|---|---|---|---|
true |
true |
Clients use it and objects persist in its schema | The current version |
true |
false |
Clients still read/write it, but objects persist as another version (conversion bridges them) | An old version kept for compatibility, or a new version before the flip |
false |
false |
Unreachable and not the storage encoding | A retired version, kept only until status.storedVersions drains, then removed |
false |
true |
Legal but unusual: the on-disk encoding is a version no client can reach | Avoid in steady state — keep the storage version served |
The mental model: a client may GET a v1alpha1/widgets/foo even though it is stored as v1. The apiserver decodes the stored v1 object and converts it to v1alpha1 to satisfy the request. With more than one served version whose schemas differ, that conversion is where a webhook becomes mandatory.
The upgrade lifecycle for adding v1 to an existing v1alpha1 CRD is strict:
- Add
v1asserved: true, storage: false; keepv1alpha1as the storage version. Ship the conversion webhook (Section 3) in the same change. - Roll out clients that understand
v1. - Flip
storage: truetov1(andfalseonv1alpha1). New writes now persist asv1. - Migrate stored objects so nothing remains physically encoded as
v1alpha1(Section 8). - Only after migration completes: drop
served: falseonv1alpha1, then remove it from theversionslist entirely.
Skipping step 4 is the classic foot-gun: you can never remove v1alpha1 from the CRD while a single object in etcd is still stored in it, because removing the version removes the schema needed to decode it.
3. Writing a conversion webhook (v1alpha1 <-> v1)
A conversion webhook is the translator from the In a nutshell analogy, made real: a small HTTPS server the apiserver calls whenever it must present a stored object in a different version than the one on disk. You need it only when two or more served versions have different schemas — if every served version is byte-identical (you renamed a version without changing its shape), the built-in None strategy is enough (see Going deeper).
When served versions diverge, set spec.conversion.strategy: Webhook. The apiserver POSTs a ConversionReview to your endpoint whenever it must translate between versions — on reads, on storage-version writes, and during migration.
spec:
conversion:
strategy: Webhook
webhook:
conversionReviewVersions: ["v1"]
clientConfig:
service:
namespace: widget-system
name: widget-conversion-webhook
path: /convert
port: 443
caBundle: <base64 PEM of the serving CA>
The contract: the request carries a list of objects all in some desiredAPIVersion, and you return the same list converted into the requested target version, preserving metadata and crucially the annotation/label data you cannot lose. Conversion must be lossless and round-trippable — a classic approach is to stash fields with no home in the target version inside an annotation so the reverse conversion can restore them.
Here is the core handler in Go. Note the uid must be echoed and the response APIVersion must match the request.
func handleConvert(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
review := &apix.ConversionReview{}
if _, _, err := codecs.UniversalDeserializer().Decode(body, nil, review); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req := review.Request
resp := &apix.ConversionResponse{UID: req.UID}
for _, raw := range req.Objects {
cr := &unstructured.Unstructured{}
if err := cr.UnmarshalJSON(raw.Raw); err != nil {
resp.Result = metav1.Status{Status: metav1.StatusFailure, Message: err.Error()}
break
}
if err := convert(cr, req.DesiredAPIVersion); err != nil {
resp.Result = metav1.Status{Status: metav1.StatusFailure, Message: err.Error()}
break
}
out, _ := cr.MarshalJSON()
resp.ConvertedObjects = append(resp.ConvertedObjects, runtime.RawExtension{Raw: out})
}
if resp.Result.Status == "" {
resp.Result = metav1.Status{Status: metav1.StatusSuccess}
}
review.Response = resp
review.Request = nil
writeJSON(w, review)
}
The convert function does the field surgery. Suppose v1alpha1 had a single spec.size string (“small”/“large”) and v1 replaced it with a structured spec.resources.replicas integer:
func convert(cr *unstructured.Unstructured, target string) error {
switch target {
case "platform.acme.io/v1":
size, _, _ := unstructured.NestedString(cr.Object, "spec", "size")
replicas := map[string]int64{"small": 1, "large": 5}[size]
unstructured.RemoveNestedField(cr.Object, "spec", "size")
_ = unstructured.SetNestedField(cr.Object, replicas, "spec", "resources", "replicas")
case "platform.acme.io/v1alpha1":
replicas, _, _ := unstructured.NestedInt64(cr.Object, "spec", "resources", "replicas")
size := "small"
if replicas >= 5 { size = "large" }
unstructured.RemoveNestedField(cr.Object, "spec", "resources")
_ = unstructured.SetNestedField(cr.Object, size, "spec", "size")
}
cr.SetAPIVersion(target)
return nil
}
Two hard rules: never mutate anything outside spec/status and your own fields, and make the conversion total — every object the apiserver hands you must convert or the whole batch fails. Conversion runs on the hot path of every cross-version read, so keep it allocation-light and never call back into the apiserver.
It helps to know exactly when the apiserver invokes your webhook, because “it only runs on writes” is a common and costly misconception:
| Trigger | Direction | Why |
|---|---|---|
| A client GETs/LISTs a version ≠ the storage version | storage → requested | Present stored bytes in the version asked for |
| A client writes (POST/PUT) a version ≠ the storage version | requested → storage | Persist in the single storage encoding |
| The storage-version migrator rewrites objects | storage(old) → storage(new) | Re-encode etcd during a storage-version flip |
kubectl get --raw at an explicit version path |
storage → that version | Same as a read at that version |
Notice conversion fires on reads, not just writes — which is exactly why a webhook that is down or lossy breaks get, not just apply.
4. Structural schemas, validation, defaulting, status
Since apiextensions.k8s.io/v1, every CRD schema must be structural: each level specifies its type, no bare additionalProperties: true at the root, and value-validations (like oneOf) only inside properties/items. Structural schemas are the precondition for server-side defaulting, pruning, and the conversion machinery. In plain terms, structural means the schema is shaped like the objects it describes — every field has a declared type and lives somewhere the apiserver can find it — which is what lets the server safely default, prune, and convert without guessing. Non-structural schemas were allowed under the old apiextensions.k8s.io/v1beta1 (removed in 1.22) and are the reason so many pre-1.16 CRDs could not adopt these features.
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: ["resources"]
properties:
resources:
type: object
properties:
replicas:
type: integer
minimum: 1
maximum: 50
default: 3 # server-side defaulting
tier:
type: string
enum: ["bronze", "silver", "gold"]
default: "bronze"
status:
type: object
properties:
readyReplicas: { type: integer }
x-kubernetes-preserve-unknown-fields: false
required: ["spec"]
Pruning is automatic with structural schemas: any field a client sends that is not in the schema is dropped, not stored. If you genuinely need to keep arbitrary keys (rare), opt in with x-kubernetes-preserve-unknown-fields: true on that node only.
Declaring the status subresource changes semantics: writes to /widgets/foo ignore the status stanza, and status is only mutated via /widgets/foo/status. This is what lets your controller update status without fighting the user’s spec edits, and it bumps metadata.generation only on spec changes — the signal your reconcile loop watches.
For validation beyond OpenAPI, prefer CEL validation rules (x-kubernetes-validations, GA since 1.29) over a validating webhook — they run in-process, need no certificates, and survive apiserver restarts:
replicas:
type: integer
x-kubernetes-validations:
- rule: "self <= 10 || oldSelf > 10"
message: "replicas above 10 can only be decreased, not increased"
That oldSelf transition rule enforces an invariant a static schema cannot — exactly the kind of policy that otherwise pushes people toward webhooks unnecessarily.
5. Standing up an extension API server (aggregation layer)
Everything so far kept you inside the kube-apiserver. An aggregated API server steps outside it. Physically, three things cooperate: (1) the aggregation layer (kube-aggregator), a component that lives inside the kube-apiserver and can proxy requests elsewhere; (2) an APIService object that tells the aggregator “requests for this group/version go to that Service”; and (3) your extension API server, an ordinary Deployment behind a Service, serving HTTPS. When a request arrives for a registered group, the kube-apiserver authenticates the user as usual, then opens a second, mutually-authenticated TLS connection to your server and forwards the request — adding headers that tell your server who the original user was.
When a CRD is not enough, you build an aggregated API server. The fastest correct path is to follow the structure of k8s.io/sample-apiserver, which wires k8s.io/apiserver’s GenericAPIServer to your scheme, or to scaffold with apiserver-builder (apiserver-boot), which generates that wiring plus storage and Makefiles.
# apiserver-builder approach
apiserver-boot init repo --domain acme.io
apiserver-boot create group version resource \
--group platform --version v1 --kind Fleet
apiserver-boot build executables
The aggregation layer must be enabled on the cluster (it is by default on managed providers). The kube-apiserver needs these flags so it can authenticate to and trust your extension server:
--requestheader-client-ca-file=/etc/kubernetes/pki/front-proxy-ca.crt
--requestheader-allowed-names=front-proxy-client
--requestheader-extra-headers-prefix=X-Remote-Extra-
--requestheader-group-headers=X-Remote-Group
--requestheader-username-headers=X-Remote-User
--proxy-client-cert-file=/etc/kubernetes/pki/front-proxy-client.crt
--proxy-client-key-file=/etc/kubernetes/pki/front-proxy-client.key
--enable-aggregator-routing=true
Those --requestheader-* and --proxy-client-* flags are the trust plumbing for that second hop. The kube-apiserver presents the proxy-client certificate to your server (so your server knows the caller is genuinely the apiserver), and it stamps the authenticated user’s identity into X-Remote-User/X-Remote-Group headers. Your server trusts those headers only because they arrive over a connection signed by the requestheader-client-ca. This is why your extension server reads the kube-system/extension-apiserver-authentication ConfigMap on startup: that is where the cluster publishes the requestheader CA it should trust. Get this wrong and you either reject the apiserver (every request 401s) or — far worse — trust forged headers from anyone who can reach your pod.
You then register the API group with an APIService. The kube-apiserver proxies /apis/platform.acme.io/v1 to your Service and verifies its serving cert against caBundle:
apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
name: v1.platform.acme.io
spec:
group: platform.acme.io
version: v1
groupPriorityMinimum: 1000
versionPriority: 15
service:
name: fleet-apiserver
namespace: fleet-system
port: 443
caBundle: <base64 PEM> # omit and use cert-manager CA injection
Your extension server, on startup, must do delegated authn/authz: it does not re-implement auth. Using the genericapiserver recommended options, it calls TokenReview and SubjectAccessReview back against the kube-apiserver, and reads the kube-system/extension-apiserver-authentication ConfigMap to learn the request-header CA. This is what makes RBAC on your aggregated resources behave identically to built-in resources.
// In your server's options wiring (sample-apiserver style)
o.RecommendedOptions.Authentication.RemoteKubeConfigFileOptional = true
o.RecommendedOptions.Authorization.RemoteKubeConfigFileOptional = true
serverConfig := genericapiserver.NewRecommendedConfig(codecs)
if err := o.RecommendedOptions.ApplyTo(serverConfig); err != nil {
return err // applies delegated authn, authz, audit, openapi, etcd
}
6. Custom storage, RBAC, and admission for aggregated resources
The aggregation layer’s real power is custom storage. k8s.io/apiserver/pkg/registry/rest defines small interfaces — Getter, Lister, Creater, Updater, GracefulDeleter, Watcher — and whichever you implement determines which verbs your resource supports. Back them with the generic etcd Store for normal cases, or implement them by hand to project an external system as Kubernetes objects (the metrics-server pattern: Get/List only, computed on the fly, no storage).
// Minimal read-only REST storage backed by a live computation
type fleetREST struct{ source ExternalInventory }
func (r *fleetREST) New() runtime.Object { return &v1.Fleet{} }
func (r *fleetREST) NewList() runtime.Object { return &v1.FleetList{} }
func (r *fleetREST) NamespaceScoped() bool { return true }
func (r *fleetREST) Get(ctx context.Context, name string, _ *metav1.GetOptions) (runtime.Object, error) {
return r.source.Lookup(genericapirequest.NamespaceValue(ctx), name)
}
func (r *fleetREST) Destroy() {}
RBAC is automatic and unified: because your server delegates authorization via SubjectAccessReview, a normal Role/ClusterRole granting verbs on your group works without any special handling.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: fleet-reader
rules:
- apiGroups: ["platform.acme.io"]
resources: ["fleets"]
verbs: ["get", "list", "watch"]
Admission inside an aggregated server is in-process: register admission plugins on the GenericAPIServer chain rather than deploying external admission webhooks. You get ValidatingAdmission and MutatingAdmission interfaces and can short-circuit with full type information — no AdmissionReview round trip, no certificate to rotate.
7. Discovery, OpenAPI publishing, and client codegen
For your API to be first-class — kubectl explain fleet, kubectl get fleet, typed clients — three things must publish correctly.
Discovery. The kube-apiserver aggregates your /apis/platform.acme.io/v1 discovery document under the cluster’s discovery. Verify it surfaced:
kubectl get apiservice v1.platform.acme.io
# NAME SERVICE AVAILABLE
# v1.platform.acme.io fleet-system/fleet-apiserver True
kubectl api-resources --api-group=platform.acme.io
AVAILABLE: False with a FailedDiscoveryCheck message almost always means a TLS or networking problem reaching your Service, not a code bug.
OpenAPI. Serve OpenAPI v2 and v3 from the extension server (RecommendedConfig wires the endpoints; you supply generated definitions). This is what powers kubectl explain and server-side apply field management. For CRDs you get OpenAPI v3 for free from the structural schema.
Client codegen. Generate typed clients, listers, and informers with code-generator’s kube_codegen.sh (the modern entry point replacing the per-tool *-gen invocations). Tag your types so the generators know what to produce:
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// +kubebuilder:object:root=true
type Fleet struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec FleetSpec `json:"spec,omitempty"`
Status FleetStatus `json:"status,omitempty"`
}
# vendor code-generator, then:
./hack/update-codegen.sh # wraps kube_codegen.sh: deepcopy, client, lister, informer
For CRDs specifically, controller-gen does the equivalent: controller-gen object crd paths=./... emits deepcopy code and the CRD manifest with embedded schema, so the same Go types feed both your controller and the published API.
8. Rollout, deprecation, and migrating stored objects
The single most dangerous moment is the storage-version flip. After you set v1 as storage, existing objects are still physically encoded as v1alpha1 in etcd until something rewrites them. They are readable (conversion handles it) but they block ever deleting v1alpha1.
Force a rewrite by reading and writing every object back through the new storage version:
# Crude but effective: touch every object so it re-persists as the storage version
kubectl get widgets -A -o name | while read w; do
kubectl annotate "$w" platform.acme.io/migrated="$(date -u +%FT%TZ)" --overwrite
done
For production scale and correctness, use the official storage-version-migrator (kube-storage-version-migrator), which drives the migration declaratively and tracks progress, instead of hand-rolling annotations:
apiVersion: migration.k8s.io/v1alpha1
kind: StorageVersionMigration
metadata:
name: widgets-to-v1
spec:
resource:
group: platform.acme.io
resource: widgets
version: v1
Then track which versions clients still read using the CRD status, and mark old versions deprecated so kubectl warns users before you remove them:
versions:
- name: v1alpha1
served: true
storage: false
deprecated: true
deprecationWarning: "platform.acme.io/v1alpha1 Widget is deprecated; use v1. Removal in operator 2.0."
Only when (a) migration reports complete and (b) status.storedVersions on the CRD no longer lists v1alpha1 should you set it served: false, and in a later release remove it entirely. The apiserver actively prevents you from dropping a version that still appears in storedVersions.
Going deeper
Everything above is enough to design and ship an extension. This section is for the reader who operates the aggregation layer and versioned CRDs at scale, where the internals decide whether an upgrade is a non-event or an outage.
How the aggregation layer actually routes a request
When a request for /apis/platform.acme.io/v1/... reaches the kube-apiserver, the kube-aggregator looks up a matching APIService. If it is a local group (built-ins and all CRDs are served by the apiserver itself), it is handled in-process. If it points at a Service, the aggregator proxies the request. Two facts make that proxy hop different from an ordinary client call:
- The user’s credential is not forwarded. The apiserver authenticates the caller with its own authenticators, then opens a fresh TLS connection to your server using the
proxy-clientcertificate and passes the resolved identity inX-Remote-User/X-Remote-Group/X-Remote-Extra-*headers. Your server’s delegated authenticator verifies that the connection was signed by the requestheader (front-proxy) CA before it trusts a single header — this is the whole security model of the second hop. - Authorization is delegated back. Your server calls
SubjectAccessReviewagainst the kube-apiserver for each request, so RBAC on your resources behaves exactly like RBAC on built-ins — the same least-privilege Role/ClusterRole design applies with no special casing.--enable-aggregator-routing=truelets the apiserver resolve the Service’s individual endpoint IPs rather than relying on the ClusterIP, which matters when kube-proxy is not in the data path.
There is a production nuance most people learn the hard way: an unavailable aggregated APIService degrades the whole cluster’s discovery. The apiserver must fetch each aggregated group’s discovery document to answer kubectl api-resources, kubectl get <anything>, and client-go’s discovery cache. If your extension server is down and its APIService is AVAILABLE: False, every such call waits for a timeout — the classic “metrics-server is broken and now kubectl is slow for everyone” symptom. Register with a sensible groupPriorityMinimum, run the server HA, and delete or disable the APIService if you decommission the server, so a dead endpoint does not tax discovery.
When you genuinely need an aggregated API server
The honest list of triggers, each with the real-world example that justifies the operational cost:
- Storage that is not etcd, or not stored at all.
metrics-serverservesmetrics.k8s.ioby computing CPU/memory live from kubelets — writing those to etcd would be absurd. The same applies to projecting an external inventory, CMDB, or billing system as read-only Kubernetes objects. - Semantics a CRD cannot express. Custom subresources beyond
status/scale, arbitrary verbs (a token-mintingPOST, an/exec-styleCONNECT), or resources that do not map to “one object = one etcd row”. - Wire format. Protobuf for high-throughput or very large
LISTresponses; CRDs are JSON-only. - In-process admission with full type information, avoiding a webhook round trip and its certificate.
Against those benefits, weigh the cost you now own: an HA deployment, serving-cert rotation, tracking the k8s.io/apiserver library version across Kubernetes upgrades, and the discovery blast radius above. This is why the hybrid — a CRD for declarative config plus a tiny aggregated API for the one virtual verb you truly need — is so common: you pay the apiserver cost only for the sliver that requires it.
Conversion strategies: None vs Webhook, and the round-trip law
spec.conversion.strategy has two values, and choosing wrong is a data-integrity decision:
None— the apiserver performs no field transformation; it only relabelsapiVersion. This is correct only when every served version is structurally identical (you bumped the version name without changing the schema). It does not, and cannot, reshape fields.Webhook— mandatory the instant two served versions differ in shape.
The round-trip law governs any webhook: conversion must be lossless and reversible. Formally, for any object o and versions a, b: convert(convert(o, a→b), b→a) == o. Because etcd stores exactly one encoding while clients may read or write any served version, a lossy conversion silently drops data on the first write-then-read at a different version. When the target version has no home for a field, the standard trick is to stash it under an annotation (e.g. platform.acme.io/v1alpha1-size: "large") so the reverse conversion restores the exact original rather than a reconstructed guess.
Two migrator options exist as of the current releases. The established, out-of-tree kube-storage-version-migrator (migration.k8s.io/v1alpha1, shown in Section 8) drives the rewrite and tracks progress. Kubernetes 1.30 also introduced an in-tree storagemigration.k8s.io/v1alpha1 StorageVersionMigration that folds this capability into the apiserver itself; check your cluster version’s docs for its current maturity before depending on it, and until then the out-of-tree tool remains the safe production choice. Either way, the rewrite is what drains status.storedVersions so you can finally remove the old version.
Structural schemas and the x-kubernetes-* vocabulary
Structural schemas unlock a set of Kubernetes-specific OpenAPI extensions worth knowing by name, because each changes server behaviour:
x-kubernetes-preserve-unknown-fields: true— disables pruning on that node, keeping fields the schema does not name. Use surgically; blanket use turns your API into an opaque JSON blob.x-kubernetes-int-or-string: true— accept either an integer or a string at that node (theIntOrStringpattern used by, e.g.,portandtargetPort).x-kubernetes-embedded-resource: true— the node is itself a complete Kubernetes object (hasapiVersion,kind,metadata), as in a template that embeds a Pod spec.x-kubernetes-list-type: atomic | set | mapplusx-kubernetes-list-map-keys— declare merge semantics for server-side apply:atomicreplaces the whole list,settreats it as a unique-scalar set,mapmerges element-by-element on the named key(s). Getting this wrong makes SSA conflicts and patches behave surprisingly.x-kubernetes-validations— the CEL rules from Section 4, including transition rules viaoldSelf. Rules run under a cost budget; the apiserver rejects an expression whose estimated cost is too high, which is a deliberate guard against a validation that could stall admission.
Defaulting has a subtlety operators must internalise: defaults apply on read as well as write, so adding a default to the storage version changes what already-stored objects appear to contain the next time they are fetched. That is usually what you want, but it means a default is not a purely additive, invisible change — it is observable retroactively.
API Priority and Fairness (APF)
Extension APIs make it easy to overload the control plane, so APF (flowcontrol.apiserver.k8s.io/v1, GA and on by default) matters here. APF classifies every inbound request through a FlowSchema into a PriorityLevelConfiguration, isolating concurrency so one noisy client cannot starve the apiserver. This bites extension authors two ways: the requests your controllers make are subject to APF, and a slow aggregated apiserver holds apiserver resources while it proxies, so bounding that matters.
apiVersion: flowcontrol.apiserver.k8s.io/v1
kind: PriorityLevelConfiguration
metadata:
name: platform-controllers
spec:
type: Limited
limited:
nominalConcurrencyShares: 40
limitResponse:
type: Queue
queuing:
queues: 64
queueLengthLimit: 50
handSize: 6
---
apiVersion: flowcontrol.apiserver.k8s.io/v1
kind: FlowSchema
metadata:
name: platform-controllers
spec:
priorityLevelConfiguration:
name: platform-controllers
matchingPrecedence: 900
rules:
- subjects:
- kind: ServiceAccount
serviceAccount:
name: platform-operator
namespace: platform-system
resourceRules:
- apiGroups: ["platform.acme.io"]
resources: ["*"]
verbs: ["*"]
Give a bulk operation — like a storage-version migration sweep over thousands of objects — its own low-share priority level so it yields, and give latency-sensitive controllers a dedicated level with guaranteed shares. Watch the apiserver_flowcontrol_* metrics and the X-Kubernetes-PF-FlowSchema-* response headers to confirm requests land where you intend.
Versioning discipline: never break the storage version
The cardinal rules that keep etcd decodable across years of API evolution:
- Evolve additively. Adding a new optional field to the storage version is safe. Removing, renaming, or retyping a field in the storage version is a data-loss event — do it via a new served version plus conversion, then migrate, never by editing the stored shape in place.
- The storage version is a contract with etcd. Treat its schema as append-compatible; the on-disk bytes of millions of objects depend on it.
status.storedVersionsis the source of truth. It lists every encoding still present; the apiserver refuses to drop a version listed there, because doing so deletes the only decoder for those bytes.- Deprecate loudly. Mark retiring versions
deprecated: truewith adeprecationWarningand a stated removal release, sokubectlwarns users well before the version disappears.
Enterprise scenario
A fintech platform team ran a multi-tenant Tenant CRD across 40 clusters, originally shipped as v1alpha1 with a flat spec.quota string like "cpu=8,mem=32Gi". Two years of adoption meant ~6,000 stored Tenant objects and dozens of teams’ GitOps repos pinned to v1alpha1. They needed a real, validated spec.quota object for v1, but a hard cutover was impossible: flipping the storage version while old v1alpha1 objects sat in etcd, with no conversion path, would have made those objects undecodable the moment they touched v1.
The constraint that bit them: they initially shipped v1 as storage version without a conversion webhook, assuming “the schemas are close enough.” The first reconcile that read an old object failed with a decode error, because the apiserver had no way to turn the stored flat string into the structured v1 shape. Reads of unmigrated tenants started 500-ing in two clusters before they rolled back.
The fix was to follow the lifecycle in order. They shipped a conversion webhook first (with v1alpha1 still storage), proving round-trip correctness in CI by converting a corpus of real objects v1alpha1 -> v1 -> v1alpha1 and asserting equality. Then they flipped storage to v1, ran kube-storage-version-migrator cluster by cluster during change windows, and watched status.storedVersions drain to ["v1"] before touching served. The webhook’s reverse path kept every pinned GitOps repo working untouched throughout. The CI round-trip gate is the part they wish they had built first:
func TestConversionRoundTrips(t *testing.T) {
for _, obj := range loadCorpus(t, "testdata/tenants_v1alpha1") {
orig := obj.DeepCopy()
if err := convert(obj, "platform.acme.io/v1"); err != nil { t.Fatal(err) }
if err := convert(obj, "platform.acme.io/v1alpha1"); err != nil { t.Fatal(err) }
if diff := cmp.Diff(orig.Object, obj.Object); diff != "" {
t.Errorf("lossy conversion:\n%s", diff)
}
}
}
That single test would have caught the lossy assumption before any cluster did.
Verify
Run these to confirm an extension is healthy end to end.
# 1. CRD multi-version: confirm served vs storage and stored encodings
kubectl get crd widgets.platform.acme.io \
-o jsonpath='{range .spec.versions[*]}{.name}{" served="}{.served}{" storage="}{.storage}{"\n"}{end}'
kubectl get crd widgets.platform.acme.io -o jsonpath='{.status.storedVersions}'
# 2. Conversion webhook actually fires: read the same object at both versions
kubectl get widget demo --output=yaml --raw \
/apis/platform.acme.io/v1alpha1/namespaces/default/widgets/demo | grep -E 'apiVersion|size|replicas'
kubectl get widget demo --output=yaml --raw \
/apis/platform.acme.io/v1/namespaces/default/widgets/demo | grep -E 'apiVersion|size|replicas'
# 3. Aggregated API server reachable and registered
kubectl get apiservice v1.platform.acme.io \
-o jsonpath='{.status.conditions[?(@.type=="Available")].status} {.status.conditions[?(@.type=="Available")].message}{"\n"}'
# 4. Discovery, explain, and RBAC all resolve
kubectl api-resources --api-group=platform.acme.io
kubectl explain fleet.spec
kubectl auth can-i list fleets.platform.acme.io --as=system:serviceaccount:default:reader
# 5. Defaulting and pruning behave (apply minimal object, read back)
kubectl apply -f - <<'EOF'
apiVersion: platform.acme.io/v1
kind: Widget
metadata: { name: defaults-check }
spec: { resources: {} }
EOF
kubectl get widget defaults-check -o jsonpath='{.spec.resources.replicas} {.spec.tier}{"\n"}' # expect: 3 bronze
A correct setup shows distinct fields per version in step 2 (proving conversion ran), Available: True in step 3, populated discovery in step 4, and defaulted values in step 5.
Common beginner mistakes
These are misconceptions, not symptoms — the wrong mental model that produces a whole class of bugs. Each pairs the belief with the correction.
-
“A CRD and an aggregated API server are basically the same thing.” They could not be more different: a CRD is data you hand the apiserver (a schema); an aggregated API server is a program you write, run, and secure. Confusing them leads people either to write a Go apiserver when a 30-line CRD would do, or to expect a CRD to do non-etcd storage. The right model: CRD = schema the apiserver serves for you; aggregated = your own server behind the front door.
-
“My conversion webhook works — I read an old object as
v1alpha1and it looked right.” One direction proves nothing. If the reverse mapping loses a field, the first client that writesv1and later readsv1alpha1(or vice versa) corrupts data. The right model: assertconvert ∘ convert == identityover a corpus of real objects, in both directions, in CI, before shipping. -
“I’ll just flip the storage version to
v1; the schemas are close enough.” This is the exact fintech foot-gun above. Without a webhook shipped first (while the old version is still storage), the first read of a still-v1alpha1-encoded object fails to decode. The right model: webhook first → prove round-trip → flip storage → migrate → then and only then drop the old version. -
“I removed
v1alpha1from the CRD to tidy up.” If any object is still stored asv1alpha1(checkstatus.storedVersions), you just deleted the only schema that can decode those bytes. The right model: migrate untilstatus.storedVersions == ["v1"], then remove the version — the apiserver enforces this, but people override it with--forceand regret it. -
“My extension apiserver handles its own authentication.” It should not. It delegates to the kube-apiserver via
TokenReview/SubjectAccessReviewand trusts the requestheader CA. Rolling your own means RBAC no longer applies to your resources and you very likely trust forgedX-Remote-Userheaders from anyone who can reach the pod. The right model: use theRecommendedOptionsdelegated authn/authz wiring. -
“
AVAILABLE: Falsemeans my code is broken.” Almost always it is TLS or networking to your Service — acaBundlethat does not match the serving cert’s CA, a cert missing the<svc>.<ns>.svcSAN, aNetworkPolicy, or a wrong port. The right model: verify the connection before you suspect the code, and remember a down APIService also slows discovery cluster-wide. -
“I’ll store arbitrary JSON with
x-kubernetes-preserve-unknown-fieldseverywhere.” Then you forfeit pruning, defaulting, and predictable conversion — you have built a JSON blob, not an API. The right model: model your fields, and reach forpreserve-unknown-fieldsonly on the specific node that genuinely needs open-ended data. -
“A non-structural (typeless) CRD schema is fine.” Non-structural schemas cannot default, cannot prune, cannot use CEL validation, and block the conversion features. The right model: every schema structural, every field typed — it is the precondition for everything else in this lesson.
Practice challenges
Work these top to bottom; they escalate from “pick the mechanism” to “protect the control plane”. Each solution is one click away — try first, then check. Where a live cluster is handy you can run the commands for real; where none is, the answers are still schema-correct.
Challenge 1 (Beginner) — CRD or aggregated? For each requirement, choose the mechanism and say why: (a) a Backup object teams create/list/watch, stored normally; (b) a NodeMetrics-style API returning live CPU/memory computed on demand; © a Certificate kind with a custom /approve action that is not create/update; (d) a high-throughput API returning 50k-item lists to many controllers that need protobuf.
<details> <summary>Solution</summary>
(a) CRD — declarative, etcd-backed CRUD + watch is precisely what a CRD is for. (b) Aggregated — computed, non-etcd data is the metrics-server pattern. © Aggregated — a custom verb beyond status/scale is impossible with a CRD. (d) Aggregated — protobuf and large lists are CRD non-starters (JSON only). Why: only (a) fits “objects live in etcd as JSON with standard verbs”; the moment any of storage, verbs, or wire format leaves that envelope, you need an aggregated server.
</details>
Challenge 2 (Beginner) — Read the flags. Given v1alpha1 (served: true, storage: false) and v1 (served: true, storage: true): where is a newly created object stored? Can an old client still GET at v1alpha1? What must exist for that GET to work if the schemas differ?
<details> <summary>Solution</summary>
Stored as v1 (the storage version). Yes, v1alpha1 is still served, so old clients keep working. For the GET to succeed when schemas differ, a conversion webhook (strategy: Webhook) must translate the stored v1 object down to v1alpha1 on read. Why: served and storage are independent — conversion is what bridges the gap between them.
</details>
Challenge 3 (Intermediate) — Spot the unsafe flip. A PR sets v1 storage: true, v1alpha1 storage: false, removes v1alpha1 from versions entirely, and ships no webhook. There are 6,000 v1alpha1 objects in etcd. List everything wrong and give the correct order of operations.
<details> <summary>Solution</summary>
Three faults: (1) schemas differ but there is no conversion webhook, so reads of old objects fail to decode; (2) removing v1alpha1 deletes the only decoder while objects are still stored in it; (3) the storage flip before migration leaves 6,000 objects encoded as v1alpha1 that now cannot be read as v1. Correct order: ship the webhook (with v1alpha1 still storage) → prove round-trip in CI → flip storage to v1 → run the storage-version-migrator until status.storedVersions == ["v1"] → set v1alpha1 served: false → in a later release remove it. Why: both the storage version and the versions list are contracts with etcd’s on-disk bytes.
</details>
Challenge 4 (Intermediate) — Make the conversion lossless. v1alpha1 has spec.size (“small”/“large”); v1 has spec.resources.replicas (int). A team maps small→1, large→5, and reverses replicas>=5→large else small. Why is this lossy, and how do you fix it losslessly?
<details> <summary>Solution</summary>
It is lossy because the reverse mapping is not injective: replicas: 3 (a perfectly valid v1 value) reverses to "small", which forward-maps back to 1, so v1 → v1alpha1 → v1 silently turns 3 into 1. Fix: when converting v1 → v1alpha1, stash the exact original in an annotation (e.g. platform.acme.io/v1-replicas: "3"); when converting v1alpha1 → v1, if that annotation is present use it, otherwise fall back to the size mapping. Now the round-trip is exact. Why: a non-injective mapping must carry the lost information in a side channel or it cannot be reversed.
</details>
Challenge 5 (Advanced) — Diagnose AVAILABLE: False. kubectl get apiservice v1.platform.acme.io shows AVAILABLE False with FailedDiscoveryCheck. Your Deployment is healthy and pod logs show it serving on :443. Give an ordered troubleshooting checklist.
<details> <summary>Solution</summary>
Cheapest first: (1) Endpoints — does the Service have Ready endpoints? kubectl get endpoints fleet-apiserver -n fleet-system. (2) caBundle — does it match the CA that signed the serving cert? A mismatched or expired CA is the #1 cause; prefer cert-manager CA injection. (3) Serving-cert SAN — must cover fleet-apiserver.fleet-system.svc. (4) NetworkPolicy — is apiserver → pod:443 allowed? (5) Delegated auth — can the server read kube-system/extension-apiserver-authentication (RBAC), and is RemoteKubeConfigFileOptional set? (6) Only then suspect the code. Why: FailedDiscoveryCheck is the apiserver failing to fetch /apis/... over TLS — a transport problem far more often than a logic one. Bonus: while it is False, cluster-wide discovery is degraded and kubectl is slow for everyone.
</details>
Challenge 6 (Advanced) — Protect reconciliation with APF. During a storage-version migration sweep, normal controllers start getting 429s and reconcile lag spikes. Explain the cause and fix it with API Priority and Fairness.
<details> <summary>Solution</summary>
The migration rewrites thousands of objects, flooding a shared priority level; APF throttles everyone in that level, including latency-sensitive controllers. Fix: give the migration sweep its own low-share PriorityLevelConfiguration (via a FlowSchema matching the migrator’s identity) so it yields under contention, and/or give critical controllers a dedicated higher-share level with guaranteed concurrency. Confirm with the apiserver_flowcontrol_* metrics and the X-Kubernetes-PF-FlowSchema-* response headers. Why: APF isolates concurrency by flow, so a bulk client cannot starve steady-state reconciliation once they are in separate levels.
</details>
Checklist
Glossary
- CustomResourceDefinition (CRD) — a declarative object that teaches the kube-apiserver a new kind; the apiserver stores and serves it from its own etcd, no server code required.
- Aggregated / extension API server — a separate HTTPS server you build and run that serves an API group behind the kube-apiserver, registered via an
APIService. - Aggregation layer (
kube-aggregator) — the component inside the kube-apiserver that proxies requests for registered API groups out to extension API servers. APIService— the object (apiregistration.k8s.io/v1) that tells the aggregation layer which Service serves a given group/version, and holds thecaBundleused to trust it.- Served version — a CRD version clients may read/write at its URL path (
served: true). - Storage version — the single CRD version whose schema is used to persist objects in etcd (
storage: true); there is always exactly one. status.storedVersions— the CRD status list of every version objects are still physically stored as; the apiserver refuses to remove a version listed here.- Conversion webhook — an HTTPS endpoint the apiserver calls to translate objects between CRD versions; required when served versions have different schemas.
- Conversion strategy —
None(relabelapiVersiononly; schemas must be identical) orWebhook(call your endpoint). - Round-trip / lossless conversion — converting
a → b → areturns the original object; the correctness requirement for every conversion webhook. - Structural schema — an OpenAPI v3 schema where every field has a declared type in a well-defined place; the precondition for defaulting, pruning, and conversion.
- Pruning — automatic dropping of fields not present in a structural schema.
- Defaulting — server-side filling of missing fields declared with
default; applies on read as well as write. x-kubernetes-*— Kubernetes extensions to OpenAPI:-preserve-unknown-fields,-int-or-string,-embedded-resource,-list-type/-list-map-keys,-validations(CEL), and more.- CEL validation (
x-kubernetes-validations) — in-process validation rules (GA 1.29), including transition rules viaoldSelf; a webhook-free alternative for many checks, bounded by a cost budget. - Subresource — a secondary path on a resource (e.g.
/status,/scale); CRDs supportstatusandscale, aggregated servers support arbitrary ones. - Delegated authn/authz — an extension apiserver deferring authentication (
TokenReview) and authorization (SubjectAccessReview) to the kube-apiserver instead of implementing its own. - Requestheader / front-proxy CA — the CA an extension apiserver trusts to authenticate the kube-apiserver’s proxied requests; published in
kube-system/extension-apiserver-authentication. caBundle— base64-encoded PEM of the CA that signed a webhook’s or APIService’s serving certificate; how the apiserver verifies the endpoint’s identity.- storage-version-migrator — a controller (out-of-tree
migration.k8s.io/v1alpha1, and an in-treestoragemigration.k8s.io/v1alpha1since 1.30) that rewrites stored objects into the current storage version. - API Priority and Fairness (APF) — apiserver concurrency isolation (
flowcontrol.apiserver.k8s.io/v1, GA) that classifies requests into FlowSchemas → PriorityLevelConfigurations so one client cannot starve others.