NovuSpark
All articles
ContainerizationAugust 29, 2025 · NovuSpark Team

Docker 101: Images, Containers, and the Docker Engine

This is the first post in our Docker fundamentals series. Later posts cover production Dockerfiles, networking, Compose, and image security.

"A container is like a lightweight virtual machine" is the analogy almost everyone hears first, and it's the reason so many people get confused six months later when a container behaves in a way no VM ever would. It's worth building the correct mental model from the start instead.

What a container actually is

A container is an isolated process running on the same kernel as the host machine — not a separate operating system, not a hypervisor-managed guest. Linux provides two kernel features that make this isolation possible: namespaces (giving a process its own view of processes, network interfaces, and filesystem mounts) and cgroups (limiting how much CPU, memory, and I/O that process can consume). Docker is, underneath all its tooling, a friendly interface over those two kernel primitives.

virtual machineshypervisorguest kernelappguest kernelappboots a full kernel per VMcontainersapp (namespaced)app (namespaced)one shared host kernelisolated by namespaces + cgroups, no separate kernel boot
Fig. 1 — VMs isolate with a full guest kernel per instance; containers isolate processes on one shared host kernel

This is why containers start in milliseconds while VMs take seconds to minutes — there's no separate kernel to boot. It's also why a container sharing the host's kernel means a kernel-level vulnerability can, in the worst case, be exploited across container boundaries in a way that's architecturally impossible with a real VM's hardware-enforced isolation. The trade-off is real in both directions, not just a speed win.

Images vs. containers

An image is a read-only template — filesystem contents plus metadata (what command to run, what ports to expose). A container is a running instance of an image, with a thin writable layer on top.

docker pull nginx:1.27
docker run -d --name my-nginx -p 8080:80 nginx:1.27

docker run creates a container from the nginx:1.27 image and starts it. Critically, docker run again creates a second, independent container from the same image:

docker run -d --name my-nginx-2 -p 8081:80 nginx:1.27

Two containers, one shared underlying image (Docker only stores the image's layers once, regardless of how many containers use it), each with their own independent writable layer and their own isolated process. This is the relationship most people get wrong at first: the image is the recipe, the container is one specific instance of a meal made from it — you can make many meals from the same recipe without changing the recipe itself.

Layers, and why they matter for more than just disk space

An image is built from a stack of read-only layers, each one representing a single instruction in the Dockerfile that created it.

FROM ubuntu:22.04
RUN apt-get update && apt-get install -y curl
COPY app.py /app/app.py
CMD ["python3", "/app/app.py"]

Each of FROM, RUN, and COPY produces its own layer, stacked on top of the previous one. Docker caches layers by content hash — rebuild this image without changing app.py, and the RUN apt-get install layer (usually the slowest step) is reused unchanged from cache, rather than re-executed:

Step 2/4 : RUN apt-get update && apt-get install -y curl
 ---> Using cache
 ---> a1b2c3d4e5f6

This caching behavior is exactly why instruction order in a Dockerfile is a real performance decision, not a stylistic one — a detail we go into in depth in the next post in this series, because getting it wrong is one of the most common reasons a team's Docker builds feel unnecessarily slow.

Inspecting an image's actual layers

It's worth actually looking at what a real image contains, rather than treating layers as an abstract concept:

docker history nginx:1.27
IMAGE          CREATED BY                                      SIZE
a1b2c3d4e5f6   CMD ["nginx" "-g" "daemon off;"]                 0B
b2c3d4e5f6a7   EXPOSE map[80/tcp:{}]                            0B
c3d4e5f6a7b8   COPY docker-entrypoint.sh /                     4.6kB
d4e5f6a7b8c9   RUN apt-get update && apt-get install nginx     62.3MB
e5f6a7b8c9d0   FROM debian:bookworm-slim                       80.4MB

Reading this top to bottom (most recent instruction first) shows exactly where an image's size actually comes from — here, the base Debian layer and the nginx package install account for nearly all of it, while the metadata-only instructions (CMD, EXPOSE) add nothing. This is genuinely the first place to look when an image feels unexpectedly large: docker history tells you which specific instruction is responsible, rather than leaving you to guess.

The container lifecycle

docker ps                  # running containers
docker ps -a                # all containers, including stopped ones
docker stop my-nginx        # sends SIGTERM, then SIGKILL after a grace period
docker start my-nginx       # starts an existing (stopped) container again
docker rm my-nginx          # permanently removes a stopped container
docker logs my-nginx        # stdout/stderr from the container's main process
docker exec -it my-nginx sh # a shell inside a running container, for debugging

Two of these deserve emphasis because they're a common source of confusion:

  • stop and rm are different operations. A stopped container still exists — its filesystem changes, its logs, its configuration — until you rm it. This is genuinely useful for debugging: a container that crashed can be inspected with docker logs even after it's stopped.
  • docker exec runs a new process inside an already-running container's namespaces — it doesn't attach to the container's original process. Killing your exec shell doesn't affect the container's actual main process at all.

Why containers are disposable, on purpose

The writable layer created by docker run is deleted along with the container when you rm it. Any file written inside a running container that isn't in a mounted volume disappears the moment that container is removed. This is a deliberate design choice, not an oversight: containers are meant to be treated as disposable and stateless, with anything that needs to persist — a database's actual data, user-uploaded files — stored outside the container in a volume or bind mount.

docker run -d --name my-db -v pgdata:/var/lib/postgresql/data postgres:16

pgdata here is a named volume, managed by Docker and stored outside any single container's writable layer. Remove and recreate the container, and the volume — and the real data inside it — survives, because it was never actually inside the container's disposable filesystem layer to begin with.

A bind mount is the other common option, mapping a specific host directory directly into the container rather than letting Docker manage the storage location:

docker run -d --name my-app -v $(pwd)/logs:/var/log/app my-app:latest

The distinction matters in practice: a named volume is portable and managed entirely by Docker (the right choice for a database's data directory); a bind mount ties the container to a specific path on this specific host (the right choice for local development, where you want to edit source files on your laptop and see the change reflected immediately inside a running container).

Resource limits at the container level

Beyond the Kubernetes-level resource requests and limits covered later in this blog, plain docker run already supports constraining a single container's CPU and memory directly:

docker run -d --name my-app --memory="512m" --cpus="0.5" my-app:latest

--memory caps the container's memory usage — exceeding it triggers the same kind of out-of-memory kill covered for Kubernetes Pods elsewhere in this blog, just enforced by the Docker Engine directly rather than a cluster scheduler. Setting these deliberately on a shared host running several containers prevents one container's runaway memory usage from starving every other container sharing that same machine — the exact same reasoning behind Kubernetes resource requests and limits, applied here one level down, to plain Docker.

Restart policies: what happens when a container's process exits

By default, a container that exits — whether cleanly or due to a crash — simply stays stopped. --restart changes that behavior:

docker run -d --name my-app --restart unless-stopped my-app:latest

unless-stopped restarts the container automatically if it exits unexpectedly, but respects a deliberate docker stop and won't restart it in that case — a reasonable default for a long-running service on a single host. always restarts unconditionally, even after a deliberate stop (only useful in narrower cases); on-failure restarts only on a non-zero exit code, leaving a clean exit alone. This is a genuinely simpler, single-host version of the same self-healing principle behind a Kubernetes Deployment recreating a failed Pod, covered in our next series — worth knowing as the plain-Docker equivalent before reaching for a full orchestrator.

What to actually remember from this post

  • A container is an isolated process sharing the host kernel, not a separate virtual machine — namespaces and cgroups provide the isolation, not a hypervisor.
  • An image is a read-only template; a container is a running instance of one — many containers, one shared image.
  • Layers are cached by content, which is what makes Dockerfile instruction order a genuine performance lever, not just style — docker history shows you exactly which instruction is responsible for an image's size.
  • Containers are disposable by design — anything that needs to survive a container being removed belongs in a named volume or bind mount, not the container's own writable layer.

Next in the series: Writing Production-Ready Dockerfiles, where layer caching and multi-stage builds turn this basic understanding into images that are actually fast to build and safe to ship.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.