The scaling reconciler (internal/scaling/) determines the desired replica count
for the cache StatefulSet. It supports two mutually exclusive modes:
- Static -- a fixed replica count set in
spec.scaling.static.numServers. - Dynamic -- a percentage-based formula that tracks cluster size and adjusts replicas automatically.
Dynamic scaling includes tolerance bands and stabilization windows to prevent unnecessary churn during cluster changes.
For production installation and operational configuration, see the DACS deployment and operations guide.
When spec.scaling.static is configured, the desired replica count is always
numServers. The normalizer state is purged (no stabilization tracking needed)
and the status.scaling field is set to nil (stabilization tracking is not applicable).
When spec.scaling.dynamic is configured, the controller computes the desired
replica count on every reconcile using this pipeline:
List schedulable nodes matching nodeSelector
|
v
Calculate raw desired = floor(nodeCount * percentageOfNodesToUse / 100)
|
v
Apply server bounds (minServers / maxServers)
|
v
Normalize (tolerance + stabilization window)
|
v
Return stabilized replicas + RequeueAfter
The core formula is:
desired = floor(effectiveNodeCount * percentageOfNodesToUse / 100)
The replica count will always be at least 1 (the cache always has at least one server), unless minServers forces a floor-to-zero (see below).
Optional minServers and maxServers fields clamp the calculator output:
- maxServers: If set, the desired count is capped to
min(desired, maxServers). - minServers: If set and
desiredis between 1 andminServers - 1(exclusive), the desired count is floored to 0. This implements an "all-or-nothing" minimum: the cache either runs at leastminServersreplicas or shuts down entirely. This is useful when running fewer thanminServersreplicas would actually be less efficient than pulling from blob.
Bounds are applied in order: maxServers cap first, then minServers floor. Both fields are optional pointers; when nil, the corresponding bound is skipped.
Stabilization parameters are internal operator configuration, not exposed in the
Cache CRD. They are set in the controller's config file
(cache-controller-config.yaml) or via environment variables.
# cache-controller-config.yaml
scaleUpStabilizationWindow: 15m
scaleDownStabilizationWindow: 15m
scaleUpTolerancePercent: 10
scaleDownTolerancePercent: 10Environment variable overrides follow the CACHE_CONTROLLER_ prefix convention
(e.g., CACHE_CONTROLLER_SCALE_UP_STABILIZATION_WINDOW=20m).
The Cache CRD exposes only the core scaling knobs:
| Field | Type | Range | Default | Description |
|---|---|---|---|---|
percentageOfNodesToUse |
int32 |
1-100 | (required) | Percentage of schedulable matching nodes to use |
minServers |
*int32 |
1-499 | (nil) | Minimum replica count; if desired falls below this, floor to 0 |
maxServers |
*int32 |
1-499 | (nil) | Maximum replica count cap; must be >= minServers |
Operator-level config (applies to all Cache CRs):
| Field | Type | Range | Default | Description |
|---|---|---|---|---|
scaleUpStabilizationWindow |
time.Duration |
>= 0 | 15m |
How long the normalizer requires desired > current before scaling up |
scaleDownStabilizationWindow |
time.Duration |
>= 0 | 15m |
How long the normalizer requires desired < current before scaling down |
scaleUpTolerancePercent |
int32 |
0--100 | 10 |
Percentage change threshold below which scale-up is suppressed |
scaleDownTolerancePercent |
int32 |
0--100 | 10 |
Percentage change threshold below which scale-down is suppressed |
These values are validated at controller startup. The normalizer reads them from
config.Config, which is injected via the scaling controller constructor.
The normalizer smooths scaling decisions to avoid flapping. It applies two stages of smoothing before the controller acts on a raw desired count.
The tolerance check prevents the system from scaling in response to negligible proportional changes in the node pool. This avoids unnecessary StatefulSet churn when the cluster is near a scaling boundary.
ratio = rawDesired / currentReplicas
if scaling up (rawDesired > currentReplicas):
if ratio <= 1.0 + scaleUpTolerance:
suppress -> rawDesired = currentReplicas
if scaling down (rawDesired < currentReplicas):
if ratio >= 1.0 - scaleDownTolerance:
suppress -> rawDesired = currentReplicas
Example: With the default tolerance of 10% and currentReplicas = 10:
- A raw desired count of
11(10% increase) is suppressed -- the ratio1.1does not exceed1.0 + 0.1. - A raw desired count of
12(20% increase) is allowed -- the ratio1.2exceeds1.1.
Tolerance is applied per direction, so scale-up and scale-down can have different sensitivity.
After the tolerance check, the (possibly suppressed) desired count enters the stabilization window. The normalizer tracks two timestamps per Cache CR:
lastAtOrAbove-- the most recent time the desired count was >= currentlastAtOrBelow-- the most recent time the desired count was <= current
Scaling is allowed only when the desired count has been continuously on one side of the current count for the full window duration:
- Scale-up: allowed when
lastAtOrBelowis older thanupWindow(desired has been continuously above current for the full window). - Scale-down: allowed when
lastAtOrAboveis older thandownWindow(desired has been continuously below current for the full window).
When allowed, scaling jumps directly to the latest desired value -- no intermediate steps.
if scale-up (desired > current) and upWindow > 0:
age = now - lastAtOrBelow
if age < upWindow:
result = current (hold)
requeueAfter = upWindow - age
else:
result = desired (jump)
if scale-down (desired < current) and downWindow > 0:
age = now - lastAtOrAbove
if age < downWindow:
result = current (hold)
requeueAfter = downWindow - age
else:
result = desired (jump)
When the normalizer is holding back a change but the computed requeueAfter
is zero or negative (due to clock skew or rounding), it is clamped to 1
second. This ensures the controller always re-evaluates rather than treating
a held-back change as needing no requeue.
When desired == currentReplicas (including tolerance-suppressed values), both
timestamps are updated -- this correctly reflects that the system was recently
at the current level.
First observation: When a Cache CR is seen for the first time (no stored state), the normalizer stores initial state and scales immediately. This covers initial deployment and controller restarts.
Spec changes: The normalizer tracks the last-seen DynamicScaleConfig.
When recordObservation detects that the scaling spec has changed (via
equality.Semantic.DeepEqual), it zeroes both timestamps. On the next
stabilize call, the zeroed timestamps are detected and scaling proceeds
immediately. This ensures user-initiated configuration changes take effect
without waiting for a prior stabilization window to expire.
Consider a Cache with 10 replicas, default settings (tolerance = 10%), a 5 minute stabilization window, and a node pool scaling down:
| Time | Raw desired | After tolerance | lastAtOrAbove | Stabilized | Action |
|---|---|---|---|---|---|
| 0m | 10 | 10 | t=0 | 10 | Seed |
| 1m | 9 | 10 (suppressed) | t=0 | 10 | Tolerance band |
| 2m | 8 | 8 | t=0 | 10 | Held (age 2m < 5m) |
| 3m | 7 | 7 | t=0 | 10 | Held (age 3m < 5m) |
| 4m | 7 | 7 | t=0 | 10 | Held (age 4m < 5m) |
| 5m | 7 | 7 | t=0 | 7 | Jump (age 5m >= 5m) |
At t=0, recordObservation sets both lastAtOrAbove and lastAtOrBelow
to t=0 (raw 10 == current 10, satisfies both >= and <=).
At t=1m, the raw value of 9 is within the 10% tolerance band and is
suppressed to 10. However, recordObservation uses the raw value (9),
which only satisfies <=, so only lastAtOrBelow updates to t=1m.
lastAtOrAbove stays at t=0.
From t=2m onwards, the raw desired of 8 and 7 exceeds tolerance (20%+ drop)
but lastAtOrAbove is only 2-4 minutes old -- the window hasn't been served.
At t=5m, lastAtOrAbove is 5 minutes old. The desired count has been
continuously below current for the full window. A single scale-down to 7
occurs -- one ring rebalance instead of stepping through 9, 8.
Consider a Cache with 5 replicas (50% of 10 nodes). The cluster scales the pool to 20 nodes over 4 minutes, with new nodes becoming Ready one by one:
| Time | Nodes | Raw desired | After tolerance | lastAtOrBelow | Stabilized | Action |
|---|---|---|---|---|---|---|
| 0m | 10 | 5 | 5 | t=0 | 5 | Seed (first observation) |
| 1m | 12 | 6 | 6 | t=0 | 5 | Held (age 1m < 5m) |
| 2m | 14 | 7 | 7 | t=0 | 5 | Held (age 2m < 5m) |
| 3m | 16 | 8 | 8 | t=0 | 5 | Held (age 3m < 5m) |
| 4m | 18 | 9 | 9 | t=0 | 5 | Held (age 4m < 5m) |
| 5m | 20 | 10 | 10 | t=0 | 10 | Jump (age 5m >= 5m) |
From t=1m to t=4m, each arriving node increases the raw desired count, but
lastAtOrBelow stays at t=0 (the seed). Since desired > current at every
step, lastAtOrBelow is never refreshed. At t=5m the window expires and
scaling jumps directly from 5 to 10 -- one ring rebalance for the entire
node pool expansion.
Many managed Kubernetes upgrades add surge nodes temporarily, then drain old
nodes.
With maxSurge=1 on a 10-node pool, the schedulable count oscillates between
10 and 11 throughout the upgrade:
| Time | Event | Schedulable | Raw desired | After tolerance | Action |
|---|---|---|---|---|---|
| 0m | Steady | 10 | 5 | 5 | No change |
| 0m30s | Surge +1 | 11 | 6 | 5 (suppressed) | 20% up, within tolerance |
| 1m | Drain -1 | 10 | 5 | 5 | No change |
| 2m | Surge +1 | 11 | 6 | 5 (suppressed) | Tolerance suppresses |
| 2m30s | Drain -1 | 10 | 5 | 5 | No change |
| ... | (repeat x10) | 10-11 | 5-6 | 5 | Tolerance suppresses |
The upgrade produces zero scaling operations. Here's why:
-
When a surge node appears (11 schedulable -> raw desired 6), that's a 20% increase from 5. With 10% tolerance,
6/5 = 1.2 > 1.1, so it would pass tolerance -- but the stabilization window still holds becauselastAtOrBelowwas just updated (we were at 5 moments ago). -
When the old node is drained (back to 10 -> desired 5 = current), both timestamps update to now.
-
The cycle repeats every ~2 minutes. Each time desired returns to current, both timestamps reset. The window never completes because the desired count keeps crossing back.
For larger pools (100 nodes, maxSurge=10), tolerance alone handles it: 110
nodes -> 55 desired is a 10% increase from 50, which falls within the tolerance
band and is suppressed entirely.
The cache controller reconciles in response to several types of events, plus timer-based requeue:
| Source | Mechanism | When it fires |
|---|---|---|
| Cache CR changes | For(&Cache{}) with GenerationChangedPredicate |
User edits spec (generation increments) |
| Owned StatefulSet | Owns(&StatefulSet{}) with generation, label, or status predicates |
Replica count changes, pods become ready |
| Owned Service / ConfigMap | Owns(&Service{}), Owns(&ConfigMap{}) with generation or label predicates |
Spec or label changes |
| Node changes | Watches(&Node{}) with NodePredicate |
Node added, removed, cordoned, uncordoned, or labels change |
| Pod changes | Watches(&Pod{}) with cacheserverPodPredicate |
Cache-server pod scheduling or status changes |
| Stabilization requeue | RequeueAfter on reconcile result |
Timer expires after oldest window recommendation ages out |
Not every node event triggers reconciliation. NodePredicate filters to only
the events that could change the scaling decision:
- Create: always enqueued (new node may match selector).
- Delete: always enqueued (node removed from pool).
- Update: enqueued only when
spec.unschedulablechanges (cordon/uncordon) or node labels change. Other updates (status heartbeats, resource pressure conditions) are ignored.
The MapNodeToCaches function finds all Cache CRs whose
spec.nodeSelectorKey/Value matches the changed node's labels and enqueues
reconciliation for each.
Reconcile(Cache)
|
+-- Static scaling?
| +-- Purge normalizer history (no stabilization needed)
| +-- Set status.scaling = nil
| +-- Return spec.static.numServers
|
+-- Dynamic scaling
+-- List schedulable nodes matching nodeSelector
+-- Calculate rawDesired (with server bounds)
|
+-- First reconciliation (currentReplicas == 0)?
| +-- Seed normalizer history (no stabilization applied)
| +-- Set status.scaling = {targetServers: N}
| +-- Return rawDesired (immediate bootstrap)
|
+-- Subsequent reconciliations
+-- normalizer.NormalizeDesiredReplicas()
| +-- validateBehavior()
| +-- tolerance check
| +-- stabilization window
|
+-- RequeueAfter > 0?
| +-- YES: set status.scaling = {targetServers: N, stabilizationEnds: ...}
| | log "stabilization holding back scaling"
| | schedule requeue
| +-- NO: set status.scaling = {targetServers: N}
|
+-- Return stabilized replica count
Kubernetes controllers are event-driven -- they reconcile only when a watched resource changes. But stabilization windows are inherently time-driven: a recommendation that was blocked now may become actionable when an older recommendation expires.
RequeueAfter bridges this gap. When the normalizer holds back a change, it
computes when the oldest recommendation in the active window will expire and
returns that duration:
expiry = oldestRecommendationTimestamp + window
requeueAfter = expiry - now
The controller passes this to the status helper, causing the workqueue to re-enqueue the Cache CR after the specified delay.
Without RequeueAfter, a stabilization-blocked decision would remain stuck
until an unrelated event (like a node change) happened to trigger
reconciliation.
Multiple events can race with the requeue timer:
- Node added/removed during window: A node event triggers immediate reconciliation. The normalizer records a new recommendation and re-evaluates the window. If the new recommendation is more conservative than the held-back one, the window extends naturally.
- User changes behavior during window: If the user modifies the scaling spec, the normalizer detects the change, resets stabilization timestamps, and scales immediately on the next reconciliation -- the prior window is discarded.
- Multiple requeue timers: The controller framework deduplicates requeue
requests -- if a reconciliation has already been scheduled sooner than the new
RequeueAfter, the earlier one wins.
Each Cache CR has independent normalizer state stored in a sync.Map keyed by
namespace/name. The state is two timestamps (lastAtOrAbove, lastAtOrBelow)
and a copy of the last-seen scaling spec -- O(1) memory regardless of how many
reconciliations occur.
State is purged:
- When a Cache switches from dynamic to static scaling.
- When a Cache CR is deleted (via the finalizer cleanup in
Deleted(key)).
The controller exposes stabilization state through the status.scaling field
on the Cache CR (not as a condition, since stabilization is an internal
optimization and should not affect the aggregate Ready state):
status:
scaling:
targetServers: 5
stabilizationEnds: "2024-01-15T10:05:00Z"
message: "Stabilization window active, scaling to 5 servers after 2024-01-15T10:05:00Z"| Field | Description |
|---|---|
targetServers |
The server count the scaling algorithm computed. Nil on error paths. When a stabilization window is active this is the count the system would apply once the window expires. |
stabilizationEnds |
Set when a stabilization window is holding back a scaling change. The time at which the current window expires. Nil when settled. |
message |
Human-readable summary of the current scaling state. Uses absolute timestamps so the value stays stable across reconcile loops. |
The field is nil only for static scaling (stabilization does not apply).
For error paths (node listing failures, calculation errors, missing config),
the field is set with a message describing the error; targetServers will
be zero and stabilizationEnds nil. A non-nil stabilizationEnds indicates
a stabilization window is active; when it is nil the system has either settled
at targetServers or encountered an error (check message). This is visible
in kubectl get cache -o yaml and gives operators immediate insight into
whether scaling is being delayed and when it will resume.
The tolerance fields use int32 representing a percentage (0--100). This
provides a simple, intuitive config (scaleUpTolerancePercent: 10 means 10%).
When currentReplicas == 0 (the Cache is first created or transitioning from
static to dynamic scaling), the normalizer is seeded but not applied. This
allows the initial scale-up to happen immediately without waiting for a
stabilization window to expire.
Each Cache CR's stabilization state is two timestamps and a spec copy -- O(1)
memory regardless of reconciliation frequency. This avoids accumulating entries
proportional to the reconciliation frequency * window duration. The sync.Map
is ideal because different Cache CRs reconcile concurrently but access
different keys.
# Check scaling status
kubectl get cache <name> -n dacs-system -o jsonpath='{.status.scaling}' | jq .
# Check conditions
kubectl get cache <name> -n dacs-system -o jsonpath='{.status.conditions}' | jq .
# Watch for scaling events
kubectl describe cache <name> -n dacs-system | grep -A5 scaling
# Check current vs desired
kubectl get cache <name> -n dacs-system -o jsonpath='{.status.desiredServers} desired, {.status.currentServers} current'The scaling controller logs at verbosity level 1 (V(1)):
"Scaling calculation"- emitted on every dynamic reconciliation withmatchingNodes,percentage,rawDesired, andcurrentReplicas."Stabilization holding back scaling"- emitted whenRequeueAfter > 0, includesrawDesired,stabilized(the held-back count), andrequeueAfter.
Enable with --zap-log-level=1 or equivalent controller-runtime log
configuration.
| Component | File | Purpose |
|---|---|---|
| Scaling reconciler | internal/scaling/scaling_controller.go |
Main entry point, node lifecycle integration |
| Calculator | internal/scaling/calculator.go |
Raw desired-count formula |
| Normalizer | internal/scaling/normalizer.go |
Tolerance + stabilization window |
| Node predicate | internal/scaling/node_predicate.go |
Filters relevant node events |
| Config | internal/config/config.go |
Default tolerance and stabilization values |