En tancar la lliçó anterior va quedar apuntada una anomalia. Al mòdul 4 vam instal·lar cert-manager i vam començar a escriure manifests amb kind: Certificate i kind: ClusterIssuer. Al mòdul 5 vam crear objectes kind: VolumeSnapshot. Cap d'aquests tipus no existeix a Kubernetes: no vénen al binari de l'apiserver, no apareixen a l'especificació original de l'API. I tanmateix el clúster els tracta exactament igual que un Deployment: kubectl get, kubectl describe, kubectl explain, validació de camps, control d'accés RBAC, emmagatzematge a etcd, kubectl edit.

Això no és màgia. És la característica més important de Kubernetes des del punt de vista de l'ecosistema: l'API es pot estendre. Qualsevol pot afegir tipus d'objecte nous i, a partir d'aquell moment, el clúster els gestiona com si sempre hi haguessin estat.

En aquesta lliçó aprendrem a fer-ho. Definirem el tipus RutaProgramada per a Rutas Norte —origen, destí, horaris, places, vehicle— amb validació real, i el farem servir amb les mateixes eines de sempre. I acabarem amb l'advertiment més important del tema: un CRD sense un controlador al darrere és només una base de dades amb formulari. Qui hi posa el programari al darrere és l'operador, i aquest és el tema de la lliçó següent.

Contingut

  1. Què significa estendre l'API de Kubernetes
  2. Les tres maneres d'estendre Kubernetes
  3. Anatomia de l'objecte CustomResourceDefinition
  4. L'esquema OpenAPI v3: validació declarativa
  5. Subrecursos: status i scale
  6. additionalPrinterColumns: kubectl get útil
  7. Exemple complet: el CRD RutaProgramada
  8. Fer servir el recurs com qualsevol objecte natiu
  9. Versionatge i conversió
  10. Quan NO crear un CRD

  1. Què significa estendre l'API de Kubernetes

Convé tenir clar què és realment l'apiserver. No és "el cervell de Kubernetes": és un servidor REST amb emmagatzematge a etcd, autenticació, autorització, validació i notificació de canvis. Els objectes que serveix —Pods, Services, Deployments— són dades amb esquema. Tota la intel·ligència és als controladors que observen aquestes dades i actuen.

Estendre l'API significa ensenyar-li un tipus d'objecte nou. A partir d'aquell moment, aquell tipus obté gratis tota la infraestructura de l'apiserver:

El que obtens gratis Què significa
Endpoints REST /apis/<grup>/<versió>/namespaces/<ns>/<plural> amb GET, POST, PUT, PATCH, DELETE, WATCH
Persistència a etcd Alta disponibilitat, transaccions, historial de revisions
Validació Rebuig de manifests mal formats abans de desar-los
RBAC Rols i permisos sobre el teu tipus, com sobre qualsevol altre (08-01)
kubectl complet get, describe, edit, apply, delete, explain, label, patch
Auditoria Cada canvi queda registrat al log d'auditoria
Watch Notificació en temps real de canvis: la base dels controladors
metadata estàndard labels, annotations, ownerReferences, finalizers, resourceVersion

Aquesta llista és la raó per la qual tot l'ecosistema de Kubernetes es construeix així. Quan cert-manager va voler modelar "un certificat que ha d'existir i renovar-se", no va inventar un format de configuració propi ni un servidor a part: va definir un tipus Certificate i va deixar que Kubernetes fes la resta.

Recursos personalitzats que ja has fet servir en aquest curs, probablement sense adonar-te'n:

Recurs Grup d'API Qui l'aporta On va aparèixer
Certificate, Issuer, ClusterIssuer cert-manager.io cert-manager 04-05
VolumeSnapshot, VolumeSnapshotClass snapshot.storage.k8s.io snapshot-controller 05-05
Backup, Restore, Schedule velero.io Velero 05-06
ServiceMonitor, PrometheusRule monitoring.coreos.com Prometheus Operator 07-03
IPPool, NetworkSet crd.projectcalico.org Calico 04-01

Els pots veure al teu propi clúster:

kubectl get crds
NAME                                         CREATED AT
certificates.cert-manager.io                 2026-07-12T09:14:22Z
challenges.acme.cert-manager.io              2026-07-12T09:14:22Z
clusterissuers.cert-manager.io               2026-07-12T09:14:22Z
issuers.cert-manager.io                      2026-07-12T09:14:23Z
volumesnapshotclasses.snapshot.storage.k8s.io  2026-07-19T11:02:41Z
volumesnapshotcontents.snapshot.storage.k8s.io 2026-07-19T11:02:41Z
volumesnapshots.snapshot.storage.k8s.io        2026-07-19T11:02:41Z

I comprovar quins dels tipus disponibles són natius i quins afegits:

kubectl api-resources --api-group=cert-manager.io
NAME              SHORTNAMES   APIVERSION              NAMESPACED   KIND
certificaterequests  cr,crs    cert-manager.io/v1      true         CertificateRequest
certificates         cert,certs cert-manager.io/v1     true         Certificate
clusterissuers                 cert-manager.io/v1      false        ClusterIssuer
issuers                        cert-manager.io/v1      true         Issuer

  1. Les tres maneres d'estendre Kubernetes

Hi ha tres mecanismes, amb propòsits diferents. Convé distingir-los perquè sovint es confonen.

Mecanisme Què afegeix Complexitat Quan fer-lo servir
CustomResourceDefinition (CRD) Tipus d'objecte nous, servits pel mateix apiserver Baixa: un YAML El 95 % dels casos
Capa d'agregació de l'API Un servidor d'API propi en què l'apiserver delega Alta: cal escriure i operar un servidor Dades que no van a etcd o lògica de lectura especial
Webhooks d'admissió Interceptar i modificar o rebutjar objectes ja existents Mitjana: un servidor HTTPS Validar o injectar sobre tipus natius o propis

CRD

Declares l'esquema en un YAML i l'apiserver comença a servir el tipus. Els objectes es desen a etcd com qualsevol altre. És el que fan servir cert-manager, Velero, Prometheus Operator i pràcticament tot l'ecosistema.

Limitacions: les dades viuen a etcd (no és lloc per a volums de dades grans ni per a escriptures molt freqüents) i no pots personalitzar com es llegeixen o s'emmagatzemen.

Capa d'agregació

Registres un objecte APIService que diu a l'apiserver "per al grup metrics.k8s.io, delega en aquest Service". L'apiserver actua de proxy cap al teu servidor.

L'exemple canònic és el metrics-server que vam activar com a addon a 01-04: les seves mètriques són dades volàtils d'alta freqüència que no han d'anar a etcd, així que se serveixen des de memòria mitjançant agregació. El veurem a 07-02.

kubectl get apiservices | grep -v Local
NAME                     SERVICE                      AVAILABLE   AGE
v1beta1.metrics.k8s.io   kube-system/metrics-server   True        14d

Requereix escriure un servidor d'API complet (autenticació, autorització, versionatge) i operar-lo amb alta disponibilitat. La regla és senzilla: si dubtes, fes servir un CRD.

Webhooks d'admissió

No afegeixen tipus: intercepten peticions a tipus que ja existeixen, en el camí entre la validació de l'apiserver i l'emmagatzematge.

  • Mutating webhook: modifica l'objecte. És el que fa una malla de serveis en injectar un sidecar a cada pod.
  • Validating webhook: accepta o rebutja. És el que aplica polítiques de seguretat complexes.

Un CRD pot tenir un webhook de validació associat per a regles que l'esquema OpenAPI no pot expressar, com ara "l'hora d'arribada ha de ser posterior a la de sortida" o "no hi pot haver dues rutes amb el mateix codi".

  1. Anatomia de l'objecte CustomResourceDefinition

Un CRD és, ell mateix, un objecte de Kubernetes del grup apiextensions.k8s.io/v1. Aquesta és la seva estructura, amb l'exemple de RutaProgramada que desenvoluparem:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  # OBLIGATORI: el nom ha de ser exactament <plural>.<group>
  name: rutesprogramades.rutasnorte.example
spec:
  group: rutasnorte.example
  scope: Namespaced
  names:
    kind: RutaProgramada
    listKind: RutaProgramadaList
    plural: rutesprogramades
    singular: rutaprogramada
    shortNames: ["ruta", "rutes"]
    categories: ["rutasnorte", "all"]
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          # ... l'esquema, apartat 4 ...

group

El grup d'API sota el qual viu el tipus. Ha de ser un nom de domini que controlis o que sigui clarament teu, per evitar col·lisions. Convenció habitual: <producte>.<el-teu-domini>.

El grup es combina amb la versió per formar l'apiVersion dels teus objectes: rutasnorte.example/v1.

names

Camp Què és Exemple
kind El kind: del manifest, en CamelCase singular RutaProgramada
listKind El kind de la llista; per convenció <kind>List RutaProgramadaList
plural Nom a la URL de l'API i a kubectl, en minúscules rutesprogramades
singular Àlies singular per a kubectl rutaprogramada
shortNames Abreviatures: kubectl get rutes ["ruta", "rutes"]
categories Grups per a kubectl get <categoria> ["rutasnorte", "all"]

Les categories són més útils del que sembla. Amb categories: ["rutasnorte"], una sola ordre llista tots els recursos personalitzats de la teva plataforma:

kubectl get rutasnorte -n rutas-norte-pro

Compte amb incloure-hi all: fa que els teus objectes apareguin a kubectl get all, que ja és una ordre sorollosa. Fes-ho servir només si el teu recurs és realment de primer nivell per als usuaris del clúster.

scope

Valor Significat Exemples reals
Namespaced L'objecte viu en un namespace Certificate, VolumeSnapshot, RutaProgramada
Cluster És global al clúster, sense namespace ClusterIssuer, VolumeSnapshotClass, StorageClass

La decisió és important i no es pot canviar després sense esborrar el CRD (i amb ell tots els seus objectes). Criteri: si equips o entorns diferents han de tenir versions separades i aïllades del recurs, és Namespaced. Si representa configuració global d'infraestructura, és Cluster.

Per a RutaProgramada triem Namespaced: les rutes de rutas-norte-dev són dades de prova i no s'han de barrejar amb les de rutas-norte-pro.

versions

Una llista, perquè un CRD pot servir diverses versions alhora:

  versions:
    - name: v1alpha1
      served: false      # ja no se serveix: els clients antics reben error
      storage: false
      schema: {...}
    - name: v1beta1
      served: true       # se serveix, per a clients que encara la fan servir
      storage: false
      schema: {...}
    - name: v1
      served: true
      storage: true      # EXACTAMENT UNA versió pot tenir storage: true
      schema: {...}
Camp Significat
served Si l'apiserver accepta peticions en aquella versió
storage Si els objectes es desen a etcd amb aquell esquema. Només una versió el pot tenir

La distinció és subtil però crucial. Un objecte creat com a v1beta1 es converteix a la versió d'emmagatzematge abans de desar-se, i es converteix de tornada en llegir-lo en v1beta1. Les dades a etcd tenen una sola forma; les versions són vistes sobre elles. Hi tornarem a l'apartat 9.

  1. L'esquema OpenAPI v3: validació declarativa

Sense esquema, un recurs personalitzat acceptaria qualsevol YAML. Amb esquema, l'apiserver valida abans de desar i rebutja el que no encaixi, amb un missatge concret. És el que fa que un CRD se senti com un tipus natiu.

L'esquema va a versions[].schema.openAPIV3Schema i és un subconjunt d'OpenAPI v3.

Tipus i estructura bàsica

      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required: ["origen", "desti", "horaris", "places"]
              properties:
                origen:
                  type: string
                  minLength: 2
                  maxLength: 60
                places:
                  type: integer
                  minimum: 1
                  maximum: 90
                activa:
                  type: boolean
                  default: true
                horaris:
                  type: array
                  minItems: 1
                  maxItems: 24
                  items:
                    type: string

Restriccions disponibles per tipus:

Tipus Restriccions Ús típic
string minLength, maxLength, pattern, enum, format Codis, noms, matrícules
integer minimum, maximum, exclusiveMinimum, multipleOf Places, preus en cèntims
number Igual que integer, amb decimals Coordenades
boolean Interruptors
array minItems, maxItems, uniqueItems, items Horaris, parades
object properties, required, additionalProperties Estructures imbricades

required i default

                origen:
                  type: string
                  # sense default: si és a required, és obligatori
                activa:
                  type: boolean
                  default: true      # si no s'indica, l'apiserver l'hi posa
                classe:
                  type: string
                  enum: ["estandar", "supra", "nocturn"]
                  default: "estandar"

Els valors per defecte els aplica l'apiserver en desar, no kubectl. Conseqüència pràctica: si llegeixes l'objecte després de crear-lo, veuràs els defaults ja escrits. Això els fa fiables per a qualsevol consumidor.

Validació per patró i per enumeració

                codi:
                  type: string
                  # Format de Rutas Norte: dues lletres, guionet, tres dígits (RN-041)
                  pattern: '^[A-Z]{2}-[0-9]{3}$'
                matriculaVehicle:
                  type: string
                  pattern: '^[0-9]{4}[A-Z]{3}$'
                horaSortida:
                  type: string
                  pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$'
                diesOperacio:
                  type: array
                  minItems: 1
                  maxItems: 7
                  uniqueItems: true
                  items:
                    type: string
                    enum: ["Dl", "Dt", "Dc", "Dj", "Dv", "Ds", "Dg"]

Els pattern fan servir sintaxi RE2 (la de Go), no PCRE. No admeten lookahead ni lookbehind ni referències cap enrere. Per a regles més complexes hi ha dues opcions: regles de validació CEL (x-kubernetes-validations) o un webhook de validació.

x-kubernetes-preserve-unknown-fields

Per defecte, l'apiserver elimina silenciosament qualsevol camp que no sigui a l'esquema. Això s'anomena poda (pruning) i és una de les causes més desconcertants de "el meu camp va desaparèixer": apliques un manifest amb un camp mal escrit, kubectl no protesta, i en llegir l'objecte aquell camp no hi és.

Per permetre camps arbitraris en una part concreta:

                metadadesExternes:
                  type: object
                  x-kubernetes-preserve-unknown-fields: true
                  description: "Dades lliures del sistema de venda heretat"

Fes-ho servir amb moderació: cada punt on ho poses és un punt on perds validació i on un error tipogràfic passa desapercebut.

Descripcions

                places:
                  type: integer
                  minimum: 1
                  maximum: 90
                  description: "Places totals del vehicle assignat a aquesta ruta"

Les description no són decoratives: són el que retorna kubectl explain. Escriure-les converteix el teu CRD en un tipus autodocumentat, i és la diferència entre un recurs que la gent sap fer servir i un que requereix llegir el codi font.

  1. Subrecursos: status i scale

    - name: v1
      served: true
      storage: true
      subresources:
        status: {}
        scale:
          specReplicasPath: .spec.vehiclesAssignats
          statusReplicasPath: .status.vehiclesActius
          labelSelectorPath: .status.selector
      schema:
        openAPIV3Schema: {...}

El subrecurs status

Activar status: {} té tres efectes concrets:

  1. S'habilita l'endpoint /status, que s'actualitza de manera independent de l'objecte principal.
  2. Les escriptures sobre l'objecte principal ignoren canvis a .status.
  3. Les escriptures sobre /status ignoren canvis a .spec.

Aquesta separació no és burocràcia: reflecteix la divisió fonamental del model declaratiu que vam estudiar a 01-06.

spec status
Qui escriu L'usuari o el sistema de desplegament El controlador
Què expressa L'estat desitjat L'estat observat
Es versiona a Git No
Es pot reconstruir No: és la intenció Sí: és una observació

Sense aquest subrecurs, un controlador que volgués actualitzar l'estat hauria de fer un update de l'objecte complet, i correria el risc de sobreescriure un canvi de spec que l'usuari acabés de fer. I a la inversa: un kubectl apply de l'usuari esborraria l'estat que el controlador acabava d'escriure.

Activa'l sempre en qualsevol CRD que hagi de tenir un controlador.

Un patró universal a Kubernetes per al status són les condicions:

            status:
              type: object
              properties:
                fase:
                  type: string
                  enum: ["Pendent", "Programada", "Activa", "Cancelada"]
                placesVenudes:
                  type: integer
                condicions:
                  type: array
                  items:
                    type: object
                    required: ["type", "status"]
                    properties:
                      type:
                        type: string
                      status:
                        type: string
                        enum: ["True", "False", "Unknown"]
                      lastTransitionTime:
                        type: string
                        format: date-time
                      reason:
                        type: string
                      message:
                        type: string

És la mateixa estructura que has vist a kubectl describe pod (Ready, PodScheduled, Initialized) i a kubectl describe node. Seguir la convenció fa que els teus objectes es llegeixin com els natius i que eines genèriques com kubectl wait --for=condition=... funcionin sobre ells.

El subrecurs scale

Habilita kubectl scale sobre el teu propi recurs, i amb ell la possibilitat que un HorizontalPodAutoscaler (09-01) hi actuï.

        scale:
          specReplicasPath: .spec.vehiclesAssignats       # on és el nombre desitjat
          statusReplicasPath: .status.vehiclesActius      # on és l'observat
          labelSelectorPath: .status.selector             # selector dels objectes gestionats
kubectl scale rutaprogramada rn-041-bilbao-santander -n rutas-norte-pro --replicas=3
rutaprogramada.rutasnorte.example/rn-041-bilbao-santander scaled

El que fa l'ordre és escriure un 3 a .spec.vehiclesAssignats. Que això es tradueixi en tres autobusos és feina del controlador; el subrecurs només estandarditza la interfície.

  1. additionalPrinterColumns: kubectl get útil

Sense configurar res, kubectl get d'un recurs personalitzat mostra dues columnes inútils:

NAME                      AGE
rn-041-bilbao-santander   3m

Amb additionalPrinterColumns es declara què mostrar, mitjançant rutes JSONPath sobre l'objecte:

      additionalPrinterColumns:
        - name: Codi
          type: string
          jsonPath: .spec.codi
        - name: Origen
          type: string
          jsonPath: .spec.origen
        - name: Destí
          type: string
          jsonPath: .spec.desti
        - name: Places
          type: integer
          jsonPath: .spec.places
        - name: Fase
          type: string
          jsonPath: .status.fase
        - name: Venudes
          type: integer
          jsonPath: .status.placesVenudes
          priority: 1        # només amb -o wide
        - name: Antiguitat
          type: date
          jsonPath: .metadata.creationTimestamp

Resultat:

NAME                      CODI     ORIGEN    DESTÍ        PLACES   FASE     ANTIGUITAT
rn-041-bilbao-santander   RN-041   Bilbao    Santander    55       Activa   3m
rn-088-oviedo-leon        RN-088   Oviedo    Lleó         38       Activa   3m

Detalls:

  • type admet string, integer, number, boolean i date. Amb date, kubectl formata com a antiguitat relativa ("3m", "2d").
  • priority: 0 (per defecte) mostra la columna sempre; priority: 1 només amb -o wide.
  • La columna NAME hi és sempre i no es configura.

És un detall petit amb un impacte enorme en la usabilitat. Compara mentalment kubectl get pods —que mostra READY, STATUS, RESTARTS i AGE— amb el que seria si només mostrés nom i edat.

  1. Exemple complet: el CRD RutaProgramada

Ho ajuntem tot. Rutas Norte vol modelar les seves rutes com a objectes de Kubernetes, de manera que l'equip d'operacions les gestioni amb les mateixes eines i el mateix flux de GitOps que la resta de la plataforma.

# k8s/base/crd-rutaprogramada.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: rutesprogramades.rutasnorte.example
  labels:
    app.kubernetes.io/part-of: rutas-norte
spec:
  group: rutasnorte.example
  scope: Namespaced
  names:
    kind: RutaProgramada
    listKind: RutaProgramadaList
    plural: rutesprogramades
    singular: rutaprogramada
    shortNames: ["ruta", "rutes"]
    categories: ["rutasnorte"]
  versions:
    - name: v1
      served: true
      storage: true
      subresources:
        status: {}
      additionalPrinterColumns:
        - name: Codi
          type: string
          jsonPath: .spec.codi
        - name: Origen
          type: string
          jsonPath: .spec.origen
        - name: Destí
          type: string
          jsonPath: .spec.desti
        - name: Places
          type: integer
          jsonPath: .spec.places
        - name: Fase
          type: string
          jsonPath: .status.fase
        - name: Venudes
          type: integer
          jsonPath: .status.placesVenudes
          priority: 1
        - name: Antiguitat
          type: date
          jsonPath: .metadata.creationTimestamp
      schema:
        openAPIV3Schema:
          type: object
          description: "Una ruta d'autobús programada de Rutas Norte S.L."
          required: ["spec"]
          properties:
            spec:
              type: object
              description: "Estat desitjat de la ruta"
              required: ["codi", "origen", "desti", "horaris", "places"]
              properties:
                codi:
                  type: string
                  description: "Codi comercial de la ruta, format XX-999 (ex. RN-041)"
                  pattern: '^[A-Z]{2}-[0-9]{3}$'
                origen:
                  type: string
                  description: "Ciutat d'origen del trajecte"
                  minLength: 2
                  maxLength: 60
                desti:
                  type: string
                  description: "Ciutat de destí del trajecte"
                  minLength: 2
                  maxLength: 60
                duracioMinuts:
                  type: integer
                  description: "Durada estimada del trajecte en minuts"
                  minimum: 10
                  maximum: 1440
                  default: 120
                places:
                  type: integer
                  description: "Places totals ofertes a cada sortida"
                  minimum: 1
                  maximum: 90
                classe:
                  type: string
                  description: "Categoria comercial del servei"
                  enum: ["estandar", "supra", "nocturn"]
                  default: "estandar"
                activa:
                  type: boolean
                  description: "Si la ruta admet vendes actualment"
                  default: true
                horaris:
                  type: array
                  description: "Hores de sortida diàries en format HH:MM"
                  minItems: 1
                  maxItems: 24
                  uniqueItems: true
                  items:
                    type: string
                    pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$'
                diesOperacio:
                  type: array
                  description: "Dies de la setmana en què opera la ruta"
                  minItems: 1
                  maxItems: 7
                  uniqueItems: true
                  default: ["Dl", "Dt", "Dc", "Dj", "Dv", "Ds", "Dg"]
                  items:
                    type: string
                    enum: ["Dl", "Dt", "Dc", "Dj", "Dv", "Ds", "Dg"]
                paradesIntermedies:
                  type: array
                  description: "Parades entre origen i destí, en ordre"
                  maxItems: 20
                  items:
                    type: object
                    required: ["localitat", "minutDesDeSortida"]
                    properties:
                      localitat:
                        type: string
                        minLength: 2
                        maxLength: 60
                      minutDesDeSortida:
                        type: integer
                        minimum: 1
                        maximum: 1439
                vehicle:
                  type: object
                  description: "Vehicle assignat a la ruta"
                  required: ["matricula"]
                  properties:
                    matricula:
                      type: string
                      description: "Matrícula espanyola sense separadors (ex. 4471BCD)"
                      pattern: '^[0-9]{4}[A-Z]{3}$'
                    model:
                      type: string
                      maxLength: 80
                    accessible:
                      type: boolean
                      default: true
                preuBaseCentims:
                  type: integer
                  description: "Preu base del bitllet en cèntims d'euro"
                  minimum: 0
                  maximum: 100000
                metadadesVendaHeretada:
                  type: object
                  description: "Camps lliures del sistema de venda heretat"
                  x-kubernetes-preserve-unknown-fields: true
            status:
              type: object
              description: "Estat observat, escrit pel controlador"
              properties:
                fase:
                  type: string
                  enum: ["Pendent", "Programada", "Activa", "Cancelada"]
                placesVenudes:
                  type: integer
                  minimum: 0
                ultimaSortidaProgramada:
                  type: string
                  format: date-time
                observedGeneration:
                  type: integer
                  description: "metadata.generation que el controlador va processar per últim cop"
                condicions:
                  type: array
                  items:
                    type: object
                    required: ["type", "status"]
                    properties:
                      type:
                        type: string
                      status:
                        type: string
                        enum: ["True", "False", "Unknown"]
                      lastTransitionTime:
                        type: string
                        format: date-time
                      reason:
                        type: string
                        maxLength: 128
                      message:
                        type: string
                        maxLength: 512
kubectl apply -f k8s/base/crd-rutaprogramada.yaml
customresourcedefinition.apiextensions.k8s.io/rutesprogramades.rutasnorte.example created
kubectl get crd rutesprogramades.rutasnorte.example
NAME                                   CREATED AT
rutesprogramades.rutasnorte.example    2026-08-05T20:11:34Z

A partir d'aquest instant, RutaProgramada és un tipus de primera classe al clúster. Ningú no ha reiniciat res, i tots els kubectl del món que apuntin a aquest clúster ja el coneixen.

Instàncies

# k8s/entorns/pro/rutes-programades.yaml
apiVersion: rutasnorte.example/v1
kind: RutaProgramada
metadata:
  name: rn-041-bilbao-santander
  namespace: rutas-norte-pro
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorn: pro
    corredor: cantabric
spec:
  codi: "RN-041"
  origen: "Bilbao"
  desti: "Santander"
  duracioMinuts: 95
  places: 55
  classe: "estandar"
  horaris: ["07:00", "09:30", "12:00", "15:30", "18:00", "20:30"]
  diesOperacio: ["Dl", "Dt", "Dc", "Dj", "Dv", "Ds", "Dg"]
  paradesIntermedies:
    - localitat: "Castro Urdiales"
      minutDesDeSortida: 40
    - localitat: "Laredo"
      minutDesDeSortida: 58
  vehicle:
    matricula: "4471BCD"
    model: "Setra S 415 (fictici)"
    accessible: true
  preuBaseCentims: 1150
---
apiVersion: rutasnorte.example/v1
kind: RutaProgramada
metadata:
  name: rn-088-oviedo-leon
  namespace: rutas-norte-pro
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorn: pro
    corredor: nord-oest
spec:
  codi: "RN-088"
  origen: "Oviedo"
  desti: "Lleó"
  duracioMinuts: 130
  places: 38
  classe: "supra"
  horaris: ["06:45", "14:15", "19:45"]
  diesOperacio: ["Dl", "Dt", "Dc", "Dj", "Dv"]
  vehicle:
    matricula: "9902XKL"
    accessible: true
  preuBaseCentims: 1490
kubectl apply -f k8s/entorns/pro/rutes-programades.yaml
rutaprogramada.rutasnorte.example/rn-041-bilbao-santander created
rutaprogramada.rutasnorte.example/rn-088-oviedo-leon created

La validació en acció

Aquesta és la part que demostra que l'esquema no és decoratiu. Un manifest amb diversos errors:

apiVersion: rutasnorte.example/v1
kind: RutaProgramada
metadata:
  name: ruta-invalida
  namespace: rutas-norte-dev
spec:
  codi: "rn41"                      # no compleix ^[A-Z]{2}-[0-9]{3}$
  origen: "A"                       # minLength és 2
  desti: "Gijón"
  places: 250                       # maximum és 90
  classe: "premium"                 # no és a l'enum
  horaris: ["25:00"]                # hora invàlida
  vehicle:
    matricula: "4471-BCD"           # el guionet no està permès
kubectl apply -f /tmp/ruta-invalida.yaml
The RutaProgramada "ruta-invalida" is invalid:
* spec.codi: Invalid value: "rn41": spec.codi in body should match '^[A-Z]{2}-[0-9]{3}$'
* spec.origen: Invalid value: "A": spec.origen in body should be at least 2 chars long
* spec.places: Invalid value: 250: spec.places in body should be less than or equal to 90
* spec.classe: Unsupported value: "premium": supported values: "estandar", "supra", "nocturn"
* spec.horaris[0]: Invalid value: "25:00": spec.horaris[0] in body should match '^([01][0-9]|2[0-3]):[0-5][0-9]$'
* spec.vehicle.matricula: Invalid value: "4471-BCD": spec.vehicle.matricula in body should match '^[0-9]{4}[A-Z]{3}$'

Sis errors, tots detectats abans de desar res, amb la ruta exacta del camp i la regla violada. I també els omesos:

kubectl apply -f - <<'EOF'
apiVersion: rutasnorte.example/v1
kind: RutaProgramada
metadata:
  name: ruta-incompleta
  namespace: rutas-norte-dev
spec:
  codi: "RN-999"
  origen: "Burgos"
EOF
The RutaProgramada "ruta-incompleta" is invalid:
* spec.desti: Required value
* spec.horaris: Required value
* spec.places: Required value

Aquesta validació gratuïta, sense escriure una línia de codi, és la raó per la qual val la pena invertir temps en un bon esquema.

  1. Fer servir el recurs com qualsevol objecte natiu

Tot el que saps de kubectl ja funciona sobre RutaProgramada.

kubectl get rutes -n rutas-norte-pro
NAME                      CODI     ORIGEN   DESTÍ       PLACES   FASE   ANTIGUITAT
rn-041-bilbao-santander   RN-041   Bilbao   Santander   55              2m
rn-088-oviedo-leon        RN-088   Oviedo   Lleó        38              2m

La columna FASE és buida perquè .status no l'escriu ningú: no hi ha controlador. Aquest buit és la lliçó de l'apartat 10.

kubectl get rutes -n rutas-norte-pro -l corredor=cantabric
kubectl get rutaprogramada rn-041-bilbao-santander -n rutas-norte-pro -o yaml | head -30
apiVersion: rutasnorte.example/v1
kind: RutaProgramada
metadata:
  creationTimestamp: "2026-08-05T20:14:02Z"
  generation: 1
  labels:
    app.kubernetes.io/part-of: rutas-norte
    corredor: cantabric
    entorn: pro
  name: rn-041-bilbao-santander
  namespace: rutas-norte-pro
  resourceVersion: "184722"
  uid: 3f1a9c04-8e2b-4c71-9a55-71b0e2d8c4f3
spec:
  activa: true                  # <- default aplicat per l'apiserver
  classe: estandar
  codi: RN-041
  desti: Santander
  diesOperacio: [Dl, Dt, Dc, Dj, Dv, Ds, Dg]
  duracioMinuts: 95

Observa activa: true: no era al manifest i l'apiserver el va escriure a partir del default de l'esquema.

kubectl explain

kubectl explain rutaprogramada.spec.vehicle
GROUP:      rutasnorte.example
KIND:       RutaProgramada
VERSION:    v1

FIELD: vehicle <Object>

DESCRIPTION:
    Vehicle assignat a la ruta

FIELDS:
  accessible <boolean>
  matricula  <string> -required-
    Matrícula espanyola sense separadors (ex. 4471BCD)
  model      <string>

La documentació surt directament de l'esquema. Un company que no hagi vist mai aquest CRD el pot descobrir tot sol, sense llegir codi ni buscar un wiki.

describe, edit, patch, label

kubectl describe rutaprogramada rn-088-oviedo-leon -n rutas-norte-pro
Name:         rn-088-oviedo-leon
Namespace:    rutas-norte-pro
Labels:       app.kubernetes.io/part-of=rutas-norte
              corredor=nord-oest
              entorn=pro
API Version:  rutasnorte.example/v1
Kind:         RutaProgramada
Spec:
  Activa:             true
  Classe:             supra
  Codi:               RN-088
  Desti:              Lleó
  Dies Operacio:      Dl, Dt, Dc, Dj, Dv
  Duracio Minuts:     130
  Horaris:            06:45, 14:15, 19:45
  Origen:             Oviedo
  Places:             38
  Preu Base Centims:  1490
  Vehicle:
    Accessible:  true
    Matricula:   9902XKL
Events:         <none>
# Editar interactivament, amb validació en desar
kubectl edit rutaprogramada rn-041-bilbao-santander -n rutas-norte-pro

# Apedaçar un camp
kubectl patch rutaprogramada rn-041-bilbao-santander -n rutas-norte-pro \
  --type=merge -p '{"spec":{"preuBaseCentims":1250}}'

# Etiquetar
kubectl label rutaprogramada rn-041-bilbao-santander -n rutas-norte-pro temporada=estiu

# Observar canvis en temps real: la base de qualsevol controlador
kubectl get rutes -n rutas-norte-pro --watch

Escriure el status

Com que vam habilitar el subrecurs, l'estat s'escriu pel seu propi endpoint. Un controlador ho faria mitjançant l'API; a mà, amb kubectl:

kubectl patch rutaprogramada rn-041-bilbao-santander -n rutas-norte-pro \
  --subresource=status --type=merge -p '{
    "status": {
      "fase": "Activa",
      "placesVenudes": 37,
      "observedGeneration": 1,
      "condicions": [{
        "type": "VehicleAssignat",
        "status": "True",
        "reason": "MatriculaValida",
        "message": "Vehicle 4471BCD assignat i disponible",
        "lastTransitionTime": "2026-08-05T20:20:00Z"
      }]
    }
  }'

kubectl get rutes -n rutas-norte-pro -o wide
NAME                      CODI     ORIGEN   DESTÍ       PLACES   FASE     VENUDES   ANTIGUITAT
rn-041-bilbao-santander   RN-041   Bilbao   Santander   55       Activa   37        8m
rn-088-oviedo-leon        RN-088   Oviedo   Lleó        38                          8m

I ara funciona fins i tot kubectl wait sobre una condició pròpia:

kubectl wait --for=condition=VehicleAssignat \
  rutaprogramada/rn-041-bilbao-santander -n rutas-norte-pro --timeout=30s
rutaprogramada.rutasnorte.example/rn-041-bilbao-santander condition met

Això és el que es guanya seguint les convencions: eines genèriques que mai no van saber de rutes d'autobús funcionen sobre el teu tipus.

RBAC sobre el recurs propi

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: gestor-rutes
  namespace: rutas-norte-pro
rules:
  - apiGroups: ["rutasnorte.example"]
    resources: ["rutesprogramades"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
  - apiGroups: ["rutasnorte.example"]
    resources: ["rutesprogramades/status"]
    verbs: ["get", "update", "patch"]

Fixa't que rutesprogramades/status és un recurs separat a efectes de permisos: es pot donar accés d'escriptura al spec sense permetre falsejar el status. Aquesta granularitat l'habilita el subrecurs, i és una altra raó per activar-lo. RBAC en profunditat és la lliçó 08-01.

  1. Versionatge i conversió

El versionatge d'un CRD és la part més difícil, i convé saber-ho abans de publicar la primera versió.

La progressió habitual

Versió Estabilitat Compromisos
v1alpha1 Experimental Pot canviar o desaparèixer sense avís; desactivada per defecte en molts projectes
v1beta1 En proves Canvis incompatibles possibles però anunciats; sol haver-hi migració
v1 Estable Compatibilitat cap enrere garantida; els camps existents no canvien de significat

Un cop a v1, no pots esborrar un camp ni canviar-ne la semàntica. Pots afegir camps opcionals amb valor per defecte, i poca cosa més. Per això convé començar en v1alpha1 i no promoure a v1 fins que el model estigui assentat.

El problema que fa difícil la migració

Recordem el mecanisme: només una versió té storage: true. Tots els objectes es desen amb l'esquema d'aquella versió, i les altres versions són vistes que es converteixen al vol.

Suposem que v1alpha1 tenia un sol camp horari i v1 el substitueix per una llista horaris. Com es converteix un objecte desat amb l'esquema antic?

conversion: None (per defecte)

spec:
  conversion:
    strategy: None

No es converteix res: l'objecte es retorna tal qual, canviant només l'apiVersion. Funciona únicament si els esquemes són compatibles camp a camp, és a dir, si entre versions només has afegit camps opcionals. Per a qualsevol canvi estructural és insuficient.

conversion: Webhook

spec:
  conversion:
    strategy: Webhook
    webhook:
      conversionReviewVersions: ["v1"]
      clientConfig:
        service:
          namespace: rutas-norte-sistema
          name: rutes-webhook-conversio
          path: /convertir
          port: 443
        caBundle: <certificat en base64>

L'apiserver crida el teu servidor HTTPS cada vegada que algú llegeix o escriu un objecte en una versió diferent de la d'emmagatzematge. El teu servidor rep l'objecte en una versió i el retorna en una altra.

El que això implica a la pràctica:

  • Cal escriure, desplegar i operar un servidor HTTPS amb certificat vàlid (aquí cert-manager de 04-05 és l'aliat natural).
  • Ha de ser ràpid i altament disponible: si el webhook no respon, ningú no pot llegir ni escriure aquests objectes. Un webhook de conversió caigut bloqueja el recurs sencer.
  • La conversió ha de ser bidireccional i sense pèrdua. Si v1alpha1.horari (string) es converteix a v1.horaris (llista d'un) i de tornada, cal decidir què passa quan la llista té tres elements. La convenció és desar el que no hi cap en una anotació.

La migració completa

# 1. Publicar la versió nova servint-la, sense canviar l'emmagatzematge
#    versions: v1alpha1 (served, storage) + v1 (served)

# 2. Canviar l'emmagatzematge a v1
#    versions: v1alpha1 (served) + v1 (served, storage)

# 3. Reescriure tots els objectes existents perquè es desin amb l'esquema nou
kubectl get rutesprogramades -A -o json | kubectl replace -f -

# 4. Comprovar quines versions queden registrades com a emmagatzemades
kubectl get crd rutesprogramades.rutasnorte.example \
  -o jsonpath='{.status.storedVersions}{"\n"}'
["v1alpha1","v1"]
# 5. Un cop tots els objectes són a v1, treure v1alpha1 de storedVersions
#    editant .status.storedVersions, i només llavors deixar de servir-la.

El pas 3 és el que s'oblida. Mentre storedVersions contingui la versió antiga, no la pots eliminar del CRD: l'apiserver s'hi nega, perquè hi hauria objectes a etcd que ja no sabria interpretar.

Conclusió pràctica: dissenya l'esquema amb cura des del principi. És molt més barat pensar dos dies el model de dades que operar un webhook de conversió durant anys.

  1. Quan NO crear un CRD

Els CRD són fàcils de crear i per això se'n creen de més. Abans d'escriure'n un, passa aquest filtre.

No el creïs si un ConfigMap n'hi ha prou

Si l'únic que necessites és desar configuració que algú llegeix, un ConfigMap (03-01) fa la feina amb zero infraestructura.

Indici Eina
Dades de configuració que una aplicació llegeix en arrencar ConfigMap
Dades que un controlador ha de reconciliar contínuament CRD
Estructura senzilla, un consumidor, sense validació ConfigMap
Estructura complexa que diversos equips escriuen i que s'ha de validar CRD
Necessites RBAC granular per tipus de dada CRD
Necessites watch, status i condicions CRD

No el creïs si ningú no reconciliarà res

Aquest és l'advertiment central de la lliçó, i mereix enunciar-se sense embuts:

Un CRD sense controlador és una base de dades amb formulari de validació.

La nostra RutaProgramada està creada. Les dues rutes existeixen a etcd. Es validen, s'etiqueten, es versionen a Git, surten a kubectl get. I no passa absolutament res. No arrenca cap autobús. No es crea cap Deployment. La columna FASE continua buida llevat de quan la vam omplir a mà.

El valor d'un recurs personalitzat no és al recurs: és al bucle de reconciliació que l'observa i actua. Sense ell tens un YAML validat, i això ho aconsegueixes més barat amb un esquema JSON al teu repositori i una comprovació al pipeline de CI.

Preguntes de control:

  1. Qui observarà aquest recurs i què farà? Si no hi ha resposta concreta, no creïs el CRD.
  2. Què escriurà a .status? Si res, probablement volies un ConfigMap.
  3. Què passa si algú l'esborra? Si la resposta és "res", no és un recurs de Kubernetes: és un document.

Altres senyals d'alarma

  • Dades d'alta freqüència. etcd no és una base de dades de sèries temporals. Un CRD que s'actualitza cada segon la degradarà. Per a això hi ha la capa d'agregació (metrics-server) o un sistema extern (Prometheus, 07-03).
  • Molts objectes. Milers d'instàncies d'un CRD ocupen etcd i alenteixen els list. Si n'esperes desenes de milers, replanteja't el model.
  • Objectes grans. El límit pràctic d'un objecte a etcd és aproximadament 1 MiB. Un CRD no és lloc per adjuntar fitxers.
  • Dades que canvien a mà constantment. Si el recurs l'editarà una persona deu vegades al dia, potser el que necessites és una aplicació amb interfície, no un CRD.
  • Modelar domini de negoci pur. Aquí convé ser honest amb el nostre propi exemple: modelar rutes d'autobús com a objectes de Kubernetes té sentit si les rutes es tradueixen en recursos del clúster (un Deployment de venda per ruta, un CronJob d'informes per corredor). Si són només files d'una taula, el seu lloc és postgres-reserves, no etcd.

Alternatives abans de decidir-te

Necessitat Alternativa al CRD
Configuració per entorn ConfigMap + Kustomize (10-04)
Plantilles de manifests Helm (10-03)
Validació de manifests al pipeline Esquemes JSON + kubeconform a CI
Polítiques sobre objectes existents Webhook d'admissió o Kyverno
Dades de negoci Una base de dades

Errors Comuns i Consells

Anomenar malament el CRD. metadata.name ha de ser exactament <plural>.<group>. Si no, l'apiserver rebutja l'objecte amb un missatge que despista força.

Camps que desapareixen sense avís. L'apiserver poda tot el que no sigui a l'esquema, en silenci. Si un camp "no es desa", gairebé sempre està mal escrit o falta a l'esquema. Compara amb kubectl get ... -o yaml.

Oblidar el subrecurs status. Sense ell, el controlador i l'usuari es trepitgen les escriptures. Activa'l des del primer dia: afegir-lo després obliga a revisar tot el codi del controlador.

Començar directament en v1. T'obliga a compatibilitat per sempre amb un model que encara no has provat. Comença en v1alpha1 mentre el disseny s'assenta.

x-kubernetes-preserve-unknown-fields: true a l'arrel. Desactiva la validació de tot l'objecte. Si el necessites, acota'l al subarbre concret.

Regex amb sintaxi PCRE. Els pattern fan servir RE2. Un (?=...) provoca un error en crear el CRD, no en fer-lo servir.

Esborrar un CRD sense pensar-hi. kubectl delete crd <nom> esborra totes les instàncies d'aquell tipus a tots els namespaces, sense confirmació. És irreversible llevat de còpia de seguretat.

Objectes orfes després de desinstal·lar un operador. Si esborres l'operador però no els seus CRD, queden tipus registrats sense ningú que els atengui. I si esborres els CRD abans que el controlador retiri els seus finalitzadors, els objectes es queden penjats en Terminating per sempre.

Consell: escriu les description de tots els camps. Alimenten kubectl explain i converteixen el CRD en autodocumentat. És el millor retorn per esforç de tota la lliçó.

Consell: posa additionalPrinterColumns des del principi. Un kubectl get que només mostra nom i edat fa inutilitzable un recurs que per la resta està ben dissenyat.

Consell: prova la validació amb manifests deliberadament trencats. És l'única manera de comprovar que l'esquema fa el que creus. Desa aquests manifests com a proves de regressió del CRD.

Consell: fes servir kubectl explain --recursive per veure l'arbre complet de l'esquema d'un cop d'ull, molt útil en revisar un CRD aliè.

Exercicis

Exercici 1: crear un CRD amb validació

Crea un CRD ParadaAutobus al grup rutasnorte.example, abast Namespaced, versió v1, amb nom curt parada i categoria rutasnorte. El seu spec ha de tenir:

  • codi (string, obligatori, patró ^P-[0-9]{4}$)
  • localitat (string, obligatori, entre 2 i 60 caràcters)
  • andanes (enter, entre 1 i 20, per defecte 1)
  • accessible (booleà, per defecte true)
  • serveis (array de strings de l'enum taquilla, cafeteria, consigna, wc, sense repeticions)

Afegeix-hi columnes per a codi, localitat, andanes i antiguitat. Crea una parada vàlida i una altra d'invàlida i comprova els missatges d'error.

Exercici 2: subrecurs status i condicions

Amplia el CRD anterior amb el subrecurs status, amb els camps operativa (booleà), viatgersDia (enter) i condicions (array amb l'estructura estàndard). Afegeix-hi una columna Operativa. Escriu l'estat mitjançant --subresource=status i comprova que un kubectl apply posterior del spec no l'esborra.

Exercici 3: decidir entre CRD i ConfigMap

Per a cadascun d'aquests quatre casos de Rutas Norte, decideix si correspon un CRD o un ConfigMap i justifica la resposta en una o dues frases:

  1. Els missatges de la interfície de botiga-web en castellà, català i anglès.
  2. Una definició de "corredor comercial" que, en crear-se, ha de provocar automàticament el desplegament d'un CronJob d'informes i una NetworkPolicy propis.
  3. La llista de dominis permesos per a CORS a api-reserves.
  4. Un "entorn de proves efímer" que un desenvolupador sol·licita i que ha de crear un namespace, una còpia de postgres-reserves des d'un snapshot i esborrar-se sol als 7 dies.

Solucions

Solució 1

# /tmp/crd-paradaautobus.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: paradesautobus.rutasnorte.example
  labels:
    app.kubernetes.io/part-of: rutas-norte
spec:
  group: rutasnorte.example
  scope: Namespaced
  names:
    kind: ParadaAutobus
    listKind: ParadaAutobusList
    plural: paradesautobus
    singular: paradaautobus
    shortNames: ["parada", "parades"]
    categories: ["rutasnorte"]
  versions:
    - name: v1
      served: true
      storage: true
      additionalPrinterColumns:
        - name: Codi
          type: string
          jsonPath: .spec.codi
        - name: Localitat
          type: string
          jsonPath: .spec.localitat
        - name: Andanes
          type: integer
          jsonPath: .spec.andanes
        - name: Antiguitat
          type: date
          jsonPath: .metadata.creationTimestamp
      schema:
        openAPIV3Schema:
          type: object
          description: "Una parada física de la xarxa de Rutas Norte"
          required: ["spec"]
          properties:
            spec:
              type: object
              required: ["codi", "localitat"]
              properties:
                codi:
                  type: string
                  description: "Codi de parada, format P-9999"
                  pattern: '^P-[0-9]{4}$'
                localitat:
                  type: string
                  description: "Localitat on es troba la parada"
                  minLength: 2
                  maxLength: 60
                andanes:
                  type: integer
                  description: "Nombre d'andanes disponibles"
                  minimum: 1
                  maximum: 20
                  default: 1
                accessible:
                  type: boolean
                  description: "Si la parada té accessibilitat completa"
                  default: true
                serveis:
                  type: array
                  description: "Serveis disponibles a la parada"
                  uniqueItems: true
                  maxItems: 4
                  items:
                    type: string
                    enum: ["taquilla", "cafeteria", "consigna", "wc"]
kubectl apply -f /tmp/crd-paradaautobus.yaml
kubectl api-resources --api-group=rutasnorte.example
NAME              SHORTNAMES        APIVERSION                  NAMESPACED   KIND
paradesautobus    parada,parades    rutasnorte.example/v1       true         ParadaAutobus

Parada vàlida:

kubectl apply -f - <<'EOF'
apiVersion: rutasnorte.example/v1
kind: ParadaAutobus
metadata:
  name: p-0041-bilbao-termibus
  namespace: rutas-norte-dev
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorn: dev
spec:
  codi: "P-0041"
  localitat: "Bilbao"
  andanes: 12
  serveis: ["taquilla", "cafeteria", "wc"]
EOF

kubectl get parades -n rutas-norte-dev
paradaautobus.rutasnorte.example/p-0041-bilbao-termibus created

NAME                     CODI     LOCALITAT   ANDANES   ANTIGUITAT
p-0041-bilbao-termibus   P-0041   Bilbao      12        9s

Parada invàlida:

kubectl apply -f - <<'EOF'
apiVersion: rutasnorte.example/v1
kind: ParadaAutobus
metadata:
  name: parada-dolenta
  namespace: rutas-norte-dev
spec:
  codi: "PARADA41"
  localitat: "X"
  andanes: 50
  serveis: ["taquilla", "taquilla", "parking"]
EOF
The ParadaAutobus "parada-dolenta" is invalid:
* spec.codi: Invalid value: "PARADA41": spec.codi in body should match '^P-[0-9]{4}$'
* spec.localitat: Invalid value: "X": spec.localitat in body should be at least 2 chars long
* spec.andanes: Invalid value: 50: spec.andanes in body should be less than or equal to 20
* spec.serveis: Invalid value: ["taquilla","taquilla","parking"]: spec.serveis in body should have unique items
* spec.serveis[2]: Unsupported value: "parking": supported values: "taquilla", "cafeteria", "consigna", "wc"

Cinc regles diferents comprovades —patró, longitud, rang, unicitat i enumeració— sense una línia de codi.

# El default també es va aplicar
kubectl get parada p-0041-bilbao-termibus -n rutas-norte-dev \
  -o jsonpath='{.spec.accessible}{"\n"}'
true

Solució 2

S'afegeix al CRD el bloc subresources, l'esquema de status i la columna:

    - name: v1
      served: true
      storage: true
      subresources:
        status: {}
      additionalPrinterColumns:
        - name: Codi
          type: string
          jsonPath: .spec.codi
        - name: Localitat
          type: string
          jsonPath: .spec.localitat
        - name: Andanes
          type: integer
          jsonPath: .spec.andanes
        - name: Operativa
          type: boolean
          jsonPath: .status.operativa
        - name: Antiguitat
          type: date
          jsonPath: .metadata.creationTimestamp
      schema:
        openAPIV3Schema:
          type: object
          required: ["spec"]
          properties:
            spec:
              # ... igual que a la solució 1 ...
            status:
              type: object
              description: "Estat observat de la parada"
              properties:
                operativa:
                  type: boolean
                viatgersDia:
                  type: integer
                  minimum: 0
                condicions:
                  type: array
                  items:
                    type: object
                    required: ["type", "status"]
                    properties:
                      type:
                        type: string
                      status:
                        type: string
                        enum: ["True", "False", "Unknown"]
                      lastTransitionTime:
                        type: string
                        format: date-time
                      reason:
                        type: string
                      message:
                        type: string
kubectl apply -f /tmp/crd-paradaautobus.yaml

kubectl patch parada p-0041-bilbao-termibus -n rutas-norte-dev \
  --subresource=status --type=merge -p '{
    "status": {
      "operativa": true,
      "viatgersDia": 3184,
      "condicions": [{
        "type": "AndanesDisponibles",
        "status": "True",
        "reason": "TotesLliures",
        "message": "12 de 12 andanes operatives",
        "lastTransitionTime": "2026-08-05T21:02:00Z"
      }]
    }
  }'

kubectl get parades -n rutas-norte-dev
NAME                     CODI     LOCALITAT   ANDANES   OPERATIVA   ANTIGUITAT
p-0041-bilbao-termibus   P-0041   Bilbao      12        true        6m

Comprovació de la independència entre spec i status:

# Reaplicar el spec original, que NO conté status
kubectl apply -f - <<'EOF'
apiVersion: rutasnorte.example/v1
kind: ParadaAutobus
metadata:
  name: p-0041-bilbao-termibus
  namespace: rutas-norte-dev
  labels:
    app.kubernetes.io/part-of: rutas-norte
    entorn: dev
spec:
  codi: "P-0041"
  localitat: "Bilbao"
  andanes: 14
  serveis: ["taquilla", "cafeteria", "wc", "consigna"]
EOF

kubectl get parades -n rutas-norte-dev
NAME                     CODI     LOCALITAT   ANDANES   OPERATIVA   ANTIGUITAT
p-0041-bilbao-termibus   P-0041   Bilbao      14        true        8m

Les andanes es van actualitzar a 14 i OPERATIVA continua en true: l'apply no va tocar el status, perquè el subrecurs l'aïlla. Sense ell, aquell apply hauria esborrat tot l'estat que el controlador acabava de calcular. Aquest és exactament el motiu pel qual cal activar-lo.

kubectl wait --for=condition=AndanesDisponibles \
  parada/p-0041-bilbao-termibus -n rutas-norte-dev --timeout=10s
paradaautobus.rutasnorte.example/p-0041-bilbao-termibus condition met
# Neteja (esborra el CRD i totes les seves instàncies)
kubectl delete crd paradesautobus.rutasnorte.example

Solució 3

1. Missatges de la interfície en tres idiomes → ConfigMap. Són dades de configuració que botiga-web llegeix en arrencar. Ningú no ha de reconciliar res: no hi ha estat desitjat a perseguir, només text per muntar com a volum o injectar com a variables. Un ConfigMap per idioma, versionat a Git, resol el cas sense infraestructura addicional.

2. Corredor comercial que desplega un CronJob i una NetworkPolicy → CRD. Aquest és el cas canònic. Hi ha un estat desitjat ("existeix el corredor Cantàbric") que s'ha de traduir en recursos reals del clúster, i alguna cosa els ha de crear, mantenir si algú els esborra a mà i netejar quan el corredor desaparegui. Això és un bucle de reconciliació, és a dir, un CRD més un controlador. Sense el controlador el CRD no valdria res.

3. Dominis permesos per a CORS → ConfigMap. És una llista de cadenes que l'aplicació llegeix. Si a més la vols validar, un esquema JSON al pipeline de CI és més barat que un CRD. La prova que no necessita CRD: ningú no escriuria res al seu .status.

4. Entorn de proves efímer amb caducitat → CRD. Requereix reconciliació activa i contínua: crear un namespace, restaurar postgres-reserves des d'un snapshot (05-05), vigilar el pas del temps i esborrar-ho tot als 7 dies. El status tindria camps amb sentit (fase, dataCaducitat, namespaceCreat) i l'esborrat en cascada es resoldria amb ownerReferences. És un operador de manual, i la lliçó següent explica com s'escriu.

Conclusió

Estendre l'API de Kubernetes significa ensenyar-li un tipus d'objecte nou, i a canvi aquell tipus hereta gratis els endpoints REST, la persistència a etcd, la validació, l'RBAC, l'auditoria, el watch i tot kubectl. És la característica que explica l'ecosistema sencer: els Certificate de cert-manager, els VolumeSnapshot del mòdul 5 i els Backup de Velero que ja has fet servir són recursos personalitzats.

De les tres maneres d'estendre —CRD, capa d'agregació i webhooks d'admissió—, el CRD cobreix la immensa majoria dels casos i només requereix un YAML. N'hem recorregut l'anatomia: group, names amb els seus plurals i dreceres, scope, i versions amb la distinció crucial entre served (es pot demanar) i storage (es desa així, i només una versió el pot tenir).

L'esquema OpenAPI v3 és el que converteix un CRD en un tipus de veritat: tipus, required, default, enum, pattern, rangs i x-kubernetes-preserve-unknown-fields quan cal permetre camps lliures. Els subrecursos status —que separa el desitjat de l'observat i evita que l'usuari i el controlador es trepitgin— i scale —que habilita kubectl scale— i les additionalPrinterColumns completen l'experiència.

Hem construït el CRD RutaProgramada amb validació real, hem comprovat que rebutja sis errors diferents amb missatges precisos, i l'hem manejat amb get, describe, explain, edit, patch, label, wait i RBAC exactament igual que un Deployment. També hem vist per què el versionatge és la part difícil, i per què un webhook de conversió caigut bloqueja el recurs sencer.

I hem acabat al lloc on calia acabar: les nostres dues rutes existeixen, es validen i surten a kubectl get, però no passa res. La columna FASE és buida perquè ningú no l'escriu. Un CRD sense controlador és una base de dades amb formulari.

El que falta és el programari que observi aquests objectes, compari el desitjat amb l'observat i actuï: el bucle de reconciliació que coneixem des de 01-02. Un recurs personalitzat més un controlador que el reconcilia és, exactament, un operador. És el que fa cert-manager amb els Certificate, el que faria un controlador de RutaProgramada, i el que resoldrà per fi la mancança que vam deixar oberta a 06-01: que postgres-reserves continua sent un StatefulSet artesanal que no sap replicar-se ni commutar per error. Aquest és el tema de la lliçó següent, l'última del mòdul: Operadors i el Patró Controlador.

Curs de Kubernetes

Mòdul 1: Introducció a Kubernetes

Mòdul 2: Components Principals de Kubernetes

Mòdul 3: Gestió de Configuració i Secrets

Mòdul 4: Xarxes a Kubernetes

Mòdul 5: Emmagatzematge a Kubernetes

Mòdul 6: Conceptes Avançats de Kubernetes

Mòdul 7: Monitoratge i Registre

Mòdul 8: Seguretat a Kubernetes

Mòdul 9: Escalat i Rendiment

Mòdul 10: Ecosistema i Eines de Kubernetes

Mòdul 11: Estudis de Cas i Aplicacions del Món Real

Mòdul 12: Preparació per a la Certificació de Kubernetes

© Copyright 2026. Tots els drets reservats