Module 1 · Container Foundations · Lesson 1 of 20
Images vs Containers: How Docker Really Works for .NET Developers
Learning outcome
You can explain the difference between an image, a container, a registry and the engine without hand-waving. You know what survives docker stop, docker start and docker rm, why "containers are ephemeral" is only half true, and why a container lives exactly as long as its main process. You can show it all with an ASP.NET Core API on .NET 10 and explain how containers differ from virtual machines.
Intuition
An image is a compiled, versioned package: read-only files plus metadata that says how to start the app. It works like a NuGet package for a whole runtime environment. A container is one running instance of that package. Each instance gets its own process, its own thin writable scratch layer, its own network identity and its own lifecycle. It works like a process started from an executable: you can start ten processes from one .exe, and deleting a process does not delete the .exe.
Keep this chain in your head for the rest of the course:
flowchart LR
DF["Dockerfile (recipe)"] -->|docker build| IMG["Image (read-only layers + config)"]
IMG -->|docker push / pull| REG["Registry (Docker Hub, ACR, GHCR)"]
IMG -->|docker run| C1["Container 1: process + writable layer"]
IMG -->|docker run| C2["Container 2: process + writable layer"]
VOL["Volume (outlives containers)"] -.mounted into.-> C1Deep dive
The moving parts
| Term | What it is | .NET developer analogy |
|---|---|---|
| Dockerfile | Text recipe that builds an image | A build script |
| Image | Read-only stack of filesystem layers plus config (entrypoint, env, user, ports) | A versioned deployment package |
| Container | A process (or process tree) started from an image, isolated by the kernel, plus a writable layer | A running process with its own private filesystem view |
| Registry | A server that stores and serves images | A NuGet feed for images |
| Tag | A movable human-readable label such as 1.4.2 or latest | A version string, but one that can be repointed |
| Digest | sha256:... content hash of an image manifest; immutable | A content hash; the real identity |
| Engine (daemon) | The service that builds images and runs containers (dockerd, containerd, runc underneath) | The runtime host |
| CLI | The docker client that talks to the engine over an API | A client SDK |
The CLI is only a client. If the engine is not running, docker ps fails with "Cannot connect to the Docker daemon" even though the docker executable exists. On Windows and macOS, Docker Desktop runs the engine inside a small Linux VM (WSL 2 on Windows). Your Linux containers therefore always see a Linux kernel, even when you type commands in PowerShell.
What isolation actually is
A Linux container is not a tiny VM. It is an ordinary process on the host kernel, fenced in by kernel features:
- Namespaces give the process its own view of process IDs (it sees itself as PID 1), network interfaces, hostname, mount points and users.
- cgroups limit and account CPU, memory and I/O (
--memory=512m,--cpus=1.5). - A union filesystem (overlay2) stacks the image's read-only layers and puts a writable layer on top.
- Capabilities, seccomp and AppArmor/SELinux profiles reduce what even root inside the container may do.
| Container | Virtual machine | |
|---|---|---|
| Kernel | Shares the host kernel | Runs its own guest kernel |
| Starts in | Milliseconds to seconds | Tens of seconds |
| Size | MBs to hundreds of MBs | GBs |
| Isolation boundary | Kernel features (weaker, but usually enough) | Hypervisor (stronger) |
| Runs a different OS kernel | No: Linux containers need a Linux kernel | Yes |
That shared kernel is the reason a Windows Server container cannot run on a Linux host, and it is the reason containers are cheap enough to run one per process.
The lifecycle and what survives it
| Command | Effect on the container | Writable-layer files |
|---|---|---|
docker run | create + start a new container | new, empty writable layer |
docker stop | sends SIGTERM to PID 1, waits 10 s, then SIGKILL | kept |
docker start | starts the same container again | still there |
docker rm (-f) | deletes the container | gone |
| new image version | does nothing to existing containers | you recreate the container from the new image |
"Containers are ephemeral" really means you should be able to delete and recreate a container at any time without losing anything that matters. Stopping a container keeps its writable-layer files, but process memory and tmpfs data do not survive a stop/start. Removing it loses everything that was not in a volume, a bind mount or an external store. Kubernetes, Azure Container Apps and every CI system remove and recreate containers all the time, so design as if every container may be removed at any moment.
PID 1 decides the container's life
The container runs the image's ENTRYPOINT/CMD as its main process, PID 1 inside its PID namespace. When that process exits, the container stops, and its exit code becomes the container's exit code. Docker is not "crashing your app". If an ASP.NET Core app throws during startup (bad connection string, missing configuration, port already in use), the process exits, the container stops, and docker logs holds the stack trace. PID 1 also receives the stop signal, which is why the ENTRYPOINT form matters (Lesson 3).
Code example: one image, two containers, one writable layer each
The image shop-api:1.0 is the small ASP.NET Core API used throughout this course. It has / (who am I), /files (write and read a file), health endpoints, and Redis and PostgreSQL endpoints that we use later. Its Dockerfile is built in Lesson 3. The session below is a real terminal transcript, captured on Docker Engine 26.1 with the official .NET 10 images.
$ docker image ls shop-api
REPOSITORY TAG IMAGE ID CREATED SIZE
shop-api 1.0 a2cbf40904c7 49 seconds ago 234MB
$ docker run -d --name shop1 -p 8081:8080 shop-api:1.0 >/dev/null; echo started shop1
started shop1
$ docker run -d --name shop2 -p 8082:8080 shop-api:1.0 >/dev/null; echo started shop2
started shop2
$ docker ps --filter name=shop --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
NAMES IMAGE STATUS PORTS
shop2 shop-api:1.0 Up Less than a second 0.0.0.0:8082->8080/tcp, :::8082->8080/tcp
shop1 shop-api:1.0 Up Less than a second 0.0.0.0:8081->8080/tcp, :::8081->8080/tcp
$ curl -s localhost:8081/
{"service":"Shop.Api","version":"dev","environment":"Production","machine":"9107afe21aa0","uid":"1654","framework":".NET 10.0.13","os":"Ubuntu 24.04.5 LTS"}
$ curl -s localhost:8082/
{"service":"Shop.Api","version":"dev","environment":"Production","machine":"18b868e27562","uid":"1654","framework":".NET 10.0.13","os":"Ubuntu 24.04.5 LTS"}
# Write a file inside shop1's writable layer, then stop and start the SAME container
$ curl -s -X POST 'localhost:8081/files?path=/tmp/cart.txt&text=3-items'
{"written":"/tmp/cart.txt"}
$ docker diff shop1
C /tmp
A /tmp/cart.txt
A /tmp/clr-debug-pipe-1-2330053-in
A /tmp/clr-debug-pipe-1-2330053-out
A /tmp/dotnet-diagnostic-1-2330053-socket
$ docker stop shop1 >/dev/null; echo stopped
stopped
$ docker start shop1 >/dev/null; echo started
started
$ curl -s 'localhost:8081/files?path=/tmp/cart.txt'
3-items
# Remove the container and create a new one from the same image
$ docker rm -f shop1 >/dev/null; echo removed shop1
removed shop1
$ docker run -d --name shop1 -p 8081:8080 shop-api:1.0 >/dev/null; echo started a NEW shop1
started a NEW shop1
$ curl -s 'localhost:8081/files?path=/tmp/cart.txt'
{"missing":"/tmp/cart.txt"}
# The container lives as long as its main process (PID 1)
$ docker run --name crash -e 'ConnectionStrings__Postgres=Host=db;Port=abc' shop-api:1.0 2>&1 | head -2
Unhandled exception. System.ArgumentException: Couldn't set port (Parameter 'port')
---> System.FormatException: The input string 'abc' was not in a correct format.
$ docker inspect crash --format 'status={{.State.Status}} exit={{.State.ExitCode}}'
status=exited exit=139
$ docker top shop2 -o pid,user,args
PID USER COMMAND
961840 1654 dotnet Shop.Api.dllWhat to notice:
- One image, two containers. Both answer from the same 234 MB image, but each has its own hostname (the container ID), its own published port and its own process. The app runs as UID
1654, the non-rootappuser that the Dockerfile selects. docker difflists the writable layer:A= added,C= changed. Next to ourcart.txtyou see the .NET runtime's diagnostic pipes in/tmp. Those are the socketsdotnet-countersanddotnet-traceuse, which is why/tmpmust stay writable in a hardened container (Lesson 7).- Stop/start kept the file. It is the same container with the same writable layer.
- Remove + run lost it. A new container gets a fresh writable layer. That is the moment data disappears in real incidents, for example after a redeploy.
- The crash. An invalid connection string makes
NpgsqlDataSource.Createthrow at startup. The process dies, the container isexited, and the logs hold the reason. The exit code (139 here, which is not the 134 you might expect from an abort; Lesson 15 explains why) only tells you the process died abnormally. Read the logs for the cause, not the number. docker topshows the host's view: a singledotnet Shop.Api.dllprocess owned by UID 1654. Inside the container that same process is PID 1.
Failure modes
- Treating a container like a server. Patching packages or editing config inside a running container with
docker execworks until the next recreate. Change the image or the configuration, then recreate. - Data in the writable layer. Uploads, SQLite files, Data Protection keys or a database written inside the container vanish on
docker rm. Use a volume or an external store (Lesson 10). - "Updating the image" and expecting running containers to change. Containers keep the image they were created from. Pull, then recreate.
- Using
latestas a version. The tag moves; two machines can run different code under the same name. Use explicit tags and record digests (Lesson 17). - Assuming a VM-level security boundary. Containers share the kernel. Do not run untrusted code as root in a container and call it sandboxed (Lesson 7).
- Reading exit codes as diagnoses. 137 usually means SIGKILL (often the OOM killer or a stop timeout). Anything else, read the logs first.
Interview angle
"What is the difference between an image and a container?" A strong answer is short and precise: "An image is an immutable, layered filesystem plus metadata such as the entrypoint, user and environment, identified by a digest and distributed through registries. A container is a running instance of an image: an isolated process on the host kernel with its own writable layer, network namespace and lifecycle. One image can back many containers. Files written in a container survive stop and start but disappear when the container is removed, so state goes in volumes or external stores." Then add one practical consequence: you deploy by building a new image and replacing containers, never by patching running ones.
Expect the follow-up "How is a container different from a VM?" Answer: shared kernel versus guest kernel, namespaces and cgroups versus a hypervisor, and the trade-off of speed and density against isolation strength.
Practice questions
- You stop a container, start it again, and a file written earlier is still there. A colleague says containers are ephemeral. Who is right?
Answer: Both. Stop/start keeps the same container and its writable layer. The file is lost when the container is removed, for example on every redeploy. Ephemeral means "must be safe to remove and recreate".
- You pushed a fixed image under the same tag. Why do the running containers still show the bug?
Answer: Containers keep the image they were created from. You must pull the new image and recreate the containers. Orchestrators do this as a rolling update.
- A container exits immediately after
docker run. What do you check first?
Answer: docker ps -a for the exit code and docker logs <name> for the startup exception. The main process exited, so the container stopped.
- Why can a Linux container start in under a second when a VM takes much longer?
Answer: It is a process on an already running kernel, isolated by namespaces and cgroups. There is no guest OS to boot.
- What is the difference between a tag and a digest?
Answer: A tag is a mutable label that can be moved to another image. A digest is the content hash of the manifest and always identifies the same bytes.
Revision checklist
- Dockerfile -> image -> container; registry stores images; the engine runs containers; the CLI is a client.
- Container = process + namespaces + cgroups + writable layer on read-only image layers.
- Stop/start keeps the writable layer;
rmdeletes it; volumes outlive containers. - PID 1 exit = container exit; read
docker logsfor the cause. - Containers share the host kernel; VMs do not.
Related on TechInterviewNotes
- Next lesson: Docker Image Layers, the Build Cache and .dockerignore
- Later in this course: Volumes, Bind Mounts and tmpfs
Analogy
The recipe, the boxed meal and the plate
The Dockerfile is a recipe. The image is a sealed, labelled boxed meal made from it: every box with the same label hash is identical. A container is one plate served from that box. You can serve many plates, each diner can add salt to their own plate (the writable layer), and clearing one plate does nothing to the box. If you want everyone to get a better meal, you change the recipe and box a new batch. You do not walk around salting plates.
Quick reference
Lifecycle
docker run -d --name api -p 8080:8080 img:tag: create + start.docker ps -a: running and stopped containers with exit status.docker logs -f api: stdout/stderr of PID 1.docker stop api/docker start api: writable layer kept.docker rm -f api: writable layer deleted.docker diff api: what changed in the writable layer.docker top api: processes, as seen by the host.