Docker and Kubernetes: Why Containers Are Light and Clusters Heal Themselves
Prerequisite:Git: History as a Merkle DAG and the Collaborative Workflow
0. Key points
Section titled “0. Key points”- A container is not a small virtual machine. It is an ordinary process on the host, with the kernel’s namespaces restricting what it can see and cgroups restricting how much it can use. There is only one kernel, and it belongs to the host.
- The real content of the “it works on my machine” problem is that a program’s true dependencies are far larger than its
requirements.txt— they include the operating system’s libraries, its file layout, and its environment variables — and that all of this is left implicit. A Dockerfile turns those implicit dependencies into code whose changes Git can track. - An image is a sequence of immutable layers. From that structure alone we obtain two facts: the build cache can only hit on a prefix of the instruction sequence (Proposition 4.2), and deleting a file in a later layer does not shrink the image (Proposition 4.5). Nearly all the conventional wisdom about writing Dockerfiles is a consequence of these two.
- The core of Kubernetes is “a declarative API plus a reconciliation loop”. The user writes down the desired state, and controllers keep closing the gap between it and the current state. We show in Proposition 6.2 that this design converges in finitely many steps and that it survives dropped notifications.
- The benefit of redundancy can be computed. Under independent failures the availability is , but if all replicas sit on the same node, adding replicas hits a ceiling set by the node’s own availability (Theorem 7.1, Corollary 7.3).
- The speed of a zero-downtime update is likewise fixed by configuration. The duration of a rolling update has the lower bound (Proposition 7.7), and the accident of “we played it safe and it took 20 minutes” can be predicted from this formula in advance.
1. Motivation: what exactly is the problem with “it works on my machine”
Section titled “1. Motivation: what exactly is the problem with “it works on my machine””Someone joins the team, clones the repository, follows the README, and nothing runs. Half a day of investigation later, the cause turns out to be that developer A’s macOS had libjpeg installed through Homebrew while the newcomer’s machine did not. In software development, time disappears into this sort of thing without limit. Failures that occur only on the production server, or only in CI, are other symptoms of the same disease.
Naively this is puzzling. The source code is shared in full through Git; not a single byte differs. The behaviour differs anyway because the source code is not the only thing the program depends on. Enumerating the dependencies shows that there are more of them than one expects.
| Layer of dependency | Examples | How it used to be shared |
|---|---|---|
| Language packages | requirements.txt, package-lock.json | Shared through Git |
| OS shared libraries | versions of libssl, libjpeg, glibc | “Please install these” in a setup document |
| OS configuration | locale, time zone, file descriptor limits | Nobody wrote it down |
| File layout | absolute paths of config files, location of certificates | Different for each person |
| Runtime environment variables | DATABASE_URL, PATH | A .env file sent over Slack |
| Kernel and architecture | Linux 5.x or 6.x, amd64 or arm64 | Not even consciously considered |
Only the top row was shared through Git. The five rows below it were shared through a setup document — a program written in natural language that is never executed. A document that is never executed cannot be tested, and what cannot be tested rots.
Historically, the answers to this problem were refined roughly in the following order.
- Setup documents (a wiki, or a Word file). They are never executed, so they inevitably drift away from reality.
- Configuration management tools (CFEngine, Puppet, Chef, Ansible). These turned setup procedures into code. That was a large step forward, but their target is “an existing server on which something is already installed”, so the result depends on the previous state. Running the same playbook against a machine that has been in production for ten years and against a brand-new one can give different results.
- Virtual machines (VMware, VirtualBox, Vagrant). Shipping the whole operating system raised reproducibility dramatically. The price was weight: images of several gigabytes, boot times of tens of seconds, and at best a handful running simultaneously on one laptop.
- Containers. These achieve the same “ship the whole environment” as virtual machines without duplicating an entire operating system.
One historical remark. The component technologies of containers were not invented by Docker. chroot, which replaces the root of the file system a process sees, was in Version 7 Unix in 1979; FreeBSD jail appeared in 2000, Solaris Zones in 2005, and Linux cgroups — developed at Google as “process containers” — entered kernel 2.6.24 in 2008. The ingredients were all on the table by 2008.
What Docker changed when it appeared in 2013 was not the isolation mechanism but the format of the image as a distributable artefact and the procedure for building it reproducibly from a single-file recipe. Once the whole chain was in place — put the recipe (the Dockerfile) in Git, distribute the artefact (the image) through a registry, run it identically anywhere — the environment became, for the first time, an object under version control. It is an example of a technology whose value lay not in the parts but in the way the parts were combined.
In what follows we first define precisely what a container is (§3), then derive the Dockerfile conventions as theorems (§4, §5), then look at what Kubernetes does in a world where one machine is no longer enough (§6), and finally evaluate the effects of redundancy, autoscaling and zero-downtime updates numerically (§7).
2. Preliminaries: the runtime environment as a 4-tuple
Section titled “2. Preliminaries: the runtime environment as a 4-tuple”To make the discussion precise, we regard “the environment in which a program runs” as the following 4-tuple.
- : the kernel. The implementation and ABI of the system calls, and the CPU architecture.
- : the root file system. Which shared libraries live in
/usr/lib, what is written in/etc. - : the visible scope. The list of processes, network interfaces, mounts, host name and user-ID mapping that the process can see.
- : the available resources. CPU time, memory, number of PIDs, I/O bandwidth.
“It works on my machine” arises because , and differ from person to person. A dynamically linked executable, for instance, searches for its shared libraries at startup.
$ ldd ./myapp linux-vdso.so.1 (0x00007ffd8b7f0000) libjpeg.so.62 => not found libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2e4c000000)That single not found line is what costs half a day.
3. What a container really is: namespaces and cgroups
Section titled “3. What a container really is: namespaces and cgroups”Definition 3.1(Container)
A container on Linux is a collection of one or more processes that have been given the following three things.
- Namespaces: the view of resources such as process IDs, mounts, networking, host name, inter-process communication and user IDs is separated from that of the host and of other containers. In the notation of §2 this is a restriction of .
- cgroups (control groups): upper bounds are imposed on the use of CPU, memory, number of PIDs and so on. This is a restriction of .
- A root file system: by means of
pivot_rootor similar, the processes see a file system prepared for the container as their root. This is a replacement of .
The kernel is the host’s, used as it is. Consequently a process inside a container is, seen from the host, an ordinary process appearing in ps.
Namespaces are independent by kind, so one can separate only what needs separating. The principal ones are these.
| Namespace | What is separated | What happens without it |
|---|---|---|
| mount | the set of mount points | files of another container are visible |
| PID | the process-ID space | one can kill processes of another container |
| network | interfaces, routing, ports | two containers cannot both use port 80 |
| IPC | shared memory, semaphores | name collisions occur |
| UTS | host name, domain name | everything has the same host name |
| user | mapping of user and group IDs | root inside the container is root on the host |
| cgroup | the view of the cgroup hierarchy | one can see outside one’s own resource limits |
The effect of the PID namespace is an easy illustration. Running ps inside a container shows one’s own process first, as PID 1. Looking at the same process from the host, the PID may be 24187. The same process carries different numbers depending on where you look at it — that is the content of “isolation”, and it is a rather modest contraption for something called virtualization.
cgroups, by contrast, are about upper bounds. If a memory limit of 512 MiB is set in cgroup v2, then when the processes in that group reach the limit the kernel attempts reclamation and, if that is not enough, stops a process in the group with the OOM killer. A Kubernetes Pod that suddenly becomes OOMKilled is the result of this mechanism at work.
The difference between the two halves of the figure comes down to the number of kernels, drawn in the accent colour. With virtual machines, each additional machine brings an additional kernel, and that kernel needs memory and boot time of its own. With containers the kernel count does not grow. This difference shows up as a difference in practical properties.
| Virtual machine | Container | |
|---|---|---|
| Kernel | one per guest | one on the host (shared) |
| Boot time | tens of seconds (including OS boot) | tens to hundreds of milliseconds |
| Image size | several GB | tens to hundreds of MB |
| Number per host | a few to a dozen or so | dozens to hundreds |
| Strength of isolation | strong isolation by the hypervisor | isolation by kernel features |
| Running a different OS | possible (Windows on Linux) | not possible (same kernel only) |
“Starts fast” and “many at once” are the preconditions for the autoscaling and rolling updates we look at later. A unit that takes tens of seconds to start cannot follow a sudden surge in load, and cannot be deployed dozens of times a day.
Sharing the kernel means that a kernel vulnerability is directly a way through the isolation. Containers were not designed to be the primary barrier in a multi-tenant environment. In practice, observe at least the following.
- Do not run as root inside the container (specify
USERin the Dockerfile; see the example in §4). - Enable the user namespace, so that uid 0 inside the container maps to an unprivileged uid on the host.
- Avoid
--privileged, unnecessary capabilities, and mounts of the host file system. - If untrusted code must run, consider execution environments that interpose a virtualization boundary, such as gVisor, Kata Containers or Firecracker.
Note also that “Docker” is no longer the only implementation. The image and runtime specifications are standardized under the OCI (Open Container Initiative), and since v1.24 Kubernetes does not call Docker Engine directly but runs containers through containerd or CRI-O, which implement the CRI. It is thanks to this standardization that an image built from a Dockerfile simply runs.
4. Images and layers: two consequences of immutability
Section titled “4. Images and layers: two consequences of immutability”Definition 4.1(Image and layer)
An image is a pair consisting of an ordered sequence of layers together with metadata describing the startup configuration (environment variables, working directory, default command, and so on). Each layer is immutable data, named uniquely by its content, recording a difference (addition, modification or deletion of files) against the file system produced by the layers before it.
When a container is started, the runtime stacks through (a union file system) and places one thin writable layer on top. Deleting a file in does not erase the actual data in or earlier; it only records a mark in (a whiteout) saying “this path has been deleted”.
That layers are immutable and content-addressed — the same idea by which Git names objects by the hash of their content (content-addressable store(Definition 2.1)[Git]) — yields the pleasant property that an identical layer need be transferred and stored only once. If ten images use the same base image, the base exists physically once on the host. On the other hand, immutability also creates two constraints. Both bear directly on how Dockerfiles should be written, so we state them as theorems.
4.1. The cache only helps on a prefix
Section titled “4.1. The cache only helps on a prefix”Let the instructions of a Dockerfile be . At build time a cache key is computed for each instruction:
Here is a cryptographic hash function and is a normalized representation of the instruction: for RUN it is the command string itself, while for COPY and ADD it includes the command string together with the hash of the contents of the files being copied.
Proposition 4.2(The prefix property of the build cache)
Suppose we build using the key computation above, under the rule that “the -th instruction hits the cache exactly when all instructions up to the -st hit and a layer corresponding to exists locally”. Assume is collision-free.
Then the set of instructions that hit the cache is a prefix for some . Moreover, if is the least index at which has changed since the previous build, then , and are all re-executed.
Proof(Proposition 4.2)
The first half follows from the rule itself. If the -th instruction hits, then by the first clause of the rule the -st also hits. Repeating this, all of hit. Hence the set of hitting indices is closed downwards, that is, a prefix. It suffices to take to be the largest hitting index (with if none hits).
For the second half: for the value of has not changed, so as long as is the same, the recurrence for the keys gives inductively that also has its previous value. The layers corresponding to these keys therefore remain locally available and can hit.
For , on the other hand, the second argument of has changed. Since is assumed collision-free, differs from its previous value. Then the first argument of has changed too, so it also differs, and inductively for all . No layer exists for a key that has never been generated before, so instructions from the -th on all miss the cache. Hence , and combining with the prefix property of the first half, everything from onwards is re-executed.
The practical meaning of this proposition is a single rule: put the instructions that change rarely first and the ones that change often last. Source code changes every time, while the list of dependencies changes a few times a month, so the ordering settles itself.
FROM python:3.12-slimWORKDIR /appCOPY . .RUN pip install --no-cache-dir -r requirements.txtCMD ["python", "-m", "myapp"]FROM python:3.12-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY . .CMD ["python", "-m", "myapp"]Example 4.3(Instruction order decides 30 minutes a day)
Compare the two Dockerfiles above. Suppose pip install takes 90 seconds and each COPY takes 1 second, that during development we build 20 times a day, and that in 19 of those builds only the source code has changed while in one requirements.txt has changed as well.
In the first Dockerfile, COPY . . is the third instruction. Editing a single character of source code changes , so by Proposition 4.2 everything from on is re-executed. That is, pip install runs every time. Per day this is
In the second, a source change affects only COPY . . (the fifth instruction) and later. Neither (COPY requirements.txt .) nor (RUN pip install ...) changes, so the cache hits through . Per day this is
The difference is 28 minutes a day, about nine and a half hours over 20 working days. For a change consisting of swapping two lines in a Dockerfile, that is a large return.
Proposition 4.2 is a statement about the classical model of sequential layer caching. BuildKit, the current default builder, analyses the dependencies between instructions as a directed acyclic graph, so independent stages of a multi-stage build are built in parallel and a change in one stage does not invalidate the cache of the other. However, within a single stage the prefix property holds exactly as stated, so the ordering convention above is unchanged.
One more point: the fact that is determined by the command string alone has a side effect. RUN apt-get update has an unchanging command string, so it keeps hitting the cache forever even after the upstream package index has been updated. If one then adds RUN apt-get install -y foo afterwards, it consults the stale index and fails. Combining apt-get update and apt-get install into a single RUN is how this trap is avoided.
4.2. Deleting does not shrink the image
Section titled “4.2. Deleting does not shrink the image”Proposition 4.5(Non-commutativity of layer deletion)
Measure the total size of an image by the sum of the actual data stored in each layer. Suppose a file of size is added in layer , and that is deleted by an instruction in a layer with .
Then the image size with the deletion and the size without the deletion instruction satisfy
where is the size of the whiteout record. In particular, compared with the size obtained by never creating in a persistent layer at all, we have : the deletion instruction does not make the image smaller by bytes.
Proof(Proposition 4.5)
By Definition 4.1, layers are immutable. At the time is built, is already fixed and named by its content hash. Rewriting the contents of would change its name and produce a different layer, so by definition a later instruction cannot modify the contents of . Therefore still contains the bytes of and is unchanged.
Now consider . In a union file system, deletion is expressed by placing a whiteout record in an upper layer (Definition 4.1). So gains a record representing the deletion of , and writing for its size, increases by . No other layer changes.
Hence , and gives . The last claim follows by substituting , which yields .
In other words, one must not think of temporary build files as something to “delete later”. There are two remedies.
- Create and delete within the same
RUN. OneRUNbecomes one layer, so a file removed before the layer is finalized is never recorded. - Use a multi-stage build. Discard the intermediates produced in a build stage and copy only the artefact into a new stage.
FROM golang:1.22 AS builderWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12COPY --from=builder /out/server /serverUSER 65532:65532ENTRYPOINT ["/server"]Example 4.6(Turning 1.2 GB into 30 MB)
Suppose that in the multi-stage Dockerfile above the sizes are as follows: the golang:1.22 base is 800 MB, the dependencies fetched by go mod download are 350 MB, the intermediates produced by the build are 30 MB, the statically linked binary produced is 30 MB, and gcr.io/distroless/static-debian12 is 2 MB.
If everything were done in a single stage with a final RUN rm -rf /go/pkg /src, then by Proposition 4.5 that rm reduces nothing. The total size would be
With the multi-stage build, the final image consists only of the base of the second stage and the binary brought in by COPY --from=builder:
about one thirty-eighth. Distributing to 50 nodes, the transfer volume drops from 60 GB to 1.6 GB, and the wait when autoscaling adds nodes gets shorter. In addition, since the final image contains no compiler, no shell and no package manager, there is a security benefit: what an intruder can do is drastically reduced.
5. The Dockerfile: what it means to turn an environment into code
Section titled “5. The Dockerfile: what it means to turn an environment into code”So far we have seen that most of the conventions for writing Dockerfiles follow from Proposition 4.2 and Proposition 4.5. The rest concerns what “turning it into code” means.
The Dockerfile lives in the same repository as the source code and goes through the same review process. As a result, “who changed the production environment, when and why” is recorded in the Git history (see the commit graph and the ancestor relation(Definition 4.1)[Git] in Version Control (Git)). The decisive difference from a setup document is that a Dockerfile is executed. What is executed breaks the build when it is wrong, so it does not rot.
One must nonetheless be careful: “the same Dockerfile” does not mean “the same image”. The following three things break reproducibility.
- Mutable tags. The image that
FROM python:3.12-slimrefers to changes its contents when the upstream is updated. If exact reproducibility is required, pin it by digest:FROM python:3.12-slim@sha256:.... - Fetching over the network.
apt-get installandpip installdepend on the upstream state at the moment of execution. The more explicitly versions are pinned (pip install -r requirements.lock), the more reproducible the build. - Processing that depends on build time. Embedding timestamps or random numbers produces a different image every time.
Here is one more pitfall that everyone eventually falls into. CMD and ENTRYPOINT have two notations.
CMD python -m myappCMD ["python", "-m", "myapp"]The upper, shell form, expands to /bin/sh -c "python -m myapp", so the process that becomes PID 1 is the shell. Most sh implementations do not forward a received SIGTERM to their children. Consequently, an attempt to stop the container does not cause the application to begin its shutdown, and after the grace period (30 seconds by default in Kubernetes) it is killed with SIGKILL. The symptom of in-flight requests being dropped on every deploy is often caused by exactly this. With the lower, exec form, the application itself becomes PID 1 and receives signals directly. The difference is only a pair of brackets, but it decides whether zero-downtime deployment is possible.
Let us also settle the handling of configuration values. The principle is that values that differ per environment, such as database endpoints and log levels, are not baked into the image but passed as environment variables (the third factor of the so-called Twelve-Factor App). This way, development, staging and production run exactly the same image. Being able to ship the very image that was tested is the greatest source of confidence in container-based deployment.
6. When one machine is not enough: the design principles of Kubernetes
Section titled “6. When one machine is not enough: the design principles of Kubernetes”If there is one container and one host, docker run is enough. In real operations, however, the following requirements arise at once.
- Make the service redundant across three machines, so that it does not stop when one fails.
- Run three machines at night and twenty at the daytime peak.
- Place containers on nodes that have free memory.
- Roll out a new version gradually, without downtime.
- Reach the service by name, without knowing which node it runs on.
Doing this by hand is impossible. Orchestration means automating this family of decisions, and Kubernetes is one implementation of it. Kubernetes was released by Google in 2014 and incorporates into its design the operational experience of Borg, Google’s internal cluster management system (for Borg see the paper of Verma et al.; for how the design philosophy was inherited see the article by Burns et al., both in the references).
6.1. From imperative to declarative
Section titled “6.1. From imperative to declarative”There is only one central design concept in Kubernetes.
Definition 6.1(Declarative API and reconciliation loop)
The user registers with the API not “which operations to perform in which order” but “what state things should be in” (the desired state). The system keeps running a reconciliation loop. On each iteration it
- reads the desired state ,
- observes the current state ,
- performs operations that reduce the difference between and .
These three steps do not depend on the history of how came about; they are determined by and at that moment alone. This property is called being level-triggered.
For contrast, consider imperative operation: a scheme in which one sends the command “add one Pod”. Under this scheme, if the command is lost on the way the count stays short, and if it arrives twice the count is too high. In the middle of an incident, both happen routinely. With a scheme that registers the state “there should be three Pods”, by contrast, whether the command is lost or duplicated, the next loop corrects things on the basis of the currently observed reality.
flowchart LR U["User: declares the desired state"] --> API["API server / persisted in etcd"] API --> C["Controller: compares desired with current"] C -->|operations closing the gap| K["Scheduler and kubelet"] K --> R["Current state: the Pods actually running"] R -->|observation| C
Proposition 6.2(Finite convergence of the reconciliation loop)
Let be a finite set of target objects with , and for a desired state and an observed state at time consider the number of mismatched objects
Assume the following.
- (A1) The operations of each loop are determined by alone (the level-triggered property of Definition 6.1).
- (A2) If , then one iteration resolves at least one mismatch, and no object that already matched becomes mismatched.
- (A3) No external change of state (node failure, or a rewrite of ) occurs while the loop runs.
Then as long as , and is reached in at most iterations. After that, by the second half of (A2) the state does not change and no operations are performed (idempotence).
Furthermore, under (A1) the input of a loop is only the current , so the convergence claim is unaffected by dropping past event notifications or by receiving the same notification several times.
Proof(Proposition 6.2)
is a function with values in the non-negative integers. By the first half of (A2), when at least one mismatch is removed. By the second half of (A2), no new mismatch appears. By (A3), neither nor changes in the meantime through external causes. Hence .
That follows from the definition of (the mismatched objects form a subset of ). A sequence of non-negative integers cannot decrease forever, so after at most iterations , that is, .
Once is reached, , so there is no mismatch for (A2) to resolve and the set of operations is empty. Running the same loop any number of times therefore leaves the state unchanged. This is idempotence.
Finally we check the last claim. By (A1), the operations at time are a function of . Dropped or duplicated notifications only change when a loop is triggered; they do not change the input of a loop that is triggered. Hence, as long as loops continue to be triggered at finite intervals, the argument above applies unchanged and convergence holds.
In real operation, the assumptions that break most easily are (A2) and (A3).
The typical failure of (A2) is a container that crashes right after starting. The controller judges that a Pod is missing and creates one, the Pod fails to start, and does not decrease. The loop keeps running, and the retry interval grows exponentially under the name CrashLoopBackOff. What Proposition 6.2 guarantees is that “if the gap-closing operations actually work, convergence follows”, not that “any configuration will eventually start working”.
As for (A3), in a cluster it is always violated to begin with. Nodes fail and users update manifests. The realistic statement is therefore not “it converges and finishes” but “it converges once the disturbances stop”. When one node fails, jumps, and the loop again pushes it down towards zero. What is called self-healing is exactly this pushing down.
6.2. The principal objects
Section titled “6.2. The principal objects”Definition 6.4(Pod)
A Pod is the smallest unit of deployment in Kubernetes: a group of one or more containers. Containers in the same Pod share the network namespace (hence the IP address and localhost) and storage volumes, and are always placed on the same node. A Pod is a disposable entity: when it is restarted, both its IP address and its name change.
The point that Pods are disposable is important. It is precisely why mechanisms for holding state on top of them are needed. Here are the principal objects.
| Object | What it declares as the desired state |
|---|---|
| Pod | run one set of these containers |
| ReplicaSet | keep copies of this Pod |
| Deployment | keep copies of this Pod, updating them by a specified strategy |
| Service | make the Pods carrying this label reachable through a fixed name and virtual IP |
| Ingress | route external HTTP traffic to Services |
| ConfigMap / Secret | inject configuration values and secrets into Pods |
| StatefulSet | keep copies, each with a stable name and a persistent volume |
| Job / CronJob | run this work to completion once, or periodically |
| HorizontalPodAutoscaler | adjust the replica count so that a metric reaches its target |
When a user writes a Deployment, the Deployment controller creates a ReplicaSet, the ReplicaSet controller creates Pods, the scheduler assigns nodes to Pods, and the kubelet on that node calls the container runtime to start the containers. The point is that each stage runs its own reconciliation loop independently: the party responsible at each stage looks only at its own desired state and current state.
apiVersion: apps/v1kind: Deploymentmetadata: name: webspec: replicas: 3 selector: matchLabels: app: web strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1 maxSurge: 1 template: metadata: labels: app: web spec: topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: web containers: - name: web image: registry.example.com/web@sha256:0b1f2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b ports: - containerPort: 8080 resources: requests: cpu: "200m" memory: "256Mi" limits: memory: "512Mi" readinessProbe: httpGet: path: /healthz port: 8080 periodSeconds: 2apiVersion: v1kind: Servicemetadata: name: webspec: selector: app: web ports: - port: 80 targetPort: 8080Example 6.5(What happens when one node fails)
Let us follow what happens right after the manifests above are applied. Since replicas: 3, the ReplicaSet controller creates three Pods, and topologySpreadConstraints places them on three distinct nodes (nodes with different kubernetes.io/hostname). The setting maxSkew: 1 requires the difference in Pod counts between nodes to stay within one.
Now suppose the hardware of one of those nodes fails.
- The periodic reports from that node’s kubelet stop arriving. By default, after some time without a response (on the order of tens of seconds) the node is recorded as
NotReady. - After a further interval, the Pods on that node are marked for deletion. At this point the observed state of the ReplicaSet is “2 running”, so (in the notation of Proposition 6.2).
- To close the gap, the ReplicaSet controller creates one Pod. The scheduler picks, among the remaining nodes, one with enough room to satisfy the
requestsofcpu: 200mandmemory: 256Miand that does not violatemaxSkew. - The container of the new Pod starts, and once
/healthzanswers the readiness probe issued every 2 seconds, the Pod is added to the Service’s endpoint list and traffic begins to flow. returns to .
Nobody lifted a finger. All the human did was write “there should be three”.
Note that without a readinessProbe, the Service starts routing traffic from the instant the container’s process starts. If the application takes 10 seconds to initialize, errors are returned for those 10 seconds. To be free of downtime, one needs a way to declare readiness.
One remark about resources. requests is the reservation the scheduler uses to decide placement, while limits is the upper bound configured in the cgroup. For memory an upper bound is written because exceeding it results in OOMKilled, but a CPU limit does not kill on excess — it throttles — and for latency-sensitive services this can cause unintended latency degradation. That is why no CPU limit is written here.
A Service is the mechanism that reconciles the disposability of Pods with the wish to call a service by a stable name from elsewhere. The cluster’s DNS (the domain name hierarchy and authoritative servers(Definition 6.1)[ネットワーク(TCP/IP)]) resolves names such as web.default.svc.cluster.local to the Service’s virtual IP, and a mechanism on each node forwards packets addressed to that virtual IP to the actual Pod IPs. Understanding which layer performs what in this forwarding becomes much clearer with the material of Networking (TCP/IP), especially the 4-tuple identifying a connection(Definition 5.1)[ネットワーク(TCP/IP)].
Whether to run stateful software such as a database on Kubernetes is a question to be judged carefully. It is possible with StatefulSets and persistent volumes, but the hard parts of operating a database — backups, failover, schema changes — remain (see Foundations of Database Design). Using a cloud provider’s managed database and putting only stateless applications on Kubernetes is still a strong option (in the terms of the service models (IaaS, PaaS, SaaS)(Definition 2.2)[クラウドコンピューティング] in Cloud Computing (AWS, GCP), it is a choice of how much operational responsibility to hand to the provider).
7. Scalability and fault tolerance, quantified
Section titled “7. Scalability and fault tolerance, quantified”One often hears “redundancy makes things stronger” and “it scales automatically”, but how much stronger can be computed. The definition of availability itself, and the composition rules for series and parallel arrangements, are collected in availability of series and parallel configurations(Proposition 6.2)[クラウドコンピューティング]. Below we apply them to the placement of Kubernetes replicas.
7.1. Redundancy and availability
Section titled “7.1. Redundancy and availability”Theorem 7.1(Redundancy under independent failures)
Suppose a service is provided by replicas, that each replica is unavailable at a given moment with probability (with ), and that the failures of the replicas are mutually independent. If the service is available whenever at least one replica is available, then its availability is
Consequently the number of replicas needed to meet a target availability (with ) is the least integer satisfying
Proof(Theorem 7.1)
The service is unavailable exactly when all replicas are unavailable. Writing for the event that replica is unavailable, independence gives
Hence the availability is .
Now for the required number of replicas. The condition is equivalent to . Both sides are positive, so taking natural logarithms gives . Since we have , so dividing by reverses the inequality and yields .
Example 7.2(How many minutes of downtime one extra replica removes)
Suppose a single replica has availability 99 %, that is, . Taking 30 days to be 43,200 minutes, we compute the downtime.
- : , downtime per month minutes (about 7.2 hours).
- : , downtime minutes.
- : , downtime seconds.
Going from one replica to two turns 7 hours of monthly downtime into 4 minutes. In the other direction, computing the number required for a target (five nines) via Theorem 7.1 gives
so .
Everything so far depends entirely on the assumption of independence. Let us see what happens when it is dropped.
Proof(Corollary 7.3)
Split the event that the service is unavailable according to whether the node has failed. If the node has failed (probability ), all replicas on it are unavailable, so the service is unavailable. If the node is healthy (probability ), each replica is conditionally unavailable independently with probability , so by the same computation as in the proof of Theorem 7.1 the conditional probability that all of them fail is . By the law of total probability,
Since we get , that is, . As we have (because ), so and the bound cannot be improved.
Concretely, suppose a node’s monthly failure probability is . In Example 7.2 we computed that three replicas give five nines, but if those three sit on the same node, the availability is capped at . That is 43.2 minutes of downtime per month, and no number of replicas changes it. The benefit of redundancy is determined by where the correlations in failure lie.
The topologySpreadConstraints of Example 6.5 is precisely the setting that breaks this correlation. Changing topologyKey from the node to the availability zone (topology.kubernetes.io/zone) also guards against the coarser correlation of an entire zone failing. At the same time, communication across zones incurs additional latency. The right order is to decide what one wants to be independent, and only then write the placement constraints.
7.2. Horizontal autoscaling
Section titled “7.2. Horizontal autoscaling”Proposition 7.4(One-step convergence of the HorizontalPodAutoscaler)
Let the current replica count be , the average value of the metric per Pod be , and the target be , and update the replica count by the same rule as the Kubernetes HorizontalPodAutoscaler:
where is a tolerance, by default .
Now suppose the total load on the system, , is constant, is distributed evenly across all Pods, and that the per-Pod metric can be written . Then, whatever the value of , a single update gives
Moreover is a fixed point: as long as holds, no change in the downward direction occurs either.
Proof(Proposition 7.4)
Substitute into the update formula:
so cancels. Hence , which does not depend on the replica count before the update.
Next we show is a fixed point. When the replica count is , the metric is . By definition of the ceiling, , so , that is, the ratio satisfies . The assumption says exactly that , so , that is, . This is precisely the tolerance condition of the update rule, so and no change occurs.
Example 7.5(An actual autoscaling computation)
Take the target to be “CPU usage per Pod equal to 60 % of requests”, and suppose 6 Pods are currently running at an average of 85 %. By the update rule of Proposition 7.4,
After the increase to 9, the average utilization, assuming the total load is unchanged, is
The ratio is , and , so it falls within the tolerance and no further change occurs. The system settles in a single adjustment, with no oscillation between growing and shrinking.
It is worth seeing what would happen without the tolerance. Applying the update rule in the state of 9 Pods at 56.7 % gives , so in this example there happens to be no oscillation. Because of the rounding up in the ceiling, however, it is easy to construct settings where a ratio that stays slightly below 1 makes the count grow and shrink repeatedly. The tolerance and the stabilization window for scaling down (5 minutes by default) exist to stop oscillations of this kind.
The crux of Proposition 7.4 is the assumption , that is, “adding Pods reduces the load per machine in inverse proportion” (for the response time when a load is spread evenly across machines see response time when load is spread evenly across n machines(Corollary 5.3)[クラウドコンピューティング]). The standard case in which this assumption breaks is a saturated downstream database. Adding Pods not only fails to shorten the waiting time; the number of connections grows, the database gets even slower, CPU utilization does not fall, and so the HPA adds still more Pods — a vicious circle. Autoscaling does not remove the rate-limiting stage. Determine where the bottleneck is before configuring it.
7.3. How long a zero-downtime update takes
Section titled “7.3. How long a zero-downtime update takes”The behaviour of a Deployment’s RollingUpdate is determined by two numbers. With the desired replica count, maxUnavailable means “keep at least Pods available” and maxSurge means “keep the total number of Pods at most ” (both default to 25 %).
Proposition 7.7(Lower bound on the duration of a rolling update)
Suppose a Deployment with replicas is rolled out with maxUnavailable and maxSurge (with ). Assume the following.
- Throughout the update, the number of available Pods satisfies and the total number of Pods satisfies .
- A new Pod requires at least a time between being created and becoming available.
- At the completion of the update, new Pods are available.
Then the duration of the update satisfies
Proof(Proposition 7.7)
Let be the number of Pods at time that have been created but are not yet available. By assumption and , so
holds at all times. That is, at most Pods can be in preparation simultaneously.
On the other hand, by the completion of the update new Pods have been created, and each of them stays in the “created but not available” state for at least (the second assumption). Therefore the integral of over the update interval , counting only the new Pods, satisfies
On the other hand, from ,
Combining the two gives , that is, .
Example 7.8(Why the safe setting takes 20 minutes)
Consider a service with for which a new Pod takes seconds to become available.
- With the defaults , that is : seconds.
- With “we absolutely must not lose capacity”, and : seconds, that is 20 minutes.
A factor of 30 for the same service. The setting — “always keep 60 Pods available and replace them one at a time” — is intuitively the safest, but if an update takes 20 minutes, so does an emergency rollback. During an incident that is a heavy constraint.
If capacity must not be reduced, it is better to keep and increase instead (for example gives seconds), at the price of 10 Pods’ worth of extra compute during the update. Proposition 7.7 is a lower bound, so the actual duration will be longer. Leave margin when choosing the settings.
8. Exercises
Section titled “8. Exercises”Exercise 8.1Easy
Answer the following about this Dockerfile. Assume npm ci takes 90 seconds and each COPY takes 1 second.
FROM node:22-slimWORKDIR /appCOPY . .RUN npm ciRUN npm run buildCMD ["node", "dist/main.js"](1) When one file under src/ is edited and the image is rebuilt, which instructions hit the cache? Explain using Proposition 4.2.
(2) Rewrite the Dockerfile so as to minimize the daily time spent waiting for npm ci, for a developer who builds 20 times a day editing only src/.
(3) With the rewritten Dockerfile, what kind of change causes npm ci to be re-executed?
Solution
(1) COPY . . is the third instruction, and includes the hash of the contents of the copied files. Editing src/ changes , so by the second half of Proposition 4.2 we get , and everything from on (COPY . ., npm ci, npm run build) is re-executed. Only and , corresponding to FROM and WORKDIR, hit.
(2) Copy only the dependency manifests first.
FROM node:22-slimWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY . .RUN npm run buildCMD ["node", "dist/main.js"]Editing src/ changes neither (COPY package.json package-lock.json ./) nor (RUN npm ci), so by Proposition 4.2 the cache hits through and npm ci is not re-executed. The daily waiting time for npm ci drops from seconds to zero.
(3) When the contents of package.json or package-lock.json change. Then changes, so everything from on is re-executed and npm ci runs. Reinstalling when dependencies are added or updated is the correct behaviour, and exactly what we intended.
Exercise 8.2Standard
Build the following Dockerfile. Assume the amounts of data newly written by each step are: base image 120 MB, instruction 2: 400 MB, instruction 3: 120 MB, the source unpacked by instruction 4: 350 MB, and the build intermediates plus the binary in instruction 5: 240 MB in total. The size of whiteout records is negligible.
FROM debian:12RUN apt-get update && apt-get install -y build-essentialRUN curl -sL https://example.com/src.tar.gz -o /tmp/src.tar.gzRUN tar xf /tmp/src.tar.gz -C /opt && rm /tmp/src.tar.gzRUN make -C /opt/src && make -C /opt/src install(1) Compute the size of the final image. How does the rm in instruction 4 affect it?
(2) Suppose the resulting binary is 40 MB and is all that is needed to run. Using debian:12-slim (80 MB) as the runtime base, write a Dockerfile that minimizes the final image, and compute its size.
Solution
(1) By Proposition 4.5, the rm /tmp/src.tar.gz of instruction 4 does not delete the 120 MB written in the layer of instruction 3. The layer of instruction 3 is immutable and named by its content hash, so no later instruction can rewrite it. The deletion appears only as a whiteout record in the layer of instruction 4, whose size we agreed to neglect. Hence
The rm reduced the image by not a single byte; what it reduced is only “the files visible from a running container”.
(2) Use a multi-stage build.
FROM debian:12 AS builderRUN apt-get update && apt-get install -y build-essential curlRUN curl -sL https://example.com/src.tar.gz -o /tmp/src.tar.gz \ && tar xf /tmp/src.tar.gz -C /opt \ && rm /tmp/src.tar.gz \ && make -C /opt/src \ && make -C /opt/src install
FROM debian:12-slimCOPY --from=builder /usr/local/bin/myapp /usr/local/bin/myappUSER 1000:1000ENTRYPOINT ["/usr/local/bin/myapp"]The final image contains only the base of the second stage and the copied binary, so
About one tenth of 1230 MB. Fetching, unpacking, building and deleting are combined into one RUN in the first stage in order to keep that stage’s layers small and save space in the intermediate cache; it has no effect on the size of the final image (the layers of the first stage are not part of it).
Exercise 8.3Standard
A single replica of a service has availability 99 % (), and the probability that a node carrying replicas fails entirely is per month.
(1) Using Corollary 7.3, find the ceiling on availability when 4 replicas are all placed on a single node. Convert it into monthly downtime, taking 30 days to be 43,200 minutes.
(2) Now spread the 4 replicas over 4 distinct nodes, and suppose node failures and replica failures may all be treated as independent. Find the probability that a single replica is unavailable because either “its node fails” or “the replica itself fails”, and then find the availability of the whole service.
(3) From the result of (2), how many replicas are needed for a target of 99.999 %?
Solution
(1) By Corollary 7.3, . The exact value is
where the replica-side contribution of order is completely buried in the node failure term . The downtime is minutes. Going from 4 replicas to 40 does not change these 86.4 minutes.
(2) A single replica is available when its node is healthy and the replica itself is healthy, with probability . So the probability that one replica is unavailable is
The four nodes are independent, so Theorem 7.1 applies directly:
The downtime is minutes per month, that is, about 0.05 seconds. Compare this with the 86.4 minutes of (1). With the same 4 replicas, changing only where they are placed makes a difference of more than five orders of magnitude.
(3) Substitute and into the formula of Theorem 7.1:
so . This holds under the assumption that the three nodes are independent, however; if the three sit in the same rack or on the same power supply, that correlation imposes a new ceiling. Re-instantiate the of Corollary 7.3 for each granularity of failure under consideration and evaluate accordingly.
Exercise 8.4Hard
A service has replicas. A new Pod takes seconds to become available. There are two operational requirements.
- During an update, keep the available replicas at all times at 90 % of or above.
- Complete an update (and a rollback) within 10 minutes.
(1) Write the conditions on (maxUnavailable) and (maxSurge) meeting the requirements, as inequalities.
(2) Find the minimizing the extra compute needed during the update.
(3) If is required, what should be? With 10 Pods per node, how many extra nodes are then needed during the update?
Solution
(1) The capacity requirement says that the lower bound on the available count during the update is at least :
The time requirement makes it necessary that the lower bound of Proposition 7.7 be at most 600 seconds:
Together: and (with ).
(2) The extra resources needed during the update amount to Pods (since the total is capped at ). We want minimal, so take , which requires . Combined with this gives , so for instance meets both requirements with zero additional resources. The lower bound is then seconds.
Note, however, that Proposition 7.7 is a lower bound, and the actual duration is longer by the time spent on Pod termination and image pulls. Against a requirement of 600 seconds, a lower bound of 450 seconds leaves only 25 % of margin, so in practice it would be better to allow more room, for example (lower bound 300 seconds).
(3) If , the conditions of (1) give . With the lower bound is seconds, exactly on the boundary of the requirement, so taking for margin gives a lower bound of 300 seconds.
With , the total number of Pods during the update is at most . The extra 30 Pods correspond, at 10 Pods per node, to 3 nodes. In other words, the price of the requirement “update in 5 minutes without losing any capacity” is 3 nodes temporarily, during the update. In an environment with a cluster autoscaler, these 3 nodes appear only during the update and disappear afterwards, so the cost is only what is proportional to the update time.
References
Section titled “References”- Kubernetes Documentation, “Concepts” and “Horizontal Pod Autoscaling”. https://kubernetes.io/docs/concepts/ — the primary source on Pods, Deployments, Services and the HPA. The HPA update formula and the default tolerance and stabilization window are documented at https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/.
- Docker Docs, “Building best practices”. https://docs.docker.com/build/building/best-practices/ — the official guide to layers, caching and multi-stage builds.
- Open Container Initiative, Image Format Specification. https://github.com/opencontainers/image-spec — the specification of layer stacking and whiteout records. The definition of an image in §4 is based on this.
- B. Burns, B. Grant, D. Oppenheimer, E. Brewer, J. Wilkes, “Borg, Omega, and Kubernetes”, Communications of the ACM 59(5) (2016), 50–57. https://doi.org/10.1145/2890784 — how the design arrived at a declarative API and a reconciliation loop.
- A. Verma, L. Pedrosa, M. Korupolu, D. Oppenheimer, E. Tune, J. Wilkes, “Large-scale cluster management at Google with Borg”, Proceedings of EuroSys 2015. https://doi.org/10.1145/2741948.2741964 — the cluster management system that was the prototype for Kubernetes.
- B. Burns, J. Beda, K. Hightower, L. Evenson, Kubernetes: Up and Running, 3rd ed., O’Reilly Media, 2022 — a practical introduction. The chapters on Deployments and rolling updates correspond to the material of §7.
Appendix: What is better left out of containers
Section titled “Appendix: What is better left out of containers”Some things do not lend themselves to containerization. Software strongly dependent on kernel modules or on particular device drivers (since containers share the kernel, separate preparation on the host’s kernel side is required), desktop applications with a GUI, and software whose license is tied to a hardware identifier of the host. Also, introducing Kubernetes into a small system that fits on a single server tends to cost more in operational complexity than it returns in availability. Maintaining the cluster itself — version upgrades, certificate renewal, management of network plugins — is by no means light work.
If you do adopt it, respect the order. The sequence that works in practice is: (1) containerize the development environment first, to eliminate “it works on my machine”; (2) run CI on containers, to gain reproducible builds; (3) containerize stateless applications in production; (4) introduce Kubernetes once the number of machines and the update frequency reach the level that actually requires orchestration. Doing (4) first means taking on the complexity without having the problem it solves.
Consider stateful components last. For databases and message queues, the correctness of backup, replication and failover depends on knowledge specific to each product. If a managed service will do, using it first is the sensible judgement, I think. If you do operate them on Kubernetes, you will find yourself considering, in addition to StatefulSets and persistent volumes, an operator specific to that product — a controller that extends the reconciliation loop with product-specific knowledge.
Report an error in this article ・Operated by: Mugen Giken LLC ・Pricing ・Terms ・Legal notice
© 2026 夢現技研合同会社 ・Feeding the text to an LLM is welcome. Code samples are MIT licensed.