La lliçó anterior va acabar amb un CRD RutaProgramada perfectament definit, validat i consultable, sobre el qual no passava absolutament res. I a la lliçó 06-01 vam deixar un altre deute: postgres-reserves és un StatefulSet que dona identitat i disc a les seves rèpliques, però que no sap triar un primari, ni replicar dades, ni commutar per error quan aquell primari mor.
Les dues mancances tenen la mateixa solució, i es resumeix en una frase que convé memoritzar:
Operador = recurs personalitzat + controlador que el reconcilia.
Un operador és el coneixement operatiu d'un expert —com es promou una rèplica de PostgreSQL, com es fa una còpia coherent, com s'actualitza una versió major sense perdre dades— codificat en programari que corre dins del clúster i no dorm mai.
Aquesta és l'última lliçó del mòdul. Tancarem els dos deutes oberts, veurem el bucle de reconciliació per dins, substituirem el nostre StatefulSet artesanal per un operador de PostgreSQL de veritat, esbossarem el controlador de RutaProgramada, i acabarem explicant per què la majoria dels equips no haurien d'escriure operadors.
Contingut
- Què és un operador, amb precisió
- Anatomia real del bucle de reconciliació
- Idempotència i absència d'estat en memòria
- El model de capacitats en cinc nivells
- Operadors que ja has fet servir en aquest curs
- Cas pràctic: un operador de PostgreSQL per a
postgres-reserves - On trobar operadors i què mirar abans d'adoptar-ne un
- Com se n'escriu un: Kubebuilder i
controller-runtime ownerReferencesi l'esborrat en cascada- Quan NO escriure un operador
- Què és un operador, amb precisió
Un operador té exactament dues peces:
- Un o diversos CRD que defineixen el vocabulari:
Cluster,Certificate,RutaProgramada. És la interfície declarativa amb què l'usuari expressa què vol. - Un controlador —normalment un Deployment corrent al mateix clúster— que observa aquests objectes i fa la feina.
graph LR
U[Usuari] -->|kubectl apply<br/>Cluster amb 3 rèpliques| API[kube-apiserver]
API -->|watch| C[Controlador de l'operador<br/>Deployment al clúster]
C -->|compara desitjat vs observat| R{Coincideixen?}
R -->|no| A[Actuar: crear pods,<br/>promoure primari,<br/>ajustar Services]
R -->|sí| N[No fer res]
A -->|escriu| API
C -->|actualitza status| API
API -->|canvi| C
El terme el va encunyar CoreOS el 2016 amb aquesta idea: quan un equip d'operacions porta anys administrant PostgreSQL, ha acumulat un conjunt de procediments —què fer si el primari deixa de respondre, com afegir una rèplica de lectura, en quin ordre actualitzar— que viuen en runbooks, en scripts solts i al cap de dues persones. Un operador converteix tot això en un programa que s'executa contínuament.
La diferència amb les eines que ja coneixes:
| Eina | Quan actua | Què manté |
|---|---|---|
| Script de desplegament | Quan algú el llança | Res: és un tret únic |
| Helm (10-03) | A install i upgrade |
Renderitza plantilles; no vigila després |
| Kustomize (10-04) | En generar els manifests | Res en temps d'execució |
| Operador | Contínuament | L'estat desitjat, passi el que passi |
Un operador no instal·la: manté. Si algú esborra un pod, el recrea. Si el primari cau a les tres de la matinada, promou una rèplica sense despertar ningú. Si el disc s'omple, l'amplia. Aquesta vigilància permanent és el seu valor.
- Anatomia real del bucle de reconciliació
A la lliçó 01-02 vam descriure el bucle de reconciliació com la idea central de Kubernetes: observar l'estat desitjat, observar el real, actuar per acostar-los. Ara el veiem per dins, tal com l'implementa qualsevol controlador seriós.
graph TB
W[Informer: WATCH sobre l'API<br/>+ memòria cau local de l'estat] -->|esdeveniment add/update/delete| Q[Cua de treball<br/>amb deduplicació i retard]
Q -->|extreu una clau<br/>namespace/nom| REC[Reconcile ns/nom]
REC --> LEER[Llegir l'objecte actual de la memòria cau]
LEER --> OBS[Observar el món real:<br/>pods, PVC, serveis]
OBS --> COMP{desitjat == observat?}
COMP -->|sí| ST[Actualitzar status<br/>i acabar]
COMP -->|no| ACT[Executar el pas següent]
ACT --> ST
ST --> RES{Resultat?}
RES -->|error| REQ[Reencuar amb<br/>retrocés exponencial]
RES -->|requeue després de N s| REQ2[Reencuar amb retard fix]
RES -->|ok| FIN[Esperar l'esdeveniment següent]
REQ --> Q
REQ2 --> Q
El watch i l'informer
El controlador no consulta l'API en bucle: obre una connexió watch i rep notificacions de cada canvi. La biblioteca estàndard (client-go) ho embolcalla en un informer, que manté a més una memòria cau local de tots els objectes observats.
La memòria cau importa per dos motius:
- Les lectures del controlador no colpegen l'apiserver: en un clúster amb centenars d'objectes, la diferència és substancial.
- La memòria cau pot estar lleugerament desactualitzada. Un controlador ha de tolerar llegir un objecte amb un parell de segons de retard, cosa que reforça la necessitat d'idempotència.
La cua de treball
Els esdeveniments no es processen directament: es tradueix cadascun a una clau (namespace/nom) i es fica en una cua amb tres propietats:
- Deduplicació: si un objecte canvia cinc vegades mentre es processa, la clau apareix una sola vegada. Es reconciliarà una vegada amb l'estat final, no cinc vegades amb estats intermedis.
- Retard: es pot demanar "torna a mirar això d'aquí a 30 segons", útil per esperar que alguna cosa externa progressi.
- Límit de taxa amb retrocés exponencial: un objecte que falla es reintenta als 5 ms, 10 ms, 20 ms… fins a un màxim. Un objecte permanentment trencat no consumeix el controlador sencer.
La funció Reconcile
És el cor, i la seva signatura diu molt:
func (r *RutaProgramadaReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error)Rep només una clau, no l'objecte ni l'esdeveniment. Aquesta decisió de disseny és deliberada: Reconcile no sap què va canviar ni per què ha estat cridada. El seu contracte és sempre el mateix: donat aquest nom, llegeix l'estat desitjat, mira el real, i fes que s'assemblin.
Retorna dues coses:
| Retorna | Efecte |
|---|---|
error != nil |
Reencuar amb retrocés exponencial |
Result{Requeue: true} |
Reencuar immediatament |
Result{RequeueAfter: 30*time.Second} |
Reencuar d'aquí a 30 segons |
Result{}, nil |
Acabat; esperar l'esdeveniment següent |
Actualitzar el status
L'últim pas de cada reconciliació és escriure l'observat a .status, a través del subrecurs que vam estudiar a 06-06. Aquí apareix un camp convencional que mereix explicació:
metadata.generation l'incrementa l'apiserver cada vegada que canvia el spec (no quan canvien etiquetes o anotacions). El controlador desa a status.observedGeneration la generació que ja va processar. Comparant-los, qualsevol pot saber si el controlador està al dia:
kubectl get rutaprogramada rn-041-bilbao-santander -n rutas-norte-pro \
-o jsonpath='generacio={.metadata.generation} observada={.status.observedGeneration}{"\n"}'Si difereixen, hi ha un canvi del spec que el controlador encara no ha atès.
- Idempotència i absència d'estat en memòria
Dues propietats no negociables de qualsevol Reconcile.
Idempotència
Reconcile es pot executar moltes més vegades de les que esperes: per un canvi real, per un reintent després d'un error, per una resincronització periòdica de l'informer (cada 10 hores per defecte), o simplement perquè el controlador es va reiniciar i reconcilia tot el que existeix.
Per això Reconcile mai no ha de pensar en termes de "crear" sinó d'"assegurar que existeix":
// MALAMENT: falla a la segona execució amb AlreadyExists
if err := r.Create(ctx, deployment); err != nil {
return ctrl.Result{}, err
}
// BÉ: idempotent
existent := &appsv1.Deployment{}
err := r.Get(ctx, client.ObjectKeyFromObject(desitjat), existent)
switch {
case apierrors.IsNotFound(err):
return ctrl.Result{}, r.Create(ctx, desitjat)
case err != nil:
return ctrl.Result{}, err
default:
if !reflect.DeepEqual(existent.Spec, desitjat.Spec) {
existent.Spec = desitjat.Spec
return ctrl.Result{}, r.Update(ctx, existent)
}
return ctrl.Result{}, nil // ja estava bé: no fer res
}Corol·lari pràctic: una reconciliació que no canvia res és el cas normal i ha de ser barata. Si el teu Reconcile escriu a l'API a cada passada encara que res no hagi canviat, provocaràs un bucle infinit: l'escriptura genera un esdeveniment, l'esdeveniment provoca una altra reconciliació, i així indefinidament. És l'error clàssic del primer operador que escriu qualsevol, i es detecta perquè l'apiserver registra milers d'update per minut sobre el mateix objecte.
Absència d'estat en memòria
El controlador no pot recordar res entre reconciliacions. Ni en variables globals, ni en mapes, ni en fitxers locals.
Motius:
- Es pot reiniciar en qualsevol moment (actualització, desallotjament, fallada del node) i ho perdria tot.
- Hi pot haver diverses rèpliques per a alta disponibilitat; només una està activa gràcies a l'elecció de líder, però el relleu pot passar en qualsevol moment.
- L'estat en memòria es dessincronitza del món real sense que ningú se n'adoni.
Tot l'estat que el controlador necessiti recordar ha de viure a l'API de Kubernetes: a .status, en anotacions, en etiquetes o en els mateixos objectes que gestiona. Si el teu operador necessita saber "ja he llançat la còpia de seguretat d'avui", això va a status.ultimaCopia, no en una variable.
La conseqüència positiva és que un operador ben escrit es pot matar i arrencar en qualsevol moment sense efectes secundaris. És una propietat que convé provar deliberadament: esborra el pod del controlador enmig d'una operació i comprova que ho reprèn correctament.
- El model de capacitats en cinc nivells
El projecte Operator Framework va definir una escala per mesurar quant sap fer un operador. És la millor eina per avaluar-ne un abans d'adoptar-lo. Aplicada a una base de dades com postgres-reserves:
| Nivell | Nom | Què significa per a una base de dades |
|---|---|---|
| 1 | Instal·lació bàsica | Crea el StatefulSet, el Service, el PVC i el Secret. Equival al que vam fer a mà a 06-01 |
| 2 | Actualitzacions senzilles | Canvia la versió menor (16.4 → 16.6) en l'ordre correcte, rèpliques abans que primari |
| 3 | Cicle de vida complet | Rèpliques de lectura, còpies programades, restauració, escalat, canvi de configuració sense aturada |
| 4 | Observabilitat profunda | Exposa mètriques, alertes, i el status reflecteix l'estat real de replicació amb el seu retard |
| 5 | Pilot automàtic | Detecta el primari caigut i commuta sol, ajusta paràmetres segons la càrrega, repara rèpliques corruptes, escala per si mateix |
Detall del que aporta cada salt:
Nivell 1 → 2: l'actualització deixa de ser un procediment manual. L'operador sap que cal actualitzar primer les rèpliques i promoure després, i que cal esperar que cadascuna se sincronitzi.
Nivell 2 → 3: aquí hi ha el gruix del valor. Còpies programades amb verificació, restauració a un instant concret, afegir una rèplica de lectura amb un sol canvi al spec, canviar shared_buffers sense perdre connexions.
Nivell 3 → 4: l'operador deixa de ser una caixa negra. Publica mètriques del retard de replicació, de la mida de la base i de l'estat de les còpies, i el seu status diu la veritat sobre el que està passant.
Nivell 4 → 5: la commutació per error automàtica. És el nivell que separa "m'estalvia feina" de "puc dormir tranquil". També el més difícil i el que més cura requereix: un operador que commuta malament pot provocar un split brain amb dos primaris acceptant escriptures.
En avaluar un operador, pregunta pel nivell. Molts projectes vistosos es queden al 2, i per al nivell 2 no compensa la complexitat afegida: això ja ho fa un chart de Helm.
- Operadors que ja has fet servir en aquest curs
Sense anomenar-los així, portem diversos mòduls fent servir operadors.
cert-manager (lliçó 04-05)
Quan vam aplicar un Certificate per a www.rutasnorte.example, va passar el següent sense que fes falta res més:
- El controlador de cert-manager va observar el nou objecte
Certificate. - Va crear un
CertificateRequest, va generar una clau privada i la va desar en un Secret. - Va crear un
Orderi unChallengeper al protocol ACME. - Va publicar un Ingress temporal amb el token del desafiament HTTP-01.
- Va esperar que Let's Encrypt validés, va obtenir el certificat i el va escriure al Secret.
- I des de llavors vigila la data de caducitat i repeteix el procés 30 dies abans que expiri.
El pas 6 és la definició d'operador. Un script hauria fet els passos 1 a 5; només un controlador que corre sempre fa el 6. En capacitats, cert-manager és al nivell 5 per al seu domini: renova sense intervenció humana.
NAME READY STATUS RESTARTS AGE
cert-manager-6d8f7c9b54-p2m4x 1/1 Running 0 24d
cert-manager-cainjector-7b9d4f8c6-k7t2v 1/1 Running 0 24d
cert-manager-webhook-59c8d7b64-w3n8q 1/1 Running 0 24dTres Deployments: el controlador, un injector de certificats de CA i el webhook de validació dels seus CRD. És l'anatomia típica d'un operador madur.
El snapshot-controller (lliçó 05-05)
Quan vam crear un VolumeSnapshot de les dades de reserves, un controlador el va observar, va parlar amb el driver CSI, va crear el VolumeSnapshotContent corresponent i va actualitzar status.readyToUse. Mateix patró: CRD més controlador.
Velero (lliçó 05-06)
Els seus Backup, Restore i Schedule són CRD, i el seu controlador és qui executa les còpies, aplica els hooks abans i després, i respecta la retenció. Un Schedule de Velero és un operador creant Jobs, conceptualment igual al que fa el controlador de CronJob amb el nostre informes-ocupacio.
Prometheus Operator (lliçó 07-03)
El que ve. Els seus CRD Prometheus, ServiceMonitor, PodMonitor i PrometheusRule permeten declarar "recull mètriques de tots els Services amb aquesta etiqueta" i l'operador genera i recarrega la configuració de Prometheus. Sense ell, afegir un objectiu nou significa editar a mà un fitxer de configuració de centenars de línies.
I els controladors natius
Convé tancar el cercle: el controlador de Deployments, el de ReplicaSets i el de Jobs funcionen exactament igual. Observen objectes, comparen desitjat amb observat i actuen. L'única diferència és que els seus tipus vénen de fàbrica i el seu codi va dins del kube-controller-manager en comptes d'en un Deployment a part. El patró és idèntic; els operadors només l'estenen a dominis que Kubernetes no coneix.
- Cas pràctic: un operador de PostgreSQL per a
postgres-reserves
postgres-reservesArribem al deute de 06-01. El nostre StatefulSet artesanal té una rèplica i no sap fer res més. El substituirem per CloudNativePG, un operador de PostgreSQL madur i de codi obert.
Instal·lar l'operador
kubectl apply --server-side -f \
https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.24/releases/cnpg-1.24.1.yaml
kubectl get deployment -n cnpg-system
kubectl get crds | grep postgresqlNAME READY UP-TO-DATE AVAILABLE AGE
cnpg-controller-manager 1/1 1 1 47s
backups.postgresql.cnpg.io 2026-08-05T21:40:11Z
clusters.postgresql.cnpg.io 2026-08-05T21:40:11Z
poolers.postgresql.cnpg.io 2026-08-05T21:40:11Z
scheduledbackups.postgresql.cnpg.io 2026-08-05T21:40:12ZUn Deployment (el controlador) i quatre CRD (el vocabulari). Exactament les dues peces de la definició.
Declarar el clúster
Ara postgres-reserves es descriu així:
# k8s/base/postgres-reserves-cluster.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: postgres-reserves
namespace: rutas-norte-pro
labels:
app: postgres-reserves
app.kubernetes.io/part-of: rutas-norte
entorn: pro
spec:
instances: 3 # un primari i dues rèpliques
imageName: ghcr.io/cloudnative-pg/postgresql:16.4
primaryUpdateStrategy: unsupervised # l'operador commuta sol en actualitzar
bootstrap:
initdb:
database: reserves
owner: rutasnorte
secret:
name: postgres-reserves-credencials
localeCollate: es_ES.UTF-8
localeCType: es_ES.UTF-8
storage:
size: 20Gi
storageClass: rutasnorte-rapida
walStorage: # WAL en volum separat: millor rendiment
size: 5Gi
storageClass: rutasnorte-rapida
postgresql:
parameters:
max_connections: "200"
shared_buffers: "512MB"
work_mem: "8MB"
log_min_duration_statement: "500" # registrar consultes de més de 500 ms
resources:
requests:
cpu: "1"
memory: 2Gi
limits:
cpu: "1"
memory: 2Gi # requests == limits: QoS Guaranteed (03-05)
affinity:
enablePodAntiAffinity: true
topologyKey: kubernetes.io/hostname
podAntiAffinityType: required # mai dues instàncies al mateix node (06-05)
nodeSelector:
disc: ssd
monitoring:
enablePodMonitor: true # mètriques per a Prometheus (07-03)
backup:
retentionPolicy: "30d"
barmanObjectStore:
destinationPath: "s3://rutasnorte-copies/postgres-reserves"
s3Credentials:
accessKeyId:
name: copies-credencials
key: ACCESS_KEY_ID
secretAccessKey:
name: copies-credencials
key: SECRET_ACCESS_KEY
wal:
compression: gzip
maxParallel: 4
data:
compression: gzip
immediateCheckpoint: false
---
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
name: postgres-reserves-copia-nocturna
namespace: rutas-norte-pro
labels:
app: postgres-reserves
app.kubernetes.io/part-of: rutas-norte
entorn: pro
spec:
schedule: "0 30 2 * * *" # 02:30 (format de 6 camps, amb segons)
backupOwnerReference: self
cluster:
name: postgres-reservesNAME AGE INSTANCES READY STATUS PRIMARY
postgres-reserves 3m42s 3 3 Cluster in healthy state postgres-reserves-1NAME READY STATUS RESTARTS AGE
pod/postgres-reserves-1 1/1 Running 0 3m
pod/postgres-reserves-2 1/1 Running 0 2m
pod/postgres-reserves-3 1/1 Running 0 2m
NAME TYPE CLUSTER-IP PORT(S)
service/postgres-reserves-rw ClusterIP 10.96.201.14 5432/TCP
service/postgres-reserves-ro ClusterIP 10.96.188.77 5432/TCP
service/postgres-reserves-r ClusterIP 10.96.140.22 5432/TCPEls tres Services són la peça que un StatefulSet no pot donar:
| Service | Apunta a | Ús a Rutas Norte |
|---|---|---|
-rw |
Només el primari actual | Escriptures d'api-reserves |
-ro |
Només les rèpliques | Consultes d'informes-ocupacio |
-r |
Qualsevol instància | Lectures que toleren retard |
I el més essencial: quan el primari canvia, l'operador reescriu el Service -rw perquè apunti al nou. L'aplicació no se n'assabenta. Això és exactament el que un StatefulSet no sap fer, perquè el seu Service headless dona noms estables però no sap quin d'aquests noms és el primari.
La prova de foc: matar el primari
kubectl delete pod postgres-reserves-1 -n rutas-norte-pro
kubectl get cluster postgres-reserves -n rutas-norte-pro -wNAME INSTANCES READY STATUS PRIMARY
postgres-reserves 3 2 Failing over to postgres-reserves-2 postgres-reserves-1
postgres-reserves 3 2 Cluster in healthy state postgres-reserves-2
postgres-reserves 3 3 Cluster in healthy state postgres-reserves-2En qüestió de segons: es detecta la caiguda, es promou postgres-reserves-2, es reescriu el Service -rw, i la instància caiguda torna reincorporada com a rèplica. Sense intervenció humana, sense runbook, sense trucada a les tres de la matinada.
Què resol l'operador que hauries de fer a mà
| Capacitat | Amb StatefulSet artesanal (06-01) | Amb operador |
|---|---|---|
| Elecció de primari | Decidir per convenció que és l'ordinal 0 | L'operador ho decideix, ho registra al status i ho publica |
| Commutació per error | Detectar la caiguda, promoure, reconfigurar rèpliques, canviar el Service: tot manual | Automàtica en segons |
| Rèpliques de lectura | pg_basebackup a mà, primary_conninfo, ranures de replicació |
instances: 3 |
| Encaminament lectura/escriptura | Res: un sol Service per a tot | Services -rw, -ro i -r mantinguts al dia |
| Còpies programades | CronJob amb pg_dump (05-06) |
ScheduledBackup amb WAL continu |
| Recuperació a un instant | Impossible sense arxivat de WAL muntat a mà | recoveryTarget.targetTime |
| Actualització de versió menor | Editar la imatge i confiar | Rèpliques primer, commutació, primari després |
| Actualització de versió major | Bolcat, restauració i hores d'aturada | Procediment guiat per l'operador |
| Ampliar el disc | kubectl patch de cada PVC (05-05) |
Canviar storage.size |
| Ranures de replicació | Configuració manual, i es trenquen en recrear pods | Gestionades |
| Sondes de salut reals | pg_isready, que no distingeix primari de rèplica |
L'operador coneix el rol de cada instància |
| Mètriques | Afegir un sidecar exportador (06-04) | enablePodMonitor: true |
Aquest és l'argument sencer a favor dels operadors per a programari amb estat: la columna de l'esquerra són setmanes de feina, procediments fràgils i guàrdies nocturnes; la de la dreta són camps d'un YAML.
Migrar sense perdre les dades
Amb el que hem après a 05-06 i 06-01, la migració des del nostre StatefulSet es fa per restauració lògica, no movent volums:
spec:
bootstrap:
initdb:
database: reserves
owner: rutasnorte
import:
type: microservice
databases: ["reserves"]
source:
externalCluster: statefulset-antic
externalClusters:
- name: statefulset-antic
connectionParameters:
host: postgres-reserves-nodes.rutas-norte-pro.svc.cluster.local
user: rutasnorte
dbname: reserves
password:
name: postgres-reserves-credencials
key: passwordL'operador arrenca, es connecta al StatefulSet antic, importa la base i munta el clúster nou. Després es verifica el nombre de reserves, s'apunta api-reserves al Service -rw i es retira el StatefulSet.
- On trobar operadors i què mirar abans d'adoptar-ne un
On buscar
| Font | Què conté |
|---|---|
| OperatorHub.io | Catàleg comunitari amb nivell de capacitats declarat |
| Artifact Hub | Charts de Helm i operadors, amb recompte de descàrregues |
| Repositori oficial del projecte | Gairebé sempre la font més fiable i actualitzada |
| Marketplace del teu proveïdor cloud | Operadors validats per a EKS, AKS o GKE (10-06) |
La llista de comprovació
Adoptar un operador és adoptar una dependència que tindrà permisos amplis sobre el teu clúster i de la qual dependran les teves dades. Abans d'instal·lar-lo:
1. Manteniment. Quan va ser l'últim commit? Quantes persones hi contribueixen? Hi ha releases regulars? Quantes incidències obertes sense resposta? Un operador abandonat que gestiona la teva base de dades és un problema seriós, perquè desinstal·lar-lo sense perdre dades poques vegades és trivial.
2. Permisos RBAC que demana. Aquest és el punt que més es passa per alt. Mira'l abans d'aplicar el manifest:
Preguntes: demana cluster-admin? (senyal d'alarma immediata) Demana accés a tots els Secrets del clúster? Pot crear ClusterRoleBindings, és a dir, ampliar-se els seus propis permisos? Un operador compromès amb permisos amplis equival a un clúster compromès. Hi tornarem a 08-01.
3. Maduresa. Quin nivell de capacitats declara i quin compleix de veritat? Hi ha casos d'ús en producció documentats? Existeix una guia d'actualització entre versions del mateix operador?
4. Què passa si el desinstal·les. La pregunta decisiva:
- Els objectes que gestionava continuen funcionant o s'aturen?
- Els seus CRD tenen finalitzadors que deixarien objectes penjats en
Terminating? - Pots exportar les dades a un format estàndard?
- Hi ha un procediment documentat de sortida?
Un operador del qual no es pot sortir és un segrest tecnològic. Amb CloudNativePG, per exemple, les còpies en format Barman són restaurables amb eines estàndard de PostgreSQL: hi ha porta de sortida.
5. Recursos i abast. Quanta CPU i memòria consumeix el controlador? Vigila tot el clúster o es pot limitar a namespaces concrets? Un operador que observa tots els objectes d'un clúster gran pot consumir força memòria.
6. Model d'actualització. Com s'actualitza l'operador sense afectar el que gestiona? És compatible cap enrere amb els CRD ja desplegats?
- Com se n'escriu un: Kubebuilder i
controller-runtime
controller-runtimeNingú no escriu un operador des de zero. Les eines estàndard:
| Eina | Què aporta |
|---|---|
controller-runtime |
Biblioteca de Go amb informers, cues, client amb memòria cau i gestor de controladors |
| Kubebuilder | Bastida: genera el projecte, els CRD des d'structs de Go, l'RBAC i el desplegament |
| Operator SDK | Embolcalla Kubebuilder i afegeix Helm i Ansible com a alternatives a Go |
Amb Operator SDK es pot construir un operador sense escriure Go, fent servir un chart de Helm o un playbook d'Ansible com a lògica de reconciliació. És una via raonable per a operadors senzills, encara que limita les capacitats als nivells 1 i 2.
Arrencada d'un projecte
mkdir -p ~/projectes/operador-rutasnorte && cd ~/projectes/operador-rutasnorte
kubebuilder init \
--domain rutasnorte.example \
--repo github.com/rutasnorte/operador-rutasnorte
kubebuilder create api \
--group rutasnorte \
--version v1 \
--kind RutaProgramada \
--resource --controllerEstructura generada:
api/v1/rutaprogramada_types.go <- els tipus Go: d'aquí surt el CRD
internal/controller/rutaprogramada_controller.go <- aquí va Reconcile
config/crd/bases/ <- CRD generats amb "make manifests"
config/rbac/ <- Rols generats des dels marcadors
config/samples/ <- exemples d'instàncies
Makefile <- make manifests, make docker-build, make deployEls tipus, dels quals surt el CRD
El CRD de la lliçó 06-06 no s'escriu a mà en un projecte real: es genera a partir d'structs de Go anotats.
// api/v1/rutaprogramada_types.go
type RutaProgramadaSpec struct {
// Codi comercial de la ruta, format XX-999 (ex. RN-041)
// +kubebuilder:validation:Pattern=`^[A-Z]{2}-[0-9]{3}$`
Codi string `json:"codi"`
// Ciutat d'origen del trajecte
// +kubebuilder:validation:MinLength=2
// +kubebuilder:validation:MaxLength=60
Origen string `json:"origen"`
// Ciutat de destí del trajecte
// +kubebuilder:validation:MinLength=2
// +kubebuilder:validation:MaxLength=60
Desti string `json:"desti"`
// Places totals ofertes a cada sortida
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=90
Places int32 `json:"places"`
// Hores de sortida diàries en format HH:MM
// +kubebuilder:validation:MinItems=1
// +kubebuilder:validation:items:Pattern=`^([01][0-9]|2[0-3]):[0-5][0-9]$`
Horaris []string `json:"horaris"`
// Si la ruta admet vendes actualment
// +kubebuilder:default=true
// +optional
Activa bool `json:"activa,omitempty"`
}
type RutaProgramadaStatus struct {
// +optional
Fase string `json:"fase,omitempty"`
// +optional
PlacesVenudes int32 `json:"placesVenudes,omitempty"`
// Generació del spec que el controlador va processar per últim cop
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// +optional
Condicions []metav1.Condition `json:"condicions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:shortName=ruta;rutes,categories=rutasnorte
// +kubebuilder:printcolumn:name="Codi",type=string,JSONPath=`.spec.codi`
// +kubebuilder:printcolumn:name="Origen",type=string,JSONPath=`.spec.origen`
// +kubebuilder:printcolumn:name="Desti",type=string,JSONPath=`.spec.desti`
// +kubebuilder:printcolumn:name="Fase",type=string,JSONPath=`.status.fase`
type RutaProgramada struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec RutaProgramadaSpec `json:"spec,omitempty"`
Status RutaProgramadaStatus `json:"status,omitempty"`
}Els comentaris // +kubebuilder:... són marcadors que el generador tradueix a l'esquema OpenAPI del CRD. make manifests produeix exactament el YAML que vam escriure a mà a 06-06, i els comentaris normals es converteixen en les description que alimenten kubectl explain.
L'esquelet de Reconcile
Aquest és el cor de l'operador de RutaProgramada: cada ruta activa ha de tenir el seu propi CronJob que generi l'informe d'ocupació d'aquella ruta.
// internal/controller/rutaprogramada_controller.go
// Permisos que necessita el controlador. Aquests marcadors generen config/rbac/role.yaml
// +kubebuilder:rbac:groups=rutasnorte.rutasnorte.example,resources=rutesprogramades,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=rutasnorte.rutasnorte.example,resources=rutesprogramades/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=rutasnorte.rutasnorte.example,resources=rutesprogramades/finalizers,verbs=update
// +kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
func (r *RutaProgramadaReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := logf.FromContext(ctx)
// ── 1. LLEGIR L'ESTAT DESITJAT ───────────────────────────────────────
var ruta rutasnortev1.RutaProgramada
if err := r.Get(ctx, req.NamespacedName, &ruta); err != nil {
// NotFound significa que la ruta s'ha esborrat. No hi ha res a fer:
// els objectes derivats s'esborren sols per ownerReferences (apartat 9).
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// ── 2. RUTA INACTIVA: retirar el que s'hagués creat ──────────────────
if !ruta.Spec.Activa {
log.Info("Ruta inactiva; no es manté el CronJob d'informes", "codi", ruta.Spec.Codi)
return ctrl.Result{}, r.actualitzarEstat(ctx, &ruta, "Cancelada")
}
// ── 3. CONSTRUIR L'ESTAT DESITJAT ────────────────────────────────────
// Funció pura: mateixes dades d'entrada, mateix objecte de sortida SEMPRE.
// Aquesta puresa és el que fa possible la comparació del pas 5.
desitjat := r.construirCronJobInforme(&ruta)
// ── 4. ownerReferences: el CronJob pertany a la ruta ─────────────────
// En esborrar la ruta, el recol·lector d'escombraries esborra el CronJob (apartat 9).
if err := ctrl.SetControllerReference(&ruta, desitjat, r.Scheme); err != nil {
return ctrl.Result{}, err
}
// ── 5. RECONCILIAR: crear si falta, actualitzar si difereix, res si coincideix ──
var actual batchv1.CronJob
err := r.Get(ctx, client.ObjectKeyFromObject(desitjat), &actual)
switch {
case apierrors.IsNotFound(err):
log.Info("Creant CronJob d'informes", "ruta", ruta.Spec.Codi)
if err := r.Create(ctx, desitjat); err != nil {
// Retornar l'error fa que la cua reencui amb retrocés exponencial
return ctrl.Result{}, err
}
r.Recorder.Eventf(&ruta, corev1.EventTypeNormal, "CronJobCreat",
"Creat el CronJob d'informes de la ruta %s", ruta.Spec.Codi)
case err != nil:
return ctrl.Result{}, err
default:
// IDEMPOTÈNCIA: si res no ha canviat, NO escriure. Escriure aquí provocaria
// un esdeveniment, que provocaria una altra reconciliació: bucle infinit.
if !equality.Semantic.DeepDerivative(desitjat.Spec, actual.Spec) {
log.Info("Actualitzant CronJob d'informes", "ruta", ruta.Spec.Codi)
actual.Spec = desitjat.Spec
if err := r.Update(ctx, &actual); err != nil {
return ctrl.Result{}, err
}
}
}
// ── 6. OBSERVAR EL MÓN REAL I ESCRIURE EL STATUS ─────────────────────
venudes, err := r.consultarPlacesVenudes(ctx, &ruta)
if err != nil {
// Fallada transitòria consultant la base de dades: reintentar d'aquí a un minut
// sense marcar la reconciliació com a fallida.
log.Error(err, "no s'han pogut consultar les places venudes")
return ctrl.Result{RequeueAfter: time.Minute}, nil
}
ruta.Status.Fase = "Activa"
ruta.Status.PlacesVenudes = venudes
ruta.Status.ObservedGeneration = ruta.Generation // marca de "ja processat"
meta.SetStatusCondition(&ruta.Status.Condicions, metav1.Condition{
Type: "InformesProgramats",
Status: metav1.ConditionTrue,
Reason: "CronJobActiu",
Message: fmt.Sprintf("Informes de la ruta %s programats", ruta.Spec.Codi),
})
// Escriptura pel SUBRECURS status: no toca el spec de l'usuari (06-06)
if err := r.Status().Update(ctx, &ruta); err != nil {
return ctrl.Result{}, err
}
// ── 7. RECONCILIACIÓ PERIÒDICA ───────────────────────────────────────
// Encara que no hi hagi esdeveniments, revisar cada 10 minuts: detecta canvis
// externs i desviacions que no van generar notificació.
return ctrl.Result{RequeueAfter: 10 * time.Minute}, nil
}
func (r *RutaProgramadaReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&rutasnortev1.RutaProgramada{}).
// Observar també els CronJobs propis: si algú n'esborra un a mà,
// es dispara una reconciliació de la seva ruta i es recrea.
Owns(&batchv1.CronJob{}).
Complete(r)
}Els set passos són l'esquelet de qualsevol operador, sigui de rutes d'autobús o de PostgreSQL: llegir el desitjat, contemplar el cas d'esborrat, construir el que hauria d'existir, marcar la propietat, crear o actualitzar només si cal, observar la realitat i escriure el status, i decidir quan tornar a mirar.
L'Owns(&batchv1.CronJob{}) de SetupWithManager mereix un comentari: fa que el controlador rebi esdeveniments dels CronJobs que ell va crear, i els tradueixi a reconciliacions de la RutaProgramada propietària. És el que fa que esborrar el CronJob a mà provoqui la seva recreació en segons. Sense aquesta línia, l'operador només reaccionaria a canvis en els seus propis recursos personalitzats.
Els permisos RBAC
Els marcadors +kubebuilder:rbac: de dalt generen aquest ClusterRole:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: operador-rutasnorte-manager-role
rules:
- apiGroups: ["rutasnorte.rutasnorte.example"]
resources: ["rutesprogramades"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["rutasnorte.rutasnorte.example"]
resources: ["rutesprogramades/status"]
verbs: ["get", "update", "patch"]
- apiGroups: ["batch"]
resources: ["cronjobs"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]Observa el principi de mínim privilegi: l'operador només pot tocar CronJobs i els seus propis recursos. No pot llegir Secrets, ni crear Deployments, ni tocar res més. Quan avaluïs un operador aliè (apartat 7), això és exactament el que has de mirar i comparar amb el que l'operador diu que fa. RBAC en detall és la lliçó 08-01.
Executar i desplegar
# Generar CRD i RBAC a partir dels marcadors
make manifests generate
# Instal·lar els CRD al clúster
make install
# Executar el controlador LOCALMENT contra el clúster: el cicle de desenvolupament
make runINFO setup starting manager
INFO Starting EventSource {"controller": "rutaprogramada", "source": "kind source: *v1.RutaProgramada"}
INFO Starting Controller {"controller": "rutaprogramada"}
INFO Creant CronJob d'informes {"ruta": "RN-041"}# I per a producció: imatge i desplegament dins del clúster
make docker-build docker-push IMG=registry.rutasnorte.example/operador-rutasnorte:0.1.0
make deploy IMG=registry.rutasnorte.example/operador-rutasnorte:0.1.0make run és l'avantatge pràctic d'aquest model: el controlador corre al teu portàtil, amb depurador si cal, parlant amb el clúster real. No cal construir imatges per a cada iteració.
ownerReferences i l'esborrat en cascada
ownerReferences i l'esborrat en cascadaA la lliçó 02-02 vam veure que un ReplicaSet posa ownerReferences als seus pods, i que per això esborrar un Deployment esborra tot el que hi ha a sota. Els operadors fan servir el mateix mecanisme, i és imprescindible entendre'l.
if err := ctrl.SetControllerReference(&ruta, desitjat, r.Scheme); err != nil {
return ctrl.Result{}, err
}Aquesta línia escriu al CronJob generat:
ownerReferences:
- apiVersion: rutasnorte.rutasnorte.example/v1
kind: RutaProgramada
name: rn-041-bilbao-santander
uid: 3f1a9c04-8e2b-4c71-9a55-71b0e2d8c4f3
controller: true
blockOwnerDeletion: trueConseqüències:
- Esborrat en cascada automàtic. En esborrar la
RutaProgramada, el recol·lector d'escombraries de Kubernetes esborra el CronJob. L'operador no ha d'escriure codi de neteja: el mateix clúster se n'ocupa. - La propietat és visible.
kubectl describe cronjobmostraControlled By: RutaProgramada/rn-041-bilbao-santander, cosa que fa traçable d'on va sortir cada objecte. - Esdeveniments cap al propietari. Gràcies a
Owns(), canvis al CronJob provoquen reconciliacions de la ruta.
Dues restriccions importants:
- El propietari i l'objecte posseït han d'estar al mateix namespace. Un objecte de namespace no pot ser propietat d'un altre d'un namespace diferent.
- Un objecte de clúster no pot ser propietat d'un de namespace. Si el teu operador crea ClusterRoles o PersistentVolumes, els hauràs de netejar tu.
Finalitzadors: neteja fora del clúster
Quan l'operador crea recursos externs —un bucket, un registre DNS, una base de dades en un proveïdor— les ownerReferences no serveixen: el recol·lector d'escombraries de Kubernetes no sap res d'aquests recursos.
Per a això hi ha els finalitzadors, el mateix mecanisme que vam veure protegint els PVC a 05-03:
const finalitzador = "rutasnorte.example/netejar-recursos-externs"
if !ruta.DeletionTimestamp.IsZero() {
// L'objecte està marcat per esborrar però NO s'ha esborrat:
// el finalitzador el reté fins que el traiem.
if controllerutil.ContainsFinalizer(&ruta, finalitzador) {
if err := r.esborrarRecursosExterns(ctx, &ruta); err != nil {
return ctrl.Result{}, err // ho reintentarà; l'objecte continua retingut
}
controllerutil.RemoveFinalizer(&ruta, finalitzador)
if err := r.Update(ctx, &ruta); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil // ara sí, Kubernetes completa l'esborrat
}
// Objecte viu: assegurar que té el finalitzador
if !controllerutil.ContainsFinalizer(&ruta, finalitzador) {
controllerutil.AddFinalizer(&ruta, finalitzador)
if err := r.Update(ctx, &ruta); err != nil {
return ctrl.Result{}, err
}
}Advertiment operatiu de primer ordre: si l'operador deixa de funcionar i hi ha objectes amb el seu finalitzador, aquests objectes es queden en Terminating per sempre. kubectl delete es queda penjat i no hi ha manera neta de sortir-ne llevat d'editar l'objecte i treure el finalitzador a mà:
kubectl patch rutaprogramada rn-041-bilbao-santander -n rutas-norte-pro \
--type=merge -p '{"metadata":{"finalizers":null}}'Això deixa els recursos externs orfes, que caldrà netejar per una altra via. És una de les raons de la pregunta "què passa si el desinstal·lo?" de l'apartat 7: desinstal·la sempre l'operador després d'esborrar els seus objectes, mai abans.
- Quan NO escriure un operador
Escriure un operador és divertit i gairebé sempre innecessari. Abans de començar, passa aquest filtre.
No l'escriguis si la teva aplicació no té estat
Un Deployment, un Service, un HorizontalPodAutoscaler i un ConfigMap cobreixen api-reserves, botiga-web i worker-notificacions completament. Un operador no hi afegiria res: el controlador de Deployments ja fa la reconciliació.
Els operadors brillen amb programari amb estat i amb procediments operatius complexos: bases de dades, cues de missatges, sistemes de consens, magatzems distribuïts. Si la teva aplicació es reinicia sense conseqüències, no en necessites cap.
No l'escriguis si Helm o Kustomize en tenen prou
| Necessitat | Eina |
|---|---|
| Desplegar amb valors diferents per entorn | Helm (10-03) o Kustomize (10-04) |
| Aplicar automàticament el que hi ha a Git | GitOps amb Argo CD o Flux (10-05) |
| Reaccionar a fallades i executar procediments contínuament | Operador |
| Executar alguna cosa periòdicament | CronJob (06-03) |
| Validar o modificar objectes a l'admissió | Webhook, no operador |
La pregunta discriminant: "què ha de passar quan alguna cosa es trenca a les tres de la matinada?" Si la resposta és "res, el Deployment ho recrea", no necessites operador. Si és "cal promoure una rèplica, reconfigurar l'encaminament i avisar", allà sí que hi ha un operador.
No l'escriguis si ja n'existeix un
Per a PostgreSQL, MySQL, Redis, Kafka, MongoDB, Elasticsearch, RabbitMQ i pràcticament qualsevol programari conegut ja existeixen operadors madurs, mantinguts per equips que hi porten anys. Escriure el teu significa reimplementar pitjor el que altres ja van resoldre, i mantenir-lo tu sol.
El cost real de mantenir un operador
El que la gent subestima:
- És un servei de producció amb permisos privilegiats. Necessita desplegament, actualitzacions, monitoratge, alertes i guàrdies.
- Una fallada a l'operador afecta tot el que gestiona. Un
Reconcileamb un error pot esborrar objectes en cascada a tot el clúster. Hi ha incidents públics famosos per això. - Els CRD són una API pública amb les obligacions de compatibilitat que vam veure a 06-06. Canviar l'esquema després és car.
- Provar un operador és difícil. Calen proves amb
envtesto un clúster efímer, i la lògica de reconciliació té molts camins possibles. - Requereix Go i coneixement profund de Kubernetes a l'equip, a perpetuïtat, no només mentre s'escriu.
Una regla d'or assenyada:
Escriu un operador quan tinguis un procediment operatiu documentat, repetitiu, que s'executa sovint i que s'executa malament quan el fa una persona cansada. Si no pots escriure aquell runbook amb precisió, tampoc no el podràs codificar.
Alternatives més barates
| En comptes d'un operador | Prova primer |
|---|---|
| Automatitzar un desplegament | Helm + GitOps (10-03, 10-05) |
| Executar alguna cosa periòdicament | CronJob (06-03) |
| Reaccionar a un esdeveniment puntual | Un Job disparat des del pipeline de CI |
| Afegir configuració als pods | Webhook mutant o initContainer (06-04) |
| Gestionar una base de dades | Un operador existent o un servei gestionat (10-06) |
| Validar manifests | Polítiques amb Kyverno o esquemes a CI |
Errors Comuns i Consells
El bucle infinit de reconciliació. L'error número u: Reconcile escriu a l'API a cada passada encara que res no hagi canviat, l'escriptura genera un esdeveniment, l'esdeveniment dispara una altra reconciliació. Símptoma: milers d'update per minut sobre el mateix objecte. Solució: comparar abans d'escriure, amb DeepDerivative o similar.
Desar estat en memòria. Variables globals, mapes de "ja ho he fet". Es perden en reiniciar i es dessincronitzen del món real. Tot l'estat va a .status, en anotacions o als objectes gestionats.
Escriure .status sense el subrecurs. Un Update de l'objecte complet pot sobreescriure un canvi de spec que l'usuari acaba de fer. Fes servir sempre r.Status().Update() amb el subrecurs activat.
Oblidar ownerReferences. Els objectes creats per l'operador queden orfes en esborrar el recurs principal. Amb el temps el clúster s'omple de CronJobs, Services i Secrets que ningú no sap d'on van sortir.
Desinstal·lar l'operador abans d'esborrar els seus objectes. Si fa servir finalitzadors, els objectes es queden en Terminating per sempre. Ordre correcte: esborrar els objectes, comprovar que desapareixen, i només llavors desinstal·lar l'operador.
Adoptar un operador que demana cluster-admin. És una porta oberta a tot el clúster. Revisa sempre el ClusterRole abans d'aplicar el manifest, i desconfia de qualsevol que demani accés a tots els Secrets sense justificar-ho.
Confondre "té CRD" amb "és un operador". Un CRD sense controlador és una base de dades amb formulari, com vam veure a 06-06. Comprova que hi ha un Deployment corrent i que escriu al status dels objectes.
No llegir el nivell de capacitats. Un operador de nivell 2 no et salvarà d'una commutació per error nocturna. Si l'adoptes creient que arriba al 5, la sorpresa arribarà en el pitjor moment.
Consell: fes servir make run en desenvolupament. El controlador corre al teu portàtil contra el clúster real, amb depurador. El cicle d'iteració passa de minuts a segons.
Consell: emet esdeveniments. r.Recorder.Eventf(...) fa que les accions de l'operador apareguin al kubectl describe de l'objecte. És la diferència entre un operador que es pot diagnosticar i un que és una caixa negra.
Consell: registra observedGeneration. Permet a qualsevol saber si el controlador ha processat l'últim canvi del spec, i és la base perquè kubectl wait funcioni de manera fiable sobre els teus recursos.
Consell: prova matant el controlador. Esborra'l enmig d'una operació i comprova que en tornar ho reprèn correctament. Si no ho fa, tens estat en memòria o falta idempotència.
Exercicis
Exercici 1: identificar operadors al teu clúster
Al teu minikube (perfil rutas-norte), identifica quins operadors hi ha instal·lats. Per a cadascun, determina: quin CRD aporta, on corre el seu controlador, i quins permisos de ClusterRole té. Després raona per què cert-manager és un operador i el controlador de Deployments, sent el mateix patró, no se'n diu així.
Exercici 2: la diferència entre reconciliar i desplegar
Demostra experimentalment que un controlador reconcilia contínuament i un desplegament no:
- Crea un Deployment
demo-reconciliacioamb 3 rèpliques arutas-norte-dev. - Esborra un pod i observa què passa i en quant de temps.
- Canvia manualment la imatge d'un pod amb
kubectl edit podi observa el resultat. - Explica quin controlador va actuar en cada cas i amb quina informació.
Exercici 3: dissenyar un operador (sense escriure'l)
Rutas Norte vol un recurs EntornProves que, en crear-se, provoqui automàticament: un namespace propi, una restauració de postgres-reserves des de l'últim snapshot, un desplegament d'api-reserves apuntant a aquella base de dades, i l'esborrat complet als 7 dies.
Sense escriure codi, dissenya:
- El
speci elstatusdel CRD (camps i tipus). - Els passos de la funció
Reconcile, en ordre. - Els permisos RBAC mínims que necessitaria.
- Què faries servir per a l'esborrat als 7 dies i per què.
- Si caldria un finalitzador, i per a què.
Solucions
Solució 1
# Quins CRD hi ha i de qui són
kubectl get crds -o custom-columns=NOM:.metadata.name,GRUP:.spec.group | sort -k2NOM GRUP
certificaterequests.cert-manager.io cert-manager.io
certificates.cert-manager.io cert-manager.io
clusterissuers.cert-manager.io cert-manager.io
issuers.cert-manager.io cert-manager.io
volumesnapshotclasses.snapshot.storage.k8s.io snapshot.storage.k8s.io
volumesnapshotcontents.snapshot.storage.k8s.io snapshot.storage.k8s.io
volumesnapshots.snapshot.storage.k8s.io snapshot.storage.k8s.io# On corren els controladors
kubectl get deployments -A | grep -Ei 'cert-manager|snapshot|controller'cert-manager cert-manager 1/1 1 1 24d
cert-manager cert-manager-cainjector 1/1 1 1 24d
cert-manager cert-manager-webhook 1/1 1 1 24d
kube-system snapshot-controller 1/1 1 1 17d# Quins permisos té cert-manager
kubectl get clusterrole -l app.kubernetes.io/instance=cert-manager \
-o custom-columns=NOM:.metadata.name --no-headers | head -5
kubectl describe clusterrole cert-manager-controller-certificates | head -20Name: cert-manager-controller-certificates
PolicyRule:
Resources Verbs
--------- -----
certificaterequests.cert-manager.io [create delete get list patch update watch]
certificates.cert-manager.io [get list patch update watch]
certificates.cert-manager.io/status [patch update]
secrets [create delete get list patch update watch]
events [create patch]Permisos acotats: els seus propis CRD, més Secrets (necessaris: hi desa claus i certificats) i esdeveniments. No demana cluster-admin.
Per què cert-manager se'n diu operador i el controlador de Deployments no, sent el mateix patró:
La diferència no és tècnica, és d'origen. Tots dos són bucles de reconciliació sobre tipus de l'API. El controlador de Deployments gestiona un tipus natiu i el seu codi viu dins del kube-controller-manager, un binari del pla de control. cert-manager gestiona tipus afegits mitjançant CRD i corre com una càrrega de treball més del clúster.
El terme "operador" designa aquesta segona situació: el patró controlador aplicat a un domini que Kubernetes no coneix de fàbrica, empaquetat com a aplicació desplegable. Conceptualment, el controlador de Deployments és un operador de Deployments; simplement ningú no en diu així perquè ve inclòs.
Solució 2
# /tmp/demo-reconciliacio.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo-reconciliacio
namespace: rutas-norte-dev
labels:
app: demo-reconciliacio
app.kubernetes.io/part-of: rutas-norte
entorn: dev
spec:
replicas: 3
selector:
matchLabels:
app: demo-reconciliacio
entorn: dev
template:
metadata:
labels:
app: demo-reconciliacio
app.kubernetes.io/part-of: rutas-norte
entorn: dev
spec:
automountServiceAccountToken: false
containers:
- name: nginx
image: nginx:1.27.2-alpine
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mikubectl apply -f /tmp/demo-reconciliacio.yaml
kubectl get pods -n rutas-norte-dev -l app=demo-reconciliacioNAME READY STATUS RESTARTS AGE
demo-reconciliacio-6b4d8f7c9-h2k4x 1/1 Running 0 22s
demo-reconciliacio-6b4d8f7c9-p8m2v 1/1 Running 0 22s
demo-reconciliacio-6b4d8f7c9-t5n7q 1/1 Running 0 22s2. Esborrar un pod:
kubectl delete pod demo-reconciliacio-6b4d8f7c9-h2k4x -n rutas-norte-dev
kubectl get pods -n rutas-norte-dev -l app=demo-reconciliacioNAME READY STATUS RESTARTS AGE
demo-reconciliacio-6b4d8f7c9-p8m2v 1/1 Running 0 2m
demo-reconciliacio-6b4d8f7c9-t5n7q 1/1 Running 0 2m
demo-reconciliacio-6b4d8f7c9-w3x9z 0/1 ContainerCreating 0 1sEn menys d'un segon hi ha un substitut. Ningú no va executar cap ordre: el ReplicaSet va observar que hi havia 2 pods on n'hi havia d'haver 3 i en va crear un.
3. Canviar la imatge d'un pod a mà:
kubectl set image pod/demo-reconciliacio-6b4d8f7c9-p8m2v \
-n rutas-norte-dev nginx=nginx:1.26.2-alpine
kubectl get pods -n rutas-norte-dev -l app=demo-reconciliacio \
-o custom-columns=POD:.metadata.name,IMATGE:.spec.containers[0].imagePOD IMATGE
demo-reconciliacio-6b4d8f7c9-p8m2v nginx:1.26.2-alpine
demo-reconciliacio-6b4d8f7c9-t5n7q nginx:1.27.2-alpine
demo-reconciliacio-6b4d8f7c9-w3x9z nginx:1.27.2-alpineEl canvi persisteix. Això sorprèn, però és correcte i molt instructiu.
4. Quin controlador va actuar i amb quina informació:
| Cas | Controlador | Què va comparar | Resultat |
|---|---|---|---|
| Pod esborrat | ReplicaSet | Pods que casen amb el seu selector (2) vs replicas (3) |
Va crear un pod |
| Imatge canviada a mà | Cap | — | El canvi persisteix |
La clau és a què reconcilia cada controlador:
- El controlador de ReplicaSets reconcilia el nombre de pods que casen amb el seu selector. En compta 2, en vol 3, en crea un. Tant li fa quina imatge tinguin.
- El controlador de Deployments reconcilia quins ReplicaSets han d'existir segons la plantilla del Deployment. Com que la plantilla no va canviar, no fa res.
- Ningú no reconcilia el contingut d'un pod individual contra la plantilla del Deployment. La plantilla es fa servir en crear el pod, no per vigilar-lo després.
Lliçó pràctica: la reconciliació és d'objectes gestionats, no de camps arbitraris. Si esborres el pod modificat, el seu substitut naixerà de la plantilla i tornarà a nginx:1.27.2-alpine. I un kubectl rollout restart deployment/demo-reconciliacio recrearia tots els pods des de la plantilla, corregint la desviació.
kubectl rollout restart deployment/demo-reconciliacio -n rutas-norte-dev
kubectl rollout status deployment/demo-reconciliacio -n rutas-norte-dev
kubectl get pods -n rutas-norte-dev -l app=demo-reconciliacio \
-o custom-columns=POD:.metadata.name,IMATGE:.spec.containers[0].imagePOD IMATGE
demo-reconciliacio-7c9e5a2b4-b1k8m nginx:1.27.2-alpine
demo-reconciliacio-7c9e5a2b4-j4t2p nginx:1.27.2-alpine
demo-reconciliacio-7c9e5a2b4-r6n9w nginx:1.27.2-alpineSolució 3
1. Disseny del CRD:
spec:
solicitant: string # obligatori, correu del desenvolupador
brancaGit: string # obligatori, patró de branca vàlida
duracioDies: integer # 1-14, per defecte 7
origenDades: # d'on restaurar
tipus: string # enum: snapshot, copiaVelero, buit
nom: string # nom del snapshot o de la còpia
components: # què desplegar
apiReserves: boolean # per defecte true
botigaWeb: boolean # per defecte false
midaBaseDades: string # per defecte 5Gi
status:
fase: string # enum: Pendent, Creant, Restaurant, Llesta, Caducant, Error
namespaceCreat: string
urlAcces: string
dataCaducitat: string (date-time) # calculada: creationTimestamp + duracioDies
observedGeneration: integer
condicions: []Condition # NamespaceCreat, DadesRestaurades, ComponentsLlestosSubrecurs status activat. additionalPrinterColumns: Solicitant, Fase, Caducitat, Antiguitat.
2. Passos de Reconcile:
- Llegir l'
EntornProves. Si no existeix, acabar (IgnoreNotFound). - Si té
DeletionTimestamp, executar la lògica del finalitzador (pas 10) i acabar. - Assegurar el finalitzador si no el té.
- Comprovar la caducitat: si
now() > status.dataCaducitat, esborrar el mateix objecte i acabar. La cascada farà la resta. - Assegurar el namespace
proves-<nom>amb etiquetes de propietat. (És un objecte de clúster: no admetownerReferencesd'un objecte de namespace; es neteja al finalitzador.) - Assegurar el PVC restaurat des del snapshot, amb
dataSource(05-05). Si encara no estàBound, actualitzarstatus.fase = "Restaurant"i retornarRequeueAfter: 30s. - Assegurar el StatefulSet/Cluster de PostgreSQL i el seu Secret de credencials generat.
- Assegurar els Deployments i Services dels components marcats a
spec.components, i l'Ingress si escau. - Observar la realitat: estan tots els pods
Ready? Escriurestatus.fase,urlAcces, condicions iobservedGenerationmitjançant el subrecurs. - Retornar
RequeueAftercalculat: fins a la caducitat si falta molt, o 1 minut si és a prop, perquè el pas 4 es dispari a temps.
3. RBAC mínim:
rules:
- apiGroups: ["rutasnorte.example"]
resources: ["entornsproves"]
verbs: ["get", "list", "watch", "update", "patch", "delete"]
- apiGroups: ["rutasnorte.example"]
resources: ["entornsproves/status", "entornsproves/finalizers"]
verbs: ["get", "update", "patch"]
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["get", "list", "watch", "create", "delete"]
- apiGroups: [""]
resources: ["services", "secrets", "persistentvolumeclaims"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["snapshot.storage.k8s.io"]
resources: ["volumesnapshots"]
verbs: ["get", "list", "watch"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses", "networkpolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]Fixa't en el que no hi és: res de cluster-admin, ni permisos sobre nodes, ni sobre RBAC. Els Secrets són inevitables (genera credencials), i això ja és motiu suficient per revisar el codi abans de donar-li aquests permisos.
4. L'esborrat als 7 dies:
L'opció correcta és el mateix bucle de reconciliació amb RequeueAfter, no un CronJob de neteja:
- L'operador ja està observant l'objecte: comprovar una data a cada reconciliació és gratis.
RequeueAftergaranteix que es desperta a temps encara que no hi hagi cap altre esdeveniment.- Un CronJob extern seria un segon component amb la seva pròpia lògica, els seus propis permisos i la seva pròpia possibilitat de fallar, i podria estar dessincronitzat amb el que l'operador creu.
- A més,
ttlSecondsAfterFinished(06-03) no hi aplica: això és per a Jobs, no per a recursos personalitzats.
Detall d'implementació: l'operador esborra el seu propi objecte (r.Delete(ctx, &entorn)), i l'esborrat en cascada més el finalitzador s'ocupen de tota la resta. És més net que anar esborrant recursos un per un.
5. Finalitzador: sí, i és imprescindible.
Motius concrets:
- El namespace és un objecte de clúster des del punt de vista de les
ownerReferencesd'un objecte que viu en un altre namespace: no es pot establir la propietat, així que cal esborrar-lo explícitament. - Cal verificar que el PVC s'allibera abans de donar l'esborrat per acabat, evitant volums orfes amb la classe
Retain. - Hi pot haver recursos externs: un registre DNS
proves-xyz.rutasnorte.example, una entrada al sistema de facturació interna. Res d'això no ho coneix el recol·lector d'escombraries de Kubernetes. - Convé notificar el sol·licitant que el seu entorn ha caducat abans de completar l'esborrat.
Amb l'advertiment de l'apartat 9 ben present: si l'operador cau amb objectes pendents de finalitzar, aquests objectes queden en Terminating per sempre. Per això el finalitzador ha de ser ràpid, tolerant a fallades i amb un camí d'escapament documentat.
Conclusió
Un operador és un recurs personalitzat més un controlador que el reconcilia: el coneixement operatiu d'un expert codificat en programari que corre dins del clúster i no dorm mai. A diferència d'un script o de Helm, que actuen quan algú els invoca, un operador manté l'estat desitjat contínuament.
El seu motor és el bucle de reconciliació de 01-02, ara vist per dins: un watch amb memòria cau local, una cua de treball amb deduplicació i retrocés exponencial, i una funció Reconcile que rep només un nom i el contracte de la qual és sempre el mateix —llegeix el desitjat, mira el real, acosta'ls— i que acaba escrivint el status pel seu subrecurs. Dues propietats no negociables: idempotència, perquè Reconcile s'executarà moltes més vegades de les que esperes, i absència d'estat en memòria, perquè el controlador es pot reiniciar en qualsevol moment.
El model de cinc nivells —instal·lació, actualitzacions, cicle de vida, observabilitat i pilot automàtic— és l'eina per avaluar un operador abans d'adoptar-lo, juntament amb el manteniment del projecte, els permisos RBAC que demana i la pregunta decisiva de què passa si el desinstal·les.
Hem tancat el deute que vam obrir a 06-01: un operador de PostgreSQL substitueix el nostre StatefulSet artesanal i aporta el que un StatefulSet no podrà donar mai per si sol —elecció de primari, commutació per error automàtica en segons, rèpliques de lectura, Services -rw i -ro que es reescriuen sols, còpies programades i recuperació a un instant concret— amb un YAML en comptes de setmanes de feina i guàrdies nocturnes. I hem esbossat el controlador de RutaProgramada amb Kubebuilder: els marcadors que generen el CRD i l'RBAC, els set passos de Reconcile, ownerReferences per a l'esborrat en cascada i finalitzadors per al que viu fora del clúster.
I hem acabat on calia acabar: la majoria dels equips no haurien d'escriure operadors. Per a aplicacions sense estat no aporten res, per a programari conegut ja existeixen i són millors que el que escriuries, i mantenir-ne un significa operar un servei privilegiat a perpetuïtat. Escriu-lo només quan tinguis un procediment repetitiu, documentat i que s'executa malament a les tres de la matinada.
Tancament del mòdul 6
Amb aquesta lliçó es tanca el mòdul de conceptes avançats. La plataforma de Rutas Norte ha canviat molt:
| Component | Com va arribar al mòdul 6 | Com en surt |
|---|---|---|
postgres-reserves |
Deployment d'1 rèplica amb Recreate |
Clúster gestionat per operador, 3 instàncies, commutació automàtica |
redis-cache |
Deployment | StatefulSet amb disc per rèplica i arrencada en calent |
informes-ocupacio |
No existia | CronJob nocturn amb el seu PVC, la seva SA i la seva NetworkPolicy |
| Logs de tots els nodes | Sense recollir | DaemonSet recol·lector a cada node |
api-reserves |
Un contenidor | Amb initContainer d'espera i ambaixador de pagaments |
worker-notificacions |
Log propietari en un fitxer | Amb adaptador que emet JSON estructurat |
| Col·locació de pods | On caigués | Pla complet: afinitat, antiafinitat, node d'anàlisi dedicat |
| Vocabulari propi | Cap | CRD RutaProgramada estenent l'API |
És una plataforma potent. I és, en aquest moment, completament opaca.
No hi ha manera de saber si api-reserves està sana, més enllà que el seu pod digui Running —que només significa que el procés va arrencar, no que funcioni—. No sabem quanta memòria consumeix realment postgres-reserves ni si els 2 GiB que li vam reservar a 03-05 sobren o falten. Si el CronJob d'informes va fallar ahir a la nit a les tres de la matinada, l'única pista són uns logs que s'esborraran amb l'historial. Si botiga-web comença a respondre en tres segons en comptes d'en cent mil·lisegons, ens n'assabentarem per una queixa d'un client que no va poder comprar el seu bitllet.
Tot el que hem construït està a cegues. Això s'acaba al mòdul 7: Monitoratge i Registre. Començarem pel més bàsic i el més important —ensenyar Kubernetes a distingir un contenidor que va arrencar d'un que funciona— amb les verificacions de salut i sondes de la lliçó 07-01.
Curs de Kubernetes
Mòdul 1: Introducció a Kubernetes
- Què és Kubernetes?
- Arquitectura de Kubernetes
- Conceptes i Terminologia Clau
- Configuració d'un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objectes, Manifests YAML i el Model Declaratiu
- El Projecte del Curs: la Plataforma Rutas Norte
Mòdul 2: Components Principals de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualitzacions, Rollbacks i Estratègies de Desplegament
- Serveis
- Namespaces
- Etiquetes, Selectors i Anotacions
Mòdul 3: Gestió de Configuració i Secrets
- ConfigMaps
- Secrets
- Variables d'Entorn
- Quotes i Límits de Recursos
- LimitRanges i Classes de Qualitat de Servei (QoS)
- ServiceAccounts i Accés a l'API des dels Pods
Mòdul 4: Xarxes a Kubernetes
- Xarxes de Clúster
- Tipus de Serveis
- DNS Intern i Descobriment de Serveis
- Controladors d'Ingress
- TLS i Gestió de Certificats amb cert-manager
- Polítiques de Xarxa
Mòdul 5: Emmagatzematge a Kubernetes
- Volums
- Volums Persistents
- Reclamacions de Volums Persistents
- Classes d'Emmagatzematge
- Aprovisionament Dinàmic, Expansió i Snapshots
- Còpies de Seguretat i Restauració de Dades
Mòdul 6: Conceptes Avançats de Kubernetes
- StatefulSets
- DaemonSets
- Treballs i CronJobs
- Init Containers, Sidecars i Patrons Multicontenidor
- Planificació: Afinitat, Taints i Toleracions
- Definicions de Recursos Personalitzats (CRDs)
- Operadors i el Patró Controlador
Mòdul 7: Monitoratge i Registre
- Verificacions de Salut i Sondes
- Servidor de Mètriques i kubectl top
- Monitoratge amb Prometheus
- Visualització i Alertes amb Grafana i Alertmanager
- Registre Centralitzat amb Elasticsearch, Fluentd i Kibana (EFK)
- Depuració d'Aplicacions i Esdeveniments del Clúster
Mòdul 8: Seguretat a Kubernetes
- Control d'Accés Basat en Rols (RBAC)
- Contextos de Seguretat i Enduriment del Contenidor
- Polítiques de Seguretat de Pods i Pod Security Standards
- Seguretat de Xarxa
- Seguretat d'Imatges
- Auditoria, Escaneig i Gestió de Vulnerabilitats
Mòdul 9: Escalat i Rendiment
- Autoescalat Horitzontal de Pods
- Autoescalat Vertical de Pods
- Autoescalat de Clúster
- Escalat per Esdeveniments i Mètriques Personalitzades amb KEDA
- Alta Disponibilitat: PodDisruptionBudgets i Topologia
- Ajust de Rendiment
Mòdul 10: Ecosistema i Eines de Kubernetes
- Minikube i Entorns Locals amb kind
- Kubeadm
- Helm
- Kustomize
- GitOps amb Argo CD i Flux
- Kubernetes Gestionat: EKS, AKS i GKE
Mòdul 11: Estudis de Cas i Aplicacions del Món Real
- Desplegament d'una Aplicació Web
- Execució d'Aplicacions amb Estat
- CI/CD amb Kubernetes
- Estratègies de Desplegament: Blue-Green i Canary
- Gestió Multi-Clúster
- Operació en Producció: Incidències, Runbooks i Costos
