Disclaimer: ChatGPT generated document.
Virtual machines and containers solve a similar problem: how can we run applications in isolated environments without letting them interfere with each other?
The major difference is what they virtualize.
A virtual machine (VM), such as one created with Hyper-V, VMware, or VirtualBox, behaves like a separate physical computer.
A simplified architecture looks like this:
Physical Computer
│
└── Hypervisor
│
├── Virtual Machine A
│ ├── Virtual hardware
│ ├── Guest operating system
│ │ └── Kernel
│ └── Application
│
└── Virtual Machine B
├── Virtual hardware
├── Guest operating system
│ └── Kernel
└── Application
Each VM has its own operating system, including its own kernel. It believes it has its own CPU, memory, disks, network interfaces, and so on, although these resources are actually provided virtually by the hypervisor.
This provides strong isolation, but it also makes VMs relatively heavy. If you create five Ubuntu VMs, you are effectively running five separate Ubuntu operating systems, each consuming memory, disk space, and other resources.
A useful simplification is:
VM = an isolated virtual computer
A container takes a lighter-weight approach.
Instead of pretending to be an entire computer, a container provides an isolated environment for running processes.
Conceptually:
Physical Computer
│
└── Operating System
│
└── Docker
│
├── Container A
│ └── Application
│
├── Container B
│ └── Application
│
└── Container C
└── Application
The important difference is that these containers share the underlying operating-system kernel.
They can still have their own files, libraries, processes, environment variables, network configuration, and so on. From an application's perspective, the container can feel like its own little machine.
But underneath, the containers aren't running separate kernels.
This makes containers much lighter than VMs. Starting a new container can take a fraction of a second rather than requiring an entire operating system to boot.
A useful simplification is:
Container = an isolated application environment
Containers need something that defines what their environment should contain. That's where Docker images come in.
A Docker image is not just a configuration file.
It is a packaged, read-only filesystem containing things such as:
Docker Image
│
├── Operating-system user-space files
├── Libraries
├── Runtime
├── Application dependencies
├── Your application
└── Configuration/default startup command
For example, an image for a Python application might contain:
- Ubuntu user-space files
- Python 3.12
- required Python packages
- your application's source code
- configuration telling Docker how to start the application
The image acts as a template from which containers are created.
The standard terminology is that a container is an instance of an image.
Docker Image
│
┌─────────┼─────────┐
▼ ▼ ▼
Container A Container B Container C
You can build an image once and create many containers from it.
This is one of the more confusing parts of Docker.
You will often see a Dockerfile beginning with something like:
FROM ubuntu:24.04It is tempting to think this means:
"This container contains an entire Ubuntu operating system."
That's only partially true.
An operating system can roughly be thought of as having two major parts:
Operating System
│
├── Kernel
│
└── User-space
├── system libraries
├── shell
├── commands
├── package manager
└── other utilities
The Ubuntu Docker image contains much of the Ubuntu user-space environment, but it does not contain its own Linux kernel.
The container uses the kernel of the underlying host.
So a Linux container looks more like:
Ubuntu Container
│
├── Ubuntu user-space
├── Application
└───────────────┐
│
▼
Host Linux Kernel
This is fundamentally different from a VM.
With a VM:
Ubuntu VM
│
├── Ubuntu user-space
└── Linux kernel
With a container:
Ubuntu Container
│
├── Ubuntu user-space
└── Uses host's Linux kernel
This sharing of the kernel is a major reason containers are so lightweight.
This raises an obvious question.
If Linux containers need a Linux kernel, how can you run an Ubuntu Docker container on Windows or macOS?
Docker typically uses a lightweight Linux virtual machine behind the scenes.
Conceptually, Docker on Windows might look like:
Windows Computer
│
└── Windows
│
└── Linux VM
│
├── Linux Kernel
│
└── Docker
│
├── Ubuntu Container
├── Debian Container
└── Alpine Container
The containers share the Linux kernel provided by that VM.
This creates an interesting combination of the two technologies:
A VM provides the Linux environment, and containers run efficiently inside that VM.
Docker Desktop handles much of this automatically, so developers usually don't have to think about the underlying VM.
A Dockerfile is different from an image.
The Dockerfile is a text file containing instructions describing how Docker should build an image.
For example:
FROM ubuntu:24.04
RUN apt-get update
RUN apt-get install -y python3
COPY myapp.py /app/myapp.py
CMD ["python3", "/app/myapp.py"]These instructions roughly mean:
- Start with the Ubuntu 24.04 image.
- Update its package information.
- Install Python.
- Copy our application into the image.
- Configure Python to run our application when the container starts.
Docker then builds the image:
Dockerfile
│
│ docker build
▼
Docker Image
│
│ docker run
▼
Container
This distinction is fundamental:
Dockerfile = instructions for creating an image
Docker image = the built, packaged environment
Docker container = an instance of that image
Dockerfiles are the standard way to define images, although technically images can be created in other ways.
When you execute something like:
docker build -t myapp .Docker reads the Dockerfile and executes its build instructions.
Conceptually:
Start with Ubuntu
↓
Install Python
↓
Install dependencies
↓
Copy application files
↓
Configure startup command
↓
Produce Image
The resulting image can then be stored, transferred to another machine, uploaded to a container registry, or used to start containers.
This is one of Docker's biggest advantages.
Instead of telling another developer:
"Install Ubuntu, then install Python, then install these packages, then configure these environment settings, then copy these files..."
you can give them the image.
The environment has already been assembled.
Internally, a Docker image isn't usually one giant block of data. It is constructed from read-only layers.
Consider:
FROM ubuntu:24.04
RUN apt-get install -y python3
COPY myapp.py /app/myapp.pyConceptually, the resulting image might look like:
┌────────────────────────────┐
│ Application files │
├────────────────────────────┤
│ Python installation │
├────────────────────────────┤
│ Ubuntu base filesystem │
└────────────────────────────┘
Together, these layers form the image.
Layers are important because Docker can reuse and share them.
Suppose you have three applications:
# Application A
FROM ubuntu:24.04
COPY app-a /app# Application B
FROM ubuntu:24.04
COPY app-b /app# Application C
FROM ubuntu:24.04
COPY app-c /appFrom your perspective, these are three separate images:
Image A = Ubuntu + App A
Image B = Ubuntu + App B
Image C = Ubuntu + App C
But Docker doesn't necessarily need to store three copies of the Ubuntu base.
Instead:
Ubuntu 24.04 layers
│
┌────────────┼────────────┐
│ │ │
App A App B App C
layers layers layers
Suppose, purely as an example, the Ubuntu portion were 100 MB:
Ubuntu layers 100 MB ← stored once
App A layers 5 MB
App B layers 8 MB
App C layers 4 MB
The common Ubuntu data can be shared rather than duplicated.
Docker can do this because image layers are content-addressed. Their contents are identified using cryptographic hashes.
Conceptually:
Image A ──┐
│
Image B ──┼──→ sha256:abc123... → shared Ubuntu layer
│
Image C ──┘
If two images reference the exact same layer, Docker knows it only needs to store that layer once.
This leads to another important question.
If a container uses the image's filesystem rather than receiving a complete copy of it, how can applications inside containers create and modify files?
Docker gives every container its own writable layer on top of the image's read-only layers.
Conceptually:
Container A's filesystem
┌──────────────────────────────┐
│ Container A writable layer │ ← private
├──────────────────────────────┤
│ Application image layer │ ← read-only
├──────────────────────────────┤
│ Dependencies image layer │ ← read-only
├──────────────────────────────┤
│ Ubuntu image layers │ ← read-only
└──────────────────────────────┘
To the application, all of this appears as one normal filesystem.
Suppose the image contains:
/app/config.txt
Initially, the container can read that file directly from the image.
If the container modifies it, Docker uses a technique commonly described as copy-on-write.
Conceptually:
Before modification:
Image
└── config.txt
Container
└── reads image's config.txt
When the container modifies the file:
Image
└── config.txt ← unchanged
Container writable layer
└── config.txt ← modified copy
The container now sees its modified version, while the original image remains unchanged.
Suppose an image contains:
config.txt = "A"
and you create two containers from that image.
Initially:
IMAGE
config.txt = "A"
│
┌─────┴─────┐
▼ ▼
Container 1 Container 2
Container 1 changes the file to "B" and Container 2 changes it to "C".
You end up conceptually with:
Image
config.txt = "A"
│
├───────────────┐
▼ ▼
Container 1 Container 2
config.txt = "B" config.txt = "C"
The image remains unchanged.
Each container only stores its own modifications in its private writable layer.
This is why you can create many containers from one image without making a complete copy of the image for every container.
There are therefore two related forms of sharing happening in Docker.
First, different images can share image layers:
Ubuntu base layers
│
┌──────┴──────┐
▼ ▼
Image A Image B
Second, multiple containers can share the read-only layers of the same image:
Image A
│
┌─────┴─────┐
▼ ▼
Container A1 Container A2
│ │
writable writable
layer layer
Putting both together:
Ubuntu layers
│
┌─────────┴─────────┐
▼ ▼
Image A Image B
│ │
┌────┴────┐ ┌────┴────┐
▼ ▼ ▼ ▼
Cont A1 Cont A2 Cont B1 Cont B2
So Docker can avoid duplication both between images and between containers.
A container's writable layer belongs to that particular container.
If you remove the container, that writable layer is normally removed with it.
This means you generally should not rely on the container's writable layer for important persistent data.
For example, imagine running a database:
Container
│
├── Database application
└── Database files
If the database files only exist in the container's writable layer, deleting the container could delete the database data as well.
For persistent data, Docker commonly uses volumes.
Conceptually:
Container
│
▼
Docker Volume
│
└── database files
The volume exists separately from the container.
You can therefore delete one container and create another container that uses the same volume:
Old Container
│
▼
Volume
▲
│
New Container
This separation is useful because containers are generally intended to be relatively disposable, while some application data needs to survive.
Because images share layers internally, another natural question is:
If Image A and Image B both share Ubuntu layers, how do I know which files I need to give someone if I want to transfer Image A?
Normally, you don't need to know.
Docker keeps track of which layers make up each image.
Suppose:
Image A
├── Ubuntu layers ──────┐
└── App A layers │
│ shared
Image B │
├── Ubuntu layers ──────┘
└── App B layers
If you ask Docker to transfer Image A, Docker knows that Image A requires:
Ubuntu layers
+
App A layers
You treat the image as one logical object, even though Docker internally stores it as multiple potentially shared layers.
The normal way to distribute Docker images is through a container registry.
Examples include Docker Hub and private registries operated by companies or cloud providers.
Conceptually:
Your Computer
│
│ docker push
▼
Container Registry
│
│ docker pull
▼
Another Computer
You might push:
docker push yourname/myapp:latestand someone else might pull:
docker pull yourname/myapp:latestDocker determines which layers are required.
If the other computer already has one of the exact required layers, Docker may not need to download that layer again.
For example:
Image being downloaded:
Ubuntu layer ← already exists
Python layer ← already exists
Application layer ← needs downloading
Docker can reuse the existing layers and only download what is missing.
Sometimes you don't want to use a registry. Perhaps you want to put an image on a USB drive, transfer it over a network manually, or archive it.
Docker can package an image into a single archive using:
docker save -o myapp.tar myapp:latestYou now have:
myapp.tar
That archive contains the image metadata and the required image layers.
You can transfer that file to another computer, where the image can be loaded with:
docker load -i myapp.tarSo there are two ways to think about an image:
Logical view:
myapp:latest
│
└── one Docker image
Internally:
Physical representation:
Image metadata
+
Layer A
+
Layer B
+
Layer C
And for manual transfer:
myapp.tar
│
├── metadata
├── Layer A
├── Layer B
└── Layer C
The important point is that you don't manually determine which layer files belong to an image. Docker handles that for you.
The basic Docker lifecycle is:
Dockerfile
│
│ docker build
▼
Docker Image
│
│ docker run
▼
Container
The Dockerfile describes how to construct the image.
The image contains the read-only filesystem and configuration needed to run the application.
A container is an instance of that image, with its own writable layer.
Multiple containers can share the same image:
Image
│
┌─────────┼─────────┐
▼ ▼ ▼
Container 1 Container 2 Container 3
│ │ │
writable writable writable
layer layer layer
And different images can themselves share common image layers:
Ubuntu layers
│
┌────────┴────────┐
▼ ▼
Image A Image B
│ │
App A layer App B layer
Finally, containers share the underlying kernel:
Container A ─┐
Container B ─┼──► Linux Kernel
Container C ─┘
while virtual machines each have their own kernel:
VM A VM B
│ │
├── Application ├── Application
├── OS user-space ├── OS user-space
└── Kernel └── Kernel
│ │
└──────────┬─────────────┘
▼
Hypervisor
│
▼
Hardware
The entire topic can be reduced to a few key ideas:
Virtual machine — A virtual computer with its own operating system and kernel.
Container — An isolated application environment that shares the underlying kernel.
Dockerfile — A recipe containing instructions for building an image.
Docker image — A read-only packaged environment consisting of reusable layers.
Docker container — An instance of an image with its own writable layer.
Image layer — A reusable, read-only piece of an image that can potentially be shared between multiple images and containers.
Writable container layer — Private changes made by a particular container.
Docker volume — Persistent storage that exists separately from a container's disposable writable layer.
Container registry — A service used to store and distribute Docker images.
And the most useful single diagram is:
Dockerfile
│
docker build
▼
Image
(read-only layers)
│
docker run
▼
Container
│
private writable layer
│
optional persistent volume
If you keep that model in mind, most of the Docker terminology and behavior follows naturally from it.
