Technical Reference

Scaling Limits of the Kubernetes Resource Model & Operator Pattern

Empirical boundaries, key metrics, and failure modes for etcd, the API server, controller-runtime, and kubebuilder — drawn from SIG Scalability, Crossplane, OpenAI, and Google's 65,000-node GKE benchmarks.

SIG Scalability SLOs
Crossplane CRD Testing
Google GKE 65K Benchmark
OpenAI 7,500-Node Clusters

The Control Plane Scaling Stack

The Kubernetes control plane has no single "max capacity" setting. Instead, practical limits emerge from a stack of intersecting constraints — etcd storage at the bottom, API server concurrency in the middle, and client-side rate limiting at the top. Understanding which layer is the bottleneck for your workload is the first step toward scaling.

Architecture — Control Plane Bottleneck Stack
%%{init: {'theme': 'dark', 'themeVariables': {'primaryColor': '#1a1e28', 'primaryBorderColor': '#3d4760', 'primaryTextColor': '#c8cdd8', 'lineColor': '#5b9aff', 'secondaryColor': '#222836', 'tertiaryColor': '#13161d', 'edgeLabelBackground': '#13161d', 'clusterBkg': '#13161d', 'clusterBorder': '#2a3040'}}}%% graph TB subgraph Operator["Operator Process"] A["Informer Cache
full copy of watched resources"] B["Work Queue
dedup + rate limit"] C["Reconciler(s)
MaxConcurrent = 1 default"] A -->|"watch events"| B B -->|"dequeued keys"| C end subgraph ClientGo["client-go Layer"] D["REST Rate Limiter
5 QPS / burst 10"] E["Watch Multiplexer
reconnect + bookmark"] end subgraph APIServer["API Server"] F["API Priority & Fairness
600 inflight requests"] G["Watch Cache
RWMutex per kind"] H["Admission + Validation"] end subgraph Etcd["etcd Cluster"] I["Raft Consensus
WAL fsync ≤ 10ms p99"] J["MVCC B+Tree
8 GB max recommended"] K["Compaction &
Defragmentation"] end C -->|"GET / PATCH / CREATE"| D D -->|"HTTP requests"| F E -->|"WATCH stream"| G F --> H --> I G -.->|"single watch per kind"| I I --> J J --> K style Operator fill:#1a1e28,stroke:#5b9aff,stroke-width:2px style ClientGo fill:#1a1e28,stroke:#f0a848,stroke-width:2px style APIServer fill:#1a1e28,stroke:#a878f0,stroke-width:2px style Etcd fill:#1a1e28,stroke:#e85858,stroke-width:2px
Key Insight

CRDs are explicitly excluded from the official SIG Scalability SLOs. The 5,000-node / 150,000-pod envelope only covers built-in resources. Operator scaling is an empirical exercise with no upstream guarantees.

Volume Limits — Hard Ceilings

The practical ceiling on how many Custom Resources, CRDs, and controllers a cluster can host is determined by etcd storage, API server memory, and informer cache overhead — not by any single configurable max.

8 GB
etcd database max (recommended)
Default: 2 GB · GKE: 6 GB · EKS: 8 GB
~500
CRDs per cluster (informal guidance)
sig-api-machinery · tested to 2,000
1.5 MiB
Max etcd value size per key
Hard limit in etcd
200K
Open watches per cluster
GKE recommended max
100K
Total etcd objects (recommended)
SAP Gardener guidance · excl. events
3–4 MiB
API server memory cost per CRD
Crossplane empirical measurement

CRs per Resource Type — The Storage Math

GKE enforces a per-resource-type budget of 800 MB in etcd. The number of Custom Resources you can store depends entirely on object size:

1 KB per CR
~800,000 CRs
5 KB per CR
~160,000
10 KB per CR
~80,000
50 KB per CR
~16,000
max_CRs = etcd_budget / (avg_CR_bytes × MVCC_overhead)
Where MVCC_overhead ≈ 1.2–1.5× (revisions + compaction lag)

CRD Count Degradation Curve

Crossplane's testing revealed a non-linear degradation pattern as CRDs increase. The problems appear in distinct phases:

CRD Count → API Server Degradation Phases
%%{init: {'theme': 'dark', 'themeVariables': {'primaryColor': '#222836', 'primaryBorderColor': '#3d4760', 'primaryTextColor': '#c8cdd8', 'lineColor': '#5b9aff'}}}%% graph LR A["0–500 CRDs
Stable
Normal ops"] --> B["500–765 CRDs
CPU Spike
OpenAPI v2 recompute"] B --> C["765–1,430 CRDs
Slow Discovery
~40s cache refresh"] C --> D["1,430–2,000 CRDs
Memory Pressure
5.5–7.5 GiB RSS"] D --> E["2,000+ CRDs
Cluster Breaks
OOM / 1hr recovery"] style A fill:#1a3a28,stroke:#48c078,color:#c8cdd8 style B fill:#2a2a18,stroke:#f0a848,color:#c8cdd8 style C fill:#2a2218,stroke:#f0a848,color:#c8cdd8 style D fill:#2a1818,stroke:#e85858,color:#c8cdd8 style E fill:#3a1818,stroke:#e85858,color:#c8cdd8,stroke-width:2px
Dimension Practical Limit Hard / Absolute Source
etcd database size8 GB recommendedConfigurable (perf degrades)etcd docs
CRs per resource type~80K @ 10KB each800 MB per type (GKE)GKE docs
Total etcd objects~100K recommendedDegrades at ~30–40KGardener, vCluster
CRDs per cluster~500 informal~2,000 tested with tuningsig-api-machinery
API server mem per CRD~3–4 MiBN/ACrossplane empirical
Open watches / cluster200,000Soft limitGKE docs
Pods per cluster150,000Soft (SLO boundary)SIG Scalability
Nodes per cluster5,000 official65,000 benchmarkedSIG Scalability, Google

Three Layers of Rate Limiting

Operator throughput is governed by three independent, multiplicative rate-limiting layers. Tuning only one while ignoring the others produces no visible improvement — you must identify which layer is actually the bottleneck.

Sequence — A Single Reconciliation Through All Three Layers
%%{init: {'theme': 'dark', 'themeVariables': {'actorBkg': '#222836', 'actorBorder': '#5b9aff', 'actorTextColor': '#c8cdd8', 'signalColor': '#5b9aff', 'labelBoxBkgColor': '#1a1e28', 'labelTextColor': '#c8cdd8', 'noteBkgColor': '#2a2218', 'noteTextColor': '#c8cdd8', 'noteBorderColor': '#f0a848', 'activationBkgColor': '#222836', 'activationBorderColor': '#5b9aff', 'sequenceNumberColor': '#c8cdd8'}}}%% sequenceDiagram participant WQ as Work Queue participant R as Reconciler participant RL as client-go
Rate Limiter participant API as API Server
(APF) participant E as etcd Note over WQ: Layer 2: Queue Rate Limiter
10 QPS global + per-item backoff WQ->>R: Dequeue key (after rate limit wait) activate R R->>RL: client.Get(CR) Note over RL: Layer 1: REST Limiter
5 QPS / burst 10 RL->>API: GET /apis/group/v1/crs/name Note over API: Layer 3: APF
600 inflight max API->>E: Range query E-->>API: Object bytes API-->>RL: 200 OK + CR RL-->>R: CR object R->>RL: client.Status().Patch() RL->>API: PATCH /apis/.../status API->>E: Put (full object) Note over E: WAL fsync ≤ 10ms p99 E-->>API: OK API-->>RL: 200 OK RL-->>R: Updated CR R-->>WQ: Result{Requeue: false} deactivate R Note over WQ: ~3 API calls ÷ 5 QPS
= ~0.6s per reconciliation
= ~1.5 reconciles/sec max

Layer 1 — client-go REST Rate Limiter

The tightest constraint for most operators. The default token bucket allows 5 requests per second with a burst of 10. A controller making 3 API calls per reconciliation (get, patch status, create child) maxes out at roughly 1.5 reconciliations per second.

max_reconciles_per_sec = client_qps / api_calls_per_reconcile
default: 5 / 3 = 1.67 reconciles/sec
client-go default throughput ceiling

Layer 2 — controller-runtime Workqueue Rate Limiter

A MaxOfRateLimiter combining per-item exponential backoff (base 5 ms, max 1,000 s) with a global token bucket (10 QPS, burst 100). Prevents a failing CR from thrashing while capping thundering herd on restart.

Flowchart — Workqueue Rate Limiting Decision Tree
%%{init: {'theme': 'dark', 'themeVariables': {'primaryColor': '#222836', 'primaryBorderColor': '#3d4760', 'primaryTextColor': '#c8cdd8', 'lineColor': '#5b9aff'}}}%% flowchart TD A["Item enqueued
(requeue or watch event)"] --> B{"First attempt
for this key?"} B -->|Yes| C["Per-item backoff:
5ms initial"] B -->|No| D["Per-item backoff:
5ms × 2^failures
max 1000s"] C --> E{"Global bucket
has token?"} D --> E E -->|Yes| F["Dequeue immediately"] E -->|No| G["Wait for token
10 QPS refill"] G --> F F --> H["Process reconciliation"] H --> I{"Result?"} I -->|"Success"| J["Reset per-item
backoff counter"] I -->|"Error / Requeue"| K["Increment per-item
failure count
→ re-enqueue"] style A fill:#222836,stroke:#5b9aff,color:#c8cdd8 style F fill:#1a3a28,stroke:#48c078,color:#c8cdd8 style K fill:#2a1818,stroke:#e85858,color:#c8cdd8

Layer 3 — API Priority & Fairness (APF)

Server-side protection distributing 600 total inflight requests (400 non-mutating + 200 mutating) across priority levels. GA since Kubernetes 1.29. When exceeded, clients receive HTTP 429.

APF Request Distribution (Default 600 Inflight)
%%{init: {'theme': 'dark', 'themeVariables': {'primaryColor': '#222836', 'primaryBorderColor': '#3d4760', 'primaryTextColor': '#c8cdd8', 'lineColor': '#5b9aff', 'pie1': '#5b9aff', 'pie2': '#a878f0', 'pie3': '#48c078', 'pie4': '#f0a848', 'pie5': '#e85858', 'pieTitleTextColor': '#e8ecf4', 'pieSectionTextColor': '#e8ecf4', 'pieLegendTextColor': '#c8cdd8'}}}%% pie title Inflight Request Budget (600 total) "Non-mutating (GET/LIST/WATCH)" : 400 "Mutating (CREATE/PATCH/DELETE)" : 200
LayerDefaultTuned (Recommended)Controls
client-go REST5 QPS / burst 1050 QPS / burst 100Outbound HTTP to API server
Workqueue (global)10 QPS / burst 10050–100 QPSReconciliation dequeue rate
Workqueue (per-item)5ms base / 1000s max1s base for external APIsFailing item backoff
APF (server)400+200 inflight1600+800 (1000+ nodes)API server admission
MaxConcurrentReconciles15–10Parallel reconciliation goroutines
etcd write throughput10–50K writes/secHardware-dependent (NVMe)Storage backend

Key Metrics & KPIs

The right metrics provide early warning of scaling issues before outages. They fall into three domains: controller health, API server pressure, and etcd capacity.

Monitoring Domain Map — Where to Look First
%%{init: {'theme': 'dark', 'themeVariables': {'primaryColor': '#222836', 'primaryBorderColor': '#3d4760', 'primaryTextColor': '#c8cdd8', 'lineColor': '#5b9aff'}}}%% graph LR subgraph Controller["🎛 Controller Health"] direction TB M1["workqueue_depth"] M2["reconcile_time_seconds"] M3["reconcile_total{error}"] M4["active_workers vs max"] M5["longest_running_processor"] end subgraph APIServer["⚡ API Server Pressure"] direction TB M6["request_duration_seconds"] M7["current_inflight_requests"] M8["flowcontrol_rejected_total"] M9["request_terminations_total"] end subgraph Etcd["💾 etcd Capacity"] direction TB M10["mvcc_db_total_size_in_bytes"] M11["mvcc_db_in_use_in_bytes"] M12["request_duration p99"] M13["disk_wal_fsync_duration"] end Controller -.->|"too many reconciles"| APIServer APIServer -.->|"too many writes"| Etcd style Controller fill:#13161d,stroke:#5b9aff,stroke-width:2px,color:#c8cdd8 style APIServer fill:#13161d,stroke:#a878f0,stroke-width:2px,color:#c8cdd8 style Etcd fill:#13161d,stroke:#e85858,stroke-width:2px,color:#c8cdd8

Controller Health — Leading Indicators

MetricHealthyWarningCritical
workqueue_depth0 (transient spikes OK)Sustained > 0Growing monotonically
reconcile_time_seconds p99< 1s1–10s> 30s
reconcile_total{result="error"} ratio< 1%1–5%> 10%
active_workers / max_concurrent< 80%80–95%100% sustained
longest_running_processor_seconds< 10s10–60s> 60s (stuck)

etcd Capacity — The Most Consequential Alerts

Critical — etcd NOSPACE

When etcd_mvcc_db_total_size_in_bytes reaches 100% of quota, etcd enters NOSPACE alarm mode and becomes read-only. The cluster cannot create, update, or delete any resources — including deletions needed to free space. Recovery requires manual etcdctl alarm disarm, compaction, and defragmentation.

Alert when db_total_size / db_quota > 0.80
Defrag when (db_total - db_in_use) / db_total > 0.50
etcd storage alerting thresholds

API Server Pressure — SIG Scalability SLOs

SLISLO TargetScope
Single-object mutating call latency (p99)≤ 1 secondPer verb, per resource
Non-streaming read-only call latency (p99)≤ 1 second (single) / ≤ 30s (list)Per scope
Pod startup latency (p99)≤ 5 secondsStateless, no init containers
APF rejected requests0 (non-overloaded cluster)Per priority level

Published Benchmarks & Research

The most rigorous scaling data converges on a consistent set of boundaries. Here are the key findings from each source.

SIG Scalability — Official Envelope

5,000
Nodes
150K
Pods
300K
Containers
≤ 20
Ops/sec churn rate
Non-convex Envelope

These thresholds form a non-convex, bounded envelope: satisfying one dimension at maximum means other dimensions must be lower. You cannot run 5,000 nodes AND 150,000 pods simultaneously.

Benchmark Timeline

OpenAI — 7,500 Nodes (Production)
5 API servers with 70 GB heap each. Separate etcd cluster for events. EndpointSlices reduced endpoint update load by 1,000×. Key insight: avoid DaemonSets that interact with the API server.
Crossplane — 2,000 CRD Experiment
At 780 CRDs: stable. At 1,430: discovery takes ~40s. Above 2,000: cluster effectively broke. Root causes fixed across K8s 1.22–1.25 (lazy OpenAPI, shared etcd clients, discovery caching).
Google GKE — 65,000 Node Benchmark (April 2025)
500 scheduler bindings/sec, 13,000 QPS for lease updates. Required replacing etcd with Spanner — demonstrating etcd as the fundamental ceiling.
Google GKE — 130,000 Node Benchmark
Sustained 1,000 pod ops/sec using Spanner backend. Proved Kubernetes API semantics can survive at extreme scale with alternative storage.
KubeEdge — 100,000 Edge Node Test
Demonstrated that edge topologies decouple node count from etcd pressure via CloudCore intermediary. API server was still the coordination bottleneck.

Upstream Fixes That Raised the CRD Ceiling

Kubernetes Version → CRD Scaling Improvements
%%{init: {'theme': 'dark', 'themeVariables': {'primaryColor': '#222836', 'primaryBorderColor': '#3d4760', 'primaryTextColor': '#c8cdd8', 'lineColor': '#5b9aff', 'cScale0': '#1a3a28', 'cScale1': '#1a2a38', 'cScale2': '#2a2a18', 'cScale3': '#1a1a38', 'cScale4': '#2a1a28'}}}%% timeline title Upstream CRD Scaling Fixes section K8s 1.22 Lazy OpenAPI v2 marshaling : Eliminated O(n²) CPU spike at ~765 CRDs section K8s 1.23 Shared etcd clients : Removed per-CRD etcd connections + Zap logger overhead section K8s 1.25 Discovery burst 100→300 : Faster discovery cache population Discovery cache TTL 10m→6h : Reduced repeated full discovery section K8s 1.27 Aggregated Discovery (KEP-3352) : ~300 HTTP requests → single endpoint section K8s 1.29 APF GA : Replaced max-inflight with fair queuing section K8s 1.34 Resilient Watch Cache Init : Rejects watches/lists during warm-up with 429

Failure Cascades

Each failure mode follows a characteristic cascade pattern. Understanding the sequence is more valuable than knowing the threshold in isolation.

The Thundering Herd Cascade

The most common catastrophic failure. Triggered by API server restart, network partition recovery, or etcd compaction.

Sequence — Thundering Herd After API Server Restart
%%{init: {'theme': 'dark', 'themeVariables': {'actorBkg': '#222836', 'actorBorder': '#e85858', 'actorTextColor': '#c8cdd8', 'signalColor': '#e85858', 'labelBoxBkgColor': '#1a1e28', 'labelTextColor': '#c8cdd8', 'noteBkgColor': '#2a1818', 'noteTextColor': '#c8cdd8', 'noteBorderColor': '#e85858'}}}%% sequenceDiagram participant K as Kubelets
(5,000 nodes) participant API as API Server
(restarted) participant WC as Watch Cache
(cold) participant E as etcd Note over API: API Server restarts
Watch cache is empty K->>API: WATCH (resourceVersion=old) API-->>K: 410 Gone ❌ Note over K: resourceVersion expired
in compaction window K->>API: LIST ?resourceVersion="" Note over API: Bypasses watch cache!
Hits etcd directly API->>E: Range scan (full list) Note over E: 90,000 concurrent LISTs
from all kubelets + operators E-->>API: Timeout / Slow response API-->>K: 504 or partial response Note over K,E: 💥 Cascade: retries amplify load
etcd WAL fsync > 50ms
Leader election instability

Informer Cache OOM Anti-Pattern

Flowchart — How a Single Get() Call Causes OOM
%%{init: {'theme': 'dark', 'themeVariables': {'primaryColor': '#222836', 'primaryBorderColor': '#3d4760', 'primaryTextColor': '#c8cdd8', 'lineColor': '#e85858'}}}%% flowchart TD A["Controller calls
client.Get(ConfigMap)"] --> B["controller-runtime
starts informer for
ALL ConfigMaps"] B --> C["Initial LIST returns
every ConfigMap in cluster
into ring buffer"] C --> D{"Event handler
keeps up?"} D -->|"Yes"| E["Stable memory
✅"] D -->|"No (slow handler)"| F["Ring buffer grows
unbounded"] F --> G["Pod memory
exceeds limit"] G --> H["OOMKilled 💀"] H --> I["Kubernetes
restarts pod"] I --> B style H fill:#3a1818,stroke:#e85858,color:#c8cdd8,stroke-width:2px style E fill:#1a3a28,stroke:#48c078,color:#c8cdd8 style I fill:#2a1818,stroke:#e85858,color:#c8cdd8
Real-World Example

The Contour project saw operator memory grow from 25 MiB to 1,000 MiB when unrelated Secrets were added to the cluster — its informer cached all Secrets cluster-wide. A Red Hat engineer reduced an operator from gigabytes to under 100 MB by removing a single Get() call that triggered an unnecessary informer.

Reconciliation Drift Under Load

full_pass_time = num_CRs × avg_reconcile_time / concurrent_workers

Example: 10,000 × 100ms / 1 = 16.7 minutes
With external API calls (1–5s each): hours
Time for controller to process all managed resources

Failure Mode Summary

Failure ModeTriggerCascadeMitigation
Thundering Herd API server restart 410 Gone → full re-LISTs → etcd overload Watch bookmarks, Resilient WC Init (1.34)
etcd NOSPACE db_size ≥ quota Read-only → no mutations → no deletes Alert at 80%, status update hygiene
Informer OOM Implicit Get() starts broad informer Full LIST → unbounded cache → OOMKill loop Scoped caches, field selectors, transforms
Reconciliation Drift Queue depth > throughput Growing lag → stale state → incorrect actions Concurrent reconcilers, SSA, sharding
GC Storm Delete parent with 1000s of children Burst of DELETEs → exhausts mutating budget Background deletion, orphan policy

Scaling Strategies — Ranked by Impact

The most impactful scaling techniques are architectural decisions made early, not parameter tuning applied later. Evidence from production operators points to a clear hierarchy.

Impact Hierarchy — Order of Operations for Scaling
%%{init: {'theme': 'dark', 'themeVariables': {'primaryColor': '#222836', 'primaryBorderColor': '#3d4760', 'primaryTextColor': '#c8cdd8', 'lineColor': '#5b9aff'}}}%% graph TD subgraph Arch["🏗 Architecture — 10–100× Impact"] A1["Narrow informer scope
label/field selectors, transforms"] A2["Server-Side Apply (SSA)
no-op writes skip etcd + watches"] A3["Controller sharding
label-based or consistent hashing"] end subgraph Tuning["🔧 Tuning — 5–10× Impact"] B1["Increase client-go QPS
5→50 QPS, 10→100 burst"] B2["MaxConcurrentReconciles
1→5–10 workers"] B3["Predicate filters
GenerationChanged, custom"] end subgraph Infra["☁️ Infrastructure — Unbounded"] C1["Multi-cluster / vCluster
separate etcd per tenant"] C2["Alternative storage
Spanner, CockroachDB"] C3["Dedicated etcd for events"] end Arch --> Tuning --> Infra style Arch fill:#13161d,stroke:#48c078,stroke-width:2px,color:#c8cdd8 style Tuning fill:#13161d,stroke:#5b9aff,stroke-width:2px,color:#c8cdd8 style Infra fill:#13161d,stroke:#a878f0,stroke-width:2px,color:#c8cdd8

1. Narrow Informer Scope

The single change with the largest impact. Configure cache.Options with per-GVK label selectors, field selectors, and namespace restrictions. Use SetTransform to strip managed fields, annotations, and last-applied-configuration before caching. Routinely reduces memory by 80–90%.

2. Server-Side Apply (SSA)

SSA's critical scaling property: no-op writes are not stored in etcd or broadcast to watchers. This eliminates the hot-loop anti-pattern where a controller writes back an unchanged object, triggering its own watch. Also removes the last-applied-configuration annotation (which can grow very large).

Comparison — Client-Side Apply vs Server-Side Apply at Scale
%%{init: {'theme': 'dark', 'themeVariables': {'primaryColor': '#222836', 'primaryBorderColor': '#3d4760', 'primaryTextColor': '#c8cdd8', 'lineColor': '#5b9aff'}}}%% flowchart LR subgraph CSA["Client-Side Apply"] direction TB C1["GET object"] --> C2["Modify in memory"] C2 --> C3["PUT entire object"] C3 --> C4["etcd stores
(even if unchanged)"] C4 --> C5["Watch event fires"] C5 --> C6["Controller re-triggered"] C6 --> C1 end subgraph SSA["Server-Side Apply"] direction TB S1["PATCH with fieldManager"] S1 --> S2{"Fields changed?"} S2 -->|"No"| S3["No etcd write ✅
No watch event ✅"] S2 -->|"Yes"| S4["etcd stores diff only"] S4 --> S5["Watch event fires
(only if changed)"] end style CSA fill:#2a1818,stroke:#e85858,stroke-width:2px,color:#c8cdd8 style SSA fill:#1a3a28,stroke:#48c078,stroke-width:2px,color:#c8cdd8

3. Controller Sharding

For >10,000 CRs. The most battle-tested approach is label-based sharding: a sharder assigns objects to instances via a shard label, and each instance uses a label selector to watch only its subset.

Architecture — Label-Based Controller Sharding
%%{init: {'theme': 'dark', 'themeVariables': {'primaryColor': '#222836', 'primaryBorderColor': '#3d4760', 'primaryTextColor': '#c8cdd8', 'lineColor': '#5b9aff'}}}%% graph TB S["Sharder
assigns shard labels"] subgraph CRs["Custom Resources"] CR1["CR-001
shard=A"] CR2["CR-002
shard=B"] CR3["CR-003
shard=A"] CR4["CR-004
shard=C"] CR5["CR-005
shard=B"] CR6["CR-006
shard=C"] end subgraph Controllers["Controller Instances"] I1["Instance A
selector: shard=A"] I2["Instance B
selector: shard=B"] I3["Instance C
selector: shard=C"] end S -->|"label"| CRs CR1 --> I1 CR3 --> I1 CR2 --> I2 CR5 --> I2 CR4 --> I3 CR6 --> I3 style S fill:#222836,stroke:#5b9aff,stroke-width:2px,color:#c8cdd8 style Controllers fill:#13161d,stroke:#48c078,stroke-width:2px,color:#c8cdd8 style CRs fill:#13161d,stroke:#f0a848,stroke-width:2px,color:#c8cdd8

4. Rate Limiter Tuning

Start with client-go REST limits — increasing from 5/10 to 50/100 QPS/burst is safe for most clusters and immediately unlocks 10× more throughput. Then increase MaxConcurrentReconciles to 5–10 (same object is never reconciled concurrently due to workqueue dedup).

5. Predicate Filters

predicate.GenerationChangedPredicate skips status-only updates (which don't change .metadata.generation). Custom predicates can filter annotation-only changes and other noisy updates. Predicates don't reduce cache size — they prevent unnecessary reconcile enqueues.

The Scaling Landscape

The Kubernetes operator scaling landscape is defined by intersecting constraints rather than any single hard limit. etcd's 8 GB ceiling and its inability to scale horizontally remain the fundamental bottleneck — Google's solution at 65,000+ nodes was to replace etcd with Spanner entirely.

Below that threshold, the client-go 5 QPS default is the most commonly encountered bottleneck, silently throttling operators that could otherwise handle 10–50× more throughput. The informer-caches-everything design, combined with controller-runtime's implicit informer creation on any Get() call, is the most frequent source of OOM kills in production operators.

1,000s
CRs manageable with defaults
100Ks
CRs with tuning + sharding
1M+
Requires non-standard storage

The most valuable insight from production experience: architectural decisions dominate parameter tuning. Narrowing informer scope, adopting Server-Side Apply, and implementing controller sharding each deliver order-of-magnitude improvements. Rate limiter tuning provides 5–10× gains. Together, these push a well-designed operator from thousands to hundreds of thousands of CRs — but reaching millions requires stepping outside the standard KRM model entirely.