Everything for Linux in one place — pick a section below. 42 reviewed items across 4 content types, plus a hands-on tutorial.
Quick Answer
Use lscpu for a quick CPU summary, nproc to get the number of available cores, and cat /proc/cpuinfo for detailed per-core info like model name and supported features.
Detailed Answer
Checking your CPU setup is like looking under the hood of a car before a road trip. You want to know how many cylinders you have, how fast it can go, and whether it has turbo. Linux gives you three main tools to inspect your processors at different levels of detail, and knowing which one to use when saves you time in production.
The go-to tool is lscpu. It pulls data from /sys/devices/system/cpu and /proc/cpuinfo and presents it in a clean, organized format. You will see the architecture (like x86_64 or aarch64), how many CPU sockets the server has, cores per socket, threads per core, speed ranges, the cache layout (L1, L2, L3), and whether virtualization is supported. This is the command you run first when sizing a server or filing a support ticket. The nproc command is even simpler. It just tells you how many processing units are available. This is great in scripts where you need to set how many things run at once, like passing -j$(nproc) to make, or setting worker threads for Nginx or Gunicorn.
Under the hood, all CPU info comes from the kernel detecting processors at boot time. On x86 systems, the kernel uses CPUID instructions to probe each processor and fills in the /proc/cpuinfo virtual file. Each logical processor gets its own block in this file, with details like processor number, vendor, CPU family, model, stepping, microcode version, cache size, and a flags field listing supported instruction sets like sse4_2, avx2, and aes. The sysfs tree under /sys/devices/system/cpu/ adds runtime details like current clock speed, online/offline status, and how cores map to sockets.
In real-world production, knowing your CPU layout matters a lot for performance. On a 4-socket server with 24 cores per socket and hyperthreading, you get 192 logical CPUs. But memory access speed varies based on which NUMA node (memory bank tied to a socket) a process runs on. Tools like numactl and taskset let you pin processes to specific cores so they stay close to their memory. For Kubernetes workloads, CPU limits and requests map to these physical resources, and understanding the difference between real cores and hyperthreads affects how you plan capacity. The lstopo tool from the hwloc package can draw visual maps of your CPU layout.
A common mistake is confusing logical CPUs with physical cores. If nproc says 16, you might only have 8 real cores with hyperthreading turned on. This matters for CPU-heavy work because hyperthreading only gives you about 15-30% more performance, not double. Another trap is in virtual machines, where lscpu might show what the hypervisor presents rather than the real hardware, and CPU steal time (visible in top) tells you when other VMs are competing for your CPU cycles. Always check the flags in /proc/cpuinfo to confirm the server supports specific instruction sets like AVX-512 before deploying software that needs them.
Code Example
# Display structured CPU summary including sockets, cores, threads lscpu # Show only the number of available processing units nproc # Use nproc to set parallel build jobs for payments-api make -j$(nproc) -C /opt/payments-api build # Display detailed per-core information from the kernel cat /proc/cpuinfo # Extract just the model name from cpuinfo grep 'model name' /proc/cpuinfo | head -1 # Count physical cores (excluding hyperthreads) grep 'core id' /proc/cpuinfo | sort -u | wc -l # Check if a specific CPU flag is supported (e.g., AES-NI for encryption) grep -o 'aes' /proc/cpuinfo | head -1 # Show NUMA topology for prod-db-01 server lscpu | grep -i numa # Check CPU frequency scaling on production server cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq # Show online CPUs (useful after hotplug events) cat /sys/devices/system/cpu/online # Pin the payments-worker process to cores 0-3 for NUMA locality taskset -c 0-3 /opt/payments-api/bin/worker --threads=4
Interview Tip
A junior engineer typically just says 'use lscpu' and stops. Go further by explaining the three-layer approach: lscpu for the summary, nproc for scripting, and /proc/cpuinfo for deep per-core details including instruction set flags. Highlight the key difference between logical CPUs (what nproc shows) and physical cores, especially when hyperthreading is on. Bring up real examples like setting Nginx worker_processes to match nproc, or pinning database processes to certain NUMA nodes with taskset. If you mention CPU steal time in VMs and how it signals resource competition, you show real hands-on experience.
◈ Architecture Diagram
┌─────────────────────────────────────────────┐ │ Linux CPU Info Sources │ ├─────────────────────────────────────────────┤ │ │ │ ┌───────────┐ ┌───────────┐ ┌────────┐│ │ │ lscpu │ │ nproc │ │/proc/ ││ │ │ (summary) │ │ (count) │ │cpuinfo ││ │ └─────┬─────┘ └─────┬─────┘ └───┬────┘│ │ │ │ │ │ │ ↓ ↓ ↓ │ │ ┌─────────────────────────────────────────┐│ │ │ Kernel CPU Subsystem ││ │ │ ┌──────────┐ ┌──────────┐ ││ │ │ │ Socket 0 │ │ Socket 1 │ ││ │ │ │┌────┐┌──┐│ │┌────┐┌──┐│ ││ │ │ ││Core││Co││ ││Core││Co││ ││ │ │ ││ 0 ││1 ││ ││ 0 ││1 ││ ││ │ │ │└────┘└──┘│ │└────┘└──┘│ ││ │ │ └──────────┘ └──────────┘ ││ │ └─────────────────────────────────────────┘│ │ ↑ │ │ ┌─────────────┐ │ │ │/sys/devices/│ │ │ │system/cpu/ │ │ │ └─────────────┘ │ └─────────────────────────────────────────────┘
💬 Comments
Quick Answer
A hard link is another name pointing to the same file data (same inode). A soft link (symlink) is a shortcut file that points to a path. Hard links break if you cross filesystems; soft links break if the target is deleted.
Detailed Answer
Think of a hard link as two entries in a phone book that both dial the same person. Deleting one entry does not affect the other because both connect directly to the real person. A soft link is more like a sticky note saying 'call this other number.' If that number gets disconnected, the sticky note becomes useless. That is the core difference between these two link types.
A hard link adds another directory entry that maps a filename to an existing inode. The inode is the real data structure on disk that holds file metadata (permissions, ownership, timestamps) and pointers to the actual data. When you create a hard link, the inode's link count goes up by one. Both filenames are equally valid, and there is no 'original' versus 'copy.' A symbolic link (soft link or symlink) is a completely separate file with its own inode. That inode's data contains the text of the target path. When the kernel encounters a symlink, it reads the stored path and redirects to that location, adding a hop of indirection.
Inside the filesystem (ext4, xfs, etc.), hard links work through the inode reference count stored in the inode table. When you run ls -l, the second column shows this count. The file's actual data blocks are only freed when the link count drops to zero AND no running process has the file open. This is why you can delete a log file that a running process still has open and the disk space will not be reclaimed until that process closes the file. For symlinks, the kernel stores the target path either directly inside the inode itself (for short paths, usually under 60 bytes on ext4) or in a separate data block. The kernel follows symlinks automatically during system calls like open(), but provides lstat() and readlink() for inspecting the symlink itself without following it.
In production, both types serve important roles. Hard links are used by package managers like rpm and dpkg to save space when multiple packages include the same file. Backup tools like rsnapshot use hard links to create space-efficient snapshots where unchanged files across backups share the same data. Symlinks are everywhere in deployment setups: /opt/payments-api/current might point to /opt/payments-api/releases/v2.4.1, letting you do atomic deployments by switching the symlink to a new release folder. The /etc/alternatives system on Debian uses symlinks to manage multiple installed versions of the same program.
A key restriction is that hard links cannot cross filesystem boundaries because inode numbers are only unique within one filesystem. Trying to ln across mount points fails with 'Invalid cross-device link.' Hard links to directories are also blocked on most filesystems to prevent loops that would break tools like find and du. Symlinks can become dangling (broken) if the target is moved or deleted, causing confusing 'No such file or directory' errors. In Docker containers, symlinks pointing to absolute paths may resolve differently depending on the container's filesystem layout, leading to subtle deployment bugs. Always use readlink -f to resolve the final target of a symlink chain when debugging.
Code Example
# Create a test file for the payments-api config echo 'db_host=prod-db-01.internal' > /etc/payments-api/db.conf # Create a hard link — both names share the same inode ln /etc/payments-api/db.conf /etc/payments-api/db.conf.backup # Verify both files share the same inode number ls -li /etc/payments-api/db.conf /etc/payments-api/db.conf.backup # Note the link count is now 2 in the second column stat /etc/payments-api/db.conf | grep Links # Create a symbolic link for the current release deployment ln -s /opt/payments-api/releases/v2.4.1 /opt/payments-api/current # Verify the symlink target readlink -f /opt/payments-api/current # Show that symlink has its own inode (different from target) ls -li /opt/payments-api/current # Atomic deployment switch using symlink replacement ln -sfn /opt/payments-api/releases/v2.5.0 /opt/payments-api/current # Find all dangling symlinks in the deployment directory find /opt/payments-api -xtype l -print # Find all hard links to a specific inode (inode 12345678) find /opt/payments-api -inum 12345678 # Show difference: deleting original breaks symlink but not hard link rm /etc/payments-api/db.conf cat /etc/payments-api/db.conf.backup # still works (hard link) cat /opt/payments-api/current/config # may break (symlink)
Interview Tip
A junior engineer typically defines both link types but cannot explain the inode mechanics or real-world uses. Stand out by drawing the inode relationship: hard links share the same inode (link count goes up), while symlinks get their own inode that stores the target path. Mention the key limits: hard links cannot cross filesystems or point to directories. Then give real examples like atomic deploys with symlink swaps (ln -sfn), rsnapshot using hard links for efficient backups, and /etc/alternatives. Bringing up dangling symlinks as a debugging scenario and the 'delete-while-open' file descriptor behavior shows you have dealt with production issues.
◈ Architecture Diagram
┌─────────────── Hard Link ──────────────────┐ │ │ │ ┌──────────┐ ┌──────────────────┐ │ │ │ db.conf │────────→│ Inode 524301 │ │ │ └──────────┘ │ link count: 2 │ │ │ ┌──────────┐ │ size: 1024 │ │ │ │db.conf. │────────→│ perms: 644 │ │ │ │backup │ │ │ │ │ │ └──────────┘ └───────┼──────────┘ │ │ ↓ │ │ ┌──────────────┐ │ │ │ Data Blocks │ │ │ │ (shared) │ │ │ └──────────────┘ │ └─────────────────────────────────────────────┘ ┌─────────────── Soft Link ──────────────────┐ │ │ │ ┌──────────┐ ┌────────────────────┐ │ │ │ current │────→│ Inode 524302 │ │ │ └──────────┘ │ type: symlink │ │ │ │ data: /opt/pay... │ │ │ └────────┼───────────┘ │ │ ↓ │ │ ┌────────────────────┐ │ │ │ Inode 524400 │ │ │ │ (target file) │ │ │ │ │ │ │ │ └───────┼────────────┘ │ │ ↓ │ │ ┌──────────────┐ │ │ │ Data Blocks │ │ │ └──────────────┘ │ └─────────────────────────────────────────────┘
💬 Comments
Quick Answer
Every process gets a unique PID and tracks its parent's PPID. New processes are born via fork() (which clones the parent), then exec() swaps in a new program. Zombies are dead processes whose parent has not yet collected their exit status.
Detailed Answer
Creating a process in Linux is like a cell dividing in biology. The parent cell splits into two identical copies via fork(), and then one copy transforms itself into something completely different via exec(). The parent is responsible for acknowledging each child's death by calling wait(). If it does not, the dead child lingers as a zombie, a ghost entry in the system's process table. This two-step create-then-replace model is how every program launches on Linux.
Every process gets a unique Process ID (PID), a number assigned by the kernel from a counter that wraps around after hitting /proc/sys/kernel/pid_max (default 32768, up to about 4 million on 64-bit systems). Each process also records its Parent PID (PPID), forming a tree rooted at PID 1 (usually systemd). The fork() system call creates a child process that is a near-exact copy of the parent, including open files, signal handlers, memory layout, and environment variables. Modern Linux uses copy-on-write (COW), meaning parent and child share the same memory pages until one of them writes something, at which point only the changed page gets copied. After fork(), the child usually calls exec() (one of the execve family), which replaces the entire program in memory with a new one loaded from disk, while keeping the same PID and open files not marked with close-on-exec.
Inside the kernel, each process is represented by a task_struct, a large data structure holding the process state (running, sleeping, zombie, etc.), scheduling info, memory management details, file descriptor table, signal handlers, and credentials. When fork() is called, the kernel actually uses clone() internally with specific flags, allocates a new task_struct, copies or shares resources based on those flags, assigns a new PID, and places the child on the scheduler's run queue. When exec() is called, the kernel loads the new executable (typically an ELF binary), sets up fresh memory regions, maps the program and its shared libraries, and jumps to the new program's starting point.
In production, understanding this lifecycle is critical for debugging service failures. When a process exits, it enters the zombie state (shown as Z in ps output) until its parent calls wait() or waitpid() to collect the exit status. A zombie uses no CPU or memory but holds onto its PID and process table slot. A few zombies are harmless, but a parent that never reaps its children can pile up thousands of them, eventually using up all available PIDs and blocking new processes from starting. This often happens with badly written daemons or init scripts. The fix is to make the parent handle SIGCHLD properly. If the parent itself dies, init/systemd (PID 1) adopts the orphaned children and reaps them. In containers, PID 1 inside the container must handle zombie reaping, which is why lightweight init tools like tini or dumb-init are used in Docker containers.
A common trap is fork bombs, where a process recursively forks without limit, eating up all available PIDs and locking up the system. Production servers should set ulimit -u (max processes per user) to prevent this. Another subtle issue: fork() in a multithreaded program only copies the calling thread, which can leave mutexes in a broken state in the child. That is why it is safer to use posix_spawn() or to call exec() right after fork() in threaded programs. Also, you cannot kill zombie processes with SIGKILL because they are already dead. You have to fix or kill the parent process instead.
Code Example
# View the full process tree rooted at PID 1 on prod-cluster node
pstree -p 1 | head -30
# Show PID, PPID, state, and command for all processes
ps -eo pid,ppid,stat,comm --sort=ppid | head -20
# Find zombie processes on the payments-api server
ps aux | awk '$8 ~ /Z/ {print $0}'
# Count total zombie processes
ps -eo stat | grep -c Z
# Find the parent of zombie processes to fix the root cause
ps -eo pid,ppid,stat,comm | awk '$3 ~ /Z/ {print "Zombie PID:"$1, "Parent PID:"$2}'
# Trace fork/exec calls of the payments-api service
strace -f -e trace=clone,execve -p $(pgrep -f payments-api) 2>&1 | head -20
# Show the process tree for a specific service
ps -ejH | grep payments
# Check the current PID limit on production server
cat /proc/sys/kernel/pid_max
# Set per-user process limit to prevent fork bombs
ulimit -u 4096
# Use tini as PID 1 in Docker to reap zombie processes
# In Dockerfile: ENTRYPOINT ["/tini", "--"]
# Then: CMD ["/opt/payments-api/bin/server"]
# Send SIGCHLD to parent to trigger zombie reaping
kill -SIGCHLD $(ps -eo pid,ppid,stat | awk '$3~/Z/{print $2}' | head -1)
# Monitor process creation in real time on prod-cluster
watch -n 1 'ps -eo stat | sort | uniq -c | sort -rn'Interview Tip
A junior engineer typically describes fork and exec separately without connecting them to the full lifecycle or real production problems. Set yourself apart by walking through the whole sequence: fork() makes a copy-on-write clone, exec() replaces the program image, and wait() reaps the exit status. Stress that zombies are not resource hogs but process table entries, and the real issue is the parent not calling wait(). Mention tini/dumb-init for container PID 1, fork bomb protection via ulimit -u, and why fork() in multithreaded programs is risky. Drawing the process state machine (Running, Sleeping, Stopped, Zombie) on a whiteboard makes a strong visual impression.
◈ Architecture Diagram
┌─────────────────────────────────────────────┐ │ Linux Process Lifecycle │ ├─────────────────────────────────────────────┤ │ │ │ ┌──────────┐ fork() ┌──────────────┐ │ │ │ Parent │──────────→│ Child (copy) │ │ │ │ PID: 100 │ │ PID: 101 │ │ │ │ PPID: 1 │ │ PPID: 100 │ │ │ └────┬─────┘ └──────┬───────┘ │ │ │ │ │ │ │ ↓ exec() │ │ │ ┌──────────────┐ │ │ │ │ New Program │ │ │ │ │ PID: 101 │ │ │ │ │ (replaced) │ │ │ │ └──────┬───────┘ │ │ │ │ │ │ │ ↓ exit() │ │ │ ┌──────────────┐ │ │ │ │ Zombie │ │ │ │ │ PID: 101 │ │ │ │ │ State: Z │ │ │ │ └──────┬───────┘ │ │ │ │ │ │ ↓ wait() │ │ │ ┌──────────┐ │ │ │ │ Parent │←────────────────┘ │ │ │ collects │ exit status │ │ │ status │ │ │ └──────────┘ │ │ │ │ ┌──────────────────────────────────────┐ │ │ │ If parent dies → init (PID 1) adopts│ │ │ │ orphans and reaps zombies │ │ │ └──────────────────────────────────────┘ │ └─────────────────────────────────────────────┘
💬 Comments
Quick Answer
Linux gives each process its own virtual address space mapped to physical RAM through page tables. When RAM gets tight, the kernel moves less-used pages to disk (swap). If both RAM and swap run out, the OOM killer picks a process to terminate based on its memory score.
Detailed Answer
Picture virtual memory as each tenant in an apartment building believing they have the whole building to themselves. Each tenant (process) has their own floor plan (virtual address space), but behind the scenes the building manager (kernel) maps their rooms to actual physical spaces and sometimes stores overflow furniture in a basement locker (swap). If the building fills up completely and someone needs more space, the manager has to evict a tenant. That is the OOM killer.
Virtual memory is the foundation of Linux memory management. Every process gets its own virtual address space, usually spanning 128 TB on 64-bit systems. This space is split into sections: code (text), data, heap (which grows upward via brk/mmap), memory-mapped files, and stack (which grows downward). The kernel keeps page tables that translate virtual addresses to physical page frames, and the hardware MMU (Memory Management Unit) does this translation on every memory access. Pages are normally 4 KB, but huge pages (2 MB or 1 GB) can reduce pressure on the TLB (Translation Lookaside Buffer, which is a small cache of recent address translations) for memory-heavy applications like databases. Linux uses demand paging, which means pages are not actually allocated until you touch them, triggering a page fault that the kernel handles by assigning a physical frame.
Under the hood, the kernel organizes physical memory using the buddy allocator, which manages free pages in power-of-two blocks to reduce fragmentation. On top of that, the slab allocator (SLUB in modern kernels) handles small kernel objects like process structures and inodes efficiently. User-space memory is tracked through Virtual Memory Areas (VMAs) in each process's memory descriptor. The kernel also uses the page cache to keep recently read file data in RAM, which is the main reason a busy server's 'free' memory looks low. Linux aggressively caches file data but will give that memory back when applications need it. The kswapd daemon runs in the background, scanning for inactive pages and moving them to swap when free memory drops below set thresholds.
Swap space extends memory beyond physical RAM by using disk as overflow. When the kernel needs to free up RAM, it writes anonymous pages (heap, stack, anything not backed by a file) to the swap partition or swap file. The vm.swappiness setting (0-200, default 60) controls how aggressively the kernel swaps versus reclaiming file cache. In production, setting swappiness too high on a database server causes horrible latency spikes because disk I/O is thousands of times slower than RAM. Many production setups use vm.swappiness=1 or vm.swappiness=10 and rely on having enough RAM. Kubernetes environments often disable swap entirely (required before Kubernetes 1.22) or use newer swap-aware features. The mkswap and swapon commands manage swap devices, and /proc/swaps and free -h show current usage.
The OOM (Out-of-Memory) killer is the kernel's last resort when both RAM and swap are full. It calculates an oom_score for each process based on how much memory it uses, adjusted by oom_score_adj (a value from -1000 to 1000, where -1000 means 'never kill me'). The process with the highest score gets killed with SIGKILL. A tricky part is Linux's memory overcommit policy (vm.overcommit_memory), which lets processes request more memory than actually exists. The malloc() call succeeds, but if the memory is actually used later, the OOM killer can strike at any random moment, not just during allocation. Production systems should tune oom_score_adj to protect critical services: set -999 for your database and monitoring, and leave application servers at the default. Check dmesg or journalctl for OOM events. Another watch-out is transparent huge pages (THP), which can cause latency spikes during memory compaction. Many database vendors recommend disabling THP entirely.
Code Example
# Check overall memory usage on prod-db-01 server free -h # Show detailed memory breakdown from the kernel cat /proc/meminfo | head -20 # Check per-process memory usage for payments-api ps -eo pid,rss,vsz,comm --sort=-rss | head -10 # View the virtual memory map of a specific process cat /proc/$(pgrep -f payments-api)/maps | head -20 # Check swap usage and configuration swapon --show # Set swappiness to 10 for database server (reduce swap tendency) sudo sysctl vm.swappiness=10 # Make swappiness persistent across reboots echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.d/99-prod-tuning.conf # Check OOM killer score for the database process cat /proc/$(pgrep -f postgresql)/oom_score # Protect the database from OOM killer on prod-db-01 echo -999 | sudo tee /proc/$(pgrep -f postgresql)/oom_score_adj # Check for recent OOM kill events in system logs sudo dmesg | grep -i 'oom\|killed process' # Monitor memory pressure in real time vmstat 1 5 # Check overcommit settings cat /proc/sys/vm/overcommit_memory # Disable transparent huge pages for database workloads echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
Interview Tip
A junior engineer typically confuses free memory with available memory and panics when free shows low 'free' RAM. Show deeper understanding by explaining that Linux intentionally uses spare RAM for the page cache, and the 'available' column in free -h is what really tells you if memory is tight. Walk through the layers: virtual addresses go through page tables to physical frames, with demand paging avoiding upfront allocation. Explain swappiness tuning (why databases use low values) and OOM killer protection via oom_score_adj. Mentioning overcommit, where malloc succeeds but the OOM killer strikes later during page faults, shows kernel-level awareness that impresses interviewers.
◈ Architecture Diagram
┌─────────────────────────────────────────────┐ │ Linux Memory Management │ ├─────────────────────────────────────────────┤ │ │ │ ┌─────────────┐ ┌─────────────┐ │ │ │ Process A │ │ Process B │ │ │ │ Virtual Addr │ │ Virtual Addr │ │ │ │ Space │ │ Space │ │ │ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ │ ↓ ↓ │ │ ┌─────────────────────────────────────┐ │ │ │ MMU + Page Tables │ │ │ │ Virtual → Physical Translation │ │ │ └──────────────────┬──────────────────┘ │ │ ↓ │ │ ┌─────────────────────────────────────┐ │ │ │ Physical RAM │ │ │ │ ┌──────┐ ┌──────┐ ┌────────────┐ │ │ │ │ │Active│ │Inact.│ │ Page Cache │ │ │ │ │ │Pages │ │Pages │ │ (file I/O) │ │ │ │ │ └──────┘ └──┬───┘ └────────────┘ │ │ │ └──────────────┼─────────────────────┘ │ │ │ kswapd │ │ ↓ │ │ ┌─────────────────────────────────────┐ │ │ │ Swap Space (disk) │ │ │ └─────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────┐ │ │ │ RAM + Swap exhausted │ │ │ │ ↓ │ │ │ │ ┌──────────────┐ │ │ │ │ │ OOM Killer │→ Kill highest │ │ │ │ │ │ oom_score process │ │ │ │ └──────────────┘ │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────────┘
💬 Comments
Quick Answer
Start with top for a live overview of CPU and memory hogs. Use vmstat for CPU and memory pressure, iostat for disk bottlenecks, and sar for historical trends. The USE method (Utilization, Saturation, Errors) gives you a structured way to find the bottleneck.
Detailed Answer
Troubleshooting a slow server is like being a detective at a crime scene. You need a method, not random guessing. Just as a detective secures the scene, interviews witnesses, and collects evidence in order, a good engineer follows a structured approach: get the big picture first, then drill into whichever resource (CPU, memory, disk, network) is the bottleneck. Brendan Gregg's USE method, which checks Utilization, Saturation, and Errors for each resource, gives you that structure.
The first tool to reach for is top (or the nicer htop). It gives you a live dashboard with load averages (1, 5, 15 minutes), a CPU breakdown (user, system, idle, I/O wait, steal), memory and swap usage, and a per-process list you can sort. Load average tells you how many processes are competing for CPU time: a load of 8 on a 4-core machine means processes are queuing up. The 'wa' (I/O wait) percentage is key: high wa means processes are stuck waiting for disk, not burning CPU. The 'st' (steal time) in VMs shows CPU cycles taken by the hypervisor for other VMs. Press 'P' to sort by CPU, 'M' by memory, and '1' to see each core separately.
vmstat (virtual memory statistics) packs system health into a single line that refreshes at whatever interval you set. Important columns include r (processes waiting for CPU), b (processes blocked on I/O), swpd (swap used), si/so (swap in/out per second), bi/bo (disk reads/writes), and the CPU percentages. If r is consistently higher than your core count, you have CPU saturation. If si/so are not zero, you are actively swapping, which causes brutal latency. Run vmstat 1 10 for ten one-second snapshots. The first line is always a since-boot average, so ignore it when looking at current behavior.
iostat (from the sysstat package) focuses on disk I/O. Run iostat -xz 1 to see per-device stats including r/s and w/s (operations per second), rkB/s and wkB/s (throughput), await (average wait time in milliseconds), and %util (how busy the device is). If await is consistently above 10ms on SSDs or 20ms on spinning disks, you have an I/O bottleneck. If %util is near 100%, the device is maxed out. Use iotop alongside it to see which processes are generating the I/O. For NVMe drives, %util can be misleading since these devices handle massive parallelism, so focus on await and queue depth (avgqu-sz) instead.
sar (System Activity Reporter) is the history tool. It collects data every 10 minutes via a cron job and stores it in /var/log/sa/ or /var/log/sysstat/. Use sar -u for past CPU usage, sar -r for memory, sar -b for disk I/O, and sar -n DEV for network stats. This is invaluable for connecting performance drops to specific events, like discovering that CPU spikes match a cron job at 2 AM or a deployment at 3 PM. Data is kept for up to a month by default.
A common trap is fixating on one metric without checking the others. High CPU usage might actually be I/O wait (check wa in top), making it a disk problem, not a CPU problem. A server with high load average but low CPU often has processes stuck waiting on disk or network. Another mistake is not checking for recent changes first. Always run last, history, and check deployment logs before diving deep. In production, setting up continuous monitoring with tools like Prometheus node_exporter and Grafana dashboards prevents the need for manual sar digging and gives you alerts before users notice problems.
Code Example
# Step 1: Quick overview — check load averages and top consumers
top -bn1 | head -20
# Step 2: Check CPU, memory, and I/O pressure with vmstat
# Run 10 samples at 1-second intervals on prod-web-01
vmstat 1 10
# Step 3: Check disk I/O performance per device
# -x for extended stats, -z to skip idle devices
iostat -xz 1 5
# Step 4: Identify which processes are causing disk I/O
sudo iotop -oP -d 2
# Step 5: Check historical CPU usage for the past day
sar -u -f /var/log/sa/sa$(date +%d)
# Check historical memory usage for correlation
sar -r -f /var/log/sa/sa$(date +%d)
# Check network throughput for the payments-api server
sar -n DEV 1 5
# Check for processes in uninterruptible sleep (D state)
ps aux | awk '$8 ~ /D/ {print}'
# Check TCP connection states for connection leaks
ss -s
# Quick USE method checklist for prod-cluster nodes
# CPU utilization
mpstat -P ALL 1 3
# Memory saturation (check si/so for active swapping)
vmstat 1 3 | awk 'NR>2 {print "swap_in:"$7, "swap_out:"$8}'
# Disk errors
dmesg | grep -i 'error\|fail\|reset' | tail -10
# Check if the system ran out of memory recently
journalctl --since '1 hour ago' | grep -i oom
# Profile system calls of the slow payments-api process
sudo strace -c -p $(pgrep -f payments-api) -e trace=allInterview Tip
A junior engineer typically jumps straight to top and tries to fix whatever shows up at the top without a plan. Impress interviewers by naming the USE method (Utilization, Saturation, Errors) by Brendan Gregg as your framework. Walk through tools in order: top for the big picture, vmstat for CPU and memory pressure, iostat for disk I/O, and sar for looking back in time. Explain what key columns actually mean: wa in top is I/O wait not CPU load, si/so in vmstat shows active swapping, await in iostat is the latency applications feel. Mention that high load average with low CPU often points to I/O problems, not CPU problems, a distinction many engineers miss.
◈ Architecture Diagram
┌─────────────────────────────────────────────┐ │ Slow Server Troubleshooting Workflow │ ├─────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ │ │ │ top / htop │ ← Start here │ │ │ Load avg? │ │ │ │ CPU %wa? │ │ │ └──────┬───────┘ │ │ │ │ │ ┌────┼────────────┬──────────┐ │ │ ↓ ↓ ↓ ↓ │ │ ┌──────┐ ┌────────┐ ┌───────┐ ┌─────────┐ │ │ │ CPU │ │ Memory │ │ Disk │ │ Network │ │ │ │ High │ │ High │ │ I/O │ │ Slow │ │ │ └──┬───┘ └───┬────┘ └──┬────┘ └────┬────┘ │ │ ↓ ↓ ↓ ↓ │ │ ┌──────┐ ┌────────┐ ┌───────┐ ┌─────────┐ │ │ │mpstat│ │vmstat │ │iostat │ │sar -n │ │ │ │perf │ │free -h │ │iotop │ │ss -s │ │ │ │pidst.│ │slabtop │ │lsblk │ │tcpdump │ │ │ └──┬───┘ └───┬────┘ └──┬────┘ └────┬────┘ │ │ │ │ │ │ │ │ └─────────┴─────────┴───────────┘ │ │ ↓ │ │ ┌────────────────┐ │ │ │ sar (history)│ │ │ │ Correlate │ │ │ │ with events │ │ │ └────────────────┘ │ └─────────────────────────────────────────────┘
💬 Comments
Quick Answer
Namespaces give each container its own isolated view of resources (processes, network, filesystem, users). Cgroups limit how much CPU, memory, and I/O a container can use. Together, they are the kernel features that Docker, Podman, and Kubernetes all rely on.
Detailed Answer
Think of a large office building where namespaces are the walls and doors that create private offices. Each tenant sees only their own space and has their own reception desk (hostname), phone system (network), and file cabinets (filesystem). Cgroups are the building management system that controls how much electricity (CPU), water (memory), and elevator bandwidth (I/O) each tenant can use. Containers are simply these two things combined with a filesystem image, wrapped behind a friendly interface like Docker.
Linux namespaces create isolated views of system resources that would normally be shared. There are eight types: PID (each container gets its own process numbering starting from 1), Network (separate network stack with its own interfaces and routing), Mount (isolated filesystem mounts), UTS (separate hostname), IPC (isolated inter-process communication), User (maps container root to an unprivileged host user for rootless containers), Cgroup (isolated view of the cgroup tree), and Time (separate system clock, added in kernel 5.6). When Docker starts a container, it calls clone() with flags like CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS to put the new process in fresh namespaces. The unshare() call can move an existing process into new namespaces, and setns() lets a process join existing ones (this is how docker exec works).
Cgroups (control groups) v2 organize processes into a tree structure under /sys/fs/cgroup/. Each node in the tree has controller files that set resource limits. The memory controller (memory.max, memory.high) caps how much memory a group can use and triggers a cgroup-level OOM killer if the limit is exceeded. The CPU controller (cpu.max) throttles CPU time. For example, setting '100000 100000' gives the group 100% of one CPU core. The I/O controller (io.max) limits disk read/write speed and operations per second per device. The PID controller (pids.max) prevents fork bombs inside a container. When a process goes over its cgroup memory limit, the kernel kills a process inside that cgroup, not a random one system-wide like the global OOM killer would. Cgroups v2 (unified hierarchy) replaced v1 (separate hierarchies per controller) and is the default on modern distributions.
In production, knowing these building blocks is essential for debugging container resource issues. When a Kubernetes pod is OOMKilled, it is the cgroup memory controller firing, not the system OOM killer. The limits in your pod spec (like resources.limits.memory: 512Mi) translate directly to memory.max in the container's cgroup. CPU limits map to cpu.max with CFS throttling, and throttling metrics show up in cpu.stat (nr_throttled, throttled_usec). CPU requests map to cpu.weight (v2) or cpu.shares (v1), which provide proportional sharing rather than hard caps. Tools like systemd-cgtop show live cgroup resource usage, and looking directly at /sys/fs/cgroup/ reveals the actual kernel-level constraints.
A big gotcha is that many applications inside containers read /proc/meminfo and /proc/cpuinfo, which show the host's total resources, not the container's limits. This causes JVMs, Node.js, and other runtimes to auto-tune based on host memory, leading to OOM kills when they allocate more than the cgroup allows. Fixes include using tools like LXCFS to provide container-aware /proc files, or setting explicit runtime flags (like Java's -XX:MaxRAMPercentage). Another pitfall is cgroup v1 versus v2: Docker on older kernels uses v1 with separate hierarchies, while newer systems use v2's unified hierarchy, requiring different file paths when debugging. Check which version is active with stat -fc %T /sys/fs/cgroup/ (cgroup2fs means v2).
Code Example
# Check which cgroup version is active on prod-cluster
stat -fc %T /sys/fs/cgroup/
# View the cgroup hierarchy for a running container
systemd-cgtop -d 1 -n 3
# Create a cgroup manually to understand the primitives
sudo mkdir /sys/fs/cgroup/payments-api-limits
# Set memory limit to 512MB for the payments-api cgroup
echo 536870912 | sudo tee /sys/fs/cgroup/payments-api-limits/memory.max
# Set CPU limit to 50% of one core (50ms per 100ms period)
echo '50000 100000' | sudo tee /sys/fs/cgroup/payments-api-limits/cpu.max
# Add a process to the cgroup
echo $$ | sudo tee /sys/fs/cgroup/payments-api-limits/cgroup.procs
# View current memory usage of a Docker container's cgroup
cat /sys/fs/cgroup/system.slice/docker-<container-id>/memory.current
# Check CPU throttling for the payments-api container
cat /sys/fs/cgroup/system.slice/docker-<container-id>/cpu.stat
# Explore namespaces of a running container process
sudo ls -la /proc/$(docker inspect -f '{{.State.Pid}}' payments-api)/ns/
# Enter a container's network namespace for debugging
sudo nsenter -t $(docker inspect -f '{{.State.Pid}}' payments-api) -n ip addr
# Create an isolated PID namespace manually
sudo unshare --pid --fork --mount-proc bash -c 'ps aux'
# Check container resource limits via Docker inspect
docker inspect payments-api --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}'Interview Tip
A junior engineer typically says 'Docker uses cgroups and namespaces' without explaining what each one actually does. Stand out by clearly separating the two: namespaces handle isolation (what a container can see), while cgroups handle resource control (what a container can use). Name the eight namespace types and at least three cgroup controllers with their files (memory.max, cpu.max, pids.max). Explain how Kubernetes resource limits map to cgroup settings, connecting the abstraction to kernel reality. Mention the /proc/meminfo gotcha where apps inside containers see host resources instead of cgroup limits, causing JVM over-allocation and OOM kills. This shows you have debugged real container issues.
◈ Architecture Diagram
┌─────────────────────────────────────────────┐ │ How Linux Enables Containers │ ├─────────────────────────────────────────────┤ │ │ │ ┌──────── Container A ────────┐ │ │ │ ┌────────────────────────┐ │ │ │ │ │ Namespaces │ │ │ │ │ │ ┌────┐┌────┐┌───┐┌──┐ │ │ │ │ │ │ │PID ││Net ││Mnt││UTS│ │ │ │ │ │ │ └────┘└────┘└───┘└──┘ │ │ │ │ │ └────────────────────────┘ │ │ │ │ ┌────────────────────────┐ │ │ │ │ │ Cgroups │ │ │ │ │ │ CPU: 50% │ Mem: 512M │ │ │ │ │ │ IO: 100M │ PIDs: 200 │ │ │ │ │ └────────────────────────┘ │ │ │ └─────────────────────────────┘ │ │ │ │ ┌──────── Container B ────────┐ │ │ │ ┌────────────────────────┐ │ │ │ │ │ Namespaces │ │ │ │ │ │ ┌────┐┌────┐┌───┐┌──┐ │ │ │ │ │ │ │PID ││Net ││Mnt││UTS│ │ │ │ │ │ │ └────┘└────┘└───┘└──┘ │ │ │ │ │ └────────────────────────┘ │ │ │ │ ┌────────────────────────┐ │ │ │ │ │ Cgroups │ │ │ │ │ │ CPU: 25% │ Mem: 256M │ │ │ │ │ └────────────────────────┘ │ │ │ └─────────────────────────────┘ │ │ │ │ │ │ ↓ ↓ │ │ ┌─────────────────────────────────────┐ │ │ │ Host Linux Kernel │ │ │ │ Shared kernel, isolated resources │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────────┘
💬 Comments
Quick Answer
systemd is the init system that manages services on modern Linux. It uses unit files to define services, timers, mounts, and more. Targets group units into boot stages. journalctl provides structured log access with powerful filtering.
Detailed Answer
Think of systemd as an airport control tower managing everything needed to get the airport running. Each unit (service, mount, timer) is a department like baggage handling, fueling, or security. Targets are operational stages: 'pre-flight checks done' (multi-user.target), 'ready for passengers' (graphical.target). The control tower knows the dependencies between departments and starts them in parallel where possible, instead of calling each one sequentially like the old SysV init scripts did.
The basic building block in systemd is the unit, which is any resource systemd manages. There are twelve types: service (daemons), socket (for socket-based activation), target (grouping and sync points), mount (filesystem mounts), automount (mount on demand), timer (scheduled activation, like cron), path (file path monitoring), device (kernel devices), swap (swap space), slice (cgroup resource grouping), scope (externally started processes), and snapshot (saved states). Unit files live in /usr/lib/systemd/system/ (vendor defaults), /etc/systemd/system/ (admin overrides), and /run/systemd/system/ (runtime units). Each file has sections: [Unit] for description and dependencies (After=, Requires=, Wants=), [Service] for how to run it (ExecStart=, Restart=, Type=), and [Install] for boot enablement (WantedBy=).
Internally, systemd runs as PID 1 and builds a dependency graph of all units at boot. A clever feature is socket activation: a socket unit listens on a port, and when a connection comes in, systemd starts the matching service and hands it the connection. This eliminates startup ordering headaches because clients just connect and wait. The Type= directive tells systemd how to track when a service is ready: Type=simple means the main process is the service, Type=forking means it forks and the parent exits, Type=notify means the service sends a 'ready' signal via sd_notify(), and Type=oneshot is for scripts that run once and exit. systemd also puts each service in its own cgroup (system.slice/servicename.service), enabling per-service resource tracking and limits through directives like MemoryMax=, CPUQuota=, and IOWeight= right in the unit file.
In production, systemctl and journalctl are daily tools. Use systemctl start/stop/restart/reload for immediate control, enable/disable for boot persistence, status for a quick health check with recent logs, and list-units/list-unit-files for inventory. To override vendor unit files without editing them directly, use systemctl edit, which creates a drop-in file at /etc/systemd/system/servicename.service.d/override.conf. journalctl reads the binary journal maintained by systemd-journald and offers powerful filtering: journalctl -u payments-api.service for one service, -p err for error-level only, --since '1 hour ago' for time ranges, -f to follow like tail -f, and -o json for structured output. The journal automatically captures stdout/stderr, syslog messages, and kernel messages in one indexed store.
A critical gotcha is the difference between Requires= and Wants=. Requires= means if the dependency fails, your unit fails too. Wants= is a soft dependency that does not drag your unit down. Using Requires= carelessly can create cascading failures where a non-critical dependency takes down your main service. Another common mistake is forgetting to run systemctl daemon-reload after changing unit files; systemd caches the parsed config and will not see changes without it. Services with Restart=always will respawn forever after crashes, which can cause rapid restart loops burning CPU. Use StartLimitIntervalSec= and StartLimitBurst= to cap restarts (for example, 5 restarts within 60 seconds). Finally, journald defaults to storing logs in memory on some distros, meaning logs vanish on reboot unless you create /var/log/journal/ and set Storage=persistent in /etc/systemd/journald.conf.
Code Example
# Check the status of the payments-api service on prod-web-01 sudo systemctl status payments-api.service # Start and enable the service to persist across reboots sudo systemctl enable --now payments-api.service # View the full unit file to understand service configuration systemctl cat payments-api.service # Create a drop-in override to increase memory limit without editing the vendor file sudo systemctl edit payments-api.service # This opens an editor; add: # [Service] # MemoryMax=1G # CPUQuota=150% # Reload systemd after any unit file changes sudo systemctl daemon-reload # View recent logs for the payments-api service journalctl -u payments-api.service --since '30 min ago' # Follow logs in real time (like tail -f) journalctl -u payments-api.service -f # Show only error-level and above log entries journalctl -u payments-api.service -p err # View logs in JSON format for parsing by monitoring tools journalctl -u payments-api.service -o json --no-pager | head -5 # List all failed units on the production server systemctl list-units --state=failed # Check which target the system booted into systemctl get-default # Show the dependency tree for multi-user.target systemctl list-dependencies multi-user.target # Check restart limit configuration to prevent crash loops systemctl show payments-api.service | grep -i startlimit # View cgroup resource usage for the service systemd-cgtop -d 1 -n 3
Interview Tip
A junior engineer typically knows systemctl start/stop/restart but cannot explain the unit type system, dependencies, or advanced features. Set yourself apart by explaining the key unit types (service, socket, target, timer) and how socket activation removes startup ordering problems. Discuss the three service types that matter most: simple, forking, and notify, and when to use each one. Show production awareness by mentioning drop-in overrides (systemctl edit), restart limits (StartLimitBurst), and that daemon-reload is required after file changes. For logging, explain that journalctl can filter by unit, priority, and time window, and that its binary format enables indexed searches far more powerful than grepping text files.
◈ Architecture Diagram
┌─────────────────────────────────────────────┐ │ systemd Architecture │ ├─────────────────────────────────────────────┤ │ │ │ ┌──────────────────────────────────────┐ │ │ │ graphical.target │ │ │ │ ↑ │ │ │ │ multi-user.target │ │ │ │ ↑ ↑ ↑ │ │ │ │ ┌────────┐┌───────┐┌──────────┐ │ │ │ │ │payments││nginx. ││sshd. │ │ │ │ │ │-api. ││service││service │ │ │ │ │ │service ││ ││ │ │ │ │ │ └───┬────┘└───────┘└──────────┘ │ │ │ │ │ │ │ │ │ ↓ │ │ │ │ ┌────────────────┐ │ │ │ │ │ network.target │ │ │ │ │ └───────┬────────┘ │ │ │ │ ↓ │ │ │ │ ┌────────────────┐ │ │ │ │ │ basic.target │ │ │ │ │ └───────┬────────┘ │ │ │ │ ↓ │ │ │ │ ┌────────────────┐ │ │ │ │ │ sysinit.target │ │ │ │ │ └────────────────┘ │ │ │ └──────────────────────────────────────┘ │ │ │ │ ┌────────────────────────────────────────┐ │ │ │ Unit File: payments-api.service │ │ │ │ ┌──────────┐┌──────────┐┌──────────┐ │ │ │ │ │ [Unit] ││[Service] ││[Install] │ │ │ │ │ │ After= ││ExecStart=││WantedBy= │ │ │ │ │ │ Wants= ││Restart= ││ │ │ │ │ │ └──────────┘└──────────┘└──────────┘ │ │ │ └────────────────────────────────────────┘ │ │ │ │ ┌────────────────────┐ │ │ │ journalctl -u ... │→ Structured logs │ │ └────────────────────┘ │ └─────────────────────────────────────────────┘
💬 Comments
Quick Answer
Linux controls file access with three levels: owner, group, and others. Each gets read (r=4), write (w=2), and execute (x=1) bits. chmod changes permissions, chown changes ownership. Special bits include setuid (run as file owner), setgid (inherit directory group), and sticky bit (only owner can delete).
Detailed Answer
Think of Linux file permissions like security badges in an office building. Each file is a room with three tiers of access: the room owner (user), the department (group), and everyone else (others). Each tier independently controls whether you can read documents (r), modify them (w), or run procedures (x). Special permissions act like master keys: setuid lets anyone run a program as if they were the owner, setgid makes new files inherit the department's group, and the sticky bit stops people from deleting each other's files in shared spaces.
The basic model uses nine bits in three groups of three: user (owner), group, and others. Each group has read (r/4), write (w/2), and execute (x/1). For regular files, read lets you view content, write lets you change it, and execute lets you run it as a program. For directories, the meanings shift slightly: read lets you list contents (ls), write lets you create or delete entries (but you also need execute), and execute lets you enter the directory (cd) and access files inside it. This directory execute bit is often misunderstood. A directory with r-- lets you list filenames but not access anything in them. chmod sets permissions using either octal (chmod 750 file) or symbolic notation (chmod u+rwx,g+rx,o-rwx file). The umask value (usually 022) controls default permissions for new files by masking bits from 666 (files) or 777 (directories).
Inside the filesystem, permissions are stored in the file's inode as a 16-bit mode field. The lower 12 bits hold the permissions: bits 0-8 for the nine rwx positions and bits 9-11 for the special permissions (sticky, setgid, setuid). When a process tries to access a file, the kernel's permission check compares the process's effective user and group IDs against the file's owner and group. If the user ID matches the owner, the owner bits are checked. If not but a group ID matches, the group bits apply. Otherwise, the 'others' bits are used. Root (or more precisely, the CAP_DAC_OVERRIDE capability) bypasses all of this. Access Control Lists (ACLs), managed with setfacl and getfacl, extend the basic model by letting you set permissions for specific individual users and groups.
In production, good permission management is critical for security. Web servers typically need the www-data user to read app files (644) and traverse directories (755) but should never be able to execute uploaded files. Database data directories should be owned by the database user with tight permissions (700). The setuid bit (chmod 4755) is used by system tools like passwd and sudo. When you run a setuid program, your process runs with the file owner's user ID (usually root), giving it elevated privileges by design. The setgid bit (chmod 2755) on directories causes new files created inside to automatically inherit the directory's group ownership, which is essential for shared project folders where multiple team members need access without manually running chgrp. The sticky bit (chmod 1777) on directories like /tmp stops users from deleting or renaming files they do not own, even if they have write permission on the directory.
A dangerous gotcha is that setuid on shell scripts is ignored by the kernel on most modern systems because of historical race condition exploits. Another common mistake is running chmod -R 777 to 'fix' permission issues, which opens everything to the world and creates serious security holes. In Docker containers, files created by root inside the container show up as root-owned on the host, complicating volume management. The fix is using the --user flag or setting up user namespace remapping. Also, moving files across filesystems with mv changes ownership to your user ID because mv does a copy-and-delete across filesystem boundaries, while mv within the same filesystem just updates directory entries and preserves ownership. Regularly audit setuid binaries with find / -perm -4000 -type f to catch unauthorized privilege escalation.
Code Example
# View permissions with ownership details on prod-web-01
ls -la /opt/payments-api/
# Set secure permissions for application directories
chmod 750 /opt/payments-api/bin/
chmod 640 /opt/payments-api/config/db.conf
# Change ownership to the application service account
sudo chown -R payments:payments /opt/payments-api/
# Set permissions using octal: owner=rwx, group=rx, others=none
chmod 750 /opt/payments-api/bin/server
# Set setgid on shared project directory so new files inherit group
chmod 2775 /opt/shared-logs/
# Verify setgid is active (s in group execute position)
ls -ld /opt/shared-logs/
# Set sticky bit on shared tmp directory
chmod 1777 /var/tmp/payments-api/
# Find all setuid binaries on the server (security audit)
sudo find / -perm -4000 -type f -exec ls -la {} \;
# View numeric permissions including special bits
stat -c '%a %U:%G %n' /opt/payments-api/bin/server
# Set ACL to give deploy-bot read access without changing group
setfacl -m u:deploy-bot:rx /opt/payments-api/bin/
# View ACLs on a file
getfacl /opt/payments-api/bin/server
# Set default umask for the payments-api service user
# Add to /etc/profile.d/payments-api.sh:
echo 'umask 027' | sudo tee /etc/profile.d/payments-api.sh
# Fix common Docker volume permission issue
sudo chown -R 1000:1000 /data/payments-api/volumes/Interview Tip
A junior engineer typically recites rwx bits and octal numbers but cannot explain the special permissions or what they mean in practice. Go deeper by explaining how file and directory permissions differ: execute on a directory means traversal, not execution. Cover the three special bits with real examples: setuid for passwd and sudo, setgid on shared directories for automatic group inheritance, and sticky bit on /tmp to prevent cross-user deletion. Touch on security: why chmod 777 is dangerous, why setuid on scripts is ignored by the kernel, and how to audit setuid binaries with find -perm -4000. If you also mention ACLs as the extension model and umask as the default permission mask, you show thorough understanding.
◈ Architecture Diagram
┌─────────────────────────────────────────────┐ │ Linux File Permission Model │ ├─────────────────────────────────────────────┤ │ │ │ Permission Bits (12-bit mode field): │ │ ┌───┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │Spc│ │ Owner │ │ Group │ │ Others │ │ │ │ │ │ r w x │ │ r w x │ │ r w x │ │ │ └─┬─┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │ │ │ │ │ │ │ ↓ ↓ ↓ ↓ │ │ 4 2 1 4 2 1 4 2 1 4 2 1 │ │ s s t r w x r w x r w x │ │ │ │ Example: chmod 4755 = setuid + rwxr-xr-x │ │ │ │ ┌──────────── Special Bits ─────────────┐ │ │ │ │ │ │ │ ┌─────────┐ setuid (4): Run as │ │ │ │ │ 4xxx │ file owner │ │ │ │ │ -rws │ Example: /usr/bin/passwd │ │ │ │ └─────────┘ │ │ │ │ │ │ │ │ ┌─────────┐ setgid (2): Inherit │ │ │ │ │ 2xxx │ group on new files │ │ │ │ │ ---s │ Example: /opt/shared/ │ │ │ │ └─────────┘ │ │ │ │ │ │ │ │ ┌─────────┐ sticky (1): Only owner │ │ │ │ │ 1xxx │ can delete files │ │ │ │ │ ---t │ Example: /tmp/ │ │ │ │ └─────────┘ │ │ │ └───────────────────────────────────────┘ │ │ │ │ ┌────────────────────────────────────────┐ │ │ │ Permission Check Flow: │ │ │ │ Process UID → Match owner? → User rwx │ │ │ │ ↓ no │ │ │ │ Process GID → Match group? → Group rwx│ │ │ │ ↓ no │ │ │ │ Fall through → Others rwx │ │ │ └────────────────────────────────────────┘ │ └─────────────────────────────────────────────┘
💬 Comments
Quick Answer
Namespaces split kernel resources so each container has its own isolated view of PIDs, network, mounts, and user IDs. However, namespaces alone cannot limit resource usage, filter system calls, or stop kernel-level exploits since all containers share the same kernel.
Detailed Answer
Think of a large office building where each company has its own reception desk, phone system, and mailroom. From inside their suite, each tenant thinks they have the whole building. But the structure itself, the walls, plumbing, and electrical systems, is shared. Linux namespaces create these virtual suites: each container gets its own view of system resources, but the kernel (the building's infrastructure) is shared and needs other protections.
Namespaces are the foundation that makes containers possible. Before namespaces, every process on a Linux system shared the same PID space, network stack, mount tree, and user database. Namespaces split these global resources into separate instances. When a container runtime like runc creates a container, it calls clone() or unshare() with flags like CLONE_NEWPID, CLONE_NEWNET, CLONE_NEWNS, and CLONE_NEWUSER. Each flag creates a new isolated instance of that resource type for the child process.
PID namespace gives a container its own process numbering starting from PID 1, so the container's main process cannot see or send signals to host processes. Network namespace creates a separate network stack with its own interfaces, routing tables, firewall rules, and port space; virtual ethernet pairs (veth) bridge the container's network to the host. Mount namespace gives the container its own filesystem view: it sees its root filesystem from the container image and any explicitly mounted volumes, but not the host's filesystem or other containers' files. User namespace maps UIDs inside the container to different UIDs on the host, so UID 0 (root) inside the container can actually be UID 100000 on the host, preventing privilege escalation if a process escapes the container.
At production scale on hosts running 50-100 containers, namespace isolation must be paired with cgroups for resource limits, seccomp for system call filtering, AppArmor or SELinux for mandatory access control, and read-only root filesystems. Monitoring should track namespace counts per host (visible in /proc/sys/user/max_*_namespaces), network namespace interface counts, mount point buildup after container restarts, and user namespace mapping configurations. Kubernetes uses all four major namespace types, and understanding them is key for debugging container networking issues, PID-based health checks, and volume mount permissions.
The non-obvious catch is that namespaces do not protect against kernel-level attacks. Every container shares the host kernel, so a kernel vulnerability (like a privilege escalation bug in a system call handler) can break through all namespace boundaries. User namespaces help prevent simple UID 0 escalation but cannot stop kernel exploits. Some resources are not namespaced at all: kernel modules, most /proc/sys tunable settings, and the system clock (until time namespaces arrived in kernel 5.6) are shared across all containers. A container that loads a kernel module or changes a shared sysctl setting affects every other container on the host.
Code Example
# List all namespaces on a container host, grouped by type
lsns --output NS,TYPE,NPROCS,PID,COMMAND
# Create a new network namespace manually (simulates what container runtimes do)
ip netns add payments-sandbox # Creates an isolated network namespace
# Create a veth pair linking the host to the new namespace
ip link add veth-host type veth peer name veth-container # Creates a virtual ethernet pair
ip link set veth-container netns payments-sandbox # Moves one end into the namespace
# Configure the interface inside the namespace
ip netns exec payments-sandbox ip addr add 10.200.1.2/24 dev veth-container # Assigns an IP inside the namespace
ip netns exec payments-sandbox ip link set veth-container up # Brings the interface up
ip netns exec payments-sandbox ip link set lo up # Brings up loopback inside the namespace
# Configure the host side of the veth pair
ip addr add 10.200.1.1/24 dev veth-host # Assigns host-side IP
ip link set veth-host up # Brings up the host-side interface
# Verify isolation: the namespace has its own routing table
ip netns exec payments-sandbox ip route # Shows routes only visible inside the namespace
# Inspect PID namespace of a running container
NS_PID=$(docker inspect --format '{{.State.Pid}}' checkout-worker) # Gets the container's init PID on the host
ls -la /proc/$NS_PID/ns/ # Lists all namespace file descriptors for the container process
# Output shows: ipc, mnt, net, pid, pid_for_children, user, uts
# Enter a container's namespace for debugging without docker exec
nsenter -t $NS_PID -n -p -m -- ss -tlnp # Enters net, pid, mount namespaces and lists listening ports
# Check user namespace UID mapping for a rootless container
cat /proc/$NS_PID/uid_map # Shows: 0 100000 65536 (container root maps to host UID 100000)
cat /proc/$NS_PID/gid_map # Shows: 0 100000 65536 (container root group maps to host GID 100000)
# Monitor namespace count on the host (alert if approaching kernel limits)
cat /proc/sys/user/max_pid_namespaces # Default: 32768
find /proc/*/ns/pid -maxdepth 0 2>/dev/null | wc -l # Current PID namespace countInterview Tip
A junior engineer typically says namespaces isolate containers and stops there. For a senior or architect role, interviewers want specific mechanics and security gaps. Explain what each namespace type isolates (PID numbering, network stack, filesystem view, UID mapping), how clone() flags create them, how veth pairs bridge network namespaces, and most importantly, what namespaces cannot protect against (kernel exploits, shared sysctls, kernel modules). Strong answers also cover user namespace UID remapping for rootless containers, nsenter for production debugging, and why time namespaces arrived late in kernel 5.6 with the system clock being shared before that.
◈ Architecture Diagram
┌─────────────────────────────┐ │ Host Kernel │ │ ┌───────────┐ ┌─────────┐ │ │ │Container A│ │ContainerB│ │ │ │PID NS 1│ │PID NS 1│ │ │ │Net NS │ │Net NS │ │ │ │Mnt NS │ │Mnt NS │ │ │ │User NS │ │User NS │ │ │ │uid0→100k │ │uid0→200k│ │ │ └─────┬─────┘ └────┬────┘ │ │ │veth │veth │ │ ↓ ↓ │ │ ┌──────────────────┐ │ │ │ Host Net Stack │ │ │ └──────────────────┘ │ └─────────────────────────────┘
💬 Comments
Quick Answer
cgroups v2 uses one unified tree where each process belongs to exactly one group, with consistent resource controllers for memory, CPU, and I/O. memory.max sets hard limits, cpu.weight shares CPU proportionally, and io.max throttles disk bandwidth. The move from v1 fixed problems with inconsistent controller hierarchies, race conditions, and added pressure monitoring (PSI).
Detailed Answer
Think of building utility management. In the old system (cgroups v1), electricity, water, and gas each had separate meters and billing departments. A tenant could be on different plans for each utility with no coordination between them. In the new system (cgroups v2), all utilities go through a single management system with one tenant record, coordinated limits, and the ability to detect when a tenant is struggling with any resource.
The move from v1 to v2 happened because v1 had fundamental design problems. In v1, each resource controller (cpu, memory, blkio, cpuset, etc.) had its own separate hierarchy. A process could be in /cgroup/cpu/payments and /cgroup/memory/checkout at the same time, creating inconsistent groupings. Controllers had different naming conventions: memory used memory.limit_in_bytes while CPU used cpu.shares, with different units and behaviors. Some controllers could not work together because they lived on separate hierarchies.
cgroups v2 enforces a single unified tree at /sys/fs/cgroup. Every process belongs to exactly one group, and all enabled controllers apply at that point. Resource files follow a consistent naming pattern: controller.parameter (like memory.max, cpu.weight, io.max). The memory controller uses memory.max for hard limits (the kernel kills processes if exceeded), memory.high as a slowdown point (the kernel throttles allocations before hitting max), and memory.low for best-effort protection from memory reclaim. The CPU controller uses cpu.weight (1-10000, default 100) for proportional CPU sharing and cpu.max for hard bandwidth limits in the format 'quota period' (for example, '200000 100000' means 200ms of CPU every 100ms period, which equals 2 cores). The IO controller uses io.max with per-device read/write bandwidth and IOPS limits.
At production scale, cgroups v2 introduced Pressure Stall Information (PSI), which reports how much time processes in a group spent waiting for CPU, memory, or I/O. This is a game-changer for monitoring because traditional metrics like CPU utilization and memory usage tell you what a container is using but not whether it is struggling. PSI tells you that a container spent 30% of the last 10 seconds stalled on I/O, which directly indicates resource saturation. Kubernetes uses cgroups v2 for memory quality-of-service (throttling via memory.high before OOM), swap management, and smarter eviction decisions.
The tricky part is migration. cgroups v1 and v2 cannot both control the same resource type at the same time. During the transition, some systems run in a hybrid mode where some controllers are on v1 and others on v2, which brings back the coordination problems v2 was built to solve. Container runtimes, systemd, and orchestrators all need to agree on which version to use. On older distributions, switching to v2 requires a kernel boot parameter (systemd.unified_cgroup_hierarchy=1) and checking that all your tools support v2. A misconfigured hybrid setup can result in containers that appear to have no memory limit because the controller is attached to the wrong hierarchy.
Code Example
# Verify the system is running cgroups v2 (unified hierarchy)
mount | grep cgroup2 # Should show: cgroup2 on /sys/fs/cgroup type cgroup2
stat -fc %T /sys/fs/cgroup/ # Should output: cgroup2fs
# Inspect a container's cgroup resource configuration
# Find the cgroup path for a running container (containerd runtime)
CGROUP_PATH=$(cat /proc/$(docker inspect --format '{{.State.Pid}}' payments-api)/cgroup) # Gets the cgroup path
cat /sys/fs/cgroup${CGROUP_PATH}/memory.max # Hard memory limit in bytes (e.g., 536870912 for 512Mi)
cat /sys/fs/cgroup${CGROUP_PATH}/memory.high # Throttle point before OOM (e.g., 469762048 for 448Mi)
cat /sys/fs/cgroup${CGROUP_PATH}/memory.current # Current memory usage in bytes
cat /sys/fs/cgroup${CGROUP_PATH}/cpu.weight # Proportional CPU weight (default 100)
cat /sys/fs/cgroup${CGROUP_PATH}/cpu.max # CPU bandwidth limit (e.g., "200000 100000" = 2 cores)
# Set IO limits for a container's cgroup (device major:minor format)
# Find the block device major:minor for the data volume
lsblk -o NAME,MAJ:MIN | grep nvme0n1 # Shows: nvme0n1 259:0
echo "259:0 rbps=104857600 wbps=52428800 riops=5000 wiops=2500" > /sys/fs/cgroup${CGROUP_PATH}/io.max
# Limits: 100MB/s read, 50MB/s write, 5000 read IOPS, 2500 write IOPS
# Read Pressure Stall Information (PSI) for container resource saturation
cat /sys/fs/cgroup${CGROUP_PATH}/cpu.pressure # CPU stall time (some/full avg10, avg60, avg300)
cat /sys/fs/cgroup${CGROUP_PATH}/memory.pressure # Memory stall time
cat /sys/fs/cgroup${CGROUP_PATH}/io.pressure # IO stall time
# Output: some avg10=2.50 avg60=1.80 avg300=0.90 total=458230
# Means: 2.5% of the last 10 seconds, at least one task was stalled on this resource
# Enable cgroups v2 on a system still using v1 (kernel boot parameter)
# Add to /etc/default/grub: GRUB_CMDLINE_LINUX="systemd.unified_cgroup_hierarchy=1"
grub2-mkconfig -o /boot/grub2/grub.cfg # Regenerates boot configuration
# Requires reboot to take effect
# Monitor all container cgroups for memory approaching limits
for cg in /sys/fs/cgroup/system.slice/docker-*.scope; do # Iterates container cgroups
current=$(cat $cg/memory.current 2>/dev/null) # Current memory usage
max=$(cat $cg/memory.max 2>/dev/null) # Memory limit
if [ "$max" != "max" ] && [ -n "$current" ]; then # Skips unlimited cgroups
pct=$((current * 100 / max)) # Calculates percentage used
echo "$cg: ${pct}% of limit" # Reports per-container memory pressure
fi
doneInterview Tip
A junior engineer typically says cgroups limit container resources and stops there. For a senior or architect role, interviewers want you to know the v2 architecture and its real-world impact. Explain the unified hierarchy versus v1's separate ones, the three memory knobs (memory.low, memory.high, memory.max) and how each behaves differently, how cpu.weight differs from v1's cpu.shares, and why PSI is a breakthrough for spotting resource saturation versus simple utilization. Strong answers also cover v1-to-v2 migration headaches, hybrid mode pitfalls, and how Kubernetes uses memory.high for quality-of-service throttling before the OOM killer steps in.
◈ Architecture Diagram
┌────────────────────────────┐ │ /sys/fs/cgroup (v2) │ │ ┌────────────────────────┐ │ │ │ payments-api.scope │ │ │ │ ┌──────┐ ┌──────────┐ │ │ │ │ │memory│ │cpu.weight│ │ │ │ │ │.max │ │100 │ │ │ │ │ │512Mi │ │ │ │ │ │ │ └──────┘ └──────────┘ │ │ │ │ ┌──────┐ ┌──────────┐ │ │ │ │ │io.max│ │PSI │ │ │ │ │ │100M/s│ │cpu: 2.5% │ │ │ │ │ └──────┘ └──────────┘ │ │ │ └────────────────────────┘ │ └────────────────────────────┘
💬 Comments
Quick Answer
eBPF lets you load small verified programs into the kernel that attach to tracepoints, function hooks, and network paths. This gives you real-time system call tracing, network flow monitoring, and security enforcement without changing application code or loading risky kernel modules.
Detailed Answer
Think of a building's smart wiring system. Traditional monitoring means opening walls to install new sensors (kernel modules), which risks damage and needs permits. eBPF is like a modular sensor network already built into the walls: you plug in new sensors (eBPF programs) that a safety inspector (the verifier) checks before turning them on. If a sensor causes problems, it gets removed without touching the walls.
eBPF exists because getting visibility and control inside the kernel used to require either kernel modules (risky, complex, tied to specific kernel versions) or user-space tools reading /proc and /sys (limited and expensive for frequent sampling). eBPF provides a safe, fast middle ground: programs run inside the kernel at native speed but are checked for safety before they load. This enables capabilities that previously required custom kernel development.
Here is how it works internally. An eBPF program is written in C (or Rust via the Aya framework) and compiled to eBPF bytecode. This bytecode is loaded into the kernel through the bpf() system call. The kernel's verifier then does a static analysis to make sure the program will terminate (no infinite loops), will not access invalid memory, stays within the instruction limit (currently about 1 million verified instructions), and only calls approved helper functions. Once verified, the program is compiled to native machine code via JIT and attached to a hook point. Hook points include kprobes (attach to any kernel function), tracepoints (stable pre-defined instrumentation points), XDP (process network packets before the full network stack touches them), and cgroup hooks (resource and access control). eBPF maps provide shared data structures like hash maps, arrays, and ring buffers for passing data between eBPF programs and user-space applications.
At production scale, eBPF powers widely-used tools: bpftrace for ad-hoc tracing, bcc tools for performance analysis, Cilium for container networking, Falco for runtime security detection, and Pixie for application observability. For system call tracing, eBPF programs attach to sys_enter and sys_exit tracepoints to capture every system call with its arguments and return values, replacing strace without the heavy ptrace overhead. For network monitoring, XDP programs process packets at the driver level before the kernel even allocates memory for them, enabling wire-speed packet filtering and DDoS protection. For security, eBPF programs on LSM (Linux Security Module) hooks can block file access, network connections, or privilege changes based on custom rules.
The biggest gotcha is the kernel version dependency problem. eBPF features are added gradually across kernel versions: basic map types need kernel 4.x, BTF (BPF Type Format) for portable programs needs 5.2+, ring buffer maps need 5.8+, and LSM hooks need 5.7+. A program that works on kernel 5.15 might fail to verify on kernel 5.4 because a required helper function does not exist yet. Production environments with mixed kernel versions across nodes create a compatibility headache. Also, eBPF programs on hot paths (like every network packet in XDP or every system call entry) add nanoseconds of latency per run. This is negligible for most workloads but measurable for ultra-low-latency systems processing millions of events per second.
Code Example
# Trace all file open syscalls on a container host to detect unauthorized access
bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s PID:%d %s\n", comm, pid, str(args->filename)); }'
# Outputs: payments-api PID:4521 /etc/payments/config.yaml
# Count syscalls by type for the checkout-worker process (performance profiling)
bpftrace -e 'tracepoint:syscalls:sys_enter_* /comm == "checkout-work"/ { @[probe] = count(); }' -c 'sleep 10'
# Shows which syscalls are most frequent: read, write, futex, epoll_wait
# Monitor TCP connections by destination for network dependency mapping
bpftrace -e 'kprobe:tcp_connect { $sk = (struct sock *)arg0; printf("PID:%d %s -> %s:%d\n", pid, comm, ntop($sk->__sk_common.skc_daddr), $sk->__sk_common.skc_dport); }'
# Output: PID:8832 payments-api -> 10.0.5.12:5432 (database connection)
# Use bcc tool to trace DNS queries from containers
/usr/share/bcc/tools/tcptracer -t # Traces TCP connect, accept, close events with timestamps
# Monitor block IO latency per cgroup (identifies slow container disk access)
bpftrace -e 'kprobe:blk_account_io_start { @start[arg0] = nsecs; } kprobe:blk_account_io_done /@start[arg0]/ { @usecs = hist((nsecs - @start[arg0]) / 1000); delete(@start[arg0]); }'
# XDP program to drop packets from a known bad CIDR (DDoS mitigation)
# Compiled from C, loaded with ip link
ip link set dev eth0 xdp obj xdp_drop_bad_cidr.o sec xdp_prog # Attaches XDP program to eth0
ip link show dev eth0 # Verifies XDP program is attached (shows xdp/id:42)
# Check loaded eBPF programs and their attachment points
bpftool prog list # Lists all loaded eBPF programs with ID, type, and attachment
bpftool map list # Lists all eBPF maps with type, key/value size, and max entries
# Monitor eBPF program execution statistics (detect performance impact)
bpftool prog profile id 42 duration 10 # Profiles program 42 for 10 seconds
# Shows: run_count, run_time_ns, avg_ns per invocation
# Security: use eBPF-based Falco rule to detect container escape attempts
# Falco rule (YAML) detecting unexpected mount namespace changes
# - rule: Detect Mount Namespace Change
# desc: A process changed its mount namespace (potential container escape)
# condition: evt.type = setns and evt.arg.nstype = mnt
# output: "Mount namespace change (user=%user.name command=%proc.cmdline container=%container.name)"
# priority: CRITICAL
# tags: [container, escape]Interview Tip
A junior engineer typically says eBPF is used in Cilium for networking and stops there. For a senior or architect role, interviewers want to hear about the kernel execution model and the full range of uses. Explain how the verifier ensures safety, how JIT compilation gets near-native speed, the difference between kprobes (dynamic, any function) and tracepoints (stable, pre-defined), and where eBPF programs can attach (XDP, cgroup, LSM, syscalls). Strong answers cover the kernel version compatibility problem, BTF for portability, the latency cost on hot paths, and real production examples like bpftrace for debugging, Falco for security alerts, and XDP for packet-level DDoS mitigation.
◈ Architecture Diagram
┌──────────────────────────┐ │ User Space │ │ ┌────────┐ ┌──────────┐ │ │ │bpftrace│ │ Falco │ │ │ └────┬───┘ └────┬─────┘ │ │ ↓ ↓ │ │ ┌────────────────────┐ │ │ │ bpf() syscall │ │ │ └────────┬───────────┘ │ └──────────┼───────────────┘ ┌──────────┼───────────────┐ │ Kernel ↓ │ │ ┌──────────┐ │ │ │ Verifier │→ JIT │ │ └──────────┘ │ │ ┌────────┐ ┌──────────┐ │ │ │kprobe │ │tracepoint│ │ │ │hooks │ │hooks │ │ │ └────────┘ └──────────┘ │ │ ┌────────┐ ┌──────────┐ │ │ │XDP pkt │ │LSM sec │ │ │ └────────┘ └──────────┘ │ └──────────────────────────┘
💬 Comments
Quick Answer
Tune the network stack with sysctl (somaxconn, tcp backlog, file descriptor limits). Use huge pages to reduce TLB misses for memory-heavy apps. Bind workloads to NUMA-local CPUs and memory to avoid cross-socket latency. Choose the right CPU scheduler policy based on your workload type.
Detailed Answer
Think of tuning a race car. Factory settings are fine for city driving, but at 200 mph every detail matters: tire pressure, gear ratios, suspension, fuel mixture. Linux kernel defaults are safe for general use, not for maximum performance. High-performance workloads like payment processing, real-time bidding, or database containers need specific kernel adjustments matched to how they consume resources.
Kernel tuning for containers covers four areas: network stack, memory management, CPU scheduling, and I/O. Network-heavy services like API gateways need higher net.core.somaxconn (the listen backlog queue, default 4096 in modern kernels but often not enough during traffic bursts), net.ipv4.tcp_max_syn_backlog (queue for half-open connections), and net.core.netdev_max_backlog (packet queue before the kernel processes them). File descriptor limits (fs.file-max and per-process RLIMIT_NOFILE) must handle containers with thousands of concurrent connections. For Kubernetes hosts, net.ipv4.ip_local_port_range should be widened to 1024-65535 to prevent running out of ephemeral ports.
Huge pages reduce TLB (Translation Lookaside Buffer) misses for memory-heavy workloads. The TLB is a small cache that stores recent virtual-to-physical address translations. With standard 4KB pages, a 64GB database process needs 16 million page table entries. With 2MB huge pages, that drops to 32,768, dramatically reducing TLB pressure. Transparent Huge Pages (THP) try to merge small pages automatically but can cause latency spikes during defragmentation. Database workloads (PostgreSQL, Redis, Elasticsearch) often run better with THP turned off and explicit huge pages allocated via vm.nr_hugepages. In Kubernetes, huge pages are a first-class resource type (hugepages-2Mi, hugepages-1Gi) that pods can request.
NUMA (Non-Uniform Memory Access) awareness is crucial on multi-socket servers. A process running on CPU socket 0 that reads memory attached to socket 1 pays 1.5-2x higher latency. Container runtimes and Kubernetes support CPU pinning through cpuset cgroups and NUMA-aware memory allocation via the Topology Manager. The single-numa-node policy ensures all resources assigned to a pod (CPU, memory, devices) come from the same NUMA node. The CPU Manager's static policy gives exclusive CPU cores to high-priority pods, preventing the scheduler from bouncing processes between cores and trashing CPU caches.
The non-obvious trap is that kernel tuning is not one-size-fits-all. A sysctl value optimized for a 10Gbps network-heavy API gateway on a 96-core server can hurt performance on a 4-core VM running batch jobs. THP improves throughput for large sequential memory allocations but increases tail latency for latency-sensitive services because of compaction stalls. NUMA binding improves steady-state performance but reduces flexibility during load spikes, potentially leaving cores idle on one socket while containers queue up on another. The right approach is to profile first using tools like perf, mpstat, and numastat, then tune based on evidence rather than copying configuration snippets from blog posts.
Code Example
# Network stack tuning for high-connection API gateway containers sysctl -w net.core.somaxconn=32768 # Increases listen backlog for services handling burst connections sysctl -w net.ipv4.tcp_max_syn_backlog=16384 # Larger SYN queue prevents drops during connection storms sysctl -w net.core.netdev_max_backlog=16384 # Kernel packet processing queue before socket delivery sysctl -w net.ipv4.ip_local_port_range="1024 65535" # Widens ephemeral port range for outbound connections sysctl -w net.ipv4.tcp_tw_reuse=1 # Allows reuse of TIME_WAIT sockets for new connections sysctl -w fs.file-max=2097152 # System-wide file descriptor limit for container-dense hosts sysctl -w net.core.rmem_max=16777216 # Maximum receive socket buffer size (16MB) sysctl -w net.core.wmem_max=16777216 # Maximum send socket buffer size (16MB) # Persist sysctls across reboots cat >> /etc/sysctl.d/99-container-performance.conf << 'SYSCTL' net.core.somaxconn = 32768 net.ipv4.tcp_max_syn_backlog = 16384 net.ipv4.ip_local_port_range = 1024 65535 vm.max_map_count = 262144 SYSCTL sysctl --system # Reloads all sysctl configuration files # Huge pages: allocate 4GB of 2MB huge pages for database containers sysctl -w vm.nr_hugepages=2048 # Allocates 2048 x 2MB = 4GB huge pages cat /proc/meminfo | grep HugePages # Verify: HugePages_Total: 2048, HugePages_Free: 2048 # Disable Transparent Huge Pages for latency-sensitive workloads echo never > /sys/kernel/mm/transparent_hugepage/enabled # Disables THP allocation echo never > /sys/kernel/mm/transparent_hugepage/defrag # Disables THP defragmentation # NUMA topology inspection before binding workloads numactl --hardware # Shows NUMA node count, CPU-to-node mapping, and memory per node numastat -c # Shows NUMA hit/miss/foreign statistics per node lscpu | grep -E 'NUMA|Socket|Core' # Shows socket count, cores per socket, NUMA topology # Run a database container bound to NUMA node 0 for memory locality numactl --cpunodebind=0 --membind=0 docker run -d \ --name payments-db \ --cpuset-cpus="0-11" \ --memory=32g \ registry.company.com/postgres:16.3 # Kubernetes: Request huge pages and CPU pinning for a latency-sensitive pod # resources: # requests: # cpu: "4" # Requests 4 exclusive CPU cores (static CPU manager) # memory: "8Gi" # Standard memory request # hugepages-2Mi: "1Gi" # Requests 1GB of 2MB huge pages # limits: # cpu: "4" # Must match request for Guaranteed QoS # memory: "8Gi" # Must match request for Guaranteed QoS # hugepages-2Mi: "1Gi" # Must match request for huge pages # Profile CPU scheduling behavior to identify migration and cache thrashing perf stat -e context-switches,cpu-migrations,cache-misses -p $(pgrep payments-api) sleep 10
Interview Tip
A junior engineer typically rattles off a list of sysctl values to copy, but for a senior or architect role, interviewers want to know why each setting matters and when it should be left alone. Explain the relationship between somaxconn and the listen backlog queue, why THP helps throughput but hurts tail latency, how NUMA binding trades scheduler flexibility for memory locality, and why tuning without profiling first (using perf, numastat, mpstat) is cargo-cult engineering. Strong answers include Kubernetes-specific features like CPU Manager static policy, Topology Manager single-numa-node policy, and huge pages as first-class resources, plus the important warning that optimal values depend on your specific hardware and workload.
◈ Architecture Diagram
┌──────────────────────────────┐ │ Kernel Tuning Layers │ │ │ │ ┌──────────┐ ┌────────────┐ │ │ │ Network │ │ Memory │ │ │ │somaxconn │ │ HugePages │ │ │ │tcp_backl │ │ THP on/off │ │ │ └──────────┘ └────────────┘ │ │ │ │ ┌──────────┐ ┌────────────┐ │ │ │ CPU │ │ IO │ │ │ │ NUMA pin │ │ scheduler │ │ │ │ cpuset │ │ io.max │ │ │ └──────────┘ └────────────┘ │ │ │ │ ┌────────────────────────┐ │ │ │ Profile First (perf) │ │ │ └────────────────────────┘ │ └──────────────────────────────┘
💬 Comments
Quick Answer
Follow a top-down approach: dmesg for kernel and hardware events, /proc for process and system state, perf for CPU profiling, and strace for system call tracing. This layered method isolates whether a problem is in the application, kernel, or hardware.
Detailed Answer
Think of diagnosing a car problem. A good mechanic does not start by taking apart the engine. They check the dashboard lights first (dmesg), then read the diagnostic codes (/proc), listen to the engine under load (perf), and only then hook up a scope to a specific sensor (strace). Each tool works at a different level of detail, and using the wrong one first wastes time. The same discipline applies to container host troubleshooting: start broad, then narrow down.
Having a methodology matters because container problems can come from many layers that normal application debugging ignores. A slow payments-api container might be caused by bad application code, a noisy neighbor container hogging shared CPU, a kernel memory reclaim storm, a disk controller running in degraded mode, or a network driver bug. Without a plan, teams chase symptoms: they restart the container (which hides the problem), scale horizontally (which wastes money), or blame the network (the default scapegoat).
The diagnostic flow starts with dmesg and journalctl for kernel ring buffer messages. OOM kills, hardware errors, filesystem corruption, and driver problems show up here first. Run dmesg -T --level=err,warn for timestamped kernel errors and warnings. Next, /proc gives you live process and system state: /proc/[pid]/status shows memory and state, /proc/[pid]/fd shows open file descriptors, /proc/[pid]/stack shows the kernel stack trace (crucial for finding processes stuck waiting on I/O or locks), /proc/meminfo shows system-wide memory details, and /proc/pressure shows PSI (Pressure Stall Information) for CPU, memory, and I/O across the whole system. For container-specific investigation, cgroup files under /sys/fs/cgroup show per-container resource usage, limits, and pressure. Then perf analyzes CPU behavior: perf top shows live CPU hotspots, perf stat counts hardware events (cache misses, context switches, CPU migrations), and perf record/report generates CPU profiles you can turn into flame graphs. Finally, strace traces individual system calls: strace -c shows call counts and time breakdown, strace -T shows per-call latency, and strace -e trace=network filters to just network calls.
At production scale, the critical discipline is correlating timestamps across tools. A container that slows down at 14:32 might correlate with a dmesg OOM kill of a different container at 14:31 (which triggered kernel memory reclaim), a spike in /proc/pressure/memory at 14:31, and increased cache misses in perf starting at 14:32 as surviving containers fight for memory. Without timestamp correlation, each tool tells an incomplete story.
The big gotcha is that strace uses ptrace and adds massive overhead to the traced process (10-100x slowdown for system-call-heavy apps). Running strace on a production container can turn a latency problem into a full outage. For production systems, prefer eBPF-based tracing (bpftrace, bcc tools) which adds nanoseconds of overhead instead of milliseconds. Save strace for development, staging, or containers that are already broken where the extra overhead does not matter. Similarly, perf record with high-frequency sampling can itself cause CPU contention on busy hosts. Use 99 Hz or 999 Hz sampling, not the default, on production systems.
Code Example
# Step 1: Check kernel messages for hardware/driver/OOM issues
dmesg -T --level=err,warn | tail -50 # Shows recent kernel errors with human-readable timestamps
dmesg -T | grep -i 'oom\|killed\|error\|fail\|hardware' | tail -20 # Filters for critical events
journalctl -k --since '1 hour ago' --priority=warning # Kernel journal for the last hour
# Step 2: Check system-wide resource pressure (PSI)
cat /proc/pressure/cpu # CPU stall information across all cgroups
cat /proc/pressure/memory # Memory stall: if avg10 > 10, system is memory-constrained
cat /proc/pressure/io # IO stall: if avg10 > 20, storage is saturated
# Step 3: Identify the problem container using cgroup metrics
for cg in /sys/fs/cgroup/system.slice/docker-*.scope; do # Iterates all container cgroups
name=$(basename $cg) # Container cgroup name
mem_current=$(cat $cg/memory.current 2>/dev/null) # Current memory in bytes
cpu_pressure=$(cat $cg/cpu.pressure 2>/dev/null | head -1) # CPU pressure
echo "$name mem=$(( mem_current / 1048576 ))MB $cpu_pressure" # Reports per-container state
done
# Step 4: Deep-dive into a specific container process
CONTAINER_PID=$(docker inspect --format '{{.State.Pid}}' payments-api) # Gets container init PID
cat /proc/$CONTAINER_PID/status | grep -E 'VmRSS|VmSize|Threads|State' # Memory and thread state
ls /proc/$CONTAINER_PID/fd | wc -l # Count open file descriptors (high count = possible leak)
cat /proc/$CONTAINER_PID/stack # Kernel stack trace (shows if process is stuck in a syscall)
cat /proc/$CONTAINER_PID/io # Process IO counters: read_bytes, write_bytes, cancelled_write_bytes
# Step 5: CPU profiling with perf (low-overhead production safe)
perf stat -e cycles,instructions,cache-misses,context-switches,cpu-migrations \
-p $CONTAINER_PID sleep 10 # Counts hardware events for 10 seconds
perf record -F 99 -p $CONTAINER_PID -g -- sleep 30 # Records CPU profile at 99Hz for 30 seconds
perf report --stdio --sort=dso,symbol | head -40 # Shows top CPU consumers by library and function
# Step 6: Syscall analysis with strace (use in staging, or on already-failing production pods)
nsenter -t $CONTAINER_PID -p -n -m -- strace -c -p 1 -f 2>&1 | head -30 # Syscall summary inside container
nsenter -t $CONTAINER_PID -p -n -m -- strace -T -e trace=network -p 1 2>&1 | head -20 # Network syscall latency
# Step 7: Network diagnostics from the container's network namespace
nsenter -t $CONTAINER_PID -n -- ss -tlnp # Listening TCP sockets inside the container
nsenter -t $CONTAINER_PID -n -- ss -s # Socket statistics: established, closed, timewait counts
nsenter -t $CONTAINER_PID -n -- cat /proc/net/sockstat # Socket memory usage by protocol
# Step 8: Disk IO analysis for the container's block device
iostat -xz 1 3 # Per-device IO stats: await (latency), %util (saturation), r/s, w/s
cat /sys/fs/cgroup/system.slice/docker-${CONTAINER_ID}.scope/io.stat # Per-device IO counters for the containerInterview Tip
A junior engineer typically jumps to application logs or restarts the container. For a senior or architect role, interviewers look for a layered diagnostic method. Explain the top-down approach: dmesg for kernel and hardware, /proc and cgroup files for process and resource state, perf for CPU and scheduling analysis, and strace for system call root cause. A strong answer emphasizes correlating timestamps across tools, explains why strace's ptrace overhead makes it unsuitable for most production use (prefer eBPF alternatives), covers /proc/[pid]/stack for finding processes stuck in the kernel, and describes how PSI metrics tell you the difference between resource usage and resource saturation. Bonus points for mentioning nsenter to enter container namespaces without docker exec.
◈ Architecture Diagram
┌──────────────────────────┐ │ Troubleshooting Flow │ │ │ │ 1. ┌──────────┐ │ │ │ dmesg │ Kernel │ │ └────┬─────┘ │ │ ↓ │ │ 2. ┌──────────┐ │ │ │ /proc │ Process │ │ │ cgroups │ State │ │ └────┬─────┘ │ │ ↓ │ │ 3. ┌──────────┐ │ │ │ perf │ CPU │ │ └────┬─────┘ │ │ ↓ │ │ 4. ┌──────────┐ │ │ │ strace │ Syscall │ │ │ bpftrace │ │ │ └──────────┘ │ └──────────────────────────┘
💬 Comments
Quick Answer
High load average with low CPU indicates I/O wait or uninterruptible sleep (D state) processes. Use iostat, vmstat, and ps to identify processes blocked on disk I/O, NFS mounts, or kernel locks.
Detailed Answer
Linux load average counts ALL processes in runnable (R) OR uninterruptible sleep (D) state. Unlike other OSes, Linux includes D-state (I/O waiting) processes in load average.
1. Check I/O wait: vmstat 1 5 — look at wa (I/O wait %) column 2. Identify D-state processes: ps aux | awk '$8 ~ /D/' — shows processes in uninterruptible sleep 3. Check disk I/O: iostat -xz 1 — look at %util, await, r/s, w/s 4. Check for NFS hangs: mount | grep nfs then nfsstat -c — stale NFS mounts cause D-state processes 5. Check memory pressure: vmstat 1 — high si/so (swap in/out) indicates swapping, which causes I/O wait
- Disk I/O saturation: Too many reads/writes for the disk to handle (check %util in iostat) - NFS mount stale/hung: Network file system timeout causes processes to enter D state indefinitely - Swap thrashing: Not enough RAM, constant page swapping to disk - Filesystem journaling: ext4/XFS journal commits blocking writes during heavy I/O - RAID rebuild: Background RAID reconstruction consuming all disk bandwidth - Kernel lock contention: BKL or filesystem lock contention (rare in modern kernels)
- Identify the bottleneck device and the processes causing I/O - For disk: add IOPS (upgrade to SSD/NVMe, increase EBS IOPS) - For NFS: fix the mount, add soft mount option, or migrate to local storage - For swap: add RAM or tune vm.swappiness - For kernel: check dmesg for blocked task warnings
Code Example
# Check load vs CPU usage
uptime
top -bn1 | head -5
# Check I/O wait
vmstat 1 5
# Look at columns: r (runnable), b (blocked), wa (I/O wait)
# Find D-state processes
ps aux | awk '$8 ~ /D/ {print}'
# Check disk I/O per device
iostat -xz 1 3
# High %util + high await = disk saturated
# Check what processes are doing I/O
iotop -oP
# Check for stale NFS mounts
mount -t nfs
df -h # Will hang if NFS is stale
# Check kernel messages for blocked tasks
dmesg | grep -i 'blocked for more than'Interview Tip
This is the classic Google SRE question. The key insight is that Linux load average includes D-state (I/O waiting) processes, unlike other OSes. Always mention vmstat's 'b' column and iostat's '%util' as your first diagnostic steps.
💬 Comments
Quick Answer
Packet arrives at host NIC → kernel network stack (iptables PREROUTING) → DNAT to container IP → bridge (docker0) → veth pair → container network namespace → application socket.
Detailed Answer
1. NIC receives frame: Hardware NIC receives Ethernet frame, DMA copies to ring buffer in kernel memory, raises IRQ
2. Kernel softirq processing: NAPI polls the ring buffer, creates sk_buff structure, passes up the stack
3. IP layer: Kernel checks destination IP. If it's the host IP with a mapped port, continues processing.
4. Netfilter/iptables PREROUTING: Docker's DNAT rule matches: `` -A DOCKER -p tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80 ` Packet destination is rewritten from host_ip:8080 to container_ip:80`
5. Routing decision: Kernel routing table sees destination 172.17.0.0/16 → route via docker0 bridge
6. Bridge forwarding: docker0 Linux bridge looks up MAC address for 172.17.0.2 in its FDB (forwarding database), forwards frame to the correct veth interface
7. veth pair: Packet enters vethXXX (host side), appears on eth0 (container side). veth pairs act as a virtual ethernet cable between namespaces.
8. Container network namespace: Packet arrives in the container's isolated network namespace with its own routing table, IP address, and iptables rules
9. TCP/IP processing in container: Kernel (shared kernel, different namespace) delivers to the listening socket
10. Application reads from socket: accept() → read() → application processes the HTTP request
Return path: Response goes back through the same chain in reverse, with SNAT (MASQUERADE) rewriting the source address on outbound.
- Network namespaces: Isolated network stacks (interfaces, routes, iptables) - veth pairs: Virtual ethernet pipe connecting two namespaces - Linux bridge: Software L2 switch - Conntrack: Tracks connection state for NAT translation of return packets
Code Example
# Trace the path yourself:
# 1. See the bridge and veth interfaces
ip link show type bridge
brctl show docker0
# 2. See iptables NAT rules Docker creates
iptables -t nat -L DOCKER -n -v
# 3. See routing table
ip route show
# 4. Find which veth connects to which container
NS_PID=$(docker inspect --format '{{.State.Pid}}' <container>)
nsenter -t $NS_PID -n ip link show eth0
# The ifindex peer tells you which veth on the host
# 5. Capture packets at each hop
tcpdump -i eth0 port 8080 -nn # Host NIC
tcpdump -i docker0 port 80 -nn # Bridge
tcpdump -i vethXXX port 80 -nn # veth to containerInterview Tip
Walk through each layer methodically — NIC → kernel → iptables → bridge → veth → namespace → socket. This is the quintessential 'do you actually understand networking or just use abstractions' question at Google and Meta.
💬 Comments
Quick Answer
Look at `container_cpu_cfs_throttled_periods_total` / `nr_throttled` in the cgroup's cpu.stat — the CFS bandwidth controller enforces CPU limits over fixed 100ms periods, so a bursty process can exhaust its quota within a single period and get throttled even when the node has plenty of idle CPU overall. Fix by raising the limit, increasing `cpu.cfs_period_us`, or removing the limit and relying on requests + node-level capacity instead.
Detailed Answer
This is a direct consequence of how the Completely Fair Scheduler's (CFS) bandwidth controller enforces Kubernetes CPU limits via cgroups. A container with resources.limits.cpu: 2 gets a cgroup quota of 200000us per 100000us (100ms) period. If the process is single-threaded-bursty or has a thread pool that spikes briefly, it can consume its entire 200ms-equivalent of CPU time within the first few milliseconds of a 100ms period and then gets throttled for the remainder of that period — even though, averaged over a full second, it never exceeded its nominal limit, and even though the node as a whole is mostly idle. Multi-threaded workloads with limits set just above their typical-but-bursty thread count are especially prone to this because all threads draw from the same shared quota.
Diagnosis: every cgroup exposes cpu.stat with nr_periods, nr_throttled, and throttled_time. A high ratio of nr_throttled/nr_periods combined with low overall node CPU usage is the signature. cAdvisor/kubelet expose this as the Prometheus metric container_cpu_cfs_throttled_periods_total — graphing throttled-periods-ratio per container is the standard way to catch this in production before users report latency spikes.
Fixes: (1) raise the CPU limit so the quota window has more headroom for bursts, (2) remove CPU limits entirely for latency-sensitive workloads and rely on requests for scheduling plus node-level isolation — this is what many SRE teams do for latency-critical services, accepting some risk of noisy-neighbor in exchange for not falsely throttling, (3) for newer kernels, the kernel's CFS quota period-handling has improved (burst feature, cpu.cfs_burst_us in cgroup v2) which allows accumulating unused quota to absorb short bursts.
Code Example
# Find the cgroup for a container and check throttling stats directly cat /sys/fs/cgroup/kubepods/.../cpu.stat # nr_periods 38291 # nr_throttled 9120 # throttled_time 184213999122 # Prometheus query: throttling ratio per pod rate(container_cpu_cfs_throttled_periods_total[5m]) / rate(container_cpu_cfs_periods_total[5m]) # cgroup v2 burst (absorb short spikes without throttling) echo 200000 > /sys/fs/cgroup/kubepods/.../cpu.max.burst
Interview Tip
The key insight interviewers want is the period-quota mismatch: a process can be throttled within a 100ms window while looking idle on a 1-second or node-wide average. Naming `cpu.stat`'s `nr_throttled` and the `container_cpu_cfs_throttled_periods_total` metric, rather than just saying 'check CPU usage,' is what separates a surface-level answer from one that shows you've actually chased this in production.
💬 Comments
Quick Answer
Deleting a file with rm only removes its directory entry (its name), but the actual disk blocks aren't freed by the kernel until every process holding an open file handle to that inode closes it — if a running process (commonly the application or a logging daemon) still has that 20GB file open for writing, the space stays allocated and invisible, showing as used by df but no longer appearing in any directory listing (a 'deleted but held open' file). You free the space without rebooting by finding the process still holding the deleted file open with lsof, and either restarting that process cleanly or truncating the still-open file descriptor directly via /proc/<pid>/fd/<fd>.
Detailed Answer
Think of a library where every book has both a card in the catalog and a spot on the shelf, but the two are managed separately. If a librarian rips the card out of the catalog while a patron is still sitting at a table reading that exact book, the book doesn't magically vanish or come back to the shelf — it's still sitting there, still taking up space in the room, right up until the patron finishes and gets up. Anyone checking the catalog would see no such book listed at all, yet the room is just as full as before, which looks like a contradiction until you realize the catalog and the physical space are two different bookkeeping systems.
Linux filesystems were designed with exactly this separation: an inode represents the actual data blocks on disk and tracks a link count (how many directory entries point to it) and, separately, the kernel tracks how many open file descriptors currently reference that inode across all running processes. rm only ever removes a directory entry, decrementing the link count by one — it does not touch the underlying data blocks directly at all. The kernel's rule is that data blocks are only actually reclaimed when both conditions are true simultaneously: the link count drops to zero AND no process has an open file descriptor referencing that inode. A process that opened the file before you ran rm keeps holding a valid file descriptor to that inode regardless of whether the file still has a name anywhere in the filesystem, so as far as that process (and the kernel's block allocator) is concerned, the file is very much still alive and still consuming its full 20GB.
Internally, this is precisely why an application's logging framework that opens a log file once at startup and keeps writing to that same file descriptor indefinitely creates exactly this trap: an admin (or a naive logrotate configuration without the copytruncate option or without sending the application a reload signal) deletes or rotates the file at the filesystem level, the application keeps writing into what is now an invisible, nameless inode, and df shows no improvement because from the block allocator's perspective, absolutely nothing has changed — the exact same amount of data is still allocated, just no longer reachable by any path a human can type.
At production scale, this is one of the most common on-call disk-full pages precisely because the standard first instinct — 'find the biggest file, delete it' — works perfectly for files nobody has open, and fails silently (no error, just no space freed) for files something does have open, which is disproportionately likely for exactly the largest log files on a busy server, since large log files are large because something is actively, continuously writing to them. The correct diagnostic is lsof +L1 (list open files with a link count of 1 or less, meaning deleted-but-open) or lsof | grep deleted, which surfaces exactly which process ID is holding the phantom file and how large it still is.
The non-obvious gotcha and the actual production-safe fix without a reboot: you do not need to kill or restart the process to reclaim the space immediately if you can instead truncate the file descriptor directly — since /proc/<pid>/fd/<fd> on Linux is a symlink to the actual open file, running something like : > /proc/12345/fd/7 (or cat /dev/null > /proc/12345/fd/7) truncates the underlying data to zero bytes while the process's file descriptor remains valid and open, meaning the application keeps writing new log data from that point forward without ever needing to be restarted or losing its handle — disk space is reclaimed instantly, with zero application downtime, which is why this specific technique is the preferred production fix over restarting a live service just to release disk space.
Code Example
# Step 1: confirm disk is still reported full despite the deletion
df -h /var/log
# Step 2: find processes holding deleted-but-still-open files (the actual culprit)
lsof +L1 | grep deleted
# Output shows: nginx 12345 root 7w REG 8,1 21474836480 (deleted)
# Step 3: confirm the exact size still held open by that file descriptor
ls -la /proc/12345/fd/7
# Step 4 (production-safe, zero downtime): truncate the open fd directly, no restart needed
: > /proc/12345/fd/7
# Step 5: verify space was actually reclaimed
df -h /var/log
# Prevention: configure logrotate with copytruncate so it never orphans an open fd
# /etc/logrotate.d/nginx
/var/log/nginx/*.log {
daily
copytruncate # truncates in place instead of rename+delete, avoids this entirely
rotate 7
compress
}Interview Tip
A junior engineer typically assumes rm -rf on a large file always frees space immediately, and gets stuck when df doesn't budge. For a senior or architect role, the interviewer wants you to explain the actual kernel mechanics — inode link count versus open file descriptor count — and to know the specific diagnostic (lsof +L1 or lsof | grep deleted) that finds the culprit process without guessing. The strongest answers go one step further and mention truncating the file directly via /proc/<pid>/fd/<fd> as the zero-downtime fix, rather than defaulting to 'restart the service,' since a service restart is real production risk (dropped connections, cold caches, cold JIT) for a problem that can be solved without any interruption at all. Mentioning logrotate's copytruncate option as the actual prevention shows you've operated this in a real fleet, not just diagnosed it once.
◈ Architecture Diagram
rm deletes DIRECTORY ENTRY only:
┌──────────┐ ┌───────────┐
│ filename │───X────│ inode │ ← link count: 0
└──────────┘ │ (20GB data)│ ← BUT still open by PID 12345
└───────────┘
df still shows FULL (blocks still allocated)
Fix: : > /proc/12345/fd/7 (truncate live fd, no restart)💬 Comments
Quick Answer
With CPU and memory ruled out, the next suspects are kernel-level networking resource limits that don't show up on standard resource dashboards: ephemeral port exhaustion (all local ports in TIME_WAIT from a high connection churn rate), the conntrack table filling up on a server doing NAT or running iptables/nftables rules, and the per-process or system-wide open file descriptor limit (ulimit -n), since every TCP socket consumes a file descriptor and Linux treats socket exhaustion as a resource error independent of CPU or RAM. Checking dmesg for kernel-logged network errors and ss -s for a summary of socket states across the system is the fastest way to identify which of these is actually happening.
Detailed Answer
Picture a busy restaurant that has plenty of empty tables (CPU) and plenty of food in the kitchen (memory), yet the host at the front still turns customers away — because the restaurant has exactly 50 phone lines for reservations, and all 50 are currently tied up with callers who've already hung up but whose lines haven't been released by the phone system yet. The restaurant looks completely idle by every visible measure, yet it's structurally unable to take a single new reservation until some of those stuck lines free up. Network connection failures with idle CPU and memory are almost always this exact shape of problem: a fixed-size resource that has nothing to do with compute capacity has quietly filled up.
Linux was designed with several networking-specific resource ceilings that exist independently of general compute resources, precisely because TCP connection handling has its own lifecycle and bookkeeping that CPU/memory monitoring was never built to surface. The most common one in production is ephemeral port exhaustion: every outbound TCP connection a server initiates (say, an application server making many short-lived calls to a database or an external API) consumes one ephemeral port from a finite range (typically 32768-60999 by default), and a closed connection doesn't immediately free its port — it lingers in the TIME_WAIT state for a configurable duration (default 60 seconds on Linux) specifically to handle any delayed packets from the old connection safely. Under high enough connection churn (many short connections opened and closed per second), a server can generate TIME_WAIT sockets faster than they expire, eventually exhausting the entire ephemeral port range, at which point new outbound connections fail immediately with 'Cannot assign requested address' or a generic connection error, despite the server otherwise being completely idle.
Internally, a second common cause on any server doing NAT, running iptables/nftables with stateful rules, or behind certain load balancer configurations is conntrack table exhaustion: the kernel's connection tracking subsystem maintains an entry for every tracked connection up to net.netfilter.nf_conntrack_max, and once that table fills, the kernel starts dropping new connection attempts at the netfilter layer before they ever reach the application, logging 'nf_conntrack: table full, dropping packet' to dmesg — a message that's easy to miss unless you specifically check kernel logs, since it produces no application-level error message of its own beyond a generic timeout.
A third, related resource ceiling is the file descriptor limit: Linux represents every open socket as a file descriptor, and both a per-process limit (ulimit -n, commonly 1024 by default unless explicitly raised) and a system-wide limit (fs.file-max) cap how many can be open simultaneously — an application under sustained load that isn't closing connections properly (a connection pool leak, or a downstream service that's slow to respond, causing connections to accumulate faster than they're released) can hit its file descriptor ceiling and start failing to open new sockets with 'Too many open files,' again with zero visible CPU or memory pressure, since a file descriptor table entry costs almost nothing in either.
The non-obvious gotcha: these three failure modes look nearly identical from the application's perspective — generic connection errors, timeouts, or 'address already in use' — and none of them show up on a standard CPU/memory/disk dashboard, which is exactly why a team can stare at seemingly perfect infrastructure health graphs while the server is structurally unable to make a new network connection; the fix is to build ss -s, conntrack -S, and file descriptor usage into the same monitoring dashboard as CPU and memory, since 'everything looks idle' is precisely the state these particular failures present as.
Code Example
# Step 1: check for TIME_WAIT / ephemeral port exhaustion ss -s # Look for a very high 'timewait' count relative to the ephemeral port range size cat /proc/sys/net/ipv4/ip_local_port_range # e.g. 32768 60999 — a range of ~28,000 ports; if TIME_WAIT count approaches this, you're exhausted # Step 2: check conntrack table saturation (common on NAT/iptables/nftables hosts) cat /proc/sys/net/netfilter/nf_conntrack_count cat /proc/sys/net/netfilter/nf_conntrack_max dmesg | grep -i 'nf_conntrack: table full' # Step 3: check file descriptor limits, both per-process and system-wide lsof -p <pid> | wc -l ulimit -n cat /proc/sys/fs/file-nr # system-wide: open, free, max # Fixes (apply the one that matches the actual bottleneck found above): # Reduce TIME_WAIT duration and allow socket reuse sysctl -w net.ipv4.tcp_tw_reuse=1 # Raise conntrack table size if legitimately near capacity sysctl -w net.netfilter.nf_conntrack_max=262144 # Raise file descriptor ceiling for the affected process ulimit -n 65536
Interview Tip
A junior engineer typically re-checks CPU and memory a second time, assuming the monitoring must be wrong. For a senior or architect role, the interviewer wants you to immediately pivot to kernel-level networking resources that live entirely outside standard compute monitoring: ephemeral port/TIME_WAIT exhaustion, conntrack table saturation, and file descriptor limits. Naming the specific diagnostic commands — ss -s for socket state summary, checking dmesg for conntrack table-full messages, ulimit -n and /proc/sys/fs/file-nr for descriptor limits — signals hands-on incident experience rather than textbook familiarity. The strongest answers explicitly note that all three failure modes are invisible on a standard CPU/memory dashboard, which is exactly why building socket-state and conntrack metrics into routine monitoring (not just checking them reactively during an incident) is the real operational lesson.
◈ Architecture Diagram
CPU: 10% Memory: 8% ← looks perfectly healthy But invisible on that dashboard: ┌─────────────────────┐ │ Ephemeral ports: │ 28,000 in TIME_WAIT / 28,000 range → exhausted ├─────────────────────┤ │ conntrack table: │ 65,536 / 65,536 → "table full, dropping packet" ├─────────────────────┤ │ File descriptors: │ 1024 / 1024 (ulimit -n) → "Too many open files" └─────────────────────┘ Any one of these = network errors despite idle CPU/RAM
💬 Comments
Quick Answer
Filter at capture time with BPF expressions (host/port/proto), limit snap length with -s, write to a file with -w instead of printing to the terminal, and use -i on the specific interface rather than "any" when possible.
Detailed Answer
On a loaded production host, an unfiltered tcpdump -i any is expensive: it copies every packet through the kernel packet filter and formats output in real time, which can add CPU and I/O pressure exactly when you can least afford it. Narrow the capture as much as possible before you start: bind to the specific interface (-i eth0), apply a BPF filter for the host/port/protocol you care about (host 10.0.1.5 and port 443), and cap the packet snap length with -s 96 (or -s 0 only when you truly need full payloads) so you are not copying full jumbo frames for a header-level investigation.
Always write to a file with -w capture.pcap rather than letting tcpdump format and print packets live — text formatting is far more expensive than writing raw packets to disk, and a pcap file lets you re-filter and analyze offline with Wireshark or tshark without re-capturing. Use -C and -W to rotate capture files so a long-running capture cannot fill the disk, and set a packet count limit (-c 100000) or a time-bounded window when you are debugging a specific incident rather than leaving a capture running indefinitely. If you only need connection setup/teardown behavior, filter on TCP flags instead of capturing full streams.
Code Example
# Bounded, filtered production capture sudo tcpdump -i eth0 -s 96 -w /var/tmp/incident.pcap \ host 10.0.1.5 and port 443 \ -C 100 -W 5 -c 200000 # Read it back / filter offline instead of re-capturing tcpdump -r /var/tmp/incident.pcap -nn "tcp[tcpflags] & (tcp-syn|tcp-rst) != 0" # Quick live check without writing to disk (short, bounded) sudo timeout 30 tcpdump -i eth0 -nn -c 500 port 443
Interview Tip
Emphasize filtering and bounding the capture (interface, BPF filter, snap length, rotation) — an unbounded capture on a live prod box is itself an incident risk.
💬 Comments
Quick Answer
Capture simultaneously on both endpoints (or as close to each as possible) filtered on the connection 5-tuple, correlate timestamps and sequence numbers, and check which side's capture shows the RST originating versus just receiving it — a RST that only appears on one side points to a middlebox (LB, firewall, NAT) between them.
Detailed Answer
Resets are directional, so a single capture point only tells you who received the RST, not who sent it. Run tcpdump on both hosts at the same time, filtered to the specific 5-tuple (source IP/port, dest IP/port, protocol) so the two captures are directly comparable, and use -tt for high-resolution timestamps so you can line up events across hosts (NTP-synced clocks matter here). If the RST shows up in the server-side capture as an outbound packet but never appears at all in an in-between capture point (a load balancer, NAT gateway, or firewall host), the reset is being generated downstream of the server and something in the path is tearing down the connection — commonly an idle-timeout on a stateful firewall/LB, a conntrack table eviction, or a security appliance resetting on a signature match.
Also check sequence and ACK numbers around the reset: a RST with a sequence number that does not match the expected next byte suggests a stale/duplicate connection (e.g., after a NAT device reused a source port) rather than an intentional application-level close. Cross-reference the reset timing against connection idle time — if resets cluster right around a round number like 60s, 300s, or 900s of inactivity, suspect an idle timeout on a middlebox rather than the two endpoints themselves.
Code Example
# Run concurrently on client and server, same 5-tuple filter sudo tcpdump -i eth0 -nn -tt -w client.pcap \ host 10.0.2.10 and host 10.0.1.5 and port 8443 sudo tcpdump -i eth0 -nn -tt -w server.pcap \ host 10.0.2.10 and host 10.0.1.5 and port 8443 # Isolate just the resets for a quick timeline comparison tcpdump -r client.pcap -nn -tt "tcp[tcpflags] & tcp-rst != 0" tcpdump -r server.pcap -nn -tt "tcp[tcpflags] & tcp-rst != 0"
Interview Tip
The key insight interviewers look for: a RST is directional, so you need captures on both sides (or at intermediate hops) to localize where it actually originates.
💬 Comments
Quick Answer
Filter on TCP SYN and RST flags on port 443 to see connection-level failures, and separately capture the ClientHello/ServerHello exchange (without decrypting) to see whether the handshake even starts and where it stops.
Detailed Answer
For connection-level failures (server never responds, or resets immediately), filter on the TCP flags rather than trying to parse TLS: SYN packets with no matching SYN-ACK indicate the server is unreachable or a firewall is dropping the connection silently; a RST shortly after SYN-ACK usually means the listener rejected the connection or an intermediate device is tearing it down. For handshake-level failures (TCP connects fine but TLS negotiation fails), you do not need to decrypt anything — tcpdump can show you how far the handshake gets by packet count and size: a ClientHello followed immediately by a RST or a TLS alert record (visible as a small packet right after the ClientHello) tells you the failure is in TLS negotiation, not the network path. For deeper protocol detail (SNI, cipher suites, alert codes) hand the same capture to tshark -Y tls or Wireshark rather than trying to decode it by hand in tcpdump.
Always correlate against certificate lifecycle: a cluster of resets right after a deploy or at a predictable time each day/week often lines up with a certificate rotation, an expired intermediate cert, or a config reload that briefly serves the wrong cert chain.
Code Example
# Connection-level: SYNs and RSTs only, on port 443 sudo tcpdump -i eth0 -nn "tcp port 443 and (tcp[tcpflags] & (tcp-syn|tcp-rst) != 0)" # Capture full handshake for deeper analysis (still encrypted payload, # but frame sizes/order show where negotiation stops) sudo tcpdump -i eth0 -nn -s 0 -w tls_handshake.pcap "tcp port 443" # Hand off for TLS-aware decoding (SNI, alert codes, cipher suite) tshark -r tls_handshake.pcap -Y "tls.handshake or tls.alert_message"
Interview Tip
Show you know tcpdump does not need to decrypt TLS to be useful — flags, packet sizes, and timing already narrow down whether the failure is network, listener, or handshake.
💬 Comments
Quick Answer
Load average on Linux counts processes in uninterruptible sleep (D state) as well as runnable ones, so a high load with low CPU usually means processes are blocked on I/O, not CPU. Check `ps` for D-state processes, `vmstat`/`iostat` for I/O wait, and `/proc/pressure` for PSI breakdown.
Detailed Answer
Linux load average is often misread as a pure CPU metric. It actually reflects the number of processes in the run queue (runnable, waiting for CPU) plus processes in uninterruptible sleep (D state, usually waiting on disk I/O, NFS, or certain kernel locks). So "high load, low %CPU" is a strong signal that something is stuck on I/O rather than compute-bound.
Start with ps -eo pid,stat,comm | grep " D" to find processes currently in uninterruptible sleep, then check what they are blocked on with cat /proc/<pid>/stack (if available) or ls -l /proc/<pid>/fd to see what files/sockets they hold. Cross-check with vmstat 1 for the b (blocked) column and wa (I/O wait) percentage, and iostat -x 1 to see if a specific block device has high %util or await. On modern kernels, /proc/pressure/io and /proc/pressure/cpu give a direct, load-average-independent view of how much time tasks actually spend stalled on each resource, which is a much cleaner signal than load average alone. Also check for NFS or other network filesystem mounts — a slow or hung NFS server is one of the most common causes of a pile of D-state processes with load average climbing while CPU stays idle.
Code Example
# Find processes stuck in uninterruptible sleep ps -eo pid,ppid,stat,wchan:32,comm | awk '$3 ~ /D/' # I/O wait and blocked-process trend vmstat 1 5 # Per-device saturation and latency iostat -x 1 5 # Direct pressure-stall breakdown (kernel 4.20+) cat /proc/pressure/io cat /proc/pressure/cpu # What is a specific D-state process actually waiting on cat /proc/<pid>/stack # requires CONFIG_STACKTRACE / root ls -l /proc/<pid>/fd
Interview Tip
The interview signal here is knowing that Linux load average includes D-state (I/O-blocked) processes, not just CPU-runnable ones — that single fact reframes the whole investigation.
💬 Comments
Quick Answer
Check `dmesg`/`journalctl -k` for "Out of memory: Killed process" entries and the OOM killer's score dump, confirm via the process exit code (137) or a crash-loop pattern, then look at cgroup/container memory limits and `/proc/<pid>/oom_score` to understand what actually got picked and why.
Detailed Answer
The kernel OOM killer logs directly to the kernel ring buffer, so the first place to confirm an OOM kill (as opposed to an application crash or a manual kill) is dmesg -T | grep -i "out of memory" or journalctl -k --since "-1h" | grep -i oom. The log includes the victim's PID, command, and the OOM score calculation, plus a full memory snapshot at kill time — free memory, per-zone stats, and the score of every candidate process, which tells you why that particular process was chosen over others (it is usually the one with the highest resident memory relative to its oom_score_adj).
At the application layer, an OOM-killed process typically exits with code 137 (128 + SIGKILL) — in Kubernetes this shows up as OOMKilled in the pod status and container exit code, which is a much faster signal than digging through kernel logs if you are running in a cluster. Before it happens again, look at whether the process has an explicit memory limit (cgroup memory.max, a container/pod limit, or a systemd MemoryMax=) that is tighter than its real working set, and whether that working set is growing linearly over time (a leak) or spiking (a burst from a specific request pattern, batch job, or GC not keeping up). Capture a heap/memory profile or a periodic RSS trend (via pidstat -r or cgroup memory.current) proactively so the next kill comes with evidence instead of just a kernel log line.
Code Example
# Confirm and inspect the OOM kill event
dmesg -T | grep -i "out of memory"
journalctl -k --since "-2h" | grep -iE "oom|killed process"
# Kubernetes: fast confirmation without touching the node
kubectl get pod <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'
# Track RSS trend for a specific process before the next kill
pidstat -r -p <pid> 5
# Check current cgroup memory limit and usage
cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory.current # cgroup v2
# Per-process OOM score (higher = more likely to be killed)
cat /proc/<pid>/oom_score
cat /proc/<pid>/oom_score_adjInterview Tip
Mention both the kernel-level evidence (dmesg/journalctl OOM log) and the platform-level shortcut (exit code 137 / Kubernetes OOMKilled status) — knowing both shows breadth.
💬 Comments
Quick Answer
Look at the `available` column (not `free`) in `free -h`, which already accounts for reclaimable cache — if `available` stays healthy while `used` climbs, that is mostly page cache, not a leak. Confirm by watching a specific process's RSS/heap over time rather than system-wide "used" memory.
Detailed Answer
Linux aggressively uses spare RAM for page cache (file-backed pages) and buffers, on the principle that unused memory is wasted memory — that cache is reclaimed automatically the instant an application needs it. This means free -h showing very little in the free column is normal and expected on a healthy, long-running Linux box; the column that matters is available, which is the kernel's own estimate of memory that can be given to a new allocation without swapping, already accounting for reclaimable cache and buffers. If available stays roughly stable over days while used grows, you are looking at cache growth, not a leak.
To confirm a real leak, stop looking at system-wide memory and track a specific process: use ps -o rss,vsz -p <pid> or pidstat -r -p <pid> 60 sampled over hours, and watch whether RSS grows monotonically with no corresponding drop after garbage collection (for managed runtimes) or after requests complete. smem or /proc/<pid>/smaps_rollup can break down how much of a process's RSS is private (its own leaked memory) versus shared (mapped libraries, shared cache) — a real leak shows up as private, dirty memory (Private_Dirty) climbing continuously. Also check /proc/meminfo for Slab and SReclaimable vs SUnreclaim — a leak in kernel-level allocations (e.g., a buggy driver or a container leaking dentries/inodes) shows up as SUnreclaim growing rather than in any single process's RSS at all.
Code Example
# The column that actually matters free -h # look at "available", not "free" # Track a specific process's RSS over time (not system-wide "used") pidstat -r -p <pid> 60 # Break down private (leak-relevant) vs shared memory for a process grep -E "Private_Dirty|Shared" /proc/<pid>/smaps_rollup # Distinguish reclaimable kernel slab (safe) from unreclaimable (potential leak) grep -E "^Slab|SReclaimable|SUnreclaim" /proc/meminfo # Force a cache drop ONLY to confirm your hypothesis in a non-prod box # (never routinely run this in production — it just evicts useful cache) sync; echo 1 > /proc/sys/vm/drop_caches
Interview Tip
Anchor on the "available" vs "free" distinction — it is the single most common Linux memory misreading, and calling it out explicitly signals real operational experience.
💬 Comments
Quick Answer
Correlate load with CPU (top/mpstat), run-queue and I/O wait; high load with high iowait points to disk, high with %us to CPU.
Detailed Answer
Load average counts running + uninterruptible (D-state, usually I/O) processes. Use top/htop for per-process CPU, vmstat/mpstat for run queue and iowait, and iostat for disk. High load with low CPU and high iowait means storage is the bottleneck, not CPU — a common misdiagnosis.
Interview Tip
Load includes D-state I/O waiters, not just CPU — key nuance.
💬 Comments
Quick Answer
FDs are handles for files/sockets; the error means a process hit its ulimit -n; raise the limit and check for leaks.
Detailed Answer
Every open file and socket consumes an FD, capped per process by ulimit -n (and system-wide by fs.file-max). 'Too many open files' means the cap was hit — often a leak (unclosed sockets). Raise the soft/hard limits (limits.conf or the systemd unit LimitNOFILE) and fix the leak; monitor with lsof/ls /proc/<pid>/fd.
Interview Tip
Mention systemd LimitNOFILE for services.
💬 Comments
Quick Answer
Use ss -tlnp (or lsof -i) for ports, and top/ps for CPU/memory by process.
Detailed Answer
ss -tlnp lists listening TCP sockets with the owning process; lsof -i :PORT does the same. For resource hogs, top/htop sort by CPU or memory, and ps aux --sort=-%mem shows the top consumers. These are the first commands in any triage.
Code Example
ss -tlnp | grep :8080
Interview Tip
ss has replaced netstat — use ss -tlnp.
💬 Comments
Quick Answer
VSZ is virtual memory reserved; RSS is the resident (physical RAM) portion actually in use.
Detailed Answer
VSZ includes mapped-but-unused memory, shared libraries, and reservations, so it overstates real usage. RSS is what's actually resident in RAM (though shared pages are counted per process). For real memory pressure, watch RSS and cgroup memory, not VSZ.
Interview Tip
RSS reflects real RAM use; VSZ is misleading.
💬 Comments
Quick Answer
Use find / -name filename to search the whole filesystem by name, or the faster locate filename if the mlocate database is present. which/whereis find executables on PATH.
Detailed Answer
find is exact and always current but scans the disk (slow on large trees), so scope it to a directory when you can (find /etc -name ...). locate queries a prebuilt index (near-instant, but may be stale until updatedb runs). For binaries on your PATH, which nginx is quickest.
Code Example
find / -name filename 2>/dev/null locate filename
Interview Tip
Contrast find (accurate, slow, live) with locate (fast, indexed, possibly stale) — knowing when to use each is the signal.
💬 Comments
Quick Answer
Use find with size and mtime filters and the delete action: find /var/log -type f -size +50M -mtime +30 -delete. Test first by replacing -delete with -print.
Detailed Answer
The predicates combine: -type f (files only), -size +50M (larger than 50MB), -mtime +30 (modified more than 30 days ago), and -delete removes matches. Always dry-run with -print first to confirm the match set, and prefer logrotate for ongoing log management rather than ad-hoc find deletes.
Code Example
find /var/log -type f -size +50M -mtime +30 -print # verify first find /var/log -type f -size +50M -mtime +30 -delete
Interview Tip
Say to dry-run with -print first, and that logrotate is the right long-term tool — ad-hoc deletes are for one-offs.
💬 Comments
Context
A payments batch worker ran every minute from cron across 18 VMs. When a downstream API slowed, overlapping jobs created duplicate work and filled local disks with logs.
Problem
Cron launched new processes without knowing whether the previous run was still alive. Operators could not see service state cleanly, and failures were scattered across mail logs and application logs.
Solution
The team replaced cron with a systemd service and timer, used a lock file, set restart limits, moved logs to journald, and added a health check that reported queue lag. Failed runs became visible through `systemctl` and alerting.
Commands
systemctl list-timers payments-worker.timer # Confirms timer schedule
systemctl status payments-worker.service # Reviews last run and exit code
journalctl -u payments-worker.service --since "1 hour ago" # Reads structured service logs
Outcome
Duplicate batch executions stopped. Mean diagnosis time for worker failures dropped from 35 minutes to 8 minutes.
Lessons Learned
Host automation still needs service semantics. Cron is fine for simple jobs, but production workers benefit from explicit lifecycle and observability.
◈ Architecture Diagram
┌──────────┐
│ Learn │
└────┬─────┘
↓
┌──────────┐
│ UseCase │
└────┬─────┘
↓
┌──────────┐
│ Operate │
└──────────┘💬 Comments
Symptom
12:08 AM: NGINX 502 errors rose to 18%, and app logs showed intermittent connection resets under peak load.
Error Message
socket() failed (24: Too many open files) while connecting to upstream
Root Cause
The service accepted more concurrent connections than the process and system file descriptor limits allowed. Each socket, log file, and upstream connection consumes a descriptor. Once the worker hit the limit, new sockets failed and NGINX returned 502s even though CPU and memory looked normal. The underlying issue was a combination of factors that individually seemed harmless but together created a cascading failure. The monitoring that should have caught this early was either missing or configured with thresholds too high to trigger before user impact. The on-call engineer initially pursued the wrong hypothesis because the symptoms resembled a different, more common failure mode. By the time the real root cause was identified, the blast radius had expanded beyond the original service to affect downstream dependencies. This incident exposed a gap in the team's runbooks and highlighted the need for better correlation between metrics, logs, and traces during diagnosis.
Diagnosis Steps
Solution
Raise systemd `LimitNOFILE`, align NGINX `worker_rlimit_nofile` and `worker_connections`, reload safely, and verify actual process limits after restart. Do not only edit `/etc/security/limits.conf`; systemd services do not necessarily inherit interactive shell limits.
Commands
systemctl edit nginx # Add LimitNOFILE=131072 in the service override
nginx -t # Validate configuration before reload
systemctl restart nginx # Applies process-level limits
Prevention
Alert on descriptor usage and EMFILE log patterns. Capacity-test connection concurrency. Keep systemd, kernel, and application limits documented together.
◈ Architecture Diagram
┌──────────┐
│ Signal │
└────┬─────┘
↓
┌──────────┐
│ Incident │
└────┬─────┘
↓
┌──────────┐
│ Action │
└──────────┘💬 Comments
Symptom
1:22 AM — PagerDuty fired 'disk-utilization-critical' for the media-upload-api host at 98% disk usage on /var/log. An on-call engineer found and deleted a 20GB nginx access log with rm -rf, expecting immediate relief, but a follow-up df -h five minutes later still showed 98% used, and new user file uploads continued failing with 'No space left on device' errors from the application.
Error Message
OSError: [Errno 28] No space left on device (raised by the upload service when writing incoming files to /var/uploads/tmp, which shared the same volume as /var/log)
Root Cause
The deleted access log had been open continuously by the running nginx worker process since the last log rotation 11 days earlier, because the server's logrotate configuration used the default create/rename strategy without sending nginx a USR1 signal (or reload) to make it reopen its log file handles after rotation — a step that had been present in the original logrotate config but was accidentally dropped 3 months earlier during a config-management refactor that consolidated several service-specific logrotate snippets into one shared template. Because rm only removes a directory entry and the kernel keeps the underlying inode's data blocks allocated as long as any process holds an open file descriptor to it, nginx's still-active worker process continued writing new access log lines into the now-nameless, invisible inode after the deletion, meaning the full 20GB remained allocated on disk the entire time, invisible to any `du` or `ls` command, and continuing to grow with every request nginx served.
Diagnosis Steps
Solution
Truncated the still-open file descriptor directly with `: > /proc/<pid>/fd/<fd-number>` rather than restarting nginx, immediately releasing the full 20GB with zero dropped connections and zero impact to in-flight uploads, since the running nginx process's file descriptor remained valid and simply began writing into a freshly truncated (zero-byte) file from that point forward. Verified with `df -h` that space was reclaimed within seconds. Restored the missing `postrotate { nginx -s reopen }` (or equivalent USR1 signal) directive to the shared logrotate template so future rotations correctly instruct nginx to reopen its log handles instead of writing into an orphaned inode.
Commands
lsof +L1 | grep deleted
ls -la /proc/8823/fd/17
: > /proc/8823/fd/17
df -h /var/log
nginx -s reopen
Prevention
Always use logrotate's `copytruncate` directive for any log file whose owning process cannot easily be signaled to reopen handles, or ensure `postrotate { nginx -s reopen }` / equivalent is present and tested after any config-management refactor touching logrotate templates, since this exact class of regression is invisible until the next disk-full incident, sometimes weeks or months later. Add a scheduled check (or a CI test against the rendered logrotate config) asserting that every service-specific logrotate snippet includes its required postrotate signal, so a shared-template refactor can't silently drop it again. Alert on `lsof +L1` output size trending upward over time as a leading indicator, since a growing deleted-but-open file is detectable well before disk utilization becomes critical.
◈ Architecture Diagram
logrotate rotates file (no reload signal sent)
│
▼
nginx worker: fd still points to OLD (now unnamed) inode
│
▼
rm -rf deletes the NAME, not the 20GB of blocks
│
▼
nginx keeps writing → inode keeps growing → disk stays 100%
Fix: lsof +L1 → find fd → : > /proc/<pid>/fd/<n> → space freed instantly💬 Comments
Symptom
4:47 PM — checkout-worker began failing roughly 30% of outbound calls to the payment-gateway API with generic connection errors, while Grafana showed CPU at 6% and memory at 9% across the entire fleet — numbers so low that the on-call engineer initially suspected a monitoring or dashboard bug rather than a real resource exhaustion, since every prior incident with visible symptoms this severe had shown at least one resource graph spiking.
Error Message
OSError: [Errno 99] Cannot assign requested address (raised when the HTTP client attempted to open a new outbound TCP connection to the payment gateway)
Root Cause
A connection pool configuration change deployed 6 hours earlier had inadvertently disabled connection reuse for the payment-gateway HTTP client (a library default had silently changed after a minor version bump, switching from a persistent connection pool to opening a brand-new TCP connection per request), and under checkout-worker's sustained load of roughly 180 requests/second, this meant 180 new outbound connections were opened and closed every second, each entering the TIME_WAIT state for the default 60 seconds after closing. With 180 new TIME_WAIT sockets accumulating per second and only expiring after 60 seconds, the server was maintaining roughly 10,800 sockets in TIME_WAIT at steady state, consuming nearly the entire default ephemeral port range of nlocal_port_range (32768-60999, about 28,000 ports) once combined with the several other services on the same host also making outbound calls — pushing the host to genuine ephemeral port exhaustion, at which point the kernel began rejecting new outbound connection attempts with EADDRNOTAVAIL, a condition entirely invisible to CPU and memory monitoring since TIME_WAIT sockets consume negligible compute resources, only port-number address space.
Diagnosis Steps
Solution
Applied `sysctl -w net.ipv4.tcp_tw_reuse=1` as an immediate mitigation, allowing the kernel to safely reuse TIME_WAIT sockets for new outbound connections under the same conditions RFC 1323 permits, which restored successful connection rates within about 90 seconds as the backlog of TIME_WAIT sockets began being recycled rather than blocking new connections. This was explicitly treated as a stopgap, not a fix — the actual root cause was rolled back by reverting the connection pool configuration change, restoring persistent connection reuse so checkout-worker made roughly 4 new connections per second instead of 180, well within sustainable TIME_WAIT churn for the available port range.
Commands
ss -s
ss -tn state time-wait dst 203.0.113.50 | wc -l
sysctl -w net.ipv4.tcp_tw_reuse=1
kubectl rollout undo deployment/checkout-worker
Prevention
Pin HTTP client library versions explicitly and review changelogs for connection-pooling defaults on any minor version bump, since this incident stemmed from a default silently changing behavior without a corresponding major version signal. Add `ss -s` TIME_WAIT count and ephemeral port utilization percentage to the same dashboard as CPU and memory for every service making high-volume outbound connections, since this class of failure is otherwise completely invisible until it becomes a live incident. Set an alert threshold on TIME_WAIT socket count as a percentage of the available ephemeral port range (for example, alert above 70%) well before actual exhaustion occurs, giving on-call a leading indicator rather than a reactive one.
◈ Architecture Diagram
Connection pool regression: 1 new TCP conn per request
│ (180 req/s, no reuse)
▼
180 new TIME_WAIT sockets/sec, 60s expiry
│
▼
~10,800 TIME_WAIT sockets steady-state
│
▼
Ephemeral port range (~28,000) exhausted
│
▼
EADDRNOTAVAIL — CPU 6%, Memory 9%, both idle
Fix: tcp_tw_reuse=1 (stopgap) + revert pool config (root cause)💬 Comments