§ 01 Year 3: the first bill since the move
Cast
- Morgan
- Co-founder. Runs Fennel’s money and reads the cloud bill.
- Jordan
- Co-founder, now managing the engineers: that’s you, in this story.
- Ari
- Platform lead. Led Fennel’s move onto Kubernetes.
I didn’t read the last chapter. What happened?
- In Chapter 4, in Year 3, Fennel moved onto managed Kubernetes. Fennel writes down what it wants running, such as “3 copies of
storefront”, in files called manifests, and Kubernetes keeps those goals in the cluster’s record. When Ari applied Fennel’s manifests to the new cluster, its services were placed on the cluster’s four nodes, 01 to 04. Ari appliedstorefront’s manifest last, and Kubernetes put its three Pods on three different nodes. - Many small loops each compare one part of the record with what is actually running, and act when the two differ. The ReplicaSet loop writes a record for each Pod (Kubernetes’ wrapper around a running container), the scheduler gives each new Pod a node (a machine in the cluster), and the node agent on that node starts it.
- One Saturday at 03:04, node 03 died. With default settings, its Pods were replaced on other nodes within about six or seven minutes, and nobody had to wake up. Since then the cluster has run on three nodes. On a Wednesday, a missing setting made
notificationscrash again and again; its node agent kept restarting it, waiting longer each time, which engineers call a crash loop. - To understand any loop, ask four things: which loop is it, what does it want (the desired state), what does it see (the real state), and what does it do when the two differ?
At 03:04 in Chapter 4, a new orders Pod appeared in the cluster’s record with no node yet, and soon it was running on node 04. Which loop gave it node 04, and what did that loop see?
Since 03:04, when node 03 died, Fennel’s cluster has run on three nodes: 01, 02 and 04. On Friday evening, as the dinner rush began, the dispatch team asked for twice as many copies of dispatch, and one of the new Pods didn’t start. Ari’s dashboard listed it as Pending for most of an hour, until Ari’s team rented two more machines and added them to the cluster as nodes 05 and 06.
On Tuesday, the orders Pod restarted three times between lunch and the end of the dinner rush. Each time, customers saw errors for a moment, and each time the dashboard gave the same reason: OOMKilled.
At the end of the month, Morgan opened the first full cloud bill since the move. Before the move, Fennel had given back the spare machines Sam used to keep and was running on four, and the new bill was higher than the last one from those days.
“We moved onto Kubernetes so our machines would be used better. Now we pay for five of them instead of four, and Ari’s dashboard says they’re less than a third busy, even on a Friday night. What are we paying for?”
Morgan
Jordan forwards the question to Ari, whose answer covers Friday and Tuesday as well.
“All three come back to two numbers our teams wrote down for every service when we moved.”
Ari
Ari’s answer starts with the loop that picked node 04 for orders after node 03 died, and it ends with the questions Jordan should ask before anyone changes those two numbers.
§ 02 What the scheduler counts
In Chapter 4’s drawings, every Pod had a size, every node had room for six units, and the scheduler looked for “a node with room”. Kubernetes didn’t measure those sizes, though. Each team wrote them into its service’s manifest when Fennel moved, declaring how much memory and how much processor each copy of the service should have set aside for it. The figure below shows the part of storefront’s manifest that does this. Engineers call these numbers the Pod’s requests. In it, storefront’s two units of room appear as its real memory request: 4 GB.
# … the rest of# storefront’s manifestcontainers:- name: storefront image: storefront:v38 resources:how much each copy needs
requests:set aside for each copy
memory: 4Gi4 GB of memory
cpu: 500mhalf a processor core (engineers write thousandths of a core)
New term · 01 / 04
requests
The memory and processor a Pod reserves on its node, written in its manifest; the scheduler places Pods by them.
memory and processor, counted separately
Chapter 4’s “room”, written down.
Each node, in turn, offers a fixed amount of both. Fennel’s nodes are rented machines with 16 GB of memory and 4 processor cores each. Part of every machine is kept back for its operating system and for Kubernetes’ own programs, so the Pods on a node can reserve only about 13 GB of memory and 3.9 cores between them.
Why can Pods reserve only about 13 GB of a 16 GB node?
Every machine runs its own operating system and Kubernetes’ own programs, such as the node agent, and they need memory and processor too. So part of each node is kept back for them, and the scheduler operates only on the rest, which engineers call the node’s allocatable room. On one provider, for example, a 16 GB node keeps back up to about 2.7 GB of memory, depending on the version it was set up with, but less than a tenth of a core, which is roughly where Fennel’s 13 GB and 3.9 cores come from. Small machines lose a bigger share: on the same provider, a 4 GB node keeps back about a quarter of its memory.
Memory and processor are counted separately, and a Pod fits on a node only if both of its requests fit into what that node still has unreserved. The figure below shows why that matters: a node can have plenty of memory left while its processor is nearly all reserved, and then a new dispatch Pod doesn’t fit there, however much memory is free.
Memory
Processor
New Pod · 2 GB · 1 core
Memory: needs 2 GB, 7 GB freeFits
Processor: needs 1 core, 0.9 freeDoesn’t fit
Not placed here
The loop that makes this check is the scheduler, the part of Kubernetes that gives each new Pod a node. It works in two steps. First, it rules out every node that doesn’t have room for the Pod’s requests. Then it scores the nodes that are left and picks the one with the best score, choosing at random if two are tied. With default settings, the score mainly rewards the node with the most room left unreserved. The scheduler also prefers, by default, to spread copies of the same service across different nodes, but only as a preference: if it can’t spread them, it places them anyway.
That’s why, in Chapter 4, when Ari applied storefront’s manifest to the new cluster, Kubernetes put its three Pods on three different nodes. Nodes 01 and 04 had the most room left, so the first Pod went to one of them and the second to the other. The third went to node 02, which had no storefront copy yet and more room than node 03. Notice what the scheduler wasn’t trying to do: fit Fennel onto as few nodes as possible.
Teams can also add placement rules of their own, such as keeping a service’s copies on different nodes, sending some Pods only to particular machines, or keeping ordinary work off special ones; your engineers will tune these.
On a quiet Tuesday morning, Ari’s dashboard shows node 02 at 15% busy. The Pods already on it have reserved all but 1 GB of its memory. A new Pod requests 2 GB. Will the scheduler put it on node 02?
The scheduler never looks at how busy a node is. When it checks for room, it counts what the Pods on each node have reserved, not what they are using. The Kubernetes documentation says the scheduler refuses a node whose reservations are full “although actual memory or CPU resource usage on nodes is very low”, and it gives the reason: the room is protected for when “resource usage later increases, for example, during a daily peak in request rate.” A reservation is a promise to the Pod, and the scheduler keeps it even while the Pod isn’t using the room. A reservation isn’t a cap, though: a Pod can use less than it reserved, or more, as long as its node has room to spare. What stops it, and what happens when the room runs out, come later in the chapter.
ANALOGYYou know this from budgeting. Think of each node as a department’s budget, each request as the budget a project reserves, and the scheduler as finance, which approves a new project only in a department whose reservations still leave room for it. Whether the projects have actually spent their money doesn’t come into it.
WHERE THIS ANALOGY BREAKSA finance team would notice a department sitting on unspent money and move it to where it’s needed. The scheduler never does: reserved room that a Pod isn’t using stays reserved, at 3 a.m. as at the dinner rush, and no other Pod can be placed in it.
In Chapter 4’s terms, the scheduler is one implementation of the reconcile loop, and you can now fill in its loop lens completely. What it wants, the desired state, is every Pod on a node with room for its requests. What it sees, the real state, is Pods that have no node, and how much memory and processor each node has left unreserved. When the two differ, because a Pod has no node, it rules out the nodes that are too full, picks the emptiest of the rest, and writes that node into the Pod’s record. That leaves one question for the next section: what happens when no node passes the first step?
§ 03 Friday, 17:45: a Pod with nowhere to go
Since node 03 died, Fennel’s cluster has run on three nodes, 01, 02 and 04, and the Pods on them have reserved most of their room. Friday’s dinner rush is when dispatch, the service that assigns drivers to orders, is busiest. At 17:45 the dispatch team decides it wants six copies running tonight instead of three, and changes the number in dispatch’s manifest.
The following widget follows the next hour step by step as you scroll. It’s drawn similarly to how it was in Chapter 4, with one change: it now counts in real units. Each node’s column stands for the 13 GB of memory its Pods can reserve, each Pod’s height is its memory request, and memory that no Pod has reserved is drawn as dashed space. Processor is reserved separately, so it gets its own line at the foot of each node, counting the cores its Pods have reserved out of the 3.9 they can. During the scheduler’s steps, each node also shows its verdict for the Pod being placed: it fits, or the node is short of memory, of processor, or of both. The record and the loops work as they did in Chapter 4.
-
PREDICTCheck 03 / 08
The third new
dispatchPod needs 2 GB of memory and one processor core. Between them, the three nodes still have 3 GB of memory and about 3 cores free. What happens to it? -
Answer the question above to see what happens
-
Answer the question above to see what happens
-
Answer the question above to see what happens
W-01Fennel’s requests and nodes are fiction. The scheduler’s reason is in the format engineers see; its exact wording varies between Kubernetes versions.
For most of that hour, the third dispatch copy was pending, and Pending is the word Ari’s dashboard showed for it.
New term · 02 / 04
pending
A Pod the scheduler hasn’t been able to place yet. It waits, and the scheduler keeps trying and records why.
Not failed; it waits until something makes room.
Each time the scheduler fails to place a Pod, it records why. On Friday the line read 0/3 nodes are available: 2 Insufficient cpu, 3 Insufficient memory., which is in the format engineers see. Read it from left to right. “0/3 nodes are available” means that none of the three nodes passed the first step. The rest counts what each node was short of: all three lacked 2 GB of free memory, and two of them, 01 and 02, also lacked a whole free core. So the line already answers the first question to ask about a pending Pod: what is short, and where?
Notice what the line doesn’t say, which is that the cluster was full. Between them, the three nodes had 3 GB of memory free, more than the Pod’s request of 2 GB, but only in pieces too small for it; free room stranded like this is common on real clusters, and it matters to Morgan, because those leftover pieces on 01, 02 and 04 are room Fennel pays for that no Pod of this size can use.
There are two wrong ways to read Pending. It isn’t a failure: nobody has to resubmit anything, because the scheduler keeps trying and places the Pod as soon as a node has room for it. But it isn’t harmless either, because in Fennel’s cluster no loop makes room by itself. Someone has to add a node or free some room, as Ari’s team did at about 18:40. (Chapter 8 introduces a loop that can add nodes.) When a Pod is pending, these are the questions to ask:
- “What’s short, and on how many nodes?”
- “Is it short because demand really grew, or because what we reserved is more than we use?”
The second question leads straight to Morgan’s bill.
Is “pending” always about room?
No. Pending also covers the time a Pod that already has a node spends downloading its image before it can start, as in Chapter 4. The scheduler’s recorded reason tells the two apart: a Pod waiting for room has one, and a Pod that is downloading its image already has a node.
§ 04 What Morgan pays for
Fennel rents its machines from a provider, the cloud company that owns them. In the provider’s standard mode, which is the one Fennel uses, the bill counts machines: Fennel pays for each node for as long as it exists, whether the room on it is reserved, used or empty. So Morgan’s five nodes constitute the bill. Nothing in Fennel’s cluster rents or adds nodes by itself. Ari’s team decides how many machines to rent and adds them by hand (Chapter 8 introduces a loop that can). What the requests decide is how many nodes Fennel needs: on Friday, the Pods’ reservations didn’t fit on three nodes, so a Pod waited until Ari’s team added two.
After Friday, the payments team doubles its memory request “to be safe”. Its Pods use no more memory than before. What does change for Fennel?
The following widget works out how many nodes Fennel needs for a given set of memory requests. Like the scheduler, it places every Pod by its requests, with both of its numbers on one node; unlike the scheduler, it packs the Pods as tightly as they will go, so it counts the fewest nodes Fennel could rent. It starts with the memory requests Fennel’s teams have been using in this chapter, and keeps room for one more node so that Fennel can lose one. Each Pod’s outline is what it reserves; the fill inside is what it used at last Friday’s rush. There is no target. Some things to try:
- Set each request to what that service used at the rush. How many nodes does Fennel need now? Look at the processor line under each node: what stops the count going lower?
- Double
payments’ request, as in the question above. Does the count change? How far can it go before it costs a node? - Push
orderspast 13 GB. What happens?
W-02The widget packs Pods as tightly as they go, to count the fewest nodes; the scheduler, by default, spreads Pods out instead, so a real cluster needs at least this many. Each change places every Pod afresh, while a real cluster never moves running Pods. Processor requests, and the memory request of notifications, stay as the teams set them.
A Pod whose request is bigger than the room any node has for Pods is never placed: it stays pending for good, however long the scheduler keeps trying. The answer is a bigger node, a machine with more memory, which Ari’s team would rent and add by hand.
A month on, nodes 05 and 06 are still nearly empty. With default settings, what does Kubernetes do about them?
Fitting Pods’ reservations onto as few nodes as possible is called bin packing, and the widget above does it to count the fewest nodes Fennel could manage with. The scheduler, by default, doesn’t. It prefers the emptiest node and spreads copies out, which is safer for Pods but isn’t the cheapest arrangement. Packing tightly is a setting engineers can switch on, and some fully managed modes pack for you. Either way, the scheduler only places new Pods. It never moves running ones to empty a node, because Pods are replaced, not moved.
The widget’s “Room for Pods on the rented nodes” bar splits the room nobody uses into two kinds, room no Pod reserved (spare nodes, or pieces stranded too small for any Pod) and room reserved but not used, and the “less than a third busy” on Ari’s dashboard counts both.
How common is this?
Two surveys give a rough sense, each from one source. One vendor’s dataset, Datadog’s State of Cloud Costs 2024, comes from organizations that use its cost tool on their AWS bills, and covers all containers on AWS machines, not only Kubernetes, from May 2023 to April 2024. It found that 83% of container spend went on idle room: about 54 points on room no Pod had reserved, and 29 on requests bigger than the workloads needed.
In a community survey by the CNCF, run from June to November 2023 with just over 100 respondents, 49% said Kubernetes had driven their cloud spend up. Over-provisioning, which means paying for more capacity than workloads need, was by far the most-cited cause, named by 70%.
These are single surveys, not industry averages.
Some setups bill per Pod instead of per machine. They charge for what each Pod requests, not for what it uses, so the lesson is the same: padding costs money.
What does a per-Pod mode charge for, exactly?
On one provider, the per-Pod mode charges, per second, for the processor, memory and temporary disk space that each running Pod requests, not for what it uses. Pods that are waiting to be placed aren’t billed there, but Pods that keep crash-looping are. Another provider’s per-Pod option adds 256 MB to each Pod for Kubernetes’ own parts and rounds up to the next size it offers, so a Pod that requests 1 core and 8 GB is given, and billed for, 2 cores and 9 GB. Not every fully managed mode bills per Pod: some still bill per machine. Prices are the subject of Chapter 14.
Here is the answer to Morgan’s question: Fennel rents five nodes because the teams’ padded requests filled three nodes on paper while the services used far less, so Ari’s team rented two more. One of those, node 06, has sat nearly empty since, a node’s worth of spare room; whether to keep it is a choice, and the next section explains why Fennel might want to do that. Requests closer to what each service uses at its busiest would let Fennel rent fewer nodes, but not as few as possible.
§ 05 Room on purpose
Nodes go down, as node 03 did in Chapter 4. When one does, its Pods are replaced, and the scheduler has to place the replacements on the nodes that are left; if those nodes are full, the replacements wait for room, like the dispatch Pod at the dinner rush. Spare nodes are insurance against that: room kept free so that a lost node’s Pods have somewhere to go. The insurance is paid for a node at a time, and the “Keep room to lose a node” switch in the widget above shows what it costs; how much to keep is a decision for engineers and finance together.
“If we lose a node during the dinner rush, where do its Pods go?”
The question to ask
§ 06 Tuesday: when a Pod uses too much
On Tuesday, Ari’s dashboard showed the orders Pod, orders-q2rw, with a restart count of 3. Its last state read Terminated · Reason: OOMKilled · Exit code: 137. Engineers will recognise the 137, but what matters for you is the word before it.
A Pod can use more than it reserves, as §02 said, but not without end. A manifest can also set limits: the most memory and processor each copy may use on its node. Here is orders’ manifest with both: it reserves 6 GB of memory and may use up to 7 GB.
resources: requests:set aside for each copy (what the scheduler counts)
memory: 6Gi cpu: "1" limits:the most each copy may use
memory: 7Giover this, the container is killed
cpu: "1"at this, the container is slowed
New term · 03 / 04
limits
The most memory and processor a Pod’s container may use. They cap use; reserving room is the request’s job.
Over the memory limit: killed. Over the processor limit: slowed.
The chart below shows orders’ memory through Tuesday, against those two numbers.
Each time orders’ memory reached 7 GB, the operating system on the node stopped the container. Engineers say it was OOM-killed, from “out of memory”. The node agent then restarted the container, in the same Pod on the same node, so this was not a replacement like the ones after node 03 died, and the restart count went up by one. Each restart left a short gap in which customers couldn’t place orders. If the kills keep coming, the node agent waits longer and longer between restarts, just as it does with any crash-looping container, as we saw in Chapter 4.
New term · 04 / 04
OOM-killed
Stopped by the node’s operating system for going over its memory limit (“out of memory”), then restarted in place.
Engineers see OOMKilled, and exit code 137.
Processor works differently. A container that reaches its processor limit isn’t killed. Instead it is slowed down to its limit (engineers say throttled), because processor time is handed out in slices that can be held back, while memory a program is already holding can’t be taken away. So the symptom of a processor cap is “it got slow”, and the symptom of a memory cap is “it restarted”.
At the next sale, storefront needs more processor than its limit allows, and orders needs more memory than its limit allows. What do customers notice?
ANALOGYBack to budgets. If a request is the budget a project reserves, a limit is the spending cap on the project’s card. A project may spend beyond its reservation, up to its cap, while the department has money to spare.
WHERE THIS ANALOGY BREAKSReal spending caps work one way, but Kubernetes has two. The processor card never declines; it just slows the spending down. The memory card cuts the project off and starts it again from nothing. And spending above your reservation isn’t safe just because it’s allowed: when money runs short, the projects that are over their reservation are cut first, as §07 explains.
What if a team sets no memory limit at all?
Then nothing caps it. The container can use all the memory on its node, and if the node runs out, the Kubernetes documentation says a container with no limits is more likely to be the one killed. Leaving the limit off isn’t the safe choice: it lets one container put the whole node at risk. The opposite case also exists: if a team sets only a limit and no request, Kubernetes uses the limit as the request too, so the container reserves that much.
§ 07 Using more than you reserved
Besides starting and restarting containers, as in Chapter 4, each node’s agent keeps an eye on how much memory its node has left. Ordinarily, Pods that use more than they reserved cause no trouble, because the node has room to spare.
If the node’s free memory falls too low, though, its agent removes Pods to protect the node and everything else running on it. That’s an eviction. The agent goes first for the Pods using the most memory beyond what they reserved, and Pods that stay within their requests go last. Unlike the kill in §06, an eviction removes the Pod itself. The ReplicaSet loop then writes a new Pod record, as it does for any Pod that disappears, and the scheduler has to place the replacement; on a full cluster, the replacement waits, like the dispatch Pod that waited at the dinner rush. So a request set below what a service really uses doesn’t save that service any room. It puts the service first in line.
To cut the bill, Morgan suggests halving every service’s memory request. What would you expect at the next Friday rush?
The table below shows the loops you’ve met so far through the loop lens, with what this chapter added to each.
| Loop | Wants (desired state) | Sees (real state) | Does when they differ | Sam’s job, now a loop’s |
|---|---|---|---|---|
| The memory kill itself is the operating system’s, on the node; the node agent restarts the container afterwards. | ||||
§ 08 The conversation
On Tuesday evening, after the third restart, Ari sends Jordan a message.
“We need to bump memory limits on
Ariorders.”
Ari messages Jordan: “We need to bump memory limits on orders.” What would you ask? Think of one or two questions, then show the answer.
ANSWER“Is that a leak or real growth? And what does it do to our node count?”
If it’s a leak, a higher limit only delays the next kill; and if the request goes up too, Fennel rents room for a bug. If it’s real growth, a higher limit may be right, and the Kubernetes documentation’s advice is to check for a leak first, then raise the limit, “and possibly” the request.
Raising only the limit doesn’t change where Pods go. Raising the request does: each copy reserves more room, and on nodes that are already full, that can mean another node on the bill (in the sizing widget in §04, from Fennel’s requests, try orders at 5 GB and then at 5.5 GB).
The figure below shows the picture engineers look for when they ask the first question.
A common sign of a leak
A common sign of real growth
If your question was “how much more memory?”, that’s the engineers’ number to work out. The two questions above decide whether more memory is the right fix, and what it costs.
In short
- Each team writes down two numbers per service: what each copy reserves (its requests) and the most it may use (its limits).
- The scheduler places each new Pod by its requests, memory and processor counted separately, on one node that has room for both. It never looks at how busy a node is.
- If no node has room, the Pod waits, pending, and the scheduler keeps trying; nothing in Fennel’s cluster makes room by itself.
- Requests, packed onto nodes, decide how many machines you rent. Padding them buys nodes; cutting them below what services use buys evictions. Some spare room is insurance, on purpose.
- At its memory limit a container is killed and restarted; at its processor limit it’s slowed. Before raising a limit, ask whether it’s a leak or real growth, and what it does to the node count.
Every loop so far has kept the same version of each service running. In the next chapter, Fennel’s teams start shipping several times a day, and a loop takes over the job Sam once did by hand on a Tuesday in Chapter 3: replacing one version of a service with the next, without anyone having to type “Nobody deploy anything”.