# Containers are just processes 

You type `docker run nginx`. An Nginx server starts. You can curl it, it serves responses, and it behaves like a real machine running a real web server. So you naturally assume there's some kind of mini-computer in there. A small isolated box. Something container-shaped.

There isn't.

What you started is a Linux process. One process, or a handful of them, running directly on your host kernel with some isolation rules applied on top. No mini-computer. No separate OS. A process.

Once that lands, a lot of Docker behaviour that seemed arbitrary starts making sense — why containers start in milliseconds, why they share the host's resources, why a crashed container briefly appears in `ps aux` on the host before disappearing.

This post covers what actually happens between typing `docker run` and that process exists on your machine.

* * *

**The full stack**

When you run `docker run nginx` at least five components are involved before a single Nginx worker starts. Most Docker tutorials skip from "type the command" to "container is running." The middle part is where it gets interesting.

```plaintext
You
 └─▶ Docker CLI
       └─▶ Docker Daemon (dockerd)
             └─▶ containerd
                   └─▶ runc
                         └─▶ Linux kernel
                               └─▶ Process (nginx)
```

Each layer does one specific thing. Let's go through them.

* * *

**Docker CLI — just a client**

The `docker` command doesn't run containers. It doesn't know how to create namespaces or apply cgroups. It takes your command, translates it into an API call, and sends it to the Docker daemon over a Unix socket.

bash

````bash
docker run nginx
```

This sends an HTTP request to `/var/run/docker.sock`. The daemon is listening there. If the daemon isn't running, you get:
```
Cannot connect to the Docker daemon at unix:///var/run/docker.sock.
Is the docker daemon running?
````

Not a container error. A connection error — the CLI couldn't reach the daemon.

* * *

**Docker daemon (dockerd) — the manager**

The daemon receives the request and figures out what needs to happen:

*   Does the image exist locally? If not, pull it from Docker Hub.
    
*   What configuration was requested — ports, volumes, environment variables, network?
    
*   Which runtime should handle creation?
    

The daemon doesn't create the container either. It delegates to containerd.

* * *

**containerd — the container lifecycle manager**

containerd is a separate process running on your machine:

bash

````bash
ps aux | grep containerd
```
```
root  1234  0.0  0.1  containerd --config /etc/containerd/config.toml
````

It manages the full container lifecycle: pulling images, managing filesystem snapshots, starting and stopping containers, and handling network attachments. Kubernetes uses containerd directly, bypassing the Docker daemon entirely.

containerd takes the request from dockerd, prepares everything the container needs, then hands off to the runtime.

* * *

## runc — where the container is actually born

runc is a small binary that implements the OCI runtime specification. Its only job: take a prepared filesystem bundle and some configuration, and ask the Linux kernel to create a container from it.

Specifically, runc calls the kernel to:

1.  Create new namespaces (PID, network, mount, UTS, IPC)
    
2.  Apply cgroup limits
    
3.  Set up the filesystem (mount the image layers)
    
4.  Execute the entry process inside those namespaces
    

Then runc exits. It doesn't stick around. The container process keeps running, but runc is gone.

bash

```bash
# runc won't appear in ps aux after container startup
ps aux | grep runc
# returns nothing
```

* * *

**The Linux kernel — where the process actually lives**

After runc does its work, what exists on your machine is a Linux process. Running on the host kernel. Visible from the host.

Start an Nginx container:

bash

```bash
docker run -d --name my-nginx nginx
```

Check the host:

bash

````bash
ps aux | grep nginx
```
```
root     14823  0.0  0.0  nginx: master process nginx -g daemon off;
www-data 14851  0.0  0.0  nginx: worker process
www-data 14852  0.0  0.0  nginx: worker process
````

Those are real processes on your host. Not inside a VM. Not behind a hypervisor. Processes with real PIDs on your real kernel.

Now check what the container sees:

bash

````bash
docker exec -it my-nginx ps aux
```
```
PID   USER     COMMAND
1     root     nginx: master process nginx -g daemon off;
30    www-data nginx: worker process
31    www-data nginx: worker process
````

PID 1 inside the container. PID 14823 on the host. Same process, two different views — the PID namespace gives the container its own numbering space, and the kernel maintains the mapping between them.

No translation layer, no emulation, no VM overhead. The process runs natively on the kernel. The isolation is applied around it, not underneath it.

* * *

**Why** do **containers start so fast?**

VMs take 30–60 seconds to boot because they're actually booting an OS. Kernel initialisation, hardware detection, service startup.

A container starts in milliseconds because there's no OS to boot. The kernel is already running. runc creates some namespaces and executes a process.

bash

````bash
time docker run --rm nginx nginx -v
```
```
nginx version: nginx/1.25.3
real    0m0.412s
```

412 milliseconds, most of which is image layer checking. The actual process creation is microseconds.

---

## Containers vs VMs vs bare metal

**Bare metal:**
```
Your process → Linux kernel → Hardware
```

**Container:**
```
Your process (namespace + cgroup isolation) → Linux kernel → Hardware
```

**Virtual machine:**
```
Your process → Guest OS kernel → Hypervisor → Host kernel → Hardware
````

A container adds one layer of kernel features on top of a bare metal process. A VM adds an entire OS and a hypervisor. When people call containers "lightweight VMs", they're describing the user experience — isolated environment, its own IP, its own filesystem. The implementation has nothing in common with a VM.

* * *

**Why Docker Desktop on Mac and Windows needs a Linux VM**

There's a question worth addressing if you're on a Mac or Windows machine reading this: if containers need a Linux kernel, why does Docker work on macOS?

The answer is that it doesn't — not directly.

Docker Desktop silently runs a lightweight Linux VM in the background using a minimal Linux distribution called LinuxKit. Your containers are running inside that VM's kernel, not on macOS or Windows directly. macOS has its own kernel (XNU), and Windows has its own kernel — neither of them supports Linux namespaces or cgroups natively. So Docker Desktop provides a Linux kernel for your containers to share.

You can see this VM if you look for it:

bash

````bash
# On Mac, Docker Desktop runs a Linux VM
docker run --rm alpine uname -r
```
```
6.6.22-linuxkit
````

That kernel version — `linuxkit` — is the VM's kernel, not your Mac's. The container is running on Linux, just Linux that Docker Desktop is managing invisibly.

On Linux, there's no VM. Containers run on your host kernel directly. That's one reason Linux is the natural home for containers in production, and why Docker Desktop on Mac and Windows adds some overhead that a native Linux setup doesn't have.

* * *

`docker run` step by step:-

When you run `docker run -d -p 8080:80 nginx`:

1.  **CLI** sends `POST /containers/create` + `POST /containers/{id}/start` to the Docker socket
    
2.  **dockerd** checks if the `nginx` image exists locally; if not, pulls it layer by layer
    
3.  **dockerd** passes the configuration to **containerd**
    
4.  **containerd** assembles the image layers into a unified filesystem
    
5.  **containerd** calls **runc** with the prepared bundle
    
6.  **runc** asks the Linux kernel to create new namespaces
    
7.  **runc** configures cgroup limits
    
8.  **runc** executes `nginx -g 'daemon off;'` as PID 1 inside the new namespaces
    
9.  **runc exits**
    
10.  **Nginx runs** as a Linux process, isolated by the kernel, visible in `ps aux` on the host
     

Under a second, start to finish.

* * *

**Verify it yourself**

Run any container, then:

bash

````bash
docker inspect --format '{{.State.Pid}}' my-nginx
```
```
14823
````

Look at that PID in the proc filesystem:

bash

````bash
cat /proc/14823/status | grep -E "Name|Pid|NSpid"
```
```
Name:    nginx
Pid:     14823
NSpid:   14823    1
````

`NSpid` shows both IDs — the host PID (14823) and the namespace PID (1)—the kernel's own process table. Container sees 1, host sees 14823, same process.

* * *

**Why PID 1 is special — and what breaks when it isn't handled right**

Every container starts its main process as PID 1. That looks like a detail, but it has a real consequence.

In Linux, PID 1 has a specific responsibility: reaping zombie processes. When a process finishes, it doesn't disappear immediately — it waits in a zombie state until its parent reads its exit status. The process sitting at PID 1 is responsible for doing that for any orphaned child processes. The kernel expects this.

The problem: your application almost certainly wasn't written to do it. Nginx, Gunicorn, and a Flask app — none of them implements zombie reaping. They're not init systems. If your container spawns child processes and some of them exit before the parent, those zombies accumulate. Eventually, this can cause issues with process table limits, signal handling, and container shutdown behaviour.

The standard fix is `tini` — a minimal init system designed to sit at PID 1 and handle exactly this:

bash

```bash
# Use tini as the entry point
docker run --init nginx
```

Or in a Dockerfile:

dockerfile

````dockerfile
FROM python:3.12-slim

RUN apt-get install -y tini

ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "app:app"]
```

`tini` starts as PID 1, does the zombie reaping, and passes signals correctly to your application. It adds almost nothing in terms of size or overhead.

It's a small thing. But it's the kind of thing that bites you in production on a long-running container and is completely non-obvious until you know it.

---
````

* * *

**What comes next**

Networking is where this gets more interesting. The containers have their own network stack — their own IPs, routing tables, firewall rules. But they're still communicating through the host kernel via a virtual bridge Docker created. Next post covers how that works: veth pairs, the docker0 bridge, internal DNS, and how a container resolves another container's hostname.

* * *

## **Connect with me**

This is part of my ongoing #90DaysOfDevOps journey. I write about what I'm building, what I'm breaking, and what I'm learning — in as much detail as I can.

Portfolio: [**https://prakharsrivastavadevops.netlify.app/**](https://prakharsrivastava-devops.netlify.app/)

GitHub: [**https://github.com/Heyyprakhar1**](https://github.com/Heyyprakhar1)

LinkedIn: [**https://www.linkedin.com/in/heyyprakhar1/**](https://www.linkedin.com/in/heyyprakhar1/)

If this helped — drop a comment. If you're stuck on something similar, let's figure it out.
