Docker & Kubernetes

Why Containers: Images, Containers and How They Differ From VMs

The problem containers solve, namespaces and cgroups in plain words, images versus containers, registries, and the first docker run.

Lesson 0012 min read#docker#containers#beginner

On this page

  1. Words used in this lesson
  2. The problem: the app is more than your code
  3. Step 1: a container is just a process
  4. Step 2: namespaces, a private view
  5. Step 3: cgroups, limits and accounting
  6. Step 4: a private file system
  7. Containers versus virtual machines
  8. Images: the recipe, not the process
  9. Your first Docker commands (all real runs)
  10. Why this design is useful
  11. Try it
  12. Interview questions
  13. Key takeaways
  14. What's next

In one line: a container is not a tiny computer. It is a normal process on your machine that the Linux kernel has put in a box: it sees its own files, its own process list and its own network, and it can only use the memory and CPU you allow. An image is the read-only recipe for that box, and Docker is the tool that builds the box and starts the process.

You have probably heard "it works on my machine". Your laptop has Node 22, the server has Node 18. Your laptop has a library installed by accident, the server does not. A container fixes this by shipping the app together with everything it needs, so it runs the same everywhere.

Most tutorials show you docker run and stop there. That is like learning to drive without ever looking under the bonnet. In this lesson we open the bonnet. We will build a "container" by hand with ordinary Linux commands, so you can see that there is no magic. After that, Docker will feel like a friendly wrapper and not a mystery.

This is lesson 0 of a ten-lesson series. You do not need to know Docker. You need to be able to use a terminal. The next lesson, Writing a Dockerfile, shows how to build your own image and keep it small. Later lessons take the same idea to a whole app on your laptop (Docker Compose) and then to many machines (Kubernetes). If you want to know why a process that is "in a box" is still not a perfect wall, the Security series explains least privilege, which we use again in lesson 2.

What I ran / checked. Everything here ran on one Linux machine: kernel 6.18.44, Docker Engine 29.8.2 (a real daemon that I started myself with dockerd), runc as the runtime, cgroup v1 (the older layout, more on that below), Docker Compose v5.6.0 and BuildKit 0.37.1 for builds, and root access in a sandbox. Commands like unshare and chroot come from the normal util-linux and coreutils packages. The images are the official busybox:1.36, alpine:3.20 and hello-world images, pulled through mirror.gcr.io (a public mirror of Docker Hub's official images) because Docker Hub itself was rate limited. Every output below is pasted from a real run. Times, process IDs, IP addresses and namespace numbers change on every run. Sources I read (my own words below): the Docker docs on "What is a container", "What is an image", the Engine security page and the networking overview (raw markdown from the docker/docs repository on GitHub), the moby/moby README, and the Kubernetes page on Pods, which says a Pod's shared context is "a set of Linux namespaces, cgroups" and so on. I also opened an image saved with docker save to look at its real files. What I could not do: I had no hypervisor, so I could not start a real virtual machine to time it against a container. The VM comparison is explained from the docs and not measured. I did not verify any GitHub star counts, so I give none.

Words used in this lesson

  • Container is a normal process (or a few) that the kernel isolates from the rest of the machine.
  • Image is a read-only package of files plus a little config (a default command, environment variables). A container starts from an image.
  • Kernel is the core of the operating system. It talks to the hardware and decides what every process may do.
  • Namespace is a kernel feature that gives a process its own private view of one thing, such as process IDs, the network or the hostname.
  • cgroup (control group) is a kernel feature that limits and counts how much CPU, memory and other resources a group of processes may use.
  • Virtual machine (VM) is a whole pretend computer, with its own kernel, that runs on top of a real one.
  • Layer is one set of file changes inside an image. An image is a stack of layers.
  • Registry is a server that stores and serves images, such as Docker Hub.
  • Tag is a human name for one version of an image, such as 1.36 or latest.
  • Digest is the exact fingerprint (a SHA-256 hash) of an image. It never changes for the same content.
  • Docker daemon is the background program (dockerd) that really builds images and runs containers. The docker command is only a client that talks to it.
  • PID 1 is the first process inside a container. It has special rules, which we meet near the end.

The problem: the app is more than your code

Your Node app is not only server.js. To run, it needs:

  • the right version of Node,
  • the libraries in node_modules, some with native parts built for one operating system,
  • system tools and libraries (for example OpenSSL),
  • settings: environment variables, a port, a folder to write to.

If any of these differ between your laptop, your teammate's laptop, the test server and production, the app can behave differently or fail. Before containers, people handled this with long setup documents, shell scripts, and virtual machines.

The idea of a container is simple: pack the app and everything it needs into one image, then run that same image everywhere. The Docker documentation describes a container as an isolated process for each part of your app, with everything it needs inside, and no reliance on what is installed on the host.

So what is the "isolated process" made of? Let's find out, starting with the smallest possible example.

Step 1: a container is just a process

Start a container that does nothing but sleep:

Shell
docker run -d --name sleepy mirror.gcr.io/library/busybox:1.36 sleep 300

-d means "detached", run it in the background. --name sleepy gives it a name. busybox is a tiny image with the basic Linux commands, and sleep 300 is the command to run.

Now ask Docker for the process ID of that container as the host sees it, and look for it with the normal ps command:

Shell
PID=$(docker inspect -f '{{.State.Pid}}' sleepy)echo pid on host: $PIDps -o pid,ppid,cmd -p $PIDdocker exec sleepy ps

Real output:

TEXT
pid on host: 2974  PID  PPID CMD 2974  2949 sleep 300PID   USER     TIME  COMMAND    1 root      0:00 sleep 300    6 root      0:00 ps

Look at this carefully. The same process has two different numbers. On the host it is process 2974. Inside the container it is process 1, and ps inside shows only two processes, the sleep and the ps itself. The rest of the machine is invisible from inside.

This is the first big idea. There is no separate computer here. The host kernel is running a plain sleep command. The host can list it, signal it, and kill it. What changed is that the process has been given a private view of the world. That private view is built from two Linux features: namespaces and cgroups. A third piece, a private file system, completes the picture.

Step 2: namespaces, a private view

A namespace wraps one kind of system resource so that the processes inside see their own copy. Linux has several kinds. These are the ones that matter for containers:

NamespaceWhat the process gets its own copy of
pidThe list of process IDs. The first process inside is PID 1.
netNetwork devices, IP addresses, routing, ports.
mntThe list of mounted file systems (what / looks like).
utsThe hostname.
ipcShared memory and message queues between processes.
userUser and group IDs (can map root inside to a normal user outside).
cgroupThe view of the cgroup tree.

Every process has a file for each namespace under /proc/self/ns. Two processes are in the same namespace when the number in the brackets is the same. Here is my host shell, and then a container made by Docker:

TEXT
host:pid pid:[4026531836]net net:[4026531833]mnt mnt:[4026531832]uts uts:[4026531838]ipc ipc:[4026531839]user user:[4026531837]cgroup cgroup:[4026531835]container:pid pid:[4026532268]net net:[4026532269]mnt mnt:[4026532264]uts uts:[4026532265]ipc ipc:[4026532266]user user:[4026531837]cgroup cgroup:[4026531835]

Five numbers are different: pid, net, mnt, uts and ipc. So the container has its own process list, network, file system view, hostname and shared memory. Two are the same as the host: user and cgroup. This is how Docker works by default (it does not use a separate user namespace unless you turn on that feature). We will come back to why this matters for safety in lesson 2.

Do it yourself with unshare

Docker did not invent any of this. The unshare command is part of every Linux machine, and it starts a program in new namespaces. Let's make our own tiny box, with a new hostname and a new process list:

Shell
unshare --user --map-root-user --pid --fork --mount-proc --uts --net -- \  sh -c 'hostname boxed; hostname; ps -ef'
TEXT
boxedUID          PID    PPID  C STIME TTY          TIME CMDroot           1       0  0 03:05 ?        00:00:00 sh -c hostname boxed; hostname; ps -efroot           4       1  0 03:05 ?        00:00:00 ps -ef

Inside, we changed the hostname to boxed, and ps sees only two processes. The host's hostname is still vm. I did this without Docker at all. (--map-root-user also made me "root" inside, but that root has no power over the real machine, because it is mapped from my real user. That is the user namespace at work.)

One more detail: the --mount-proc flag. ps reads the /proc folder, so a new PID namespace also needs a fresh /proc mounted, or ps would still show host processes. Docker does this for you.

A real trap: free lies inside a container

Here is something that surprises people. I started two containers, one with no limit and one with --memory 64m, and ran free -m in both:

TEXT
              total        used        free      shared  buff/cache   availableMem:          16093        1382        8190          14        6522       14384              total        used        free      shared  buff/cache   availableMem:          16093        1391        8181          14        6522       14374

Both say 16093 MB, which is the memory of the whole host. The second container is limited to 64 MB, but free does not know that. It reads /proc/meminfo, which is not namespaced by default. That means programs that "look at the machine's memory to decide how much to use" can pick a size that is much too big for the limit. Modern runtimes (Node, Java, Go) have learned to read the cgroup limit instead, but the lesson stays: inside a container, what you see is not always what you are allowed. To see the real limit, you must read the cgroup files, as we do next.

Step 3: cgroups, limits and accounting

Namespaces control what a process can see. They do not stop it from eating all the memory. That is the job of cgroups. A cgroup is a group of processes with rules attached: "this group may use at most 64 MB of memory", "at most half a CPU", "at most 50 processes".

Run a container with three limits and read them back from inside:

Shell
docker run --rm --memory 64m --cpus 0.5 --pids-limit 50 mirror.gcr.io/library/busybox:1.36 sh -c '  cat /sys/fs/cgroup/memory/memory.limit_in_bytes  cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us /sys/fs/cgroup/cpu/cpu.cfs_period_us  cat /sys/fs/cgroup/pids/pids.max'
TEXT
671088645000010000050
  • 67108864 bytes is exactly 64 MiB.
  • A quota of 50000 microseconds out of every 100000 is half a CPU.
  • 50 is the process limit.

My machine uses cgroup v1, where each resource has its own folder (/sys/fs/cgroup/memory/, /sys/fs/cgroup/cpu/). Newer systems use cgroup v2, one folder tree with files like memory.max and cpu.max. The ideas are the same and Docker hides the difference. Docker itself printed a warning on my machine that cgroup v1 support is deprecated, so you will see v2 more and more.

What happens when a container goes over its memory limit

I wrote a loop that keeps growing a string, and ran it with a 32 MB limit and no swap:

Shell
docker run --name oom --memory 32m --memory-swap 32m mirror.gcr.io/library/alpine:3.20 sh -c '  x=""; while true; do x="$x$(head -c 1000000 /dev/zero | tr "\0" a)"; done'echo exit=$?docker inspect -f 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' oom
TEXT
exit=137OOMKilled=true ExitCode=137

The kernel killed the process. The exit code 137 means 128 + 9: the process died from signal 9 (SIGKILL). Docker also records OOMKilled=true. Remember 137. You will see it again in Kubernetes as the famous OOMKilled status in lesson 8, and in interviews.

Step 4: a private file system

The last piece is the file system. A container does not see your host's /. It sees a folder tree that comes from the image. The old Unix command for this is chroot ("change root"): it makes a folder look like / to a program.

Let's export the files of a busybox image into a plain folder, and use chroot on it:

Shell
mkdir rootfsdocker export $(docker create mirror.gcr.io/library/busybox:1.36) | tar -x -C rootfsdu -sh rootfschroot rootfs /bin/sh -c 'ls /; head -2 /etc/passwd'
TEXT
4.4M	rootfsbindevetchomeliblib64procrootsystmpusrvarroot:x:0:0:root:/root:/bin/shdaemon:x:1:1:daemon:/usr/sbin:/bin/false

An entire small Linux user space is 4.4 MB, and the shell inside only sees that. Now put the pieces together: new namespaces and this root folder.

Shell
unshare --pid --fork --mount-proc=rootfs/proc --uts --ipc --net --mount \  chroot rootfs /bin/sh -c 'hostname isolated; hostname; ps; wget -qO- http://example.com'
TEXT
isolatedPID   USER     TIME  COMMAND    1 root      0:00 /bin/sh -c hostname isolated; hostname; ps; wget -qO- http://example.com    4 root      0:00 pswget: bad address 'example.com'

That is a container made by hand, in one command. It has its own hostname, its own process list (our shell is PID 1), its own file system, and an empty network (so wget cannot find any address). It is not a safe one: I set no memory limit, no capability restrictions and no system-call filter. Docker adds all of those, plus an image format, a network setup, storage and a friendly CLI. But the heart of it is what you just saw.

So here is the recipe:

container = process + namespaces (what it can see) + cgroups (what it can use) + a root file system from an image

Containers versus virtual machines

A virtual machine pretends to be a whole computer. A piece of software called a hypervisor gives it fake hardware, and it boots its own kernel and a full operating system. A container does none of that. It shares the host's kernel.

You can prove the sharing in one line:

Shell
uname -rdocker run --rm mirror.gcr.io/library/busybox:1.36 uname -r
TEXT
6.18.44-fc-v1146.18.44-fc-v114

Same kernel version, because it is the same kernel. Now compare:

Virtual machineContainer
What it includesIts own kernel + a full OS + your appYour app + its libraries + a root file system
What it sharesOnly the physical hardware, via a hypervisorThe host kernel
Typical sizeGigabytesMegabytes to a few hundred MB
Start timeSeconds to minutes (it boots an OS)Usually under a second
IsolationStrong (a separate kernel is a hard wall)Good, but weaker (one kernel for everyone)
Runs a different OS?Yes (Windows VM on a Linux host)No, same kernel. A Linux container needs a Linux kernel

A real start time on my machine: docker run --rm busybox true took about 0.43 seconds each for three runs (0.454, 0.432, 0.416 s). That includes creating the namespaces, the file system and the process, then tearing it all down. I did not time a VM here, so I make no number-for-number comparison. The point is the shape: a container does not boot an operating system, it just starts a process.

Three consequences to remember:

  1. Weaker wall. A bug in the shared kernel can let a container affect the host. That is why people who run code from strangers often put containers inside VMs. The Docker documentation itself says containers and VMs are often used together: in the cloud, the machines you rent are VMs, and each one runs many containers.
  2. Linux on Windows and Mac. Containers need a Linux kernel. Docker Desktop on Windows or macOS quietly runs a small Linux VM and starts your containers inside it. So on a Mac your "container" is a process inside a hidden VM.
  3. Not a replacement for everything. If you need a different kernel, special hardware drivers, or very strong isolation, you still want a VM.

Images: the recipe, not the process

We have talked about the running side. Now the other half: the image. Two rules from the Docker docs explain everything:

  1. Images are immutable. Once built, an image does not change. You can only build a new one, possibly on top of the old.
  2. Images are made of layers. Each layer is a set of file changes: add, change or delete files.

What is inside an image

Let's look at a real image on disk. docker save writes an image to a tar file:

Shell
docker save mirror.gcr.io/library/busybox:1.36 -o bb.tartar -tf bb.tar
TEXT
blobs/blobs/sha256/blobs/sha256/034d6572bf287fa79604fee9be062f0683809ae3a3685bc7b0e17bd1c48c50e8blobs/sha256/5108bc06b83a6cf2e1c06c2f80498ef7ab04bb6866a049b9d604c24ac3f982a0...index.jsonmanifest.jsonoci-layout

This is the OCI image layout, an open standard, so the same image works with Docker, Podman, containerd and Kubernetes. I read the JSON files with a small Python script. The chain is:

  • index.json points to an image index, a list of one manifest for each CPU type (amd64, arm and others). So busybox:1.36 really means "the right one for your CPU".
  • The manifest for amd64 points to one config (459 bytes) and a list of layers.
  • Busybox has exactly one layer, a gzip-compressed tar file of 2,206,402 bytes (about 2.2 MB).
  • The config says: architecture amd64, os linux, default command ["sh"], and a history line BusyBox 1.36.1 (glibc), Debian 13.

So an image is: a few small JSON files plus tar files. Unpack the layer and you get the bin, dev, etc folders you saw earlier. Every file is named by the SHA-256 hash of its content. That is why identical layers are stored and downloaded only once, even across many images.

Image versus container, again

  • An image is read-only. It is a class.
  • A container is a running (or stopped) instance. It is an object made from the class. It gets a thin writable layer on top of the image layers.

I started two containers from the same image, wrote a file in one, and asked what changed:

Shell
docker run -d --name c1 mirror.gcr.io/library/busybox:1.36 sleep 100docker run -d --name c2 mirror.gcr.io/library/busybox:1.36 sleep 100docker exec c1 sh -c 'echo secret > /tmp/only-in-c1.txt'docker diff c1docker exec c2 ls /tmp
TEXT
C /tmpA /tmp/only-in-c1.txt

docker diff shows A (added) and C (changed). The second container's /tmp is empty (the ls printed nothing). They share the image layers but each has its own writable layer. When you remove a container (docker rm), its writable layer is deleted too. That is why data you write inside a container is lost unless you use a volume (lesson 2).

Registries, names and tags

Images live in a registry. Docker Hub is the best known. Cloud providers and GitHub also run registries. An image name has parts:

TEXT
mirror.gcr.io / library / busybox : 1.36 registry       namespace   name     tag

If you write only busybox, Docker fills in docker.io/library/busybox:latest. Two ideas to know:

  • A tag like 1.36 or latest is just a label that can be moved. latest does not mean "newest" or "safest". It is the default tag name when you give none. Someone can push a new image and move 1.36 to it.
  • A digest (image@sha256:...) is the fingerprint of the exact content. It cannot be moved. For production, pinning a version tag is the minimum, and a digest is the strongest.

Do not use latest in anything that matters. Today it is Node 22 and tomorrow it can be Node 24, so the same Dockerfile builds a different thing. The linter hadolint has a rule for exactly this (DL3007, "Using latest is prone to errors") and we will run it in lesson 1.

Your first Docker commands (all real runs)

Time to use the Docker CLI properly. The client talks to the daemon, and the daemon does the work. First, the classic check:

Shell
docker run --rm mirror.gcr.io/library/hello-world
TEXT
Hello from Docker!This message shows that your installation appears to be working correctly.To generate this message, Docker took the following steps: 1. The Docker client contacted the Docker daemon....

If the image was not on the machine, Docker pulled it first from the registry (the pull prints one "Download complete" line per layer). Then it created a container, ran it, printed the text, and, because of --rm, deleted the container afterwards.

Now something more like a real service: a tiny web server, with a port published to the host.

Shell
docker run -d --name web -p 127.0.0.1:8088:8080 mirror.gcr.io/library/busybox:1.36 \  sh -c 'echo "hello from busybox" > /tmp/index.html; httpd -f -p 8080 -h /tmp'docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'curl -s localhost:8088/docker exec web sh -c 'ls /tmp; ps'
TEXT
NAMES     IMAGE                                STATUS                  PORTSweb       mirror.gcr.io/library/busybox:1.36   Up Less than a second   127.0.0.1:8088->8080/tcphello from busyboxindex.htmlPID   USER     TIME  COMMAND    1 root      0:00 httpd -f -p 8080 -h /tmp   50 root      0:00 ps

Let's read the options:

OptionMeaning
-dRun in the background (detached).
--name webGive the container a name instead of a random one.
-p 127.0.0.1:8088:8080Publish: host port 8088 (on localhost only) goes to container port 8080.
--rmDelete the container when it stops.
-itInteractive terminal, for shells like docker run -it alpine sh.
-e NAME=valueSet an environment variable.

The web server is PID 1 inside the container. Use docker exec to run another process in the same namespaces, which is how you "get inside" to debug.

The commands you will use every day:

CommandWhat it does
docker run IMAGECreate a container from an image and start it (pulls if needed).
docker ps / docker ps -aList running containers / all containers including stopped.
docker logs NAMEShow what the process wrote to stdout and stderr.
docker exec -it NAME shStart a shell inside a running container.
docker stop NAMEAsk the container to stop (SIGTERM, then SIGKILL after 10 seconds).
docker rm NAMEDelete a stopped container (and its writable layer).
docker imagesList images on this machine.
docker pull IMAGEDownload an image without running it.
docker rmi IMAGEDelete an image.

Containers are meant to be thrown away

After docker stop web, I listed the containers again:

TEXT
web          Exited (137) Less than a second ago

Two things here. First, a stopped container still exists until you remove it with docker rm. Second, look at 137 again. That is not an out-of-memory kill. That is docker stop sending SIGTERM (the polite "please stop"), the web server ignoring it, and Docker sending SIGKILL after the wait time. Why would a program ignore SIGTERM? Because of PID 1.

PID 1 is special

On Linux, the kernel treats PID 1 differently. A normal process that does not install a signal handler dies on SIGTERM. But PID 1 does not get the default action: signals with no handler are ignored for it. So if your program is PID 1 in the container and does not handle SIGTERM, docker stop waits the full 10 seconds and then kills it hard. In lesson 1 I measured this with a Node app: 0.17 seconds to stop with a handler, 10.2 seconds without. Containers get stopped all the time (every deploy, every scale-down in Kubernetes), so this small detail matters a lot. The Node.js best practices project (goldbergyoni/nodebestpractices) has a whole item on it, telling you to start Node directly and not through npm start, so signals reach your code.

Why this design is useful

Now that you know what it is, the benefits are easy to see:

  • Same everywhere. The image carries Node, the libraries and the system files. The host only needs a kernel and a container runtime.
  • Fast and light. No OS boot. Many containers share one kernel and shared layers.
  • Isolated enough for most uses. Two apps on one machine cannot trip over each other's ports, files or library versions.
  • Cheap to throw away. Because containers are replaceable, tools like Kubernetes can kill and restart them freely. This is a different way of thinking: you do not "fix" a container, you replace it.
  • A standard. The OCI image format means Docker, Podman, containerd and Kubernetes all understand the same images.

And the limits:

  • Shared kernel, so weaker isolation than a VM.
  • Linux containers need Linux. (Docker Desktop hides a VM.)
  • State is not saved. Files written inside are lost with the container.
  • A container is not a security boundary by itself. Running as root inside is a risk. Lesson 2 shows the habits that help.

Docker is not the only tool. "Container" is an open idea. containerd and runc (the program that really creates the namespaces and cgroups) are the parts under Docker. Kubernetes today talks to a runtime such as containerd directly and does not need Docker. Podman is another tool with a similar command line. Learning Docker teaches you the ideas that all of them use.

Try it

Do these on any Linux machine with Docker. (On Mac or Windows, Docker Desktop works, but the namespace commands with unshare need a Linux shell, which you can get from docker run -it --privileged alpine sh.)

  1. Run docker run -d --name t1 busybox:1.36 sleep 300. Find its PID on the host. Then run docker exec t1 ps. Why are the two numbers different?
  2. Run a container with --memory 16m and read /sys/fs/cgroup/memory/memory.limit_in_bytes (cgroup v1) or /sys/fs/cgroup/memory.max (cgroup v2). What number do you expect?
  3. Run docker run --rm busybox:1.36 uname -r and uname -r on the host. What do you expect? What would a VM show?
  4. Start two containers from the same image. Create a file in one. Does the other see it? Which command shows what changed?
  5. After docker rm of a container, where did the files you wrote in it go?
  6. You pull myapp:latest on Monday and on Friday. Are you sure you get the same image? What would you use instead?

Answers

  1. The host sees the process in its own PID namespace, so it has an ordinary number like 2974. Inside the container the process is in a new PID namespace where it is the first process, so it is PID 1.
  2. 16 MiB is 16 × 1024 × 1024 = 16777216 bytes (cgroup v1 shows the number, v2 shows 16777216 in memory.max).
  3. Both show the same kernel version, because a container shares the host kernel. A VM would show the guest's own kernel version, which can be different.
  4. No, the other does not see it. Each container has its own writable layer on top of the shared image layers. docker diff NAME lists the changes.
  5. They were in the container's writable layer, and docker rm deletes that layer. They are gone. Use a volume to keep data.
  6. No. latest is a tag that can move. Use a version tag like 1.4.2, or better a digest (myapp@sha256:...).

Interview questions

What is a container, and how is it different from a VM? (Must know)

A container is an ordinary process on the host that the kernel isolates using namespaces, limits with cgroups, and gives a separate root file system taken from an image. A VM runs a whole guest operating system with its own kernel on top of a hypervisor. So a container shares the host kernel, starts in well under a second and is small, while a VM boots its own OS and is heavier but has a stronger wall. I can show it in one line: uname -r inside a container prints the host kernel version.

What are namespaces and cgroups, and what does each one do for a container? (Must know)

Namespaces control what a process can see: its own process IDs, network, mounts, hostname, IPC and users. Cgroups control what it can use: memory, CPU, number of processes and so on. Isolation comes from namespaces, resource control comes from cgroups. If you only had namespaces, one container could still eat all the memory. In a lab I ran a container with --memory 32m and it was killed with exit code 137 and OOMKilled=true.

What is the difference between an image and a container? (Must know)

An image is a read-only, immutable bundle of layers and config. A container is a running or stopped instance made from an image, with its own thin writable layer on top. Many containers can come from one image and they share the image layers. Files written in a container live in its writable layer and are removed with the container.

What is a layer, and why does Docker use them? (Must know)

A layer is a set of file changes (add, change or delete) stored as a tar file named by its hash. An image is an ordered stack of layers. They let images be built on top of each other, and they allow sharing: identical layers are stored and downloaded once. They also make builds faster, because Docker can reuse a layer when its inputs did not change. That is the topic of the next lesson.

Does a container have its own kernel? What follows from that? (Trap)

No. It shares the host kernel, and that has consequences. The isolation is weaker than a VM, since a kernel bug affects everyone. A Linux container needs a Linux kernel, so on Mac and Windows Docker Desktop runs a Linux VM behind the scenes. And a container cannot run a different kernel version than the host.

What is the difference between a tag and a digest? Why not use latest? (Must know)

A tag is a movable label such as 1.36 or latest. A digest is the SHA-256 hash of the exact image content, so it never changes. latest is only the default tag name, not "the newest" or "the safest", and the image behind it can change at any time, so builds stop being repeatable. I use explicit version tags, and for sensitive deploys I pin a digest.

What happens when you run docker run nginx? (Senior signal)

The docker client sends the request to the daemon. If the image is not on the machine, the daemon pulls it from the registry: it fetches the manifest, then the config and the layers it does not already have. It then creates a container: a writable layer on top of the layers, a new set of namespaces, cgroups, a network interface, and a root file system. Finally the runtime (runc) starts the image's default command as PID 1 inside it. The daemon keeps its output for docker logs.

A container exits with code 137. What does it mean? (Senior signal)

137 is 128 plus 9, so the process was killed by SIGKILL. There are two common causes. One is the kernel's OOM killer, because the container went over its memory limit; docker inspect shows OOMKilled=true. The other is docker stop or Kubernetes sending SIGTERM, the process not exiting, and a SIGKILL after the grace period. I check OOMKilled first to tell them apart. If it is false, I look at how the app handles SIGTERM and whether it is PID 1.

Why does free -m inside a limited container show the host's memory? (Trap)

Because /proc/meminfo is not namespaced by default. The memory limit lives in a cgroup file, and tools that read /proc/meminfo do not see it. A program that sizes itself from that number can use more than its limit and be killed. Modern runtimes read the cgroup limit, but when I debug I read the cgroup file myself. For Node specifically, Docker's limit and the V8 heap setting should be set together, which the Node best practices list recommends.

Are containers secure by default? (Senior signal)

They are isolated, not sandboxed like a VM. Docker's defaults are reasonable: separate namespaces, a reduced set of Linux capabilities, a default system-call filter. But the process still runs on the shared kernel, and many images run as root. So I add habits: run as a non-root user, drop capabilities, use a read-only file system where I can, never mount the Docker socket, and keep images small and updated. Lesson 2 of this series demonstrates these.

Why do people say containers should be "ephemeral"? (Must know)

Because a container should be possible to stop, destroy and replace with a fresh one with almost no setup. The Docker best practices page says this directly and points to the "stateless processes" idea of the Twelve-Factor App. Data that must survive goes into a volume or an external service, and settings come from environment variables or mounted config. That is what lets an orchestrator like Kubernetes kill and restart containers freely.

Key takeaways

  • A container is a process in a box: namespaces for what it sees, cgroups for what it can use, an image for its files.
  • It shares the host kernel. That makes it small and quick, and also less isolated than a VM.
  • An image is read-only layers plus config, stored as hashed tar files. A container adds a thin writable layer and a running process.
  • A tag can move. A digest cannot. Avoid latest.
  • Exit code 137 means SIGKILL. Check OOMKilled to see if it was memory.
  • PID 1 ignores SIGTERM unless your program handles it, so docker stop can take 10 seconds.
  • free and /proc/meminfo inside a container do not show the container's limit. The cgroup files do.
  • The daemon does the work. The docker command is only a client.

What's next

Next: Writing a Dockerfile: Layers, Caching and Small Images, Lesson 1. You now know what an image is made of. Next you will write the recipe yourself, see why instruction order changes build time (I measured 7 seconds versus 1.6 seconds), cut an image from 1.69 GB to 243 MB, and run a linter over your Dockerfile. Practise now: run docker run --rm -it alpine:3.20 sh, look around with ps, ls / and cat /proc/self/cgroup, and compare it with your host.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Part of the study guide

Related series