Containers vs Virtual Machines
Containers and virtual machines both isolate applications, but they isolate them at different layers. A virtual machine runs a full guest operating system on virtual hardware, while a Docker container runs regular Linux processes that share a kernel with the host. Understanding that difference explains why containers start quickly, why images are smaller than VM disk images, and why containers are not simply lightweight VMs.
Overview: How Containers and VMs Work
A virtual machine is built around hardware virtualization. A hypervisor such as Hyper-V, VMware, KVM, or VirtualBox presents virtual CPUs, memory, disks, and network cards. Inside that virtual hardware, you install a complete guest operating system with its own kernel, system services, package manager, users, and filesystem. If you run three Linux VMs on one laptop, you are running three Linux kernels plus the host operating system.
A Docker container does not boot its own kernel. On Linux, Docker asks the host kernel to isolate a process using namespaces, control groups, capabilities, seccomp filters, and a union filesystem. Namespaces give the process its own view of things such as process IDs, hostnames, networks, mounts, and users. Control groups limit and account for CPU, memory, and I/O. The union filesystem mounts the image’s read-only layers and adds a thin writable layer for that one container.
This is the key mental model: an image is a read-only template made from stacked layers, and a container is an instance of that image plus a writable layer and a running process. The container feels like a small machine because it has its own filesystem view, environment, network interface, and process tree. Under the hood, however, it is still a process tree on the host kernel.
Docker Desktop on macOS and Windows adds one extra detail. macOS and Windows do not provide the same Linux kernel interfaces directly, so Docker Desktop runs a managed Linux virtual machine. Your containers still share the kernel of that Desktop VM, not the macOS or Windows kernel. This is why Docker commands look the same across platforms, but bind mounts and filesystem performance can behave differently on Docker Desktop.
Containers are usually better for packaging and running application processes: web servers, workers, databases for development, command-line tools, CI jobs, and microservices. VMs are better when you need a full operating system boundary, a different kernel, kernel modules, a complete init system, or stronger separation between tenants. In real infrastructure, teams often use both: cloud VMs provide the host machines, and containers run the applications on those hosts.
Syntax
The basic command for creating a container from an image is:
docker run [OPTIONS] IMAGE [COMMAND] [ARGUMENTS]
| Part | Meaning |
|---|---|
docker run |
Creates a new container, prepares its filesystem and network, then starts its main process. |
OPTIONS |
Container settings such as name, port publishing, memory limits, CPU limits, mounts, environment variables, and removal behavior. |
IMAGE |
The image to use, such as alpine:3.20 or nginx:1.27-alpine. Prefer specific tags over latest. |
COMMAND |
Optional command that replaces the image’s default command. |
ARGUMENTS |
Arguments passed to the command inside the container. |
Several options are especially useful when comparing containers with VMs:
| Option | What it shows |
|---|---|
--rm |
Removes the container when its main process exits, reinforcing that containers are disposable. |
-d |
Runs the process in the background, similar to starting a service. |
--name app |
Assigns a stable container name for later commands. |
-p 8080:80 |
Publishes a host port to a container port. Without this, the service is isolated from the host network. |
--memory 128m |
Limits how much memory the container can use through cgroups. |
--cpus 0.5 |
Limits CPU scheduling share. This does not create a virtual CPU; it constrains a host process. |
Examples
Example 1: A Container Starts as a Process, Not a Booting OS
docker run --rm alpine:3.20 uname -a
Output:
Linux 3b7f2b2b9f35 6.8.0-39-generic #39-Ubuntu SMP PREEMPT_DYNAMIC x86_64 Linux
This command starts a container from alpine:3.20, runs uname -a, prints kernel information, and exits. Alpine supplies the userspace tools and filesystem, but the kernel version shown is the host Linux kernel or the Linux kernel inside Docker Desktop’s managed VM. A VM would show the kernel installed in that VM’s guest operating system because it boots its own OS.
Example 2: Limit Resources Without Creating Virtual Hardware
docker run -d --name resource-demo --memory 128m --cpus 0.5 nginx:1.27-alpine
docker stats --no-stream resource-demo
docker rm -f resource-demo
Output:
7d9f6a4b2c10
CONTAINER ID NAME CPU % MEM USAGE / LIMIT NET I/O BLOCK I/O
7d9f6a4b2c10 resource-demo 0.03% 8.4MiB / 128MiB 1.2kB / 0B 0B / 0B
resource-demo
The memory and CPU flags tell the kernel’s cgroup system to constrain the Nginx process tree. Docker is not assigning a virtual memory stick or a virtual CPU socket the way a hypervisor would. It is applying accounting and scheduling limits to ordinary host processes. This is one reason containers are lightweight: they avoid booting an additional operating system for each service.
Example 3: A Container Has Its Own Filesystem View
docker run --rm alpine:3.20 sh -c "echo container-only > /tmp/demo.txt && cat /tmp/demo.txt"
Output:
container-only
The file exists inside that container’s writable layer. Because --rm removes the container after the command exits, the writable layer is also removed. The original alpine:3.20 image is unchanged. This differs from many VM workflows where a virtual disk is expected to persist as the machine’s main long-lived state. In Docker, persistent data belongs in a volume, bind mount, database, or external storage service.
Example 4: A Minimal Image Still Has Layers
FROM alpine:3.20
RUN adduser -D appuser
USER appuser
CMD ["sh", "-c", "echo hello from $(whoami)"]
This Dockerfile uses a pinned base image tag instead of alpine:latest, so rebuilds are more predictable. It creates a non-root user and runs a tiny command. Each Dockerfile instruction contributes image metadata or a filesystem layer, and a container created from the final image adds its own writable layer on top.
docker build -t container-vm-demo:1.0 .
docker run --rm container-vm-demo:1.0
Output:
[+] Building 1.2s (6/6) FINISHED
Successfully tagged container-vm-demo:1.0
hello from appuser
The image does not contain a bootloader, a kernel, or a complete VM disk. It contains a filesystem, configuration, and a default command. Docker starts the configured command as a process with isolation around it.
How It Works Step by Step
- You run
docker run. The Docker CLI sends a request to the Docker daemon using the Docker API. - The daemon checks whether the image exists locally. If not, it pulls the image manifest and missing layer blobs from a registry.
- Docker creates container metadata: image, command, environment, user, mounts, network settings, resource limits, and security settings.
- The daemon mounts the image’s read-only layers through a union filesystem and creates a thin writable layer for the container.
- The kernel creates namespaces so the process gets isolated views of PIDs, networking, hostnames, mounts, and other resources.
- The kernel applies cgroup rules for memory, CPU, process counts, and other resource controls if you requested limits.
- Docker starts the container’s main process. If that process exits, the container stops; there is no guest operating system continuing to run in the background.
- When you remove the container, Docker deletes its metadata and writable layer. The image layers remain until you remove the image.
A VM follows a different sequence. The hypervisor allocates virtual hardware, loads firmware or a bootloader, boots a guest kernel, starts an init system, and then starts services. That gives a stronger machine-shaped boundary and more OS flexibility, but it also costs more disk, memory, and startup time.
Common Mistakes
Thinking a Container Is a Tiny VM
docker run --rm ubuntu:24.04 /sbin/init
This is the wrong default instinct. Containers are designed to run a foreground process, not boot a whole operating system with a full init tree. Some advanced containers do run supervisors or system services, but most application containers should start the application directly.
docker run --rm ubuntu:24.04 echo "run one foreground process"
Assuming Container Isolation Equals VM Isolation
Containers share a kernel, so a kernel vulnerability can matter across containers. Docker adds important isolation layers, but a container is not the same security boundary as a separate VM, especially if it runs as root, uses broad capabilities, mounts sensitive host paths, or runs with elevated privileges. For untrusted multi-tenant workloads, combine containers with hardened hosts, least privilege, user namespaces where appropriate, and often VM-level separation.
Expecting Container Files to Persist Like a VM Disk
docker run --name throwaway alpine:3.20 sh -c "echo important > /data.txt"
docker rm throwaway
The data was written into the container’s writable layer. Removing the container removes that layer. Use a named volume when data must outlive a container:
docker volume create demo-data
docker run --rm -v demo-data:/data alpine:3.20 sh -c "echo important > /data/data.txt"
Using latest as if It Means Stable
docker run --rm alpine:latest uname -a
The latest tag is just a mutable tag. It can point to different image content later, which hurts reproducibility. Use a specific tag such as alpine:3.20, and for production promotion pipelines consider pinning tested images by digest.
Best Practices
- Use containers for isolated application processes, repeatable development environments, CI jobs, and services that can be replaced from an image.
- Use VMs when you need a separate kernel, full OS boot behavior, kernel-level customization, or stronger tenant separation.
- Treat containers as disposable. Keep important state in named volumes, bind mounts for local development, databases, or external storage.
- Use specific image tags instead of
latestso the same command resolves to expected software versions. - Run containers as non-root when practical, drop unnecessary privileges, and avoid mounting sensitive host paths.
- Set resource limits for noisy or memory-hungry workloads, especially on shared development machines and CI runners.
- Remember the Docker Desktop VM layer on macOS and Windows when diagnosing filesystem performance, path sharing, or kernel-version surprises.
- Do not model each container as a long-lived server to manually patch. Rebuild the image, replace the container, and keep runtime state outside the container.
Practice Exercises
- Run
alpine:3.20withuname -aand compare the kernel version to your host or Docker Desktop VM. Expected end state: you can explain which kernel the container is using. - Start
nginx:1.27-alpinewith--memory 64mand--cpus 0.25, then inspect it withdocker stats --no-stream. Hint: remove the container afterward withdocker rm -f. - Create a named volume and write a file into it from an
alpine:3.20container. Start a second container using the same volume and read the file. Expected end state: the data survives because it is stored outside the container’s writable layer.
Summary
- VMs virtualize hardware and boot a full guest OS with its own kernel.
- Docker containers isolate processes that share the host Linux kernel or Docker Desktop’s Linux VM kernel.
- Containers start quickly because Docker starts a process instead of booting an operating system.
- Images are read-only layered templates; containers add a thin writable layer and runtime configuration.
- Containers are lighter than VMs, but they are not automatically the same security boundary.
- Use containers and VMs together when it makes sense: VMs for machine isolation, containers for repeatable application packaging and deployment.
