DEV Community

Cover image for Docker Containers Aren't Virtual Machines: How Namespaces, cgroups, and OverlayFS Actually Work Under the Hood

Docker Containers Aren't Virtual Machines: How Namespaces, cgroups, and OverlayFS Actually Work Under the Hood

S M Tahosin on October 22, 2026

Ask almost any developer to explain what a Docker container is, and you will usually get a diagram contrasting two towers: On the left sits a Virt...
Collapse
 
pengeszikra profile image
Peter Vivo •

Thx, I was never deep divie into docker

Collapse
 
smtahosin profile image
S M Tahosin •

Thanks Peter! Glad it helped. A fun way to start is the unshare example from the post. Running it by hand makes it clear that a container is just a Linux process with some isolation around it.

Collapse
 
amorizz profile image
Amorizz •

Gotcha 1 has a kernel rule behind it. The reason Node sits out the full 10 seconds isn't really Node: kill(2) says PID 1 only receives signals it has a handler for, so SIGTERM's default action never applies inside the container. That gives two fixes that stack. init: true in Compose, or docker run --init, adds tini without touching the Dockerfile. And process.on('SIGTERM', ...) in the app works even as PID 1, it just won't reap zombies. That handler is also the place for server.close() and your error SDK's flush, or the last error of each deploy never leaves the container.

Collapse
 
smtahosin profile image
S M Tahosin •

Great addition, thanks. You are right, the kernel rule for PID 1 is the real reason, so it is not really a Node problem. Both fixes stack well: --init (or init: true in Compose) for signal forwarding and zombie reaping, plus a SIGTERM handler in the app. That handler is the right place for server.close() and flushing logs or the error SDK before exit.

Collapse
 
amorizz profile image
Amorizz •

Good to pick this back up with you. One thing that can still undo both fixes: shell-form CMD node server.js puts /bin/sh -c between tini and Node, and depending on the shell, SIGTERM may never get passed on, so exec form CMD ["node", "server.js"] removes the doubt. And the 10 seconds is just Docker's default stop timeout, so if draining plus the flush needs longer, stop_grace_period: 30s in Compose pushes the SIGKILL back.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

The PID 1 section is the one that taught me the most expensive lesson in my early container days: my Node service ate the full 10-second docker stop timeout on every deploy because nothing in the process tree forwarded SIGTERM, and I only found it when a rollout visibly paused traffic. I came at systems from C first, and this article clicks with me at the syscall level — once you have called unshare yourself, you stop believing in the lightweight-VM diagram forever. The one gap I would love to see covered: how user namespaces change the root-in-container threat model, since CAP_SYS_ADMIN inside a privileged container is still the escape hatch everyone underestimates. Did you experiment with rootless Docker while writing this, and how much of the networking behavior held up?

Collapse
 
smtahosin profile image
S M Tahosin •

Thanks Odinaka, that deploy pause story is exactly why I included the PID 1 section. And I agree, calling unshare yourself changes how you think about containers.

On user namespaces: root inside the container maps to an unprivileged UID on the host, so CAP_SYS_ADMIN only applies within that namespace and not to the host. That is why a user-namespaced or rootless setup is a much smaller risk than a normal privileged container. For networking, rootless Docker cannot create the usual veth and docker0 setup on the host, so it uses a user-space network stack like slirp4netns or pasta. It works, but it is slower, and binding ports below 1024 needs extra setup. It would make a good follow-up post.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

The pasta detail is the part I would read first. A follow-up that measures that slower user-space networking against a normal bridge setup would settle how much it costs in practice.

Collapse
 
glenallen profile image
Glen Allen •

One thing worth considering is how container resource limits affect applications under real workloads. A container may run perfectly during development but behave differently when CPU or memory constraints come into play. Testing under realistic conditions and setting appropriate resource limits early can help prevent unexpected issues after deployment.

Collapse
 
smtahosin profile image
S M Tahosin •

Great point, Glen. Limits behave very differently in real traffic. A CPU quota shows up as latency spikes, while a memory limit means the OOM killer ends the process with exit code 137. So I'd load test with the same limits as production and watch memory.current and CPU throttling before shipping.

Collapse
 
glenallen profile image
Glen Allen •

Absolutely, testing under realistic workloads makes a big difference. Monitoring CPU throttling, memory usage, and OOM events before deployment can reveal issues that basic development testing might miss. These checks are especially important when applications experience sudden traffic spikes.

Collapse
 
abhinavs1920 profile image
Abhinav Sharma • • Edited

The lightweight VM framing breaks down fast once a container process shows up in the host process table like any other. The isolation is also stronger than a view filter: per namespaces(7), processes in a namespace get their own isolated instance of the wrapped resource, like the mount table or network stack. The kernel underneath is still shared, which is where the security questions live.

Collapse
 
smtahosin profile image
S M Tahosin •

Well put, Abhinav. Namespaces are real isolation, not just a view filter, but the kernel is still shared. That is why seccomp, capabilities and AppArmor matter, and why sandboxes like gVisor or Kata exist when you need a stronger boundary.