Replication Controller: Scale Up and Scale Down¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
In the previous part, we saw the reconciliation loop. Now let's look at how the ReplicationController creates pods for a Deployment and manages their count.
Creating a Pod from a Deployment¶
func (rc *ReplicationController) createPodForDeployment(
dep *Deployment, index int) {
pod := &Pod{
Kind: "Pod",
Metadata: ObjectMeta{
Name: fmt.Sprintf("%s-%d",
dep.Metadata.Name, index),
Namespace: dep.Metadata.Namespace,
UID: generateUID(),
Labels: mergeLabels(
dep.Spec.Template.Metadata.Labels,
dep.Spec.Selector),
CreatedAt: time.Now(),
},
Spec: dep.Spec.Template.Spec,
Status: PodStatus{Phase: PodPending},
}
rc.store.CreatePod(pod)
rc.store.RecordEvent(Event{
Type: "Normal",
Reason: "Created",
Message: fmt.Sprintf("Created pod %s for deployment %s",
pod.Metadata.Name, dep.Metadata.Name),
Object: "deployment/" + dep.Metadata.Name,
})
// Scheduling
go rc.scheduler.SchedulePod(pod)
}
Notice the naming: web-0, web-1, web-2. Like a StatefulSet in Kubernetes, just simpler.
The pod's labels are a merge of the deployment's template and selector. This is what ties pods to the deployment.
The Link Through Labels¶
graph TB
DEP["Deployment: web<br/>selector: app=web<br/>replicas: 3"]
DEP --> P0["Pod: web-0<br/>labels: app=web"]
DEP --> P1["Pod: web-1<br/>labels: app=web"]
DEP --> P2["Pod: web-2<br/>labels: app=web"]
A Deployment doesn't store a list of its pods. It finds them through label selectors: "all pods with label app=web in my namespace are mine."
func mergeLabels(sets ...map[string]string) map[string]string {
result := make(map[string]string)
for _, s := range sets {
for k, v := range s { result[k] = v }
}
return result
}
Scale Up¶
If there are fewer pods than spec.replicas:
if current < desired {
for i := 0; i < desired-current; i++ {
rc.createPodForDeployment(dep, current+i)
}
rc.logger.Printf(
"replication controller: scaled up %s from %d to %d",
dep.Metadata.Name, current, desired)
}
New pods are created with index current+i. The Scheduler assigns them to nodes automatically.
Scale Down¶
If there are more pods than needed:
if current > desired {
for i := 0; i < current-desired; i++ {
pod := matchingPods[len(matchingPods)-1-i]
rc.store.DeletePod(
pod.Metadata.Namespace, pod.Metadata.Name)
}
}
We delete from the end of the list. In real Kubernetes, there's more sophisticated logic: Failed pods are deleted first, then Pending, then those with the shortest uptime.
Updating Deployment Status¶
ready := 0
for _, pod := range matchingPods {
if pod.Status.Phase == PodRunning { ready++ }
}
dep.Status.Replicas = len(matchingPods)
dep.Status.ReadyReplicas = ready
dep.Status.AvailableReplicas = ready
rc.store.UpdateDeployment(dep)
ReadyReplicas is how many pods are actually Running. sheepctl get deployments will show something like 3/3 Ready.
The Full Flow from sheepctl to Container¶
sequenceDiagram
participant U as sheepctl
participant API as API Server
participant RC as ReplicationController
participant S as Scheduler
participant A as Agent
participant R as Sheep Runtime
U->>API: POST /api/v1/deployments<br/>{replicas: 3}
API->>API: Save to Store
RC->>RC: reconcile() every 5s
RC->>API: Sees: 0 pods, needs 3
RC->>API: CreatePod(web-0)
RC->>API: CreatePod(web-1)
RC->>API: CreatePod(web-2)
S->>S: reconcile() every 2s
S->>API: Pod web-0 → node-1
A->>A: reconcile() every 3s
A->>R: Create + Start container
A->>API: Pod web-0: Running
Everything is asynchronous. Each component works at its own pace and reads state from the Store.
What We Skipped¶
Scale down removes pods from the end of the list without considering their state. In Kubernetes, a ReplicaSet first deletes Failed pods, then Pending, then those with the shortest uptime. Our approach is simpler but less smart.
- Index-based naming
web-0..web-Nis dangerous: deleteweb-1, and on scale-up the controller countscurrentand recreates a pod with the same name -- or steps on a stale record. That's why a Kubernetes ReplicaSet gives pods random suffixes (web-x7k2p) instead of sequential indices. - If two selectors in different deployments overlap, a single pod can "belong" to both -- and two controllers will start alternately creating and deleting it. Owner references in Kubernetes exist precisely to guard against this.
💡 Fun facts¶
- Sequential names like
web-0, web-1are actually the StatefulSet model, not Deployment. A regular Kubernetes Deployment creates a ReplicaSet with a random hash in the pod names precisely because stable identity is only needed by stateful workloads (databases, queues), while stateless replicas are interchangeable. - A Deployment in Kubernetes doesn't manage pods directly -- there's an intermediate object, the ReplicaSet, between them. That layer is exactly what makes rolling updates work: a new ReplicaSet is created and the old one is scaled down to zero. Our controller skips this level.
- The "deployment → pods" link in Kubernetes rests not only on the label selector but also on the
ownerReferencesfield in each pod. This is what gives you cascading deletion (garbage collection): delete the deployment and its pods get cleaned up automatically. - The scale-down deletion order in Kubernetes is defined by an explicit cost function: Pending/unscheduled first, then not-Ready, then younger pods, then those on nodes with more replicas -- to thin out the cluster evenly.
ReplicationControllerwas literally the first controller Kubernetes ever shipped -- our class name is a direct homage. It was later superseded by ReplicaSet (which gained set-based selectors), butkubectlstill answers to thercshortname for backward compatibility.kubectl scaledoesn't trigger any special "scale" operation -- it just patches thespec.replicasfield via a dedicated/scalesubresource. The controller picks up the new number on its next reconcile, exactly like ours does. The same field is what a HorizontalPodAutoscaler rewrites automatically based on CPU/memory metrics -- the controller has no idea it's being autoscaled.replicas: 0is perfectly valid and is the standard way to "pause" a workload without deleting its config. Scale-to-zero is the whole foundation of serverless platforms like Knative and KEDA, which spin a Deployment down to nothing when idle and back up on the first request.
What I figured out while digging into this¶
The least obvious thing turned out to be that a deployment doesn't really "own" its pods in any direct sense -- it doesn't hold them in a list, it re-finds them every time through the label selector. At first that felt fragile: what if someone slaps the same label on by hand? But then I realized that this very loose coupling is what makes the system resilient -- the controller doesn't care where a pod came from, only that the labels match. The downside is that selectors are easy to overlap by accident, and then two controllers end up fighting over the same pods.
What could be improved¶
- Replace sequential indices with random name suffixes so that scale-up after a mid-list deletion doesn't conflict.
- Implement a smart scale-down order: Failed first, then Pending, then the youngest pods.
- Add an intermediate ReplicaSet layer to get a foundation for rolling updates and rollbacks.
- Introduce
ownerReferencesand cascading deletion instead of relying on the selector alone.
Try It Yourself¶
# Scale deployment:
sheepctl scale deployment web --replicas 5
sheepctl get pods # you'll see 5 pods
sheepctl scale deployment web --replicas 2
sheepctl get pods # you'll see 2 pods
Scaling works. Next up -- Service Discovery: how a Service automatically finds its pods.
Resources¶
- ReplicaSet — declarative replication in Kubernetes
- ReplicationController — the original (now legacy) controller our class is named after
- Deployment — the Deployment → ReplicaSet → Pod layering
- HorizontalPodAutoscaler — automatic scaling that just rewrites
spec.replicas - Controllers — controller pattern
- Manage deployments — operational guide
Source code for the series: github.com/igorgorovoy/sheep-shepherd-meadow
Previous: Reconciliation Loop | Next: Service Discovery
