skip to content

What changes about a Kubernetes custom resource when you enable `subresources: {status: {}}` on its CustomResourceDefinition, and why do controllers want it?

level: middleimportance: must knowfreq 40%

answer

  1. two endpoints: main ignores status, /status ignores everything else
  2. apply no longer wipes controller-written status
  3. generation bumps only on spec change → observedGeneration
  4. RBAC target <plural>/status
  5. scale subresource → kubectl scale + HPA support

basics

~20 s

It splits the object into two write paths: writes to the main endpoint ignore status, and writes to /status change only status. It also makes metadata.generation increment only on spec changes, and lets RBAC grant status updates separately.

solid answer

~50 s

Enabling the status subresource gives the resource a second endpoint, `/apis/<group>/<v>/…/<name>/status`, and changes the write semantics of both: - A normal update to the object **ignores** any `status` the client sends. - An update to `/status` **ignores** everything except `status`. That separation is exactly the spec/status contract of built-in resources: users declare intent in `spec`, the controller reports observed reality in `status`, and neither can accidentally clobber the other. A `kubectl apply` of the user's manifest can no longer wipe the controller's conditions. It also makes `metadata.generation` behave usefully: the API server increments it only when `spec` changes, which enables the **`observedGeneration`** pattern — the controller records the generation it has reconciled, and tooling compares the two to tell "applied" from "reconciled". And RBAC gains a separate resource name, `<plural>/status`, so a controller can be allowed to write status without permission to change spec.

code

yaml · 36 lines
yaml
versions:
  - name: v1
    served: true
    storage: true
    subresources:
      status: {}
      scale:
        specReplicasPath: .spec.replicas
        statusReplicasPath: .status.replicas
        labelSelectorPath: .status.labelSelector
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            properties:
              replicas: { type: integer, minimum: 0, default: 1 }
          status:
            type: object
            properties:
              replicas: { type: integer }
              labelSelector: { type: string }
              observedGeneration: { type: integer, format: int64 }
              conditions:
                type: array
                items:
                  type: object
                  required: ["type", "status"]
                  properties:
                    type: { type: string }
                    status: { type: string }
                    reason: { type: string }
                    message: { type: string }
                    observedGeneration: { type: integer, format: int64 }
                    lastTransitionTime: { type: string, format: date-time }

go deeper

for a junior

Say it creates a separate /status endpoint so users write spec and the controller writes status without overwriting each other.

for a middle

Add the exact write semantics of both endpoints, the generation/observedGeneration effect, and that status writes need a different client call.

for a senior

Bring in RBAC separation via <plural>/status, standard condition shape for kubectl wait, conflict handling on status writes, and the migration hazard of enabling it on an existing CRD.

for a principal

Frame spec/status as the API contract that makes the platform observable and least-privileged: what belongs in status, reconstructibility, how readiness is surfaced to delivery tooling, and where the scale subresource plugs custom kinds into autoscaling.

## The two-endpoint model By default a custom resource is a single document at one endpoint: whatever a client PUTs replaces the whole object, `status` included. Adding ```yaml subresources: status: {} ``` to a version entry in the CRD creates a second endpoint, `.../<name>/status`, and changes how both behave: - **Main endpoint** (create, update, patch, apply): the `status` field of the submitted object is discarded. On create, `status` is dropped entirely; on update, the stored status is preserved. - **Status endpoint**: everything except `status` is discarded — spec and most metadata changes submitted there are ignored. This is not a convenience; it is the mechanism that makes the declarative contract safe. ## Why the split matters Kubernetes objects follow a strict division: **`spec` is desired state written by the user**, **`status` is observed state written by the controller**. Without the subresource, that division is only a convention, and it breaks in ordinary use: - A user runs `kubectl apply -f db.yaml` from a manifest with no status. Without the subresource, the apply erases the conditions and observed fields the controller just wrote; the controller writes them back; and every apply causes a spurious update event. - A buggy controller doing a full-object update to report status can silently overwrite a spec change made a moment earlier — a lost-update bug that is very hard to see. With the subresource, both classes of accident are impossible at the API level rather than by discipline. ## generation and observedGeneration A second, easily missed effect: with the status subresource enabled, the API server increments **`metadata.generation`** only when the **spec** changes. Status writes do not bump it. That makes the standard reconciliation-progress idiom work: ```yaml status: observedGeneration: 7 conditions: - type: Ready status: "True" observedGeneration: 7 lastTransitionTime: "2026-08-12T10:04:11Z" reason: Provisioned message: Instance is serving ``` The controller copies `metadata.generation` into `status.observedGeneration` when it finishes reconciling. Anyone — a human, a CI job, a progressive-delivery tool — can then distinguish "the API accepted my change" from "the controller has acted on my change" by comparing `metadata.generation` with `status.observedGeneration`. Without the subresource, generation churns on every status write and the comparison is meaningless. Conditions themselves should follow the standard shape (`type`, `status` of True/False/Unknown, `reason`, `message`, `lastTransitionTime`, `observedGeneration`), because that is what `kubectl wait --for=condition=Ready`, `kubectl describe` and most tooling understand. ## RBAC separation The subresource is a distinct RBAC target named `<plural>/status`. So you can write: - controller ServiceAccount: `get, list, watch` on `databases` and `update, patch` on `databases/status` — it can report, but cannot rewrite users' intent; - application teams: full verbs on `databases` and nothing on `databases/status` — they declare intent but cannot fake readiness. That is a meaningful least-privilege boundary, and it is only available once the subresource exists. ## The scale subresource, briefly The sibling `subresources.scale` maps three JSONPaths (`specReplicasPath`, `statusReplicasPath`, optional `labelSelectorPath`) onto a `/scale` endpoint. Once present, `kubectl scale` works on your custom resource and — importantly — a HorizontalPodAutoscaler can target it, because the HPA drives whatever exposes `/scale`. Custom workload kinds that own replicas should declare it. ## Practical consequences for controller code - Two API calls: `Update(obj)` for spec/metadata and `Status().Update(obj)` (controller-runtime) or `UpdateStatus` (client-go) for status. Mixing them up is the most common reason "my status never persists" — the write went to the main endpoint and was dropped. - Server-side apply has a distinct field manager for status; use `Status().Patch(..., client.Apply, ...)` with its own owner name. - Conflicts are still possible: status writes use the same `resourceVersion` optimistic concurrency, so controllers must handle 409 by re-reading and retrying. - Status should be **reconstructible**. Because it is observed state, a controller must be able to rebuild it from the world; never store the only copy of something important there. ## Enabling it later Adding the subresource to an existing CRD is safe and takes effect immediately for all objects, but any client that was writing status through the main endpoint silently stops working — the write is accepted and dropped, with no error. Migrate controllers first, then enable it, and grep for status writes in tooling that touches the resource.

  • A controller writes status but the change never appears. What is the most likely cause?
    It is updating the object through the main endpoint while the status subresource is enabled, so the API server accepts the write and silently discards the status field. The controller must call the status endpoint instead — Status().Update or Status().Patch in controller-runtime, UpdateStatus in client-go. Nothing errors, which is why this bug survives review.
  • How does the status subresource change the meaning of metadata.generation?
    With the subresource enabled the API server increments generation only when spec changes, so status writes no longer churn it. That makes the observedGeneration pattern reliable: the controller copies generation into status.observedGeneration after reconciling, and comparing the two tells you whether the controller has acted on the latest intent rather than merely that the API accepted it.
  • What does declaring the scale subresource on a CRD enable?
    It exposes a /scale endpoint backed by the JSONPaths you specify for the spec replicas, status replicas and label selector. That makes kubectl scale work against your custom resource and, more importantly, lets a HorizontalPodAutoscaler target it, because the HPA operates on anything that serves /scale rather than on Deployments specifically.

It is like separating a form's applicant section from the clerk's stamp box: the applicant can resubmit the form as often as they like without erasing the stamp, and the clerk cannot quietly rewrite what the applicant asked for.

saying these in an interview costs you the question

  • Updating the whole object to write status and wondering why status disappears
  • Believing spec/status separation is enforced by convention alone without the subresource
  • Thinking generation increments on every write even with the subresource enabled
  • Storing authoritative data in status that cannot be reconstructed by the controller
  • Enabling the subresource on an existing CRD without first migrating controllers that wrote status via the main endpoint

context