You can use Docker for years without knowing what happens after you press Enter on docker run. That works until something breaks in the gap between the commands and the kernel: the daemon restarts and takes containers with it, the disk fills under /var/lib/docker, or a container sees files you did not expect it to see.
This article takes the machine apart. Our Docker for Linux admins guide covers day-to-day usage and production settings; here the focus is the moving parts β the daemon stack, what an image actually is, and what a container is to the kernel β with commands to see each one on a running host.
The Stack: Client, dockerd, containerd, runc
The docker command is only a client. It turns your command into HTTP requests against the Docker Engine API, which listens on a Unix socket. Everything else happens in a chain of separate programs:
| Component | Role | Lifetime |
|---|---|---|
docker | CLI client; speaks the Engine API over a socket | Exits when the command finishes |
dockerd | The daemon: API, image builds, networks, volumes, logging | Long-running systemd service |
containerd | Container lifecycle and image content store | Long-running systemd service |
containerd-shim-runc-v2 | Per-container parent; holds stdio and the exit status | As long as the container runs |
runc | Creates namespaces and cgroups, then execs the process | Exits once the container starts |
# The API is just HTTP on a Unix socket
$ curl -s --unix-socket /var/run/docker.sock http://localhost/version | head -c 120
{"Platform":{"Name":"Docker Engine - Community"},"Components":[{"Name":"Engine","Version":"27.3.1",...
# Both daemons are separate services
$ systemctl status docker containerd --no-pager | grep -E 'service|Active'
# Each running container has a shim; its child is your process
$ docker run -d --name web nginx:alpine
$ ps -eo pid,ppid,cmd --forest | grep -A2 '[c]ontainerd-shim'
4182 1 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 6f1c... -address /run/containerd/containerd.sock
4203 4182 \_ nginx: master process nginx -g daemon off;
4241 4203 \_ nginx: worker process
The shim is why live-restore works β the container's parent is the shim, not dockerd. With "live-restore": true in /etc/docker/daemon.json, you can restart or upgrade dockerd and running containers keep running.
Containers are ordinary processes β nginx above has a normal host PID and shows up in ps, top and the process explorer. Isolation changes what the process can see, not where it runs.
Remote daemons β the client can target another host: DOCKER_HOST=ssh://admin@build01 docker ps, or save it with docker context create.
docker.sock Is Root
The daemon runs as root and does whatever its API is asked. Anyone who can write to the socket can start a privileged container with the host filesystem mounted, which makes the socket equivalent to a root shell:
$ ls -l /var/run/docker.sock
srw-rw---- 1 root docker 0 Oct 2 09:14 /var/run/docker.sock
# Membership in the docker group is all this takes
$ docker run --rm -it -v /:/host alpine chroot /host id
uid=0(root) gid=0(root) groups=0(root)
Treat the docker group like sudo β audit who is in it, and never add service accounts that do not need full root.
Do not mount the socket casually β a container with -v /var/run/docker.sock:/var/run/docker.sock controls the host. CI runners and monitoring agents that ask for it should be treated as root-level software.
Rootless mode β Docker can run the whole daemon as an unprivileged user (dockerd-rootless-setuptool.sh install), so a container escape lands in that user's account rather than root. Podman, the default on RHEL and Fedora, is daemonless and runs rootless by default for non-root users.
What an Image Actually Is
An image is not one file. It is a manifest that points to a config JSON and an ordered list of layers β compressed tarballs of filesystem changes β all addressed by SHA-256 digest. Tags such as nginx:alpine are just movable names pointing at a manifest.
# Multi-arch images are an index of per-platform manifests
$ docker buildx imagetools inspect nginx:alpine
Name: docker.io/library/nginx:alpine
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:...
Manifests:
Name: docker.io/library/nginx:alpine@sha256:...
Platform: linux/amd64
Name: docker.io/library/nginx:alpine@sha256:...
Platform: linux/arm64
...
# The layers of the image you pulled (uncompressed diff IDs)
$ docker image inspect nginx:alpine --format '{{json .RootFS.Layers}}'
# Which Dockerfile instruction produced which layer, and its size
$ docker history nginx:alpine
# Pin by digest when you need the exact same bytes every time
$ docker pull nginx:alpine@sha256:<digest>
Layers are shared β ten images built FROM the same base store that base once. This is why pulling a second image from the same family is fast, and why docker images can show sizes that add up to more than the disk used.
Deleting in a later layer does not shrink the image β a file added in one RUN and removed in the next still ships in the earlier layer. Clean up in the same instruction, or use a multi-stage build β the Dockerfile generator does this for you.
Config holds the defaults β entrypoint, cmd, environment, exposed ports, user and labels live in the config JSON, not in a layer. docker image inspect shows them under .Config.
Containers: Read-Only Layers Plus One Writable Layer
A container is an image's layers stacked read-only, plus a thin writable layer on top, merged into one view with an overlay filesystem. Writes go to the top layer; modifying an existing file copies it up first. Remove the container and the writable layer goes with it.
# Where the overlay2 driver keeps each piece of this container
$ docker inspect web --format '{{json .GraphDriver.Data}}' | python3 -m json.tool
{
"LowerDir": "/var/lib/docker/overlay2/8c1e...-init/diff:/var/lib/docker/overlay2/b7a2.../diff:...",
"MergedDir": "/var/lib/docker/overlay2/8c1e.../merged",
"UpperDir": "/var/lib/docker/overlay2/8c1e.../diff",
"WorkDir": "/var/lib/docker/overlay2/8c1e.../work"
}
# What the container changed relative to its image (A=added, C=changed, D=deleted)
$ docker diff web
C /var
C /var/cache/nginx
A /var/cache/nginx/client_temp
C /run
A /run/nginx.pid
# Writable-layer size per container, and disk use overall
$ docker ps -s
$ docker system df
| Data | Lives in | Survives docker rm? |
|---|---|---|
| Image layers | Read-only lower layers | Yes, until the image is removed |
| Files written inside the container | Writable upper layer | No |
| Named volume | /var/lib/docker/volumes/NAME/_data | Yes |
| Bind mount | Wherever you pointed it on the host | Yes β it is just a host directory |
| Container logs (json-file) | /var/lib/docker/containers/ID/ID-json.log | No |
Never edit /var/lib/docker by hand β the daemon tracks layers in its own metadata. Deleting directories under overlay2 corrupts images; use docker image prune and docker system prune instead.
Storage backend varies β check yours with docker info --format '{{.Driver}}'. Recent Docker Engine releases can use the containerd image store instead of overlay2, in which case image content lives under containerd's own directories.
Logs fill disks β the default json-file driver does not rotate. Set log-opts such as max-size and max-file in daemon.json, or use the local driver, which rotates by default.
What the Kernel Sees: Namespaces and Cgroups
There is no "container" object in the Linux kernel. runc builds one from two features: namespaces, which limit what a process can see, and cgroups, which limit what it can use. You can inspect both from the host.
# Host PID of the container's main process
$ PID=$(docker inspect --format '{{.State.Pid}}' web)
# Each namespace is a link; different inode = different namespace than the host
$ sudo ls -l /proc/$PID/ns | awk '{print $9, $10, $11}'
cgroup -> cgroup:[4026532611]
ipc -> ipc:[4026532539]
mnt -> mnt:[4026532537]
net -> net:[4026532541]
pid -> pid:[4026532540]
...
uts -> uts:[4026532538]
# Run a host tool inside the container's network namespace only
$ sudo nsenter -t $PID -n ip -brief addr
lo UNKNOWN 127.0.0.1/8
eth0@if23 UP 172.17.0.2/16
# The cgroup that enforces --memory and --cpus (cgroup v2, systemd driver)
$ cat /proc/$PID/cgroup
0::/system.slice/docker-6f1c....scope
$ docker run -d --name capped --memory 256m --cpus 0.5 nginx:alpine
$ cat /sys/fs/cgroup/system.slice/docker-$(docker inspect -f '{{.Id}}' capped).scope/memory.max
268435456
| Namespace | Isolates | Effect in a container |
|---|---|---|
| pid | Process IDs | Main process is PID 1 inside; cannot see host processes |
| mnt | Mount table | Sees the image root, not the host / |
| net | Interfaces, routes, ports | Own eth0 and loopback; ports must be published |
| uts | Hostname | Hostname is the short container ID by default |
| ipc | SysV IPC, POSIX queues | No shared memory with host processes |
| user | UID/GID mapping | Off by default β root inside is root outside unless userns-remap or rootless |
Shared kernel β every container uses the host kernel. A kernel exploit is a host exploit, and kernel modules, sysctls outside the namespaced set, and the clock are shared.
PID 1 duties β the main process is PID 1 in its namespace, so it must reap zombies and handle SIGTERM. Use docker run --init if your application does neither.
Defaults also drop capabilities β Docker removes most Linux capabilities and applies a seccomp profile. --privileged undoes all of that; use --cap-add for the one capability you actually need.
Following One docker run End to End
Putting it together, here is what docker run -d -p 8080:80 nginx:alpine does on a Linux host:
1. docker (client) POST /containers/create and /containers/{id}/start to dockerd
2. dockerd resolves nginx:alpine; asks the registry for the manifest if missing
3. dockerd/containerd pull and unpack missing layers, verifying each SHA-256 digest
4. dockerd prepares the overlay mount, network (veth pair on docker0), port mapping
5. containerd writes an OCI runtime bundle and starts a containerd-shim-runc-v2
6. runc creates namespaces and the cgroup, pivots root, execs nginx, exits
7. shim stays as parent: holds stdio for logs, waits for the exit code
8. dockerd streams logs to the log driver and reports state back to the client
Debugging by layer β if docker commands hang, check dockerd (journalctl -u docker). If dockerd is fine but containers will not start, check containerd (journalctl -u containerd) and the runc error in the message. If the container starts and dies, it is your application: docker logs and docker inspect --format '{{.State.ExitCode}}'.
Port publishing is netfilter β -p 8080:80 creates NAT rules on the host, and those rules are evaluated before many host firewall setups see the traffic. Check them when building firewall rules on a Docker host.
Distro packaging β Debian and Ubuntu ship docker.io; Docker's own repository provides docker-ce with newer releases. On RHEL and Fedora the default is Podman, and docker-ce comes from Docker's repository.
Frequently Asked Questions
What is the difference between a Docker image and a container?
An image is a read-only template: a manifest, a config, and a stack of compressed filesystem layers addressed by digest. A container is a running or stopped instance of that image, with its own writable layer on top, its own namespaces, and a cgroup that limits its resources.
What does the Docker daemon actually do?
dockerd serves the Docker Engine API, builds and manages images, networks and volumes, and handles logging. It delegates container lifecycle to containerd, which uses a per-container shim and runc to create the namespaces and cgroups and start the process.
Do containers stop if the Docker daemon restarts?
By default, yes. With live-restore set to true in /etc/docker/daemon.json, running containers keep going while dockerd restarts or is upgraded, because each container's parent process is its containerd shim rather than the daemon.
Is being in the docker group the same as having root?
Effectively yes. Anyone who can talk to /var/run/docker.sock can start a container that mounts the host root filesystem and runs as root. Treat docker group membership like sudo access, or use rootless Docker or Podman to avoid a root daemon.
Why does my image stay large after I delete files in the Dockerfile?
Each instruction creates a new layer, and earlier layers are immutable. A file added in one RUN and deleted in the next still exists in the earlier layer and is shipped with the image. Remove temporary files in the same RUN instruction or use a multi-stage build.