Containers
a container is not a small virtual machine. it is an ordinary linux process (tree) that the kernel has been told to lie to — about what processes exist, what the filesystem looks like, what the network is, who root is — plus an accountant capping what it may consume. the lying is namespaces, the accounting is cgroups, and everything else (images, registries, orchestrators) is packaging around those two syscall families (Tanenbaum, Andrew S., 2008).
what a container actually is
namespaces: the six that matter
a namespace wraps one global kernel resource so a process group gets a private view of it. spawn a process with clone() or unshare() flags and it lives in new namespaces:
| namespace | isolates | inside the container you see |
|---|---|---|
pid | process ids | your init is pid 1; other processes don’t exist |
mnt | mount table | a private filesystem tree (the image) |
net | network stack | own interfaces, routes, ports, firewall |
uts | host + domain name | your own hostname |
ipc | sysv ipc, posix queues | private shared-memory segments |
user | uid/gid mappings | “root” inside maps to a nobody outside |
(two more exist — cgroup and time — worth knowing but rarely the point.) the user namespace is the security-critical one: it lets a container’s uid 0 be an unprivileged uid on the host, which is what “rootless” means below.
cgroups v2: the accountant
- control groups meter and cap resource use per process-tree:
cpu.max(bandwidth quota),memory.max(hard cap — exceed it and the cgroup’s own oom killer fires),io.max,pids.max(fork-bomb fence). - v2’s improvement is a single unified hierarchy: one tree under
/sys/fs/cgroup, every controller attached to the same nodes, so “this pod gets 2 cpus and 4 gb” is one subtree — the primitive kubernetes resource limits compile down to. - namespaces decide what you can see; cgroups decide what you can spend. isolation needs both — a container that can see nothing but eat all the ram is still a noisy neighbour.
not a vm
| dimension | container | virtual machine |
|---|---|---|
| kernel | shared with host | own guest kernel |
| boots in | milliseconds (it’s a fork) | seconds-plus (it’s a machine) |
| size | mb (layers, shared) | gb (disk image) |
| density | hundreds per host | tens per host |
| isolation | one kernel = one attack surface | hypervisor boundary, much smaller |
| os mixing | linux-on-linux only | anything on anything |
the isolation row is the honest one: a kernel exploit escapes every container on the host, which is why multi-tenant clouds run containers inside micro-vms (firecracker, kata) — taking both columns. 𐃏
images and layers
union filesystems and copy-on-write
- an image is a stack of read-only layers, each a tarball of filesystem changes. at run time, overlayfs merges them: the layers become read-only
lowerdirs, a fresh emptyupperdirgoes on top, and the container sees the union. - writes are copy-on-write: modifying a file from a lower layer first copies it up to
upperdir, then edits the copy. deletes write a whiteout entry that masks the lower file. the image is never touched — which is why 200 containers can share one image at the cost of one, and why anything written to the container layer dies with the container (volumes below are the escape hatch).
content-addressable everything
layers and manifests are named by the sha256 of their bytes. the demo image built below really is just three digests:
$ docker image inspect layerdemo --format '{{range .RootFS.Layers}}{{println .}}{{end}}'
sha256:88b4fba61c4c714a2fc173ddf7e9324a257304696830bf41ad28ecc58c11c95f
sha256:e48e9d6daab227918910cc87acc76d6ca91c822d44ba4633bbe0354ac5531381
sha256:6e1631f50f3a107cc640c8591071adc8f4df45704b5181756ac7ae78d9028b41
identical layers dedupe on disk, in transfer, and in the registry — the same content-addressing bet as git and the build-system caches.
dockerfile mechanics
the cache rules
each instruction produces one layer, and the builder replays instructions top-down against its cache: an instruction is a cache hit if the instruction text is unchanged and (for COPY and ADD) the checksums of the copied files are unchanged. the first miss invalidates everything after it — layers below depend on layers above, exactly the dirty-cone propagation from build systems.
the practical layering discipline follows immediately: order layers by change frequency — base image, then system packages, then dependency manifests (requirements.txt, package.json) + install, then your source code last, so the daily edit invalidates one cheap COPY instead of re-running pip install.
multi-stage builds, worked
ship the artefact, not the toolchain: build in one stage, COPY --from the result into a minimal runtime stage. executed:
# ---- stage 1: "build" ----
FROM alpine:3.20 AS build
WORKDIR /src
RUN echo "banner built in stage 1" > banner.txt # heavy toolchain lives here
# ---- stage 2: runtime (ships without the toolchain) ----
FROM alpine:3.20
COPY --from=build /src/banner.txt /banner.txt
COPY hello.sh /hello.sh
CMD ["sh", "/hello.sh"]
== build 2 (nothing changed):
#6 CACHED
#7 CACHED
#8 CACHED
== build 3 (hello.sh edited):
#6 [build 3/3] RUN echo "banner built in stage 1" > banner.txt
#6 CACHED
#8 [stage-1 2/3] COPY --from=build /src/banner.txt /banner.txt
#8 CACHED
#9 [stage-1 3/3] COPY hello.sh /hello.sh
== run it:
banner built in stage 1
hello from pid 1 on b8c22872ae9a
one more line
read the trace: the unchanged build stage and banner COPY stayed cached; editing hello.sh re-ran only its own COPY layer. and the run line quietly demonstrates two namespaces — the shell is pid 1 (pid namespace) on a random hostname (uts namespace). more directly:
$ docker run --rm alpine:3.20 sh -c 'ps; hostname'
PID USER TIME COMMAND
1 root 0:00 sh -c ps; hostname
7 root 0:00 ps
d5493fe8cb30
a machine running hundreds of processes, and inside the namespace: two.
networking
- bridge (default): each container gets a network namespace with a veth pair — one end inside as
eth0, the other plugged into a software bridge on the host; outbound traffic is nat’d, inbound needs explicit publishing (-p 8080:80programs a dnat rule). containers on the same bridge reach each other by ip (and by name, on user-defined bridges). - host: no network namespace at all — the container shares the host stack, ports and all. fast, and exactly as isolated as it sounds.
- none: a loopback and silence; you build the rest yourself.
- container:x / pod networking: join another container’s network namespace — how kubernetes pods give all their containers one ip and a shared
localhost. see internet networks for the stack underneath.
volumes
- the writable layer is a scratchpad that dies with the container; anything worth keeping lives in a volume — a host-managed directory mounted into the container’s mnt namespace, bypassing overlayfs (and its copy-up cost) entirely.
- bind mounts map an explicit host path (great for development: source code mounted live); named volumes let the engine own the path (right for databases); tmpfs mounts keep secrets in ram only.
- rule of thumb: image = code, immutable; volume = state, precious; container layer = cache, disposable.
registries and digests
- a registry is an http content store for image manifests and layer blobs;
pushandpullmove only the layers the other side lacks (content addressing again). - tags are mutable pointers:
python:3.12can point at different bytes tomorrow. digests are immutable:alpine@sha256:d9e853e87e5552...(the real digest of the image the demos above pulled) names exactly one manifest forever. - production discipline: humans read tags, machines pin digests — ci resolves the tag once and deploys by digest, or the supply chain inherits a mutable dependency. the same hermeticity argument as reproducible builds, one level up.
security, one paragraph
defence is subtraction. rootless: run the engine and containers in a user namespace so container-root is a nobody on the host — a breakout lands unprivileged. capabilities: root’s powers are roughly forty separate flags (CAP_NET_ADMIN, CAP_SYS_ADMIN, …); runtimes drop most by default and you should drop the rest (--cap-drop=ALL plus the two you need). seccomp: a syscall allowlist filter — the default profiles block a few dozen exotic syscalls, closing most historical kernel-escape routes. add read-only root filesystems and no-new-privileges, and the honest residual risk is the shared kernel itself — which is the vm row in the earlier table, and why the paranoid run micro-vms.
orchestration, one honest paragraph
one host runs containers; a fleet needs a scheduler. kubernetes’ model in one breath: the unit is the pod (containers sharing network and volumes, scheduled together); you declare desired state (deployment: replicas: 3) and controllers run reconciliation loops that diff desired against actual and act — a crashed pod is rescheduled not because something noticed the crash, but because the loop noticed the count was 2. services give pods a stable virtual ip (the service discovery problem, solved by the platform). that is genuinely the whole idea; the other 900 pages are api surface.
the oci spec
“docker” stopped being one thing in 2015: the open container initiative standardises the image format (layers + manifest + config), the runtime spec (what a bundle is and what create, start and kill mean), and the distribution spec (the registry http api). consequence: images build with docker or buildah, run on containerd or cri-o or podman, and any registry serves them — the formats are the interface, the tools compete. podman’s whole pitch is oci compatibility without a root daemon.
see also
- microservices — what all these boxes are deployed to run
- build systems — layer caching is the build dag’s staleness problem in a trenchcoat
- linux — the kernel these processes all share
- concurrency — isolation by process when threads share too much
References
Tanenbaum, Andrew S. (2008). Modern Operating Systems, Pearson.
Backlinks (5)
1. Build Systems /wiki/se/implementation/build-systems/
every build system — make, ninja, bazel, cargo, even npm scripts pretending otherwise — is the same machine: a directed acyclic graph of files and commands, plus a policy for deciding which part of the graph is stale. everything else is syntax.
the model: a dag of targets
- a target is a file the build can produce; a rule says how (a command) and from what (its dependencies).
- dependencies point from outputs to inputs; because outputs of one rule are inputs to another, the whole thing is a dag — a cycle would mean “to build a you must first build a”.
- a build is then two steps:
- mark dirty: a target is dirty if it is missing, if any dependency is dirty, or if the staleness policy (timestamps or hashes, below) says an input changed. dirtiness propagates along edges — one flipped source poisons its whole downstream cone.
- evaluate in topological order: run each dirty target’s command after all its dependencies are up to date (Cormen, Thomas H. and Leiserson, Charles E. and Rivest, Ronald L. and Stein, Clifford, 2009). independent dirty targets can run in parallel (
-j8) — the dag is the parallelism plan, which is why builds are embarrassingly parallel until the link step serialises everything.
- the entire correctness contract: the graph must be complete. every undeclared dependency is a future “works after
make clean” bug — the build system faithfully skips rebuilding things it was never told could change.
make: the actual semantics
make’s whole rebuild rule fits in one sentence: a target is rebuilt if it does not exist, or if any prerequisite’s mtime is newer than the target’s mtime. everything else is macro expansion.
2. Concurrency /wiki/se/implementation/concurrency/
concurrency is structure: many logical tasks in flight, interleaved on however many cpus you have (possibly one). parallelism is hardware: tasks literally executing at the same instant. a single-core machine juggling 400 socket connections is concurrent, not parallel; a gpu multiplying matrices is parallel, barely concurrent. you design concurrency; you buy parallelism (Tanenbaum, Andrew S., 2008).
threads and shared memory
a thread is an independent stream of execution inside one address space: own stack and registers, shared everything else. the sharing is the point — and the disease.
3. Microservices /wiki/se/architecture-design/microservices/
a microservice architecture decomposes one application into many independently deployable services, each owning its own data, talking over a network. the honest framing: you are trading a code organisation problem for a distributed systems problem. 𐃏 that trade is sometimes worth it. it is not worth it nearly as often as conference talks suggest.
the trade, not the hype
what you actually buy
- independent deployment: team a ships without waiting for team b’s release train. this is the real prize — everything else is secondary.
- independent scaling: scale the search service to 40 replicas while billing idles on 2.
- fault isolation: a memory leak in recommendations does not take down checkout — if you also build the resilience machinery below. isolation is earned, not free.
- technology heterogeneity: the ml service can be python while the ledger is jvm. (also a curse: n stacks to patch.)
- organisational scaling: conway’s law working for you — service boundaries that mirror team boundaries let teams own code end to end.
what you actually pay
- every in-process function call that crosses a new service boundary becomes a network call: it can now fail independently, time out, arrive twice, or arrive late.
- transactions that were a single
BEGIN...COMMITbecome sagas (below) — you give up atomicity across boundaries and must design compensation by hand. - refactoring across service boundaries is an order of magnitude harder than moving code between modules — the boundary is now an api contract with independent release cadences (Fowler, Martin, 2018).
- operational surface explodes: per-service ci, dashboards, alerts, on-call, versioned contracts, backwards-compatible migrations.
when a modular monolith wins
- a modular monolith — one deployable, strictly enforced internal module boundaries, one database with schema-per-module discipline — captures most of the design benefit at a fraction of the operational cost.
- you get cheap refactoring while the domain boundaries are still wrong (they always start wrong), real stack traces, local transactions, and one thing to deploy.
- the sane migration path is monolith-first: find the boundaries by living with them in-process, then extract the one or two services that genuinely need independent deployment or scaling — the strangler fig approach of routing traffic incrementally to extracted pieces. 𐃏
- rule of thumb: if one team can hold the whole system in its head and deploys are not blocked on other teams, microservices solve a problem you do not have.
decomposing a system
bounded contexts (ddd-lite)
the useful sliver of domain-driven design:
4. Wiki /wiki/
Knowledge is a paradox. The more one understand, the more one realises the vastness of his ignorance.