Before you start · 5 min readFrom Docker to Kubernetes: A Short Story and Three Manuals#

A short story for developers and DevOps engineers who have a vague idea of Docker and Kubernetes and would like a clear one, followed by three manuals: one for the shell that runs underneath all of it, and two for when they want to write the files themselves.

The promise

Read this in an evening or two and you will know what every core piece of Docker and Kubernetes is for, why it exists, and the twenty percent of commands that do eighty percent of the work. You will also know what Helm, Istio, Prometheus, Grafana and Argo CD add on top, and when you would reach for each. And when you inherit a repository written in 2018, you will know what every old word in it became.

Then, if you want it, Part IV makes you the person who writes and reviews the files: Dockerfiles that are small, cached, non-root and shut down cleanly; Compose projects with health-gated startup and per-environment overrides; and Kubernetes manifests that pass the restricted security standard, roll out without dropping a request, and survive a node being drained. And it teaches the language underneath every RUN line and every entrypoint, from first principles: what a command, a process and a quote really are, then grep, sed, awk and jq, up to writing a production bash script and reading anyone else's.

The story reads as one plot. Maya, a developer at a tiny startup, ships an app called Pantry. Every chapter starts with something breaking or somebody asking for something the current setup cannot do. The fix is the chapter's concept. By the end of the chapter, a new crack has appeared. Concepts you learn because you needed them are the ones you remember.

Every concept comes with three memory hooks: a place in one running metaphor (a kitchen, then a city), a diagram of how it actually works, and a one-sentence Remember it as line you can repeat out loud. Every chapter ends with the commands, a hands-on section you can paste into a terminal, the traps that bite silently, five recall questions (answers in the epilogue), and exactly three takeaways.

Who this is for

You have run docker run at least once and have heard of Pods. You may have copied a YAML file without knowing what half of it did. You want to be the person on the team who knows what is going on, without reading two thousand pages of documentation. If you are already an SRE with years of cluster time, this book will be too slow for you.

How to read it

  • Straight through the first time. The plot carries the concepts.
  • Skim on a re-read: each chapter has the same shape, so jump to The idea for the concept or The commands for the recipe.
  • The Epilogue is the reference: the full metaphor table, the diagram legend, and one cheat sheet of every command in the book.
  • Part IV, The Manuals comes after the epilogue. The story stands on its own; the manuals are reference you read after it or come back to when you sit down to write a Dockerfile, a Compose file or a manifest. Each chapter climbs from basic to advanced, ends in one annotated production file, a review checklist and a broken file to fix.
  • The Shell manual needs no prior chapter, not even the story. Read it first if set -e and exec "$@" look like noise to you, or come to it whenever a script confuses you.

Following along

The companion app lives in examples/pantry/. It is a fifty-line Python web service that greets you and counts visits in Redis. You need:

  • Docker Desktop (or Docker Engine on Linux) with at least 8 GB of memory allocated if you plan to install Act III's tools.
  • kubectl and kind for Act II. The kind cluster config is examples/kind-config.yaml.
  • helm, istioctl and argocd CLIs for Act III, each introduced in its chapter.

Commands are written for a POSIX shell (macOS, Linux, or WSL2 on Windows). If you get lost, examples/reset.sh <chapter> puts your machine back at that chapter's starting line.

Tested on: Docker 27.5 · Docker Compose 2.32 · kind 0.33 (Kubernetes 1.37) · kubectl 1.31 · Helm 4.3 · Istio 1.31 · kube-prometheus-stack chart 80.x · Argo CD 3.5 · Shell manual: bash 5.2 with GNU tools and BusyBox 1.37 in the shell lab (examples/manual/shell-lab/), and macOS /bin/bash 3.2 with BSD tools. Newer versions will almost certainly work; the commands here are the stable ones.

Contents

Act I: One kitchen (Docker)

  1. It Works On My Machine
  2. Write the Recipe Down
  3. Memory and Neighbours
  4. Ship It
  5. The Whole Band
  6. The Night It Fell Over

Act II: The city (Kubernetes)

  1. Meet the City
  2. The Smallest Thing That Runs
  3. Someone Who Never Sleeps
  4. A Number That Never Changes
  5. Sticky Notes and the Locked Drawer
  6. Are You Alive?
  7. Memory That Survives, Again
  8. Districts, Key Cards and Rush Hour
  9. The Night It Fell Over, Again

Interludes

  • Old Maps: the history of containers, orchestrators and moving YAML, told through an inherited repository
  • The Landlord's Week: emptying a node safely and putting a ceiling on a district

Act III: The city grows up (the ecosystem)

  1. Flat-Pack Furniture (Helm)
  2. A Concierge at Every Door (Istio)
  3. The Control Room (Prometheus and Grafana)
  4. Nobody Touches the City by Hand (Argo CD)

Epilogue

  1. The Map: metaphors, timeline, old words, cheat card, cheat sheet, recall answers, where to go next

Part IV: The Manuals

  • The Manuals: how to use them, and a map of every chapter

The Shell manual

  1. The Ground Floor
  2. Every Command Is a Process
  3. How the Shell Reads a Line
  4. Variables and Quoting
  5. Exit Status and Tests
  6. Redirection and Pipes
  7. Loops, Globs and Lines
  8. Functions and Arrays
  9. Processes and Signals
  10. Finding Things: grep, find and xargs
  11. sed
  12. awk
  13. The Pipeline Toolkit
  14. Watching the System
  15. Writing a Script
  16. Portability
  17. Reading Any Script

The Docker manual

  1. Every Instruction
  2. ENTRYPOINT, CMD and PID 1
  3. Context and Cache
  4. Multi-Stage Builds
  5. Users and Permissions
  6. Supply Chain and Secrets
  7. Networks
  8. Volumes
  9. Compose: The File
  10. Compose: The Project

The Kubernetes manual

  1. Reading Any Manifest
  2. The Pod Spec
  3. Security Context
  4. Lifecycle and Rollouts
  5. Resources and Placement
  6. Config and Secrets
  7. Storage
  8. Networking
  9. Identity and Access
  10. Packaging: Helm and Kustomize
  11. Debugging

The Bridge

  • The Bridge: every Docker concept mapped to its Kubernetes field

Chapter 1 · Act I · 4 min readIt Works On My Machine#

The problem

Pantry is three weeks old. It is a small web service that says hello and counts how many people have said hello back, and it stores that count in Redis. Maya wrote it, Maya runs it, and on Maya's laptop it has never once failed.

Today is demo day. Sam from sales has borrowed the app to show a customer, and Sam's laptop is a different story. Python is the wrong version. Redis is not installed, and the installer wants an admin password Sam does not have. Forty minutes before the meeting, Sam sends Maya a screenshot of a stack trace and the words "it doesn't work".

Maya knows exactly what is wrong. Pantry does not just need its own code; it needs a particular Python, a particular set of libraries, a running Redis, and an environment variable telling it where Redis lives. On her laptop all of that happens to be there. On Sam's laptop none of it is. The app is fine. The surroundings are the problem.

She has a vague memory of a colleague saying "just put it in a container". She has forty minutes to find out what that means.

The idea

A container is a process running on your machine with its own private view of the filesystem, network and process list. It is not a virtual machine. There is no second operating system booting inside it. It is your kernel, running your program, with walls around it. Those walls mean the program sees exactly the files and libraries it was packaged with, and nothing from the host that could surprise it.

Where do the files come from? From an image. An image is a read-only, versioned snapshot of a filesystem plus a little metadata: which program to start, which port it listens on, which environment variables to set. You download an image once and can start as many containers from it as you like. Each container gets its own thin writable layer on top of the image, so changes inside one container never leak into another.

Docker Hub (the cookbook library)docker rundocker rundocker run --rm

Image redis:7-alpine
(recipe card, read-only)

Container 'redis'
(dish #1, running)

Container 'redis-test'
(dish #2, running)

Container (throwaway dish)

Think of the image as a recipe card. It is written down, it has a version, and it does not change while you cook. A container is a dish cooked from that card. You can cook the same dish three times for three tables, throw one away when the table leaves, and the card is untouched. This is the single most important distinction in Docker, and almost every early confusion comes from mixing the two up. docker images lists recipe cards. docker ps lists dishes.

Remember it as: an image is the recipe card, a container is a dish cooked from it, and you can cook as many as you like.

A container has a short life cycle, and it helps to know the states:

docker create / runstartprocess ends or docker stopdocker startdocker rmdocker rm -f

Created

Running

Exited

Because a stopped container keeps its writable layer, you can docker start it again and find your files still there. Because that layer is deleted with docker rm, anything you want to keep must live somewhere else, which is the subject of chapter 3.

Maya's forty-minute fix is embarrassingly small. Instead of installing Redis on Sam's laptop, Sam runs one command that downloads the Redis image and starts a container from it. Redis is now listening on port 6379 of Sam's laptop, no admin password required, and it can be thrown away after the meeting with one more command.

The commands

# Start a container in the background (-d), give it a name, publish a port host:container
docker run -d --name redis -p 6379:6379 redis:7-alpine

# Run something interactive and throw the container away when it exits
docker run -it --rm python:3.12-slim python -c 'print("hello from inside")'

# What is running? (-a also shows stopped containers)
docker ps
docker ps -a

# Read a container's stdout/stderr; -f follows like tail -f
docker logs -f redis

# Run a command inside a running container (get a shell with: docker exec -it redis sh)
docker exec -it redis redis-cli ping

# Stop (SIGTERM, then SIGKILL after 10s) and remove
docker stop redis
docker rm redis

# See what images (recipe cards) you have locally
docker images

Try it

  1. Start Redis in a container:
   docker run -d --name redis -p 6379:6379 redis:7-alpine
   

Docker prints a long container ID. The first run also pulls the image; the second time it starts in under a second.

  1. Prove it is running and talk to it:
   docker ps
   docker exec -it redis redis-cli ping
   
   CONTAINER ID   IMAGE            COMMAND                  STATUS         PORTS                    NAMES
   3f9c2a1b8e44   redis:7-alpine   "docker-entrypoint.s…"   Up 5 seconds   0.0.0.0:6379->6379/tcp   redis
   PONG
   
  1. Run a throwaway container to feel how cheap they are:
   docker run -it --rm python:3.12-slim python -c 'import sys; print(sys.version)'
   

You get a Python 3.12 prompt's worth of output on a machine that may not have Python installed at all. When the process exits, --rm deletes the container.

  1. Stop and remove Redis, then check it is gone:
   docker stop redis && docker rm redis
   docker ps -a
   

The image is still there (docker images). Only the dish was thrown away, not the recipe.

Recall

  1. What is the difference between an image and a container, in one sentence?
  2. Which command lists images, and which lists running containers?
  3. What does --rm do, and when would you not want it?
  4. After docker stop and docker rm, is the image still on your machine?
  5. In the kitchen metaphor, what is a container?
Show answers
  1. An image is a read-only recipe; a container is a running instance cooked from it.
  2. docker images lists images; docker ps lists running containers.
  3. --rm deletes the container when it exits; not when you want its logs or files afterwards.
  4. Yes, the image stays until docker rmi.
  5. A dish cooked from the recipe card.

What Maya learned

  • An image is a read-only recipe; a container is a running dish cooked from it, and the two are listed by different commands.
  • A container is just a process with walls, so starting one costs about as much as starting the program itself.
  • run, ps, logs, exec, stop and rm cover almost everything you do with a single container.

…but

Sam's demo works. Redis came from a container, but Pantry itself still runs from a folder of source files with python app.py, and it still needs the right Python and the right libraries. The customer asks a reasonable question: "Can you give us this to try on our own machines?" Maya cannot hand them her laptop. She needs a recipe card for Pantry.

Chapter 2 · Act I · 4 min readWrite the Recipe Down#

The problem

The customer wants to try Pantry on their own machines. Maya's first instinct is a README: install Python 3.12, create a virtualenv, pip install these three libraries, run Redis somehow, export REDIS_HOST, then python app.py. She writes it, reads it back, and realises it is the same list of things that broke on Sam's laptop, just written down politely.

Yesterday Redis came out of a container and it was wonderful, because somebody at Redis had already written the recipe. Nobody has written the recipe for Pantry. Maya has to write it herself.

She has one more constraint. She changes app.py twenty times a day. If every change means a full ten-minute rebuild of "download Python, install libraries, copy code", she will never use this. The recipe has to be cheap to re-cook when only the last step changed.

The idea

A Dockerfile is the recipe. It is a plain text file with one instruction per line, read top to bottom, and docker build turns it into an image. Here is Pantry's entire recipe:

FROM python:3.12-slim                       # start from someone else's recipe
WORKDIR /app                                # cd, creating the directory if needed
COPY requirements.txt .                     # copy the dependency list in first...
RUN pip install --no-cache-dir -r requirements.txt   # ...and install it
COPY app.py .                               # now the code, which changes most often
ENV PANTRY_VERSION=v1                       # default environment
EXPOSE 8080                                 # documentation: the app listens here
CMD ["python", "app.py"]                    # what to run when a container starts

Every instruction produces a layer: a snapshot of what changed on disk after that step. An image is a stack of layers, and here is the trick that makes daily use bearable. Docker caches each layer, keyed on the instruction and the files it touched. When you rebuild, Docker walks down the recipe, reusing cached layers until it hits the first step whose inputs changed. Everything after that point is rebuilt; everything before is free.

FROM python:3.12-slim
(base layers, cached)

COPY requirements.txt
(cached unless the file changes)

RUN pip install
(cached with it: the slow step)

COPY app.py
(changes every edit: rebuilt)

CMD python app.py
(metadata only)

docker run → container
(thin writable layer on top)

That is why the recipe copies requirements.txt and installs libraries before copying app.py. Editing the code invalidates only the last two layers, which take a second. Editing the dependency list invalidates the install step, which is rare. Order the recipe so the things that change most often come last.

Remember it as: a Dockerfile is a recipe, each line is a step that becomes a cached layer, so put the steps that change most often at the bottom.

A few details matter in practice. FROM picks a base image; python:3.12-slim is a trimmed Debian with Python installed, and slim is usually the right choice. RUN executes at build time, CMD at container start time; confusing the two is the second most common Dockerfile mistake. A .dockerignore file next to the Dockerfile lists paths that should never be copied in, such as .git and __pycache__, which keeps builds fast and images clean.

Finally, tags. An image name like pantry:v1 is repository:tag. The tag is free text; v1, 2024-06-01 and latest are all just labels. latest is not magic and does not mean newest, it is simply the tag used when you do not give one. Tag every build with something meaningful and treat latest with suspicion.

Two instructions look alike and are not. CMD is the default command, replaced entirely if you pass one on docker run; ENTRYPOINT is the command that always runs, with CMD or your arguments appended to it. A tool image sets ENTRYPOINT ["redis-cli"] so that docker run redis-cli ping reads naturally; a service like Pantry only needs CMD. Both come in two forms. The exec form, CMD ["python", "app.py"], runs your program as process 1. The shell form, CMD python app.py, runs /bin/sh -c "python app.py", so process 1 is a shell and your program is its child. That matters at stop time: Docker sends SIGTERM to process 1, the shell does not pass it on, and after ten seconds Docker kills everything. Use the exec form, and make sure whatever runs as process 1 actually handles the signal.

The commands

# Build an image from the Dockerfile in the current directory and tag it
docker build -t pantry:v1 .

# Build with a different value for an ARG in the Dockerfile
docker build -t pantry:v2 --build-arg PANTRY_VERSION=v2 --build-arg PANTRY_ERROR_RATE=0.3 .

# Which recipe cards do I have?
docker images

# Give an existing image a second name (same layers, no copy)
docker tag pantry:v1 pantry:latest

# Show the layers an image is made of, biggest first tells you where the weight is
docker history pantry:v1

# Delete an image you no longer need (fails if a container still uses it)
docker rmi pantry:latest

Try it

  1. From examples/pantry, build the image and time it:
   cd examples/pantry
   docker build -t pantry:v1 .
   

The first build pulls the base image and installs the libraries. It ends with naming to docker.io/library/pantry:v1.

  1. Build again without changing anything:
   docker build -t pantry:v1 .
   

Every step says CACHED. Now open app.py, change the greeting, save, and build a third time. Only the COPY app.py step and the ones after it run.

  1. Look at the layers:
   docker history pantry:v1
   
   IMAGE          CREATED BY                                      SIZE
   a33fb03b2eb8   CMD ["python" "app.py"]                         0B
   <missing>      ENV PANTRY_VERSION=v1 PANTRY_ERROR_RATE=0       0B
   <missing>      COPY app.py . # buildkit                        12.3kB
   <missing>      RUN /bin/sh -c pip install --no-cache-dir -r…   18.7MB
   <missing>      COPY requirements.txt . # buildkit              12.3kB
   <missing>      WORKDIR /app                                    8.19kB
   ...
   

The libraries sit in one 19 MB cached layer. Your code is a few kilobytes on top, and the metadata lines cost nothing.

  1. Run it. Pantry needs Redis, so start that first on a shared network (chapter 3 explains this line):
   docker network create pantry-net
   docker run -d --name redis --network pantry-net redis:7-alpine
   docker run -d --name pantry --network pantry-net -p 8000:8080 pantry:v1
   curl localhost:8000/
   
   Hello from Pantry (v1). You are visitor #1. Served by 84ba985b15bd.
   

The recipe works. Note -p 8000:8080: port 8000 on your laptop is the serving hatch, port 8080 inside the container is the kitchen door. They do not have to match.

Gotchas

  • Process 1 in a container only receives SIGTERM if it installs a handler; shells do not, and neither does Flask's dev server, so docker stop pantry waits the full ten seconds. docker run --init puts a tiny init at PID 1 and the stop takes a quarter of a second; a real server such as gunicorn handles it itself.
  • COPY . . before pip install means any code edit re-runs the install. Copy the dependency list first, install, then copy the code.
  • COPY . . also ships everything in the directory, .git and data files included; a .dockerignore keeps stray files out of the context and the image.
  • An ARG declared before FROM is not visible after it unless you declare it again.

Then and now

You will see MAINTAINER lines and ENV key value without an equals sign in older Dockerfiles; both were replaced by LABEL and ENV key=value years ago and still build, with warnings. Old Maps tells the story.

Recall

  1. Why does the Dockerfile copy requirements.txt before app.py?
  2. What is the difference between RUN and CMD?
  3. Which form of CMD lets your program receive SIGTERM, and why?
  4. What does the latest tag actually mean?
  5. In the kitchen metaphor, what is a layer?
Show answers
  1. So a code change does not invalidate the cached dependency layer.
  2. RUN executes at build time; CMD is what runs when a container starts.
  3. The exec form, and only if process 1 handles the signal; a shell at PID 1 swallows it.
  4. The tag used when none is given; nothing about freshness.
  5. One step of the recipe, cached if unchanged.

What Maya learned

  • A Dockerfile is a recipe read top to bottom; docker build cooks it into a tagged image.
  • Each instruction is a cached layer, so put slow, rarely-changing steps first and your code last.
  • Tags are just labels; latest is the default label, not a promise of freshness.

…but

The customer's trial goes well, and one of their engineers asks where the visit counter is kept. Maya says "in Redis, in the container". The engineer asks what happens to the count when the container is removed. Maya opens a terminal to check and does not like the answer.

Go deeper

Docker manual 1 · Every Instruction · Docker manual 2 · ENTRYPOINT, CMD and PID 1 · Docker manual 3 · Context and Cache

Chapter 3 · Act I · 3 min readMemory and Neighbours#

The problem

Maya runs the experiment. Redis in a container, ten visits to Pantry, counter reads ten. She removes the Redis container, starts a fresh one from the same image, and the counter reads one. The data lived in the container's thin writable layer, and docker rm took the layer with it.

That is only half of the day's trouble. Pantry finds Redis by the hostname redis. On her laptop that works because both containers are on the network she created yesterday, but a colleague who copied the commands without the network line gets redis unreachable. And the customer's engineer wants to know why Pantry is on port 8000 on her machine but 8080 in the logs.

Two separate things have gone wrong: containers forget, and containers cannot see each other by default. Both are deliberate, and both have a proper fix.

The idea

Containers forget on purpose. The writable layer is meant to be disposable, so that a container can be replaced without ceremony. Anything that must outlive the container goes on a volume: a directory managed by Docker that lives outside any container and is mounted into one at a path. Remove the container, the volume stays. Start a new container with the same volume, the data is back.

There are two flavours. A named volume (-v pantry-data:/data) is created and stored by Docker; you rarely need to know where on disk. A bind mount (-v $(pwd):/app) mounts a directory from your laptop straight into the container. Bind mounts are for development: edit code on the host, see it change inside the container immediately. Named volumes are for data.

container pantry (dev)container redis (dish #2, later)container redis (dish #1)Your laptop

Named volume
pantry-data
(the pantry shelf)

./src
(bind mount)

/data

/data

/app

Remember it as: a volume is the pantry shelf; dishes get thrown away, the shelf stays.

Containers cannot see each other by default, and that is also on purpose. Each container gets its own network stack. On the default network, containers can only reach each other by IP address, which changes. On a user-defined network (docker network create pantry-net), Docker runs a small DNS server and every container can reach every other by its --name. That is why REDIS_HOST=redis works: redis is a hostname on pantry-net.

Ports are a separate idea. Inside its network, a container's port is just open; Pantry listens on 8080 and Redis on 6379, and other containers on the same network can connect to those directly. Nothing outside the network can, until you publish a port with -p host:container. Publishing punches a hole from your laptop's port 8000 through to the container's 8080.

network pantry-net (the kitchen corridor)published port8000 → 8080DNS: 'redis':6379

curl localhost:8000
(the street)

pantry
:8080

redis
:6379

Remember it as: a network is the corridor between kitchens, and a published port is a serving hatch to the street.

Redis is unpublished in this picture, and that is right: only Pantry needs it. Publish as little as you can.

The commands

# Volumes: create, list, inspect where it lives, remove
docker volume create pantry-data
docker volume ls
docker volume rm pantry-data

# Mount a named volume (data) or a host directory (dev) into a container
docker run -d --name redis -v pantry-data:/data redis:7-alpine redis-server --appendonly yes
docker run --rm -it -v "$(pwd)":/app -w /app python:3.12-slim python app.py

# Networks: create one so containers can find each other by name
docker network create pantry-net
docker network ls
docker run -d --name redis --network pantry-net redis:7-alpine

# Publish a container port on the host: -p host:container
docker run -d --name pantry --network pantry-net -p 8000:8080 pantry:v1

# Everything Docker knows about a container (IP, mounts, env, ports) as JSON
docker inspect pantry

# Live CPU and memory per container, and copying files in or out
docker stats --no-stream
docker cp pantry:/app/app.py ./app-from-container.py

Try it

  1. Clean up yesterday's containers and start Redis on a named volume, with persistence turned on:
   docker rm -f pantry redis
   docker run -d --name redis --network pantry-net -v pantry-data:/data redis:7-alpine redis-server --appendonly yes
   docker run -d --name pantry --network pantry-net -p 8000:8080 pantry:v1
   curl localhost:8000/; curl localhost:8000/
   

You are visitor #1, then #2.

  1. Destroy Redis and bring it back:
   docker rm -f redis
   docker run -d --name redis --network pantry-net -v pantry-data:/data redis:7-alpine redis-server --appendonly yes
   curl localhost:8000/
   
   Hello from Pantry (v1). You are visitor #3. Served by 84ba985b15bd.
   

The shelf survived the dish.

  1. See the network doing its job:
   docker exec pantry python -c "import socket; print(socket.gethostbyname('redis'))"
   docker network inspect pantry-net --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
   

Pantry resolved redis to the IP the network assigned to the Redis container.

  1. Try to reach Redis from your laptop:
   nc -zv localhost 6379
   

It refuses. Redis has no published port, and that is the correct state for a database.

Gotchas

  • A bind mount path must be absolute: -v app.py:/x creates a named volume called app.py. Use "$(pwd)/app.py".
  • On the default bridge network containers cannot resolve each other by name; only a network you created has DNS.
  • localhost inside a container is the container. To reach your laptop, Docker Desktop offers host.docker.internal.
  • A container running as a non-root USER may not be able to write to a bind-mounted directory owned by you; check ownership before blaming the app.

Then and now

You will see --link redis:redis and "data-only containers" in older guides; user-defined networks (Docker 1.9, 2015) and named volumes replaced both, and --link still works while everyone tells you not to use it. Old Maps tells the story.

Recall

  1. Where does a named volume live, and what happens to it on docker rm?
  2. Why does REDIS_HOST=redis resolve inside pantry-net but not on the default network?
  3. What does -p 8000:8080 open, and which side is the container?
  4. Which command shows a container's IP, mounts and environment as JSON?
  5. In the kitchen metaphor, what is a published port?
Show answers
  1. In Docker-managed storage outside any container; it survives docker rm.
  2. Only user-defined networks have DNS for container names.
  3. Laptop port 8000 forwards to container port 8080; the right-hand side is the container.
  4. docker inspect.
  5. A serving hatch to the street.

What Maya learned

  • Containers forget by design; put data on a named volume, and it survives the container being removed.
  • Containers on a user-defined network can reach each other by container name, on their real ports.
  • -p host:container opens a port to the outside world; publish only what the outside world needs.

…but

Pantry now survives restarts and finds its database by name. The customer signs. Their first request is not a feature: "Can you give us the image? We do not want to build it ourselves." Maya has an image on her laptop and no way to hand it over except, apparently, email.

Go deeper

Docker manual 7 · Networks · Docker manual 8 · Volumes

Chapter 4 · Act I · 3 min readShip It#

The problem

The customer wants the image, not the source. docker save can write an image to a tar file, and Maya's is 213 MB, which is on the wrong side of the email attachment limit and the wrong side of dignity.

Then she looks at the size again. Two hundred megabytes for fifty lines of Python? She opens docker history and finds most of it is the base image, plus the libraries. The customer's engineer, who has clearly done this before, asks two questions: "Where do you publish images?" and "Do you really need a compiler in production?"

Maya does not publish images anywhere, and she had not thought about compilers at all. Both questions turn out to be the same lesson: an image is a thing you ship, and shipping has rules.

The idea

Images live in a registry. Docker Hub is the public one, and the one you have been pulling from all along; redis:7-alpine is shorthand for docker.io/library/redis:7-alpine. Companies run private registries (GitHub Container Registry, AWS ECR, Google Artifact Registry, a self-hosted Harbor) that work exactly the same way. The verbs are push to upload and pull to download, and the image name tells Docker where to go.

pushpushpull

Maya's laptop
docker build
docker push

Registry
ghcr.io/pantry/pantry:v1
(the cookbook library)

Customer's server
docker pull
docker run

CI server
docker build
docker push

A full image name has up to four parts: registry/namespace/repository:tag. Leave out the registry and Docker assumes Docker Hub. Leave out the tag and it assumes latest. To push to your own account on Docker Hub, the image must be named yourname/pantry:v1; that is what docker tag is for. Every push and pull is layer by layer, and layers already present on the other side are skipped, which is another reason to keep the slow, stable layers at the top of the Dockerfile.

Remember it as: a registry is the cookbook library; you push a recipe card in, anyone with access pulls it out.

The compiler question is about what ends up inside the image. Some Python packages need a C compiler to install. The lazy fix is to build from the full python:3.12 base image, which includes compilers, and then ship that: 1.6 GB, most of it tools nobody will run in production. The right fix is a multi-stage build: use a fat image to do the installing, then copy only the results into a slim final image. The Dockerfile has two FROM lines, and only the last stage is shipped.

FROM python:3.12 AS builder                     # stage 1: the workshop, 1.6 GB, has compilers
WORKDIR /app
COPY requirements.txt .
RUN python -m venv /venv && /venv/bin/pip install --no-cache-dir -r requirements.txt

FROM python:3.12-slim                           # stage 2: the shop floor, small
WORKDIR /app
COPY --from=builder /venv /venv                 # take only the finished goods
COPY app.py .
ENV PATH=/venv/bin:$PATH
USER nobody                                     # do not run as root
CMD ["python", "app.py"]

Pantry's multi-stage image is 217 MB. The builder stage alone is 1.63 GB. Nothing from the builder except the /venv directory made it into the image the customer gets. Smaller images pull faster, start faster, and contain fewer things an attacker could use. USER nobody is the other habit worth forming today: nothing in the image needs root, so do not give it root.

The commands

# Log in to a registry (Docker Hub by default; pass a hostname for others)
docker login
docker login ghcr.io

# Name the image for the registry, then push
docker tag pantry:v1 yourname/pantry:v1
docker push yourname/pantry:v1

# On another machine: pull and run
docker pull yourname/pantry:v1
docker run -d -p 8000:8080 yourname/pantry:v1

# Build a multi-stage Dockerfile (-f picks a file); --target stops at a stage
docker build -t pantry:slim -f Dockerfile.multistage .
docker build -t pantry:builder --target builder -f Dockerfile.multistage .

# Reclaim disk: remove stopped containers, unused networks, dangling images and build cache
docker system prune

Try it

  1. Build both stages and compare:
   docker build -t pantry:slim -f Dockerfile.multistage .
   docker build -t pantry:builder --target builder -f Dockerfile.multistage .
   docker images pantry
   
   REPOSITORY   TAG       SIZE
   pantry       builder   1.63GB
   pantry       slim      217MB
   pantry       v1        213MB
   

The workshop is seven times bigger than what ships.

  1. Run the slim image in place of v1 and confirm nothing changed for the user:
   docker rm -f pantry
   docker run -d --name pantry --network pantry-net -p 8000:8080 pantry:slim
   curl localhost:8000/
   docker exec pantry whoami
   

The greeting is the same, and the process now runs as nobody.

  1. (Optional: needs a free Docker Hub account.) Publish it:
   docker login
   docker tag pantry:slim yourname/pantry:v1
   docker push yourname/pantry:v1
   

Then on any other machine, docker run -d -p 8000:8080 yourname/pantry:v1 is the whole installation guide. Everything in this book works without a registry account, because chapter 7 loads images straight into the local cluster.

  1. See how much disk Docker is using and tidy up:
   docker system df
   docker system prune
   

Gotchas

  • docker build produces an image for the machine it runs on: arm64 on an Apple Silicon Mac. Docker Desktop runs it anyway through emulation, so you notice only when an amd64 server says exec format error. Check with docker image inspect --format '{{.Architecture}}', and build with --platform linux/amd64 or multi-arch with buildx.
  • docker push pantry:v1 is refused: without a registry and namespace it means docker.io/library/pantry, which is not yours. Tag it yourname/pantry:v1 first.
  • Anonymous pulls from Docker Hub are rate-limited (since 2020); a CI server that pulls a lot without docker login starts failing at random.

Then and now

You will see older build output with Step 3/8 lines; that was the legacy builder, replaced by BuildKit as the default in Docker 23 (2023), whose output looks quite different. Old Maps tells the story.

Recall

  1. What are the four parts of a full image name, and which two have defaults?
  2. Why is the multi-stage image 217 MB when its builder stage is 1.6 GB?
  3. Which command pushes an image, and what must its name include?
  4. Why run as USER nobody?
  5. In the kitchen metaphor, what is a registry?
Show answers
  1. Registry, namespace, repository, tag; registry defaults to Docker Hub, tag to latest.
  2. Only the final stage ships; the builder's 1.6 GB of tools stays behind.
  3. docker push; the name must include your registry namespace.
  4. Nothing in the image needs root, and a compromised process should not have it.
  5. The cookbook library.

What Maya learned

  • An image is shipped by pushing it to a registry and pulling it somewhere else; the image name says which registry.
  • A multi-stage build uses a fat image to build and a slim image to ship, so compilers never reach production.
  • Run as a non-root user and prune regularly; small, clean images are faster and safer.

…but

The customer pulls the image and runs it. Twenty minutes later they call: "It says Redis is unreachable." Of course it does. Pantry is one container, but the app is two containers, a network and a volume, started in the right order with the right flags. Maya has those flags in her shell history. The customer does not.

Go deeper

Docker manual 4 · Multi-Stage Builds · Docker manual 5 · Users and Permissions · Docker manual 6 · Supply Chain and Secrets

Chapter 5 · Act I · 3 min readThe Whole Band#

The problem

Maya writes the customer a shell script: create the network, create the volume, start Redis with persistence, wait a moment, start Pantry with the network and the port and the environment variable. It works. Then she needs to change one flag, and realises she now maintains a shell script with six docker run invocations that nobody else understands, that cannot tell whether Redis is actually ready before Pantry starts, and that has no idea how to stop everything cleanly.

She counts the moving parts. Two containers. One network. One volume. One port. One environment variable. A startup order. This is not a container problem any more; it is a "several containers that belong together" problem, and she is solving it with the wrong tool.

The idea

Docker Compose describes a set of containers that run together as one application, in one YAML file, and gives you one command to start, stop and inspect the lot. Each container is a service. Compose creates a network for the project automatically, so services reach each other by service name, and it manages named volumes declared in the file.

Here is all of Pantry:

services:
  web:
    build: .                      # build from the Dockerfile here, or use image: yourname/pantry:v1
    ports:
      - "8000:8080"
    environment:
      REDIS_HOST: redis           # the service name is the hostname
    depends_on:
      redis:
        condition: service_healthy   # wait until redis's healthcheck passes
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz')"]
      interval: 10s
  redis:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - pantry-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s

volumes:
  pantry-data:
docker compose up (project 'pantry')network pantry_defaultREDIS_HOST=redisdepends_on:service_healthy

service web
(pantry image)
:8080

service redis
:6379

volume pantry-data

localhost:8000

Two of those lines deserve attention. A healthcheck is a command Docker runs inside the container on a timer; if it fails repeatedly, the container is marked unhealthy. On its own that only changes what docker ps shows. Combined with depends_on and condition: service_healthy, it makes Compose wait for Redis to actually answer PONG before starting Pantry. "Started" and "ready" are different things, and this is the first time in the book that difference matters. It will not be the last.

Remember it as: Compose is the dinner menu; one file lists every dish, and one command cooks them all in the right order.

The file is committed next to the code. Anyone with the repository runs docker compose up and gets the same two containers, network and volume that Maya has, with none of the shell history. It is also the honest answer to "how do I run this locally?" for most services that need a database, a queue or a cache.

Compose is a single-machine tool. It knows nothing about a second host, and if a container dies it will restart it only if you asked with restart: unless-stopped. Keep that in mind.

The commands

# Start everything in the background, building images if needed
docker compose up -d
docker compose up -d --build

# What is running in this project, and is it healthy?
docker compose ps

# Logs from all services, or one; -f follows
docker compose logs -f
docker compose logs -f web

# Run a command inside a service's container
docker compose exec redis redis-cli GET visits

# Rebuild images without starting anything
docker compose build

# Print the effective file after defaults and variables are applied; old keys warn here
docker compose config

# Stop and remove containers and the network (volumes survive); -v removes volumes too
docker compose down
docker compose down -v

Try it

  1. Tear down the hand-made containers and start the composed version:
   docker rm -f pantry redis
   docker compose up -d --build
   docker compose ps
   
   NAME             STATUS
   pantry-redis-1   Up 9 seconds (healthy)
   pantry-web-1     Up 3 seconds (health: starting)
   

Redis was healthy before web was even created. A few seconds later web is healthy too.

  1. Use it, then stop and start it, and watch the counter:
   curl localhost:8000/; curl localhost:8000/
   docker compose down
   docker compose up -d
   curl localhost:8000/
   
   Hello from Pantry (v1). You are visitor #3. Served by 30ef7b1ee3b1.
   

down removed the containers and the network but kept the volume, so the counter survived. The hostname changed because it is a new container.

  1. Peek at the data directly:
   docker compose exec redis redis-cli GET visits
   
  1. Start again from nothing:
   docker compose down -v
   

The -v deletes the volume. The next up will greet visitor #1.

Gotchas

  • A plain depends_on: [redis] orders start, not readiness: web comes up while Redis is still booting and the first requests fail. Only condition: service_healthy waits.
  • docker compose down keeps named volumes on purpose; only down -v deletes them, and there is no confirmation.
  • The project name, and so the container and volume names, comes from the directory name. Two checkouts in differently named folders are two separate projects.
  • ports: - 8000:8080 unquoted is parsed as a number by YAML in some cases; always quote port mappings.

Then and now

You will see docker-compose with a hyphen, a version: "3" line at the top, and a file called docker-compose.yml; the Go plugin docker compose (2020) ignores version and prefers compose.yaml. Old Maps tells the story.

Recall

  1. What does a service name become on the project network?
  2. What is the difference between depends_on with and without condition: service_healthy?
  3. Which command removes containers but keeps the data, and which removes both?
  4. Where does Compose get the project name from?
  5. In the kitchen metaphor, what is the Compose file?
Show answers
  1. Its hostname on the project network.
  2. Without the condition it waits for the container to start; with it, for the healthcheck to pass.
  3. docker compose down keeps volumes; down -v deletes them.
  4. The directory name.
  5. The dinner menu.

What Maya learned

  • Compose describes a multi-container app in one file and runs it with one command; service names are hostnames.
  • Healthchecks plus depends_on: condition: service_healthy make startup order mean "ready", not just "started".
  • down keeps volumes and down -v deletes them; know which one you are typing.

…but

The customer is happy. Pantry has real users now. Maya moves it from her laptop to a rented server, runs docker compose up -d there, and goes home. At 3 a.m. her phone rings.

Go deeper

Docker manual 9 · Compose: The File · Docker manual 10 · Compose: The Project

Chapter 6 · Act I · 4 min readThe Night It Fell Over#

The problem

Pantry is down. Maya logs in to the server at 3:10 a.m. and finds the web container exited two hours ago with a Python traceback from a dependency bug that shows up once in a few thousand requests. Nothing restarted it, because nothing was told to. She adds restart: unless-stopped to the Compose file, brings it up, and goes back to bed feeling that she has fixed it.

At 9 a.m. the customer's launch email goes out and traffic triples. One Pantry container on one server cannot keep up; requests queue, then time out. Maya can run three copies of web on the same server, but they would fight over port 8000, and the server has only so much CPU anyway. She rents a second server. Now she has two Compose files on two machines, no way to send traffic to both, and no way to know when one of them dies.

At 11 a.m. she ships a fix for the traceback. docker compose up -d --build stops the old container and starts the new one. For eight seconds, Pantry is down again, on purpose, in the middle of the morning rush.

Nothing here is a bug in Pantry. Every one of these problems is a thing a single Docker host, however well configured, simply does not do.

The idea

Maya sits down with a coffee and writes the list of what she has and what she needs.

does not reachMany hosts: what 3 a.m. asked for

Self-healing:
notice a dead copy anywhere
and replace it

Scaling:
run N copies across machines
and spread traffic

Safe rollout:
replace copies one by one
with zero downtime

One address:
find the copies
wherever they are

One host: what Docker gives you

Packaged app
(image)

Run it
(container)

Run several together
(Compose)

Restart if it dies
(restart policy)

The pattern in the right-hand column is that every item is about many copies on many machines, and about something watching them. A restart policy is a bandage on one container on one host. What Maya needs is a system whose whole job is to be told "I want three copies of this image, reachable at one address, always" and to make that true, keep it true when servers die, and change it gracefully when she ships a new version.

That system is an orchestrator, and the one the industry settled on is Kubernetes. It takes a description of what you want (the desired state) and runs a loop, forever, comparing it to what exists (the actual state) and fixing the difference. Container died? The loop notices and starts another. Server died? The loop moves its containers elsewhere. New image? The loop replaces the copies one at a time and stops if the new ones fail to come up.

Remember it as: Docker cooks one meal in one kitchen; Kubernetes runs the whole city and keeps every kitchen open.

It is worth saying plainly that Kubernetes does not replace Docker. Everything from the first five chapters carries over untouched. The image Maya built is the image Kubernetes will run. The Dockerfile, the registry, the idea that containers forget and need volumes, the idea that "started" is not "ready": all of it is the foundation. Kubernetes adds a control plane, a vocabulary for describing what you want, and the loop.

The cost is real. Kubernetes has more concepts than Docker, and its YAML can be intimidating the first time. The next nine chapters introduce those concepts one at a time, each because Maya needs it, and each with a picture. By chapter 15 she will be able to diagnose a 3 a.m. outage in five minutes with four commands.

The commands

The one thing a single host can offer, so you know what it does and does not solve:

# Restart the container if it exits, unless you stopped it yourself (Compose: restart: unless-stopped)
docker run -d --restart unless-stopped --name pantry -p 8000:8080 pantry:v1

# Make the app crash from the inside (docker kill counts as "you stopped it" and is NOT restarted)
curl localhost:8000/crash

# Update means stop, then start: count the seconds of downtime
docker compose up -d --build

# What Docker saw: the container died and was started again
docker events --since 30s --until 1s --filter container=pantry --format '{{.Action}}'

Try it

  1. Start Pantry with a restart policy, then kill it and watch Docker bring it back:
   docker compose down -v
   docker network create pantry-net 2>/dev/null
   docker run -d --name redis --network pantry-net redis:7-alpine
   docker run -d --restart unless-stopped --name pantry --network pantry-net -p 8000:8080 pantry:v1
   sleep 2; curl localhost:8000/crash; sleep 3
   docker ps --filter name=pantry --format '{{.Names}} {{.Status}}'
   docker events --since 30s --until 1s --filter container=pantry --format '{{.Action}}'
   
   pantry Up 3 seconds
   create
   start
   die
   start
   

Pantry has a /crash endpoint that makes the process exit, which is the honest way to simulate a bug. (docker kill would not trigger a restart: Docker treats that as you stopping it on purpose.)

That handles the 3 a.m. crash. Now try the 9 a.m. problem.

  1. Try to run a second copy on the same port:
   docker run -d --name pantry2 --network pantry-net -p 8000:8080 pantry:v1
   
   docker: Error response from daemon: ... Bind for 0.0.0.0:8000 failed: port is already allocated.
   

One port, one container. There is no built-in way to say "spread across these" without adding a load balancer by hand.

  1. Measure the 11 a.m. problem:
   docker rm -f pantry pantry2
   docker run -d --restart unless-stopped --name pantry --network pantry-net -p 8000:8080 pantry:v1
   ( for i in $(seq 1 12); do curl -s -o /dev/null -m 1 -w '%{http_code}\n' localhost:8000/ || echo down; sleep 0.5; done ) &
   sleep 1; docker rm -f pantry && docker run -d --name pantry --network pantry-net -p 8000:8080 pantry:v2
   wait
   
   200
   200
   000
   down
   000
   down
   ...
   200
   500
   

Every down is half a second with no Pantry at all, about four seconds in total. That is downtime you chose. (The 500 at the end is v2 being deliberately flaky, which comes in useful later.)

  1. Clean up before the city:
   docker rm -f pantry redis
   docker network rm pantry-net
   

Gotchas

  • docker stop and docker kill both count as "you stopped it"; a container with --restart unless-stopped stays down afterwards. Only a crash from inside triggers the restart.
  • A restart policy restarts a container that exits; it does nothing for one that hangs while its process stays alive. That needs a health check, which Docker records but does not act on.
  • The eight seconds of downtime during compose up -d --build is not a bug to fix in Compose; it is the absence of a rolling update.

Then and now

You will see docker stack deploy and Compose files with a deploy: section in repositories from 2017 to 2019; that was Docker Swarm, the orchestrator that lost to Kubernetes, and Compose ignores deploy: today. Old Maps tells the story.

Recall

  1. Name the three things a single Docker host cannot do that the 3 a.m. story exposed.
  2. Why did docker kill not trigger the restart policy while /crash did?
  3. What is "desired state", and what does the orchestrator do with it?
  4. Does Kubernetes replace the images and Dockerfiles from chapters 1 to 5?
  5. In the metaphor, what does Docker cook and what does Kubernetes run?
Show answers
  1. Self-heal across machines, scale across machines, roll out without downtime.
  2. docker kill counts as you stopping it; /crash was the process dying on its own.
  3. What you want to be true; the orchestrator loops until reality matches it.
  4. No; Kubernetes runs the same images.
  5. Docker cooks one meal; Kubernetes runs the whole city.

What Maya learned

  • A restart policy heals one container on one host; it cannot scale, spread traffic, or roll out without downtime.
  • Every 3 a.m. problem is a "many copies on many machines, watched continuously" problem.
  • Kubernetes is that watcher: you declare the desired state and a loop keeps reality matching it, using the same images.

…but

Raj, who joined last month from a company that ran hundreds of services, listens to Maya's list and nods. "You need a cluster," he says. "Let me show you the city." He opens a terminal and types kind create cluster.

Go deeper

Docker manual 2 · ENTRYPOINT, CMD and PID 1 · Docker manual 5 · Users and Permissions

Chapter 7 · Act II · 4 min readMeet the City#

The problem

Raj's terminal says kind create cluster, and ninety seconds later Maya is looking at two Docker containers pretending to be two servers. "That's a cluster," Raj says. "Same thing the cloud gives you, just smaller and on your laptop."

Maya's questions come fast. If it is all containers, what is running Pantry? Nothing yet. Where do the requests go? Nowhere yet. What is the kubectl he keeps typing, and why does it talk to the cluster and not a server? And when he says "the control plane decides where things run", who exactly is deciding?

Raj does what good ops people do. Before running anything, he draws the map. Kubernetes has a lot of parts, but they fall into two groups, and once Maya can name them she will be able to read every kubectl output for the rest of the book.

The idea

A cluster is a set of machines, called nodes, managed as one. Some nodes run your containers; these are the worker nodes. One or more nodes run the management software; together they are the control plane. In a cloud, the provider runs the control plane for you and you only see workers. In kind, both are Docker containers on your laptop.

Worker node (a building)Worker node (a building)Control plane node (city hall)watcheswatcheswatcheswatches

API server
(the front desk:
every request goes here)

etcd
(the records room:
the only place state is stored)

Scheduler
(housing office:
picks a node for each Pod)

Controller manager
(inspectors on rounds:
make reality match the records)

kubelet
(caretaker)

Pods

kubelet
(caretaker)

Pods

kubectl / you

Think of the cluster as a city. Each node is a building. The control plane is city hall, and city hall has four departments worth knowing:

  • The API server is the front desk. Every request, from you, from kubectl, from every other component, goes through it. Nobody talks to anything else directly.
  • etcd is the records room. It is a small key-value database and it is the only place cluster state is stored. Everything else can be restarted and rebuilt from the records.
  • The scheduler is the housing office. When a new Pod needs a home, the scheduler picks a node with enough room and writes that decision back to the records.
  • The controller manager runs the inspectors. Each controller watches one kind of record ("there should be three copies of this") and takes action when reality drifts.

Every building has a kubelet, the caretaker. It watches the records for Pods assigned to its node, asks the container runtime to start them, reports their health, and restarts them if they die. The kubelet is the only part of Kubernetes that actually runs containers.

Remember it as: the cluster is a city, nodes are buildings, city hall keeps the records and everyone else watches the records and does their part.

The important word in the diagram is watches. Nothing in Kubernetes commands anything else. You write what you want into the records via the front desk, and each department notices and acts. That is what makes it self-healing: the inspectors never stop looking.

kubelet (node)ScheduleretcdAPI serverkubectlkubelet (node)ScheduleretcdAPI serverkubectlapply: "I want a Pod running pantry:v1"store the recordwatching: a Pod with no node!assign it to worker-1update the recordwatching: a Pod for my node!start the containerreport: Running

kubectl is the front-desk phone. It reads a config file (~/.kube/config) that can hold several clusters, and the currently selected one is called the context. Most of the "why is this not working" moments in a new team come from being pointed at the wrong context. The three commands for that are in the list below; learn them first.

Two kubectl verbs will carry you through the whole book. get lists things, and -o wide shows more columns while -o yaml shows everything. describe tells the story of one thing, including the events the departments logged about it, which is where the answers are when something is wrong. A third verb, explain, is the built-in manual for any field of any resource, and it means you never have to guess what a YAML key does.

The commands

# Create a local cluster from a config file (two nodes, ports mapped); delete it when done
kind create cluster --config examples/kind-config.yaml
kind delete cluster --name pantry

# Which cluster am I talking to? Switch if it is the wrong one
kubectl config current-context
kubectl config get-contexts
kubectl config use-context kind-pantry

# Where is the front desk, and is it answering?
kubectl cluster-info

# The buildings, with more columns
kubectl get nodes -o wide

# Everything running in every namespace (the control plane is just Pods too)
kubectl get pods -A

# The story of one thing, events included
kubectl describe node pantry-worker

# The built-in manual for any resource or field
kubectl explain pod.spec.containers

# Copy an image from your laptop into the cluster's nodes (no registry needed)
kind load docker-image pantry:v1 pantry:v2 --name pantry

Try it

  1. Create the cluster (this is the config the whole book uses):
   cd examples
   kind create cluster --config kind-config.yaml
   kubectl config current-context
   
   kind-pantry
   
  1. Look at the buildings:
   kubectl get nodes -o wide
   
   NAME                   STATUS   ROLES           AGE   VERSION   INTERNAL-IP   OS-IMAGE
   pantry-control-plane   Ready    control-plane   60s   v1.37.0   172.20.0.3    Debian GNU/Linux 13
   pantry-worker          Ready    <none>          45s   v1.37.0   172.20.0.2    Debian GNU/Linux 13
   

Run docker ps in another terminal: the two nodes are two containers.

  1. Look at city hall. It runs as Pods, in a namespace called kube-system:
   kubectl get pods -A
   

You will see kube-apiserver-..., etcd-..., kube-scheduler-..., kube-controller-manager-... and a kube-proxy and kindnet per node. Every box in the diagram is on that list.

  1. Read the manual for something you will write tomorrow:
   kubectl explain pod.spec.containers.image
   
  1. Load Pantry's images into the cluster so no registry is needed:
   cd pantry
   docker build -t pantry:v1 .
   docker build -t pantry:v2 --build-arg PANTRY_VERSION=v2 --build-arg PANTRY_ERROR_RATE=0.3 .
   kind load docker-image pantry:v1 pantry:v2 --name pantry
   

The images are now on both nodes' disks. On a real cluster this step is docker push to a registry the nodes can pull from.

Gotchas

  • An image tagged :latest, or with no tag, gets imagePullPolicy: Always, so the kubelet ignores the copy you loaded into kind and tries Docker Hub: ErrImagePull. Use a real tag like v1, or set imagePullPolicy: IfNotPresent.
  • kubectl talks to whichever context is current, not to the cluster you are thinking about. After creating or switching clusters, run kubectl config current-context before anything else.
  • kind load docker-image copies the image onto the nodes that exist now; a node added later, or a recreated cluster, needs it loaded again.

Then and now

You will see the control-plane node called master in older manifests and tutorials; the label went in Kubernetes 1.24 and the taint in 1.25. Old Maps tells the story, and why "Kubernetes dropped Docker" changed nothing.

Recall

  1. Name the four control-plane departments and one sentence on what each does.
  2. Which component actually starts containers on a node?
  3. Which command tells you which cluster kubectl is pointed at?
  4. What does "everything watches the API server" buy you?
  5. In the city metaphor, what is a node and what is etcd?
Show answers
  1. API server (front desk), etcd (records), scheduler (housing office), controller manager (inspectors).
  2. The kubelet on each node.
  3. kubectl config current-context.
  4. Self-healing: nothing commands anything; every part notices and acts, forever.
  5. A building, and the records room.

What Maya learned

  • A cluster is nodes plus a control plane; the control plane stores desired state in etcd and everything else watches it and acts.
  • The kubelet on each node is the only thing that runs containers; the scheduler only decides where.
  • kubectl config current-context, get, describe and explain are the first four commands to reach for in any cluster.

…but

The city is built and the images are inside it, but nothing is running. Raj hands Maya a YAML file with eleven lines in it and says, "That is the smallest thing that runs."

Go deeper

Kubernetes manual 1 · Reading Any Manifest

Chapter 8 · Act II · 4 min readThe Smallest Thing That Runs#

The problem

Maya wants to run one copy of Pantry in the cluster, the way she ran one container in chapter 1, and see it answer a request. That is all. Raj's YAML file is eleven lines, and every one of them looks like it means something.

She tries to skip the file. Surely there is a kubectl run pantry:v1 that just works? There is, sort of, and Raj lets her try it. It works. Then he asks her to run it again on Tuesday with a different environment variable, and on Wednesday to explain to a colleague exactly what she ran on Tuesday. Shell history is not a system. The file is the system.

The second surprise is the word Kubernetes uses. She asked for a container. Kubernetes gave her a Pod.

The idea

A Pod is the smallest thing Kubernetes runs. It is one or more containers that share a network address and can share storage, scheduled together onto the same node, living and dying together. Nearly every Pod you write will have exactly one container in it. The reason the concept exists is the "nearly": sometimes a program needs a helper right beside it, sharing its localhost, and Act III has a famous example.

Think of a Pod as an apartment. The containers inside share the address and the kitchen. Pantry lives alone in its apartment. Later a concierge will move in.

Node: pantry-worker (building)env REDIS_HOST=redisPod: pantry IP 10.244.1.3 (apartment)

container: web
image: pantry:v1
:8080

Service: redis
(chapter 10)

Remember it as: a Pod is an apartment: one address, usually one tenant, sometimes a helper in the spare room.

Here is the file, with the four parts every Kubernetes resource has:

apiVersion: v1          # which version of the API this kind of thing lives in
kind: Pod               # what it is
metadata:               # name, labels, namespace: how it is identified
  name: pantry
  labels:
    app: pantry
spec:                   # what you want: the desired state
  containers:
    - name: web
      image: pantry:v1
      ports:
        - containerPort: 8080
      env:
        - name: REDIS_HOST
          value: redis

apiVersion, kind, metadata, spec. Every Deployment, Service, ConfigMap and Ingress in this book has those same four top-level keys, and the only one that changes shape is spec. When Kubernetes accepts the file it adds a fifth section, status, which is what it observed. Desired state is yours; observed state is the cluster's; the gap between them is what the controllers work on.

The file goes in with kubectl apply -f. apply is idempotent: run it again with no changes and nothing happens, change one line and only that changes. It is the verb you will use most, and it is why the file is the system.

Nobody writes these files from a blank page. The honest way is to ask kubectl for a skeleton and edit it: kubectl create deployment pantry --image=pantry:v1 --dry-run=client -o yaml > deployment.yaml. --dry-run=client means "build the object but do not send it", and -o yaml prints it. The same trick works for create service, create configmap and most other kinds, and it is how experienced people produce YAML that is correct on the first try.

One more resident of the apartment is worth knowing now. An init container runs to completion before the main container starts, and the Pod stays Init:0/1 until it exits successfully. It is the helper who arrives before the tenant moves in: waiting for Redis to answer, fetching a config file, running a migration. k8s/pod-init.yaml waits for Redis with a two-line shell loop, which is the most common init container in the world.

A Pod has a life cycle, and its phase is the first thing to look at when something is off:

created, waiting for a node oran imagecontainers startedall containers exited 0 (Jobs)a container exited non-zeroand will not restartcontainer crashed, kubeletrestarts it (RESTARTS columngrows)could not be scheduled orimage never arrived

Pending

Running

Succeeded

Failed

Pending means "not yet placed or not yet started" and almost always means either no node has room or the image cannot be pulled. Running with a climbing RESTARTS count means the program keeps dying. Chapter 15 turns this diagram into a checklist.

One more thing you will do constantly: reach a Pod from your laptop without exposing it to anyone. kubectl port-forward opens a tunnel from a local port to a port in the Pod, through the API server. It is the Kubernetes equivalent of -p 8000:8080 and it is for you, not for users.

The commands

# Send a file to the front desk; apply is idempotent, run it as often as you like
kubectl apply -f k8s/pod.yaml

# List Pods; -o wide adds IP and node, -w watches for changes
kubectl get pods
kubectl get pods -o wide
kubectl get pods -w

# The story of one Pod, events at the bottom
kubectl describe pod pantry

# Everything the cluster knows about it, including status
kubectl get pod pantry -o yaml

# Logs (-f follows, -c picks a container in a multi-container Pod)
kubectl logs -f pantry

# Run a command inside; get a shell with -- sh
kubectl exec -it pantry -- sh

# Tunnel local port 8000 to the Pod's 8080 (Ctrl-C to stop)
kubectl port-forward pod/pantry 8000:8080

# A quick throwaway Pod for poking around the network, deleted on exit
kubectl run curl --rm -it --image=curlimages/curl:8.11.1 -- sh

# Generate a correct skeleton instead of typing YAML from memory
kubectl create deployment pantry --image=pantry:v1 --dry-run=client -o yaml

# Delete by file or by name
kubectl delete -f k8s/pod.yaml
kubectl delete pod pantry

Try it

  1. Pantry needs Redis. Apply Raj's Redis file first (it is a Deployment and a Service, both explained in the next two chapters; for now it is "a Redis called redis"), then the Pod:
   cd examples/pantry
   kubectl apply -f k8s/redis.yaml
   kubectl apply -f k8s/pod.yaml
   kubectl get pods -o wide
   
   NAME                     READY   STATUS    RESTARTS   AGE   IP           NODE
   pantry                   1/1     Running   0          3s    10.244.1.3   pantry-worker
   redis-5d9f8c7b6d-x2k9p   1/1     Running   0          8s    10.244.1.2   pantry-worker
   

The scheduler put both on the worker; the control-plane node is reserved for city hall.

  1. Talk to it through a tunnel:
   kubectl port-forward pod/pantry 8000:8080 &
   curl localhost:8000/
   kill %1
   
   Hello from Pantry (v1). You are visitor #1. Served by pantry.
   

The hostname is the Pod name. Pantry is running in the city.

  1. Read its story:
   kubectl describe pod pantry | tail -8
   

The Events section shows Scheduled, Pulled, Created, Started: one line per department that touched it.

  1. Delete it and watch what happens:
   kubectl delete pod pantry
   kubectl get pods
   

It is gone, and nothing brings it back. A bare Pod has no inspector watching it. That is the problem for the next chapter.

Then and now

You will see tutorials where kubectl run nginx --image=nginx creates a Deployment; it did until Kubernetes 1.18 (2020) removed the generators, and since then it creates only a Pod. Old Maps tells the story.

Recall

  1. What are the four top-level keys of every Kubernetes resource, and which one the cluster adds?
  2. Why does kubectl apply -f twice with no changes do nothing?
  3. How do you generate a Deployment manifest without writing it by hand?
  4. What does an init container guarantee about the main container?
  5. In the city metaphor, what is a Pod?
Show answers
  1. apiVersion, kind, metadata, spec; the cluster adds status.
  2. apply compares the file with the cluster and changes only differences.
  3. kubectl create deployment … --dry-run=client -o yaml.
  4. That it will not start until the init container has exited successfully.
  5. An apartment.

What Maya learned

  • A Pod is the unit Kubernetes runs: one or more containers sharing an address, almost always one.
  • Every resource is apiVersion, kind, metadata, spec; apply -f makes the file the source of truth.
  • get, describe, logs, exec and port-forward are the Pod toolkit; describe shows the events that explain problems.

…but

Maya deletes the Pod and it stays deleted. She kills the process inside it and the kubelet restarts the container, which is nice, but if the node dies the Pod dies with it and nobody notices. This is exactly the 3 a.m. problem, and a Pod on its own does not solve it. "Correct," says Raj. "Pods are not supposed to. You never run a Pod by itself. You ask someone to run it for you."

Go deeper

Kubernetes manual 1 · Reading Any Manifest · Kubernetes manual 2 · The Pod Spec · The Bridge

Chapter 9 · Act II · 4 min readSomeone Who Never Sleeps#

The problem

Maya's list from chapter 6 is on the whiteboard: self-healing, scaling, safe rollout. A single Pod delivers none of them. Delete it and it is gone. Ask for three and you write three files. Ship a new version and you delete the old Pod and create a new one, which is the eight seconds of downtime she was trying to escape.

"Who watches the Pods?" she asks. Raj writes one word on the whiteboard: Deployment. "You don't run Pods. You tell a Deployment how many Pods you want and what they should look like. It never sleeps."

The idea

A Deployment is a record that says: keep N copies of this Pod template running, and when the template changes, move from the old copies to the new ones gradually. A controller in city hall watches every Deployment and makes it true.

It does the job through a middle manager. The Deployment creates a ReplicaSet, whose only responsibility is "exactly N Pods matching this label selector must exist". If a Pod dies or a node vanishes, the ReplicaSet creates a replacement. When you change the Deployment's Pod template (a new image, say), the Deployment creates a second ReplicaSet for the new template and shifts the count from old to new, one Pod at a time. The old ReplicaSet is kept at zero so you can roll back.

selector:app=pantrypod-template-hash=647759c9cb

Deployment: pantry
replicas: 3
template: image pantry:v1

ReplicaSet pantry-647759c9cb
(template v1) desired 3

ReplicaSet pantry-5f67fcb5db
(template v2) desired 0
(kept for rollback)

Pod
app=pantry

Pod
app=pantry

Pod
app=pantry

The dotted lines are the important part. The ReplicaSet does not "own" Pods by holding a list of them. It selects them by labels, and labels are just key-value tags in a resource's metadata. Every Pod the Deployment creates gets app: pantry plus a hash of the template; the ReplicaSet counts Pods with those labels. Labels and selectors are how everything in Kubernetes finds everything else, and the next chapter uses the same trick for traffic.

Think of the Deployment as the building manager who guarantees N identical apartments are always occupied, and labels as the name tags on the doors the manager checks against a list.

Remember it as: a Deployment is the building manager who never sleeps: N tenants, always, and new tenants move in one at a time.

Here is the Deployment's spec, which wraps the Pod from the last chapter:

spec:
  replicas: 3
  selector:
    matchLabels:
      app: pantry            # must match the template's labels
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1            # may run one extra Pod during a rollout
      maxUnavailable: 0      # may never drop below 3 ready Pods
  template:                  # the Pod, minus apiVersion/kind/name
    metadata:
      labels:
        app: pantry
    spec:
      containers:
        - name: web
          image: pantry:v1

A rollout with those two numbers looks like this over time:

ReplicaSet v2ReplicaSet v1DeploymentReplicaSet v2ReplicaSet v1Deploymentcreate, desired 1 (surge)1 Pod readydesired 2desired 22 readydesired 1desired 33 readydesired 0 (kept for undo)

At no point are fewer than three Pods ready, so users never notice. If the new Pods fail to become ready, the rollout stops with the old ones still serving, and rollout undo shifts the counts back the other way. That is the 11 a.m. problem solved by arithmetic.

The commands

# Create or update the Deployment from the file (the normal way to change anything)
kubectl apply -f k8s/deployment.yaml

# The three layers, in one listing (-l filters by label)
kubectl get deploy,rs,pods -l app=pantry

# Change the number of copies now (the file still says 3; apply would put it back)
kubectl scale deploy/pantry --replicas=5

# Change the image without editing the file (handy in scripts and demos)
kubectl set image deploy/pantry web=pantry:v2

# Watch a rollout finish, see the history, go back one revision
kubectl rollout status deploy/pantry
kubectl rollout history deploy/pantry
kubectl rollout undo deploy/pantry

# Restart every Pod gracefully (new Pods first): picks up new ConfigMaps, clears bad state
kubectl rollout restart deploy/pantry

# Show labels, or add one
kubectl get pods --show-labels
kubectl label pod <name> tier=web

Try it

  1. Apply the Deployment and look at all three layers:
   kubectl apply -f k8s/deployment.yaml
   kubectl rollout status deploy/pantry
   kubectl get deploy,rs,pods -l app=pantry
   
   NAME                     READY   UP-TO-DATE   AVAILABLE   AGE
   deployment.apps/pantry   3/3     3            3           2s

   NAME                                DESIRED   CURRENT   READY   AGE
   replicaset.apps/pantry-647759c9cb   3         3         3       2s

   NAME                          READY   STATUS    RESTARTS   AGE
   pod/pantry-647759c9cb-64rhd   1/1     Running   0          2s
   pod/pantry-647759c9cb-7cfk6   1/1     Running   0          2s
   pod/pantry-647759c9cb-wtwht   1/1     Running   0          2s
   

Pod names are <deployment>-<template hash>-<random>. The hash is the label the ReplicaSet selects on.

  1. Kill one and watch the manager replace it:
   kubectl delete pod $(kubectl get pod -l app=pantry -o jsonpath='{.items[0].metadata.name}')
   kubectl get pods -l app=pantry
   

There are still three, and one of them is a few seconds old. The 3 a.m. problem is solved.

  1. Roll out v2 and watch it happen:
   kubectl set image deploy/pantry web=pantry:v2
   kubectl rollout status deploy/pantry
   
   Waiting for deployment "pantry" rollout to finish: 1 out of 3 new replicas have been updated...
   Waiting for deployment "pantry" rollout to finish: 2 out of 3 new replicas have been updated...
   Waiting for deployment "pantry" rollout to finish: 1 old replicas are pending termination...
   deployment "pantry" successfully rolled out
   
  1. v2 is flaky (it returns errors on purpose). Go back:
   kubectl rollout history deploy/pantry
   kubectl rollout undo deploy/pantry
   kubectl get pods -l app=pantry -o jsonpath='{.items[*].spec.containers[0].image}'
   
   pantry:v1 pantry:v1 pantry:v1
   

Both ReplicaSets still exist: kubectl get rs shows one at 3 and one at 0. The undo just moved the numbers.

Gotchas

  • A Deployment's selector cannot be changed after creation; kubectl apply fails with "field is immutable". Delete and recreate, or leave the selector alone and change labels elsewhere.
  • Without a readiness probe, a Pod counts as available the instant its container starts, so a rolling update can finish while the new version is still booting. Chapter 12 fixes this.
  • kubectl scale and set image change the cluster, not the file; the next apply -f puts the file's values back. Edit the file, or accept that you are experimenting.
  • Template labels must match the selector or the Deployment is rejected; a typo in one of them is the usual cause of "selector does not match template labels".

Then and now

You will see kind: ReplicationController and Deployments under apiVersion: extensions/v1beta1 in older manifests; the address stopped working in Kubernetes 1.16 (2019) and apps/v1 made the selector mandatory. Old Maps tells the story.

Recall

  1. What does a ReplicaSet do that a Deployment does not, and the other way round?
  2. How does a ReplicaSet know which Pods are "its" Pods?
  3. Which two numbers make a rollout zero-downtime, and what did we set them to?
  4. Which command goes back one revision?
  5. In the city metaphor, what is a Deployment?
Show answers
  1. A ReplicaSet keeps N Pods alive; a Deployment manages ReplicaSets to roll out changes.
  2. By label selector.
  3. maxSurge: 1, maxUnavailable: 0.
  4. kubectl rollout undo deploy/<name>.
  5. The building manager who never sleeps.

What Maya learned

  • Never run bare Pods; a Deployment keeps N copies alive and replaces any that die, on any node.
  • A Deployment manages ReplicaSets, which select Pods by label; changing the template creates a new ReplicaSet and shifts Pods across gradually.
  • scale, set image, rollout status, rollout undo and rollout restart are the day-to-day Deployment verbs.

…but

Three copies of Pantry are running, and each has its own Pod IP. Which one should a user talk to? Maya port-forwards to one of them, deletes that Pod to prove the manager works, and her tunnel dies with it. The new Pod has a new IP. The manager keeps the apartments full; it does not give anyone a phone number.

Go deeper

Kubernetes manual 4 · Lifecycle and Rollouts

Chapter 10 · Act II · 4 min readA Number That Never Changes#

The problem

Three Pantry Pods, three IP addresses, and every one of them temporary. Maya's port-forward died when its Pod did. Redis has the same problem from the other side: Pantry finds it by the name redis, and in chapter 8 that worked, but she never asked why. If the Redis Pod is replaced, its IP changes, and something must be keeping the name pointed at the right place.

Then the customer asks for a URL. Not a tunnel, not a port on a node, a proper https://pantry.example.com that their users can type. Nothing Maya has so far answers to a hostname.

Two questions, then: how do Pods find each other when everything moves, and how does the outside world find the Pods at all?

The idea

A Service is a stable name and IP for a group of Pods, chosen by label selector. Traffic sent to the Service is spread across whichever Pods currently match. Pods come and go; the Service does not. Every Service also gets a DNS name inside the cluster, <name>.<namespace>.svc.cluster.local, and from the same namespace just <name> works. That is why REDIS_HOST=redis has been working: redis is a Service.

http://pantry/selector: app=pantry

client Pod
(or port-forward)

Service: pantry
ClusterIP 10.96.80.31
DNS: pantry.default.svc
port 80 → 8080

Pod 10.244.1.4
app=pantry

Pod 10.244.1.5
app=pantry

Pod 10.244.1.9 (new)
app=pantry

Think of it as a phone number for a group of apartments. Tenants move in and out; the number stays on the sign. The selector is the list of doors the number rings.

Remember it as: a Service is a phone number for a group of Pods that never changes when the Pods do.

Services come in three types, and the difference is only who can dial the number:

Type Reachable from Use it for
ClusterIP (default) Inside the cluster only Almost everything: app to database, app to app
NodePort Any node's IP on a fixed high port (30000–32767) Quick tests, kind on a laptop, the hook a load balancer attaches to
LoadBalancer A cloud load balancer with a public IP Exposing one Service directly on a cloud cluster

NodePort and LoadBalancer build on ClusterIP; each type is the one below plus a door. On a laptop, kind maps a NodePort to localhost for you. On a cloud, LoadBalancer costs money per Service, which is why nobody exposes twenty Services that way.

They do the job for "find each other". They do not answer to a hostname. For that there is Ingress: a set of rules saying "requests for host X and path Y go to Service Z". An Ingress is only a record; something has to read it and act, and that something is an Ingress controller, a reverse proxy (usually NGINX) running in the cluster, exposed once, routing to every Service by hostname. One front gate, many signposts.

read by

user
http://pantry.localtest.me

Ingress controller
(nginx Pod, exposed on :80)

Ingress rules
host pantry.localtest.me / → pantry:80
host shop.localtest.me / → shop:80

Service pantry

Service shop

Remember it as: Ingress is the city's front gate with a signpost: one entrance, routed by hostname and path.

The metaphor stretches in one place, so here it is plainly: an Ingress can also terminate TLS (the certificate lives in a Secret named in the Ingress) and rewrite paths. Those are proxy features, not gates, and the controller's documentation covers them.

The commands

# Create the Service; get shows type, cluster IP and ports
kubectl apply -f k8s/service.yaml
kubectl get svc

# Which Pod IPs is the Service currently pointing at? (empty = your selector is wrong)
kubectl get endpoints pantry

# Tunnel to the Service instead of a Pod: survives Pod restarts
kubectl port-forward svc/pantry 8000:80

# Test DNS and routing from inside the cluster with a throwaway Pod
kubectl run curl --rm -it --image=curlimages/curl:8.11.1 -- sh -c 'curl -s http://pantry/; nslookup redis'

# Install an Ingress controller (kind flavour: listens on the node's port 80)
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml

# Create the Ingress and see which host it answers to
kubectl apply -f k8s/ingress.yaml
kubectl get ingress
kubectl describe ingress pantry

Try it

  1. Create the Service and look at its endpoints:
   kubectl apply -f k8s/service.yaml
   kubectl get svc pantry
   kubectl get endpoints pantry
   
   NAME     TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)   AGE
   pantry   ClusterIP   10.96.80.31   <none>        80/TCP    2s
   NAME     ENDPOINTS                                            AGE
   pantry   10.244.1.4:8080,10.244.1.5:8080,10.244.1.6:8080      2s
   

Three Pod IPs behind one cluster IP. Delete a Pod and run the endpoints command again: one IP has changed, the Service has not.

  1. Call it from inside the city, by name:
   kubectl run curl --rm -it --restart=Never --image=curlimages/curl:8.11.1 -- -s http://pantry/
   
   Hello from Pantry (v1). You are visitor #1. Served by pantry-647759c9cb-xthw5.
   

Run it a few times: the Pod name at the end changes as the Service spreads the load.

  1. Open a door on the node. The book's kind config maps node port 30080 to your laptop:
   kubectl apply -f k8s/service-nodeport.yaml
   curl localhost:30080/
   kubectl delete -f k8s/service-nodeport.yaml
   
  1. Install the Ingress controller, wait for it, and add the front gate:
   kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
   kubectl wait -n ingress-nginx --for=condition=Ready pod -l app.kubernetes.io/component=controller --timeout=180s
   kubectl apply -f k8s/ingress.yaml
   curl -H 'Host: pantry.localtest.me' localhost/
   curl -o /dev/null -w '%{http_code}\n' localhost/
   
   Hello from Pantry (v1). You are visitor #4. Served by pantry-647759c9cb-g6hp9.
   404
   

With the right hostname you reach Pantry; without it the gate has no signpost and says 404. (*.localtest.me resolves to 127.0.0.1 on most networks, in which case plain curl pantry.localtest.me/ works too; some home routers block that, so the book uses the Host header.)

Gotchas

  • Three ports, three meanings: port is the Service's own, targetPort is the container's, nodePort is the door on the node. Mixing the first two is the most common Service bug.
  • A Service whose selector matches nothing is created happily and routes nowhere; kubectl get endpoints showing <none> is the only tell.
  • From another namespace, redis does not resolve; redis.default or redis.default.svc does.
  • An Ingress without an ingressClassName, on a cluster with no default class, is accepted and ignored by every controller: no ADDRESS in kubectl get ingress, and 503 from the gate.

Then and now

You will see the kubernetes.io/ingress.class annotation and networking.k8s.io/v1beta1 Ingresses; v1 arrived in 1.19 and the old address went in 1.22 (2021), and since 1.33 get endpoints warns about EndpointSlices. Old Maps tells the story.

Recall

  1. What is a Service, in one sentence, and what makes its address stable?
  2. Which command shows the Pod IPs behind a Service, and what does an empty result mean?
  3. Who can reach a ClusterIP, a NodePort and a LoadBalancer Service?
  4. What reads an Ingress resource, and what happens without it?
  5. In the city metaphor, what are the Service and the Ingress?
Show answers
  1. A stable name and IP for whichever Pods match a selector; the Service object outlives the Pods.
  2. kubectl get endpoints <svc>; empty means the selector matches nothing.
  3. Inside the cluster; any node's IP on a high port; the internet via a cloud load balancer.
  4. An Ingress controller; without one the rules do nothing.
  5. The phone number and the front gate.

What Maya learned

  • A Service is a stable name, IP and DNS entry for whichever Pods match its selector; check endpoints when it seems broken.
  • ClusterIP is for inside, NodePort opens a fixed port on every node, LoadBalancer asks the cloud for a public IP.
  • Ingress routes by hostname and path through one controller, so a whole cluster needs one public entrance, not one per Service.

…but

Pantry has a URL. The customer's engineer opens the deployment YAML to review it and finds REDIS_HOST: redis hard-coded in the Pod template, and the greeting hard-coded in the image. "How do we change these for staging?" she asks. "Rebuild the image?" Maya opens her mouth and closes it again.

Go deeper

Kubernetes manual 8 · Networking

Chapter 11 · Act II · 4 min readSticky Notes and the Locked Drawer#

The problem

Staging needs a different greeting, a different Redis, and soon a different API token for the payment provider. Maya's options so far are to bake values into the image (one image per environment, which defeats the point of images) or to write them into the Deployment's Pod template (one Deployment file per environment, all nearly identical, drifting apart). Neither is right.

The token is worse. It cannot go in the image, because images get pushed to registries and pulled by whoever has access. It cannot go in the Deployment file, because the file is committed to Git. It has to be somewhere, and that somewhere has to be readable by the Pod and by nobody else.

Raj's answer is two resources that look almost identical and are treated very differently.

The idea

A ConfigMap is a named bag of key-value pairs that lives in the cluster and is injected into Pods at start-up, either as environment variables or as files. The Deployment stays the same in every environment; only the ConfigMap changes. Configuration has moved out of the image and out of the Pod template into a thing you can apply on its own.

A Secret is the same bag with a different label on it. The values are base64-encoded (that is encoding, not encryption; anyone with get secret rights can read them), and the difference is in how the cluster handles them: Secrets can be encrypted at rest in etcd, RBAC lets you grant access to ConfigMaps without granting access to Secrets, and tooling knows not to print them. Put anything you would not paste into a public chat in a Secret.

ConfigMap Secret
Holds Settings, hostnames, feature flags, config files Tokens, passwords, TLS keys
Stored as Plain text base64 (plus optional encryption at rest)
Metaphor Sticky notes on the fridge The locked drawer
Injected as env vars or files env vars or files
Rule of thumb Fine in Git Never in Git; create from a vault or CI
Pod: pantryenvFromenvFromvolume mount(key → file)

ConfigMap pantry-config
GREETING=Welcome…
REDIS_HOST=redis
motd.txt=…

Secret pantry-secret
API_TOKEN=****

environment
GREETING, REDIS_HOST,
API_TOKEN

/etc/pantry/motd.txt
(mounted file)

Remember it as: a ConfigMap is sticky notes on the fridge, a Secret is the locked drawer; both are read by the Pod, only one is meant to be seen.

There are two ways in, and they behave differently. Environment variables are simple and are what most apps read, but they are fixed when the container starts; change the ConfigMap and the Pod keeps its old values until it is restarted (kubectl rollout restart is for exactly this). Mounted files update in place a minute or so after the ConfigMap changes, which suits config files a program re-reads. Pantry uses both: settings as env vars, a message-of-the-day as a file.

Here is the piece of the Deployment that does the wiring; the ConfigMap and Secret themselves are three-line files:

containers:
  - name: web
    image: pantry:v1
    envFrom:
      - configMapRef: { name: pantry-config }    # every key becomes an env var
      - secretRef:    { name: pantry-secret }
    volumeMounts:
      - name: notes
        mountPath: /etc/pantry
        readOnly: true
volumes:
  - name: notes
    configMap:
      name: pantry-config
      items:
        - key: motd.txt
          path: motd.txt                          # one key becomes one file

The Secret file in the repository uses stringData so it is readable, and it contains a fake token. In real life the file is not committed; the Secret is created by kubectl create secret from a CI variable, or by an operator that syncs from a vault.

The commands

# Create from files (fine for config; the Secret file here holds a fake token)
kubectl apply -f k8s/configmap.yaml -f k8s/secret.yaml

# Create from the command line: the usual way for real Secrets
kubectl create configmap pantry-config --from-literal=GREETING='Hi' --from-file=motd.txt
kubectl create secret generic pantry-secret --from-literal=API_TOKEN="$TOKEN"

# See them; Secrets show sizes, not values
kubectl get configmap,secret
kubectl describe configmap pantry-config

# Read a Secret value back (base64, then decode)
kubectl get secret pantry-secret -o jsonpath='{.data.API_TOKEN}' | base64 -d

# Apply the Deployment that consumes them, then confirm from inside a Pod
kubectl apply -f k8s/deployment-config.yaml
kubectl exec deploy/pantry -- sh -c 'echo $GREETING; cat /etc/pantry/motd.txt'

# Env vars are read at start: restart Pods to pick up a changed ConfigMap
kubectl rollout restart deploy/pantry

Try it

  1. Create both and look at how they are shown:
   kubectl apply -f k8s/configmap.yaml -f k8s/secret.yaml
   kubectl get configmap pantry-config -o yaml | head -12
   kubectl get secret pantry-secret -o yaml | grep -A2 '^data'
   

The ConfigMap shows its values. The Secret shows API_TOKEN: czNjcjN0..., which is base64 and decodes with one command.

  1. Wire them into Pantry and check from inside:
   kubectl apply -f k8s/deployment-config.yaml
   kubectl rollout status deploy/pantry
   kubectl exec deploy/pantry -- sh -c 'echo GREETING=$GREETING; echo API_TOKEN=$API_TOKEN; cat /etc/pantry/motd.txt'
   
   GREETING=Welcome to the Pantry
   API_TOKEN=s3cr3t-token-do-not-commit-real-ones
   Today's special: containers, freshly orchestrated.
   

kubectl exec deploy/pantry picks a current Pod of the Deployment for you.

  1. Change a value and see which path notices:
   kubectl patch configmap pantry-config -p '{"data":{"GREETING":"Hello, staging","motd.txt":"Menu changed.\n"}}'
   sleep 60; kubectl exec deploy/pantry -- sh -c 'echo $GREETING; cat /etc/pantry/motd.txt'
   

The file says Menu changed.; the env var still says Welcome to the Pantry. Then:

   kubectl rollout restart deploy/pantry && kubectl rollout status deploy/pantry
   curl -H 'Host: pantry.localtest.me' localhost/
   
   Hello, staging (v1). You are visitor #6. Served by pantry-7c9d4b8f6-q2xkm.
   
  1. Put it back for the next chapter:
   kubectl apply -f k8s/configmap.yaml
   kubectl rollout restart deploy/pantry
   

Gotchas

  • Environment variables are read once at container start; a changed ConfigMap does nothing to running Pods until rollout restart. Mounted files do update, after up to a minute.
  • A Secret is base64, and kubectl get secret -o yaml shows it to anyone with read access. Protection is RBAC and never committing real values, not the encoding.
  • A ConfigMap is capped at about 1 MiB; a 2 MB file is refused with too large, and belongs in the image or a volume instead.
  • Keys used with envFrom must be valid environment variable names; a key like motd.txt is silently skipped as an env var and only works as a mounted file.

Recall

  1. What is the actual difference between a ConfigMap and a Secret?
  2. Which injection path picks up a changed value without a restart?
  3. How do you read a Secret's value back in plain text?
  4. Why does one image now serve staging and production?
  5. In the city metaphor, what is a Secret?
Show answers
  1. Handling: Secrets can be encrypted at rest, gated by RBAC separately, and hidden by tooling. The data is just base64.
  2. Mounted files.
  3. kubectl get secret … -o jsonpath='{.data.KEY}' | base64 -d.
  4. Configuration moved out of the image into ConfigMaps and Secrets.
  5. The locked drawer.

What Maya learned

  • Configuration lives in ConfigMaps and secrets in Secrets, injected as env vars or files, so one image and one Deployment serve every environment.
  • A Secret is base64, not encrypted; its protection is in access control and handling, so never commit real ones.
  • Env vars need a Pod restart to change; mounted files update in place.

…but

Staging is configured. During the first load test, one Pantry Pod starts answering slowly, then stops answering at all, while still showing Running. The Service keeps sending it a third of the traffic. Kubernetes cannot tell a Pod that is up from a Pod that is working, because nobody has told it how to check.

Go deeper

Kubernetes manual 6 · Config and Secrets

Chapter 12 · Act II · 4 min readAre You Alive?#

The problem

During the load test one Pantry Pod wedged. The process was alive, so the kubelet saw a healthy container. The Pod showed Running and 1/1, so the Service kept sending it a third of all requests, and a third of all requests timed out. Kubernetes did exactly what it had been told, which was nothing.

Two other things surfaced in the same afternoon. When Maya scaled to ten Pods, several were sent traffic in the half-second between the process starting and Redis being reachable, and returned errors. And one Pod, sharing a node with a memory-hungry neighbour, was slow for no reason of its own.

Raj sums it up: "You never told the city how to check on a tenant, and you never told it how much space a tenant needs."

The idea

A probe is a check the kubelet runs against a container on a timer. There are three, and they answer three different questions:

Probe Question On failure Metaphor
Liveness Is the process still sane? Container is restarted "Are you awake?" Ring the bell; no answer, call the locksmith
Readiness Can it take traffic right now? Pod is removed from Service endpoints (no restart) "Are you taking visitors?" No, then don't send any
Startup Has it finished starting? Other probes wait; restart if it takes too long "Are you still moving in?"

Each can be an HTTP GET (a 2xx or 3xx passes), a TCP connect, or a command run inside the container. Pantry's /healthz returns 200 only when Redis answers, so it is a good readiness check and an acceptable liveness check.

container startedstartup probe failing (withinbudget)startup ok, readiness okstartup failed too many timesreadiness fails (removed fromService)readiness passes againliveness fails N timesliveness fails N times

Starting

Ready

Restarted

NotReady

The mistake everyone makes once is using the same aggressive check for liveness and readiness. A readiness failure is gentle: stop sending traffic, keep the Pod. A liveness failure is violent: kill and restart. If Redis goes down for a minute and liveness checks Redis, every Pantry Pod is restarted in a loop for something that was never their fault. Make readiness strict and liveness lenient (longer period, more failures allowed), and give slow starters a startup probe so liveness does not kill them mid-boot.

Remember it as: liveness asks "are you awake?" and restarts you, readiness asks "are you taking visitors?" and just stops the traffic.

Resources are the other half. Every container should declare a request, the CPU and memory it needs to run properly, and a limit, the most it may ever use. The scheduler uses requests to pick a node with room; that is the only information it has. The kubelet enforces limits: a container that exceeds its memory limit is killed (OOMKilled), and one that exceeds its CPU limit is throttled.

resources:
  requests: { cpu: 50m,  memory: 64Mi }    # what the lease promises: 5% of a core, 64 MiB
  limits:   { cpu: 500m, memory: 128Mi }   # the ceiling: half a core, 128 MiB

Think of it as the lease: requests are what you are promised, limits are the ceiling you cannot exceed. Without requests, the scheduler packs Pods onto nodes blind, which is how the memory-hungry neighbour happened. m is millicores (1000m = one CPU), Mi is mebibytes. Set requests from what you measure with kubectl top, and set memory limits close to requests, because memory cannot be throttled, only killed.

The commands

# Apply the Deployment with probes and resources
kubectl apply -f k8s/deployment-probes.yaml

# READY shows containers passing readiness; RESTARTS climbs on liveness failures
kubectl get pods -l app=pantry

# See probe results and OOM kills in the events
kubectl describe pod <pod> | grep -A12 Events

# Install metrics-server on kind (cloud clusters have it already), then measure
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
kubectl patch -n kube-system deploy metrics-server --type=json -p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'
kubectl top pod
kubectl top node

# What has been promised on each node, and to whom
kubectl describe node pantry-worker | grep -A6 'Allocated resources'

Try it

  1. Roll out the probed version:
   kubectl apply -f k8s/deployment-probes.yaml
   kubectl rollout status deploy/pantry
   

The rollout takes a little longer now: a new Pod only counts as available once its readiness probe passes, so maxUnavailable: 0 really means zero.

  1. Make Redis disappear and watch readiness do its job:
   kubectl scale deploy/redis --replicas=0
   sleep 15; kubectl get pods -l app=pantry
   kubectl get endpoints pantry
   
   NAME                      READY   STATUS    RESTARTS   AGE
   pantry-5c8f7d9b4-2jk8x    0/1     Running   0          2m
   ...
   NAME     ENDPOINTS   AGE
   pantry   <none>      40m
   

0/1: running, not ready, no traffic. Nothing was restarted. Bring Redis back and the endpoints return within seconds:

   kubectl scale deploy/redis --replicas=1
   
  1. Break one Pod and watch liveness restart it:
   kubectl port-forward deploy/pantry 8000:8080 &
   curl localhost:8000/crash; kill %1
   sleep 5; kubectl get pods -l app=pantry
   

One Pod shows RESTARTS 1. Its events say Container web failed liveness probe, will be restarted or simply that the process exited and was restarted; either way the kubelet handled it.

  1. Measure what Pantry actually uses:
   kubectl top pod -l app=pantry
   
   NAME                     CPU(cores)   MEMORY(bytes)
   pantry-6b6ff9b46-64857   3m           28Mi
   pantry-6b6ff9b46-8wfhf   2m           27Mi
   

Two millicores and 28 MiB. The request of 50m and 64Mi is generous, and the limit of 128Mi has room. Chapter 15 shows what happens when it does not.

Gotchas

  • A liveness probe that checks a dependency (Redis) restarts every Pantry Pod whenever Redis blinks, for a fault that was never theirs. Liveness checks the process; readiness checks the dependencies.
  • CPU over the limit is throttled, memory over the limit is killed. A memory limit set from a guess is how OOMKilled happens to healthy apps.
  • Without a startup probe, a slow-booting container can fail its liveness checks before it is up and be restarted forever, looking exactly like a crash loop.
  • kubectl top shows "Metrics API not available" until metrics-server is installed and has had a minute to collect.

Then and now

You will see Heapster in older monitoring guides and dashboards; it was retired in Kubernetes 1.13 (2018) in favour of metrics-server, which is why kubectl top has a prerequisite today. Old Maps tells the story.

Recall

  1. What happens on a readiness failure, and what happens on a liveness failure?
  2. Which endpoint does Pantry use for liveness and which for readiness, and why are they different?
  3. What does the scheduler use requests for, and what does the kubelet use limits for?
  4. Which command shows real CPU and memory use per Pod?
  5. In the city metaphor, what are requests and limits?
Show answers
  1. Readiness failure removes the Pod from the Service; liveness failure restarts the container.
  2. /livez for liveness (process only), /healthz for readiness (needs Redis), so a Redis outage does not restart Pantry.
  3. Requests to place Pods; limits to throttle CPU and kill on memory.
  4. kubectl top pod.
  5. The lease: what is promised, and the ceiling.

What Maya learned

  • Readiness gates traffic and liveness triggers restarts; make readiness strict, liveness lenient, and add a startup probe for slow boots.
  • Requests are what the scheduler reserves, limits are what the kubelet enforces; memory over the limit means the container is killed.
  • Measure with kubectl top and set requests from reality, not guesses.

…but

Pantry's Pods are honest about their health now. Then Raj scales the Redis Deployment to zero and back up, and the visit counter starts again at one. Maya has seen this before, in chapter 3, and she knows the word for what is missing. She just does not know what it is called in the city.

Go deeper

Kubernetes manual 4 · Lifecycle and Rollouts · Kubernetes manual 5 · Resources and Placement

Chapter 13 · Act II · 4 min readMemory That Survives, Again#

The problem

The Redis Pod was replaced, and the visit counter went back to one. Maya recognises the shape immediately: a Pod's filesystem is the container's writable layer, and it dies with the container, exactly as in chapter 3. Then she needed a volume. Now she needs a volume that works when the Pod might come back on a different node, where a directory on the old node's disk is no use at all.

There is a second wrinkle. Redis is a database. Databases care about identity: they want the same name and the same disk every time they start, not a random Pod name and whichever disk is free. A Deployment, which treats all its Pods as interchangeable, is the wrong manager for that.

The idea

Kubernetes separates the disk from the request for a disk. A PersistentVolume (PV) is a real piece of storage: a cloud disk, an NFS export, a directory on a node. A PersistentVolumeClaim (PVC) is a request: "I need 100 MiB, read-write by one node at a time." The cluster binds a claim to a volume that satisfies it, and the Pod mounts the claim, never the volume directly. That indirection is what lets the same Pod spec work on a laptop and in three clouds.

Nobody wants to create PVs by hand, so a StorageClass names a provisioner that creates them on demand. kind ships one called standard; clouds ship their own. A PVC that names no class gets the default.

volumeMountrequests fromprovisionsbound to

Pod redis-0
mounts /data

PVC data-redis-0
'100Mi, ReadWriteOnce'
(the rental request)

StorageClass standard
provisioner: local-path
(the storage company)

PV pvc-92ac9f2c…
100Mi on pantry-worker
(the unit)

Think of storage units: the PV is the unit, the PVC is the rental request, and the StorageClass is the storage company that builds a unit when someone asks. Delete the Pod and the claim, and the unit, stay. Delete the claim and the class's reclaim policy decides whether the unit is wiped.

Remember it as: a PVC is the rental request, the PV is the storage unit, and the Pod mounts the request so it does not care which unit it got.

One thing surprises people on kind and on most clouds: a fresh PVC shows Pending until a Pod uses it. The class's binding mode is WaitForFirstConsumer, so the unit is built on the node where the Pod is scheduled, not somewhere the Pod cannot reach. It is not an error.

For Redis's identity problem there is a second manager: the StatefulSet. It is a Deployment for things with names. Its Pods are numbered (redis-0, redis-1), start and stop in order, each get a stable DNS name through a headless Service, and each get their own PVC from a template, so redis-0 always mounts data-redis-0 even after being rescheduled. Databases, queues and anything that says "node ID" in its docs go in a StatefulSet. Everything else stays in a Deployment.

The whole StatefulSet spec adds three things to what you already know: a serviceName pointing at a headless Service, a volumeClaimTemplates list, and that is all.

kind: StatefulSet
spec:
  serviceName: redis                 # headless Service (clusterIP: None): gives redis-0.redis a DNS name
  replicas: 1
  template:
    spec:
      containers:
        - name: redis
          args: ["redis-server", "--appendonly", "yes"]
          volumeMounts:
            - name: data
              mountPath: /data
  volumeClaimTemplates:              # one PVC per Pod, named data-<pod>
    - metadata: { name: data }
      spec:
        accessModes: ["ReadWriteOnce"]
        resources: { requests: { storage: 100Mi } }

The commands

# What storage companies exist, and which is the default?
kubectl get storageclass

# Make a claim, and watch it wait for a consumer
kubectl apply -f k8s/pvc.yaml
kubectl get pvc

# Replace the throwaway Redis with the StatefulSet version
kubectl delete -f k8s/redis.yaml
kubectl apply -f k8s/redis-statefulset.yaml
kubectl rollout status sts/redis

# The claim it made for itself, and the volume that was built
kubectl get pvc,pv

# StatefulSet Pods have stable names; delete one and the same name comes back
kubectl delete pod redis-0
kubectl get pods -l app=redis -w

# Copy a file out of (or into) a Pod
kubectl cp redis-0:/data/appendonlydir ./redis-backup

Try it

  1. See the storage company and make a standalone claim:
   kubectl get storageclass
   kubectl apply -f k8s/pvc.yaml
   kubectl get pvc scratch
   
   NAME                 PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      AGE
   standard (default)   rancher.io/local-path   Delete          WaitForFirstConsumer   5m
   NAME      STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   AGE
   scratch   Pending                                      standard       8s
   

Pending, and it will stay that way until a Pod mounts it. That is the binding mode, not a fault.

  1. Swap Redis for the version that remembers:
   kubectl delete -f k8s/redis.yaml
   kubectl apply -f k8s/redis-statefulset.yaml
   kubectl rollout status sts/redis
   kubectl get pvc,pv
   
   NAME                                 STATUS   VOLUME                                     CAPACITY   STORAGECLASS
   persistentvolumeclaim/data-redis-0   Bound    pvc-92ac9f2c-5034-485d-96f8-875737557612   100Mi      standard
   persistentvolumeclaim/scratch        Pending                                                        standard
   

data-redis-0 was claimed by the StatefulSet's template, bound, and a PV was built for it.

  1. Count some visits, destroy the Pod, and count again:
   curl -H 'Host: pantry.localtest.me' localhost/; curl -H 'Host: pantry.localtest.me' localhost/
   kubectl delete pod redis-0
   kubectl wait --for=condition=Ready pod/redis-0 --timeout=90s
   curl -H 'Host: pantry.localtest.me' localhost/
   
   Hello from Pantry (v1). You are visitor #1. ...
   Hello from Pantry (v1). You are visitor #2. ...
   pod "redis-0" deleted
   Hello from Pantry (v1). You are visitor #3. ...
   

Same name, same claim, same data. The pantry shelf has moved to the city.

  1. Clean up the unused claim:
   kubectl delete pvc scratch
   

Gotchas

  • A claim can never shrink (can not be less than status.capacity), and it can grow only if its StorageClass allows expansion; kind's standard does not, so size claims generously.
  • Deleting a StatefulSet does not delete its PVCs, on purpose; the data waits for a StatefulSet of the same name. Delete the claims yourself if you really mean it.
  • A default StorageClass with reclaim policy Delete wipes the volume when the claim is deleted; check kubectl get storageclass before deleting a claim on a real cluster.

Recall

  1. What is the relationship between a PV, a PVC and a StorageClass?
  2. Why is a fresh PVC on kind Pending, and is that a fault?
  3. What does a StatefulSet give Redis that a Deployment does not?
  4. Which command lists claims and volumes together?
  5. In the city metaphor, what is a PVC?
Show answers
  1. A PVC requests storage, a StorageClass provisions a PV to satisfy it, the Pod mounts the PVC.
  2. WaitForFirstConsumer binding; it binds when a Pod uses it. Not a fault.
  3. A stable name and its own claim that follows it.
  4. kubectl get pvc,pv.
  5. The rental request for a storage unit.

What Maya learned

  • Pods mount a PersistentVolumeClaim; a StorageClass provisions a PersistentVolume to satisfy it, so the Pod never names a disk.
  • A claim showing Pending on kind or a cloud is usually waiting for its first Pod, not failing.
  • StatefulSets give Pods stable names and their own claims; use them for databases and Deployments for everything else.

…but

Pantry is healthy, configured and durable. The company hires a second team, who want a cluster for their own service and would rather not share a default namespace with Maya's. Then the launch traffic returns, and Maya finds herself running kubectl scale by hand at lunchtime every day.

Go deeper

Kubernetes manual 7 · Storage

Chapter 14 · Act II · 5 min readDistricts, Key Cards and Rush Hour#

The problem

The new team's service is called Shop, and on day one one of its engineers runs kubectl delete deploy pantry in the wrong terminal. Nothing was lost, thanks to chapter 9, but everyone agrees this should not have been possible. Two teams, one cluster, one flat list of everything, and every developer holding the admin credentials Raj handed out on day one.

Meanwhile, the traffic. Pantry needs six Pods at lunchtime and two overnight. Maya has a calendar reminder to run kubectl scale at 11:45 and again at 14:30, which is a restart policy for humans.

And there is a growing list of small jobs: reset a counter once, print a report every minute, run a log shipper on every node. None of them is a long-running web service, and Deployments keep trying to restart the ones that finish.

Four problems, and it turns out to be one short chapter, because all four answers are small.

The idea

Namespaces are districts. Every namespaced resource (Pods, Services, ConfigMaps, most things) lives in exactly one, names only need to be unique within it, and kubectl commands act on one at a time (-n shop, or -A for all). Pantry gets pantry, Shop gets shop, and kubectl delete deploy pantry from the shop district finds nothing to delete. Namespaces also carry quotas and, most usefully, are the unit that permissions attach to. DNS respects them too: pantry.pantry.svc from anywhere, pantry only from inside its own district.

RBAC (role-based access control) is the key cards. A Role lists what may be done to which resources in one namespace (get, list, watch on pods). A RoleBinding hands that Role to a subject: a user, a group, or a ServiceAccount, which is the identity a Pod runs as. Cluster-wide versions (ClusterRole, ClusterRoleBinding) exist for things like nodes and for admins. Shop's engineers get a binding in shop and nothing in pantry.

Namespace: shop (district)Namespace: pantry (district)grantstocan read Pods here onlyno key card

Deployment pantry

ServiceAccount pod-reader

Role pod-reader
verbs: get,list,watch
resources: pods, pods/log

RoleBinding

Deployment shop

Key cards say who may ask city hall for what. They say nothing about which apartments may talk to each other; by default every Pod can reach every other Pod in the cluster. A NetworkPolicy closes corridors: it selects Pods and lists who may connect to them, on which ports. k8s/networkpolicy.yaml says that Redis accepts connections only from Pods labelled app: pantry, on 6379, and nothing else. A policy needs a network plugin that enforces it; kind's does, and so do all the cloud ones.

Remember it as: a Namespace is a district and RBAC is the key card that opens some doors in some districts and none in others.

The HorizontalPodAutoscaler (HPA) is hiring temps in rush hour. It watches a metric (CPU utilisation against the request from chapter 12, by default) and adjusts a Deployment's replica count between a minimum and a maximum to hold the metric near a target. It is a control loop like everything else, and it only works if the Pods declare CPU requests and metrics-server is installed.

Deployment pantryHPA (target 50% CPU, 2–6)metrics-serverDeployment pantryHPA (target 50% CPU, 2–6)metrics-serverloop[every 15 s]how busy are pantry's Pods?254% of requested CPUreplicas = ceil(3 × 254/50) → 6 (max)6 Pods runninghow busy now?40%(after cool-down) replicas = 4

Remember it as: the HPA hires temps when the queue grows and lets them go when it shrinks, within the numbers you set.

The last three are workload types for things that are not web servers, and a table is all they need:

Kind Runs Metaphor Example in k8s/
Job A Pod until it exits 0, retrying on failure, then stops A one-off contractor job.yaml resets the counter
CronJob A Job on a schedule The scheduled cleaner cronjob.yaml prints the count every minute
DaemonSet Exactly one Pod on every node (new nodes included) One caretaker per building daemonset.yaml, stand-in for a log shipper

The commands

# Districts: create, list, act inside one (-n), or everywhere (-A)
kubectl create namespace pantry-staging
kubectl get namespaces
kubectl get pods -n pantry-staging
kubectl get pods -A

# Key cards: apply a ServiceAccount + Role + RoleBinding, then ask "can X do Y?"
kubectl apply -f k8s/rbac.yaml
kubectl auth can-i list pods -n pantry-staging --as=system:serviceaccount:pantry-staging:pod-reader
kubectl auth can-i delete pods -n pantry-staging --as=system:serviceaccount:pantry-staging:pod-reader

# Corridors: who may talk to whom
kubectl apply -f k8s/networkpolicy.yaml
kubectl get networkpolicy

# Rush hour: create the autoscaler and watch it think
kubectl apply -f k8s/hpa.yaml
kubectl get hpa -w

# Contractors and cleaners
kubectl apply -f k8s/job.yaml && kubectl logs job/reset-counter
kubectl apply -f k8s/cronjob.yaml && kubectl get cronjob
kubectl apply -f k8s/daemonset.yaml && kubectl get ds

Try it

  1. Make a district and a key card, and test the card:
   kubectl apply -f k8s/namespace.yaml -f k8s/rbac.yaml
   kubectl auth can-i list pods -n pantry-staging --as=system:serviceaccount:pantry-staging:pod-reader
   kubectl auth can-i delete pods -n pantry-staging --as=system:serviceaccount:pantry-staging:pod-reader
   kubectl auth can-i list pods -n default --as=system:serviceaccount:pantry-staging:pod-reader
   
   yes
   no
   no
   

Read Pods in its district: yes. Delete them: no. Read Pods next door: no. auth can-i with --as is the fastest way to debug any permission question.

  1. Hire temps. Pantry already has CPU requests from chapter 12; add the autoscaler and some load:
   kubectl apply -f k8s/hpa.yaml
   kubectl run load --image=busybox:1.36 --restart=Never -- sh -c 'while true; do wget -qO- http://pantry/ >/dev/null 2>&1; done'
   sleep 75; kubectl get hpa pantry
   
   NAME     REFERENCE           TARGETS         MINPODS   MAXPODS   REPLICAS   AGE
   pantry   Deployment/pantry   cpu: 254%/50%   2         6         6          76s
   

One busy loop drove tiny Pods to 254% of their request, and the HPA went to the maximum. Delete the load Pod and, after a five-minute cool-down, it scales back to two.

   kubectl delete pod load
   
  1. Run a contractor and a cleaner:
   kubectl apply -f k8s/job.yaml
   kubectl wait --for=condition=Complete job/reset-counter --timeout=60s
   kubectl logs job/reset-counter
   kubectl apply -f k8s/cronjob.yaml
   sleep 70; kubectl get jobs
   

The Job printed OK and finished. The CronJob has created its first child Job, named report-visits-<timestamp>, and kubectl logs job/<that name> shows the count.

  1. Put a caretaker in every building:
   kubectl apply -f k8s/daemonset.yaml
   kubectl get ds caretaker
   
   NAME        DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   AGE
   caretaker   2         2         2       2            2           10s
   

Two nodes, two Pods. The manifest carries a toleration so it may also run on the control-plane node, which normally refuses ordinary workloads.

Gotchas

  • The HPA does nothing, and says nothing, if the Pods have no CPU requests; kubectl describe hpa shows <unknown> for the target. Requests first, autoscaler second.
  • CronJob schedules run in UTC unless you set spec.timeZone; a "7 a.m." job that fires at 8 is this.
  • kubectl delete namespace can hang in Terminating for ever when a resource inside has a finalizer nobody will honour; look for it before you start removing finalizers by hand.
  • A NetworkPolicy with an empty podSelector: {} selects every Pod in the namespace; with policyTypes: [Ingress] and no rules, that is "deny all inbound".

Then and now

You will see PodSecurityPolicy (removed in 1.25, 2022), autoscaling/v2beta2 HPAs, batch/v1beta1 CronJobs, and code that expects every ServiceAccount to have a token Secret (not since 1.24). Old Maps tells the story.

Recall

  1. What three things does a Namespace separate?
  2. Which command answers "can this ServiceAccount delete Pods here?"
  3. What two prerequisites does the HPA need before it will scale anything?
  4. Which resource controls which Pods may connect to Redis?
  5. In the city metaphor, what are a Job, a CronJob and a DaemonSet?
Show answers
  1. Names, quotas and permissions.
  2. kubectl auth can-i delete pods -n <ns> --as=system:serviceaccount:<ns>:<sa>.
  3. CPU requests on the Pods and metrics-server in the cluster.
  4. A NetworkPolicy.
  5. A one-off contractor, a scheduled cleaner, one caretaker per building.

What Maya learned

  • Namespaces split a cluster into districts for naming, quotas and permissions; -n picks one and -A sees all.
  • RBAC grants verbs on resources in a namespace to users or ServiceAccounts; auth can-i --as answers any permission question.
  • The HPA scales a Deployment on CPU against its requests; Jobs run to completion, CronJobs on a schedule, DaemonSets once per node.

…but

The city is well run. At 3 a.m., for the first time since Act I, Maya's phone rings. Four different things are wrong at once, and the dashboard says only Pending, ImagePullBackOff, CrashLoopBackOff and OOMKilled. This time she has the tools. She just needs the order.

Go deeper

Kubernetes manual 3 · Security Context · Kubernetes manual 5 · Resources and Placement · Kubernetes manual 9 · Identity and Access

Chapter 15 · Act II · 3 min readThe Night It Fell Over, Again#

The problem

3:04 a.m. Shop's team pushed four changes before going home, and the cluster has four unhappy Pods. Maya looks at kubectl get pods and sees the four words every Kubernetes engineer learns to read at a glance:

NAME                              READY   STATUS             RESTARTS      AGE
broken-crash-864749c4c6-d447b     0/1     CrashLoopBackOff   3 (43s ago)   60s
broken-image-85b97ddc-9wmxd       0/1     ImagePullBackOff   0             60s
broken-oom-7cd56c996d-xk8lb       0/1     OOMKilled          3 (40s ago)   60s
broken-pending-7866dd6949-7j2t8   0/1     Pending            0             60s

In Act I, one dead container took her two hours to find. Tonight there are four, and she has Raj's rule taped to her monitor: the status tells you which question to ask, and there are only four questions.

The idea

Every broken Pod is stuck at one of four points in its life: it could not be placed, its image could not be fetched, its process keeps dying, or it is being killed by the kubelet for breaking its lease. The STATUS column tells you which, and each has one command that reveals the cause.

PendingImagePullBackOff /ErrImagePullCrashLoopBackOff / ErrorOOMKilledRunning but not READY

kubectl get pods
what does STATUS say?

kubectl describe pod
read Events at the bottom

'Insufficient cpu/memory'
→ requests too big, or cluster full:
lower requests or add nodes

'didn't match node selector /
taint'
→ fix selector, tolerations, or labels

'pod has unbound PVC'
→ check kubectl get pvc

kubectl describe pod
'Failed to pull image …'

typo in name or tag,
private registry needs imagePullSecrets,
or image not loaded into kind

kubectl logs pod --previous
(the log of the crashed attempt)

app error: wrong env / config,
missing dependency, bad command;
fix the manifest

kubectl describe pod
Last State: Terminated, Reason: OOMKilled

raise memory limit,
or fix the leak

kubectl describe pod
'Readiness probe failed'

dependency down or probe path wrong;
kubectl exec + curl localhost to test

Remember it as: Pending asks the scheduler, ImagePull asks the registry, CrashLoop asks the app's previous log, OOMKilled asks the lease.

The tools are ones you already own, with two additions. describe shows the Pod's events, which is where the scheduler and kubelet explain themselves. logs --previous shows the output of the last crashed container rather than the one currently restarting, which is where the stack trace lives. get events --sort-by shows the whole namespace's recent history in time order, which is how you notice that four things broke at 2:58 a.m. and not four separate times. exec lets you test from inside (is Redis reachable from here? does /healthz answer on localhost?). And debug attaches a throwaway container with real tools to a Pod whose image has no shell at all.

Two habits make this fast. First, always read the bottom of describe; the events are last and the newest is at the bottom. Second, when a Deployment's Pods are wrong, fix the Deployment (kubectl apply a corrected file or kubectl set image), never the Pod; the manager will just replace whatever you edit by hand.

The reverse case, Running and 1/1 but users see errors, is a chapter 12 problem: the probes are not checking the right thing. Running and 0/1 is a readiness failure, and the events say why.

These four have a STATUS word. The failures that do not, the Pod that is Running and 1/1 while users see errors, or the Service that routes nowhere, are the silent kind, and each chapter's Gotchas section lists the ones its concept produces.

The commands

# Triage: statuses, then the recent history of the whole district in order
kubectl get pods
kubectl get events --sort-by=.lastTimestamp | tail -20

# The story of one Pod; events are at the bottom
kubectl describe pod <pod>

# Logs of the crashed attempt, not the current retry
kubectl logs <pod> --previous

# Test from inside: is the dependency reachable? does the probe path work?
kubectl exec -it <pod> -- sh
kubectl exec <pod> -- python -c "import socket; print(socket.gethostbyname('redis'))"

# A tools container attached to a Pod that has no shell of its own
kubectl debug -it <pod> --image=busybox:1.36 --target=web

# The Pod's last exit reason in one line
kubectl get pod <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'

# Fix the manager, not the Pod
kubectl set image deploy/<name> web=pantry:v1
kubectl apply -f <corrected>.yaml

Try it

Reproduce the night. The four broken Deployments are in k8s/broken/:

kubectl apply -f k8s/broken/
sleep 60; kubectl get pods -l 'app in (broken-pending,broken-image,broken-crash,broken-oom)'
  1. Pending. Ask the scheduler:
   kubectl describe pod -l app=broken-pending | tail -5
   
   Warning  FailedScheduling  ...  0/2 nodes are available: 1 Insufficient memory, 1 node(s) had untolerated taint ...
   

The manifest requests 64 GiB of memory. Nobody has a flat that big. Fix: k8s/broken/pending.yaml, lower the request, apply.

  1. ImagePullBackOff. Ask the registry:
   kubectl describe pod -l app=broken-image | grep -E 'Failed|BackOff' | tail -2
   
   Warning  Failed   ...  Failed to pull image "pantry:v1-typo": ... not found
   Normal   BackOff  ...  Back-off pulling image "pantry:v1-typo"
   

A tag that does not exist. Fix: kubectl set image deploy/broken-image web=pantry:v1.

  1. CrashLoopBackOff. Ask the previous log:
   kubectl logs -l app=broken-crash --previous
   
   cannot find REDIS_HOST=rediss
   

A typo in an environment variable, printed by the app as it died. Fix the manifest, apply.

  1. OOMKilled. Ask the lease:
   kubectl get pod -l app=broken-oom -o jsonpath='{.items[0].status.containerStatuses[0].lastState.terminated.reason}'
   
   OOMKilled
   

The Pod allocates 200 MiB and its limit is 64 MiB. Fix: raise the limit or stop allocating. Then look at the whole night in order:

   kubectl get events --sort-by=.lastTimestamp | grep -i broken | tail
   
  1. Clean up:
   kubectl delete -f k8s/broken/
   

Four failures, four questions, four commands. Maya is back in bed by 3:20.

Recall

  1. For each of Pending, ImagePullBackOff, CrashLoopBackOff and OOMKilled, which command answers "why"?
  2. Where in kubectl describe output are the events, and in what order?
  3. Why fix the Deployment rather than the Pod?
  4. Which command shows the whole namespace's recent history in time order?
  5. In the metaphor, what do the four status words ask?
Show answers
  1. describe for Pending, ImagePullBackOff and OOMKilled; logs --previous for CrashLoopBackOff.
  2. At the bottom, oldest first.
  3. The manager replaces anything you patch on a Pod.
  4. kubectl get events --sort-by=.lastTimestamp.
  5. The scheduler, the registry, the previous log, the lease.

What Maya learned

  • The STATUS column names the failure and picks the command: describe for Pending, ImagePull and OOM, logs --previous for crashes.
  • Events, read bottom-up, are where the scheduler and kubelet explain themselves; get events --sort-by shows the whole timeline.
  • Fix the Deployment, not the Pod; the manager will replace anything you patch by hand.

…but

Pantry is a solved problem. Then the company grows. Shop's team arrives with a service, a deadline, and a repository left behind by a contractor who has since moved on. Maya opens it on a quiet Friday afternoon and finds docker-compose.yml files with a version: line, a script that calls docker stack deploy, a Helm chart whose README says to run helm init, and a warning in bold that Kubernetes is about to drop Docker. Every word is one she almost knows.

Go deeper

Kubernetes manual 11 · Debugging

Interlude · 7 min readOld Maps#

The problem

Shop's previous contractor has left, and left a repository. Maya opens it on a quiet Friday afternoon, expecting to read it in an hour, and finds a language she almost speaks.

There is a docker-compose.yml that starts with version: "2" and wires the services together with links:. There is a deploy.sh containing one line, docker stack deploy -c stack.yml pantry, and a stack.yml full of a deploy: key she has never seen. There is a Helm chart whose README says "run helm init first", with its dependencies in a separate file called requirements.yaml. There is a Deployment with apiVersion: extensions/v1beta1 and no selector. There is an Ingress whose class is set by an annotation. There is something called a PodSecurityPolicy. And there is a note at the top of the README, in bold, warning that "Kubernetes is dropping Docker support, we need to migrate off Docker before it breaks."

Some of these will apply to the cluster. Some will fail. Some will apply and silently do nothing. Maya cannot tell which is which, and she cannot tell whether the bold warning is true.

Raj reads it over her shoulder and laughs, not unkindly. "None of this is wrong," he says. "It's just old. Let me tell you when each of these was true."

The idea

Everything in that repository was correct when it was written. Understanding it means knowing three stories, each of which ends with a sentence about what survived.

Arc one: how containers escaped Docker. Docker appeared in 2013 and for two years "container" and "Docker" meant the same thing. In 2015 Docker and others founded the Open Container Initiative (OCI), which wrote down two standards: what an image is, and how a runtime starts a container from one. Once those were standards, anyone could build a runtime, and the runtime that mattered was containerd, the engine underneath Docker itself. Kubernetes talks to runtimes through its Container Runtime Interface (CRI). Docker did not speak CRI, so Kubernetes carried an adapter called the dockershim. In Kubernetes 1.20 (December 2020) the project announced the shim would go, and the internet read it as "Kubernetes drops Docker". In 1.24 (May 2022) it went. Nothing about images changed: a docker build still produces an OCI image, and every cluster still runs it, through containerd directly instead of through Docker. The README's bold warning was true, and it did not matter.

Remember it as: the recipe card became a standard, so the caretaker stopped borrowing Docker's oven and bought his own.

kubelet usedremoved

2013 Docker
(image + runtime, one product)

2015 OCI
image spec + runtime spec

containerd + runc
(the runtime under Docker)

dockershim
kubelet → Docker → containerd

1.24 (2022)
kubelet → containerd via CRI

Arc two: how the orchestrator war ended. Kubernetes was announced in 2014 and reached 1.0 in July 2015. Docker answered with its own orchestrator: Swarm mode, built into Docker 1.12 (June 2016), and a version 3 of the Compose file format with a deploy: key (Compose 1.10 and Docker 1.13, January 2017) so that one file could describe both a laptop and a cluster. docker stack deploy read that file and ran it on a swarm. For about two years teams had to choose. Then Docker itself shipped Kubernetes inside Docker Desktop (version 18.06, July 2018), which was the end of the argument. Compose survived, as the best way to run several containers on one machine, which is exactly how this book used it. A stack.yml is a Compose file with a deploy: section that Compose ignores; Swarm still exists and some small shops still run it happily.

Remember it as: two cities were built; one kept its kitchens and gave up on being a city.

Arc three: how the YAML kept moving. Kubernetes promises that apiVersion, kind, metadata, spec never change shape, and it has kept that promise. What changes is the addresses. Deployments started life in extensions/v1beta1; that address stopped working in 1.16 (September 2019), and apps/v1 requires the selector the old one let you omit. kubectl run created a Deployment until 1.18 (2020), when its generators were removed and it began creating only Pods, which is why old tutorials' kubectl run nginx --image=nginx did something different. The node that runs city hall was labelled master until the label was removed in 1.24 and the matching taint in 1.25; today it is control-plane. Ingress moved from networking.k8s.io/v1beta1 to v1 in 1.19, the old address was removed in 1.22 (2021), and with it the kubernetes.io/ingress.class annotation gave way to ingressClassName. PodSecurityPolicy was removed in 1.25 (2022) in favour of Pod Security Admission, which is a namespace label rather than a resource. Under the hood, kube-dns became CoreDNS by default in 1.13 (2018), Heapster gave way to metrics-server the same year, ServiceAccounts stopped getting a token Secret created automatically in 1.24, and in 1.33 (2025) the Endpoints API you met in chapter 10 began warning that EndpointSlices are its replacement.

The tools moved too. Helm 2 ran a server inside the cluster called Tiller, installed with helm init, with chart dependencies in requirements.yaml; Helm 3 (November 2019) removed Tiller and moved dependencies into Chart.yaml, and Helm 4 (November 2025) is the version in this book. Istio's telemetry ran through a component called Mixer, deprecated in 1.5 (2020) and removed in 1.8; its sidecars became optional when ambient mode reached GA in 1.24 (2024). Docker's own builder was replaced by BuildKit by default in Docker 23 (2023), which is why old build output looks different, and Docker Hub began rate-limiting anonymous pulls in November 2020.

Remember it as: the four-key shape never changed; only the street addresses did.

It becameYou will see

docker-compose · version: '2' · links:

extensions/v1beta1 · master · kubectl run → Deployment

ingress.class annotation · PodSecurityPolicy

helm init · Tiller · requirements.yaml

docker compose · no version · one network

apps/v1 · control-plane · kubectl create deployment

ingressClassName · Pod Security Admission label

Helm 3/4 · no server · Chart.yaml dependencies

Alive, just not here. Some names you will meet are not old, only absent from this book. Podman runs OCI containers without a daemon and accepts most docker commands unchanged. Colima and Rancher Desktop are alternatives to Docker Desktop on a Mac, which became a paid product for larger companies in 2021. minikube is kind's older sibling, a one-node cluster in a VM. Kompose translates a Compose file into Kubernetes manifests, which is a reasonable first draft and a poor final one. Swarm, as said, still runs in small shops. All of them use the same images.

The commands

# Which API addresses does this cluster actually serve? (the old ones will be missing)
kubectl api-resources --api-group=extensions
kubectl api-resources | grep -i ingress

# Validate a manifest against the live cluster without creating anything
kubectl apply --dry-run=server -f old-maps/deployment-v1beta1.yaml

# The manual for the current address of any kind
kubectl explain deployment
kubectl explain deployment --api-version=apps/v1 --recursive | head -40

# Compose: parse and print the effective file; old keys produce warnings here
docker compose -f old-maps/docker-compose.yml config

# Helm: lint an inherited chart; Helm 2 charts and their READMEs show their age
helm lint old-maps/chart-helm2
helm version --short

Try it

The inherited files are in examples/pantry/old-maps/, each with a *.modern.* neighbour that is what you would write today.

  1. Start with the Compose file. Nothing is broken, but the tool tells you it is old:
   cd examples/pantry
   docker compose -f old-maps/docker-compose.yml config | head -3
   
   level=warning msg="old-maps/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion"
   name: old-maps
   services:
   

version: is ignored, links: still works, and compose.modern.yaml is the same file without either.

  1. The Swarm script fails on the first line, because your Docker is not a swarm:
   old-maps/deploy.sh
   
   Error response from daemon: This node is not a swarm manager. Use "docker swarm init" or "docker swarm join" ...
   

Read stack.yml anyway. It is chapter 5's file with a deploy: block: replicas, update policy, restart policy. Every one of those ideas is a Deployment now.

  1. Ask the cluster about the old addresses:
   kubectl apply --dry-run=server -f old-maps/deployment-v1beta1.yaml
   kubectl apply --dry-run=server -f old-maps/ingress-v1beta1.yaml
   kubectl apply --dry-run=server -f old-maps/psp.yaml
   
   error: resource mapping not found for name: "pantry-old" ... no matches for kind "Deployment" in version "extensions/v1beta1"
   error: resource mapping not found for name: "pantry-old" ... no matches for kind "Ingress" in version "networking.k8s.io/v1beta1"
   error: resource mapping not found for name: "restricted" ... no matches for kind "PodSecurityPolicy" in version "policy/v1beta1"
   

"No matches for kind X in version Y" is the sentence that means this file is from before Y was removed. Now the modern neighbours:

   kubectl apply --dry-run=server -f old-maps/deployment.modern.yaml -f old-maps/ingress.modern.yaml -f old-maps/psa.modern.yaml
   

All three are accepted. Open the two Deployments side by side: the new one has apps/v1 and a selector, and nothing else changed.

  1. Lint the Helm 2 chart and try the command its README asks for:
   helm lint old-maps/chart-helm2
   helm init
   
   [ERROR] templates/deployment.yaml: a Deployment must contain matchLabels or matchExpressions ...
   [WARNING] chart directory is missing these dependencies: redis
   Error: unknown command "init" for "helm"
   

Three eras in three lines: the template is extensions/v1beta1 without a selector, the dependencies live in requirements.yaml where Helm 3 no longer looks, and helm init installed a server that no longer exists.

  1. Finally the bold warning. Ask the cluster what runs its containers:
   kubectl get nodes -o wide | awk '{print $1, $NF}'
   
   pantry-control-plane containerd://2.3.4
   pantry-worker containerd://2.3.4
   

Not Docker. And pantry:v1, built with Docker in chapter 2, is running on it right now.

Gotchas

  • docker compose silently ignores version:; the Python docker-compose (with a hyphen) honoured it and still exists on some machines, so which docker-compose explains a lot of "works for me" arguments.
  • A Helm 2 chart with apiVersion: v1 still installs with Helm 3 and 4, but its requirements.yaml is read only if you run helm dependency update; move the dependencies into Chart.yaml and the confusion ends.
  • kubectl convert is no longer built in; it is a plugin, and for most files it is faster to change the apiVersion by hand and add the selector.

Recall

  1. What did the OCI standardise, and why did that let Kubernetes drop the dockershim without breaking any image?
  2. What is a stack.yml, and which single Kubernetes resource replaces its deploy: block?
  3. Which Kubernetes version stopped serving extensions/v1beta1 Deployments, and what field did apps/v1 make mandatory?
  4. What was Tiller, and which Helm version removed it?
  5. In the city metaphor, what is the dockershim?
Show answers
  1. The image and runtime formats; the image never depended on Docker, only the adapter did.
  2. A Compose file with a deploy: block for Swarm; a Deployment.
  3. 1.16; the selector.
  4. Helm 2's in-cluster server; Helm 3 removed it.
  5. The adapter that let the caretaker use Docker's oven, now retired.

…but

By five o'clock Maya has modernised every file in Shop's repository by hand: new addresses, a selector here, a label there, dependencies moved into Chart.yaml. She closes the laptop feeling like an archaeologist. Then the cloud provider's email arrives: the worker node's host needs a kernel patch on Thursday, and Raj forwards it with one line. "Your building. You're the landlord this week."

Interlude · 5 min readThe Landlord's Week#

The problem

The cloud provider sends an email: the worker node's underlying host needs a kernel patch, and it will be rebooted on Thursday between two and four. Raj forwards it to Maya with one line: "Your building. You're the landlord this week."

Maya's first instinct is to do nothing. Pantry has three replicas and a Deployment that never sleeps; if the node reboots, the manager will notice the missing Pods and start new ones. That is true, and it is also a plan for three minutes of downtime and a Redis restart at an hour of the provider's choosing. What she wants is to move every tenant out before the reboot, at a time she picks, without a single failed request, and to stop anyone moving in while the paint is wet.

Then Shop's team asks a second question in the same week: their new intern deployed a load test into the staging district with a replicas: 200, and the whole cluster slowed to a crawl. Could the district have a ceiling?

Both are landlord problems: how to empty a building safely, and how to stop one tenant taking the whole block.

The idea

Emptying a building is two verbs. kubectl cordon marks a node unschedulable: existing Pods stay, new ones go elsewhere. "No new tenants." kubectl drain cordons and then evicts every Pod on the node, one at a time, so that their managers recreate them on other nodes. "Everyone out, politely." Eviction is a proper request through the API server, not a kill, so the Pod's grace period is honoured and, crucially, a PodDisruptionBudget can say no.

A PodDisruptionBudget (PDB) is a rule attached to a label selector: "at least N of these Pods must be available at all times" (minAvailable), or "at most N may be down" (maxUnavailable). Drain asks the budget before every eviction. If evicting the next Pod would break it, drain waits and retries until a replacement is running somewhere else. With three replicas and minAvailable: 2, the building empties one apartment at a time, and Pantry never drops below two serving Pods.

DeploymentPDB pantry (minAvailable 2)API serverkubectl drainDeploymentPDB pantry (minAvailable 2)API serverkubectl draindrain waits and retriescordon pantry-workerevict pantry-a3 available, may I?yes (2 remain)pantry-a gonenew Pod on another node… Pending (no room!)evict pantry-b2 available, may I?no

That last exchange is the interesting one, and on this book's two-node cluster it is exactly what happens: the only other building is the control plane, which refuses ordinary tenants with a taint, so the replacement Pod goes Pending and the budget blocks the second eviction. Drain is doing its job. The landlord's mistake was owning two buildings and keeping one locked.

Remember it as: cordon is "no new tenants", drain is "everyone out, politely", and the PodDisruptionBudget is the minimum who must stay in during works.

When the work is done, kubectl uncordon reopens the building. Pods do not move back on their own; they are only rebalanced when something replaces them, so a rollout restart afterwards is how the landlord gets tenants back in.

The ceiling on a district is two more resources, both namespaced. A ResourceQuota caps what a namespace may consume in total: Pods, requested CPU and memory, limits, even object counts. A LimitRange sets per-container defaults and bounds, so that a Pod created without any resources: gets a sensible lease instead of none. Together they are the district's building code: the quota is the ceiling for the whole district, the LimitRange is the default lease for any tenant who does not name one. Quotas count requests, which is why the LimitRange matters: without a default, a Pod with no requests counts as zero and the ceiling means nothing.

Namespace pantry-staging (district)fills incounts requests, refuses atthe ceiling

ResourceQuota staging-ceiling
pods 10 · requests.cpu 1 · requests.memory 1Gi

LimitRange default-lease
default: 200m / 128Mi
defaultRequest: 50m / 64Mi

Pod with no resources:
gets the default lease

Pod #11:
refused by the quota

Remember it as: the quota is the district's ceiling and the LimitRange is the default lease; the ceiling only works if every tenant has a lease.

Neither protects against a bad neighbour on the same floor; that is the requests-and-limits story from chapter 12. These protect the block from the district.

The commands

# No new tenants; everyone out politely; reopen
kubectl cordon pantry-worker
kubectl drain pantry-worker --ignore-daemonsets --delete-emptydir-data
kubectl uncordon pantry-worker

# The budget that drain must respect, and how many evictions it currently allows
kubectl apply -f k8s/pdb.yaml
kubectl get pdb

# Where is everyone right now?
kubectl get pods -o wide

# Let ordinary Pods onto the control-plane node (remove its taint), and put the taint back
kubectl taint nodes pantry-control-plane node-role.kubernetes.io/control-plane:NoSchedule-
kubectl taint nodes pantry-control-plane node-role.kubernetes.io/control-plane:NoSchedule

# The district's ceiling and default lease, and how much of the ceiling is used
kubectl apply -f k8s/quota.yaml -f k8s/limitrange.yaml
kubectl describe quota staging-ceiling -n pantry-staging
kubectl describe limitrange default-lease -n pantry-staging

Try it

Pantry is running with three replicas and probes from chapter 12. All three Pods are on pantry-worker, because the control plane refuses them.

  1. Add the budget, close the building, and try to empty it:
   cd examples/pantry
   kubectl apply -f k8s/pdb.yaml
   kubectl cordon pantry-worker
   kubectl drain pantry-worker --ignore-daemonsets --delete-emptydir-data --timeout=45s
   kubectl get pdb pantry
   kubectl get pods -l app=pantry -o wide
   
   evicting pod default/pantry-ff65dd7b-7sw9g
   error when evicting pods/"pantry-ff65dd7b-hhkf9" -n "default": global timeout reached: 45s
   NAME     MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
   pantry   2               N/A               0                     20s
   pantry-ff65dd7b-4qvjz   Running   pantry-worker
   pantry-ff65dd7b-hhkf9   Running   pantry-worker
   pantry-ff65dd7b-xh79p   Pending   <none>
   

One Pod was evicted, its replacement has nowhere to go, two are still serving, and the budget's ALLOWED DISRUPTIONS is 0. Drain gave up at your timeout instead of breaking the promise. Pantry never dropped below two.

  1. Open the second building and drain again:
   kubectl taint nodes pantry-control-plane node-role.kubernetes.io/control-plane:NoSchedule-
   kubectl drain pantry-worker --ignore-daemonsets --delete-emptydir-data
   kubectl get pods -o wide
   
   node/pantry-worker drained
   pantry-ff65dd7b-mgft9   Running   pantry-control-plane
   pantry-ff65dd7b-s6p6p   Running   pantry-control-plane
   pantry-ff65dd7b-xh79p   Running   pantry-control-plane
   redis-5d85cc97f8-5fw8v  Running   pantry-control-plane
   

Every tenant moved, one at a time, with two of them always serving. This is the moment the provider may reboot the host.

  1. Repairs done. Reopen the worker, lock the control plane again, and bring the tenants home:
   kubectl uncordon pantry-worker
   kubectl taint nodes pantry-control-plane node-role.kubernetes.io/control-plane:NoSchedule
   kubectl rollout restart deploy/pantry deploy/redis
   kubectl get pods -l app=pantry -o wide
   

All three back on pantry-worker. Note that Redis, still a plain Deployment in this cluster, lost its counter in the move; chapter 13's StatefulSet with a claim would not have.

  1. Now the intern. Give the staging district a ceiling and a default lease, then watch a Pod with no resources: get one:
   kubectl apply -f k8s/namespace.yaml -f k8s/quota.yaml -f k8s/limitrange.yaml
   kubectl run lease-test -n pantry-staging --image=busybox:1.36 --restart=Never -- sleep 300
   kubectl get pod lease-test -n pantry-staging -o jsonpath='{.spec.containers[0].resources}{"\n"}'
   kubectl describe quota staging-ceiling -n pantry-staging | tail -5
   
   {"limits":{"cpu":"200m","memory":"128Mi"},"requests":{"cpu":"50m","memory":"64Mi"}}
   Resource         Used   Hard
   pods             1      10
   requests.cpu     50m    1
   requests.memory  64Mi   1Gi
   

The Pod asked for nothing and was given the default lease, and the district's ceiling now counts it. An eleventh Pod, or a request that would push CPU past one core, is refused at admission with a message naming the quota. Clean up with kubectl delete pod lease-test -n pantry-staging.

Gotchas

  • drain refuses to start if the node runs Pods that nothing manages (bare Pods) or Pods with local emptyDir data; --force and --delete-emptydir-data are you accepting that loss, so read the list first.
  • A PDB with minAvailable equal to the replica count can never be satisfied during a drain; the drain simply hangs. Leave room for at least one eviction.
  • A ResourceQuota on CPU or memory silently blocks every Pod that has no requests for them, with an admission error rather than a Pending Pod; pair every such quota with a LimitRange.

Recall

  1. What is the difference between cordon and drain?
  2. Why did the first drain stop after one eviction, and which resource stopped it?
  3. After uncordon, why are the Pods still on the other node, and what moves them back?
  4. What does a ResourceQuota count, and why is a LimitRange needed for it to mean anything?
  5. In the city metaphor, what are cordon, drain and the PodDisruptionBudget?
Show answers
  1. cordon blocks new Pods; drain cordons and evicts the existing ones.
  2. The replacement had nowhere to go, so the PodDisruptionBudget refused the second eviction.
  3. Nothing moves Pods on its own; a rollout restart replaces them where there is room.
  4. Requests; a Pod without requests counts as zero, so the LimitRange supplies defaults.
  5. "No new tenants", "everyone out politely", and the minimum who must stay in.

…but

Thursday's reboot comes and goes and nobody notices, which is the highest compliment an operation can get. Shop's staging district has a ceiling. Maya has, without quite meaning to, become the person who runs the city rather than the person who lives in it. And Shop's repository, modernised by hand in one afternoon, is one of twelve that need installing, upgrading and rolling back, plus a forty-file metrics stack the security team wants. Raj asks how she plans to do that by hand, and whether she would like a box that a whole application comes in.

Chapter 16 · Act III · 4 min readFlat-Pack Furniture#

The problem

Shop needs Redis. Raj copies Pantry's k8s/ directory, renames eleven things, and misses one, and Shop's Redis Service ends up selecting Pantry's Pods for an afternoon. Then the security team asks for a metrics stack, whose upstream install is forty YAML files and four thousand lines, and asks whether anyone plans to hand-edit that when the next version comes out.

Kubernetes has no idea that those forty files are one thing. It knows Deployments and Services; it does not know "a Redis" or "a monitoring stack" or "Pantry, the staging flavour". Maya has been treating YAML files as the unit of deployment, and they are too small. She needs a box that a whole application comes in.

The idea

Helm is the package manager for Kubernetes. A chart is a directory of YAML templates plus a values.yaml of defaults; a release is a chart installed into a cluster under a name, with your values filled in. helm install renders the templates with the values, applies the result, and records the release so it can be upgraded, rolled back, or removed as one unit. Public charts for Redis, PostgreSQL, NGINX, Prometheus and most other things live in repositories you add once and search.

helm rollback → rev 2

Chart (the flat-pack box)
Chart.yaml
values.yaml (defaults)
templates/*.yaml

Your values
--set image.tag=v2
-f staging-values.yaml
(the options sheet)

helm template / install
renders templates + values

Release 'pantry-helm' rev 3
(the assembled piece in your flat)
Deployment, Service, ConfigMap…

Think of flat-pack furniture. The chart is the box on the shelf. The values are the options sheet (colour, size). The release is the assembled piece standing in your flat, and Helm remembers every version you assembled so you can put the previous one back.

Remember it as: a chart is the flat-pack box, values are the options sheet, and a release is the piece assembled in your flat.

Pantry's chart is the manifests from chapters 8 to 12 with the parts that vary replaced by {{ .Values.… }}:

# chart/templates/deployment.yaml (excerpt)
spec:
  replicas: {{ .Values.replicas }}
  template:
    spec:
      containers:
        - name: web
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
# chart/values.yaml (the defaults, the whole options sheet)
image: { repository: pantry, tag: v1 }
replicas: 3
greeting: "Welcome to the Pantry"
resources:
  requests: { cpu: 50m, memory: 64Mi }
  limits: { cpu: 500m, memory: 128Mi }
redis: { enabled: true }

Staging is now helm install pantry-staging ./chart -f staging.yaml, where staging.yaml overrides three lines. Shop's Redis is helm install shop-redis bitnami/redis, no copying. The forty-file metrics stack is one helm install, and chapter 18 does exactly that.

Two commands are worth learning before the rest: helm template renders a chart to plain YAML without installing anything, so you can read what a public chart is about to do to your cluster, and helm get values shows what a running release was actually installed with.

The alternative to know is Kustomize, built into kubectl. Instead of templates it takes plain YAML as a base and applies overlays (a patch for replicas here, a different image tag there) per environment. No templating language, no release history, no public repositories; just kubectl apply -k overlays/prod. Use it when you own all the YAML and want environments to differ by a few lines; use Helm when you install other people's software or need rollback and packaging.

Helm Kustomize
Unit Chart + values → release Base + overlays → rendered YAML
Variation Template variables Patches on plain YAML
History and rollback Yes (helm rollback) No (it is just apply)
Third-party software Thousands of public charts Bring your own YAML
Best for Installing things; packaging your app for others Your own manifests across dev/staging/prod

The commands

# Add a public repository and search it
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm search repo prometheus-community/kube-prometheus-stack

# See what a chart would create, without creating it
helm template pantry-helm ./chart

# Install; upgrade --install does both, which is what scripts should use
helm install pantry-helm ./chart
helm upgrade --install pantry-helm ./chart --set image.tag=v2 --set errorRate=0.3

# What is installed, in what state, with which values?
helm list
helm status pantry-helm
helm get values pantry-helm

# Every revision, and how to go back
helm history pantry-helm
helm rollback pantry-helm 1

# Remove the release and everything it created
helm uninstall pantry-helm

# Kustomize: render, diff, apply an overlay
kubectl kustomize kustomize/overlays/prod
kubectl diff -k kustomize/overlays/dev
kubectl apply -k kustomize/overlays/dev

Try it

  1. Render first, then install the Pantry chart under a new release name:
   cd examples/pantry
   helm template pantry-helm ./chart | grep -E '^kind:|name: pantry-helm' | head
   helm install pantry-helm ./chart
   kubectl rollout status deploy/pantry-helm
   helm list
   
   NAME         NAMESPACE  REVISION  STATUS    CHART         APP VERSION
   pantry-helm  default    1         deployed  pantry-0.1.0  v1
   

The release created its own Deployment, Service, ConfigMap and Redis, all prefixed with the release name, next to the hand-made ones.

  1. Change an option and upgrade, then look at the history:
   helm upgrade pantry-helm ./chart --set image.tag=v2 --set errorRate=0.3
   kubectl rollout status deploy/pantry-helm
   helm history pantry-helm
   helm get values pantry-helm
   
   REVISION  STATUS      CHART         DESCRIPTION
   1         superseded  pantry-0.1.0  Install complete
   2         deployed    pantry-0.1.0  Upgrade complete
   USER-SUPPLIED VALUES:
   errorRate: "0.3"
   image:
     tag: v2
   
  1. Regret it and roll back in one line:
   helm rollback pantry-helm 1
   kubectl get deploy pantry-helm -o jsonpath='{.spec.template.spec.containers[0].image}'
   
   Rollback was a success! Happy Helming!
   pantry:v1
   

That is revision 3, whose contents equal revision 1. Helm never deletes history.

  1. Try the same variation with Kustomize, then clean both up:
   kubectl create namespace pantry-dev; kubectl create namespace pantry-prod
   kubectl apply -k kustomize/overlays/dev
   kubectl apply -k kustomize/overlays/prod
   kubectl get deploy pantry -n pantry-dev -o jsonpath='dev: {.spec.replicas} × {.spec.template.spec.containers[0].image}{"\n"}'
   kubectl get deploy pantry -n pantry-prod -o jsonpath='prod: {.spec.replicas} × {.spec.template.spec.containers[0].image}{"\n"}'
   helm uninstall pantry-helm
   kubectl delete namespace pantry-dev pantry-prod
   
   dev: 1 × pantry:v1
   prod: 5 × pantry:v2
   

Same base, two overlays, two districts.

Gotchas

  • --set splits on commas, so --set greeting="Hello, world" fails with key " world" has no value; escape it as Hello\, world or use a values file. And --set-string enabled=false is the string "false", which templates treat as true.
  • Helm installs a chart's CRDs on first install and never upgrades or deletes them; a new chart version with changed CRDs needs them applied by hand.
  • A release name must be unique within a namespace; helm install pantry-helm twice fails with "cannot re-use a name that is still in use". Use upgrade --install in scripts.
  • helm uninstall removes what the release created, not what you created next to it by hand, and not PVCs made from volumeClaimTemplates.

Then and now

You will see helm init, a server called Tiller, helm install --name, requirements.yaml and the stable repository in guides from before Helm 3 (2019); none of them exist in Helm 3 or 4. Old Maps tells the story.

Recall

  1. What are a chart, a release and values, in the flat-pack metaphor?
  2. Which command shows what a chart would create without installing anything?
  3. How do you go back to the previous revision of a release?
  4. When would you pick Kustomize over Helm?
  5. Which command shows the values a running release was installed with?
Show answers
  1. The box, the assembled piece in your flat, the options sheet.
  2. helm template.
  3. helm rollback <release> <revision>.
  4. When you own all the YAML and environments differ by a few lines.
  5. helm get values <release>.

What Maya learned

  • Helm packages an application as a chart, installs it as a release with your values, and keeps every revision for rollback.
  • helm template before helm install shows exactly what a chart will do; helm get values shows what a release was installed with.
  • Kustomize varies your own YAML by overlay without templates; Helm is for packaging and for other people's software.

…but

Shop is installed from a chart, Pantry has one too, and the two services now call each other across the cluster in plain HTTP. The security review says every internal call must be encrypted and authenticated. Pantry's v2, the flaky one, needs to go out to some users before all of them. Maya has a Deployment that replaces Pods one by one, which is not the same as sending ten percent of requests somewhere. She needs something that sits in front of every Pod and understands traffic.

Go deeper

Kubernetes manual 10 · Packaging: Helm and Kustomize

Chapter 17 · Act III · 5 min readA Concierge at Every Door#

The problem

The security review lists two findings. Internal traffic between Shop and Pantry is unencrypted, and nothing checks that the caller is really Shop. Maya could add TLS to every service, which means certificates in every image, rotation in every team, and a retry-and-timeout library in three languages. That is a year of work across the company to solve a problem every service has identically.

And the canary. Pantry v2 is ready enough to try on real users, but not all of them. A Deployment rollout is all-or-nothing over a few minutes; what Maya wants is ten percent of requests to v2, held there for a day while she watches, then a dial she can turn. Kubernetes Services spread traffic evenly across Pods and have no dial.

Both problems are about what happens between services. Raj says the word "mesh" and Maya braces herself.

The idea

A service mesh puts a small proxy next to every Pod and routes all of that Pod's traffic, in and out, through it. Because every request passes through a proxy the mesh controls, the mesh can encrypt it (mTLS, mutual TLS, where both sides present certificates), verify who is calling, retry it, time it out, record how long it took, and send it to a different destination than the one asked for. None of that touches application code. Pantry is unchanged.

Istio is the most widely used mesh. In its classic mode the proxy (Envoy) is injected as a second container into each Pod, the sidecar, which is the "helper in the spare room" chapter 8 promised. Istio's control plane (istiod) issues certificates and pushes routing configuration to every sidecar. A newer ambient mode moves the proxy out of the Pod and onto the node, which saves resources; the concepts are the same and this chapter uses sidecars because they are easier to see.

redis containersidecar (redis Pod)pantry containersidecar (pantry Pod)Istio ingress gatewayredis containersidecar (redis Pod)pantry containersidecar (pantry Pod)Istio ingress gatewayverify cert, log, apply rulesHTTP request (mTLS)plain HTTP on localhostconnect to redis:6379mTLS (encrypted, authenticated)plain on localhostreply (back through both sidecars)

Think of a concierge at every apartment door. Nothing reaches the tenant except through the concierge, who checks ID at the door (mTLS), keeps a visitor log (telemetry), and can, when told, send one visitor in ten to the new tenant across the hall.

Remember it as: a mesh puts a concierge at every door who checks ID, keeps the log, and can send one visitor in ten to the new tenant.

Traffic rules use three resources. A Gateway is the mesh's own front gate, like Ingress but Istio-aware. A VirtualService is the signpost: for this host, send requests to these destinations with these weights. A DestinationRule names subsets of a Service's Pods by label, so "v1" and "v2" are things the signpost can point at. The dial Maya wanted is two numbers in the VirtualService.

uses subsets from9010selects both

curl localhost:30080

Gateway pantry-gateway
(istio ingressgateway :80)

VirtualService pantry
90% → subset v1
10% → subset v2

DestinationRule pantry
v1: version=v1
v2: version=v2

Service pantry

Pods version=v1 (×3)

Pod version=v2 (×1)

# istio/virtualservice.yaml: the dial
http:
  - route:
      - destination: { host: pantry, subset: v1 }
        weight: 90
      - destination: { host: pantry, subset: v2 }
        weight: 10

The security finding is one more resource. A PeerAuthentication with mode: STRICT in a namespace means sidecars refuse any connection that is not mTLS. Applied, every internal call in the district is encrypted and authenticated, and nobody changed a line of application code. One consequence to know: anything without a sidecar can no longer talk to Pantry, and that includes the ingress-nginx controller from chapter 10, which starts returning 502. While the mesh is strict, traffic enters through Istio's own gateway (port 30080 in this book) or you inject the ingress controller's namespace too.

Meshes cost something: a proxy per Pod (memory, a little latency), and one more control plane to run. If you only need mTLS and simple retries, Linkerd is the lighter alternative: smaller proxies, fewer concepts, less configurability. The sidebar table is the honest comparison. Kiali is the dashboard that draws the mesh as a graph, and istioctl dashboard kiali opens it if you install the add-on.

Istio Linkerd
Proxy Envoy (feature-rich, heavier) Purpose-built Rust proxy (light)
Traffic rules Gateway, VirtualService, DestinationRule, and more Simpler; uses Gateway API resources
mTLS by default After you ask for STRICT On by default
Choose when You need rich routing, multi-cluster, or an ecosystem You want mTLS and metrics with the least surface area

The commands

# Install Istio into the cluster (demo profile: everything on, small footprint)
istioctl install --set profile=demo -y

# On kind: expose the ingress gateway on the node port the book maps to localhost
kubectl patch svc istio-ingressgateway -n istio-system --type=json -p '[{"op":"replace","path":"/spec/type","value":"NodePort"},{"op":"replace","path":"/spec/ports/1/nodePort","value":30080}]'

# Tell Istio to inject a sidecar into every new Pod in this namespace, then restart the Pods
kubectl label namespace default istio-injection=enabled
kubectl rollout restart deploy/pantry deploy/redis

# Apply the routing (v2 Deployment, Gateway, DestinationRule, VirtualService) and mTLS
kubectl apply -f istio/

# Is my configuration sane? Which Pods have proxies, and are they in sync?
istioctl analyze
istioctl proxy-status

# The mesh graph (needs the Kiali add-on from Istio's samples)
istioctl dashboard kiali

# Undo it all
kubectl delete -f istio/
kubectl label namespace default istio-injection-
istioctl uninstall --purge -y

Try it

  1. Install Istio and open the gate on port 30080:
   istioctl install --set profile=demo -y
   kubectl patch svc istio-ingressgateway -n istio-system --type=json -p '[{"op":"replace","path":"/spec/type","value":"NodePort"},{"op":"replace","path":"/spec/ports/1/nodePort","value":30080}]'
   
  1. Move concierges in. Label the district and restart the tenants:
   kubectl label namespace default istio-injection=enabled
   kubectl rollout restart deploy/pantry deploy/redis
   kubectl rollout status deploy/pantry
   kubectl get pods -l app=pantry
   
   NAME                      READY   STATUS    RESTARTS   AGE
   pantry-5d8d76f9f5-4vqnh   2/2     Running   0          40s
   

2/2: the app and its sidecar. Nothing in the Deployment changed.

  1. Add the new tenant and the dial, and check the mesh agrees with you:
   kubectl apply -f istio/
   kubectl rollout status deploy/pantry-v2
   istioctl analyze
   istioctl proxy-status | head -4
   

analyze may print an Info about Service port names; anything at Warning or Error is a real problem.

  1. Send a hundred visitors through the gate and count where they went:
   for i in $(seq 1 100); do curl -s localhost:30080/; done | grep -o '(v[12])' | sort | uniq -c
   for i in $(seq 1 100); do curl -s -o /dev/null -w '%{http_code}\n' localhost:30080/; done | sort | uniq -c
   
     88 (v1)
     10 (v2)
     98 200
      2 500
   

Roughly ten percent reached v2, and only those saw its errors. Turn the dial: edit the weights to 50/50, kubectl apply, and run the loop again. No Pods restart.

  1. Confirm the concierge is checking ID, then remove the mesh so the next chapter starts clean:
   kubectl get peerauthentication
   kubectl delete -f istio/
   kubectl label namespace default istio-injection-
   istioctl uninstall --purge -y
   kubectl rollout restart deploy/pantry deploy/redis
   

Gotchas

  • With PeerAuthentication STRICT, anything without a sidecar cannot reach meshed Pods, including ingress-nginx, which starts returning 502. Enter through Istio's gateway, or inject the ingress namespace too.
  • Istio wants Service ports named (http, grpc, tcp-redis); istioctl analyze reports unnamed ports as IST0118, and protocol detection may guess wrong without them.
  • Labelling a namespace istio-injection=enabled affects only Pods created afterwards; existing Pods have no sidecar until rollout restart.
  • A DestinationRule subset selects on labels; a Deployment whose Pods lack version: v2 gets zero traffic and no error.

Then and now

You will see Mixer, istio-telemetry and istio-policy Deployments in older Istio installs; Mixer was deprecated in 1.5 (2020) and removed in 1.8, and sidecars themselves became optional with ambient mode (GA in 1.24, 2024). Old Maps tells the story.

Recall

  1. What does the mesh give you without changing application code? Name three things.
  2. What do Gateway, VirtualService and DestinationRule each do?
  3. Which two numbers make the canary, and where do they live?
  4. Why does ingress-nginx return 502 once mTLS is strict?
  5. In the city metaphor, what is the sidecar?
Show answers
  1. mTLS, retries and timeouts, telemetry, traffic splitting; none in application code.
  2. Gateway is the door, VirtualService the signpost with weights, DestinationRule the named subsets.
  3. The weight values, in the VirtualService.
  4. It has no sidecar, and STRICT mode refuses plaintext.
  5. The concierge at the door.

What Maya learned

  • A mesh routes every Pod's traffic through a proxy beside it, which gives mTLS, retries, telemetry and traffic splitting with no application changes.
  • Gateway is the door, VirtualService is the signpost with weights, DestinationRule names the subsets the signpost can point at.
  • Istio is the full-featured choice and Linkerd the lighter one; both cost a proxy per Pod and a control plane to run.

…but

Ten percent of users are on v2, and Maya sees 500 in her curl loop. How many users saw one? Is v2's error rate two percent or twenty? Is it slower? kubectl top shows CPU. The curl loop shows her own hundred requests. Nobody in the company can answer "is the canary healthy?" with a number, and without a number the dial is a guess.

Chapter 18 · Act III · 4 min readThe Control Room#

The problem

"Is the canary healthy?" Maya's honest answer is a shrug. She has kubectl top, which says how much CPU Pantry uses and nothing about whether it is working. She has kubectl logs, which is a firehose across four Pods. She has her own curl loop, a hundred requests from her laptop. The customer's question is about every request from every user over the last hour, split by version, and nobody has that number.

Raj has an old sticker on his laptop that says if you can't measure it, you can't roll it out. Shop's team has been asking for dashboards since the day they joined. Both are the same request: the city needs a control room.

The idea

Prometheus is a metrics database that works by scraping: on a timer, it fetches a /metrics page from every target it knows about and stores the numbers it finds there, with labels. Applications expose counters and histograms; Prometheus does the polling. Nothing is pushed, so a dead target simply stops showing up, which is itself a signal.

Pantry has exposed /metrics since chapter 2 without anyone reading it. Two series matter: pantry_requests_total{version, status}, a counter of requests, and pantry_request_seconds_bucket, a histogram of latency. From those two, everything the customer asked for can be computed.

Grafana is the wall of screens. It queries Prometheus and draws the results, and a dashboard is a saved set of those queries. The two ship together in the kube-prometheus-stack Helm chart, along with the Prometheus Operator, which lets you tell Prometheus about a new target with a Kubernetes resource instead of editing its config file. That resource is the ServiceMonitor: "scrape every Service with this label, on this port, at this path".

tells Prometheus what toscrapescrape /metricsscrapescrapePromQL query

pantry Pod
/metrics

pantry Pod
/metrics

pantry-v2 Pod
/metrics

ServiceMonitor pantry
select Service app=pantry
port http, path /metrics, every 15s

Prometheus
(polls every building on a timer,
stores series with labels)

Grafana dashboard 'Pantry'
rate · error rate · p95

Think of the sensor network: Prometheus is the system that polls every building on a timer and files the readings, and Grafana is the control-room wall where the readings become pictures.

Remember it as: Prometheus polls every building on a timer and files the numbers; Grafana is the wall of screens that draws them.

The query language is PromQL, and it has a reputation. The truth is that three queries answer ninety percent of "is it healthy?", and they are the same three for every HTTP service in the world:

# 1. Request rate, per version (requests per second over the last minute)
sum by (version) (rate(pantry_requests_total[1m]))

# 2. Error rate, per version (fraction of requests that were 5xx)
sum by (version) (rate(pantry_requests_total{status=~"5.."}[1m]))
  / sum by (version) (rate(pantry_requests_total[1m]))

# 3. p95 latency, per version (95% of requests were faster than this, in seconds)
histogram_quantile(0.95, sum by (le, version) (rate(pantry_request_seconds_bucket[5m])))

Read them once, slowly. rate(counter[1m]) turns an ever-growing counter into "per second, recently". sum by (version) collapses the Pods into one line per version, which is the whole point for a canary. The error rate divides a filtered rate by the total rate. The histogram query is the one people copy and paste, and that is fine: it says "take the latency buckets, find the value below which 95% of requests fall".

Those three queries are also the three panels on Pantry's dashboard, in monitoring/grafana-dashboard.json. kubectl top is still useful for "which Pod is eating memory"; Prometheus is for "how is the service behaving over time, and compared to yesterday".

The commands

# Install Prometheus, Grafana and the operator in one release (takes a few minutes on first pull)
helm install monitoring prometheus-community/kube-prometheus-stack --version 91.4.1 \
  --namespace monitoring --create-namespace \
  --set grafana.adminPassword=pantry --set alertmanager.enabled=false --set nodeExporter.enabled=false

# Tell Prometheus to scrape Pantry
kubectl apply -f monitoring/servicemonitor.yaml

# Open Prometheus (targets page: Status → Targets) and Grafana (admin / pantry)
kubectl port-forward -n monitoring svc/monitoring-kube-prometheus-prometheus 9090:9090
kubectl port-forward -n monitoring svc/monitoring-grafana 3000:80

# Ask Prometheus a question from the terminal
curl -s --data-urlencode 'query=sum by (version) (rate(pantry_requests_total[1m]))' http://localhost:9090/api/v1/query

# Still the right tool for "which Pod is heavy right now?"
kubectl top pod -l app=pantry

# Remove the stack
helm uninstall monitoring -n monitoring

Try it

  1. Install the stack and wait for the Pods:
   helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
   helm repo update
   helm install monitoring prometheus-community/kube-prometheus-stack --version 91.4.1 --namespace monitoring --create-namespace --set grafana.adminPassword=pantry --set alertmanager.enabled=false --set nodeExporter.enabled=false
   kubectl get pods -n monitoring -w
   

Wait for prometheus-monitoring-kube-prometheus-prometheus-0 to show 2/2 Running. First-time image pulls can take several minutes.

  1. Point the sensor network at Pantry, generate some traffic, and check the target is up:
   kubectl apply -f monitoring/servicemonitor.yaml
   for i in $(seq 1 80); do curl -s -o /dev/null -H 'Host: pantry.localtest.me' localhost/; done
   kubectl port-forward -n monitoring svc/monitoring-kube-prometheus-prometheus 9090:9090 &
   sleep 40; curl -s 'http://localhost:9090/api/v1/targets?state=active' | grep -o '"job":"pantry"[^}]*"health":"up"' | head -3
   

Three up matches, one per Pantry Pod. If the list is empty, check that the Service carries the app: pantry label and a port named http; ServiceMonitors select on both.

  1. Ask the three questions:
   curl -s --data-urlencode 'query=sum by (version) (rate(pantry_requests_total[1m]))' http://localhost:9090/api/v1/query
   curl -s --data-urlencode 'query=histogram_quantile(0.95, sum by (le, version) (rate(pantry_request_seconds_bucket[5m])))' http://localhost:9090/api/v1/query
   kill %1
   
   {"status":"success","data":{"result":[{"metric":{"version":"v1"},"value":[...,"0.3643"]}]}}
   {"status":"success","data":{"result":[{"metric":{"version":"v1"},"value":[...,"0.0047"]}]}}
   

About a third of a request per second, and 95% of them under five milliseconds. The error-rate query returns nothing yet, because v1 has produced no 5xx responses and Prometheus does not invent zero series. Run v2 (chapter 17's canary, or kubectl set image deploy/pantry web=pantry:v2) and it appears.

  1. Open the wall of screens and import Pantry's dashboard:
   kubectl port-forward -n monitoring svc/monitoring-grafana 3000:80 &
   

Browse to http://localhost:3000, log in as admin / pantry, choose Dashboards → New → Import, and upload monitoring/grafana-dashboard.json. Three panels: request rate, error rate, p95, each with one line per version. Leave it open. The next chapter is going to make the error-rate line move.

Gotchas

  • The Prometheus installed by kube-prometheus-stack only picks up ServiceMonitors carrying the label release: <release name>; without it your target never appears and nothing complains.
  • A rate() window shorter than two scrape intervals returns nothing at all: with 15-second scrapes, [10s] is empty and [30s] is the floor; [1m] is the sensible default.
  • Prometheus does not invent zero: a series for status="500" exists only after the first 500, so an error-rate panel is empty, not zero, until something fails.
  • A ServiceMonitor selects a Service port by name; an unnamed port means no scrape.

Then and now

You will see "Prometheus Operator", "kube-prometheus" and "kube-prometheus-stack" used as if they were three products; they are the controller, the manifests built on it, and the Helm chart that installs both. Old Maps tells the story.

Recall

  1. How does Prometheus get its data, and what does that mean when a target dies?
  2. What are the three queries, in words?
  3. What does a ServiceMonitor need to match, on the Service and on itself?
  4. Which tool answers "which Pod is heavy right now" and which answers "how is the service behaving over time"?
  5. In the city metaphor, what are Prometheus and Grafana?
Show answers
  1. It scrapes /metrics on a timer; a dead target stops appearing.
  2. Request rate, error rate, p95 latency, each per version.
  3. The Service's app label and a named port; the ServiceMonitor's release label.
  4. kubectl top for now; Prometheus for over time.
  5. The sensor network and the wall of screens.

What Maya learned

  • Prometheus scrapes /metrics from targets on a timer; a ServiceMonitor tells it which Services to scrape.
  • Request rate, error rate and p95 latency, each sum by (version), answer "is it healthy?" for any HTTP service.
  • Grafana turns those queries into a dashboard; kubectl top remains the tool for "which Pod is heavy right now".

…but

Maya can finally see. What she sees, when she looks at the deployment history, is that the cluster does not match the repository. Someone raised Pantry's replicas by hand on Tuesday. Shop's ConfigMap has a value nobody committed. Three engineers have kubectl applyed from three laptops and the only record is their shell history. The dashboard shows what is happening; nothing shows what is supposed to be happening.

Chapter 19 · Act III · 5 min readNobody Touches the City by Hand#

The problem

The repository says Pantry has three replicas. The cluster has five. The repository says v1; one Deployment in staging says v2. Shop's ConfigMap has a feature flag that exists nowhere in Git. Every one of these was a sensible change made by a sensible person with kubectl apply or kubectl edit from a laptop, and every one of them is invisible to everybody else.

Maya's own release process is a Slack message saying "deploying now". If the cluster died tomorrow, rebuilding it would mean reconstructing what was running from memory.

The dashboard from chapter 18 shows what the city is. What she wants is a way to say what the city should be, in one place, and have something enforce it. She has said those words before, in chapter 6, about Pods. Raj points that out.

The idea

GitOps is the practice of keeping the desired state of a cluster in a Git repository and running a controller that continuously makes the cluster match it. Git becomes the only way to change production: a pull request is the change, its review is the approval, the merge is the deployment, and git log is the audit trail. Rolling back is git revert. Nobody runs kubectl apply against production, because anything applied by hand is drift, and the controller puts it back.

Argo CD is the most widely used of those controllers. You install it and give it an Application: a Git repository, a path, a target revision, and a destination cluster and namespace. It clones the repository, renders what it finds (plain YAML, a Helm chart, or a Kustomize overlay), compares it with the cluster, and reports Synced or OutOfSync. With automated sync it applies the difference itself; selfHeal also reverts drift, and prune deletes what Git no longer has.

ClusterArgo CDGit repositoryMayaClusterArgo CDGit repositoryMayaloop[every 3 minutes (or on webhook)]someone runs kubectl scale --replicas=5commit: image tag v1 → v2fetch examples/pantry/gitopsread live Deploymentdiff desired vs liveapply (auto-sync)drift detected → self-heal back to 3git revert (v2 was bad)apply: back to v1

Think of the city plan in the archive. The plan is the law; inspectors walk the city continuously and make every building match it, and if someone knocks a wall through by hand, they rebuild it. To change a building, you change the plan, which records every change and who made it.

Remember it as: Git is the city plan, Argo CD is the inspector who makes the city match it, and nobody changes a building by hand.

The Application is one resource:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: pantry
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/<you>/<this-repo>.git
    targetRevision: HEAD
    path: examples/pantry/gitops          # plain YAML; could be a chart or a kustomize overlay
  destination:
    server: https://kubernetes.default.svc
    namespace: default
  syncPolicy:
    automated:
      prune: true                          # delete what Git no longer has
      selfHeal: true                       # undo changes made by hand

The alternative to know is Flux: the same job as a set of smaller controllers configured entirely by Kubernetes resources, with no UI. Argo CD's UI draws every Application as a tree, which is a good way to learn what a Deployment owns; Flux is leaner.

Argo CD Flux
Shape One control plane with UI, CLI and API Composable controllers, no UI (community dashboards exist)
Config Application resources (or the UI) GitRepository + Kustomization / HelmRelease
Drift Reports OutOfSync; self-heal optional Reconciles on an interval
Choose when You want to see the app tree and hand it to a wider team You want the smallest footprint and everything as YAML

Here the three previous chapters meet. The v2 canary goes out through Istio at ten percent; Prometheus's error-rate panel for v2 climbs; the fix is git revert of the commit that changed the tag, and within three minutes Argo CD has put v1 back. The dial, the sensor and the plan are one system.

The commands

# Install Argo CD (takes a minute or two); --server-side because one of its CRDs is too big for client-side apply
kubectl create namespace argocd
kubectl apply -n argocd --server-side -f https://raw.githubusercontent.com/argoproj/argo-cd/v3.5.3/manifests/install.yaml
kubectl port-forward -n argocd svc/argocd-server 8443:443
kubectl get secret -n argocd argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d

# Log the CLI in through the tunnel
argocd login localhost:8443 --username admin --insecure

# Register the Application (from the file, or with the CLI) and look at it
kubectl apply -f argocd/application.yaml
argocd app create pantry --repo https://github.com/<you>/<this-repo>.git --path examples/pantry/gitops --dest-server https://kubernetes.default.svc --dest-namespace default --sync-policy automated --self-heal
argocd app list
argocd app get pantry

# The kubectl-native way to see what applying a file would change (what Argo does continuously)
kubectl diff -f gitops/

# Sync now, show what differs, see the history, go back
argocd app sync pantry
argocd app diff pantry
argocd app history pantry
argocd app rollback pantry <id>

# Remove
argocd app delete pantry
kubectl delete -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/v3.5.3/manifests/install.yaml

Try it

This chapter needs a Git repository Argo CD can reach: fork this book's repository on GitHub and put its URL in argocd/application.yaml.

  1. Install Argo CD and log in:
   kubectl create namespace argocd
   kubectl apply -n argocd --server-side -f https://raw.githubusercontent.com/argoproj/argo-cd/v3.5.3/manifests/install.yaml
   kubectl wait -n argocd --for=condition=Available deploy/argocd-server --timeout=300s
   kubectl port-forward -n argocd svc/argocd-server 8443:443 &
   argocd login localhost:8443 --username admin --insecure --password "$(kubectl get secret -n argocd argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d)"
   

Open https://localhost:8443 in a browser for the UI (same credentials).

  1. Hand Argo CD the plan and watch it sync:
   kubectl apply -f argocd/application.yaml
   sleep 30; argocd app get pantry | grep -E 'Sync Status|Health Status'
   
   Sync Status:        Synced to  (81c6092)
   Health Status:      Healthy
   

argocd app get pantry also lists what it owns: both Deployments, both Services and the ConfigMap, each Synced and Healthy.

  1. Knock a wall through by hand and watch the inspector fix it:
   kubectl scale deploy/pantry --replicas=5
   sleep 3; kubectl get deploy pantry
   
   NAME     READY   UP-TO-DATE   AVAILABLE   AGE
   pantry   3/3     3            3           4m
   

With selfHeal on, the extra Pods were gone before the command returned. argocd app history pantry records nothing, because nothing in Git changed. The only way to have five replicas is to commit five replicas.

  1. Ship the canary the proper way, then revert it. Edit examples/pantry/gitops/deployment.yaml, change pantry:v1 to pantry:v2, commit and push, then watch Argo CD notice and act:
   git commit -am "pantry: try v2" && git push
   argocd app get pantry --refresh | grep 'Sync Status'
   sleep 60; argocd app get pantry | grep 'Sync Status'
   kubectl get deploy pantry -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
   curl -s -H 'Host: pantry.localtest.me' localhost/
   
   Sync Status:        OutOfSync from  (551fb85)
   Sync Status:        Synced to  (551fb85)
   pantry:v2
   Welcome to the Pantry (v2). You are visitor #15. Served by pantry-759dbdd485-5gfgg.
   

v2 is live, and a few of those curls will come back as a 500, because v2 is flaky on purpose. The Grafana panel from chapter 18 shows v2's error rate climbing toward 30%. That is the number the customer asked for, and it says no:

   git revert --no-edit HEAD && git push
   sleep 60; argocd app get pantry | grep 'Sync Status'
   curl -s -H 'Host: pantry.localtest.me' localhost/
   argocd app history pantry
   
   Sync Status:        Synced to  (c1c2573)
   Welcome to the Pantry (v1). You are visitor #18. Served by pantry-5784c9bc56-fcqt8.
   ID      DATE                           REVISION
   0       2026-09-19 09:07:09 +0100 BST   (81c6092)
   1       2026-09-19 09:09:21 +0100 BST   (551fb85)
   2       2026-09-19 09:09:51 +0100 BST   (c1c2573)
   

Nobody ran kubectl. The commit is the rollback, the history is the audit trail, and everyone can see both. (Argo CD polls Git every three minutes; argocd app sync pantry skips the wait, and a webhook makes it instant.)

Gotchas

  • Argo CD's install manifest must be applied with --server-side; client-side apply rejects the ApplicationSet CRD as too large and the applicationset controller crash-loops.
  • argocd app delete cascades by default and removes every resource the Application manages; use --cascade=false to forget the Application and keep the workload.
  • With selfHeal on, a kubectl hotfix during an incident is reverted within seconds; the fix has to go through Git, which is the point, and also the thing to remember at 3 a.m.
  • A private repository needs a credential registered in Argo CD before the Application is created, or creation fails with "repository not accessible".

Recall

  1. What does "GitOps" change about how production is modified?
  2. What five things does an Application name?
  3. What do prune and selfHeal each do?
  4. How is a bad release rolled back under GitOps?
  5. In the city metaphor, what are Git and Argo CD?
Show answers
  1. Git becomes the only way; a merge deploys and a revert rolls back.
  2. Repository, path, revision, destination cluster, destination namespace.
  3. prune deletes what Git removed; selfHeal reverts changes made by hand.
  4. git revert and let Argo CD sync.
  5. The city plan in the archive and the inspector who enforces it.

What Maya learned

  • GitOps keeps the cluster's desired state in Git and lets a controller make the cluster match it, so a merge is a deployment and a revert is a rollback.
  • An Argo CD Application names a repo, a path and a destination; automated sync with self-heal reverts anything changed by hand.
  • Istio limits the blast radius, Prometheus measures it, and Git plus Argo CD is how the decision is made and recorded.

…but

There is no but. The city runs itself. Maya's phone has not rung at 3 a.m. in a month, and when Shop's next canary goes wrong, the person on call reads a dashboard, reverts a commit, and goes back to sleep. Everything she needed was one concept at a time, each one because the last one had a crack in it. The Epilogue has the whole map.

Go deeper

Kubernetes manual 10 · Packaging: Helm and Kustomize

Epilogue · 14 min readThe Map#

Everything in the book, on a few pages. Use this as the reference after the story has done its work.

The whole city

Act I, one kitchen:

docker builddocker push / pulldocker rundocker compose up

Dockerfile
(recipe)

Image pantry:v1
(recipe card, layers)

Registry
(cookbook library)

Container
(dish)

Volume
(pantry shelf)

Network + published port
(corridor + serving hatch)

compose.yaml
(dinner menu)

Act II, the city:

Cluster (city) · Namespace (district)labelsselector

user

Ingress
(front gate)

Service
(phone number)

Deployment / HPA
(building manager,
temps in rush hour)

Pod
(apartment)

Pod

ConfigMap / Secret
(sticky notes / locked drawer)

PVC → PV
(storage unit)

Probes + requests/limits
(doorbell + lease)

Act III, running the city:

Argo CD syncs(inspector)helm install/metrics scrapederror rate says no

Git
(city plan)

Cluster

Helm chart
(flat-pack box)

Istio sidecars
(concierge at every door)
mTLS · 90/10 split

Prometheus
(sensor network)

Grafana
(wall of screens)

Diagram legend

Mark Meaning
Solid arrow Traffic, or a command
Dashed arrow Selects, watches, or "is told by"
Box inside a box Runs inside
Yellow (image) Images, layers, registries: read-only things you build and ship
Orange (container) Containers and Compose: running things on one host
Blue (pod) Pods
Grey (node) Nodes and machines
Purple (control) Control plane parts and controllers: Deployments, probes, RBAC, ConfigMaps
Green (storage) Volumes, PVs, PVCs
Red (net) Services, Ingress, networks, ports
Pink (tool) Act III tools: Helm, Istio, Prometheus, Grafana, Argo CD

The metaphor table

Concept Remember it as Chapter
Image A recipe card: read-only, versioned 1
Container A dish cooked from the recipe; disposable, many per recipe 1
Layer / build cache A step in the recipe; unchanged steps are cached 2
Tag A label on the card; latest is just the default label 2
Volume / bind mount The pantry shelf that survives the dish; a bind mount is your own counter-top 3
Network / published port Corridor between kitchens; a published port is a serving hatch to the street 3
Registry The cookbook library you push to and pull from 4
Multi-stage build Build in the workshop, ship from the shop floor 4
Compose The dinner menu: every dish in one file, cooked in order 5
Orchestrator Docker cooks one meal; Kubernetes runs the whole city 6
Cluster / Node The city / a building 7
Control plane City hall: API server = front desk, etcd = records room, scheduler = housing office, controllers = inspectors 7
kubelet The building's caretaker: the only one who runs containers 7
Pod An apartment: one address, usually one tenant 8
Deployment / ReplicaSet The building manager who keeps N apartments occupied 9
Label / selector Name tags on doors, and the manager's list 9
Service A phone number for a group of apartments that never changes 10
ClusterIP / NodePort / LoadBalancer Inside only / a door on every building / the cloud's public number 10
Ingress The city's front gate with a signpost by hostname 10
ConfigMap Sticky notes on the fridge 11
Secret The locked drawer (base64, not encryption) 11
Liveness / readiness / startup probe Are you awake? / taking visitors? / still moving in? 12
Requests / limits The lease: what you are promised, the ceiling you cannot exceed 12
PV / PVC / StorageClass The storage unit / the rental request / the storage company 13
StatefulSet A Deployment for tenants with names and their own units 13
Namespace A district 14
RBAC (Role, RoleBinding, ServiceAccount) Key cards 14
HPA Hiring temps in rush hour 14
Job / CronJob / DaemonSet One-off contractor / scheduled cleaner / one caretaker per building 14
Pending / ImagePull / CrashLoop / OOMKilled Ask the scheduler / the registry / the previous log / the lease 15
Helm chart / values / release Flat-pack box / options sheet / the assembled piece in your flat 16
Kustomize The same blueprint with an overlay per district 16
Service mesh / sidecar A concierge at every door: checks ID, keeps the log, sends one in ten next door 17
Gateway / VirtualService / DestinationRule The mesh's gate / the signpost with weights / the named groups it points at 17
mTLS Every visitor shows ID, both ways 17
Prometheus / Grafana The sensor network that polls on a timer / the wall of screens 18
PromQL's three queries Request rate, error rate, p95 latency, each by (version) 18
GitOps / Argo CD The city plan in the archive is the law; the inspector makes the city match it 19
ENTRYPOINT vs CMD The dish the kitchen always makes, and the default order if nobody asks 2
Init container The helper who arrives before the tenant moves in 8
NetworkPolicy Which corridors connect which districts 14
cordon / drain "No new tenants" / "everyone out, politely, one at a time" Landlord's Week
PodDisruptionBudget The minimum number of tenants who must stay in during works Landlord's Week
ResourceQuota / LimitRange The district's building code: a ceiling for the district, a default lease per tenant Landlord's Week
OCI The standard for recipe cards and ovens that any kitchen can use Old Maps
CRI / containerd The oven the caretaker actually uses Old Maps
dockershim The adapter that let the caretaker use Docker's oven; retired Old Maps
Docker Swarm The other city, the one that lost Old Maps
Tiller Helm 2's butler who lived in city hall and was let go Old Maps
Gateway API The new standard front gate Old Maps

The timeline

Everything Old Maps tells, on one line.

2013Docker ships2014Kubernetes announced2015OCI foundedKubernetes 1.02016Docker Swarm mode2017Compose v3 withdeployHelm 2 and Tiller2018Kubernetes in DockerDesktopCoreDNS becomesdefault2019extensions/v1beta1removed (1.16)Helm 3 drops Tiller2013 to 2019: containers become a standard, one city wins
2020dockershimdeprecated (1.20)Docker Hub rate limits2022dockershim removed(1.24)PodSecurityPolicyremoved (1.25)2023BuildKit default(Docker 23)Gateway API GA2024Istio ambient mode GA2025Endpoints deprecated(1.33)Helm 42020 to 2025: the addresses move, the shape stays

Old words

What you will meet in inherited repositories and older tutorials, and what it became.

You will see It became When Where in the book
docker-compose (with a hyphen) docker compose, the Go plugin 2020 onward; v1 retired 2023 Ch 5, Old Maps
version: "2" / "3" at the top of a Compose file Nothing; the key is obsolete and ignored Compose spec, 2020 Ch 5, Old Maps
--link redis:redis A user-defined network; names resolve on it Docker 1.9, 2015 Ch 3
MAINTAINER, ENV key value LABEL, ENV key=value Docker 1.13, 2017 Ch 2
Legacy docker build output (Step 3/8) BuildKit, docker buildx Default in Docker 23, 2023 Ch 4
docker stack deploy, deploy: in Compose Docker Swarm; Kubernetes won Swarm mode 2016; Compose ignores deploy: Ch 6, Old Maps
"Kubernetes is dropping Docker" dockershim removed; images unchanged, containerd runs them Deprecated 1.20 (2020), removed 1.24 (2022) Ch 7, Old Maps
master node, node-role.kubernetes.io/master control-plane Label removed 1.24, taint 1.25 Ch 7, Old Maps
kubectl get componentstatuses (cs) Nothing; deprecated 1.19, 2020 Old Maps
kind: ReplicationController Deployment (with a ReplicaSet) Deployments GA 1.9, 2017 Ch 9
apiVersion: extensions/v1beta1 for Deployments, DaemonSets apps/v1, with a mandatory selector Removed 1.16, 2019 Ch 9, Old Maps
kubectl run nginx --image=nginx creating a Deployment Creates a Pod; use kubectl create deployment Generators removed 1.18, 2020 Ch 8
kubernetes.io/ingress.class annotation, serviceName/servicePort ingressClassName, backend.service, networking.k8s.io/v1 v1 in 1.19, v1beta1 removed 1.22 (2021) Ch 10, Old Maps
Endpoints EndpointSlices Deprecation warning since 1.33, 2025 Ch 10
Heapster metrics-server Retired 1.13, 2018 Ch 12
kube-dns CoreDNS Default since 1.13, 2018 Ch 10, Old Maps
PodSecurityPolicy Pod Security Admission (a namespace label) Removed 1.25, 2022 Ch 14, Old Maps
autoscaling/v2beta2 HPA, batch/v1beta1 CronJob autoscaling/v2, batch/v1 Removed 1.26 and 1.25 Ch 14
A token Secret for every ServiceAccount Created on request only (TokenRequest) 1.24, 2022 Ch 14
Tiller, helm init, helm install --name Helm 3: no server, helm install <name> Helm 3, 2019; Helm 4, 2025 Ch 16, Old Maps
requirements.yaml in a chart dependencies: in Chart.yaml Helm 3, 2019 Old Maps
The stable Helm repository Per-project repositories; Artifact Hub to find them Archived 2020 Ch 16
Istio Mixer, istio-telemetry, istio-policy Telemetry in the proxy; istiod Deprecated 1.5 (2020), removed 1.8 Ch 17, Old Maps
Sidecar-only Istio Ambient mode, sidecars optional GA in 1.24, 2024 Ch 17
kube-prometheus vs Prometheus Operator vs kube-prometheus-stack One Helm chart installing the operator and the manifests Chart, 2020 onward Ch 18

The cheat card

Fifteen commands for a normal day. Everything else is in the cheat sheet below.

kubectl config current-context                 # which city am I in?
kubectl get pods -o wide                       # who is running, where
kubectl get deploy,svc,ingress                 # the shape of the district
kubectl describe pod <pod>                     # the story; events at the bottom
kubectl logs -f <pod>                          # what it says
kubectl logs <pod> --previous                  # what it said before it crashed
kubectl exec -it <pod> -- sh                   # get inside
kubectl apply -f <file>                        # make the file true
kubectl rollout status deploy/<name>           # is the change finished?
kubectl rollout undo deploy/<name>             # put it back
kubectl port-forward svc/<name> 8000:80        # private tunnel
kubectl get events --sort-by=.lastTimestamp    # what just happened here
kubectl top pod                                # who is heavy right now
docker compose up -d                           # the whole menu, locally
docker build -t <name>:<tag> .                 # cook the recipe

The cheat sheet

Every command in the book, once, grouped by what you are trying to do. The number at the end is the chapter that introduced it.

See what is running

docker ps                                          # running containers                                1
docker ps -a                                       # ...including stopped ones                         1
docker images                                      # local images                                      1
docker compose ps                                  # services in this project, with health            5
kubectl config current-context                     # which cluster am I on?                            7
kubectl config get-contexts                        # all clusters in my kubeconfig                     7
kubectl config use-context kind-pantry             # switch cluster                                    7
kubectl cluster-info                               # is the API server answering?                      7
kubectl get nodes -o wide                          # the buildings                                     7
kubectl get pods                                   # Pods in this namespace                            8
kubectl get pods -o wide                           # ...with IP and node                               8
kubectl get pods -w                                # ...and watch changes                              8
kubectl get pods -A                                # ...in every namespace                             7
kubectl get pods -n pantry-staging                 # ...in one namespace                               14
kubectl get pods -l app=pantry                     # ...matching a label                               9
kubectl get pods --show-labels                     # labels on everything                              9
kubectl get deploy,rs,pods -l app=pantry           # the three layers of a Deployment                  9
kubectl get svc                                    # Services                                          10
kubectl get endpoints pantry                       # the Pod IPs behind a Service                      10
kubectl get ingress                                # front gates                                       10
kubectl get configmap,secret                       # sticky notes and drawers                          11
kubectl get storageclass                           # storage companies                                 13
kubectl get pvc,pv                                 # claims and volumes                                13
kubectl get namespaces                             # districts                                         14
kubectl get hpa -w                                 # autoscalers, live                                 14
kubectl get cronjob                                # scheduled cleaners                                14
kubectl get ds                                     # caretakers                                        14
kubectl top pod                                    # CPU/memory per Pod now (needs metrics-server)     12
kubectl top node                                   # CPU/memory per node now                           12
helm list                                          # installed releases                                16
helm status pantry-helm                            # one release                                       16
helm get values pantry-helm                        # what a release was installed with                 16
istioctl proxy-status                              # which Pods have sidecars, and are they in sync    17
argocd app list                                    # Applications                                      19
argocd app get pantry                              # one Application: sync and health                  19

Build and ship

docker build -t pantry:v1 .                                        # cook the recipe                    2
docker build -t pantry:v2 --build-arg PANTRY_VERSION=v2 .          # ...with a build argument           2
docker build -t pantry:slim -f Dockerfile.multistage .             # ...from a different file           4
docker build -t pantry:builder --target builder -f Dockerfile.multistage .   # ...stopping at a stage   4
docker tag pantry:v1 yourname/pantry:v1                            # name it for a registry             2
docker history pantry:v1                                           # layers and their sizes             2
docker login                                                       # sign in to Docker Hub (or ghcr.io) 4
docker push yourname/pantry:v1                                     # upload                             4
docker pull yourname/pantry:v1                                     # download                           4
kind load docker-image pantry:v1 pantry:v2 --name pantry           # copy images into the local cluster 7
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts   # add a chart repo   16
helm repo update                                                   # refresh chart lists                16
helm search repo prometheus-community/kube-prometheus-stack        # find a chart                       16
helm template pantry-helm ./chart                                  # render a chart without installing  16
kubectl kustomize kustomize/overlays/prod                          # render an overlay                  16

Run and change

docker run -d --name redis -p 6379:6379 redis:7-alpine             # background, named, port published  1
docker run -it --rm python:3.12-slim python -c 'print(1)'          # interactive, thrown away on exit   1
docker run -d --name redis --network pantry-net -v pantry-data:/data redis:7-alpine   # on a network, with a volume   3
docker run -d --restart unless-stopped --name pantry -p 8000:8080 pantry:v1   # restart if it dies      6
docker stop redis                                                  # SIGTERM, then SIGKILL              1
docker rm redis                                                    # throw the dish away                1
docker rmi pantry:latest                                           # discard a recipe card              2
docker volume create pantry-data                                   # a shelf                            3
docker volume ls                                                   # shelves                            3
docker volume rm pantry-data                                       # remove a shelf                     3
docker network create pantry-net                                   # a corridor                         3
docker network ls                                                  # corridors                          3
docker compose up -d                                               # the whole menu, in the background  5
docker compose up -d --build                                       # ...rebuilding images first         5
docker compose build                                               # only build                         5
docker compose down                                                # stop and remove (keeps volumes)    5
docker compose down -v                                             # ...and delete volumes              5
kubectl apply -f k8s/pod.yaml                                      # make the file true (idempotent)    8
kubectl apply -k kustomize/overlays/dev                            # same, from a Kustomize overlay     16
kubectl delete -f k8s/pod.yaml                                     # remove what the file describes     8
kubectl delete pod pantry                                          # remove by name                     8
kubectl run curl --rm -it --image=curlimages/curl:8.11.1 -- sh     # a throwaway Pod                    8
kubectl create deployment pantry --image=pantry:v1 --dry-run=client -o yaml   # a correct skeleton to edit   8
kubectl create namespace pantry-staging                            # a district                         14
kubectl create configmap pantry-config --from-literal=GREETING=Hi  # sticky notes from the CLI          11
kubectl create secret generic pantry-secret --from-literal=API_TOKEN=x   # a drawer from the CLI        11
kubectl scale deploy/pantry --replicas=5                           # more or fewer copies now           9
kubectl set image deploy/pantry web=pantry:v2                      # new version now                    9
kubectl rollout status deploy/pantry                               # watch a rollout finish             9
kubectl rollout history deploy/pantry                              # revisions                          9
kubectl rollout undo deploy/pantry                                 # back one revision                  9
kubectl rollout restart deploy/pantry                              # bounce every Pod gracefully        9
kubectl label pod <name> tier=web                                  # tag it                             9
kubectl patch configmap pantry-config -p '{"data":{"GREETING":"Hi"}}'   # change one field in place     11
kubectl port-forward pod/pantry 8000:8080                          # tunnel to a Pod                    8
kubectl port-forward svc/pantry 8000:80                            # tunnel to a Service                10
helm install pantry-helm ./chart                                   # assemble                           16
helm upgrade --install pantry-helm ./chart --set image.tag=v2      # reassemble (or install if absent)  16
helm rollback pantry-helm 1                                        # put the previous revision back     16
helm uninstall pantry-helm                                         # remove the release                 16
istioctl install --set profile=demo -y                             # install the mesh                   17
kubectl label namespace default istio-injection=enabled            # concierges for every new Pod here  17
kubectl apply -f istio/                                            # gateway, subsets, weights, mTLS    17
kubectl apply -n argocd --server-side -f <argo-cd install.yaml>   # install Argo CD (CRD too big for client-side)   19
kubectl apply -f argocd/application.yaml                           # hand Argo CD the plan              19
argocd login localhost:8443 --username admin --insecure            # CLI sign-in through a tunnel       19
argocd app create pantry --repo <url> --path examples/pantry/gitops --dest-server https://kubernetes.default.svc --dest-namespace default   # same, from the CLI   19
argocd app sync pantry                                             # sync now                           19
argocd app rollback pantry <id>                                    # back to an earlier sync            19
argocd app delete pantry                                           # forget the Application             19
kubectl diff -f gitops/                                            # what would change if I applied this?   19

Get inside and find out why it broke

docker logs -f redis                                               # follow a container's output        1
docker exec -it redis redis-cli ping                               # run a command inside               1
docker compose logs -f web                                         # follow one service's output        5
docker compose exec redis redis-cli GET visits                     # run a command in a service         5
docker inspect pantry                                              # everything about a container       3
docker stats --no-stream                                           # CPU and memory per container now   3
docker cp pantry:/app/app.py ./app-from-container.py               # copy files out (or in)             3
docker compose config                                              # the effective Compose file         5
docker events --since 30s --filter container=pantry                # what happened to it                6
kubectl describe pod pantry                                        # the story; Events at the bottom    8
kubectl describe node pantry-worker                                # a building, and what it has left   7
kubectl describe ingress pantry                                    # which hosts route where            10
kubectl get networkpolicy                                          # which corridors are open           14
kubectl describe quota staging-ceiling -n pantry-staging           # how much of the ceiling is used    Landlord's Week
kubectl get pdb                                                    # budgets, and disruptions allowed   Landlord's Week
kubectl apply --dry-run=server -f old-maps/deployment-v1beta1.yaml # does the cluster accept this file? Old Maps
kubectl api-resources --api-group=extensions                       # which addresses does it serve?     Old Maps
kubectl logs -f pantry                                             # follow a Pod's output              8
kubectl logs <pod> --previous                                      # output of the crashed attempt      15
kubectl exec -it pantry -- sh                                      # a shell inside                     8
kubectl exec deploy/pantry -- sh -c 'echo $GREETING'               # a command in a current Pod         11
kubectl get pod pantry -o yaml                                     # observed state, every field        8
kubectl get events --sort-by=.lastTimestamp                        # the district's timeline            15
kubectl explain pod.spec.containers                                # the manual                         7
kubectl debug -it <pod> --image=busybox:1.36 --target=web          # tools beside a bare Pod            15
kubectl cp redis-0:/data/appendonlydir ./redis-backup              # copy files out (or in)             13
kubectl auth can-i list pods -n pantry-staging --as=system:serviceaccount:pantry-staging:pod-reader   # permission question   14
istioctl analyze                                                   # is my mesh config sane?            17
argocd app diff pantry                                             # what differs from Git              19
argocd app history pantry                                          # what was synced, when              19
curl -s --data-urlencode 'query=<promql>' http://localhost:9090/api/v1/query   # ask Prometheus         18

Maintain the buildings

kubectl cordon pantry-worker                                       # no new tenants                     Landlord's Week
kubectl drain pantry-worker --ignore-daemonsets --delete-emptydir-data   # everyone out, politely       Landlord's Week
kubectl uncordon pantry-worker                                     # reopen                             Landlord's Week
kubectl taint nodes pantry-control-plane node-role.kubernetes.io/control-plane:NoSchedule-   # open city hall to tenants   Landlord's Week
kubectl apply -f k8s/pdb.yaml                                      # the minimum who must stay in       Landlord's Week
kubectl apply -f k8s/quota.yaml -f k8s/limitrange.yaml             # the district's building code       Landlord's Week
helm lint old-maps/chart-helm2                                     # how old is this chart?             Old Maps

Watch it, and clean up

kubectl port-forward -n monitoring svc/monitoring-kube-prometheus-prometheus 9090:9090   # Prometheus UI   18
kubectl port-forward -n monitoring svc/monitoring-grafana 3000:80                        # Grafana UI      18
istioctl dashboard kiali                                           # the mesh as a graph                17
docker system prune                                                # disk back                          4
kind create cluster --config examples/kind-config.yaml             # a new city                         7
kind delete cluster --name pantry                                  # the whole city, gone               7
helm uninstall monitoring -n monitoring                            # remove the monitoring stack        18
kubectl delete -f istio/ && istioctl uninstall --purge -y          # remove the mesh                    17
kubectl delete -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/v3.5.3/manifests/install.yaml   # remove Argo CD   19

Answers

One line per Recall question, by chapter. Attempt the questions first.

It Works On My Machine

  1. An image is a read-only recipe; a container is a running instance cooked from it.
  2. docker images lists images; docker ps lists running containers.
  3. --rm deletes the container when it exits; not when you want its logs or files afterwards.
  4. Yes, the image stays until docker rmi.
  5. A dish cooked from the recipe card.

Write the Recipe Down

  1. So a code change does not invalidate the cached dependency layer.
  2. RUN executes at build time; CMD is what runs when a container starts.
  3. The exec form, and only if process 1 handles the signal; a shell at PID 1 swallows it.
  4. The tag used when none is given; nothing about freshness.
  5. One step of the recipe, cached if unchanged.

Memory and Neighbours

  1. In Docker-managed storage outside any container; it survives docker rm.
  2. Only user-defined networks have DNS for container names.
  3. Laptop port 8000 forwards to container port 8080; the right-hand side is the container.
  4. docker inspect.
  5. A serving hatch to the street.

Ship It

  1. Registry, namespace, repository, tag; registry defaults to Docker Hub, tag to latest.
  2. Only the final stage ships; the builder's 1.6 GB of tools stays behind.
  3. docker push; the name must include your registry namespace.
  4. Nothing in the image needs root, and a compromised process should not have it.
  5. The cookbook library.

The Whole Band

  1. Its hostname on the project network.
  2. Without the condition it waits for the container to start; with it, for the healthcheck to pass.
  3. docker compose down keeps volumes; down -v deletes them.
  4. The directory name.
  5. The dinner menu.

The Night It Fell Over

  1. Self-heal across machines, scale across machines, roll out without downtime.
  2. docker kill counts as you stopping it; /crash was the process dying on its own.
  3. What you want to be true; the orchestrator loops until reality matches it.
  4. No; Kubernetes runs the same images.
  5. Docker cooks one meal; Kubernetes runs the whole city.

Meet the City

  1. API server (front desk), etcd (records), scheduler (housing office), controller manager (inspectors).
  2. The kubelet on each node.
  3. kubectl config current-context.
  4. Self-healing: nothing commands anything; every part notices and acts, forever.
  5. A building, and the records room.

The Smallest Thing That Runs

  1. apiVersion, kind, metadata, spec; the cluster adds status.
  2. apply compares the file with the cluster and changes only differences.
  3. kubectl create deployment … --dry-run=client -o yaml.
  4. That it will not start until the init container has exited successfully.
  5. An apartment.

Someone Who Never Sleeps

  1. A ReplicaSet keeps N Pods alive; a Deployment manages ReplicaSets to roll out changes.
  2. By label selector.
  3. maxSurge: 1, maxUnavailable: 0.
  4. kubectl rollout undo deploy/<name>.
  5. The building manager who never sleeps.

A Number That Never Changes

  1. A stable name and IP for whichever Pods match a selector; the Service object outlives the Pods.
  2. kubectl get endpoints <svc>; empty means the selector matches nothing.
  3. Inside the cluster; any node's IP on a high port; the internet via a cloud load balancer.
  4. An Ingress controller; without one the rules do nothing.
  5. The phone number and the front gate.

Sticky Notes and the Locked Drawer

  1. Handling: Secrets can be encrypted at rest, gated by RBAC separately, and hidden by tooling. The data is just base64.
  2. Mounted files.
  3. kubectl get secret … -o jsonpath='{.data.KEY}' | base64 -d.
  4. Configuration moved out of the image into ConfigMaps and Secrets.
  5. The locked drawer.

Are You Alive?

  1. Readiness failure removes the Pod from the Service; liveness failure restarts the container.
  2. /livez for liveness (process only), /healthz for readiness (needs Redis), so a Redis outage does not restart Pantry.
  3. Requests to place Pods; limits to throttle CPU and kill on memory.
  4. kubectl top pod.
  5. The lease: what is promised, and the ceiling.

Memory That Survives, Again

  1. A PVC requests storage, a StorageClass provisions a PV to satisfy it, the Pod mounts the PVC.
  2. WaitForFirstConsumer binding; it binds when a Pod uses it. Not a fault.
  3. A stable name and its own claim that follows it.
  4. kubectl get pvc,pv.
  5. The rental request for a storage unit.

Districts, Key Cards and Rush Hour

  1. Names, quotas and permissions.
  2. kubectl auth can-i delete pods -n <ns> --as=system:serviceaccount:<ns>:<sa>.
  3. CPU requests on the Pods and metrics-server in the cluster.
  4. A NetworkPolicy.
  5. A one-off contractor, a scheduled cleaner, one caretaker per building.

The Night It Fell Over, Again

  1. describe for Pending, ImagePullBackOff and OOMKilled; logs --previous for CrashLoopBackOff.
  2. At the bottom, oldest first.
  3. The manager replaces anything you patch on a Pod.
  4. kubectl get events --sort-by=.lastTimestamp.
  5. The scheduler, the registry, the previous log, the lease.

Old Maps

  1. The image and runtime formats; the image never depended on Docker, only the adapter did.
  2. A Compose file with a deploy: block for Swarm; a Deployment.
  3. 1.16; the selector.
  4. Helm 2's in-cluster server; Helm 3 removed it.
  5. The adapter that let the caretaker use Docker's oven, now retired.

The Landlord's Week

  1. cordon blocks new Pods; drain cordons and evicts the existing ones.
  2. The replacement had nowhere to go, so the PodDisruptionBudget refused the second eviction.
  3. Nothing moves Pods on its own; a rollout restart replaces them where there is room.
  4. Requests; a Pod without requests counts as zero, so the LimitRange supplies defaults.
  5. "No new tenants", "everyone out politely", and the minimum who must stay in.

Flat-Pack Furniture

  1. The box, the assembled piece in your flat, the options sheet.
  2. helm template.
  3. helm rollback <release> <revision>.
  4. When you own all the YAML and environments differ by a few lines.
  5. helm get values <release>.

A Concierge at Every Door

  1. mTLS, retries and timeouts, telemetry, traffic splitting; none in application code.
  2. Gateway is the door, VirtualService the signpost with weights, DestinationRule the named subsets.
  3. The weight values, in the VirtualService.
  4. It has no sidecar, and STRICT mode refuses plaintext.
  5. The concierge at the door.

The Control Room

  1. It scrapes /metrics on a timer; a dead target stops appearing.
  2. Request rate, error rate, p95 latency, each per version.
  3. The Service's app label and a named port; the ServiceMonitor's release label.
  4. kubectl top for now; Prometheus for over time.
  5. The sensor network and the wall of screens.

Nobody Touches the City by Hand

  1. Git becomes the only way; a merge deploys and a revert rolls back.
  2. Repository, path, revision, destination cluster, destination namespace.
  3. prune deletes what Git removed; selfHeal reverts changes made by hand.
  4. git revert and let Argo CD sync.
  5. The city plan in the archive and the inspector who enforces it.

Where to go next

The book stopped where a laptop stops. The next steps, one pointer each:

  • A real cluster. EKS, GKE and AKS run the control plane for you; the concepts are identical, the Ingress and StorageClass names change. Start with your cloud's "create cluster" quickstart, then kubectl config use-context into it and re-run chapter 8.
  • Operators. A controller you install that understands a specific application (a database, a certificate authority) and manages it from a custom resource. Prometheus Operator in chapter 18 was one. Try cert-manager next: it turns "I want a TLS certificate for this Ingress" into a resource.
  • CI that builds images. The book pushed images by hand. GitHub Actions or GitLab CI should docker build, docker push, and bump the tag in gitops/ on every merge; Argo CD does the rest. Search for "GitOps image updater".
  • Deeper Istio. Ambient mode, retries and timeouts, authorization policies, multi-cluster. The Istio docs' "Tasks" section is task-shaped like this book.
  • Alerting. Prometheus's alertmanager (switched off in chapter 18 to save memory) turns the error-rate query into a page. Turn it on and write one rule: v2 error rate above 5% for 5 minutes.
  • Reading the source of truth. kubectl explain covers every field; the Kubernetes docs' "Concepts" section is the long version of Act II, and now you know the map.

Thank you for reading. Delete the cluster when you are done:

kind delete cluster --name pantry

Part IV · 7 min readThe Manuals#

The story is over. Pantry runs, the city runs itself, and you know what every piece is for. This part is different: it is not a story, it has no characters, and it has no length limit. It is what you reach for when you sit down to write a shell script, a Dockerfile, a Compose file or a Kubernetes manifest from a blank page, or when someone asks you to review one.

There are three manuals. The Shell manual comes first and assumes nothing at all: it starts at the terminal prompt and ends with you writing production bash and reading anyone else's. You can read it before the story, after it, or whenever a script in this book looks like noise. The Docker and Kubernetes manuals assume the story. They assume you know what an image, a volume, a Pod and a Service are, and they do not explain those again. What they add is everything the story's twelve-command budget kept out: why the order of two lines in a Dockerfile costs you four minutes on every build, why USER app gets your Pod rejected while USER 10001 does not, why your rollout drops requests even though every probe is green, and what a reviewer looks for before they approve.

Who they are for

For the Docker and Kubernetes manuals: you have finished the story, or you already use Docker and Kubernetes at work and want to stop copying files you do not fully understand. For the Shell manual: you have opened a terminal, and that is all. Maybe you paste commands from a README without knowing what 2>&1 or "$@" does, or you avoid scripts because they look like line noise. After a manual you should be able to write its golden file from memory, explain every line of it, and find what is wrong in someone else's.

How a manual chapter works

Every chapter has the same shape, so you can jump straight to the part you need.

The signpost. The line under the title. In the Docker and Kubernetes manuals, In the story: gives the chapter's metaphor and links to the story chapter it builds on. The Shell manual has no story behind it, so its line is You've met it:, which names a place this book already used the feature (the exec "$@" at the end of an entrypoint, the jq pipeline in a triage script) and links to it.

The model. Some chapters, mostly the first Shell chapters, start with a picture before any rules: what a process receives and returns, or the exact order in which the shell expands a line. Rules attach to a model. Without one, they are just things to memorise.

The ladder. The chapter is a sequence of rungs, from the first thing everyone needs to the advanced thing only some teams need. Each rung shows a small file or command and then three lines:

  • Rule: the one sentence you could write in a review comment.
  • Why: the mechanism behind the rule. This is what makes it stick, and what lets you work out the cases the rule does not cover.
  • Trap: what goes wrong when you get it wrong, and how you would notice. Every trap was reproduced, and its symptom is written down.

You can stop climbing whenever the rungs stop being relevant to you. The first two or three rungs of every chapter are things everybody needs. The last ones are what make you the expert in the room.

Decide. Some chapters have a table for choices with no single right answer: slim or distroless, env vars or mounted files, Helm or Kustomize.

The golden file. One production-grade file that uses every rung, with a comment on every line. The Docker manual's golden files grow into one hardened Pantry Dockerfile and one Compose project. The Kubernetes manual's grow from a single Pod into a whole namespace, then into a Helm chart. The Shell manual's are scripts, and they grow into one production tool, pantry-ctl.

Review checklist. The rungs restated as checkboxes. Use it on your own files and on pull requests.

Spot the bug. A file that looks reasonable and has several problems in it. Find them before you open the answers. If you find them all, you have learned the chapter. If you miss one, the answer tells you which rung to reread.

The Shell manual's tags and lab

The Shell manual teaches bash 5 with the GNU tools you find on Linux. Your laptop and your containers may run something else. macOS ships bash 3.2 from 2007 with BSD tools, and Alpine images have BusyBox. So everything that behaves differently somewhere carries a tag right next to it:

Tag Means
POSIX Works in any POSIX sh, including dash and BusyBox ash
bash Not POSIX: fails in dash and plain sh
bash 4+ / bash 5+ Needs that bash version. macOS /bin/bash is 3.2
GNU GNU tools (Linux)
BSD BSD tools (macOS)
busybox BusyBox tools (Alpine images)
gawk / mawk Only that awk. Untagged awk is POSIX awk and runs everywhere
[systemd] Needs systemd (for example journalctl); not on macOS, Alpine or a plain container

No tag means it behaves the same everywhere. To try any example exactly as the book ran it, open the shell lab, a container with bash 5, GNU tools, BusyBox, gawk, mawk, jq, shellcheck and bats:

examples/manual/shell-lab/lab.sh            # bash 5 + GNU tools, your directory at /w
examples/manual/shell-lab/lab.sh sh         # BusyBox ash: what an Alpine image gives you

Where the files live

Every file shown with a path above it is in examples/manual/, exactly as printed, and was built or applied on the versions in the front matter. Each chapter's folder has a VERIFY.md with the commands that were run and what came back, including the symptom of every trap. Shell examples were run in the shell lab, and every tagged behaviour also on macOS. Their scripts pass shellcheck. Docker examples use host port 8000 and up. The Kubernetes examples run on kind, a few of them on a three-node cluster (examples/manual/kind-multinode.yaml) so that spreading, draining and storage behave the way they do on a real cluster.

The map

The Shell manual (needs nothing)

  1. The Ground Floor: terminal, shell and kernel; files, paths and help
  2. Every Command Is a Process: arguments, environment, file descriptors and exit status
  3. How the Shell Reads a Line: the expansion order that explains every quoting bug
  4. Variables and Quoting: single, double and $'…' quotes; parameter expansion
  5. Exit Status and Tests: if runs a command; [, [[ and ((
  6. Redirection and Pipes: file descriptors, 2>&1, here-docs, process substitution
  7. Loops, Globs and Lines: for, while read, globs and reading files safely
  8. Functions and Arrays: scope, return values, "$@", indexed and associative arrays
  9. Processes and Signals: subshells, background jobs, trap, exec
  10. Finding Things: grep, find and xargs — searching text and files, safely with odd names
  11. sed: addresses, substitution, in-place editing
  12. awk: fields, patterns, one-liners and reports
  13. The Pipeline Toolkit: sort, uniq, cut, tr and friends, plus jq and curl
  14. Watching the System: processes, memory, disk, ports and logs when something breaks
  15. Writing a Script: strict mode and its holes, getopts, cleanup, logging, shellcheck, tests
  16. Portability: every difference between bash, sh, GNU, BSD and BusyBox, in one place
  17. Reading Any Script: a method for decoding someone else's script, line by line

The Docker manual

  1. Every Instruction: what each Dockerfile line does, and the ones to avoid
  2. ENTRYPOINT, CMD and PID 1: how your process starts, and why it does not stop
  3. Context and Cache: fast builds, small contexts, BuildKit mounts
  4. Multi-Stage Builds: one file for dev, test and prod; choosing a base
  5. Users and Permissions: non-root, numeric UIDs, read-only filesystems, capabilities
  6. Supply Chain and Secrets: pinning, build secrets, multi-platform, SBOMs and scanning
  7. Networks: bridges, DNS, publishing ports safely, internal networks
  8. Volumes: named, bind and tmpfs mounts, ownership, backups
  9. Compose: The File: services, environment, health-gated startup
  10. Compose: The Project: overrides, profiles, includes, secrets, watch, limits

The Kubernetes manual

  1. Reading Any Manifest: anatomy, labels, explain, dry runs and server-side apply
  2. The Pod Spec: command and args, env sources, init containers, native sidecars
  3. Security Context: non-root, read-only, capabilities, seccomp, Pod Security Standards
  4. Lifecycle and Rollouts: probes done right, graceful shutdown, rollout strategy, disruption budgets
  5. Resources and Placement: requests, limits, QoS, affinity, taints, topology spread
  6. Config and Secrets: env versus files, reloads, projected volumes, what Secrets do not protect
  7. Storage: emptyDir, claims and classes, access modes, StatefulSets
  8. Networking: Service types, headless Services, Gateway API, NetworkPolicy
  9. Identity and Access: ServiceAccounts, tokens, least-privilege RBAC
  10. Packaging: Helm and Kustomize: charts worth reusing, overlays, when to pick which
  11. Debugging: the triage order, ephemeral containers, DNS and policy problems

The Bridge maps every Docker concept from the first manual to the Kubernetes field or object that replaces it, and says what changes in meaning on the way.

Shell manual · 1 · 13 min readThe Ground Floor#

You've met it: the very first command in this book, docker run -d --name redis -p 6379:6379 redis:7, typed at a prompt and run by a shell · It Works On My Machine

You have typed commands into a terminal before: this book is full of them. This chapter is about the thing you typed them into. It starts from nothing: what a terminal is, what a shell is, how to move around files, how to read permissions, where help lives, and which keys save you hours. Everything later in this manual assumes it.

To follow along, open a terminal. On macOS that is Terminal (or iTerm, or the one in your editor). On Linux it is whatever your desktop calls Terminal. Then open the shell lab, so that what you see matches the book exactly:

cd examples/manual/shell-lab
./lab.sh

You get a prompt like learner@lab:/w$. That is bash 5, inside a container, with your current directory mounted at /w. exit (or Ctrl-D) takes you back.

The model

Four layers sit between your fingers and the work being done.

keys

a line of text

run this program
with these arguments

text

You

Terminal emulator
draws text, sends keys

Shell: bash, zsh, sh
reads the line, decides what to run

Kernel
starts processes, owns files

Programs: ls, git, docker
files on disk

The terminal (also called a terminal emulator) is only a window. It draws characters, and it sends what you type somewhere. It does not understand commands.

The shell is a program that the terminal starts for you. It prints a prompt, reads one line, works out what you meant, and asks the kernel to run it. When that finishes, it prints the prompt again. Bash, zsh, dash and BusyBox sh are all shells. They agree on the basics and disagree on details. Those details are why this manual has portability tags.

The kernel is the operating system's core. It starts and stops programs (called processes while they run), and it owns the files.

Programs like ls, git and docker are files on disk. The shell finds them, and the kernel runs them. A few commands, such as cd, are not programs at all. They are built into the shell, and chapter 2 explains why.

Keep this picture. When something goes wrong, the first question is always which layer did it. A garbled screen is the terminal. A line that means something different from what you typed is the shell. "Permission denied" is the kernel.

The ladder

Rung 1 Which shell am I in?

The terminal starts whichever shell your account is set to use. On macOS since 10.15 (2019) that is zsh, not bash. On most Linux systems it is bash. Inside a container it is often BusyBox sh or dash. Ask before you trust an example:

$ echo "$0"
bash
$ echo "$BASH_VERSION"
5.2.37(1)-release
$ ps -p $$
    PID TTY          TIME CMD
      8 pts/0    00:00:00 bash

$0 is the name the shell was started as, $$ is its process ID, and $BASH_VERSION is set only in bash. On a Mac, a new Terminal window answers -zsh (the leading - means a login shell). Type bash to start bash inside it, and exit to return. That bash is /bin/bash, version 3.2.57 from 2007. Apple stopped updating it at the last release under the GPLv2 licence, so bash 4 and 5 features are missing from it. This manual tags those features bash 4+ and bash 5+. Everything else here works in it.

Rule Before you run an example, check which shell you are in (echo "$0"), and use bash, or the lab, for this manual.

Why The same line of text can mean different things in different shells. A shell does not tell you it is a different one. It just does something else.

Trap In zsh, an unquoted variable holding a b stays one word, while bash splits it into two (chapter 3). So x="a b"; for w in $x; do echo "[$w]"; done prints [a b] in zsh and [a] then [b] in bash. Arrays start at 1 in zsh and at 0 in bash. If an example here gives a different answer on your Mac, check echo "$0" first.

Rung 2 Paths: where you are, and how to say where something is

Files live in one tree. The top is / (called "root"). Every file has an absolute path from there, such as /Users/you/projects/pantry/app.py. The shell also has a current directory (also called working directory), and a relative path starts from it.

$ pwd                       # print working directory
/w
$ cd /etc                   # absolute: starts with /
$ cd ..                     # .. is the parent directory
$ pwd
/
$ cd ~                      # ~ is your home directory; plain `cd` goes there too
$ cd projects/pantry        # relative: from where you are now
$ cd -                      # back to the previous directory, and print it
/home/learner
$ ls ./app.py ../pantry     # . is the current directory itself

Press Tab while typing a path, and the shell completes it. Press it twice and the shell lists the candidates. This is the most useful key you will learn, because it also catches typos before you press Enter.

Rule Use Tab to type paths. In scripts, build paths from something you know (an absolute path, or the script's own directory), not from wherever the user happens to be standing.

Why A relative path means something different in every directory. The shell resolves it against the current directory at the moment the command runs.

Trap ~ is replaced only when it is unquoted, at the start of a word. cd "~/projects" fails with No such file or directory, because the shell passed the two characters ~/ through unchanged. Write cd ~/projects or cd "$HOME/projects".

Rung 3 Looking: ls, cat, less, head, tail, file

$ ls -la
total 20
drwxr-xr-x 3 learner learner 4096 Oct 24 08:56 .
drwx------ 1 learner learner 4096 Oct 24 08:56 ..
-rw-r--r-- 1 learner learner   17 Oct 24 08:56 .env
-rwxr-xr-x 1 learner learner   35 Oct 24 08:56 deploy.sh
drwxr-xr-x 3 learner learner 4096 Oct 24 08:56 src

-l gives the long format and -a shows names starting with . (hidden files, also called dotfiles). The columns are:

Column Example Meaning
type + permissions -rwxr-xr-x - file, d directory, l symbolic link; then the permission bits (rung 5)
links 1 how many names point at this file; rarely matters
owner, group learner learner who owns it
size 35 bytes (ls -lh prints 4.0K, 1.2M)
date Oct 24 08:56 last modified; files older than six months show the year instead of the time
name deploy.sh

Then, to read files:

cat .env                 # print a whole (small) file
less app.log             # page through a big one: Space, b, /word to search, n for next, q to quit
head -n 20 app.log       # first 20 lines
tail -n 20 app.log       # last 20 lines
tail -f app.log          # keep printing new lines as they arrive; Ctrl-C to stop
file deploy.sh logo.png  # what kind of content is this? "Bourne-Again shell script", "PNG image data"
wc -l app.log            # count lines

Rule Use ls -la to see what is really in a directory, less for anything longer than a screen, and file before you cat something you do not recognise.

Why ls without -a hides configuration files (.env, .git, .bashrc). cat writes raw bytes to the terminal, and a binary file can contain bytes that the terminal treats as commands.

Trap The size column of a directory is the size of its list of names, not of what is inside. A directory holding a 100 KB file shows 4096 in ls -ld on Linux (96 on a Mac), and so does an empty one. Use du -sh dir for the space it really uses (104K). (If cat on a binary leaves your terminal printing garbage, type reset and press Enter, even if you cannot see what you type.)

Rung 4 Changing things: mkdir, touch, cp, mv, ln, rm

mkdir -p projects/pantry/logs   # make a directory, and any missing parents (-p: no error if it exists)
touch notes.txt                 # create an empty file, or update the date of an existing one
cp notes.txt notes.bak          # copy a file
cp -r src backup                # copy a directory and everything in it (-r: recursive)
mv notes.txt docs/              # move into a directory
mv draft.md final.md            # a rename is a move
ln -s /opt/tool/bin/tool ~/bin/tool   # symbolic link: a name that points at another path (target first, like cp)
rm notes.bak                    # delete a file
rm -r backup                    # delete a directory and everything in it
rm -- -weird-name               # -- means "no more options": for names that start with a dash

There is no undo and no trash can at the command line. rm asks the kernel to remove the name, and the space is reused.

Rule Before an rm -r, run ls on exactly the same path first, and look at what it lists.

Why Commands do exactly what the text says, immediately. Because Tab completion and the history (rung 6) make it easy to reuse a line, the ls costs you one keystroke and the mistake costs you the files.

Trap cp -r src dest does two different things depending on whether dest already exists. If it does not, you get a copy called dest. If it does, you get dest/src. Run the same line twice and the second run nests a copy inside the first. The same is true of mv with directories.

Rung 5 Permissions, and why you type ./

-rwxr-xr--  1 learner staff  35 Oct 24 08:56 deploy.sh
 └┬┘└┬┘└┬┘
  │  │  └── others: r--   read only
  │  └───── group:  r-x   read and run
  └──────── owner:  rwx   read, write and run

Each of three groups of people (the owner, the file's group, everyone else) gets three bits: read, write and execute (run). On a directory, r means list the names, w means create and delete names inside it, and x means enter it or pass through it.

chmod +x deploy.sh        # let everyone run it
chmod u+x,go-w deploy.sh  # owner may run; group and others may not write
chmod 755 deploy.sh       # the same bits as numbers: 7 = rwx, 5 = r-x, 4 = r--, 6 = rw-
chmod 600 secrets.env     # owner reads and writes, nobody else anything

To run a program, the shell looks for it in the directories listed in PATH (chapter 2). The current directory is not in that list, on purpose: otherwise a file named ls left in a shared directory would run instead of the real one. So to run a script that is right here, you name its path, ./deploy.sh.

Rule Make scripts executable with chmod +x, run them as ./name (or by their full path), and give secrets mode 600.

Why The kernel refuses to run a file without the x bit, and the shell looks for bare names only in PATH.

Trap The two failures look alike and are not. ./deploy.sh without the x bit gives Permission denied with exit status 126. deploy.sh without ./ gives command not found with status 127. Chapter 2 explains the numbers. Reaching for chmod 777 "to fix permissions" fixes neither, and it also lets everyone on the machine edit your script.

Rung 6 Help, and the keys that save hours

Every command documents itself, in one of three places:

man ls               # the manual page: Space and b to page, /word to search, n for the next hit, q to quit
man 1 printf         # section 1 = commands; man 3 printf is the C library function of the same name
ls --help            # GNU tools print a summary [GNU]; macOS ls rejects --help [BSD]
help cd              # bash's own builtins have no man page of their own: use help
type cd ls ll        # what is this name? a builtin, a program at a path, an alias, a function

And the keyboard. These work in bash and zsh:

Keys Does
Tab, Tab Tab complete a command or path, or list the choices
↑ / ↓ previous / next command from history
Ctrl-R search history backwards: type part of a command, Ctrl-R again for older matches, Enter to run, Ctrl-G to cancel
Ctrl-C interrupt the running program (send it SIGINT, chapter 9)
Ctrl-D end of input. At an empty prompt, it exits the shell
Ctrl-Z suspend the running program. fg brings it back, bg lets it continue in the background
Ctrl-A / Ctrl-E jump to the start / end of the line
Ctrl-W / Ctrl-U delete the previous word / everything before the cursor
Ctrl-L clear the screen (the command clear does the same)
Alt-. (Esc then .) insert the last argument of the previous command

Rule Reach for type, man and help before a search engine, and learn Tab, Ctrl-R and Ctrl-C before anything else on this list.

Why The local documentation describes the exact version installed, and on a Mac that is often not the version an online answer was written for.

Trap Ctrl-S looks like it does nothing, and then the terminal stops responding to everything. It is the old "pause output" signal (XOFF) from serial terminals. Ctrl-Q (XON) resumes. Run stty -ixon in your startup file if you never want it.

Rung 7 Dotfiles: what bash reads when it starts

Bash reads startup files from your home directory, which is how you keep settings between sessions. Which file it reads depends on how it was started:

How bash started Example Reads
login shell a new macOS Terminal window, ssh host, bash -l /etc/profile, then the first of ~/.bash_profile, ~/.bash_login, ~/.profile
interactive, not login a new tab in most Linux terminals, typing bash ~/.bashrc
not interactive running a script, bash -c '…' nothing (only the file named in $BASH_ENV, if set)

The usual arrangement is to put everything in ~/.bashrc, and make ~/.bash_profile a single line that reads it. Then every interactive bash gets the same settings:

# ~/.bash_profile: the whole file
[ -f ~/.bashrc ] && . ~/.bashrc
source ~/.bashrc     # after editing: load the changes into the shell you are in (or open a new terminal)
echo "$-"            # the shell's option letters; an i in there means interactive
shopt login_shell    # on or off

Rule Keep settings in ~/.bashrc, have ~/.bash_profile source it, and write ~/.bashrc so that sourcing it twice changes nothing.

Why Files that read each other, plus source ~/.bashrc after each edit, mean your startup file runs many times in one session.

Trap export PATH="$HOME/bin:$PATH" in ~/.bashrc adds another copy of ~/bin every time the file is read. After a few source ~/.bashrcs, echo "$PATH" is long and repetitive, and a lookup that fails searches every copy. The golden file adds the directory only if it is missing.

Rung 8 Old forms you will meet

Older tutorials, colleagues' dotfiles and inherited servers use some habits that predate the tools above. You should recognise them, and you should not copy them:

# from an inherited ~/.profile, around 2010
alias ls='ls -G'
which python || echo "no python"
\rm -f core
pushd /var/log; tail messages; popd
sudo !!
Old form What it does Why it existed Write instead Still runs?
which cmd prints the path of the program cmd an external program from csh days that predates type. It looks only at PATH type cmd (interactive), command -v cmd (scripts) yes, but it ignores builtins, aliases and functions, and its output differs by system
\rm, command rm runs rm, ignoring any alias called rm a backslash, or command, stops alias expansion, which mattered when systems aliased rm to rm -i do not alias rm in the first place yes
alias ls='ls -G' colour on BSD and macOS ls -G is BSD's flag; on GNU ls, -G means "no group column" ls --color=auto, which GNU and current macOS both accept yes, with different meanings
pushd dir … popd a stack of directories: go there, then come back before cd -, and still useful several levels deep cd dir … cd -, or a subshell ( cd dir && … ) (chapter 9) yes bash
!!, !$, ^old^new, sudo !! history expansion: the last command, its last word, the last command with old replaced, the last command under sudo csh's history syntax, kept by bash for interactive use ↑, Alt-., Ctrl-R interactively only. Scripts have history expansion off
.profile holding bash settings the file every Bourne-family shell reads at login before bash, sh read only .profile .bash_profile sourcing .bashrc yes, but bash skips it when .bash_profile exists

Rule Recognise these when you read them. In your own files and scripts, use type/command -v, --color=auto and cd -.

Why They come from csh, the Bourne shell and BSD, and survive in copied dotfiles and answers written before the modern forms were everywhere.

Trap which gives confident wrong answers. On macOS, which cd prints /usr/bin/cd (a small wrapper script exists so other programs can run "cd"), but your shell's cd is the builtin, as type cd says. On Debian, which cd prints nothing at all and fails. Neither tells you about an alias or function that would really run. And inside a script, type ll says not found even when ll is an alias in your terminal, because scripts do not expand aliases. Use type when you are curious, and command -v in scripts.

The golden file

A minimal ~/.bashrc that is safe to source any number of times, works on Linux and macOS, and does nothing in non-interactive shells. Try it in the lab without touching your own:

examples/manual/b01/bashrc
# ~/.bashrc: read by every interactive bash that is not a login shell.
# Sourced, not run, so there is no shebang and no `exit`.
# macOS Terminal starts login shells, which read ~/.bash_profile instead. Make that file:
#   [ -f ~/.bashrc ] && . ~/.bashrc

# Shells that are not interactive (scp, rsync, `ssh host cmd`) stop here: nothing below is for them.
case $- in
  *i*) ;;
  *) return ;;
esac

# History: large, no repeats, and appended (not overwritten) when several terminals close.
HISTSIZE=10000
HISTFILESIZE=20000
HISTCONTROL=ignoreboth          # skip duplicates, and lines typed with a leading space
shopt -s histappend

# Re-read the window size after each command, so long lines wrap correctly after a resize.
shopt -s checkwinsize

# Add ~/bin to PATH once, however many times this file is sourced.
case ":$PATH:" in
  *":$HOME/bin:"*) ;;
  *) PATH="$HOME/bin:$PATH" ;;
esac
export PATH

# Ask before cp or mv overwrite a file. No rm alias: a safety net you rely on is missing on every other machine.
alias cp='cp -i'
alias mv='mv -i'

# Colour for ls where the ls understands it (GNU, and macOS since it adopted --color); plain ls otherwise.
if ls --color=auto / >/dev/null 2>&1; then
  alias ls='ls --color=auto'
fi
alias ll='ls -lA'

# A redirection with > refuses to overwrite an existing file. Write >| when you mean to.
set -o noclobber

# Prompt: user@host:directory$ (# for root). \w shows $HOME as ~.
PS1='\u@\h:\w\$ '
cd examples/manual/b01
../shell-lab/lab.sh run bash --rcfile /w/bashrc -i   # an interactive bash using only this file; exit to leave

On your Mac, bash --rcfile bashrc -i does the same with /bin/bash. To adopt it, copy it to ~/.bashrc and create the one-line ~/.bash_profile from the comment at the top.

Review checklist

Spot the bug

A colleague shares their ~/.bashrc. They say it "works fine", and they have used it for years.

examples/manual/b01/trap/spot.bashrc
# ~/.bashrc
set -e
export PATH=.:$PATH:$HOME/bin
alias rm='rm -rf'
echo "Welcome back, $USER"
cd ~/projects

This file has 5 problems.

Show answers
  1. set -e makes the shell exit when any command fails. In a startup file, that is your terminal. The first mistyped command or failing grep closes the window. In the lab, false ended the shell with status 1 before the next command ran (rung 7: nothing in a startup file may make the shell exit).
  2. . at the front of PATH means that a program in the current directory beats the real one. In a directory containing a script called ls, typing ls printed not the real ls (rung 5).
  3. PATH is extended every time the file is sourced, with no check that the directories are already there. Each source ~/.bashrc adds more duplicates (rung 7). Use the case ":$PATH:" guard from the golden file.
  4. alias rm='rm -rf' deletes directories without a question and silences the error for a missing file, so a typo in a path is never reported. Everywhere else (a server, a colleague's machine, a script) rm behaves normally, so the habit the alias builds is wrong (rung 4, rung 8).
  5. Nothing stops the file at non-interactive shells. The echo and the cd run for scp, rsync and ssh host cmd as well. The extra output breaks file-transfer protocols, which expect a silent shell (rsync reports protocol version mismatch -- is your shell clean?). The cd silently moves every remote command, and fails wherever ~/projects does not exist. Return early unless $- contains i, as the golden file does.

Shell manual · 2 · 12 min readEvery Command Is a Process#

You've met it: CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"], a program and its argument list written out by hand, with no shell in between · ENTRYPOINT, CMD and PID 1

Almost everything that confuses people about the shell becomes simple once you know what a running program actually receives from it and gives back. That exchange is small and fixed, and it is the same for every program on Linux and macOS, from ls to docker to your own script. This chapter is about that exchange. The next chapter is about how the shell builds it from the line you typed.

The model

When the shell runs a command, the kernel starts a new process: a running copy of a program. That process gets exactly four things, and gives back exactly two.

What comes back

What the process is given

argv: a list of strings
[grep, -i, error, app.log]

environment: NAME=value pairs
PATH, HOME, LANG…

working directory
/home/you/pantry

open files 0, 1, 2
stdin, stdout, stderr

process

exit status
0 to 255, 0 = success

bytes written to fd 1 and fd 2

  • argv (argument vector) is a list of strings. The first is the program's name, and the rest are its arguments. It is a list. The program never sees your line of text, your quotes or your spaces. It sees the list the shell built from them.
  • The environment is a set of NAME=value strings, copied from the parent.
  • The working directory is where relative paths start, also copied from the parent.
  • File descriptors (fds) 0, 1 and 2 are the program's input, normal output and error output. By default all three are your terminal.
  • The exit status is one number from 0 to 255. Zero means success, and anything else means some kind of failure.
  • And whatever the program wrote to fd 1 and fd 2.

Nothing else passes between them. A child process cannot reach back into its parent and change the parent's variables or directory. That one fact explains rungs 5 and 6.

The shell's whole job is to turn your line into an argv list, set up the environment, directory and fds, start the process, and collect its status. Everything in the rest of this manual is a detail of one of those steps.

The probe used throughout this manual makes the list visible:

$ ./args.sh one "two words" '' '$HOME'
argc: 4
argv[1]: <one>
argv[2]: <two words>
argv[3]: <>
argv[4]: <$HOME>
cwd: /w
fd 0: terminal
fd 1: terminal
fd 2: terminal

Four arguments. The quotes are gone, "two words" is one argument, the empty '' is an argument too, and the single quotes kept $HOME from being replaced. args.sh is this chapter's golden file.

The ladder

Rung 1 A command is a program plus a list of arguments

The shell splits your line into words at unquoted spaces and tabs. The first word names the program, and the others become its arguments. Quotes tell the shell where an argument starts and stops, and are then removed.

$ echo Hello,    world
Hello, world
$ echo "Hello,    world"
Hello,    world

In the first line, echo got two arguments, Hello, and world, and printed them with one space between, which is what echo does. The four spaces were never given to it. In the second line it got one argument containing the four spaces.

Rule Think of every command as a list of strings, and use quotes to say exactly where each string begins and ends.

Why Programs receive the list, not the line. The shell's quoting decides the list, and the program cannot recover anything the shell threw away.

Trap Spaces you meant to keep disappear without an error. echo Total: 5 prints Total: 5, and a file name with two spaces in it turns into two arguments that name files that do not exist. Run the line with ./args.sh in place of the command to see the list the program would get.

Rung 2 Where programs come from: PATH

When the first word has no / in it, the shell looks it up in this order:

  1. an alias (interactive shells only)
  2. a function you defined (chapter 8)
  3. a builtin, a command implemented inside the shell itself (cd, echo, export, type)
  4. a program file, searched for in each directory listed in PATH, left to right
$ echo "$PATH"
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
$ type -a echo             # every match, in lookup order
echo is a shell builtin
echo is /usr/bin/echo
echo is /bin/echo
$ command -v jq            # the one that would run, or nothing and status 1 (use this in scripts)
/usr/bin/jq
$ /usr/bin/echo hi         # a word with a / skips the lookup entirely
hi

Rule Use type -a name to see what a name means and what it hides, and command -v name in scripts to check that a program exists before relying on it.

Why The same name can be an alias, a function, a builtin and two different programs all at once. The first match in lookup order is the one that runs, whatever the other matches do.

Trap Bash remembers where it found each program (it hashes the path), so it does not search again. If a new copy of a tool appears earlier in PATH, the shell keeps running the old one: type tool says tool is hashed (/tmp/b/tool). hash -r forgets the remembered paths. So does opening a new shell, or assigning PATH again.

Rung 3 Exit status: the only thing a program returns

$ ls /etc/hosts
/etc/hosts
$ echo $?                  # $? is the status of the last command
0
$ grep zzz /etc/hosts; echo $?
1
$ grep zzz /nope; echo $?
grep: /nope: No such file or directory
2
Status Means
0 success
1 general failure. For grep it means "no match", which is an answer, not an error
2 wrong usage, or a program-specific error (grep uses it for a real error)
126 found, but could not be run (no x bit, or not a program)
127 not found
128 + n killed by signal n: 130 is Ctrl-C (SIGINT, 2), 137 is SIGKILL (9), 143 is SIGTERM (15) (chapter 9)

A status is one byte. exit 256 becomes 0, and exit 300 becomes 44 (300 − 256).

Rule Read $? immediately, or better, test the command itself (if grep -q …, chapter 5). Read the program's manual for what its non-zero statuses mean.

Why Every command sets $?, including echo, [ and assignments with a command substitution. The value you wanted is replaced as soon as anything else runs.

Trap false; echo "message"; echo "status: $?" prints status: 0. That is the status of the echo, not of the command you cared about. Scripts that log a line between a command and its check report success for every failure.

Rung 4 Three streams: stdin, stdout, stderr

Every process starts with three open files: 0 stdin (input), 1 stdout (normal output) and 2 stderr (errors and diagnostics). At a prompt, all three are your terminal, so output and errors look like one stream. They are not.

$ ls /etc/hosts /nope > out.txt
ls: /nope: No such file or directory
$ cat out.txt
/etc/hosts

> out.txt redirected only fd 1 into the file. The error went to fd 2, which is still the terminal. A pipe | does the same: it connects only stdout to the next program's stdin. Chapter 6 is all about moving these around.

Rule Write normal output (data that another program might read) to stdout, and messages for a human (progress, warnings, errors) to stderr: echo "warning: …" >&2.

Why Someone redirecting or piping your output wants the data and nothing else, and they still need to see the errors.

Trap find / -name app.log > results.txt still floods the terminal with Permission denied lines, while the file holds only the matches. People "fix" this by redirecting both into one file and then parse error messages as results. Keep them apart (2>/dev/null to discard errors, chapter 6).

Rung 5 The environment, and why children never change parents

The shell has its own variables. Only exported variables are copied into the environment of the programs it starts.

$ DEBUG=1
$ bash -c 'echo "child sees DEBUG=[$DEBUG]"'
child sees DEBUG=[]
$ export DEBUG
$ bash -c 'echo "child sees DEBUG=[$DEBUG]"'
child sees DEBUG=[1]
$ X=7 bash -c 'echo "X=[$X]"'        # NAME=value before a command: exported to that command only
X=[7]
$ echo "after: X=[$X]"
after: X=[]

env lists the whole environment, and printenv NAME prints one variable.

Each child gets a copy. Whatever the child does with its copy (changes it, exports more, unsets things) disappears when it exits.

Rule export a variable when programs you start must see it. Use the NAME=value command prefix to set something for one command only.

Why A plain assignment creates a shell variable, which lives only in this shell. Export marks it to be copied into the environment of every child started afterwards.

Trap You set DEBUG=1, run your script, and it behaves as if you had not, because the variable was never exported. The reverse also bites: a script that runs export PATH=… does not change your terminal's PATH, because the script was a child.

Rung 6 The working directory, cd, and running vs sourcing a script

cd changes the current directory of the process that runs it. If cd were a program, it would change its own directory and exit, and your shell would not move. That is why cd is a builtin, and why a script cannot move you either:

$ cat m.sh
cd /
export GREETING=hi
$ cd /tmp
$ ./m.sh;  echo "ran: $PWD [$GREETING]"
ran: /tmp []
$ . ./m.sh; echo "sourced: $PWD [$GREETING]"
sourced: / [hi]

./m.sh started a new bash process to run the file. . ./m.sh (or source ./m.sh bash) read the file's lines into the current shell, as if you had typed them.

Rule Run scripts as programs (./name). Source a file (. file) only when its purpose is to change the current shell: startup files, a venv/bin/activate, a file of settings.

Why Running gives the script its own copy of everything, so it cannot damage your shell. Sourcing gives it your shell itself, including the power to change directory, set variables, or exit your terminal.

Trap A "setup" script that ends with cd ~/project and export ENV=dev reports success and changes nothing for you. The opposite trap is sourcing a script that calls exit: your terminal closes.

Rung 7 The first line, and the execute bit

#!/usr/bin/env bash
echo "hello from $BASH_VERSION"

./hello.sh asks the kernel to run the file. The kernel sees that it starts with #! (called a shebang), runs the program named on that line, and passes it the script's path. So the line chooses the interpreter, whatever shell you typed the command in. #!/usr/bin/env bash runs the first bash in PATH. #!/bin/bash runs exactly /bin/bash (bash 3.2 on a Mac, even when you have installed a newer one).

How you run it Who interprets it Needs x bit Changes your shell
./x.sh the shebang's interpreter yes no
bash x.sh bash, whatever the shebang says no no
sh x.sh sh: dash on Debian/Ubuntu, BusyBox in Alpine, bash in POSIX mode on macOS no no
. x.sh, source x.sh the shell you are in no yes

Rule Start every bash script with #!/usr/bin/env bash, make it executable, run it as ./name, and never run a bash script with sh.

Why The shebang is the only thing that pins the interpreter. sh x.sh silently swaps bash for whatever sh is on that machine. On Debian, [[ then fails with [[: not found, and the if around it simply takes the else branch, with status 0.

Trap A script saved with Windows line endings (CRLF) fails with env: 'bash\r': No such file or directory and status 127, even though the file is right there and executable. The kernel read bash followed by a carriage return as the interpreter's name. Convert with sed -i 's/\r$//' x.sh GNU (on macOS: sed -i '' 's/\r$//' x.sh BSD), and set your editor to LF.

Rung 8 Old forms you will meet

#!/bin/sh
# deploy.sh, 2009: "works on our servers"
PATH=/usr/local/bin:/usr/bin:/bin; export PATH
. ./config.sh
hash -r
if [[ -n "$TAG" ]]; then
  ./release "$TAG"
fi
exit $((errors + 0))
Old form What it does Why it existed Write instead Still runs?
#!/bin/sh on a script that uses bash features asks for "sh" on many Linux systems /bin/sh was bash, until Ubuntu 6.10 (2006) and Debian 6.0 (2011) switched it to dash for speed #!/usr/bin/env bash macOS yes (its sh is bash), Debian/Ubuntu/Alpine no: [[: not found
VAR=x; export VAR assign, then export the original Bourne shell could not export and assign in one command export VAR=x yes
. ./file source a file . is the POSIX spelling; source came from csh . in portable code, either in bash yes; source is bash
hash -r forget remembered program paths needed after installing tools mid-script rarely needed: assigning PATH already clears the table yes
#!/usr/bin/env bash -e tries to pass -e to bash works on macOS, which splits shebang arguments set -e on the next line, or #!/usr/bin/env -S bash -e (-S added in GNU coreutils 8.30, 2018) macOS yes BSD; Linux no: env: 'bash -e': No such file or directory
exit $errors with a count uses the status as a counter quick way to report "how many failed" exit 1 if any failed, and print the count yes, but 256 errors exit 0

Rule In old scripts, read #!/bin/sh as a question to check ("does this use bash features?"), not as a fact.

Why For years /bin/sh was bash on most Linux machines, so #!/bin/sh scripts full of bash features worked everywhere their authors tried them.

Trap The same script works on your Mac and fails in the Alpine or Debian container, or on the CI runner. The failure is often silent: if [[ … ]] prints [[: not found and takes the else branch, and the script carries on. Shellcheck flags bash features under #!/bin/sh (SC3010 and friends). Run it on anything you inherit.

Decide

You want Run it as
a tool that does a job and exits ./tool.sh (shebang, chmod +x)
to run a script that is not executable, or to force bash bash tool.sh
to change your current shell: settings, cd, activate an environment . ./settings.sh
to check that a script is POSIX sh dash tool.sh or lab.sh sh tool.sh, not sh on a Mac

The golden file

The probe used throughout this manual. Put it on your PATH (or call it by path), and run it in place of any command whose arguments puzzle you.

examples/manual/b02/args.sh
#!/usr/bin/env bash
# args.sh: show exactly what a program receives when the shell runs it.
#
#   ./args.sh one "two words" '$HOME' *.txt
#
# Prints the argument count, each argument between < and >, the working directory, and
# whether each standard stream is a terminal. Exits with $ARGS_EXIT (default 0), so it can
# also stand in for a command that fails. Later chapters use it as a probe.
set -euo pipefail

printf 'argc: %d\n' "$#"                    # how many arguments, not counting the program's own name
n=0
for arg in "$@"; do                         # "$@" is every argument, each kept whole
  n=$((n + 1))
  printf 'argv[%d]: <%s>\n' "$n" "$arg"     # < and > make spaces and empty arguments visible
done
printf 'cwd: %s\n' "$PWD"                   # the directory it was started in

for fd in 0 1 2; do                         # 0 = stdin, 1 = stdout, 2 = stderr
  if [ -t "$fd" ]; then where=terminal; else where='file or pipe'; fi
  printf 'fd %d: %s\n' "$fd" "$where" >&2   # on stderr, so redirecting stdout does not hide it
done

exit "${ARGS_EXIT:-0}"
cd examples/manual/b02
./args.sh one "two words" '' '$HOME' *.sh      # what would the program get?
./args.sh x > out.txt                           # fd 1 is now a file; the fd report stays on screen
ARGS_EXIT=3 ./args.sh; echo $?                  # 3

Review checklist

Spot the bug

A colleague wrote this to "get a terminal ready for Pantry". They run it as ./setup-dev.sh, it prints ready, and then nothing works.

examples/manual/b02/trap/setup-dev.sh
#!/bin/sh
# setup-dev.sh: get a terminal ready for working on Pantry.
# Usage: ./setup-dev.sh
cd ~/projects/pantry
PANTRY_ENV=dev
if [[ -f .env ]]; then
  echo "using .env"
fi
python3 -m venv .venv
echo "creating virtualenv"
if [ $? -ne 0 ]; then
  echo "virtualenv failed" >&2
  exit 1
fi
echo "ready: now in $PWD with PANTRY_ENV=$PANTRY_ENV"

This file has 5 problems.

Show answers
  1. It is run as a program, so its cd and its variables die with it. After ready: now in /home/learner/projects/pantry, the terminal was still in /tmp with PANTRY_ENV empty (rung 6). Something meant to change your shell has to be sourced (. ./setup-dev.sh), or it should just print what to type.
  2. PANTRY_ENV=dev is not exported, so even when sourced, the programs started afterwards (the Pantry server, tests) do not see it (rung 5). Use export PANTRY_ENV=dev.
  3. #!/bin/sh with [[ … ]]. On Debian, sh is dash, which printed [[: not found. The if took its else branch, and using .env never appeared. On a Mac it works, so the author never saw this (rungs 7 and 8).
  4. The if [ $? -ne 0 ] tests the status of echo "creating virtualenv", not of python3. With python3 failing on purpose, the script still printed ready and exited 0 (rung 3). Test the command itself: if ! python3 -m venv .venv; then ….
  5. cd ~/projects/pantry is not checked. When the directory is missing, the script continues wherever it was started and creates .venv there (it printed ready: now in /tmp). Use cd ~/projects/pantry || exit 1 (chapter 5).

Shell manual · 3 · 11 min readHow the Shell Reads a Line#

You've met it: for pod in $(kubectl -n "$NS" get pods -o name); do in the triage script, which works only because the shell splits that command's output into words · Debugging

Chapter 2 showed what a program receives: a list of strings. This chapter shows how bash turns the line you typed into that list. It always takes the same steps in the same order. Once you know them, you can predict what any line does before you run it, and almost every "the shell did something weird" moment turns out to be one of these steps doing exactly what it always does.

The model

the line you typed
cp $src/*.txt ~/"my docs"

1 · split into words and operators
quotes are remembered, not yet removed

2 · brace expansion {a,b} {1..3}

3 · tilde ~ → /home/you

4 · parameter $x · arithmetic $((…)) · command $(…)
left to right

5 · word splitting on IFS
only results of step 4, only where unquoted

6 · pathname expansion (globbing) * ? […]
only where unquoted

7 · quote removal

8 · redirections set up > < 2>&1

9 · run: argv = the words that are left

Three things about this pipeline explain most surprises:

  • The order is fixed. Brace expansion happens before variables are expanded, so {1..$n} cannot work. Tilde expansion happens before variables too, so a ~ that comes out of a variable stays a ~.
  • Steps 5 and 6 are the dangerous ones (red). They act only on text that came out of an expansion in step 4 (or, for globs, any unquoted pattern), and only where that text was not inside double quotes. That is why "quote your variables" is the most repeated advice in shell scripting: double quotes switch off steps 5 and 6.
  • Results are never re-read as shell syntax. When $x expands to "a b" or {1,2} or ~ or $HOME, those characters are just characters. The quotes in the value do not group, the braces do not expand, the $ does not expand. Only splitting and globbing touch the result.

Here is the line in the diagram, with src='my docs', traced by putting args.sh (chapter 2) in front of it. Here and from now on, its cwd and fd lines are left out:

$ src='my docs'
$ args.sh cp $src/*.txt "$dest"
argc: 4
argv[1]: <cp>
argv[2]: <my>
argv[3]: <docs/*.txt>
argv[4]: </tmp>

Step 4 produced my docs/*.txt. Step 5 split it at the space into my and docs/*.txt, because $src was unquoted. Step 6 tried docs/*.txt as a pattern. There is no docs directory here, so it matched nothing and was left as it was. cp would be asked to copy a file called my and a file called docs/*.txt. "$src"/*.txt gives one word, and the glob then runs on my docs/*.txt.

The ladder

Rung 1 Words, operators, comments and line breaks

The shell first cuts the line into words at unquoted spaces, tabs and newlines, and picks out operators, the characters with their own meaning: | & ; ( ) < >, and their combinations (&&, ||, >>, 2>&1). A # at the start of a word begins a comment that runs to the end of the line.

echo a#b                    # prints a#b: the # is inside a word
echo a #b                   # prints a: the # starts a word, so it is a comment
cd /tmp; ls                 # ; ends a command, like a newline does
docker run --rm \
  -e MODE=dev \
  pantry:dev                # a backslash right before the newline joins the lines

Rule End a continued line with a backslash as its very last character, and break long commands one option per line.

Why \ escapes the next character. Before a newline, the escaped newline disappears and the lines join. Before anything else, it escapes that character instead.

Trap A space after the backslash (invisible in most editors) escapes the space, not the newline. The command ends there, and the next line runs as a separate command: -e: command not found, or in a script, a step that silently loses half its options. cat -A file GNU (cat -e on macOS BSD) shows \ $ where the line should end in \$.

Rung 2 Quotes decide what is one word

Quoting Inside it Example Becomes
none everything is special echo $HOME/*.txt expanded, split, globbed
'single' nothing is special, not even \ '$HOME and *' exactly that text
"double" only $, ` and \ are special "$HOME and *" /home/you and *, one word
\x the next character only \$HOME, my\ file $HOME, my file
echo 'Total: $5 * 2'                # Total: $5 * 2
echo "Saved to $HOME/out"           # Saved to /home/learner/out
echo "Price: \$5, \"quoted\""      # Price: $5, "quoted"
touch my\ file.txt                  # one file, named "my file.txt"

Pieces next to each other with no space form one word, whatever their quoting: "$dir"'/it'"'"'s here' is the single word /tmp/it's here.

Rule Use single quotes for fixed text, double quotes around anything containing $, and nothing unquoted unless you want splitting and globbing.

Why Quotes are instructions to the shell about word boundaries and expansion. Step 7 removes them, so the program never sees them.

Trap A single quote cannot appear inside single quotes, not even escaped. echo 'it's here' leaves a quote open, so the shell keeps reading. At a prompt you get a > and it waits for more. In a script you get unexpected EOF while looking for matching ''', reported at the end of the file, far from the real mistake. Write "it's here", or 'it'\''s here'.

Rung 3 Expansion results are data, not syntax

Steps 2 to 4 run in a fixed order, and what they produce is never parsed again for quotes, braces, tildes or $:

$ x='*.sh'; args.sh $x "$x"
argv[1]: <sizes.sh>
argv[2]: <*.sh>
$ b='{1,2}'; args.sh $b
argv[1]: <{1,2}>
$ t='~'; args.sh $t ~
argv[1]: <~>
argv[2]: </home/learner>

The unquoted $x was globbed (step 6 runs on results). The braces and the tilde in variables were left alone (steps 2 and 3 had already happened).

Rule Never store shell syntax (quotes, braces, ~, $) in a variable and expect it to act when the variable is expanded. To keep a list of arguments, use an array (chapter 8).

Why Expansion results only go through splitting and globbing. Re-reading them as code would mean any data containing a quote or $ could change what your script does.

Trap opts='--name "my app"'; docker run $opts pantry passes three arguments, --name, "my and app", with the quote characters inside them. Docker rejects the name, or worse, accepts a container literally called "my. Use opts=(--name "my app") and "${opts[@]}" (chapter 8).

Rung 4 Word splitting: why every expansion gets double quotes

After step 4, every unquoted expansion result is split into words wherever it contains a character from IFS (the Internal Field Separator, by default space, tab and newline). Runs of whitespace count as one separator, leading and trailing whitespace disappears, and a result that is empty disappears completely.

$ y='  a   b  '
$ args.sh $y
argc: 2
argv[1]: <a>
argv[2]: <b>
$ args.sh "$y"
argc: 1
argv[1]: <  a   b  >

Rule Put double quotes around every expansion ("$var", "$(cmd)", "${arr[@]}"). Leave one unquoted only when you want it split, and then say so in a comment.

Why Splitting and globbing apply only to unquoted results, so quoting is how you tell the shell that this value is one piece of data. A file name, a message or a path can contain spaces, tabs, * or nothing at all.

Trap An empty variable does not become an empty argument. It becomes no argument. With x='', [ $x = yes ] is run as [ = yes ] and fails with [: =: unary operator expected (status 2). rm -rf $dir/* with dir empty becomes rm -rf /*. "$x" keeps an empty value as one empty argument.

Rung 5 Globbing: patterns the shell matches against file names

Pattern Matches
* any run of characters, including none, but not a leading . and never /
? exactly one character
[abc], [a-z], [!0-9] one character from the set, a range, or not in the set ([^…] also works in bash)

The shell expands the pattern into the sorted list of matching names before the command runs. ls *.txt does not ask ls to search; ls just receives the names. Names starting with . match only a pattern that starts with ..

for f in *.log; do                  # each matching name, as one word, spaces and all
  gzip -- "$f"
done
ls -d .[!.]*                        # dotfiles: the pattern has to start with a dot
cp -- "$dir"/*.txt /tmp             # quote the variable part, leave the * outside the quotes

Globs are not regular expressions. In a glob, . is a dot, * stands alone, and [1-10] is a set of the characters 1 and 0, not the numbers one to ten.

Rule Loop and act on globs directly (for f in *.log), never on the output of ls. In bash scripts, turn on shopt -s nullglob bash so that a pattern with no match produces nothing.

Why The glob gives you every name as exactly one word, whatever it contains. ls output is text meant for people, and the shell splits it again at every space.

Trap By default, a pattern that matches nothing is passed on unchanged. rm *.tmp in a directory with no .tmp files runs rm on a file literally named *.tmp (cannot remove '*.tmp'). for f in *.log runs once, with f set to *.log. nullglob makes it run zero times. failglob bash makes it an error that aborts the command. zsh reports no matches found and refuses to run the command at all. That is one more reason to test bash scripts in bash.

Rung 6 Brace expansion is not globbing

$ echo {a,b,c}.txt          # always generates the words, whether or not files exist
a.txt b.txt c.txt
$ echo {1..5}
1 2 3 4 5
$ mkdir -p app/{src,test,docs}
$ cp config.yaml{,.bak}     # config.yaml config.yaml.bak: a quick backup

Brace expansion bash is step 2, a text generator. It does not look at the disk. POSIX sh (dash, BusyBox) does not have it at all and prints {a,b,c}.txt unchanged. Zero-padded ranges like {01..10} need bash 4 bash 4+. bash 3.2 prints 1 2 3 … without the padding.

Rule Use braces to type less (mkdir -p a/{b,c}, cp f{,.bak}), and use seq or a C-style loop (chapter 7) when the range comes from a variable.

Why Braces expand at step 2, before variables exist in the line (step 4).

Trap n=3; for i in {1..$n} loops once, with i set to the literal {1..3}. There is no error. Use for ((i = 1; i <= n; i++)) bash or for i in $(seq 1 "$n").

Rung 7 Seeing what the shell did

$ set -x                         # trace: print each command after expansion, before running it
$ cp "$src"/*.txt /tmp
+ cp 'my docs/a.txt' 'my docs/b.txt' /tmp
$ set +x                         # trace off
$ printf '%q\n' "$f"             # show a value in a form you could paste back into the shell [bash]
my\ file.txt
$ declare -p f                   # show a variable's exact value and type [bash]
declare -- f="my file.txt"
$ PS4='+ ${BASH_SOURCE}:${LINENO}: '   # make the trace show file and line

bash -x script.sh traces a whole script without editing it. In the trace, bash adds quotes so that you can see where each word starts and ends. Those quotes are for display only.

Rule When a line does not do what you expect, trace it (set -x or bash -x) or put args.sh in front of it, before changing anything.

Why The trace shows the argv after every step of the pipeline, which is exactly what the program received.

Trap The trace prints everything, expanded, to stderr, and that includes secrets. set -x followed by curl -H "Authorization: Bearer $TOKEN" writes + curl -H 'Authorization: Bearer s3cr3t' into your CI log for anyone to read. Turn tracing off (set +x) around lines that handle secrets, or do not trace in CI.

Rung 8 Old forms you will meet

#!/bin/sh
# 1990s-style argument handling
OLDIFS=$IFS
IFS=,
set -- $LIST
IFS=$OLDIFS
set -f; pattern=$1; set +f
name=$(echo $2)                       # "trim" the whitespace
eval "value=\$$3"                     # the variable whose name is in $3
exec "$TOOL" ${1+"$@"}
Old form What it does Why it existed Write instead Still runs?
OLDIFS=$IFS; IFS=,; …; IFS=$OLDIFS split on commas, then restore no arrays and no read -a in the Bourne shell IFS=, read -r a b c <<< "$line" bash, or set IFS only in a function with local IFS yes, but if IFS was unset, "restoring" it sets it to empty, which turns word splitting off for the rest of the script
set -f … set +f turn globbing off and on to split a value without * in it expanding quote the expansion yes
x=$(echo $y) squeeze runs of whitespace, trim the ends splitting plus echo as a cheap trim parameter expansion (chapter 4) yes, but it also globs (* becomes file names) and mangles -n or -e
eval "v=\$$name" read the variable whose name is in $name no indirect expansion in the Bourne shell ${!name} bash yes, and it runs any code inside $name (see Trap)
${1+"$@"} "all arguments" the V7 Bourne shell turned an empty "$@" into one empty argument "$@" yes, harmless. macOS ships it in /usr/bin/cd today

Rule When you meet eval, an IFS save and restore, or ${1+"$@"}, read it as a workaround for something old, and replace it with the modern form when you own the script.

Why Each existed because the Bourne shell lacked arrays, indirect expansion, read -a or a correct "$@". Autoconf's manual still documents ${1+"$@"} for shells from the 1980s.

Trap eval runs its argument as shell code, data included. With name='HOME; touch /tmp/pwned', eval "val=\$$name" reads $HOME and creates /tmp/pwned. Replace the touch with anything at all, and a variable name from a file, a URL or an argument becomes a way to run commands on your machine. Never eval data you did not write.

The golden file

Sizes of every file matching some patterns, correct for spaces in names, leading dashes, directories and patterns that match nothing. It expands the globs itself, with splitting turned off:

examples/manual/b03/sizes.sh
#!/usr/bin/env bash
# sizes.sh: print the size in bytes of every file matching the given patterns, then a total.
#
#   ./sizes.sh '*.log' 'my docs/*.txt'
#
# Quote the patterns, so that this script expands them instead of your shell. A pattern that
# matches nothing is then reported, instead of being passed on as a literal file name.
# Exits 1 if any pattern matched no file.
set -euo pipefail
shopt -s nullglob                 # a pattern with no match expands to nothing, not to itself

if [ "$#" -eq 0 ]; then
  echo "usage: sizes.sh PATTERN..." >&2
  exit 2
fi

IFS=                              # no word splitting from here on: only globbing applies below
total=0
status=0
for pattern in "$@"; do           # "$@": each pattern whole, spaces and all
  found=0
  # shellcheck disable=SC2086     # $pattern is unquoted on purpose: that is what expands the glob
  for file in $pattern; do        # each match is one word, even with spaces in its name
    [ -f "$file" ] || continue    # skip directories and other non-files
    size=$(wc -c < "$file")       # read through < so the name never reaches wc as an option
    size=$((size))                # arithmetic drops the padding that BSD wc adds
    printf '%10d  %s\n' "$size" "$file"
    total=$((total + size))
    found=1
  done
  if [ "$found" -eq 0 ]; then
    echo "sizes.sh: no file matches $pattern" >&2
    status=1
  fi
done
printf '%10d  total\n' "$total"
exit "$status"
cd examples/manual/b03
./sizes.sh '*.sh' '../b02/*.sh' '*.nope'; echo "status $?"   # the last pattern is reported, status 1

Review checklist

Spot the bug

It is run as ./archive-logs.sh "my logs" 2 by a nightly job. The job is always green.

examples/manual/b03/trap/archive-logs.sh
#!/usr/bin/env bash
# archive-logs.sh DIR [KEEP]: move rotated logs (app.log.1, app.log.2, ...) from DIR into DIR/old,
# leaving the newest KEEP (default 2) where they are.
dir=$1
keep=${2:-2}
mkdir -p $dir/old
for n in {1..$keep}; do
  echo "keeping *.log.$n"
done
for f in $(ls $dir/*.log.*); do
  case $f in *.log.[1-$keep]) continue ;; esac
  mv $f $dir/old/
done
echo "archived to $dir/old"

This file has 5 problems.

Show answers
  1. $dir is unquoted everywhere. With my logs, mkdir -p $dir/old created ./my and ./logs/old, the ls and mv worked on the wrong paths, and the real logs were never touched. The job still printed archived to my logs/old (rung 4).
  2. {1..$keep} does not expand, because braces come before variables. The script printed keeping *.log.{1..2} (rung 6). Use for ((n = 1; n <= keep; n++)).
  3. for f in $(ls …) splits the listing at spaces. web server.log.4 became …/web and server.log.4, and mv failed on both (rung 5). Loop over the glob itself: for f in "$dir"/*.log.*.
  4. With nothing to archive, the glob was passed through unchanged, and ls printed cannot access '/tmp/empty/*.log.*'. Turn on nullglob, or skip names that do not exist (rung 5).
  5. [1-$keep] is a glob character set, not a number range. With keep=10 it became [1-10], the characters 1 and 0, so app.log.5 was archived even though the newest ten should stay (rung 5). Compare numbers with arithmetic (chapter 5) after extracting the suffix with ${f##*.} (chapter 4).

Shell manual · 4 · 10 min readVariables and Quoting#

You've met it: : "${REDIS_HOST:?REDIS_HOST must be set}" at the top of Pantry's entrypoint, one line that checks a setting and stops with a clear message · ENTRYPOINT, CMD and PID 1

Shell variables hold strings, and almost everything you do with them happens at step 4 of chapter 3's pipeline: parameter expansion. Bash has a small language inside ${…} for defaults, required values, trimming paths, replacing text and changing case, all without starting another program. This chapter covers that language, command substitution and arithmetic, and the quoting that keeps the results intact.

The ladder

Rung 1 Assigning and reading

name=pantry                 # no spaces around =
greeting="hello, $name"     # quotes needed only because of the comma and space
count=3
echo "$name"                # read: $name
echo "${name}_backup"       # braces mark where the name ends
unset count                 # remove the variable
readonly VERSION=1.4.2      # cannot be changed or unset afterwards

Names are letters, digits and underscores, and cannot start with a digit. They are case-sensitive. By convention, environment variables and constants are UPPER_CASE and a script's own variables are lower_case, so they cannot accidentally clash with PATH, HOME or USER.

Rule Write name=value with no spaces, and use ${name} whenever the next character could be part of a name.

Why x = 1 is not an assignment. It runs a command called x with the arguments = and 1 (x: command not found, status 127). And the shell reads a name greedily: everything that can be part of a name, is.

Trap echo "$file_backup" reads a variable called file_backup, which does not exist, so it prints an empty line without an error. You meant "${file}_backup". (Chapter 14's set -u turns this into an error.) Also, assigning to a readonly variable in a script does not just fail: it ends the whole script.

Rung 2 Which quotes, and why printf beats echo

Write When
'single' fixed text, especially containing $, *, \ or !
"double" text with variables or command substitutions inside
$'ansi-c' bash text containing escape sequences: $'line1\nline2', $'a\tb', $'it\'s'

To print, use printf. Its first argument is a format, in which %s is replaced by the next argument as a string, %d by an integer, and \n is a newline:

printf '%s\n' "$message"                 # exactly the message, then a newline
printf '%-10s %5d\n' "$name" "$count"    # left-aligned in 10 columns, right-aligned in 5
printf 'Saved %d files to %s\n' 3 "$dir"

Rule Use echo only for fixed text. Use printf '%s\n' "$var" for anything that contains a variable.

Why printf behaves the same in every shell. Never put a variable in the format itself (printf "$msg"): a % in the data would be read as a format code.

Trap echo is not the same command everywhere. echo 'a\tb' prints a\tb in bash and a tab in dash. echo -e 'y\tz' prints -e y z in dash, -e included. And a variable that happens to be -n or -e is taken as an option and disappears. Output that looks right on your Mac comes out different in a Debian container.

Rung 3 Defaults, and values that must be set

Expansion If x is unset or empty If x has a value
${x:-default} gives default gives $x
${x:=default} assigns default to x, then gives it gives $x
${x:?message} prints message and exits (in a script) gives $x
${x:+other} gives nothing gives other

Without the colon (${x-default} and so on), only unset counts. A variable that is set to the empty string keeps its empty value. A common idiom applies defaults without running anything, using the do-nothing command ::

: "${PORT:=8080}"                         # set PORT to 8080 unless it already has a value
: "${API_TOKEN:?API_TOKEN must be set}"   # stop here with a clear message
curl ${VERBOSE:+-v} "$URL"                # add -v only when VERBOSE is non-empty (unquoted on purpose)

Rule Use the colon forms (:-, :=, :?) unless you really mean "set to empty is different from not set".

Why Empty values come from everywhere: a .env line PORT=, a CI variable that exists but has no value, export X= in a profile. Almost always, empty should mean "use the default".

Trap PORT= in a .env file, then ${PORT-8080}, gives an empty port, and the server fails to start, or listens on a random one. ${PORT:-8080} gives 8080. The exit status of a failed :? also differs between shells (1 in bash 5, 2 in dash and BusyBox, 127 from bash 3.2 in a subshell), so test for non-zero, not for a number.

Rung 4 Cutting strings: prefixes, suffixes and length

# removes from the front and % from the end. One character removes the shortest match, and two remove the longest. The pattern is a glob (chapter 3), not a regular expression.

f=/srv/v1.2/archive.tar.gz
${f##*/}      # archive.tar.gz            everything up to the last /   (like basename)
${f%/*}       # /srv/v1.2                 from the last / to the end    (like dirname)
${f%.*}       # /srv/v1.2/archive.tar     the last extension
${f#*/}       # srv/v1.2/archive.tar.gz   up to the first /
${#f}         # 24                        the length

To remember which is which: on a US keyboard, # is left of $ and % is right of it.

Rule Take a path apart with ##*/ (the name) and %/* (the directory), and remove one extension with %.*, after first cutting off the directory.

Why These run inside the shell, with no extra process, and they work on any string, including names with spaces. They exist in every POSIX shell.

Trap %%.* removes from the first dot, wherever it is. ${f%%.*} on /srv/v1.2/archive.tar.gz gives /srv/v1, because the directory name has a dot in it. On a name with no directory, report.final.csv loses final.csv, not just .csv. And ${b%.*} on .bashrc is empty. Cut the directory off first (base=${f##*/}), then work on base.

Rung 5 Replacing, slicing and changing case

x=hello-world
${x/o/0}        # hell0-world      first match replaced          [bash]
${x//o/0}       # hell0-w0rld      every match                   [bash]
${x/#hello/bye} # bye-world        only at the start (% for the end)  [bash]
${x:0:5}        # hello            5 characters from offset 0    [bash]
${x: -5}        # world            the last 5 (note the space)   [bash]
${x^^}          # HELLO-WORLD      upper case                    [bash 4+]
${x,,}          # hello-world      lower case                    [bash 4+]

Dash rejects all of these with Bad substitution. BusyBox accepts most of them, but its /# did not anchor in testing. ${x^^} and ${x,,} are bash 4. On macOS's /bin/bash they fail with bad substitution. The portable way to change case is tr: printf '%s' "$x" | tr '[:upper:]' '[:lower:]'.

Rule Use these freely in bash scripts. For case changes in a script that must run on a stock Mac, use tr.

Why Each saves a sed or cut process and a pipe, and handles any characters. The bash 4 case operators are the ones most often missing in practice, because macOS still ships bash 3.2.

Trap ${x:-5} and ${x: -5} differ only by a space. The first is "default to 5" (rung 3), so on a non-empty x it returns all of x. The second is "the last five characters". Without the space you get no error, just the whole string.

Rung 6 Command substitution: a command's output as a value

today=$(date +%Y-%m-%d)
here=$(cd "$(dirname "$0")" && pwd)       # nested: each $( ) is its own quoting context
if version=$(git describe --tags 2>/dev/null); then   # the status of the command survives
  echo "building $version"
fi

$(command) runs the command (in a subshell, chapter 9) and is replaced by what it printed to stdout. Quote it like any other expansion: "$(cmd)". The quotes inside the parentheses do not clash with the quotes around them.

Rule Use $( ), quote it, and check the command's status when the value matters (if x=$(cmd); then).

Why $( ) nests without escaping and reads clearly. Unquoted, its output goes through word splitting and globbing like any other expansion.

Trap Command substitution removes all trailing newlines. x=$(printf 'a\n\n\n') gives a, and a file read with content=$(cat file) loses its blank last lines. Usually that is what you want. When it is not, add a sentinel and remove it: x=$(cmd; printf .); x=${x%.}.

Rung 7 Arithmetic: integers only

echo $((7 / 2)) $((7 % 2)) $((2 ** 10))   # 3 1 1024: integer division, remainder, power [bash]
i=$((i + 1))                              # POSIX
((i++))                                   # [bash], and see chapter 5 for its exit status
echo $((16#ff)) $((2#1010))               # 255 10: other bases [bash]
awk 'BEGIN { printf "%.2f\n", 1.5 * 2 / 7 }'   # 0.43: decimals need awk (or bc)

Inside $(( )), variables do not need a $, and spaces are free. $((7 / 2)) is 3: the shell has no decimals, and $((1.5 * 2)) is a syntax error.

Rule Do integer arithmetic with $(( )), and hand anything with decimals to awk.

Why It is built in, fast and POSIX (** and base# are bash extensions).

Trap A number with a leading zero is read as octal (base 8). $((010)) is 8, and $((08 + 1)) fails with value too great for base. That ends the script. Days, months, times and version numbers like 08 and 09 arrive this way from date and from file names. Remove the zeros first (m=${m#0}), or force base 10 with $((10#$m)) bash.

Rung 8 Old forms you will meet

#!/bin/sh
# counter.sh, circa 2003
TOP=`dirname $0`
N=`expr $N + 1`
LEN=`expr length "$NAME"`
NAME_UC=`echo $NAME | tr a-z A-Z`
let TOTAL=TOTAL+N
typeset -i COUNT=0
echo -n "Average: "; echo "scale=2; $TOTAL / $N" | bc
eval "VALUE=\$$KEY"
SUM=$[A + B]
Old form What it does Why it existed Write instead Still runs?
`cmd` (backticks) command substitution the Bourne shell's only form. $( ) was standardised by POSIX.2 (1992) $(cmd) yes, but backslashes inside are processed again: `echo \$HOME` prints your home, $(echo \$HOME) prints $HOME. Nesting needs \`
expr $n + 1 arithmetic, string length, substrings, regex matches the Bourne shell had no arithmetic $((n + 1)), ${#s}, ${s:0:3}, [[ =~ ]] yes, but expr length is GNU (macOS: syntax error), * must be escaped, and see Trap
$[ a + b ] arithmetic an early bash syntax. bash 2.0's release notes dropped it in favour of $(( )) $((a + b)) bash still accepts it, undocumented. Dash and BusyBox print it literally
let x=x+1, typeset -i arithmetic assignment, an integer-typed variable ksh features copied into bash x=$((x + 1)) yes bash
`dirname $0`, `basename $f .txt` path parts no parameter expansion for paths in the Bourne shell ${0%/*}, ${f##*/} then ${base%.txt} yes, one process each, and unquoted they break on spaces
echo $x \| tr a-z A-Z upper-case no case operators before bash 4 (2009) ${x^^} bash 4+, or tr '[:lower:]' '[:upper:]' yes. Ranges like a-z misbehave in some locales; the classes are safer
echo -n print without a newline BSD echo had -n, System V used \c printf '%s' "$x" yes in bash and BusyBox; dash supports -n but not -e
echo "…" \| bc decimal arithmetic the shell has only integers awk 'BEGIN { … }' yes, but bc is often missing from containers
eval "V=\$$KEY" read the variable named in $KEY no indirection in the Bourne shell ${!KEY} bash yes, and it runs any code in $KEY (chapter 3)

Rule Translate expr, backticks, $[ ], let and basename/dirname calls into $(( )), $( ) and parameter expansion when you work on a script.

Why Each modern form runs inside the shell, handles any characters, and has one set of rules everywhere bash runs.

Trap expr exits with status 1 when its result is 0 or empty. That is a feature for if expr …, but under set -e, n=$(expr $n - 1) ends the script the moment the counter reaches zero, with no message (in every shell tested). $((n - 1)) has no exit status of its own to trip over.

Decide

You need Use
fixed text 'single quotes'
text with variables "double quotes"
a tab, newline or other escape inside a value $'…' bash, or printf
output of any of the above printf '%s\n' "$x", not echo
a value that must be safe to paste back into a shell printf '%q' "$x" bash

The golden file

Pantry's settings in one sourced file. Its first line tells shellcheck which shell to assume, since a sourced file has no shebang. It has required values, defaults that an empty value cannot dodge, normalisation that works on bash 3.2, validation with globs, and derived values built in one place. Chapter 14's pantry-ctl sources it.

examples/manual/b04/pantry-env.sh
# shellcheck shell=bash
# pantry-env.sh: Pantry's settings, with defaults, checks and derived values.
# Sourced, not run:
#   PANTRY_ENV=dev . ./pantry-env.sh
# Any setting already in the environment wins over the default here. Returns 1, with a message
# on stderr, if a setting is missing or invalid. Works in bash 3.2 and later.

# Refuse to be run as a program: settings made in a child process would vanish with it.
if ! (return 0 2>/dev/null); then
  echo "pantry-env.sh: source this file (. ./pantry-env.sh), do not run it" >&2
  exit 2
fi

# Required, and empty counts as missing. ${PANTRY_ENV:?message} would also stop here, but in a sourced
# file it ends the caller's whole script; returning 1 lets the caller decide. :- keeps set -u callers safe.
if [ -z "${PANTRY_ENV:-}" ]; then
  echo "pantry-env.sh: set PANTRY_ENV to dev, staging or prod" >&2
  return 1
fi

# Defaults. := assigns when the variable is unset or empty, and the : command ignores the result.
: "${PANTRY_PORT:=8080}"
: "${REDIS_HOST:=localhost}"
: "${REDIS_PORT:=6379}"
: "${PANTRY_LOG_LEVEL:=info}"
: "${PANTRY_DATA_DIR:=$HOME/.local/share/pantry}"

# Normalise. tr instead of ${PANTRY_ENV,,}, which needs bash 4.
PANTRY_ENV=$(printf '%s' "$PANTRY_ENV" | tr '[:upper:]' '[:lower:]')
PANTRY_DATA_DIR=${PANTRY_DATA_DIR%/}           # drop one trailing slash: /data/ becomes /data

# Validate. Patterns here are globs: *[!0-9]* means "contains a character that is not a digit".
case $PANTRY_ENV in
  dev | staging | prod) ;;
  *) echo "pantry-env.sh: PANTRY_ENV must be dev, staging or prod, not '$PANTRY_ENV'" >&2; return 1 ;;
esac
case $PANTRY_PORT in
  '' | *[!0-9]* | 0*) echo "pantry-env.sh: PANTRY_PORT must be a number without leading zeros, not '$PANTRY_PORT'" >&2; return 1 ;;
esac
if [ "$PANTRY_PORT" -gt 65535 ]; then
  echo "pantry-env.sh: PANTRY_PORT must be at most 65535, not $PANTRY_PORT" >&2
  return 1
fi

# Derived values: built here once, so no other script assembles them differently.
PANTRY_URL="http://localhost:${PANTRY_PORT}"
REDIS_URL="redis://${REDIS_HOST}:${REDIS_PORT}/0"
PANTRY_IMAGE="pantry:${PANTRY_VERSION:-dev}-${PANTRY_ENV}"

export PANTRY_ENV PANTRY_PORT REDIS_HOST REDIS_PORT PANTRY_LOG_LEVEL PANTRY_DATA_DIR \
  PANTRY_URL REDIS_URL PANTRY_IMAGE
cd examples/manual/b04
PANTRY_ENV=PROD PANTRY_PORT=9000 bash -c '. ./pantry-env.sh && echo "$PANTRY_ENV $PANTRY_URL $PANTRY_IMAGE"'
PANTRY_ENV=dev PANTRY_PORT=08080 bash -c '. ./pantry-env.sh'; echo "status $?"   # rejected: leading zero
bash pantry-env.sh; echo "status $?"                                              # 2: must be sourced

Review checklist

Spot the bug

A helper that names backup files. It "works on the test file".

examples/manual/b04/trap/backup-name.sh
#!/usr/bin/env bash
# backup-name.sh FILE: print the name its backup should get, e.g. data/report.2026-10-24.csv
# KEEP (default 7) is how many backups the rotation keeps; this also prints how many older ones stay.
file=$1
dir=`dirname $file`
base=${file##*/}
name=${base%%.*}
ext=${base#*.}
stamp=$(date +%Y-%m-%d)
keep=${KEEP-7}
echo "$dir/$name.$stamp.$ext"
echo "keeping $((keep - 1)) older backups"

This file has 5 problems.

Show answers
  1. `dirname $file` is unquoted (and uses backticks). With my data/report.csv, dirname got two arguments and printed two lines, . and data, so the backup name came out split across two lines (rung 6, rung 8). Use dir=$(dirname -- "$file"), or ${file%/*} once you know the path contains a /.
  2. ${base%%.*} cuts at the first dot. v1.2-plan.md became v1 plus the "extension" 2-plan.md, giving notes/v1.2026-10-24.2-plan.md (rung 4). Use ${base%.*} and ${base##*.} for the last extension.
  3. A name with no dot is not handled. For Makefile, both ${base%%.*} and ${base#*.} return the whole name, giving ./Makefile.2026-10-24.Makefile (rung 4). Check for a dot first: case $base in *.*) … ;; *) ext= ;; esac.
  4. ${KEEP-7} has no colon, so KEEP= (empty) is kept. $((keep - 1)) then printed keeping -1 older backups (rung 3). Use ${KEEP:-7}.
  5. KEEP=08 is read as octal, and the arithmetic failed with 08: value too great for base. The script exits 1 after printing half its output (rung 7). Validate it (case $keep in *[!0-9]*)), then use $((10#$keep - 1)).

Shell manual · 5 · 10 min readExit Status and Tests#

You've met it: kubectl -n "$NS" logs "$name" --tail=2 2>/dev/null || true in the triage script, where || true means "this may fail, and that is fine" · Debugging

The shell has no true or false values. Every decision it makes, in if, while, && and ||, is about one thing only: the exit status of a command (chapter 2). Zero means success and counts as true. Anything else counts as false. Even [, the thing that looks like a condition, is a command that exits 0 or 1. Once you see that, the strange-looking parts of shell conditions stop being strange.

The ladder

Rung 1 if runs a command

if grep -q "ERROR" app.log; then        # the condition is the command: grep exits 0 on a match
  echo "errors found"
elif [ -s app.log ]; then               # [ is a command too; -s: file exists and is not empty
  echo "clean log"
else
  echo "no log yet"
fi

if ! command -v jq >/dev/null; then     # ! inverts a command's status
  echo "please install jq" >&2
fi

if runs the command after it. Exit status 0 takes the then branch, anything else the elif or else branch. Many commands have a "quiet" option made for exactly this: grep -q, cmp -s, diff -q.

Rule Put the command itself after if. Use [ … ] only when you really are comparing strings, numbers or file attributes.

Why The command's status is the answer. Capturing output and testing it (if [ "$(grep -c x f)" -gt 0 ]) runs more processes, and loses the difference between "no match" and "error".

Trap if [ false ] takes the then branch. [ with one argument tests whether that string is non-empty, and false is a five-letter string. The same goes for [ 0 ] and [ "$flag" ]. To run the false command, write if false. To test a flag, compare it: [ "$flag" = yes ].

Rung 2 &&, ||, and your own exit codes

mkdir -p "$out" && cp report.csv "$out"/      # run the second only if the first succeeded
cd "$dir" || exit 1                           # stop if the cd failed
grep -q x f || true                           # failure is expected and fine: make the status 0

a && b runs b only if a succeeded. a || b runs b only if a failed. The status of the whole line is that of the last command that ran. Your own script reports its result the same way, with exit N (and a function with return N, chapter 8):

Exit with Meaning, by convention
0 it worked
1 it failed (the general case)
2 it was called wrongly: bad options or arguments (print usage)
your own small numbers documented failure kinds, like wait-for.sh's 1 for "timed out"
avoid 126, 127, 128+ the shell uses these for "cannot run", "not found" and "killed by a signal"

exit without a number exits with the status of the last command, which is often not what you meant.

Rule Use && and || for one-step "and then" and "or else" chains, and if for anything with two outcomes. End every script with an explicit exit that matches its result.

Why a && b || c reads like an if/else, but it is not one. c runs when a fails, and also when b fails.

Trap [ -d "$dir" ] && cd "$dir" || mkdir "$dir". If the directory exists but cd fails (no permission), mkdir runs, fails as well, and the script continues in the wrong directory. In the lab, true && false || echo "c ran" printed c ran. Write the if.

Rung 3 [ is a command: strings, numbers and files

[ is a command (also called test) whose last argument must be ]. Because it is a command, it needs spaces around every part, and because its arguments are expanded first, every variable in it needs quotes.

[ -z "$x" ]            # empty string          [ -n "$x" ]  # not empty
[ "$a" = "$b" ]        # strings equal         [ "$a" != "$b" ]
[ "$n" -eq 3 ]         # integers: -eq -ne -lt -le -gt -ge
[ -e "$p" ]            # exists                [ -f "$p" ]  # regular file   [ -d "$p" ]  # directory
[ -r "$p" ]            # readable              [ -w "$p" ]  # writable       [ -x "$p" ]  # executable
[ -s "$p" ]            # exists, not empty     [ -L "$p" ]  # symbolic link
[ "$a" -nt "$b" ]      # a is newer than b

= compares text and -eq compares numbers: [ 10 = 010 ] is false and [ 10 -eq 010 ] is true.

Rule Put spaces around [, ] and every operator, double-quote every variable, and use = for strings and -eq/-lt/… for numbers.

Why Unquoted, an empty variable disappears and the test changes meaning. [ -n $x ] with x empty becomes [ -n ], which asks "is -n a non-empty string?". The answer is yes, so the test is true for an empty x, in every shell.

Trap [ "$a" > "$b" ] does not compare anything. > is a redirection (chapter 6). The shell creates (or empties) a file named after $b, runs [ "$a" ], and the test is true whenever $a is not empty. In the lab it printed true? and left a file called zzz behind. Compare numbers with -gt, or strings with [[ $a > $b ]] (rung 4).

Rung 4 [[ … ]]: bash's safer test

if [[ $file == *.txt ]]; then …                  # glob pattern match (right side unquoted)
if [[ $version =~ ^v([0-9]+)\.([0-9]+) ]]; then   # regular expression
  major=${BASH_REMATCH[1]}                       # the captured groups
fi
if [[ -f $cfg && $mode != test ]]; then …         # && and || inside the brackets
if [[ $a < $b ]]; then …                         # string order, not a redirection

[[ … ]] bash is part of bash's syntax, not a command. That is why it can skip word splitting and globbing on the variables inside it ([[ -n $x ]] is safe unquoted), and why &&, < and > mean what they look like. Dash has no [[ at all. BusyBox accepts [[ and =~, but sets no BASH_REMATCH, and treats < inside it as a redirection.

Rule In bash scripts, prefer [[ … ]] for string and file tests. Keep [ … ] for scripts that must run in sh.

Why It removes the two classic [ failures, missing quotes and > as a redirection, and adds pattern and regex matching without starting grep.

Trap On the right side of == and =~, quoting turns the pattern into plain text. [[ $f == "*.txt" ]] is true only for a file literally named *.txt, and [[ $f =~ "^r" ]] looks for the characters ^r. Leave patterns unquoted, or put a regex in a variable and use it unquoted: re='^v[0-9]+'; [[ $v =~ $re ]].

Rung 5 Arithmetic tests: (( … ))

if (( count > 3 )); then …          # C-like comparison, no $ needed  [bash]
(( retries++ ))                      # increment
(( n % 2 == 0 )) && echo even

(( expr )) bash evaluates an arithmetic expression and exits 0 if the result is non-zero, and 1 if it is zero. That makes comparisons read naturally, because a true comparison has the value 1.

Rule Use (( … )) for numeric conditions in bash, and [ "$n" -gt 3 ] in sh scripts.

Why <, >= and == mean what they look like inside (( )), variables need no $, and there is no quoting to get wrong.

Trap The status follows the value, not whether it worked. (( count++ )) with count=0 has the value 0 (post-increment returns the old value), so its status is 1. Under set -e (chapter 14), bash 5 ends the script right there, on the first increment, with no message. macOS's bash 3.2 does not. Use count=$((count + 1)), which is an assignment and always succeeds.

Rung 6 case: matching one value against patterns

case $1 in
  start | up)      start_service ;;           # | separates alternatives
  stop)            stop_service ;;
  -h | --help)     usage; exit 0 ;;
  *.tar.gz | *.tgz) tar -xzf "$1" ;;           # glob patterns, as in chapter 3
  '')              echo "missing argument" >&2; exit 2 ;;
  *)               echo "unknown: $1" >&2; exit 2 ;;   # everything else: put it last
esac

The value is compared with each pattern in turn. The first match runs its commands up to ;;, and case ends. Patterns are globs, the value needs no quotes (it is not split), and case works in every POSIX shell.

Rule Use case whenever one value decides between several branches: arguments, file types, environments, yes/no answers.

Why It is clearer than a chain of elif [ … ], it matches patterns in every shell, and the *) branch makes "everything else" explicit.

Trap ;& (fall through into the next branch's commands) and ;;& (keep testing the next patterns) bash 4+ are syntax errors in macOS's bash 3.2: syntax error near unexpected token '&'. A syntax error stops the whole script before it runs a single line, so one bash-4 branch makes a script dead on arrival on a Mac. List the patterns together with | instead.

Rung 7 Loops on commands: while, until, and retrying

while read -r line; do …; done < file     # repeat while the command succeeds (chapter 7)
until curl -fsS "$url" >/dev/null; do     # repeat while it fails
  sleep 1
done

attempt=1 delay=1                          # retry with a limit and a growing delay
until docker pull "$image"; do
  if [ "$attempt" -ge 5 ]; then
    echo "giving up after $attempt attempts" >&2
    exit 1
  fi
  sleep "$delay"
  attempt=$((attempt + 1)) delay=$((delay * 2))
done

while and until, like if, run a command and look at its status. break leaves the loop and continue starts the next pass.

Rule Every retry loop needs a limit (attempts or a deadline), a delay, and a message when it gives up.

Why Retrying covers a temporary failure: a service still starting, a network blip. A permanent failure, such as a wrong host name or a 404, never goes away, and only the limit ends the loop.

Trap until curl -fsS "$url"; do sleep 1; done with a typo in $url loops forever and prints the same error every second. In CI it runs until the job's own timeout kills it an hour later, with nothing to say why. The golden file has the deadline built in.

Rung 8 Old forms you will meet

#!/bin/sh
# 1998-vintage conditions
if test "x$1" = "x-v"; then VERBOSE=1; fi
if [ ! -z "$FILE" -a -r "$FILE" -o "$FORCE" = yes ]; then
  process
fi
grep -q done status
if [ $? -eq 0 ]; then echo "done"; fi
while :; do check && break; sleep 5; done
if expr "$NAME" : 'pantry-.*' >/dev/null; then echo match; fi
[ "$a" == "$b" ] && echo same
select opt in start stop quit; do …; done
Old form What it does Why it existed Write instead Still runs?
test … the same command as [ … ] the original name; [ came later as an alias [ … ], or [[ … ]] in bash yes
[ "x$a" = "xfoo" ] compare with an x glued to both sides old test misread values like -n, ! or ( as operators. Autoconf's manual still recommends a leading X [ "$a" = foo ] yes, but not needed in any POSIX shell
-a, -o inside [ ] and, or one test call instead of two [ … ] && [ … ], [ … ] \|\| [ … ], or [[ … && … ]] marked obsolescent in POSIX.1-2008 and removed in POSIX.1-2024. See Trap
[ ! -z "$x" ] not empty [ -n "$x" ] yes
if [ $? -eq 0 ] test the previous command's status a habit carried over from other languages if command; then yes, but anything run in between replaces $? (chapter 2)
while : loop forever : is the do-nothing command that always succeeds while true reads better, and is the same yes
expr "$x" : 're' regex match the Bourne shell's only regex [[ $x =~ re ]], or case with a glob yes, and it prints the match length
[ "$a" == "$b" ] string equality bash accepts == inside [ = in [ ], == in [[ ]] bash, BusyBox and macOS yes. Dash: [: unexpected operator bash
select x in … a numbered menu ksh's interactive menus case on an argument, or read yes bash

Rule Translate test "x$a", -a/-o and if [ $? … ] into plain modern tests when you edit a script, and use = (not ==) in [ ].

Why These idioms protected against bugs in shells that no longer exist, or saved a process when processes were expensive. The modern forms are shorter and harder to get wrong.

Trap -a and -o still fail today on ordinary data. With x='!', [ "$x" = "!" -a "$y" = 1 ] fails in every shell tested: bash and bash 3.2 say too many arguments, dash says unexpected operator, BusyBox says unknown operand. The if then takes its else branch as though the values were different. Two separate tests joined by && worked in all of them.

Decide

Test Use for Works in
[ … ] strings, numbers, files in a script that must run in sh every shell
[[ … ]] strings, files, glob patterns and regexes in bash bash, zsh, ksh (BusyBox partly)
(( … )) numeric comparisons and counters in bash bash, zsh, ksh
the command itself "did it work?" every shell
case one value against several patterns every shell

The golden file

Waits for a port or a URL with a deadline, then gets out of the way. Its exit status is the whole interface: ./wait-for.sh -t 60 redis:6379 && ./start.sh. Chapter 14's pantry-ctl uses it before starting Pantry.

examples/manual/b05/wait-for.sh
#!/usr/bin/env bash
# wait-for.sh: wait until a TCP port accepts connections, or an HTTP(S) URL answers with 2xx or 3xx.
#
#   wait-for.sh [-t SECONDS] [-i SECONDS] HOST:PORT
#   wait-for.sh [-t SECONDS] [-i SECONDS] http://HOST[:PORT]/PATH
#
#   -t  give up after this many seconds (default 30)
#   -i  wait this many seconds between attempts (default 1)
#
# Exit status: 0 ready, 1 timed out, 2 usage error.
# Ports are checked with bash's own /dev/tcp, so no nc is needed. URLs need curl.
# Typical use:  ./wait-for.sh -t 60 localhost:6379 && ./start-app.sh
set -euo pipefail

usage() {
  echo "usage: wait-for.sh [-t SECONDS] [-i SECONDS] HOST:PORT|URL" >&2
  exit 2
}

timeout=30
interval=1
while [ "$#" -gt 0 ]; do                 # options first; the loop stops at the target
  case $1 in
    -t) [ "$#" -ge 2 ] || usage; timeout=$2; shift 2 ;;
    -i) [ "$#" -ge 2 ] || usage; interval=$2; shift 2 ;;
    -h | --help) usage ;;
    -*) usage ;;
    *) break ;;
  esac
done
[ "$#" -eq 1 ] || usage
target=$1

for n in "$timeout" "$interval"; do     # whole seconds only: digits, at least one, not 0
  case $n in '' | *[!0-9]* | 0) usage ;; esac
done

case $target in
  http://* | https://*)
    command -v curl >/dev/null 2>&1 || { echo "wait-for.sh: URLs need curl" >&2; exit 2; }
    # -f: fail on 4xx and 5xx; -sS: no progress bar, but do show errors; bounded by one interval
    probe() { curl -fsS -o /dev/null --max-time "$interval" "$target" 2>/dev/null; }
    ;;
  *:*)
    host=${target%:*}
    port=${target##*:}
    case $port in '' | *[!0-9]*) usage ;; esac
    # Opening /dev/tcp/HOST/PORT makes bash connect. The subshell closes the socket again at once.
    probe() { (exec 3<>"/dev/tcp/$host/$port") 2>/dev/null; }
    ;;
  *) usage ;;
esac

deadline=$((SECONDS + timeout))          # SECONDS counts whole seconds since this shell started
until probe; do                          # until: repeat while the command fails
  if [ "$SECONDS" -ge "$deadline" ]; then
    echo "wait-for.sh: $target not ready after ${timeout}s" >&2
    exit 1
  fi
  sleep "$interval"
done
echo "wait-for.sh: $target is ready" >&2
cd examples/manual/b05
./wait-for.sh -t 3 127.0.0.1:9; echo "status $?"                # 1 after about 3 seconds: nothing listens
(sleep 2; nc -l 19123) & ./wait-for.sh -t 10 127.0.0.1:19123     # ready after about 2 seconds (macOS nc;
                                                                 # in the lab: /opt/busybox/bin/nc -l -p 19123)

Review checklist

Spot the bug

The deploy job rolls back when this exits non-zero. It has never rolled anything back.

examples/manual/b05/trap/check-health.sh
#!/bin/sh
# check-health.sh URL: report a service's health, and exit 0 only if it answers 200.
# The deploy job rolls back whenever this exits non-zero.
url=$1
code=$(curl -s -o /dev/null -w '%{http_code}' "$url")
[ -n $SKIP_HEALTH ] && echo "health check skipped" && exit 0
if [ $code == 200 ]; then
  echo "healthy"
elif [ "$code" > 499 ]; then
  echo "server error ($code)"
else
  echo "unexpected answer ($code)"
fi

This file has 5 problems.

Show answers
  1. [ -n $SKIP_HEALTH ] is unquoted. With SKIP_HEALTH unset it becomes [ -n ], which is true. Every run printed health check skipped and exited 0, so the check never checked anything (rung 3). Use [ -n "${SKIP_HEALTH:-}" ].
  2. == inside [ ] under #!/bin/sh. In the Debian container, dash said [: 200: unexpected operator, so healthy was never printed, even for a 200 (rung 8).
  3. [ "$code" > 499 ] is a redirection, not a comparison. It created a file named 499 in the current directory and tested [ "$code" ], which is true, so a healthy service was reported as server error (200) (rung 3). Use [ "$code" -ge 500 ].
  4. The script never exits non-zero. Its status is that of the last echo, so a 404 or a 500 still gave 0, and the deploy job never rolled back (rung 2). Add exit 0 after healthy and exit 1 in the other branches, or end with [ "$code" = 200 ].
  5. The URL argument is not checked. Without one, curl complained, $code was empty, and the script still exited 0 (rung 2). Start with [ "$#" -eq 1 ] || { echo "usage: check-health.sh URL" >&2; exit 2; }.

Shell manual · 6 · 10 min readRedirection and Pipes#

You've met it: SHELL ["/bin/bash", "-o", "pipefail", "-c"], the line that stops a failing curl … | tar in a Dockerfile from passing as success · Every Instruction

Chapter 2 introduced the three streams every process starts with: stdin (0), stdout (1) and stderr (2). This chapter is about pointing them somewhere else: into files, out of files, into other programs, or nowhere. All of it happens in step 8 of chapter 3's pipeline, after expansion and before the program starts. The program itself never knows. It writes to fd 1 as usual, and fd 1 now happens to be a file.

The ladder

Rung 1 Pointing streams at files, and why the order matters

Write Means
cmd > file fd 1 to file (created, or emptied first)
cmd >> file fd 1 to file, appended
cmd < file fd 0 reads from file
cmd 2> errors.log fd 2 to a file
cmd > out.log 2>&1 fd 1 to the file, then fd 2 to wherever fd 1 now points: both in the file
cmd &> out.log the same, shorter bash
cmd > /dev/null 2>&1 discard everything. /dev/null swallows whatever is written to it
cmd 2> /dev/null discard only the errors

2>&1 means "make fd 2 a copy of fd 1 as it is right now". Redirections are applied left to right, so the order changes the result:

ls /nope 2>&1 > out

ls /nope > out 2>&1

1st: fd 1 → out

2nd: fd 2 → copy of fd 1 = out

1st: fd 2 → copy of fd 1 = terminal

2nd: fd 1 → out

error in out

error on the terminal, out empty

Rule To capture both streams, write > file 2>&1, in that order.

Why Each redirection acts on the fd table as it stands after the ones before it. 2>&1 copies, it does not link, so a later change to fd 1 does not move fd 2.

Trap cmd 2>&1 > file leaves the errors on the terminal and only stdout in the file. Your log is clean, and the errors went to a terminal nobody was watching. And in dash and BusyBox sh, cmd &> file is not a redirection at all: it parses as cmd & (run in the background) followed by > file (empty the file). The command's output lands on the terminal later, and file stays empty.

Rung 2 Pipes connect stdout to stdin

grep ERROR app.log | sort | uniq -c | sort -rn | head -5   # the five most common errors
cmd 2>&1 | tee build.log                                   # stderr into the pipe too
cmd |& tee build.log                                       # the same, shorter [bash 4+]

a | b starts both programs at the same time and connects a's stdout to b's stdin. Each part of a pipeline is a separate process (in bash, even a builtin or a while loop at the end, chapter 7). Only stdout goes through. stderr still goes to the terminal, unless you redirect it first.

Rule Build pipelines from small commands that each read stdin and write stdout, and redirect stderr into the pipe only when the next command should read it.

Why Pipes are how separate tools combine into one job. Because all the parts run at once, a pipeline over a huge file uses almost no memory, and head can stop it early.

Trap Reading and writing the same file in one command line destroys it. sort f > f empties f. The shell sets up > f (truncating it) before sort starts, so sort reads an empty file. cat f | sort > f is a race, which sometimes survives and sometimes does not. Write to a new file and rename it (rung 6), or use sort -o f f, which reads everything before writing.

Rung 3 A pipeline's status, and pipefail

$ ls /nope | wc -l; echo "status $?"
ls: cannot access '/nope': No such file or directory
0
status 0
$ set -o pipefail
$ ls /nope | wc -l; echo "status $?"
ls: cannot access '/nope': No such file or directory
0
status 2
$ false | true | (exit 3); echo "${PIPESTATUS[@]}"     # every part's status [bash]
1 0 3

By default, a pipeline's status is the status of its last command. With set -o pipefail, it is the status of the last command that failed, so any failure in the pipeline counts. It worked in every shell tested here, including dash and BusyBox. PIPESTATUS bash keeps each part's status, until the next command replaces it.

Rule Turn on set -o pipefail at the top of every bash script (chapter 14 makes it part of strict mode).

Why Otherwise curl "$url" | tar -xz "succeeds" when the download fails, and grep pattern missing.log | wc -l reports 0 matches with status 0 instead of an error.

Trap pipefail also reports failures you may not care about. grep -q x f | … or a head -1 at the end can make an early command die from SIGPIPE (it was still writing when the reader stopped), and the pipeline then fails with status 141. For pipelines that end early on purpose, check PIPESTATUS, or add || true to that one line.

Rung 4 Here-documents and here-strings

cat <<EOF > settings.ini          # unquoted delimiter: $variables and $(commands) expand
[server]
name = $SERVER_NAME
started = $(date +%F)
EOF

cat <<'EOF' > deploy.sh           # quoted delimiter: everything is literal
echo "Deploying $TAG to $HOST"    # these stay as written, for deploy.sh to expand later
EOF

if true; then
	cat <<-EOF                    # <<- removes leading TABS (not spaces) from each line
	indented with tabs to match the code
	EOF
fi

read -r first rest <<< "one two three"   # here-string: one line of input [bash]

A here-document feeds the lines up to the delimiter (here EOF, but any word works) to the command's stdin. Quoting the delimiter decides whether anything inside is expanded.

Rule Quote the delimiter (<<'EOF') whenever the text is a template, a script or a config with its own $ signs, and leave it unquoted only when you want this shell's variables in it.

Why In an unquoted here-document, every $word, $(…) and backtick expands, exactly as inside double quotes.

Trap cat <<EOF around an nginx config, a Makefile or a generated script quietly replaces $host, $1 or $PATH with this shell's values, usually empty ones. The generated file looks right until a line like proxy_set_header Host $host; arrives as proxy_set_header Host ;. Escape the ones you need to keep (\$host), or quote the delimiter.

Rung 5 Process substitution: a command where a file name goes

diff <(sort old.txt) <(sort new.txt)          # compare two sorted outputs, with no temporary files
comm -13 <(sort a) <(sort b)                  # lines only in b
while read -r pod; do …; done < <(kubectl get pods -o name)   # chapter 7: the loop is not in a subshell
cmd > >(tee out.log) 2> >(tee err.log >&2)    # send each stream to a file and the screen

<(cmd) bash runs cmd and is replaced by a file name (like /dev/fd/63) that reads its output. >(cmd) gives a name that writes into cmd. BusyBox also accepts both. Dash rejects them with a syntax error.

Rule Use <( ) when a program wants file names and what you have is command output, and you would otherwise write temporary files.

Why It avoids creating, naming and cleaning up temporary files, and both commands run at the same time.

Trap The exit status of the command inside <( ) is lost. diff <(false) <(true) exits 0: two empty inputs are equal, and nothing reports that one side failed. Not even pipefail sees it. When the producer can fail (a curl, a kubectl), run it first into a variable or a temporary file and check it.

Rung 6 Writing files safely

cmd | tee out.log                    # write to a file and keep showing it
cmd | tee -a out.log >/dev/null      # append, quietly
set -o noclobber                     # > refuses to overwrite an existing file
cmd >| out.log                       # overwrite anyway, on purpose

tmp=$(mktemp "$dir/.config.XXXXXX")  # atomic replace: write a new file next to the old one,
generate > "$tmp" && mv -f "$tmp" "$dir/config.yaml"   # then rename it over the old one
echo "127.0.0.1 pantry.test" | sudo tee -a /etc/hosts  # write a root-owned file

A redirection is performed by your shell, as you, before the command starts. That explains two things. sudo echo x > /etc/hosts fails with Permission denied, because sudo raises the privileges of echo, not of your shell's >. Use | sudo tee. And > file empties the file first, before the command has produced anything.

Rule Generate a file into a temporary name in the same directory, check the result, then mv it into place.

Why mv within one filesystem is a rename, which happens in a single step. Anyone reading the file sees either the old complete version or the new one, and a failed generator leaves the old file untouched.

Trap generate > config.yaml, where generate fails halfway or right away, leaves config.yaml truncated or empty. The previous good config is gone, and the service that reads it on its next restart has nothing. In the lab, a failing generator turned an 11-byte config into 0 bytes.

Rung 7 exec redirections and extra file descriptors

exec > >(tee -a run.log) 2>&1   # from here on, everything this script prints also goes to run.log [bash]
exec 3> debug.log                # open fd 3 for writing, for the rest of the script
echo "detail" >&3                # write to it
exec 3>&-                        # close it
exec 4< hosts.txt                # open fd 4 for reading
read -r first <&4                # each read takes the next line
read -r second <&4
exec 4<&-

exec with only redirections (no command) applies them to the current shell itself, permanently. File descriptors 3 to 9 are free for your own use.

Rule Use exec >… once at the top of a script that must log everything, and extra fds when a loop needs a second input or output besides stdin and stdout.

Why One line redirects every later command, including ones in functions you did not write. An extra fd lets a loop read a file on fd 3 while its body still reads the terminal or the network on fd 0 (chapter 7).

Trap After exec > run.log 2>&1, your script's errors go into the log file too, so a failure in a cron job or a terminal shows nothing at all. Use exec > >(tee -a run.log) 2>&1 to keep a copy on the screen, or print the log's path to the terminal before the exec.

Rung 8 Old forms you will meet

#!/bin/sh
# nightly.sh, 2006
cat access.log | grep 404 | wc -l
: > /var/tmp/nightly.out
nohup ./long-job.sh > job.out 2>&1 &
mkfifo /tmp/pipe.$$; sort < /tmp/pipe.$$ > sorted.txt & producer > /tmp/pipe.$$
echo "nameserver 1.1.1.1" | sudo tee /etc/resolv.conf > /dev/null
yes | ./installer.sh
make >& build.log
Old form What it does Why it existed Write instead Still runs?
cat file \| cmd feed a file to a command readability, or habit. Called a "useless use of cat" cmd < file, or cmd file yes. One extra process, and cmd cannot seek in a pipe
: > file truncate (or create) a file : is the no-op command, so only the redirection happens > file in bash, or keep : >, which is the portable form yes
nohup cmd & keep running after the terminal closes terminals sending SIGHUP on logout nohup is still right for that; for services, use a supervisor (systemd, a container) yes
mkfifo named pipes connect two commands through a file name no process substitution in the Bourne shell <( ), >( ) bash yes, and still useful between unrelated processes
\| sudo tee file > /dev/null write a root-owned file redirections are done by your shell, not by sudo the same: it is still the right way yes
yes \| cmd answer "y" to every question installers without a non-interactive option the program's own -y/--yes/--non-interactive flag yes, but it answers questions you never read
cmd >& file stdout and stderr to a file csh syntax, accepted by bash > file 2>&1 bash yes bash. In sh, >& needs an fd number

Rule Replace cat file | with a redirection, and >&/&> with > file 2>&1 in anything that might run under sh.

Why These came from csh habits, from shells without process substitution, and from the days before programs had non-interactive flags. The modern forms do the same with fewer processes and fewer surprises.

Trap yes | ./installer.sh agrees to everything, including questions added in a later version: overwrite the existing configuration, remove the old data directory. The script keeps "working". Find the installer's documented non-interactive flags, and set only the answers you mean.

Decide

You want Write
text with this script's variables filled in cat <<EOF
text exactly as written (a script, a template, a config with $) cat <<'EOF'
either, indented with the code <<-EOF or <<-'EOF', indented with tabs
one short line of input <<< "$value" bash, or printf '%s\n' "$value" \|
many lines from a command a pipe, or < <(cmd) bash when the loop must not run in a subshell

The golden file

Renders a config from a here-document. The shell's values are filled in, nginx's own variables are kept, messages go to stderr, a dry run goes to stdout, and the real write is atomic. A failure at any point leaves the old file as it was.

examples/manual/b06/render-config.sh
#!/usr/bin/env bash
# render-config.sh: write the nginx front-end config for Pantry from settings in the environment.
#
#   SERVER_NAME=pantry.example.com render-config.sh [--dry-run] OUTPUT
#
# Settings: SERVER_NAME (required), PANTRY_PORT (default 8080), UPSTREAM (default 127.0.0.1).
# OUTPUT is replaced atomically: readers see the old file or the complete new one, never a part.
# --dry-run prints the config to stdout and writes nothing. Messages go to stderr.
set -euo pipefail

log() { printf 'render-config: %s\n' "$*" >&2; }

dry_run=0
if [ "${1:-}" = --dry-run ]; then
  dry_run=1
  shift
fi
if [ "$#" -ne 1 ]; then
  log "usage: render-config.sh [--dry-run] OUTPUT"
  exit 2
fi
out=$1

if [ -z "${SERVER_NAME:-}" ]; then
  log "SERVER_NAME must be set"
  exit 2
fi
port=${PANTRY_PORT:-8080}
upstream=${UPSTREAM:-127.0.0.1}

render() {
  # The timestamp is taken first, so a failing date stops the render instead of leaving a gap.
  local now
  now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
  # Unquoted EOF: $SERVER_NAME, $upstream, $port and $now expand here.
  # nginx's own variables must reach the file as written, so their $ is escaped: \$host.
  cat <<EOF
# Generated by render-config.sh at $now. Do not edit by hand.
server {
    listen 80;
    server_name $SERVER_NAME;

    location / {
        proxy_pass http://$upstream:$port;
        proxy_set_header Host \$host;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
    }
}
EOF
}

if [ "$dry_run" -eq 1 ]; then
  render
  exit 0
fi

dir=$(dirname -- "$out")
tmp=$(mktemp "$dir/.render-config.XXXXXX")   # in the same directory, so the mv below is a rename
trap 'rm -f -- "$tmp"' EXIT                    # on any failure, remove the half-written file (chapter 9)
render > "$tmp"
chmod 644 "$tmp"                               # mktemp creates mode 600; nginx's workers must read it
mv -f -- "$tmp" "$out"                         # one rename: the old file is replaced in a single step
trap - EXIT                                    # nothing left to clean up
log "wrote $out"
cd examples/manual/b06
SERVER_NAME=pantry.test ./render-config.sh --dry-run out.conf      # print, write nothing
SERVER_NAME=pantry.test ./render-config.sh /tmp/pantry.conf        # render-config: wrote /tmp/pantry.conf
./render-config.sh /tmp/pantry.conf; echo "status $?"              # 2: SERVER_NAME missing, file untouched

Review checklist

Spot the bug

Cron runs this every night in the pantry user's home directory, and emails whatever it prints. Two weeks in, report.txt is empty.

examples/manual/b06/trap/error-report.sh
#!/usr/bin/env bash
# error-report.sh: add today's distinct errors from Pantry's log to report.txt,
# keeping the report sorted and free of duplicate lines. Run nightly from cron.
log=/var/log/pantry/app.log
grep ERROR $log | cut -d' ' -f3- | sort | uniq -c > summary.txt 2>&1
cat <<EOF >> report.txt
$(date +%F): $(wc -l < summary.txt) distinct errors; at $5 per incident, see summary.txt
EOF
sort report.txt | uniq > report.txt

This file has 5 problems.

Show answers
  1. 2>&1 at the end applies only to uniq, the last command of the pipeline. With the log missing, grep's error went to stderr (into cron's email), not into summary.txt. Redirect where you mean it, for example { grep … | uniq -c; } > summary.txt 2>&1, or let errors reach stderr on purpose (rungs 1 and 2).
  2. There is no pipefail, so the pipeline's status was uniq's: 0. A missing log produced an empty summary and a "successful" run (rung 3). Add set -o pipefail, and check that $log exists first.
  3. The here-document is unquoted, so $5 expanded to the script's (empty) fifth argument: the report line read at per incident (rung 4). Escape it (\$5) or quote the delimiter.
  4. sort report.txt | uniq > report.txt empties the file before sort reads it. After the first run, report.txt was 0 bytes, history and all (rung 2). Use sort -u -o report.txt report.txt, or write to a temporary file and mv it (rung 6).
  5. summary.txt and report.txt are relative paths, so they land wherever cron starts the job (the home directory), not next to the log or the script (chapter 1, rung 2). Use absolute paths, or cd to a known directory and check that the cd worked.

Shell manual · 7 · 10 min readLoops, Globs and Lines#

You've met it: for i in $(seq 1 100); do curl -s localhost:30080/; done | grep -o '(v[12])' | sort | uniq -c, a loop whose output feeds a pipeline, used to count which version answered · A Concierge at Every Door

Most scripts are loops: over arguments, over files, over the lines of a file or of a command's output. Each of those has one correct form, and each wrong form fails only on unusual input, such as a name with a space, a missing last newline, or a command inside the loop that reads stdin. This chapter gives the correct form for each, and shows exactly what the wrong one does.

The ladder

Rung 1 for over words, arguments and numbers

for env in dev staging prod; do echo "deploying to $env"; done   # a fixed list
for arg in "$@"; do echo "[$arg]"; done                          # every argument, each whole
for arg; do echo "[$arg]"; done                                  # the same: "in "$@"" is the default
for ((i = 0; i < 3; i++)); do echo "$i"; done                    # C-style counter [bash]
for i in $(seq 1 "$n"); do echo "$i"; done                       # counter in any shell

for name in words runs the body once per word, after the words have gone through every expansion of chapter 3. That is the whole rule, and every surprise follows from it.

Rule Loop over "$@" for arguments, an array (chapter 8) or a glob for lists, and ((…)) bash or seq for numbers.

Why Each of those produces exactly one word per item. A list held in one string has no way to say where items end except by splitting on spaces.

Trap for x in "$list" with list="a b c" runs once, with x set to all of a b c: the quotes made it one word. Without the quotes it runs three times, but then an item that contains a space becomes two items. Neither is right when the items can contain spaces. Keep lists in arrays.

Rung 2 Looping over files: globs, not ls

shopt -s nullglob                  # [bash] no match: the loop runs zero times
for f in ./*.log; do
  gzip -- "$f"
done

for f in ./*.log; do               # portable version, for sh
  [ -e "$f" ] || continue          # no match: the literal pattern does not exist, so skip it
  gzip -- "$f"
done

A glob produces each matching name as exactly one word (chapter 3), sorted, whatever characters it contains. Writing ./*.log rather than *.log means every name starts with ./, so a file called -rf.log cannot be mistaken for an option.

Rule Loop over a glob, and handle the no-match case with nullglob or a [ -e "$f" ] check.

Why The glob is evaluated by the shell, which knows exactly where each name begins and ends. ls output is text for people, and the loop would split it again on spaces and newlines.

Trap for f in $(ls *.txt) splits my report.txt into my and report.txt, and the loop then works on two files that do not exist (or, worse, on two other files that happen to have those names). It also mangles names containing * or ?, which are globbed a second time.

Rung 3 Recursion: ** and its limits

shopt -s globstar                      # [bash 4+] make ** match any depth of directories
for f in ./**/*.log; do …; done        # every .log file below here, at any depth

In bash 4 and later, ** with globstar matches files in all subdirectories. For anything beyond that, such as filtering by age or size, skipping directories, or very large trees, use find (chapter 10), which also works in every shell.

Rule Use ** only in scripts that check they run on bash 4 or later. Otherwise use find … -print0 with a NUL-delimited loop (rung 7).

Why globstar does not exist in bash 3.2, and without it ** silently means *.

Trap On macOS's /bin/bash, shopt -s globstar fails with invalid shell option name, the script carries on, and **/*.log matches only files exactly one directory down. In the lab it found top.log, a/mid.log and a/b/deep.log, and on the Mac only a/mid.log, with no further error. A backup or cleanup script then quietly skips most of the tree.

Rung 4 Reading lines and fields: while IFS= read -r

while IFS= read -r line || [ -n "$line" ]; do     # every line, exactly as written
  printf '%s\n' "$line"
done < file.txt

while IFS=: read -r user _ uid _ _ home shell; do  # split each line into fields on :
  echo "$user has uid $uid and home $home"
done < /etc/passwd

read -r -a words <<< "p q r"                       # split one line into an array [bash]

Each part of the first line is there for a reason:

Part Without it
IFS= leading and trailing spaces and tabs are trimmed from each line
-r backslashes are treated as escapes and disappear: lead\ing becomes leading
\|\| [ -n "$line" ] a last line with no newline at the end is read, but read reports failure, and the loop stops before using it
< file after done (see rung 5 for what goes wrong with cat file \|)

With field names, read splits on IFS and the last variable gets the rest of the line: read -r a b on 1 2 3 4 gives b='2 3 4'. _ is a conventional name for a field you do not need.

Rule Read lines with while IFS= read -r line || [ -n "$line" ]; do … done < file, and fields with IFS=<sep> read -r f1 f2 rest.

Why Plain read line was designed to read words typed by a person, not data. It trims, unescapes, and drops an unterminated last line.

Trap Whitespace separators merge. With IFS set to a tab, the line a<TAB><TAB>c gives y=c and z empty: the empty middle field disappeared, and every later column is shifted one place left. Non-whitespace separators keep empty fields (a,,c gives y empty and z=c). For tab-separated data with empty fields, use awk -F'\t' (chapter 12).

Rung 5 Piping into while: the subshell trap

count=0
grep ERROR app.log | while IFS= read -r line; do count=$((count + 1)); done
echo "$count"          # 0: the loop ran in a subshell, and its count died with it

count=0
while IFS= read -r line; do count=$((count + 1)); done < <(grep ERROR app.log)   # [bash]
echo "$count"          # the real number

mapfile -t lines < <(grep ERROR app.log)    # [bash 4+] every line into an array at once

In bash, dash and BusyBox, every part of a pipeline runs in its own child process, the while loop at the end included. Variables set inside it vanish when the pipeline ends (chapter 2: children never change parents). zsh runs the last part in the current shell, so the same line works in zsh, which is one more reason to test in bash.

Rule Feed a loop that must set variables with done < file or done < <(command), not with command | while.

Why With a redirection, the loop runs in the current shell, so its variables survive. <( ) (chapter 6) turns a command into something you can redirect from.

Trap The loop clearly runs, it prints lines, it increments, and afterwards the count is 0, with no error. shopt -s lastpipe bash 4+ makes bash run the last part in the current shell, but only when job control is off (in scripts), and macOS's bash 3.2 rejects the option. The redirect form works everywhere bash does.

Rung 6 Commands inside the loop that read stdin

while IFS= read -r host; do
  ssh -n "$host" uptime                 # -n: ssh reads nothing from stdin
done < hosts.txt

while IFS= read -r host; do
  some-tool "$host" < /dev/null         # the general fix: give the command empty input
done < hosts.txt

while IFS= read -r host <&3; do         # or: read the list on fd 3, leaving stdin alone
  ssh "$host" uptime
done 3< hosts.txt

Inside while … done < file, stdin of every command in the body is that file. A command that reads stdin, such as ssh, ffmpeg, docker exec -i, kubectl exec -i or cat, swallows the rest of the list.

Rule When a loop reads its list from stdin, run every command in the body that might read stdin with < /dev/null (or its own flag, like ssh -n), or read the list from fd 3.

Why The loop and the commands inside it share one stdin. Whatever the inner command reads, read never sees.

Trap The loop processes the first line and then stops, with status 0 and no error. In the lab, with cat standing in for ssh, a three-host list printed checking web1 and nothing else.

Rung 7 Names with newlines: NUL-delimited loops

find . -name '*.log' -print0 |
  while IFS= read -r -d '' f; do       # -d '': read up to a NUL byte instead of a newline [bash]
    gzip -- "$f"
  done

find . -name '*.log' -print0 | xargs -0 gzip --    # the same, without a loop (chapter 10)

A file name can contain any byte except / and NUL (the zero byte), including a newline. So the only separator that is safe for lists of names is NUL. find -print0 produces NUL-separated lists, and xargs -0 and read -d '' consume them.

Rule For lists of file names from a command, use NUL separators from end to end: -print0 into read -d '' or xargs -0.

Why A newline-separated list of names is ambiguous as soon as one name contains a newline, whether by accident, through a bug in another program, or on purpose by someone who wants your script to act on the wrong file.

Trap find . -name '*.txt' | while read -r f turned the single file new⏎line.txt into two names, ./new and line.txt. Neither exists, so any rm or mv on them fails or hits something else. read -d is bash: dash rejects it (Illegal option -d), and BusyBox accepts it.

Rung 8 Old forms you will meet

#!/bin/sh
# cleanup.sh, mid-2000s
for i in `seq 1 10`; do echo "pass $i"; done
for f in `ls /var/tmp/app`; do rm /var/tmp/app/$f; done
IFS='
'
for line in $(cat targets.txt); do deploy "$line"; done
ls *.bak | while read f; do mv $f old/; done
find /var/log -name '*.gz' -exec rm {} \;
find . -name '*.tmp' | xargs rm
set -- *.conf; [ "$1" = '*.conf' ] && echo "no configs"
jot 10
Old form What it does Why it existed Write instead Still runs?
for i in `seq 1 10` count no arithmetic loop in the Bourne shell for ((i = 1; i <= 10; i++)) bash, or seq with $( ) yes
for f in `ls dir` loop over files habit for f in dir/* yes, but breaks on spaces (rung 2)
IFS=$'\n' then for line in $(cat f) loop over lines no reliable read -r loop in early shells while IFS= read -r line (rung 4) yes. See Trap
ls \| while read f loop over files a glob yes, and loses variables (rung 5)
find … -exec cmd {} \; run cmd once per file the original -exec -exec cmd {} + (many files per run) yes, but one process per file: slow on big trees
find … \| xargs cmd run cmd on many files -exec … + came later find … -print0 \| xargs -0 cmd yes, but splits names on spaces and newlines
set -- *; [ "$1" = '*' ] detect "no match" no nullglob in sh nullglob bash, or [ -e "$1" ] yes
jot 10 print 1 to 10 BSD's counting tool BSD seq 10 (macOS has it too), or a loop macOS yes; Linux: jot: command not found

Rule In loops you inherit, replace ls, backticks, IFS juggling and plain xargs with globs, while IFS= read -r and NUL-separated find.

Why Every one of these works on the test data and fails on a name with a space, an empty line or a *, which real data contains sooner or later.

Trap IFS=$'\n'; for line in $(cat targets.txt) looks like a line loop, but it is still word splitting plus globbing. Empty lines disappear, and a line that is a pattern expands into file names. With the target *.key in the file, the loop deployed to secret.key, a file in the current directory, instead of the line that was written.

Decide

You have Loop with
the script's arguments for arg in "$@"
a fixed list, or an array for x in a b c, for x in "${arr[@]}"
files in one directory for f in ./*.ext with nullglob
files in a tree, with conditions find … -print0 \| while IFS= read -r -d '' f bash, or -exec … +
lines of a file while IFS= read -r line \|\| [ -n "$line" ]; do … done < file
lines of a command, setting variables … done < <(cmd) bash, or mapfile -t bash 4+
a count for ((i = 0; i < n; i++)) bash, or seq

The golden file

Renames files by extension. It loops over a glob with nullglob, protects names that start with a dash, never overwrites, and changes nothing until you add --apply. Its option loop is chapter 5's case, and its counts are chapter 4's arithmetic.

examples/manual/b07/rename-ext.sh
#!/usr/bin/env bash
# rename-ext.sh: change the extension of every matching file in a directory.
#
#   rename-ext.sh [--apply] [-C DIR] FROM TO        e.g.  rename-ext.sh jpeg jpg
#
# Without --apply it only prints what it would do. It never overwrites: a file whose new name
# is taken is skipped and reported. Names with spaces, leading dashes or newlines are fine.
# Exit status: 0 nothing skipped, 1 some files skipped, 2 usage error.
set -euo pipefail
shopt -s nullglob                          # no matching files: the loop runs zero times

usage() {
  echo "usage: rename-ext.sh [--apply] [-C DIR] FROM TO" >&2
  exit 2
}

apply=0
dir=.
while [ "$#" -gt 0 ]; do
  case $1 in
    --apply) apply=1; shift ;;
    -C) [ "$#" -ge 2 ] || usage; dir=$2; shift 2 ;;
    --) shift; break ;;
    -*) usage ;;
    *) break ;;
  esac
done
[ "$#" -eq 2 ] || usage
from=${1#.}                                # accept "jpeg" or ".jpeg"
to=${2#.}
if [ -z "$from" ] || [ -z "$to" ] || [ "$from" = "$to" ]; then usage; fi
cd -- "$dir" || exit 2

planned=0
skipped=0
for old in *."$from"; do                   # the * is unquoted so it globs; the extension is quoted
  [ -f "$old" ] || continue                # regular files only (not directories named x.jpeg)
  new=${old%."$from"}.$to                  # swap only the last extension
  if [ -e "$new" ]; then
    printf 'skip: %s -> %s (already exists)\n' "$old" "$new" >&2
    skipped=$((skipped + 1))
    continue
  fi
  if [ "$apply" -eq 1 ]; then
    mv -n -- "$old" "$new"                 # -n: never overwrite, even if the name appeared just now
    printf 'renamed: %s -> %s\n' "$old" "$new"
  else
    printf 'would rename: %s -> %s\n' "$old" "$new"
  fi
  planned=$((planned + 1))
done

if [ "$apply" -eq 1 ]; then
  printf '%d renamed, %d skipped\n' "$planned" "$skipped" >&2
else
  printf '%d to rename, %d skipped (dry run: add --apply)\n' "$planned" "$skipped" >&2
fi
[ "$skipped" -eq 0 ] || exit 1
cd /some/photos
rename-ext.sh jpeg jpg            # the plan, and a count; nothing changes
rename-ext.sh --apply jpeg jpg    # do it; status 1 if any file had to be skipped

Review checklist

Spot the bug

The on-call runbook says: "run ./check-hosts.sh hosts.txt; if it says 0, all is well." It has said 0 for a month.

examples/manual/b07/trap/check-hosts.sh
#!/usr/bin/env bash
# check-hosts.sh HOSTFILE: try to log in to every host listed in HOSTFILE (one per line),
# print the ones that are down, and finish with how many that was.
down=0
cat "$1" | while read host; do
  if ! ssh -o ConnectTimeout=3 "$host" true; then
    echo "DOWN: $host"
    down=$((down + 1))
  fi
done
echo "$down hosts down"

This file has 4 problems.

Show answers
  1. cat "$1" | while … runs the loop in a subshell, so down is incremented there and is still 0 outside. With down1 failing, it printed DOWN: down1 and then 0 hosts down (rung 5). Use while … done < "$1".
  2. ssh reads stdin, which is the host list, so it swallowed the remaining lines after the first host. With web1 first, no other host was ever checked (rung 6). Use ssh -n, or < /dev/null, or read the list on fd 3.
  3. The last line of hosts.txt has no newline, and read host without || [ -n "$host" ] drops it. With stdin fixed, down3 was still never checked (rung 4). Also use IFS= read -r.
  4. The script always exits 0, even when it prints DOWN lines, so nothing but a human reading the output can notice a failure (chapter 5, rung 2). End with [ "$down" -eq 0 ], so that the status says whether all hosts are up.

Shell manual · 8 · 10 min readFunctions and Arrays#

You've met it: docker_clean() { docker compose -f pantry/compose.yaml down -v --remove-orphans 2>/dev/null || true; … } in examples/reset.sh, the script that puts your machine back at any chapter's starting line · From Docker to Kubernetes

Functions let a script name a step and reuse it. Arrays let it hold a list whose items can contain anything, which is the one thing a plain string cannot do safely. Both behave differently from what you might expect from other languages. Functions return only a status, variables are global unless you say otherwise, and arrays are bash-only, with associative arrays missing entirely from macOS's bash. This chapter covers what you need to write both correctly and to read them in other people's scripts.

The ladder

Rung 1 Defining and calling, and the function's arguments

greet() {                         # define: name, (), then a { } body
  echo "hello, $1"                # $1, $2, … are the function's own arguments
  echo "called with $# arguments: $*"
}
greet world                       # call it like any command

wrapper() {
  docker "$@"                     # pass every argument on, each one whole
}
shift                             # drop $1; the others move down one place
set -- new "list here"            # replace all the positional parameters

Inside a function, $1, $2, $#, $@ and $* are the function's arguments, not the script's. $0 stays the script's name. The three ways to say "all the arguments" differ only in quoting, and only one is right for passing them on:

Form wrap "a b" c passes on
"$@" a b, c: two arguments, each whole
"$*" a b c: one argument, joined with the first character of IFS
$@ or $* unquoted a, b, c: split again (chapter 3)

Rule Pass arguments on with "$@", always quoted. Use "$*" only to build one string for a message.

Why "$@" is the one expansion that produces exactly the words that were given, however many there are and whatever they contain.

Trap A script runs top to bottom, and a function exists only after its definition has run. Calling greet on line 1 of a script that defines it on line 5 fails with greet: command not found, and the script carries on. Put all functions first and the main code last (often in a function called main, called on the final line: main "$@").

Rung 2 Returning: a status, or printed output

is_port_open() {                  # answer a yes/no question with the status
  (exec 3<>"/dev/tcp/$1/$2") 2>/dev/null
}
if is_port_open localhost 6379; then …; fi

image_tag() {                     # produce a value: print it, and let the caller capture it
  printf '%s:%s\n' "$1" "${2:-latest}"
}
tag=$(image_tag pantry v3)

set_default() {                   # assign into a variable the caller names [bash]
  printf -v "$1" '%s' "$2"
}
set_default region eu-west-1

return N ends the function with status N, which works exactly like a command's exit status (chapter 5). Without return, the status is that of the last command the function ran. A function produces values the way programs do: by printing them.

Rule Use the status for success or failure and yes or no, print the value and capture it with $( ), and keep printf -v for the rare case where a subshell would cost too much or lose state.

Why A function is a small command. The caller tests it with if and captures its output with $( ), the same as any program.

Trap return takes only a number from 0 to 255. return 300 gives 44 in bash and BusyBox (dash even reports 300), and return ok is an error: numeric argument required, status 2 in bash 5 and 255 in bash 3.2. A function that tries to "return" a count or a string returns nonsense. Print it instead.

Rung 3 Scope: global unless you say local

name=report
process() { name=tmp; }           # assigns the caller's (global) name
process
echo "$name"                      # tmp

process() {
  local name=tmp i                # these exist only during this call
  for i in 1 2 3; do …; done
}

Every variable a function assigns is global unless it is declared local. local is not in POSIX, but dash and BusyBox both have it. Bash scoping is dynamic: a function can see the local variables of whoever called it, as if they were global.

Rule Declare every variable a function uses with local at the top of the function, loop variables included.

Why Without it, a helper silently overwrites the caller's variables. Common names like i, f, line, name and status collide all the time.

Trap local x=$(cmd) hides cmd's failure: the status of that line is the status of local, which is 0. Under set -e the script carries on with an empty x. In the lab, local out=$(false) was followed by the next line running, and local o2; o2=$(false) stopped the script. Declare first, then assign.

Rung 4 Indexed arrays, and commands built as arrays

files=(a.txt "my notes.txt")       # an array of two items                     [bash]
files+=(c.txt)                     # append
echo "${#files[@]}"                # 3: how many
echo "${files[1]}"                 # my notes.txt: indexes start at 0
for f in "${files[@]}"; do …; done # each item, whole
echo "${files[@]:1:2}"             # a slice: 2 items from index 1
unset 'files[0]'                   # remove one (indexes do not move: 1 2 remain)

cmd=(docker run --rm -v "$dir:/data")    # a command, item by item
[ -n "$debug" ] && cmd+=(-e DEBUG=1)     # add options conditionally
cmd+=(pantry:latest)
"${cmd[@]}"                              # run it

Arrays bash are lists of strings in which each item keeps its spaces, quotes and glob characters, because "${arr[@]}" expands to one word per item, like "$@". Dash and BusyBox have no arrays. Their only list is set -- and "$@".

Rule Whenever you build a list (of files, of options, of a command line), use an array, and expand it as "${arr[@]}".

Why A string can only be split on whitespace, so any item containing a space breaks. An array keeps the boundaries you put in it.

Trap cmd="docker run -v $dir:/data img"; $cmd works until dir contains a space. Then -v /data/my and files:/data arrive as two arguments, and docker mounts the wrong path or rejects the command. The array version passed /data/my files:/data as one argument. On macOS's bash 3.2 there is one more catch. With set -u, expanding an empty array ("${a[@]}") fails with a[@]: unbound variable. Write ${a[@]+"${a[@]}"} in scripts that must run there.

Rung 5 Associative arrays, and checking the bash version

declare -A port=([web]=8080 [redis]=6379)    # keys are strings      [bash 4+]
port[db]=5432
echo "${port[redis]}"                        # 6379
for svc in "${!port[@]}"; do                 # the keys, in no particular order
  echo "$svc listens on ${port[$svc]}"
done
[ -n "${port[cache]+set}" ] || echo "no cache configured"   # test for a key

if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then     # guard: stop with a clear message
  echo "$0: needs bash 4 or later (this is $BASH_VERSION)" >&2
  exit 2
fi

declare -A makes a map from string keys to values. It is the most useful bash 4 feature, and the most common reason a script fails on a Mac.

Rule Use associative arrays when you need lookups by name, and put a version guard at the top of every script that does (the golden file's require_bash 4).

Why Without the guard, the script does not fail where the problem is. It fails much later, or not at all, with wrong data.

Trap On bash 3.2, declare -A prints invalid option, and if the script continues, the assignment that follows creates an ordinary indexed array. Every key is evaluated as arithmetic, where an unknown word is 0, so keys collapse into a few numbered slots. In the test, all three lookups (redis, web, db) returned 5432, and the map claimed to have one key. Nothing else reported an error.

Rung 6 Libraries: source, its path, and include guards

here=$(cd "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)   # the directory this file is in
. "$here/lib.sh"                                        # load helpers into this shell

# in lib.sh:
[ -n "${LIB_SH_LOADED:-}" ] && return 0                 # sourced already: do nothing
LIB_SH_LOADED=1

. file (or source file) runs the file in the current shell (chapter 2), which is how a script loads shared functions and settings. BASH_SOURCE[0] bash is the path of the file being read, even when that file was itself sourced, where $0 would be the outer script or the shell.

Rule Source libraries by a path built from BASH_SOURCE[0], give them an include guard, and prefix or carefully name their functions so they cannot clash with commands.

Why A library is used from many scripts, started from many directories. Its location has to be computed, never assumed.

Trap . lib.sh with no slash does not mean "the lib.sh next to this script". Bash looks for it in PATH first, and then in the current directory. Run from the script's own directory it works. From anywhere else it fails (lib.sh: No such file or directory), and with some other lib.sh on your PATH, it silently loads that one. In the lab, a stray lib.sh on PATH printed a different lib.sh, found on PATH.

Rung 7 Errors inside functions

die() { echo "error: $*" >&2; exit 1; }

load_config() {
  [ -r "$1" ] || { echo "cannot read $1" >&2; return 1; }   # report, then return early
  …
}
load_config "$cfg" || die "no usable configuration"

A function reports failure with a non-zero status and a message on stderr. The caller decides whether that ends the script. die is the common helper for "stop now".

Rule Inside functions, check each command that can fail and return 1 with a message. Do not rely on set -e inside them.

Why set -e (chapter 14) is switched off for everything that runs inside a condition: the command after if, while, !, and the left side of && and ||. If the function is called in one of those places, its body runs with set -e off, however deep the calls go.

Trap With set -e on, f() { false; echo "kept going"; } stops the script when f is called on its own line. Called as if f; then, it prints kept going and reports success, because the echo was the last command. The same function is safe or broken depending on how it is called.

Rung 8 Old forms you will meet

#!/bin/bash
# from a 2008 deploy library
function log {
  echo "[deploy] $*"
}
get_version() ( cd "$1" && cat VERSION )      # body in ( ): a subshell, so no local needed
find_host() { RET=$(grep -m1 "$1" hosts); }   # "returns" a string in a global
setvar() { eval "$1=\$2"; }                   # assign to a variable whose name is in $1
set -A servers web1 web2                      # ksh array syntax
. `dirname $0`/common.sh
Old form What it does Why it existed Write instead Still runs?
function name { … } define a function ksh syntax, accepted by bash name() { … } bash yes bash. Dash: syntax error
f() ( … ) a function whose body is a subshell gives it private variables and cd, before local existed local, or a ( ) inside the body only where needed yes, but it can never set anything for its caller
RET=… globals "return" a string functions can only return a number print it and capture with $( ), or printf -v yes, and any other function can overwrite it
eval "$1=\$2" assign to a variable named in $1 no indirect assignment in the Bourne shell printf -v "$1" '%s' "$2", or declare -n ref=$1 (namerefs, bash 4.3) bash 4+ yes, and it runs any code in $1 (chapter 3)
set -A arr … create an array ksh syntax arr=(…) ksh only. Bash: set: -A: invalid option
. `dirname $0`/lib.sh load a file from the script's directory $0 was the only way to know where the script was . "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh" yes, but see Trap

Rule Read function f {, subshell bodies and RET globals as pre-local habits, and replace eval assignments with printf -v when you touch them.

Why Each of these works around something bash now does directly: local variables, indirect assignment, arrays.

Trap $(dirname "$0") (and BASH_SOURCE too) gives the directory of the link when the script is run through a symbolic link, a common way to "install" a tool into ~/bin. The library is then looked for in ~/bin and not found. Resolve the link first with readlink -f (on macOS since 12.3, 2022), or install the library where the link is.

Decide

The function needs to give back Use
success or failure, yes or no its exit status: return 0 / return 1
one value (a string, a number) printf it, and the caller writes x=$(f …)
a value, without a subshell, into a variable the caller names printf -v "$name" … bash
several values print them one per line and read them with read or mapfile, or set documented globals
a list print it NUL- or newline-separated, or fill an array the caller names

The golden file

The helper library the remaining scripts share: logging to stderr, die, checks for required programs and the bash version, joining, and run, which shows a command, exactly quoted, before running it (or instead of running it, with DRY_RUN=1). It works on bash 3.2.

examples/manual/b08/lib.sh
# shellcheck shell=bash
# lib.sh: small helpers shared by Pantry's scripts. Sourced, not run:
#   . "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
# Works in bash 3.2 and later. Messages go to stderr, so stdout stays free for data.

# Refuse to be run as a program; sourcing it twice is harmless.
if ! (return 0 2>/dev/null); then
  echo "lib.sh: source this file, do not run it" >&2
  exit 2
fi
[ -n "${LIB_SH_LOADED:-}" ] && return 0
LIB_SH_LOADED=1

# The name shown in messages: the script that sourced us, unless the caller sets LIB_PROG.
LIB_PROG=${LIB_PROG:-${0##*/}}

# log MESSAGE...: one timestamped line on stderr.
log() {
  printf '%s %s: %s\n' "$(date +%H:%M:%S)" "$LIB_PROG" "$*" >&2
}

warn() { log "warning: $*"; }

# die MESSAGE...: report and stop the script with status 1.
# Inside $( ) it only ends that subshell, so call it from the main shell or check the status.
die() {
  log "error: $*"
  exit 1
}

# require_cmd NAME...: stop unless every named program is installed. Reports all missing ones.
require_cmd() {
  local cmd missing=0
  for cmd in "$@"; do
    if ! command -v "$cmd" >/dev/null 2>&1; then
      log "error: '$cmd' is required but not installed"
      missing=1
    fi
  done
  [ "$missing" -eq 0 ] || exit 1
}

# require_bash MAJOR [MINOR]: stop unless this bash is at least MAJOR.MINOR.
require_bash() {
  local major=$1 minor=${2:-0}
  if [ "${BASH_VERSINFO[0]}" -lt "$major" ] ||
    { [ "${BASH_VERSINFO[0]}" -eq "$major" ] && [ "${BASH_VERSINFO[1]}" -lt "$minor" ]; }; then
    die "needs bash $major.$minor or later; this is $BASH_VERSION"
  fi
}

# join_by SEP ITEM...: print the items separated by SEP (any string, not just one character).
join_by() {
  local sep=$1 out item
  shift
  if [ "$#" -eq 0 ]; then
    echo
    return 0
  fi
  out=$1
  shift
  for item in "$@"; do
    out=$out$sep$item
  done
  printf '%s\n' "$out"
}

# quote_args ARG...: print the arguments as one line you could paste back into a shell.
quote_args() {
  local arg q out=
  for arg in "$@"; do
    printf -v q '%q' "$arg"            # printf -v assigns to q instead of printing
    out=$out${out:+ }$q                # a space before every item except the first
  done
  printf '%s\n' "$out"
}

# run CMD [ARG...]: log the command, then run it with its arguments exactly as given.
# With DRY_RUN=1 it only logs. Returns the command's own status.
run() {
  if [ "${DRY_RUN:-0}" = 1 ]; then
    log "dry run: $(quote_args "$@")"
    return 0
  fi
  log "+ $(quote_args "$@")"
  "$@"
}
cd examples/manual/b08
bash -c 'LIB_PROG=demo; . ./lib.sh; run printf "<%s>\n" "a b" "*"'      # logs "+ printf \<%s\>\\n a\ b \*", then runs it
bash -c '. ./lib.sh; require_bash 4; echo "bash 4 or later"'             # on macOS /bin/bash: error, status 1

Review checklist

Spot the bug

Run as backup-sets.sh photos "music library". It reported a failure for photos, which had just been copied. A colleague on a Mac says it does not run at all.

examples/manual/b08/trap/backup-sets.sh
#!/usr/bin/env bash
# backup-sets.sh SET...: copy each named set of files into $BACKUP_ROOT,
# e.g.  backup-sets.sh photos "music library". Exits non-zero if any set failed.
set -e
root=${BACKUP_ROOT:-/backup}
declare -A dirs=([photos]="$HOME/Pictures" [documents]="$HOME/Documents" [music library]="$HOME/My Music")

copy_set() {
  known=0
  for name in "${!dirs[@]}"; do
    if [ "$name" = "$1" ]; then known=1; fi
  done
  [ "$known" -eq 1 ] || return 1
  cmd="cp -R ${dirs[$1]} $root/"
  $cmd
  touch "$root/.last-backup"
}

failed=0
for name in $@; do
  if copy_set $name; then
    echo "copied $name"
  else
    echo "FAILED $name"
    failed=$((failed + 1))
  fi
done
[ "$failed" -eq 0 ]

This file has 5 problems.

Show answers
  1. declare -A with no version check. On macOS's bash 3.2 it is an invalid option, and with set -e the script stopped at line 6 with status 2. Without set -e it continued with an indexed array whose keys had collapsed, and ${dirs[photos]} pointed at the Documents directory (rung 5). Add a BASH_VERSINFO guard.
  2. for name in $@ and copy_set $name are unquoted, so "music library" was split into music and library, two sets that do not exist (rung 1). Use "$@" and "$name".
  3. cmd="cp -R ${dirs[$1]} $root/"; $cmd is a command in a string. For $HOME/My Music it ran cp -R /home/learner/My Music /tmp/bk/ and failed on My and Music (rung 4). Use cp -R -- "${dirs[$1]}" "$root/", or an array.
  4. copy_set uses name and known without local. Its loop overwrote the caller's name, so the main loop reported FAILED photos for sets that had nothing to do with photos (rung 3).
  5. copy_set is called in an if, so set -e is off inside it. A failed cp was followed by touch, the function returned 0, and the script printed copied … for a set that was never copied (rung 7). Check the copy: cp … || return 1.

Shell manual · 9 · 11 min readProcesses and Signals#

You've met it: exec "$@" at the end of every entrypoint script, which replaces the shell with your program so the program becomes PID 1 and receives the signals · ENTRYPOINT, CMD and PID 1

Chapter 2 said every command is a process. This chapter is about several processes at once: subshells, background jobs, the signals that stop them, and cleaning up when they do. It is the shell side of what the Docker manual taught about PID 1 and graceful shutdown, and it is what lets a script run work in parallel and still tidy up after itself when someone presses Ctrl-C.

The model

( … ) $( … ) a | b cmd &

the child runs shell code

exec cmd (or the last stage of a pipeline)

exec cmd (no fork)

your shell (a process)

fork: a child process
a copy, with its own variables and cwd

subshell
changes stay inside it

the child becomes cmd
same PID, new program

the shell itself becomes cmd
nothing after exec runs

Two operations build everything here. fork makes a child process that is a copy of the shell. exec replaces a process's program with another, keeping the same process (and PID). A normal command is fork, then exec in the child. A subshell is fork without exec: the child keeps running shell code. exec cmd on its own is exec without fork: the shell becomes cmd, which is why exec "$@" makes your program PID 1 in a container. Because a child is a copy, nothing it does to its variables or its current directory reaches the parent (chapter 2).

The ladder

Rung 1 Subshells and grouping

( cd build && make )          # a subshell: the cd is undone when the ( ) ends
echo "still in $PWD"

{ echo "=== report ==="; date; df -h; } > report.txt   # grouping: one redirect for several commands

total=$(du -sb . | cut -f1)   # command substitution runs in a subshell, and its output is captured

( … ) runs its commands in a child shell. { …; } groups commands in the current shell, only so a redirection or a pipe can apply to all of them (note the spaces and the final ; or newline).

Rule Use ( … ) to contain a cd or a changed variable, and { …; } only to share a redirection.

Why The subshell gets its own copy of the directory and the variables, so whatever it changes is thrown away when it ends. That is exactly what you want around a cd.

Trap Anything set inside ( … ) is lost when it ends, and so is anything set in a while loop on the right of a pipe (chapter 7, same cause). ( cd src; VERSION=$(cat VERSION) ) leaves VERSION unset afterwards, with no error. If you need the value, use { …; }, or capture the subshell's output.

Rung 2 Background jobs: &, $! and wait

long-task &                   # run in the background; the shell does not wait
pid=$!                        # the PID of that job (only right after &)
echo "started $pid"
wait "$pid"                   # wait for it; $? is its exit status
echo "it finished with $?"

download a & download b & download c &   # three at once
wait                                      # wait for all background jobs

& starts a command without waiting. $! is the PID of the most recent one. wait PID waits for a specific job and reports its status; plain wait waits for all of them but gives status 0.

Rule Save each background PID in an array as you start the jobs, then wait on each one and check its status.

Why Plain wait tells you only that everything finished, not whether anything failed. To know which job failed, you have to wait for each PID.

Trap download a & download b & wait returns 0 even if both downloads failed, so the script carries on as though the files are there. wait "$pid" after a specific job gives that job's real status (143 if it was terminated, and so on). wait -n bash 4+ returns as soon as any one job finishes, which is how the golden file keeps a fixed number running; macOS's bash 3.2 does not have it.

Rung 3 Signals and exit codes

A signal is a message to a process. The ones that matter in scripts:

Signal Number Sent by Default
SIGINT 2 Ctrl-C stop; exit 130 (128 + 2)
SIGTERM 15 kill PID, docker stop, Kubernetes stop; exit 143 (128 + 15)
SIGHUP 1 terminal closed stop
SIGKILL 9 kill -9 stop at once, cannot be caught
SIGQUIT 3 Ctrl-\ stop and dump core
kill "$pid"            # send SIGTERM (polite: the program may clean up)
kill -INT "$pid"       # send SIGINT, as Ctrl-C does
kill -9 "$pid"         # send SIGKILL (last resort: no cleanup possible)
kill -0 "$pid"         # send nothing; just check whether the process exists (status 0 if it does)

A process killed by signal n reports exit status 128 + n (chapter 2). That is how wait and $? tell you a job was terminated rather than exiting on its own.

Rule Stop a process with plain kill (SIGTERM) and give it a moment. Use kill -9 only when it ignores that.

Why SIGTERM and SIGINT can be caught, so a program can flush its data and remove its temporary files first. SIGKILL cannot be caught, so the program stops mid-write, with no chance to clean up.

Trap kill -9 as a first resort leaves exactly the mess the program's own cleanup would have prevented: a half-written file, a stale lock, a container that never drained. Reach for SIGTERM first; kill -9 is what you send when SIGTERM did not work.

Rung 4 trap: cleaning up on the way out

tmp=$(mktemp -d)
trap 'rm -rf -- "$tmp"' EXIT          # runs however the script ends: normally, by error, or by signal

trap 'echo "interrupted" >&2; exit 130' INT    # Ctrl-C: message, then exit (which runs the EXIT trap)
trap 'exit 143' TERM

work_in "$tmp"

trap 'commands' SIGNAL… runs commands when a signal arrives, instead of the default. The pseudo-signal EXIT runs when the shell exits for any reason, which makes it the right place for cleanup.

Rule Create temporary files with mktemp, and remove them in an EXIT trap set right after. Add INT and TERM traps that stop any background children and then exit with the right code.

Why An EXIT trap runs on a normal end, on an error under set -e, and on a signal you turned into an exit. One trap covers every path out of the script, so nothing is left behind.

Trap Bash does run an EXIT trap when a SIGTERM or SIGINT ends the script: in the test, a cleanup-only script sent SIGTERM still removed its files and exited 143. But the trap runs as the shell dies, and a background child the shell started is not its concern, so that child keeps running. Trap INT and TERM explicitly to kill the children first (and to choose the exit status), as the golden file does. SIGKILL (9) runs no trap at all, ever: it cannot be caught.

Rung 5 exec: replace the shell

exec "$@"                              # become the given command (entrypoint scripts end like this)
exec mytool --config /etc/my.conf      # the shell is gone; nothing after this line runs
exec > >(tee -a run.log) 2>&1          # exec with only redirections changes fds, without replacing (chapter 6)

exec cmd replaces the shell process with cmd. The PID stays the same, and there is no extra shell left sitting between the caller and the program.

Rule End a wrapper script that sets things up and then hands off to one long-running program with exec that-program "$@".

Why Without exec, the shell stays as the parent, forwarding signals (or not) and taking up a process slot. With it, the program is the process, so docker stop and kill reach the program directly (the Docker manual's PID 1 problem).

Trap Nothing after exec cmd runs, by design. An exec in the middle of a script, or an EXIT trap you expected to clean up afterwards, silently never happens: exec replaces the shell, traps and all. Put exec last.

Rung 6 Timeouts and whole-group signals

timeout 30 slow-command            # [GNU] kill it after 30s; status 124 if it timed out
timeout -s KILL 30 slow-command    # SIGKILL instead of SIGTERM, for something that ignores TERM

( sleep 5 & wait ) &               # a job and its children; killing the group needs the leader

timeout GNU runs a command with a time limit. On timeout it sends SIGTERM and reports status 124.

Rule Bound any command that can hang (a network fetch, a lock wait) with timeout, and on macOS install GNU coreutils or use a watchdog in the script.

Why A command with no timeout that hangs makes the whole script hang, until a human notices or an outer CI timeout kills it with no useful message.

Trap timeout is GNU and is not on macOS by default: timeout: command not found, status 127. A script that relies on it fails on every Mac, and the failure is a missing command, not a timeout. brew install coreutils provides gtimeout; otherwise write the watchdog yourself (cmd & p=$!; (sleep 30; kill "$p" 2>/dev/null) & …).

Rung 7 Keeping a fixed number running

max=4
for item in "${items[@]}"; do
  while [ "$(jobs -pr | wc -l)" -ge "$max" ]; do   # wait for a free slot
    wait -n 2>/dev/null || sleep 0.1               # [bash 4+], else poll
  done
  process "$item" &
done
wait                                               # let the last ones finish

To run many tasks a few at a time, start a job, and before starting the next one, wait until fewer than max are running. jobs -pr lists the PIDs of still-running background jobs.

Rule Cap parallelism with a slot check (wait -n on bash 4.3+, a poll on 3.2), or hand the whole job to xargs -P (chapter 10).

Why Starting one background job per item, with no cap, can launch thousands at once. They exhaust memory, file descriptors or the remote service's connection limit, and finish slower than a handful at a time would.

Trap for … do fetch "$url" & done; wait with a long list opens every connection at once. The server starts refusing them, and your "parallel" fetch is slower and half-failed. Cap it. wait -n is bash 4+: on bash 3.2 it is invalid option, so the golden file polls with sleep there instead.

Rung 8 Old forms you will meet

#!/bin/sh
# watchdog.sh, mid-2000s
TMP=/tmp/build.$$
trap "rm -f $TMP" 0 1 2 15
( long-build ) & BUILD=$!
( sleep 300; kill $BUILD ) & TIMER=$!
wait $BUILD
PID=`ps ax | grep '[m]yserver' | awk '{print $1}'`
kill -HUP $PID
( cmd & )
nohup ./worker.sh &
Old form What it does Why it existed Write instead Still runs?
trap … 0 1 2 15 trap by number: EXIT, HUP, INT, TERM signal names came later trap … EXIT HUP INT TERM yes, but the numbers are easy to misread
/tmp/build.$$ a temp file named after the PID before mktemp mktemp or mktemp -d yes, but the name is predictable, so in a shared /tmp it is unsafe (see Trap)
( sleep 300; kill $PID ) & a homemade timeout no timeout command timeout 300 cmd GNU yes, and still needed on macOS, which has no timeout
ps ax \| grep '[m]yserver' find a process by name no pgrep. The [m] stops grep matching its own line pgrep -f myserver yes, but fragile: it matches arguments and paths too
grep -v grep drop grep's own line from ps output same, the clumsier way pgrep, or the [m] trick yes
kill -HUP $PID to reload ask a daemon to re-read its config HUP meant "reload" by convention the service's own reload (systemctl reload, an API) yes, where the program still honours it
( cmd & ) start cmd fully detached double fork, so it is not a job of this shell and wait ignores it setsid cmd GNU, or a supervisor yes
nohup cmd & survive the terminal closing terminals send HUP on logout nohup for a one-off; a service manager for anything lasting yes

Rule Replace $$ temp files with mktemp, ps | grep with pgrep, and homemade timeouts with timeout where it exists.

Why Each old form has a sharp edge: a race, a wrong match, a signal the program no longer honours. The modern tools close those edges.

Trap A fixed name like /tmp/build.$$ is predictable ($$ is just the PID), so in a directory everyone can write, such as /tmp, another user can create or point that name at a file they should not be able to touch before your script writes to it. mktemp instead creates a brand-new file with an unguessable name and mode 600 (owner-only), and tells you the name it chose. Use it for every temporary file.

Decide

You want Use
to contain a cd or a variable change ( … ) subshell
one redirection over several commands { …; } group
to run one thing in the background cmd & pid=$!, then wait "$pid"
a fixed number of parallel tasks the slot loop (rung 7), or xargs -P (chapter 10)
to clean up on every exit trap '…' EXIT, plus INT/TERM traps that exit
to hand off to one long program exec cmd "$@" as the last line
a time limit timeout N cmd GNU, or a watchdog on macOS

The golden file

Runs a command over many items, a few at a time, collects each run's output and status, and cleans up on any exit. It uses wait -n on bash 4.3+ and polls on 3.2, and turns Ctrl-C into a tidy stop. It reuses wait-for.sh from chapter 5. Chapter 14's pantry-ctl uses it to check many services at once.

examples/manual/b09/pmap.sh
#!/usr/bin/env bash
# pmap.sh: run a command once for each item, several at a time, and report how each run went.
#
#   pmap.sh [-j JOBS] COMMAND [ARG...] -- ITEM...
#   e.g.  pmap.sh -j 3 ./wait-for.sh -t 30 -- redis:6379 db:5432 web:8080
#
# Each run is COMMAND ARG... ITEM, and at most JOBS (default 4) run at once. Each run's output
# goes to its own temporary file and is printed, whole and in item order, when all are done.
# INT or TERM stops every run, removes the temporary files and exits 130 or 143.
# Exit status: 0 every run succeeded, 1 some failed, 2 usage error.
# Works in bash 3.2 (polls for a free slot) and uses wait -n where bash has it (4.3 and later).
set -euo pipefail

usage() {
  echo "usage: pmap.sh [-j JOBS] COMMAND [ARG...] -- ITEM..." >&2
  exit 2
}

jobs_max=4
if [ "${1:-}" = -j ]; then
  [ "$#" -ge 2 ] || usage
  jobs_max=$2
  shift 2
fi
case $jobs_max in '' | *[!0-9]* | 0) usage ;; esac

cmd=()                                     # everything before -- is the command and its arguments
while [ "$#" -gt 0 ] && [ "$1" != -- ]; do
  cmd+=("$1")
  shift
done
[ "$#" -gt 0 ] || usage                    # no -- at all
shift                                      # drop the --
if [ "${#cmd[@]}" -eq 0 ] || [ "$#" -eq 0 ]; then usage; fi
items=("$@")

tmp=$(mktemp -d)                           # one private directory for every run's output
pids=()

cleanup() { rm -rf -- "$tmp"; }
trap cleanup EXIT                          # runs on every exit below, including the signal ones

on_signal() {                              # $1: the status to exit with
  trap - INT TERM
  if [ "${#pids[@]}" -gt 0 ]; then
    kill "${pids[@]}" 2>/dev/null || true  # stop the runs; they are our direct children
    wait 2>/dev/null || true               # and let them finish dying before we remove files
  fi
  exit "$1"
}
trap 'on_signal 130' INT                   # 128 + 2
trap 'on_signal 143' TERM                  # 128 + 15

have_wait_n=0                              # wait -n arrived in bash 4.3
if [ "${BASH_VERSINFO[0]}" -gt 4 ] || { [ "${BASH_VERSINFO[0]}" -eq 4 ] && [ "${BASH_VERSINFO[1]}" -ge 3 ]; }; then
  have_wait_n=1
fi

running() { jobs -pr | wc -l; }            # how many of our background jobs are still running

i=0
for item in "${items[@]}"; do
  while [ "$(running)" -ge "$jobs_max" ]; do   # all slots busy: wait for one to free up
    if [ "$have_wait_n" -eq 1 ]; then
      wait -n || true                      # the status is collected below, by PID
    else
      sleep 0.1
    fi
  done
  "${cmd[@]}" "$item" > "$tmp/$i.out" 2>&1 &   # the command itself is the job: kill reaches it
  pids+=("$!")
  i=$((i + 1))
done

failed=0
for i in "${!items[@]}"; do                # in item order: wait for each run and collect its status
  if wait "${pids[$i]}"; then status=0; else status=$?; fi
  printf '== %s (status %d)\n' "${items[$i]}" "$status"
  cat -- "$tmp/$i.out"
  [ "$status" -eq 0 ] || failed=$((failed + 1))
done

printf '%d runs, %d failed\n' "${#items[@]}" "$failed" >&2
[ "$failed" -eq 0 ]
cd examples/manual/b09
./pmap.sh -j 2 sh -c 'echo "$1"; sleep 1' _ -- one two three four   # two at a time, ~2s total
./pmap.sh -j 3 ../b05/wait-for.sh -t 20 -- redis:6379 db:5432       # wait for several ports at once

Review checklist

Spot the bug

Cron runs this nightly to warm a report cache. The log always says it is fine, and the cache is always a day behind.

examples/manual/b09/trap/warm-cache.sh
#!/usr/bin/env bash
# warm-cache.sh: fetch the daily, weekly and monthly reports in parallel into ./cache,
# then build cache/index.txt from all of them. Exits non-zero if anything failed.
set -euo pipefail
mkdir -p cache
( cd cache )
for name in daily weekly monthly; do
  timeout 30 curl -fsS -o "$name.txt" "$REPORT_URL/$name.txt" &
done
cat ./*.txt | sort -u > index.txt
echo "index built: $(wc -l < index.txt) entries"

This file has 5 problems.

Show answers
  1. ( cd cache ) changes directory in a subshell that ends immediately, so every file is written in the current directory, not in cache/ (rung 1). Use cd cache || exit 1 in the main shell, or write full paths like cache/$name.txt.
  2. The three curls run in the background with &, but nothing waits for them. cat ./*.txt ran before any download finished, so index.txt was built from whatever was already there (rung 2). Add wait before the cat, and check each job's status.
  3. cat ./*.txt reads every .txt in the directory, including yesterday's files and anything unrelated, and (because of bug 1) the reports were written here too. The index was built from stale files, which is why it was a day behind (chapter 7, rung 2, plus bug 1). Read the exact files: cat cache/daily.txt cache/weekly.txt cache/monthly.txt.
  4. There is no set -o pipefail, and curl runs in the background, so a 404 (the monthly.txt that does not exist) never affected the script's status. It printed index built and exited 0 every night (chapter 6, rung 3). Collect each download's status with wait "$pid".
  5. timeout is GNU and missing on macOS, where every curl line failed with timeout: command not found before curl even ran, and the script still exited 0 (rung 6). Guard it (command -v timeout), or drop it in favour of curl --max-time.

Shell manual · 10 · 10 min readFinding Things#

You've met it: kubectl … describe "$pod" | awk '/^Events:/{e=1} e' | grep -i 'warning', pulling the last warning out of a Pod's events in k11's triage.sh · Debugging

Three tools answer almost every "where is it?" question. grep finds lines inside files. find finds files inside a tree. xargs runs a command on a list that another command produced. They are small, they compose, and each has one safe form and several that fail only on a file name with a space or a newline. This chapter gives the safe form of each, and shows what the unsafe one does.

The ladder

Rung 1 grep: a pattern, a result, and an exit status

grep ERROR app.log            # print every line that contains ERROR
grep -i error app.log         # case-insensitive
grep -w id app.log            # whole word: matches "id", not "void" or "ident"
grep -F '1.2.3' versions      # -F: fixed string, no regex (the dots are literal)
grep -E 'cat|dog' pets        # -E: extended regex (| means "or"); basic regex needs \|
if grep -q ERROR app.log; then echo "found errors"; fi   # -q: say nothing, just set the status

grep prints the matching lines, but its exit status is the part scripts use: 0 if it matched something, 1 if it matched nothing, 2 if it could not read a file. That makes grep -q a test, like any other command after if (chapter 5).

Rule Use grep -q PATTERN FILE as a yes/no test, and reach for -F when the pattern is a literal string and -E when you want |, + or ?.

Why Plain grep reads its pattern as a basic regular expression, where . and * are already special. -F turns that off; -E turns on the richer syntax. Picking the right one means the pattern matches what you think it does.

Trap The exit status has three values, not two. grep PATTERN missing.txt returns 2, not 1, and under set -e (chapter 15) that stops your script where you expected "no match, carry on". Test for what you mean: if grep -q …; then handles the match, and || [ $? -eq 1 ] tells a real no-match apart from an error.

Rung 2 Which lines, which files: the flags you will actually use

grep -rn ERROR .              # -r recurse into the tree, -n show line numbers
grep -rl ERROR .              # -l just the file names that contain a match
grep -c ERROR app.log         # -c a count of matching lines, not the lines
grep -o 'v[0-9]*' app.log     # -o only the matching text, one per line
grep -v DEBUG app.log         # -v invert: lines that do NOT match
grep -rn --include='*.py' TODO .   # [GNU] limit a recursive search by name

These flags combine. grep -rln gives numbered file names; grep -rc gives a per-file count.

Rule Add -r to search a tree, -l when you want files not lines, -c for counts, -o to extract just the match, and -v to keep the lines that miss.

Why Each flag turns grep into a different question. -l answers "which files", -c answers "how many", -o turns grep into a crude field extractor, and -v filters a stream (the grep -v ' true ' in triage.sh drops the healthy Pods).

Trap -o prints one line per match, not per input line, so a line with three matches becomes three lines; piping that into wc -l counts matches, not lines. And --include/--exclude are GNU: BSD grep on macOS takes --include too, but BusyBox grep does not, so a portable recursive filter is find … -name '*.py' | xargs grep (rung 6) instead.

Rung 3 Regexes you can rely on, and the one that is not portable

grep '^ERROR'  app.log        # ^ anchors to the start of the line
grep 'done$'   app.log        # $ anchors to the end
grep '[0-9]\{3\}' app.log     # basic regex: braces need backslashes
grep -E '[0-9]{3}' app.log    # extended regex: braces are bare
grep -E '^(GET|POST) ' access.log       # grouping with ( ) needs -E
grep '[[:space:]]' app.log    # POSIX class: whitespace, locale-correct
grep -P '\d{3}' app.log       # [GNU] Perl regex: \d, \b, lookaround

Two regex dialects ship with grep: basic (the default) and extended (-E). They differ only in which characters need a backslash. In basic, +, ?, {, |, ( and ) are literal unless escaped; in extended they are operators unless escaped. POSIX character classes like [[:digit:]] work in both and are clearer than [0-9] once locales are involved.

Rule Anchor with ^ and $, prefer -E when a pattern has groups or alternation, and use [[:class:]] names over hand-built ranges.

Why An unanchored grep 0 matches "10:03" and "v0.9"; ^0 or -w 0 says what you meant. -E keeps the pattern readable instead of a thicket of backslashes.

Trap grep -P (Perl regex, the source of \d, \b and lookahead) is GNU only. On macOS and BusyBox it fails with invalid option -- P and exit status 2, so a script that greps \d works in CI and dies on a laptop. Write the POSIX class [0-9] or [[:digit:]] with -E instead; it runs everywhere.

Rung 4 find: the expression that selects files

find . -name '*.log'              # by name, anywhere below .  (quote the glob!)
find . -type f -name '*.log'      # regular files only (-type d for directories)
find . -mtime -1                  # changed in the last 24 hours (-1 = less than 1 day ago)
find . -size +10M                 # larger than 10 megabytes  [GNU/BSD extension: M]
find . -name '*.log' -print       # -print is the default action
find logs -maxdepth 1 -name '*.log'   # do not descend below logs/ itself

find DIR TESTS walks the tree under DIR and, for each file, checks the tests left to right. -name matches the base name against a glob, so the glob must be quoted or the shell expands it first (chapter 3). -mtime -N means "less than N days old"; +N means "more than".

Rule Start with the directory, then -type f, then the tests; always quote the -name pattern.

Why find does the walking and the matching itself, so it is not fooled by the number of files or by odd names the way a glob-and-loop can be. Quoting -name '*.log' hands the * to find, which matches it against every base name at every depth, not just the current directory.

Trap An unquoted find . -name *.log lets the shell expand *.log first. If exactly one .log file is in the current directory, find sees that name and searches for it alone; if none is, bash may pass the literal *.log or error. Either way the result is wrong and silent. Always write -name '*.log'.

Rung 5 Combining tests and acting on the result

find . -type f \( -name '*.log' -o -name '*.txt' \)   # this OR that: group with \( \)
find . -name node_modules -prune -o -type f -print    # skip a whole directory
find . -name '*.tmp' -delete                          # [GNU/BSD] remove the matches
find . -name '*.log' -exec gzip {} \;                 # run gzip once per file ({} is the name)
find . -name '*.log' -exec gzip {} +                  # run gzip once for many files

Tests are joined with an implicit and; -o is or, and \( \) groups them (the backslashes keep the shell off the parentheses). An action at the end does something with each match: -print (the default), -delete, or -exec.

Rule Prefer -exec cmd {} + over -exec cmd {} \; whenever the command accepts many files at once.

Why \; runs the command once per file — a thousand files, a thousand processes. + passes as many names as fit onto one command line, so a thousand files become one or two runs. Both pass names as arguments, so spaces and newlines in them are safe.

Trap -delete and -exec rm act on everything the expression selected, and a too-broad expression deletes too much — there is no undo (chapter 1). Run the expression with -print first and read the list, then swap in the action. Also, -delete implies -depth, which can reorder a prune, so test it on a copy.

Rung 6 find into xargs: one list, one safe separator

find . -name '*.log' -print0 | xargs -0 gzip --       # NUL-separated: the only safe way
find . -name '*.log' -print0 | xargs -0 -n1 gzip --   # -n1: one file per gzip run
find . -name '*.log' -print0 | xargs -0 -P4 gzip --   # -P4: four runs in parallel [GNU/BSD]
find . -type f -print0 | xargs -0 -I{} cp -- {} /backup/   # -I{}: put the name here, not at the end

xargs reads items on its input and appends them to a command. find -print0 ends each name with a NUL byte, the one byte a file name cannot contain, and xargs -0 splits on exactly that. Together they carry any name safely.

Rule Join find and xargs only through -print0 … xargs -0. Use -I{} when the name goes in the middle of the command, and -P to run in parallel.

Why A file name can hold any byte but / and NUL (chapter 7). NUL is therefore the only separator that is never part of a name, so it is the only one that cannot split one name into two.

Trap Bare find … | xargs cmd splits on spaces, tabs and newlines. Given b c.txt, xargs passed b and c.txt as two names, and the command reported grep: b: No such file or directory — the same failure in GNU, BSD and BusyBox. Worse, a name with a newline can inject a second argument on purpose. Always -print0 … -0.

Rung 7 Old forms you will meet

#!/bin/sh
# scan.sh, inherited
egrep 'cat|dog' pets
fgrep '1.2.3' versions
grep pat *.log
for f in `find . -name '*.log'`; do gzip $f; done
find . -name '*.log' | xargs gzip
find /var/tmp -name '*.tmp' -exec rm {} \;
which gzip
Old form What it does Why it existed Write instead Still runs?
egrep extended-regex grep separate programs before -E grep -E yes, but GNU grep 3.8 (2022) warns it is obsolescent
fgrep fixed-string grep separate programs before -F grep -F yes, with the same warning
grep pat *.log search listed files before -r was common grep -r pat . yes, but breaks on no match or a -name
for f in `find …` act on each file before -exec +/xargs find … -exec … +, or -print0 \| xargs -0 yes, but splits names on spaces (chapter 7)
find … \| xargs cmd act on a list -exec … + came later (findutils) find … -print0 \| xargs -0 cmd yes, but splits names; see Trap
find … -exec rm {} \; one process per file the original -exec -delete, or -exec rm {} + yes, but slow on big trees
which gzip locate a program a csh script, not a builtin command -v gzip (chapter 2) yes, but not POSIX and often wrong

Rule In searches you inherit, turn egrep/fgrep into grep -E/-F, backtick-find loops and bare xargs into -print0 | xargs -0, and -exec … \; into +.

Why Each old form works on the files in the test and fails on a name with a space, an empty match or a dash, which real trees contain eventually.

Trap grep pat *.log seems harmless until a directory has no .log file: the *.log stays literal and grep searches a file actually named *.log (usually "No such file"), or, if one .log file is also named like an option such as -v.log, that name becomes a flag. grep -r pat . or grep pat ./*.log avoids both.

Decide

You want Reach for
lines matching a pattern, in known files grep -E PATTERN files
a yes/no "does it appear?" grep -q PATTERN file (use the exit status)
matches anywhere in a tree grep -rn PATTERN .
just the file names that match grep -rl PATTERN .
files selected by name, age, size or type find DIR -type f TESTS
run a command on each found file find … -exec cmd {} +
run a command on a produced list find … -print0 \| xargs -0 cmd
a literal string, dots and all grep -F

The golden file

Finds log files under a directory and, optionally, only those whose contents match a pattern. It is read-only. It carries names NUL-separated end to end, so a space, a leading dash or a newline in a name is handled, and it reports its count on stderr so the paths on stdout stay clean for a pipe.

examples/manual/b10/find-logs.sh
#!/usr/bin/env bash
# find-logs.sh: find log files under a directory, safely for any file name.
#
#   find-logs.sh [-C DIR] [-n GLOB] [-d DAYS] [-g REGEX]
#
#   -C DIR    search under DIR (default: .)
#   -n GLOB   match this file-name pattern (default: *.log)
#   -d DAYS   only files changed in the last DAYS days
#   -g REGEX  only files whose contents match this extended regex
#
# Prints one matching path per line, and a count on stderr. Read-only: it finds
# and greps, and changes nothing. A name may hold any byte but / and NUL, so the
# matches are carried NUL-separated from end to end (chapter 7).
# Exit status: 0 at least one match, 1 no match, 2 usage error.
set -euo pipefail

usage() {
  echo "usage: find-logs.sh [-C DIR] [-n GLOB] [-d DAYS] [-g REGEX]" >&2
  exit 2
}

dir=.
glob='*.log'
days=
regex=
while [ "$#" -gt 0 ]; do                     # option loop by hand; getopts is chapter 15
  case $1 in
    -C) [ "$#" -ge 2 ] || usage; dir=$2; shift 2 ;;
    -n) [ "$#" -ge 2 ] || usage; glob=$2; shift 2 ;;
    -d) [ "$#" -ge 2 ] || usage; days=$2; shift 2 ;;
    -g) [ "$#" -ge 2 ] || usage; regex=$2; shift 2 ;;
    --) shift; break ;;
    -*) usage ;;
    *) break ;;
  esac
done
[ "$#" -eq 0 ] || usage
[ -d "$dir" ] || { echo "find-logs.sh: not a directory: $dir" >&2; exit 2; }

# find's tests go in an array, so each stays one word whatever DAYS holds (chapter 8).
tests=(-type f -name "$glob")
[ -n "$days" ] && tests+=(-mtime "-$days")

# Collect matches NUL-safe. find -print0 emits a NUL after each name; read -d '' stops at it.
# With -g, keep only files whose contents match: grep -qE is given the name as an argument,
# so a space or newline in it is harmless, and it stays portable (grep -lZ means NUL output
# on GNU but prints a newline on BSD, so we do not rely on it).
files=()
while IFS= read -r -d '' f; do
  if [ -n "$regex" ]; then
    grep -qE -e "$regex" -- "$f" 2>/dev/null || continue
  fi
  files+=("$f")
done < <(find "$dir" "${tests[@]}" -print0 2>/dev/null)

printf '%s\n' ${files[@]+"${files[@]}"}      # guard: an empty array under set -u (bash 3.2)
printf '%d file(s)\n' "${#files[@]}" >&2
[ "${#files[@]}" -gt 0 ]
find-logs.sh -C /var/log -d 7                 # *.log changed in the last week
find-logs.sh -C . -n '*.conf' -g 'ERROR'      # .conf files that mention ERROR

Review checklist

Spot the bug

A nightly job is meant to compress yesterday's logs and report how many it found with errors. Operators say it sometimes compresses the wrong files and the error count is "always a bit off".

examples/manual/b10/trap/archive-logs.sh
#!/bin/bash
# archive-logs.sh DIR: gzip every *.log under DIR, and print how many contain ERROR.
dir=$1
errors=`grep -l ERROR $dir/*.log | wc -l`
for f in `find $dir -name *.log`; do
  gzip $f
done
echo "$errors logs had errors"

This file has 5 problems.

Show answers
  1. grep -l ERROR $dir/*.log is unquoted and uses a glob, so a log named -v.log becomes an option and a name with a space splits; and if there are no .log files the glob stays literal and grep errors. Use find "$dir" -name '*.log' -print0 | xargs -0 grep -lZ or a NUL loop, and quote "$dir".
  2. for f in `find $dir -name *.log` is unquoted twice over: the shell expands *.log before find sees it (rung 4), and the backtick result is then split on spaces and newlines (rung 6), so names with spaces are mangled and the wrong files are gzipped. Use find "$dir" -name '*.log' -print0 | xargs -0 gzip --.
  3. gzip $f is unquoted, so a file called a b.log is passed as two arguments. Quote it: gzip -- "$f", with -- so a leading-dash name is not read as a flag.
  4. The error count is taken before anything is compressed, but it counts .log files while the loop then renames them to .log.gz; run once it is right, but any new run recounts stale state. More to the point, counting with grep -l … | wc -l over a bare glob (problem 1) is what makes it "a bit off". Count with find … -print0 | xargs -0 grep -l | wc -l, or better grep -rlZ ERROR "$dir" | grep -zc ..
  5. $1 is used unquoted as $dir throughout and is never checked; with no argument, $dir is empty and the globs become /*.log, searching the filesystem root. Add [ -d "${1:-}" ] || { echo "usage: archive-logs.sh DIR" >&2; exit 2; } and quote "$dir" (chapter 4).

Shell manual · 11 · 8 min readsed#

You've met it: sed -i 's/\r$//' x.sh, stripping the carriage returns off a file saved with Windows line endings · Every Command Is a Process

sed is a stream editor: it reads a file line by line, applies a short program to each line, and prints the result. It is the fastest way to change text in a pipe or across files, and it is the tool people most often use to quietly destroy a file. This chapter is the small part of sed you will write — substitution, addresses, and editing files in place — and the one command that truncates your file to nothing.

The model

sed works one line at a time, in a loop you do not write:

      ┌─► read next line into the pattern space
      │        │
      │        ▼
      │   run the script's commands on it
      │        │
      │        ▼
      │   auto-print the pattern space      (-n suppresses this step)
      │        │
      └────────┘   repeat for the next line

Each line is read into a workspace called the pattern space. Your script's commands run on it in order. Then, unless you passed -n, sed prints the pattern space and starts again with the next line. Every sed surprise comes from this loop: s/// changes the pattern space, -n turns off the automatic print so only an explicit p shows anything, and an address decides which lines a command runs on. Hold the loop in mind and the rest is detail.

The ladder

Rung 1 Substitution: s/old/new/ and its flags

sed 's/red/green/'   file     # replace the FIRST red on each line with green
sed 's/red/green/g'  file     # g: every red on the line, not just the first
sed 's/red/green/2'  file     # just the 2nd red on each line
sed 's/red/green/gI' file     # [GNU] g + case-insensitive (I); BSD spells it gi
sed 's/.*/[&]/'      file     # & stands for the whole match: wraps each line in [ ]
sed -E 's/(\w+) (\w+)/\2 \1/' file   # \1 \2 are captured groups: swap two words

s/pattern/replacement/flags is the command you will write most. In the replacement, & means "the text that matched" and \1…\9 mean the capture groups \( \) (basic) or ( ) (-E).

Rule Write s/old/new/ for the first match on a line and add g for all of them; use & and \1 to keep parts of what you matched.

Why Without g, sed replaces only the first match per line, which is usually a bug when you meant "all". With & you can wrap or duplicate a match without retyping it.

Trap The replacement is not a second regex: &, \ and the delimiter are special there, but ., * and [ are literal. And a bare s/a.b/x/ matches a-anything-b, because . is special on the left. To match a literal dot, escape it (a\.b) — the source of many "it replaced too much" surprises.

Rung 2 Addresses: which lines a command runs on

sed '3d'          file        # delete line 3
sed '$d'          file        # delete the last line ($ = last)
sed '/^#/d'       file        # delete every line that starts with #
sed '2,5s/x/y/'   file        # substitute only on lines 2 through 5
sed '/BEGIN/,/END/d' file     # delete the block from a BEGIN line to an END line
sed '/^$/!d'      file        # ! negates: delete every line that is NOT empty
sed '1~2d'        file        # [GNU] delete every 2nd line starting at 1 (step)

Any command can be prefixed with an address that restricts it to some lines: a line number, $ for the last line, a /regex/, or a start,end range (numbers or regexes). A ! after the address inverts it.

Rule Put an address before a command to scope it; use /regex/,/regex/ for a block and ! to mean "every line except".

Why Without an address, a command runs on every line. Addresses are how you say "only the header", "only inside this section", or "everywhere but the comments".

Trap A /start/,/end/ range opens on the first start and closes on the next end; if end never appears, the range runs to the end of the file. And the step form 1~2 and case-insensitive address flags are GNU: on macOS and BusyBox they error, so a cleanup script that deletes "every other line" with 1~2d does nothing portable.

Rung 3 Printing selectively: -n, p, d, q

sed -n '5p'        file       # print ONLY line 5 (-n: no auto-print; p: print)
sed -n '10,20p'    file       # print lines 10-20, like a ranged head/tail
sed -n '/ERROR/p'  file       # print only matching lines (a grep, basically)
sed '/^#/d'        file       # drop comment lines (auto-print keeps the rest)
sed '100q'         file       # print up to line 100, then quit (fast on huge files)
sed -n '/A/,/B/p'  file       # print the block from A to B

-n turns off the automatic print, so nothing appears unless you ask with p. That inversion — -n plus p — is how sed prints a selection. q quits immediately, which is how you stop reading a 10 GB file after line 100.

Rule Pair -n with p to print a selection, use d (with auto-print on) to drop a selection, and q to stop early.

Why -n '…p' and '…d' are two routes to the same place — keep these, or drop those — and the clearer one depends on whether you are keeping few or many. q turns sed into an early-exit reader.

Trap sed -n 's/x/y/p' prints only the lines where the substitution happened (the p flag on s prints the pattern space when it changed). People reach for that to "show what changed", then forget the -n and get every line printed, with the matches shown twice.

Rung 4 Delimiters and the two regex dialects

sed 's|/usr/local|/opt|g' file      # | as the delimiter, so / needs no escaping
sed 's#http://#https://#'  file      # any character after s can be the delimiter
sed  's/[0-9]\{3\}/NNN/g'  file      # basic regex: interval braces need backslashes
sed -E 's/[0-9]{3}/NNN/g'  file      # extended regex (-E): braces and ( ) are bare
sed -e 's/a/A/' -e 's/b/B/' file     # two commands with -e
sed 's/a/A/; s/b/B/'       file      # or separated by ;

The character right after s is the delimiter; pick one that does not appear in your text and you never escape it. sed reads basic regexes by default and extended ones with -E; as with grep, they differ only in which characters need a backslash.

Rule Change the delimiter when the pattern is full of slashes, use -E when it has { }, ( ), + or |, and chain commands with ; or -e.

Why s|…|…| reading a path is far clearer than s/\/usr\/local/\/opt/. -E keeps a real regex readable instead of a hedge of backslashes.

Trap -E is portable (GNU, BSD and BusyBox), but GNU's old -r spelling of the same thing is GNU only, and the escapes \+, \?, \| that work as operators in GNU basic regex are literal characters in POSIX basic regex. Write -E and bare + ? |; it means the same thing everywhere.

Rung 5 Editing files in place, portably

sed -i    's/old/new/g' file        # [GNU] edit in place, no backup
sed -i.bak 's/old/new/g' file       # [GNU] edit in place, keep file.bak
sed -i '' 's/old/new/g' file        # [BSD] the SAME idea; the '' is a required empty suffix

# portable: write a temp file in the same directory, then rename it over the original
tmp=$(mktemp "$file.XXXXXX")
sed 's/old/new/g' "$file" > "$tmp" && mv -- "$tmp" "$file"

There is no portable sed -i. GNU takes an optional attached suffix (-i or -i.bak); BSD/macOS requires the suffix as a separate argument, so sed -i '' on BSD and sed -i on GNU mean "no backup", and each is a syntax error to the other.

Rule In a script that must run on Linux and macOS, do not use sed -i. Write to a temporary file in the same directory and mv it over the original.

Why mv within a directory is an atomic rename (chapter 6): the file is either the old content or the new, never a half-written mix, even if the machine loses power mid-write. sed -i itself does the same dance internally — it just spells its arguments differently on each system.

Trap sed 's/old/new/' file > file empties the file. The shell opens file for writing (truncating it to zero) before sed runs, so sed reads nothing. In the lab, sed 's/o/0/' f > f left f empty with no error at all. Never redirect a command's output onto its own input; use a temp file and mv.

Rung 6 Old forms you will meet

#!/bin/sh
# patch-config.sh, inherited
sed -i 's/8080/9090/' app.conf
sed -n '1,5p' app.conf
sed 's/foo/bar/' app.conf > app.conf
cat app.conf | sed 's/foo/bar/'
sed 's/[0-9]\+/N/g' app.conf
sed 'y/abc/ABC/' app.conf
Old form What it does Why it existed Write instead Still runs?
sed -i 's/…/…/' f edit in place GNU's own spelling temp file + mv for portability (rung 5) GNU yes; macOS: invalid command code
sed -n '1,5p' first five lines before head was everywhere head -n 5 yes, both fine
sed '…' f > f "edit" a file a mistake, not an idiom temp file + mv yes, and it empties f (rung 5)
cat f \| sed … run sed on a file habit sed … f yes, but a needless cat
sed 's/[0-9]\+/N/g' + in basic regex GNU basic-regex extension sed -E 's/[0-9]+/N/g' GNU yes; BSD/BusyBox: \+ is a literal +
y/abc/ABC/ transliterate chars sed's built-in tr tr abc ABC for whole streams yes

Rule Replace sed -i with a temp-file rename for portable scripts, sed '…' f > f with the same (it is a data-loss bug), and GNU \+ in basic regex with -E.

Why Each works on the author's machine and breaks elsewhere: sed -i on a Mac, \+ on BSD, and f > f everywhere, silently.

Trap sed 's/[0-9]\+/N/g' on macOS and BusyBox does not match "one or more digits"; \+ there is a literal plus, so the command matches a digit followed by a +, replaces almost nothing, and the script reports success. Use -E 's/[0-9]+/N/g', which means the same on all three.

Decide

You want Use
replace text in a stream sed 's/old/new/g'
replace a literal string with metacharacters sed with the chars escaped, or a bash substitution ${v//old/new} (chapter 4)
print a range of lines sed -n 'A,Bp' (or head/tail)
delete matching lines sed '/regex/d'
edit a file in place, one system sed -i (GNU) or sed -i '' (BSD)
edit a file in place, portably temp file + mv (the golden)
more than a line at a time, or arithmetic awk (chapter 12)

The golden file

Replaces one version string with another across files. It is a dry run by default (a unified diff of what would change), edits in place only with --apply, and does it portably through a temporary file and mv rather than sed -i. It treats the old and new strings literally, escaping the characters that are special on each side of s///.

examples/manual/b11/bump-version.sh
#!/usr/bin/env bash
# bump-version.sh: replace one version string with another across files, portably.
#
#   bump-version.sh [--apply] OLD NEW FILE...     e.g.  bump-version.sh 1.2.3 1.3.0 README.md deploy.yaml
#
# Without --apply it prints a unified diff of what would change and touches nothing.
# With --apply it rewrites each file through a temporary file and mv, never `sed -i`
# (GNU `sed -i` and BSD `sed -i ''` are not the same command), so it behaves the same
# on Linux and macOS and never leaves a half-written file.
# Exit status: 0 something changed (or would), 1 nothing matched, 2 usage error.
set -euo pipefail

usage() {
  echo "usage: bump-version.sh [--apply] OLD NEW FILE..." >&2
  exit 2
}

apply=0
if [ "${1:-}" = --apply ]; then apply=1; shift; fi
[ "$#" -ge 3 ] || usage
old=$1 new=$2
shift 2

# OLD goes on the left of s///, where . [ ] * ^ $ \ and the / delimiter are special;
# NEW goes on the right, where \ & and / are special. Escape each for its side so a
# version like 1.2.3 matches literally and does not act as a regex.
# The class lists ] first and keeps [ away from . : = so BSD sed does not read a collating symbol.
lhs=$(printf '%s' "$old" | sed 's|[].[\\*^$/]|\\&|g')
rhs=$(printf '%s' "$new" | sed 's|[\\&/]|\\&|g')
script="s/$lhs/$rhs/g"

changed=0
for f in "$@"; do
  if [ ! -f "$f" ]; then
    echo "bump-version.sh: no such file: $f" >&2
    continue
  fi
  if ! grep -qF -- "$old" "$f"; then
    continue                                 # nothing to do; leave the file untouched
  fi
  changed=1
  if [ "$apply" -eq 1 ]; then
    tmp=$(mktemp "$f.XXXXXX")                 # same directory, so mv is an atomic rename
    cp -p -- "$f" "$tmp"                      # -p copies the mode; redirection below keeps it
    sed "$script" "$f" > "$tmp"               # overwrite the content, not the permissions
    mv -- "$tmp" "$f"
    echo "updated $f"
  else
    # dry run: show the change without writing. diff exits 1 when files differ; that is fine.
    diff -u "$f" <(sed "$script" "$f") || true
  fi
done

[ "$changed" -eq 1 ]
bump-version.sh 1.2.3 1.3.0 README.md chart/values.yaml    # show the diff; change nothing
bump-version.sh --apply 1.2.3 1.3.0 README.md chart/values.yaml   # rewrite them in place

Review checklist

Spot the bug

A release script is supposed to stamp the new version into three files and keep a backup. On the Mac it "does nothing but print errors"; in CI it empties a file.

examples/manual/b11/trap/stamp.sh
#!/bin/bash
# stamp.sh VERSION: write VERSION into the three release files, with backups.
version=$1
for f in VERSION chart/values.yaml README.md; do
  sed -i 's/version: .*/version: '$version'/' $f
  sed 's/{{VERSION}}/'$version'/' $f > $f
done
echo "stamped $version"

This file has 4 problems.

Show answers
  1. sed -i 's/…/…/' $f with no suffix is GNU-only. On macOS, -i takes the next argument as the backup suffix, so s/version: .*/…/ becomes the suffix and $f the script, giving invalid command code and changing nothing (rung 5). Use a temp file and mv, or branch on the system.
  2. sed 's/{{VERSION}}/…/' $f > $f redirects over its own input: the shell truncates $f to empty before sed reads it, so the file is destroyed (rung 5). Write to a temp file and mv it into place.
  3. $version and $f are unquoted. A version like 1.0 beta or a path with a space splits into extra arguments, and $version is also interpolated straight into the sed script, where a / or & in it would break or corrupt the command (rung 1, chapter 4). Quote them, and keep the value out of the script text.
  4. The comment promises backups, but neither command makes one (and -i without a suffix would not, even on GNU). Either keep the pre-edit file (cp -p "$f" "$f.bak" first) or drop the promise; silently not backing up is how the next person loses data.

Shell manual · 12 · 9 min readawk#

You've met it: kubectl … describe "$pod" | awk '/^Events:/{e=1} e', printing everything from the Events: line onward in k11's triage.sh · Debugging

awk splits each line into fields and runs your program on it. That one idea — a line is a row of columns — makes it the right tool for two jobs the shell is bad at: pulling a column out of a stream, and adding things up into a report. This chapter teaches the field model, a graded set of one-liners you will reuse, and how to turn many lines into one grouped summary.

The model

awk runs the same small loop over every line, like sed, but the line arrives already split into fields:

   read a line (a record)              NR = line number, NF = field count
        │  split on whitespace
        ▼
   $1  $2  $3  …  $NF
        │
        ▼   for each rule, in order:
   pattern matches? ──yes──►  run its { action }
        │                          │
        └──no──► skip ─────────────┤
                                   ▼
                        read the next line
   (BEGIN runs before the first line, END after the last)

A program is a list of pattern { action } rules. For each line, awk splits it on whitespace into $1, $2, … up to $NF (NF is the number of fields), sets NR to the line number, then tries every rule: if the pattern matches, it runs the action. A missing pattern means "every line"; a missing action means { print }. BEGIN and END are rules that run once, before the first line and after the last. Everything else is detail.

The ladder

Rung 1 Fields: $1, $NF, and changing the separator

awk '{ print $1 }'        file      # the first whitespace-separated field of each line
awk '{ print $NF }'       file      # the last field, whatever the count ($NF, not $(NF))
awk '{ print $1, $3 }'    file      # fields 1 and 3, joined by OFS (a space by default)
awk -F: '{ print $1 }'    /etc/passwd    # -F: split on ":" instead of whitespace
awk -F'\t' '{ print $2 }' data.tsv  # split on a tab
awk 'NF' file                       # print lines that have at least one field (drop blanks)

$0 is the whole line; $1…$NF are the fields. Default splitting is on runs of whitespace, with leading and trailing whitespace ignored, so a b has two fields. -F sets the field separator to something else.

Rule Reach for awk '{print $N}' to pull a column, -F to set the separator, and $NF for "the last field".

Why The shell has no idea what a column is; awk splits every line for you. $NF means "last" without counting, which survives rows of different widths.

Trap Default whitespace splitting collapses repeated spaces and ignores empty fields, so a::c with -F: gives three fields ($2 empty) but a c with the default gives two, not three. For real tab-separated data with empty cells, set -F'\t'; for CSV with quoted commas, awk's plain splitting is not enough (use a real parser).

Rung 2 Patterns: which lines the action runs on

awk '/ERROR/'            file       # bare pattern: print matching lines (a grep)
awk '$3 >= 500'          file       # a field test: lines whose 3rd field is >= 500
awk '$1 == "GET"'        file       # string comparison (quote the string)
awk 'NR == 1'            file       # just the first line
awk '$3 >= 400 && $1 != "HEAD"' file    # combine with && and ||
awk '/BEGIN/,/END/'      file       # a range: from a BEGIN line to an END line

A pattern is anything that is true or false: a /regex/, a comparison, or a combination with &&, || and !. awk '$3 >= 500' with no action prints the whole matching line.

Rule Put the test before the { action }; use a bare pattern (no action) when you just want the matching lines.

Why This is where awk beats grep: grep matches text anywhere on the line, while awk '$3 >= 500' matches a field by value, as a number. grep 500 would also match a path /img/500px.

Trap awk '$3 == 200' compares numerically if $3 looks like a number and as a string otherwise, and the two disagree on leading zeros and whitespace: $3 == "0200" is false while $3 == 200 is true for the field 0200. Decide which you mean; force a number with $3 + 0 or a string with $3 "".

Rung 3 One-liners you will reuse

awk '{ s += $3 } END { print s }'           file   # sum column 3
awk '{ s += $3 } END { print s/NR }'         file   # average of column 3
awk 'END { print NR }'                       file   # count lines (like wc -l)
awk 'NR >= 10 && NR <= 20'                   file   # print lines 10-20 (like sed -n)
awk '$2 == "GET" { print $3 }'               file   # the 3rd field of GET lines only
awk '{ print $2, $1 }'                       file   # reorder columns
awk '!seen[$0]++'                            file   # print the first copy of each line (dedup, unsorted)

Each of these is a whole tool in one line. s += $3 accumulates across lines; the value survives because awk variables are global and persist between lines. !seen[$0]++ is the classic "unique without sorting": the first time a line is seen seen[$0] is 0 (false, so ! prints it) and then incremented.

Rule Keep a running total in a variable and print it in END; use arr[key]++ to remember what you have seen.

Why awk gives you a variable that lives across all lines and arrays with string keys, so sums, counts and "seen before?" are one expression each — the things a while read loop does slowly and verbosely.

Trap s += $3 treats a missing or non-numeric field as 0 silently, so a malformed line does not error, it just contributes nothing — a sum that is quietly too low. If that matters, guard with $3 ~ /^[0-9]+$/ before adding.

Rung 4 Variables, BEGIN/END, and printf

awk -v min=400 '$3 >= min'           file      # -v passes a shell value in as an awk variable
awk 'BEGIN { FS=":"; OFS="\t" } { print $1, $3 }' /etc/passwd   # set separators once, up front
awk '{ printf "%-10s %5d\n", $1, $3 }'         file   # aligned columns with printf
awk 'BEGIN { print "name\tsize" } { print }'   file   # a header before the data

BEGIN { } runs before any line (use it for headers and to set FS/OFS); END { } runs after the last (use it for totals). -v name=value is how a shell value gets into awk safely, without pasting it into the program text. printf works as it does in C and in the shell (chapter 6).

Rule Pass shell values in with -v, set separators in BEGIN, format columns with printf, and compute totals in END.

Why -v keeps the awk program a fixed string, so a value with a space, a slash or a quote cannot break or rewrite it — the same lesson as not pasting variables into sed (chapter 11) or SQL.

Trap Building the program by shell-quoting a variable into it — awk "\$3 > $limit" — breaks the moment $limit is empty or hostile, and set -u will not save you because it is inside double quotes. Use -v limit="$limit" and reference limit in the program.

Rung 5 Reports: group by a key with an array

# requests and bytes per HTTP status class, printed in order at the end
{
  reqs++                       # total lines seen
  bytes += $4                  # running total of the 4th field
  class[int($3 / 100)]++       # 200->2, 404->4: count per class, keyed by a number
}
END {
  printf "requests: %d\n", reqs
  for (c = 2; c <= 5; c++)
    if (class[c]) printf "  %dxx: %d\n", c, class[c]
  printf "bytes: %d (avg %.1f)\n", bytes, (reqs ? bytes / reqs : 0)
}

This is the report pattern: an associative array keyed by some field (class[...], but it could be by_user[$1] or count[$path]), incremented on every line, then printed in END. Arrays in awk are keyed by strings, created on first use, and ideal for "group by".

Rule To group and total, build arr[key] += value (or arr[key]++) as the lines stream past, and print the array in END.

Why This is the report sort | uniq -c (chapter 13) cannot do: it sums a second column per group, not just counts, in one pass over the data with no sorting.

Trap for (key in arr) visits the keys in an unspecified order that differs between awks, so a report that iterates an array directly prints its rows in a different order on gawk than on mawk — bad for a diff or a test. Iterate a known range (for (c = 2; c <= 5; c++)), or pipe the output through sort (chapter 13), or use gawk's PROCINFO["sorted_in"] gawk only when gawk is guaranteed.

Rung 6 gawk, mawk and BSD awk

awk  '{ gsub(/o/, "0"); print }'   file    # gsub: in-place global sub, portable
gawk 'BEGIN { print gensub(/o/,"0","g","foo") }'   # [gawk] gensub returns the result, portable awk lacks it
awk  'END { print length(a) }'     file    # length(array) now works in gawk, mawk, busybox and BSD awk
gawk 'BEGIN { PROCINFO["sorted_in"]="@ind_num_asc"; … }'   # [gawk] iterate an array in order

The field model, printf, arrays, gsub, sub, split, substr and the pattern { action } core are the same everywhere. The differences are gawk's extras: gensub (a substitute that returns its result instead of editing in place), sorted array iteration, true arrays of arrays, and bitwise functions.

Rule Write POSIX awk — fields, patterns, arrays, gsub/sub/split/substr, printf — and tag anything gawk-only with gawk, giving a portable fallback.

Why Debian's default awk is mawk, macOS ships the BSD "one true awk", and Alpine has BusyBox awk. A script that needs gawk must say so and be installed with it; one written in POSIX awk runs on all of them unchanged, as report.awk does.

Trap gensub is the easy one to reach for and the one that is gawk only. On mawk it fails with function gensub never defined; the script dies on Debian, where awk is mawk. Use gsub/sub (which edit $0 or a field in place) for portable code, and keep gensub for gawk-guaranteed scripts.

Rung 7 Old forms you will meet

#!/bin/sh
# summarise.sh, inherited
cat access.log | awk '{print $1}'
grep GET access.log | awk '{print $7}'
nawk '{ sum += $1 } END { print sum }' nums
awk '{print $1}' | sort | uniq -c
echo "$line" | awk -F, '{print $2}'
awk "/$pattern/" access.log
Old form What it does Why it existed Write instead Still runs?
cat f \| awk … run awk on a file habit awk … f (awk reads files itself) yes, a needless cat
grep X \| awk '{…}' filter then field two tools by reflex awk '/X/ {…}' (awk does both) yes, but one awk is enough
nawk / oawk / gawk --posix "the new awk" 1980s: old vs new awk awk nawk is a symlink on some systems, absent on others
awk '{print $2}' \| sort \| uniq -c count by field fine, but awk '{c[$2]++} END{for(k in c)print c[k],k}' when you also need sums yes
awk -F, '{print $2}' on CSV parse CSV awk predates CSV-with-quotes a real CSV parser, or @csv tools, when fields contain commas yes, but wrong on quoted commas
awk "/$pattern/" interpolate a pattern before -v was habit awk -v p="$pattern" '$0 ~ p' yes, but breaks on a / or space in $pattern

Rule Drop the cat and the grep before awk, prefer awk to pass a shell value with -v, and reach for an array when you are counting or summing by a key.

Why awk reads files, filters by regex and matches fields itself, so the extra cat and grep are habit, not need; and interpolating a variable into the program is the same injection trap as in sed.

Trap awk "/$pattern/" pastes the shell value into the program. If $pattern is foo/bar, the / ends the regex early and awk reports a syntax error; if it is empty, the program becomes // (match everything). awk -v p="$pattern" '$0 ~ p' passes the value as data, where none of that can happen.

Decide

You want Use
one column out of a stream awk '{print $N}' (or cut, chapter 13)
rows where a field meets a test awk '$N >= x'
a sum, average or count of a column awk '{s+=$N} END{print s}'
a count per group awk '{c[$k]++} END{…}', or sort \| uniq -c for simple cases
a sum per group awk '{s[$k]+=$v} END{…}' (uniq cannot)
a shell value inside the program awk -v name="$value" '…'
a gawk-only feature guard it (gawk) or write the POSIX fallback

The golden file

A report over an access log of METHOD PATH STATUS BYTES lines, and a shell wrapper that runs it. The awk program lives in its own .awk file (run with awk -f), uses only POSIX awk, and prints its status classes in a fixed order so the output is identical on gawk, mawk, BusyBox and BSD awk. The wrapper finds the program beside itself and reads a file or stdin.

examples/manual/b12/report.awk
# report.awk: summarise an access log whose lines are "METHOD PATH STATUS BYTES".
#   awk -f report.awk access.log
# Prints the request count, a count per status class (2xx..5xx) in a fixed order,
# the total and average bytes, and the busiest path. Plain POSIX awk: it runs the
# same on gawk, mawk and BSD awk (no gawk-only functions).

$0 !~ /^[[:space:]]*$/ && $0 !~ /^#/ {     # skip blank lines and comments
  reqs++
  bytes += $4
  class[int($4 == "" ? 0 : $3 / 100)]++    # 2 -> 2xx, 5 -> 5xx; int() truncates
  path[$2]++                               # count requests per path
  if ($2 != "" && path[$2] > top_n) { top_n = path[$2]; top = $2 }
}

END {
  printf "requests: %d\n", reqs
  for (c = 2; c <= 5; c++)                  # fixed 2..5 order, so output is deterministic
    if (class[c]) printf "  %dxx: %d\n", c, class[c]
  printf "bytes: %d (avg %.1f)\n", bytes, (reqs ? bytes / reqs : 0)
  if (reqs) printf "busiest: %s (%d)\n", top, top_n
}
examples/manual/b12/access-report.sh
#!/usr/bin/env bash
# access-report.sh: run report.awk over an access log (or stdin).
#
#   access-report.sh [LOGFILE]        e.g.  access-report.sh access.log
#                                           kubectl logs deploy/pantry | access-report.sh
#
# Each log line is "METHOD PATH STATUS BYTES". With no file it reads stdin, so it
# drops into a pipe. The awk program lives beside this script and is run with -f.
# Exit status: 0 ok, 2 usage error (no such file).
set -euo pipefail

here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)   # the script's own directory (chapter 8)
prog=$here/report.awk

if [ "$#" -gt 1 ]; then
  echo "usage: access-report.sh [LOGFILE]" >&2
  exit 2
fi
if [ "$#" -eq 1 ] && [ ! -r "$1" ]; then
  echo "access-report.sh: cannot read: $1" >&2
  exit 2
fi

# "$@" is the file if given, nothing if not; with nothing, awk reads stdin.
awk -f "$prog" "$@"
access-report.sh access.log            # a report from a file
kubectl logs deploy/pantry | access-report.sh    # or straight from a pipe

Review checklist

Spot the bug

A script is meant to report the total bytes served per client IP from an access log of IP METHOD PATH STATUS BYTES lines, biggest first. It prints wrong totals and sometimes nothing.

examples/manual/b12/trap/by-client.sh
#!/bin/bash
# by-client.sh LOG: total bytes per client IP, largest first.
log=$1
pattern=$2
cat $log | grep "$pattern" | awk "{ bytes[\$1] += \$4 }
  END { for (ip in bytes) print bytes[ip], ip }" | sort -n

This file has 4 problems.

Show answers
  1. The byte field is $5 (IP METHOD PATH STATUS BYTES), not $4. bytes[$1] += $4 sums the status column, so every total is wrong (rung 1). Count the fields and use $5.
  2. sort -n sorts ascending, so the biggest client ends up last, not "largest first" as promised. Use sort -rn (chapter 13).
  3. $log is unquoted, so a path with a space splits into two arguments, and the cat "$log" | grep … | awk … prefix is pure habit — awk reads the file and filters by regex itself. Write awk '…' "$log".
  4. $pattern ($2) is a second argument the usage never mentions; when the script is called as the comment shows (by-client.sh LOG), it is empty, so grep "" matches every line and the filter does nothing — the tool only looks like it works. Drop the filter, or make the pattern a documented, -v-passed option. The whole thing becomes awk '{ c[$1] += $5 } END { for (ip in c) print c[ip], ip }' "$log" | sort -rn.

Shell manual · 13 · 8 min readThe Pipeline Toolkit#

You've met it: for i in $(seq 1 100); do curl -s -o /dev/null -w '%{http_code}\n' localhost:30080/; done | sort | uniq -c, counting how many of a hundred requests got each HTTP status · A Concierge at Every Door

Between grep/find and awk sits a drawer of small filters that each do one thing to a stream: order it, dedupe it, cut columns, translate characters, fetch it, or parse its JSON. None is interesting alone; together they are how you turn raw output into an answer. This chapter is the drawer, and the handful of combinations you will reach for most.

The ladder

Rung 1 sort: order, by field or number, stably

sort file                     # whole lines, by the current locale's rules
sort -n file                  # numeric: so 10 comes after 9, not after 1
sort -k2 file                 # by the 2nd whitespace field onward
sort -t: -k3 -n /etc/passwd   # -t sets the separator; sort by field 3, numerically
sort -u file                  # sort and drop duplicates in one step
LC_ALL=C sort file            # sort by byte value: fast, and the same on every machine

sort orders lines. -n compares them as numbers, -k picks the field to sort on, -t sets the field separator, and -u removes duplicates. The sort order depends on the locale unless you pin it.

Rule Add -n whenever the key is a number, -k/-t to sort on a field, and LC_ALL=C when you need the order to be identical everywhere.

Why Without -n, sort compares digit strings as text, so 10 sorts before 9. And the locale decides letter order: in a UTF-8 locale sort folds case together, while LC_ALL=C sorts by raw byte, putting all capitals before any lowercase.

Trap The same sort gives different output on two machines. In LC_ALL=C the lines Apple apple banana stay in that byte order (capitals first); in en_US.UTF-8 they come out apple Apple banana, case folded together. A script that greps a sorted file, or diffs two sorted lists, breaks across locales unless both sides pin LC_ALL=C.

Rung 2 uniq: adjacent duplicates, so sort first

sort file | uniq              # collapse runs of identical lines
sort file | uniq -c           # ... with a count of each
sort file | uniq -d           # only the lines that were duplicated
sort file | uniq -u           # only the lines that were unique
sort file | uniq -c | sort -rn | head    # the count-and-rank: most common first

uniq only removes adjacent duplicates, so its input must be sorted first. -c prefixes each line with its count. The sort | uniq -c | sort -rn pipeline — count, then rank by count — is one of the most useful in the toolkit.

Rule Always sort before uniq; add -c to count, and a second sort -rn to rank.

Why uniq reads one line at a time and only compares each to the one before, so unsorted input leaves scattered duplicates uncounted. Sorting brings identical lines together first.

Trap uniq -c on unsorted input silently undercounts: a b a gives three groups of 1, not a:2 b:1. There is no error — just a wrong count, which is the whole bug in the spot-the-bug of chapter 6. (For counting with a sum per group, or without sorting, reach for awk instead, chapter 12.)

Rung 3 Columns: cut, paste, join, comm

cut -d: -f1 /etc/passwd       # field 1, delimiter ":"
cut -d, -f1,3 data.csv        # fields 1 and 3
cut -c1-8 file                # characters 1 to 8 (fixed-width)
paste a.txt b.txt             # glue two files side by side, tab between
comm -23 <(sort a) <(sort b)  # lines only in a (needs both sorted)

cut pulls fields or character ranges out of each line. paste joins files column-wise; comm compares two sorted files (which lines are only in the first, only in the second, or in both); join merges two files on a shared key, like a database join.

Rule Use cut for a quick column with a single-character delimiter; move to awk when fields are whitespace-separated runs, or you need $NF, a computation, or a condition.

Why cut -d' ' splits on one space, so two spaces make an empty field; awk splits on runs of whitespace and gives $NF. cut is right for :- or ,-delimited data, awk for columns separated by variable spacing.

Trap cut -d' ' -f2 on root 0 0 (aligned with multiple spaces) returns an empty field, because the second "field" is the space between columns. For whitespace-aligned output (like ps or ls -l), use awk '{print $2}', which treats the run of spaces as one separator.

Rung 4 tr and the small filters

tr 'a-z' 'A-Z' < file         # translate: lowercase to uppercase
tr -d '\r' < file             # delete characters: strip carriage returns
tr -s ' ' < file              # squeeze runs of a character to one
tr -cd '[:print:]\n' < file   # -c complement: keep only printable chars and newlines
wc -l file ; head -n 5 file ; tail -n 5 file ; tail -f log    # count, first, last, follow
command | tee out.txt | less  # tee: write to a file AND pass the stream on
printf 'a b\nc d\n' | column -t    # align into columns

tr works on single characters: translate one set to another, -d delete, -s squeeze repeats, -c complement a set. The rest are the everyday stream tools: wc counts, head/tail take ends (tail -f follows a growing file), tee splits a stream to a file and onward, column -t aligns.

Rule Use tr for character-level fixes (case, deleting \r, squeezing spaces) and tee to capture a stream mid-pipe without stopping it.

Why tr is the smallest tool for "change these bytes to those", far simpler than sed for a character map. tee lets you keep a copy of what flows past — a log of a pipeline — without breaking the pipe.

Trap tr reads only stdin; tr 'a' 'b' file does not read file, it ignores it and waits on the keyboard. Always redirect: tr 'a' 'b' < file. And tr ranges like a-z are affected by the locale; LC_ALL=C tr 'A-Z' 'a-z' keeps them to plain ASCII.

Rung 5 jq: the same idea for JSON

jq '.'            file.json   # pretty-print and validate
jq '.items'       file.json   # the value at key "items"
jq '.items[]'     file.json   # each element of that array, one per line
jq -r '.name'     file.json   # -r: raw strings, no quotes (for use in the shell)
jq '.[] | select(.status >= 500)' log.json      # filter an array by a condition
jq -r '.[] | [.method, .path] | @tsv' log.json   # pick fields, emit as TSV
jq -r '.[] | .client' log.json | sort | uniq -c  # jq into the count-and-rank pipeline

jq is grep/cut/awk for JSON. . is the whole document, .key indexes an object, .[] iterates an array, select() filters, and @tsv/@csv turn a row into delimited text you can feed to the rest of the toolkit. -r drops the quotes so values become plain shell text.

Rule Parse JSON with jq, never with grep/sed; use -r when the result feeds another command, and @tsv to hand arrays to awk or cut.

Why JSON has quoting, nesting and escaping that line tools cannot track; jq understands the structure, so .a.b[0] is exact where a regex is a guess. The book's own scripts pull Kubernetes fields this way.

Trap Without -r, jq '.name' prints "pantry" with the quotes, and the next command sees a value wrapped in literal ". A path used in rm or cd then has quotes in it and fails. Use -r whenever the output is not itself JSON.

Rung 6 curl: fetching, the way a script wants it

curl -fsS https://host/path                 # the script-safe quartet (see below)
curl -fsS -o out.json https://host/api       # -o: save to a file
curl -fsSL https://host/redirect             # -L: follow redirects
curl -fsS --retry 3 --retry-delay 2 https://host/   # retry transient failures
curl -fsS -H 'Authorization: Bearer '"$TOKEN" https://host/api   # a header
code=$(curl -s -o /dev/null -w '%{http_code}' https://host/)     # just the status code

For scripts, curl wants four flags: -f (fail, i.e. non-zero exit on an HTTP error like 404 or 500), -s (silent, no progress bar), -S (but still show errors), and often -L (follow redirects). -w '%{http_code}' extracts the status when you want to branch on it.

Rule In a script, fetch with curl -fsS (add -L for redirects), and check the exit status; use -w '%{http_code}' only when you need the code itself.

Why Without -f, curl prints the server's error page to stdout and exits 0, so your script treats a 404's HTML as success. -f turns an HTTP error into a non-zero exit you can test.

Trap curl without -f on a 404 exits 0 and feeds the error body downstream; with -f it exits 22. In the lab, curl -fsS …/nope exited 22 while curl -sS …/nope exited 0 and printed the error page. Note that -f is not perfectly reliable for every status; when the code matters, read it with -w '%{http_code}'. (wget -qO- is the fallback where curl is absent.)

Rung 7 Old forms you will meet

#!/bin/sh
# tally.sh, inherited
cat access.log | sort | uniq -c
sort access.log | uniq -c | sort -n
cut -d' ' -f1 access.log | sort | uniq
cat data.json | grep '"status"' | sed 's/.*: *//'
tac /var/log/app.log | head
wget -qO- http://host/health
Old form What it does Why it existed Write instead Still runs?
cat f \| sort sort a file habit sort f yes, a needless cat
uniq without sort dedupe forgetting uniq is adjacent-only sort f \| uniq yes, but undercounts (rung 2)
sort \| uniq -c \| sort -n rank ascending by mistake … \| sort -rn for most-common-first yes
grep '"k"' f.json \| sed … read JSON before jq was installed jq -r '.k' f.json yes, but breaks on nesting and escaping
tac file reverse lines GNU GNU tail -r on macOS; tac on Linux Linux yes; macOS: tac: command not found
wget -qO- url fetch to stdout wget was the default fetcher curl -fsS url (same idea) yes, where wget is installed

Rule Drop the leading cat, always sort before uniq, rank with sort -rn, parse JSON with jq, and prefer curl -fsS for fetching.

Why Each works on the author's data and fails elsewhere: uniq without sort undercounts, grep on JSON breaks on nesting, and tac is missing on macOS.

Trap tac is GNU and absent on macOS, where the equivalent is tail -r; tail -r in turn does not exist in GNU coreutils. A log-reversing script therefore needs a branch or a portable substitute (awk '{a[NR]=$0} END{for(i=NR;i;i--)print a[i]}' reverses lines in any awk). The lab's tac printed 3 2 1; macOS answered tac: command not found.

Decide

You want Reach for
lines in order sort (-n for numbers, -k/-t for fields)
the same order on every machine LC_ALL=C sort
counts of repeated lines sort \| uniq -c
most common first sort \| uniq -c \| sort -rn
a column from :/, data cut -d<sep> -f<n>
a column from whitespace-aligned data awk '{print $n}'
a character-level change tr
a field out of JSON jq -r
to fetch a URL in a script curl -fsS

The golden file

Counts the most frequent values of a field and ranks them — the count-and-rank pipeline packaged with options. It handles whitespace columns by default and JSON lines with -j (via jq), pins LC_ALL=C so the order is stable, and reads a file or stdin so it drops into a pipe.

examples/manual/b13/top-talkers.sh
#!/usr/bin/env bash
# top-talkers.sh: show the most frequent values of a field, biggest first.
#
#   top-talkers.sh [-n N] [-k FIELD] [-j FILTER] [FILE]
#
#   -n N       how many to show (default 10)
#   -k FIELD   which whitespace field to count (default 1)
#   -j FILTER  treat each line as JSON and pull the field with jq (e.g. -j .client)
#   FILE       read FILE, or stdin if omitted
#
# The pipeline is the classic count-and-rank: extract the key, sort, uniq -c, sort -rn.
# LC_ALL=C makes the ordering byte-stable regardless of the caller's locale.
# Exit status: 0 printed at least one row, 1 no input rows, 2 usage error.
set -euo pipefail

usage() {
  echo "usage: top-talkers.sh [-n N] [-k FIELD] [-j FILTER] [FILE]" >&2
  exit 2
}

n=10
field=1
jq_filter=
while [ "$#" -gt 0 ]; do
  case $1 in
    -n) [ "$#" -ge 2 ] || usage; n=$2; shift 2 ;;
    -k) [ "$#" -ge 2 ] || usage; field=$2; shift 2 ;;
    -j) [ "$#" -ge 2 ] || usage; jq_filter=$2; shift 2 ;;
    --) shift; break ;;
    -*) usage ;;
    *) break ;;
  esac
done
[ "$#" -le 1 ] || usage
case $n$field in *[!0-9]*) usage ;; esac       # -n and -k must be numbers

# extract_keys: one key per input line on stdout (empty keys dropped).
extract_keys() {
  if [ -n "$jq_filter" ]; then
    command -v jq >/dev/null 2>&1 || { echo "top-talkers.sh: jq not installed" >&2; exit 2; }
    jq -r "$jq_filter // empty"                # // empty drops nulls/missing
  else
    awk -v f="$field" '{ if ($f != "") print $f }'
  fi
}

# The count-and-rank pipeline. LC_ALL=C: sort by byte value, same on every machine.
# uniq -c needs its input sorted; the second sort -rn ranks by the count it added.
rows=$(extract_keys < "${1:-/dev/stdin}" \
  | LC_ALL=C sort \
  | LC_ALL=C uniq -c \
  | LC_ALL=C sort -rn \
  | head -n "$n")

[ -n "$rows" ] || exit 1
printf '%s\n' "$rows"
top-talkers.sh -k 1 -n 5 access.log          # the 5 busiest first fields (e.g. client IPs)
kubectl get pods -o json | top-talkers.sh -j .status.phase   # count Pods by phase

Review checklist

Spot the bug

A report is meant to list the three busiest URLs in an access log of IP METHOD URL STATUS lines. It prints the wrong three, in the wrong order.

examples/manual/b13/trap/busiest.sh
#!/bin/bash
# busiest.sh LOG: the three most-requested URLs.
log=$1
cut -d' ' -f3 $log | uniq -c | sort -n | head -3

This file has 4 problems.

Show answers
  1. uniq -c runs on unsorted input, so it counts only adjacent duplicates and the totals are wrong (rung 2). Sort first: sort | uniq -c.
  2. sort -n is ascending, so head -3 takes the three least busy URLs, not the busiest. Use sort -rn (rung 1).
  3. cut -d' ' -f3 assumes single-space separation; a log aligned with multiple spaces, or with a method like GET followed by two spaces, puts the URL in a different field or returns empty. For whitespace columns use awk '{print $3}' (rung 3).
  4. $log is unquoted and unchecked: a path with a space splits, and with no argument cut reads stdin and the script hangs waiting on the keyboard. Quote "$log" and check it exists. The whole thing is awk '{print $3}' "$log" | sort | uniq -c | sort -rn | head -3.

Shell manual · 14 · 9 min readWatching the System#

You've met it: k11's triage.sh, which walks a broken namespace Pod by Pod — the same move this chapter makes on a single machine · Debugging

When something is wrong — "it's slow", "disk full", "connection refused", "it keeps dying" — the answer is almost always one command away, if you know which one. This chapter is the diagnostic drawer: how to see what is running, what memory and disk are doing, what is holding a port, and how to pull the telling line out of a log. It is where grep, awk and the pipeline toolkit stop being filters and become a method.

The model

Diagnosis is a loop, not a tool. A symptom points at a place to look, which picks a tool, which shows the cause:

   symptom                where?              which tool              the line that
   slow / full /    ──►    CPU · memory ·  ──►  ps/top · free ·   ──►  shows the cause
   refused /               disk · ports ·       df/du · ss/lsof ·           │
   crashing                logs                 grep                        │
       ▲                                                                    │
       └──────────────────────── still unclear ◄───────────────────────────┘

The skill is not memorising flags; it is the first arrow — turning "it's slow" into "which resource?". "Slow" with idle CPU and full memory is a memory problem; with a full disk it is a disk problem; with neither it is probably downstream, in the logs. Each rung below is one where, with the tool that answers it and the line to read.

The ladder

Rung 1 What is running: ps, pgrep, top

ps aux                         # every process, BSD style: USER PID %CPU %MEM VSZ RSS ...
ps -ef                         # every process, SysV style: UID PID PPID ... CMD
ps axo rss,pid,comm | sort -rn | head    # the biggest by resident memory (RSS, in KiB)
pgrep -a nginx                 # PIDs (and -a: command lines) of processes matching "nginx"
pgrep -af pantry               # -f: match against the whole command line, not just the name
top                            # live, interactive: press M to sort by memory, q to quit

ps prints a snapshot of processes; ps aux and ps -ef are the same picture in two column layouts. Sort by %CPU or RSS to find the hog. pgrep finds a process by name without the ps | grep dance; top (or htop) is the live view.

Rule Snapshot with ps axo … piped to sort, find a named process with pgrep -af, and watch live with top.

Why ps composes with the pipeline toolkit — ps axo rss,comm | sort -rn | head is "the five biggest", built from chapter 13 — while top is for watching a number move. pgrep returns PIDs you can act on.

Trap ps -aux (with a dash) is not ps aux. procps reads the dash form as "all processes with a TTY, plus processes owned by a user named x", and only falls back to aux if no such user exists. The two happen to agree today, but rely on the fallback and a real user x changes the output. Write ps aux (BSD) or ps -ef (SysV), not the hybrid.

Rung 2 Memory: free, vm_stat, and the OOM killer

free -h                        # [GNU] total/used/free/available memory, human units (Linux)
cat /proc/meminfo              # the raw source free reads (Linux)
vm_stat                        # the macOS equivalent (pages, not bytes)
ps axo rss,pid,comm | sort -rn | head    # which process is using it
dmesg | grep -i 'killed process'         # the OOM killer's record (Linux, often needs root)

On Linux, free -h is the summary: the number that matters is available, not free — the kernel uses spare RAM for cache and hands it back under pressure, so low "free" with high "available" is healthy. RSS in ps is a process's resident memory. When memory runs out, the kernel's OOM killer kills a process and records it in the kernel log.

Rule Read free -h for the summary (watch available), ps axo rss … for the culprit, and dmesg for whether the OOM killer struck.

Why "Out of memory" is two questions — is the machine short, and who is using it — answered by free and ps respectively. The OOM killer explains a process that "just disappeared" with no error in its own log.

Trap free is Linux-only; on macOS it is command not found, and the equivalent is vm_stat (which reports pages of 4096 bytes, not bytes). And reading free's "free" column as "how much I can use" is the classic misread: a healthy Linux box shows little free and plenty available, because cache counts as used until something needs the RAM.

Rung 3 Disk: df for space, du for where it went

df -h                          # free space per filesystem, human units
df -h /var                     # just the filesystem holding /var
df -i                          # inodes, not bytes: a disk "full" with space left
du -sh ./*                     # size of each item here (-s summary, -h human)
du -h "$dir" | sort -rh | head # the biggest directories under $dir (chapter 13)
du -xh / | sort -rh | head     # -x: stay on one filesystem (do not wander into mounts)

df answers "which filesystem is full"; du answers "what is filling it". The pair is the whole disk investigation: df to find the full mount, then du … | sort -rh under it to find the heavy directory.

Rule Start with df -h to find the full filesystem, then du -h <that mount> | sort -rh | head to find what is using it; check df -i when df -h shows space but writes still fail.

Why A path's free space is a property of its filesystem, so df must come first — du on the wrong mount measures the wrong thing. sort -rh ranks the human sizes du prints (chapter 13).

Trap "No space left on device" with df -h showing free space means you are out of inodes, not bytes — millions of tiny files. df -i reveals it. And plain du / descends into other mounted filesystems (and /proc, /sys); du -x stays on one, which is what you almost always want.

Rung 4 What holds a file or a port: lsof, ss

ss -tlnp                       # [GNU] listening TCP ports and the process (-p needs root)
netstat -an | grep LISTEN      # the portable fallback (macOS, older systems)
lsof -iTCP -sTCP:LISTEN -P -n  # the same, via lsof (also on macOS)
lsof -i :8080                  # who is using port 8080
lsof PATH                      # who has this file open (why you cannot unmount/delete it)
lsof +D /var/log               # every open file under a directory

ss (Linux) and lsof (Linux and macOS) answer "what is listening on this port?" and "what is holding this file open?" — the questions behind "address already in use" and "device busy".

Rule For a port, ss -tlnp on Linux or lsof -i :PORT anywhere; for a stuck file or unmount, lsof PATH.

Why "Port already in use" and "cannot delete, file in use" both mean some process has it; ss and lsof name that process so you can stop the right one (with kill, chapter 9) instead of guessing.

Trap ss is GNU (iproute2) and absent on macOS, which uses netstat or lsof; netstat in turn is deprecated on Linux in favour of ss. And the process column (ss -p, lsof) usually needs root — without it you see the port but not who owns it, which looks like "nothing is listening" when something is.

Rung 5 Reading logs: the diagnostic payoff

tail -n 100 app.log            # the last 100 lines
tail -f app.log                # follow the file as it grows (Ctrl-C to stop); less +F is similar
grep -i error app.log          # the error lines
grep -i error -A3 -B1 app.log  # ... with 3 lines after and 1 before: the error in context
awk '$9 >= 500' access.log     # structured logs: filter by a field (chapter 12)
grep -i error app.log | awk '{print $5}' | sort | uniq -c | sort -rn   # which error, how often
zgrep -i error app.log.1.gz    # search a rotated, gzipped log without unzipping it

A log is just lines, so the whole manual applies: grep to find, -A/-B/-C for context around a match, awk to filter by field and build a report, and the count-and-rank pipeline to see which error dominates. tail -f watches a log live.

Rule Find with grep -i, see the surroundings with -A/-B/-C, count with … | sort | uniq -c | sort -rn, and watch live with tail -f.

Why The cause is usually a line near the first error, not the error itself; -A3 -B1 shows that neighbourhood. Counting turns a wall of errors into "this one, 900 times, and these three once" — the difference between the symptom and the cause.

Trap Where the logs are varies. A plain file lives under /var/log; a systemd service's log is in the journal, read with journalctl -u NAME -e ([systemd], so not on macOS, Alpine or a plain container — this book's lab has no systemd); the kernel's is dmesg; and a container's is docker logs or kubectl logs (the story's tools), not a file at all. Grepping /var/log for a service that logs to the journal finds nothing and looks like "no errors".

Rung 6 Old forms you will meet

#!/bin/sh
# watchdog.sh, inherited
ps -aux | grep nginx | grep -v grep
cat /proc/meminfo | grep MemFree
du -sk /var/* | sort -n | tail
netstat -an | grep 8080
tail -f app.log | grep ERROR
watch 'ps aux --sort=-%mem | head'
Old form What it does Why it existed Write instead Still runs?
ps -aux all processes the dash slipped in ps aux or ps -ef yes, but see rung 1's Trap
ps … \| grep x \| grep -v grep find a process before pgrep pgrep -af x yes, but clumsy and self-matching
cat /proc/meminfo \| grep read memory habit grep MemFree /proc/meminfo, or free -h Linux only; needless cat
du -sk * \| sort -n biggest dirs before sort -h du -h * \| sort -rh yes (-h is on GNU, macOS and busybox)
netstat -an ports before ss ss -tlnp on Linux yes, deprecated on Linux; still standard on macOS
tail -f … \| grep watch for a pattern fine, but tail -f then grep, or less +F with / search yes

Rule Replace ps \| grep with pgrep, netstat with ss on Linux, and du -sk \| sort -n with du -h \| sort -rh; drop the cat before grep.

Why Each old form works but is clumsier or Linux-dated: pgrep avoids matching its own grep, ss is the maintained tool, and sort -h reads the human sizes directly.

Trap ps aux | grep nginx always matches its own grep nginx line too, so it reports one more process than exists — and a script that counts the lines to check "is nginx up?" is always off by one, reading "up" when nginx is down and only the grep matched. pgrep nginx (or the old trick grep '[n]ginx') avoids it.

Decide

The symptom Look at With
slow, CPU pegged processes by CPU ps axo pcpu,pid,comm \| sort -rn \| head, top
slow, memory tight memory and the hog free -h, ps axo rss,comm \| sort -rn
"no space left" the full filesystem, then the dir df -h, then du -h MOUNT \| sort -rh
"no space" but df shows free inodes df -i
"address already in use" what holds the port ss -tlnp, lsof -i :PORT
"device busy" / cannot delete what holds the file lsof PATH
a process vanished the kernel log dmesg \| grep -i kill
errors, cause unknown the logs, in context grep -i error -A3 -B1, then count

The golden file

A read-only snapshot you run first when a machine misbehaves: the biggest processes, memory, disk space and the heaviest directories, listening ports, and the last errors in any logs you name. It detects which tools the system has (ss or netstat or lsof; free or vm_stat) and reports what it can rather than failing, so it runs on Linux and macOS alike. It kills nothing and writes nothing.

examples/manual/b14/triage.sh
#!/usr/bin/env bash
# triage.sh: a read-only snapshot of a machine when something is wrong.
#
#   triage.sh [-n N] [-d DIR] [-l LOGFILE]
#
#   -n N        how many rows in the "top" lists (default 5)
#   -d DIR      directory to measure disk usage under (default .)
#   -l LOGFILE  a log to scan for recent errors (repeatable)
#
# Prints, in order: the busiest processes by memory, free memory, disk space and
# the biggest directories, listening ports, and the last errors in each log. It
# only reads; it kills nothing and changes nothing. Tools differ between Linux and
# macOS and some need root, so each section reports what it can and says when a
# tool is missing instead of failing.
# Exit status: 0 always (it is a report, not a test).
set -uo pipefail                               # not -e: a missing tool must not abort the report

usage() { echo "usage: triage.sh [-n N] [-d DIR] [-l LOGFILE]..." >&2; }
have() { command -v "$1" >/dev/null 2>&1; }    # is this tool on PATH?
rule() { printf '== %s ==\n' "$1"; }

n=5
dir=.
logs=()
while [ "$#" -gt 0 ]; do
  case $1 in
    -n) [ "$#" -ge 2 ] || { usage; exit 2; }; n=$2; shift 2 ;;
    -d) [ "$#" -ge 2 ] || { usage; exit 2; }; dir=$2; shift 2 ;;
    -l) [ "$#" -ge 2 ] || { usage; exit 2; }; logs+=("$2"); shift 2 ;;
    --) shift; break ;;
    *) usage; exit 2 ;;
  esac
done

rule "top processes by memory"
# ps with -o works on Linux (procps) and macOS (BSD): ask for rss (KiB) and the command,
# then rank. rss is resident memory, the number that matters when memory is tight.
if have ps; then
  ps axo rss,pid,comm 2>/dev/null | sort -rn | head -n "$((n + 1))"   # +1 for the header-less top
else
  echo "(ps not available)"
fi

rule "memory"
if have free; then
  free -h                                      # Linux
elif have vm_stat; then
  vm_stat | head -n 6                          # macOS
else
  echo "(no free/vm_stat; /proc/meminfo on Linux)"
fi

rule "disk space"
df -h "$dir" 2>/dev/null || echo "(df failed)"

rule "biggest directories under $dir"
# du -h sizes each; sort -h orders human sizes (K/M/G). Both are on GNU, macOS and busybox.
du -h "$dir" 2>/dev/null | sort -rh | head -n "$n" || echo "(du failed)"

rule "listening ports"
if have ss; then
  ss -tlnp 2>/dev/null || ss -tln               # -p (process) needs root; fall back without it
elif have netstat; then
  netstat -an 2>/dev/null | grep -i listen | head -n "$n"
elif have lsof; then
  lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | head -n "$n"
else
  echo "(no ss/netstat/lsof)"
fi

for log in ${logs[@]+"${logs[@]}"}; do
  rule "last errors in $log"
  if [ -r "$log" ]; then
    # case-insensitive match of error/fail/warn, then the last N (the most recent)
    grep -iE 'error|fail|warn' -- "$log" | tail -n "$n" || echo "(no matches)"
  else
    echo "(cannot read $log)"
  fi
done
triage.sh -d /var -l /var/log/syslog           # snapshot, with a log to scan
triage.sh -n 10 -d / -l app.log -l db.log      # top 10, several logs

Review checklist

Spot the bug

A health-check script is meant to exit 0 when nginx is running and the disk is under 90% full, and non-zero otherwise. It reports healthy on a box where nginx is stopped and the disk is full.

examples/manual/b14/trap/healthcheck.sh
#!/bin/bash
# healthcheck.sh: exit 0 if nginx is up and / is under 90% full.
procs=$(ps aux | grep nginx | wc -l)
if [ $procs -gt 0 ]; then
  echo "nginx: up ($procs)"
fi
full=$(df -h / | tail -1 | awk '{print $5}')
if [ "$full" < "90%" ]; then
  echo "disk: ok ($full)"
fi
echo "healthy"

This file has 5 problems.

Show answers
  1. ps aux | grep nginx | wc -l always counts at least 1 — the grep nginx process matches itself — so procs is never 0 and "nginx: up" prints even when nginx is down (rung 6). Use pgrep -c nginx (a count) or grep '[n]ginx'.
  2. The script always prints healthy and never sets a failing exit status: the two ifs only print, and the last line and the implicit exit 0 make every run "healthy" regardless. It needs to exit 1 on each failure (chapter 5), not just skip a print.
  3. [ "$full" < "90%" ] does not compare numbers. Inside [ ], < is a redirection (chapter 6), so the shell tries to open a file named 90%; with [[ ]] it would be a string comparison, still not numeric. Strip the % and compare as an integer: used=${full%\%}; [ "$used" -lt 90 ].
  4. df -h / | tail -1 | awk '{print $5}' can take the wrong field when df wraps a long device name onto two lines; and -h is irrelevant to the percentage. Use df -P / (POSIX one-line-per-filesystem format) before slicing, so $5 is reliably the use percentage.
  5. [ $procs -gt 0 ] leaves $procs unquoted; combined with no set -u and no error handling, an empty or spaced value makes [ fail with a confusing error rather than a clean result. Quote it ([ "$procs" -gt 0 ]) and turn on set -euo pipefail (chapter 15).

Shell manual · 15 · 10 min readWriting a Script#

You've met it: set -eu, : "${REDIS_HOST:?…}", exec "$@" in d02/docker-entrypoint.sh — a small script that already does most of what this chapter formalises · ENTRYPOINT, CMD and PID 1

You can now read and write every line of a shell script. This chapter is about the script as a whole: how to start one so its mistakes stop it instead of compounding, how to take options and subcommands, how to clean up after itself, and how to keep it correct with shellcheck and bats. The worked example, pantry-ctl, is not new code — it is the four golden scripts from earlier chapters, wired together, which is what a real tool mostly is.

The model

A well-behaved script moves through four stages, and most bugs are a stage skipped:

   parse            validate           act               clean up
   ─────            ────────           ───               ────────
   options,     →   required inputs,→  do the work,   →  remove temp files,
   subcommand       config, deps       logging as it      restore state
                                        goes               (always, via trap)

Parse the arguments, validate everything before touching anything, do the work with logging so a failure is legible, and clean up on the way out — including when the work fails or the user interrupts. The cleanup stage is the one beginners omit, and it is why a crashed script leaves a half-written file or a stale lock.

The ladder

Rung 1 The skeleton and strict mode

#!/usr/bin/env bash
# name.sh: one line on what it does and how to call it.
set -euo pipefail                 # the strict-mode trio (see below)

Every script starts the same way: a shebang (#!/usr/bin/env bash, chapter 2), a header comment, and set -euo pipefail. set -e exits on an unhandled failure, set -u makes an unset variable an error instead of an empty string, and set -o pipefail makes a pipeline fail if any stage fails, not just the last (chapter 6).

Rule Begin every bash script with #!/usr/bin/env bash, a header, and set -euo pipefail.

Why The defaults are the opposite of what you want in a script: a failed command is ignored, a typo'd variable is empty, and a pipeline hides all but its last failure. Strict mode turns each silent wrong into a loud stop.

Trap set -euo pipefail is bash; a #!/bin/sh script gets only set -eu (POSIX has no pipefail). And strict mode is a floor, not a ceiling — it has real holes (next rung), so it reduces silent failures, it does not abolish them.

Rung 2 The holes in strict mode

set -euo pipefail
local v=$(failing)                # local/declare/export MASK the failure: status is the builtin's (0)
x=$(failing)                      # a BARE assignment does stop (its status is the command's) — good
count=$(grep -c x file) || true   # but grep's exit 1 (no match) would stop the script; handle it
n=0; ((n++))                      # ((...)) is status 1 when the result is 0: set -e EXITS here unexpectedly
if risky_fn; then :; fi           # set -e is OFF for ALL of risky_fn when it is used as a condition

set -e has documented blind spots. The sharpest: local x=$(cmd) (and declare/export/readonly) hides a failing cmd, because the status that counts is the builtin's, which is 0. A bare x=$(cmd) is fine — it does stop. And set -e is suspended for the entire function or command used in a condition.

Rule Split local/export from their command substitution (local x; x=$(cmd)); initialise and increment with n=$((n + 1)), not ((n++)); and remember set -e is off throughout anything used as a condition.

Why set -e watches a command's own status. local x=$(cmd) runs local, whose status is its own success, so the inner failure never surfaces. And a function called in if/while/&&/! runs with set -e disabled top to bottom, so a false buried in it does not stop anything.

Trap local out=$(curl -fsS "$url") under set -e sails on when the download fails — out is empty and the script continues — because local succeeded. The same as x=$(curl …) on its own line does stop. In the lab, local x=$(false) reached the next line while a bare x=$(false) exited. Write local out; out=$(curl -fsS "$url"). (And ((n++)) is the opposite surprise: it exits when the result is 0 — in bash 5; bash 3.2 does not — so use n=$((n + 1)).)

Rung 3 Options and subcommands with getopts

usage() { echo "usage: tool [-n] [-t SECONDS] COMMAND [ARG...]" >&2; exit 2; }
dry=0; timeout=30
while getopts ':nt:h' opt; do      # leading ':' = we handle errors; 't:' takes a value
  case $opt in
    n) dry=1 ;;
    t) timeout=$OPTARG ;;          # OPTARG holds the option's value
    h) usage ;;
    :) echo "need a value for -$OPTARG" >&2; usage ;;
    \?) echo "unknown option -$OPTARG" >&2; usage ;;
  esac
done
shift "$((OPTIND - 1))"            # drop the options; $1 is now the first non-option
cmd=${1:-}; shift || true

getopts is the built-in option parser for single-letter options. It fills $opt with each option letter, $OPTARG with a value (for letters followed by : in the spec), and counts consumed arguments in OPTIND, so shift $((OPTIND-1)) leaves the non-options (the subcommand and its arguments). It stops at the first non-option, which is exactly how a git-style tool COMMAND dispatch wants it.

Rule Parse single-letter options with getopts; shift $((OPTIND-1)) to reach the command; dispatch commands with a case.

Why getopts handles -n -t5, -nt 5 and -- correctly, which a hand-rolled loop gets wrong. It is in POSIX and in every shell, unlike the external getopt(1) program.

Trap getopts does not do long options (--timeout); for those you parse by hand or use the external getopt (util-linux), which is a different, non-portable program. And getopts reads single-letter clusters, so a tool needing --flag has outgrown it. The Decide table below says which to pick.

Rung 4 Config, logging, cleanup, dry-run

log()  { printf '%s %s: %s\n' "$(date +%H:%M:%S)" "${0##*/}" "$*" >&2; }  # messages to stderr
die()  { log "error: $*"; exit 1; }
: "${TIMEOUT:=30}"                         # config from the environment, with a default (chapter 4)
tmp=$(mktemp -d)                            # a private temp directory (chapter 9)
trap 'rm -rf "$tmp"' EXIT                   # cleanup runs on normal exit, error, and Ctrl-C/TERM
run() { if [ "${DRY_RUN:-0}" = 1 ]; then log "dry run: $*"; else log "+ $*"; "$@"; fi; }

Four habits make a script operable: send logs to stderr so stdout stays clean for data (chapter 6); read config from the environment with defaults (chapter 4); register cleanup in a trap so it runs however the script ends (chapter 9); and route side effects through a run wrapper that a DRY_RUN flag can turn into logging only. The golden lib.sh (chapter 8) already provides log, die and run.

Rule Log to stderr, read config from the environment, clean up in a trap … EXIT, and gate real actions through a run helper so --dry-run is free.

Why A script you can watch (logs), configure (env), trust to tidy up (trap), and rehearse (--dry-run) is one you can run in production. Each is one line, and lib.sh bundles them.

Trap A trap 'rm -rf "$tmp"' EXIT with tmp unset or empty expands to rm -rf "" or worse; set tmp before the trap, and quote it. And a cleanup that itself can fail should end with || true, or a failing cleanup under set -e masks the real exit status.

Rung 5 Reuse: readiness, parallelism, and assembling the tool

"$here/wait-for.sh" -t 60 "$REDIS_HOST:$REDIS_PORT"       # block until a dependency is up (chapter 5)
"$here/pmap.sh" -j 4 "$here/wait-for.sh" -t 30 -- "$@"    # check many targets at once (chapter 9)
. "$here/lib.sh"; . "$here/pantry-env.sh"                 # source the library and config (chapters 8, 4)

A real tool is an entrypoint plus a library. pantry-ctl sources lib.sh (for log/die/run) and pantry-env.sh (for configuration), and runs wait-for.sh and pmap.sh as commands. Those four files are the goldens from chapters 4, 5, 8 and 9, copied in unchanged — the glue is the new part.

Rule Build a tool from small, tested scripts: source the ones that set variables or define functions, and run the ones that do a job, finding them next to the tool with BASH_SOURCE (chapter 8).

Why Each spine script was written and tested on its own, so the tool inherits that correctness and only has to get the wiring right. This is why the earlier chapters' goldens were built to be reused.

Trap A sourced file runs in your shell, so its set -e, its variables and its exit affect you; a run file is a child and cannot change your variables (chapter 2). pantry-env.sh is written to return (not exit) on bad input precisely so the caller decides — source it with . file || handle, not as a command.

Rung 6 shellcheck as a gate, bats as a net

shellcheck -x pantry-ctl              # static analysis: catches unquoted vars, SC2086 and friends
shellcheck -s sh script.sh            # check it as POSIX sh (chapter 16)
# shellcheck disable=SC2034           # silence one rule on the next line, with a reason
bats test/                            # run the behaviour tests

shellcheck reads a script and reports the bugs this manual has been naming — unquoted expansions, A && B || C, [ $x = y ] without quotes — before you run it. bats runs a script against expected behaviour, so a change that breaks it is caught. A tool should pass shellcheck with no findings and have bats tests for its contract.

Rule Make shellcheck -x pass with zero findings (disabling a rule only with a commented reason), and write bats tests for each exit status and subcommand.

Why shellcheck encodes the traps in this book as automated checks, so the computer catches the quoting bug you would otherwise ship. bats turns "it worked when I tried it" into a test that keeps working.

Trap A blanket # shellcheck disable=… at the top of a file silences the rule everywhere, hiding real instances later; disable on the single line that needs it, with a reason, as pantry-ctl does for the LIB_PROG it hands to lib.sh. And shellcheck cannot see into a sourced file, so it flags variables set there as unused — that is the one place a scoped disable is right.

Rung 7 Old forms you will meet

#!/bin/sh
# deploy.sh, inherited
set -e
for arg in "$@"; do case $arg in -v) verbose=1;; esac; done   # options by hand
tmp=/tmp/deploy.$$                                            # predictable temp file
trap "rm -f $tmp" 0 1 2 15                                    # numeric signals
log() { echo "$1"; }                                          # logging to stdout
eval "target=\$$1"                                            # indirection by eval
Old form What it does Why it existed Write instead Still runs?
set -e alone stop on error before -u/pipefail were habit set -euo pipefail bash yes, but misses unset vars and pipe failures
hand-rolled case $arg option loop parse options before getopts was known getopts (rung 3) yes, but mishandles -ab, --, values
getopt (external) parse options the older program; adds long options getopts for short; getopt only when you need --long yes, but not portable; the classic getopt mangles spaces
/tmp/x.$$ a temp file before mktemp mktemp / mktemp -d (chapter 9) yes, but predictable and racy
trap '…' 0 1 2 15 cleanup numeric signals trap '…' EXIT INT TERM (names) yes, but the numbers are cryptic
eval "v=\$$name" indirection before namerefs ${!name} bash, or declare -n bash 4+ yes, but eval runs arbitrary code

Rule Upgrade set -e to the full trio, hand-rolled option loops to getopts, /tmp/$$ to mktemp, numeric traps to names, and eval indirection to ${!name}.

Why Each old form works until an edge case — an unset variable, a clustered option, a predictable temp name, a forgotten signal number — that the modern form handles by design.

Trap trap "rm -f $tmp" 0 1 2 15 expands $tmp when the trap is set, so if tmp changes later the trap deletes the old path; and the double quotes mean a crafted tmp could inject a command. Write trap 'rm -rf "$tmp"' EXIT INT TERM with single quotes, so $tmp is read when the trap fires (chapter 9).

Decide

For Use
short options -n, -t 5 getopts
long options --timeout hand-written parse, or external getopt (non-portable)
config that varies by environment environment variables with : "${VAR:=default}"
a value used in many runs a config file sourced, or - flags
cleanup that must always happen trap '…' EXIT
a result from a function print it, or printf -v, or a status (chapter 8)
catching bugs before running shellcheck -x
catching regressions bats tests

The golden file

pantry-ctl, the Pantry control tool. It is getopts for its options, a case for its subcommands, strict mode, config and logging from lib.sh and pantry-env.sh, and the real work handed to wait-for.sh and pmap.sh. The four spine files sit beside it, copied unchanged from chapters 4, 5, 8 and 9; the tool is the wiring.

examples/manual/b15/pantry-ctl
#!/usr/bin/env bash
# pantry-ctl: the Pantry control tool, assembled from the chapters' golden scripts.
#
#   pantry-ctl [-e ENV] [-n] [-t SECONDS] [-j JOBS] COMMAND [ARG...]
#
#   commands:
#     config               print the resolved configuration and exit
#     wait TARGET...        wait for each target (host:port or URL) in turn
#     check TARGET...       wait for all targets at once (bounded), report which failed
#
#   options:
#     -e ENV      PANTRY_ENV: dev | staging | prod (default: $PANTRY_ENV or dev)
#     -n          dry run: log what would run, change nothing
#     -t SECONDS  per-target readiness timeout (default 30)
#     -j JOBS     how many checks to run at once in `check` (default 4)
#
# It sources the sibling library and config (lib.sh, pantry-env.sh) and drives the
# sibling tools (wait-for.sh, pmap.sh) — the same four golden scripts from chapters
# 4, 5, 8 and 9, copied in here unchanged (see README.md).
# Exit status: 0 success, 1 a target was not ready, 2 usage or config error.
set -euo pipefail

here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)   # our own directory (chapter 8)

usage() {
  sed -n '2,19s/^# \{0,1\}//p' "${BASH_SOURCE[0]}" >&2     # print this header as help
  exit "${1:-2}"
}

# --- options, before the command ------------------------------------------------
# getopts parses the single-letter options and stops at the first non-option (the command).
# The leading ':' in the spec means "let me handle errors": ? is an unknown option, : a missing
# argument. OPTARG holds an option's value; OPTIND counts how many arguments getopts consumed.
env=${PANTRY_ENV:-dev}
timeout=30
jobs=4
export DRY_RUN=${DRY_RUN:-0}
while getopts ':e:t:j:nh' opt; do
  case $opt in
    e) env=$OPTARG ;;
    t) timeout=$OPTARG ;;
    j) jobs=$OPTARG ;;
    n) DRY_RUN=1 ;;
    h) usage 0 ;;
    :) echo "pantry-ctl: option -$OPTARG needs a value" >&2; usage ;;
    \?) echo "pantry-ctl: unknown option -$OPTARG" >&2; usage ;;
  esac
done
shift "$((OPTIND - 1))"                         # drop the parsed options; $1 is now the command
[ "$#" -ge 1 ] || usage

# --- bring in the library and the configuration ---------------------------------
# lib.sh gives log/warn/die/require_cmd/run; LIB_PROG names us in its messages.
# shellcheck disable=SC2034  # LIB_PROG is read by lib.sh once we source it below
LIB_PROG=pantry-ctl
# shellcheck source=/dev/null
. "$here/lib.sh"
# pantry-env.sh reads PANTRY_ENV and returns non-zero on a bad setting; report it and exit 2.
PANTRY_ENV=$env
# shellcheck source=/dev/null
. "$here/pantry-env.sh" || { log "error: invalid configuration for PANTRY_ENV=$env"; exit 2; }

cmd=$1
shift

case $cmd in
  config)
    [ "$#" -eq 0 ] || usage
    printf '%s\n' \
      "PANTRY_ENV=$PANTRY_ENV" \
      "PANTRY_URL=$PANTRY_URL" \
      "REDIS_URL=$REDIS_URL" \
      "PANTRY_IMAGE=$PANTRY_IMAGE" \
      "PANTRY_DATA_DIR=$PANTRY_DATA_DIR"
    ;;

  wait)
    [ "$#" -ge 1 ] || { log "error: wait needs at least one TARGET"; exit 2; }
    for target in "$@"; do
      run "$here/wait-for.sh" -t "$timeout" "$target"   # run logs the command; dry-run skips it
    done
    ;;

  check)
    [ "$#" -ge 1 ] || { log "error: check needs at least one TARGET"; exit 2; }
    if [ "$DRY_RUN" = 1 ]; then
      run "$here/pmap.sh" -j "$jobs" "$here/wait-for.sh" -t "$timeout" -- "$@"
    else
      # pmap runs wait-for.sh once per target, $jobs at a time, and exits non-zero if any failed.
      "$here/pmap.sh" -j "$jobs" "$here/wait-for.sh" -t "$timeout" -- "$@"
    fi
    ;;

  *)
    log "error: unknown command: $cmd (try: config, wait, check)"
    exit 2
    ;;
esac
pantry-ctl -e staging config                 # the resolved settings for staging
pantry-ctl -t 60 check localhost:6379 localhost:8080   # wait for both, in parallel
pantry-ctl -n wait localhost:6379            # dry run: log the command, probe nothing

Review checklist

Spot the bug

A deploy wrapper is meant to build an image, wait for the database, and run a migration, cleaning up its temp directory whatever happens. It leaves temp directories behind and "succeeds" even when the build fails.

examples/manual/b15/trap/deploy.sh
#!/bin/bash
set -e
tmp=/tmp/deploy.$$
mkdir $tmp
local log=$(docker build -t app . 2>&1)
./wait-for.sh db:5432
tries=0
for i in 1 2 3; do ((tries++)); done
migrate --db db:5432
rm -rf $tmp
echo "deployed in $tries tries"

This file has 5 problems.

Show answers
  1. local log=$(docker build …) masks the build's failure: local succeeds even when the command substitution fails, so a broken build does not stop the script (rung 2) — and local outside a function is itself an error in some shells. A bare log=$(docker build …) would stop under set -e; better still, split it: local log; log=$(docker build … 2>&1) || die "build failed: $log".
  2. Cleanup is a plain rm -rf $tmp at the end, so any failure before it — a failed wait-for.sh or migrate — exits via set -e and skips the cleanup, leaving the temp directory behind. Register it with trap 'rm -rf "$tmp"' EXIT right after creating tmp (rung 4).
  3. tmp=/tmp/deploy.$$ is a predictable temp name (rung 7, chapter 9): another process can guess it, and a symlink planted there can redirect the rm -rf. Use tmp=$(mktemp -d).
  4. ((tries++)) evaluates to 0 on the first iteration (the post-increment returns the old value, 0), which is exit status 1, and under set -e on bash 5 that stops the script before "deployed" ever prints (rung 2). Write tries=$((tries + 1)).
  5. $tmp is unquoted in mkdir and rm -rf, and the script uses bare set -e, not set -euo pipefail. With a hostile or empty $tmp the rm -rf is dangerous, and a future pipeline's failure would pass unnoticed. Quote "$tmp" and use the full trio.

Shell manual · 16 · 8 min readPortability#

You've met it: the choice between #!/usr/bin/env bash and #!/bin/sh that every script in this book makes on its first line · Every Command Is a Process

Every earlier chapter tagged the features that behave differently somewhere. This chapter gathers them — bash versus POSIX sh, GNU versus BSD tools, bash 3.2 versus 5 — into one reference, with a decision for each: write it portably, guard it, or depend on it and say so. It is the page to keep open when a script has to run on more than your own machine.

The model

Portability is a ladder of targets, widest at the bottom. You pick a rung and stay on it:

   narrow  │  this exact bash           (whatever you have; fine for a personal script)
           │  any bash >= 3.2           (macOS included: no bash 4+ features, or guard them)
           │  POSIX sh                  (dash, BusyBox ash: no bash-only features at all)
   wide    │  POSIX sh + portable tools (the above, and no GNU-only tool flags)

The lower the rung, the more machines run your script unchanged and the fewer features you may use. The mistake is not picking a rung: writing bash features under a #!/bin/sh shebang targets POSIX but uses bash, so it works until it meets a real POSIX shell.

The ladder

Rung 1 Choose a target and declare it

#!/usr/bin/env bash                 # "I need bash" — the honest shebang for a bash script
#!/bin/sh                           # "I am POSIX sh" — then use NO bash-only features
require_bash() { [ "${BASH_VERSINFO[0]:-0}" -ge "$1" ] || { echo "needs bash $1+" >&2; exit 2; }; }
require_bash 4                      # [bash] guard a bash 4+ feature at the top, with a clear message

The shebang is a promise. #!/usr/bin/env bash says "run me with bash"; #!/bin/sh says "I stay within POSIX". If you need a newer bash feature than the floor you target (bash 3.2 on macOS), check BASH_VERSINFO at the top and exit with a message, rather than failing obscurely deep in the script.

Rule Match the shebang to what you actually use; if you need bash 4+ on a system that might have 3.2, guard it with BASH_VERSINFO and a clear error.

Why A promise the script keeps is portability you can reason about. The guard turns "unexpected token near (" on an old bash into "needs bash 4+", which tells the user exactly what to do.

Trap #!/bin/sh does not give you bash. On Debian and Ubuntu /bin/sh is dash; on Alpine it is BusyBox ash. A script with [[ … ]] or arrays under #!/bin/sh runs fine when you test it with bash but breaks on the server, because there sh is really dash (chapter 2).

Rung 2 The shell table: bash features and where they are not

[[ "$x" == a* ]]        # [bash] pattern test; dash and POSIX sh have only [ ]
(( x > 0 ))             # [bash] arithmetic test; use [ "$x" -gt 0 ] for sh
arr=(a b c)             # [bash] indexed arrays; sh has only "$@" and set --
declare -A m            # [bash 4+] associative arrays
${x//a/b}               # [bash] replace-all expansion; sh does one with case/sed
${x^^}                  # [bash 4+] uppercase; sh uses tr '[:lower:]' '[:upper:]'
$'a\tb'                 # [bash] ANSI-C quoting; sh uses printf
diff <(a) <(b)          # [bash] process substitution; sh uses temp files or a FIFO
mapfile -t a < f        # [bash 4+] read a file into an array
local -n ref=var        # [bash 5+] namerefs are more capable in 5.x; 4.3+ has declare -n

Everything here is bash; the ones needing a newer bash are tagged bash 4+ (associative arrays, ${x^^}, mapfile) or bash 5+ (fuller namerefs). None exists in POSIX sh, so a #!/bin/sh script must avoid all of them or move to a bash shebang.

Rule Treat [[, ((, arrays, ${x//}, ${x^^}, $'…', process substitution and mapfile as bash-only; in sh use [ ], case, tr, sed and temporary files instead.

Why These are the features worth knowing the dialect boundary for, because they are the ones you reach for daily and the ones a POSIX shell rejects outright with a syntax error, not a warning.

Trap local is the subtle one: it is not in POSIX, but dash and BusyBox ash both implement it, so local "works in sh" on Linux and then fails on a shell that is strictly POSIX. The book treats local as safe for dash and BusyBox (the shells you actually meet) but never relies on it in a script claiming strict POSIX conformance.

Rung 3 The tool table: GNU versus BSD versus BusyBox

sed -i 's/a/b/' f       # [GNU] in place; BSD needs sed -i '' (chapter 11)
grep -P '\d' f          # [GNU] Perl regex; absent on macOS and BusyBox (chapter 10)
find . -printf '%p\n'   # [GNU] -printf; BSD find has no -printf
tac f                   # [GNU] reverse; macOS uses tail -r (chapter 13)
readlink -f "$p"        # [GNU] canonical path; macOS got -f only in 12.3 (2022)
date -d '1 day ago'     # [GNU] relative dates; BSD is date -v-1d
ps axo rss,comm         # works on GNU and BSD; free is [GNU] only (chapter 14)
ss -tlnp                # [GNU] iproute2; macOS uses netstat or lsof (chapter 14)
awk 'BEGIN{print gensub(/o/,"0","g","foo")}'   # [gawk] gensub; mawk and BSD awk lack it (chapter 12)
journalctl -u svc       # [systemd] the journal; not on macOS, Alpine or a plain container

The userland splits three ways: GNU (Linux), BSD (macOS), and BusyBox (Alpine). The big divergences are the GNU flags above, the BSD spellings that replace them (date -v, tail -r), the gawk-only awk functions, and [systemd]-only tools like journalctl. sort -h/-V, by contrast, are on all three despite looking GNU-ish.

Rule Before using a tool flag in a portable script, check it against this table; where GNU and BSD differ, either use the portable alternative (a temp file for sed -i, [0-9] for grep -P) or branch on the system.

Why These flags are where "works on my Linux box" quietly fails on a Mac or in an Alpine container. Knowing the dozen that actually differ saves checking every flag.

Trap The dangerous ones are the flags that exist but mean something different, not the ones that are missing. sed -i is accepted by both GNU and BSD but takes its argument differently, so sed -i 's/…/…/' f edits on GNU and errors on BSD — a failure, which you notice — while a subtler mismatch can corrupt instead of error. When GNU and BSD both have a flag, confirm they agree, not just that it exists.

Rung 4 Locale and PATH

LC_ALL=C sort f         # sort and compare by byte, identically on every machine (chapter 13)
LC_ALL=C grep '[A-Z]'   # character ranges mean ASCII, not locale-folded letters
export LC_ALL=C         # pin a whole script's text handling for reproducibility
command -p sed          # [POSIX] run the standard sed from a default PATH, not a shadowed one

Two environment settings change behaviour out from under a correct-looking script: the locale (LC_ALL, LANG) decides sort order, character ranges and case folding; and PATH decides which sed or grep actually runs. Pin LC_ALL=C when you need byte-exact, reproducible text handling.

Rule Set LC_ALL=C for any sort, comparison or character-range match whose result must be identical across machines; do not assume the user's PATH gives the standard tool.

Why The same script gives different output in a UTF-8 locale and in C (chapter 13's sort trap). Pinning the locale removes a variable you otherwise cannot see in the code.

Trap A cron job or a CI runner often has a different locale (frequently C) and a barer PATH than your interactive shell, so a script that passed by hand behaves differently when scheduled — different sort order, a tool not found. Set the locale explicitly and use absolute paths or command -p where it matters.

Rung 5 Old forms you will meet

#!/bin/sh
# compat.sh, inherited
echo -e "a\tb"
if [ "x$1" == "xgo" ]; then run; fi
which python
seq 1 5
readlink -f "$0"
Old form What it does Why it existed Write instead Still runs?
echo -e / echo -n escapes / no newline BSD vs SysV echo split printf yes, but output varies by shell (chapter 13)
[ a == b ] string compare bashism leaking into [ ] [ a = b ] (single =) bash yes; dash: unexpected operator
which cmd find a program a csh script command -v cmd POSIX yes, but not POSIX and often wrong (chapter 2)
seq 1 5 a number range GNU/BSD, not POSIX for i in 1 2 3 4 5, or a while yes on GNU/BSD/busybox; not guaranteed by POSIX
readlink -f resolve a path GNU; macOS only since 12.3 a cd && pwd subshell for portability newer systems yes; old macOS no
#!/bin/sh with bashisms "portable" script tested only under bash-as-sh a real #!/bin/sh subset, or #!/usr/bin/env bash breaks on dash/BusyBox

Rule Replace echo -e with printf, == in [ ] with =, which with command -v, and a bashism-laden #!/bin/sh with an honest shebang.

Why Each passes under bash and fails under the POSIX shell the #!/bin/sh invites, which is exactly where a "portable" script is supposed to work.

Trap [ "$1" == "go" ] runs fine in bash (which accepts == in [ ] as an extension) and fails in dash with [: ==: unexpected operator. Because the author tested with bash, the bug ships to every Debian/Ubuntu system where /bin/sh is dash. Use a single =, which is POSIX and works in both.

Decide

For each difference, one of three choices:

Situation Choice
a bash feature, and you target bash depend on it; shebang #!/usr/bin/env bash
a bash feature, but you target POSIX sh rewrite portably ([ ], case, tr, temp files)
a bash 4+ feature on a system that may have 3.2 guard with BASH_VERSINFO, exit with a message
a GNU tool flag, running only on Linux depend on it; note it in the header
a GNU/BSD tool split (sed -i, date) write the portable form, or branch on uname
order/case must match across machines pin LC_ALL=C
a tool might be missing check with command -v and degrade or exit

The golden file

Two files: portable.sh, written to run unchanged under bash, dash and BusyBox ash (the decisions of this chapter, applied), and check-portable.sh, which proves it by running it under every shell present and linting it as POSIX sh.

examples/manual/b16/portable.sh
#!/bin/sh
# portable.sh: greet each name and check required tools, using only POSIX sh.
#
#   portable.sh NAME...
#
# Written to run identically under bash, dash and BusyBox ash: no arrays, no [[ ]],
# no `local`, no `echo -e`, no process substitution — only features POSIX guarantees.
# Exit status: 0 ok, 1 a required tool is missing, 2 usage error.
set -eu                                   # POSIX has -e and -u; pipefail is bash-only, so not here

prog=${0##*/}                             # basename without a `basename` process (POSIX expansion)

usage() {
  printf 'usage: %s NAME...\n' "$prog" >&2
  exit 2
}

[ "$#" -ge 1 ] || usage

# Check tools with command -v (POSIX), never `which`. A plain for-in over a fixed list.
for tool in date printf; do
  if ! command -v "$tool" >/dev/null 2>&1; then
    printf '%s: required tool not found: %s\n' "$prog" "$tool" >&2
    exit 1
  fi
done

# printf, not `echo -e`: echo's handling of -n and backslashes is implementation-defined.
for name in "$@"; do
  printf 'hello, %s\n' "$name"
done
printf '%s: greeted %d name(s)\n' "$prog" "$#"
examples/manual/b16/check-portable.sh
#!/usr/bin/env bash
# check-portable.sh: prove portable.sh behaves identically under every shell we have,
# and that it passes a POSIX-sh lint.
#
#   check-portable.sh
#
# Runs portable.sh under bash, dash and BusyBox ash (whichever are installed), compares
# their output, and runs shellcheck -s sh and checkbashisms if present. This is how the
# book verifies the "runs everywhere" claim in the lab's four columns.
# Exit status: 0 all present shells agree and the lint passes, 1 otherwise.
set -euo pipefail

here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
prog=$here/portable.sh

fail=0
expected=
for shell in bash dash "busybox sh"; do
  name=${shell%% *}
  command -v "$name" >/dev/null 2>&1 || { echo "skip: $shell not installed"; continue; }
  # shellcheck disable=SC2086  # "busybox sh" is deliberately two words
  out=$($shell "$prog" alice bob 2>&1) || { echo "FAIL: $shell exited non-zero"; fail=1; continue; }
  if [ -z "$expected" ]; then
    expected=$out
    echo "baseline ($shell):"
    printf '%s\n' "$out" | sed 's/^/  /'
  elif [ "$out" != "$expected" ]; then
    echo "FAIL: $shell output differs from the baseline"
    fail=1
  else
    echo "ok: $shell matches"
  fi
done

if command -v shellcheck >/dev/null 2>&1; then
  shellcheck -s sh "$prog" && echo "ok: shellcheck -s sh" || fail=1
else
  echo "skip: shellcheck not installed"
fi
if command -v checkbashisms >/dev/null 2>&1; then
  checkbashisms "$prog" && echo "ok: checkbashisms" || fail=1
else
  echo "skip: checkbashisms not installed"
fi

[ "$fail" -eq 0 ]
check-portable.sh       # runs portable.sh under bash, dash and busybox; lints it as sh

Review checklist

Spot the bug

A script is marked #!/bin/sh and "POSIX, runs anywhere". It works in CI (where it is run with bash) and fails on an Alpine image and a Debian cron job.

examples/manual/b16/trap/release.sh
#!/bin/sh
# release.sh TAG: tag the release and write a dated note.
if [[ -z "$1" ]]; then
  echo -e "usage:\trelease.sh TAG"
  exit 1
fi
files=(CHANGELOG.md VERSION)
for f in ${files[@]}; do
  sed -i "s/unreleased/$1/" $f
done
echo "released $1 on $(date -d today +%F)"

This file has 5 problems.

Show answers
  1. [[ … ]] under #!/bin/sh is a bashism: dash and BusyBox ash do not have [[, so the script fails immediately on them with a syntax error (rung 2). Use [ -z "$1" ].
  2. files=(…) and ${files[@]} are bash arrays, absent in POSIX sh (rung 2). Use the positional parameters (set -- CHANGELOG.md VERSION; for f in "$@") or a plain list.
  3. echo -e "usage:\trelease.sh TAG" relies on echo -e, which is not portable — dash's echo prints the literal -e (rung 5). Use printf 'usage:\trelease.sh TAG\n'.
  4. sed -i "s/…/…/" $f is GNU-only in that form; on macOS/BSD it needs sed -i '' and the edit is not atomic anyway (chapter 11). Write to a temp file and mv, which is both portable and safe.
  5. date -d today +%F is GNU; BSD/macOS date has no -d and spells it date -v. And $f/$1 are unquoted ($f splits, and the array expansion is unquoted too). Quote everything, and use a portable date (date +%F needs no -d for "today").

Shell manual · 17 · 8 min readReading Any Script#

You've met it: kubectl … describe "$pod" | awk '/^Events:/{e=1} e' | grep -i 'warning' | tail -1 || true — the kind of dense line this chapter teaches you to take apart · Debugging

You can now write good scripts. The last skill is reading anyone else's — a script you inherited, found in a Stack Overflow answer, or wrote yourself two years ago. This chapter is a method for doing that quickly and safely: a fixed order to read in, how to follow the data, how to decode a dense line, how to spot the dangerous ones, and an index of every historical idiom this manual has met. It has no golden script of its own; its golden file is a worked reading.

The model

Read a script in the same order every time, so you always know what you have established and what you have not:

   1. shebang + options   what shell, what strict-mode, what it refuses to run without
   2. globals + config    the variables and defaults set at the top
   3. functions           what each does, read but not yet traced
   4. the main flow       the actual sequence, calling into the functions
   5. the dangerous lines  eval, rm -rf, curl|sh, unquoted expansions — read twice

Top to bottom, outside in. The shebang and set line tell you the dialect and how forgiving it is; the globals tell you what it is configured by; the functions are vocabulary; the main flow is the sentence. The dangerous lines you find on the way get a second, careful read before you run anything.

The ladder

Rung 1 The first pass: shell, options, and what it needs

head -n 20 script.sh            # read the top: shebang, header, set, the first config
# look for, in order:
#   #!/usr/bin/env bash | #!/bin/sh    the dialect (bash features allowed, or POSIX only?)
#   set -euo pipefail                  how it fails (loudly?) — or nothing (silently?)
#   : "${VAR:?...}"  require_cmd ...    what it refuses to start without
#   trap '...' EXIT                     what it promises to clean up

The first twenty lines answer the questions that frame everything below: which shell, how it handles errors, what inputs and tools it requires, and what it cleans up. A #!/bin/sh with [[ in it is already suspect (chapter 16); a script with no set -e will march past failures (chapter 15).

Rule Read the shebang, the set line, the required-input checks and the trap first, before any logic.

Why These set the rules of the game. Reading the logic without knowing the dialect and the error behaviour is guessing; a line that is safe under set -euo pipefail is a bug without it.

Trap A #!/bin/sh shebang does not mean the script is POSIX — it means it should be. If you then see [[, arrays or local, the author tested only with bash-as-sh and the script will break on dash (chapter 16). Judge the dialect by the features used, not only by the shebang claimed.

Rung 2 Follow the data: quoting and expansion

cp $src/*.txt "$dest"           # where does $src come from? is it quoted? (chapter 3, 4)
for f in $(ls "$dir"); do …     # unquoted $(...): splits and globs — a bug on odd names (ch 7)
rm -rf "$build/"                # what is $build if it is empty? (chapter 4, and rung 4)
eval "$cmd"                     # $cmd could be anything — trace it to its source (rung 4)

Once you know the rules, follow each variable from where it is set to where it is used, asking the chapter-3 question at every expansion: is it quoted, and what does the shell do to it? An unquoted expansion that feeds a command, a loop or a glob is where most inherited bugs live.

Rule Trace each variable from its assignment to its uses; at every use, check whether it is quoted and whether word splitting or globbing applies.

Why A script's behaviour is decided by what the shell does to its words before any command runs (chapter 3). The logic can be perfect and the script still wrong because $src was unquoted and a path had a space.

Trap The bug is usually not where the symptom is. "It deleted the wrong files" traces back through an unquoted $f in the loop to an unquoted $(ls) that split a name — three lines up from the rm. Follow the data to the source, not just the line that failed.

Rung 3 Decode the dense line

# take it apart left to right, naming each stage:
find . -name '*.log' -mtime +7 -print0 | xargs -0 gzip
#   find …                 files named *.log, older than 7 days (chapter 10)
#   -print0 | xargs -0     NUL-separated, so odd names survive (chapter 10)
#   gzip                   compress each

kubectl describe "$pod" | awk '/^Events:/{e=1} e' | grep -i warning | tail -1
#   awk '/^Events:/{e=1} e'   set a flag at the Events: line, then print while the flag is set (ch 12)
#   grep -i warning           keep warning lines (chapter 10)
#   tail -1                   the last one (chapter 13)

A one-liner is a pipeline (chapter 6): read it left to right, naming what each stage receives and emits. The awk range idiom /start/{e=1} e — "once you see start, print from there on" — is worth memorising, but even unfamiliar fragments yield to "what does this stage get, what does it pass on?".

Rule Split a dense line at its pipes and name each stage's input and output; look up only the one fragment you do not recognise.

Why Every pipeline is a sequence of simple transformations (chapter 13), however alien it looks. Decoding stage by stage turns a wall of symbols into a sentence, and localises the one piece you need to look up.

Trap awk '/^Events:/{e=1} e' has no explicit print: the bare e at the end is a pattern with no action, and a true pattern means "print the line" (chapter 12). Read dense awk knowing that a bare expression prints, or you will misread it as doing nothing.

Rung 4 Spot the dangerous lines

eval "$user_input"              # runs whatever is in the string — arbitrary code
rm -rf "$dir/"                  # if $dir is empty: rm -rf / … ; always check $dir is set
curl -fsSL https://x | sh       # runs a downloaded script unseen — read it first
chmod 777 "$f"                  # world-writable: almost never what you want
: "${TMPDIR}/build.$$"          # a predictable temp name — racy and hijackable (chapter 9)
cmd $UNQUOTED                   # splitting and globbing on untrusted input

A few constructs can do real damage, and they deserve a second read before you run the script: eval, rm -rf with a variable, piping a download into a shell, loosened permissions, predictable temp files, and unquoted expansions of anything you do not control. None is always wrong, but each is where a script turns from useless to harmful.

Rule Before running an inherited script, grep it for eval, rm -rf, curl … | sh, chmod 777 and unquoted expansions, and read each one in full.

Why These are the lines that do not just fail — they delete, overwrite or execute. The cost of misreading them is not a wrong answer but a lost disk or a compromised machine.

Trap rm -rf "$dir/" with $dir unset or empty becomes rm -rf / (under older behaviour) or rm -rf /*-adjacent damage; the quotes do not save you, because the value is empty, not unset in a way set -u catches if it is ${dir:-}. Confirm the variable is both set and non-empty — [ -n "$dir" ] — before any rm -rf "$dir".

Rung 5 The historical index

# met a line you do not recognise? match its shape here, then read the chapter:
n=`expr $n + 1`          # backticks + expr      -> Variables and Quoting
if [ "x$1" = "x-v" ]     # the x-prefix test     -> Exit Status and Tests
for f in `ls`            # a backtick ls loop    -> Loops, Globs and Lines
ps aux | grep [f]oo      # ps | grep for a PID   -> Watching the System

When a line looks archaic, find it here, by what you see, and follow the link to the chapter that explains what it was, why, and what to write instead.

You see It is Explained in
`cmd` (backticks), expr, $[ ], echo -n old command substitution and arithmetic Variables and Quoting
which cmd, !!, ^old^new, pushd finding things, shell history The Ground Floor
VAR=x; export VAR, #!/bin/sh that needs bash old environment and shebang habits Every Command Is a Process
OLDIFS=$IFS …, set -f, ${1+"$@"} taming expansion by hand How the Shell Reads a Line
[ "x$a" = "xfoo" ], -a/-o in [, == in [ old test idioms Exit Status and Tests
cat f \| cmd, >& f, \| sudo tee old redirection and pipes Redirection and Pipes
for f in `ls`, for i in $(seq …), IFS=$'\n' loops old loops over files and lines Loops, Globs and Lines
function f {, global RET=, eval "$n=\$v" old functions and indirection Functions and Arrays
ps \| grep, /tmp/foo.$$, trap '…' 0 1 2 15 old process and signal handling Processes and Signals
egrep, fgrep, find … \| xargs without -0 old search tools Finding Things
sed -i (unportable form), sed '…' f > f old stream editing sed
nawk, grep X \| awk '{…}' old awk habits awk
sort \| uniq unsorted, tac, wget -qO- old pipeline habits The Pipeline Toolkit
ps -aux, netstat -an, du -sk \| sort -n old diagnostic commands Watching the System
set -e alone, getopt, /tmp/$$, eval config old script scaffolding Writing a Script
echo -e, [ a == b ], readlink -f, which portability pitfalls Portability

Rule When you meet an idiom you do not recognise, match it by shape in this index and read the chapter it points to before judging or rewriting it.

Why Old scripts are written in the dialects of their decade; recognising the idiom is the difference between "this is wrong" and "this is the 2005 way of doing what I now write differently". The index collects every old form the manual has met, in one place.

Trap Recognising an old form is not the same as safely replacing it. Some old idioms still do exactly the right thing ([ "x$a" = "xfoo" ] is harmless, just dated); others changed behaviour (an unquoted `ls` loop breaks on spaces). The chapter each row links to says which — read it before you "modernise" a line, lest you break what worked.

Decide

The line is Do
clear and safe read on
dense but not dangerous (a pipeline) decode it stage by stage (rung 3)
an unfamiliar old idiom match it in the index (rung 5), read the chapter
dangerous (eval, rm -rf $x, curl\|sh) read it in full, trace every variable, before running (rung 4)
about to run on real data run it on a copy first, or with a dry-run flag if it has one

The golden file

Not a script this time, but a worked reading: decode.md takes the book's own d02/docker-entrypoint.sh apart line by line, each line tagged with the chapter that explains it. It is the method of this chapter, applied.

examples/manual/b17/decode.md
Reading d02/docker-entrypoint.sh, line by line
===============================================

A container entrypoint, read in the chapter's order: the shebang and options
first, then the configuration it needs, then the main flow, watching for
anything dangerous. Each line is tagged with the chapter that explains it.

  #!/bin/sh
      POSIX sh, not bash. So nothing bash-only may appear below: no [[ ]], no
      arrays, no $'...'. (B2 Every Command Is a Process; B16 Portability.)

  set -eu
      Stop on an unhandled error (-e); treat an unset variable as an error (-u).
      No pipefail, because POSIX sh has none. (B15 Writing a Script; B6.)

  : "${REDIS_HOST:?REDIS_HOST must be set}"
      ":" is the do-nothing command; it exists here only to force the expansion.
      ${VAR:?msg} aborts the script with msg if REDIS_HOST is unset or empty, so
      the container fails fast with a clear message instead of on the first
      request. (B4 Variables and Quoting.)

  if [ "${1#-}" != "$1" ]; then
      ${1#-} removes one leading "-" from $1 (prefix removal, B4). If the result
      differs from $1, then $1 began with "-" — it is a flag, not a command.
      [ ... != ... ] is a string comparison, and both sides are quoted. (B5, B4.)

    set -- gunicorn "$@"
      set -- replaces the positional parameters: put "gunicorn" in front of what
      was passed, so "--workers 4" becomes "gunicorn --workers 4". (B8 Functions
      and Arrays; the "$@" keeps each argument one word, B3/B8.)
  fi

  echo "pantry: starting $1 (REDIS_HOST=$REDIS_HOST)"
      A status line on stdout. (B2 streams.)

  exec "$@"
      exec replaces this shell with the command, so the server becomes PID 1 and
      receives SIGTERM directly on "docker stop" — a graceful shutdown. "$@"
      passes every argument through, each as one word. (B9 Processes and Signals.)

Dangerous lines? None. There is no eval, no unquoted expansion feeding a command,
no rm, no curl | sh. The one subtlety is ${1#-}: an empty $1 would make the test
false and then exec an empty command — but set -u and the :? guard above
guarantee an argument exists by this point. The script is short, strict, and
every expansion is quoted: a model of the habits chapters 15 and 16 teach.
# read any script the same way:
less script.sh            # top to bottom: shebang, set, config, functions, flow
grep -nE 'eval|rm -rf|curl .*\| *sh|chmod 777' script.sh    # find the dangerous lines first

Review checklist

Spot the bug

This "cleanup" script was found in a repository's bin/, marked "removes old build dirs". Reading it the chapter's way, four things should stop you before you ever run it.

examples/manual/b17/trap/cleanup.sh
#!/bin/sh
# cleanup.sh: remove build directories older than $DAYS days.
root=$BUILD_ROOT
for d in `ls $root`; do
  age=$(find $root/$d -maxdepth 0 -mtime +$DAYS)
  eval rm -rf "$root/$d"
done

This file has 4 problems.

Show answers
  1. rm -rf "$root/$d" with root=$BUILD_ROOT unquoted and unchecked: if BUILD_ROOT is unset, root is empty and the target becomes /$d — deleting from the filesystem root (rung 4). Nothing checks that BUILD_ROOT is set and non-empty; there is no set -u and no : "${BUILD_ROOT:?}".
  2. for d in `ls $root` splits ls output on whitespace and globs it (chapters 7, and rung 2 here), so a build dir with a space becomes two names and the wrong paths are removed. Loop over "$root"/* instead.
  3. eval rm -rf "$root/$d" adds eval for no reason (rung 4): it re-parses the path, so a directory name containing a space, a ; or a $(...) is executed as code. Drop the eval entirely — rm -rf -- "$root/$d" is both safer and correct.
  4. age=$(find …) computes an age but nothing uses it — the script removes every directory, not the old ones, so the $DAYS promise is a lie. The find needs to gate the rm ([ -n "$age" ] && rm -rf …), and $DAYS itself is unquoted and unchecked.

Docker manual · 1 · 12 min readEvery Instruction#

In the story: the recipe card, one step per line · Write the Recipe Down

A Dockerfile has about eighteen instructions, and you will write six of them every day. This chapter covers all the ones you will meet. For each one it gives the rule a reviewer applies and the reason behind it, so that when someone hands you a Dockerfile you can say what every line does and which ones are wrong. Two instructions are big enough to get their own chapters: ENTRYPOINT and CMD are in D2, and USER is in D5. Here they appear only where they have to.

Every Dockerfile in this chapter starts from the same idea. Each instruction either changes the filesystem, which makes a new layer, or changes the image's configuration (environment, user, default command, metadata), which costs nothing at runtime. Knowing which kind a line is answers most questions about it.

Changes the filesystem (new layer) Changes the configuration only
RUN, COPY, ADD FROM (picks the starting point), WORKDIR*, ENV, ARG, LABEL, EXPOSE, USER, HEALTHCHECK, STOPSIGNAL, SHELL, ENTRYPOINT, CMD, VOLUME, ONBUILD

* WORKDIR creates the directory if it is missing, which adds a tiny layer the first time.

The ladder

Rung 1 The five lines

Almost every Dockerfile you write starts as these five instructions in this order: a base, a working directory, the files, the build step, the default command.

examples/manual/d01/rung1.Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "app.py"]

FROM names the image you build on. Every stage starts with one. WORKDIR sets the directory that every later RUN, COPY, CMD and ENTRYPOINT uses, creating it if needed. Use it instead of RUN cd /app, because a cd lasts only until the end of its own RUN line. COPY <src> <dest> copies from the build context, which is the directory you pass to docker build, not the directory the Dockerfile sits in. RUN executes a command at build time and saves the resulting filesystem as a layer. CMD is what runs when someone starts a container without naming a command.

docker build -t pantry-manual:d01-r1 -f rung1.Dockerfile ../../pantry
docker run --rm -d -p 8001:8080 pantry-manual:d01-r1 && curl -s localhost:8001/livez

Rule Start every Dockerfile from the five lines (FROM, WORKDIR, COPY, RUN, CMD), using absolute paths for WORKDIR and exec form (["…"]) for CMD.

Why Each line has one job. WORKDIR persists across instructions while cd does not, and COPY sources are resolved against the build context, so the image is reproducible from that directory alone.

Trap COPY ../shared/lib.py . fails with "not found", however the path looks from where you are standing. A build can only see inside its context. Move the file into the context or make the context bigger (docker build -f app/Dockerfile . from the parent).

Rung 2 ARG and ENV, and where each one is visible

ARG is a build-time variable that you set with --build-arg. ENV is an environment variable baked into the image that every container gets. They look alike, and they have different lifetimes.

examples/manual/d01/rung2-arg-scope.Dockerfile
ARG PY=3.12
FROM python:${PY}-slim
ARG APP_VERSION=dev
ENV PANTRY_VERSION=${APP_VERSION}
RUN echo "PY at build time: '${PY}'  APP_VERSION at build time: '${APP_VERSION}'"
CMD ["sh", "-c", "echo PY=$PY APP_VERSION=$APP_VERSION PANTRY_VERSION=$PANTRY_VERSION"]

Built with --build-arg APP_VERSION=v7:

#5 0.211 PY at build time: ''  APP_VERSION at build time: 'v7'
$ docker run --rm pantry-manual:d01-r2
PY= APP_VERSION= PANTRY_VERSION=v7

Three things happened. PY was used by FROM (the base really was python:3.12-slim) and is empty inside the stage. An ARG declared before the first FROM is visible only to FROM lines. To use it inside a stage, declare it again with a bare ARG PY after the FROM. APP_VERSION existed during the build and is gone in the container. PANTRY_VERSION survived because an ENV line copied it.

The scope rules in one list:

  • ARG before FROM: visible in FROM lines only. Redeclare it (ARG PY) inside each stage that needs it.
  • ARG inside a stage: visible from that line to the end of that stage. Every stage has its own.
  • ENV: visible from that line on, in this stage and in any stage built FROM this one, and in every container.
  • Both are visible in docker history (D6). Neither is the place for a password.

Rule Use ARG for build inputs and ENV for runtime settings. When a build input must reach runtime, copy it explicitly with ENV X=${X}.

Why ARG values live only for the build of their stage, and an ARG before FROM lives only for the FROM lines. ENV values are written into the image configuration and inherited.

Trap ARG VERSION=1.2 at the top of the file, then RUN echo $VERSION prints an empty line and nothing errors. You notice when a version string or download URL inside the image is blank. Redeclare ARG VERSION after FROM.

Rung 3 COPY, and the two cases where ADD is right

ADD does everything COPY does, plus two surprises. It unpacks local tar archives, and it downloads URLs.

examples/manual/d01/r3/Dockerfile
FROM alpine:3.20
COPY notes.tar.gz /copied/
ADD  notes.tar.gz /added/
ADD --checksum=sha256:489a8e1108509ed98a37bb983e11e0f7e1d31f0bd8f99a79c8448e7ff37d07ea https://raw.githubusercontent.com/pallets/flask/3.1.0/LICENSE.txt /licenses/flask.txt
CMD ["sh", "-c", "ls /copied /added /licenses"]
/added:
notes.txt
/copied:
notes.tar.gz
/licenses:
flask.txt

The same notes.tar.gz arrived packed through COPY and unpacked through ADD. If the file had been a .tar.gz you meant to ship as is (a dataset, a backup), ADD would quietly have changed your image. The remote ADD with --checksum is the one form worth keeping. BuildKit downloads the file, checks its SHA-256, and fails the build if it does not match:

ERROR: failed to solve: digest mismatch sha256:489a8e11…07ea: sha256:0000…0000

COPY has useful flags of its own. --chown and --chmod set ownership and mode while copying (D5), --from copies out of another stage or image (D4), and --link puts the copied files in an independent layer that survives a base-image change without a rebuild (D3).

Rule Use COPY for everything local. Use ADD only to unpack a local archive on purpose, or to fetch a remote file with --checksum.

Why COPY does exactly one thing. ADD's extra behaviours depend on what the file is, so the same line does different things to different inputs.

Trap ADD https://…/tool.tar.gz /opt/ without --checksum downloads whatever the server sends today, and a remote archive is not unpacked (only local ones are). You notice when /opt/tool.tar.gz is sitting there unextracted, or when last month's build and today's differ. Add --checksum, or RUN curl … | sha256sum -c in a pipefail shell (rung 5).

Rung 4 EXPOSE and LABEL: configuration, not behaviour

examples/manual/d01/rung4-expose.Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8080
LABEL org.opencontainers.image.title="pantry" \
      org.opencontainers.image.source="https://github.com/kodebot/quick-guide-to-docker-kubernetes"
CMD ["python", "app.py"]

EXPOSE 8080 records that the process listens on 8080. It does not open anything. Without -p, nothing reaches the container from the host. With -P, Docker publishes every exposed port on a random host port:

$ docker run -d --name d01e pantry-manual:d01-r4 ; docker port d01e
$ docker run -d --name d01p -P pantry-manual:d01-r4 ; docker port d01p
8080/tcp -> 0.0.0.0:55000

LABEL adds key/value metadata to the image configuration. Use the OCI keys (org.opencontainers.image.source, .revision, .version, .title, .description, .licenses). Registries show them, scanners and Renovate read source to link an image to its repository, and docker inspect prints them. Group them in one LABEL instruction to keep the configuration tidy.

Rule Write EXPOSE for every port the process listens on, and set the OCI LABELs, knowing neither changes what the container does.

Why Both write to the image configuration only. EXPOSE is read by -P, by humans and by tools. Publishing is always the runner's decision (-p, Compose ports, a Kubernetes Service).

Trap You see EXPOSE 8080 and expect curl localhost:8080 to work, and you get "connection refused" with the container running. docker port <name> shows what is really published, and an empty answer means nothing is.

Rung 5 SHELL and the pipe that hides failures

RUN in shell form (RUN some command) runs through /bin/sh -c. A shell reports the exit status of the last command in a pipe, so a failing command on the left of | is invisible.

examples/manual/d01/rung5-pipefail.Dockerfile
FROM python:3.12-slim
RUN pip download no-such-package-xyz 2>&1 | tail -n 1
RUN echo "the build carried on"
#5 3.661 ERROR: No matching distribution found for no-such-package-xyz
#6 0.201 the build carried on

pip failed, tail succeeded, and the build moved on. Switching the shell to bash with pipefail makes the whole pipe fail when any part of it does:

examples/manual/d01/rung5-pipefail-fixed.Dockerfile
FROM python:3.12-slim
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN pip download no-such-package-xyz 2>&1 | tail -n 1
RUN echo "the build carried on"
#5 ERROR: process "/bin/bash -o pipefail -c pip download no-such-package-xyz 2>&1 | tail -n 1" did not complete successfully: exit code: 1

SHELL changes the shell for every later shell-form RUN, CMD and ENTRYPOINT in the stage. Alpine has no bash. There, use SHELL ["/bin/ash", "-eo", "pipefail", "-c"], since busybox ash supports pipefail.

Long RUN lines read better as a heredoc, which BuildKit supports:

RUN <<EOF
set -eux
apt-get update
apt-get install -y --no-install-recommends ca-certificates
rm -rf /var/lib/apt/lists/*
EOF

set -e stops at the first failure, -u fails on unset variables, and -x prints each command in the build log.

Rule Put SHELL ["/bin/bash", "-o", "pipefail", "-c"] (or the ash equivalent) before any RUN that contains a pipe, and use set -eux in multi-line RUN heredocs.

Why A shell's exit status is that of the last command in a pipe, and BuildKit fails a step only on a non-zero exit status.

Trap RUN curl -fsSL $URL | tar -xz with a dead URL "succeeds" and leaves an empty directory. You find out at runtime, when the binary is missing. The build log shows curl's error a few lines above a step marked DONE.

Rung 6 HEALTHCHECK with what the image already has

HEALTHCHECK tells Docker how to test whether the process is working, not just running. docker ps then shows (healthy), (unhealthy) or (health: starting), and Compose can wait for healthy (D9).

examples/manual/d01/rung6-healthcheck.Dockerfile
FROM pantry-manual:d01-r4
HEALTHCHECK --interval=5s --timeout=2s --start-period=10s --retries=3 \
  CMD ["python", "-c", "import urllib.request,sys; urllib.request.urlopen('http://localhost:8080/livez', timeout=2)"]

The options:

  • --interval: time between checks.
  • --timeout: how long one check may take.
  • --start-period: a grace period during which failures do not count.
  • --retries: consecutive failures before the container is unhealthy.

The command must exit 0 for healthy and 1 for unhealthy.

The common mistake is the one in this file:

examples/manual/d01/rung6-healthcheck-curl.Dockerfile
FROM pantry-manual:d01-r4
HEALTHCHECK --interval=5s --timeout=2s --retries=2 CMD curl -fs http://localhost:8080/livez || exit 1
d01hg  Up 16 seconds (healthy)
d01hb  Up 17 seconds (unhealthy)
$ docker inspect -f '{{json (index .State.Health.Log 0).Output}}' d01hb
"/bin/sh: 1: curl: not found\n"

The app was fine. The check could not run, because -slim images do not ship curl. Use the language runtime the image already has, or a tiny static probe binary in distroless images.

Two limits are worth knowing. Docker only reports health. It does not restart an unhealthy container on its own (Compose's restart and Swarm do different things, D9). And Kubernetes ignores HEALTHCHECK completely, because it uses probes (K4).

Rule Write HEALTHCHECK in exec form, using only binaries the final image contains, with a --start-period that covers startup.

Why The check runs inside the container, so a missing tool fails every check. --start-period keeps a slow start from counting as failure.

Trap The container is unhealthy and the app answers fine. docker inspect -f '{{json .State.Health}}' <name> shows the check's own output, which is usually "not found" or a connection error to the wrong port.

Rung 7 VOLUME belongs to the runner, not the image

VOLUME /data in a Dockerfile tells Docker to mount a volume at /data in every container, and to create an anonymous one if the runner did not provide one.

examples/manual/d01/rung7-volume.Dockerfile
FROM alpine:3.20
RUN mkdir /data && echo "seed" > /data/seed.txt
VOLUME /data
RUN echo "added after VOLUME" > /data/late.txt
CMD ["ls", "/data"]
$ docker run --rm pantry-manual:d01-r7
late.txt
seed.txt
volumes before=119 after two plain runs=121

Every docker run without --rm and without its own mount left behind a new anonymous volume, named by a 64-character hash, which nothing will ever reuse. On a CI machine that builds and runs tests a hundred times a day, that is a hundred volumes a day.

Notice late.txt. With BuildKit (the default builder since Docker Engine 23.0, 2023), a RUN after VOLUME keeps its changes. The legacy builder threw them away, which is why older advice says "never modify a path after declaring it a volume". The leak exists with either builder.

Rule Do not put VOLUME in application images. Let the runner mount storage (-v, Compose volumes, a Kubernetes volume, D8).

Why VOLUME cannot be undone by a child image or by the runner. Every container gets a volume at that path whether it wants one or not, and anonymous volumes outlive their containers unless removed with -v.

Trap docker volume ls shows hundreds of hash-named volumes and the disk fills up. docker volume ls -f dangling=true lists the orphans, and docker image inspect -f '{{json .Config.Volumes}}' <image> shows which image declares them.

Rung 8 ONBUILD and other lines to recognise

ONBUILD <instruction> stores an instruction in the image and runs it when another Dockerfile uses this image as its FROM. It was popular for "builder" base images. You will meet it in old repositories, and you should not write it.

examples/manual/d01/r8/base.Dockerfile
FROM python:3.12-slim
WORKDIR /app
ONBUILD COPY requirements.txt .
ONBUILD RUN pip install --no-cache-dir -r requirements.txt
ONBUILD COPY . .

A child Dockerfile that says only FROM pantry-manual:d01-onbuild-base and CMD [...] fails with an error about a line it does not contain:

#6 [2/4] ONBUILD COPY requirements.txt .
ERROR: failed to solve: … "/requirements.txt": not found

Other lines to recognise and replace:

  • MAINTAINER name: deprecated. Use LABEL org.opencontainers.image.authors=….
  • STOPSIGNAL SIGQUIT: which signal docker stop sends. D2 covers it.
  • # syntax=docker/dockerfile:1: the first line of a file, not an instruction. It pins the Dockerfile frontend to the latest stable 1.x, so features such as heredocs, --checksum and RUN --mount work on any BuildKit, whatever the daemon's own version.
  • # check=error=true: makes BuildKit's build checks fail the build instead of warning (see Decide).

Rule Do not write ONBUILD. Give child images explicit COPY and RUN lines, and start every Dockerfile with # syntax=docker/dockerfile:1.

Why ONBUILD triggers are invisible in the file you are reading (only docker inspect shows them). The syntax line makes features independent of the Docker version on the machine that builds.

Trap A build fails on COPY requirements.txt and your Dockerfile has no such line. docker inspect -f '{{json .Config.OnBuild}}' <base image> reveals the hidden steps.

Decide

You want to… Use Not
Copy local files COPY ADD
Unpack a local .tar.* into the image ADD file.tar.gz /dest/ COPY + RUN tar (an extra layer holding the archive)
Download a file at build time ADD --checksum=sha256:… <url> ADD <url> with no checksum
Pass a value into the build ARG + --build-arg ENV (it leaks into every container)
Configure the running app ENV (defaults), runtime -e / Compose / ConfigMap (real values) ARG (gone at runtime)
Pass a secret into the build RUN --mount=type=secret (D6) ARG or ENV (both readable in docker history)
Persistent data a mount chosen by the runner (D8) VOLUME in the image
Strict builds # check=error=true ignoring the warnings

BuildKit runs a set of build checks on every build and prints warnings such as JSONArgsRecommended (shell-form CMD), UndefinedVar and FromAsCasing. docker build --check . runs only the checks, and the # check=error=true directive under the syntax line turns warnings into failures. In CI, use the directive.

The golden file

Every instruction from the ladder, used the way a reviewer expects in a single-stage image. D4 splits it into stages, and D5 hardens it into the final golden Dockerfile.

examples/manual/d01/Dockerfile
# syntax=docker/dockerfile:1
# Pantry, single stage, every common instruction used the way a reviewer expects.
# (Multi-stage builds are D4; the hardened version is D5's golden file.)

# Build-time knob for the base image; ARG before FROM is visible to FROM only.
ARG PYTHON_VERSION=3.12
FROM python:${PYTHON_VERSION}-slim

# Fail a RUN when any command in a pipe fails, not just the last one.
SHELL ["/bin/bash", "-o", "pipefail", "-c"]

# Metadata registries and scanners read; no effect at runtime.
LABEL org.opencontainers.image.title="pantry" \
      org.opencontainers.image.description="Greets you and counts visits in Redis" \
      org.opencontainers.image.source="https://github.com/kodebot/quick-guide-to-docker-kubernetes"

# Absolute working directory; created if missing, used by RUN, COPY, CMD below.
WORKDIR /app

# Dependencies first: this layer is reused until requirements change.
COPY requirements.txt requirements-serve.txt ./
RUN pip install --no-cache-dir -r requirements-serve.txt

# Then the code, which changes on every commit. COPY, not ADD: no surprises.
COPY app.py .

# Build-time input (ARG) turned into a runtime setting (ENV) on purpose.
ARG PANTRY_VERSION=v1
ENV PANTRY_VERSION=${PANTRY_VERSION} \
    PYTHONUNBUFFERED=1

# Documentation for humans and for -P; it publishes nothing by itself.
EXPOSE 8080

# Uses only what the image contains (python), not curl.
HEALTHCHECK --interval=10s --timeout=2s --start-period=10s --retries=3 \
  CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/livez', timeout=2)"]

# A fixed numeric user, not root (D5 explains why numeric).
USER 10001:10001

# Exec form: gunicorn is PID 1 and receives SIGTERM directly (D2).
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
docker build -t pantry-manual:d01 --build-arg PANTRY_VERSION=v3 -f Dockerfile ../../pantry
docker network create d01net
docker run -d --rm --name d01-redis --network d01net --network-alias redis redis:7-alpine
docker run -d --rm --name d01-web --network d01net -p 8001:8080 pantry-manual:d01
curl -s localhost:8001/           # Hello from Pantry (v3). You are visitor #1. …
docker ps --format '{{.Status}}'  # Up 14 seconds (healthy)

requirements-serve.txt is Pantry's own requirements.txt plus gunicorn, a production WSGI server. Flask's built-in server is for development, and it has no SIGTERM handler, which D2 shows matters.

Review checklist

Spot the bug

A Dockerfile from a pull request. It builds, and the container serves requests.

examples/manual/d01/bug/Dockerfile
FROM python:latest
WORKDIR /app
ADD . .
RUN pip install -r requirements.txt 2>&1 | tee /tmp/pip.log
VOLUME /app
EXPOSE 8080
CMD python app.py

This file has 6 problems.

Show answers
  1. FROM python:latest is unpinned, and it is the full image. On the day of testing it resolved to Python 3.15.1 and produced a 1.66 GB image, against 205 MB for the golden file. Pin python:3.12-slim (and by digest, D6).
  2. ADD . . uses ADD where COPY is meant. Because it copies the whole context before installing dependencies, every code change reinstalls everything (D3), and Dockerfiles, gitops/ and old-maps/ ended up in the image. Copy requirements*.txt first and app.py after the install.
  3. pip install … | tee without pipefail hides a failed install. With flask==99.0.0 in requirements, pip printed "No matching distribution" and the build still produced an image. Add the SHELL line from rung 5, or drop the tee. pip also runs without --no-cache-dir, which leaves 1.9 MB of cache in the layer.
  4. VOLUME /app gives every container an anonymous volume over the code directory, and they pile up. Remove it.
  5. There is no USER, so the process runs as uid=0(root). Add a numeric non-root user (D5).
  6. CMD python app.py is shell form. /bin/sh is PID 1, the build prints JSONArgsRecommended, and docker stop waited the full 10 seconds before killing it. Use exec form with a server that handles SIGTERM (D2).

Docker manual · 2 · 10 min readENTRYPOINT, CMD and PID 1#

In the story: the dish the kitchen always makes, and the default order if nobody asks for anything else · Write the Recipe Down, The Night It Fell Over

Two lines decide how your container starts. Two facts about Linux decide whether it stops. Most "my container takes ten seconds to stop", "my arguments are ignored" and "Ctrl-C does nothing" problems come from one of these four things, and none of them shows up until you look.

The two lines are ENTRYPOINT and CMD. Docker glues them into one command line. The two facts are both about the first process in a container. It has process ID 1, and in Linux PID 1 is special in two ways. Signals it has no handler for are ignored instead of killing it, and it inherits every orphaned process in the container and is expected to reap them.

workers / childrenPID 1 in the containerDocker daemondocker stopworkers / childrenPID 1 in the containerDocker daemondocker stopsignal ignored or not passed onalt[PID 1 handles the signal][no handler, or a shell in the way]stop (timeout 10s)STOPSIGNAL (default SIGTERM)forward, finish in-flight workexitexit 0 (seconds)SIGKILL after the timeoutexit 137 (work lost)

The ladder

Rung 1 Exec form and shell form

Both CMD and ENTRYPOINT (and RUN) come in two forms. Exec form is a JSON array, CMD ["python", "app.py"]. Docker runs that program directly, with those arguments. Shell form is plain text, CMD python app.py. Docker wraps it as /bin/sh -c "python app.py".

examples/manual/d02/shell-form.Dockerfile
FROM pantry-manual:d01-r4
CMD python app.py
$ docker exec d02-shell cat /proc/1/cmdline | tr '\0' ' '
/bin/sh -c python app.py
$ time docker stop d02-shell
11s

The shell is PID 1. docker stop sends SIGTERM to the shell, the shell does not pass it to Python, and after ten seconds Docker gives up and sends SIGKILL. BuildKit warns about exactly this. The build prints JSONArgsRecommended: JSON arguments recommended for CMD to prevent unintended behavior related to OS signals.

Exec form has one limit: no shell means no variable expansion. CMD ["echo", "$HOME"] prints $HOME literally. When you need expansion, use an entrypoint script (rung 6), or CMD ["sh", "-c", "exec mytool --port \"$PORT\""] with an explicit exec.

Rule Write CMD and ENTRYPOINT in exec form, every time.

Why Exec form makes your program PID 1, so it receives signals directly. Shell form puts /bin/sh in front, and the shell does not forward them.

Trap docker stop always takes exactly ten seconds, and the exit code is 137 (128 + 9, killed). docker exec <name> cat /proc/1/cmdline shows /bin/sh -c … as PID 1.

Rung 2 ENTRYPOINT and CMD together

When both are set, Docker runs ENTRYPOINT followed by CMD. CMD becomes the default arguments, and docker run IMAGE args… replaces them. Measured on Alpine with echo:

ENTRYPOINT CMD docker run IMG docker run IMG x y
(none) ["echo","cmd"] cmd runs x y (here: "x: executable file not found")
["echo","ep"] (none) ep ep x y
["echo","ep"] ["cmd"] ep cmd ep x y
echo ep (shell) ["cmd"] ep ep
["echo","ep"] echo cmd (shell) ep /bin/sh -c echo cmd ep x y

Read the last two rows twice. A shell-form ENTRYPOINT swallows CMD and the run arguments: they become arguments to sh -c, which ignores them. A shell-form CMD under an exec-form ENTRYPOINT passes the literal words /bin/sh -c … as arguments.

So the useful patterns are:

  • Tool image: ENTRYPOINT ["mytool"] plus CMD ["--help"]. docker run img --version works naturally.
  • Service image: ENTRYPOINT ["docker-entrypoint.sh"] plus CMD ["server", "--flags"]. You can still run docker run img sh to debug (rung 6).
  • Simple image: CMD ["server", "--flags"] with no ENTRYPOINT. The whole command is replaceable.
ENTRYPOINT ["echo", "ep"]
CMD ["cmd"]

Rule Put what always runs in exec-form ENTRYPOINT and the overridable defaults in exec-form CMD, or use CMD alone.

Why Docker concatenates ENTRYPOINT + (docker run arguments, or else CMD). Only exec form keeps those arguments as separate words.

Trap docker run myimage --debug behaves exactly like docker run myimage. docker image inspect -f '{{json .Config.Entrypoint}}' myimage shows a string wrapped in ["/bin/sh","-c",…], which is shell form.

Rung 3 Overriding at run time

Everything after the image name replaces CMD. --entrypoint replaces ENTRYPOINT, and it also clears CMD:

$ docker image inspect -f '{{json .Config.Entrypoint}} {{json .Config.Cmd}}' pantry-manual:d02-t3
["echo","ep"] ["cmd"]
$ docker run --rm --entrypoint echo pantry-manual:d02-t3 overridden
overridden
$ docker run --rm --entrypoint echo pantry-manual:d02-t3
                                         ← empty: not "cmd"

--entrypoint takes a single executable, not a command line. --entrypoint "python -m pdb" fails. Use --entrypoint python IMG -m pdb app.py. Compose has the same two keys, entrypoint: and command: (D9), and Kubernetes calls them command: and args: (K2). Note that Kubernetes' command replaces ENTRYPOINT, not CMD. The Bridge chapter maps all of them.

docker run --rm --entrypoint sh pantry-manual:d02 -c 'id; ls /app'     # debug shell, bypassing the entrypoint
docker run --rm pantry-manual:d02 --version                             # arguments replace CMD only

Rule Override CMD with arguments after the image name, and use --entrypoint only to get a debugging shell, passing everything you need after the image.

Why --entrypoint resets CMD to empty by design, because the old defaults were written for the old entrypoint.

Trap docker run --entrypoint /usr/local/bin/myserver img starts with no flags at all and fails on "missing --config". The defaults you expected lived in CMD, which --entrypoint threw away.

Rung 4 PID 1 ignores signals it does not handle

Exec form is necessary, and it is not sufficient. Here is Pantry's Flask development server, in exec form:

examples/manual/d02/exec-form.Dockerfile
FROM pantry-manual:d01-r4
CMD ["python", "app.py"]
$ docker exec d02-exec cat /proc/1/cmdline   →  python app.py
$ time docker stop d02-exec                  →  10s

Python is PID 1 now, and it still takes ten seconds. Normally SIGTERM's default action is "terminate". For PID 1 the kernel does not apply default actions: a signal is delivered only if the process installed a handler. Python installs a handler for SIGINT (it raises KeyboardInterrupt) but not for SIGTERM. That gives three fixes, each verified:

  1. Run a program that handles SIGTERM. gunicorn, nginx, the JVM, Node with a process.on('SIGTERM') handler, Go with signal.Notify. The D1 golden image stopped in 0.7 s.
  2. Tell Docker to send a signal it does handle. STOPSIGNAL SIGINT stopped the Flask server in under a second:
examples/manual/d02/stopsignal.Dockerfile
FROM pantry-manual:d01-r4
STOPSIGNAL SIGINT
CMD ["python", "app.py"]
  1. Put a real init in front with --init (rung 5). It stopped in 1 s.

The best fix is the first one. A process that handles SIGTERM can finish in-flight requests, and the other two only make it die faster.

Rule Make sure PID 1 handles SIGTERM, preferably by running a server that drains on SIGTERM, otherwise with STOPSIGNAL or an init.

Why The kernel never applies a default signal action to a namespace's PID 1, so a signal without a handler is simply dropped.

Trap The command is in exec form and docker stop still takes the full timeout. Look for the exit code: 137 means it was killed, while 0 or 143 (128 + 15) means it exited on SIGTERM.

Rung 5 An init process, and zombies

PID 1 has a second job. When a process dies, its parent must call wait() to collect its exit status. Until then it is a zombie, an entry in the process table. When the parent dies first, the orphan is re-parented to PID 1, and PID 1 must reap it. A program that never expected to be PID 1 does not do this:

docker run -d --name z alpine:3.20 sh -c 'for i in 1 2 3; do (sleep 0.1 &); done; exec sleep 300'
docker exec z ps -o pid,ppid,stat,comm
PID   PPID  STAT COMMAND
    1     0 S    sleep
    8     1 Z    sleep
   10     1 Z    sleep
   12     1 Z    sleep

With --init, Docker starts its bundled tini as PID 1 (/sbin/docker-init). It runs your command as its child, forwards signals to it, and reaps every orphan:

$ docker run -d --init --name z2 alpine:3.20 sh -c '…same…'
    1     0 S    docker-init
    7     1 S    sleep              ← no Z rows

Zombies cost a process-table slot each. A long-running container that shells out (health scripts, image converters, headless browsers) can eventually hit the PID limit and fail to fork. --init is a runtime flag. Kubernetes has no equivalent, so when an image needs an init, bake one in:

RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/tini", "--", "docker-entrypoint.sh"]

tini reaps orphans only. If a process that is still alive spawns children and never waits for them, those zombies stay its children, and no init can help. That is a bug in that program. (In the same test, a Python 3.12 PID 1 happened to leave no zombies, so whether a given runtime reaps is something to test, not assume.)

Rule Use --init, or bake tini into the image, whenever PID 1 is not a program written to be an init (any image that spawns subprocesses).

Why Orphans are re-parented to PID 1, and only PID 1 can reap them. tini both reaps and forwards signals.

Trap ps inside the container shows Z or <defunct> entries growing over days, and eventually fork: Resource temporarily unavailable. docker top <name> -o pid,ppid,stat,comm shows them without needing ps in the image.

Rung 6 Entrypoint scripts end with exec "$@"

A shell script as ENTRYPOINT is the standard way to do setup before the real process starts: check required settings, render a config file, wait for a dependency, fix permissions on a volume. The script must finish by replacing itself with the real command:

examples/manual/d02/docker-entrypoint.sh
#!/bin/sh
# Runs before the real process: check config, then get out of the way.
set -eu

# Fail fast with a clear message instead of a stack trace on the first request.
: "${REDIS_HOST:?REDIS_HOST must be set}"

# Allow "docker run IMAGE sh" or any other command to bypass the server.
if [ "${1#-}" != "$1" ]; then
  # First argument is a flag ("--workers 4"): treat all arguments as gunicorn options.
  set -- gunicorn "$@"
fi

echo "pantry: starting $1 (REDIS_HOST=$REDIS_HOST)"
# exec replaces this shell, so the server becomes PID 1 and receives SIGTERM itself.
exec "$@"

exec replaces the shell process with the command, so the server takes over PID 1 and receives signals. "$@" is the list of arguments the script received, which is CMD or whatever was passed to docker run, with each word kept intact by the quotes. The ${1#-} test is a convention from the official images: if the first argument starts with -, the user passed flags, so they are prepended with the default program.

$ docker run --rm pantry-manual:d02 --version
pantry: starting gunicorn (REDIS_HOST=redis)
gunicorn (version 26.2.0)
$ docker run --rm pantry-manual:d02 python -c "print('bypassed')"
bypassed
$ docker run --rm -e REDIS_HOST= pantry-manual:d02
/usr/local/bin/docker-entrypoint.sh: 6: REDIS_HOST: REDIS_HOST must be set

Without exec, the shell stays as the parent, signals stop at the shell, and you are back at rung 1. Without "$@", CMD and the run arguments are silently ignored. The spot-the-bug file does both.

Rule End every entrypoint script with exec "$@", start it with set -eu, and keep it short.

Why exec hands PID 1 to the real process. "$@" passes the arguments through unchanged. set -eu stops on the first failed command or unset variable instead of starting a half-configured server.

Trap The script works, but docker stop takes ten seconds. In /proc/*/cmdline, /bin/sh /usr/local/bin/start.sh sits between PID 1 and the server.

Rung 7 STOPSIGNAL and the stop timeout

docker stop sends the image's STOPSIGNAL (default SIGTERM), waits a timeout (default 10 s), then sends SIGKILL. A server that drains connections on SIGTERM needs that window to be longer than its drain:

examples/manual/d02/drain.Dockerfile
FROM alpine:3.20
# A process that needs 15 seconds to finish its work after SIGTERM.
CMD ["sh", "-c", "trap 'echo draining; sleep 15; echo drained; exit 0' TERM; while :; do sleep 1; done"]
$ docker stop d02-d1           →  11s exit=137 logs: draining
$ docker stop -t 30 d02-d2     →  16s exit=0   logs: draining drained

The timeout belongs to the runner: docker stop -t 30, docker run --stop-timeout 30, Compose stop_grace_period: 30s, or Kubernetes terminationGracePeriodSeconds: 30 (default 30, K4). Inside the image, make the server's own drain timeout shorter than the smallest grace period it will get. gunicorn's --graceful-timeout defaults to 30 s, longer than Docker's 10 s, which is why the golden file sets 8. Some programs want a different signal. nginx drains on SIGQUIT, so its official image sets STOPSIGNAL SIGQUIT.

Rule Set the server's drain timeout below the runner's stop timeout, and set STOPSIGNAL only when the program drains on something other than SIGTERM.

Why After the timeout, Docker sends SIGKILL, which cannot be caught, and in-flight work is lost.

Trap Deploys drop a handful of requests every time and the container's exit code is 137 even though the app handles SIGTERM. Its drain is simply longer than the grace period.

Decide

PID 1 is… Signals Orphans Do this
A server that handles SIGTERM (gunicorn, nginx, JVM, a Go service) handled reaped by the server for its own workers exec form, nothing else
A runtime with no SIGTERM handler (Flask dev server, a bare script) dropped maybe not fix the program, or STOPSIGNAL/--init
A program that spawns helpers (shells out, headless browser) depends not reaped --init or tini in the image
A shell script stops at the shell not reaped exec "$@" at the end
/bin/sh -c (shell form) stops at the shell not reaped rewrite in exec form

The golden file

Pantry with an entrypoint script, gunicorn as PID 1, and a drain that fits inside the default stop timeout. The script comes in through a second, named build context (--build-context manual=.), because the main context is the Pantry app directory and the script lives with this chapter's files.

examples/manual/d02/Dockerfile
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt requirements-serve.txt ./
RUN pip install --no-cache-dir -r requirements-serve.txt
COPY app.py .

# The entrypoint script comes from a second, named build context:
#   docker build --build-context manual=. -f Dockerfile ../../pantry
# It prepares, then execs the command (exec "$@").
COPY --from=manual --chmod=755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh

ENV REDIS_HOST=redis PYTHONUNBUFFERED=1
EXPOSE 8080
USER 10001:10001

# SIGTERM is the default; written down because the drain below depends on it.
STOPSIGNAL SIGTERM

# ENTRYPOINT = what always runs; CMD = the default arguments, easy to override.
ENTRYPOINT ["docker-entrypoint.sh"]
# --graceful-timeout must be shorter than the runner's stop timeout (docker stop -t, default 10s).
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--graceful-timeout", "8", "app:app"]
examples/manual/d02/docker-entrypoint.sh
#!/bin/sh
# Runs before the real process: check config, then get out of the way.
set -eu

# Fail fast with a clear message instead of a stack trace on the first request.
: "${REDIS_HOST:?REDIS_HOST must be set}"

# Allow "docker run IMAGE sh" or any other command to bypass the server.
if [ "${1#-}" != "$1" ]; then
  # First argument is a flag ("--workers 4"): treat all arguments as gunicorn options.
  set -- gunicorn "$@"
fi

echo "pantry: starting $1 (REDIS_HOST=$REDIS_HOST)"
# exec replaces this shell, so the server becomes PID 1 and receives SIGTERM itself.
exec "$@"
docker build --build-context manual=. -t pantry-manual:d02 -f Dockerfile ../../pantry
docker run -d --name d02-web --network d02net -p 8002:8080 pantry-manual:d02
docker exec d02-web cat /proc/1/cmdline | tr '\0' ' '   # …/gunicorn --bind 0.0.0.0:8080 …
time docker stop d02-web                                 # 0s, exit 0

Review checklist

Spot the bug

It serves requests, and the pull request says "adds 4 workers".

examples/manual/d02/bug/Dockerfile
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt requirements-serve.txt ./
RUN pip install --no-cache-dir -r requirements-serve.txt
COPY app.py .
COPY --chmod=755 <<'EOF' /usr/local/bin/start.sh
#!/bin/sh
echo "starting pantry"
gunicorn --bind 0.0.0.0:8080 --workers 2 app:app
EOF
USER 10001:10001
HEALTHCHECK CMD curl -f http://localhost:8080/livez || exit 1
ENTRYPOINT /usr/local/bin/start.sh
CMD ["--workers", "4"]

This file has 4 problems.

Show answers
  1. ENTRYPOINT /usr/local/bin/start.sh is shell form. PID 1 is /bin/sh -c /usr/local/bin/start.sh --workers 4, and the CMD words became arguments to sh -c, which ignores them. Only 2 workers booted. Use ENTRYPOINT ["/usr/local/bin/start.sh"].
  2. start.sh runs gunicorn without exec, so a second shell (/bin/sh /usr/local/bin/start.sh) stays between PID 1 and gunicorn. SIGTERM never reached gunicorn: docker stop took 11 s and exited 137. End with exec gunicorn …, or better exec "$@".
  3. start.sh ignores "$@" and hard-codes its flags, so neither CMD nor docker run IMAGE --version can change anything (--version just started the server). Move the flags to CMD and end the script with exec "$@", as in the golden file.
  4. The HEALTHCHECK calls curl, which python:3.12-slim does not have. After about 100 seconds the container was unhealthy, with "curl: not found" in the health log (D1, rung 6).

Docker manual · 3 · 9 min readContext and Cache#

In the story: a step in the recipe; unchanged steps are not cooked again · Write the Recipe Down

A build has two inputs: the Dockerfile and the build context, which is the directory you hand to docker build. It produces a stack of layers. BuildKit keeps every layer it has made and reuses one whenever it can prove the result would be identical. That proof is the cache key, and almost every slow build, bloated image or leaked file comes down to not knowing what goes into it.

Build context (what you send)

checksum

checksum

changes only this key

requirements*.txt

app.py

FROM python:3.12-slim
key: base digest

RUN pip install
key: parent + command text
+ mounted files' checksums

COPY app.py
key: parent + app.py checksum

image

edit app.py

A layer's key is its parent's key plus what the instruction depends on. For RUN, that is the command text, the build arguments in scope, and the checksums of any files it mounts. For COPY, it is the checksums of the copied files. When a key changes, that layer and every layer after it are rebuilt. Everything in this chapter follows from that one rule.

The ladder

Rung 1 The build context is everything you send

docker build -f Dockerfile <dir> sends <dir> to the builder (minus what .dockerignore excludes) before reading a single instruction. COPY can only see what was sent. To see what you are sending, copy Pantry into a scratch directory next to a 200 MB database dump and a .env file, then build with a naive Dockerfile:

examples/manual/d03/naive.Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements-serve.txt
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]
#5 transferring context: 209.77MB 3.9s done
$ docker run --rm pantry-manual:d03-naive sh -c 'cat /app/.env; du -sh /app'
API_TOKEN=sk-live-not-a-real-token
201M	/app

The token and the dump are inside the image now. Anyone who can pull the image can read them, and deleting them in a later layer does not remove them from the earlier one (rung 4). On a remote builder the 210 MB also crosses the network on every build.

Rule Choose the smallest directory that contains what the image needs as the context, and never let secrets, data or .git into it.

Why The whole context (minus ignores) is sent to the builder, and COPY . . copies everything that was sent into a layer that lives forever.

Trap The image is far larger than the app, or a credential scanner flags it. docker run --rm IMAGE ls -A /app shows dotfiles you never meant to ship, and the build log's transferring context: line shows the size before anything else happens.

Rung 2 .dockerignore, written as an allowlist

.dockerignore at the root of the context excludes paths from it. Write it as an allowlist: ignore everything, then let back in only what the Dockerfile reads. A new file in the repository then stays out of images until someone decides otherwise.

# Allowlist: ignore everything, then let back in only what the image needs.
*
!app.py
!requirements*.txt
#4 transferring context: 215B 0.0s done
$ docker run --rm pantry-manual:d03-allow ls -A /app
app.py  requirements-serve.txt  requirements.txt

The context went from 210 MB to 215 bytes. Two details matter:

  • Placement. BuildKit first looks for <Dockerfile name>.dockerignore next to the Dockerfile (naive.Dockerfile.dockerignore, Dockerfile.dockerignore), and only falls back to .dockerignore at the context root. One context can therefore serve several Dockerfiles with different ignore lists. This manual's chapters build from examples/pantry this way.
  • Syntax. Patterns are Go filepath.Match plus **, matched from the context root, and later lines win. ! re-includes. Comments start with #.
examples/manual/d03/allowlist.Dockerfile.dockerignore
# Allowlist: ignore everything, then let back in only what the image needs.
*
!app.py
!requirements*.txt

Rule Keep a .dockerignore (or <name>.dockerignore beside the Dockerfile) that starts with * and re-includes only the files the build needs.

Why An allowlist fails safe: new files, secrets and build output stay out by default. A denylist (.git, node_modules) forgets whatever nobody thought of.

Trap After you add the allowlist, the build fails with "/config.yaml": not found. That is the allowlist doing its job: a file the Dockerfile copies was not re-included. Add a !config.yaml line.

Rung 3 Order layers from least to most often changed

Every layer after a changed one is rebuilt. So the order of instructions decides what an everyday code edit costs. Copy the dependency manifest and install dependencies first, and copy the code last:

examples/manual/d03/order-good.Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt requirements-serve.txt ./
RUN pip install --no-cache-dir -r requirements-serve.txt
COPY app.py .
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]

Measured after appending one comment line to app.py:

bad order (COPY . . before pip):   11.4s   pip install re-ran
good order (requirements first):    1.7s   pip install CACHED, only COPY app.py re-ran

The same principle applies to every ecosystem. Copy package.json and the lockfile, then npm ci, then COPY . .. Copy go.mod and go.sum, then go mod download, then the source. Copy pom.xml, then mvn dependency:go-offline.

Rule Copy dependency manifests and install dependencies before copying source code.

Why A COPY key includes the checksums of the files copied, so COPY . . changes on every edit and invalidates the expensive install that follows it.

Trap Every build reinstalls all dependencies even though only code changed. The plain build log (--progress=plain) shows no CACHED line for the install step.

Rung 4 What does and does not invalidate the cache

Three measured surprises:

1) touch app.py (content unchanged)          → COPY app.py  CACHED
2) --build-arg BUILD_NUMBER=1 → 2            → every RUN after the ARG line re-ran, including one that never uses it
3) RUN apt-get update, rebuilt days later    → CACHED (the package lists are as old as the first build)
examples/manual/d03/arg.Dockerfile
FROM python:3.12-slim
RUN echo "step one"
ARG BUILD_NUMBER=0
RUN echo "step two"
RUN echo "step three, build ${BUILD_NUMBER}"
  1. Content, not timestamps. BuildKit hashes file contents, so git checkout or touch does not bust the cache, and only real changes do.
  2. An ARG taints every RUN after it. A build argument in scope is part of every following RUN's environment, so changing its value changes their keys, used or not. A BUILD_NUMBER, GIT_SHA or DATE argument declared near the top reruns the entire build on every commit. Declare such arguments as late as possible, right before the one instruction that uses them, ideally a LABEL at the end.
  3. The cache does not know the outside world changed. RUN apt-get update has the same text tomorrow, so its cached layer, with yesterday's package lists, is reused. When you later add a package on a separate RUN apt-get install line, it installs against stale lists and can fail with 404s. Always run update and install in one RUN, and use --pull or --no-cache in CI when you want fresh inputs.
RUN apt-get update \
 && apt-get install -y --no-install-recommends tini \
 && rm -rf /var/lib/apt/lists/*

Deleting files in a later layer never makes the image smaller. The earlier layer still contains them, and the later one only adds a whiteout marker. Cleanup must happen in the same RUN that created the mess, as above, or in a stage that is not shipped (D4).

Rule Declare volatile ARGs just before their only use, keep apt-get update and install in one RUN that also cleans up, and remember that the cache matches text, not the internet.

Why A RUN key is its parent, its command text and the arguments in scope. File checksums matter only for files the step copies or mounts.

Trap Builds are slow on CI and fast locally. CI passes --build-arg GIT_SHA=$SHA to a Dockerfile that declares ARG GIT_SHA on line 2, and --progress=plain shows every step after line 2 running again.

Rung 5 Cache mounts: keep the package cache between builds, not in the image

RUN --mount=type=cache,target=<dir> mounts a directory that BuildKit keeps between builds and never puts in a layer. Point it at the package manager's download cache:

examples/manual/d03/cache-mount.Dockerfile
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt requirements-serve.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements-serve.txt
COPY app.py .
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]
#9 3.147   Using cached flask-3.1.0-py3-none-any.whl.metadata (2.7 kB)
#9 3.485   Using cached gunicorn-26.2.0-py3-none-any.whl.metadata (5.5 kB)
$ docker run --rm pantry-manual:d03-cm sh -c 'du -sh /root/.cache/pip 2>/dev/null || echo "no pip cache in the image"'
no pip cache in the image

What it saves depends on how big your dependencies are. Measured when one dependency was added:

Dependencies No cache mount Cache mount
Pantry's own (5 small pure-Python wheels) 12.7 s 12.7 s
Pantry + pandas + pyarrow (~100 MB of wheels) 96.4 s 60.3 s

The image built with the mount was also 11 MB smaller (589 MB against 600 MB), because no download cache ended up in the layer.

A cache mount speeds up the case that layer caching cannot: the dependency layer has to rebuild because a manifest changed. Then the manager reinstalls everything, and the mount turns most downloads into local reads. It also removes the need for --no-cache-dir, since the cache never reaches the image. Common targets: /root/.cache/pip (pip as root), /root/.npm, /go/pkg/mod plus /root/.cache/go-build, /root/.m2, /var/cache/apt (with sharing=locked and the docker-clean hook removed).

Cache mounts live in the builder. They are empty on a fresh CI runner unless the builder itself is persistent, or you export the cache (D6). They are shared by every build on that builder that uses the same id (the target path, by default).

Rule Mount the package manager's download cache with --mount=type=cache in every dependency-install RUN.

Why The mount persists across builds without becoming part of any layer, so rebuilds reuse downloads and images carry no cache.

Trap The cache mount "does nothing" in CI. Each job gets a fresh builder with an empty cache. docker buildx du on the runner shows no exec.cachemount entries, so persist the builder or export the cache to a registry.

Rung 6 Bind mounts: read files without copying them into a layer

RUN --mount=type=bind,source=<path>,target=<path> makes files from the context (or another stage) visible during one RUN, without a COPY layer:

RUN --mount=type=bind,source=requirements.txt,target=/tmp/req/requirements.txt \
    --mount=type=bind,source=requirements-serve.txt,target=/tmp/req/requirements-serve.txt \
    --mount=type=cache,target=/root/.cache/pip \
    pip install -r /tmp/req/requirements-serve.txt

The checksums of the mounted files are part of the step's cache key, so the install still reruns exactly when the requirements change. Nothing mounted stays in the image: ls -A /tmp in the golden image is empty. Bind mounts are read-only by default, and writes with rw are thrown away after the step. Use them for manifests, for build scripts you do not want shipped, and for large inputs such as a source tree a compiler reads once.

Rule Use --mount=type=bind for files a RUN needs to read but the image does not need to contain.

Why A bind mount contributes its files' checksums to the cache key without creating a layer, so the image holds only the step's output.

Trap The step fails with Could not open requirements file: … No such file or directory: '/tmp/req/requirements.txt' for a file you can see in the context. Every path the command reads must be mounted, and -r requirements-serve.txt pulls in requirements.txt, which needed its own mount here.

Rung 7 See what the cache did

docker build --progress=plain -t img .                       # every step, CACHED markers, timings, RUN output
docker history --format '{{.Size}}\t{{.CreatedBy}}' img      # size each instruction added
docker build --no-cache -t img .                             # ignore the cache entirely
docker build --pull -t img .                                 # re-resolve FROM tags (new base patch releases)
docker build --no-cache-filter test -t img .                 # rebuild one stage only (D4)
docker buildx du                                             # what the build cache holds, and its size

docker history is the quickest bloat detector. It shows that the broken file's rm -rf /root/.cache/pip layer added 12.3 kB and the pip layer underneath still holds 23.2 MB. Read it top to bottom as "newest first".

docker history --format '{{.Size}}\t{{.CreatedBy}}' pantry-manual:d03

Rule Read --progress=plain output and docker history whenever a build is slower or an image is bigger than you expect.

Why They show, step by step, which keys missed and how many bytes each layer added, which is all the cache knows.

Trap Builds look fast locally because of cache, and the first CI build or a teammate's clone takes twenty minutes. Run docker build --no-cache occasionally to see the true cost.

Decide

Speed problem Fix
Every code edit reinstalls dependencies Reorder: manifests, install, then code (rung 3)
Every commit rebuilds everything A volatile ARG declared early; move it down (rung 4)
Adding one dependency redownloads all Cache mount (rung 5)
Large context upload Allowlist .dockerignore (rung 2)
Fresh CI builder every job Registry or GHA cache export (D6)
Big image docker history; cleanup in the same RUN; multi-stage (D4)

The golden file

Pantry built for fast, clean rebuilds: an allowlisted context, bind-mounted manifests, a pip cache mount, and code copied last with --link. It produces the same 205 MB image as D1, and rebuilds in under 3 seconds after a code edit.

examples/manual/d03/Dockerfile
# syntax=docker/dockerfile:1
# Pantry, built for fast rebuilds. Context: examples/pantry. Ignore file: Dockerfile.dockerignore (next to this file).
FROM python:3.12-slim
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
WORKDIR /app

# Dependencies: requirements are bind-mounted (no layer holds them), wheels come from a cache mount
# that survives between builds, and nothing from the cache ends up in the image.
RUN --mount=type=bind,source=requirements.txt,target=/tmp/req/requirements.txt \
    --mount=type=bind,source=requirements-serve.txt,target=/tmp/req/requirements-serve.txt \
    --mount=type=cache,target=/root/.cache/pip \
    pip install -r /tmp/req/requirements-serve.txt

# Code last: an edit here re-runs only this step. --link keeps the layer independent of the ones below it.
COPY --link app.py .

ENV PYTHONUNBUFFERED=1
EXPOSE 8080
USER 10001:10001
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
examples/manual/d03/Dockerfile.dockerignore
# Allowlist for examples/pantry: send only what this Dockerfile reads.
*
!app.py
!requirements.txt
!requirements-serve.txt
docker build -t pantry-manual:d03 -f Dockerfile ../../pantry     # transferring context: 215B
docker run --rm -u 0 pantry-manual:d03 sh -c 'ls -A /root/.cache /tmp'   # no cache, no requirements files

Review checklist

Spot the bug

It builds. It is slow on CI and larger than it should be.

examples/manual/d03/bug/Dockerfile
FROM python:3.12-slim
ARG GIT_SHA=unknown
RUN apt-get update
RUN apt-get install -y --no-install-recommends tini
WORKDIR /app
COPY . .
RUN pip install -r requirements-serve.txt
RUN rm -rf /root/.cache/pip
LABEL org.opencontainers.image.revision=${GIT_SHA}
USER 10001:10001
CMD ["tini", "--", "gunicorn", "--bind", "0.0.0.0:8080", "app:app"]

This file has 5 problems.

Show answers
  1. ARG GIT_SHA is declared on line 2, so every RUN after it depends on it. Built with a new SHA and no file changed, every step from apt-get update to rm re-ran. Declare it just before the LABEL that uses it.
  2. apt-get update and apt-get install are separate RUNs, so the update layer is cached with stale lists and the next added package can 404. The lists also stay in the image (21.6 MB). Use one RUN that updates, installs and removes /var/lib/apt/lists/*.
  3. COPY . . comes before pip install, so every code edit reinstalls all dependencies. Copy requirements*.txt first and the code after the install.
  4. pip install runs without a cache mount or --no-cache-dir, so its download cache goes into the layer (23.2 MB against 20.9 MB). Use --mount=type=cache,target=/root/.cache/pip.
  5. RUN rm -rf /root/.cache/pip in its own layer frees nothing: docker history shows it added 12.3 kB while the pip layer below still holds the cache. The image is 259 MB against the golden file's 205 MB. Clean up in the same RUN (or not at all, with a cache mount).

Docker manual · 4 · 9 min readMulti-Stage Builds#

In the story: build in one kitchen, serve from a clean one · Ship It

A multi-stage Dockerfile has more than one FROM. Each FROM starts a stage with its own filesystem, and later stages copy only what they need from earlier ones with COPY --from. Whatever you do not copy (compilers, headers, test tools, package caches, source code) stays behind and never ships. That one idea gives you small images, one Dockerfile for dev, test and production, and tests that run as part of the build.

COPY --from=deps /venv

COPY --from=test marker
(forces tests to run)

context:
requirements, app.py

manual context:
tests/

deps
python:3.12-slim + venv

test
deps + pytest + tests

dev
deps + flask --reload

prod (default)
fresh slim + venv + app.py

The ladder

Rung 1 A builder stage and a runtime stage

The basic pattern: build in an image that has everything needed to build, then start again from a small image and copy in the result.

examples/manual/d04/two-stage.Dockerfile
# syntax=docker/dockerfile:1
# Stage 1 has compilers and headers; stage 2 receives only the finished virtualenv.
FROM python:3.12 AS builder
RUN python -m venv /venv
COPY requirements.txt requirements-serve.txt ./
RUN /venv/bin/pip install --no-cache-dir -r requirements-serve.txt

FROM python:3.12-slim
COPY --from=builder /venv /venv
WORKDIR /app
COPY app.py .
ENV PATH=/venv/bin:$PATH
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]
d04-single-stage   1.63GB     /usr/bin/gcc  /usr/bin/make  /usr/bin/git
d04-two-stage      209MB      no gcc, make or git

The single-stage image built on python:3.12 ships a compiler, make and git, plus the 1.4 GB of Debian it took to get them. The two-stage image ships the same app and the same virtualenv on python:3.12-slim. A virtualenv is a good unit to copy: one directory holding every dependency. It has one condition, though. Its interpreter symlink points to the builder's Python (/usr/local/bin/python), so the runtime must have the same Python at the same path and the same minor version (rung 6 and the spot-the-bug show what happens otherwise).

Rule Build in a stage that has the tools, and ship a final stage that starts from a small base and copies in only the build output.

Why Each stage is a separate filesystem. Layers of earlier stages are never part of the final image unless copied, so tools and caches used to build cost nothing at runtime.

Trap The "small" image is still huge. The final stage was written FROM builder instead of FROM python:3.12-slim, so it inherited every layer. docker history shows the builder's apt-get layers.

Rung 2 Named stages and --target: dev, test and prod from one file

Name stages with AS and build any of them with --target. A stage can start FROM an earlier one. The golden file has four stages: deps builds the virtualenv once, test adds pytest and runs the tests, dev runs Flask's reloading server, and prod is the clean runtime.

docker build --build-context manual=. -f Dockerfile ../../pantry                          # prod (the last stage)
docker build --build-context manual=. --target dev -t pantry-manual:d04-dev -f Dockerfile ../../pantry
docker run -d -p 8004:8080 -v "$PWD/../../pantry":/app pantry-manual:d04-dev                # edits reload
alive
alive and reloaded
 * Restarting with stat
 * Debugger is active!

The dev image is the same base, the same dependencies and the same paths as production. Only the server and the debug settings differ, so "works in dev" means something. The tests/ directory and requirements-test.txt arrive through a second named context (--build-context manual=.), so the app's own directory stays clean. Compose can pick a target too (build: { target: dev }, D9).

Rule Name every stage, and build dev and test variants as --targets of the production Dockerfile instead of keeping separate Dockerfiles.

Why Shared stages build once and are cached for every target, and one file cannot drift out of sync with itself.

Trap COPY --from=0 (by index) works until someone adds a stage above it. Then it copies from the wrong stage: inserting a stage at the top of the spot-the-bug file produced "/venv": not found. Always copy --from=<name>.

Rung 3 BuildKit builds only what the target needs

BuildKit works backwards from the target and builds only the stages it depends on. Independent stages run in parallel: two stages that each sleep 5 seconds built in 8.5 s total. The other side of that is that a stage nothing depends on is skipped, test stages included. With a deliberately failing test:

prod without a dependency on test:   no [test …] steps; "naming to …d04-skip done"; exit 0
golden prod (COPY --from=test …):    FAILED tests/test_app.py::test_livez - AssertionError: assert b'alive\n' == b...

The image built happily with a failing test, because nothing asked for the test stage. Two fixes:

  1. Make the final stage depend on the test stage. The golden file copies a marker file the test stage creates only when the tests pass:
   RUN python -m pytest -q tests && touch /tmp/tests-passed
   …
   COPY --from=test /tmp/tests-passed /tmp/tests-passed
   
  1. Or run the test target explicitly in CI (docker build --target test .) before building prod.

--no-cache-filter test reruns only that stage (and anything after it) while keeping the cache for the rest, which is useful to rerun flaky tests without reinstalling dependencies.

Rule Make the shipped stage depend on the test stage (or build --target test in CI), never assume a stage runs because it is in the file.

Why BuildKit resolves the dependency graph from the target backwards, and stages outside that graph are not built at all.

Trap A broken commit produced a green build and a pushed image. The build log has no [test …] lines at all. Count them, or grep for the test runner's summary line.

Rung 4 COPY --from an image

--from also accepts any image reference, not just a stage name. Use it to take one file out of an image without inheriting the rest:

# A static busybox, only for the healthcheck: distroless has no shell, curl or wget.
COPY --from=busybox:1.37-musl /bin/busybox /busybox
HEALTHCHECK --interval=10s --timeout=2s --start-period=10s CMD ["/busybox", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/livez"]
Up 14 seconds (healthy)

The same pattern installs tools that publish themselves as images (a static binary in a scratch image is a common distribution format), and copies the CA bundle or time-zone data out of a builder (rung 7). The file must work in the target. A static binary works anywhere. A dynamically linked one needs the same libc.

Rule Use COPY --from=<image>@<digest or tag> to take single, self-contained files from other images, pinned like any base image.

Why The source image is pulled into the build but contributes only the copied paths, and pinning keeps the build reproducible (D6).

Trap The copied binary fails with exec … no such file or directory although ls shows it. It is dynamically linked against a libc the target lacks (glibc binary into Alpine, or anything into scratch). Copy a static build, such as busybox's -musl variant, which is static.

Rung 5 Choose the base: slim, alpine, distroless, scratch

The same Pantry app on five bases:

Base Size Shell Package manager libc Notes
python:3.12 1.63 GB yes apt glibc builder only
python:3.12-slim 209 MB yes apt glibc the safe default
python:3.12-alpine 102 MB yes (ash) apk musl wheels may not exist
gcr.io/distroless/python3-debian13 107 MB no no glibc Python 3.13 fixed by the image
scratch (Go, static) 9 MB no no none static binaries only

Alpine's catch is musl. Python wheels and many prebuilt binaries target glibc. When a package has no musllinux wheel, pip builds it from source, which needs compilers, takes minutes, and often fails:

#5 4.909   Downloading pyarrow-18.1.0.tar.gz (1.1 MB)
#5 37.53   Building wheel for pyarrow (pyproject.toml): started
#5 38.70   error: subprocess-exited-with-error

For interpreted languages, -slim gets most of the size win with none of the surprises. Distroless is the next step when you want no shell and no package manager to exploit. Scratch is for static binaries.

FROM python:3.12-alpine

Rule Default to -slim. Choose distroless to remove the shell and package manager, scratch for static binaries, and Alpine only after checking that every dependency has a musl build.

Why Size matters less than what is inside. Fewer packages mean fewer CVEs and less for an attacker to use, while a libc mismatch turns into build failures or subtle runtime bugs.

Trap The Alpine build takes twenty minutes on CI and works on a laptop with a warm cache. The log shows Downloading <pkg>.tar.gz and Building wheel for <pkg> where the Debian build said .whl.

Rung 6 Distroless, done right

Distroless images contain a language runtime, CA certificates, /etc/passwd with a nonroot user (65532), and nothing else: no shell, no package manager, no coreutils. They have two consequences you must design for. The runtime version belongs to the image (distroless python3-debian13 is Python 3.13.5 at /usr/bin/python3, debian12 is 3.11.2). And anything that needs a shell (shell-form CMD, entrypoint scripts, docker exec … sh) is gone.

Copying a virtualenv from a different Python breaks in a confusing way:

examples/manual/d04/distroless-wrong.Dockerfile
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS deps
RUN python -m venv /venv && /venv/bin/pip install --no-cache-dir gunicorn==26.2.0 flask==3.1.0 redis==5.2.1 prometheus-client==0.21.1

FROM gcr.io/distroless/python3-debian13:nonroot
COPY --from=deps /venv /venv
COPY app.py /app/
WORKDIR /app
ENTRYPOINT ["/venv/bin/gunicorn"]
CMD ["--bind", "0.0.0.0:8080", "app:app"]
exec /venv/bin/gunicorn: no such file or directory

The file exists. Its shebang points to /venv/bin/python, a symlink to /usr/local/bin/python3.12, which does not exist in this image. The fix is to build with the same Python minor version as the runtime, and install into a plain directory used with PYTHONPATH, so there are no symlinks to break:

examples/manual/d04/distroless.Dockerfile
# syntax=docker/dockerfile:1
# The builder's Python minor version must match the distroless image (debian13 ships 3.13).
FROM python:3.13-slim-trixie AS deps
RUN --mount=type=bind,source=requirements.txt,target=/tmp/req/requirements.txt \
    --mount=type=bind,source=requirements-serve.txt,target=/tmp/req/requirements-serve.txt \
    --mount=type=cache,target=/root/.cache/pip \
    pip install --target=/deps -r /tmp/req/requirements-serve.txt

FROM gcr.io/distroless/python3-debian13:nonroot
COPY --from=deps /deps /deps
# A static busybox, only for the healthcheck: distroless has no shell, curl or wget.
COPY --from=busybox:1.37-musl /bin/busybox /busybox
COPY app.py /app/
WORKDIR /app
ENV PYTHONPATH=/deps PYTHONUNBUFFERED=1
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=2s --start-period=10s CMD ["/busybox", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/livez"]
# The image's ENTRYPOINT is python3.13; CMD supplies the arguments.
CMD ["-m", "gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
Hello from Pantry (v1). You are visitor #1. Served by bd68d2bd6db3.
Up 14 seconds (healthy)
$ docker exec d04-dl sh
exec: "sh": executable file not found in $PATH
$ docker exec d04-dl /busybox id
uid=65532(nonroot) gid=65532(nonroot) groups=65532(nonroot)

The image's ENTRYPOINT is /usr/bin/python3.13, so CMD supplies only arguments (-m gunicorn …). To debug a shell-less container, temporarily run the :debug (or :debug-nonroot) variant of the same distroless image, which adds a busybox shell at /busybox/sh, or, in Kubernetes, attach an ephemeral debug container (K11).

Rule For distroless, build with the exact Python minor version of the runtime image, install into a directory on PYTHONPATH, use the :nonroot tag, and write CMD as arguments to the image's entrypoint.

Why Distroless fixes the interpreter's version and path, and has no shell to run scripts or shell-form commands.

Trap exec …: no such file or directory for a file that ls shows. The interpreter named in its shebang (or symlink) is missing. docker run --rm --entrypoint /busybox IMAGE ls -l /venv/bin/python (with busybox copied in) shows where the link points.

Rung 7 Static binaries on scratch

scratch is the empty image. A statically linked binary is all it needs, and Go makes one with CGO_ENABLED=0:

examples/manual/d04/go/Dockerfile
# syntax=docker/dockerfile:1
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY main.go ./
# CGO off: a fully static binary that needs no libc in the runtime image.
RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/hello .

FROM scratch
# scratch is empty: bring CA certificates for outbound TLS (and zoneinfo if you use time zones).
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/hello /hello
# A numeric user needs no /etc/passwd.
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/hello"]
with certs:    upstream status: 200 OK
without certs: Get "https://www.docker.com/": tls: failed to verify certificate: x509: certificate signed by unknown authority
listening on :8080 as uid 65532

Everything a normal base gives you implicitly must be copied in explicitly:

  • CA certificates for outbound TLS, as above.
  • Time-zone data (/usr/share/zoneinfo, or Go's -tags timetzdata) if you call time.LoadLocation.
  • /etc/passwd only if something looks up a user name. A numeric USER 65532:65532 works without it.
  • A writable /tmp if the program needs one (mount a tmpfs, D5).

The result is 9 MB, and docker stop took about a second. The Go runtime exits on SIGTERM even as PID 1, unlike Python (D2).

Rule Ship static binaries on scratch (or distroless/static) and copy in CA certificates and anything else the binary reads from the filesystem.

Why scratch has no files at all, so every file the program opens at runtime must come from a COPY.

Trap Outbound HTTPS calls fail only in the container with x509: certificate signed by unknown authority. The image has no CA bundle.

Decide

You have… Build stage Runtime stage
Python, Node, Ruby service full or slim image + deps -slim of the same version, copy the venv / node_modules
Same, hardened slim of the runtime's exact minor version, install to a directory distroless/<lang>:nonroot
Go, Rust (static) golang, rust scratch or distroless/static:nonroot + CA certs
Java JDK image + Maven/Gradle JRE or distroless/java
Static website node to build nginx:alpine, or caddy
Needs a shell at runtime any -slim (distroless and scratch have none)

The golden file

Pantry as one file with four targets: deps, test, dev and prod. Tests must pass for prod to build, dev reloads on edits, and prod is a fresh slim base with only the virtualenv and the code. D5 hardens this exact file into the final golden Dockerfile.

examples/manual/d04/Dockerfile
# syntax=docker/dockerfile:1
# One file, four targets:  deps → test → prod (default) and deps → dev.
#   docker build --build-context manual=. -f Dockerfile ../../pantry                 # prod, tests must pass
#   docker build --build-context manual=. --target dev -f Dockerfile ../../pantry    # dev server with reload

ARG PYTHON_VERSION=3.12

# ---- deps: a virtualenv with the runtime dependencies, built once, shared by every target
FROM python:${PYTHON_VERSION}-slim AS deps
RUN python -m venv /venv
ENV PATH=/venv/bin:$PATH
RUN --mount=type=bind,source=requirements.txt,target=/tmp/req/requirements.txt \
    --mount=type=bind,source=requirements-serve.txt,target=/tmp/req/requirements-serve.txt \
    --mount=type=cache,target=/root/.cache/pip \
    pip install -r /tmp/req/requirements-serve.txt

# ---- test: deps + test tools + tests; the build fails here if a test fails
FROM deps AS test
WORKDIR /app
RUN --mount=type=bind,from=manual,source=requirements-test.txt,target=/tmp/req/requirements-test.txt \
    --mount=type=cache,target=/root/.cache/pip \
    pip install -r /tmp/req/requirements-test.txt
COPY app.py .
COPY --from=manual tests/ tests/
RUN python -m pytest -q tests && touch /tmp/tests-passed

# ---- dev: Flask's reloading server; mount the source with -v "$PWD":/app
FROM deps AS dev
WORKDIR /app
COPY app.py .
ENV FLASK_APP=app.py FLASK_DEBUG=1 PYTHONUNBUFFERED=1
CMD ["flask", "run", "--host", "0.0.0.0", "--port", "8080"]

# ---- prod: a clean slim base with only the venv and the code
FROM python:${PYTHON_VERSION}-slim AS prod
# Depending on the test stage makes BuildKit run it for the default target too.
COPY --from=test /tmp/tests-passed /tmp/tests-passed
COPY --from=deps /venv /venv
WORKDIR /app
COPY app.py .
ENV PATH=/venv/bin:$PATH PYTHONUNBUFFERED=1
EXPOSE 8080
USER 10001:10001
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
examples/manual/d04/tests/test_app.py
# Runs inside the image's test stage: no Redis needed for these two routes.
from app import app


def test_livez():
    resp = app.test_client().get("/livez")
    assert resp.status_code == 200
    assert resp.data == b"alive\n"


def test_healthz_reports_missing_redis():
    resp = app.test_client().get("/healthz")
    assert resp.status_code == 503
docker build --build-context manual=. -t pantry-manual:d04 -f Dockerfile ../../pantry     # … 2 passed in 0.52s; 209MB
docker build --build-context manual=. --target dev -t pantry-manual:d04-dev -f Dockerfile ../../pantry

Review checklist

Spot the bug

It builds, the pull request description says "tests run in the build", and the image is small.

examples/manual/d04/bug/Dockerfile
# syntax=docker/dockerfile:1
ARG PYTHON_VERSION=3.12
ARG APP_VERSION=dev

FROM python:3.13 AS builder
RUN python -m venv /venv
COPY requirements.txt requirements-serve.txt ./
RUN /venv/bin/pip install --no-cache-dir -r requirements-serve.txt

FROM builder AS test
COPY app.py .
COPY --from=manual tests/ tests/
RUN /venv/bin/pip install --no-cache-dir pytest==8.3.4 && /venv/bin/python -m pytest -q tests

FROM python:${PYTHON_VERSION}-slim
COPY --from=0 /venv /venv
WORKDIR /app
COPY app.py .
LABEL org.opencontainers.image.version=${APP_VERSION}
ENV PATH=/venv/bin:$PATH
USER 10001:10001
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]

This file has 4 problems.

Show answers
  1. The builder is python:3.13 and the runtime python:3.12-slim. The venv holds lib/python3.13, so the container dies with ModuleNotFoundError: No module named 'gunicorn'. Build and run on the same version, and use the PYTHON_VERSION argument in both stages.
  2. Nothing depends on the test stage, so BuildKit skipped it. The build log has zero [test …] steps. Copy a marker from it into the final stage, or build --target test in CI.
  3. COPY --from=0 copies by index. With one stage added at the top it copied from the wrong stage and failed with "/venv": not found. Use --from=builder.
  4. APP_VERSION is declared before the first FROM and never redeclared, so the label is empty ('') despite --build-arg APP_VERSION=1.4.0 (D1, rung 2). Add ARG APP_VERSION inside the final stage.

Docker manual · 5 · 10 min readUsers and Permissions#

In the story: the cook who works the kitchen, and the keys they are given · Ship It, The Night It Fell Over

A container is a normal Linux process with a fence around it. Inside the fence it has a user ID, a group ID, a set of capabilities (slices of root's power) and a filesystem whose files have owners and modes. By default that user is root (UID 0), with a dozen capabilities, on a writable filesystem. That is the same UID 0 the host kernel trusts, kept in check only by the fence. If the fence has a hole (a kernel bug, a mounted Docker socket, a --privileged flag, a writable host path), root inside becomes root outside.

This chapter takes the process from "root on a writable filesystem" to "an unprivileged numeric user, no capabilities, no way to gain more, and a read-only filesystem except for the paths it needs". Each step is measured. The last step is the golden Dockerfile for Pantry.

Container

Host kernel (one UID space)

is

is

write: Permission denied

UID 0 root

UID 501/1000 you

UID 10001 (nobody on the host)

root in a default container

USER 10001:10001

bind mount /srv/project
owned by 1000

Without user namespaces (rung 7), there is one UID space. UID 10001 in the container is UID 10001 on the host, which is why ownership of mounted files matters.

The ladder

Rung 1 Root is the default, and what that means

$ docker run --rm python:3.12-slim sh -c 'id; grep -E "^Cap(Eff|Bnd)" /proc/self/status'
uid=0(root) gid=0(root) groups=0(root)
CapEff:	00000000a80425fb
CapBnd:	00000000a80425fb

a80425fb is Docker's default capability set: CHOWN, DAC_OVERRIDE (ignore file permissions), FOWNER, FSETID, KILL, SETGID, SETUID, SETPCAP, NET_BIND_SERVICE, NET_RAW, SYS_CHROOT, MKNOD, AUDIT_WRITE and SETFCAP. Root in a container cannot load kernel modules or remount the host, but it can read and write any file it can see, change ownership, and create raw sockets. Every file a root process writes to a bind mount is root-owned on a Linux host.

The cure starts with one line:

USER 10001:10001
$ docker run --rm -u 10001 python:3.12-slim grep -E '^Cap(Eff|Bnd)' /proc/self/status
CapEff:	0000000000000000
CapBnd:	00000000a80425fb

A non-root user has no effective capabilities. The bounding set (CapBnd) is still there, and rung 5 deals with it.

Rule Every production image ends with a non-root USER.

Why Without user namespaces, UID 0 in the container is UID 0 on the host, so any escape, or any host path mounted in, is exposed to full root.

Trap docker image inspect -f 'User={{.Config.User}}' IMAGE prints User= (empty) or User=root, and nobody noticed because the app works the same.

Rung 2 Fixed numeric UID and GID, and why the number matters

USER app and USER 10001 both drop root. Only the second is verifiable without reading the image's /etc/passwd, and Kubernetes refuses to guess:

$ kubectl get pod d05-named d05-numeric          # both with securityContext.runAsNonRoot: true
NAME          READY   STATUS                       RESTARTS   AGE
d05-named     0/1     CreateContainerConfigError   0          8s
d05-numeric   1/1     Running                      0          8s
container has runAsNonRoot and image has non-numeric user (app), cannot verify user is non-root

Create the account with explicit numbers and use the numbers in USER:

RUN groupadd --system --gid 10001 pantry \
 && useradd --system --uid 10001 --gid pantry --no-create-home --home-dir /nonexistent \
            --shell /usr/sbin/nologin pantry
USER 10001:10001

Why create the account at all, if the number is enough? A UID with no /etc/passwd entry works, but tools notice:

$ docker run --rm -u 10001:10001 python:3.12-slim sh -c 'id; echo HOME=$HOME; whoami'
uid=10001 gid=10001 groups=10001
HOME=/
whoami: cannot find name for user ID 10001

Why 10001 and not whatever useradd picks? A bare useradd app takes the first free ID, which on Debian is 1000. That is also the first human user on most Linux hosts, so files the container writes to a bind mount become that person's files, and the number changes if a base-image update adds an account. Pick a high number nobody uses on hosts (10001 and 65532, distroless's nonroot, are common), and keep it identical in every image, Compose file and Kubernetes securityContext.

Rule Create the runtime user with an explicit UID and GID above 10000, and write USER <uid>:<gid> with numbers.

Why Numbers are what the kernel and Kubernetes check. A name must be resolved through the image's /etc/passwd, which runAsNonRoot will not trust.

Trap The pod sits in CreateContainerConfigError, and kubectl describe pod says image has non-numeric user (app), cannot verify user is non-root.

Rung 3 Who owns what: code read-only, one writable directory

The runtime user needs to read the code and dependencies and write almost nothing. So leave the code owned by root (COPY's default) and give the user exactly one writable directory:

COPY --from=deps /venv /venv
WORKDIR /app
COPY app.py .
RUN install -d -o 10001 -g 10001 -m 0750 /data
USER 10001:10001
$ docker run --rm -u 10001 pantry-manual:d05 sh -c 'echo x >> /app/app.py; touch /venv/bin/evil'
sh: 1: cannot create /app/app.py: Permission denied
touch: cannot touch '/venv/bin/evil': Permission denied

The common opposite, COPY --chown=app:app . /app plus chown -R app /venv "so permissions just work", hands an attacker who gets code execution in the process the ability to rewrite the application and plant files in its PATH. The spot-the-bug image allowed exactly that. When you genuinely need ownership on copied files (a writable cache directory, files a non-root process must replace), set it during the copy, never with a later RUN chown -R:

examples/manual/d05/chown-run.Dockerfile
FROM python:3.12-slim
RUN python -m venv /venv && /venv/bin/pip install --no-cache-dir pandas==2.2.3
RUN chown -R 10001:10001 /venv
examples/manual/d05/chown-copy.Dockerfile
FROM python:3.12-slim AS deps
RUN python -m venv /venv && /venv/bin/pip install --no-cache-dir pandas==2.2.3

FROM python:3.12-slim
COPY --from=deps --chown=10001:10001 /venv /venv
chown-run:   632MB    176MB venv layer + 172MB chown layer (every file copied again)
chown-copy:  407MB    172MB COPY --chown layer

A chown in a later layer rewrites the metadata of every file, and on overlay filesystems that copies every file into the new layer. COPY --chown and COPY --chmod set ownership and mode as the files are written, in one layer.

Rule Leave code and dependencies root-owned and not writable by the runtime user, create only the directories it must write with install -d -o <uid>, and use COPY --chown/--chmod instead of RUN chown/chmod on copied files.

Why A process that cannot write its own code cannot be made to modify it, and a recursive chown after a copy duplicates every file into a new layer.

Trap The image doubles in size after "fixing permissions". docker history shows a chown -R layer as large as the layer before it.

Rung 4 A read-only root filesystem, with tmpfs where needed

--read-only mounts the container's root filesystem read-only. Nothing can be added, changed or deleted anywhere except mounted volumes and tmpfs. That stops downloaded tools, modified binaries and web shells. Most programs need a scratch directory, though:

$ docker run --rm --read-only pantry-manual:d04
FileNotFoundError: [Errno 2] No usable temporary directory found in ['/tmp', '/var/tmp', '/usr/tmp', '/app']
$ docker run -d --read-only --tmpfs /tmp pantry-manual:d04 …
Hello from Pantry (v1). You are visitor #1.
touch: cannot touch '/app/x': Read-only file system
/tmp writable

gunicorn keeps worker heartbeat files in a temporary directory, so /tmp must be writable. A tmpfs is an in-memory filesystem that vanishes with the container. Find what a program writes by running it read-only and reading the errors, or by running it normally and checking docker diff <container>, which lists every path added (A) or changed (C) since start. For Pantry it lists nothing, because gunicorn's heartbeat files are created and immediately unlinked. Set PYTHONDONTWRITEBYTECODE=1 so Python does not try to write .pyc files next to the code. Persistent data goes on a volume (-v pantry-data:/data, D8).

docker run --read-only --tmpfs /tmp -v pantry-data:/data pantry-manual:d05
docker diff <container>          # what a normal run writes: candidates for tmpfs or a volume

Rule Run with --read-only, adding --tmpfs for scratch paths and volumes for data, and design the image so that works.

Why A read-only root filesystem makes every unexpected write fail loudly, which blocks most post-exploitation steps and documents exactly where state lives.

Trap The app works locally and crashes in production, where the platform enforces a read-only root (Kubernetes readOnlyRootFilesystem, K3), with "Read-only file system" or "No usable temporary directory". Run it read-only locally first.

Rung 5 Drop capabilities and forbid privilege escalation

Even as UID 10001, two doors remain open. The capability bounding set is still full, and setuid binaries can raise the effective user. A deliberately setuid copy of id shows the second door:

setuid binary, default:            uid=10001 gid=10001 euid=0(root) groups=10001
with --cap-drop ALL only:          uid=10001 gid=10001 euid=0(root) groups=10001
with no-new-privileges:            uid=10001 gid=10001 groups=10001

The user ran a setuid program and became root (euid=0). Dropping capabilities removed root's powers (chown as root then fails with "Operation not permitted"), but not its identity, and identity is enough to read and write root-owned files. Only no-new-privileges stopped the escalation itself. Use both:

docker run --cap-drop ALL --security-opt no-new-privileges pantry-manual:d05
CapEff:	0000000000000000
CapBnd:	0000000000000000
NoNewPrivs:	1

Add back only what the program needs, by name: --cap-add NET_BIND_SERVICE to bind ports below 1024 as non-root on hosts that require it. Docker itself sets net.ipv4.ip_unprivileged_port_start=0 inside containers, so binding port 80 as UID 10001 worked here without it, but Kubernetes nodes may not. Never use --privileged. It grants every capability and every device.

Rule Run every container with --cap-drop ALL and --security-opt no-new-privileges, adding single capabilities back only when a measured need exists.

Why Capabilities are root's powers, and setuid is a way to become root. Dropping one without blocking the other leaves a path open.

Trap A "non-root" container shows euid=0 in /proc/<pid>/status (Uid: line, second field) after running a helper binary. find / -xdev -perm -4000 -type f lists the setuid binaries that make it possible: python:3.12-slim ships eight (su, passwd, mount, umount, newgrp, chfn, chsh, gpasswd).

Rung 6 Bind mounts and the UID mismatch

Bind mounts share a host directory with the container, and on Linux they share UIDs too. Docker Desktop on macOS and Windows hides this: its file-sharing layer maps every file to your host user. That is convenient, and it means "works on my Mac" says nothing about Linux. A named volume keeps real Linux ownership, so it stands in for a Linux host directory owned by UID 1000:

$ ls -ldn /srv/project                       → drwxr-xr-x 1000 1000
$ docker run -u 10001:10001 -v …:/srv/project … touch /srv/project/out.txt
touch: cannot touch '/srv/project/out.txt': Permission denied
$ docker run -u 1000:1000 …                  → ok

A brand-new named volume mounted where the image has no directory is created root-owned, with the same result for a non-root user:

$ docker run --rm -u 10001:10001 -v d05-data:/data python:3.12-slim sh -c 'ls -ldn /data; touch /data/x'
drwxr-xr-x 2 0 0 4096 Oct 23 22:52 /data
touch: cannot touch '/data/x': Permission denied

Mounted over a directory the image does have, a new volume copies that directory's contents and ownership. That is why the golden file creates /data owned by 10001:

$ docker run --rm --read-only --tmpfs /tmp -v d05-golden-data:/data pantry-manual:d05 sh -c 'ls -ldn /data; touch /data/visits'
drwxr-x--- 2 10001 10001 4096 Oct 23 22:56 /data
/data writable

For development bind mounts on Linux, run as yourself: docker run --user "$(id -u):$(id -g)" -v "$PWD":/src …, or Compose user: "${UID}:${GID}".

docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/src -w /src python:3.12-slim python -c 'open("out.txt","w")'

Rule Create every mount point in the image owned by the runtime UID, and for host bind mounts, run as the host user's UID or chown the host directory.

Why Ownership is a number, and the container's UID is checked against the host directory's numbers. Named volumes take their initial ownership from the image directory they are mounted over.

Trap CI on Linux fails with "Permission denied" writing to a mounted workspace that worked on every developer's Mac. Compare id in the container with ls -ln of the directory.

Rung 7 Rootless Docker and user namespaces

Everything so far limits what UID 0 can do. User namespaces change what UID 0 is. With userns-remap in the daemon ("userns-remap": "default" in daemon.json), container UIDs 0–65535 map to an unprivileged range on the host (such as 100000–165535). Root in the container is then nobody on the host. Rootless Docker goes further and runs the daemon itself as a normal user. Both are host settings, invisible to the Dockerfile, and they make the bind-mount ownership puzzle of rung 6 harder, because host and container numbers now differ by an offset. Kubernetes has user namespaces per Pod (hostUsers: false, K3).

docker info --format '{{json .SecurityOptions}}'    # shows "name=userns" or "name=rootless" when enabled

Rule Treat user namespaces and rootless mode as a second layer on hosts you control, and never as a reason to skip USER.

Why Remapping protects the host from container root, but the image must still be correct on hosts without it, which includes most CI runners and many clusters.

Trap After enabling userns-remap, existing named volumes show Permission denied. Their files are owned by the old, unmapped UIDs. ls -ln on the host shows the numbers, and the fix is to chown them to the remapped range.

Decide

Need Do Not
Run as non-root useradd --uid 10001 … + USER 10001:10001 USER app, or no USER
App must write somewhere install -d -o 10001 … + tmpfs or volume there chmod -R 777, chown -R app /app
Files owned by the user COPY --chown=10001:10001 RUN chown -R after copying
Port below 1024 listen on 8080 and map -p 80:8080 run as root
Debug as root once docker exec -u 0 <name> sh USER root in the image
Install a package USER root → RUN … → USER 10001 again ending the file with USER root
Container must manage the host it is an agent, not an app: review separately --privileged by default

The golden file

The golden Dockerfile for Pantry: D1's instructions, D2's PID 1, D3's cache, D4's stages and test gate, and this chapter's user, ownership, read-only layout and runtime flags. It passes the review checklist of every Docker manual chapter so far, and builds for linux/amd64 and linux/arm64.

examples/manual/d05/Dockerfile
# syntax=docker/dockerfile:1
# check=error=true
#
# Pantry, the golden Dockerfile. Build:
#   docker build --build-context manual=. -t pantry-manual:d05 -f Dockerfile ../../pantry
# Run it the way production should:
#   docker run --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \
#     -e REDIS_HOST=redis -p 8000:8080 pantry-manual:d05

# One pinned base for every stage: the tag for humans, the digest (of the multi-arch index) for machines.
# Refresh it on purpose, with: docker buildx imagetools inspect python:3.12-slim
ARG PYTHON_IMAGE=python:3.12-slim@sha256:a0f57064cbdcd6285fa45f51237ab0b35ea8ebaed06a1f5973e89e19dfb86704

# ---- deps: the virtualenv, built once; requirements are bind-mounted, wheels come from a cache mount
FROM ${PYTHON_IMAGE} AS deps
RUN python -m venv /venv
ENV PATH=/venv/bin:$PATH
RUN --mount=type=bind,source=requirements.txt,target=/tmp/req/requirements.txt \
    --mount=type=bind,source=requirements-serve.txt,target=/tmp/req/requirements-serve.txt \
    --mount=type=cache,target=/root/.cache/pip \
    pip install -r /tmp/req/requirements-serve.txt

# ---- test: the build fails here if a test fails
FROM deps AS test
WORKDIR /app
RUN --mount=type=bind,from=manual,source=requirements-test.txt,target=/tmp/req/requirements-test.txt \
    --mount=type=cache,target=/root/.cache/pip \
    pip install -r /tmp/req/requirements-test.txt
COPY app.py .
COPY --from=manual tests/ tests/
RUN python -m pytest -q -p no:cacheprovider tests && touch /tmp/tests-passed

# ---- prod: what ships
FROM ${PYTHON_IMAGE} AS prod

# A named account with a fixed UID/GID: no home directory, no login shell.
# (The name is for humans and tools like ps; USER below uses the numbers.)
RUN groupadd --system --gid 10001 pantry \
 && useradd --system --uid 10001 --gid pantry --no-create-home --home-dir /nonexistent \
            --shell /usr/sbin/nologin pantry

# Tests must have passed (this COPY makes BuildKit run the test stage).
COPY --from=test /tmp/tests-passed /tmp/tests-passed

# Dependencies and code stay owned by root and read-only to the app: it can run them, not change them.
COPY --from=deps /venv /venv
WORKDIR /app
COPY app.py .

# The one directory the app may write to, created and owned by the app user.
# A volume mounted here starts with this ownership (D8).
RUN install -d -o 10001 -g 10001 -m 0750 /data

ENV PATH=/venv/bin:$PATH \
    PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    REDIS_HOST=redis

EXPOSE 8080

# Python is in the image; curl is not.
HEALTHCHECK --interval=10s --timeout=2s --start-period=10s --retries=3 \
  CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/livez', timeout=2)"]

# Build inputs that change every release, declared last so they do not bust earlier layers.
ARG APP_VERSION=dev
ARG GIT_SHA=unknown
LABEL org.opencontainers.image.title="pantry" \
      org.opencontainers.image.source="https://github.com/kodebot/quick-guide-to-docker-kubernetes" \
      org.opencontainers.image.version="${APP_VERSION}" \
      org.opencontainers.image.revision="${GIT_SHA}"
ENV PANTRY_VERSION=${APP_VERSION}

# Numeric, so Kubernetes can verify runAsNonRoot.
USER 10001:10001

STOPSIGNAL SIGTERM
# gunicorn handles SIGTERM as PID 1; its drain (8s) fits inside docker stop's 10s.
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--graceful-timeout", "8", "--access-logfile", "-", "app:app"]
docker build --build-context manual=. --build-arg APP_VERSION=1.0.0 --build-arg GIT_SHA=c16e39a \
  -t pantry-manual:d05 -f Dockerfile ../../pantry                                # 2 passed; 209MB
docker run -d --name d05-web --network d05net -p 8009:8080 \
  --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges pantry-manual:d05
docker exec d05-web sh -c 'id; grep -E "^(CapEff|CapBnd|NoNewPrivs)" /proc/1/status'
docker buildx build --platform linux/amd64,linux/arm64 --build-context manual=. -t pantry-manual:d05-multi -f Dockerfile ../../pantry
# ~16 min on an Intel Mac: arm64 runs under emulation, tests included (D6 shows how to avoid that)

Review checklist

Spot the bug

It builds, the healthcheck passes, and "the container runs as app".

examples/manual/d05/bug/Dockerfile
FROM python:3.12-slim
RUN useradd --create-home app
WORKDIR /app
COPY requirements.txt requirements-serve.txt ./
RUN python -m venv /venv && /venv/bin/pip install --no-cache-dir -r requirements-serve.txt
RUN chown -R app:app /venv
COPY --chown=app:app app.py .
RUN chmod -R 777 /app
ENV PATH=/venv/bin:$PATH
USER app
HEALTHCHECK CMD curl -fs http://localhost:8080/livez || exit 1
# curl is needed for the healthcheck
USER root
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]

This file has 5 problems.

Show answers
  1. useradd --create-home app takes the first free UID, which was uid=1000(app): the first human user on most Linux hosts, and a number that can change. USER app is a name, so Kubernetes runAsNonRoot rejects it. Use --uid 10001 --gid 10001 and USER 10001:10001.
  2. The last USER root, added for the curl install, is never undone. The image config says User=root, and id in the container prints uid=0(root). Switch back with USER 10001:10001 after the install, or better, drop curl and use a Python healthcheck (D1).
  3. COPY --chown=app:app and chown -R app:app /venv hand the app its own code and dependencies. As app, the test wrote to /app/app.py and created /venv/bin/evil. Leave them root-owned.
  4. RUN chown -R app:app /venv in a separate layer copied the whole virtualenv again (24.8 MB on a 29.5 MB layer), giving a 277 MB image against the golden file's 209 MB. Use COPY --chown if ownership is really needed.
  5. chmod -R 777 /app makes every file world-writable and executable (-rwxrwxrwx), so any UID in the container can modify the code. Remove it: root-owned 0644 files are readable by everyone, which is all the app needs.

Docker manual · 6 · 10 min readSupply Chain and Secrets#

In the story: the cookbook library: which edition of the recipe you used, who wrote it, and what went into it · Ship It

Your image is built from other people's images and packages, by a build that may need credentials, for machines with different CPUs, and it is pulled by systems that have to trust it. Each of those is a place where something can silently change or leak:

  • a tag that points somewhere new;
  • a token that ends up in a layer;
  • an arm64 build that takes sixteen minutes;
  • a vulnerable library nobody looked at.

This chapter closes each gap with something you can check.

--cache-from / --cache-to

none

found

base image
@sha256 digest

docker buildx build
--platform amd64,arm64

secret / ssh
(mounted, never stored)

registry cache
:buildcache

image index
@sha256

attestations:
SBOM + provenance

scan gate
CRITICAL fixed?

deploy by digest

fail the release

The ladder

Rung 1 Tags move; digests do not

A tag is a movable name. The registry can point python:3.12-slim at a new image whenever the publisher pushes, which for official images is every patch release and every Debian security update. While this chapter was being tested, the same tag resolved to two different images a few hours apart:

#4 [1/4] FROM docker.io/library/python:3.12-slim@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea
…later the same day…
#7 [deps 1/3] FROM docker.io/library/python:3.12-slim@sha256:a0f57064cbdcd6285fa45f51237ab0b35ea8ebaed06a1f5973e89e19dfb86704

A digest (@sha256:…) names content, and it can never point anywhere else. Pin the digest of the multi-platform index, not of one platform's manifest, so one reference works on every architecture. Keep the tag next to it for humans:

docker buildx imagetools inspect python:3.12-slim                 # "Digest:" line is the index digest
ARG PYTHON_IMAGE=python:3.12-slim@sha256:a0f57064cbdcd6285fa45f51237ab0b35ea8ebaed06a1f5973e89e19dfb86704
FROM ${PYTHON_IMAGE}

When a tag and a digest are both present, the digest wins and the tag is only a label. Pinning means updates are now your job. Let Renovate or Dependabot open a pull request when the tag's digest changes, so security fixes still arrive, through review and CI.

Rule Pin every FROM and COPY --from=<image> to tag@sha256:<index digest>, and automate the pull requests that move the pins.

Why A digest is the hash of the content, so a pinned build gets exactly the same bytes tomorrow, while a tag can be repointed at any time.

Trap A rebuild of an old commit behaves differently from the original build. Compare the FROM …@sha256 lines in the two build logs (--progress=plain): the tag resolved to a different digest.

Rung 2 Build secrets are mounted, never passed as ARG or ENV

A build that needs a token (a private package index, a private git repository, an API to download a licence) must not receive it as an ARG, an ENV or a file COPY. Build arguments are recorded in the image history:

examples/manual/d06/secret-arg.Dockerfile
FROM python:3.12-slim
ARG PIP_INDEX_TOKEN
RUN test -n "$PIP_INDEX_TOKEN" && echo "pretend to log in to a private index"
WARN: SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "PIP_INDEX_TOKEN") (line 2)
$ docker history --no-trunc --format '{{.CreatedBy}}' pantry-manual:d06-arg | grep -o "PIP_INDEX_TOKEN=[^ ]*"
PIP_INDEX_TOKEN=tok-not-a-real-secret-123
occurrences in the saved ARG image: 1

Anyone who can pull the image can read the token. A secret mount makes the value available as a file (or an environment variable) for one RUN, and nowhere else:

examples/manual/d06/secret-mount.Dockerfile
# syntax=docker/dockerfile:1
FROM python:3.12-slim
# The secret is a file at /run/secrets/<id> during this RUN only; it is never written to a layer.
RUN --mount=type=secret,id=pip_token,required=true \
    test -s /run/secrets/pip_token && echo "token is $(wc -c < /run/secrets/pip_token) bytes"
# Or exposed as an environment variable for this RUN only:
RUN --mount=type=secret,id=pip_token,env=PIP_TOKEN,required=true \
    test -n "$PIP_TOKEN" && echo "token is in \$PIP_TOKEN for this step only"
export PIP_INDEX_TOKEN=…
docker build --secret id=pip_token,env=PIP_INDEX_TOKEN -f secret-mount.Dockerfile .
#7 0.221 token is 25 bytes
#8 [stage-0 3/3] RUN --mount=type=secret,id=pip_token,env=PIP_TOKEN,required=true     test -n "****" && …
occurrences of the token in history: 0
occurrences in the saved image tarball: 0

BuildKit even masked the value in the log (****). --secret takes the value from an environment variable (env=) or a file (src=). required=true fails the build with secret pip_token: not found when it is missing, instead of silently building without it. The secret is not part of the cache key, so rotating a token does not trigger a rebuild. If a step's output depends on the secret, add a cache-busting input on purpose.

Rule Pass build-time credentials only with --secret and RUN --mount=type=secret,…,required=true, never with ARG, ENV or COPY.

Why A secret mount exists only for the duration of one RUN and is never written to a layer, the history or the cache.

Trap The build prints SecretsUsedInArgOrEnv, or docker history --no-trunc IMAGE shows TOKEN=…. Rotate the credential first, then fix the Dockerfile: the image is already out there.

Rung 3 SSH forwarding for private repositories

For git clone or go mod download from private repositories over SSH, forward your agent instead of copying a key:

examples/manual/d06/ssh-mount.Dockerfile
# syntax=docker/dockerfile:1
FROM alpine:3.20
RUN apk add --no-cache openssh-client
# The agent socket is forwarded for this RUN only; the private key never enters the build.
RUN --mount=type=ssh ssh-add -l
$ eval $(ssh-agent -s) && ssh-add ~/.ssh/id_ed25519
$ docker build --ssh default -f ssh-mount.Dockerfile .
#8 0.293 256 SHA256:FlO7JfR3Ohm1IlOohdH3+iaehEPAPd8SZ3zJndgqjfo d06-throwaway (ED25519)
$ # without --ssh:
ERROR: failed to solve: process "/bin/sh -c ssh-add -l" did not complete successfully: exit code: 2

The build can use the key through the agent socket, but the private key never enters the build. Combine it with known_hosts pinned in the image (RUN mkdir -p ~/.ssh && ssh-keyscan github.com >> ~/.ssh/known_hosts, or better, the host's published keys copied in) so the clone does not prompt or trust blindly.

Rule Use --ssh default with RUN --mount=type=ssh for SSH access during a build, and never COPY a private key, even into a stage you do not ship.

Why The agent signs on the build's behalf without revealing the key. A copied key sits in a layer of the builder stage, which is in the build cache and in any image built from that stage.

Trap Permission denied (publickey) inside the build although ssh -T git@github.com works on your machine. The RUN has no --mount=type=ssh, or docker build was called without --ssh default.

Rung 4 Multi-platform builds without emulation

docker buildx build --platform linux/amd64,linux/arm64 builds one image per platform and joins them in an index, so docker pull on a Mac with Apple silicon, on a Raspberry Pi and on an x86 server each gets the right one. By default each platform's RUN steps execute as that platform, under QEMU emulation when the CPU differs, which is slow:

golden Pantry Dockerfile (pip install + tests), linux/amd64 + linux/arm64 on an Intel Mac:  976 s

BuildKit gives every stage four automatic arguments: BUILDPLATFORM, BUILDARCH (the machine running the build), TARGETPLATFORM and TARGETARCH (the image being built). For compiled languages, run the build stage natively and cross-compile:

examples/manual/d06/go/Dockerfile
# syntax=docker/dockerfile:1
# The build stage runs natively ($BUILDPLATFORM); Go cross-compiles for $TARGETOS/$TARGETARCH.
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS build
ARG TARGETOS TARGETARCH
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY main.go ./
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /out/hello .

FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/hello /hello
USER 65532:65532
ENTRYPOINT ["/hello"]

Measured for the two-platform Go image, built with --no-cache on an Intel Mac:

go/emulated.Dockerfile (build stage emulated for arm64):   414 s
go/Dockerfile (FROM --platform=$BUILDPLATFORM, GOARCH):     40 s

The same arguments pick the right download for each target without emulation, even for tools that cannot cross-compile:

examples/manual/d06/targetarch.Dockerfile
# syntax=docker/dockerfile:1
# BUILDPLATFORM = where the build runs; TARGETPLATFORM/TARGETARCH = what the image is for.
FROM --platform=$BUILDPLATFORM alpine:3.20 AS fetch
ARG TARGETARCH
ARG BUILDARCH
# Runs natively on the build machine, but picks the download for the target architecture.
RUN echo "building on ${BUILDARCH}, fetching the ${TARGETARCH} binary" > /fetched.txt

FROM alpine:3.20
COPY --from=fetch /fetched.txt /
CMD ["sh", "-c", "cat /fetched.txt; echo \"running on $(uname -m)\""]
building on amd64, fetching the amd64 binary
running on x86_64
building on amd64, fetching the arm64 binary
running on aarch64

Interpreted languages with native wheels or modules (Python, Node) usually need either emulation for the install step, or a native builder for each architecture (docker buildx create --append with an arm64 node, or CI runners per architecture).

Rule Build every image you publish for each platform it runs on, running build stages --platform=$BUILDPLATFORM and cross-compiling with TARGETOS/TARGETARCH wherever the toolchain allows.

Why A single-platform image fails, or runs emulated and slowly, on other CPUs. Cross-compiling on the build platform avoids emulating every compiler instruction.

Trap The image works in CI and fails on an Apple-silicon laptop with exec format error or a warning that the requested image's platform (linux/amd64) does not match the detected host platform. docker buildx imagetools inspect IMAGE lists which platforms the tag really contains.

Rung 5 SBOMs and provenance attestations

BuildKit can attach two attestations to the image index: an SBOM (software bill of materials: every package in the image) and provenance (how, from what, and by which builder it was built, in SLSA format). Both travel with the image in the registry:

docker buildx build --platform linux/amd64,linux/arm64 --sbom=true --provenance=mode=max \
  -t localhost:5001/pantry-manual:d06-arch --push -f targetarch.Dockerfile .
docker buildx imagetools inspect localhost:5001/pantry-manual:d06-arch --format '{{json .SBOM}}'
docker buildx imagetools inspect localhost:5001/pantry-manual:d06-arch --format '{{json .Provenance}}'
Platform:    unknown/unknown
  vnd.docker.reference.type:   attestation-manifest
linux/amd64 15 packages, e.g. ['alpine-baselayout', 'alpine-baselayout-data', 'alpine-keys', 'apk-tools', 'busybox', 'busybox-binsh']
provenance keys: ['buildConfig', 'buildType', 'builder', 'invocation', 'materials', 'metadata']

When a new CVE is published, the SBOMs of your registry answer "which of our images contain this package, at which version?" without rebuilding anything. mode=max provenance records the full build definition, including build-argument names and values, which is one more reason secrets never go through ARG. Attestations need a registry (or an OCI output). The classic docker image store keeps only the image, so push or use the containerd image store.

Rule Publish every release image with --sbom=true --provenance=mode=max.

Why They record what is inside the image and how it was built, attached to the exact digest, so audits and vulnerability response work from facts instead of rebuilding to find out.

Trap imagetools inspect shows no attestation-manifest entries. The build loaded into the local image store instead of pushing, or the CI builder's driver does not support attestations. Check for exporting attestation manifest in the build log.

Rung 6 Scan, and gate on what you can fix

A scanner compares the image's packages with vulnerability databases. Trivy needs no account and can scan a saved image without access to the Docker socket. On the golden images:

docker save -o d05.tar pantry-manual:d05
docker run --rm -v "$PWD":/in:ro -v trivy-cache:/root/.cache aquasec/trivy:0.76.1 image --input /in/d05.tar --severity HIGH,CRITICAL
Image HIGH + CRITICAL with --ignore-unfixed
pantry-manual:d05 (python:3.12-slim, Debian 13.7) 44 0
pantry-manual:d04-distroless 26 0
Pantry's Python packages (both images) 0 0

Two things are visible. Every finding was in the Debian packages, and none in Pantry's Python dependencies. And not one of them had a fix available yet. A gate that fails on every HIGH would have blocked every release until Debian shipped patches, so teams learn to ignore it. Gate on what you can act on (--ignore-unfixed --exit-code 1 for CRITICAL), look at the rest on a schedule, and rebuild often so that fixed packages arrive through new base digests (rung 1). Fewer packages also means fewer findings to triage: distroless had 26 against slim's 44.

Rule Scan every release image in CI and fail on fixable vulnerabilities above an agreed severity, and track the unfixed ones instead of gating on them.

Why A gate that blocks on findings nobody can fix gets bypassed. A gate on fixable findings forces exactly the update that would remove them.

Trap The scan is green and the image is still months old. Scanners report on what is in the image now, against the database now, so an image that is never rebuilt never picks up its base's fixes. Rebuild on a schedule and pin digests through Renovate.

Rung 7 Share the build cache through the registry

A fresh CI runner has an empty BuildKit cache, so every job rebuilds everything (D3). Export the cache to the registry next to the image, and import it at the start of the next build, on any machine:

docker buildx build --cache-to type=registry,ref=localhost:5001/pantry-manual:buildcache,mode=max …
docker buildx create --name d06-fresh --driver docker-container --driver-opt network=host --bootstrap
docker buildx build --builder d06-fresh --cache-from type=registry,ref=localhost:5001/pantry-manual:buildcache …
importing cache manifest from localhost:5001/pantry-manual:buildcache
CACHED

A brand-new builder with an empty cache rebuilt nothing. mode=max exports the layers of every stage, including deps and test, not only the final one, and that is what makes a cold runner fast. Other backends work the same way: type=gha for GitHub Actions, type=local for a directory a CI system caches, type=s3. Keep the cache in its own tag (or repository) so it does not clutter image listings.

Rule In CI, --cache-from and --cache-to … mode=max to a registry (or the CI's native cache), keyed per branch with a fallback to the main branch.

Why BuildKit can import a remote cache manifest and reuse any layer whose key matches, which turns a cold runner into a warm one.

Trap CI exports the cache but every build is still cold. It used the default mode=min, which exports only the final stage's layers, while the expensive deps stage was not exported. Look for CACHED on the dependency step in the build log.

Decide

Need Mechanism Not
Reproducible base tag@sha256:<index digest> + Renovate :latest, floating :3
Token during build --secret + RUN --mount=type=secret ARG, ENV, COPY .netrc, rm later
Private git over SSH --ssh default + RUN --mount=type=ssh copying id_rsa into a stage
arm64 + amd64, compiled FROM --platform=$BUILDPLATFORM + TARGETARCH emulating the compiler
arm64 + amd64, interpreted native builder per arch, or accept emulation shipping amd64 only
What is inside --sbom=true rebuilding to find out
How it was built --provenance=mode=max a wiki page
Vulnerabilities scan in CI, gate on fixable CRITICAL gating on every finding
Cold CI runners --cache-to/--cache-from type=registry,mode=max --no-cache every time

The golden file

The release script CI runs for Pantry. It builds the D5 golden Dockerfile for two platforms with attestations and a registry cache, pushes, gates on fixable critical vulnerabilities, and prints the digest to deploy. Verified against a throwaway local registry (registry:2 on 127.0.0.1:5001).

examples/manual/d06/release.sh
#!/bin/sh
# Release build for Pantry: multi-platform, attested (SBOM + provenance), cached in the registry,
# scanned before anyone deploys it, and identified by digest. CI runs exactly this script.
#   REGISTRY=registry.example.com/team PLATFORMS=linux/amd64,linux/arm64 ./release.sh
set -eu
cd "$(dirname "$0")"

REGISTRY=${REGISTRY:-localhost:5001}
IMAGE="$REGISTRY/pantry-manual"
PLATFORMS=${PLATFORMS:-linux/amd64,linux/arm64}
GIT_SHA=$(git rev-parse --short HEAD)
VERSION=${VERSION:-$(git describe --tags --always)}
METADATA=$(mktemp)
trap 'rm -f "$METADATA"' EXIT

# The golden Dockerfile from d05; its tests run inside the build (for every platform).
docker buildx build \
  --platform "$PLATFORMS" \
  --build-context manual=../d05 \
  -f ../d05/Dockerfile \
  --build-arg APP_VERSION="$VERSION" \
  --build-arg GIT_SHA="$GIT_SHA" \
  --sbom=true \
  --provenance=mode=max \
  --cache-from type=registry,ref="$IMAGE:buildcache" \
  --cache-to type=registry,ref="$IMAGE:buildcache",mode=max \
  --tag "$IMAGE:$VERSION" \
  --tag "$IMAGE:sha-$GIT_SHA" \
  --metadata-file "$METADATA" \
  --push \
  ../../pantry

# Tags can be moved; the digest of the multi-platform index cannot. Deploy the digest.
# The build wrote it to the metadata file as "containerimage.digest".
DIGEST=$(sed -n 's/.*"containerimage.digest": *"\(sha256:[0-9a-f]*\)".*/\1/p' "$METADATA")
[ -n "$DIGEST" ] || { echo "no image digest in the build metadata" >&2; exit 1; }

# Gate: fail the release on CRITICAL vulnerabilities that have a fix available.
docker run --rm --network host -v trivy-cache:/root/.cache aquasec/trivy:0.76.1 image \
  --quiet --severity CRITICAL --ignore-unfixed --exit-code 1 "$IMAGE@$DIGEST"

echo "released $IMAGE@$DIGEST"
docker run -d --name registry -p 127.0.0.1:5001:5000 registry:2     # a throwaway registry to try it against
./release.sh
#   #9 importing cache manifest from localhost:5001/pantry-manual:buildcache
#   #44 exporting cache to registry
#   - '0': Clean (no security findings detected)
#   released localhost:5001/pantry-manual@sha256:3fab95cb02e77fd6fa9a795b8f066a586726e61bd4213ebc79cfa2d66aa0fc00

With the cache warm, the whole two-platform release took 18 seconds. The first version of this script read the digest with an imagetools inspect --format template that does not exist, got a multi-line listing instead, and Trivy failed on it. Reading containerimage.digest from --metadata-file is the reliable way: the build itself reports what it pushed.

Review checklist

Spot the bug

It builds and runs. Security review flagged it.

examples/manual/d06/bug/Dockerfile
FROM python:3-slim
ARG PIP_INDEX_TOKEN
RUN mkdir -p /root/.config/pip \
 && printf '[global]\nextra-index-url = https://__token__:%s@pypi.example.com/simple\n' "$PIP_INDEX_TOKEN" > /root/.config/pip/pip.conf
WORKDIR /app
COPY requirements.txt requirements-serve.txt ./
RUN pip install --no-cache-dir -r requirements-serve.txt
RUN rm /root/.config/pip/pip.conf
ADD https://raw.githubusercontent.com/pallets/flask/3.1.0/LICENSE.txt /licenses/flask.txt
COPY --from=busybox:latest /bin/busybox /busybox
COPY app.py .
USER 10001:10001
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]

This file has 5 problems.

Show answers
  1. FROM python:3-slim floats across minor versions: it resolved to Python 3.15.1, while the code was tested on 3.12. Pin python:3.12-slim@sha256:<index digest> (rung 1).
  2. ARG PIP_INDEX_TOKEN puts the token in the image history (4 lines of docker history --no-trunc), and the build warns SecretsUsedInArgOrEnv. Use --secret and RUN --mount=type=secret (rung 2).
  3. The token is written to pip.conf in one layer and "removed" in a later one. The saved image still has the file with token__:tok-not-a-real-secret-123 in layer bd3640154cdf. The later layer adds only a whiteout entry (.wh.pip.conf). Pass the index URL through a secret-mounted environment variable for the one RUN that needs it.
  4. ADD https://… has no --checksum, so the build ships whatever the server returns today (D1, rung 3). Add --checksum=sha256:….
  5. COPY --from=busybox:latest is unpinned: a different busybox can arrive on any build. Pin it like a base image, for example busybox:1.37-musl@sha256:….

Docker manual · 7 · 8 min readNetworks#

In the story: doors between kitchens, and a serving hatch to the street · Memory and Neighbours

Every container gets its own network namespace: its own interfaces, its own localhost, its own routing table and ports. Docker connects namespaces to networks. On a single host that is usually a virtual bridge with an embedded DNS server. To let the outside world in, it publishes chosen ports on the host. Almost every networking surprise is one of three things:

  • the wrong network, so names do not resolve;
  • the wrong idea of localhost;
  • a port published wider than you meant.

Host

pantry-back (internal)

pantry-front (bridge)

published: 127.0.0.1:8000 → 8080

not published

redis:6379 (name via DNS)

no route out

127.0.0.1:8000

192.168.0.39:*

web
:8080

redis
:6379

127.0.0.11
embedded DNS

internet

The ladder

Rung 1 The default bridge has no DNS; user-defined bridges do

Containers started without --network join the default network, bridge. It is a leftover from early Docker: containers on it can reach each other by IP, but not by name. Any network you create yourself gets an embedded DNS server at 127.0.0.11, which resolves container names and aliases:

default bridge, by name:      gaierror [Errno -2] Name or service not known
default bridge, by IP:        connected
$ docker network create d07net
on d07net: nameserver 127.0.0.11; d07-redis -> 172.24.0.2; connected by name
docker network create d07net
docker run -d --name d07-redis --network d07net redis:7-alpine
docker run --rm --network d07net python:3.12-slim python -c "import socket; print(socket.gethostbyname('d07-redis'))"

And localhost inside a container is that container. REDIS_HOST=localhost points the app at its own namespace, where nothing listens on 6379: ConnectionRefusedError: [Errno 111] Connection refused. Compose creates a user-defined network for every project, which is why service names "just work" there (D9).

Rule Put containers that talk to each other on a network you created, and address them by container name or alias, never by IP and never as localhost.

Why Only user-defined networks have the embedded DNS, IPs change on every restart, and localhost is per namespace.

Trap Name or service not known for a container you can see in docker ps. docker inspect -f '{{json .NetworkSettings.Networks}}' <name> shows it is on bridge, or on a different network from its caller.

Rung 2 Publishing: choose the interface

-p 8000:8080 publishes container port 8080 on host port 8000 on every interface of the host. On a laptop that is your Wi-Fi address. On a server it is the public one:

-p 8010:8080             8080/tcp -> 0.0.0.0:8010     localhost: 200   192.168.0.39: 200
-p 127.0.0.1:8013:8080   8080/tcp -> 127.0.0.1:8013   localhost: 200   192.168.0.39: (refused)
docker run -d -p 127.0.0.1:8000:8080 pantry-manual:d05     # only this machine can reach it
docker port <name>                                         # what is published, and where

On a Linux server there is a second surprise. Docker writes its own iptables rules for published ports, and they are evaluated before the rules of host firewalls such as ufw or firewalld. ufw deny 6379 does not close a port that Docker published. Docker's documentation ("Packet filtering and firewalls") says so explicitly. Docker Desktop has no ufw, so this cannot be shown here. On servers, bind to 127.0.0.1 or a private interface, or do not publish at all and let a reverse proxy on the same network reach the container.

Rule Publish with an explicit address (-p 127.0.0.1:host:container for local access) and publish only the ports that must be reached from outside Docker.

Why A bare -p binds 0.0.0.0 (and ::). On Linux, Docker's iptables rules for it bypass the host firewall.

Trap A database you started "for local testing" shows up in someone's internet-wide scan. docker ps shows 0.0.0.0:6379->6379/tcp in the PORTS column, and nc <your LAN IP> 6379 from another machine answers.

Rung 3 Containers reach each other without publishing

Publishing is for the host and beyond. Containers on the same network reach every port the other container listens on, published or not, EXPOSEd or not. Redis in the golden layout publishes nothing and is reached by name:

docker run -d --name pantry-redis --network pantry-back --network-alias redis redis:7-alpine
docker run -d --name pantry-web --network pantry-back -e REDIS_HOST=redis pantry-manual:d05     # → redis:6379, nothing published

The flip side is that a shared network is a flat trust zone. Every container on it can reach every port of every other. Split networks by who needs to talk to whom (rung 4). Compose's expose: key and the Dockerfile's EXPOSE are documentation only and change nothing about reachability.

Rule Publish only the entry point (web), and reach everything else (databases, caches, internal APIs) over a shared network by name.

Why Published ports cross the host boundary. Network membership is what controls container-to-container access, regardless of EXPOSE or expose.

Trap The database is published "so the app can reach it", and the app then connects through host.docker.internal:5432. It works, and it has also exposed the database on the host's interfaces. Put both on one network and remove the -p.

Rung 4 Internal networks and containers on two networks

docker network create --internal makes a bridge with no route to the outside: containers on it can talk to each other and to nothing else. Ports cannot even be published from it. A container can join several networks, so the web tier can sit on a normal network (for its published port and outbound calls) and on the internal one (to reach the data tier):

web on pantry-front only:            the pantry is closed, Redis at 'cache' is unreachable
docker network connect d07-backend d07-web
web on both:                         Hello from Pantry (1.0.0). You are visitor #1.
from the internal network → 1.1.1.1:443:   OSError: [Errno 101] Network is unreachable
-p 8015:6379 on an internal-only container: (nothing published)
docker network create --internal pantry-back
docker network connect pantry-back pantry-web        # add a second network to a running container
docker network disconnect pantry-back pantry-web

--network-alias redis gives a container an extra DNS name on one network. Several containers can share an alias, and DNS then returns all of them, which is a crude form of load balancing. The app is configured with the role (redis), not the container's name.

Rule Put data stores on an --internal network, attach only the containers that need them, and give them role aliases.

Why An internal network removes both inbound publishing and outbound egress, which is what limits the damage when one container is compromised.

Trap A container on an internal network cannot download anything (Network is unreachable), including at startup: package installs, licence checks, telemetry. Do those at build time, or give that container a second, non-internal network.

Rung 5 none, host, and reaching the host

--network none gives a container only a loopback interface, which suits batch jobs that read and write mounted files and must never talk to anything:

$ docker run --rm --network none alpine:3.20 ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> … inet 127.0.0.1/8 scope host lo
(the tunnel devices exist but are DOWN)

--network host shares the host's network namespace: no isolation and no port mapping, so the process binds the host's ports directly. On Linux it removes NAT overhead for very high packet rates. On Docker Desktop "the host" is the Linux VM, so it means much less there. Avoid it unless you have measured a need.

To reach a service on the host from a container, use host.docker.internal. Docker Desktop resolves it out of the box (here 192.168.65.254). On Linux, add it explicitly, and the same flag works everywhere:

docker run --add-host=host.docker.internal:host-gateway alpine:3.20 grep host.docker.internal /etc/hosts

Rule Use --network none for jobs that need no network, avoid --network host, and reach host services through host.docker.internal with --add-host=host.docker.internal:host-gateway.

Why none is the strongest isolation available. host removes isolation entirely. host-gateway resolves to the host's address on the bridge on every platform.

Trap The app reaches the host's database on a Mac and fails on Linux CI with Name or service not known for host.docker.internal. Linux needs the --add-host (or Compose extra_hosts) line.

Rung 6 Debug from inside the container's network namespace

Hardened images have no ping, dig, ss or curl. You do not need them in the image. Start a toolbox container in the target's network namespace with --network container:<name>. It sees the same interfaces, the same localhost and the same DNS:

docker run --rm --network container:d07-web2 nicolaka/netshoot:v0.13 sh -c \
  'ss -lnt; dig +short d07-redis; nc -zv d07-redis 6379'
LISTEN 0      2048         0.0.0.0:8080       0.0.0.0:*
LISTEN 0      4096      127.0.0.11:35865      0.0.0.0:*
172.24.0.2
Connection to d07-redis (172.24.0.2) 6379 port [tcp/redis] succeeded!

The order of questions is always the same:

  1. Is the process listening, and on which address? ss -lnt shows 0.0.0.0 or 127.0.0.1.
  2. Does the name resolve? dig. An NXDOMAIN means wrong network or wrong name.
  3. Does the port connect? nc -zv.
  4. Only then, does the protocol work? curl, redis-cli.

A server listening on 127.0.0.1 inside its container is unreachable from every other container and through -p. Programs must bind 0.0.0.0 in containers.

Rule Debug networking from a toolbox container sharing the target's namespace, in the order listen → resolve → connect → protocol.

Why --network container: joins the target's exact network stack without adding tools to the image.

Trap -p 8000:8080 is published, and curl localhost:8000 fails with curl: (52) Empty reply from server. ss -lnt in the namespace shows the app listening on 127.0.0.1:8080, which is only reachable from inside the container. Bind 0.0.0.0.

Rung 7 Other drivers, in one paragraph each

docker network ls                                    # bridge, host, none, and your own
docker network inspect pantry-back -f '{{.Internal}} {{json .IPAM.Config}}'
  • macvlan / ipvlan: the container gets its own address on the physical LAN, as if it were a separate machine. Use it for software that must be reachable on the LAN without port mapping (legacy appliances, some network tools). It needs a NIC in promiscuous mode, and by design the host cannot talk to its own macvlan containers.
  • overlay: one network spanning several Docker hosts (Swarm). In Kubernetes the CNI plugin plays this role instead (K8).
  • Custom subnets: docker network create --subnet 10.99.0.0/24 … when the default 172.17+.0.0/16 ranges collide with a corporate VPN, which shows up as "the VPN breaks when Docker is running".

Rule Stay on user-defined bridges for single-host work, and reach for macvlan, overlay or a custom subnet only for the specific problem each solves.

Why Bridges give isolation, DNS and port publishing with no host configuration. The other drivers trade isolation for reachability.

Trap After docker compose up, part of the office network becomes unreachable. A Docker bridge took a subnet the VPN routes. docker network inspect shows the subnet. Set default-address-pools in daemon.json, or pick subnets explicitly.

Decide

Need Network Publish
Two containers talk user-defined bridge, by name nothing
Browser on this machine same -p 127.0.0.1:8000:8080
Public service on a server user-defined bridge + reverse proxy only the proxy's 80/443
Database, cache --internal network, alias never
Batch job, no network --network none n/a
Reach a host service host.docker.internal + --add-host …:host-gateway n/a
Container needs a LAN address macvlan n/a
Containers across hosts overlay (Swarm) or Kubernetes n/a

The golden file

Pantry's network layout with plain docker commands: a front network with outbound access, an internal back network for Redis, the web container on both, and the only published port bound to loopback. D9 expresses the same layout as a Compose file.

examples/manual/d07/up.sh
#!/bin/sh
# Pantry's network layout with plain docker commands (D9 writes the same thing as a Compose file).
#
#   host 127.0.0.1:8000 ──► web ──(pantry-back, internal)──► redis
#                            │
#                       pantry-front (outbound allowed)
set -eu
IMAGE=${IMAGE:-pantry-manual:d05}

# A user-defined bridge for the web tier: embedded DNS, outbound internet allowed.
docker network create pantry-front
# An internal bridge for data: no route out, nothing on it can be published.
docker network create --internal pantry-back

# Redis lives only on the internal network, reachable by the alias "redis".
docker run -d --name pantry-redis --network pantry-back --network-alias redis \
  -v pantry-manual-data:/data redis:7-alpine redis-server --appendonly yes

# The web container starts on the front network, published on loopback only...
docker run -d --name pantry-web --network pantry-front -p 127.0.0.1:8000:8080 \
  -e REDIS_HOST=redis \
  --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \
  "$IMAGE"
# ...and is then attached to the back network too, so it can reach redis by name.
docker network connect pantry-back pantry-web

echo "up: curl -s localhost:8000/"
examples/manual/d07/down.sh
#!/bin/sh
# Remove what up.sh created; the pantry-manual-data volume is kept (add -v semantics by hand if you mean it).
docker rm -f pantry-web pantry-redis 2>/dev/null
docker network rm pantry-front pantry-back 2>/dev/null
true
./up.sh && curl -s localhost:8000/          # Hello from Pantry (1.0.0). You are visitor #1. …
curl -s -m2 192.168.0.39:8000/ || echo "not reachable from the LAN"
docker exec pantry-redis wget -q -T 3 -O /dev/null http://1.1.1.1 || echo "redis: no route out"
./down.sh

Review checklist

Spot the bug

"Pantry on a dev server, two commands."

examples/manual/d07/bug/up.sh
#!/bin/sh
set -eu
docker run -d --name pantry-redis -p 6379:6379 redis:7-alpine
docker run -d --name pantry-web -p 8000:8080 -e REDIS_HOST=localhost pantry-manual:d05

This file has 4 problems.

Show answers
  1. -p 6379:6379 publishes Redis on every interface. From the LAN address, PING got +PONG with no authentication. Do not publish it.
  2. REDIS_HOST=localhost points Pantry at its own container: "Redis at 'localhost' is unreachable". Use the Redis container's name or alias.
  3. Both containers are on the default bridge, where names do not resolve: even pantry-redis gives Name or service not known. Create a network, preferably an --internal one for Redis, and attach both.
  4. -p 8000:8080 publishes the app on every interface of a dev server. Bind 127.0.0.1:8000:8080 and reach it through an SSH tunnel or a reverse proxy.

Docker manual · 8 · 7 min readVolumes#

In the story: the pantry shelf that survives the dish being thrown away · Memory and Neighbours

A container's own filesystem is a thin writable layer on top of the image. It disappears when the container is removed, and it is slow for heavy writes. Anything that must outlive the container, or be shared with the host or another container, goes in a mount. Docker has three kinds, and they behave differently in exactly the places that cause data loss.

Container

writable layer
(gone with the container)

/data

/src

/tmp

named volume
managed by Docker
survives rm

host directory
(bind mount)

memory
(tmpfs)

The ladder

Rung 1 Three kinds of mount, three lifetimes

docker run --name d08-c -v d08-named:/named -v "$PWD/bind":/bind --tmpfs /scratch alpine:3.20 \
  sh -c 'for d in /container /named /bind /scratch; do mkdir -p $d; echo hi > $d/f; done'
docker rm d08-c
docker run --rm -v d08-named:/named -v "$PWD/bind":/bind --tmpfs /scratch alpine:3.20 sh -c 'cat /named/f /bind/f'
/container  gone
/named      hi
/bind       hi
/scratch    gone
  • Named volume (-v name:/path): storage Docker manages (under /var/lib/docker/volumes on Linux, inside the VM on Desktop). It survives docker rm, needs no host path, and keeps real Linux ownership. Use it for databases and any app data.
  • Bind mount (-v /host/path:/path): a host directory, shared live. Use it for source code during development and for config files you edit on the host. On Docker Desktop it crosses the VM boundary, which is slower for heavy I/O and remaps ownership (D5, rung 6).
  • tmpfs (--tmpfs /path): memory, gone when the container stops. Use it for scratch space and for secrets the app writes at runtime, especially with --read-only (D5).

Rule Put persistent data in named volumes, development source in bind mounts and scratch in tmpfs, and nothing important in the container's writable layer.

Why Each kind has a different lifetime and owner: Docker, the host, or memory. Only named volumes are both persistent and independent of a host path.

Trap After docker compose down and up, or a redeploy, the data is gone. It was written to a path with no mount, inside the container's writable layer. docker inspect -f '{{json .Mounts}}' <name> shows which paths are mounted.

Rung 2 Copy-up happens once, and only for empty volumes

When an empty named volume is mounted over a directory that has content in the image, Docker copies that content (and its ownership) into the volume. It happens once. After that, the volume's content wins, whatever newer images contain:

new volume over /srv/assets, v1 image:   VERSION app.css   → v1
same volume, v2 image:                   VERSION app.css   → v1     (v2's files and new.js never appear)
empty bind mount over /srv/assets:       0 entries                  (bind mounts hide, never copy)
--mount …,volume-nocopy:                 0 entries
docker run --rm -v d08-assets:/srv/assets pantry-manual:d08-v1 cat /srv/assets/VERSION    # v1, copied up
docker run --rm -v d08-assets:/srv/assets pantry-manual:d08-v2 cat /srv/assets/VERSION    # still v1

That is the right behaviour for data: a database's files must not be replaced by an image's defaults. It is a disaster for anything that belongs to the image, such as code, static assets or default config. Mount volumes only on paths that hold data the image does not ship. Copy-up is also why the golden Dockerfile creates /data owned by 10001: a new volume there starts with the right owner (D5).

Rule Mount volumes only over data directories, never over paths whose content ships with the image (code, assets, bundled config).

Why Docker copies image content into a volume only while it is empty. Later images cannot update a non-empty volume.

Trap The new release is deployed, the container runs the new image, and users still see the old CSS or old behaviour. docker inspect shows a volume mounted over the app directory, which serves the first version forever.

Rung 3 Prefer --mount, and read-only where you can

-v is short and forgiving. Given a host path that does not exist, it creates an empty directory and mounts that. A typo silently becomes "no config":

-v ./confg:/etc/app          → container sees 0 files; an empty "confg" directory appears on the host
--mount type=bind,src=./confg,dst=/etc/app
                             → invalid mount config for type "bind": bind source path does not exist

--mount takes explicit key=value pairs (type, src, dst, readonly, volume-nocopy, tmpfs-size) and fails on a missing source. Mount anything the container only reads as read-only. A compromised process then cannot tamper with config or with shared data:

docker run --mount type=bind,src="$PWD/config",dst=/etc/pantry,readonly pantry-manual:d05
docker run -v pantry-manual-data:/data:ro alpine:3.20 ls /data                    # short form, read-only
sh: can't create /data/f: Read-only file system

Rule Write mounts with --mount in scripts and docs, and add readonly (:ro) to every mount the container does not need to write.

Why --mount turns a missing source into an error instead of an empty directory, and read-only mounts make writes fail loudly.

Trap A container starts with default settings even though you mounted a config directory. ls on the host shows a new, empty directory next to the real one (confg/ beside config/), created by -v.

Rung 4 Back up and restore through a throwaway container

You never need to know where Docker keeps a volume on disk. To back it up, mount it read-only into a small container next to a host directory, and write a tarball:

examples/manual/d08/backup.sh
#!/bin/sh
# Back up a named volume to a compressed tarball, using a throwaway container.
#   ./backup.sh pantry-manual-data ./backups
# For a consistent copy, the writer must flush or stop first (for Redis: redis-cli SAVE).
set -eu
VOLUME=${1:?usage: backup.sh VOLUME [DIR]}
DIR=${2:-./backups}
mkdir -p "$DIR"
FILE="$VOLUME-$(date -u +%Y%m%dT%H%M%SZ).tar.gz"

# Fail early instead of creating an empty volume with a typo'd name.
docker volume inspect "$VOLUME" >/dev/null

# The volume is mounted read-only; numeric owners are kept so a restore gets the same UIDs back.
docker run --rm --network none \
  -v "$VOLUME":/data:ro \
  -v "$(cd "$DIR" && pwd)":/backup \
  alpine:3.20 tar -czf "/backup/$FILE" --numeric-owner -C /data .

echo "$DIR/$FILE"

The restore mirrors it, and refuses to overwrite a non-empty volume unless told to:

examples/manual/d08/restore.sh
#!/bin/sh
# Restore a tarball made by backup.sh into a named volume.
#   ./restore.sh pantry-manual-data ./backups/pantry-manual-data-20261023T231500Z.tar.gz
# Refuses to overwrite a volume that already has data unless FORCE=1.
set -eu
VOLUME=${1:?usage: restore.sh VOLUME FILE}
FILE=${2:?usage: restore.sh VOLUME FILE}
[ -f "$FILE" ] || { echo "no such file: $FILE" >&2; exit 1; }

docker volume create "$VOLUME" >/dev/null
if [ "${FORCE:-0}" != 1 ] && [ -n "$(docker run --rm -v "$VOLUME":/data alpine:3.20 ls -A /data)" ]; then
  echo "volume $VOLUME is not empty; stop its users and rerun with FORCE=1 to replace its contents" >&2
  exit 1
fi

docker run --rm --network none \
  -v "$VOLUME":/data \
  -v "$(cd "$(dirname "$FILE")" && pwd)":/backup:ro \
  alpine:3.20 sh -c "find /data -mindepth 1 -delete && tar -xzf /backup/$(basename "$FILE") --numeric-owner -C /data"

echo "restored $FILE into $VOLUME"
$ docker exec d08-redis redis-cli SAVE
OK
$ ./backup.sh pantry-manual-data ./backups
./backups/pantry-manual-data-20261023T233219Z.tar.gz
$ docker volume rm pantry-manual-data && ./restore.sh pantry-manual-data ./backups/pantry-manual-data-…tar.gz
restored … into pantry-manual-data
visits after restore: 5

--numeric-owner keeps the files' UIDs (Redis's 999 here), so the restored data is readable by the same user. Consistency is the application's business: tar copies files, not transactions. Flush first (redis-cli SAVE, pg_dump instead of copying PostgreSQL's files), or stop the writer for the duration.

Rule Back up volumes with a throwaway container that mounts the volume read-only, after making the application flush or pause, and test restores on a schedule.

Why The container sees the volume as plain files wherever Docker stores them. A backup that has never been restored is a guess.

Trap The backup file is never on the host. The tar wrote into the throwaway container's own filesystem, which --rm deleted, because no host directory was mounted as the destination. Check that the file exists after every run.

Rung 5 Anonymous volumes and how they leak

A volume without a name is anonymous. You get one from -v /path with no name, and from any image that declares VOLUME when you do not mount that path yourself. The official Redis image declares VOLUME /data:

$ docker image inspect -f '{{json .Config.Volumes}}' redis:7-alpine
{"/data":{}}
$ docker run -d --name d08-anon redis:7-alpine
volume ed73e237d52ca4f2… -> /data
$ docker rm -f d08-anon
ed73e237d52ca4f2… still exists

The data is not lost, but nothing will ever mount that volume again. The next docker run gets a new, empty one. The machine these examples ran on had 121 such orphans. Clean up deliberately:

docker rm -v <container>                   # remove a container and its anonymous volumes
docker run --rm …                          # --rm also removes the anonymous volumes
docker volume ls -f dangling=true          # volumes no container references
docker volume prune                        # removes unused *anonymous* volumes; -a includes named ones

Rule Name every volume you care about, run throwaway containers with --rm, and remove anonymous volumes with rm -v or volume prune.

Why Anonymous volumes outlive their containers but have no name to find them by, so data in them is effectively lost and disk usage only grows.

Trap After recreating a database container, the app starts with an empty database, and the old data is in a hash-named volume. docker volume ls -f dangling=true and docker run --rm -v <hash>:/d alpine ls /d find it. Copy it into a named volume.

Rung 6 tmpfs options, drivers and disk accounting

A tmpfs defaults to half of the machine's memory (3.9 GB in this VM), and it counts against the container's memory limit. Size it and set its mode:

docker run --tmpfs /tmp:size=64m,mode=1777 alpine:3.20 df -h /tmp
docker run --mount type=tmpfs,dst=/tmp,tmpfs-size=67108864 alpine:3.20 df -h /tmp
docker system df -v                     # space per image, container, volume and build cache
tmpfs                    64.0M         0     64.0M   0% /tmp
dd … stopped at 67108864 bytes (64.0MB)

Volume drivers put a named volume somewhere other than local disk. The built-in local driver can mount NFS or CIFS (syntax: docker volume create --driver local --opt type=nfs --opt o=addr=<server>,rw --opt device=:/export pantry-nfs; not run here, it needs an NFS server), and plugins target cloud block storage. Network storage behaves very differently under databases: latency, locking, fsync. Single-host Docker rarely needs it. Kubernetes' PersistentVolumes and StorageClasses are the standard answer at that scale (K7).

Rule Give every tmpfs a size, and check docker system df -v before blaming anything else for a full disk.

Why An unsized tmpfs can take half the host's memory, and volumes and build cache are the usual hidden consumers of disk.

Trap Processes in a memory-limited container die with Killed under load though their own memory looks small. They filled an unsized tmpfs, which counts against the limit: with --memory 64m, writing 200 MB to /tmp got dd killed and docker inspect reported OOMKilled=true. Size the tmpfs below the limit.

Decide

Data Mount Options
Database files, uploads named volume owner set by the image's directory (copy-up)
Source code in development bind mount :ro if the container must not write; --user $(id -u) on Linux
Config edited on the host bind mount, single file or directory readonly
Scratch, caches, PID files tmpfs size=…
Secrets at runtime tmpfs (or a secret mount in Compose, D10) mode=0700
Code, assets, bundled defaults none: they belong in the image
Shared between hosts Kubernetes PV, or NFS via the local driver test fsync and locking

The golden file

Backup and restore for any named volume. Run against Pantry's Redis volume, it round-trips the visit count and keeps owners intact.

examples/manual/d08/backup.sh
#!/bin/sh
# Back up a named volume to a compressed tarball, using a throwaway container.
#   ./backup.sh pantry-manual-data ./backups
# For a consistent copy, the writer must flush or stop first (for Redis: redis-cli SAVE).
set -eu
VOLUME=${1:?usage: backup.sh VOLUME [DIR]}
DIR=${2:-./backups}
mkdir -p "$DIR"
FILE="$VOLUME-$(date -u +%Y%m%dT%H%M%SZ).tar.gz"

# Fail early instead of creating an empty volume with a typo'd name.
docker volume inspect "$VOLUME" >/dev/null

# The volume is mounted read-only; numeric owners are kept so a restore gets the same UIDs back.
docker run --rm --network none \
  -v "$VOLUME":/data:ro \
  -v "$(cd "$DIR" && pwd)":/backup \
  alpine:3.20 tar -czf "/backup/$FILE" --numeric-owner -C /data .

echo "$DIR/$FILE"
examples/manual/d08/restore.sh
#!/bin/sh
# Restore a tarball made by backup.sh into a named volume.
#   ./restore.sh pantry-manual-data ./backups/pantry-manual-data-20261023T231500Z.tar.gz
# Refuses to overwrite a volume that already has data unless FORCE=1.
set -eu
VOLUME=${1:?usage: restore.sh VOLUME FILE}
FILE=${2:?usage: restore.sh VOLUME FILE}
[ -f "$FILE" ] || { echo "no such file: $FILE" >&2; exit 1; }

docker volume create "$VOLUME" >/dev/null
if [ "${FORCE:-0}" != 1 ] && [ -n "$(docker run --rm -v "$VOLUME":/data alpine:3.20 ls -A /data)" ]; then
  echo "volume $VOLUME is not empty; stop its users and rerun with FORCE=1 to replace its contents" >&2
  exit 1
fi

docker run --rm --network none \
  -v "$VOLUME":/data \
  -v "$(cd "$(dirname "$FILE")" && pwd)":/backup:ro \
  alpine:3.20 sh -c "find /data -mindepth 1 -delete && tar -xzf /backup/$(basename "$FILE") --numeric-owner -C /data"

echo "restored $FILE into $VOLUME"
docker exec pantry-redis redis-cli SAVE
./backup.sh pantry-manual-data ./backups
FORCE=1 ./restore.sh pantry-manual-data ./backups/pantry-manual-data-<timestamp>.tar.gz

Review checklist

Spot the bug

"Pantry with a nightly backup."

examples/manual/d08/bug/run.sh
#!/bin/sh
set -eu
docker network create pantry-net
docker run -d --name pantry-redis --network pantry-net --network-alias redis redis:7-alpine redis-server --appendonly yes
docker run -d --name pantry-web --network pantry-net -p 127.0.0.1:8000:8080 \
  -v pantry-code:/app pantry-manual:d05
# nightly backup
docker run --rm --volumes-from pantry-redis alpine:3.20 tar -czf /backup.tar.gz /data

This file has 3 problems.

Show answers
  1. Redis has no -v, so its data goes into an anonymous volume created by the image's VOLUME /data. After docker rm -f pantry-redis and the same docker run, Pantry said "You are visitor #1", and the old count was stranded in a hash-named volume. Add -v pantry-data:/data.
  2. -v pantry-code:/app puts a named volume over the code directory. Copy-up filled it once, and after upgrading to an image whose app.py was changed, the container still served the old text. Remove the mount: code belongs in the image.
  3. The backup writes /backup.tar.gz into the throwaway container's own filesystem, and --rm deletes it. No backup file existed anywhere afterwards. Mount a host directory as the destination, flush Redis first (redis-cli SAVE), and read the volume read-only, as backup.sh does.

Docker manual · 9 · 9 min readCompose: The File#

In the story: a dinner menu that cooks several dishes together · The Whole Band

A Compose file describes a project: services (containers built or pulled from one definition), the networks they share, and the volumes they keep. docker compose up turns that description into containers, and running it again changes only what differs. Everything earlier chapters did with docker run flags (ports, mounts, users, capabilities, health checks) has a key here. What Compose adds is relationships: who starts after whom, what counts as ready, and which values come from outside the file.

webseed (one-shot)redisdocker compose up --waitwebseed (one-shot)redisdocker compose up --waitloop[healthcheck every 2s]--wait returns 0create + startredis-cli pinghealthystart (depends_on: service_healthy)exited 0start (depends_on: service_completed_successfully)healthy

The golden file for this chapter is Pantry: a built web service, Redis with a health check and a named volume, and a one-shot seed job that must succeed before the web service starts.

The ladder

Rung 1 Services: build, image, and the project

A service either builds an image from a Dockerfile or runs an existing image. With both keys, Compose builds and tags the result with the given name, instead of the generated <project>-<service>:

services:
  web:
    build:
      context: ../../pantry                 # the app's own directory
      dockerfile: ../manual/d05/Dockerfile  # the golden Dockerfile (path relative to the context)
      additional_contexts:
        manual: ../d05                      # what --build-context manual=… did on the command line
    image: pantry-manual:d09
  redis:
    image: redis:7-alpine

The project is the unit Compose manages. Its name comes from the top-level name: key (here pantry-manual-d09), from -p, or from the directory's name. Containers (pantry-manual-d09-web-1), the default network (pantry-manual-d09_default) and volumes (pantry-manual-d09_redis-data) are all prefixed with it, so two copies of the same file in different directories do not collide. Set name: explicitly so the project does not change identity when someone renames a folder.

Rule Give every Compose file a top-level name:, and give built services both build: and an image: name.

Why The project name prefixes every container, network and volume. An explicit name keeps them stable, and a named image can be pushed or reused by other services.

Trap After a colleague clones the repository into a differently named directory, docker compose up starts with an empty database. The project name came from the directory, so the volume name changed (foo_redis-data became bar_redis-data). docker volume ls shows both.

Rung 2 Ports, and the reachability you get for free

Every project gets its own network, and services reach each other by service name on any port. Publish only what the host needs, on loopback, with the port configurable:

    ports:
      - "127.0.0.1:${WEB_PORT:-8000}:8080"  # loopback only; the host port is configurable
redis     running   Up 13 seconds (healthy)   6379/tcp
web       running   Up 6 seconds (healthy)    127.0.0.1:8000->8080/tcp

Redis shows 6379/tcp with no host side: the port exists on the project network and nowhere else. expose: exists, but like the Dockerfile's EXPOSE it only documents (D7, rung 3). Quote port mappings: unquoted 60:60 style values were once parsed by YAML 1.1 as base-60 numbers, and quoting avoids the whole class of problem.

Rule Publish only entry points, always as quoted strings with 127.0.0.1: for local work, and let services reach each other by service name.

Why Compose's project network already connects every service. ports: is only for traffic from outside Docker, and a bare host port binds every interface.

Trap docker compose ps shows 0.0.0.0:6379->6379/tcp for a database. Anyone who can reach the machine can reach the database (the spot-the-bug file answered PONG from the LAN).

Rung 3 environment, env_file and .env are three different things

These three are confused more than anything else in Compose:

  • environment: sets variables in the container.
  • env_file: loads variables from a file into the container.
  • .env (next to the Compose file) feeds interpolation of the Compose file itself, the ${VAR} expressions, and nothing else.
    environment:
      REDIS_HOST: redis                     # the service name is a DNS name on the project network
      GREETING: ${GREETING:-Hello from Pantry}

Measured:

.env: GREETING=Hello from .env              → "Hello from .env (dev)…"
.env: UNUSED_SECRET=…  (not referenced)     → absent in the container
shell GREETING=… and .env                   → "Hello from the shell (dev)…"   (shell wins)
neither                                     → "Hello from Pantry" (the :- default)
env_file BOTH=from-env_file, environment BOTH=from-environment → BOTH=from-environment

Interpolation order, highest first: the shell's environment, then --env-file/.env, then the default written in the file. Inside the container, environment: wins over env_file:. The operators:

Expression Unset or empty Use
${VAR} empty string (with a warning) rarely
${VAR:-default} default optional settings
${VAR:?message} error, Compose refuses to run required settings and secrets
$$ a literal $ shell commands in command:
error while interpolating services.web.environment.DB_PASSWORD: required variable DB_PASSWORD is missing a value: set DB_PASSWORD in .env or the shell

Rule Put container settings in environment: with ${VAR:-default} or ${VAR:?message}, keep .env for interpolation values (and out of git when it holds secrets), and remember that .env does not reach the container by itself.

Why Compose resolves ${…} before creating anything, from the shell first and .env second, while the container only ever sees environment: and env_file:.

Trap A variable in .env is "ignored" by the app. It is never passed to the container unless environment: references it (FOO: ${FOO}) or an env_file: lists the file. docker compose config shows the final environment.

Rung 4 Named volumes and networks belong to the project

Top-level volumes: and networks: declare resources that services mount or join by name, created with the project prefix:

  redis:
    volumes:
      - redis-data:/data                    # named: survives "down", unlike the image's anonymous VOLUME
volumes:
  redis-data:
pantry-manual-d09_redis-data
$ docker compose down        → containers and network removed, volume kept
$ docker compose down -v     → named volumes declared in the file removed too

Leave out the named volume and Redis's image-declared VOLUME /data still creates an anonymous one. It survives docker compose up re-creations, but down followed by up gets a new, empty one: the spot-the-bug project went from a counter of 1 to 0. To use a volume or network that exists outside the project (shared, or created by an operator), mark it external: true with its real name:, and Compose will neither create nor delete it.

volumes:
  shared-uploads:
    external: true
    name: uploads-prod

Rule Declare every persistent path as a named volume under top-level volumes:, and treat down -v as "delete the data".

Why Named project volumes are created once and reused across up/down cycles. Anonymous volumes are tied to one container's lifetime in practice.

Trap docker compose down && docker compose up -d and the database is empty. docker inspect -f '{{json .Mounts}}' <container> shows a 64-character anonymous volume name instead of <project>_<name>.

Rung 5 Health checks and depends_on conditions

depends_on: [redis] only orders container starts. It does not wait for Redis to be ready. With a database that takes a few seconds to start, the one-shot seed job ran too early:

seed-1  | Could not connect to Redis at redis:6379: Connection refused

Give the dependency a healthcheck, and depend on its health:

  redis:
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 2s
      timeout: 2s
      retries: 15
      start_period: 5s
  seed:
    depends_on:
      redis:
        condition: service_healthy
    restart: "no"
  web:
    depends_on:
      redis:
        condition: service_healthy          # not just "started": answering PING
      seed:
        condition: service_completed_successfully

The three conditions are service_started (the default), service_healthy (needs a healthcheck) and service_completed_successfully (for jobs: migrations, seeding, cache warm-up). docker compose up -d --wait blocks until every service is running and healthy (or a job has exited 0), and returns non-zero if not. Use it in scripts and CI instead of sleep.

 Container pantry-manual-d09-redis-1  Healthy
 Container pantry-manual-d09-seed-1  Exited
 Container pantry-manual-d09-web-1  Healthy

Startup ordering is not a substitute for retries. Dependencies restart independently later, so the app must still survive a database that goes away for a while.

Rule Give every dependency a healthcheck, depend on service_healthy (or service_completed_successfully for jobs), and start with up --wait.

Why "Started" means the process exists, not that it accepts connections. The healthcheck is the only readiness signal Compose has.

Trap The stack works on a fast laptop and fails on CI with "connection refused" in the first seconds. depends_on was a plain list, so the app raced the database's startup.

Rung 6 Restart policies

restart: tells Docker what to do when the container's main process exits. Measured with a process that exits after one second:

restart: exit 3 (crash) exit 0 (finished) after docker stop/kill
"no" (default) stays exited (0 restarts) stays exited stays stopped
on-failure:2 restarted twice, then gives up stays exited stays stopped
unless-stopped restarted forever (7 in 20 s) restarted forever stays stopped, also after a daemon restart
always restarted forever restarted forever stays stopped until the daemon restarts

Services use unless-stopped. One-shot jobs must use "no" (or on-failure): the spot-the-bug file gave its seed job restart: always, and it ran 8 times in 15 seconds and never stopped. Three details:

  • Docker does not restart a container for being unhealthy. Only Swarm and Kubernetes act on health.
  • /crash killed one gunicorn worker, the master respawned it, and the container never restarted. Supervision inside the container hides crashes from Docker.
  • A manual docker kill or docker stop is never undone by unless-stopped.
    restart: unless-stopped

Rule Use restart: unless-stopped for long-running services and restart: "no" for jobs.

Why Restart policies react only to the main process exiting, and always/unless-stopped treat a successful exit like a crash.

Trap docker compose ps shows a job in Restarting (0) forever. Exit code 0 plus restart: always loops, re-running the job (and its side effects) every few seconds.

Rung 7 Overriding the command, running one-offs, and exec

command: replaces the image's CMD, and entrypoint: replaces its ENTRYPOINT (and, as with --entrypoint, clears CMD, D2). Write both in exec form (lists). In list form, $ must be doubled ($$HOME) when you mean the container's variable rather than Compose interpolation.

  seed:
    image: redis:7-alpine
    command: ["redis-cli", "-h", "redis", "SET", "visits", "${SEED_VISITS:-0}", "NX"]

Two commands cover day-to-day use:

docker compose run --rm --no-deps web python -c "print('one-off in a fresh web container')"   # new container, same config
docker compose exec web id                                                                     # inside the running one

run starts a new container from the service's definition (same image, environment, volumes and networks). --rm removes it afterwards, --no-deps skips starting dependencies, and --service-ports is needed if you want its ports: published. exec enters the existing container.

Rule Override with command:/entrypoint: in list form, run one-off tasks with compose run --rm, and inspect running services with compose exec.

Why run reuses the service's full configuration without touching the running container, and list form avoids shell parsing and signal problems.

Trap docker compose run web leaves a growing list of pantry-manual-d09-web-run-… containers in docker ps -a. Add --rm.

Rung 8 docker compose config is the truth

Compose merges files, applies defaults, resolves ${…}, converts short syntax to long, and warns about obsolete keys before it does anything. docker compose config prints the result. Every "why is it using that value" question starts there:

docker compose config                       # the fully resolved project
docker compose config --quiet               # validate only: exit code and errors
docker compose config --services            # list service names
WARN … the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion

The top-level version: key is from the Compose v1 file formats and Compose v2 ignores it, so delete it. Run docker compose config --quiet in CI. It catches YAML errors, unknown keys, and required variables that are unset.

Rule Validate every Compose file with docker compose config --quiet in CI, and read docker compose config when a value surprises you.

Why It shows exactly what Compose will act on after interpolation and merging, which is often not what the file appears to say.

Trap A setting "has no effect". docker compose config shows it under the wrong service or misspelled, or replaced by an override file (D10).

Decide

You want Compose
Wait for a database healthcheck + depends_on: { db: { condition: service_healthy } }
Migrations before the app job service, restart: "no", condition: service_completed_successfully
A required secret value ${VAR:?message} (better: secrets:, D10)
An optional setting ${VAR:-default}
Settings for the container environment: (or env_file:)
Values for the file itself .env / shell variables
Data that survives down top-level named volume
A resource managed elsewhere external: true + name:
A one-off task docker compose run --rm <service> <cmd>
Scripted startup docker compose up -d --wait

The golden file

Pantry as one Compose file: the golden Dockerfile built through additional_contexts, loopback-only publishing with a configurable port, interpolated settings with defaults, health-gated startup with a seed job, the D5 runtime hardening as Compose keys, and a named volume.

examples/manual/d09/compose.yaml
# Pantry as one Compose file: build, ports, environment, a volume, health-gated startup, a one-shot job.
#   docker compose up -d --wait        # returns when everything is healthy (or exits non-zero)
name: pantry-manual-d09

services:
  web:
    build:
      context: ../../pantry                 # the app's own directory
      dockerfile: ../manual/d05/Dockerfile  # the golden Dockerfile (path relative to the context)
      additional_contexts:
        manual: ../d05                      # what --build-context manual=… did on the command line
      args:
        APP_VERSION: ${APP_VERSION:-dev}
    image: pantry-manual:d09                # name the built image instead of pantry-manual-d09-web
    ports:
      - "127.0.0.1:${WEB_PORT:-8000}:8080"  # loopback only; the host port is configurable
    environment:
      REDIS_HOST: redis                     # the service name is a DNS name on the project network
      GREETING: ${GREETING:-Hello from Pantry}
    depends_on:
      redis:
        condition: service_healthy          # not just "started": answering PING
      seed:
        condition: service_completed_successfully
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp:size=16m
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    stop_grace_period: 10s

  redis:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redis-data:/data                    # named: survives "down", unlike the image's anonymous VOLUME
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 2s
      timeout: 2s
      retries: 15
      start_period: 5s
    restart: unless-stopped

  # A one-shot job: runs once Redis is healthy, must exit 0 before web starts.
  seed:
    image: redis:7-alpine
    command: ["redis-cli", "-h", "redis", "SET", "visits", "${SEED_VISITS:-0}", "NX"]
    depends_on:
      redis:
        condition: service_healthy
    restart: "no"

volumes:
  redis-data:
docker compose config --quiet && docker compose up -d --wait     # redis Healthy → seed Exited → web Healthy
curl -s localhost:8000/                                          # Hello from Pantry (dev). You are visitor #1. …
docker compose down                                              # keeps pantry-manual-d09_redis-data

Review checklist

Spot the bug

"Pantry with a seed job." It comes up, and the web page loads.

examples/manual/d09/bug/compose.yaml
version: "3.8"
services:
  web:
    image: pantry-manual:d09
    ports:
      - "8000:8080"
    environment:
      - REDIS_HOST=localhost
    depends_on:
      - redis
      - seed
  redis:
    image: redis:7-alpine
    # sleep stands in for a database that takes a few seconds to start
    command: sh -c "sleep 5 && exec redis-server --appendonly yes"
    ports:
      - "6379:6379"
  seed:
    image: redis:7-alpine
    command: redis-cli -h redis SET visits 0 NX
    depends_on:
      - redis
    restart: always

This file has 6 problems.

Show answers
  1. version: "3.8" is obsolete. Compose warns "the attribute version is obsolete, it will be ignored". Delete it, and add name:.
  2. REDIS_HOST=localhost makes web talk to itself: "Redis at 'localhost' is unreachable". Use redis.
  3. depends_on lists only order starts. The seed job ran before Redis accepted connections ("Could not connect to Redis at redis:6379: Connection refused"). Add a Redis healthcheck and use condition: service_healthy (and service_completed_successfully for web on seed).
  4. restart: always on a one-shot job restarts it after every successful exit: 8 restarts in 15 seconds, state Restarting (0). Use restart: "no".
  5. ports: "6379:6379" publishes Redis on every interface (PONG from the LAN address), and "8000:8080" does the same for web. Remove Redis's ports: and bind web to 127.0.0.1.
  6. Redis has no named volume, so its data lives in the anonymous volume from the image's VOLUME /data. After down and up, the visit count was back to 0 in a new anonymous volume. Add redis-data:/data with a top-level volumes: entry.

Docker manual · 10 · 10 min readCompose: The Project#

In the story: the same dinner menu, cooked for a weeknight and for a wedding · The Whole Band

D9 wrote one file for one environment. Real projects have several environments (a laptop with live reload, CI, a staging host, production), optional tools, secrets that must stay out of git, and parts owned by different teams. Compose handles all of it with a few mechanisms:

  • several files merged in order;
  • profiles;
  • include;
  • extension fields;
  • secrets;
  • develop.watch.

Each mechanism has one rule that surprises people. This chapter measures all of them.

loaded automatically

-f, explicit

compose.yaml
(shared)

redis/compose.yaml
(include)

compose.override.yaml
(dev: watch, debug)

compose.prod.yaml
(image, limits, logs)

dev project
docker compose up --watch

prod project
docker compose -f compose.yaml -f compose.prod.yaml up -d

The ladder

Rung 1 One project per environment, by name

A project is identified by its name, and Compose owns every container, network and volume carrying that name, wherever the file that created them lives. D9 said to set name:. The other half of that rule is that two running copies (prod and staging on one host, two feature branches on one laptop) need different names. Otherwise the second up adopts the first copy's resources:

staging checkout (same "name: pantry", port 8100):  docker compose up -d
 Container pantry-redis-1  Running
 Container pantry-web  Recreate
prod :8000 → (gone)      staging :8100 → visitor #4      ← prod's Redis and prod's data
docker compose -p pantry-staging up -d             # or COMPOSE_PROJECT_NAME=pantry-staging in that host's environment
docker compose ls                                  # every project on this Docker host, with its files

The file's name: is the default, and -p or COMPOSE_PROJECT_NAME overrides it per deployment.

Rule Set name: in the file for the normal case, and give every additional copy on the same Docker host its own -p/COMPOSE_PROJECT_NAME.

Why Compose matches existing resources by project-name labels, not by directory or file, so two copies with one name are one project.

Trap Deploying staging takes production down. docker compose ls shows one project whose CONFIG FILES column points at the staging checkout.

Rung 2 Override files and the merge rules

docker compose up with no -f reads compose.yaml and then, if present, compose.override.yaml, merging the second over the first. You can pass any list with -f a.yaml -f b.yaml. Later files win, but "win" means different things for different keys. Measured with docker compose config:

Key kind Example Merge
Scalars image, command, entrypoint, user, read_only replaced
Mappings environment, labels, deploy merged per key, later wins
Lists of strings ports, cap_drop, dns, expose appended
Mounts volumes, secrets merged by target path, later replaces the whole entry
Explicit ports: !override [...] replaced
Explicit build: !reset null, depends_on: !reset [] removed
ports:   ['127.0.0.1:8000->80', '127.0.0.1:9000->80']      ← both published
volumes: data:/data kept;  /etc/app → conf-dev (and the base's :ro is gone)
with !override: ['127.0.0.1:9000->80'];  with !reset: build and depends_on absent

Appending is the trap. An override that "changes the port" adds a second one, and the spot-the-bug file ended up publishing 0.0.0.0:8080 next to 127.0.0.1:8000. And a volume override on the same target replaces the entry, flags included.

services:
  web:
    ports: !override
      - "127.0.0.1:9000:8080"

Rule Check every override with docker compose config, and use !override to replace lists and !reset to remove keys.

Why Compose appends lists, merges mappings and replaces scalars. Only the !override/!reset tags state the intent explicitly.

Trap After adding a dev override "to change the port", docker compose ps shows two port mappings, one of them on 0.0.0.0. The list was appended.

Rung 3 One base file, one file per environment

Put what every environment shares in compose.yaml. Put development extras in compose.override.yaml, so plain docker compose up on a laptop is the dev setup. Put production in compose.prod.yaml, used only with an explicit -f list, because an explicit list replaces the automatic override:

docker compose up -d --wait                                          # laptop: compose.yaml + compose.override.yaml
REGISTRY=localhost:5001 TAG=1.1.0 \
  docker compose -f compose.yaml -f compose.prod.yaml up -d --wait   # server: no override, a released image
without REGISTRY:  error while interpolating services.web.image: required variable REGISTRY is missing a value: set REGISTRY
with TAG=1.1.0:    web Pulled … Healthy … Hello from Pantry (1.1.0). You are visitor #6.
HostConfig: memory=268435456 nanocpus=1000000000 log=json-file {"max-file":"3","max-size":"10m"}

The production file does what only production needs. build: !reset null means the server never builds: it pulls what CI built and scanned (D6). deploy.resources.limits are enforced by plain docker compose, not just Swarm (256 MiB and one CPU above). logging rotates json-file logs, which otherwise grow without limit. Two more facts were measured. --scale web=2 fails while a fixed host port is published (port is already allocated), so scaling needs a proxy in front. And down interpolates the files too: run it without REGISTRY and TAG and nothing is stopped.

Rule Keep compose.yaml shared, compose.override.yaml for development only, and production in a separate file always named with -f, with images by release tag (or digest), resource limits and log rotation.

Why The override file is loaded automatically wherever docker compose up runs without -f, including on a server where someone typed it from habit.

Trap Production shows the debug banner. The deploy script ran docker compose up -d in a checkout that contains compose.override.yaml, so the dev file was merged in. That is what the spot-the-bug project did: "DEBUG BUILD - do not use".

Rung 4 Profiles: services that start only when asked

A service with profiles: [tools] is ignored by up unless the profile is active. Use it for admin tools, debug containers, load generators and one-off jobs you do not want running all the time:

  redis-cli:
    image: redis:7-alpine
    profiles: [tools]
$ docker compose ps --services           → redis, web            (redis-cli not started)
$ docker compose --profile tools run --rm redis-cli GET visits    → 1
$ docker compose run --rm redis-cli GET visits                    → 6   (run names it, so its profile is enabled)
$ docker compose config --profiles       → tools

COMPOSE_PROFILES=tools,debug activates profiles from the environment. A service without profiles: always starts, so do not make a core service depend on a profiled one.

Rule Put optional services in profiles and run them with docker compose run --rm <service>.

Why Profiles keep the default up to the services the application needs, while the tools stay defined, versioned and on the same networks.

Trap docker compose up fails with service "app" depends on undefined service "debug": invalid compose project. A core service depends_on a profiled one, which does not exist while its profile is off.

Rung 5 include and extends

include: pulls another Compose project fragment (services, volumes, networks, secrets) into this one. Relative paths in the included file resolve against its directory, so a team can own redis/compose.yaml independently:

include:
  - redis/compose.yaml            # services, volumes and secrets from another file, with its own relative paths

extends: copies one service's definition into another, then merges local keys over it, which is useful for a family of similar services (workers on different queues):

services:
  worker:
    extends: {file: common.yaml, service: worker-base}
    command: ["echo", "worker"]
    environment: {QUEUE: emails}
→ cap_drop: [ALL], LOG_LEVEL: info, QUEUE: emails, image: alpine:3.20

The difference: include adds whole resources and refuses to redefine them (services.redis conflicts with imported resource), and extends builds one service from another. Neither is a substitute for override files, which change the same services per environment.

Rule Use include to compose a project from fragments owned elsewhere, extends for services that share most of their definition, and override files for per-environment changes.

Why Each mechanism merges at a different level (project, service, environment), with its own path resolution and conflict rules.

Trap A volume path in an included file "does not exist". It was written relative to the main project, but include resolves it relative to the included file's own directory.

Rung 6 Extension fields and YAML anchors

Top-level keys starting with x- are ignored by Compose, which makes them a place to define reusable blocks. YAML anchors (&name) and merge keys (<<: *name) do the reuse:

x-hardening: &hardening
  read_only: true
  tmpfs: ["/tmp:size=16m"]
  cap_drop: [ALL]
  security_opt: ["no-new-privileges:true"]

services:
  web:
    <<: *hardening
    environment:
      <<: *redis-client
      GREETING: ${GREETING:-Hello from Pantry}

Anchors are plain YAML, resolved when the file is parsed. They work inside one file only (not across -f files or include), and <<: merges mappings, not lists. A list you write next to it replaces the anchored list.

Rule Define repeated blocks (hardening, logging, client settings) once under x- keys and merge them with <<: *anchor.

Why One definition means one place to change, and Compose ignores x- keys, so they cost nothing.

Trap An anchor "does nothing" in the override file. Anchors do not cross files. Repeat the block, or move the setting into the base file.

Rung 7 Secrets (and configs) as files

Compose secrets: mounts a value as a file at /run/secrets/<name>, outside docker inspect's environment listing and outside docker compose config output. The app reads the file. Pantry reads REDIS_PASSWORD_FILE:

secrets:
  redis_password:
    file: ./secrets/redis_password.txt   # git-ignored; bind-mounted read-only
mkdir -p secrets && openssl rand -hex 16 > secrets/redis_password.txt && chmod 600 secrets/redis_password.txt

Compose can also take a secret's value from an environment variable (environment: REDIS_PASSWORD), but it does so by copying the value into the container's filesystem before start. On a hardened service that fails:

secrets: … environment: REDIS_PASSWORD  +  read_only: true
Error response from daemon: container rootfs is marked read-only          (also with a tmpfs at /run/secrets)
secrets: … file: ./secrets/redis_password.txt  → works

Getting the value into a program that wants it as a flag needs one more step, because flags are visible in ps, docker top and docker inspect. The Redis fragment writes the password into a config file on a tmpfs and starts redis-server /tmp/redis.conf, and clients use REDISCLI_AUTH instead of -a. Afterwards the password appeared 0 times in docker top output and 0 times in docker compose config. configs: work the same way for non-secret files (mounted at a path you choose). On Linux the bind-mounted file keeps its host owner and mode, so a 600 file owned by you is unreadable by UID 10001. Docker Desktop hides this. Make it readable by the container's GID.

Rule Pass secrets as secrets: files read by the app (*_FILE variables), keep the files out of git, and never put a secret's value in environment:, command: or the Compose file.

Why Files under /run/secrets do not appear in the environment, inspect output or process lists, and a file-sourced secret is a bind mount, which works with read_only.

Trap A hardened service fails to start with container rootfs is marked read-only after secrets were added. The secret is sourced from environment:. Use file:.

Rung 8 develop.watch: edit, sync, restart, rebuild

develop.watch (with docker compose up --watch or docker compose watch) reacts to file changes without bind mounts:

    develop:
      watch:
        - action: sync+restart
          path: ../../pantry/app.py
          target: /app/app.py
        - action: rebuild
          path: ../../pantry/requirements.txt

The actions:

  • sync copies the changed file into the running container, for servers that reload by themselves.
  • sync+restart copies the file, then restarts the container, for servers like gunicorn that need a restart.
  • rebuild rebuilds the image and recreates the container, for dependency changes.

Unlike a bind mount, watch works with the image's own layout and ownership, and does not drag the host's node_modules or __pycache__ into the container.

It needs a writable target, so the dev override sets read_only: false. One more trap, found while testing this chapter on Compose 2.32:

Syncing and restarting service "web" after 1 changes were detected
Error handling changed files for service web: … getent unable to find entry "10001:10001" in passwd database

The copy resolves the container's user string, and uid:gid form is not a passwd entry. With user: "10001" in the override:

Syncing and restarting service "web" after 1 changes were detected
service "web" restarted
Hello from dev (dev). You are visitor #5. Edited live. Edited again. …

Rule Use develop.watch with sync, sync+restart and rebuild in the dev override instead of bind-mounting the source, and give watched services a plain numeric user.

Why Watch keeps the container's image-defined filesystem and copies only what changed, so dev runs the same layout as production.

Trap Saving a file does nothing, and the watch output shows an error copying files. The target is read-only, or the container user is in uid:gid form. Read the line after "Syncing".

Decide

You need Use
Dev-only settings compose.override.yaml (automatic)
Production settings compose.prod.yaml with an explicit -f list
A second copy on the same host -p <name> / COMPOSE_PROJECT_NAME
Replace a list in an override !override
Drop a key in an override !reset
Optional tools profiles: + docker compose run --rm
A fragment owned by another team include:
Many similar services extends:
Repeated settings in one file x- + anchors
Passwords and keys secrets: with file: + *_FILE in the app
Live reload without bind mounts develop.watch
Several hosts, self-healing, rolling updates Kubernetes (the rest of this book)

Compose stops being enough at the last row. It runs one project on one Docker host. It does not restart unhealthy containers, does not move work when the host dies, and cannot roll out a new version without a gap. The Bridge chapter maps every key in this chapter to its Kubernetes counterpart.

The golden file

Pantry as a project: a shared base that includes the Redis fragment, uses anchors for hardening and client settings, a tools profile and a file secret; a dev override with watch; and a production file that runs a released image with limits and rotated logs.

examples/manual/d10/compose.yaml
# Pantry, the project. Base file: what every environment shares.
#   dev:   docker compose up --watch            (compose.yaml + compose.override.yaml, loaded automatically)
#   prod:  docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
#   tools: docker compose --profile tools run --rm redis-cli
name: pantry-manual-d10

include:
  - redis/compose.yaml            # services, volumes and secrets from another file, with its own relative paths

# Extension fields: ignored by Compose, reused through YAML anchors.
x-hardening: &hardening
  read_only: true
  tmpfs: ["/tmp:size=16m"]
  cap_drop: [ALL]
  security_opt: ["no-new-privileges:true"]

x-redis-client: &redis-client
  REDIS_HOST: redis
  REDIS_PASSWORD_FILE: /run/secrets/redis_password

services:
  web:
    <<: *hardening
    build:
      context: ../../pantry
      dockerfile: ../manual/d05/Dockerfile
      additional_contexts:
        manual: ../d05
    image: ${REGISTRY:-pantry-manual}/pantry:${TAG:-dev}
    environment:
      <<: *redis-client
      GREETING: ${GREETING:-Hello from Pantry}
    secrets: [redis_password]
    networks: [front, back]
    ports:
      - "127.0.0.1:${WEB_PORT:-8000}:8080"
    depends_on:
      redis:
        condition: service_healthy
      seed:
        condition: service_completed_successfully
    restart: unless-stopped

  seed:
    <<: *hardening
    image: redis:7-alpine
    command: ["sh", "-c", "REDISCLI_AUTH=\"$$(cat /run/secrets/redis_password)\" redis-cli -h redis SET visits ${SEED_VISITS:-0} NX"]
    secrets: [redis_password]
    networks: [back]
    depends_on:
      redis:
        condition: service_healthy
    restart: "no"

  # Only with --profile tools: an interactive client on the internal network.
  redis-cli:
    image: redis:7-alpine
    profiles: [tools]
    entrypoint: ["sh", "-c", "REDISCLI_AUTH=\"$$(cat /run/secrets/redis_password)\" exec redis-cli -h redis \"$$@\"", "--"]
    secrets: [redis_password]
    networks: [back]

networks:
  front: {}
  back:
    internal: true

secrets:
  redis_password:
    file: ./secrets/redis_password.txt   # bind-mounted read-only at /run/secrets/redis_password (git-ignored)
examples/manual/d10/redis/compose.yaml
# Redis as its own Compose file, owned by whoever owns the data tier; compose.yaml includes it.
services:
  redis:
    image: redis:7-alpine
    # The password comes from a secret file and goes into a config file in tmpfs, not into the
    # process arguments (where ps and docker top would show it). "exec" keeps redis-server as PID 1.
    command: ["sh", "-c", "printf 'requirepass %s\\n' \"$$(cat /run/secrets/redis_password)\" > /tmp/redis.conf && exec redis-server /tmp/redis.conf --appendonly yes"]
    tmpfs: ["/tmp:size=1m,mode=0700"]
    secrets: [redis_password]
    volumes:
      - redis-data:/data
    networks: [back]
    healthcheck:
      test: ["CMD-SHELL", "REDISCLI_AUTH=\"$$(cat /run/secrets/redis_password)\" redis-cli ping | grep -q PONG"]
      interval: 2s
      timeout: 2s
      retries: 15
      start_period: 5s
    restart: unless-stopped

volumes:
  redis-data:
examples/manual/d10/compose.override.yaml
# Development: loaded automatically by "docker compose up" (not when -f is given).
services:
  web:
    read_only: false                       # watch must be able to write the synced files
    user: "10001"                          # watch's copy looks the user up by this string; "10001:10001" fails
    environment:
      GREETING: Hello from dev             # merged into the base environment: other keys are kept
    develop:
      watch:
        # Edit app.py → copied into the running container, then the container restarts.
        - action: sync+restart
          path: ../../pantry/app.py
          target: /app/app.py
        # Change dependencies → rebuild the image and recreate the container.
        - action: rebuild
          path: ../../pantry/requirements.txt
examples/manual/d10/compose.prod.yaml
# Production: docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
# (no compose.override.yaml: an explicit -f list replaces the automatic one)
services:
  web:
    build: !reset null                     # never build on the server: run what CI built and scanned
    image: ${REGISTRY:?set REGISTRY}/pantry-manual:${TAG:?set TAG to a released version}
    pull_policy: always                    # re-resolve the tag on every up (a digest pins it for good)
    deploy:
      resources:
        limits: {cpus: "1.0", memory: 256M}
        reservations: {memory: 128M}
    logging: &logging
      driver: json-file
      options: {max-size: "10m", max-file: "3"}
    stop_grace_period: 10s

  redis:
    deploy:
      resources:
        limits: {cpus: "0.5", memory: 192M}
    logging: *logging

  seed:
    logging: *logging
mkdir -p secrets && openssl rand -hex 16 > secrets/redis_password.txt && chmod 600 secrets/redis_password.txt
docker compose up -d --wait                       # dev: Hello from dev (dev). …
docker compose --profile tools run --rm redis-cli GET visits
REGISTRY=localhost:5001 TAG=1.1.0 docker compose -f compose.yaml -f compose.prod.yaml up -d --wait

Review checklist

Spot the bug

The project as committed, deployed with git pull && docker compose up -d.

# compose.yaml — deployed on the server with:  git pull && docker compose up -d
name: pantry
services:
  web:
    image: localhost:5001/pantry-manual:1.1.0
    container_name: pantry-web
    environment:
      REDIS_HOST: redis
      GREETING: Hello from Pantry
    ports:
      - "127.0.0.1:8000:8080"
  redis:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]
    volumes: [redis-data:/data]
volumes:
  redis-data:
---
# compose.override.yaml — "local development tweaks", committed next to it
services:
  web:
    environment:
      GREETING: DEBUG BUILD - do not use
    ports:
      - "8080:8080"

This file has 4 problems.

Show answers
  1. The deploy command has no -f, so the server merged compose.override.yaml and served "DEBUG BUILD - do not use". Deploy with docker compose -f compose.yaml -f compose.prod.yaml up -d, and keep dev settings out of whatever the server loads.
  2. The override's ports list was appended, not substituted: 0.0.0.0:8080->8080/tcp, 127.0.0.1:8000->8080/tcp, and port 8080 answered from the LAN. Use ports: !override [...], and bind to 127.0.0.1.
  3. container_name: pantry-web makes the service impossible to scale ("Docker requires each container to have a unique name") and collides with any second copy. Remove it.
  4. The fixed name: pantry is shared by every checkout. Bringing up a staging copy on port 8100 recreated production's web container and used production's Redis (visitor #4). Give each copy its own -p.

Kubernetes manual · 1 · 8 min readReading Any Manifest#

In the story: forms handed to the front desk at city hall · Meet the City, The Smallest Thing That Runs

Every Kubernetes object, however unfamiliar, has the same skeleton. Four top-level fields describe what you want, and a fifth, written by the cluster, says what it has done about it. Once you can read that skeleton, find the meaning of any field without leaving the terminal, and predict what the API server will accept, no manifest is opaque. This chapter is the reading lesson. Every later chapter is vocabulary.

kubectl apply

watch

write status

create / delete

you: apiVersion · kind
metadata · spec

API server
validate · default · admit

etcd
stored object

controllers
(Deployment, ReplicaSet…)

Pods

The ladder

Rung 1 Five fields: apiVersion, kind, metadata, spec, status

examples/manual/k01/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pantry
  namespace: k01
  labels:
    app.kubernetes.io/name: pantry
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: pantry
  template:
    metadata:
      labels:
        app.kubernetes.io/name: pantry
    spec:
      containers:
        - name: web
          image: pantry-manual:d05
          ports:
            - containerPort: 8080
  • apiVersion is group/version (apps/v1), or just v1 for the core group (Pods, Services, ConfigMaps).
  • kind is the type within that group.
  • metadata holds identity (name, namespace, uid) and description (labels, annotations), plus fields the server manages, such as generation, resourceVersion and creationTimestamp.
  • spec is what you want.
  • status is what the controller observed. You never write status.
$ kubectl -n k01 get deploy pantry -o yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  generation: 1
spec: …
status:
  observedGeneration: 1

generation increases with every change to spec, and the controller copies it to status.observedGeneration once it has acted on it. When the two differ, the controller has not caught up yet. That is the first thing to check when "the change did nothing". -o yaml hides metadata.managedFields (who owns which field, rung 7) unless you add --show-managed-fields.

Rule Read every object as desired state (spec) against observed state (status), and compare metadata.generation with status.observedGeneration before deciding a change was ignored.

Why Kubernetes is declarative. You store intent, and controllers reconcile towards it asynchronously, recording progress in status.

Trap A manifest copied from kubectl get -o yaml and re-applied carries status, uid, resourceVersion and creationTimestamp with it, which causes conflicts or no-ops. Keep the source manifest in git and never round-trip live objects.

Rung 2 kubectl explain is the manual

You never need a browser to find out what a field means, its type or its default. The cluster serves its own schema, so explain is always right for this cluster's version:

kubectl explain deployment.spec.strategy.rollingUpdate.maxSurge
kubectl explain deployment.spec --recursive | less          # every field under spec, indented
kubectl api-resources --api-group=apps                      # which kinds exist, and their apiVersion
FIELD: maxSurge <IntOrString>
DESCRIPTION:
    The maximum number of pods that can be scheduled above the desired number of
    pods. Value can be an absolute number (ex: 5) or a percentage of desired
    pods (ex: 10%). … Defaults to 25%.

kubectl api-resources answers "which apiVersion does this kind use here?", including for CRDs the cluster has installed. kubectl create deployment … --dry-run=client -o yaml generates a correct skeleton to start from.

Rule Look fields up with kubectl explain <kind>.<path> and kinds with kubectl api-resources, against the cluster you deploy to.

Why Both read the API server's own OpenAPI schema, so they match its version and its installed CRDs exactly, which no blog post does.

Trap A field from a tutorial is rejected, or silently does nothing, on your cluster. kubectl explain shows that it does not exist at that path or in that version.

Rung 3 Labels identify, selectors find

Labels are key/value pairs that identify an object, and selectors query them. A Deployment finds its Pods, a Service its endpoints and a NetworkPolicy its targets this way, so the most consequential lines in a manifest are often its labels. Use the recommended app.kubernetes.io/* set, which every tool understands:

Label Meaning Example
app.kubernetes.io/name the application pantry
app.kubernetes.io/instance this installation of it pantry-k01
app.kubernetes.io/component its role web, cache
app.kubernetes.io/part-of the larger system pantry
app.kubernetes.io/version the running version "1.1.0"
app.kubernetes.io/managed-by the tool that applied it kubectl, Helm

Three rules are enforced by the API server:

selector ≠ template labels:  `selector` does not match template `labels`
changing a selector:        spec.selector: … field is immutable
label value of 70 chars:    must be no more than 63 characters
kubectl get pods -l app.kubernetes.io/name=pantry,app.kubernetes.io/component=web
kubectl get pods -L app.kubernetes.io/version                # show a label as a column

A Deployment's selector is fixed at creation. Put only stable identity labels in it (name, instance), never version, or every upgrade would need a new selector.

Rule Label everything with the app.kubernetes.io/* set, and select on the smallest stable subset (name + instance).

Why Selectors are how objects find each other. Workload selectors are immutable, and values are capped at 63 characters of [A-Za-z0-9._-].

Trap A Service has no endpoints and every request fails. Its selector matches no Pod's labels, often through a typo or a label used in the Deployment's selector but not in its template. kubectl get endpointslices -l kubernetes.io/service-name=<svc> shows an empty list.

Rung 4 Annotations describe

Annotations are key/value metadata that nothing selects on. They hold larger, non-identifying data: owners, runbook links, build info, and configuration for tools that read them (ingress controllers, Prometheus scrapers, cert-manager, Argo CD):

  annotations:                              # describe; nothing selects on these
    pantry.example.com/owner: "team-pantry"
    pantry.example.com/runbook: "https://example.com/runbooks/pantry"

Use a domain prefix you own for your own keys. Unprefixed keys and kubernetes.io/ belong to Kubernetes. Annotation values may be long (up to 256 KB in total per object), which is why controllers stash state there. kubectl.kubernetes.io/last-applied-configuration is the client-side apply record.

Rule Put identity in labels and everything else (owners, links, tool configuration) in annotations, under a prefix you own.

Why Labels are indexed for selection and tightly limited. Annotations are free-form and never affect which objects match a selector.

Trap A tool "ignores" its configuration. It was written as a label, or with a misspelled annotation key. The API server accepts any well-formed key, so typos fail silently. Compare with the tool's documented key.

Rung 5 Multi-document files, and order

One file can hold several objects separated by ---. kubectl apply -f file.yaml (or -f dir/) sends them in order, so an object that needs another, such as a namespaced object and its Namespace, or a custom resource and its CRD, must come after it:

ConfigMap first, its Namespace second, in one file:
namespace/k01-late created
Error from server (NotFound): error when creating "order.yaml": namespaces "k01-late" not found

The Namespace was created, so a second apply succeeds. That is why ordering bugs pass on the second CI run and fail on a fresh cluster. Order documents by dependency: Namespaces, CRDs, ServiceAccounts and RBAC, ConfigMaps and Secrets, then workloads and Services. Helm (K10) does this sorting for you. With plain directories, use numbered file names.

kubectl apply -f k01/pantry.yaml           # Namespace document first, then Deployment, then Service

Rule Order objects by dependency (Namespace and CRDs first) in multi-document files and directories, and test applies against an empty cluster.

Why kubectl apply creates objects in the order it reads them, and the API server rejects an object whose namespace or kind does not exist yet.

Trap The first deploy to a new cluster fails with namespaces "…" not found or no matches for kind, and a retry "fixes" it. Order the documents.

Rung 6 Dry runs and diff before apply

Client dry run builds objects locally and validates almost nothing. Server dry run sends them through the API server's full validation and admission (schemas, defaults, admission webhooks, Pod Security) without storing them. kubectl diff shows what would change on the live objects:

into a namespace that does not exist:
--dry-run=client   deployment.apps/pantry created (dry run)
--dry-run=server   Error from server (NotFound): … namespaces "k01-nope" not found
kubectl apply --dry-run=server -f pantry.yaml       # would the cluster accept it?
kubectl diff -f pantry.yaml                         # what would change (exit 1 when there are differences)
-  replicas: 4
+  replicas: 3

Server dry run has one blind spot: it creates nothing, including Namespaces. A file that creates its own namespace fails a server dry run with namespaces "k01" not found. In CI, create namespaces first (or run the dry run against a cluster that has them).

Rule Run kubectl apply --dry-run=server and kubectl diff before every apply in CI, and treat client dry runs as syntax checks only.

Why Only the server knows its schemas, admission policies and current state, and only the server dry run asks it.

Trap CI's --dry-run=client stage is green and the real apply is rejected. The client dry run never contacted the API server.

Rung 7 Server-side apply and field ownership

With server-side apply (kubectl apply --server-side), the API server records which manager owns each field. A second manager changing a field you apply produces a conflict instead of a silent tug-of-war:

$ kubectl -n k01 patch deploy pantry -p '{"spec":{"replicas":4}}' --field-manager=autoscaler-sim
$ kubectl apply --server-side -f deployment.yaml
error: Apply failed with 1 conflict: conflict with "autoscaler-sim" using apps/v1: .spec.replicas
$ kubectl -n k01 get deploy pantry --show-managed-fields -o jsonpath='{range .metadata.managedFields[*]}{.manager} {.operation}{"\n"}{end}'
kubectl Apply
autoscaler-sim Update
kube-controller-manager Update

The conflict is the point: someone else (an HPA, an operator, a human with kubectl scale) manages replicas. Either stop setting the field in your manifest, which is right when an HPA owns it (K5), or take it over deliberately with --force-conflicts. Server-side apply also works on objects too large for the client-side last-applied-configuration annotation, as Argo CD's install manifest showed in the story. Pick one mode per object and stay with it: a client-side kubectl apply on an object created server-side prints missing the kubectl.kubernetes.io/last-applied-configuration annotation, and the two bookkeeping systems then disagree about who owns what.

kubectl apply --server-side -f pantry.yaml
kubectl apply --server-side --force-conflicts -f pantry.yaml      # only when you mean to take ownership

Rule Apply with --server-side, leave fields owned by controllers (an HPA's replicas) out of your manifests, and use --force-conflicts only to take ownership on purpose.

Why Field ownership turns concurrent changes into explicit conflicts, and every apply re-asserts the fields you own.

Trap Replicas flap: the HPA scales to 6, the next CI apply resets to 2, and the HPA scales up again. The manifest sets spec.replicas on an autoscaled Deployment. Remove the field.

Decide

Question Command
What does this field mean? kubectl explain <kind>.<path>
Which apiVersion for this kind? kubectl api-resources
Is this manifest valid for this cluster? kubectl apply --dry-run=server -f …
What will change? kubectl diff -f …
Who changed this field? kubectl get … --show-managed-fields -o yaml
Has the controller acted on my change? compare metadata.generation and status.observedGeneration
Which objects does this selector match? kubectl get <kind> -l <selector>
A starting skeleton kubectl create <kind> … --dry-run=client -o yaml

The golden file

Pantry's first manifest, written to be read: a Namespace first, explicit namespaces, the recommended labels (reused with a YAML anchor), annotations under an owned prefix, a minimal stable selector, and a named port the Service targets by name.

examples/manual/k01/pantry.yaml
# Pantry's first manifest: a namespace, a Deployment and a Service, labelled the recommended way.
# Apply in order of dependency: the Namespace document comes first.
#   kubectl apply --server-side --dry-run=server -f pantry.yaml && kubectl diff --server-side -f pantry.yaml
#   kubectl apply --server-side -f pantry.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: k01
---
apiVersion: apps/v1                         # group/version: kubectl api-resources | grep deployments
kind: Deployment
metadata:
  name: pantry
  namespace: k01                            # explicit: never rely on the current context's namespace
  labels: &labels                           # identify; selectors and tools query these
    app.kubernetes.io/name: pantry
    app.kubernetes.io/instance: pantry-k01
    app.kubernetes.io/component: web
    app.kubernetes.io/part-of: pantry
    app.kubernetes.io/version: "1.1.0"
    app.kubernetes.io/managed-by: kubectl
  annotations:                              # describe; nothing selects on these
    pantry.example.com/owner: "team-pantry"
    pantry.example.com/runbook: "https://example.com/runbooks/pantry"
spec:
  replicas: 2
  selector:                                 # immutable after creation: only stable identity labels
    matchLabels:
      app.kubernetes.io/name: pantry
      app.kubernetes.io/instance: pantry-k01
  template:
    metadata:
      labels: *labels                       # must include every selector label
    spec:
      containers:
        - name: web
          image: pantry-manual:d05
          ports:
            - name: http                    # named, so Services can target "http" (K8)
              containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: pantry
  namespace: k01
  labels:
    app.kubernetes.io/name: pantry
    app.kubernetes.io/instance: pantry-k01
spec:
  selector:
    app.kubernetes.io/name: pantry
    app.kubernetes.io/instance: pantry-k01
  ports:
    - name: http
      port: 80
      targetPort: http
kubectl create namespace k01 --dry-run=client -o yaml | kubectl apply -f -     # so the server dry run can see it
kubectl apply --server-side --dry-run=server -f pantry.yaml && kubectl diff --server-side -f pantry.yaml
kubectl apply --server-side -f pantry.yaml
kubectl -n k01 get pods -L app.kubernetes.io/version

Review checklist

Spot the bug

From an old repository. "It worked in 2018."

examples/manual/k01/bug/deployment.yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: pantry
  labels:
    app: pantry
spec:
  replicas: 2
  selector:
    matchLabels:
      app: pantry-web
  template:
    metadata:
      labels:
        app: pantry
    spec:
      containers:
        - name: web
          image: pantry-manual:d05
          port: 8080

This file has 4 problems.

Show answers
  1. apiVersion: extensions/v1beta1 no longer serves Deployments: no matches for kind "Deployment" in version "extensions/v1beta1". Use apps/v1 (Old Maps tells the history).
  2. port: 8080 is not a field. With apps/v1 the server rejects it with strict decoding error: unknown field "spec.template.spec.containers[0].port". It is ports: [{containerPort: 8080, name: http}].
  3. The selector (app: pantry-web) does not match the template's labels (app: pantry): `selector` does not match template `labels`. Select on labels the template has, preferably the app.kubernetes.io/* set.
  4. There is no namespace:. Once the other errors were fixed, it applied into the current context's namespace, which was default here and is something else on your colleague's laptop. State the namespace.

Kubernetes manual · 2 · 9 min readThe Pod Spec#

In the story: an apartment, its tenants, and the helper who arrives before them · The Smallest Thing That Runs

Every workload (Deployment, StatefulSet, DaemonSet, Job, CronJob) wraps the same thing: a Pod template. Learn spec.template.spec once and you can read all of them. This chapter covers how the containers start, what they are told, who runs before and beside them, and which image they get. Security (K3), lifecycle (K4) and resources (K5) are the next three chapters, and they add fields to this same spec.

main: websidecar (native)init: wait-for-rediskubeletmain: websidecar (native)init: wait-for-rediskubelet… serves …startexit 0 (Redis answered)start, keeps runningstart (command/args, env)exits (Job done, or Pod deleted)SIGTERM, after the main containers

The ladder

Rung 1 command and args versus ENTRYPOINT and CMD

Kubernetes renamed Docker's two fields. command replaces the image's ENTRYPOINT, and args replaces its CMD. The catch is in the third row:

Pod spec Runs (image: ENTRYPOINT docker-entrypoint.sh, CMD redis-server) Measured
neither docker-entrypoint.sh redis-server redis-server *:6379
args: [redis-server, --port, "6380"] docker-entrypoint.sh redis-server --port 6380 redis-server *:6380
command: [redis-cli] redis-cli (the image's CMD is dropped too) ran alone: "Could not connect…"
command + args exactly what you wrote
examples/manual/k02/cmd-args.yaml
# How command/args combine with the image's ENTRYPOINT/CMD (redis:7-alpine: ENTRYPOINT docker-entrypoint.sh, CMD redis-server)
apiVersion: v1
kind: Pod
metadata: {name: neither, namespace: k02}
spec:
  restartPolicy: Never
  containers:
    - {name: c, image: "redis:7-alpine"}
---
apiVersion: v1
kind: Pod
metadata: {name: args-only, namespace: k02}
spec:
  restartPolicy: Never
  containers:
    - {name: c, image: "redis:7-alpine", args: ["redis-server", "--port", "6380"]}
---
apiVersion: v1
kind: Pod
metadata: {name: command-only, namespace: k02}
spec:
  restartPolicy: Never
  containers:
    - {name: c, image: "redis:7-alpine", command: ["redis-cli"]}
---
apiVersion: v1
kind: Pod
metadata: {name: both, namespace: k02}
spec:
  restartPolicy: Never
  containers:
    - {name: c, image: "redis:7-alpine", command: ["sh", "-c"], args: ["echo PID 1 is $$; cat /proc/1/cmdline | tr '\\0' ' '"]}

So the usual choice is args. It keeps the image's entrypoint script (the D2 exec "$@" pattern) and changes only what it runs. Use command only to bypass the entrypoint, and then write the whole command line. Kubernetes expands $(VAR) in both fields from the container's environment, but there is no shell: command: ["echo", "$HOME"] prints $HOME. Use ["sh", "-c", "…"] when you need one, and write $$ for a literal $.

Rule Override with args to keep the image's entrypoint. Use command only when you mean to replace it, and then also give args.

Why command replaces ENTRYPOINT and discards the image's CMD, while args replaces only CMD.

Trap The container fails with exec: "--workers": executable file not found. Flags intended as arguments were written under command, so the first flag became the program.

Rung 2 env and envFrom

env sets variables one by one: a literal value, or valueFrom a ConfigMap key, a Secret key, the Pod's own fields, or its resource limits. envFrom imports every key of a ConfigMap or Secret. When both define a name, env wins:

      envFrom:
        - configMapRef: {name: pantry-settings}          # every key becomes a variable
      env:
        - {name: LOG_LEVEL, value: debug}                # env wins over envFrom
GREETING=Hello from a ConfigMap   ← envFrom
LOG_LEVEL=debug                   ← env beat the ConfigMap's "info"

Environment variables are read once, at start. Changing the ConfigMap does not change a running container's environment, which is K6's subject. envFrom also imports keys you did not expect: anyone who adds a key to the ConfigMap adds a variable to your process. Prefer explicit env entries for anything that matters, and envFrom for whole, purpose-made groups.

Rule Use env with explicit valueFrom references for settings the code depends on, and envFrom only for ConfigMaps or Secrets made for that one workload.

Why env documents every name the process receives and takes precedence, while envFrom imports whatever keys exist at start time.

Trap A Pod fails to start with CreateContainerConfigError, configmap "…" not found or couldn't find key …. A referenced ConfigMap or key does not exist. Add optional: true only when the program really copes without it.

Rung 3 $(VAR) expansion and its order

A variable's value may reference earlier variables with $(NAME). "Earlier" is literal, meaning above it in the list:

        - {name: EARLY, value: "$(LATE)/x"}              # LATE is defined below: not expanded
        - {name: LATE, value: "late"}
        - {name: URL, value: "http://$(POD_IP):8080/"}   # POD_IP is defined above: expanded
EARLY=$(LATE)/x
URL=http://10.244.2.12:8080/

A reference to an unknown or later variable is left as literal text, with no error. Variables from envFrom count as defined before every env entry (REF: "got $(GREETING)" with GREETING from a ConfigMap gave got Hello from Pantry). $$(NAME) escapes to a literal $(NAME).

Rule Order env entries so every $(VAR) refers to a variable defined above it, and check the result with kubectl exec … -- env.

Why Kubernetes expands references in one pass, top to bottom, and leaves anything it cannot resolve untouched.

Trap The app logs a connection string containing the literal text $(REDIS_HOST). The reference sits above the variable it names.

Rung 4 The downward API: facts about this Pod

The downward API exposes the Pod's own metadata and resources to its containers, through env (fieldRef, resourceFieldRef) or as files in a downwardAPI volume (which, unlike env, update when labels and annotations change):

        - name: POD_NAME
          valueFrom: {fieldRef: {fieldPath: metadata.name}}
        - name: NODE_NAME
          valueFrom: {fieldRef: {fieldPath: spec.nodeName}}
        - name: MEM_LIMIT_MB
          valueFrom: {resourceFieldRef: {resource: limits.memory, divisor: 1Mi}}
POD_NAME=env-demo
NODE_NAME=manual-worker
POD_IP=10.244.2.12
MEM_LIMIT_MB=64

Typical uses: log lines that name the Pod and node, metrics labels, sizing a runtime's heap from its limit (JVMs, Node's --max-old-space-size), and peer discovery. The golden Pantry reports k02 on $(NODE_NAME) as its version, so every response says which node served it.

Rule Get the Pod's name, namespace, IP, node and limits from the downward API, never by calling the Kubernetes API or guessing from the hostname.

Why The kubelet fills these values when it creates the container, without the Pod needing any API access or RBAC (K9).

Trap A process sized from the node's memory (/proc/meminfo) gets OOM-killed. It sees the node's RAM, not its own limit. Pass limits.memory through resourceFieldRef and size from that.

Rung 5 Init containers run first, one at a time, to completion

initContainers run in order before any main container starts, and each must exit 0. Use them for preconditions: wait for a dependency, run a migration, render config into a shared emptyDir, fix volume permissions.

      initContainers:
        # Runs to completion before the app starts; the Pod stays in Init:0/1 until Redis answers.
        - name: wait-for-redis
          image: redis:7-alpine
          command: ["sh", "-c", "until redis-cli -h redis ping; do echo waiting for redis; sleep 2; done"]
init-waits   0/1   Init:0/1     0             20s     "…redis:6379: Name does not resolve"
(after the redis Service was created)
init-waits   1/1   Running
init-fails   0/1   Init:Error   2 (28s ago)   30s     "migration failed"

A failing init container is retried with back-off (Init:Error, then Init:CrashLoopBackOff), and the app never starts. Init containers run on every Pod start, including after a node move, so make them idempotent. A wait loop at startup is still not a substitute for the app handling a dependency that disappears later (K4).

Rule Use init containers for preconditions that must hold before the app starts, make them idempotent and bounded, and keep long-running helpers out of them (rung 6).

Why Init containers are sequential and blocking by design, so the main containers start only after every one of them has exited 0.

Trap A Pod sits in Init:0/1 forever. An init container never exits: it waits on something that will never appear, or it is a long-running helper put in the wrong list. kubectl logs <pod> -c <init container> shows what it is waiting for.

Rung 6 Native sidecars

A helper that must run alongside the app (a proxy, a log shipper, a config reloader) used to be a second entry in containers. That has two problems: no guarantee it starts first, and it keeps a Job's Pod alive after the work is done. A native sidecar is an init container with restartPolicy: Always. It starts in init order, does not block the next init container once started, runs for the Pod's lifetime, and is stopped after the main containers exit (stable since Kubernetes 1.33):

examples/manual/k02/jobs-sidecar.yaml
# A Job whose main container needs a helper running next to it (a log shipper, a DB proxy).
apiVersion: batch/v1
kind: Job
metadata: {name: classic-sidecar, namespace: k02}
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      containers:
        - {name: main, image: "busybox:1.37-musl", command: ["sh", "-c", "echo work done"]}
        - {name: helper, image: "busybox:1.37-musl", command: ["sh", "-c", "while true; do sleep 1; done"]}
---
apiVersion: batch/v1
kind: Job
metadata: {name: native-sidecar, namespace: k02}
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      initContainers:
        - name: helper
          image: busybox:1.37-musl
          restartPolicy: Always          # this makes it a sidecar: started first, stopped after the main containers
          command: ["sh", "-c", "trap 'echo helper stopping; exit 0' TERM; while true; do sleep 1; done"]
      containers:
        - {name: main, image: "busybox:1.37-musl", command: ["sh", "-c", "echo work done"]}
classic-sidecar   Running    0/1           25s        ← never completes: the helper never exits
native-sidecar    Complete   1/1           7s
helper log:       helper stopping                      ← SIGTERM after main exited

The golden file's seed Job uses one: a socat proxy on 127.0.0.1:6379 that forwards to the Redis Service, which is the same shape as a cloud database proxy next to a migration job.

Rule Declare helpers that run beside the app as native sidecars (initContainers with restartPolicy: Always), never as extra containers, especially in Jobs.

Why Native sidecars start before the main containers, restart independently, and are terminated after them, so Jobs complete and the helper outlives the app's shutdown.

Trap A Job never completes: kubectl get job shows 0/1, and the Pod shows one container finished and one running. The helper is in containers. Move it to initContainers with restartPolicy: Always.

Rung 7 Image references and the pull policy

imagePullPolicy decides whether the kubelet pulls before every start. If you omit it, it is defaulted when the object is created: Always for :latest or no tag, IfNotPresent otherwise.

pantry-manual:latest   pullPolicy defaulted to: Always         ImagePullBackOff   (the image exists only on the nodes)
pantry-manual:d05      pullPolicy defaulted to: IfNotPresent   Running

The default is stored. A Deployment created with :latest and later changed to a pinned tag kept imagePullPolicy: Always, and failed to pull an image that every node had:

image=pantry-manual:d05 pullPolicy=Always          ← after changing the tag in an existing Deployment
fresh: image=pantry-manual:d05 pullPolicy=IfNotPresent

In production, reference images by immutable tag or, better, by digest (image: registry/app@sha256:…, the output of D6's release script). A digest makes "which bytes are running" unambiguous, and IfNotPresent is then always correct. Private registries need imagePullSecrets (K9).

          image: localhost:5001/pantry-manual@sha256:6cb0485b09bed372b17a62389b0ba943c742f5d524df7ab9914aa446729faad2
          imagePullPolicy: IfNotPresent

Rule Reference images by digest or an immutable release tag, never :latest, and set imagePullPolicy explicitly.

Why A tag can move under a running Deployment, and the pull policy is defaulted from the tag once, at creation, then stored.

Trap Pods on different nodes run different code after a rollout, or an image present on every node goes into ErrImagePull. Check kubectl get deploy -o jsonpath='{..imagePullPolicy}' and the tag. :latest gives Always, and the stored value survives tag changes.

Rung 8 Pod-level fields to recognise

A few Pod-level fields change behaviour for every container. Recognise them in review:

spec:
  restartPolicy: Always          # Always (Deployments), OnFailure/Never (Jobs)
  terminationGracePeriodSeconds: 30   # K4
  hostNetwork: false             # true = the node's network namespace: port clashes, no NetworkPolicy isolation
  shareProcessNamespace: false   # true = containers see (and can signal) each other's processes
  dnsPolicy: ClusterFirst        # Default = the node's resolv.conf, so Service names stop resolving
  enableServiceLinks: false      # stops Docker-link-style *_SERVICE_HOST variables for every Service in the namespace
  automountServiceAccountToken: false   # K9

enableServiceLinks deserves a word. By default, every container gets environment variables for every Service in its namespace (REDIS_SERVICE_HOST, REDIS_PORT=tcp://…). In a busy namespace that is hundreds of variables, and they override the image's own ENV: an image built with ENV REDIS_PORT=6379, run in a namespace with a Service named redis, saw REDIS_PORT=tcp://10.96.3.96:6379. Explicit env entries in the Pod spec still win, and KUBERNETES_SERVICE_HOST is always set.

Rule Leave hostNetwork, hostPID and shareProcessNamespace false unless there is a reviewed reason, and set enableServiceLinks: false on new workloads.

Why Each of these widens what one container can see or affect, and service links inject variables you did not choose.

Trap An app's REDIS_PORT default is suddenly tcp://10.96.3.96:6379 and the client fails to parse it. A Service named redis exists in the namespace, and service links overrode the image's ENV. Set enableServiceLinks: false, or set the variable explicitly in env.

Decide

You need Use
Different flags, same image entrypoint args
A different program entirely command + args
One setting from a ConfigMap/Secret env[].valueFrom
A purpose-made group of settings envFrom
The Pod's name, node, IP, limits downward API
Something done once before start init container
A helper running for the Pod's lifetime native sidecar (initContainers + restartPolicy: Always)
Immutable image reference image@sha256:… + IfNotPresent

The golden file

Pantry's Pod spec: Redis started through args, the web Deployment with an init container, explicit and imported environment, downward API values and named ports, and a seed Job whose proxy runs as a native sidecar.

examples/manual/k02/pantry.yaml
# Pantry's Pod spec, field by field: Redis, the web Deployment (init container, env sources,
# downward API, named ports), and a seed Job with a native sidecar.
apiVersion: v1
kind: Namespace
metadata: {name: k02}
---
apiVersion: v1
kind: ConfigMap
metadata: {name: pantry-settings, namespace: k02}
data:
  GREETING: "Hello from Pantry"
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: redis, namespace: k02, labels: {app.kubernetes.io/name: redis}}
spec:
  replicas: 1
  selector: {matchLabels: {app.kubernetes.io/name: redis}}
  template:
    metadata: {labels: {app.kubernetes.io/name: redis}}
    spec:
      containers:
        - name: redis
          image: redis:7-alpine
          args: ["redis-server", "--appendonly", "yes"]     # args = the image's CMD; its ENTRYPOINT still runs
          ports: [{name: redis, containerPort: 6379}]
---
apiVersion: v1
kind: Service
metadata: {name: redis, namespace: k02}
spec:
  selector: {app.kubernetes.io/name: redis}
  ports: [{name: redis, port: 6379, targetPort: redis}]
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: pantry, namespace: k02, labels: {app.kubernetes.io/name: pantry}}
spec:
  replicas: 2
  selector: {matchLabels: {app.kubernetes.io/name: pantry}}
  template:
    metadata: {labels: {app.kubernetes.io/name: pantry}}
    spec:
      initContainers:
        # Runs to completion before the app starts; the Pod stays in Init:0/1 until Redis answers.
        - name: wait-for-redis
          image: redis:7-alpine
          command: ["sh", "-c", "until redis-cli -h redis ping; do echo waiting for redis; sleep 2; done"]
      containers:
        - name: web
          image: pantry-manual:d05                      # a pinned tag: pull policy defaults to IfNotPresent
          # No command/args: the image's CMD (gunicorn) is right. args would replace CMD, command would replace ENTRYPOINT.
          ports: [{name: http, containerPort: 8080}]
          envFrom:
            - configMapRef: {name: pantry-settings}     # every key becomes a variable
          env:
            - {name: REDIS_HOST, value: redis}
            - name: POD_NAME                            # downward API: facts about this Pod
              valueFrom: {fieldRef: {fieldPath: metadata.name}}
            - name: NODE_NAME
              valueFrom: {fieldRef: {fieldPath: spec.nodeName}}
            - name: PANTRY_VERSION
              value: "k02 on $(NODE_NAME)"              # $(VAR) expands variables defined above it
---
apiVersion: batch/v1
kind: Job
metadata: {name: seed, namespace: k02}
spec:
  backoffLimit: 3
  template:
    spec:
      restartPolicy: Never
      initContainers:
        # A native sidecar (restartPolicy: Always in initContainers): starts first, keeps running,
        # and is stopped after the main container exits, so the Job can complete.
        - name: redis-proxy
          image: alpine/socat:1.8.1.3
          restartPolicy: Always
          args: ["TCP-LISTEN:6379,fork,reuseaddr", "TCP:redis:6379"]
      containers:
        - name: seed
          image: redis:7-alpine
          command: ["sh", "-c", "until redis-cli -h 127.0.0.1 ping; do sleep 1; done; redis-cli -h 127.0.0.1 SET visits 100 NX"]
kubectl apply --server-side -f pantry.yaml
kubectl -n k02 wait --for=condition=complete job/seed && kubectl -n k02 rollout status deploy/pantry
kubectl -n k02 port-forward deploy/pantry 8020:8080 & curl -s localhost:8020/   # … (k02 on manual-worker2). You are visitor #101.

Review checklist

Spot the bug

"Adds a log shipper and four workers."

examples/manual/k02/bug/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata: {name: pantry-bug, namespace: k02}
spec:
  replicas: 1
  selector: {matchLabels: {app: pantry-bug}}
  template:
    metadata: {labels: {app: pantry-bug}}
    spec:
      initContainers:
        - name: log-shipper
          image: busybox:1.37-musl
          command: ["sh", "-c", "while true; do sleep 5; done"]
      containers:
        - name: web
          image: pantry-manual:latest
          command: ["--workers", "4"]
          env:
            - {name: GREETING, value: "Hello from $(REDIS_HOST)"}
            - {name: REDIS_HOST, value: redis}

This file has 4 problems.

Show answers
  1. The log shipper is a plain init container that never exits, so the Pod sat in Init:0/1 and the app never started. Make it a native sidecar with restartPolicy: Always.
  2. pantry-manual:latest defaulted the pull policy to Always, which gave ErrImagePull on a cluster whose nodes had the image. Pin a tag or digest, and set imagePullPolicy explicitly (and, on an existing Deployment, set it: the stored Always survives a tag change).
  3. command: ["--workers", "4"] replaces the image's command with a flag: exec: "--workers": executable file not found. Write the server command under args (args: [gunicorn, --bind, 0.0.0.0:8080, --workers, "4", app:app]).
  4. GREETING references $(REDIS_HOST), which is defined below it, so the app said "Hello from $(REDIS_HOST)". Move REDIS_HOST above it.

Kubernetes manual · 3 · 8 min readSecurity Context#

In the story: the building's house rules: who may live here, and what they may do inside · Districts, Key Cards and Rush Hour

D5 hardened Pantry with docker run flags. In Kubernetes the same protections are fields in securityContext, set at the Pod level (for every container and for volumes) and at the container level. A namespace label then decides which Pods the API server will even admit. This chapter moves each D5 flag into its field, measures what the restricted Pod Security Standard demands, and ends with a Pod it admits.

docker run (D5) Pod spec
USER 10001:10001 in the image runAsUser: 10001, runAsGroup: 10001, runAsNonRoot: true
--read-only readOnlyRootFilesystem: true
--tmpfs /tmp an emptyDir (medium: Memory) mounted at /tmp
--cap-drop ALL capabilities: {drop: ["ALL"]}
--security-opt no-new-privileges allowPrivilegeEscalation: false
default seccomp profile seccompProfile: {type: RuntimeDefault}
volume ownership through the image's directory fsGroup: 10001
userns-remap on the daemon hostUsers: false per Pod

The ladder

Rung 1 Pod Security Admission: labels on the namespace

Kubernetes ships three Pod Security Standards. privileged allows everything. baseline blocks known escalations: host namespaces, privileged containers, dangerous capabilities. restricted requires the hardened settings of this chapter. A built-in admission controller applies them per namespace, through labels:

examples/manual/k03/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: k03
  labels:
    pod-security.kubernetes.io/enforce: restricted          # reject Pods that break the restricted standard
    pod-security.kubernetes.io/enforce-version: v1.37       # pin the rules to a version; bump it deliberately
    pod-security.kubernetes.io/warn: restricted             # also warn kubectl users
    pod-security.kubernetes.io/audit: restricted            # and record violations in the audit log
$ kubectl -n k03 run bare --image=pantry-manual:d05
Error from server (Forbidden): pods "bare" is forbidden: violates PodSecurity "restricted:v1.37":
  allowPrivilegeEscalation != false (container "bare" must set securityContext.allowPrivilegeEscalation=false),
  unrestricted capabilities (container "bare" must set securityContext.capabilities.drop=["ALL"]),
  runAsNonRoot != true (pod or container "bare" must set securityContext.runAsNonRoot=true),
  seccompProfile (pod or container "bare" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")

The error is the checklist: four fields. enforce rejects, warn prints a warning to whoever applies, and audit records the violation in the audit log. Pin *-version: without it the rules are latest, and they can tighten when the cluster is upgraded (the unpinned warn above evaluated restricted:latest). Pod Security Admission replaced PodSecurityPolicy, which was removed in Kubernetes 1.25 (2022).

The admission check applies to Pods. A Deployment that violates the standard is still created, and its Pods are refused:

deployment.apps/bare created
NAME   READY   UP-TO-DATE   AVAILABLE
bare   0/1     0            0
event FailedCreate: Error creating: pods "bare-…" is forbidden: violates PodSecurity "restricted:v1.37": …

Rule Label every application namespace enforce: restricted, with warn and audit too, and pin enforce-version to the cluster's minor version.

Why The labels make the API server reject non-compliant Pods before they run, and a pinned version stops an upgrade from silently changing the rules.

Trap kubectl apply succeeds and nothing runs: the Deployment shows 0/1 with no Pods. Admission rejected the ReplicaSet's Pods. kubectl get events --field-selector reason=FailedCreate shows violates PodSecurity.

Rung 2 runAsNonRoot needs a number the kubelet can check

runAsNonRoot: true makes the kubelet refuse to start a container whose user would be UID 0. It checks runAsUser if set, and otherwise the image's USER, which must be numeric (D5, rung 2). An image that runs as root fails at container creation:

redis:7-alpine with runAsNonRoot and no runAsUser:  CreateContainerConfigError
container has runAsNonRoot and image will run as root (pod: "redis-root_k03(…)", container: web)

Set runAsUser and runAsGroup explicitly to the image's numbers. That documents the UID in the manifest and still works if a future image forgets its USER. Third-party images that start as root and drop privileges themselves (the official Redis and PostgreSQL images do) need a numeric runAsUser that the image supports, or a non-root variant of the image.

  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    runAsGroup: 10001

Rule Set runAsNonRoot: true with explicit numeric runAsUser and runAsGroup matching the image's USER.

Why The kubelet can verify only numbers, and explicit values make the identity independent of what the image declares.

Trap A Pod is stuck in CreateContainerConfigError with image will run as root (or non-numeric user). The image has no numeric USER. Set runAsUser, or fix the image (D5).

Rung 3 fsGroup: who owns the volumes

fsGroup makes every volume that supports ownership management group-owned by that GID, with the setgid bit, and adds the GID to every container's supplementary groups. A non-root process can then write its volumes without a chown init container:

$ kubectl -n k03 exec pantry -- ls -ld /tmp
drwxrwsrwt 2 root pantry 60 Oct 24 00:29 /tmp       ← group pantry (10001), setgid "s"

On large persistent volumes, the recursive ownership change at every mount is slow. Set fsGroupChangePolicy: OnRootMismatch to skip it when the volume's root already has the right owner (K7). Volumes that cannot change ownership (some NFS, hostPath, Secret and ConfigMap projections with their own defaultMode) ignore fsGroup.

  securityContext:
    fsGroup: 10001
    fsGroupChangePolicy: OnRootMismatch

Rule Set fsGroup to the app's GID on every Pod that writes to volumes, with fsGroupChangePolicy: OnRootMismatch for large persistent volumes.

Why Kubernetes applies group ownership to the volume at mount time, which replaces D5's "create the directory in the image with the right owner".

Trap A Pod with a big PVC takes minutes to start, and nothing is in the logs. The kubelet is recursively changing the group of every file on each mount. Add fsGroupChangePolicy: OnRootMismatch.

Rung 4 readOnlyRootFilesystem, with emptyDir where the app writes

readOnlyRootFilesystem: true is D5's --read-only, with the same failure when the app needs scratch space:

without the /tmp emptyDir:   Error, RESTARTS 2
FileNotFoundError: [Errno 2] No usable temporary directory found in ['/tmp', '/var/tmp', '/usr/tmp', '/app']
with it:                     touch: cannot touch '/app/x': Read-only file system ; /tmp writable

Give every path the app writes an emptyDir (or a PVC for data). medium: Memory makes it a tmpfs. It counts against the container's memory limit, so give it a sizeLimit. A disk-backed emptyDir counts against ephemeral storage, and exceeding its sizeLimit evicts the Pod (K7).

      volumeMounts:
        - {name: tmp, mountPath: /tmp}
  volumes:
    - name: tmp
      emptyDir: {medium: Memory, sizeLimit: 16Mi}

Rule Run every container with readOnlyRootFilesystem: true, and mount a sized emptyDir on each path it writes.

Why A read-only root makes every unexpected write fail, and emptyDir volumes make the expected ones explicit.

Trap The Pod crash-loops right after readOnlyRootFilesystem was enabled, with "Read-only file system" or "No usable temporary directory". Run the image with docker run --read-only (D5) to list the paths it writes, and mount emptyDirs there.

Rung 5 No privilege escalation, no capabilities

allowPrivilegeEscalation: false sets the no_new_privs bit, so setuid binaries cannot raise privileges (D5, rung 5). capabilities.drop: ["ALL"] removes root's powers. restricted allows adding back exactly one capability, NET_BIND_SERVICE:

add NET_ADMIN:         violates PodSecurity "restricted:v1.37": unrestricted capabilities (container "web" must not include "NET_ADMIN" in securityContext.capabilities.add)
add NET_BIND_SERVICE:  pod/pantry-bind created
golden Pod:            CapEff: 0000000000000000   NoNewPrivs: 1
      securityContext:
        allowPrivilegeEscalation: false
        capabilities:
          drop: ["ALL"]

privileged: true gives a container every capability and every host device, so it is effectively root on the node. It is banned by baseline, and should exist only in reviewed system agents (CNI plugins, storage drivers), each in its own privileged namespace.

Rule Set allowPrivilegeEscalation: false and capabilities.drop: ["ALL"] on every container, add back only NET_BIND_SERVICE when needed, and never privileged: true in application namespaces.

Why These close the two remaining routes from a non-root process to root's powers: setuid escalation and inherited capabilities.

Trap An image that worked under Docker now fails with Operation not permitted (for example ping, or a chown in an entrypoint script). It relied on a capability from Docker's default set. Find which one, then remove the need from the image rather than adding the capability back.

Rung 6 seccomp and AppArmor

seccomp filters system calls. RuntimeDefault is the container runtime's profile, which blocks a few dozen dangerous syscalls (such as mount, kexec_load, unshare into new namespaces) and allows everything ordinary programs use. restricted requires RuntimeDefault or a Localhost profile:

Seccomp: 2          ← /proc/1/status in the golden Pod: filter mode active
  securityContext:
    seccompProfile:
      type: RuntimeDefault

The default container is Unconfined unless the kubelet is configured otherwise, so set it at Pod level. AppArmor (on Ubuntu/Debian nodes) is configured with securityContext.appArmorProfile: {type: RuntimeDefault} and works the same way. It was not tested on this kind node. A Localhost seccomp profile lets you go further, with only the syscalls your app uses, generated by tracing it in a test environment.

Rule Set seccompProfile: {type: RuntimeDefault} at Pod level on every workload.

Why It costs nothing for normal programs and blocks the syscalls most container escapes use, and restricted refuses Pods without it.

Trap A Pod that passes baseline is rejected after a namespace is moved to restricted, only for seccompProfile. Earlier hardening forgot it because Docker applies its default profile without being asked.

Rung 7 User namespaces: UID 0 inside is nobody outside

hostUsers: false gives the Pod its own user namespace. UIDs inside map to an unprivileged range on the node, so even a process that is root in the container is an ordinary user on the host. Measured on this cluster (kernel 6.12, containerd 2.3, runc 1.4):

inside:   uid=10001 gid=10001     /proc/self/uid_map:  0 1089929216 65536
on node:  1089939217 sleep 3600                         ← 1089929216 + 10001
without:  10001 /venv/bin/python /venv/bin/gunicorn …   ← the same number on the node
spec:
  hostUsers: false

User namespaces for Pods became generally available in Kubernetes 1.36 (2026), after being enabled by default since 1.33. They need Linux 6.3+ and a recent containerd/CRI-O and runc. They combine with everything above: defence in depth, not a replacement for runAsNonRoot.

Rule Add hostUsers: false where the nodes support it, on top of the rest of this chapter.

Why A container escape then lands as an unprivileged UID on the node, which turns most breakouts from "root on the node" into "nobody on the node".

Trap Pods with hostUsers: false fail to start on some nodes, with an error about idmap mounts or user namespaces. Those nodes run an older kernel or runtime. Check uname -r and the runtime versions before rolling it out cluster-wide.

Rung 8 Turning restricted on for an existing namespace

Labelling a namespace does not evict running Pods. It refuses new ones, so the next rollout or node drain is what breaks. Preview the effect first with a server-side dry run of the label:

kubectl label --dry-run=server --overwrite ns k03-legacy pod-security.kubernetes.io/enforce=restricted
Warning: existing pods in namespace "k03-legacy" violate the new PodSecurity enforce level "restricted:latest"
Warning: old: allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile

The rollout is three steps:

  1. Add warn and audit at restricted, and keep enforce at baseline.
  2. Fix workloads until the warnings stop.
  3. Then switch enforce.

A cluster-wide default for unlabelled namespaces can be set in the API server's PodSecurity admission configuration.

Rule Introduce restricted with warn/audit first, check existing Pods with a dry-run label, and only then set enforce.

Why Enforcement applies at Pod creation, so violations in running Pods surface later, during a rollout, a node drain or an autoscale.

Trap Weeks after a namespace was labelled, a node drain leaves an app with no Pods. Its replacements were rejected (FailedCreate), while the old Pods had kept running until evicted.

Decide

Workload Namespace label Notes
Your applications restricted, pinned version the golden Pod
Third-party apps that need a little more baseline + warn: restricted track what blocks restricted
CNI, CSI, node agents privileged, in their own namespace reviewed, minimal RBAC
Anything needing hostPath, hostNetwork not in an app namespace redesign first

The golden file

Pantry as a single Pod that restricted:v1.37 admits: Pod-level identity, group ownership and seccomp, and container-level escalation, capabilities and a read-only root with a memory-backed /tmp. K5 turns this Pod spec into the golden Deployment.

examples/manual/k03/pod.yaml
# Pantry as a single Pod that the "restricted" Pod Security Standard admits.
#   kubectl apply -f namespace.yaml -f pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: pantry
  namespace: k03
  labels: {app.kubernetes.io/name: pantry}
spec:
  automountServiceAccountToken: false      # this Pod never calls the API (K9)
  securityContext:                         # Pod level: applies to every container and to volumes
    runAsNonRoot: true                     # the kubelet refuses to start a container that would be UID 0
    runAsUser: 10001                       # same numbers as the image's USER 10001:10001 (D5)
    runAsGroup: 10001
    fsGroup: 10001                         # volumes are group-owned by 10001 (and setgid), so the app can write them
    seccompProfile:
      type: RuntimeDefault                 # the container runtime's default syscall filter
  containers:
    - name: web
      image: pantry-manual:d05
      imagePullPolicy: IfNotPresent
      ports: [{name: http, containerPort: 8080}]
      env:
        - {name: REDIS_HOST, value: redis}
      securityContext:                     # container level: what the restricted standard checks per container
        allowPrivilegeEscalation: false    # no setuid gains (D5's no-new-privileges)
        readOnlyRootFilesystem: true       # D5's --read-only
        capabilities:
          drop: ["ALL"]                    # D5's --cap-drop ALL
      volumeMounts:
        - {name: tmp, mountPath: /tmp}     # gunicorn needs a writable temp dir
  volumes:
    - name: tmp
      emptyDir:
        medium: Memory                     # D5's --tmpfs, counted against the memory limit
        sizeLimit: 16Mi
kubectl apply -f namespace.yaml -f pod.yaml
kubectl -n k03 exec pantry -- sh -c 'id; grep -E "^(CapEff|NoNewPrivs|Seccomp):" /proc/1/status'

Review checklist

Spot the bug

It is applied to the restricted namespace. "The security team said to add a securityContext, so we did."

examples/manual/k03/bug/pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: pantry-bug
  namespace: k03
spec:
  securityContext:
    runAsNonRoot: true
  containers:
    - name: web
      image: pantry-manual:d05
      securityContext:
        allowPrivilegeEscalation: true
        readOnlyRootFilesystem: true
        capabilities:
          add: ["NET_RAW"]

This file has 5 problems.

Show answers
  1. allowPrivilegeEscalation: true is rejected: allowPrivilegeEscalation != false (container "web" must set securityContext.allowPrivilegeEscalation=false).
  2. No capabilities.drop: ["ALL"]: must set securityContext.capabilities.drop=["ALL"].
  3. add: ["NET_RAW"] is not allowed: must not include "NET_RAW" in securityContext.capabilities.add. Only NET_BIND_SERVICE may be added under restricted.
  4. No seccompProfile: must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost". Add it at Pod level.
  5. With those fixed, the Pod is admitted and then crash-loops: readOnlyRootFilesystem: true without a writable /tmp gave No usable temporary directory found. Mount a sized emptyDir at /tmp.

Kubernetes manual · 4 · 9 min readLifecycle and Rollouts#

In the story: the doorbell test, and moving tenants without anyone noticing · Are You Alive?, Someone Who Never Sleeps

A Deployment promises a number of running Pods. Running is not the same as able to serve, though, and replacing a Pod is not the same as replacing it without dropping requests. This chapter is about the gap between those. Three probes tell Kubernetes what "able to serve" means. A preStop hook and a grace period make shutdown clean. A rollout strategy and a PodDisruptionBudget bound how much capacity may be missing at any moment.

Every claim below was measured with a load generator sending a request through the Service every 50 ms during rollouts, drains and outages.

containerkubeletkube-proxy (every node)EndpointSlice controllerAPI servercontainerkubeletkube-proxy (every node)EndpointSlice controllerAPI serverrules updated on every node, a moment laterif still running when the grace period ends: SIGKILL (137)Pod marked terminatingPod marked terminatingendpoint no longer readypreStop (sleep 5)SIGTERM after preStopdrains in-flight requests, exits 0

The race in the middle is why preStop exists. Removal from endpoints and SIGTERM begin at the same moment, but it takes time for every node's kube-proxy (and every ingress or gateway) to stop sending traffic to the Pod. If the app stops accepting connections first, those requests fail.

The ladder

Rung 1 Three probes, three questions

Probe Question On failure
startupProbe Has it finished starting? keeps checking until failureThreshold, then restarts. Liveness and readiness wait for it
readinessProbe Can it serve traffic now? removed from Service endpoints, not restarted
livenessProbe Is the process stuck beyond repair? container restarted
          readinessProbe:                   # in the Service's endpoints only while this passes
            httpGet: {path: /healthz, port: http}   # /healthz checks Redis: no traffic if the Pod cannot serve it
            periodSeconds: 3
            failureThreshold: 2
          livenessProbe:                    # restart only if the process itself is stuck
            httpGet: {path: /livez, port: http}     # never a dependency: Redis being down must not restart Pantry
            periodSeconds: 10
            failureThreshold: 3

Pantry has two endpoints for this reason. /livez answers if the process is alive, and /healthz answers if it can reach Redis. Readiness uses the one that includes dependencies, and liveness the one that does not. Probes can be httpGet, tcpSocket, grpc or exec. Prefer HTTP or gRPC: exec forks a process every period.

Rule Give every serving container a readiness probe that includes what it needs to serve, and a liveness probe that checks only the process itself.

Why Readiness failures only take a Pod out of rotation, which is safe and reversible. Liveness failures restart it, which is right for a deadlock and wrong for a dependency outage.

Trap Without a readiness probe, a Pod is added to endpoints the moment its container starts, before the app listens, and every new Pod costs a burst of failed requests. kubectl get endpointslices lists it as ready while its log is still printing startup lines.

Rung 2 Never point liveness at a dependency

Redis was scaled to zero for 75 seconds, with two variants running side by side:

liveness on /healthz (checks Redis):   READY false   RESTARTS 2   (all 3 Pods)
golden (liveness on /livez):           READY false   RESTARTS 0   (all 3 Pods)

Both correctly stopped serving, since readiness failed. The first variant also restarted every Pod, twice. In a real outage that turns "the database is slow" into "every app Pod is restarting, re-warming caches and reconnecting at the same moment the database recovers". That kind of self-inflicted thundering herd prolongs outages. Liveness should detect only what a restart fixes: a deadlock, a wedged event loop, exhausted internal state.

          livenessProbe:
            httpGet: {path: /livez, port: http}

Rule Make liveness checks local (process responsive, no dependencies), and leave dependency health to readiness.

Why Restarting a container cannot fix a remote dependency, but it adds load and startup time at the worst moment.

Trap During a database incident, application RESTARTS counts climb on every Pod at once, and kubectl describe pod shows Liveness probe failed: …/healthz. Move the dependency check to readiness.

Rung 3 startupProbe for slow starters

A liveness probe that runs before the app has started kills it while it is still starting. With START_DELAY=25 (Pantry sleeps before binding) and liveness every 5 s × 3:

no startupProbe:     RESTARTS 3, never Ready after 90 s
with startupProbe:   RESTARTS 0, Ready
          startupProbe:                     # until this passes, liveness and readiness are not run
            httpGet: {path: /livez, port: http}
            periodSeconds: 2
            failureThreshold: 30            # up to 60 s to start

The startup probe gives a generous budget (periodSeconds × failureThreshold) for the first start only. Once it passes, liveness takes over with its tighter settings. This replaces the old habit of a long initialDelaySeconds, which delays the first check on every restart, even a fast one.

Rule Add a startupProbe (same endpoint as liveness) with a budget covering the slowest realistic start, and keep initialDelaySeconds off liveness and readiness.

Why The startup probe holds the other probes back until the app is up, so liveness can be strict without killing slow starts.

Trap A Pod is stuck in a restart loop after a change that made startup slower (a bigger cache warm-up, a slower node, a JVM with less CPU), with Liveness probe failed: connection refused in its events. It never got the time to start. Add or widen the startup probe.

Rung 4 Graceful shutdown: preStop, SIGTERM and the grace period

When a Pod is deleted, it is removed from endpoints, and the kubelet runs preStop and then sends SIGTERM. Both start at once, so the app must keep serving for a few seconds while every proxy catches up. Measured with kubectl rollout restart under load:

naive (no probes, no preStop):           12 failed requests
probes, no preStop:                      0 and 1 failed requests (two runs)
golden (probes + preStop sleep 5):       0 and 0
      terminationGracePeriodSeconds: 30     # preStop + the app's own drain must fit inside this
          lifecycle:
            preStop:                        # runs before SIGTERM; meanwhile the Pod leaves the endpoints
              sleep: {seconds: 5}

kind updates endpoints almost instantly. On larger clusters, and behind ingress controllers or cloud load balancers, the window is longer, and a missing preStop costs more. The sleep action needs no sleep binary in the image, so it works with distroless. The grace period covers preStop and the app's own drain, after which SIGKILL ends everything (exit 137, D2). The API rejects a preStop sleep that does not fit (must be non-negative and less than terminationGracePeriodSeconds), but an exec of sleep 10 with a 5 s grace period is accepted, and fails with FailedPreStopHook events.

Rule Add a preStop sleep of a few seconds to every serving container, and set terminationGracePeriodSeconds above preStop plus the app's drain timeout (gunicorn --graceful-timeout 8 here).

Why The sleep keeps the old Pod serving while the endpoint removal propagates. Only then does SIGTERM start the app's own graceful shutdown.

Trap Every deploy produces a short burst of 502s or "connection refused" at the edge, though readiness is perfect. Traffic reached Pods that had already stopped accepting connections. Add the preStop sleep.

Rung 5 Rollout strategy and the progress deadline

RollingUpdate replaces Pods in steps bounded by maxSurge (extra Pods allowed) and maxUnavailable (missing Pods allowed). maxSurge: 1, maxUnavailable: 0 never drops below full capacity. minReadySeconds requires a new Pod to stay Ready for a while before it counts, which catches crash-after-start. progressDeadlineSeconds marks a stuck rollout as failed:

$ kubectl -n k04 set image deploy/pantry web=pantry-manual:does-not-exist
error: deployment "pantry" exceeded its progress deadline
Progressing=False ProgressDeadlineExceeded
1 × 0/1 ErrImagePull, 3 × 1/1 Running        failed requests while stuck: 0 (of 600)
$ kubectl -n k04 rollout undo deploy/pantry
deployment "pantry" successfully rolled out

With maxUnavailable: 0, a broken image never took capacity away. The rollout stalled with the old Pods serving. The opposite setting replaces everything at once. With Pantry's two-second start, kube-proxy hid the gap by sending traffic to terminating Pods that were still serving. With a 15-second start, maxSurge: 0, maxUnavailable: 3 lost 9 requests over 17 seconds, and the golden strategy lost none. Recreate stops all old Pods before starting new ones. It is for apps that cannot run two versions at once (a single-writer volume, an incompatible schema), and it always means downtime.

kubectl -n k04 rollout status deploy/pantry       # waits, fails on ProgressDeadlineExceeded
kubectl -n k04 rollout history deploy/pantry      # revisions kept (revisionHistoryLimit)
kubectl -n k04 rollout undo deploy/pantry         # back to the previous ReplicaSet

Rule Use RollingUpdate with maxSurge: 1 (or 25%) and maxUnavailable: 0, set minReadySeconds and progressDeadlineSeconds, and gate deploy pipelines on kubectl rollout status.

Why These bound capacity during a rollout, and turn "the new version never became ready" into a visible failed rollout instead of a slow outage.

Trap A pipeline reports success while the new version crash-loops. It ran kubectl apply without rollout status, which is the step that waits and fails.

Rung 6 What triggers a rollout, and what does not

Only a change to spec.template creates a new ReplicaSet and rolls Pods: image, env, probes, annotations on the template. Changing replicas scales in place. A changed ConfigMap or Secret that the Pod consumes as environment variables does not restart anything (K6). kubectl rollout restart works by stamping a kubectl.kubernetes.io/restartedAt annotation on the template, which is why it rolls.

kubectl -n k04 rollout restart deploy/pantry
kubectl -n k04 get deploy pantry -o jsonpath='{.spec.template.metadata.annotations}'
kubectl -n k04 rollout pause deploy/pantry       # batch several template changes into one rollout
kubectl -n k04 rollout resume deploy/pantry

rollout pause lets you apply several template changes, then roll once on resume. The golden pipeline changes the image and annotations together in one apply instead.

Rule Change workloads only through the Pod template (declaratively, in one apply), and use rollout restart when a dependency changed outside the template.

Why The Deployment controller compares templates. Anything outside the template is invisible to it.

Trap A ConfigMap was updated, "deployed", and nothing changed. The template did not change, so no rollout happened. Use a config-hash annotation (K6) or rollout restart.

Rung 7 PodDisruptionBudgets protect against voluntary disruptions

Node drains (upgrades, autoscaler scale-downs, maintenance) evict Pods through the Eviction API, which respects PodDisruptionBudgets. A rolling update is not an eviction and is governed by the strategy instead. The worst case, measured with all three Pods packed on the drained node, a 15-second start and no preStop:

no PDB:                drain 4 s;  31 failed requests (00:58:27 → 00:58:45)
PDB minAvailable: 2:   drain 42 s; "Cannot evict pod as it would violate the pod's disruption budget" (retried); 2 failed

Without the PDB, the drain evicted all three at once and the app was gone for 18 seconds. With it, the drain waited for replacements to become Ready elsewhere before evicting more. When the Pods were already spread over two nodes (K5), even the no-PDB drain failed nothing, because one Pod elsewhere kept serving. Spreading and PDBs work together.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: {name: pantry, namespace: k04}
spec:
  minAvailable: 2
  selector: {matchLabels: {app.kubernetes.io/name: pantry}}

A PDB that allows no disruption blocks drains forever: minAvailable: 3 with 3 replicas gave ALLOWED DISRUPTIONS 0, sixteen eviction retries and a timed-out drain. Cluster upgrades then stall, or someone --forces through.

Rule Give every replicated workload a PDB that allows at least one disruption (minAvailable: replicas - 1, or maxUnavailable: 1), and spread its Pods across nodes.

Why The Eviction API refuses evictions that would breach the budget, so drains proceed only as fast as replacements become Ready.

Trap kubectl drain hangs, printing Cannot evict pod as it would violate the pod's disruption budget forever. kubectl get pdb shows ALLOWED DISRUPTIONS 0: the budget equals the replica count, or the Pods are not Ready.

Decide

Situation Setting
Normal web service readiness (with deps) + liveness (local) + startup, preStop sleep 5, maxSurge 1 / maxUnavailable 0, PDB maxUnavailable: 1
Slow or variable startup startupProbe with a large failureThreshold
Long requests or WebSockets longer preStop and app drain, terminationGracePeriodSeconds to match
Can't run two versions at once strategy: Recreate (plan the downtime)
Single replica PDB is pointless, so run 2 if it must survive drains
Batch / queue workers no readiness needed, liveness on progress, a grace period long enough to finish one item

The golden file

Pantry, rolled out and shut down without dropping a request: three probes with distinct jobs, a preStop sleep inside a sufficient grace period, a strategy that never removes capacity, deadlines that make failures visible, and a PodDisruptionBudget. K5 adds resources and placement to this same Deployment.

examples/manual/k04/deployment.yaml
# Pantry, rolled out and shut down without dropping a request.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pantry
  namespace: k04
  labels: {app.kubernetes.io/name: pantry}
spec:
  replicas: 3
  revisionHistoryLimit: 5                   # how many old ReplicaSets "rollout undo" can go back to
  minReadySeconds: 5                        # a new Pod must stay Ready this long before it counts as available
  progressDeadlineSeconds: 120              # mark the rollout failed (ProgressDeadlineExceeded) after this long
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1                           # at most one extra Pod during the rollout
      maxUnavailable: 0                     # never fewer Ready Pods than replicas
  selector: {matchLabels: {app.kubernetes.io/name: pantry}}
  template:
    metadata: {labels: {app.kubernetes.io/name: pantry}}
    spec:
      terminationGracePeriodSeconds: 30     # preStop + the app's own drain must fit inside this
      securityContext: {runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, fsGroup: 10001, seccompProfile: {type: RuntimeDefault}}
      containers:
        - name: web
          image: pantry-manual:d05
          imagePullPolicy: IfNotPresent
          ports: [{name: http, containerPort: 8080}]
          env: [{name: REDIS_HOST, value: redis}]
          startupProbe:                     # until this passes, liveness and readiness are not run
            httpGet: {path: /livez, port: http}
            periodSeconds: 2
            failureThreshold: 30            # up to 60 s to start
          readinessProbe:                   # in the Service's endpoints only while this passes
            httpGet: {path: /healthz, port: http}   # /healthz checks Redis: no traffic if the Pod cannot serve it
            periodSeconds: 3
            failureThreshold: 2
          livenessProbe:                    # restart only if the process itself is stuck
            httpGet: {path: /livez, port: http}     # never a dependency: Redis being down must not restart Pantry
            periodSeconds: 10
            failureThreshold: 3
          lifecycle:
            preStop:                        # runs before SIGTERM; meanwhile the Pod leaves the endpoints
              sleep: {seconds: 5}
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
          volumeMounts: [{name: tmp, mountPath: /tmp}]
      volumes: [{name: tmp, emptyDir: {medium: Memory, sizeLimit: 16Mi}}]
---
apiVersion: v1
kind: Service
metadata: {name: pantry, namespace: k04}
spec:
  selector: {app.kubernetes.io/name: pantry}
  ports: [{name: http, port: 80, targetPort: http}]
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: {name: pantry, namespace: k04}
spec:
  minAvailable: 2                           # drains and other voluntary disruptions keep at least 2 Pods
  selector: {matchLabels: {app.kubernetes.io/name: pantry}}
kubectl apply -f base.yaml -f deployment.yaml && kubectl -n k04 rollout status deploy/pantry
kubectl -n k04 rollout restart deploy/pantry && kubectl -n k04 rollout status deploy/pantry
kubectl -n k04 logs loader | grep -c FAIL          # 0

Review checklist

Spot the bug

"Probes, preStop and a PDB, as the checklist says."

examples/manual/k04/bug/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pantry
  namespace: k04
  labels: {app.kubernetes.io/name: pantry}
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 0
      maxUnavailable: 3
  selector: {matchLabels: {app.kubernetes.io/name: pantry}}
  template:
    metadata: {labels: {app.kubernetes.io/name: pantry}}
    spec:
      terminationGracePeriodSeconds: 5
      securityContext: {runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, fsGroup: 10001, seccompProfile: {type: RuntimeDefault}}
      containers:
        - name: web
          image: pantry-manual:d05
          imagePullPolicy: IfNotPresent
          ports: [{name: http, containerPort: 8080}]
          env: [{name: REDIS_HOST, value: redis}]
          livenessProbe:
            httpGet: {path: /healthz, port: http}
            periodSeconds: 10
            failureThreshold: 3
          lifecycle:
            preStop:
              exec: {command: ["sleep", "10"]}
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
          volumeMounts: [{name: tmp, mountPath: /tmp}]
      volumes: [{name: tmp, emptyDir: {medium: Memory, sizeLimit: 16Mi}}]
---
apiVersion: v1
kind: Service
metadata: {name: pantry, namespace: k04}
spec:
  selector: {app.kubernetes.io/name: pantry}
  ports: [{name: http, port: 80, targetPort: http}]
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: {name: pantry, namespace: k04}
spec:
  minAvailable: 3
  selector: {matchLabels: {app.kubernetes.io/name: pantry}}

This file has 5 problems.

Show answers
  1. There is no readiness probe (and no startup probe), so new Pods receive traffic as soon as their container starts, before the app listens, and Pods that lose Redis keep receiving traffic. Add readiness on /healthz and a startup probe.
  2. The liveness probe checks /healthz, which includes Redis. During a Redis outage, every Pod restarted twice in 75 seconds. Point liveness at /livez.
  3. maxSurge: 0, maxUnavailable: 3 replaces all three Pods at once. With a 15-second start it failed 9 requests in one rollout (the golden strategy failed none). Use maxSurge: 1, maxUnavailable: 0.
  4. The preStop runs sleep 10 inside a 5-second grace period, so the kubelet kills the container mid-hook (FailedPreStopHook) and the app never drains. Make the grace period longer than preStop plus the drain (the sleep action form is validated and rejects this outright).
  5. minAvailable: 3 with 3 replicas allows zero disruptions. kubectl drain retried evictions until it timed out, which blocks node maintenance. Use maxUnavailable: 1.

Kubernetes manual · 5 · 9 min readResources and Placement#

In the story: the lease (what you are promised and the ceiling you cannot exceed), and which building you may live in · Are You Alive?, The Landlord's Week

Two questions decide where a Pod runs and how it behaves under pressure: how much it asks for, and where it may go. Requests are a reservation the scheduler honours. Limits are a ceiling the kernel enforces, with very different consequences for memory and for CPU. Affinity, taints, spread constraints and priorities shape placement. Get them right and a node failure, a drain or a noisy neighbour is a non-event. Get them wrong and Pods are OOM-killed, throttled, stacked on one node, or Pending forever.

taint not tolerated

fits requests,
affinity ok

spread: zone-a has 2,
zone-b has 1

new Pod
requests cpu 100m, mem 128Mi

filter nodes

✗ control plane

score nodes

✓ worker2 (zone-b)

worker (zone-a)

kubelet: cgroup limits
memory 256Mi (OOM-kill), CPU unlimited

The ladder

Rung 1 Requests are what the scheduler reserves

The scheduler places a Pod only on a node whose unreserved capacity covers its requests. It does not look at actual usage:

requests cpu: "5"  →  Pending
0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 Insufficient cpu.
          resources:
            requests:                       # what the scheduler reserves, and what the HPA's % is based on
              cpu: 100m
              memory: 128Mi

Requests are also what the Pod is guaranteed under contention, and what an HPA's percentage is relative to (the golden file autoscales on it). Set them from measurement: kubectl top pod under realistic load, plus headroom. Too low and nodes are overpacked and contended. Too high and the cluster looks full while it idles. On kind every node reports the whole Docker VM (4 CPUs, about 8 GB here) though all three share it, so scheduling looks roomier than reality.

kubectl top pod -n k05 --containers
kubectl describe node manual-worker | grep -A6 "Allocated resources"

Rule Give every container CPU and memory requests based on measured usage, not on guesses or defaults.

Why The scheduler packs nodes by requests alone, so requests decide both whether a Pod fits and how crowded its node is.

Trap A Pod is Pending with Insufficient cpu on a cluster whose nodes are mostly idle. Requests, not usage, filled the nodes. kubectl describe node shows "Allocated resources" near 100% while kubectl top node is low.

Rung 2 A memory limit is a hard wall

When a container exceeds its memory limit, the kernel kills it. There is no slowdown first:

PANTRY_HOG_MB=200, limits.memory 128Mi  →  restarts=2  last=OOMKilled  exit=137
            limits:
              memory: 256Mi                 # a memory limit: past it the kernel kills the container (OOMKilled)

Always set a memory limit. Without one, a leak takes memory from every other Pod on the node until the node itself starts evicting. Set the limit above the real peak (startup, cache warm-up, the largest request), and size runtimes from it: JVM -XX:MaxRAMPercentage, Node --max-old-space-size, or the downward API (K2). Memory-backed emptyDirs and tmpfs count against it (D8). A container with only a limit gets a request equal to the limit ({"limits":{"memory":"32Mi"},"requests":{"memory":"32Mi"}}), which is fine for memory.

Rule Set a memory limit on every container, comfortably above its measured peak, and make the runtime aware of it.

Why Memory cannot be throttled, only reclaimed by killing. The limit decides whether it is this container or a random neighbour that dies.

Trap kubectl get pods shows climbing RESTARTS, with OOMKilled / exit 137 in kubectl describe pod under Last State. The limit is below the real peak, or the runtime is sized from the node's memory rather than the limit.

Rung 3 A CPU limit throttles, even when the node is idle

A CPU limit is enforced by the CFS quota: in every 100 ms period, the container may use its quota and then waits. A busy loop limited to 200m:

cpu.max          20000 100000
nr_periods       401
nr_throttled     396           ← throttled in 99% of periods
throttled_usec   17321250      ← 17 s spent waiting in ~40 s
kubectl top:     200m

The node had idle cores the whole time. For latency-sensitive services, that throttling shows up as tail latency, because a request that needs 30 ms of CPU in a period that has 20 ms left waits for the next period. Two common positions exist:

  • No CPU limit, honest requests. The container borrows idle CPU and is only slowed by real contention, in proportion to its request. The golden file does this.
  • CPU limit = request (Guaranteed QoS). Predictable and isolated, and required on some multi-tenant platforms. Size it generously and watch nr_throttled.
            limits:
              memory: 256Mi
              # no CPU limit: CPU above the request is borrowed, never throttled (rung 3)

Rule Set CPU requests on everything, and set CPU limits only where isolation matters more than latency, then monitor throttling.

Why A CPU limit caps usage per scheduling period regardless of free capacity, while requests already guarantee each container its share under contention.

Trap p99 latency is bad, CPU usage graphs sit exactly at the limit, and the node is mostly idle. Check /sys/fs/cgroup/cpu.stat in the container: a high nr_throttled against nr_periods is the throttling.

Rung 4 QoS classes decide who is evicted first

Every Pod gets a QoS class from its resources:

QoS class Condition Measured on
Guaranteed every container has CPU and memory requests equal to limits requests = limits
Burstable at least one request or limit, not Guaranteed requests only, the golden Pantry
BestEffort no requests or limits at all nothing set
kubectl get pods -n k05 -o custom-columns=NAME:.metadata.name,QOS:.status.qosClass

When a node runs short of memory, the kubelet evicts BestEffort Pods first, then Burstable Pods using the most above their requests, and Guaranteed last. The kernel's OOM killer uses the same ranking. A Pod with no requests at all is the first thing to go, and it was also scheduled as if it needed nothing.

Rule Never run production workloads as BestEffort, and make the critical ones Guaranteed for memory (memory request = limit) when the node's memory is tight.

Why QoS class is the eviction order under node pressure, and it follows mechanically from requests and limits.

Trap Under memory pressure, the same Pods are evicted again and again (The node was low on resource: memory in kubectl describe pod). They are BestEffort or far above their requests.

Rung 5 LimitRange and ResourceQuota: the namespace's guard-rails

A LimitRange fills in defaults for containers that state nothing, and bounds what any one may ask for. A ResourceQuota caps the namespace's total:

examples/manual/k05/limits.yaml
# Namespace guard-rails: defaults for containers that state nothing, and a ceiling for the namespace.
apiVersion: v1
kind: LimitRange
metadata: {name: defaults, namespace: k05}
spec:
  limits:
    - type: Container
      defaultRequest: {cpu: 50m, memory: 64Mi}   # applied when a container sets no request
      default: {memory: 256Mi}                   # applied when a container sets no limit
      max: {memory: 1Gi}                         # no single container above this
---
apiVersion: v1
kind: ResourceQuota
metadata: {name: ceiling, namespace: k05}
spec:
  hard:
    requests.cpu: "2"
    requests.memory: 2Gi
    limits.memory: 4Gi
    pods: "20"
defaulted:     {"limits":{"memory":"256Mi"},"requests":{"cpu":"50m","memory":"64Mi"}}
limit 2Gi:     pods "huge" is forbidden: maximum memory usage per Container is 1Gi, but limit is 2Gi
quota, no LimitRange, no resources:
               pods "noreq" is forbidden: failed quota: ceiling: must specify limits.memory for: c; requests.cpu for: c; requests.memory for: c

A quota on requests.cpu makes every Pod in the namespace state a CPU request. Without a LimitRange to supply defaults, Pods that do not are rejected. Rejections happen when the Pod is created, so for Deployments they show up as FailedCreate events on the ReplicaSet, not as errors from kubectl apply (K3, rung 1).

Rule Give each team namespace a ResourceQuota and a LimitRange with sensible defaults, and still set explicit resources in every manifest.

Why Quotas cap a namespace's share of the cluster. Defaults keep unannotated Pods from being rejected or running as BestEffort.

Trap A Deployment scales to 0 ready Pods after a quota was added, and kubectl get events shows exceeded quota or must specify requests.cpu. The Pods do not fit the quota or do not state requests.

Rung 6 Choosing nodes: affinity, taints and tolerations

nodeSelector and nodeAffinity pull Pods towards nodes with given labels. Taints push Pods away from nodes, unless they tolerate the taint. A toleration only permits, it never attracts. With manual-worker2 tainted dedicated=batch:NoSchedule:

no toleration (4 Pods):                   4 manual-worker
toleration only (6 Pods):                 3 manual-worker; 3 manual-worker2     ← still lands on both
toleration + nodeAffinity zone-b (3):     3 manual-worker2
      tolerations:
        - {key: dedicated, operator: Equal, value: batch, effect: NoSchedule}
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions: [{key: topology.kubernetes.io/zone, operator: In, values: [zone-b]}]

Dedicating nodes takes both halves: a taint so nothing else lands there, and affinity plus a toleration so the intended Pods do. preferredDuringScheduling… variants are soft and only weigh the score. NoExecute taints also evict running Pods that do not tolerate them, and Kubernetes itself uses them for unreachable or not-ready nodes.

Rule To dedicate nodes, taint them and give the intended Pods both a toleration and node affinity, and use soft (preferred) rules unless the requirement is absolute.

Why Taints keep others off and affinity pulls the right Pods on. Either one alone leaks in one direction.

Trap "Dedicated" GPU or batch nodes fill with ordinary web Pods, or the batch Pods run everywhere. Only half of the pattern was applied: a toleration without affinity, or affinity without a taint.

Rung 7 Spreading replicas: anti-affinity and topology spread

Replicas on one node or in one zone fail together. Two tools spread them. Required pod anti-affinity is absolute:

3 replicas, required anti-affinity on hostname, 2 schedulable nodes:
1 Pending; 1 manual-worker; 1 manual-worker2
0/3 nodes are available: … 2 node(s) didn't match pod anti-affinity rules.

Topology spread constraints say "at most maxSkew apart" per topology domain, and can be soft:

      topologySpreadConstraints:
        - maxSkew: 1                        # zones: never more than one Pod apart
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels: {app.kubernetes.io/name: pantry, app.kubernetes.io/component: web}
          matchLabelKeys: [pod-template-hash]   # count only this revision's Pods during a rollout
        - maxSkew: 1                        # nodes: prefer one apart, but do not block scheduling
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
zone spread, 4 replicas:   2 manual-worker (zone-a); 2 manual-worker2 (zone-b)
golden (HPA min 3):        manual-worker2, manual-worker, manual-worker

matchLabelKeys: [pod-template-hash] makes each rollout's Pods spread among themselves, instead of being skewed by the old revision's Pods that are about to go away. Combined with a PDB (K4), spreading is what made drains failure-free.

Rule Spread replicas with topologySpreadConstraints: hard across zones, soft across nodes, with matchLabelKeys: [pod-template-hash]. Keep required anti-affinity for the rare "never two on one node" case.

Why Spread constraints bound imbalance without blocking scheduling, while required anti-affinity caps replicas at the number of nodes and leaves the rest Pending.

Trap After a scale-up, some replicas stay Pending with didn't match pod anti-affinity rules. Required anti-affinity allows only one Pod per node. Replace it with a soft spread constraint.

Rung 8 Priority and preemption

When the cluster is full, a PriorityClass decides who waits and who is evicted. A higher-priority Pod that does not fit causes the scheduler to preempt (evict) lower-priority Pods:

examples/manual/k05/priority.yaml
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata: {name: pantry-critical}
value: 100000
description: "Customer-facing Pantry services: may preempt batch work."
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata: {name: batch-low}
value: 1000
preemptionPolicy: Never          # low-priority work never evicts anyone else
description: "Batch work that can wait."
batch (4 × 1500m, batch-low) filled both workers:   4 Running
critical (pantry-critical) created:                 event: Preempted by pod … on node manual-worker2
after the victim's grace period:                    batch 1 Pending, 3 Running | critical Running

The evicted batch Pod's replacement stayed Pending, because preemptionPolicy: Never stops low-priority work from evicting anyone itself. Preemption respects graceful termination, so the critical Pod waited for the victim's grace period, and it tries to respect PDBs but will break them if it must. Kubernetes' own components use system-cluster-critical and system-node-critical. Keep application priorities below those.

Rule Give customer-facing workloads a higher PriorityClass than batch and best-effort work, and give the batch classes preemptionPolicy: Never.

Why Under pressure, the scheduler evicts lower-priority Pods to place higher ones, which decides who loses capacity when the cluster is full.

Trap A low-value job, given a high priority "so it starts quickly", evicts production Pods during a busy period. Look for Preempted by pod … events, which name the preemptor.

Decide

Workload Requests Limits Placement
Latency-sensitive web measured CPU + memory memory only zone spread (hard), node spread (soft), PDB, high priority
Strict multi-tenant CPU + memory equal to requests (Guaranteed) as above
Batch / queue workers measured memory; CPU optional tolerate batch nodes + affinity, low priority, preemptionPolicy: Never
Databases (StatefulSet) generous memory = request one per node (anti-affinity) per zone, PDB maxUnavailable: 1
Node agents (DaemonSet) small memory tolerate everything they must run on

The golden file

The golden Pantry Deployment: K3's security context, K4's probes and rollout, measured requests, a memory limit and no CPU limit, zone and node spreading, a priority class, an HPA that owns the replica count, and a PDB. K9 puts this Deployment into the full namespace bundle.

examples/manual/k05/deployment.yaml
# The golden Pantry Deployment: K3's security context, K4's probes and rollout, and this
# chapter's resources and placement. The HPA owns the replica count, so the Deployment omits it.
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata: {name: pantry-web}
value: 100000                               # above batch work: may preempt it when the cluster is full
description: "Customer-facing Pantry web Pods."
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pantry
  namespace: k05
  labels: {app.kubernetes.io/name: pantry, app.kubernetes.io/component: web}
spec:
  # no "replicas": the HorizontalPodAutoscaler below manages it (K1, rung 7)
  revisionHistoryLimit: 5
  minReadySeconds: 5
  progressDeadlineSeconds: 120
  strategy:
    type: RollingUpdate
    rollingUpdate: {maxSurge: 1, maxUnavailable: 0}
  selector:
    matchLabels: {app.kubernetes.io/name: pantry, app.kubernetes.io/component: web}
  template:
    metadata:
      labels: {app.kubernetes.io/name: pantry, app.kubernetes.io/component: web}
    spec:
      priorityClassName: pantry-web
      terminationGracePeriodSeconds: 30
      enableServiceLinks: false
      automountServiceAccountToken: false
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
        seccompProfile: {type: RuntimeDefault}
      topologySpreadConstraints:
        - maxSkew: 1                        # zones: never more than one Pod apart
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels: {app.kubernetes.io/name: pantry, app.kubernetes.io/component: web}
          matchLabelKeys: [pod-template-hash]   # count only this revision's Pods during a rollout
        - maxSkew: 1                        # nodes: prefer one apart, but do not block scheduling
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels: {app.kubernetes.io/name: pantry, app.kubernetes.io/component: web}
          matchLabelKeys: [pod-template-hash]
      containers:
        - name: web
          image: pantry-manual:d05
          imagePullPolicy: IfNotPresent
          ports: [{name: http, containerPort: 8080}]
          env: [{name: REDIS_HOST, value: redis}]
          resources:
            requests:                       # what the scheduler reserves, and what the HPA's % is based on
              cpu: 100m
              memory: 128Mi
            limits:
              memory: 256Mi                 # a memory limit: past it the kernel kills the container (OOMKilled)
              # no CPU limit: CPU above the request is borrowed, never throttled (rung 3)
          startupProbe:
            httpGet: {path: /livez, port: http}
            periodSeconds: 2
            failureThreshold: 30
          readinessProbe:
            httpGet: {path: /healthz, port: http}
            periodSeconds: 3
            failureThreshold: 2
          livenessProbe:
            httpGet: {path: /livez, port: http}
            periodSeconds: 10
            failureThreshold: 3
          lifecycle:
            preStop:
              sleep: {seconds: 5}
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: {drop: ["ALL"]}
          volumeMounts: [{name: tmp, mountPath: /tmp}]
      volumes:
        - name: tmp
          emptyDir: {medium: Memory, sizeLimit: 16Mi}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: {name: pantry, namespace: k05}
spec:
  scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: pantry}
  minReplicas: 3                            # one per zone plus one, so a drain never takes the last
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: {type: Utilization, averageUtilization: 70}   # % of the CPU *request*
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: {name: pantry, namespace: k05}
spec:
  maxUnavailable: 1
  selector:
    matchLabels: {app.kubernetes.io/name: pantry, app.kubernetes.io/component: web}
---
apiVersion: v1
kind: Service
metadata: {name: pantry, namespace: k05}
spec:
  selector: {app.kubernetes.io/name: pantry, app.kubernetes.io/component: web}
  ports: [{name: http, port: 80, targetPort: http}]
kubectl apply --server-side -f deployment.yaml && kubectl -n k05 rollout status deploy/pantry
kubectl -n k05 get pods -o wide -l app.kubernetes.io/name=pantry      # spread over both zones
kubectl -n k05 get hpa pantry                                         # cpu: 1%/70%, 3 replicas

Review checklist

Spot the bug

"Autoscaled, spread, and memory-capped."

examples/manual/k05/bug/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata: {name: pantry-bug, namespace: k05}
spec:
  replicas: 1
  selector: {matchLabels: {app: pantry-bug}}
  template:
    metadata: {labels: {app: pantry-bug}}
    spec:
      securityContext: {runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, fsGroup: 10001, seccompProfile: {type: RuntimeDefault}}
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector: {matchLabels: {app: pantry-bug}}
              topologyKey: kubernetes.io/hostname
      containers:
        - name: web
          image: pantry-manual:d05
          env: [{name: REDIS_HOST, value: redis}]
          resources:
            limits: {memory: 32Mi}
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
          volumeMounts: [{name: tmp, mountPath: /tmp}]
      volumes: [{name: tmp, emptyDir: {medium: Memory, sizeLimit: 16Mi}}]
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: {name: pantry-bug, namespace: k05}
spec:
  scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: pantry-bug}
  minReplicas: 3
  maxReplicas: 6
  metrics:
    - type: Resource
      resource: {name: cpu, target: {type: Utilization, averageUtilization: 70}}

This file has 4 problems.

Show answers
  1. The Deployment sets replicas: 1 while an HPA manages it. Every re-apply scaled it from 3 back to 1 (Scaled down replica set … from 3 to 1) until the HPA restored it. Remove replicas.
  2. There is no CPU request, so the HPA cannot compute utilisation: missing request for cpu in container web. Autoscaling never happens. Add measured CPU and memory requests.
  3. limits.memory: 32Mi is below Pantry's footprint, and the Pods were OOMKilled (3 restarts each within a minute). Measure, then set the limit above the peak.
  4. Required anti-affinity on kubernetes.io/hostname with minReplicas: 3 and two schedulable nodes left one replica Pending ("didn't match pod anti-affinity rules"). Use a topology spread constraint instead.

Kubernetes manual · 6 · 9 min readConfig and Secrets#

In the story: sticky notes on the fridge, and the locked drawer · Sticky Notes and the Locked Drawer

ConfigMaps and Secrets get configuration out of images and into the cluster, and both reach a Pod the same three ways: environment variables, mounted files, or single files via subPath. Those three behave very differently when the value changes. "Secret" means much less than the name suggests, too. This chapter measures what updates and when, what a Secret actually protects, and how to make a configuration change roll out the way a code change does.

env / envFrom
read once at start

volume (directory)

subPath

new name → new Pod template

ConfigMap / Secret
(in etcd)

process env
never updates

files via ..data symlink
updates in ~1 min

single file
never updates

kustomize generator
name-<hash>

rolling update

The ladder

Rung 1 Environment variables are read once

A container's environment is fixed when the container starts. Changing the ConfigMap behind valueFrom or envFrom changes nothing in a running Pod. One ConfigMap was consumed three ways, then changed from v1 to v2:

examples/manual/k06/reload.yaml
# One ConfigMap, consumed three ways, to see which of them notice a change.
apiVersion: v1
kind: Namespace
metadata:
  name: k06
  labels: {pod-security.kubernetes.io/enforce: restricted, pod-security.kubernetes.io/enforce-version: v1.37}
---
apiVersion: v1
kind: ConfigMap
metadata: {name: greeting, namespace: k06}
data:
  GREETING: "v1"
---
apiVersion: v1
kind: Pod
metadata: {name: reader, namespace: k06}
spec:
  securityContext: {runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, seccompProfile: {type: RuntimeDefault}}
  containers:
    - name: c
      image: busybox:1.37-musl
      command: ["sh", "-c", "while true; do echo \"$(date +%T) env=$GREETING dir=$(cat /etc/dir/GREETING) subPath=$(cat /etc/one/GREETING)\"; sleep 5; done"]
      env:
        - name: GREETING
          valueFrom: {configMapKeyRef: {name: greeting, key: GREETING}}
      volumeMounts:
        - {name: cfg, mountPath: /etc/dir}                              # the whole ConfigMap as a directory
        - {name: cfg, mountPath: /etc/one/GREETING, subPath: GREETING}  # one key as a single file
      securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
  volumes:
    - name: cfg
      configMap: {name: greeting}
changed at 01:22:19
01:23:33 env=v1 dir=v1 subPath=v1
01:23:38 env=v1 dir=v2 subPath=v1        ← the directory mount updated ~79 s later
01:23:48 env=v1 dir=v2 subPath=v1        ← env and subPath never change

The directory mount updated. The kubelet refreshes projected files on its sync loop, typically within a minute or two, by swapping a ..data symlink so that readers see either the old set or the new set, never half of each. The environment variable never changes. Only a new container picks up a new value, which in practice means a rollout (rung 7).

Rule Treat environment variables from ConfigMaps and Secrets as fixed at Pod start, and ship changes to them as a rollout.

Why The kubelet resolves valueFrom and envFrom once, when it creates the container, and the kernel offers no way to change another process's environment.

Trap A ConfigMap was changed and the app still uses the old value. Some Pods use the new value and some the old after a node replacement, depending on when each Pod started. kubectl exec … -- env shows the value each one has.

Rung 2 subPath mounts never update

subPath mounts one key as one file at an exact path, which is handy for dropping nginx.conf into a directory that has other files. It is a bind mount of the file as it was at start, so it never sees updates:

        - {name: cfg, mountPath: /etc/one/GREETING, subPath: GREETING}  # one key as a single file
subPath file 100 s after the ConfigMap edit:  still "Hello from Pantry"

To get a single file that does update, mount the whole ConfigMap as a directory somewhere neutral (/etc/pantry/) and point the app at the file inside it, or symlink to it from the image. To place one file among others, use a directory mount with items: selecting which keys appear.

Rule Use subPath only for configuration that changes with a rollout, and mount directories for anything that must update live.

Why A subPath mount binds the file's inode at container start, while directory mounts follow the kubelet's atomic symlink swap.

Trap A "live reloadable" config never reloads, though the app watches its file correctly. The file is mounted with subPath. kubectl get pod -o yaml shows subPath: on that mount.

Rung 3 Files: projected volumes, modes and ownership

Files are the better way to deliver secrets: not visible in env, /proc/<pid>/environ or crash dumps, and with permissions. A projected volume merges several sources (Secrets, ConfigMaps, the downward API, service-account tokens) into one directory:

      volumes:
        - name: secrets
          projected:                                      # several sources in one directory
            defaultMode: 0440
            sources:
              - secret:
                  name: redis-auth                        # rewritten to the hashed name too
                  items: [{key: password, path: redis-password}]
              - downwardAPI:
                  items: [{path: pod-name, fieldRef: {fieldPath: metadata.name}}]

The files are owned by root. Whether a non-root process can read them depends on the mode and on fsGroup:

defaultMode 0400, no fsGroup:     PermissionError: [Errno 13] Permission denied: '/etc/pantry/redis-password'
defaultMode 0400, fsGroup 10001:  -r--r----- 1 root pantry … redis-password    (group read added)

Rule Mount secrets as files (a projected volume when there are several sources), read-only, with defaultMode: 0440 and the Pod's fsGroup set to the app's GID.

Why Files keep secrets out of environment dumps. fsGroup gives the files the app's group, which is the only way a non-root process reads root-owned 04x0 files.

Trap A hardened Pod crash-loops with Permission denied on its secret file right after runAsUser was added. The file is 0400 root:root and the Pod has no fsGroup.

Rung 4 Immutable ConfigMaps and Secrets

immutable: true freezes the data. The API server rejects changes, and the kubelet stops watching the object, which takes load off the API server in clusters with thousands of Pods:

The ConfigMap "frozen" is invalid: data: Forbidden: field is immutable when `immutable` is set
apiVersion: v1
kind: ConfigMap
metadata: {name: pantry-settings-2gfkk74k97}
immutable: true
data: {GREETING: "Hello again from Pantry"}

Immutability fits naturally with hashed names (rung 7): each version is a new object, never edited, and the old ones remain for rollback until they are cleaned up.

Rule Make versioned ConfigMaps and Secrets immutable: true, and change configuration by creating a new object, never by editing one in place.

Why An object that cannot change cannot drift between Pods, and the kubelet does not need to watch it.

Trap Someone "just edits" a ConfigMap to fix production, and half the Pods pick it up (directory mounts) while half do not (env), with no record in git. Immutable objects make that edit fail.

Rung 5 What a Secret does and does not protect

A Secret is base64-encoded, not encrypted. On this cluster it was also stored in plain text in etcd:

.data.password: czNjcmV0LW5vdC1yZWFsLTQy → base64 -d → s3cret-not-real-42
etcdctl get /registry/secrets/k06/redis-auth → … s3cret-not-real-42 …         (no encryption at rest)
envFrom a Secret, then exec: /proc/1/environ → TOKEN=tok-visible-in-environ
client-side apply of stringData: last-applied-configuration annotation → "stringData":{"password":"hunter2"}

What makes a Secret different from a ConfigMap is access control and handling, not cryptography:

  • RBAC can grant ConfigMaps but not Secrets.
  • The kubelet keeps Secret volumes in memory (tmpfs) on the node.
  • Tooling redacts them in describe output.

So access is what to protect. Anyone with get secrets in a namespace can read them all, and anyone who can create Pods there can mount any of them (K9). Encrypt etcd at rest (the API server's EncryptionConfiguration, ideally with a KMS provider), and keep secret values out of git entirely.

Rule Treat Secrets as access-controlled, not encrypted: restrict get/list on them and Pod creation via RBAC, enable encryption at rest, deliver them as files, and keep their values out of git.

Why base64 is an encoding, etcd stores what it is given, and env vars and client-side apply annotations copy values into more places.

Trap A "secret" manifest with stringData: is committed to the repository, so the password is now in git history forever. Rotate the credential, purge the history, and switch to a generator from an ignored file, Sealed Secrets/SOPS, or an external store (rung 8).

Rung 6 Typed Secrets are validated

The type field tells the API server which keys must exist, and tells consumers how to use the Secret:

Type Required keys Used by
Opaque none your apps
kubernetes.io/tls tls.crt, tls.key Ingress / Gateway TLS, cert-manager
kubernetes.io/dockerconfigjson .dockerconfigjson imagePullSecrets (K9)
kubernetes.io/basic-auth username and/or password some controllers
kubernetes.io/service-account-token filled in by Kubernetes legacy long-lived tokens (avoid, K9)
type: kubernetes.io/tls with only cert.pem:
The Secret "bad-tls" is invalid: * data[tls.crt]: Required value * data[tls.key]: Required value
kubectl -n k06 create secret tls pantry-tls --cert=tls.crt --key=tls.key
kubectl -n k06 create secret docker-registry regcred --docker-server=… --docker-username=… --docker-password=…

Rule Use the matching typed Secret (tls, dockerconfigjson) and the kubectl create secret <type> helpers, rather than Opaque with hand-named keys.

Why Typed Secrets are validated on admission, and controllers find the keys where they expect them.

Trap A Gateway's HTTPS listener reports InvalidCertificateRef or serves a default certificate. The Secret is Opaque or has cert.pem instead of tls.crt.

Rung 7 Make configuration changes roll

Since environment values only change with a new container (rung 1), the safe way to change configuration is to change the Pod template when the configuration changes. Kustomize's generators do this by appending a hash of the content to the name and rewriting every reference:

examples/manual/k06/pantry/kustomization.yaml
# Pantry's configuration and secret, generated with content-hash names:
#   kubectl apply -k examples/manual/k06/pantry
# Changing config.env or the password file changes the generated name, which changes the
# Pod template, which rolls the Deployment. The old ConfigMap stays for rollback.
namespace: k06
resources:
  - redis.yaml
  - deployment.yaml
configMapGenerator:
  - name: pantry-settings
    envs: [config.env]                       # KEY=value lines → ConfigMap keys
secretGenerator:
  - name: redis-auth
    files: [password=redis-password.txt]     # git-ignored; create it with: openssl rand -hex 16 > redis-password.txt
    options:
      disableNameSuffixHash: false
kubectl apply -k pantry           → configmap/pantry-settings-9m5c7d52k9 created, secret/redis-auth-8h7bb9cg7d created
(edit config.env)
kubectl apply -k pantry           → configmap/pantry-settings-2gfkk74k97 created; deployment.apps/pantry configured
rollout history:                    1, 2           both ConfigMaps kept; GREETING in new Pods: Hello again from Pantry

Configuration now behaves like code. It rolls with the Deployment's strategy and probes (K4), a bad value fails the rollout instead of breaking every Pod at once, and kubectl rollout undo brings back the previous ConfigMap because the old template still names it. Helm does the same with a checksum annotation on the template (K10). Old generated objects accumulate, so prune them periodically, or let Argo CD's pruning handle it.

Rule Generate ConfigMaps and Secrets with content-hashed names (kustomize generators, or a checksum annotation in Helm), so every configuration change is a rollout.

Why A changed name changes the Pod template, and template changes are the only thing a Deployment rolls out and can roll back.

Trap A configuration change "takes effect" in one Pod (it restarted) and not in the others, or a bad value breaks every Pod at once on their next restart with no rollout to stop it. The ConfigMap is edited in place under a fixed name.

Rung 8 Encryption at rest and external secret stores

The cluster-side and git-side fixes, briefly:

  • Encryption at rest. The API server's EncryptionConfiguration encrypts chosen resources in etcd. KMS providers keep the key outside the cluster, in a cloud KMS or Vault. Managed clusters usually offer this as a checkbox. kind does not enable it: encryption-provider-config was absent from this API server's flags.
  • External Secrets Operator, or the Secrets Store CSI Driver, reads values from a cloud secret manager or Vault and creates or mounts them, so git holds only a reference.
  • Sealed Secrets or SOPS encrypt values so the ciphertext can live in git, and decryption happens in-cluster or in CI.
kubectl -n kube-system get pod -l component=kube-apiserver -o yaml | grep encryption-provider-config   # empty on kind

Rule In production, enable encryption at rest with a KMS provider, and source secrets from an external store or an encrypted-in-git format, never from plain manifests.

Why These are the only controls that protect secret values from someone who obtains etcd data, a backup, or the repository.

Trap etcd backups are copied to a shared bucket "because they are just cluster state", and every Secret in the cluster is in them in plain text.

Decide

You need Use
A setting read at start env.valueFrom a ConfigMap (rolled with hashed names)
A config file the app reloads ConfigMap volume (directory), no subPath
A file placed among others in a directory subPath, accepting it updates only on rollout
A password or key Secret as a file in a projected volume, 0440 + fsGroup
TLS certificate kubernetes.io/tls Secret (cert-manager in production)
Registry credentials kubernetes.io/dockerconfigjson + imagePullSecrets
Config changes as rollouts kustomize generators or Helm checksum annotations
Secrets out of git External Secrets / CSI driver, or Sealed Secrets / SOPS

The golden file

Pantry's configuration as a kustomize directory: settings from a plain config.env, the Redis password from a git-ignored file, both generated with content-hashed names, the password delivered as a file through a projected volume with group-readable mode, and Redis reading the same secret into a tmpfs config file.

examples/manual/k06/pantry/kustomization.yaml
# Pantry's configuration and secret, generated with content-hash names:
#   kubectl apply -k examples/manual/k06/pantry
# Changing config.env or the password file changes the generated name, which changes the
# Pod template, which rolls the Deployment. The old ConfigMap stays for rollback.
namespace: k06
resources:
  - redis.yaml
  - deployment.yaml
configMapGenerator:
  - name: pantry-settings
    envs: [config.env]                       # KEY=value lines → ConfigMap keys
secretGenerator:
  - name: redis-auth
    files: [password=redis-password.txt]     # git-ignored; create it with: openssl rand -hex 16 > redis-password.txt
    options:
      disableNameSuffixHash: false
examples/manual/k06/pantry/config.env
GREETING=Hello from Pantry
PANTRY_VERSION=k06
examples/manual/k06/pantry/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata: {name: pantry, labels: {app.kubernetes.io/name: pantry}}
spec:
  replicas: 2
  selector: {matchLabels: {app.kubernetes.io/name: pantry}}
  template:
    metadata: {labels: {app.kubernetes.io/name: pantry}}
    spec:
      enableServiceLinks: false
      automountServiceAccountToken: false
      securityContext: {runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, fsGroup: 10001, seccompProfile: {type: RuntimeDefault}}
      containers:
        - name: web
          image: pantry-manual:d05
          imagePullPolicy: IfNotPresent
          ports: [{name: http, containerPort: 8080}]
          envFrom:
            - configMapRef: {name: pantry-settings}       # kustomize rewrites this to the hashed name
          env:
            - {name: REDIS_HOST, value: redis}
            - {name: REDIS_PASSWORD_FILE, value: /etc/pantry/secrets/redis-password}
          readinessProbe: {httpGet: {path: /healthz, port: http}, periodSeconds: 3}
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
          volumeMounts:
            - {name: secrets, mountPath: /etc/pantry/secrets, readOnly: true}
            - {name: tmp, mountPath: /tmp}
      volumes:
        - name: secrets
          projected:                                      # several sources in one directory
            defaultMode: 0440
            sources:
              - secret:
                  name: redis-auth                        # rewritten to the hashed name too
                  items: [{key: password, path: redis-password}]
              - downwardAPI:
                  items: [{path: pod-name, fieldRef: {fieldPath: metadata.name}}]
        - {name: tmp, emptyDir: {medium: Memory, sizeLimit: 16Mi}}
examples/manual/k06/pantry/redis.yaml
apiVersion: apps/v1
kind: Deployment
metadata: {name: redis}
spec:
  selector: {matchLabels: {app.kubernetes.io/name: redis}}
  template:
    metadata: {labels: {app.kubernetes.io/name: redis}}
    spec:
      securityContext: {runAsNonRoot: true, runAsUser: 999, runAsGroup: 1000, fsGroup: 1000, seccompProfile: {type: RuntimeDefault}}
      containers:
        - name: redis
          image: redis:7-alpine
          # the password goes into a config file on tmpfs, never onto the command line (D10)
          command: ["sh", "-c", "printf 'requirepass %s\\n' \"$(cat /etc/redis-auth/password)\" > /tmp/redis.conf && exec redis-server /tmp/redis.conf --appendonly yes"]
          ports: [{name: redis, containerPort: 6379}]
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
          volumeMounts:
            - {name: auth, mountPath: /etc/redis-auth, readOnly: true}
            - {name: tmp, mountPath: /tmp}
            - {name: data, mountPath: /data}
      volumes:
        - name: auth
          secret: {secretName: redis-auth, defaultMode: 0440}   # group-readable: fsGroup gives the group
        - {name: tmp, emptyDir: {medium: Memory, sizeLimit: 1Mi}}
        - {name: data, emptyDir: {}}
---
apiVersion: v1
kind: Service
metadata: {name: redis}
spec:
  selector: {app.kubernetes.io/name: redis}
  ports: [{name: redis, port: 6379, targetPort: redis}]
openssl rand -hex 16 | tr -d '\n' > pantry/redis-password.txt      # git-ignored
kubectl apply -k pantry && kubectl -n k06 rollout status deploy/pantry
# edit pantry/config.env, apply again: a new pantry-settings-<hash>, and the Deployment rolls

Review checklist

Spot the bug

"Password in a Secret, greeting in a ConfigMap, both mounted as files."

examples/manual/k06/bug/pantry.yaml
apiVersion: v1
kind: Secret
metadata: {name: redis-auth-bug, namespace: k06}
stringData:
  password: hunter2
---
apiVersion: v1
kind: ConfigMap
metadata: {name: pantry-files, namespace: k06}
data:
  greeting.txt: "Hello from Pantry"
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: pantry-bug, namespace: k06}
spec:
  replicas: 1
  selector: {matchLabels: {app: pantry-bug}}
  template:
    metadata: {labels: {app: pantry-bug}}
    spec:
      securityContext: {runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, seccompProfile: {type: RuntimeDefault}}
      containers:
        - name: web
          image: pantry-manual:d05
          env:
            - {name: REDIS_HOST, value: redis}
            - {name: REDIS_PASSWORD_FILE, value: /etc/pantry/redis-password}
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
          volumeMounts:
            - {name: auth, mountPath: /etc/pantry/redis-password, subPath: password}
            - {name: files, mountPath: /etc/pantry/greeting.txt, subPath: greeting.txt}
            - {name: tmp, mountPath: /tmp}
      volumes:
        - name: auth
          secret: {secretName: redis-auth-bug, defaultMode: 0400}
        - name: files
          configMap: {name: pantry-files}
        - {name: tmp, emptyDir: {medium: Memory, sizeLimit: 16Mi}}

This file has 3 problems.

Show answers
  1. The password is in the manifest as stringData: {password: hunter2}: in git, readable by anyone with the repository. After a client-side kubectl apply, it was also copied verbatim into the last-applied-configuration annotation. Generate the Secret from an ignored file or an external store.
  2. The secret file is mounted 0400 with no fsGroup, so as UID 10001 the app crashed with PermissionError: [Errno 13] Permission denied: '/etc/pantry/redis-password'. Add fsGroup: 10001 (the file became -r--r----- root pantry), or use defaultMode: 0440.
  3. Both files are mounted with subPath, so they never update. 100 seconds after the ConfigMap was edited, the file still said "Hello from Pantry". Mount the ConfigMap as a directory, and roll config changes with hashed names.

Kubernetes manual · 7 · 8 min readStorage#

In the story: storage units: the unit itself, the rental request, and the storage company · Memory That Survives, Again

A Pod's filesystem lasts as long as the Pod. Anything else needs a volume, and in Kubernetes persistent volumes come with three layers of indirection:

  • a PersistentVolumeClaim, what the Pod asks for: "1 GiB, one writer";
  • a StorageClass, who provides it and how;
  • a PersistentVolume, the actual disk.

Most storage incidents come from misreading one of those layers: a claim that waits forever, an access mode that does not mean what it says, a reclaim policy that deletes data with the app, a Deployment where a StatefulSet belonged. This chapter measures each on kind's built-in local-path provisioner and says plainly what kind cannot show.

creates

mounts

binds when the Pod is scheduled
(WaitForFirstConsumer)

provisions

StatefulSet redis
volumeClaimTemplates

PVC data-redis-0
RWOP, 1Gi, standard

Pod redis-0

PV pvc-…
on manual-worker
reclaim: Delete

StorageClass standard
rancher.io/local-path

headless Service redis
redis-0.redis.k07.svc

The ladder

Rung 1 emptyDir: scratch space with a limit

emptyDir lives as long as the Pod (it survives container restarts, not Pod deletion). It is backed by the node's disk, or by memory with medium: Memory. Disk-backed emptyDirs count against the Pod's ephemeral storage, and exceeding a sizeLimit evicts the Pod:

emptyDir sizeLimit 20Mi, 60 MB written:
phase=Failed reason=Evicted message=Usage of EmptyDir volume "s" exceeds the limit "20Mi".
  volumes:
    - name: cache
      emptyDir: {sizeLimit: 512Mi}            # disk; evicts the Pod if exceeded
    - name: tmp
      emptyDir: {medium: Memory, sizeLimit: 16Mi}   # tmpfs; counts against the memory limit (D8, K3)

Use emptyDir for caches, scratch files, sockets and files shared between containers in one Pod (an init container rendering config for the main one). An unbounded emptyDir lets one Pod fill the node's disk and trigger evictions of other Pods.

Rule Give every emptyDir a sizeLimit, and never keep data in one that must survive the Pod.

Why emptyDir is deleted with the Pod, and an unbounded one consumes the node's ephemeral storage, which the kubelet defends by evicting Pods.

Trap Pods on one node are evicted with The node was low on resource: ephemeral-storage. Some Pod's unbounded emptyDir or container log filled the disk. kubectl describe node shows DiskPressure.

Rung 2 Claims, classes, and waiting for the first consumer

A PVC asks for storage, and its StorageClass's provisioner creates a PV to satisfy it. With volumeBindingMode: WaitForFirstConsumer, provisioning waits until a Pod using the claim is scheduled, so the volume is created where the Pod will run (the right zone, or with local-path the right node):

examples/manual/k07/claims.yaml
# Two claims against kind's default StorageClass (local-path, WaitForFirstConsumer, Delete).
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: data-rwo, namespace: k07}
spec:
  accessModes: [ReadWriteOnce]
  resources: {requests: {storage: 1Gi}}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: data-rwx, namespace: k07}
spec:
  accessModes: [ReadWriteMany]
  resources: {requests: {storage: 1Gi}}
data-rwo   Pending   …   waiting for first consumer to be created before binding
(after a Pod used it)
data-rwo   Bound     pvc-55ba6d07-…   1Gi   RWO   standard
kubectl get storageclass                                   # which classes exist, which is (default)
kubectl get pvc,pv -n k07

A Pending claim with that message is normal. A Pending claim with a consumer is a real problem: read its events. Name the StorageClass explicitly in manifests. "The default" differs between clusters and can change.

Rule Set storageClassName explicitly, prefer classes with WaitForFirstConsumer, and read a Pending claim's events before assuming it is broken.

Why Late binding places volumes where their Pods land, and an explicit class makes the manifest mean the same thing on every cluster.

Trap A Pod is Pending with volume node affinity conflict or didn't find available persistent volumes. Its volume was bound in another zone before the Pod was scheduled (an Immediate binding mode), or the class cannot provision there.

Rung 3 Access modes: RWO means one node, not one Pod

The access mode describes how many nodes (or Pods) may mount a volume:

Mode Meaning Measured on local-path
ReadWriteOnce (RWO) read-write by one node, any number of Pods on it 2 Pods, both on manual-worker, both writing
ReadWriteOncePod (RWOP) read-write by one Pod second Pod Pending: … ReadWriteOncePod access mode already in-use
ReadWriteMany (RWX) read-write by many nodes NodePath only supports ReadWriteOnce and ReadWriteOncePod (never bound)
ReadOnlyMany (ROX) read-only by many nodes needs suitable storage

The RWO surprise is the dangerous one. Two Pods of a Deployment shared one RWO claim, the scheduler placed both on the PV's node, and both wrote to it. For a database, that is two processes on one data directory (the spot-the-bug shows the result). RWX needs storage built for it: NFS, CephFS, a cloud file service. Block storage (EBS, Persistent Disk, local disks) cannot provide it.

        accessModes: [ReadWriteOncePod]    # exactly one Pod may use it

Rule Use ReadWriteOncePod for single-writer data, request ReadWriteMany only from a class that supports it, and never share a writable claim between replicas of a database.

Why Access modes are enforced per node for RWO and per Pod only for RWOP, and RWX is a capability of the storage, not a setting.

Trap Two replicas of a stateful app "share" a volume and the data is corrupted or inconsistent. The claim was RWO and both Pods landed on one node. Use RWOP and a StatefulSet.

Rung 4 Reclaim policy: what happens to the disk when the claim goes

When a PVC is deleted, its PV's persistentVolumeReclaimPolicy decides the data's fate. Dynamically provisioned volumes inherit Delete from their class:

Delete (default):   PV pvc-55ba6d07-… after deleting its claim: NotFound      ← the data is gone
Retain (patched):   pvc-90e5ed89-…  Retain  Released                          ← the disk and "precious" remain
kubectl patch pv <pv-name> -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'

A Released PV is not reused automatically. It keeps its old claimRef until an admin clears it or copies the data out, which is the point. Deleting a namespace deletes its PVCs, and with Delete, their disks. So does kubectl delete -f on a file that contains the PVC next to the app.

Rule Use a Retain StorageClass (or patch the PV) for data you cannot regenerate, keep PVCs out of app manifests that get deleted and re-applied, and back up anyway (snapshots or app-level dumps).

Why With Delete, removing the claim, by hand, by namespace deletion or by a manifest delete, destroys the underlying disk immediately.

Trap "Recreating the namespace to start fresh" wiped the production database. The PVCs went with the namespace and the class's policy was Delete. kubectl get pv showed nothing left to recover.

Rung 5 StatefulSets: stable names, stable storage

A StatefulSet gives each replica a stable identity: an ordinal name (redis-0), a DNS name through a headless Service (redis-0.redis.k07.svc), and its own claim from volumeClaimTemplates (data-redis-0). Pods are created in order, and a replaced Pod gets back the same name and the same claim:

SET visits 42; delete Pod redis-0          → redis-0 back, visits=42
nslookup redis-0.redis.k07.svc.cluster.local → 10.244.2.128 (the Pod's own IP)
delete the StatefulSet                     → data-redis-0 still Bound
re-create it                               → visits=42
spec:
  serviceName: redis                       # gives each Pod the stable name <pod>.redis.<ns>.svc
  persistentVolumeClaimRetentionPolicy:
    whenDeleted: Retain                    # deleting the StatefulSet keeps the data (the default, written down)
    whenScaled: Retain                     # scaling down keeps redis-1's claim for when it comes back

The headless Service (clusterIP: None) is what makes per-Pod DNS names exist, and clustered databases use those names to find each other. StatefulSets do not replicate data. They give each replica a stable place to keep its own copy. Replication is the database's job.

Rule Run anything with per-replica persistent state as a StatefulSet with a headless Service and volumeClaimTemplates, never as a Deployment with a shared claim.

Why StatefulSets bind each ordinal to its own claim and DNS name for life, which is exactly what databases and quorum systems assume.

Trap After scaling a StatefulSet down and up, a replica comes back with old data. Its claim was retained by design (whenScaled: Retain) and re-attached. Delete the claim explicitly if the data should not return.

Rung 6 Volume ownership: fsGroup, and where it does not apply

fsGroup makes volumes group-writable for a non-root Pod (K3), and fsGroupChangePolicy: OnRootMismatch avoids re-chowning large volumes at every mount. Not every volume type honours it:

emptyDir with fsGroup 10001 (K3):          drwxrwsrwt root pantry      ← applied
local-path PV with fsGroup 1000 (here):    drwxrwxrwx root root        ← ignored; writable only because it is 0777
      securityContext:
        fsGroup: 1000                      # volume group-owned by redis
        fsGroupChangePolicy: OnRootMismatch   # skip the recursive chown once ownership is right (K3)

Block-based CSI volumes (cloud disks) support fsGroup. hostPath-based provisioners like local-path, and many NFS setups, do not. Check with ls -ld in the container rather than assuming.

Rule Set fsGroup and fsGroupChangePolicy: OnRootMismatch on stateful Pods, and verify the mounted directory's owner and mode on each storage class you use.

Why Ownership changes are applied by the kubelet only for volume types that support them. Others keep whatever owner and mode the provisioner chose.

Trap A database that runs as non-root fails with Permission denied on one cluster's storage and works on another. One class honours fsGroup and the other does not. ls -ld /data in the container shows it.

Rung 7 Expansion, snapshots, and what kind cannot show

Two features depend entirely on the CSI driver behind the class:

  • Expansion. With allowVolumeExpansion: true, edit the PVC's resources.requests.storage upwards and the driver grows the disk, often online. Volumes never shrink.
  • Snapshots. VolumeSnapshot objects, backed by a VolumeSnapshotClass, take point-in-time copies that can seed new claims.

On kind, neither exists:

allowVolumeExpansion=[]
error: the server doesn't have a resource type "volumesnapshotclasses"
kubectl get storageclass -o custom-columns=NAME:.metadata.name,EXPAND:.allowVolumeExpansion,RECLAIM:.reclaimPolicy
kubectl api-resources | grep snapshot.storage.k8s.io

local-path volumes are directories on one node, so a node failure takes the data with it. That is fine for learning and wrong for production. On a real cluster, check that each class you depend on supports expansion and snapshots before you need them, and that snapshots are actually restorable.

Rule Before relying on a StorageClass, confirm its access modes, reclaim policy, binding mode, expansion and snapshot support, and test a restore.

Why All of these are properties of the CSI driver, and they differ completely between local, network and cloud storage.

Trap A disk fills, someone edits the PVC to grow it, and the API refuses: persistentvolumeclaims "data-redis-0" is forbidden: only dynamically provisioned pvc can be resized and the storageclass that provisions the pvc must support resize (measured here). The class does not allow expansion. Find out before the disk is full.

Decide

Data Use Access mode Reclaim
Scratch, cache, render output emptyDir with sizeLimit n/a gone with the Pod
Single database / queue StatefulSet + volumeClaimTemplates RWOP Retain
Clustered database (3 replicas) StatefulSet, anti-affinity per zone RWOP each Retain
Files shared by many Pods RWX class (NFS, CephFS, cloud files) RWX per data value
Static content bake it into the image, or object storage n/a n/a
Config ConfigMap (K6), not a volume you edit ROX via projection n/a

The golden file

Redis with storage that outlives its Pods: a headless Service for stable DNS, a StatefulSet with an explicit retention policy, a single-Pod claim per replica from an explicit class, non-root identity with fsGroup and OnRootMismatch, a readiness probe, and resources.

examples/manual/k07/redis.yaml
# Redis with storage that survives Pods: a StatefulSet, its headless Service, one claim per replica.
apiVersion: v1
kind: Service
metadata: {name: redis, namespace: k07, labels: {app.kubernetes.io/name: redis}}
spec:
  clusterIP: None                          # headless: DNS returns Pod addresses, and redis-0.redis resolves
  selector: {app.kubernetes.io/name: redis}
  ports: [{name: redis, port: 6379, targetPort: redis}]
---
apiVersion: apps/v1
kind: StatefulSet
metadata: {name: redis, namespace: k07}
spec:
  serviceName: redis                       # gives each Pod the stable name <pod>.redis.<ns>.svc
  replicas: 1
  selector: {matchLabels: {app.kubernetes.io/name: redis}}
  persistentVolumeClaimRetentionPolicy:
    whenDeleted: Retain                    # deleting the StatefulSet keeps the data (the default, written down)
    whenScaled: Retain                     # scaling down keeps redis-1's claim for when it comes back
  template:
    metadata: {labels: {app.kubernetes.io/name: redis}}
    spec:
      terminationGracePeriodSeconds: 30    # time for Redis to finish its AOF write on SIGTERM
      securityContext:
        runAsNonRoot: true
        runAsUser: 999                     # the image's redis user
        runAsGroup: 1000
        fsGroup: 1000                      # volume group-owned by redis
        fsGroupChangePolicy: OnRootMismatch   # skip the recursive chown once ownership is right (K3)
        seccompProfile: {type: RuntimeDefault}
      containers:
        - name: redis
          image: redis:7-alpine
          args: ["redis-server", "--appendonly", "yes", "--dir", "/data"]
          ports: [{name: redis, containerPort: 6379}]
          readinessProbe:
            exec: {command: ["redis-cli", "ping"]}
            periodSeconds: 5
          resources:
            requests: {cpu: 50m, memory: 64Mi}
            limits: {memory: 192Mi}
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
          volumeMounts: [{name: data, mountPath: /data}]
  volumeClaimTemplates:                    # one PVC per Pod: data-redis-0, data-redis-1, …
    - metadata: {name: data}
      spec:
        accessModes: [ReadWriteOncePod]    # exactly one Pod may use it
        storageClassName: standard         # explicit, not "whatever the default is today"
        resources: {requests: {storage: 1Gi}}
kubectl apply -f namespace.yaml -f redis.yaml && kubectl -n k07 rollout status sts/redis
kubectl -n k07 exec redis-0 -- redis-cli SET visits 42
kubectl -n k07 delete pod redis-0 && kubectl -n k07 wait --for=condition=Ready pod/redis-0
kubectl -n k07 exec redis-0 -- redis-cli GET visits        # 42

Review checklist

Spot the bug

"Redis with a persistent volume, two replicas for safety."

examples/manual/k07/bug/redis.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: redis-data, namespace: k07}
spec:
  accessModes: [ReadWriteMany]
  resources: {requests: {storage: 1Gi}}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: redis, namespace: k07}
spec:
  replicas: 2
  selector: {matchLabels: {app: redis-bug}}
  template:
    metadata: {labels: {app: redis-bug}}
    spec:
      securityContext: {runAsNonRoot: true, runAsUser: 999, runAsGroup: 1000, fsGroup: 1000, seccompProfile: {type: RuntimeDefault}}
      containers:
        - name: redis
          image: redis:7-alpine
          args: ["redis-server", "--appendonly", "yes"]
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
          volumeMounts: [{name: data, mountPath: /data}]
      volumes:
        - name: data
          persistentVolumeClaim: {claimName: redis-data}

This file has 4 problems.

Show answers
  1. ReadWriteMany from a class that cannot provide it: the claim and both Pods stayed Pending (NodePath only supports ReadWriteOnce and ReadWriteOncePod). Request what the class supports, and RWOP for a single writer.
  2. A Deployment with two replicas on one claim. With RWO, both Pods ran on the same node over one data directory, and each Redis answered GET who with its own value. Two processes, one set of files. Use a StatefulSet with volumeClaimTemplates (one claim each), and Redis replication if you need two copies.
  3. The PVC sits in the same file as the app and the class's policy is Delete, so kubectl delete -f on this file deleted the PV and the data (NotFound). Keep data claims out of app manifests (or use volumeClaimTemplates with whenDeleted: Retain), and use a Retain class.
  4. There is no storageClassName, so the claim gets whatever the cluster's default is (here local-path, on a different cluster something else, with different modes and reclaim policy). Name the class.

Kubernetes manual · 8 · 10 min readNetworking#

In the story: the phone number that never changes, the city's front gate, and which corridors connect which districts · A Number That Never Changes, Districts, Key Cards and Rush Hour

Kubernetes networking is three layers:

  • Services give a set of Pods one stable name and virtual IP, and keep their EndpointSlices up to date.
  • Gateways (or the older Ingress) bring traffic in from outside by hostname and path.
  • NetworkPolicies decide which Pods may talk to which.

Each layer has a status you can read. Most "the network is broken" incidents are one of those statuses saying exactly what is wrong while nobody looks.

Host: pantry.example.test
127.0.0.1:30090

HTTPRoute k08/pantry
allowed by namespace label

EndpointSlice
ready Pods only

NetworkPolicy: egress to redis:6379 + DNS

blocked

client

Gateway public
(Envoy proxy)
ns: gateway / envoy-gateway-system

Service pantry :80
→ targetPort http

Pantry Pod :8080

Pantry Pod :8080

Redis :6379

other Pod in k08

The ladder

Rung 1 Service types, and targeting ports by name

Type Reachable from Notes
ClusterIP (default) inside the cluster name.namespace.svc DNS + a virtual IP
NodePort every node's IP on a high port (30000–32767) building block for LBs and kind
LoadBalancer an external IP from a cloud or bare-metal controller <pending> forever where there is none, as on kind
ExternalName DNS CNAME to an outside name no proxying, no ports
headless (clusterIP: None) DNS returns Pod IPs directly StatefulSets (K7)

Target container ports by name, so the port number can change in one place:

  ports: [{name: http, port: 80, targetPort: http}]    # targets the container port *named* http

A name that matches no container port fails silently. The Service exists, has endpoints, and has no ports:

targetPort: web   →   EndpointSlice ports: null   endpoints: ["10.244.2.141"]
through the gateway: 503

Rule Use ClusterIP Services for everything internal, expose externally through a Gateway, name every container port, and target it by name.

Why Named ports decouple the Service from the container's port number, and a single entry point (the Gateway) replaces one LoadBalancer per Service.

Trap A Service has endpoints and every request fails or returns 503. kubectl get endpointslices -l kubernetes.io/service-name=<svc> -o yaml shows ports: null: the targetPort name matches no container port.

Rung 2 EndpointSlices: who is actually behind the Service

A Service is only as good as its EndpointSlices. They list the Pods whose labels match the selector, with ready, serving and terminating conditions. Readiness probes (K4) control ready, and kube-proxy sends traffic only to ready endpoints. When none are ready it falls back to terminating ones that are still serving, which is why the K4 drains lost nothing.

kubectl -n k08 get endpointslices -l kubernetes.io/service-name=pantry -o wide
kubectl -n k08 get endpointslices -l kubernetes.io/service-name=pantry -o jsonpath='{range .items[*].endpoints[*]}{.addresses} ready={.conditions.ready}{"\n"}{end}'

A Service with no endpoints refuses connections outright: right after creation, a client got Connection refused until the first endpoint appeared. That is not a NetworkPolicy, which times out instead (rung 7). The difference between "refused" and "timed out" is the first diagnostic.

Rule When traffic to a Service fails, read its EndpointSlices first: empty means the selector or readiness, ports: null means the target port, and only then look further.

Why Every Service problem below the Gateway appears in the slices: no matching Pods, unready Pods, or no matching port.

Trap Connection refused from a Service whose Pods look Running. They are not Ready (a failing readiness probe), so the slice has no ready endpoints. kubectl describe pod shows Readiness probe failed.

Rung 3 externalTrafficPolicy and the client's address

For NodePort and LoadBalancer Services, externalTrafficPolicy decides what happens to traffic arriving at a node. With Cluster, any node accepts and forwards, but the source address is rewritten. With Local, only nodes with a ready Pod accept, and the client's address survives:

echo Pod on manual-worker; requests from 172.20.0.5
Cluster via manual-worker:    client seen as 10.244.2.1     (SNAT)
Cluster via manual-worker2:   client seen as 172.20.0.7     (SNAT to that node)
Local   via manual-worker:    client seen as 172.20.0.5     (real address)
Local   via manual-worker2:   no answer (timeout)

This bit the golden setup itself. Envoy Gateway creates its proxy Service with Local, kind maps host port 30090 to manual-worker, and the proxy ran on manual-worker2, so requests from the laptop got an empty reply until the EnvoyProxy set Cluster. Cloud load balancers handle Local correctly by health-checking nodes. A static port mapping, as on kind, does not.

      envoyService:
        type: NodePort
        externalTrafficPolicy: Cluster     # Envoy Gateway defaults to Local: only the node running the proxy would answer

Rule Use externalTrafficPolicy: Local only behind a load balancer that health-checks nodes and when you need client IPs, and otherwise use Cluster (or get client IPs from the proxy's X-Forwarded-For).

Why Local avoids the extra hop and SNAT but drops traffic at nodes without a ready Pod. Cluster always forwards and hides the client.

Trap An externally exposed Service works only some of the time, or only from some load balancer nodes. The policy is Local, and traffic reaches nodes without a Pod. kubectl get svc -o jsonpath='{.spec.externalTrafficPolicy}' shows it.

Rung 4 Gateway API: three objects, three owners

Gateway API splits ingress into objects owned by different people. The GatewayClass (the infrastructure team) names the implementation. The Gateway (the platform team) is an entry point with listeners, and says which namespaces may attach routes. The HTTPRoute (the app team) maps hostnames and paths to Services:

examples/manual/k08/gateway.yaml
# Platform team: one shared entry point, and which namespaces may attach routes to it.
apiVersion: v1
kind: Namespace
metadata:
  name: gateway
  labels: {pod-security.kubernetes.io/enforce: restricted, pod-security.kubernetes.io/enforce-version: v1.37}
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: {name: public, namespace: gateway}
spec:
  gatewayClassName: eg
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: Selector              # only namespaces the platform team has labelled
          selector:
            matchLabels: {pantry.example.com/gateway-access: "true"}
gateway/public    CLASS eg   ADDRESS 172.20.0.7   PROGRAMMED True
route from k08 (namespace labelled gateway-access):   Accepted=True
route from an unlabelled namespace:                   Accepted=False NotAllowedByListeners: No listeners included by this parent ref allowed this attachment.

That last line is the point of the split. An app team cannot claim pantry.example.test on the shared Gateway from a namespace the platform team has not approved. Gateway API reached v1.0 (GA) in October 2023, and it is what the Kubernetes project now points Ingress users towards.

Rule Expose services through Gateway API: one GatewayClass per implementation, Gateways owned by the platform with allowedRoutes restricted by namespace selector, and HTTPRoutes in the app namespaces.

Why The role split puts each decision (implementation, entry point and TLS, routing) with the team that owns it, enforced by the API rather than by convention.

Trap A route is applied and traffic never arrives (404). Its status says NotAllowedByListeners, or it has no status at all because its parentRefs points to a Gateway in the wrong namespace. Read kubectl get httproute -o yaml → status.parents.

Rung 5 HTTPRoutes: status tells you everything

An HTTPRoute matches on hostname, path, headers, method or query parameters, and can split traffic by weight between backends (canaries), rewrite paths, redirect, and set headers:

examples/manual/k08/pantry.yaml
# App team: Pantry, its Redis, and the route that attaches it to the shared Gateway.
apiVersion: v1
kind: Namespace
metadata:
  name: k08
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: v1.37
    pantry.example.com/gateway-access: "true"     # granted by the platform team: routes here may attach
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: redis, namespace: k08}
spec:
  selector: {matchLabels: {app.kubernetes.io/name: redis}}
  template:
    metadata: {labels: {app.kubernetes.io/name: redis}}
    spec:
      securityContext: {runAsNonRoot: true, runAsUser: 999, runAsGroup: 1000, fsGroup: 1000, seccompProfile: {type: RuntimeDefault}}
      containers:
        - name: redis
          image: redis:7-alpine
          ports: [{name: redis, containerPort: 6379}]
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
          volumeMounts: [{name: data, mountPath: /data}]
      volumes: [{name: data, emptyDir: {sizeLimit: 256Mi}}]
---
apiVersion: v1
kind: Service
metadata: {name: redis, namespace: k08}
spec:
  selector: {app.kubernetes.io/name: redis}
  ports: [{name: redis, port: 6379, targetPort: redis}]
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: pantry, namespace: k08}
spec:
  replicas: 2
  selector: {matchLabels: {app.kubernetes.io/name: pantry}}
  template:
    metadata: {labels: {app.kubernetes.io/name: pantry}}
    spec:
      enableServiceLinks: false
      securityContext: {runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, fsGroup: 10001, seccompProfile: {type: RuntimeDefault}}
      containers:
        - name: web
          image: pantry-manual:d05
          imagePullPolicy: IfNotPresent
          ports: [{name: http, containerPort: 8080}]
          env: [{name: REDIS_HOST, value: redis}]
          readinessProbe: {httpGet: {path: /healthz, port: http}, periodSeconds: 3}
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
          volumeMounts: [{name: tmp, mountPath: /tmp}]
      volumes: [{name: tmp, emptyDir: {medium: Memory, sizeLimit: 16Mi}}]
---
apiVersion: v1
kind: Service
metadata: {name: pantry, namespace: k08}
spec:
  selector: {app.kubernetes.io/name: pantry}
  ports: [{name: http, port: 80, targetPort: http}]    # targets the container port *named* http
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: pantry, namespace: k08}
spec:
  parentRefs:
    - {name: public, namespace: gateway, sectionName: http}
  hostnames: ["pantry.example.test"]
  rules:
    - matches: [{path: {type: PathPrefix, value: /}}]
      backendRefs: [{name: pantry, port: 80}]
HTTPRoute k08/pantry:  Accepted=True Accepted   ResolvedRefs=True ResolvedRefs
Host pantry.example.test → 200      Host other.example.test → 404
  rules:
    - matches: [{path: {type: PathPrefix, value: /api}}]
      backendRefs:
        - {name: pantry, port: 80, weight: 90}
        - {name: pantry-canary, port: 80, weight: 10}

The status conditions separate the failures. Accepted=False means the Gateway refused the route (namespace, listener or hostname mismatch). ResolvedRefs=False means a backend is missing (BackendNotFound), or is in another namespace without a ReferenceGrant. A 404 from the gateway means no route matched the host or path. A 503 means it matched but no backend endpoint answered. In this chapter's spot-the-bug, fixing each layer in turn moved the response from 404 to 503 to a timeout to 200.

Rule After applying a route, check status.parents[].conditions for Accepted and ResolvedRefs, and read 404 as "no route matched" and 503 as "no backend answered".

Why The gateway controller reports exactly why a route is not in effect, and the HTTP status code says which layer stopped the request.

Trap A cross-namespace backendRef shows ResolvedRefs=False RefNotPermitted. The target namespace needs a ReferenceGrant allowing routes from the route's namespace.

Rung 6 Ingress: the legacy form

Ingress is the older API: one object with host and path rules, interpreted by whichever controller owns its ingressClassName. It still works where a controller is installed, and much existing YAML uses it. With no controller, it is accepted and does nothing, without an error:

examples/manual/k08/ingress-legacy.yaml
# The same route as a classic Ingress, for comparison. It needs an Ingress controller; this cluster has none,
# and ingress-nginx, the most common one, was retired upstream (maintenance ended March 2026).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata: {name: pantry, namespace: k08}
spec:
  ingressClassName: nginx
  rules:
    - host: pantry.example.test
      http:
        paths:
          - path: /
            pathType: Prefix
            backend: {service: {name: pantry, port: {name: http}}}
$ kubectl get ingressclass
No resources found
$ kubectl -n k08 get ingress pantry
NAME     CLASS   HOSTS                 ADDRESS   PORTS
pantry   nginx   pantry.example.test             80          ← no address, ever

The most widely deployed controller, ingress-nginx, was retired by the Kubernetes project: announced in November 2025, with best-effort maintenance ending in March 2026 and no further releases or security fixes after that. Existing installations keep working, but they are now unmaintained software at the edge of the cluster. Advanced behaviour in Ingress lived in controller-specific annotations, which is the main thing a migration has to translate (the Ingress2Gateway tool helps).

Rule Write new routing as Gateway API, plan migrations off retired Ingress controllers, and when you must read an Ingress, check its class and controller first.

Why Ingress semantics beyond host and path rules are controller-specific annotations, and the most common controller no longer receives security fixes.

Trap An Ingress copied from another cluster has no ADDRESS and traffic never arrives. No controller serves its ingressClassName here. kubectl get ingressclass shows what exists.

Rung 7 NetworkPolicy: default deny, and the DNS trap

Without any NetworkPolicy, every Pod can reach every other Pod in the cluster. A policy selecting a Pod makes it default-deny for the directions it lists, plus whatever its rules allow. Policies are additive. Start with deny-all for the namespace, then allow flows one by one. The first flow to allow is DNS, or nothing resolves:

default-deny (Ingress + Egress) only:
  socket.gaierror: [Errno -3] Temporary failure in name resolution
  new Pod after restart: 0/1 (readiness needs Redis, which needs DNS)
  through the gateway: 000 (timeout)
examples/manual/k08/networkpolicy.yaml
# Default deny, then exactly the flows Pantry needs. Order does not matter: policies are additive.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: default-deny, namespace: k08}
spec:
  podSelector: {}                           # every Pod in the namespace
  policyTypes: [Ingress, Egress]            # nothing in, nothing out, unless allowed below
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: allow-dns, namespace: k08}
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}
          podSelector: {matchLabels: {k8s-app: kube-dns}}
      ports: [{port: 53, protocol: UDP}, {port: 53, protocol: TCP}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: pantry-web, namespace: k08}
spec:
  podSelector: {matchLabels: {app.kubernetes.io/name: pantry}}
  policyTypes: [Ingress, Egress]
  ingress:
    - from:                                 # only the gateway's proxy Pods may call Pantry
        - namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: envoy-gateway-system}}
          podSelector: {matchLabels: {gateway.envoyproxy.io/owning-gateway-name: public}}
      ports: [{port: http}]
  egress:
    - to:
        - podSelector: {matchLabels: {app.kubernetes.io/name: redis}}
      ports: [{port: 6379}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: redis, namespace: k08}
spec:
  podSelector: {matchLabels: {app.kubernetes.io/name: redis}}
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector: {matchLabels: {app.kubernetes.io/name: pantry}}
      ports: [{port: 6379}]
with the golden policies:   rollout completes; through the gateway: Hello from Pantry …
other Pod in k08 → redis:   blocked (timed out)

Policies select peers by labels: podSelector for Pods in the same namespace, namespaceSelector (with kubernetes.io/metadata.name, which every namespace has) for other namespaces, or both together to mean "these Pods in that namespace". Denied traffic is dropped, so it shows up as a timeout, not a refusal.

Rule Put a default-deny policy (Ingress and Egress) in every application namespace, allow DNS egress to kube-dns explicitly, then allow each required flow by label.

Why NetworkPolicies are allow-lists once a Pod is selected, so DNS, like every other flow, must be listed or it is blocked.

Trap Right after adding NetworkPolicies, new Pods fail with Temporary failure in name resolution or time out to every name. Egress was denied without the DNS rule.

Rung 8 NetworkPolicy details that cause outages

Three measured details:

  • Ports are the Pod's ports, not the Service's. A rule allowing port: 80 (the Service port) blocked the gateway, because the Pods listen on 8080. The response went from 200 to a timeout. Use the named port (port: http) and the mismatch cannot happen.
  • kubectl port-forward ignores NetworkPolicy. With deny-all in place, port-forward to a Pod still answered alive, because the kubelet connects inside the Pod's network namespace. "It works with port-forward" proves nothing about the policy.
  • Established connections can outlive a new policy. Pods with an open Redis connection stayed Ready after deny-all was applied, while new Pods could not connect. A policy change can look harmless until the next restart.
      ports: [{port: http}]                 # the container port's name, never the Service port

Rule Reference Pod ports by name in policies, test policies with real traffic through the real path (not port-forward), and restart a workload after changing its policies to see the steady state.

Why Policies match packets at the Pod, by the Pod's port, after Service translation. port-forward does not traverse that path, and existing flows may be tracked from before the change.

Trap A policy change "passes testing" with kubectl port-forward and breaks production traffic through the gateway. The port in the rule is the Service port, or the gateway's namespace and Pod labels do not match the from selector.

Decide

Need Use
Internal service-to-service ClusterIP + DNS name svc.namespace
Per-Pod DNS (databases) headless Service + StatefulSet
External HTTP(S) Gateway + HTTPRoute (one shared Gateway per environment)
External TCP/UDP Gateway TCPRoute/UDPRoute, or a LoadBalancer Service
Client IP at the app X-Forwarded-For from the gateway, or externalTrafficPolicy: Local behind a health-checking LB
Canary / weighted traffic HTTPRoute backendRefs with weight
Isolation default-deny + DNS + explicit allows, per namespace
Existing Ingress YAML keep running where maintained, and migrate to Gateway API (Ingress2Gateway)

The golden file

Pantry's network, owned by three teams: the GatewayClass and proxy exposure (infrastructure), the shared Gateway with namespace-restricted routes (platform), and the app's Services, HTTPRoute and NetworkPolicies (app team: default-deny, DNS, and exactly the flows Pantry needs).

examples/manual/k08/gatewayclass.yaml
# Infrastructure team: which gateway implementation, and how its proxy is exposed.
# On kind, the proxy's Service is a NodePort on 30090 (mapped to 127.0.0.1:30090 by kind-multinode.yaml,
# on one worker only, which is why the traffic policy below matters).
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata: {name: kind-nodeport, namespace: envoy-gateway-system}
spec:
  provider:
    type: Kubernetes
    kubernetes:
      envoyService:
        type: NodePort
        externalTrafficPolicy: Cluster     # Envoy Gateway defaults to Local: only the node running the proxy would answer
        patch:
          type: StrategicMerge
          value:
            spec:
              ports: [{port: 80, nodePort: 30090}]
---
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata: {name: eg}
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
  parametersRef: {group: gateway.envoyproxy.io, kind: EnvoyProxy, name: kind-nodeport, namespace: envoy-gateway-system}
examples/manual/k08/gateway.yaml
# Platform team: one shared entry point, and which namespaces may attach routes to it.
apiVersion: v1
kind: Namespace
metadata:
  name: gateway
  labels: {pod-security.kubernetes.io/enforce: restricted, pod-security.kubernetes.io/enforce-version: v1.37}
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: {name: public, namespace: gateway}
spec:
  gatewayClassName: eg
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: Selector              # only namespaces the platform team has labelled
          selector:
            matchLabels: {pantry.example.com/gateway-access: "true"}
examples/manual/k08/pantry.yaml
# App team: Pantry, its Redis, and the route that attaches it to the shared Gateway.
apiVersion: v1
kind: Namespace
metadata:
  name: k08
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: v1.37
    pantry.example.com/gateway-access: "true"     # granted by the platform team: routes here may attach
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: redis, namespace: k08}
spec:
  selector: {matchLabels: {app.kubernetes.io/name: redis}}
  template:
    metadata: {labels: {app.kubernetes.io/name: redis}}
    spec:
      securityContext: {runAsNonRoot: true, runAsUser: 999, runAsGroup: 1000, fsGroup: 1000, seccompProfile: {type: RuntimeDefault}}
      containers:
        - name: redis
          image: redis:7-alpine
          ports: [{name: redis, containerPort: 6379}]
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
          volumeMounts: [{name: data, mountPath: /data}]
      volumes: [{name: data, emptyDir: {sizeLimit: 256Mi}}]
---
apiVersion: v1
kind: Service
metadata: {name: redis, namespace: k08}
spec:
  selector: {app.kubernetes.io/name: redis}
  ports: [{name: redis, port: 6379, targetPort: redis}]
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: pantry, namespace: k08}
spec:
  replicas: 2
  selector: {matchLabels: {app.kubernetes.io/name: pantry}}
  template:
    metadata: {labels: {app.kubernetes.io/name: pantry}}
    spec:
      enableServiceLinks: false
      securityContext: {runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, fsGroup: 10001, seccompProfile: {type: RuntimeDefault}}
      containers:
        - name: web
          image: pantry-manual:d05
          imagePullPolicy: IfNotPresent
          ports: [{name: http, containerPort: 8080}]
          env: [{name: REDIS_HOST, value: redis}]
          readinessProbe: {httpGet: {path: /healthz, port: http}, periodSeconds: 3}
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
          volumeMounts: [{name: tmp, mountPath: /tmp}]
      volumes: [{name: tmp, emptyDir: {medium: Memory, sizeLimit: 16Mi}}]
---
apiVersion: v1
kind: Service
metadata: {name: pantry, namespace: k08}
spec:
  selector: {app.kubernetes.io/name: pantry}
  ports: [{name: http, port: 80, targetPort: http}]    # targets the container port *named* http
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: pantry, namespace: k08}
spec:
  parentRefs:
    - {name: public, namespace: gateway, sectionName: http}
  hostnames: ["pantry.example.test"]
  rules:
    - matches: [{path: {type: PathPrefix, value: /}}]
      backendRefs: [{name: pantry, port: 80}]
examples/manual/k08/networkpolicy.yaml
# Default deny, then exactly the flows Pantry needs. Order does not matter: policies are additive.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: default-deny, namespace: k08}
spec:
  podSelector: {}                           # every Pod in the namespace
  policyTypes: [Ingress, Egress]            # nothing in, nothing out, unless allowed below
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: allow-dns, namespace: k08}
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}
          podSelector: {matchLabels: {k8s-app: kube-dns}}
      ports: [{port: 53, protocol: UDP}, {port: 53, protocol: TCP}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: pantry-web, namespace: k08}
spec:
  podSelector: {matchLabels: {app.kubernetes.io/name: pantry}}
  policyTypes: [Ingress, Egress]
  ingress:
    - from:                                 # only the gateway's proxy Pods may call Pantry
        - namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: envoy-gateway-system}}
          podSelector: {matchLabels: {gateway.envoyproxy.io/owning-gateway-name: public}}
      ports: [{port: http}]
  egress:
    - to:
        - podSelector: {matchLabels: {app.kubernetes.io/name: redis}}
      ports: [{port: 6379}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: redis, namespace: k08}
spec:
  podSelector: {matchLabels: {app.kubernetes.io/name: redis}}
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector: {matchLabels: {app.kubernetes.io/name: pantry}}
      ports: [{port: 6379}]
kubectl apply -f gatewayclass.yaml -f gateway.yaml && kubectl -n gateway wait --for=condition=Programmed gateway/public
kubectl apply -f pantry.yaml -f networkpolicy.yaml && kubectl -n k08 rollout status deploy/pantry
curl -s -H "Host: pantry.example.test" http://127.0.0.1:30090/          # Hello from Pantry …

Review checklist

Spot the bug

"Exposes Pantry on bug.example.test and locks the namespace down." Applied next to the golden Pantry Deployment.

examples/manual/k08/bug/exposure.yaml
apiVersion: v1
kind: Service
metadata: {name: pantry-bug, namespace: k08}
spec:
  type: LoadBalancer
  selector: {app.kubernetes.io/name: pantry}
  ports: [{port: 80, targetPort: web}]
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: pantry-bug, namespace: k08}
spec:
  parentRefs: [{name: public}]
  hostnames: ["bug.example.test"]
  rules: [{backendRefs: [{name: pantry-bug, port: 80}]}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: lockdown, namespace: k08}
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]
  ingress:
    - from:
        - namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: envoy-gateway-system}}
      ports: [{port: 80}]
  egress:
    - to:
        - podSelector: {matchLabels: {app.kubernetes.io/name: redis}}

This file has 5 problems.

Show answers
  1. parentRefs: [{name: public}] has no namespace, so it means a Gateway public in k08, which does not exist. The route got no status, and bug.example.test returned 404. Add namespace: gateway.
  2. targetPort: web matches no container port. The EndpointSlice had ports: null, and the gateway returned 503. Use targetPort: http.
  3. The ingress rule allows port: 80, the Service's port, but policies match the Pod's port (8080). The gateway timed out until the rule said port: 8080 (better: port: http).
  4. Egress is denied except to Redis, with no DNS rule. Existing Pods kept working, but after a restart new Pods failed with Temporary failure in name resolution and stayed 0/1. Allow UDP and TCP 53 to kube-dns.
  5. type: LoadBalancer gives a second, direct way in that bypasses the Gateway. On kind it stayed EXTERNAL-IP <pending>, and on a cloud it would create a public load balancer. Use ClusterIP and route through the Gateway.

Kubernetes manual · 9 · 9 min readIdentity and Access#

In the story: the key cards that say who may open which door · Districts, Key Cards and Rush Hour

Two kinds of client talk to the Kubernetes API: ServiceAccounts (workloads in the cluster) and users (humans and CI, authenticated outside the cluster). Both are authorised the same way, by RBAC: Roles list what may be done, and bindings say who may do it. The default is deny, but the defaults around it are generous, and the common mistakes, a token nobody needs, a ClusterRoleBinding where a Role belonged, a wildcard "to save time", all grant far more than intended. This chapter builds least privilege and ends with Pantry's whole namespace in one file.

projected token
(if automounted)

Bearer token

member of group
pantry-oncall

RoleBinding

allow / 403

ServiceAccount
pantry-web

Pantry Pod

API server:
authenticate → authorize (RBAC) → admit

human / CI
(cert, OIDC)

Role pantry-oncall
get/list/watch + patch deploy/pantry

resources in this namespace

The ladder

Rung 1 ServiceAccount tokens, and turning them off

Every Pod runs as a ServiceAccount (the namespace's default if none is named). By default the kubelet mounts a short-lived, audience-scoped token at /var/run/secrets/kubernetes.io/serviceaccount/token, which the Pod can use to call the API. Most Pods never call the API, so the token is pure attack surface. Turn it off:

apiVersion: v1
kind: ServiceAccount
metadata: {name: pantry-web, namespace: k09}
automountServiceAccountToken: false
with automount off: ls /var/run/secrets/kubernetes.io/serviceaccount → exit code 1 (directory absent)
default token claims: aud [https://kubernetes.default.svc...], exp−iat = 1 year (bound to the Pod), warnafter = iat + ~1h
token after the Pod is deleted: 401                  (bound tokens die with the Pod)

Modern projected tokens are bound to the Pod and rotated hourly by the kubelet, so a stolen one is far less useful than the old, never-expiring Secret tokens (Old Maps). Set automountServiceAccountToken: false on the ServiceAccount (covering every Pod that uses it) or on the Pod spec.

Rule Set automountServiceAccountToken: false on every ServiceAccount, and turn it back on per Pod only where the workload calls the API.

Why A mounted token is a credential inside the container. A Pod that never uses it loses nothing by not having it, and an attacker who lands in the container loses a starting point.

Trap A compromised web Pod can list Secrets cluster-wide. It had an automounted token and the default ServiceAccount had been granted broad rights. kubectl get pod -o jsonpath='{.spec.automountServiceAccountToken}' and the SA's bindings show it.

Rung 2 A ServiceAccount per workload

The default ServiceAccount is shared by every Pod in the namespace that names none, so anything granted to default is granted to all of them. Give each workload its own:

    spec:
      serviceAccountName: pantry-web
      automountServiceAccountToken: false

Then each workload's rights are exactly its own, bindings read clearly, and an audit of "what can the web tier do" is a question about one ServiceAccount. Granting rights to default is the opposite: invisible, and inherited by every Pod that forgot to set serviceAccountName.

Rule Create a named ServiceAccount per workload and set serviceAccountName on the Pod spec. Never grant rights to the default ServiceAccount.

Why Permissions attach to a ServiceAccount, and a shared account spreads whatever it is granted to every Pod that uses it.

Trap A new Pod unexpectedly has API access it never asked for. It uses default, which someone bound a role to long ago. kubectl get rolebindings,clusterrolebindings -A -o wide | grep ':default' finds them.

Rung 3 Roles, bindings, and resourceNames

A Role is a list of rules: API groups, resources, verbs, and optionally resourceNames. A RoleBinding grants a Role to subjects (ServiceAccounts, users, groups) in one namespace. Least privilege down to a single object:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: {name: read-pantry-settings, namespace: k09}
rules:
  - apiGroups: [""]
    resources: [configmaps]
    resourceNames: [pantry-settings]        # this one object, nothing else
    verbs: [get, watch]
get configmap/pantry-settings  yes
get configmap/other            no
list configmaps                no          ← list/watch cannot be limited by resourceNames
get secrets                    no

resourceNames restricts get, update, patch and delete to named objects, but not list or watch, because those return collections. So a role that can list configmaps can read every configmap, resourceNames or not. Grant list/watch deliberately. RBAC is additive and has no deny rules: a subject may do the union of everything bound to it.

Rule Write Roles that name the exact apiGroups, resources and verbs needed, use resourceNames for single-object access, and never grant list/watch on Secrets casually.

Why RBAC only adds permissions, and list/watch ignore resourceNames, so a "read one ConfigMap" role that uses list reads them all.

Trap A role meant to read one object reads every object of that kind. It granted list or watch, which resourceNames does not constrain.

Rung 4 Role versus ClusterRole, RoleBinding versus ClusterRoleBinding

The scope comes from the binding, and this is the most consequential RBAC mistake:

Role type Binding Grants
Role RoleBinding the Role's rules, in that one namespace
ClusterRole RoleBinding the ClusterRole's rules, in that one namespace
ClusterRole ClusterRoleBinding the ClusterRole's rules, in every namespace and cluster-wide

A ClusterRole bound with a RoleBinding is the useful, safe pattern: define view/edit-style rules once, grant them per namespace. A ClusterRole bound with a ClusterRoleBinding grants them everywhere. The spot-the-bug file binds the built-in edit ClusterRole to a ServiceAccount with a ClusterRoleBinding:

SA pantry (ClusterRoleBinding → edit):  get secrets -n kube-system: yes ;  get secrets -n k09: yes

The web app can now read every Secret in the cluster. The fix is a RoleBinding in its own namespace. Use ClusterRoleBinding only for genuinely cluster-wide subjects (controllers, node agents), and keep its roles minimal.

Rule Grant namespaced access with a RoleBinding (to a Role or a ClusterRole), and reserve ClusterRoleBinding for truly cluster-wide needs.

Why A ClusterRoleBinding ignores namespaces, so it grants its role in all of them at once, including kube-system.

Trap A workload or user can act in namespaces it should never touch. kubectl get clusterrolebindings -o wide shows a binding to a namespaced subject. Replace it with a RoleBinding per namespace.

Rung 5 Built-in roles and least privilege

Kubernetes ships aggregated ClusterRoles: view (read, no Secrets), edit (write, no RBAC), admin (edit plus RBAC within a namespace), cluster-admin (everything). They are convenient and usually too broad:

view → get secrets:  no
edit → get secrets:  yes          ← edit can read Secrets
edit → create pods:  yes

edit reading Secrets surprises people. For an on-call human who should restart a rollout and read logs but never read Secrets, a purpose-built Role is safer than edit. The golden bundle's pantry-oncall Role allows read on the workload resources, logs, and patch on the one Deployment named pantry:

on-call (group pantry-oncall) in k09:
  get pods yes ; get pods/log yes ; get secrets no ; patch deployment/pantry yes ; patch deployment/other no ; delete pods no ; create pods no

Rule Start from a purpose-built Role, use the built-in view/edit/admin only when their breadth is acceptable, and never bind cluster-admin to a workload.

Why The built-in roles are deliberately broad, and edit includes reading Secrets, which is rarely what "let them edit" is meant to allow.

Trap "We gave the team edit so they could deploy" also gave them every Secret in the namespace. Check with kubectl auth can-i get secrets --as=….

Rung 6 Permissions that grant more than they say

Some verbs are escalation paths, because they let a subject change the permission system itself:

  • create/patch on roles/rolebindings lets a subject grant itself anything (RBAC prevents granting more than you hold, but escalate/bind verbs override that check).
  • create pods in a namespace means mounting any ServiceAccount there and using its token, and setting nodeName to skip the scheduler.
  • get/list secrets includes ServiceAccount tokens and TLS keys.
  • create pods/exec, pods/attach, pods/portforward is a shell in any Pod.
  • impersonate (users, groups, serviceaccounts) is acting as anyone.
  • escalate/bind on roles, approve on certificatesigningrequests, write on validatingwebhookconfigurations are cluster-takeover primitives.

The spot-the-bug file's wildcard Role (apiGroups ["*"], resources ["*"], verbs ["*"]) on the default ServiceAccount grants all of these in its namespace:

SA default (wildcard Role) → delete rolebindings: yes ;  create roles: yes

Treat these verbs as privileged wherever they appear, and never use resource or verb wildcards in application roles.

Rule Audit for create pods, Secret read, exec/attach, impersonate, write on roles/bindings/webhooks and * wildcards, and grant them only to trusted operators.

Why These permissions let a subject reach other identities, run code in other Pods, or edit RBAC itself, so they are effectively administrative however narrowly the manifest reads.

Trap A seemingly modest role "to let the CI deploy" includes create pods and create secrets, which together let it mint and mount privileged identities. Review the verbs, not the role's name.

Rung 7 imagePullSecrets and workload identity

Two more things attach to a ServiceAccount. imagePullSecrets on the SA are added to every Pod that uses it, so private-registry credentials live in one place:

ServiceAccount with imagePullSecrets [regcred] → Pod spec.imagePullSecrets: [{"name":"regcred"}]  (injected)
apiVersion: v1
kind: ServiceAccount
metadata: {name: pantry-web, namespace: k09}
automountServiceAccountToken: false
imagePullSecrets: [{name: regcred}]        # added to every Pod using this SA

And for workloads that do call cloud APIs, the ServiceAccount is the identity to federate: a projected token with a cloud audience, exchanged for cloud credentials (IRSA on EKS, Workload Identity on GKE, Azure Workload Identity). That keeps long-lived cloud keys out of the cluster entirely. It is cloud-specific, so it is out of scope here, but the principle is the same: the Pod's identity is its ServiceAccount.

Rule Put imagePullSecrets on the ServiceAccount, and federate the ServiceAccount to the cloud's identity system rather than storing cloud keys in Secrets.

Why Both are properties of the workload's identity, so attaching them to the ServiceAccount keeps credentials in one audited place and out of Pod specs.

Trap The same registry secret is pasted into every Deployment, and rotating it means editing them all. Move it to the ServiceAccount's imagePullSecrets.

Rung 8 Humans: groups, impersonation and can-i

Kubernetes does not store users. It trusts an external authenticator (client certs, OIDC, a cloud IAM) to assert a username and groups, and RBAC binds to those. Bind Roles to groups, not individuals, so joining the team is adding a person to a group, not editing RBAC:

subjects: [{kind: Group, name: pantry-oncall, apiGroup: rbac.authorization.k8s.io}]

Two commands make RBAC debuggable without handing out credentials:

kubectl auth whoami                                               # who the API thinks you are
kubectl auth can-i patch deploy/pantry -n k09 --as=alice --as-group=pantry-oncall
kubectl auth can-i --list -n k09 --as=system:serviceaccount:k09:pantry-web
kubectl auth whoami → Username kubernetes-admin, Groups [kubeadm:cluster-admins system:authenticated]

--as/--as-group impersonate a subject to test what they can do (it needs the impersonate permission, which is itself privileged, rung 6). can-i --list dumps everything a subject may do. Use them in CI to assert that a role grants no more than intended.

Rule Authenticate humans through your identity provider, bind Roles to groups, and verify every role with kubectl auth can-i --as/--as-group (ideally in CI).

Why Binding to groups keeps membership out of RBAC, and impersonation checks turn "does this role over-grant?" into a test you can run.

Trap Access was granted by binding to individual usernames, and an offboarded employee's access lingers, or a role grants more than anyone realised. Bind to groups, and audit with can-i --list.

Decide

Subject Identity Authorisation
App that never calls the API (Pantry) own SA, automountServiceAccountToken: false no RBAC
Controller / operator own SA, token on the minimal ClusterRole it needs
App calling cloud APIs own SA federated to cloud IAM cloud-side
On-call humans identity provider group purpose-built Role via RoleBinding
Namespace admins group admin ClusterRole via RoleBinding
CI deploying one namespace SA or user Role via RoleBinding, no create pods + create secrets together
Cluster operators group cluster-admin, few people, audited

The golden file

Pantry's entire namespace, assembled from K1–K9: PSA labels and quotas (K3, K5), a ServiceAccount per workload with tokens off (K9), an on-call Role bound to a group, Redis as a StatefulSet (K7), the web Deployment with probes, resources, spread and a priority class (K2–K5), the Service, HPA and PDBs (K4, K5, K8), the HTTPRoute and NetworkPolicies (K8).

examples/manual/k09/bundle.yaml
# Pantry's whole namespace in one file: every golden piece from K1–K9.
#   kubectl apply --server-side -f bundle.yaml
# Needs: the "eg" GatewayClass and gateway/public Gateway from k08, and metrics-server for the HPA.
# Objects are ordered by dependency: cluster-scoped, Namespace, guard-rails, identity, data, app, edges.
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata: {name: pantry-web}
value: 100000
description: "Customer-facing Pantry web Pods."
---
apiVersion: v1
kind: Namespace
metadata:
  name: k09
  labels:
    pod-security.kubernetes.io/enforce: restricted       # K3
    pod-security.kubernetes.io/enforce-version: v1.37
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/audit: restricted
    pantry.example.com/gateway-access: "true"            # K8: routes here may attach to gateway/public
---
apiVersion: v1
kind: ResourceQuota                                       # K5: the namespace's ceiling
metadata: {name: ceiling, namespace: k09}
spec:
  hard: {requests.cpu: "4", requests.memory: 4Gi, limits.memory: 8Gi, pods: "40"}
---
apiVersion: v1
kind: LimitRange                                          # K5: defaults for anything that states nothing
metadata: {name: defaults, namespace: k09}
spec:
  limits:
    - type: Container
      defaultRequest: {cpu: 50m, memory: 64Mi}
      default: {memory: 256Mi}
---
# K9: one identity per workload, no API token unless it needs one
apiVersion: v1
kind: ServiceAccount
metadata: {name: pantry-web, namespace: k09}
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata: {name: redis, namespace: k09}
automountServiceAccountToken: false
---
# K9: what the on-call humans may do here: read workloads and logs, restart rollouts, never read Secrets
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: {name: pantry-oncall, namespace: k09}
rules:
  - apiGroups: [""]
    resources: [pods, pods/log, services, endpoints, events, configmaps, persistentvolumeclaims]
    verbs: [get, list, watch]
  - apiGroups: [apps]
    resources: [deployments, statefulsets, replicasets]
    verbs: [get, list, watch]
  - apiGroups: [apps]
    resources: [deployments]
    resourceNames: [pantry]
    verbs: [patch]                                        # kubectl rollout restart deploy/pantry, nothing else
  - apiGroups: [discovery.k8s.io]
    resources: [endpointslices]
    verbs: [get, list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: {name: pantry-oncall, namespace: k09}
subjects: [{kind: Group, name: pantry-oncall, apiGroup: rbac.authorization.k8s.io}]
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: Role, name: pantry-oncall}
---
# K7: Redis with stable storage
apiVersion: v1
kind: Service
metadata: {name: redis, namespace: k09}
spec:
  clusterIP: None
  selector: {app.kubernetes.io/name: redis}
  ports: [{name: redis, port: 6379, targetPort: redis}]
---
apiVersion: apps/v1
kind: StatefulSet
metadata: {name: redis, namespace: k09}
spec:
  serviceName: redis
  replicas: 1
  selector: {matchLabels: {app.kubernetes.io/name: redis}}
  persistentVolumeClaimRetentionPolicy: {whenDeleted: Retain, whenScaled: Retain}
  template:
    metadata: {labels: {app.kubernetes.io/name: redis}}
    spec:
      serviceAccountName: redis
      automountServiceAccountToken: false
      enableServiceLinks: false
      terminationGracePeriodSeconds: 30
      securityContext: {runAsNonRoot: true, runAsUser: 999, runAsGroup: 1000, fsGroup: 1000, fsGroupChangePolicy: OnRootMismatch, seccompProfile: {type: RuntimeDefault}}
      containers:
        - name: redis
          image: redis:7-alpine
          args: ["redis-server", "--appendonly", "yes", "--dir", "/data"]
          ports: [{name: redis, containerPort: 6379}]
          readinessProbe: {exec: {command: ["redis-cli", "ping"]}, periodSeconds: 5}
          resources: {requests: {cpu: 50m, memory: 64Mi}, limits: {memory: 192Mi}}
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
          volumeMounts: [{name: data, mountPath: /data}]
  volumeClaimTemplates:
    - metadata: {name: data}
      spec:
        accessModes: [ReadWriteOncePod]
        storageClassName: standard
        resources: {requests: {storage: 1Gi}}
---
# K2–K5: the web tier
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pantry
  namespace: k09
  labels: {app.kubernetes.io/name: pantry, app.kubernetes.io/component: web, app.kubernetes.io/part-of: pantry}
spec:
  revisionHistoryLimit: 5
  minReadySeconds: 5
  progressDeadlineSeconds: 120
  strategy: {type: RollingUpdate, rollingUpdate: {maxSurge: 1, maxUnavailable: 0}}
  selector: {matchLabels: {app.kubernetes.io/name: pantry, app.kubernetes.io/component: web}}
  template:
    metadata:
      labels: {app.kubernetes.io/name: pantry, app.kubernetes.io/component: web, app.kubernetes.io/part-of: pantry}
    spec:
      serviceAccountName: pantry-web
      automountServiceAccountToken: false
      enableServiceLinks: false
      priorityClassName: pantry-web
      terminationGracePeriodSeconds: 30
      securityContext: {runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, fsGroup: 10001, seccompProfile: {type: RuntimeDefault}}
      topologySpreadConstraints:
        - {maxSkew: 1, topologyKey: topology.kubernetes.io/zone, whenUnsatisfiable: DoNotSchedule, matchLabelKeys: [pod-template-hash],
           labelSelector: {matchLabels: {app.kubernetes.io/name: pantry, app.kubernetes.io/component: web}}}
        - {maxSkew: 1, topologyKey: kubernetes.io/hostname, whenUnsatisfiable: ScheduleAnyway, matchLabelKeys: [pod-template-hash],
           labelSelector: {matchLabels: {app.kubernetes.io/name: pantry, app.kubernetes.io/component: web}}}
      containers:
        - name: web
          image: pantry-manual:d05
          imagePullPolicy: IfNotPresent
          ports: [{name: http, containerPort: 8080}]
          env: [{name: REDIS_HOST, value: redis}]
          resources: {requests: {cpu: 100m, memory: 128Mi}, limits: {memory: 256Mi}}
          startupProbe: {httpGet: {path: /livez, port: http}, periodSeconds: 2, failureThreshold: 30}
          readinessProbe: {httpGet: {path: /healthz, port: http}, periodSeconds: 3, failureThreshold: 2}
          livenessProbe: {httpGet: {path: /livez, port: http}, periodSeconds: 10, failureThreshold: 3}
          lifecycle: {preStop: {sleep: {seconds: 5}}}
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: ["ALL"]}}
          volumeMounts: [{name: tmp, mountPath: /tmp}]
      volumes:
        - {name: tmp, emptyDir: {medium: Memory, sizeLimit: 16Mi}}
---
apiVersion: v1
kind: Service
metadata: {name: pantry, namespace: k09}
spec:
  selector: {app.kubernetes.io/name: pantry, app.kubernetes.io/component: web}
  ports: [{name: http, port: 80, targetPort: http}]
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: {name: pantry, namespace: k09}
spec:
  scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: pantry}
  minReplicas: 3
  maxReplicas: 10
  metrics:
    - type: Resource
      resource: {name: cpu, target: {type: Utilization, averageUtilization: 70}}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: {name: pantry, namespace: k09}
spec:
  maxUnavailable: 1
  selector: {matchLabels: {app.kubernetes.io/name: pantry, app.kubernetes.io/component: web}}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: {name: redis, namespace: k09}
spec:
  maxUnavailable: 1                                       # one replica: a drain may move it, never blocks forever
  selector: {matchLabels: {app.kubernetes.io/name: redis}}
---
# K8: the way in, and the only flows allowed
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: pantry, namespace: k09}
spec:
  parentRefs: [{name: public, namespace: gateway, sectionName: http}]
  hostnames: ["pantry-k09.example.test"]
  rules: [{matches: [{path: {type: PathPrefix, value: /}}], backendRefs: [{name: pantry, port: 80}]}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: default-deny, namespace: k09}
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: allow-dns, namespace: k09}
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to: [{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}, podSelector: {matchLabels: {k8s-app: kube-dns}}}]
      ports: [{port: 53, protocol: UDP}, {port: 53, protocol: TCP}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: pantry-web, namespace: k09}
spec:
  podSelector: {matchLabels: {app.kubernetes.io/name: pantry}}
  policyTypes: [Ingress, Egress]
  ingress:
    - from: [{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: envoy-gateway-system}}, podSelector: {matchLabels: {gateway.envoyproxy.io/owning-gateway-name: public}}}]
      ports: [{port: http}]
  egress:
    - to: [{podSelector: {matchLabels: {app.kubernetes.io/name: redis}}}]
      ports: [{port: 6379}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: redis, namespace: k09}
spec:
  podSelector: {matchLabels: {app.kubernetes.io/name: redis}}
  policyTypes: [Ingress]
  ingress:
    - from: [{podSelector: {matchLabels: {app.kubernetes.io/name: pantry}}}]
      ports: [{port: 6379}]
kubectl apply --server-side -f bundle.yaml          # needs the k08 GatewayClass + Gateway and metrics-server
kubectl -n k09 rollout status sts/redis && kubectl -n k09 rollout status deploy/pantry
curl -s -H "Host: pantry-k09.example.test" http://127.0.0.1:30090/
kubectl auth can-i get secrets -n k09 --as=alice --as-group=pantry-oncall      # no

On this cluster the bundle came up with Pods spread across both zones, served traffic through the Gateway, survived a drain of a worker with zero failed requests (PDB + spread), autoscaled from 3 to 6 under load, blocked an unlisted Pod from Redis, and let the on-call group restart the Deployment but not read Secrets.

Review checklist

Spot the bug

"Gives the Pantry app and the team the access they need."

examples/manual/k09/bug/rbac.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: k09-bug
  labels: {pod-security.kubernetes.io/enforce: restricted, pod-security.kubernetes.io/enforce-version: v1.37}
---
apiVersion: v1
kind: ServiceAccount
metadata: {name: pantry, namespace: k09-bug}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata: {name: pantry-edit}
subjects: [{kind: ServiceAccount, name: pantry, namespace: k09-bug}]
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: edit}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: {name: everything, namespace: k09-bug}
rules:
  - apiGroups: ["*"]
    resources: ["*"]
    verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: {name: everything, namespace: k09-bug}
subjects: [{kind: ServiceAccount, name: default, namespace: k09-bug}]
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: Role, name: everything}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: pantry, namespace: k09-bug}
spec:
  replicas: 1
  selector: {matchLabels: {app: pantry}}
  template:
    metadata: {labels: {app: pantry}}
    spec:
      securityContext: {runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, fsGroup: 10001, seccompProfile: {type: RuntimeDefault}}
      containers:
        - name: web
          image: pantry-manual:d05
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
          volumeMounts: [{name: tmp, mountPath: /tmp}]
      volumes: [{name: tmp, emptyDir: {medium: Memory, sizeLimit: 16Mi}}]

This file has 4 problems.

Show answers
  1. The pantry ServiceAccount is bound to the built-in edit ClusterRole with a ClusterRoleBinding, so it can read Secrets in every namespace (get secrets -n kube-system: yes). Use a RoleBinding in k09-bug, and a far narrower role.
  2. A Role grants apiGroups ["*"], resources ["*"], verbs ["*"] to the default ServiceAccount: every Pod with no serviceAccountName (including this Deployment's) can create roles and delete rolebindings in the namespace (rung 6). Remove it; grant specific rights to named SAs.
  3. The Deployment sets no serviceAccountName, so its Pods run as default and inherit that wildcard Role, and it does not set automountServiceAccountToken: false, so the token is mounted. Name a dedicated SA with the token off.
  4. Even corrected, the app needs no API access at all. Pantry never calls the API, so the right answer is its own ServiceAccount with no RBAC and no mounted token.

Kubernetes manual · 10 · 8 min readPackaging: Helm and Kustomize#

In the story: flat-pack furniture, and the same blueprint with a different overlay per district · Flat-Pack Furniture, Nobody Touches the City by Hand

The K9 bundle is one file for one environment. Real deployments need the same manifests across dev, staging and several production clusters, with a few differences each. The two standard tools take opposite approaches. Helm is templates plus values: one parameterised chart, installed with different values per environment, and a release you can roll back. Kustomize is a base plus overlays: plain manifests, patched per environment, with no templating language. This chapter builds the golden bundle both ways and says when to pick which.

Kustomize

base: plain manifests

overlay dev
patch

overlay prod
patch

kubectl apply -k

Helm

chart: templates +
values.yaml + values.schema.json

values-dev

values-prod

release pantry
revisions, rollback

The ladder

Rung 1 Chart layout

A chart is a directory with a fixed shape. Chart.yaml is metadata, values.yaml holds the defaults, templates/ holds the manifests, and templates/tests/ holds test hooks:

chart/
  Chart.yaml            name, version (the chart's), appVersion (the app's)
  values.yaml           defaults
  values.schema.json    validates values (rung 4)
  templates/
    _helpers.tpl        named template snippets
    deployment.yaml     …and one file per object
    tests/connection.yaml   a test hook (must be under templates/)
helm lint ./chart        → 1 chart(s) linted, 0 chart(s) failed
helm template r ./chart  → the rendered manifests, for review and diff

The two versions matter. version is the chart's own, bumped when the chart changes. appVersion is the application's, and it is the default image tag here. helm template renders without touching the cluster, which is what to read in code review and diff in CI. Files whose names start with _ (like _helpers.tpl) render nothing themselves; they define reusable snippets.

Rule Keep the standard layout, bump version on chart changes and appVersion on app releases, and review charts by reading helm template output.

Why Helm relies on the directory shape (templates/, templates/tests/, _ for partials), and helm template is the deterministic preview of what will be applied.

Trap helm test reports TEST SUITE: None though a test file exists. It is in a top-level tests/ directory; Helm only renders files under templates/. Move it to templates/tests/.

Rung 2 Helpers and the DRY labels

_helpers.tpl defines named templates for things repeated across files: the release-qualified name, the recommended labels (K1), the selector subset, the image reference:

examples/manual/k10/chart/templates/_helpers.tpl
{{/* The release-qualified name, truncated to the 63-char label limit. */}}
{{- define "pantry.fullname" -}}
{{- if contains .Chart.Name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}

{{/* Labels every object carries (the recommended app.kubernetes.io/* set, K1). */}}
{{- define "pantry.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
{{- end -}}

{{/* The stable subset selectors match on (never changes across upgrades, K1). */}}
{{- define "pantry.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}

{{/* The web image reference; tag falls back to the chart's appVersion. */}}
{{- define "pantry.image" -}}
{{- printf "%s:%s" .Values.image.repository (.Values.image.tag | default .Chart.AppVersion) -}}
{{- end -}}
metadata:
  name: {{ include "pantry.fullname" . }}
  labels: {{- include "pantry.labels" . | nindent 4 }}

include "name" . renders a named template with the current context. nindent 4 adds a newline and indents each line by four spaces, which is how a multi-line block is placed under a key. The fullname helper uses the common idiom: when the release name already contains the chart name, use it alone, otherwise join them, so a release called pantry produces objects named pantry, not pantry-pantry. Keep the selector labels (name + instance) in their own helper, because a Deployment's selector is immutable (K1) and must never include volatile labels like version.

Rule Define the fullname, full label set, selector subset and image reference as helpers, and build every object's metadata and selectors from them.

Why One definition of the labels keeps every object consistent and keeps the immutable selector stable across upgrades.

Trap An upgrade fails with field is immutable on the Deployment selector. A helper used for both the selector and the labels included a changing value (the chart version or app version). Keep the selector helper minimal.

Rung 3 Template the right things, and only those

Values should cover what differs between environments: replica count, image tag, resources, whether optional pieces (an HTTPRoute, autoscaling) are on. Everything that is the same everywhere (the security context, probes, the preStop hook) stays literal in the template. Over-parameterising a chart makes it as hard to read as the manifests it replaced.

examples/manual/k10/chart/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "pantry.fullname" . }}
  labels: {{- include "pantry.labels" . | nindent 4 }}
    app.kubernetes.io/component: web
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}      {{/* omitted when the HPA owns it, K1/K5 */}}
  {{- end }}
  revisionHistoryLimit: 5
  minReadySeconds: 5
  progressDeadlineSeconds: 120
  strategy:
    type: RollingUpdate
    rollingUpdate: {maxSurge: 1, maxUnavailable: 0}
  selector:
    matchLabels: {{- include "pantry.selectorLabels" . | nindent 6 }}
      app.kubernetes.io/component: web
  template:
    metadata:
      labels: {{- include "pantry.selectorLabels" . | nindent 8 }}
        app.kubernetes.io/component: web
    spec:
      serviceAccountName: {{ include "pantry.fullname" . }}-web
      automountServiceAccountToken: false
      enableServiceLinks: false
      terminationGracePeriodSeconds: 30
      securityContext: {runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, fsGroup: 10001, seccompProfile: {type: RuntimeDefault}}
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          matchLabelKeys: [pod-template-hash]
          labelSelector:
            matchLabels: {{- include "pantry.selectorLabels" . | nindent 14 }}
              app.kubernetes.io/component: web
      containers:
        - name: web
          image: {{ include "pantry.image" . | quote }}
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports: [{name: http, containerPort: 8080}]
          env: [{name: REDIS_HOST, value: {{ include "pantry.fullname" . }}-redis}]
          resources: {{- toYaml .Values.resources | nindent 12 }}
          startupProbe: {httpGet: {path: /livez, port: http}, periodSeconds: 2, failureThreshold: 30}
          readinessProbe: {httpGet: {path: /healthz, port: http}, periodSeconds: 3, failureThreshold: 2}
          livenessProbe: {httpGet: {path: /livez, port: http}, periodSeconds: 10, failureThreshold: 3}
          lifecycle: {preStop: {sleep: {seconds: 5}}}
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: ["ALL"]}}
          volumeMounts: [{name: tmp, mountPath: /tmp}]
      volumes:
        - {name: tmp, emptyDir: {medium: Memory, sizeLimit: 16Mi}}

Two patterns here. {{- if not .Values.autoscaling.enabled }}replicas:{{- end }} omits replicas entirely when the HPA owns it (K1), instead of fighting over it. {{- toYaml .Values.resources | nindent 12 }} renders a whole values sub-tree as YAML, which is how a map value reaches the manifest. Writing {{ .Values.resources }} instead prints Go's map[...] representation and breaks the render (the spot-the-bug does exactly that).

Rule Parameterise only what changes between environments, keep invariant hardening literal, and render map and list values with toYaml | nindent.

Why A small values surface is readable and safe, and toYaml is the only correct way to emit a structured value.

Trap helm template fails with error converting YAML to JSON or a manifest shows map[cpu:100m ...]. A structured value was emitted with {{ .Values.x }} instead of {{ toYaml .Values.x | nindent N }}.

Rung 4 values.schema.json catches bad values before the cluster does

A chart can ship a JSON Schema that Helm validates values against, at template, install and upgrade:

examples/manual/k10/chart/values.schema.json
{
  "$schema": "https://json-schema.org/draft-07/schema#",
  "type": "object",
  "additionalProperties": false,
  "required": ["replicaCount", "image", "redis", "resources", "autoscaling", "route"],
  "properties": {
    "replicaCount": {"type": "integer", "minimum": 1},
    "image": {
      "type": "object",
      "additionalProperties": false,
      "required": ["repository", "pullPolicy"],
      "properties": {
        "repository": {"type": "string", "minLength": 1},
        "tag": {"type": "string"},
        "pullPolicy": {"enum": ["Always", "IfNotPresent", "Never"]}
      }
    },
    "redis": {
      "type": "object",
      "additionalProperties": false,
      "required": ["image"],
      "properties": {"image": {"type": "string", "minLength": 1}}
    },
    "resources": {
      "type": "object",
      "required": ["requests", "limits"],
      "properties": {
        "requests": {"type": "object", "required": ["cpu", "memory"]},
        "limits": {"type": "object", "required": ["memory"]}
      }
    },
    "autoscaling": {
      "type": "object",
      "additionalProperties": false,
      "required": ["enabled", "minReplicas", "maxReplicas", "targetCPUUtilizationPercentage"],
      "properties": {
        "enabled": {"type": "boolean"},
        "minReplicas": {"type": "integer", "minimum": 1},
        "maxReplicas": {"type": "integer", "minimum": 1},
        "targetCPUUtilizationPercentage": {"type": "integer", "minimum": 1, "maximum": 100}
      }
    },
    "route": {
      "type": "object",
      "additionalProperties": false,
      "required": ["enabled", "hostname", "gatewayName", "gatewayNamespace"],
      "properties": {
        "enabled": {"type": "boolean"},
        "hostname": {"type": "string", "format": "hostname"},
        "gatewayName": {"type": "string"},
        "gatewayNamespace": {"type": "string"}
      }
    }
  }
}
--set replicaCount=two       → Error: at '/replicaCount': got string, want integer
--set imagePullPolicy=Always → Error: values don't meet the schema   (additionalProperties: false rejects the typo)
helm install bad … --set replicaCount=two → INSTALLATION FAILED: values don't meet the schema

The schema turns "the chart does something odd with a wrong value" into "install fails with a clear message". additionalProperties: false is the most useful line: it rejects a mistyped value key (imagePullPolicy at the top level instead of image.pullPolicy), which is otherwise silently ignored. A schema cannot catch a typo in the template (a reference to .Values.appName that no one defined renders empty), so helpers and review still matter.

Rule Ship a values.schema.json with types, required, enums and additionalProperties: false, and rely on it to reject bad and misspelled values.

Why The schema validates every values source (defaults, files, --set) before anything is applied, turning wrong values into a failed command.

Trap A value set with --set foo.bar=x has no effect and no warning. The key is not where the chart reads it. additionalProperties: false in the schema would have rejected it.

Rung 5 Install, test, upgrade, roll back

A Helm release is a named, versioned installation. Each install/upgrade is a revision, and rollback goes back to one:

helm install pantry ./chart -n k10 --wait   → STATUS deployed, REVISION 1, 3 web pods + redis-0 Running
helm test pantry -n k10                      → TEST SUITE pantry-test, Phase Succeeded
helm upgrade pantry ./chart --set image.tag=d05 → REVISION 3, image pantry-manual:d05
helm rollback pantry 2 -n k10                → "Rollback was a success"; image back to pantry-manual:1.1.0
helm install pantry ./chart -n k10 --wait --timeout 3m
helm test pantry -n k10                       # runs the test hooks
helm history pantry -n k10                    # revisions
helm rollback pantry <revision> -n k10
helm uninstall pantry -n k10

--wait makes install and upgrade block until the workloads are ready (Helm 4 uses kstatus to decide), and fail otherwise, which is what a deploy pipeline wants. A test hook (helm.sh/hook: test) is a Pod Helm runs on helm test, for a smoke check against the live release. Helm stores each revision's manifests, so rollback re-applies an earlier one exactly.

Rule Install and upgrade with --wait, provide a helm test smoke check, and treat helm rollback as the recovery path, with chart revisions bumped so history is meaningful.

Why Releases and revisions give Kubernetes deployments the one thing raw kubectl apply lacks: a recorded, reversible history.

Trap helm upgrade --wait reports failure but the old Pods still serve, and a plain helm upgrade would have reported success while the new version crash-looped. Always --wait, and helm rollback on failure.

Rung 6 Kustomize: base plus overlays, no templating

Kustomize takes plain manifests (a base) and transforms them per environment (overlays) with patches, name prefixes, namespaces, image tags and replica counts. No templating language:

examples/manual/k10/kustomize/overlays/prod/kustomization.yaml
# Prod: more replicas, a pinned digest, and a strategic-merge patch adding the hardening the base omits.
namespace: pantry-prod
resources: [../../base]
replicas:
  - {name: pantry, count: 5}
patches:
  - path: harden.yaml
    target: {kind: Deployment, name: pantry}
examples/manual/k10/kustomize/overlays/prod/harden.yaml
# A strategic-merge patch: only the fields it names are changed, lists of containers merged by name.
apiVersion: apps/v1
kind: Deployment
metadata: {name: pantry}
spec:
  template:
    spec:
      containers:
        - name: web
          startupProbe: {httpGet: {path: /livez, port: http}, periodSeconds: 2, failureThreshold: 30}
          livenessProbe: {httpGet: {path: /livez, port: http}, periodSeconds: 10, failureThreshold: 3}
kubectl kustomize overlays/dev  → namespace pantry-dev,  replicas 1, image pantry-manual:d05
kubectl kustomize overlays/prod → namespace pantry-prod, replicas 5, image pantry-manual:1.1.0, + startup/liveness probes
kubectl apply -k overlays/dev   → deployment "pantry" successfully rolled out

Overlays set structured fields natively: images rewrites tags and digests, replicas sets counts, namespace moves everything, and patches apply strategic-merge or JSON 6902 patches. A strategic-merge patch (above) names only the fields it changes, and merges container lists by name, so adding two probes to the web container needs only those probes. kubectl apply -k and kubectl kustomize are built into kubectl; the standalone kustomize binary has the newest features. Prefer labels: over the deprecated commonLabels, with includeSelectors: false so the immutable selector is left alone.

Rule Keep a clean base and small overlays, use the typed transformers (images, replicas, namespace) for structured changes and strategic-merge patches for the rest, and leave selectors out of label transformers.

Why Kustomize layers plain YAML without a templating language, so the base stays valid and reviewable and each overlay shows exactly its environment's diff.

Trap kubectl apply -k fails with field is immutable on the selector. A labels/commonLabels transformer added labels to the selector too. Set includeSelectors: false.

Rung 7 Choosing between them, and hooks

You want Helm Kustomize
Distribute to others strong (chart repos, versioning) weaker (share a git ref)
Per-environment values values-<env>.yaml overlays
Validation of inputs values.schema.json none (plain YAML)
Release history and rollback built in none (git + kubectl apply)
Conditionals and loops templating none (that is the point)
Readable output helm template kubectl kustomize
Learning curve a templating language just YAML + patches
helm template pantry ./chart > rendered.yaml        # then a kustomization with resources: [rendered.yaml]
kustomize build .                                   # applies overlays on top

Many teams use both: Kustomize overlays on the manifests Helm renders (a kustomization whose resources is Helm's output), or a GitOps tool doing exactly that (the next chapter is debugging; Argo CD is Act III of the story). Helm hooks (helm.sh/hook: pre-install, post-upgrade, test) run Jobs at points in a release's life, for migrations and smoke tests. Use them sparingly: a hook that fails can wedge a release, and much of what hooks were used for (ordering, waiting) is better done with init containers, --wait and native sidecars (K2).

Rule Choose Helm when you distribute charts or need release history and input validation, Kustomize when you want plain, un-templated YAML, and combine them rather than reinventing either.

Why They solve different problems: Helm packages and versions a configurable application, Kustomize layers edits onto static manifests.

Trap A chart is so full of {{ if }} and nested values that no one can read helm template output. The configuration surface grew without limit. Split it, or switch the parts that only differ by field values to overlays.

Decide

Situation Tool
Internal app, a few environments Kustomize (simplest)
App shipped to other teams/customers Helm (versioned chart)
Needs input validation Helm + values.schema.json
Needs rollback by command Helm releases
Third-party app to tweak Kustomize overlay on their manifests, or a Helm chart's values
GitOps either, applied by Argo CD (the GitOps chapter)

The golden file

Pantry as a Helm chart: schema-validated values, helpers for the recommended labels and names, the full golden workload from K1–K9 as templates, an HPA that owns the replica count, a test hook, and the Kustomize equivalent alongside for comparison. The complete chart and overlays are under examples/manual/k10/.

examples/manual/k10/chart/values.yaml
# Pantry chart defaults. Every value is validated by values.schema.json.
replicaCount: 3                 # used only when autoscaling.enabled is false

image:
  repository: pantry-manual
  tag: ""                       # defaults to .Chart.AppVersion when empty
  pullPolicy: IfNotPresent

redis:
  image: redis:7-alpine

resources:
  requests: {cpu: 100m, memory: 128Mi}
  limits: {memory: 256Mi}

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

# The HTTPRoute from K8; off by default because it needs the shared Gateway.
route:
  enabled: false
  hostname: pantry.example.test
  gatewayName: public
  gatewayNamespace: gateway
examples/manual/k10/chart/values.schema.json
{
  "$schema": "https://json-schema.org/draft-07/schema#",
  "type": "object",
  "additionalProperties": false,
  "required": ["replicaCount", "image", "redis", "resources", "autoscaling", "route"],
  "properties": {
    "replicaCount": {"type": "integer", "minimum": 1},
    "image": {
      "type": "object",
      "additionalProperties": false,
      "required": ["repository", "pullPolicy"],
      "properties": {
        "repository": {"type": "string", "minLength": 1},
        "tag": {"type": "string"},
        "pullPolicy": {"enum": ["Always", "IfNotPresent", "Never"]}
      }
    },
    "redis": {
      "type": "object",
      "additionalProperties": false,
      "required": ["image"],
      "properties": {"image": {"type": "string", "minLength": 1}}
    },
    "resources": {
      "type": "object",
      "required": ["requests", "limits"],
      "properties": {
        "requests": {"type": "object", "required": ["cpu", "memory"]},
        "limits": {"type": "object", "required": ["memory"]}
      }
    },
    "autoscaling": {
      "type": "object",
      "additionalProperties": false,
      "required": ["enabled", "minReplicas", "maxReplicas", "targetCPUUtilizationPercentage"],
      "properties": {
        "enabled": {"type": "boolean"},
        "minReplicas": {"type": "integer", "minimum": 1},
        "maxReplicas": {"type": "integer", "minimum": 1},
        "targetCPUUtilizationPercentage": {"type": "integer", "minimum": 1, "maximum": 100}
      }
    },
    "route": {
      "type": "object",
      "additionalProperties": false,
      "required": ["enabled", "hostname", "gatewayName", "gatewayNamespace"],
      "properties": {
        "enabled": {"type": "boolean"},
        "hostname": {"type": "string", "format": "hostname"},
        "gatewayName": {"type": "string"},
        "gatewayNamespace": {"type": "string"}
      }
    }
  }
}
examples/manual/k10/chart/templates/_helpers.tpl
{{/* The release-qualified name, truncated to the 63-char label limit. */}}
{{- define "pantry.fullname" -}}
{{- if contains .Chart.Name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}

{{/* Labels every object carries (the recommended app.kubernetes.io/* set, K1). */}}
{{- define "pantry.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
{{- end -}}

{{/* The stable subset selectors match on (never changes across upgrades, K1). */}}
{{- define "pantry.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}

{{/* The web image reference; tag falls back to the chart's appVersion. */}}
{{- define "pantry.image" -}}
{{- printf "%s:%s" .Values.image.repository (.Values.image.tag | default .Chart.AppVersion) -}}
{{- end -}}
examples/manual/k10/chart/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "pantry.fullname" . }}
  labels: {{- include "pantry.labels" . | nindent 4 }}
    app.kubernetes.io/component: web
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}      {{/* omitted when the HPA owns it, K1/K5 */}}
  {{- end }}
  revisionHistoryLimit: 5
  minReadySeconds: 5
  progressDeadlineSeconds: 120
  strategy:
    type: RollingUpdate
    rollingUpdate: {maxSurge: 1, maxUnavailable: 0}
  selector:
    matchLabels: {{- include "pantry.selectorLabels" . | nindent 6 }}
      app.kubernetes.io/component: web
  template:
    metadata:
      labels: {{- include "pantry.selectorLabels" . | nindent 8 }}
        app.kubernetes.io/component: web
    spec:
      serviceAccountName: {{ include "pantry.fullname" . }}-web
      automountServiceAccountToken: false
      enableServiceLinks: false
      terminationGracePeriodSeconds: 30
      securityContext: {runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, fsGroup: 10001, seccompProfile: {type: RuntimeDefault}}
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          matchLabelKeys: [pod-template-hash]
          labelSelector:
            matchLabels: {{- include "pantry.selectorLabels" . | nindent 14 }}
              app.kubernetes.io/component: web
      containers:
        - name: web
          image: {{ include "pantry.image" . | quote }}
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports: [{name: http, containerPort: 8080}]
          env: [{name: REDIS_HOST, value: {{ include "pantry.fullname" . }}-redis}]
          resources: {{- toYaml .Values.resources | nindent 12 }}
          startupProbe: {httpGet: {path: /livez, port: http}, periodSeconds: 2, failureThreshold: 30}
          readinessProbe: {httpGet: {path: /healthz, port: http}, periodSeconds: 3, failureThreshold: 2}
          livenessProbe: {httpGet: {path: /livez, port: http}, periodSeconds: 10, failureThreshold: 3}
          lifecycle: {preStop: {sleep: {seconds: 5}}}
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: ["ALL"]}}
          volumeMounts: [{name: tmp, mountPath: /tmp}]
      volumes:
        - {name: tmp, emptyDir: {medium: Memory, sizeLimit: 16Mi}}
helm lint ./chart
helm install pantry ./chart -n k10 --create-namespace --wait          # image default is appVersion 1.1.0
helm test pantry -n k10
helm upgrade pantry ./chart -n k10 --set image.tag=d05 --wait
helm rollback pantry 1 -n k10                                          # back to 1.1.0
kubectl apply -k kustomize/overlays/prod                              # the Kustomize equivalent

Review checklist

Spot the bug

A Deployment template from a chart pull request. The chart ships the values.schema.json above.

examples/manual/k10/bug/deployment.yaml
# A Deployment template from a pull request. The chart has a values.schema.json; this file does not use the helpers.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pantry
  labels:
    app: {{ .Values.appName }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels: {app: pantry}
  template:
    metadata:
      labels: {app: pantry}
    spec:
      containers:
        - name: web
          image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
          resources: {{ .Values.resources }}

This file has 4 problems.

Show answers
  1. app: {{ .Values.appName }} references a value that does not exist (the key is not in the schema or values.yaml), so it renders app: empty. A schema cannot catch a template typo, only a values one. Use the pantry.labels helper.
  2. resources: {{ .Values.resources }} emits the resources map as Go's map[cpu:100m ...], which fails the render (error converting YAML to JSON). Use {{- toYaml .Values.resources | nindent 12 }}.
  3. image: {{ .Values.image.repository }}:{{ .Values.image.tag }} with the default empty tag renders pantry-manual: (no tag). Fall back to appVersion with a helper (default .Chart.AppVersion).
  4. The labels and selector are hand-written app: pantry instead of the recommended app.kubernetes.io/* helpers, and replicas is hardcoded even though the chart's HPA manages it. Use the helpers, and omit replicas when autoscaling is enabled.

Kubernetes manual · 11 · 8 min readDebugging#

In the story: reading the status words when the city stops cooperating · The Night It Fell Over, Again

When a workload will not run, Kubernetes almost always says why, in an event, a status field, or a log line. Debugging is knowing where it says it and in what order to look, so you reach the cause in four commands instead of forty. This chapter is a fixed triage order, the handful of commands that answer each step, and kubectl debug for the Pods that have no shell. Every output below comes from a namespace with five deliberately broken workloads.

no

yes

no

yes

no

yes

no

yes

workload not working

1. events
kubectl get events / describe

Pod scheduled?

FailedScheduling → requests, taints, affinity

image pulled?

ImagePullBackOff → tag, registry, pull secret

container stays up?

logs --previous → the crash

Ready?

readiness probe / dependency

Service endpoints, DNS, NetworkPolicy (K8)

The ladder

Rung 1 Events first, always

Most failures announce themselves as events. Read the namespace's warnings newest-last before anything else:

kubectl -n k11 get events --field-selector type=Warning --sort-by=.lastTimestamp
kubectl -n k11 describe pod <name>        # the Events: section at the bottom is per-object
FailedScheduling   toobig     0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 Insufficient cpu
Failed             badimage   Failed to pull image "pantry-manual:v9.9.9-does-not-exist": … pull access denied
BackOff            crasher    Back-off restarting failed container c
Unhealthy          pantry     Readiness probe failed: HTTP probe failed with statuscode: 503

Four different failures, each named in one line. kubectl get events is the namespace-wide view; the Events section of kubectl describe pod is the per-object view and usually the richer one. Events expire (an hour by default), so a problem that is hours old may need describe on the object instead. kubectl events --for deploy/pantry filters to one object's events.

Rule Start every investigation with the warning events, namespace-wide then on the specific object, before reading any logs or YAML.

Why The kubelet, scheduler and controllers report why they could not do something as events, so the cause is usually there in plain words.

Trap Time is lost reading logs for a Pod that never started. There are no application logs because there is no running container. kubectl describe pod shows a scheduling or image-pull event instead.

Rung 2 Read the Pod's status and phase

kubectl get pods plus the right columns tells you which stage failed. A STATUS word maps to a stage (Old Maps and chapter 15 list the full set):

kubectl -n k11 get pods -o wide
kubectl -n k11 get pod <name> -o custom-columns=\
PHASE:.status.phase,READY:.status.containerStatuses[0].ready,\
WAITING:.status.containerStatuses[0].state.waiting.reason,\
LASTEXIT:.status.containerStatuses[0].lastState.terminated.exitCode
badimage   Pending   false   ImagePullBackOff   -
crasher    Running   false   <none>             1       (restarts: 5)
toobig     Pending   <none>  <none>             -
pantry     Running   false   <none>             -       (readiness 503)
  • Pending with ImagePullBackOff/ErrImagePull: the image. Check the tag, registry and imagePullSecrets (K9).
  • Pending with no container status: scheduling. describe shows FailedScheduling (requests, taints, affinity, K5).
  • CrashLoopBackOff / Running with climbing restarts and lastExit: the container exits. Go to its logs (rung 3).
  • Running but not Ready: the readiness probe (rung 5), often a dependency.
  • CreateContainerConfigError / CreateContainerError: the spec, a missing ConfigMap/Secret key (K6), or a Pod Security violation (K3).

Rule Read phase, ready, the waiting reason and the last exit code to place the failure at a stage, then use the command for that stage.

Why Each stage (schedule, pull, start, become ready) fails with a distinct status, so the status tells you which command answers next.

Trap A Pod is "stuck" and the fix depends on which Pending it is. Image pull and unschedulable both show Pending; the container status (or its absence) and describe separate them.

Rung 3 Logs, and the previous container's logs

For a container that ran and exited, its current logs may be empty (it is restarting); the previous container's logs hold the crash:

kubectl -n k11 logs <pod>                      # the current container
kubectl -n k11 logs <pod> --previous           # the one that just crashed  ← the useful one for CrashLoop
kubectl -n k11 logs <pod> -c <container>        # a specific container (init, sidecar, K2)
kubectl -n k11 logs deploy/pantry --all-pods --tail=20   # across a Deployment's Pods
$ kubectl -n k11 logs crasher-… --previous
starting
config missing

The crasher exits 1 after printing config missing, which is the whole story. For init containers and sidecars, name the container with -c (a Pod stuck in Init: logs its init container, K2). --timestamps and --since=5m narrow a noisy log, and --all-pods/-l <selector> reads across a Deployment.

Rule For a crashing container, read logs --previous, and name the container with -c for init containers and sidecars.

Why A restarting container's current logs are from the new, not-yet-crashed instance; the previous instance's logs hold the failure.

Trap kubectl logs is empty or says container … is waiting to start, and the crash is invisible. Add --previous, or the container never started (go back to rung 2).

Rung 4 kubectl debug: a shell when the Pod has none

Hardened images (D4, D5) have no shell, so kubectl exec … sh fails. kubectl debug attaches an ephemeral container with its own tools to the running Pod, sharing its network and (optionally) process namespace. In a restricted namespace the debug container must itself be compliant, which --profile=restricted arranges:

kubectl -n k11 debug <pod> -it --image=nicolaka/netshoot:v0.13 --target=web --profile=restricted
# inside the ephemeral container, in the Pod's network namespace:
nslookup redis   →  ** server can't find redis: NXDOMAIN
curl localhost:8080/livez  →  200
# without a profile, in a restricted namespace:
Error from server (Forbidden): … violates PodSecurity "restricted:v1.37": allowPrivilegeEscalation != false …

--target=web shares the target container's process namespace (so ps shows its processes). The ephemeral container cannot be removed, so it adds a little clutter until the Pod is replaced. For a Pod that crashes before you can look, --copy-to makes an editable copy with a changed command that stays up:

kubectl -n k11 debug crasher-… --copy-to crasher-debug --container c --image=busybox:1.37-musl \
  --profile=restricted -- sh -c 'sleep 600'
crasher-debug   1/1   Running        ← a copy with the command overridden, safe to exec into; the original keeps crashing

Rule Debug shell-less Pods with kubectl debug … --profile=restricted (ephemeral container), and crash-looping Pods with --copy-to and an overridden command, instead of adding tools to production images.

Why Ephemeral containers bring debugging tools into the Pod's namespaces without changing the image, and a profile keeps them compliant with Pod Security.

Trap kubectl debug fails with a Pod Security violation, or the debug container cannot do what you need. Add --profile=restricted (or --profile=sysadmin for node debugging), matching the namespace's enforcement.

Rung 5 Service, DNS and NetworkPolicy (the K8 layer)

When Pods are Ready but traffic does not flow, the problem is in the networking layer (K8), diagnosed from an ephemeral or throwaway Pod in the right namespace. The order is endpoints, then DNS, then connect:

kubectl -n k11 get endpointslices -l kubernetes.io/service-name=pantry -o yaml   # any ready endpoints?
# from a debug/throwaway pod:
nslookup pantry.k11.svc.cluster.local            # does the name resolve?
nc -zv pantry 80                                 # does the port connect?
Service selector {app: pantry-web} vs Pod labels {app: pantry}  →  EndpointSlice endpoints: null
pantry Pod readiness /healthz  →  503 (Redis absent), so even a correct selector would have no *ready* endpoint

Two independent faults here: a selector typo (no endpoints at all) and a failing readiness probe (no ready endpoints). An empty EndpointSlice means the selector matches nothing or nothing is Ready; a Connection refused means no endpoints; a timeout means a NetworkPolicy is dropping the packet (K8, rung 7). DNS NXDOMAIN from inside the cluster means the wrong name, the wrong namespace, or a missing DNS egress rule.

Rule For "Pods are Ready but requests fail", check EndpointSlices, then DNS, then connectivity, and read refused as no-endpoint and timeout as blocked-by-policy.

Why The networking failure is always one of selector/readiness (no endpoints), DNS (wrong name or blocked), or policy (dropped), and each has a distinct signature.

Trap A Service "is down" and every Pod is healthy. Its selector does not match the Pods' labels, so kubectl get endpointslices is empty. Compare the Service selector with the Pod labels.

Rung 6 Node and cluster-level tools

A few commands for when the problem is below the Pod:

kubectl debug node/manual-worker -it --image=busybox:1.37-musl --profile=sysadmin \
  -- nsenter --target 1 --mount df -h /          # a privileged pod in the node's namespaces
kubectl get --raw '/healthz?verbose'             # is the API server itself healthy?
kubectl get pods -v=6                            # the HTTP requests kubectl makes (bump to -v=8 for bodies)
kubectl top nodes ; kubectl top pods -n k11      # live CPU/memory (needs metrics-server)
kubectl get events -A --field-selector type=Warning --sort-by=.lastTimestamp | tail
node debug → node hostname manual-worker; overlay 1007G 67G 889G 7% /    (the node's real root filesystem)
get --raw /healthz?verbose → healthz check passed
get pods -v=6 → GET https://…/api/v1/namespaces/k11/pods?limit=500

kubectl debug node/<name> runs a Pod in the node's namespaces to inspect its disk, processes and network. -v=6/-v=8 show exactly what kubectl sends, which is how you turn "kubectl does something odd" into a concrete request. kubectl top needs metrics-server (K5). Cluster-wide warning events catch problems that are not in the namespace you were looking at.

Rule Drop to node debugging (kubectl debug node/), get --raw and -v=6 only after the Pod-level triage has placed the problem below the application.

Why These inspect the node, the API server and kubectl itself, which matters only once the ordinary Pod-level signals have ruled out the application.

Trap An investigation jumps straight to node internals for a problem that kubectl describe pod already explained. Exhaust the events and status first.

Rung 7 The reusable triage order

The whole order, as a script you can keep: warning events, the not-Ready Pods, then each one's newest warning and last log line.

examples/manual/k11/triage.sh
#!/bin/sh
# Triage one namespace in the order the chapter teaches: warning events, then every Pod that is
# not Ready, with its reason, its newest warning, and its last log line. Read-only; changes nothing.
#   ./triage.sh k11
set -eu
NS=${1:?usage: triage.sh NAMESPACE}

echo "== recent warning events (newest last) =="
kubectl -n "$NS" get events --field-selector type=Warning --sort-by=.lastTimestamp \
  -o custom-columns=REASON:.reason,OBJECT:.involvedObject.name,MESSAGE:.message --no-headers | tail -15

echo
echo "== pods that are not Ready =="
kubectl -n "$NS" get pods -o custom-columns=\
POD:.metadata.name,PHASE:.status.phase,\
READY:.status.containerStatuses[0].ready,\
WAITING:.status.containerStatuses[0].state.waiting.reason,\
LASTEXIT:.status.containerStatuses[0].lastState.terminated.exitCode,\
RESTARTS:.status.containerStatuses[0].restartCount --no-headers | grep -v ' true '

echo
echo "== for each not-Ready pod: newest warning, then its last (or previous) log line =="
for pod in $(kubectl -n "$NS" get pods -o name); do
  ready=$(kubectl -n "$NS" get "$pod" -o jsonpath='{.status.containerStatuses[0].ready}' 2>/dev/null || true)
  [ "$ready" = "true" ] && continue
  name=${pod#pod/}
  echo "--- $name"
  kubectl -n "$NS" describe "$pod" | awk '/^Events:/{e=1} e' | grep -i 'warning' | tail -1 || true
  kubectl -n "$NS" logs "$name" --previous --tail=2 2>/dev/null \
    || kubectl -n "$NS" logs "$name" --tail=2 2>/dev/null || true
done
== pods that are not Ready ==
badimage   Pending   false   ImagePullBackOff   -   0
crasher    Running   false   -                  1   5
pantry     Running   false   -                  -   0
toobig     Pending   -       -                  -   -

Running it against the broken namespace surfaces all five failures in one pass, each at the right stage. In practice the first screen of its output names the cause most of the time, and the rungs above are how you follow the one line that matters.

Rule Keep a read-only triage script that prints warning events and the not-Ready Pods with their reasons, and run it first on any misbehaving namespace.

Why A fixed first pass over events and Pod status finds the stage of every failure at once, so you spend your attention on the cause, not on remembering which command to run.

Trap Each incident starts from scratch with half-remembered commands. A saved triage script makes the first five minutes identical and fast every time.

Decide

Symptom Look at
Pending, ImagePullBackOff tag, registry, imagePullSecrets (K9)
Pending, no container status describe → FailedScheduling: requests, taints, affinity (K5)
CrashLoopBackOff logs --previous, then exit code and command
CreateContainerConfigError missing ConfigMap/Secret key (K6), or PSA violation (K3)
Running, not Ready readiness probe and its dependency (K4)
Ready, but requests fail EndpointSlices → DNS → NetworkPolicy (K8)
No shell to exec into kubectl debug … --profile=restricted
Crashes before you can look kubectl debug --copy-to … -- sleep
Node-level suspicion kubectl debug node/…, kubectl top
"kubectl does something odd" -v=6/-v=8, get --raw

The golden file

A read-only triage script: warning events, the Pods that are not Ready, and for each its newest warning and last (or previous) log line. Run it first on any namespace that is misbehaving.

examples/manual/k11/triage.sh
#!/bin/sh
# Triage one namespace in the order the chapter teaches: warning events, then every Pod that is
# not Ready, with its reason, its newest warning, and its last log line. Read-only; changes nothing.
#   ./triage.sh k11
set -eu
NS=${1:?usage: triage.sh NAMESPACE}

echo "== recent warning events (newest last) =="
kubectl -n "$NS" get events --field-selector type=Warning --sort-by=.lastTimestamp \
  -o custom-columns=REASON:.reason,OBJECT:.involvedObject.name,MESSAGE:.message --no-headers | tail -15

echo
echo "== pods that are not Ready =="
kubectl -n "$NS" get pods -o custom-columns=\
POD:.metadata.name,PHASE:.status.phase,\
READY:.status.containerStatuses[0].ready,\
WAITING:.status.containerStatuses[0].state.waiting.reason,\
LASTEXIT:.status.containerStatuses[0].lastState.terminated.exitCode,\
RESTARTS:.status.containerStatuses[0].restartCount --no-headers | grep -v ' true '

echo
echo "== for each not-Ready pod: newest warning, then its last (or previous) log line =="
for pod in $(kubectl -n "$NS" get pods -o name); do
  ready=$(kubectl -n "$NS" get "$pod" -o jsonpath='{.status.containerStatuses[0].ready}' 2>/dev/null || true)
  [ "$ready" = "true" ] && continue
  name=${pod#pod/}
  echo "--- $name"
  kubectl -n "$NS" describe "$pod" | awk '/^Events:/{e=1} e' | grep -i 'warning' | tail -1 || true
  kubectl -n "$NS" logs "$name" --previous --tail=2 2>/dev/null \
    || kubectl -n "$NS" logs "$name" --tail=2 2>/dev/null || true
done
kubectl apply -f broken.yaml        # five planted failures in namespace k11
./triage.sh k11                     # one pass names all five

Review checklist

Spot the bug

A Deployment and its Service. Symptom: the Pod never becomes Ready, and curl to the Service hangs. Name why, by reading (you can apply it and run ./triage.sh k11-bug).

examples/manual/k11/bug/pod.yaml
# Symptom: this Deployment's Pod never becomes Ready, and curl to its Service hangs.
# The reader diagnoses it by reading (and may apply it and run ./triage.sh k11-bug).
apiVersion: apps/v1
kind: Deployment
metadata: {name: pantry, namespace: k11-bug}
spec:
  replicas: 1
  selector: {matchLabels: {app: pantry}}
  template:
    metadata: {labels: {app: pantry}}
    spec:
      securityContext: {runAsNonRoot: true, runAsUser: 10001, runAsGroup: 10001, fsGroup: 10001, seccompProfile: {type: RuntimeDefault}}
      containers:
        - name: web
          image: pantry-manual:latest
          env: [{name: REDIS_HOST, value: redis}]
          readinessProbe: {httpGet: {path: /healthz, port: 80}}
          securityContext: {allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}
          volumeMounts: [{name: tmp, mountPath: /tmp}]
      volumes: [{name: tmp, emptyDir: {medium: Memory, sizeLimit: 16Mi}}]
---
apiVersion: v1
kind: Service
metadata: {name: pantry, namespace: k11-bug}
spec:
  selector: {app: web}
  ports: [{port: 80, targetPort: 8080}]

This file has 4 problems.

Show answers
  1. image: pantry-manual:latest is not in the cluster (and :latest is unpinned, K2): the Pod sits in ImagePullBackOff. Pin a tag that exists.
  2. The readiness probe targets port: 80, but the container listens on 8080. Even with the image fixed, readiness would fail with connection refused. Use port: 8080 (or a named port).
  3. The Service selector is {app: web} while the Pod's label is {app: pantry}, so the EndpointSlice is empty and curl to the Service hangs with no backend. Match the labels.
  4. /healthz checks Redis (via REDIS_HOST=redis), but no Redis Deployment or Service exists, so once the image and probe port are fixed the probe still returns 503 and nslookup redis is NXDOMAIN. Deploy Redis (or point readiness at /livez, K4).

Part IV · 6 min readThe Bridge#

You have written a hardened Dockerfile and a Compose project (the Docker manual), and a namespace of manifests (the Kubernetes manual). This chapter connects them. Almost everything the Docker manual taught has a direct counterpart in Kubernetes: the same intent, a different field, and usually one thing that changes in meaning on the way across. Read a row as "what I wrote in Docker → where it lives in Kubernetes → the catch".

The links point to the manual chapter on each side.

The image: build once, run anywhere

The image is the one thing that crosses unchanged. A well-built image (small, non-root, signal-correct, pinned) is exactly what makes the Kubernetes side easy: runAsNonRoot just works, probes are simple, and restricted admits the Pod on the first try.

Docker / Compose Kubernetes What changes
FROM, multi-stage, distroless (D4) the image reference, spec.containers[].image (K2) identical image; a smaller, non-root image makes the restricted standard (K3) trivial to meet
image@sha256: digest pin (D6) image: repo@sha256: (K2) same digest; Kubernetes adds imagePullPolicy and imagePullSecrets (K9)
docker buildx multi-platform (D6) nodes of each architecture; the index is pulled per node (K2) the registry serves the right variant to each node automatically

Running the process

Docker / Compose Kubernetes What changes
ENTRYPOINT (D2) command (K2) same idea; command replaces the image's entrypoint
CMD (D2) args (K2) same; command without args drops the image's CMD too
ENV / --env (D1) env, envFrom (K2), from ConfigMaps (K6) read once at start, both places; a config change is a rollout (K4)
--user / USER 10001 (D5) securityContext.runAsUser / runAsNonRoot (K3) numeric UID is required, not just preferred: runAsNonRoot rejects a named user
--read-only + --tmpfs /tmp (D5) readOnlyRootFilesystem: true + an emptyDir (K3) a memory emptyDir is the tmpfs, and it counts against the memory limit (K5)
--cap-drop ALL / --security-opt no-new-privileges (D5) capabilities.drop: [ALL], allowPrivilegeEscalation: false (K3) same, plus seccompProfile: RuntimeDefault is required by restricted
--init / tini as PID 1 (D2) usually unnecessary; shareProcessNamespace or a native sidecar for helpers (K2) one container per concern, so PID 1 reaping rarely bites
STOPSIGNAL + stop timeout (D2) terminationGracePeriodSeconds + preStop (K4) the grace period covers preStop and the app's drain, then SIGKILL (exit 137 either way)

Health, lifecycle and scaling

Docker / Compose Kubernetes What changes
HEALTHCHECK (D1) readinessProbe and livenessProbe (K4) Kubernetes ignores the image's HEALTHCHECK; and one healthcheck becomes two probes with different jobs
Compose depends_on: service_healthy (D9) initContainers that wait + readiness; the app still retries (K2, K4) ordering is best-effort; the app must survive a dependency that leaves later
depends_on: service_completed_successfully (D9) an init container, or a Job the app waits on (K2) one-shot setup becomes an init container or a Job
restart: unless-stopped (D9) a Deployment (restartPolicy: Always) (K4) the controller also reschedules onto another node when one dies
deploy.replicas / --scale (D10) spec.replicas, or an HPA that owns it (K5) real horizontal scaling across nodes, autoscaled on CPU
deploy.resources.limits (D10) resources.requests and resources.limits (K5) requests (what Compose had no concept of) drive scheduling and QoS
rolling restart by hand RollingUpdate strategy, rollout (K4) zero-downtime rollouts and rollout undo are built in

Networking

Docker / Compose Kubernetes What changes
user-defined network + service name DNS (D7) a Service and its name.namespace DNS (K8) the Service IP is stable while Pods come and go
-p 8000:8080 publish (D7) a Gateway + HTTPRoute (or a LoadBalancer) (K8) routing by hostname/path, TLS, and a role split across teams
EXPOSE / Compose expose (D1) containerPort (documentation) + a named Service port (K8) both are documentation; membership, not expose, controls reachability
--internal network (D7) a default-deny NetworkPolicy + explicit allows (K8) isolation is an allow-list, and DNS egress must be allowed explicitly
127.0.0.1: bind to avoid exposure (D7) ClusterIP (never published) (K8) internal services are simply never put behind a Gateway

Data and configuration

Docker / Compose Kubernetes What changes
named volume (D8) a PersistentVolumeClaim (K7) a StorageClass provisions it; access modes and reclaim policy are explicit
tmpfs mount (D8) emptyDir with medium: Memory (K7) same, and it counts against the memory limit (K5)
bind mount for data a PVC, or emptyDir for scratch (K7) Pods move between nodes, so a host path is not portable
Redis with a named volume a StatefulSet + volumeClaimTemplates + headless Service (K7) stable per-replica identity, storage and DNS
Compose configs (D10) a ConfigMap mounted as files or env (K6) env never updates; a file directory does; roll it with hashed names
Compose secrets file (D10) a Secret in a projected volume (K6) access-controlled, not encrypted; set fsGroup for a non-root reader
--secret build mount (D6) build-time, so it stays in CI, not the cluster (D6) build secrets never become Kubernetes objects

Project structure and operations

Docker / Compose Kubernetes What changes
compose.yaml + compose.override.yaml (D10) a base + Kustomize overlays, or a Helm chart's values (K10) per-environment differences become overlays or values, versioned
Compose profiles (D10) separate manifests, a Helm value, or a Kustomize component (K10) optional pieces are toggled by values or overlay membership
x- anchors / extends (D10) Helm helpers (_helpers.tpl) or Kustomize bases (K10) reuse moves from YAML anchors to templates or bases
docker compose up --wait (D9) kubectl rollout status / helm install --wait (K4, K10) the deploy gate that fails on a bad rollout
docker compose logs / exec / ps kubectl logs / exec / kubectl debug (K11) a shell-less image needs kubectl debug, not exec
docker compose config (D9) kubectl apply --dry-run=server + kubectl diff (K01) the server validates against admission and current state, not just syntax
--cpus / docker stats requests/limits + kubectl top (K5) requests are new: a reservation, not just a ceiling

What has no clean equivalent

A few things do not map one to one:

  • Compose has no requests. It only has limits (deploy.resources). Kubernetes requests (the reservation the scheduler honours, and the basis of QoS and autoscaling, K5) have no Compose counterpart, and they are the single biggest new idea when crossing over.
  • Kubernetes ignores the image's HEALTHCHECK and STOPSIGNAL entirely. Both have to be re-expressed as probes (K4) and terminationGracePeriodSeconds + preStop. An image that relies on them behaves differently under Kubernetes.
  • Pod Security, RBAC and ServiceAccounts (K3, K9) have no Compose analogue at all. A single-host Compose project trusts whoever can reach the Docker socket; a cluster is multi-tenant, so identity and admission are first-class.
  • The node and the scheduler (K5) have no equivalent either. Compose runs everything on one host; affinity, taints, spread and priority exist because Kubernetes places Pods across many.
  • Going the other way, Compose's developer conveniences (develop.watch, D10) have no built-in Kubernetes equivalent; tools like Tilt or Skaffold fill that gap, and a local Compose remains the fastest inner loop even for teams that deploy to Kubernetes.

The rule across the whole bridge: the image and the intent carry over; Kubernetes adds a scheduler, multi-tenancy and a controller that keeps reconciling, and it drops two image-level features (HEALTHCHECK, STOPSIGNAL) that you must restate as Pod fields.