The kernel as an operator sees it — the syscall boundary, the scheduler, the page cache, cgroups, and the failure modes each one produces in production.
Everything a process does that it cannot do itself crosses this boundary exactly once.
Containers do not add a layer to this diagram. They are the cgroups and namespaces boxes applied to an ordinary process — which is why a container problem is always a Linux problem underneath.
The diagram above is the high level: what the pieces are. These two are the ones you want when something is wrong — what is inside one of those boxes, and the path a request really takes through them.
The four abstractions that every production incident eventually comes back to.
Your process runs in user mode and cannot touch hardware, other processes'
memory, or the page tables. Everything it needs from the outside world goes through a
system call: it puts a number in rax, arguments in registers, and
executes the syscall instruction. The CPU switches to ring 0, jumps
to a fixed kernel entry point, and the kernel does the work on the process's behalf.
The base cost is roughly 50–100 ns, meaningfully more since Spectre/Meltdown mitigations added
page-table isolation. That is cheap once and ruinous a million times a second — which is the entire
reason io_uring, batched writes and buffered I/O exist.
Some calls don't need the kernel at all. gettimeofday() and
clock_gettime() are served from the vDSO — a small shared page the
kernel maps into every process, containing data the kernel keeps updated. The call becomes an
ordinary function call at a few nanoseconds instead of a trap. If you ever wonder why timestamping
every log line is affordable, this is why.
Linux schedules tasks. A process and a thread are both a
task_struct; the only difference is how much they share. fork() creates a
task with a copy of the address space, clone() with CLONE_VM creates one
that shares it. "Thread" is a userspace word for a task that shares memory with its siblings.
fork() does not copy your 8 GB heap. It marks every page read-only in both parent
and child and copies a page only when one of them writes to it. This is why forking a large process
is fast — and why a forked child can still trigger an out-of-memory kill minutes later, when the
writes finally arrive. Redis's background save is the canonical example.
Since kernel 6.6 the default is EEVDF (Earliest Eligible Virtual Deadline First), replacing CFS. Both are fair-share designs: each runnable task accrues virtual runtime, and the one that has had least gets the CPU. EEVDF adds an explicit deadline so latency-sensitive tasks can be served ahead of throughput-hungry ones rather than merely fairly.
What matters operationally is unchanged: fair-share means nobody is starved and nobody
is guaranteed. If you need a guarantee, you need SCHED_FIFO, cgroup CPU
bandwidth, or pinning — not a nice value.
| State | In ps | What it means |
|---|---|---|
| Running / runnable | R | On a CPU, or in the run queue waiting for one |
| Interruptible sleep | S | Waiting on I/O or an event; signals wake it |
| Uninterruptible sleep | D | In a kernel path that cannot be interrupted — usually disk or NFS. Persistent D state is a storage problem |
| Stopped | T | SIGSTOP, or under a debugger |
| Zombie | Z | Exited, but the parent hasn't called wait(). Harmless singly, a PID leak in bulk |
The kernel uses every spare byte of RAM as page cache — a cache of file
contents. A box showing 200 MB free and 60 GB cached is not short of memory; it is doing exactly
what it should. Cache is reclaimable on demand. The number to watch is available in
/proc/meminfo, which accounts for that.
A buffered write returns as soon as the page is marked dirty. Flushing happens later, governed
by vm.dirty_ratio and vm.dirty_background_ratio. When dirty pages exceed
dirty_ratio, writers are throttled synchronously — the application
stalls in the kernel until writeback catches up. On a box with lots of RAM and a slow disk, the
defaults let gigabytes accumulate and then stall everything at once. Lowering the ratios trades a
little throughput for far less latency variance.
A file descriptor is a small integer indexing a per-process table. Behind it the
VFS presents one interface — read, write, seek, close — over regular files,
sockets, pipes, devices, epoll instances, timers, even other processes' memory. That uniformity is
why strace is so useful: almost everything a process does is a read or a write to
some fd.
Descriptors are a hard-limited resource, and the limit is per-process, inherited at exec, and
usually far lower than people expect. A service that leaks one fd per request will run for hours
and then fail all at once with EMFILE — accept() starts failing while the process
looks perfectly healthy.
Container behaviour, memory accounting, signals and the modern I/O paths.
A container is a normal Linux process with two things done to it. Namespaces change what it can see; cgroups limit what it can use. There is no container object in the kernel, which is exactly why containers start in milliseconds and why a container escape is a kernel bug rather than a hypervisor bug.
PID 1 has special duties: it reaps orphaned children and it does not get default signal
handlers. Run an application as PID 1 without thinking about it and you get two classic bugs —
zombie processes accumulating because nothing reaps them, and SIGTERM being ignored
so every deploy waits the full termination grace period and then gets SIGKILLed. That is what
--init and tini exist to fix.
Two different OOM paths exist and they behave differently. Global OOM fires
when the whole machine is out and the kernel picks a victim by oom_score — roughly,
whoever is using the most, adjusted by oom_score_adj. cgroup OOM
fires when a single cgroup hits its memory.max, and kills something inside that cgroup
only. The host has plenty of free memory; the container still dies.
This is the entire explanation for the most confusing Kubernetes failure mode: a pod is OOMKilled while the node's memory graph shows 40% used. The node was never the constraint. The container's limit was.
Under cgroup v2, the charge includes anonymous memory and page cache the cgroup caused. A process that reads a lot of files can be OOMKilled for cache it does not need and would happily give back — the kernel does try to reclaim first, but a fast enough reader can outrun reclaim. This is why a batch job that streams large files needs a limit well above its apparent working set.
Signals are the kernel's interrupt mechanism for processes. Most have a default disposition:
SIGTERM terminates, SIGKILL terminates and cannot be caught,
SIGSEGV dumps core. A process can install a handler for anything except
SIGKILL and SIGSTOP.
PID 1 is the exception. The kernel does not apply default dispositions to it.
A process running as PID 1 with no explicit SIGTERM handler simply ignores the signal.
Kubernetes sends SIGTERM, waits terminationGracePeriodSeconds (30 by default), then
SIGKILLs. If your entrypoint is the application itself and it has no handler, every single pod
deletion takes the full 30 seconds and ends in a hard kill — connections dropped, in-flight
requests lost, and a rolling deploy that takes half an hour.
ENTRYPOINT ./app in shell form becomes /bin/sh -c ./app.
The shell is PID 1, your app is a child, and sh does not forward signals to it. Use
exec form — ENTRYPOINT ["./app"] — or exec ./app in your wrapper script.
This one line is behind a remarkable proportion of "why are deploys so slow" investigations.
Buffered I/O is the default: reads are served from page cache when possible, writes return once dirty. Great throughput, unpredictable latency, and a copy between kernel and user buffers on every call.
Direct I/O (O_DIRECT) bypasses the page cache entirely and DMAs
straight into your buffer. Databases use it because they maintain a better cache than the kernel
can — they know which pages matter. It demands aligned buffers and aligned offsets, and it is
slower for anything that would have hit cache.
io_uring replaces the syscall-per-operation model with two shared ring buffers between userspace and kernel. You write submission entries into a ring, the kernel writes completions into another, and with polling mode you can do sustained I/O with zero syscalls. For anything issuing hundreds of thousands of operations a second, this is the difference between spending 40% of your CPU in kernel entry overhead and spending almost none.
| Path | Syscalls per op | Page cache | Use when |
|---|---|---|---|
| Buffered read/write | 1+ | Yes | Almost everything. Default for a reason. |
mmap | 0 after map | Yes | Random access to a file that fits in RAM; beware page-fault stalls |
O_DIRECT | 1+ | Bypassed | You maintain your own cache — databases, mostly |
io_uring | ~0 (batched) | Optional | Very high IOPS, or many concurrent ops from few threads |
A process is slow and you have sixty seconds. This is the order that finds the answer fastest, because each step rules out an entire class of cause.
In Kubernetes, nr_throttled climbing is the single most common
cause of "the app is slow and the CPU graph looks fine". CFS bandwidth control gives the cgroup a
quota per 100 ms period; burn it in 20 ms and the process is frozen for the remaining 80 ms. The
average utilisation looks like 20%. The p99 latency looks like a disaster. Both are true.
| Command | What it answers |
|---|---|
ps -o pid,stat,wchan:25,comm -p PID | Running, sleeping, or stuck — and in which kernel function |
strace -c -f -p PID | Which syscalls dominate, and which are erroring |
ltrace -p PID | Library calls, when the syscalls look innocent |
cat /proc/PID/status | Threads, signal masks, VmRSS, context switch counts |
cat /proc/PID/limits | Every rlimit, soft and hard |
cat /proc/PID/stack | Kernel stack of a D-state process — what it's blocked in |
grep Pss /proc/PID/smaps_rollup | Honest per-process memory, shared pages apportioned |
ls -l /proc/PID/ns/ | Which namespaces it's in — is it actually containerised? |
cat /proc/PID/cgroup | Which cgroup, so you can find its limits |
cat <cgroup>/cpu.stat | nr_throttled — CPU-limit throttling |
cat <cgroup>/memory.events | high, max, oom_kill counts |
dmesg -T | grep -i oom | Who got killed, by which OOM path, and how big they were |
vmstat 1 | Run queue, context switches, swap, io wait, at a glance |
pidstat -d -p PID 1 | Per-process read/write throughput |
sysctl -a | grep dirty | Writeback thresholds — the stall-under-load knobs |
perf record -F 99 -g -p PID | Sampled stacks; the only honest answer to 'where is the time' |