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.
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).
| Path | What lives there | Spring/Java engineer cares because |
|---|---|---|
/etc | System and service configuration (/etc/hosts, /etc/resolv.conf, /etc/systemd/system) | Where unit files, limits.conf and sudoers live |
/var/log | Logs of the OS and installed daemons; the journal under /var/log/journal when persistent | First stop when something broke; the first thing to fill a disk |
/var/lib | Persistent state of daemons (databases, Docker images and volumes under /var/lib/docker) | Big, growing, and often on its own volume |
/opt, /srv, /usr/local | Third-party or locally deployed software | A conventional home for /opt/orders/orders.jar |
/tmp, /var/tmp | Scratch space; /tmp is often cleared at boot, /var/tmp survives reboots | Tomcat and Netty use it for temporary files; world-writable with the sticky bit |
/proc | Virtual: one directory per process plus kernel info (/proc/meminfo, /proc/loadavg) | Every tool in this page reads it; so can you |
/sys | Virtual: devices, drivers, and the cgroup tree under /sys/fs/cgroup | Where container resource limits are visible |
/dev | Device files: null, zero, urandom, disks, terminals | /dev/urandom and /dev/null show up in scripts and Dockerfiles |
/home, /root | User home directories | Not 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.
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.
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.
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.
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.
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:
- "Permission denied" is the system telling you the owner or group is wrong.
chmod -R 777makes 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.
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.
The STAT column is the process state. Letters you will meet daily:
| STAT | State | Meaning and what to do |
|---|---|---|
R | Running or runnable | On a CPU or waiting for one. Many R at once means CPU contention |
S | Interruptible sleep | Waiting for an event (network, timer, input). The normal state of an idle service |
D | Uninterruptible sleep | Waiting 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 |
T | Stopped | Paused by SIGSTOP or Ctrl-Z. Resume with kill -CONT |
Z | Zombie | Exited, but the parent has not collected its exit status. Uses no memory, only a process-table slot |
s, l, +, N | Modifiers | Session leader, multi-threaded, foreground job, low priority (nice) |
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".
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.
| Signal | Number | Default | Typical use |
|---|---|---|---|
SIGTERM | 15 | Terminate | The polite stop. kill PID sends it; systemd, Docker and Kubernetes all send it first. Catch it to finish requests and close resources |
SIGINT | 2 | Terminate | Ctrl-C in a terminal |
SIGHUP | 1 | Terminate | Terminal closed; many daemons reuse it to reload configuration |
SIGKILL | 9 | Terminate, uncatchable | The hammer. The kernel removes the process: no handler, no finally, no shutdown hook, no flushed buffers. Last resort |
SIGSTOP / SIGCONT | 19 / 18 | Pause / resume | Freeze a process without losing state (numbers are for x86 Linux) |
SIGQUIT | 3 | Terminate (core) | A JVM prints a thread dump to stdout and keeps running: kill -3 PID |
SIGUSR1, SIGUSR2 | 10, 12 | Terminate | Application-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:
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.
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.
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.
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 -9skips 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 inDstate) before reaching for the hammer.
Reading the Machine's Vital Signs
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.
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.
| Pattern | Reading | Next step |
|---|---|---|
Load below core count, CPU busy in us | Busy but keeping up | Profile the application if latency is bad |
Load far above cores, CPU near 100% (us+sy) | CPU-bound: more runnable work than CPUs | Find the hot process (top, pidstat), then the hot thread (top -H, then a thread dump) |
Load high, CPU mostly idle, wa high | Tasks stuck waiting for disk | iostat -x 1, iotop; check a full or failing disk, a noisy neighbour, NFS |
| 1-min load much higher than 15-min | Recent spike | Look at what started 1-5 minutes ago (deploy, batch job, traffic) |
| 1-min load much lower than 15-min | Recovering | The problem is already passing |
| Load low but the service is slow | Not a CPU problem | It 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).
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.
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.
- 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 byoom_score_adj(-1000 protects a process, +1000 volunteers it). The victim getsSIGKILL, 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) orjournalctl -k.
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.
- "The server has only 1 GB free!" is the most common false alarm. Look at
available. And the reverse mistake: a JVM with-Xmxequal to the container limit will be OOM-killed, because the heap is not the whole process.
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?
| Tool | Question it answers | Example |
|---|---|---|
df -hT | How full is each mounted filesystem, and of what type? | df -hT (add -i for inodes) |
du -sh | How much does this directory tree use? | du -xh --max-depth=1 /var | sort -rh | head (-x stays on one filesystem) |
lsblk | What block devices, partitions and mounts exist? | lsblk -f adds filesystem type and UUID |
findmnt | What is mounted where, with which options? | findmnt /var/lib |
iostat -x 1 | Is the disk saturated? (%util, await, queue size) | from the sysstat package |
lsof +L1 | Which deleted files are still held open? | the "df full, du small" case |
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.
- Deleting
app.logwhile the JVM holds it open frees nothing: the process keeps writing to the unlinked file. Truncate (: > file), use log rotation withcopytruncateor a reopen signal, or restart the service. And never run cleanup commands with a variable that can be empty (rm -rf "$DIR"/*withDIRunset becomesrm -rf /*);set -uin scripts (chapter 08) exists for exactly this.
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?
| Layer | Question | Tool and example |
|---|---|---|
| Interface | Do I have an address and is the link up? | ip -br addr (brief), ip link |
| Routing | Where do packets for that address go? | ip route, ip route get 10.0.1.5 |
| Name | Does the name resolve, and to what? | dig +short example.com, getent hosts example.com |
| Path | Where along the path does it stop? | traceroute / tracepath (not installed on every box), mtr |
| Port | Is something listening, and which process? | ss -tlnp |
| Connect | Can I open a TCP connection? | nc -zv host 443 |
| Application | What does HTTP say, and where does the time go? | curl -v, curl -w timings |
| Packets | What is really on the wire? | tcpdump -i any -nn port 8080 |
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:
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.
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:
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.
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).
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.
- 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 ofRmid-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.
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 &.
| Command | What it does |
|---|---|
systemctl status orders | State, main PID, memory, and the last log lines |
systemctl start|stop|restart|reload orders | Control the service (stop sends SIGTERM, waits TimeoutStopSec, then SIGKILL) |
systemctl enable --now orders | Start at boot and start now |
systemctl daemon-reload | Re-read unit files after editing them (forgetting this is a classic) |
systemctl cat orders, systemctl show orders -p MainPID | Show the effective unit; query a property |
systemctl list-units --failed | Everything that has failed |
systemd-analyze verify orders.service | Lint a unit file |
systemctl edit orders | Create a drop-in override instead of editing the vendor 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.
| Line | Why it is there |
|---|---|
After= and Wants=network-online.target | Start after the network is configured. Ordering only; use Requires= for hard dependencies |
Type=simple | systemd 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.env | Secrets 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=75 | Heap as a share of the memory the JVM sees; the JVM reads the cgroup limit that MemoryMax sets |
SuccessExitStatus=143 | The JVM exits with 143 after SIGTERM; without this systemd would record a failed stop |
TimeoutStopSec=45 | Grace 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=5 | Restart 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=65536 | Raises the open-file limit for this service only (chapter 09) |
MemoryMax=1500M | A cgroup memory ceiling: the unit is OOM-killed instead of taking the whole host down |
NoNewPrivileges, ProtectSystem=full, PrivateTmp | Cheap hardening: no setuid escalation, read-only /usr and /boot, a private /tmp |
WantedBy=multi-user.target | What systemctl enable hooks the unit into, so it starts at normal boot |
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:
- On a default install the journal may be volatile (kept in memory under
/run, lost at reboot) unless/var/log/journalexists orStorage=persistentis set injournald.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 bylogrotate. Usetail -F(capital F follows across rotation),less +F, andzcatorzgrepfor the rotated.gzfiles. - In containers and on Kubernetes the rule is the opposite: log to stdout and let the platform collect it (page 07).
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.
| Tool | Job | One-line mental model |
|---|---|---|
grep | Select lines by pattern | filter 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 |
sed | Edit a stream | find and replace: s/old/new/, -n with p to print only matches, -E for extended regex, -i edits in place (take a backup) |
awk | Treat each line as fields | $1, $NF (last field), conditions and sums: awk '$9 >= 500 {n++} END {print n}' |
cut, tr, paste | Slice columns, translate characters, join | cut -d= -f2, tr -d ms |
sort, uniq | Order and group | uniq only collapses adjacent duplicates, so always sort | uniq -c; sort -n numeric, -h human sizes, -r reverse, -k2 by column |
jq | Query JSON | filter, map and reshape JSON lines and API responses |
xargs | Turn lines into arguments | ... | xargs -I{} cmd {}; use -0 with find -print0 for names with spaces |
wc, head, tail, tee | Count, take the ends, split a stream to a file | tail -F follows through log rotation |
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:
-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:
- Regex on JSON, HTML or YAML is fragile: use
jqfor JSON andyqfor YAML. Andls | greporfor f in $(ls)break on spaces; use globs (for f in *.log) orfind -print0. When a pipeline gets past about five stages or needs state, switch to a real script.
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.
| Piece | Effect | Demonstration |
|---|---|---|
#!/usr/bin/env bash | Shebang: which interpreter runs the file. env finds bash on the PATH; the file also needs chmod +x | Without the executable bit: permission denied |
set -e | Exit immediately if a command fails (with exceptions: conditions, && and || lists) | Script stops at the first failing step |
set -u | An unset variable is an error, not an empty string | Real: bash: line 1: UNDEFINED_VAR: unbound variable, non-zero exit |
set -o pipefail | A pipeline fails if any stage fails, not only the last | See below |
- 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).
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).
log()writes to stderr (>&2) so that stdout stays clean for data a caller might capture.|| trueaftercurlstopsset -ekilling the script when curl exits non-zero (connection refused is exactly the case we want to retry).trap cleanup EXITruns on normal exit, onexit 1, and on errors underset -e, so the temporary directory is always removed. TrapTERMandINTtoo if you must clean up on a kill (seesig.shin chapter 02).SECONDSis a bash built-in that counts seconds since the shell started: a cheap clock for timeouts.
| Idea | Syntax | Note |
|---|---|---|
| Last command's status | $? | 0 is success. Read it immediately; the next command overwrites it |
| Run B only if A succeeds / fails | A && B A || B | The idiom cmd || { echo "failed"; exit 1; } |
| Test then act | if grep -q ERROR f; then ...; fi | grep -q is silent and returns 0 on a match: use commands as conditions, no [ ] needed |
| Functions | name() { local x=1; ...; return 0; } | local keeps variables out of the global scope; return sets the function status, output goes to stdout |
| Loop over files | for f in /var/log/orders/*.log; do ...; done | Quote "$f" inside |
| Read lines | while IFS= read -r line; do ...; done < file | -r keeps backslashes; IFS= keeps leading spaces |
| Arguments | "$1", "$@", $#, shift | "$@" preserves each argument; $* does not |
| Common codes | 0 ok, 1 general error, 2 misuse, 126 not executable, 127 command not found, 130 Ctrl-C, 137 SIGKILL, 143 SIGTERM | Return 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.
rm -rf "$BUILD_DIR/"with an unset variable (useset -uor"${BUILD_DIR:?}"),cd somewhere; rm *where thecdfailed (usecd ... || exit 1orset -e), unquoted$@, and relying on bash features while the shebang sayssh(on Debian and Ubuntu/bin/shisdash, which has no[[and no arrays). Declarebashand use it.
File Descriptors, Limits and the Foundations of Containers
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.
| Where the limit comes from | How to change it |
|---|---|
| Interactive shell | ulimit -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 service | LimitNOFILE=65536 in the unit: limits.conf does not apply to systemd services |
| Docker container | docker run --ulimit nofile=65536:65536 |
| Kernel-wide ceiling | fs.file-max, fs.nr_open |
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).
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.
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.
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.
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 file | Meaning | Container setting that drives it |
|---|---|---|
memory.max | Hard memory limit; exceeding it triggers the OOM killer inside this group | docker run --memory 512m; Kubernetes resources.limits.memory |
memory.current, memory.events | Usage now; counters including oom_kill | read-only |
cpu.max | quota 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.weight | Relative share when CPUs are contended | --cpu-shares; Kubernetes resources.requests.cpu |
pids.max | Maximum processes and threads (a fork-bomb brake) | --pids-limit |
io.max | Per-device bandwidth and IOPS limits | --device-write-bps |
- 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.
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.
| Symptom | First command | Most common cause behind it |
|---|---|---|
| Everything slow, load very high | uptime; top | CPU saturation, a runaway thread, or GC thrash |
| Slow, CPU idle | curl -w, ss -tn, thread dump | Blocked on a database, a downstream call or an exhausted connection pool |
| Intermittent timeouts | dmesg, ss -s | Port or fd exhaustion, DNS, packet loss |
| Random restarts, exit 137 | dmesg -T | grep -i oom | Memory limit too low, or heap sized as large as the limit |
| No space left on device | df -hT; df -i | Logs, Docker layers, inode exhaustion, deleted-but-open files |
| Cannot bind port | ss -tlnp | A previous instance still running, or a port clash |
| Permission denied | ls -l, id, ps -o user= -p PID | Wrong owner or group, umask, or a mount option such as noexec |
| Works on the server, not from outside | ss -tlnp, security group, firewall | Bound to 127.0.0.1, or a blocked port |
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,topsorted 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.
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.
| # | Task | You should observe |
|---|---|---|
| 1 | Create 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 files | The letters match the octal; 4755 shows s in the owner triplet; files created under umask 077 are 600 |
| 2 | Start sleep 300 &, send kill, then kill -9 to another, and print $? after wait | 143 and 137: exit code = 128 + signal number |
| 3 | Write 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 does | Catching versus the uncatchable signal |
| 4 | Read /proc/loadavg, run yes > /dev/null & once per CPU core (nproc), watch the 1-minute load climb for a minute, then kill them | Load rises toward the core count; clean up with kill %1 %2 ... |
| 5 | Start 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 ports | Errno 98; exit code 7 for refused; the timing breakdown |
| 6 | Mount 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 open | df stays full after the delete; lsof +L1 shows the culprit; killing tail frees the space |
| 7 | Write 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 |
| 8 | Adapt wait_for_http.sh to take an expected status code as a third argument; run bash -n and shellcheck | Syntax OK; shellcheck finds nothing |
| 9 | Write 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 |
| 10 | Inspect /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 |
- Editing a unit file and not running
systemctl daemon-reload. - Putting
ulimit -nin/etc/security/limits.conffor a systemd service (it needsLimitNOFILE=). - Running a service as root, or as a login user whose home is shared.
- Unquoted variables and missing
set -euo pipefailin deployment scripts. - Trusting
freeinstead ofavailable, and-Xmxequal to the container limit. - Using
kill -9as the first move, andsh -c "java ..."as a container entrypoint (the shell is PID 1 and swallows SIGTERM; useexec). - 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".
Linux Interview Q&A
- Q: What does
chmod 750mean, 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 notexecthe app, or does not forward signals, causes a delay at shutdown that ends in SIGKILL. Useexec, 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 calledwait(); 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 atwaand 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. Checkavailableinfree, swap-in/out activity invmstat, and OOM messages indmesg. - Q: A process was killed with exit code 137. How do you find out why?
Checkdmesg -T | grep -i "killed process"orjournalctl -kfor the OOM killer; in containers check the cgroupmemory.eventsand the platform status (OOMKilled). Otherwise something sent SIGKILL (an orchestrator whose grace period expired, a script). - Q:
dfsays the disk is full butdufinds little. Why?
Deleted files still held open by a process (find withlsof +L1, free by restarting or truncating), files hidden under a mount point, or inode exhaustion (comparedf -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 -tlnpandtcpdumpseparate them. - Q: Why does
digresolve a name that the application cannot?digqueries a DNS server directly. Applications usegetaddrinfo, which followsnsswitch.conf(/etc/hostsfirst), the search domains and ndots, and any in-process cache such as the JVM's. - Q: What does
set -euo pipefaildo?
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, raiseLimitNOFILEin the unit,--ulimitin Docker, orlimits.conffor 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?
Overmemory.maxthe 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, standardstatusandjournalctlaccess, 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.uniqneeds sorted input.
Cheat Sheet and Recap
| Need | Command | Notes |
|---|---|---|
| Who runs what | ps -ef --forest, pgrep -af name, top (P, M, H, 1) | STAT: R, S, D (unkillable I/O wait), Z (zombie) |
| Stop a process | kill PID then kill -9 PID | 143 = TERM, 137 = KILL |
| Load and CPU | uptime, top, vmstat 1, pidstat 1 | Load vs core count; wa, st |
| Memory | free -h, vmstat 1, dmesg -T | grep -i oom | Read available; watch swap activity |
| Disk | df -hT, df -i, du -xh --max-depth=1 | sort -rh, lsof +L1 | Blocks, inodes, deleted-but-open |
| Network | ip -br addr, ss -tlnp, nc -zv, curl -w, dig, tcpdump | Bottom-up; refused vs timeout |
| Service | systemctl status|restart|enable --now, journalctl -u X -f | daemon-reload after editing units |
| Text | grep -E, awk, sed -E, jq, sort | uniq -c | sort -rn, xargs -0 | uniq needs sorted input |
| Scripts | set -euo pipefail, quote everything, trap cleanup EXIT, bash -n, shellcheck | Explicit exit codes |
| Limits | ulimit -n, /proc/PID/limits, LimitNOFILE= | limits.conf does not apply to systemd |
| Containers underneath | /proc/PID/ns, /proc/PID/cgroup, /sys/fs/cgroup | namespaces = see, cgroups = use |
- 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, notfree, 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.
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.
- Containers are processes with namespaces and cgroups: continue with 02 · Docker, then 03 · Kubernetes Core. Back to the DevOps map. Related: Performance Engineering for JVM and database tuning that these tools help you observe.