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.
Overview
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.
Section 01
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:
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 size | 8 GB recommended | Configurable (perf degrades) | etcd docs |
| CRs per resource type | ~80K @ 10KB each | 800 MB per type (GKE) | GKE docs |
| Total etcd objects | ~100K recommended | Degrades at ~30–40K | Gardener, vCluster |
| CRDs per cluster | ~500 informal | ~2,000 tested with tuning | sig-api-machinery |
| API server mem per CRD | ~3–4 MiB | N/A | Crossplane empirical |
| Open watches / cluster | 200,000 | Soft limit | GKE docs |
| Pods per cluster | 150,000 | Soft (SLO boundary) | SIG Scalability |
| Nodes per cluster | 5,000 official | 65,000 benchmarked | SIG Scalability, Google |
Section 02
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
| Layer | Default | Tuned (Recommended) | Controls |
| client-go REST | 5 QPS / burst 10 | 50 QPS / burst 100 | Outbound HTTP to API server |
| Workqueue (global) | 10 QPS / burst 100 | 50–100 QPS | Reconciliation dequeue rate |
| Workqueue (per-item) | 5ms base / 1000s max | 1s base for external APIs | Failing item backoff |
| APF (server) | 400+200 inflight | 1600+800 (1000+ nodes) | API server admission |
| MaxConcurrentReconciles | 1 | 5–10 | Parallel reconciliation goroutines |
| etcd write throughput | 10–50K writes/sec | Hardware-dependent (NVMe) | Storage backend |
Section 03
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
| Metric | Healthy | Warning | Critical |
workqueue_depth | 0 (transient spikes OK) | Sustained > 0 | Growing monotonically |
reconcile_time_seconds p99 | < 1s | 1–10s | > 30s |
reconcile_total{result="error"} ratio | < 1% | 1–5% | > 10% |
active_workers / max_concurrent | < 80% | 80–95% | 100% sustained |
longest_running_processor_seconds | < 10s | 10–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
| SLI | SLO Target | Scope |
| Single-object mutating call latency (p99) | ≤ 1 second | Per verb, per resource |
| Non-streaming read-only call latency (p99) | ≤ 1 second (single) / ≤ 30s (list) | Per scope |
| Pod startup latency (p99) | ≤ 5 seconds | Stateless, no init containers |
| APF rejected requests | 0 (non-overloaded cluster) | Per priority level |
Section 04
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
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
Section 05
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 Mode | Trigger | Cascade | Mitigation |
| 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 |
Section 06
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.
Conclusion
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.