A resource type of your own (CRD)
Teach the API a new type
So far you have worked with the types Kubernetes ships out of the box: Pod, Deployment, Service, ConfigMap. They all share the same mechanics: an apiVersion, a kind, a metadata, a spec, and a kubectl get that lists them.
The question this lesson asks is uncomfortable and very productive: what is so special about those types?
The book presents CRDs in the Kubernetes objects chapter and drops a claim that is hard to believe until you see it: creating a Custom Resource triggers absolutely nothing in the cluster. Here you are going to check it.
The answer is: nothing. And you are going to prove it by creating your own.
A CustomResourceDefinition (CRD) is an object that tells the API server: "from now on there is a type called Promocion, it is stored at /apis/tienda.example.com/v1/promociones, and these are the fields it accepts". (Promocion is Spanish for promotion, as in a sale; like every identifier in this course, it stays in Spanish.) From that moment on, your type is a first-class citizen of the API: it has an endpoint, it has validation, it is stored in etcd, it works with kubectl get, with RBAC, with kubectl label, with kubectl apply. With everything.
π‘ This lesson builds half of the puzzle. The other half (why your new type, for now, does nothing) is the next lesson. Hold on to the feeling of anticlimax you will have at the end of this one: it is intentional.
Step 1: Define the type
Create the file promocion-crd.yaml:
cat << 'EOF' > promocion-crd.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: promociones.tienda.example.com
spec:
group: tienda.example.com
scope: Namespaced
names:
plural: promociones
singular: promocion
kind: Promocion
shortNames:
- promo
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required:
- banner
properties:
banner:
type: string
replicas:
type: integer
minimum: 1
maximum: 10
default: 1
additionalPrinterColumns:
- name: BANNER
type: string
jsonPath: .spec.banner
- name: Replicas
type: integer
jsonPath: .spec.replicas
- name: Edad
type: date
jsonPath: .metadata.creationTimestamp
EOF
The YAML, explained in questions and answers
Why apiVersion: apiextensions.k8s.io/v1 and not plain v1?
Because the CRD itself is a native Kubernetes object, not a custom resource. It lives in the apiextensions.k8s.io group, which is literally the API group in charge of extending the API. It is the same apiVersion for any CRD you will ever create, whatever type it defines.
Why does metadata.name have to be promociones.tienda.example.com and not promociones?
Because Kubernetes demands it: the name of a CRD is always <plural>.<group>. And it demands it for a practical reason. If two different Helm charts defined a type called promociones, one in the tienda.example.com group and the other in otraempresa.example.com, they would not collide. The group is your namespace within the API.
What is the difference between group and a Kubernetes Namespace?
They have nothing to do with each other, even though the name invites you to confuse them. The group groups things in the API: together with the version it forms the apiVersion your users will write (tienda.example.com/v1). The Namespace groups objects inside the cluster. A single API group can have objects spread across a hundred namespaces.
What does scope: Namespaced mean?
That instances of your type will live inside a namespace, like Pods or Deployments. The alternative is scope: Cluster, for global objects such as Nodes or PersistentVolumes. The vast majority of custom resources are Namespaced, and there is a strong reason: it is what lets you grant RBAC permissions per namespace and isolate teams. A Cluster resource belongs to everyone and to no one.
What are plural, singular, kind and shortNames for?
They are the four ways of naming the same thing:
kind(Promocion): what you write in the resource's YAML.plural(promociones): the real API endpoint, and what you use inkubectl get promociones.singular(promocion): what shows up in error messages and in the help.shortNames(promo): the short alias, so you can typekubectl get promo.
What are served and storage?
served: true means this version can be queried through the API. storage: true means it is the version in which the object is physically stored in etcd. Only one version can have storage: true at a time. The distinction looks bureaucratic until the day you have v1beta1 and v1 living side by side and need to migrate from one to the other without stopping the cluster: then those two fields are the whole mechanism.
Why is openAPIV3Schema mandatory?
Because without it, the API server would accept anything inside your spec. A spec: {pickles: true} would be stored in etcd without a second thought. The schema is what turns your type into an API rather than a junk drawer: it defines which fields exist, what type they are and which ones are required. In apiextensions.k8s.io/v1 it is not optional: a CRD without a schema is rejected.
What do required, minimum, maximum and default add?
Validation and ergonomics, for free and on the server:
required: [banner]: a Promocion without abanneris rejected atapply.minimum: 1/maximum: 10:replicas: 50is rejected. Nobody writes code for this.default: 1: if the user omitsreplicas, the API server fills it in by itself.
This is what separates a serious CRD from a toy CRD. The validation is not done by your controller: the API server does it, before storing anything.
And additionalPrinterColumns?
It is what makes kubectl get promociones readable instead of showing only NAME and AGE. Each column extracts a field with a jsonPath. It costs six lines and it is the difference between a resource you can operate and one you have to open with -o yaml every time.
Step 2: Apply it
kubectl apply -f promocion-crd.yaml
Check that the API server has registered it:
kubectl get crd promociones.tienda.example.com
kubectl get crd promociones.tienda.example.com -o jsonpath='{.status.conditions}' | jq .
Look for the Established: True condition. It means the endpoint already exists and accepts requests.
Step 3: Your type is already first class
This is what is really worth understanding. Ask the API which resources it knows in your group:
kubectl api-resources --api-group=tienda.example.com
And look at the endpoint directly, with no intermediaries:
kubectl get --raw /apis/tienda.example.com/v1 | jq .
There it is: a REST endpoint, with its verbs (get, list, watch, create, update, patch, delete), exactly like the one for Pods. There are no two categories of resources in Kubernetes. The native ones and yours are served by the same API server, stored in the same etcd, protected by the same RBAC and queried with the same kubectl.
Continue with the next unit to create objects of your new type... and find out what happens when you do.
The silence
You have a new type in the API. Let's use it.
Step 4: Create a Custom Resource
Create promocion.yaml (the object's name, rebajas-verano, is Spanish for summer sale):
cat << 'EOF' > promocion.yaml
apiVersion: tienda.example.com/v1
kind: Promocion
metadata:
name: rebajas-verano
spec:
banner: "https://tienda.example.com"
replicas: 3
EOF
The YAML, explained in questions and answers
Where does apiVersion: tienda.example.com/v1 come from?
From the CRD's spec.group + spec.versions[].name. That is exactly how the API server knows which CRD this object belongs to: it looks for a registered CRD whose group and version match. If it does not find one, kubectl apply fails with the most common error in the world of operators: no matches for kind "Promocion" in version "tienda.example.com/v1". When you see it in a real cluster, it almost always means the same thing: the CRD is not installed.
And kind: Promocion?
From spec.names.kind. That is the contract between the two files: the CRD declares that the valid kind is called Promocion, and the resource uses it as is.
Why only banner and replicas?
Because they are the only fields the schema declared. It is not a convention: it is a boundary. You will check it in the next step.
And the question that matters: if I apply this, do three Pods get created?
No. Absolutely nothing gets created.
Keep that answer. You are going to verify it yourself in two minutes.
Apply it
kubectl apply -f promocion.yaml
kubectl get promociones
Notice the output: there are your additionalPrinterColumns, with the banner and the replicas. kubectl get promo works too.
Step 5: The schema is a real boundary
Try to fool the API server. Apply this without saving it, straight from the terminal:
cat << 'YAML' | kubectl apply -f -
apiVersion: tienda.example.com/v1
kind: Promocion
metadata:
name: promocion-invalida
spec:
banner: "https://ejemplo.com"
replicas: "tres"
YAML
The API server rejects it: replicas is declared as integer and "tres" is a string. Try replicas: 50 too (it exceeds the maximum), and a field that does not exist:
cat << 'YAML' | kubectl apply -f -
apiVersion: tienda.example.com/v1
kind: Promocion
metadata:
name: promocion-invalida
spec:
banner: "https://ejemplo.com"
color: azul
YAML
That last one will give you back an unknown-field warning, and color will not be stored.
None of this is being done by code of yours. The API server does it, with the schema you gave it, before writing anything to etcd. You wrote the validation of your API in six lines of YAML.
Step 6: The silence
Now, the moment of the lesson. Your Promocion declares replicas: 3 and a banner. Look at what it has triggered in the cluster:
kubectl get pods
kubectl get deployments
kubectl get services
kubectl get events --sort-by=.lastTimestamp
There is nothing. Not a Pod. Not a Deployment. Not an event. Not a complaint.
This is a strange task: you pass it by doing nothing. And it is the most important one in the module.
A CRD teaches Kubernetes a noun, not a verb. You have told the API server how a Promocion is written, validated and stored. You have not told it what it means. spec.replicas: 3 is, to the cluster, exactly as meaningful as spec.color: azul: a string of bytes with a schema. Nobody is reading it.
And this is where many people get their first disappointment with operators: they install a product's CRD, apply the custom resource, and wait for something that never happens. The object is there. kubectl get shows it. And nothing happens, because the controller is not deployed.
Summary
- A CRD adds a type to the Kubernetes API: REST endpoint, schema validation, storage in etcd, RBAC,
kubectl. Everything the native resources have. - The
openAPIV3Schemais not bureaucracy: it is your API contract, and the API server enforces it, not your code. additionalPrinterColumnsturns your resource into something you can operate from the terminal.- A CRD has no behavior. It defines a noun. Creating the object runs nothing.
- What turns a declarative object into real changes in the cluster is a controller. Without it, your resource is a very well validated sticky note.
- Previous lesson
- The commands that aren't in any manual
- Next lesson
- The operator