DevOps · 01

Linux & Shell Essentials

Containers, Kubernetes and CI/CD all run on Linux, and every production incident eventually lands in a shell. This page builds the working knowledge a backend engineer needs: reading permissions and processes, knowing what load and memory numbers really mean, finding what filled a disk or holds a port, writing a systemd unit for a Spring Boot jar, taming logs with pipelines, writing shell scripts that fail safely, and understanding the namespaces and cgroups that make a container.

13
Chapters
9
Diagrams
3
Playbooks
10
Exercises
01
Chapter One - Files, Users and Permissions

The Filesystem, Ownership and rwx

Almost everything you operate on Linux is a file: configuration, logs, devices (/dev/null), kernel state (/proc, /sys) and sockets. Learning where things live and who may touch them solves a surprising share of production problems, because "permission denied" and "no such file" are two of the three most common failures (the third is "address already in use", chapter 05).

PathWhat lives thereSpring/Java engineer cares because
/etcSystem and service configuration (/etc/hosts, /etc/resolv.conf, /etc/systemd/system)Where unit files, limits.conf and sudoers live
/var/logLogs of the OS and installed daemons; the journal under /var/log/journal when persistentFirst stop when something broke; the first thing to fill a disk
/var/libPersistent state of daemons (databases, Docker images and volumes under /var/lib/docker)Big, growing, and often on its own volume
/opt, /srv, /usr/localThird-party or locally deployed softwareA conventional home for /opt/orders/orders.jar
/tmp, /var/tmpScratch space; /tmp is often cleared at boot, /var/tmp survives rebootsTomcat and Netty use it for temporary files; world-writable with the sticky bit
/procVirtual: one directory per process plus kernel info (/proc/meminfo, /proc/loadavg)Every tool in this page reads it; so can you
/sysVirtual: devices, drivers, and the cgroup tree under /sys/fs/cgroupWhere container resource limits are visible
/devDevice files: null, zero, urandom, disks, terminals/dev/urandom and /dev/null show up in scripts and Dockerfiles
/home, /rootUser home directoriesNot where services should keep data

On modern distributions /bin, /sbin and /lib are symbolic links into /usr (the "merged /usr" layout). The listing below is real, from the machine used to write this page (Ubuntu with kernel 7.0); the permission bits are exactly what the next section explains.

Shell - real output (abridged: first lines of ls -l /)
$ ls -l / | head -8 total 8388860 lrwxrwxrwx 1 root root 7 Apr 22 2024 bin -> usr/bin drwxr-xr-x 5 root root 4096 Oct 16 06:10 boot drwxr-xr-x 22 root root 5820 Oct 31 22:52 dev drwxr-xr-x 162 root root 12288 Oct 31 23:12 etc drwxr-xr-x 4 root root 4096 Dec 1 2025 home lrwxrwxrwx 1 root root 7 Apr 22 2024 lib -> usr/lib
Permissions: the nine bits
Reading ls -l: type, three permission triplets, and the octal arithmetic
-rwxr-x--- 1 orders ops 4096 app.jar type owner (u) rwx = 7 group (g) r-x = 5 others (o) --- = 0 Octal 750: owner may read, write, execute; group may read and execute; others nothing. r = 4 w = 2 x = 1 add them per triplet: rwx = 4+2+1 = 7, r-x = 4+0+1 = 5 On a directory: r = list names, w = create/delete entries, x = enter (cd) Three special bits (a leading fourth octal digit) setuid (4000) shows as s file runs as its OWNER /usr/bin/passwd, sudo setgid (2000) shows as s file: runs as its group; dir: new files inherit the directory group sticky (1000) shows as t only the file owner may delete in the dir: /tmp is drwxrwxrwt

Every file has an owner, a group and three permission triplets for owner, group and everyone else. The kernel checks them in that order and stops at the first that applies: if you are the owner, only the owner triplet counts, even if the "others" triplet is more generous.

Shell - real output: changing and reading permissions
$ touch f; chmod 640 f; stat -c '%A %a %U:%G %n' f -rw-r----- 640 vinay-kumar-k:vinay-kumar-k f $ chmod u+x f; stat -c '%A %a' f -rwxr----- 740 $ ls -ld /tmp /etc/shadow /usr/bin/passwd drwxrwxrwt 373 root root 163840 Oct 31 23:13 /tmp -rw-r----- 1 root shadow 1643 Oct 26 14:05 /etc/shadow -rwsr-xr-x 1 root root 64152 May 30 2024 /usr/bin/passwd

Read the last three lines with the diagram: /tmp ends in t (sticky: you cannot delete someone else's file even though everyone can write there), /etc/shadow is readable only by root and the shadow group, and passwd has s in the owner triplet (setuid) so an ordinary user can change their own password, which requires writing a root-owned file.

umask: the default you never set

New files start from 666 and new directories from 777, and the process's umask removes bits. Umask 022 removes write for group and others, giving 644 files and 755 directories; umask 077 gives private 600 and 700. A service that writes logs or secrets should set the mode it needs explicitly rather than trust whoever started it.

Shell - real output
$ umask 0002 $ (umask 022; touch a22; mkdir d22; umask 077; touch a77); ls -ld a22 d22 a77 -rw-r--r-- 1 me me 0 Oct 31 23:13 a22 -rw------- 1 me me 0 Oct 31 23:13 a77 drwxr-xr-x 2 me me 4096 Oct 31 23:13 d22
Users, groups and sudo

Every process runs as a numeric UID and one or more GIDs; names are only a lookup in /etc/passwd and /etc/group (or LDAP and friends). UID 0 is root and bypasses permission checks. Services should never run as root: give each one a dedicated unprivileged user with no login shell, and give that user only the files it needs.

Shell - the usual commands (illustrative: they change the system, so not run here)
$ sudo useradd --system --no-create-home --shell /usr/sbin/nologin orders $ sudo mkdir -p /opt/orders /var/lib/orders $ sudo chown -R orders:orders /var/lib/orders # data the service writes $ sudo chown -R root:orders /opt/orders # code: readable, not writable, by the service $ sudo chmod -R u=rwX,g=rX,o= /opt/orders $ id orders uid=998(orders) gid=998(orders) groups=998(orders)

sudo runs one command as another user (root by default) after checking /etc/sudoers and the files in /etc/sudoers.d/. Always edit with visudo, which validates syntax before saving; a syntax error in sudoers can lock everyone out of root. Grant narrow rights rather than ALL:

/etc/sudoers.d/orders-deploy - example (edit with: sudo visudo -f /etc/sudoers.d/orders-deploy)
# members of group deployers may restart exactly this service, without a password %deployers ALL=(root) NOPASSWD: /usr/bin/systemctl restart orders, /usr/bin/systemctl status orders
⚠ chmod 777 as a fix
  • "Permission denied" is the system telling you the owner or group is wrong. chmod -R 777 makes the error go away and gives every account on the box, and every compromised process, write access. Find the user the process runs as (ps -o user= -p PID), then fix ownership (chown) or add that user to the right group.
02
Chapter Two - Processes and Signals

What Is Running, and How to Stop It Properly

A process is a running program with a PID, a parent (PPID), an owner, memory, open files and a current directory. Processes form a tree rooted at PID 1 (systemd on most servers, your entrypoint inside a container). Every process is created by fork from a parent and replaced by exec; the shell does exactly this each time you type a command.

Shell - real output: PID 1 and my own shell
$ ps -o pid,ppid,stat,comm -p 1,$$ PID PPID STAT COMMAND 1 0 Ss systemd 2335805 2277141 Ss zsh

The STAT column is the process state. Letters you will meet daily:

STATStateMeaning and what to do
RRunning or runnableOn a CPU or waiting for one. Many R at once means CPU contention
SInterruptible sleepWaiting for an event (network, timer, input). The normal state of an idle service
DUninterruptible sleepWaiting on I/O inside the kernel (slow disk, hung NFS). Cannot be killed, even by kill -9, until the I/O returns; counted in load average
TStoppedPaused by SIGSTOP or Ctrl-Z. Resume with kill -CONT
ZZombieExited, but the parent has not collected its exit status. Uses no memory, only a process-table slot
s, l, +, NModifiersSession leader, multi-threaded, foreground job, low priority (nice)
Looking at processes
Shell - the commands you will use constantly (illustrative)
$ ps aux # every process, BSD style (user, %CPU, %MEM, RSS, command) $ ps -ef --forest # full format with the parent/child tree $ ps -o pid,ppid,stat,%cpu,%mem,rss,etime,args -p 4242 $ pgrep -af orders # PIDs whose command line matches $ top # live view: P sorts by CPU, M by memory, 1 shows each core, H shows threads $ htop # nicer top (install it: not present on every box) $ ps -L -p 4242 -o pid,tid,pcpu,comm # threads of one process (a JVM has dozens) $ ls -l /proc/4242/cwd /proc/4242/exe # where it runs and what binary it is $ tr '\0' ' ' < /proc/4242/cmdline # its exact command line

RSS is the resident set size: RAM the process actually holds. VSZ (virtual size) is address space reserved, often several times larger for a JVM and not a useful number for "how much memory does it use".

Signals: the polite request and the hammer

A signal is a small notification the kernel delivers to a process. A process can catch most signals and run its own handler; two cannot be caught or ignored: SIGKILL and SIGSTOP.

SignalNumberDefaultTypical use
SIGTERM15TerminateThe polite stop. kill PID sends it; systemd, Docker and Kubernetes all send it first. Catch it to finish requests and close resources
SIGINT2TerminateCtrl-C in a terminal
SIGHUP1TerminateTerminal closed; many daemons reuse it to reload configuration
SIGKILL9Terminate, uncatchableThe hammer. The kernel removes the process: no handler, no finally, no shutdown hook, no flushed buffers. Last resort
SIGSTOP / SIGCONT19 / 18Pause / resumeFreeze a process without losing state (numbers are for x86 Linux)
SIGQUIT3Terminate (core)A JVM prints a thread dump to stdout and keeps running: kill -3 PID
SIGUSR1, SIGUSR210, 12TerminateApplication-defined

Exit codes tell you how a process ended: 0 is success, 1-125 are application errors, and 128 + N means "killed by signal N". So 143 = 128 + 15 (SIGTERM) and 137 = 128 + 9 (SIGKILL). A container that "exited with 137" was killed with SIGKILL, very often by the out-of-memory killer (chapter 03) or by an orchestrator whose grace period ran out. Both codes below are real:

Shell - real output: how the exit code records the signal
$ sleep 100 & pid=$!; kill $pid; wait $pid; echo "exit code: $?" exit code: 143 $ sleep 100 & pid=$!; kill -9 $pid; wait $pid; echo "exit code: $?" exit code: 137
Process lifecycle and what each signal does
Running (R / S) Handler runs (you caught SIGTERM) Exits code 143 or 0 kill -TERM (15) close, drain Kernel removes it (no handler possible) kill -KILL (9) Parent calls wait() reaps the exit status Zombie (Z) dead, status not yet read If the parent never calls wait(), the entry stays. If the parent dies, PID 1 adopts the orphan and reaps it. Why PID 1 is special inside a container container PID namespace PID 1 = your entrypoint The kernel gives PID 1 no default signal actions: a SIGTERM with no handler is ignored. And PID 1 must reap orphans. A shell as PID 1 will not forward SIGTERM to java: use exec, an init (tini), or the JVM as the entrypoint.
A process that handles SIGTERM

The script below traps TERM, does its cleanup, and exits 0. It was syntax-checked with bash -n (syntax only, no execution) and then run for real; the output is what happened here.

Shell - sig.sh (validated with bash -n)
#!/usr/bin/env bash set -euo pipefail cleanup() { echo "cleanup: got SIGTERM, finishing up"; exit 0; } trap cleanup TERM INT echo "running as PID $$" while true; do sleep 1; done
Shell - real output
$ bash -n sig.sh && echo syntax-ok syntax-ok $ ./sig.sh & pid=$!; sleep 0.5; kill -TERM $pid; wait $pid; echo "exit code: $?" running as PID 2337764 cleanup: got SIGTERM, finishing up exit code: 0

A Spring Boot application behaves the same way: the JVM installs a SIGTERM handler that runs shutdown hooks, and with server.shutdown=graceful Spring stops accepting new requests and lets in-flight ones finish for up to spring.lifecycle.timeout-per-shutdown-phase (30 seconds by default). A JVM that exits because of SIGTERM reports 143, which is why the systemd unit in chapter 06 declares SuccessExitStatus=143.

Zombies and orphans

A zombie costs nothing but a process-table entry, and you cannot kill it (it is already dead). The fix is for the parent to call wait(), or for the parent to die so that PID 1 adopts and reaps the zombie. A pile of zombies therefore points to a buggy parent, not to the zombies. Real demonstration: a Python parent forks a child that exits at once, and the parent has not waited for it yet.

Shell - real output (state Z, then gone once the parent called os.waitpid)
$ ps -o pid,ppid,stat,comm -p 2338414 PID PPID STAT COMMAND 2338414 2338270 Z python3 <- state Z: dead, waiting to be reaped $ ps -o pid,ppid,stat,comm -p 2338414 # after the parent called os.waitpid PID PPID STAT COMMAND

This matters in containers: if your entrypoint is a Java process that spawns helpers and never waits for them, they accumulate, because inside the container you are PID 1 and nobody adopts them. The cure is a tiny init process (docker run --init, tini) or not spawning children. The container page (page 02) returns to this.

⚠ kill -9 first
  • kill -9 skips shutdown hooks: connection pools are not closed, Kafka consumers do not leave their group (a rebalance waits for the session timeout), buffered logs are lost, and temporary files stay. Send SIGTERM, wait, then escalate. If SIGTERM does nothing, find out why (a hung shutdown hook, a process in D state) before reaching for the hammer.
03
Chapter Three - CPU, Load and Memory

Reading the Machine's Vital Signs

Load average, properly

uptime, top and /proc/loadavg show three numbers: the exponentially damped average over the last 1, 5 and 15 minutes of the number of tasks that are runnable (using or waiting for a CPU) or in uninterruptible sleep (usually waiting for disk). It is a count of demand, not a percentage.

Shell - real output (a 12-core developer laptop under build load)
$ cat /proc/loadavg 6.76 5.19 4.96 3/4562 2335841 $ nproc 12

The first three fields are the 1, 5 and 15 minute averages; 3/4562 is "tasks runnable right now / total tasks"; the last is the most recent PID. Compare the load with the number of cores: roughly 6.8 on 12 cores means about half the CPUs' worth of work was queued, healthy. On a 2-vCPU host the same 6.8 would mean three runnable tasks per CPU, and everything is slow.

PatternReadingNext step
Load below core count, CPU busy in usBusy but keeping upProfile the application if latency is bad
Load far above cores, CPU near 100% (us+sy)CPU-bound: more runnable work than CPUsFind the hot process (top, pidstat), then the hot thread (top -H, then a thread dump)
Load high, CPU mostly idle, wa highTasks stuck waiting for diskiostat -x 1, iotop; check a full or failing disk, a noisy neighbour, NFS
1-min load much higher than 15-minRecent spikeLook at what started 1-5 minutes ago (deploy, batch job, traffic)
1-min load much lower than 15-minRecoveringThe problem is already passing
Load low but the service is slowNot a CPU problemIt is waiting on something else: database, downstream call, locks, GC (chapter 09)

top's header splits CPU time into us (user code), sy (kernel), ni (nice), id (idle), wa (waiting for I/O), hi/si (interrupts) and st (steal: time the hypervisor gave to another VM, a sign of a noisy neighbour on a cloud instance, or exhausted burst credits on a burstable AWS instance type).

Shell - real output of top (header only)
$ top -b -n1 | head -5 top - 23:13:23 up 12 days, 12:51, 1 user, load average: 7.79, 5.49, 5.06 Tasks: 621 total, 9 running, 610 sleeping, 0 stopped, 2 zombie %Cpu(s): 61.9 us, 20.0 sy, 7.5 ni, 8.8 id, 0.6 wa, 0.0 hi, 1.2 si, 0.0 st MiB Mem : 31726.4 total, 1640.1 free, 23240.8 used, 8626.6 buff/cache MiB Swap: 8192.0 total, 0.1 free, 8191.9 used. 8485.6 avail Mem
Memory: free is not the number you want

Linux uses spare RAM as a page cache for file data, so "free" is normally small on a healthy, long-running machine. What matters is available: free memory plus cache that can be dropped without hurting anything.

Shell - real output
$ free -h total used free shared buff/cache available Mem: 30Gi 22Gi 1.0Gi 2.3Gi 8.8Gi 8.3Gi Swap: 8.0Gi 8.0Gi 220Ki $ grep -E 'MemTotal|MemAvailable|^Cached|SwapTotal' /proc/meminfo MemTotal: 32487824 kB MemAvailable: 8695912 kB Cached: 7903552 kB SwapTotal: 8388604 kB

Here only 1.0 GiB is "free", but 8.3 GiB is available, so there is no shortage. What does deserve attention on this laptop is that swap is completely used: the kernel pushed idle pages out to disk, and they will be slow the moment something touches them. Swapping is not failure by itself; sustained swap-in/out activity is. Watch it with vmstat 1 (columns si and so) rather than staring at the swap total.

Where the RAM goes: used, cache, available, swap, and the OOM killer
used by processes (anonymous memory) heap, stacks, native buffers page cache + buffers file data kept for speed free physical RAM reclaimable on demand: clean cache is dropped before the kernel swaps or kills available = free + the part of the cache that can be dropped quickly (the number to watch) When anonymous memory cannot be reclaimed Pressure rises available falls Swap used slow, disk speed OOM killer picks highest oom_score SIGKILL: exit 137 line in dmesg In a container the same happens at the cgroup limit (memory.max), even when the host has plenty of free RAM. The JVM heap is only part of the footprint: metaspace, thread stacks, direct buffers and code cache add to it.
Swap, swappiness and the OOM killer
  • Swap is disk used as slow overflow RAM. vm.swappiness (60 on this machine) controls how eagerly the kernel swaps anonymous pages versus dropping cache. Many latency-sensitive services set it low. Kubernetes nodes have traditionally run with swap off, so check your Kubernetes version's documentation before relying on swap on a node.
  • When memory cannot be freed, the kernel's OOM killer chooses a victim by oom_score (visible in /proc/PID/oom_score; it is mostly proportional to memory use) adjusted by oom_score_adj (-1000 protects a process, +1000 volunteers it). The victim gets SIGKILL, so exit code 137, and no shutdown hooks run.
  • The kill is recorded in the kernel ring buffer: read it with dmesg -T (needs root on many systems, as here) or journalctl -k.
Shell - find OOM kills (illustrative output: dmesg is root-only on this machine, and nothing was killed here)
$ sudo dmesg -T | grep -iE 'out of memory|killed process' [Fri Oct 31 02:14:09 2026] Out of memory: Killed process 4242 (java) total-vm:9132044kB, anon-rss:1523100kB, file-rss:0kB, shmem-rss:0kB, UID:998 pgtables:3820kB oom_score_adj:0 $ journalctl -k --since "1 hour ago" | grep -i oom

Inside a container the limit is the cgroup, not the host. A container with a 512 MiB limit and a JVM configured for a larger heap is killed at 512 MiB while the host has gigabytes free. Modern JVMs (JDK 10 onward, and 8u191) read the cgroup limit; size the heap as a percentage of it with -XX:MaxRAMPercentage (the systemd unit in chapter 06 uses 75) and leave headroom for non-heap memory.

Shell - read the limits and usage of the cgroup you are in
$ cat /sys/fs/cgroup/user.slice/user-1000.slice/memory.max # real output on this machine: no limit max $ cat /sys/fs/cgroup/<your-cgroup>/memory.current # bytes in use now (illustrative path) $ cat /sys/fs/cgroup/<your-cgroup>/memory.events # counters incl. "oom_kill N" (illustrative path)
⚠ Reading "free" as a shortage
  • "The server has only 1 GB free!" is the most common false alarm. Look at available. And the reverse mistake: a JVM with -Xmx equal to the container limit will be OOM-killed, because the heap is not the whole process.
04
Chapter Four - Disks

df, du, lsblk and Inodes

Disk problems come in three flavours (diagram below), and each has a different tool. The first step is always the same: which filesystem is full?

Shell - real output
$ df -hT / Filesystem Type Size Used Avail Use% Mounted on /dev/mapper/ubuntu--vg-ubuntu--lv ext4 466G 378G 64G 86% / $ df -i / Filesystem Inodes IUsed IFree IUse% Mounted on /dev/mapper/ubuntu--vg-ubuntu--lv 31055872 1679578 29376294 6% / $ lsblk -o NAME,SIZE,TYPE,MOUNTPOINT # abridged: loop devices are snap packages NAME SIZE TYPE MOUNTPOINT loop1 543.3M loop /snap/code/274
ToolQuestion it answersExample
df -hTHow full is each mounted filesystem, and of what type?df -hT (add -i for inodes)
du -shHow much does this directory tree use?du -xh --max-depth=1 /var | sort -rh | head (-x stays on one filesystem)
lsblkWhat block devices, partitions and mounts exist?lsblk -f adds filesystem type and UUID
findmntWhat is mounted where, with which options?findmnt /var/lib
iostat -x 1Is the disk saturated? (%util, await, queue size)from the sysstat package
lsof +L1Which deleted files are still held open?the "df full, du small" case
Why df and du disagree: three ways a disk is "full"
Blocks used df -h shows 100% files are simply big Inodes used df -i shows 100% millions of tiny files Deleted but open df full, du small a process holds the file du -xh --max-depth=1 /var | sort -rh | head then descend find /var -xdev -type f | cut -d/ -f1-4 | sort | uniq -c | sort -rn lsof +L1 restart, or truncate via : > /proc/PID/fd/N Unlinking a file removes its name; the blocks are freed only when the last open file descriptor closes. Log rotation that deletes a log a JVM still has open is the classic cause of "df is full, du shows little".

Inodes are the filesystem's per-file records. ext4 fixes their number when the filesystem is created, so a directory with millions of tiny files (session files, a cache, unrotated small logs) can exhaust inodes while 90% of the blocks are free. Symptom: "No space left on device" while df -h shows space. df -i shows the truth.

Shell - drill down, then clean up safely (illustrative: run on your own box)
$ du -xh --max-depth=1 /var | sort -rh | head -5 $ sudo journalctl --disk-usage $ sudo journalctl --vacuum-size=500M # trim the systemd journal to 500 MB $ sudo lsof +L1 | head # deleted-but-open files: NLINK 0 in the output $ : > /var/log/orders/app.log # truncate a live log in place (keeps the open fd valid)
⚠ rm on a log a service still writes
  • Deleting app.log while the JVM holds it open frees nothing: the process keeps writing to the unlinked file. Truncate (: > file), use log rotation with copytruncate or a reopen signal, or restart the service. And never run cleanup commands with a variable that can be empty (rm -rf "$DIR"/* with DIR unset becomes rm -rf /*); set -u in scripts (chapter 08) exists for exactly this.
05
Chapter Five - Networking

ip, ss, curl, dig, nc and tcpdump

Networking trouble splits into a few layers, and there is one tool per layer. Work from the bottom up: is the interface up and addressed, is there a route, does the name resolve, can I open the TCP connection, and only then does the application answer correctly?

LayerQuestionTool and example
InterfaceDo I have an address and is the link up?ip -br addr (brief), ip link
RoutingWhere do packets for that address go?ip route, ip route get 10.0.1.5
NameDoes the name resolve, and to what?dig +short example.com, getent hosts example.com
PathWhere along the path does it stop?traceroute / tracepath (not installed on every box), mtr
PortIs something listening, and which process?ss -tlnp
ConnectCan I open a TCP connection?nc -zv host 443
ApplicationWhat does HTTP say, and where does the time go?curl -v, curl -w timings
PacketsWhat is really on the wire?tcpdump -i any -nn port 8080
Interface, route and listening sockets
Shell - real output (addresses abridged)
$ ip -br addr lo UNKNOWN 127.0.0.1/8 ::1/128 wlp0s20f3 UP 192.168.1.111/24 ... docker0 DOWN 172.17.0.1/16 ... $ ip route | head -1 default via 192.168.1.1 dev wlp0s20f3 proto dhcp src 192.168.1.111 metric 600 $ ss -s | head -2 Total: 2501 TCP: 473 (estab 137, closed 307, orphaned 1, timewait 301)

ss (the successor of netstat) lists sockets. The options worth memorising: -t TCP, -u UDP, -l listening only, -n numeric (no DNS or service-name lookups, much faster), -p show the owning process (needs root to see other users' processes), -a all states. A temporary web server on port 18100 makes the output concrete:

Shell - real output: who owns port 18100
$ ss -tlnp | grep ':18100' LISTEN 0 5 127.0.0.1:18100 0.0.0.0:* users:(("python3",pid=2354568,fd=3)) $ lsof -nP -iTCP:18100 -sTCP:LISTEN COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME python3 2354568 vinay-kumar-k 3u IPv4 68510163 0t0 TCP 127.0.0.1:18100 (LISTEN) $ python3 -m http.server 18100 --bind 127.0.0.1 # second server on the same port OSError: [Errno 98] Address already in use

Read the Local Address column: 127.0.0.1:18100 is reachable only from the same host, while 0.0.0.0:8080 (or *:8080) is reachable from outside. "It works with curl on the server but not from my laptop" is very often a service bound to loopback (server.address=127.0.0.1 in Spring) or a security group, not a code bug.

curl: the Swiss army knife for HTTP
Shell - real output against a local server
$ curl -s -o /dev/null -w 'code=%{http_code} dns=%{time_namelookup}s connect=%{time_connect}s ttfb=%{time_starttransfer}s total=%{time_total}s\n' http://127.0.0.1:18100/ code=200 dns=0.000048s connect=0.001023s ttfb=0.002866s total=0.003145s $ curl -sI http://127.0.0.1:18100/ | head -2 HTTP/1.0 200 OK Server: SimpleHTTP/0.6 Python/3.13.11 $ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18101/ ; echo "curl exit=$?" 000 curl exit=7

The -w timings are the quickest way to localise latency: a large dns means resolver trouble, a large connect means network or firewall, a large gap between connect and ttfb means the server is slow to answer, and a large total minus ttfb means a big or slow body. curl exit code 7 means "failed to connect" (nothing listening or refused); 28 means timeout; 6 means could not resolve host. nc -zv host port does the same connect-only check without HTTP:

Shell - real output: open versus closed port
$ nc -zv 127.0.0.1 18100 Connection to 127.0.0.1 18100 port [tcp/*] succeeded! $ nc -zv -w1 127.0.0.1 18101; echo "nc exit=$?" nc: connect to 127.0.0.1 port 18101 (tcp) failed: Connection refused nc exit=1

Connection refused (an immediate RST) means the host is reachable but nothing listens on that port. A timeout (no answer at all) usually means a firewall or security group silently dropping packets, or a wrong route. Telling these two apart is half of network debugging.

DNS: dig versus the real resolver
DNS: what happens when an application resolves a name
Java / curl getaddrinfo() /etc/nsswitch.conf hosts: files ... dns 1 /etc/hosts miss 2 resolver in /etc/resolv.conf stub or upstream DNS server (systemd-resolved 127.0.0.53, VPC resolver, corporate DNS) cache miss recursive lookup root > TLD > authoritative Answer carries a TTL: caches (OS, resolver, and the JVM) may keep it that long. search / ndots: a short name like "payments" is tried with each search domain appended before (or after) the bare name; inside Kubernetes ndots:5 causes several wasted queries per lookup. dig asks the server directly and skips /etc/hosts; getent hosts and curl go through the full path.
Shell - real output (the resolver's own address omitted)
$ dig +short example.com A 172.66.147.243 104.20.23.154 $ dig +noall +answer example.com example.com. 199 IN A 104.20.23.154 example.com. 199 IN A 172.66.147.243 $ getent hosts localhost ::1 localhost

In the second answer, 199 is the remaining TTL in seconds. Useful forms: dig +short NAME (answer only), dig @8.8.8.8 NAME (ask a specific server), dig +trace NAME (walk from the root), dig -x IP (reverse lookup), dig NAME CNAME|MX|TXT|NS. Remember that dig talks to the DNS server directly; **getent hosts follows nsswitch.conf, reads /etc/hosts first and is what applications actually do.** If dig works but the application cannot resolve, check /etc/hosts, nsswitch.conf, the search domains and the JVM's own DNS cache (the networkaddress.cache.ttl security property; check the documented defaults for your JDK version).

tcpdump: see the packets

When the logs and the metrics disagree, the wire does not lie. tcpdump needs root. Always filter and limit (-c), because a busy interface produces thousands of lines per second.

Shell - illustrative (needs root; common recipes)
$ sudo tcpdump -i any -nn -c 20 'tcp port 8080' # first 20 packets on port 8080, no name lookups $ sudo tcpdump -i any -nn 'host 10.0.1.9 and tcp port 5432' # one client talking to a database $ sudo tcpdump -i any -nn 'tcp[tcpflags] & tcp-rst != 0' # only TCP resets $ sudo tcpdump -i any -nn -w /tmp/cap.pcap -c 1000 port 8080 # save to a file to open in Wireshark
  • Flags: [S] SYN (connection attempt), [S.] SYN-ACK, [.] plain ACK, [P.] data pushed, [F.] FIN (orderly close), [R] or [R.] RST (abrupt reset).
  • A SYN that repeats with no reply: dropped by a firewall. A SYN answered by R.: nothing listening. Lots of R mid-stream: a proxy or server closing idle connections.
  • Do not capture traffic that contains secrets or personal data without permission, and prefer TLS-aware tools (curl -v, application logs) when the traffic is encrypted.
06
Chapter Six - systemd and Logs

Running a Spring Boot Jar as a Service

systemd is PID 1 on almost every mainstream server distribution. It starts services in dependency order, restarts them when they fail, limits their resources, and collects their output in the journal. Anything that must survive a reboot or a crash (your jar on an EC2 instance) should be a unit, not a nohup java -jar &.

CommandWhat it does
systemctl status ordersState, main PID, memory, and the last log lines
systemctl start|stop|restart|reload ordersControl the service (stop sends SIGTERM, waits TimeoutStopSec, then SIGKILL)
systemctl enable --now ordersStart at boot and start now
systemctl daemon-reloadRe-read unit files after editing them (forgetting this is a classic)
systemctl cat orders, systemctl show orders -p MainPIDShow the effective unit; query a property
systemctl list-units --failedEverything that has failed
systemd-analyze verify orders.serviceLint a unit file
systemctl edit ordersCreate a drop-in override instead of editing the vendor file
A real unit file

The unit below was written for a Spring Boot jar in /opt/orders. It was checked with systemd-analyze verify, which reported nothing for this file (the warnings in the output belonged to unrelated units already on the machine). It was not installed or started here, because that needs root.

/etc/systemd/system/orders.service
[Unit] Description=Orders service (Spring Boot) Documentation=https://example.com/runbooks/orders After=network-online.target Wants=network-online.target [Service] Type=simple User=orders Group=orders WorkingDirectory=/opt/orders EnvironmentFile=-/etc/orders/orders.env ExecStart=/usr/bin/java -XX:MaxRAMPercentage=75 -jar /opt/orders/orders.jar # The JVM exits with 143 (128 + SIGTERM) after a graceful shutdown: treat that as success. SuccessExitStatus=143 # SIGTERM first (Spring graceful shutdown), SIGKILL after 45 seconds. TimeoutStopSec=45 Restart=on-failure RestartSec=5 LimitNOFILE=65536 MemoryMax=1500M NoNewPrivileges=true ProtectSystem=full PrivateTmp=true [Install] WantedBy=multi-user.target
LineWhy it is there
After= and Wants=network-online.targetStart after the network is configured. Ordering only; use Requires= for hard dependencies
Type=simplesystemd considers the service started as soon as the process is forked. Spring Boot does not speak the sd_notify protocol by itself, so Type=notify would hang unless you add support. Readiness is the health endpoint, not systemd
User= and Group=Never root. The account from chapter 01
EnvironmentFile=-/etc/orders/orders.envSecrets and per-host settings (SPRING_PROFILES_ACTIVE, SPRING_DATASOURCE_URL) as KEY=value lines. The leading - means "ignore if the file is absent"
-XX:MaxRAMPercentage=75Heap as a share of the memory the JVM sees; the JVM reads the cgroup limit that MemoryMax sets
SuccessExitStatus=143The JVM exits with 143 after SIGTERM; without this systemd would record a failed stop
TimeoutStopSec=45Grace period for Spring's graceful shutdown (its own phase timeout defaults to 30 s) before systemd sends SIGKILL. systemd's default is 90 s
Restart=on-failure, RestartSec=5Restart after a crash or non-success exit, but not after a clean stop. systemd also rate-limits restarts (StartLimitBurst), so a crash loop eventually gives up
LimitNOFILE=65536Raises the open-file limit for this service only (chapter 09)
MemoryMax=1500MA cgroup memory ceiling: the unit is OOM-killed instead of taking the whole host down
NoNewPrivileges, ProtectSystem=full, PrivateTmpCheap hardening: no setuid escalation, read-only /usr and /boot, a private /tmp
WantedBy=multi-user.targetWhat systemctl enable hooks the unit into, so it starts at normal boot
Shell - deploying it (illustrative: needs root)
$ sudo cp orders.service /etc/systemd/system/ $ sudo systemctl daemon-reload $ sudo systemctl enable --now orders $ systemctl status orders --no-pager $ curl -s localhost:8080/actuator/health # readiness is the app's job, not systemd's
journalctl and plain-text logs

systemd captures the service's stdout and stderr in the journal, so a Spring Boot app logging to the console needs no log file at all. Query it with journalctl; the unit name is the filter:

Shell - the journalctl cheat sheet (illustrative: run it against your own service)
$ journalctl -u orders -f # follow, like tail -f $ journalctl -u orders -n 100 --no-pager # last 100 lines $ journalctl -u orders --since "10 min ago" $ journalctl -u orders --since 09:00 --until 09:30 $ journalctl -u orders -p err # priority error and worse $ journalctl -u orders -o json-pretty -n 1 # structured fields $ journalctl -b -1 -k # kernel messages from the previous boot (OOM kills!) $ journalctl --disk-usage
  • On a default install the journal may be volatile (kept in memory under /run, lost at reboot) unless /var/log/journal exists or Storage=persistent is set in journald.conf. Check before you need last night's logs.
  • Classic text logs still live in /var/log (syslog, auth.log, messages, nginx and others), rotated by logrotate. Use tail -F (capital F follows across rotation), less +F, and zcat or zgrep for the rotated .gz files.
  • In containers and on Kubernetes the rule is the opposite: log to stdout and let the platform collect it (page 07).
07
Chapter Seven - Text Processing

grep, sed, awk, jq, xargs and Pipelines

Unix tools do one thing and read stdin, write stdout, so they compose with |. A five-stage pipeline answers questions no dashboard anticipated. Everything in this chapter was run on the three small sample files below; the outputs are real.

orders.log - a Spring Boot style application log (sample, 10 lines)
2026-10-31T10:00:01.120Z INFO [http-nio-8080-exec-1] c.a.orders.OrderController - POST /orders 201 traceId=a1f3 took=42ms 2026-10-31T10:00:02.480Z INFO [http-nio-8080-exec-2] c.a.orders.OrderController - GET /orders/17 200 traceId=b2c4 took=8ms 2026-10-31T10:00:03.901Z WARN [http-nio-8080-exec-3] c.a.orders.PaymentClient - retry 1 for payment-svc traceId=c3d5 2026-10-31T10:00:05.015Z ERROR [http-nio-8080-exec-3] c.a.orders.PaymentClient - payment-svc timeout after 2000ms traceId=c3d5 2026-10-31T10:00:05.020Z INFO [http-nio-8080-exec-3] c.a.orders.OrderController - POST /orders 502 traceId=c3d5 took=2011ms 2026-10-31T10:00:07.700Z INFO [http-nio-8080-exec-4] c.a.orders.OrderController - GET /orders/18 200 traceId=d4e6 took=11ms 2026-10-31T10:00:09.310Z INFO [http-nio-8080-exec-1] c.a.orders.OrderController - GET /orders/17 200 traceId=e5f7 took=7ms 2026-10-31T10:00:11.002Z ERROR [http-nio-8080-exec-2] c.a.orders.OrderRepository - Connection is not available, request timed out after 30000ms traceId=f6a8 2026-10-31T10:00:11.010Z INFO [http-nio-8080-exec-2] c.a.orders.OrderController - GET /orders/19 500 traceId=f6a8 took=30009ms 2026-10-31T10:00:12.555Z INFO [http-nio-8080-exec-4] c.a.orders.OrderController - POST /orders 201 traceId=a7b9 took=51ms
access.log - an nginx-style access log with the request time as the last field (sample)
10.0.1.5 - - [31/Oct/2026:10:00:01 +0000] "GET /orders/17 HTTP/1.1" 200 512 0.008 10.0.1.6 - - [31/Oct/2026:10:00:02 +0000] "POST /orders HTTP/1.1" 201 87 0.042 10.0.1.5 - - [31/Oct/2026:10:00:03 +0000] "GET /orders/18 HTTP/1.1" 200 498 0.011 10.0.1.9 - - [31/Oct/2026:10:00:04 +0000] "POST /orders HTTP/1.1" 502 0 2.011 10.0.1.5 - - [31/Oct/2026:10:00:05 +0000] "GET /orders/17 HTTP/1.1" 200 512 0.007 10.0.1.9 - - [31/Oct/2026:10:00:06 +0000] "GET /health HTTP/1.1" 200 15 0.001 10.0.1.6 - - [31/Oct/2026:10:00:07 +0000] "GET /orders/19 HTTP/1.1" 500 120 30.009 10.0.1.9 - - [31/Oct/2026:10:00:08 +0000] "POST /orders HTTP/1.1" 502 0 2.004
ToolJobOne-line mental model
grepSelect lines by patternfilter rows. -E extended regex, -i ignore case, -v invert, -c count, -o only the match, -n line numbers, -r recurse, -A 3 / -B 3 context lines
sedEdit a streamfind and replace: s/old/new/, -n with p to print only matches, -E for extended regex, -i edits in place (take a backup)
awkTreat each line as fields$1, $NF (last field), conditions and sums: awk '$9 >= 500 {n++} END {print n}'
cut, tr, pasteSlice columns, translate characters, joincut -d= -f2, tr -d ms
sort, uniqOrder and groupuniq only collapses adjacent duplicates, so always sort | uniq -c; sort -n numeric, -h human sizes, -r reverse, -k2 by column
jqQuery JSONfilter, map and reshape JSON lines and API responses
xargsTurn lines into arguments... | xargs -I{} cmd {}; use -0 with find -print0 for names with spaces
wc, head, tail, teeCount, take the ends, split a stream to a filetail -F follows through log rotation
Real pipelines
Shell - real output: how many errors, and which lines
$ grep -c ERROR orders.log 2 $ grep -E 'ERROR|WARN' orders.log | cut -c1-90 2026-10-31T10:00:03.901Z WARN [http-nio-8080-exec-3] c.a.orders.PaymentClient - retry 1 f 2026-10-31T10:00:05.015Z ERROR [http-nio-8080-exec-3] c.a.orders.PaymentClient - payment-s 2026-10-31T10:00:11.002Z ERROR [http-nio-8080-exec-2] c.a.orders.OrderRepository - Connect
Shell - real output: the classic status-code histogram (awk, sort, uniq -c, sort -rn)
$ awk '{print $9}' access.log | sort | uniq -c | sort -rn 4 200 2 502 1 500 1 201 $ awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -3 # busiest clients 3 10.0.1.9 3 10.0.1.5 2 10.0.1.6
Shell - real output: latency questions with awk (request time is the last field, $NF)
$ awk '$NF > 1 {print $7, $9, $NF}' access.log # requests slower than 1 second /orders 502 2.011 /orders/19 500 30.009 /orders 502 2.004 $ awk '{s+=$NF; n++} END {printf "%d requests, avg %.3fs\n", n, s/n}' access.log 8 requests, avg 4.262s

The average (4.3 s) is dominated by one 30-second request. That is why dashboards show percentiles, and why you should look at the distribution before quoting a mean. Here is the same idea on the application log, extracting the took= values with grep -o:

Shell - real output: the three slowest requests in milliseconds
$ grep -o 'took=[0-9]*ms' orders.log | cut -d= -f2 | tr -d ms | sort -n | tail -3 51 2011 30009
Shell - real output: sed to extract and to mask
$ sed -n 's/.*traceId=\([a-z0-9]*\).*/\1/p' orders.log | head -3 a1f3 b2c4 c3d5 $ sed -E 's/(traceId=)[a-z0-9]+/\1***/' orders.log | head -1 2026-10-31T10:00:01.120Z INFO [http-nio-8080-exec-1] c.a.orders.OrderController - POST /orders 201 traceId=*** took=42ms $ grep f6a8 orders.log | wc -l # follow one request across components by its trace id 2
jq and xargs
events.json - one JSON object per line (sample)
{"level":"INFO","service":"orders","msg":"created","orderId":"o-1","ms":42} {"level":"ERROR","service":"payments","msg":"timeout","orderId":"o-1","ms":2000} {"level":"INFO","service":"orders","msg":"created","orderId":"o-2","ms":51} {"level":"WARN","service":"orders","msg":"slow","orderId":"o-3","ms":950}
Shell - real output
$ jq -r 'select(.level != "INFO") | "\(.service) \(.msg) \(.ms)ms"' events.json payments timeout 2000ms orders slow 950ms $ jq -s -c 'group_by(.service) | map({service: .[0].service, n: length, maxMs: (map(.ms) | max)})' events.json [{"service":"orders","n":3,"maxMs":950},{"service":"payments","n":1,"maxMs":2000}] $ printf 'a\nb\nc\n' | xargs -I{} echo "pod-{}" pod-a pod-b pod-c

-r prints raw strings without quotes, -s ("slurp") reads all the lines into one array so you can group_by, and -c prints compactly. Real-world uses: aws ... | jq -r '.Reservations[].Instances[].InstanceId' and kubectl get pods -o json | jq .... For files with awkward names, always pair find -print0 with xargs -0:

Shell - illustrative
$ find /var/log/orders -name '*.log' -mtime +14 -print0 | xargs -0 gzip $ grep -rl 'OutOfMemoryError' /var/log/orders | xargs -r ls -l
⚠ Parsing with the wrong tool
  • Regex on JSON, HTML or YAML is fragile: use jq for JSON and yq for YAML. And ls | grep or for f in $(ls) break on spaces; use globs (for f in *.log) or find -print0. When a pipeline gets past about five stages or needs state, switch to a real script.
08
Chapter Eight - Shell Scripting

Writing Scripts You Can Trust at 3 a.m.

A script that deploys, rotates, backs up or waits for a service is code, and it fails in the same ways code does. The difference is that the shell's defaults are unsafe: it keeps going after errors, treats unset variables as empty strings, and splits unquoted values on spaces. The standard header turns those defaults off.

Shell - the safe header
#!/usr/bin/env bash set -euo pipefail
PieceEffectDemonstration
#!/usr/bin/env bashShebang: which interpreter runs the file. env finds bash on the PATH; the file also needs chmod +xWithout the executable bit: permission denied
set -eExit immediately if a command fails (with exceptions: conditions, && and || lists)Script stops at the first failing step
set -uAn unset variable is an error, not an empty stringReal: bash: line 1: UNDEFINED_VAR: unbound variable, non-zero exit
set -o pipefailA pipeline fails if any stage fails, not only the lastSee below
Shell - real output: why pipefail matters
$ bash -c 'false | true; echo "no pipefail: $?"' no pipefail: 0 $ bash -c 'set -o pipefail; false | true; echo "pipefail: $?"' pipefail: 1
Quoting: the single biggest source of shell bugs
Shell - real output
$ f="my file.txt" $ printf '[%s]\n' $f # unquoted: split into two words [my] [file.txt] $ printf '[%s]\n' "$f" # quoted: one word [my file.txt]
  • Always double-quote variable expansions: "$var", "$@", "$(command)". Use single quotes when you want no expansion at all.
  • Prefer [[ ... ]] over [ ... ] in bash (no word splitting inside, supports == patterns and =~ regex).
  • Use $(command), never backticks; they nest and read better.
  • Defaults and required values: "${PORT:-8080}" (default when unset) and "${1:?usage: script URL}" (abort with a message when missing).
A complete, useful script

wait_for_http.sh polls a health URL until it returns 200 or a timeout expires. It is the kind of step a deployment or an integration test needs after starting a service. It uses a function, a trap for cleanup, arithmetic, quoting, required and default arguments, and explicit exit codes. It was validated with bash -n and then run for real against a throw-away local server (output below the listing).

wait_for_http.sh
#!/usr/bin/env bash # Usage: wait_for_http.sh URL [TIMEOUT_SECONDS] # Polls URL until it returns HTTP 200, or exits 1 after the timeout. set -euo pipefail url="${1:?usage: $0 URL [TIMEOUT_SECONDS]}" timeout="${2:-30}" tmp="$(mktemp -d)" log() { printf '%s %s\n' "$(date +%T)" "$*" >&2; } cleanup() { rm -rf "$tmp"; } trap cleanup EXIT deadline=$(( SECONDS + timeout )) while (( SECONDS < deadline )); do code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 "$url" || true)" if [[ "$code" == "200" ]]; then log "healthy: $url" exit 0 fi log "not ready (HTTP ${code:-000}), retrying" sleep 1 done log "gave up after ${timeout}s waiting for $url" exit 1
Shell - real output: success, timeout and missing argument
$ bash -n wait_for_http.sh && echo syntax-ok syntax-ok $ ./wait_for_http.sh http://127.0.0.1:18099/health 5; echo "exit=$?" 23:14:08 healthy: http://127.0.0.1:18099/health exit=0 $ ./wait_for_http.sh http://127.0.0.1:18099/nope 2; echo "exit=$?" 23:14:08 not ready (HTTP 404), retrying 23:14:09 not ready (HTTP 404), retrying 23:14:10 gave up after 2s waiting for http://127.0.0.1:18099/nope exit=1 $ ./wait_for_http.sh; echo "exit=$?" ./wait_for_http.sh: line 6: 1: usage: ./wait_for_http.sh URL [TIMEOUT_SECONDS] exit=1
  • log() writes to stderr (>&2) so that stdout stays clean for data a caller might capture.
  • || true after curl stops set -e killing the script when curl exits non-zero (connection refused is exactly the case we want to retry).
  • trap cleanup EXIT runs on normal exit, on exit 1, and on errors under set -e, so the temporary directory is always removed. Trap TERM and INT too if you must clean up on a kill (see sig.sh in chapter 02).
  • SECONDS is a bash built-in that counts seconds since the shell started: a cheap clock for timeouts.
Exit codes and control flow
IdeaSyntaxNote
Last command's status$?0 is success. Read it immediately; the next command overwrites it
Run B only if A succeeds / failsA && B A || BThe idiom cmd || { echo "failed"; exit 1; }
Test then actif grep -q ERROR f; then ...; figrep -q is silent and returns 0 on a match: use commands as conditions, no [ ] needed
Functionsname() { local x=1; ...; return 0; }local keeps variables out of the global scope; return sets the function status, output goes to stdout
Loop over filesfor f in /var/log/orders/*.log; do ...; doneQuote "$f" inside
Read lineswhile IFS= read -r line; do ...; done < file-r keeps backslashes; IFS= keeps leading spaces
Arguments"$1", "$@", $#, shift"$@" preserves each argument; $* does not
Common codes0 ok, 1 general error, 2 misuse, 126 not executable, 127 command not found, 130 Ctrl-C, 137 SIGKILL, 143 SIGTERMReturn distinct codes your callers can act on

Two more habits: run **shellcheck** (a static analyser that spots quoting and logic bugs; not installed on this machine, so it was not run here, but it is one package install away and worth wiring into CI), and use bash -n script.sh for a syntax-only check and bash -x script.sh to trace every command as it executes.

⚠ Scripts that "worked on my machine"
  • rm -rf "$BUILD_DIR/" with an unset variable (use set -u or "${BUILD_DIR:?}"), cd somewhere; rm * where the cd failed (use cd ... || exit 1 or set -e), unquoted $@, and relying on bash features while the shebang says sh (on Debian and Ubuntu /bin/sh is dash, which has no [[ and no arrays). Declare bash and use it.
09
Chapter Nine - Limits, Namespaces and cgroups

File Descriptors, Limits and the Foundations of Containers

ulimits and file descriptors

A file descriptor (fd) is a small integer a process uses for an open file, socket, or pipe. Every client connection, every database connection, every open log file uses one. Each process has a limit, the ulimit, on how many it may hold (nofile) and on processes (nproc). Hitting it produces the infamous java.io.IOException: Too many open files or SocketException, usually under load and often only in production, where the limit is lower than on your laptop.

Shell - real output (this machine has generous limits; many servers default to 1024)
$ ulimit -Sn; ulimit -Hn # soft and hard limit for open files 1048576 1048576 $ grep -E 'Max open files|Max processes' /proc/$$/limits Max processes 115158 115158 processes Max open files 1048576 1048576 files $ ls /proc/$$/fd | wc -l # how many fds the current shell holds 5
Where the limit comes fromHow to change it
Interactive shellulimit -n 65536 (only up to the hard limit), per shell
PAM login sessions/etc/security/limits.conf or /etc/security/limits.d/*.conf: orders soft nofile 65536
systemd serviceLimitNOFILE=65536 in the unit: limits.conf does not apply to systemd services
Docker containerdocker run --ulimit nofile=65536:65536
Kernel-wide ceilingfs.file-max, fs.nr_open
Shell - diagnose a descriptor leak on a live process (illustrative: PID 4242)
$ ls /proc/4242/fd | wc -l # how many right now $ grep 'open files' /proc/4242/limits # against what limit $ ls -l /proc/4242/fd | awk '{print $NF}' | sed 's/[0-9]*$//' | sort | uniq -c | sort -rn | head $ lsof -p 4242 -a -i | head # which sockets, to which peers

If the count climbs steadily and never falls, you have a leak: connections or streams that are never closed (an unclosed InputStream, an HTTP client created per request, a pool with no maximum). Raising the limit only postpones the crash; fix the leak, and set the limit high enough for legitimate peak concurrency (one fd per concurrent connection, plus files).

Namespaces: what a process can see

A namespace gives a group of processes their own private copy of one global resource. Linux has namespaces for PID (process numbering; the first process is PID 1), network (interfaces, routing table, ports), mount (the filesystem tree), UTS (hostname), IPC, user (UID mapping), plus cgroup and time. Every process is in exactly one namespace of each kind, visible as links under /proc/PID/ns; two processes share a namespace when the numbers match.

Shell - real output: the namespaces of my shell (the host's own, shared by every normal process)
$ ls -l /proc/$$/ns | awk '{print $9, $10, $11}' cgroup -> cgroup:[4026531835] ipc -> ipc:[4026531839] mnt -> mnt:[4026531832] net -> net:[4026531833] pid -> pid:[4026531836] user -> user:[4026531837] uts -> uts:[4026531838]
Shell - unshare demo (ILLUSTRATIVE: unprivileged user namespaces are blocked on the machine used to write this page, so this output was not produced here; run it with sudo on a box that allows it)
$ sudo unshare --pid --fork --mount-proc --uts sh -c 'hostname demo; hostname; ps -o pid,comm; echo "I am PID $$"' demo PID COMMAND 1 sh 2 ps I am PID 1 $ hostname # back in the host: unchanged my-laptop

That is the whole trick: a shell that believes it is PID 1, has its own hostname and sees only its own processes. When a container runs ps, it sees a handful of processes, not the host's hundreds, for exactly this reason. Docker adds a network namespace with a virtual ethernet pair, a mount namespace whose root is the image's filesystem, and a user namespace if configured.

cgroups: what a process may use

Control groups organise processes into a tree and enforce resource budgets and accounting per node. Modern distributions use cgroup v2 (a single hierarchy mounted at /sys/fs/cgroup, filesystem type cgroup2). Every process's group is shown in /proc/PID/cgroup; systemd puts each service in its own cgroup, which is how MemoryMax= in the unit works and how systemctl status shows per-service memory.

Shell - real output
$ stat -fc %T /sys/fs/cgroup cgroup2fs $ cat /sys/fs/cgroup/cgroup.controllers cpuset cpu io memory hugetlb pids rdma misc dmem $ cat /proc/$$/cgroup 0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-org.gnome.Terminal.slice/vte-spawn-....scope

The single line 0::/path is the v2 format: hierarchy 0, no controller list, then the group path. For a process in a Docker container on a cgroup v2 host the path looks like 0::/system.slice/docker-<container-id>.scope seen from the host (illustrative); inside the container you usually see 0::/, because the container has its own cgroup namespace.

cgroup v2 fileMeaningContainer setting that drives it
memory.maxHard memory limit; exceeding it triggers the OOM killer inside this groupdocker run --memory 512m; Kubernetes resources.limits.memory
memory.current, memory.eventsUsage now; counters including oom_killread-only
cpu.maxquota period, e.g. 50000 100000 = half a CPU. The group is throttled, not killed, when it uses its quota--cpus 0.5; Kubernetes resources.limits.cpu
cpu.weightRelative share when CPUs are contended--cpu-shares; Kubernetes resources.requests.cpu
pids.maxMaximum processes and threads (a fork-bomb brake)--pids-limit
io.maxPer-device bandwidth and IOPS limits--device-write-bps
A container is a process with a restricted view (namespaces) and a budget (cgroups)
Linux kernel (shared by every container on the host) container process java -jar orders.jar sees: its own PID 1, its own network, hostname, mounts and a private root filesystem namespaces: WHAT it sees pid process IDs net interfaces, ports mnt mount table uts hostname ipc shared memory user UID mapping cgroups: HOW MUCH memory.max cpu.max pids.max io.max memory.events Docker and Kubernetes create the process with these namespaces, write these cgroup files, and mount an image as its root filesystem No guest kernel and no hypervisor: containers start in milliseconds and share the host kernel (and its bugs).
Why this belongs in a DevOps course
  • Memory limit exceeded gives exit 137 and OOMKilled (a hard kill); CPU limit exceeded gives throttling (a latency problem, no error anywhere). Those two sentences explain a large share of "my pod is restarting" and "my pod is slow" tickets. Pages 02 and 03 build on this chapter.
10
Chapter Ten - Troubleshooting Playbook

Slow, Full, Busy and Out of Memory

Production problems feel unique, but the first ten minutes are almost always the same few commands. The three diagrams below are decision trees: follow the question, run the command, branch on the result. They deliberately end in a next action, not in a theory.

The method: Observe, hypothesise, test, change one thing. Collect a few facts before touching anything: what changed recently (deploy, config, traffic), what the symptom is exactly (error text, exit code), since when, and for whom. Then walk the tree. The worst outcome is a fix applied to the wrong layer that hides the real cause.
Playbook 1: "the service is slow"
Is it slow for everyone, or one endpoint? uptime / top: load vs cores, CPU us/sy/wa, steal CPU high wa high all idle top -H, then a JVM thread dump (kill -3 / jstack): hot loop, GC storm, regex, busy lock? iostat -x 1, df -h, dmesg: slow or full disk, swap? vmstat si/so Waiting on something else: locks, thread pool, DB pool, downstream call curl -w timings; ss -tn (Send-Q, ESTAB, TIME-WAIT) DNS slow? connect slow? upstream slow? Check its logs, DB active connections, slow query log Capture evidence BEFORE restarting: thread dump, heap histogram, top output, ss -s. A restart fixes the symptom and destroys the proof.
SymptomFirst commandMost common cause behind it
Everything slow, load very highuptime; topCPU saturation, a runaway thread, or GC thrash
Slow, CPU idlecurl -w, ss -tn, thread dumpBlocked on a database, a downstream call or an exhausted connection pool
Intermittent timeoutsdmesg, ss -sPort or fd exhaustion, DNS, packet loss
Random restarts, exit 137dmesg -T | grep -i oomMemory limit too low, or heap sized as large as the limit
No space left on devicedf -hT; df -iLogs, Docker layers, inode exhaustion, deleted-but-open files
Cannot bind portss -tlnpA previous instance still running, or a port clash
Permission deniedls -l, id, ps -o user= -p PIDWrong owner or group, umask, or a mount option such as noexec
Works on the server, not from outsidess -tlnp, security group, firewallBound to 127.0.0.1, or a blocked port
Playbook 2: "the box is full" (No space left on device)
df -hT : which filesystem? Use% high? yes du -xh --max-depth=1 | sort -rh Big dir found: logs, docker, old jars, core dumps? Clean it up low, still full df -i : inodes at 100%? yes find -xdev | cut | uniq -c: file counts Delete tiny files no du total much below df used? yes lsof +L1 : deleted files still open Truncate via fd or restart Prevention: logrotate with a size cap, journald SystemMaxUse, docker system df, a disk alert at 80%, and separate volumes for /var/log and /var/lib so one runaway cannot stop the OS.
Playbook 3: "port already in use" and "out of memory"
Address already in use / cannot bind ss -tlnp | grep :8080 who is the PID? old instance of the same app: stop it properly (systemctl stop, SIGTERM) another service: change a port nothing listed: check ss -tan states Out of memory (process died, exit 137, or the host is thrashing) dmesg -T | grep -i oom Killed process (java)? yes Whose limit? host RAM or cgroup memory.max (memory.events oom_kill) Host out of RAM: top, free -h, vmstat 1 (si/so). Find the biggest consumer; add RAM or move work; swap only as a cushion Container or unit limit: raise it or shrink the app. JVM: -XX:MaxRAMPercentage, heap vs non-heap, thread count, direct buffers A JVM OutOfMemoryError is DIFFERENT: the JVM threw it (heap or metaspace full), nothing killed it. Use -XX:+HeapDumpOnOutOfMemoryError and analyse the dump; the kernel OOM killer leaves no dump. Bind failures can also be permission (ports below 1024 need privileges) or a wrong address.
A 60-second checklist for an unfamiliar box
  • uptime (load; a short uptime means a recent reboot), dmesg -T | tail (OOM, disk errors), vmstat 1 5 (run queue, swap, wa), free -h, df -h; df -i, ss -s, top sorted by CPU then by memory, journalctl -p err -n 50. It is the "USE" idea (utilisation, saturation, errors for each resource) turned into a handful of commands.
11
Chapter Eleven - Practice

Hands-On Exercises and Common Mistakes

Do these on a throw-away VM, a WSL instance or a local Ubuntu. Every command is safe on a personal machine; none needs root unless marked. Each exercise ends with what you should observe.

Exercises
#TaskYou should observe
1Create a file, then set it to 640, 750 and 4755 with chmod; read each result with stat -c "%A %a". Change the umask in a subshell and create filesThe letters match the octal; 4755 shows s in the owner triplet; files created under umask 077 are 600
2Start sleep 300 &, send kill, then kill -9 to another, and print $? after wait143 and 137: exit code = 128 + signal number
3Write sig.sh from chapter 02, run it in the background, send SIGTERM, then change the trap to ignore TERM (trap "" TERM) and see that kill no longer stops it but kill -9 doesCatching versus the uncatchable signal
4Read /proc/loadavg, run yes > /dev/null & once per CPU core (nproc), watch the 1-minute load climb for a minute, then kill themLoad rises toward the core count; clean up with kill %1 %2 ...
5Start python3 -m http.server 18100, find it with ss -tlnp, start a second one, read the error, then probe with curl -w and nc -zv on open and closed portsErrno 98; exit code 7 for refused; the timing breakdown
6Mount a small tmpfs (sudo mount -t tmpfs -o size=10m tmpfs /mnt/t), fill it with dd, then delete a file that a background tail -f holds opendf stays full after the delete; lsof +L1 shows the culprit; killing tail frees the space
7Write a pipeline that prints the five most frequent IP addresses and the share of 5xx responses from an access log (use the sample in chapter 07)Your sort | uniq -c | sort -rn | head -5 and an awk ratio
8Adapt wait_for_http.sh to take an expected status code as a third argument; run bash -n and shellcheckSyntax OK; shellcheck finds nothing
9Write a unit file for any small program, systemd-analyze verify it, install it, break it on purpose (bad path), and read journalctl -u to diagnose (needs root)The journal shows the exact exec error
10Inspect /proc/self/cgroup and /sys/fs/cgroup; run systemd-run --user --scope -p MemoryMax=100M sleep 60 and read that group's memory.max (cgroup v2; needs a user systemd with delegation)A transient scope with the limit applied
Common mistakes
⚠ Collected from real incidents
  • Editing a unit file and not running systemctl daemon-reload.
  • Putting ulimit -n in /etc/security/limits.conf for a systemd service (it needs LimitNOFILE=).
  • Running a service as root, or as a login user whose home is shared.
  • Unquoted variables and missing set -euo pipefail in deployment scripts.
  • Trusting free instead of available, and -Xmx equal to the container limit.
  • Using kill -9 as the first move, and sh -c "java ..." as a container entrypoint (the shell is PID 1 and swallows SIGTERM; use exec).
  • Investigating after the restart: no thread dump, no dmesg, no evidence left.
  • Reading an average latency without looking at the tail: a 30-second outlier hides inside "4 s average".
12
Chapter Twelve - Interview

Linux Interview Q&A

  • Q: What does chmod 750 mean, and what do setuid and the sticky bit do?
    7 for the owner (rwx), 5 for the group (r-x), 0 for others. Setuid makes an executable run with its owner's privileges (passwd); setgid on a directory makes new files inherit its group; sticky on a directory (/tmp) lets only a file's owner delete it.
  • Q: How is the default file mode decided?
    Start from 666 for files (777 for directories) and clear the bits in the umask; umask 022 gives 644 and 755. It is inherited from the parent process, so a service should set modes explicitly.
  • Q: What is the difference between SIGTERM and SIGKILL?
    SIGTERM (15) can be caught, so the process can finish requests and release resources; SIGKILL (9) cannot be caught or ignored, the kernel simply removes the process. Send TERM first, wait, escalate to KILL. Exit codes 143 and 137 record them.
  • Q: Why does PID 1 matter in a container?
    The kernel gives PID 1 no default signal handlers, so SIGTERM without a handler is ignored, and PID 1 must reap orphaned children. A shell wrapper that does not exec the app, or does not forward signals, causes a delay at shutdown that ends in SIGKILL. Use exec, a real handler (the JVM has one), or an init like tini (docker run --init).
  • Q: What is a zombie process, and how do you remove it?
    A child that exited but whose parent has not called wait(); it holds only a process-table entry. You cannot kill it: fix or restart the parent, and PID 1 will adopt and reap the zombie.
  • Q: What does load average measure?
    The damped average over 1, 5, 15 minutes of tasks that are runnable or in uninterruptible sleep. Compare it with the number of cores, and look at wa and the process states to tell CPU contention from I/O waits.
  • Q: A server shows 1 GB free of 32 GB. Is something wrong?
    Not necessarily: Linux caches files in spare RAM. Check available in free, swap-in/out activity in vmstat, and OOM messages in dmesg.
  • Q: A process was killed with exit code 137. How do you find out why?
    Check dmesg -T | grep -i "killed process" or journalctl -k for the OOM killer; in containers check the cgroup memory.events and the platform status (OOMKilled). Otherwise something sent SIGKILL (an orchestrator whose grace period expired, a script).
  • Q: df says the disk is full but du finds little. Why?
    Deleted files still held open by a process (find with lsof +L1, free by restarting or truncating), files hidden under a mount point, or inode exhaustion (compare df -i).
  • Q: "Connection refused" versus a timeout?
    Refused: the host answered with a reset, nothing listens on that port or address. Timeout: packets are dropped (firewall, security group, route) or the host is down. nc -zv, ss -tlnp and tcpdump separate them.
  • Q: Why does dig resolve a name that the application cannot?
    dig queries a DNS server directly. Applications use getaddrinfo, which follows nsswitch.conf (/etc/hosts first), the search domains and ndots, and any in-process cache such as the JVM's.
  • Q: What does set -euo pipefail do?
    Abort on any failing command, treat unset variables as errors, and make a pipeline fail when any stage fails. Together with quoting, they remove the most common silent failures of shell scripts.
  • Q: How do you stop a "Too many open files" error?
    Find the real count (ls /proc/PID/fd | wc -l) and limit (/proc/PID/limits). If it climbs forever, fix the leak (unclosed connections or streams). If legitimate, raise LimitNOFILE in the unit, --ulimit in Docker, or limits.conf for login sessions.
  • Q: What are namespaces and cgroups, and how do they relate to containers?
    Namespaces limit what a process sees (PIDs, network, mounts, hostname, users); cgroups limit what it can use (memory, CPU, PIDs, I/O). A container is a normal process started with a set of namespaces, a cgroup and an image as root filesystem, sharing the host kernel, unlike a VM.
  • Q: What happens at the cgroup memory limit versus the CPU limit?
    Over memory.max the OOM killer kills a process in that group (exit 137). Over the CPU quota the group is throttled, so latency grows but nothing dies.
  • Q: Why a systemd unit rather than nohup java -jar &?
    Restart on failure, start at boot, dependency ordering, resource limits, a dedicated user and sandboxing, standard status and journalctl access, and a defined stop sequence (SIGTERM then SIGKILL).
  • Q: How do you find the ten most common errors in a log?
    grep ERROR app.log | sed -E "s/[0-9]+/N/g" | sort | uniq -c | sort -rn | head: normalise the variable parts, then count. uniq needs sorted input.
13
Chapter Thirteen - Summary

Cheat Sheet and Recap

Cheat sheet
NeedCommandNotes
Who runs whatps -ef --forest, pgrep -af name, top (P, M, H, 1)STAT: R, S, D (unkillable I/O wait), Z (zombie)
Stop a processkill PID then kill -9 PID143 = TERM, 137 = KILL
Load and CPUuptime, top, vmstat 1, pidstat 1Load vs core count; wa, st
Memoryfree -h, vmstat 1, dmesg -T | grep -i oomRead available; watch swap activity
Diskdf -hT, df -i, du -xh --max-depth=1 | sort -rh, lsof +L1Blocks, inodes, deleted-but-open
Networkip -br addr, ss -tlnp, nc -zv, curl -w, dig, tcpdumpBottom-up; refused vs timeout
Servicesystemctl status|restart|enable --now, journalctl -u X -fdaemon-reload after editing units
Textgrep -E, awk, sed -E, jq, sort | uniq -c | sort -rn, xargs -0uniq needs sorted input
Scriptsset -euo pipefail, quote everything, trap cleanup EXIT, bash -n, shellcheckExplicit exit codes
Limitsulimit -n, /proc/PID/limits, LimitNOFILE=limits.conf does not apply to systemd
Containers underneath/proc/PID/ns, /proc/PID/cgroup, /sys/fs/cgroupnamespaces = see, cgroups = use
∑ Linux and shell essentials - what to remember
  • Everything is a file, and permissions are three triplets. Fix ownership, not modes; never run services as root.
  • SIGTERM is a request, SIGKILL is a removal. Exit codes 143 and 137 tell the story; PID 1 in a container needs a signal handler or an init.
  • Load average counts runnable and uninterruptible tasks. Judge it against cores and look at wa; available, not free, is memory.
  • Disks fill three ways: blocks, inodes, and deleted-but-open files. Different tools find each.
  • Debug networks bottom-up, and separate refused from timeout.
  • systemd units beat nohup: dedicated user, Restart=, LimitNOFILE=, SuccessExitStatus=143, journal for logs.
  • Pipelines answer ad-hoc questions; scripts need set -euo pipefail, quotes and traps.
  • A container is a process with namespaces and a cgroup. Memory limit means OOM kill; CPU limit means throttling.
Version note

Commands were run on Ubuntu with kernel 7.0 (systemd 255, util-linux 2.39, cgroup v2) on 2026-10-31; outputs marked real were pasted from those runs with hostnames and private addresses abridged, and anything labelled illustrative needs root or a different machine (unprivileged unshare and dmesg were blocked here). Scripts were validated with bash -n; the unit file with systemd-analyze verify. shellcheck, htop and traceroute were not installed and were not run. Defaults quoted from memory of stable behaviour (swappiness 60, systemd stop timeout 90 s, Spring graceful shutdown phase timeout 30 s, JDK container support) were not re-fetched from vendor docs for this page: recheck them on your own distribution and versions.

Next in this section