Kubernetes Architect Prep — Advanced Topics Module
1. Custom Resource Definitions (CRDs)
Core concept: A CRD extends the Kubernetes API by registering a new resource type (group/version/kind) with the API server, without modifying Kubernetes source code. Once registered, instances of that type (“Custom Resources” or CRs) are stored in etcd and managed through kubectl, RBAC, and the standard API machinery exactly like built-in resources (Pods, Deployments).
Why it matters architecturally: CRDs are the foundation of the “Kubernetes as a platform” pattern — they let you model domain-specific abstractions (a Database, a KafkaTopic, a CertificateRequest) declaratively, then drive reconciliation through controllers/Operators.
Anatomy of a CRD:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: databases.platform.example.com
spec:
group: platform.example.com
scope: Namespaced # or Cluster
names:
kind: Database
plural: databases
singular: database
shortNames: [db]
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
engine: { type: string, enum: ["postgres","mysql"] }
replicas: { type: integer, minimum: 1 }
required: ["engine"]
required: ["spec"]
subresources:
status: {}
additionalPrinterColumns:
- name: Engine
type: string
jsonPath: .spec.engine
Key architectural details:
| Aspect | Detail |
|---|---|
| Validation | OpenAPI v3 schema (structural schemas mandatory since v1). Use XValidation (CEL expressions) for cross-field validation since 1.25+ |
| Versioning | Multiple versions[] with exactly one storage: true; conversion webhooks handle version translation when schemas diverge |
| Status subresource | Separates spec (desired) from status (observed) updates — prevents controllers and users from racing on the same object via optimistic concurrency (resourceVersion) |
| Scale subresource | /scale endpoint lets HPA target CRs directly without bespoke logic |
| Defaulting/Pruning | Default values declared in schema; unknown fields pruned unless x-kubernetes-preserve-unknown-fields: true |
| Storage | CRDs are stored as generic Unstructured objects in etcd unless you build a typed API (aggregated API server) |
CRD vs Aggregated API Server:
| CRD | Aggregated API Server | |
|---|---|---|
| Implementation effort | Low — declarative YAML | High — implement full API server |
| Storage | etcd (via main API server) | Pluggable, can use own datastore |
| Performance | Good for moderate scale | Better for high-throughput, custom storage needs |
| Use case | 95% of platform extension needs | Specialized needs (e.g., metrics API, custom auth) |
Interview-grade Q&A:
- Q: How does the API server validate a CR without compiled Go types? — It uses the structural OpenAPI schema embedded in the CRD itself, validated via reflection/dynamic typing, not compiled structs.
- Q: What happens during a CRD version migration? — A
conversionwebhook (strategy:Webhook) is invoked by the API server to convert betweenstorageversion andservedversions on read/write;Nonestrategy assumes identical schemas. - Q: Why have a separate
statussubresource? — It gets its own RBAC verb and prevents clients updatingspecfrom clobbering controller-writtenstatus(and vice versa) under concurrent writes.
2. Operators
Core concept: An Operator = CRD + Controller implementing the reconciliation control loop to encode operational knowledge (deployment, scaling, backup, failover) for a specific application, turning a human SRE runbook into code.
Control loop pattern (the core of all of Kubernetes):
┌─────────────┐ watch ┌──────────────┐
│ API Server │ ─────────────► │ Controller │
│ (etcd) │ ◄───────────── │ Reconcile() │
└─────────────┘ write status └──────────────┘
▲ │
│ desired state │
└────────── compare ───────────┘
Reconcile loop pseudocode (controller-runtime / Go, the de facto standard via Kubebuilder/Operator SDK):
func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var db platformv1.Database
if err := r.Get(ctx, req.NamespacedName, &db); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
desired := buildStatefulSet(&db)
var existing appsv1.StatefulSet
err := r.Get(ctx, client.ObjectKeyFromObject(desired), &existing)
if errors.IsNotFound(err) {
return ctrl.Result{}, r.Create(ctx, desired)
}
if !reflect.DeepEqual(existing.Spec, desired.Spec) {
existing.Spec = desired.Spec
return ctrl.Result{}, r.Update(ctx, &existing)
}
db.Status.Phase = "Ready"
return ctrl.Result{RequeueAfter: 30 * time.Second}, r.Status().Update(ctx, &db)
}
Key design principles architects are expected to know:
| Principle | Explanation |
|---|---|
| Level-based, not edge-based | Reconcile compares full desired vs actual state every time — never assumes it knows what “changed.” Makes it self-healing and idempotent. |
| Idempotency | Reconcile() can be called any number of times for the same state with no side effects beyond convergence. |
| Eventual consistency | RequeueAfter / exponential backoff on error, not synchronous guarantees. |
| Owner references | Child objects (StatefulSet, Service) set ownerReferences to the CR for garbage collection cascade. |
| Finalizers | Block deletion until external cleanup (e.g., cloud DB snapshot, deregistering from external system) completes. |
| Informer/cache pattern | Controllers don’t poll the API server directly — they use a shared informer cache (list-watch + local indexed store) to avoid hammering etcd. |
Operator Maturity Model (commonly tested):
- Basic Install – installs the application
- Seamless Upgrades – handles version upgrades
- Full Lifecycle – backup, restore, failover
- Deep Insights – metrics, alerts, log processing
- Auto Pilot – auto-scaling, auto-tuning, anomaly detection
Operators vs Helm:
| Helm | Operator | |
|---|---|---|
| Model | Templating + one-shot install/upgrade | Continuous reconciliation |
| Day-2 ops | Manual (helm upgrade) |
Automated, self-healing |
| State awareness | None post-install | Continuously watches actual state |
| Best for | Stateless apps, simple config | Stateful apps with complex operational logic (databases, message brokers) |
Interview-grade Q&A:
- Q: How does a controller avoid overwhelming the API server with requests? — Client-go informers maintain a local cache via list-watch; reconcile reads from cache, writes go through a rate-limited workqueue with exponential backoff.
- Q: What’s the difference between a Controller and an Operator? — A Controller manages built-in or generic resources; an Operator is a Controller specifically encoding application-specific operational knowledge via a CRD.
- Q: How do you prevent two controller replicas from reconciling simultaneously? — Leader election (
coordination.k8s.io/Leaseobjects) ensures only one active reconciler; others stand by.
3. Multi-Cluster Management
Why multi-cluster: blast-radius isolation, regulatory data residency, latency-sensitive edge deployments, vendor/cloud diversification, and avoiding single control-plane scale limits (etcd practically caps around 5k nodes / 150k pods per cluster).
Architectural patterns:
| Pattern | Description | Tooling |
|---|---|---|
| Cluster API (CAPI) | Declarative cluster lifecycle management — clusters as CRs in a management cluster | cluster-api, provider plugins (CAPA, CAPZ, CAPG) |
| Fleet / Hub-Spoke | Central “hub” cluster pushes config/policy to “spoke” clusters | Argo CD ApplicationSets, Flux, Rancher Fleet, Open Cluster Management (OCM) |
| Federation (KubeFed v2) | Single API to propagate resources across member clusters | Largely superseded by GitOps-based fleet patterns |
| Service Mesh Federation | Cross-cluster service discovery and mTLS at L7 | Istio multi-primary / primary-remote, Linkerd multicluster |
| Multi-cluster Ingress/Gateway | Global load balancing across clusters | GKE Multi-Cluster Gateway, AWS Global Accelerator + Route53 latency routing |
GitOps-driven multi-cluster (most common architect-level answer today):
Git Repo (source of truth)
│
▼
Argo CD (on hub cluster)
│
├── ApplicationSet (cluster generator) ──► Cluster A
├── ApplicationSet ──► Cluster B
└── ApplicationSet ──► Cluster C
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: payments-service
spec:
generators:
- clusters:
selector:
matchLabels: { env: production }
template:
metadata:
name: '{{name}}-payments'
spec:
destination:
server: '{{server}}'
source:
repoURL: https://git.example.com/platform/payments
targetRevision: main
path: overlays/{{name}}
Cross-cluster networking decisions an architect must make:
- Flat network (cloud VPC peering / Cilium ClusterMesh) vs gateway-mediated (Istio east-west gateway) connectivity
- Consistent CIDR planning across clusters to avoid pod-IP overlap before any peering
- DNS strategy: per-cluster CoreDNS + stub domains, or a global service registry
- Failover: active-active (traffic split via global LB) vs active-passive (DR cluster, RTO/RPO driven)
Multi-cluster vs multi-tenant single cluster trade-off (frequently asked):
| Driver | Favors Multi-Cluster | Favors Multi-Tenant Single Cluster |
|---|---|---|
| Blast radius / isolation | ✅ | ❌ |
| Operational overhead | ❌ (N control planes) | ✅ |
| Compliance/data residency | ✅ | ❌ |
| Resource utilization efficiency | ❌ | ✅ |
| Noisy-neighbor risk | ✅ avoided | ❌ requires strict quotas/NetworkPolicy |
Interview-grade Q&A:
- Q: How do you handle config drift across 50 clusters? — GitOps reconciliation (Argo CD/Flux) continuously pulls desired state from Git per cluster; drift is auto-corrected, not manually patched.
- Q: How does Cluster API differ from a managed Kubernetes service? — CAPI manages cluster lifecycle as Kubernetes objects on a management cluster, giving a uniform declarative API across any infrastructure provider, vs each cloud’s bespoke control plane API.
- Q: What’s your approach to cross-cluster service discovery? — Either a service mesh’s multicluster control plane (shared trust domain, ServiceEntry sync) or a global DNS/service registry pattern with per-cluster failover policies.
4. Kubernetes API Internals
The request lifecycle (the single most important diagram to know cold):
kubectl/client
│
▼
┌─────────────────────────────────────────────────────────┐
│ kube-apiserver │
│ 1. Authentication (certs, tokens, OIDC) │
│ 2. Authorization (RBAC, Node, Webhook) │
│ 3. Admission Control │
│ a. Mutating Admission Webhooks (+ built-in mutators) │
│ b. Object Schema Validation │
│ c. Validating Admission Webhooks │
│ 4. etcd write (via watch-cache / registry layer) │
└─────────────────────────────────────────────────────────┘
│
▼
Watch events fan out to all watchers (controllers, kubelets, schedulers)
Key internal mechanisms:
| Mechanism | Detail |
|---|---|
| etcd | Distributed key-value store (Raft consensus); the only persistent state store in K8s. API server is the sole writer. |
| Watch/List pattern | Clients don’t poll; they LIST once (get resourceVersion) then WATCH from that version — long-lived HTTP streams, chunked encoding |
| Resource Versioning | Every object has a resourceVersion (maps to etcd’s mod-revision) used for optimistic concurrency — Update fails with 409 Conflict if stale |
| API Aggregation Layer | Routes requests for registered APIService paths (e.g., metrics.k8s.io) to extension API servers behind the front-end proxy |
| OpenAPI/Discovery | /openapi/v2, /apis endpoints let kubectl and clients discover available resources and schemas dynamically |
| Admission Webhooks | External HTTP services called synchronously during the request; must respond fast (typically <1s, configurable timeoutSeconds); failure policy (Fail/Ignore) determines behavior on webhook unavailability |
| Server-Side Apply (SSA) | Field-level ownership tracking (managedFields) lets multiple controllers co-own an object’s different fields without clobbering — the modern alternative to client-side 3-way merge patches |
| Watch bookmarks & informer relist | resourceVersion too old (etcd compaction) triggers 410 Gone; client must re-LIST. Informers handle this transparently. |
Why kube-scheduler and kubelet are “just clients”: Architecturally critical insight — the scheduler and kubelet have no special API access; they’re regular clients that watch/list/patch objects via the same API surface as kubectl. The scheduler watches unscheduled Pods (spec.nodeName == ""), runs filtering/scoring, then patches spec.nodeName — it doesn’t “launch” anything. The kubelet on that node then watches for Pods bound to it and starts containers via CRI.
Admission control ordering (commonly tested detail): Mutating webhooks run before validating webhooks, and built-in admission plugins are interleaved per the configured --enable-admission-plugins order — so a mutation can still pass through validation logic after modification, but never the reverse.
Interview-grade Q&A:
- Q: What happens on a
409 Conflict? — Client sent a staleresourceVersion; it must re-GET, re-apply changes, and retry — this is the entire basis of optimistic concurrency control in K8s. - Q: Difference between client-side apply and server-side apply? — Client-side computes a 3-way merge patch locally and has no concept of field ownership across multiple writers; SSA delegates merge logic to the server and tracks ownership per field/manager, resolving multi-controller conflicts deterministically.
- Q: How does the watch cache scale to thousands of watchers? — The API server maintains an in-memory ring buffer (
watchCache) per resource type fed from etcd’s watch stream, multiplexing a single etcd watch to N client watches instead of N etcd connections.
5. Event-Driven Autoscaling (KEDA)
Core concept: KEDA (Kubernetes Event-Driven Autoscaling) extends HPA to scale workloads based on external event sources (queue depth, topic lag, cron, custom metrics) rather than just CPU/memory — and uniquely, can scale to and from zero, which the native HPA cannot do.
Architecture:
External Source (Kafka lag, SQS depth, Cron, Prometheus, Redis...)
│
▼
┌───────────────────┐ ┌──────────────────────┐
│ KEDA Operator │ │ Metrics Adapter │
│ (ScaledObject │◄────►│ (implements external. │──► HPA (native K8s)
│ controller) │ │ metrics.k8s.io API) │ │
└───────────────────┘ └──────────────────────┘ ▼
│ scales Deployment
▼ (scale 0→1)
Activates Deployment directly (bypassing HPA, which can't scale from 0)
KEDA has two operating modes:
- 0 → 1 (Activation): KEDA’s own controller scales the Deployment from zero based on polling the scaler — HPA can’t do this since it requires running pods to read metrics from.
- 1 → N (Scaling): KEDA registers as a Kubernetes External Metrics Adapter, and the native HPA controller takes over, polling KEDA’s metrics API and scaling per standard HPA algorithm.
ScaledObject example (Kafka consumer lag scaling):
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: order-consumer-scaler
spec:
scaleTargetRef:
name: order-consumer # Deployment to scale
minReplicaCount: 0
maxReplicaCount: 50
cooldownPeriod: 60 # seconds before scaling to 0 after activity stops
pollingInterval: 15 # seconds between scaler checks
triggers:
- type: kafka
metadata:
bootstrapServers: kafka-broker:9092
consumerGroup: order-consumer-group
topic: orders
lagThreshold: "50" # target avg lag per replica
activationLagThreshold: "5" # min lag to trigger 0→1 activation
authenticationRef:
name: kafka-trigger-auth
ScaledJob (alternative to ScaledObject) — for batch/queue-drain workloads:
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
name: image-processing-job
spec:
jobTargetRef:
template:
spec:
containers:
- name: processor
image: registry/image-processor:v1
restartPolicy: Never
maxReplicaCount: 100
triggers:
- type: aws-sqs-queue
metadata:
queueURL: https://sqs.us-east-1.amazonaws.com/xxxx/image-jobs
queueLength: "5"
Use ScaledObject for long-running services (Deployments/StatefulSets) and ScaledJob when each unit of work should map to a discrete Job pod (better isolation, natural retry semantics, no idle pod cost between work items).
Key architectural decisions:
| Decision | Guidance |
|---|---|
| Scaler count per workload | One ScaledObject can have multiple triggers; KEDA scales on the max computed replica count across all triggers |
| Activation vs scaling threshold | Separate thresholds (activationLagThreshold vs lagThreshold) prevent flapping at 0↔1 boundary |
| Polling vs push | Most scalers (Kafka, SQS, Cron) poll on pollingInterval; some (Prometheus, custom metrics) can be near-real-time depending on backend |
| Cooldown tuning | Too short → thrashing under bursty load; too long → wasted spend holding idle replicas |
| Authentication | TriggerAuthentication/ClusterTriggerAuthentication CRs centralize secrets (IRSA, pod identity, secret refs) — never inline credentials in ScaledObject |
KEDA vs native HPA vs Cluster Autoscaler/Karpenter (frequently conflated in interviews — be precise):
| HPA | KEDA | Cluster Autoscaler / Karpenter | |
|---|---|---|---|
| Scales | Pod replica count | Pod replica count (incl. to/from 0) | Node count |
| Metric source | CPU/memory, or custom/external metrics API | 60+ event sources (queues, streams, cron, DBs) | Pending/unschedulable pods |
| Scale to zero | ❌ | ✅ | N/A (scales nodes, not pods) |
| Relationship | KEDA feeds HPA via the metrics adapter; Cluster Autoscaler reacts to the pod scaling decisions made above it by provisioning nodes |
Interview-grade Q&A:
- Q: Why can’t native HPA scale to zero but KEDA can? — HPA computes metrics by querying running pods (resource metrics) or an external metrics API — at zero replicas there’s nothing to query. KEDA’s operator runs independently of the workload, polling the event source directly, so it can detect “should be 1” before any pod exists.
- Q: How does KEDA integrate with HPA rather than replace it? — KEDA implements the
external.metrics.k8s.ioAPI; it creates an HPA object behind the scenes once replicas > 0, and the standard Kubernetes HPA controller owns the 1→N scaling decision loop, with KEDA only supplying the metric value. - Q: How do you avoid scaling storms with bursty Kafka traffic? — Tune
activationLagThresholdseparately fromlagThreshold, set a sanecooldownPeriod, and considerstabilizationWindowSecondson the underlying HPA behavior to dampen scale-down flapping.