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 :

  1. kubectl envoie une requête HTTP à l’API server (kube-apiserver)
  2. L’API server authentifie et autorise la requête
  3. Il va chercher les données dans etcd (la db de Kubernetes)
  4. Il renvoie le résultat
  5. kubectl formate la réponse et l’affiche

Pour les ressources custom, deux chemins sont possibles :

Architecture CRD vs APIService

  • 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 delete fonctionnent 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 (champ additionalPrinterColumns)
  • 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 brew via un plugin qui patche le status côté client, mais aucun endpoint /brew n’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 :

  1. 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)
  2. Un Service qui pointe vers notre application
  3. Un objet APIService qui 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 :

EndpointDescription
GET /apis/une-tasse-de.cafe/v1alpha1Discovery (liste des ressources disponibles)
GET /apis/une-tasse-de.cafe/v1alpha1/namespaces/{ns}/cafesLister 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}/brewVerbe 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é).

CRDAPIService
Logique brewCô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’écritN’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 cafes fonctionne 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 brew dé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 👀) :

CRDAPIService
Stockageetcd (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
ValidationJSON Schema (OpenAPI)Implémentation custom
Endpoints custom❌ Non✅ Oui
Exemples connuscert-manager, ArgoCDmetrics-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 ! ☕️