Container #
A container is the living embodiment of Docker. When developers and operators talk about “running Docker”, what they’re actually running, stopping, scaling, and deploying is a container — not an image, not the daemon, not the CLI. An image is the mold; a container is the real, running object. Understanding containers deeply means understanding how Linux isolates processes with namespaces, limits resources with cgroups, and stores runtime changes with the union filesystem.
This article covers containers from the kernel foundation to operational behavior. We’ll look at how containers actually work at the operating system level, how they interact with the outside world through the network namespace, and how you can manage them with discipline in production. After reading this article, a container is no longer a “black box” — it’s an isolated process you can explain.
Container as a Runtime Instance #
A container is an ordinary Linux process running on the host with specific namespaces and cgroups. It carries no kernel of its own, no hypervisor, no hardware abstraction. What distinguishes it from a regular process are the virtual boundaries imposed by the kernel.
flowchart TB
subgraph HOST["Host OS (Linux Kernel)"]
subgraph NS1["Container A Namespace"]
PID1["PID: 1 = nginx"]
NET1["eth0: 172.17.0.2"]
MNT1["/ mounted"]
UTS1["hostname: container-a"]
end
subgraph NS2["Container B Namespace"]
PID2["PID: 1 = redis"]
NET2["eth0: 172.17.0.3"]
MNT2["/ mounted"]
UTS2["hostname: container-b"]
end
subgraph NS3["Container C Namespace"]
PID3["PID: 1 = postgres"]
NET3["eth0: 172.17.0.4"]
MNT3["/ mounted"]
UTS3["hostname: container-c"]
end
CG1["cgroup: 256 MB RAM, 0.5 CPU"]
CG2["cgroup: 512 MB RAM, 1.0 CPU"]
CG3["cgroup: 1 GB RAM, 2.0 CPU"]
end
NS1 --> CG1
NS2 --> CG2
NS3 --> CG3A container’s three main characteristics:
- Isolation — namespaces make the processes inside feel like they own a “system of their own” (PID, network, filesystem, hostname, user, IPC).
- Resource limiting — cgroups ensure a container can’t consume all of the host’s CPU/RAM.
- Filesystem layering — the union filesystem (OverlayFS) merges the image layers (read-only) with the container layer (writable) into a single mount the container sees as
/.
When you run docker run -d nginx, Docker (through the daemon and runc) only does the following: creates a new PID namespace, a new NET namespace, a new MNT namespace, and so on, then execve()s the nginx binary inside those namespaces. No hypervisor, no boot sequence, no init system. That’s why containers start in milliseconds, not minutes.
A way to remember: a container is a “system that doesn’t exist” — what the processes inside see is an illusion created by the Linux kernel. All the resources it “owns” (PID 1, eth0, hostname) are actually host resources given a different view. As soon as the process inside the container exits, its namespaces are removed and the illusion disappears.
Namespaces: The Foundation of Isolation #
Namespaces are a Linux kernel feature that makes a set of processes see system resources differently. There are eight types of namespaces used by modern containers; the seven most relevant ones for Docker are below.
Namespace Types and Their Effects #
| Namespace | Isolates | Visual Effect from Inside the Container |
|---|---|---|
PID | Process IDs | The container sees its own PIDs starting from 1 |
NET | Network interfaces, routing tables, ports | The container has its own virtual NIC (eth0) |
MNT | Filesystem mount points | The container sees a restricted filesystem |
UTS | Hostname and domain name | The container can have its own hostname |
IPC | Inter-process communication (System V IPC, POSIX mq) | The container can’t send signals to host processes |
USER | User and group IDs | The container has a root different from the host’s |
CGROUP | View of the cgroup hierarchy | The container only sees its own cgroup |
TIME | System clock (Linux 5.6+) | The container can have a different time (rarely used) |
Namespaces in Action #
To see the effect of namespaces directly, you can enter a running container and “inspect” its environment:
# Enter the container
docker exec -it mycontainer sh
# Inside the container:
ps aux # Only sees container processes (PID 1, etc.)
hostname # "mycontainer" - not the host's hostname
ip addr # eth0 with IP 172.17.0.2
ls / # The image's filesystem, not the host's
cat /proc/1/cgroup # The container's cgroup
Every output above is a virtual view created by namespaces. From the host side, all container processes appear as ordinary PIDs (12345, 12346, etc.), with the host’s hostname, connected to the docker0 bridge.
Commands for Viewing Namespaces from the Host #
# See which namespaces a container uses
docker inspect mycontainer --format '{{json .State.Pid}}'
# Output: 12345
# List all namespaces of PID 12345
ls -la /proc/12345/ns/
# Output:
# lrwxrwxrwx 1 root root 0 Feb 7 10:00 ipc -> ipc:[4026532...]
# lrwxrwxrwx 1 root root 0 Feb 7 10:00 mnt -> mnt:[4026532...]
# lrwxrwxrwx 1 root root 0 Feb 7 10:00 net -> net:[4026532...]
# lrwxrwxrwx 1 root root 0 Feb 7 10:00 pid -> pid:[4026532...]
# lrwxrwxrwx 1 root root 0 Feb 7 10:00 user -> user:[4026532...]
# lrwxrwxrwx 1 root root 0 Feb 7 10:00 uts -> uts:[4026532...]
Each symlink in /proc/<pid>/ns/ points to a specific namespace inode. The same inode means the same namespace; a different inode means a different namespace. This is the low-level representation of the “isolation” we keep talking about.
Interesting detail: namespaces have existed in Linux since 2002 (mount namespace), but only went mainstream with containers thanks to Docker 0.9 (2014). Before that, custom containers (LXC) already used namespaces, but Docker is what made the technology mainstream for developers.
cgroups: Resource Limiting #
cgroups (control groups) are a Linux kernel feature that limits and measures the resource usage of a set of processes. If namespaces answer “what does the container see?”, cgroups answer “how much resource is the container allowed to use?”.
Resources That Can Be Limited #
| Resource | cgroup Controller | Example Docker Flag |
|---|---|---|
| CPU | cpu, cpuacct | --cpus=1.5, --cpu-shares=512 |
| Memory | memory | --memory=512m, --memory-swap=1g |
| Block I/O | blkio | --device-read-bps, --device-write-iops |
| Network | net_cls, net_prio | (via tc, not a Docker flag) |
| PIDs | pids | --pids-limit=100 |
| Devices | devices | --device=/dev/snd (device whitelist) |
| Freezer | freezer | (for docker pause) |
Example Applications #
# Container with a 256 MB memory limit and 0.5 CPU cores
docker run -d --memory=256m --cpus=0.5 --name web nginx
# Container with a PID limit (prevents fork bombs)
docker run -d --pids-limit=100 myapp
# Container with lower CPU priority
docker run -d --cpu-shares=512 myapp # default 1024
cgroups v1 vs cgroups v2 #
Since kernel 5.x and Docker 20.10+, Docker is gradually migrating to cgroups v2 — a unified hierarchy that’s simpler and more consistent.
flowchart TB
subgraph V1["cgroups v1 (legacy)"]
V1A["/sys/fs/cgroup/cpu"]
V1B["/sys/fs/cgroup/memory"]
V1C["/sys/fs/cgroup/blkio"]
V1D["/sys/fs/cgroup/pids"]
V1E["/sys/fs/cgroup/devices"]
end
subgraph V2["cgroups v2 (modern)"]
V2A["/sys/fs/cgroup/<br/>(unified)"]
endcgroups v2 has several advantages:
- A single hierarchy (no duplicated controllers).
- PSI (Pressure Stall Information) for more accurate monitoring.
- More granular control over memory and I/O.
- Better support for rootless mode.
How to check the cgroup version on the host:
# Check the cgroup mount point
mount | grep cgroup
# v1 output: tmpfs on /sys/fs/cgroup type tmpfs
# cgroup on /sys/fs/cgroup/cpu type cgroup ...
# v2 output: cgroup2 on /sys/fs/cgroup type cgroup2
Anti-pattern: running containers without resource limits in production. Without--memoryand--cpus, a single container can consume all host resources and starve every other container. Always set explicit limits, unless you truly understand the trade-offs.
Container Lifecycle #
Containers have a clear state machine. Understanding these states matters for scripting and monitoring.
stateDiagram-v2
[*] --> created: docker create
created --> running: docker start
running --> paused: docker pause
paused --> running: docker unpause
running --> exited: process exit / docker stop
created --> exited: never started
exited --> running: docker start
exited --> [*]: docker rm
running --> restarting: restart policy
restarting --> running: success
restarting --> exited: failedStates and What They Mean #
| State | Meaning | How to Reach |
|---|---|---|
created | Container created, filesystem ready, process not yet running | docker create or before docker start |
running | The main process (PID 1) is running | docker start |
paused | Processes temporarily suspended (SIGSTOP to all threads) | docker pause |
exited | Process finished (exit code 0 = normal, others = error) | natural exit or docker stop |
restarting | Docker is restarting the container (per policy) | restart policy triggered |
dead | Container can’t be restarted, must be removed | cleanup failure or force remove |
Restart Policies #
# no : container doesn't auto-restart
# on-failure: restart only if exit code != 0
# always : always restart, including when the Docker daemon starts
# unless-stopped: restart unless the user manually stops it
docker run -d --restart=always nginx
docker run -d --restart=on-failure:5 myapp
# 5 = max retries, then state = exited
The Most Frequently Used Lifecycle Commands #
# List running containers
docker ps
# List all containers (including exited ones)
docker ps -a
# Inspect container details
docker inspect mycontainer
# View logs
docker logs mycontainer
docker logs -f --tail 100 mycontainer # follow + tail
# Enter a running container
docker exec -it mycontainer sh
docker exec -it mycontainer bash # if the image has bash
# Send signals
docker stop mycontainer # SIGTERM, then SIGKILL after 10 seconds
docker kill mycontainer # SIGKILL immediately
docker kill -s SIGUSR1 mycontainer # send a custom signal
# Remove a container (must be stopped first, or use -f)
docker rm mycontainer
docker rm -f mycontainer # force remove even while running
# Remove all stopped containers
docker container prune
Interesting detail: docker stop sends SIGTERM and waits 10 seconds before SIGKILL. This gives the application time for a graceful shutdown — finishing in-flight requests, flushing buffers, closing database connections. Good applications handle SIGTERM properly; bad ones die instantly and lose state. Always test SIGTERM handling before deploying to production.
Container Networking #
Every running container has at least one network interface attached to a specific network namespace. This network determines how the container communicates — with the host, with other containers, and with the outside world.
Network Driver Types #
| Driver | Use Case | Characteristics |
|---|---|---|
| bridge | Default for standalone containers | Containers get private IPs, NAT to the host |
| host | High performance, all ports directly on the host | No network isolation |
| none | Container fully isolated (no network) | Loopback interface only |
| overlay | Multi-host (Swarm, K8s) | VXLAN tunnels between hosts |
| macvlan | Containers get their own MAC address | Useful for legacy apps needing L2 access |
| ipvlan | Containers share the host MAC, different IPs | Like macvlan, more MAC-address-efficient |
Default Bridge vs Custom Bridge #
Docker automatically creates the default bridge (docker0) used by all containers unless you create a custom network.
flowchart LR
subgraph HOST["Host"]
DOCKER0[docker0 bridge<br/>172.17.0.1/16]
end
subgraph DEFAULT["Default bridge"]
C1["container-A<br/>172.17.0.2"]
C2["container-B<br/>172.17.0.3"]
C3["container-C<br/>172.17.0.4"]
end
subgraph CUSTOM["Custom network: app-net"]
C4["api<br/>172.18.0.2"]
C5["db<br/>172.18.0.3"]
C6["cache<br/>172.18.0.4"]
end
DOCKER0 --> C1
DOCKER0 --> C2
DOCKER0 --> C3
C4 --- C5
C5 --- C6
C4 --- C5Custom bridge networks have significant advantages over the default:
- Automatic DNS — containers can refer to each other by name, not IP.
- Isolation — containers on different networks can’t communicate unless explicitly attached.
- Better attach/detach — containers can be attached/detached from a network while running.
# Create a custom network
docker network create app-net
# Run containers on this network
docker run -d --name api --network app-net myapi
docker run -d --name db --network app-net postgres
# Inside the 'api' container, you can:
ping db # resolves to the 'db' container's IP
psql -h db -U postgres # also works
Port Mapping (Publishing) #
Containers on the default bridge are not exposed to the host. To make a container accessible from outside, you need to publish ports.
# Format: -p HOST_PORT:CONTAINER_PORT
docker run -d -p 8080:80 nginx
# 8080 on the host → 80 in the container
# Listen on a specific IP
docker run -d -p 127.0.0.1:8080:80 nginx
# Publish a port range
docker run -d -p 8000-8100:8000-8100 myapp
# Publish all ports declared in EXPOSE
docker run -d -P nginx
Anti-pattern: docker run --network host unless you truly understand the consequences. Host mode removes network isolation — the container can listen on any port without publishing, and its ports can clash with host services. For almost every use case, a custom bridge network is the safer, more manageable choice.Container Storage — Writable Layer, Volumes, Bind Mounts #
Every running container has a writable layer on top of its image layers. This layer stores all runtime changes — logs, caches, files written by the application. But this layer is temporary (ephemeral): when the container is deleted, the layer disappears.
flowchart TB
subgraph IMAGE["Image Layers (read-only)"]
L1[Layer 1: base OS]
L2[Layer 2: dependencies]
L3[Layer 3: app code]
end
subgraph CONTAINER["Container"]
L4["Writable Layer<br/>(runtime changes)"]
end
subgraph HOST_FS["Host Filesystem"]
VOL["Named Volume<br/>(/var/lib/docker/volumes/)"]
BIND["Bind Mount<br/>(/host/path)"]
end
L1 --> L2 --> L3 --> L4
L4 -.->|"docker run -v"| VOL
L4 -.->|"docker run -v"| BINDThe Three Persistence Mechanisms #
| Mechanism | Syntax | Physical Location | Use Case |
|---|---|---|---|
| Writable layer | (default) | OverlayFS top layer | Temporary runtime changes |
| Named volume | -v mydata:/var/lib/data | /var/lib/docker/volumes/ | Databases, app data — managed by Docker |
| Bind mount | -v /host/path:/container/path | Any host path | Config, logs, dev code |
When to Use Each One #
# Named volume: for persistent data managed by Docker
docker run -d -v db_data:/var/lib/postgresql/data postgres
# Docker creates, mounts, and removes the directory
# Bind mount: to share files between host and container
docker run -d -v /home/user/app:/app myapp
# Host files are directly available in the container
# tmpfs: for secret data that must never touch disk
docker run -d --tmpfs /run/secrets:rw,noexec,nosuid myapp
# Data lives in RAM only, gone when the container stops
Practical guidance: use named volumes for application data (databases, file uploads, logs) and bind mounts for configuration and development. Don’t store important data in the container’s writable layer — it will be lost when the container is deleted or restarted without a volume.
Container vs Virtual Machine #
A common misunderstanding is treating containers and VMs as two equivalent choices. In reality they work at very different layers.
flowchart TB
subgraph CONTAINER_STACK["Container Stack"]
APP1["App A"]
RUNTIME1["Bins/Libs"]
CONTAINER_ENGINE1["Container Engine (Docker)"]
OS1["Host OS"]
HW1["Hardware"]
end
subgraph VM_STACK["VM Stack"]
APP2A["App A"]
RUNTIME2A["Bins/Libs"]
GUEST_OS2A["Guest OS"]
HYPERVISOR2["Hypervisor"]
HOST_OS2["Host OS"]
HW2["Hardware"]
APP2B["App B"]
RUNTIME2B["Bins/Libs"]
GUEST_OS2B["Guest OS"]
end
APP1 --> RUNTIME1 --> CONTAINER_ENGINE1 --> OS1 --> HW1
APP2A --> RUNTIME2A --> GUEST_OS2A --> HYPERVISOR2 --> HOST_OS2 --> HW2
APP2B --> RUNTIME2B --> GUEST_OS2B --> HYPERVISOR2Detailed Comparison #
| Aspect | Container | Virtual Machine |
|---|---|---|
| Isolation layer | Kernel (namespaces + cgroups) | Hardware abstraction (hypervisor) |
| OS inside the unit | None (shared kernel) | Complete guest OS |
| Image size | MB (10–500 MB) | GB (1–50 GB) |
| Start time | Milliseconds–seconds | Minutes (30–120 seconds) |
| RAM overhead | MB (app only) | 500 MB – 4 GB (guest OS) |
| CPU overhead | < 1% (native) | 1–5% (emulation) |
| Density per host | 50–500 | 5–20 |
| Kernel | Shared with the host | Own, per VM |
| Security isolation | Weaker (shared kernel) | Very strong (separate kernel) |
| Ideal use cases | Microservices, CI/CD, cloud-native | Different OSes, strict compliance, legacy |
When to Choose Which #
Choose containers when:
- The application is a modern microservice or stateless design.
- You need fast deployments and auto-scaling.
- You want high density on a single host.
- You need a development environment consistent with production.
Choose VMs when:
- You need to run different OSes on one host (e.g. Linux + Windows).
- Compliance regulations explicitly require hardware isolation.
- You have legacy applications depending on specific kernel modules or drivers.
- You have multi-tenancy with very strong trust boundaries.
Combining both is the most common pattern: VMs as the infrastructure unit (provisioned by the cloud), containers as the application unit (running inside the VM). A Kubernetes node is basically a VM (or bare metal), and pods are groups of containers running on top of that node.
Container Best Practices for Production #
One Container, One Process #
# ANTI-PATTERN: many processes in one container (via a supervisor)
docker run -d myimage
# /etc/supervisor/conf.d/:
# [program:nginx]
# command=/usr/sbin/nginx
# [program:php-fpm]
# command=/usr/sbin/php-fpm
# [program:redis]
# command=redis-server
# CORRECT: one main process per container, orchestrated by Compose/K8s
docker run -d nginx
docker run -d php-fpm
docker run -d redis
Why one process per container is better:
- Structured logging — stdout/stderr goes straight to the main process, easy to collect.
- Independent restarts — if
php-fpmcrashes,nginxkeeps running. - Accurate resource limits — the cgroup knows exactly which process to limit.
- Scalability — just replicate the container that needs scaling.
Handle Signals Properly #
Applications inside containers must handle SIGTERM gracefully. This lets docker stop perform a clean shutdown instead of going straight to SIGKILL.
# ANTI-PATTERN: doesn't handle SIGTERM
import time
while True:
process_request()
time.sleep(1)
# CORRECT: handles SIGTERM
import signal
import time
def shutdown(signum, frame):
print("Shutting down gracefully...")
# flush buffers, close connections, etc.
exit(0)
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
while True:
process_request()
time.sleep(1)
Don’t Store State in the Container #
Containers are ephemeral — they must be deletable and replaceable without losing important data. Always use volumes or external storage.
MUST NOT do:
✗ Store database files in /var/lib/postgres inside the
container.
✗ Store uploaded files in /app/uploads.
✗ Store runtime configuration in /etc/myapp.conf
(use configmap/secret/volume mounts).
✗ Write logs to files in /var/log (use stdout/stderr).
ALWAYS do:
✓ Named volumes for database storage.
✓ Object storage (S3/GCS) for uploaded files.
✓ External config (file/env/secret) that is mounted or
passed as env vars.
✓ stdout/stderr for logs, collected by the logging driver.
Non-Root Users #
By default, containers run as root inside their own namespace. Root inside a user namespace isn’t host root (thanks to user namespaces), but the least privilege principle still says we shouldn’t run as root.
# In the Dockerfile
RUN adduser -D -u 1000 appuser
USER appuser
CMD ["./myapp"]
Or at runtime:
docker run -d --user 1000:1000 myapp
A note on user namespaces: since Docker 20.10 with userns-remap, the root UID inside a container is mapped to a non-root UID on the host. So even without --user, container root doesn’t have host-root-equivalent access. But combining userns-remap + USER nonroot in the Dockerfile is still best practice.
Health Checks #
Add HEALTHCHECK to the Dockerfile so Docker knows whether the container is genuinely ready to serve requests, not just that its process is running.
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
docker ps will show the health status:
CONTAINER ID STATUS NAMES
abc123def456 Up 5 minutes (healthy) myapi
789ghi012jkl Up 10 minutes (unhealthy) myapi-broken
Unhealthy containers can be restarted automatically or excluded from the load balancer (especially in Docker Swarm / K8s).
Decision Tree — Container, VM, or a Combination? #
flowchart TD
A{Does the app need<br/>a different OS?}
A -- Yes --> VM["Use VMs<br/>(or a multi-VM K8s cluster)"]
A -- No --> B{Does compliance<br/>require hardware isolation?}
B -- Yes --> VM
B -- No --> C{Need fast scaling<br/>and startup?}
C -- Yes --> CONT[Container + Orchestrator]
C -- No --> D{Strong trust boundaries<br/>between workloads?}
D -- Yes --> VM_AT_EDGE["VM per workload<br/>+ containers inside"]
D -- No --> CONTFor 90% of modern cloud-native applications, containers are the right choice. For the remaining 10% (legacy, compliance, OS-specific), VMs remain relevant. VMs as hosts + containers as application units is the most common pattern in modern production.
Summary #
- A container is a Linux process running in specific namespaces and cgroups. It carries no kernel of its own — all containers on one host share the host kernel. That’s what makes containers lightweight and fast to start.
- Namespaces isolate the container’s perception (what it sees): PID, network, filesystem, hostname, user, IPC. cgroups isolate resources (how much it may use): CPU, memory, I/O, PIDs.
- Container lifecycle: created → running → paused → exited → (restarting) → removed. Every state has a clear transition. Always test SIGTERM handling so containers can shut down gracefully.
- The writable layer on top of the image layers stores runtime changes, but it’s temporary — gone when the container is deleted. For persistent data, use named volumes or bind mounts.
- Container networking has several drivers: bridge (default), host, none, overlay, macvlan, ipvlan. Custom bridges provide automatic DNS and better isolation than the default bridge.
- Production best practices: one container one process, run as non-root, tolerate SIGTERM, use health checks, log to stdout/stderr, store data in volumes/external storage.
- Container ≠ VM. Containers isolate at the kernel level, VMs at the hardware level. Containers are lighter and faster; VMs are stronger on isolation. The most common pattern: VMs as hosts, containers as the application unit.
- Container orchestration (Compose, Swarm, Kubernetes) manages many containers across many hosts. For small setups, Compose is enough. For multi-host, you need a proper orchestrator.