CRD ou APIService : comment étendre l’API Kubernetes ?
Il y a quelques mois en manipulant le metrics-server de Kubernetes, je suis tombé sur un concept que je ne connaissais pas : les APIService. Je savais que les CRD existaient, mais je n’avais jamais entendu parler de cette autre façon d’étendre l’API Kubernetes. En creusant un peu, j’ai découvert que les APIService sont en fait la même mécanique que les CRD, mais avec une approche complètement différente.
Ces deux approches permettent toutes les deux d’étendre l’API Kubernetes et de faire fonctionner kubectl avec vos ressources custom. Mais elles ont des philosophies radicalement différentes, et choisir la mauvaise peut vite devenir une source de douleur (on va annéantir le suspence : dans 90% des cas, c’est le CRD qu’il vous faut).
Afin de comparer les deux et de comprendre les différences entre ces deux méthodes, on va créer la même ressource Cafe, comparer concrètement ce que ça implique, et voir quand l’une prend l’avantage sur l’autre.
C’est parti pour un article expresso sur les CRD vs APIService ! ☕️
L’API Kubernetes under the hood
Avant de plonger dans les deux approches, un petit rappel sur le fonctionnement de l’API Kubernetes.
Quand vous faites kubectl get pods, voici ce qui se passe grosso modo :
kubectlenvoie une requête HTTP à l’API server (kube-apiserver)- L’API server authentifie et autorise la requête
- Il va chercher les données dans etcd (la db de Kubernetes)
- Il renvoie le résultat
kubectlformate la réponse et l’affiche
Pour les ressources custom, deux chemins sont possibles :
- Avec un CRD : l’API server sait directement gérer votre ressource, il la stocke dans etcd comme n’importe quelle ressource native.
- Avec un APIService : l’API server délègue à un composant appelé
kube-aggregator, qui proxifie la requête vers votre propre serveur HTTP.
La différence est fondamentale : dans un cas, Kubernetes gère tout. Dans l’autre, c’est vous qui implémentez un serveur compatible avec les conventions de l’API Kubernetes.
Les CRDs
Comment ça marche
Un CRD (Custom Resource Definition) c’est, comme son nom l’indique, la définition d’une ressource custom. Vous déclarez un schéma (en format OpenAPI), et l’API server comprend automatiquement comment stocker, valider et exposer cette ressource.
Pas besoin d’écrire une seule ligne de code serveur. Kubernetes fait le travail.
La validation se fait via OpenAPI / JSON Schema : vous définissez les champs, leurs types, leurs contraintes, et l’API server rejettera automatiquement les ressources mal formées. C’est propre mais surtout : c’est natif.
Information
Un CRD ne fait que définir la structure de la ressource. Pour ajouter de la logique métier (exemple : créer automatiquement un Secret quand un Cafe est créé), il faut un operateurr ou un controlleur séparé. J’en parle dans mon article sur les opérateurs Kubernetes.
Exemple concret : créer une ressource Cafe
On veut pouvoir faire :
kubectl apply -f mon-espresso.yaml
kubectl get cafes
Voici le CRD complet :
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: cafes.une-tasse-de.cafe
spec:
group: une-tasse-de.cafe
names:
kind: Cafe
listKind: CafeList
plural: cafes
singular: cafe
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
type:
type: string
enum: ["espresso", "lungo", "cappuccino", "latte"]
temperature:
type: integer
minimum: 60
maximum: 95
required: ["type"]
status:
type: object
properties:
ready:
type: boolean
lastBrewed:
type: string
subresources:
status: {}
additionalPrinterColumns:
- name: Type
type: string
jsonPath: .spec.type
- name: Temperature
type: integer
jsonPath: .spec.temperature
- name: Ready
type: boolean
jsonPath: .status.ready
Et voici comment créer une ressource :
apiVersion: une-tasse-de.cafe/v1alpha1
kind: Cafe
metadata:
name: mon-espresso
namespace: default
spec:
type: espresso
temperature: 90
kubectl apply -f mon-espresso.yaml
# cafe.une-tasse-de.cafe/mon-espresso created
kubectl get cafes
# NAME TYPE TEMPERATURE READY
# mon-espresso espresso 90 <none>
Et si on essaie de créer un café avec une température impossible :
kubectl apply -f cafe-bouillant.yaml
# The Cafe "cafe-bouillant" is invalid:
# spec.temperature: Invalid value: 120: spec.temperature in body should be less than or equal to 95
La validation est faite directement par l’API server, sans aucun code custom.
En récapitulant les avantages d’un CRD :
kubectl get,kubectl describe,kubectl deletefonctionnent nativement- Les ressources sont stockées dans etcd → elles survivent aux redémarrages des controllers
- La validation est automatique via le schéma OpenAPI
- Les colonnes custom dans
kubectl get(champadditionalPrinterColumns) - Pas une seule ligne de code serveur à écrire
Et les inconvénients :
- La logique métier nécessite un controller/operator séparé
- Les données sont forcément dans etcd — impossible d’agréger des données d’une source externe
- Pas de verbes custom enregistrés dans l’API — on peut simuler un
kubectl brewvia un plugin qui patche le status côté client, mais aucun endpoint/brewn’existe côté serveur (on en parle un peu plus bas) - Moins adapté pour des APIs très dynamiques ou calculées à la volée ( ou alors ça implique de faire beaucoup de requête à l’API-Server pour mettre à jour le status, ce qui peut vite devenir un cauchemar de performance)
Les APIService : l’agrégation d’API
Comment ça marche
Un APIService est une ressource Kubernetes qui enregistre un serveur HTTP externe comme extension de l’API. Quand kubectl demande des ressources de votre groupe API, kube-apiserver proxifie la requête vers votre serveur.
Le composant qui gère ça s’appelle kube-aggregator. Il est intégré dans l’API server depuis Kubernetes 1.7. Vous l’utilisez peut-être sans le savoir : kubectl top pods fonctionne grâce à cette mécanique (via metrics-server qui expose metrics.k8s.io/v1beta1).
kubectl get apiservices | grep metrics
# v1beta1.metrics.k8s.io kube-system/metrics-server True 47d
Le metrics-server : un APIService qu’on utilise tous les jours
Comme dit plus haut, metrics-server est un APIService. C’est lui qui expose les métriques CPU/mémoire des nodes et des pods, utilisées par kubectl top et le Horizontal Pod Autoscaler.
Commençons le dig des APIService en regardant comment interagit metrics-server avec Kubernetes. Voici l’objet APIService qui enregistre metrics.k8s.io/v1beta1 :
kubectl get apiservice v1beta1.metrics.k8s.io -o yaml
apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
creationTimestamp: "2025-04-09T18:49:08Z"
labels:
k8s-app: metrics-server
name: v1beta1.metrics.k8s.io
resourceVersion: "222127265"
uid: 1e71196d-df49-479d-b963-75d8982e6ae0
spec:
group: metrics.k8s.io
groupPriorityMinimum: 100
insecureSkipTLSVerify: true
service:
name: metrics-server
namespace: kube-system
port: 443
version: v1beta1
versionPriority: 100
status:
conditions:
- lastTransitionTime: "2026-03-04T08:13:55Z"
message: all checks passed
reason: Passed
status: "True"
type: Available
Ce que vous voyez ici, c’est exactement la même structure que vous utiliserez pour votre propre APIService. Le kube-aggregator va proxifier les requêtes vers kube-system/metrics-server:443.
kubectl top nodes est simplement un affichage formaté de la même requête HTTP. La donnée sous-jacente est identique :
kubectl top nodes
NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)
talos-4e6-sx1 2214m 18% 20992Mi 16%
# Exactement la même information, en brut :
kubectl get --raw /apis/metrics.k8s.io/v1beta1/nodes \
| jq '.items[] | {name: .metadata.name, cpu: .usage.cpu, memory: .usage.memory}'
{
"name": "talos-4e6-sx1",
"cpu": "2517420747n",
"memory": "21639128Ki"
}
La différence ? kubectl top calcule les pourcentages et formate l’affichage. La requête vers le metrics-server est strictement identique.
Le endpoint de découverte (discovery)
Un détail important que nous devrons implémenter dans notre serveur : l’endpoint de découverte. C’est lui qui permet à kubectl de savoir quelles ressources sont disponibles dans votre groupe API.
kubectl get --raw /apis/metrics.k8s.io/v1beta1
{
"kind": "APIResourceList",
"apiVersion": "v1",
"groupVersion": "metrics.k8s.io/v1beta1",
"resources": [
{
"name": "nodes",
"singularName": "",
"namespaced": false,
"kind": "NodeMetrics",
"verbs": [
"get",
"list"
]
},
{
"name": "pods",
"singularName": "",
"namespaced": true,
"kind": "PodMetrics",
"verbs": [
"get",
"list"
]
}
]
}
Ce endpoint /apis/<group>/<version> est obligatoire. Sans lui, kube-aggregator ne peut pas faire la découverte de votre API, et kubectl api-resources ne listera pas vos ressources custom.
Et ce que fait metrics-server, vous pouvez le faire aussi avec l’exacte même mécanisme — un serveur HTTP, un objet APIService, et kube-aggregator qui colle les deux.
APIService pour Cafe
Comme dit plus haut, pour exposer notre ressource Cafe via un APIService, il faut trois choses :
- Un serveur HTTP qui implémente les conventions de l’API Kubernetes (un pod qui tourne quelque part, ou même un service externe à Kubernetes)
- Un Service qui pointe vers notre application
- Un objet
APIServicequi enregistre l’extension
Commençons les deux derniers points, qui sont les plus simples car purement déclaratifs.
Notre APIService doit être enregistré dans Kubernetes et exposer le groupe une-tasse-de.cafe/v1alpha1. Il doit pointer vers un service cafe-api-server qui expose notre serveur HTTP.
apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
name: v1alpha1.une-tasse-de.cafe
spec:
group: une-tasse-de.cafe
version: v1alpha1
groupPriorityMinimum: 1000
versionPriority: 15
service:
name: cafe-api-server
namespace: default
port: 443
insecureSkipTLSVerify: true # pour les tests :D (en prod, TLS obligatoire)
Et ce même service qui pointe vers notre serveur HTTP (dans ce cas, un simple Deployment) :
apiVersion: v1
kind: Service
metadata:
name: cafe-api-server
namespace: default
spec:
selector:
app: cafe-api-server
ports:
- port: 443
targetPort: 8443
Le serveur HTTP en Go
C’est là que ça se corse. Votre serveur doit implémenter les conventions de l’API Kubernetes. Au minimum, il faut répondre à ces endpoints :
| Endpoint | Description |
|---|---|
GET /apis/une-tasse-de.cafe/v1alpha1 | Discovery (liste des ressources disponibles) |
GET /apis/une-tasse-de.cafe/v1alpha1/namespaces/{ns}/cafes | Lister les cafés |
GET /apis/une-tasse-de.cafe/v1alpha1/namespaces/{ns}/cafes/{name} | Récupérer un café |
POST /apis/une-tasse-de.cafe/v1alpha1/namespaces/{ns}/cafes/{name}/brew | Verbe custom : préparer un café (optionel) |
Et les réponses doivent être au format Kubernetes, avec apiVersion, kind, metadata, etc.
package main
import (
"encoding/json"
"net/http"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// Nos types, compatibles avec les conventions Kubernetes
type Cafe struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec CafeSpec `json:"spec"`
Status CafeStatus `json:"status,omitempty"`
}
type CafeSpec struct {
Type string `json:"type"`
Temperature int `json:"temperature,omitempty"`
}
type CafeStatus struct {
Ready bool `json:"ready,omitempty"`
LastBrewed string `json:"lastBrewed,omitempty"`
}
type CafeList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata"`
Items []Cafe `json:"items"`
}
func handleCafes(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Ici, on peut aller chercher les données n'importe où :
// base de données externe, API tierce, calculs à la volée...
cafes := CafeList{
TypeMeta: metav1.TypeMeta{
APIVersion: "une-tasse-de.cafe/v1alpha1",
Kind: "CafeList",
},
Items: []Cafe{
{
TypeMeta: metav1.TypeMeta{
APIVersion: "une-tasse-de.cafe/v1alpha1",
Kind: "Cafe",
},
ObjectMeta: metav1.ObjectMeta{
Name: "espresso-du-matin",
Namespace: "default",
},
Spec: CafeSpec{Type: "espresso", Temperature: 90},
},
},
}
json.NewEncoder(w).Encode(cafes)
}
func main() {
http.HandleFunc("/apis/une-tasse-de.cafe/v1alpha1/namespaces/", handleCafes)
// ... endpoint de discovery, TLS, etc.
http.ListenAndServeTLS(":8443", "tls.crt", "tls.key", nil)
}
Avertissement
Je suis rapidement passé dessus mais je rappelle que le TLS est obligatoire. kube-aggregator refusera de proxifier vers un serveur non-TLS (sauf avec insecureSkipTLSVerify: true, ce qui n’est vraiment que pour les tests). Il faudra gérer des certificats, typiquement avec cert-manager, il est possible de faire de l’auto-signé et mettre la CA dans l’APIService (.spec.caBundle).
Une fois qu’on a ça, on peut faire :
k get cafe
NAME AGE
espresso-du-matin 60m
cappuccino-du-dimanche 60m
Les colonnes d’affichage (additionalPrinterColumns)
Avec un CRD, les colonnes affichées par kubectl get se définissent en YAML via additionalPrinterColumns. Avec un APIService, ce champ n’existe pas — mais on peut obtenir exactement le même résultat en implémentant la Table API.
Quand kubectl fait kubectl get cafes, il envoie un header de négociation de contenu :
Accept: application/json;as=Table;v=v1;g=meta.k8s.io,application/json
Si le serveur répond avec un objet metav1.Table, kubectl affiche les colonnes définies. Si le serveur ignore ce header et renvoie un CafeList classique, kubectl n’affiche que NAME et AGE.
// wantsTable détecte si kubectl demande un affichage tabulaire
func wantsTable(r *http.Request) bool {
return strings.Contains(r.Header.Get("Accept"), "as=Table")
}
// cafeTable construit la réponse avec les colonnes custom
// — l'équivalent de additionalPrinterColumns pour un CRD
func cafeTable(cafes ...Cafe) *metav1.Table {
table := &metav1.Table{
TypeMeta: metav1.TypeMeta{APIVersion: "meta.k8s.io/v1", Kind: "Table"},
ColumnDefinitions: []metav1.TableColumnDefinition{
{Name: "Name", Type: "string"},
{Name: "Type", Type: "string"},
{Name: "Temperature", Type: "integer"},
{Name: "Ready", Type: "boolean"},
{Name: "Last Brewed", Type: "string"},
{Name: "Age", Type: "string"},
},
Rows: []metav1.TableRow{},
}
for _, c := range cafes {
table.Rows = append(table.Rows, metav1.TableRow{
Cells: []any{
c.Name, c.Spec.Type, c.Spec.Temperature,
c.Status.Ready, c.Status.LastBrewed, age(c.CreationTimestamp),
},
})
}
return table
}
Et dans le handler GET, on détecte le header avant de répondre :
case http.MethodGet:
items := s.store.list(namespace)
if wantsTable(r) {
writeJSON(w, http.StatusOK, cafeTable(items...))
return
}
writeJSON(w, http.StatusOK, &CafeList{...})
Résultat :
kubectl get cafes
# NAME TYPE TEMPERATURE READY LAST BREWED AGE
# espresso-du-matin espresso 90 true 2026-03-26T10:14:04Z 3s
# cappuccino-du-dimanche cappuccino 70 true 2026-03-26T10:14:04Z 3s
Mais ce n’est pas tout : avec un APIService, on peut faire bien plus que ce que permet un CRD… comme par exemple, ajouter des verbes custom à notre API.
Le verbe custom brew
Je souhaite mettre à jour une ressource et déclencher une interaction métier à la demande : kubectl brew espresso-du-matin prépare le café, met à jour le status, et renvoie la ressource mise à jour. Il s’agit d’une sous-ressource du type Cafe présente dans le status.
Astuce
Une subresource status est une ressource spéciale dans les CRD qui permet de faire des mises à jour partielles du status sans toucher à la spec. C’est très pratique pour séparer les responsabilités entre le controller (qui met à jour le status) et les utilisateurs (qui modifient la spec).
On peut définir des rôles RBAC différents pour le verbe update sur la subresource status et le verbe update sur la ressource principale, ce qui permet de contrôler qui peut déclencher la logique métier via le patch du status.
Avec un CRD, on peut déjà implémenter un endpoint custom : un plugin qui patche le status directement côté client fait l’affaire.
# Simulation "brew" avec un CRD — logique côté client, dans le plugin
kubectl patch cafe espresso-du-matin --subresource=status --type=merge \
-p "{\"status\":{\"ready\":true,\"lastBrewed\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}"
Ça marche. Mais il y a un énorme problème : c’est juste un PATCH standard sur le status avec la logique coté client. La différence avec un APIService tient à où tourne la logique (une alternative serait de mettre une variable qui déclencherait la logique dans un controller, mais ça devient vite compliqué).
| CRD | APIService | |
|---|---|---|
Logique brew | Côté client (dans le plugin) | Côté serveur (dans le handler) |
Endpoint /brew découvrable | ❌ (kubectl api-resources ne le voit pas) | ✅ (enregistré dans la discovery) |
| Qui contrôle ce qui s’écrit | N’importe qui avec le droit patch sur le status (moyennant ce qu’on a accepté dans le CRD) | Le serveur — il valide et décide |
RBAC sur le verbe brew spécifiquement | ❌ | ✅ |
Avec un APIService, brew est un endpoint de première classe dans l’API Kubernetes : synchrone, découvrable, avec une logique serveur qui ne peut pas être contournée. C’est juste un handler HTTP.
Pour brew, le endpoint est POST .../cafes/{name}/brew. Il suffit de l’ajouter à la liste des ressources exposées dans le endpoint de découverte :
APIResources: []metav1.APIResource{
{
Name: "cafes",
Kind: "Cafe",
Verbs: metav1.Verbs{"create", "delete", "get", "list", "update"},
},
{
Name: "cafes/brew", // convention : "ressource/sous-ressource"
Namespaced: true,
Kind: "Cafe",
Verbs: metav1.Verbs{"create"},
},
},
Coté serveur, c’est juste un handler HTTP comme les autres qui gère la logique métier de préparation du café :
// POST /apis/une-tasse-de.cafe/v1alpha1/namespaces/{ns}/cafes/{name}/brew
func (s *Server) handleBrew(w http.ResponseWriter, r *http.Request, namespace, name string) {
c, ok := s.store.get(namespace, name)
if !ok {
kubeError(w, http.StatusNotFound, fmt.Sprintf(`cafes %q introuvable`, name))
return
}
// Logique métier déclenchée à la demande, calculée à la volée
c.Status.Ready = true
c.Status.LastBrewed = time.Now().Format(time.RFC3339)
s.store.put(namespace, c)
writeJSON(w, http.StatusOK, c)
}
Et après rollout, il suffit d’appeler cet endpoint pour déclencher la préparation du café :
kubectl create --raw \
/apis/une-tasse-de.cafe/v1alpha1/namespaces/default/cafes/espresso-du-matin/brew \
-f - <<< '{}' | jq
{
"kind": "Cafe",
"apiVersion": "une-tasse-de.cafe/v1alpha1",
"metadata": {
"name": "espresso-du-matin",
"namespace": "default",
"creationTimestamp": "2026-03-25T14:03:17Z"
},
"spec": {
"type": "espresso",
"temperature": 90
},
"status": {
"ready": true,
"lastBrewed": "2026-03-25T11:09:18Z"
}
}
La ressource est mise à jour en temps réel, sans passer par etcd, et la logique métier est déclenchée à la demande.
# Vérifier le statut mis à jour
kubectl get cafe espresso-du-matin -o jsonpath='{.status}' | jq
{
"lastBrewed": "2026-03-25T14:03:30Z",
"ready": true
}
C’est fonctionnel, mais passer par kubectl create --raw c’est pas très user-friendly. C’est là qu’interviennent les plugins kubectl.
Le plugin kubectl brew
Un plugin kubectl, c’est simplement un exécutable nommé kubectl-<verbe> quelque part dans votre $PATH. Rien de plus. Quand kubectl ne reconnaît pas une sous-commande, il cherche un binaire kubectl-<sous-commande> et le délègue.
# kubectl ne connaît pas "brew"...
# ...mais s'il trouve kubectl-brew dans le PATH, il l'exécute
kubectl brew espresso-du-matin
Le plugin récupère le namespace courant depuis le kubeconfig, appelle kubectl create --raw en coulisse, et formate la réponse :
func main() {
name, namespace := parseArgs(os.Args[1:])
if namespace == "" {
namespace = currentNamespace() // lit le kubeconfig
}
path := fmt.Sprintf(
"/apis/une-tasse-de.cafe/v1alpha1/namespaces/%s/cafes/%s/brew",
namespace, name,
)
fmt.Printf("☕ Brewing %s (namespace: %s)...\n", name, namespace)
cmd := exec.Command("kubectl", "create", "--raw", path, "-f", "-")
cmd.Stdin = strings.NewReader("{}")
// parse la réponse JSON et affiche un résumé lisible
}
Installation et utilisation :
# Compiler et installer
go build -o kubectl-brew ./dev/kubectl-brew/
sudo mv kubectl-brew /usr/local/bin/
# Utiliser comme n'importe quelle sous-commande kubectl
kubectl brew espresso-du-matin
# ☕ Brewing espresso-du-matin (namespace: default)...
# ✓ espresso-du-matin est prêt ! (espresso, 90°C) — préparé à 19:16:02 CET
kubectl brew cappuccino-du-dimanche -n production
# ☕ Brewing cappuccino-du-dimanche (namespace: production)...
# ✓ cappuccino-du-dimanche est prêt ! (cappuccino, 70°C) — préparé à 19:16:13 CET
kubectl brew cafe-inexistant
# ☕ Brewing cafe-inexistant (namespace: default)...
# Error from server: cafes "cafe-inexistant" introuvable
# Erreur : impossible de brewer "cafe-inexistant" : exit status 1
Information
Pour distribuer un plugin kubectl à grande échelle, il existe krew — le gestionnaire de plugins officiel. Il n’est pas parfait (e.g. il ne peut pas installer une version spécifique d’un plugin), mais c’est un bon point de départ pour partager votre plugin avec la communauté.
En bref :
kubectl get cafesfonctionne exactement pareil que pour un CRD — colonnes custom incluses, via la Table API- Les données peuvent venir de n’importe où : base de données externe, API tierce, calculs temps réel. En fonction de ce qu’on cherche à query, on veut éviter de surcharger l’etcd.
- Flexibilité totale sur le comportement de l’API
- Verbes custom côté serveur :
kubectl brewdéclenche une logique métier synchrone sur le serveur, avec un endpoint découvrable dans l’API — avec un CRD on peut simuler ça via un plugin qui patche le status, mais la logique tourne côté client et le verbe n’est pas enregistré dans la discovery
Et puis en revanche : Si notre serveur est down, les ressources sont inaccessibles (contrairement au CRD).
Information
Pour simplifier l’implémentation d’un serveur compatible Kubernetes, il existe des outils comme apiserver-builder. J’ai pas vraiment testé mais si c’est je suis preneur de retours.
Si je tente de faire un (vraiment) grossier résumé des différences ( pour ceux qui vont directement à la fin de l’article sans lire le reste, je vous vois 👀) :
| CRD | APIService | |
|---|---|---|
| Stockage | etcd (automatique) | Custom (DB externe, mémoire, API…) |
| Complexité | Faible (YAML uniquement) | Élevée (serveur HTTP à implémenter) |
| Disponibilité | Toujours (stocké dans etcd) | Dépend de votre serveur |
| Validation | JSON Schema (OpenAPI) | Implémentation custom |
| Endpoints custom | ❌ Non | ✅ Oui |
| Exemples connus | cert-manager, ArgoCD | metrics-server |
Et pour les usecases :
Un CRD:
- Vous voulez stocker de la configuration ou des ressources dans Kubernetes
- Vous suivez le pattern operator (CRD + controller)
- Vos données vivent dans Kubernetes et nulle part ailleurs
- Vous avez besoin de persistance garantie (les données survivent aux redémarrages)
C’est le cas de la très grande majorité des extensions Kubernetes : cert-manager stocke ses Certificate dans etcd, ArgoCD stocke ses Application, KubeVirt stocke ses VirtualMachine. Tout ça, c’est des CRD.
Et un APIService :
- Vos données viennent d’une source externe (base de données, service tiers, système legacy)
- Vous avez besoin d’un comportement API très spécifique impossible avec un CRD
- Vous exposez des métriques ou données calculées en temps réel
- Vous avez une bonne raison de ne pas stocker dans etcd (trop volumineux, trop dynamique…)
Le cas d’usage emblématique reste metrics-server : les métriques CPU/mémoire sont calculées en temps réel à partir des stats des nodes. Impossible de les stocker dans etcd, ça change toutes les secondes. Un APIService s’impose.
Conclusion
Ces deux mécanismes répondent à des besoins fondamentalement différents, même si leur résultat côté kubectl est identique. Le CRD c’est l’approche native, déclarative, qui délègue tout le stockage et la validation à Kubernetes. L’APIService c’est la solution pour les cas plus complexes, quand vos données ne peuvent pas (ou ne doivent pas) vivre dans etcd.
Si vous souhaitez aller plus loin sur la partie logique métier côté CRD, j’ai écrit un article complet sur la création d’un opérateur Kubernetes qui couvre la boucle de réconciliation, la gestion des événements, et les Webhooks de validation/mutation.
Et si vous le souhaitez, vous pouvez tester le code complet de l’APIService Cafe et du plugin kubectl-brew sur ce repo GitHub
Bon kawa ! ☕️
