🔌 CRDs vs API Aggregation — When to Use Which

Both CRDs and API Aggregation extend Kubernetes with new resource types. They take fundamentally different approaches:

CRDsAPI Aggregation
Storageetcd (via API server)Your own backend (any database/store)
AuthHandled by API serverDelegated to API server — you implement the logic
ValidationOpenAPI v3 schemaFull Go code — any logic possible
Subresourcesstatus, scale onlyAny custom subresource (e.g. /exec, /logs, /proxy)
OperationsStandard CRUD + watchAny — streaming, long-running, websockets
ComplexityLow — just YAMLHigh — must run + operate your own API server
ExamplesPrometheus Operator, cert-managermetrics-server, custom autoscalers, service catalog
🔵 Use CRDs by default API aggregation is for advanced use cases: non-etcd backends, custom subresources like /exec or streaming endpoints, or when you need full control over validation logic. For 95% of operator use cases, CRDs are the right tool.

How the aggregation layer works

kubectl GET /apis/metrics.k8s.io kube-apiserver aggregation layer checks APIService registry proxies if group registered Extension API Server metrics-server / your server Custom Storage proxy

When a request arrives for an aggregated API group (e.g. metrics.k8s.io), the API server's aggregation layer looks up the registered APIService, then proxies the request to the extension API server running in the cluster. Authentication and authorization are still handled by the main API server — the extension server delegates these checks back.

🔧 Custom REST Handler — Storage Layer

Unlike CRDs (which store in etcd automatically), an extension API server implements its own storage. You can return data from any source — in-memory, a database, or computed on the fly:

// pkg/apis/mymetrics/v1alpha1/podmetricssummary_rest.go
type PodMetricsSummaryREST struct {
    metricsClient MetricsClient
}

// Get implements the Get verb for /apis/mymetrics/v1alpha1/namespaces/{ns}/podmetricssummaries/{name}
func (r *PodMetricsSummaryREST) Get(
    ctx context.Context,
    name string,
    opts *metav1.GetOptions,
) (runtime.Object, error) {
    ns := request.NamespaceValue(ctx)

    // Fetch live data from Prometheus (not etcd!)
    metrics, err := r.metricsClient.QueryPodMetrics(ctx, ns, name)
    if err != nil {
        return nil, errors.NewInternalError(err)
    }

    return &v1alpha1.PodMetricsSummary{
        ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns},
        CPU:    metrics.CPU,
        Memory: metrics.Memory,
        Requests: metrics.Requests,
        Errors:   metrics.Errors,
    }, nil
}

// List implements GET /apis/mymetrics/v1alpha1/namespaces/{ns}/podmetricssummaries
func (r *PodMetricsSummaryREST) List(
    ctx context.Context,
    opts *internalversion.ListOptions,
) (runtime.Object, error) {
    // Return a list of metrics for all pods in the namespace
    ns := request.NamespaceValue(ctx)
    return r.metricsClient.QueryAllPodMetrics(ctx, ns)
}

// Implement NamespacedScopeStrategy to scope resources to namespaces
func (r *PodMetricsSummaryREST) NamespaceScoped() bool { return true }
func (r *PodMetricsSummaryREST) New() runtime.Object   { return &v1alpha1.PodMetricsSummary{} }
func (r *PodMetricsSummaryREST) NewList() runtime.Object { return &v1alpha1.PodMetricsSummaryList{} }

⚖️ API Aggregation vs CRDs — Decision Guide

Choose CRDs when…

You need CRUD on objects stored in etcd, standard watch semantics, simple validation, and want minimal operational overhead. This is 95% of use cases.

Choose API Aggregation when…

You need non-etcd storage, custom subresources beyond status/scale, streaming endpoints, computed/virtual resources, or long-running operations.

Real-world exampleWhy aggregation, not CRDs
metrics-server Data is computed live from kubelet — not stored in etcd. No persistence needed.
Kubernetes Dashboard proxy Needs custom subresources for proxying — beyond what CRD subresources support.
Service Catalog (deprecated) Needed integration with external service brokers with their own storage.
Custom autoscaler Exposes a /scale subresource with proprietary scaling logic not expressible in CRD schema.
⚠️ Operational burden of extension API servers Running your own API server means you are responsible for: high availability (multiple replicas), TLS certificate management, auth delegation configuration, health probes, and ensuring the extension server is available before the main API server routes requests to it. If your extension server goes down, all requests for its API group will fail. This is a significant operational burden compared to CRDs.

Availability impact — APIService status

# If the extension server is down, the APIService shows Unavailable
kubectl get apiservice v1beta1.metrics.k8s.io
# NAME                     SERVICE                      AVAILABLE   AGE
# v1beta1.metrics.k8s.io   kube-system/metrics-server   False       10m

# Describe for the error message
kubectl describe apiservice v1beta1.metrics.k8s.io
# Status:
#   Conditions:
#     Message: failing or missing response from https://10.96.x.x:443/apis/metrics.k8s.io/v1beta1
#     Reason:  ServiceNotFound
#     Status:  False
#     Type:    Available

# Impact: kubectl top nodes/pods will fail when metrics-server is down
kubectl top nodes
# Error from server (ServiceUnavailable): the server is currently unable to handle the request

📋 The APIService Object

An APIService is a cluster-scoped Kubernetes object that registers a new API group with the aggregation layer. Its name follows the convention version.group:

apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
  name: v1beta1.metrics.k8s.io    # version.group
spec:
  group:                metrics.k8s.io
  version:              v1beta1
  groupPriorityMinimum: 100       # higher = preferred when multiple groups match
  versionPriority:      100       # higher = preferred version within the group
  insecureSkipTLSVerify: true     # dev only — use caBundle in production
  service:
    name:      metrics-server
    namespace: kube-system
    port:      443

# Production: provide a CA bundle so the API server verifies the extension server's TLS cert
spec:
  caBundle: <base64-encoded-CA-cert>
  insecureSkipTLSVerify: false

Verify registration

# List all registered APIServices
kubectl get apiservices

# Check if a specific one is Available
kubectl get apiservice v1beta1.metrics.k8s.io -o yaml
# status.conditions:
# - type: Available
#   status: "True"
#   reason: Passed
#   message: all checks passed

# Query through the aggregated API
kubectl get --raw /apis/metrics.k8s.io/v1beta1/nodes | jq .

Building an extension API server with apiserver-builder

The apiserver-builder library scaffolds an extension API server that handles auth delegation, discovery, and storage. It mirrors the Kubebuilder experience for aggregated APIs:

# Scaffold a new extension API server project
go install sigs.k8s.io/apiserver-builder-alpha/cmd/apiserver-boot@latest

apiserver-boot init repo --domain myorg.example.com
apiserver-boot create group version resource \
  --group mymetrics \
  --version v1alpha1 \
  --kind PodMetricsSummary

# Generated structure:
# pkg/apis/mymetrics/v1alpha1/podmetricssummary_types.go   ← type definition
# pkg/apis/mymetrics/v1alpha1/podmetricssummary_rest.go    ← REST handler
# main.go                                                   ← server entrypoint

Authentication delegation — the key mechanism

The extension API server doesn't authenticate requests itself. It delegates to the main API server using TokenReview and SubjectAccessReview:

// The extension server's authentication flow (handled by apiserver-builder):
// 1. Client sends Bearer token to extension server
// 2. Extension server calls kube-apiserver TokenReview API
//    POST /apis/authentication.k8s.io/v1/tokenreviews
// 3. If valid, extension server calls SubjectAccessReview
//    POST /apis/authorization.k8s.io/v1/subjectaccessreviews
// 4. If authorized, handle the request

# RBAC: the extension server's ServiceAccount needs permission to call these APIs
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: extension-server-auth-delegator
rules:
- apiGroups: ["authentication.k8s.io"]
  resources: ["tokenreviews"]
  verbs:     ["create"]
- apiGroups: ["authorization.k8s.io"]
  resources: ["subjectaccessreviews"]
  verbs:     ["create"]

🧠 Knowledge Check

Q1. What happens when a client requests GET /apis/metrics.k8s.io/v1beta1/nodes?

A) kube-apiserver reads node metrics directly from etcd
B) The request is rejected — metrics.k8s.io is not a built-in API group
C) The aggregation layer looks up the registered APIService and proxies the request to the extension API server
D) CoreDNS resolves metrics.k8s.io and the client connects directly

Q2. Why does metrics-server use API Aggregation instead of a CRD?

A) CRDs don't support the metrics.k8s.io API group name
B) Metrics are computed live from kubelet scrapes — no persistent storage needed, so etcd-backed CRDs don't fit
C) CRDs can't be queried with kubectl top
D) API Aggregation has better performance for read-heavy workloads

Q3. An extension API server needs to verify a client's bearer token. How does it do this without duplicating authentication logic?

A) It reads the token from the Kubernetes Secret directly
B) It validates the JWT signature using the cluster CA certificate
C) It delegates to the main API server via TokenReview and SubjectAccessReview APIs
D> It trusts all requests since the API server already authenticated them

Q4. The APIService for your extension server shows Available: False. What is the impact and first diagnostic step?

A> No impact — the API server caches the last known response
B) All requests to that API group fail; first step: kubectl describe apiservice for the error message
C) Only write operations fail; reads continue from the etcd cache
D) The cluster auto-removes the APIService after 5 minutes