The short answer: a virtual machine emulates a whole computer. A hypervisor gives each VM virtual CPUs, memory, disks and network cards, and the VM boots its own operating system with its own kernel. A container is an ordinary process (or group of processes) on the host, which the Linux kernel isolates so it sees its own filesystem, process list, network interfaces and hostname, with limits on CPU and memory. Containers share the host’s kernel; VMs don’t.
Everything else follows from that one difference. Containers are smaller and start in about the time it takes to launch a process, because there’s no OS to boot. VMs isolate more strongly, because an attacker who escapes a VM has to break the hypervisor rather than just the shared kernel. That’s why the real-world answer is often “both”: containers for packaging and density, running inside VMs for the security boundary between customers.
Containers and VMs side by side
| Containers | Virtual machines | |
|---|---|---|
| What’s virtualised | The operating system’s view (processes, network, filesystem) | The hardware (CPU, memory, disks, NICs) |
| Kernel | Shared with the host | Each VM has its own |
| Isolation mechanism | Linux namespaces, cgroups, seccomp, capabilities | Hypervisor with hardware virtualisation (Intel VT-x, AMD-V, Arm) |
| Startup | Typically under a second: it’s a process start | Typically seconds to minutes: firmware, kernel and OS boot |
| Image size | Megabytes to a few hundred MB (app plus libraries) | Gigabytes (a full OS install) |
| Memory overhead | Only what the processes use | A full guest OS per VM, reserved up front |
| Density per host | Hundreds of containers is common | Tens of VMs is typical |
| Guest OS choice | Must match the host kernel (Linux containers need a Linux kernel) | Any OS the hypervisor supports |
| Security boundary | Weaker: a kernel bug can affect every container | Stronger: escape requires a hypervisor flaw |
| Typical tools | Docker, containerd, Podman, Kubernetes | VMware ESXi, KVM, Hyper-V, VirtualBox, cloud instances |
What a container actually is
There’s no “container” object in the Linux kernel. Docker (through containerd and runc) assembles one from several kernel features when you run docker run:
- Namespaces change what a process can see. A PID namespace makes the app see itself as PID 1. A network namespace gives it its own interfaces and ports. A mount namespace gives it its own root filesystem, built from the image. UTS, IPC, user and cgroup namespaces isolate the hostname, shared memory, user ids and the cgroup tree.
- cgroups limit what a process can use:
--memory 256msets a memory limit, after which the kernel’s OOM killer ends the process, and--cpus 1.5caps CPU time. - A layered filesystem (overlay2 by default) stacks read-only image layers with a thin writable layer per container. Ten containers from one image share the same layers on disk.
- Security filters: a default seccomp profile blocks a set of risky system calls, and most Linux capabilities are dropped.
The process is still visible from the host. On a Linux host, ps aux shows your container’s node server.js alongside everything else, just with different namespace ids. That’s the clearest way to explain the difference in an interview: a VM is a machine inside a machine, while a container is a process wearing blinkers.
A walkthrough: the same app both ways
Docker wasn’t running on the machine I wrote this on, so the commands below are illustrative and their output is described rather than pasted. They’re standard Docker CLI usage.
Suppose you ship a small Node API. As a VM, you’d create an instance from an Ubuntu image, wait for it to boot, install Node, copy the code, set up a systemd unit and open the firewall. The disk holds a complete OS: kernel, init system, package manager, SSH server, all of which you now have to patch.
As a container, you write a Dockerfile that starts from a Node base image, copies the code and sets the command, then:
docker build -t orders-api .
docker run -d --name orders -p 8080:3000 --memory 256m --cpus 1 orders-api
docker exec orders cat /etc/os-release
docker exec orders uname -rThe os-release file reports whatever distribution the base image used (Debian for the default Node images, Alpine for the -alpine variants), because that’s the userland in the image. But uname -r reports the host’s kernel version, because there’s only one kernel. On a Linux server, it’s the server’s kernel. On a Mac, it’s the kernel of the small Linux VM that Docker Desktop runs, which is why Docker on macOS and Windows always involves a VM: my laptop reports Darwin 25.6.0 from uname -sr, and Linux containers can’t run on a Darwin kernel directly.
That one experiment answers several follow-up questions at once. You can run an Alpine container on an Ubuntu host (same kernel, different userland). You can’t run a Windows container on a Linux host without a VM (different kernel). And a container that needs a newer kernel feature than the host has will fail, which a VM wouldn’t care about.
Security: where the boundary really is
Interviewers often ask whether containers are “secure”. A precise answer:
- All containers on a host share one kernel. A kernel vulnerability, or a container run with
--privileged, a mounted Docker socket or added capabilities, can let a process reach the host and everything on it. - Running as root inside a container is root on the host’s kernel unless user namespaces remap it. Running as a non-root user, read-only filesystems and dropping capabilities all shrink the risk; running containers as non-root covers the practical side.
- VMs put a much smaller interface (the hypervisor) between tenants, and hardware enforces the separation of memory.
That’s why public clouds don’t put different customers’ containers on one shared kernel without another layer. AWS built Firecracker micro-VMs for Lambda and Fargate, Kata Containers runs each pod in a lightweight VM, and gVisor puts a user-space kernel between the container and the host. These options aim for most of a container’s speed with most of a VM’s isolation.
Mistakes and myths
“Containers are lightweight VMs.” It’s a handy first approximation, but it leads to wrong conclusions, like expecting your own kernel, or running systemd and SSH inside a container. A container should usually run one main process.
“Containers make apps portable everywhere.” They’re portable across hosts with a compatible kernel and CPU architecture. An amd64 image won’t run natively on an Arm host; it needs a multi-platform image or emulation.
“VMs are obsolete.” Your Kubernetes nodes are almost certainly VMs. You still need VMs for different operating systems, kernel-level customisation, strong multi-tenant isolation and many legacy workloads.
“Container limits are free.” Without --memory, a container can use all the host’s memory and starve its neighbours. Limits are opt-in for plain Docker; see resource limits.
Exercise for the interview: choose the platform
A SaaS company runs (a) its own stateless web services, (b) a code-execution feature where customers submit arbitrary scripts, and (c) a vendor’s licensed reporting tool that only supports Windows Server. What would you run each on?
Answer
(a) Containers, orchestrated by Kubernetes or a managed container service, on VMs as nodes. The services are the company’s own code, so they trust each other enough to share a kernel, and they benefit from fast startup, dense packing and identical images from laptop to production. (b) Untrusted code needs a stronger boundary than a shared kernel. Use micro-VMs (Firecracker or Kata Containers) or at least a sandboxed runtime like gVisor, with strict CPU, memory and time limits and no network access by default. Explaining that ordinary containers aren’t enough here is the main thing the question tests. (c) A Windows VM. Linux containers can’t run it, and while Windows containers exist, a vendor-supported Windows Server install in a VM is the low-risk choice. The overall pattern is to match the isolation to how much you trust the workload.