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 --> CG3

A 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 #

NamespaceIsolatesVisual Effect from Inside the Container
PIDProcess IDsThe container sees its own PIDs starting from 1
NETNetwork interfaces, routing tables, portsThe container has its own virtual NIC (eth0)
MNTFilesystem mount pointsThe container sees a restricted filesystem
UTSHostname and domain nameThe container can have its own hostname
IPCInter-process communication (System V IPC, POSIX mq)The container can’t send signals to host processes
USERUser and group IDsThe container has a root different from the host’s
CGROUPView of the cgroup hierarchyThe container only sees its own cgroup
TIMESystem 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 #

Resourcecgroup ControllerExample Docker Flag
CPUcpu, cpuacct--cpus=1.5, --cpu-shares=512
Memorymemory--memory=512m, --memory-swap=1g
Block I/Oblkio--device-read-bps, --device-write-iops
Networknet_cls, net_prio(via tc, not a Docker flag)
PIDspids--pids-limit=100
Devicesdevices--device=/dev/snd (device whitelist)
Freezerfreezer(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)"]
    end

cgroups 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 --memory and --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: failed

States and What They Mean #

StateMeaningHow to Reach
createdContainer created, filesystem ready, process not yet runningdocker create or before docker start
runningThe main process (PID 1) is runningdocker start
pausedProcesses temporarily suspended (SIGSTOP to all threads)docker pause
exitedProcess finished (exit code 0 = normal, others = error)natural exit or docker stop
restartingDocker is restarting the container (per policy)restart policy triggered
deadContainer can’t be restarted, must be removedcleanup 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 #

DriverUse CaseCharacteristics
bridgeDefault for standalone containersContainers get private IPs, NAT to the host
hostHigh performance, all ports directly on the hostNo network isolation
noneContainer fully isolated (no network)Loopback interface only
overlayMulti-host (Swarm, K8s)VXLAN tunnels between hosts
macvlanContainers get their own MAC addressUseful for legacy apps needing L2 access
ipvlanContainers share the host MAC, different IPsLike 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 --- C5

Custom bridge networks have significant advantages over the default:

  1. Automatic DNS — containers can refer to each other by name, not IP.
  2. Isolation — containers on different networks can’t communicate unless explicitly attached.
  3. 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"| BIND

The Three Persistence Mechanisms #

MechanismSyntaxPhysical LocationUse Case
Writable layer(default)OverlayFS top layerTemporary 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/pathAny host pathConfig, 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 --> HYPERVISOR2

Detailed Comparison #

AspectContainerVirtual Machine
Isolation layerKernel (namespaces + cgroups)Hardware abstraction (hypervisor)
OS inside the unitNone (shared kernel)Complete guest OS
Image sizeMB (10–500 MB)GB (1–50 GB)
Start timeMilliseconds–secondsMinutes (30–120 seconds)
RAM overheadMB (app only)500 MB – 4 GB (guest OS)
CPU overhead< 1% (native)1–5% (emulation)
Density per host50–5005–20
KernelShared with the hostOwn, per VM
Security isolationWeaker (shared kernel)Very strong (separate kernel)
Ideal use casesMicroservices, CI/CD, cloud-nativeDifferent 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-fpm crashes, nginx keeps 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 --> CONT

For 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.

← Previous: Image   Next: Registry →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact