TT Lab
Get started
Learn Learning paths Courses

CNPA — Cloud Native Platform Engineering Associate

You Learn the Contract by Being Rejected

Continue in TT Lab

In one sentence

The value of a CRD lies in borrowing the API server's validation, defaults, RBAC, and auditing. On a cluster that accepts anything, you cannot confirm any of these.

Why you have to get rejected

In the previous module you built a platform API with a CRD. But the place where that lab ran lacked the API server's capabilities, so it accepted whatever you put in.

A CRD is something that "lends capabilities the API server already has to my type." Those capabilities are these.

검증      스키마에 안 맞으면 거절한다
기본값    빠진 값을 저장 시점에 채운다
RBAC      다른 자원과 똑같이 권한을 건다
감사      누가 언제 무엇을 바꿨는지 남는다
watch     컨트롤러가 변화를 구독한다
문서      kubectl explain 이 스키마를 읽어 준다

In the order above, these are validation (reject what does not fit the schema), defaults (fill in missing values at storage time), RBAC (apply permissions exactly as with other resources), auditing (record who changed what and when), watch (controllers subscribe to changes), and documentation (kubectl explain reads the schema to you).

If you build an API yourself, you have to rebuild all six. But on a cluster that only accepts, you could not confirm any of them.

Fields that are silently erased

A structural schema prunes by default. A field that is not in properties is erased on its own — you did not turn on a blocking setting. If you write replicas mistakenly as replica, that value disappears and nobody tells you.

It is also the place where you get bitten trying to use additionalProperties: false. You cannot use it together with properties (Forbidden: mutual exclusive). It is blocked because there is no need to use it.

Conversely, if you add x-kubernetes-preserve-unknown-fields: true, both pruning and validation are switched off entirely. The moment you add it for convenience, every fence disappears.

It matters who fills in the default

스키마의 default    저장 시점에 채워져 kubectl get -o yaml 에 바로 보인다
컨트롤러가 채움     한참 뒤에 나타난다. 그 사이에는 비어 있다

In other words, a default in the schema is filled in at storage time and shows up immediately in the YAML output of kubectl get, while a value filled in by a controller appears a good while later and is empty in between.

A developer needs the value to be there when they check in order to know what will happen. The golden path is built here.

What breaks when you change the schema

Once a CRD is deployed, there are already stored objects. Changing the schema is reinterpreting stored data, so there are rules.

Change Safe? Reason
Add an optional field ✅ Old objects simply have that field empty
Add a required field ❌ Old objects get caught by validation and can no longer even be edited
Delete a field ⚠️ The values are pruned away. It cannot be undone
Change a type (string→int) ❌ Stored values cannot be read
Add an enum value ✅
Delete an enum value ❌ Objects that used that value become invalid

If you must add a required field, create a new version (v1alpha1 → v1beta1). There is only one version with storage: true, and the others are served through conversion. Without a conversion webhook, the strategy is None and fields pass through as they are, so if the structure differs, you must stand up a webhook.

How far can validation go with a schema

There is a split between what the OpenAPI schema can do and what it cannot.

properties:
  replicas:
    type: integer
    minimum: 1
    maximum: 100
    default: 3
  tier:
    type: string
    enum: [bronze, silver, gold]
  name:
    type: string
    pattern: '^[a-z][a-z0-9-]{2,30}$'

Up to here, the schema can do it. Relationships between fields it cannot — for example, "if tier is gold, replicas must be 5 or more." It used to require a webhook, but now you can express it inside the CRD with CEL validation rules.

x-kubernetes-validations:
  - rule: "self.tier != 'gold' || self.replicas >= 5"
    message: "gold 등급은 복제본이 5개 이상이어야 합니다"
  - rule: "self.name == oldSelf.name"      # 불변 필드
    message: "name 은 만든 뒤에 바꿀 수 없습니다"

The advantages over a webhook are clear. There is no separate deployment, there is no case of the API stopping because a webhook died, and the error message sits in the same place as the schema. Use a webhook only when it cannot be expressed in CEL.

Where to put state

Keep status separate from spec as a subresource.

subresources:
  status: {}
  scale:
    specReplicasPath: .spec.replicas
    statusReplicasPath: .status.replicas

With this, users cannot edit status and controllers cannot edit spec. Permissions split naturally. If you add a scale subresource, kubectl scale and the HPA work as they are — this is how you attach an HPA to a custom resource.

What really matters in practice

A typo comes back not as an error but as silence. A structural schema prunes by default, so if you write replicas as replica, the value silently disappears. When you ship a platform API, it is better to write in the guide that people should read back the stored result with kubectl get -o yaml.

x-kubernetes-preserve-unknown-fields: true is a last resort. The moment you add it for convenience, both pruning and validation are switched off entirely, and every fence you built on that type disappears.

Put defaults in the schema, not in a controller. A schema's default is filled in at storage time so developers can check it right away, but if a controller fills it in, it is empty in between. The golden path is built from this difference.

In the next lab, you confirm these things by being rejected yourself on a real API server.