Real Linux interview questions with detailed answers on permissions, processes, systemd, networking and storage, grouped by difficulty.
100questions with answers
The shell is the program that reads and runs your commands (bash, zsh, dash), the terminal is the window or device that shows text and sends your keystrokes, and the console is the machine's primary text interface, either physical or virtual. They are separate layers that people mix up.
When you open a terminal app, it starts a shell inside it. Over SSH, the terminal is on your laptop and the shell runs on the server. You can swap one without the other, for example running zsh inside the same terminal.
echo $SHELL # your login shell (set at login)ps -p $$ -o comm= # the shell you are really running right nowcat /etc/shells # shells the system allows as login shellsA common trap: $SHELL shows the login shell, not necessarily the shell you are in. If you type zsh inside bash, $SHELL still says bash.
chown changes the owner (and optionally the group) of a file, and chgrp changes only the group. Only root can give a file to another user, but the owner can change the group to any group they belong to.
sudo chown alice:devs report.txt # owner alice, group devssudo chown -R www-data: /var/www # owner www-data, group = its login groupchgrp devs shared/ # group only-R walks the whole tree, so a wrong path or an unset variable can change ownership of a huge area. The classic accident is chown -R user .*, because .* also matches .., the parent directory, and the command climbs out of the folder you meant. Check the path first with ls, and be careful with symlinks: by default -R does not follow them, which is the safe behaviour.
Use the manual pages first (man), then the command's own --help, then info and the files under /usr/share/doc. All of these work offline.
man ls # full manual pageman 5 passwd # section 5 = file formats (the file, not the command)man -k "copy files" # search page descriptions (same as apropos)ls --help | less # short built-in summaryhelp cd # for shell builtins, which have no man page of their ownMan pages are split into numbered sections: 1 is user commands, 5 is file formats, 8 is admin commands. The same name can exist in several sections, which is why passwd and man 5 passwd are different pages. A trap: man cd often shows the big bash manual, because cd is a builtin. Use help cd instead.
Linux identifies users by a number, the UID, and groups by a number, the GID. Names are only labels. UID 0 is root, the account the kernel treats as all-powerful, and the check is on the number, not on the name "root".
Every user has one primary group and can have extra (supplementary) groups. Permission checks use the process's UID, its primary GID and its supplementary groups.
id alice # uid, gid and all groupsgetent group docker # who is in the docker groupawk -F: '$3==0' /etc/passwd # every account with UID 0The last command is a quick security check. Only root should have UID 0. A second account with UID 0, even with an innocent name, is effectively another root and is a classic sign of a backdoor.
curl is a flexible tool for talking to URLs and APIs (any HTTP method, headers, many protocols), while wget is built mainly for downloading files, including resuming and mirroring whole sites. For API work, curl is the usual choice.
curl -fsSL https://example.com/file -o file # fail on errors, quiet, follow redirectscurl -I https://example.com # headers onlycurl -X POST -H "Content-Type: application/json" -d '{"a":1}' https://api.example.comwget -c https://example.com/big.iso # resume a partial downloadThe big trap is that plain curl exits with 0 even on an HTTP 404 or 500, because the transfer itself worked. Add -f (--fail) in scripts so an HTTP error becomes a non-zero exit code. Also, curl ... | bash runs whatever the server sends. Download first, read it, then run it.
A foreground job owns your terminal, and a background job runs without taking the prompt. The shell tracks both in a job list for that session.
sleep 300 & # start in the backgroundjobs # list jobs: [1]+ Running sleep 300 &fg %1 # bring job 1 to the foreground# press Ctrl-Z # pause the foreground job (SIGTSTP)bg %1 # let the paused job continue in the backgroundkill %1 # stop job 1Ctrl-C sends SIGINT (interrupt) and Ctrl-Z sends SIGTSTP (pause), so Ctrl-Z does not end the program, it only stops it until you resume it. Job numbers like %1 belong to one shell session. A background job usually dies when that session ends, so use nohup, disown or tmux for anything that must outlive your login.
They differ mainly in package format and tools, release and support style, and default security and firewall tooling. Skills transfer well, but commands and file paths change.
| Debian family (Debian, Ubuntu) | Red Hat family (RHEL, Rocky, Alma, Fedora) | |
|---|---|---|
| Package format | .deb, apt, dpkg |
.rpm, dnf, rpm |
| Auth log | /var/log/auth.log |
/var/log/secure |
| Default MAC | AppArmor (Ubuntu) | SELinux |
| Default firewall tool | ufw |
firewalld |
| Network config | Netplan or ifupdown | NetworkManager |
Rocky and AlmaLinux are rebuilds of RHEL, and Ubuntu has long-term support (LTS) releases that are common on servers. The trap is writing scripts that assume apt or /var/log/syslog. Detect the system with /etc/os-release or write for the family you actually run.
Use lscpu, lsmem, lspci, lsusb and dmidecode, which read what the kernel and firmware report. Together they answer "what is this machine?".
nproc # number of usable CPU coreslscpu # CPU model, cores, threads, cachelsmem # memory blocks (or: grep MemTotal /proc/meminfo)lspci | grep -i -E "ethernet|nvme|vga" # network cards, NVMe, GPUssudo dmidecode -t system # vendor, model, serial (needs root)systemd-detect-virt # prints kvm, vmware, none ... if virtualisedOn a virtual machine, dmidecode shows the hypervisor's virtual hardware, not real chips, which is useful to know when a cloud instance behaves differently from a bare-metal server. lshw gives a combined tree if it is installed.
Never cat a multi-gigabyte file. Use a pager (less) or look at the edges with head and tail, and search with grep instead of opening it in an editor.
less +G big.log # open at the end; press / to search, q to quithead -n 20 big.log # first 20 linestail -n 100 big.log # last 100 linestail -f big.log # follow new lines livewc -l big.log # count lineszless app.log.gz # read a compressed log without unpackinggrep -n "timeout" big.log | headless does not load the whole file into memory, but a normal editor such as vim may. If a binary file scrambles your terminal after cat, run reset to restore it. For wide lines, less -S stops wrapping so you can scroll sideways.
Use diff to see what differs, and patch to apply a saved difference. The unified format (-u) is the standard because it shows context and is readable in code review.
diff -u old.conf new.conf > change.patch # save the differencediff -ru dirA dirB # compare directories recursivelypatch -p1 --dry-run < change.patch # test first, change nothingpatch -p1 < change.patch # apply itdiff <(ssh web1 cat /etc/nginx/nginx.conf) <(ssh web2 cat /etc/nginx/nginx.conf)The last line uses process substitution to compare a file on two servers without copying anything, which is very handy for spotting configuration drift. Use cmp when you only need to know whether two binary files are identical, and diff -q for a quick yes or no on directories.
rm removes a name from a directory, and there is no recycle bin, so the data is gone. rmdir only removes empty directories, and rm -rf removes a whole tree without asking.
rm -i file.txt # ask before each deleterm -I -r old_dir/ # ask once before a big recursive deletels -d /data/old_* # preview what the glob matches firstrm -rf -- "$BUILD_DIR" # -- stops a name starting with - being read as an optionHabits that prevent disasters: run ls with the same pattern first, quote variables, and never run rm -rf "$DIR/" in a script unless you are sure $DIR is set. If $DIR is empty, that becomes rm -rf /. GNU rm refuses to remove / itself (--preserve-root is on by default), but that does not protect /* or your home directory.
/tmp is for short-lived files that may be deleted at reboot (and is often RAM-backed tmpfs), while /var/tmp is for temporary files that must survive a reboot. Both are world-writable with the sticky bit.
Which one is cleaned and when depends on the distribution. On systemd systems, the rules in /usr/lib/tmpfiles.d/tmp.conf decide when old files are removed, and /var/tmp is usually kept much longer (around 30 days).
ls -ld /tmp /var/tmp # drwxrwxrwt: the t is the sticky bitfindmnt /tmp # tmpfs? then files count against RAMsystemd-tmpfiles --cat-config | grep -E "/tmp|/var/tmp"Do not keep anything important in /tmp. If /tmp is a tmpfs, a program that fills it also uses memory, which can push the machine into swap.
A system call is the controlled way a normal program asks the kernel to do something it is not allowed to do itself, such as open a file, send network data or create a process. User space is where applications run with limited rights, and kernel space is where the kernel runs with full access to hardware and memory.
A program cannot touch the disk or network directly. It calls a library function (like open() in the C library), which triggers a system call, the CPU switches into kernel mode, the kernel does the work and returns the result.
strace -e trace=openat,read,write cat /etc/hostname # watch the system calls cat makesThis is why strace is so useful: whatever an application claims, its system calls show what it really asked the kernel to do. System calls are also a performance cost, since each one is a switch into the kernel, which is why programs batch reads and writes.
A target is a named group of units that represents a system state, and it replaces the old SysV runlevels. For example, multi-user.target is the normal text-mode server state and graphical.target adds a desktop.
| Old runlevel | systemd target |
|---|---|
| 0 | poweroff.target |
| 1 | rescue.target |
| 3 | multi-user.target |
| 5 | graphical.target |
| 6 | reboot.target |
systemctl get-default # which target boots by defaultsudo systemctl set-default multi-user.targetsudo systemctl isolate rescue.target # switch now (stops other services)To boot into rescue mode once, add systemd.unit=rescue.target to the kernel line in GRUB. Most servers use multi-user.target, because they do not need a graphical interface and it saves memory and attack surface.
Use lsblk for the device tree, blkid for UUIDs and filesystem types, and findmnt or df for what is mounted and how full it is. Each answers a different question.
lsblk -f # devices, partitions, filesystem type, UUID, mount pointsudo blkid # UUID and type of every block devicesudo fdisk -l /dev/sdb # partition table of one diskfindmnt # tree of mounted filesystemsdf -hT # usage and filesystem type of mounted filesystemsls -l /dev/disk/by-uuid/ # stable names for devicesDevice names like /dev/sda and /dev/sdb can change between boots, depending on detection order, so use the UUID (or a label) in /etc/fstab. A common mix-up: lsblk shows devices that exist, while df only shows filesystems that are mounted, so an unmounted or unformatted disk will appear in the first but not the second.
Try to open a connection with nc, curl or Bash's /dev/tcp, and read the result: success, "connection refused" or a timeout. The three outcomes mean different things.
nc -vz db.internal 5432 # -z = just test, -v = verbosecurl -v --connect-timeout 3 telnet://db.internal:5432timeout 3 bash -c '</dev/tcp/db.internal/5432' && echo open || echo closednmap -p 5432 db.internal # if nmap is installedRemember to test from the right place: the machine that will really make the connection, not just your laptop. UDP is harder to test, because no reply is not proof of failure (nc -vzu is only a rough hint).
Use ss to list listening sockets with the process that owns them, then stop or reconfigure that process. ss is the modern replacement for netstat.
sudo ss -tulpn # TCP and UDP listeners, numeric, with process namessudo ss -tlnp 'sport = :8080' # only port 8080sudo lsof -i :8080 # alternative viewsudo fuser 8080/tcp # PID using the portYou need root to see process names for other users. If your app fails with "address already in use", usually another instance is still running (an old process or a duplicate service), or the port is in the TIME_WAIT state after a restart and the app did not set SO_REUSEADDR. Find the PID, check what it is (ps -fp PID), and stop it properly with systemctl stop rather than kill -9 where possible.
For a listening socket, Recv-Q and Send-Q in ss -ltn mean the current accept queue and its maximum size, which is a different meaning from established connections.
MBR is the old scheme, limited to 2 TiB disks and four primary partitions, while GPT is the modern scheme that supports huge disks and many partitions and keeps a backup copy of its header. Use GPT for new disks.
| MBR | GPT | |
|---|---|---|
| Max disk size | about 2 TiB | about 9.4 ZB |
| Partitions | 4 primary (or 3 + extended) | 128 by default |
| Boot method | BIOS | UEFI (also works with BIOS plus a small boot partition) |
| Safety | One copy of the table | Backup header and CRC checks |
sudo parted /dev/sdb --script mklabel gpt # create a GPT table (erases the disk!)sudo gdisk -l /dev/sdb # inspect a GPT diskUEFI systems need an EFI System Partition (ESP), a small FAT filesystem that holds boot loaders. A trap: creating a new partition table wipes the old one, so double-check the device name with lsblk before running parted, fdisk or mklabel.
ext4 is the safe general-purpose default, XFS is excellent for large files and heavy parallel I/O, btrfs and ZFS add snapshots and checksums, and tmpfs lives in RAM. Choose by workload and by the features you need.
/run and /tmp.df -hT # filesystem types of what is mountedmkfs.xfs /dev/vg0/dataThe decision usually comes down to "will I need to shrink it?" (ext4), "huge files or databases with many threads?" (XFS), and "do I need snapshots and checksums built in?" (btrfs or ZFS). When no special need exists, the default of your distribution is the lowest-risk choice.
Both give cryptographically secure random bytes from the kernel. On modern kernels (5.6 and later), /dev/random only blocks until the random generator is initialised at boot, and after that it behaves the same as /dev/urandom.
Older advice said "use /dev/random for keys because /dev/urandom may run out of entropy". That is outdated: once the kernel's generator is seeded, it does not "run out". The real risk is very early boot, especially on virtual machines, when the generator has not yet collected enough randomness.
head -c 32 /dev/urandom | base64 # a random tokenopenssl rand -hex 16 # same idea with a common toolcat /proc/sys/kernel/random/entropy_availIn code, call getrandom() (or your language's secure random API), which blocks only until the generator is ready, instead of reading the device files. For ordinary key and token generation, /dev/urandom is correct.
chroot changes a process's idea of the root directory, so it can only see files below a chosen folder. It is useful for rescue work and builds, but it is not a strong security boundary.
# rescue a broken system from a live USBsudo mount /dev/sda2 /mntfor d in dev proc sys; do sudo mount --bind /$d /mnt/$d; donesudo chroot /mnt /bin/bash # now fix packages, reinstall GRUB, reset a passwordA process with root rights inside a chroot can often escape it, because chroot does not restrict system calls, devices or privileges. It also does not isolate processes, network or users. Containers use namespaces, cgroups, capability limits and seccomp to provide real isolation. So use chroot for repairs and package builds, and use containers or VMs (or systemd sandboxing) when isolation matters.
ping sends ICMP echo requests and tells you whether a host answers and how long it takes. traceroute shows the routers along the path by sending packets with increasing TTL values. Both use ICMP or UDP, not your application's TCP traffic.
ping -c 4 10.0.0.5traceroute 8.8.8.8mtr -rwc 50 example.com # ping and traceroute combined, with loss per hopsudo traceroute -T -p 443 example.com # use TCP port 443, which firewalls usually allowping failing does not mean the server is down. Many firewalls and cloud security groups block ICMP while allowing TCP, so a web server can be healthy yet unpingable. The opposite also happens: ping works but the application port is blocked. So test the real port (nc -vz host 443 or curl) to prove the service is reachable. In traceroute, lines of * * * often just mean a router ignores these probes, not that traffic stops there.
remove uninstalls a package but keeps its configuration files, purge removes the package and its configuration, and autoremove removes dependencies that nothing needs any more.
sudo apt remove nginx # config in /etc/nginx stayssudo apt purge nginx # config removed toosudo apt autoremove # remove orphaned dependenciesdpkg -l | grep '^rc' # packages removed but config still presentsudo apt clean # empty the downloaded package cacheKeeping the config on remove is useful if you reinstall later, but it can leave old settings behind that surprise you, so purge when you truly want a clean slate. On RHEL, dnf remove removes the package, and dnf autoremove removes unneeded dependencies (config files that were edited are left as .rpmsave files). Read the list before you press yes on autoremove, and use apt-mark hold for packages that must not change.
Private ranges are addresses reserved for internal networks that are not routed on the public internet, and CIDR notation like 10.0.0.0/24 says how many leading bits identify the network. The number of addresses is 2^(32 - prefix).
| Range | CIDR | Addresses |
|---|---|---|
| 10.0.0.0 to 10.255.255.255 | 10.0.0.0/8 |
about 16.7 million |
| 172.16.0.0 to 172.31.255.255 | 172.16.0.0/12 |
about 1 million |
| 192.168.0.0 to 192.168.255.255 | 192.168.0.0/16 |
65,536 |
A /24 has 2^8 = 256 addresses, of which 254 are usable hosts (the first is the network address, the last is broadcast). A /16 has 65,536, and a /28 has 16.
ip -br addr # your interfaces and their CIDRipcalc 10.0.5.0/24 # if ipcalc or sipcalc is installedAlso know 169.254.0.0/16 (link-local, shows up when DHCP fails) and 100.64.0.0/10 (carrier-grade NAT). A real-world trap is overlapping ranges: two networks that use 10.0.0.0/16 cannot be joined by a VPN or peering without renumbering.
Single quotes keep everything literal, double quotes still expand $variables, $(commands) and backticks, and a backslash escapes one character. Quoting is how you stop the shell from splitting and globbing your text.
name="a b"echo '$name' # prints: $name (literal)echo "$name" # prints: a b (expanded, kept as one word)echo $name # two words: a and b (split on the space)echo "Cost: \$5" # prints: Cost: $5echo $'tab\there' # bash: $'...' understands \t and \nThe most useful rule is "always quote variable expansions": "$var". Unquoted, the value is split on whitespace and any * in it is expanded to file names. When you send a command through SSH, ssh host "echo $HOME" expands $HOME on your machine first. Use single quotes (ssh host 'echo $HOME') if the remote side should expand it.
They are positional parameters: $1, $2 are the arguments, $# is how many there are, and "$@" expands to each argument as a separate, intact word. "$@" is the form that forwards arguments correctly.
echo "count: $#"for a in "$@"; do echo "arg: [$a]"; done# run: ./s.sh "hello world" two -> two args, not three"$@" keeps hello world as one argument."$*" joins everything into one string (using the first character of IFS).$@ or $* is re-split on spaces, so hello world becomes two arguments.Use shift to drop $1 and move the rest down, and $0 for the script name. A wrapper script that calls another program should almost always end with exec "$@".
$(cmd) captures a command's output as text and is the modern form that nests cleanly, while backticks do the same but are harder to nest and read. <(cmd) is different: it makes a command's output look like a file, which is useful for tools that expect file names.
today=$(date +%F) # command substitutioncount=$(wc -l < "$(ls -t *.log | head -1)") # nesting is easy with $()diff <(sort a.txt) <(sort b.txt) # compare sorted versions, no temp filesecho <(true) # prints something like /dev/fd/63Command substitution strips trailing newlines, which is usually what you want. Process substitution is a bash, zsh and ksh feature and does not exist in plain POSIX sh, so a script with #!/bin/sh may fail on it. >(cmd) is the reverse: it gives a file name that feeds a command.
They let you set defaults, require values and trim strings without calling sed or cut, so scripts stay fast and simple.
echo "${PORT:-8080}" # use 8080 if PORT is unset or empty: "${API_KEY:?API_KEY is required}" # abort with a message if unset or emptyf=/var/log/app.logecho "${f##*/}" # app.log (strip longest prefix up to the last /)echo "${f%.log}" # /var/log/app (strip suffix)echo "${f/var/tmp}" # /tmp/log/app.log (first replacement)echo "${#f}" # length of the stringKnow the difference between :- and -: with the colon, an empty value also triggers the default, without it only an unset variable does. With set -u on, reading an unset variable is an error, so use ${var:-} when "unset" is acceptable.
The redirection > is done by your own shell, before sudo runs, so the file is opened with your privileges, not root's. Only echo runs as root, and by then it is too late.
sudo echo "net.ipv4.ip_forward=1" > /etc/sysctl.d/99-fw.conf # FAILSecho "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-fw.conf # worksecho "extra line" | sudo tee -a /etc/hosts > /dev/null # -a appends, hide echosudo sh -c 'echo "x" > /etc/file' # also workstee reads standard input and writes it to the file, and since tee itself runs under sudo, it has the right to write. For editing a root-owned file interactively, sudoedit (or sudo -e) is safer than sudo vim.
Bash checks in order: aliases, shell keywords, functions, builtins, then files found in PATH (using a cache of earlier lookups). The first match wins, which explains many "why is it running the wrong thing" bugs.
type -a ls # shows every match in order: alias, then /usr/bin/lscommand -v python3 # portable way to find what would run\ls # a backslash skips an alias for one callcommand ls # bypass aliases and functionshash -r # clear the remembered command locationswhich is not a reliable answer, because it is an external program that does not know about aliases, functions or builtins. After you move or install a binary and the shell still runs the old path, the cached location is the cause, and hash -r fixes it. Scripts do not expand aliases by default, so an alias that works in your terminal will not work inside a script.
A script runs in a child process, and a child cannot change its parent's directory or environment. When it exits, those changes disappear. source script.sh (or . script.sh) runs the commands inside your current shell instead, so the changes stay.
# setenv.sh contains: export MODE=prod; cd /srv/app./setenv.sh; echo "$MODE"; pwd # MODE is empty, directory unchangedsource setenv.sh; echo "$MODE"; pwd # MODE=prod, now in /srv/app( cd /tmp && ls ) # subshell: cd affects only the parenthesesThis is why nvm, Python virtualenv activate and similar tools must be sourced, not executed. A handy trick inside scripts is the subshell ( cd dir && command ), which runs a command elsewhere without changing the script's own directory. The reverse also matters: sourcing an untrusted file runs its code with your shell's full privileges.
A here-document (<<) feeds several lines of text to a command's standard input, written right in the script. It is the clean way to create config files or send multi-line input without temporary files.
cat > /etc/app.conf <<'EOF'listen = 0.0.0.0:8080path = $HOME/data # quoted EOF: no expansion, written literallyEOF ssh web1 bash <<'EOF' # run several commands remotely systemctl is-active nginx df -h /EOF grep -q "error" <<< "$output" # here-string: one string as inputQuoting the marker (<<'EOF') stops variable and command expansion, which is usually what you want for config files. Unquoted <<EOF expands $variables, which is useful for templates but surprising otherwise. <<-EOF strips leading tab characters (not spaces), so you can indent the block. Two traps: the closing marker must be alone on its line, and indenting it with spaces breaks it.
Use while IFS= read -r line; do ...; done < file. The for ... in $(cat file) form splits on every space, not on lines, and also expands wildcards in the data.
while IFS= read -r line || [ -n "$line" ]; do printf 'line: %s\n' "$line"done < servers.txtIFS= stops leading and trailing spaces being trimmed.-r stops backslashes being treated as escapes.|| [ -n "$line" ] still processes a last line that has no trailing newline.Two classic traps. First, cat file | while read ... runs the loop in a subshell, so variables set inside are lost afterwards. Use done < file or done < <(command) instead. Second, if the loop body runs ssh, ssh reads the rest of the file from standard input and the loop ends after one iteration. Use ssh -n or < /dev/null.
set -e exits on a failing simple command, but it is deliberately switched off in several situations, so it gives a false sense of safety. Do not rely on it alone.
Cases where a failure does not stop the script:
if or while condition, or on the left of && or ||.set -o pipefail is on.!.local x=$(failing_cmd), because local returns success and hides the error. Declare first, assign second.(( i++ )) when the value is 0, which returns status 1 and can kill the script unexpectedly.set -euo pipefaillocal outout=$(might_fail) # now a failure is seencp a b || { echo "copy failed" >&2; exit 1; } # explicit check where it mattersGood scripts combine strict mode with explicit checks on the important steps and a trap ... ERR or EXIT handler for cleanup and clear error messages.
Check the syntax without running it, trace the commands as they execute, and lint it with ShellCheck. Tracing shows exactly what the shell expanded and ran.
bash -n script.sh # syntax check only, executes nothingbash -x script.sh # print each command after expansionPS4='+ ${BASH_SOURCE}:${LINENO}: ' bash -x script.sh # show file and line numbersshellcheck script.sh # finds quoting bugs, unused variables, bashismsInside a script you can trace one region with set -x ... set +x. A trap 'echo "failed at line $LINENO" >&2' ERR pinpoints where an error happened. Be careful: set -x prints variable values, so secrets such as tokens and passwords can leak into CI logs. ShellCheck catches most real-world bugs, such as unquoted variables, before the script ever runs, so it is worth running in CI.
POSIX sh is the small standard shell language, and Bash is a larger shell with extra features called bashisms. On Debian and Ubuntu, /bin/sh is dash, a fast minimal shell without those extras, so a Bash-only script run as sh fails.
Common bashisms: [[ ... ]], arrays, == inside [ ], <<<, <(...), {1..5}, source (use .), echo -e, $'...', function name {}.
if [[ -f /etc/hosts ]]; then echo ok; fi # fails on dash: [[: not foundcheckbashisms script.sh # from devscriptsshellcheck -s sh script.sh # lint as POSIX shUse #!/usr/bin/env bash when you want Bash features, and #!/bin/sh only if the script is truly POSIX. Containers based on Alpine have BusyBox ash and often no Bash at all, which is a frequent cause of "works on my laptop, fails in the container".
jq is a filter language for JSON: you describe the path or condition and it prints the result. Never parse JSON with grep or sed, because it breaks on whitespace, nesting and escaping.
curl -s https://api.example.com/items | jq '.items[].name' # every namejq -r '.items[] | select(.status=="failed") | .id' data.json # -r = raw, no quotesjq '.items | length' data.json # countjq --arg env prod '.[] | select(.env==$env)' data.json # pass a shell value safelyjq -e '.ready == true' status.json # exit code 1 if falsejq -r '.[] | [.name,.cpu] | @csv' data.json # to CSVPut the jq program in single quotes so the shell does not touch $ and .. Use --arg instead of building the filter by string concatenation, which avoids injection and quoting bugs. -e makes jq usable in if conditions and CI health checks.
Wrap the command in flock, which takes an exclusive lock on a file. If the previous run is still going, the new run exits (with -n) or waits. The lock is released by the kernel when the process ends, even if it crashes.
# crontab: run every minute, skip if the last run has not finished* * * * * flock -n /var/lock/report.lock /usr/local/bin/report.sh # inside a scriptexec 9>/var/lock/report.lockflock -n 9 || { echo "already running" >&2; exit 1; }This beats a hand-made PID file because a PID file left behind after a crash blocks the job forever, and checking it is racy. Combine flock with timeout 50m command so a hung job cannot hold the lock forever. Do not put lock files on NFS unless you know the lock semantics, since flock behaviour over network filesystems varies.
xargs -P N runs up to N commands at the same time, and -n 1 or -I{} controls how items are passed. It is the simplest way to parallelise a loop with no extra tools.
# compress 4 files at a timefind /var/log/app -name "*.log" -print0 | xargs -0 -n1 -P4 gzip # ping many hosts, 10 in parallel, {} is the placeholderxargs -a hosts.txt -I{} -P10 sh -c 'ping -c1 -W1 {} >/dev/null && echo "{} up" || echo "{} down"'Things to know: output from parallel jobs can interleave, so write each job's output to its own file or use GNU parallel which groups it. xargs exits with 123 if any command failed, so check that in scripts. Use -r (GNU) so nothing runs when the input is empty. Choose -P based on the bottleneck: CPU-bound jobs near the core count, network or disk jobs lower to avoid overload.
They are three regex dialects. Basic (BRE) is the default in grep and sed, Extended (ERE) is used with grep -E and awk, and Perl-compatible (PCRE) is used with grep -P and adds features like \d and lookahead.
grep 'ab\+c' f # BRE: + needs a backslash (GNU extension)grep -E 'ab+c|xyz' f # ERE: + ? | () work without backslashesgrep -P '\d{3}-\d{4}' f # PCRE: \d, lookahead, non-greedy .*?grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' f # -o prints only the matchgrep -E '[[:digit:]]+' f # POSIX class, works everywhereThe common trap is using \d in grep -E and wondering why it matches nothing: \d exists only in PCRE. Use [0-9] or [[:digit:]] for portability. grep -P is not available on every system (macOS grep lacks it). Also remember that regex . means "any character" and * means "repeat the previous item", unlike shell globs.
Locale settings control character encoding and sort order, and Windows line endings (CRLF) leave a hidden carriage return at the end of each line. Both produce bugs that look like random failures.
locale # current settings (LANG, LC_ALL ...)LC_ALL=C sort file # byte-wise sort: fast and the same on every machinefile script.sh # "with CRLF line terminators" = Windows endingssed -i 's/\r$//' script.sh # or: dos2unix script.shcat -A script.sh | head -3 # shows ^M at line endsiconv -f latin1 -t utf-8 in.txt > out.txtThe classic error is bad interpreter: /bin/bash^M, caused by a script saved with CRLF. Sort order also depends on locale: in en_US.UTF-8 upper and lower case are mixed, while in C all capitals come first. That mismatch can make comm or join give wrong results when inputs were sorted under different locales. Set LC_ALL=C for scripts that need predictable, fast text processing.
Many programs can start a shell or run commands, so permission to run them as root is effectively full root access. These shell escapes are well documented for common tools.
sudo vim then :!sh -> root shellsudo less file then !sh -> root shellsudo find . -exec /bin/sh \; -quit -> root shellsudo awk 'BEGIN {system("/bin/sh")}' -> root shellThe same applies to editors, pagers, interpreters (python, perl), tar, zip, env, and any wildcard in a sudoers argument. Mitigations: allow sudoedit (which edits a copy as your own user) instead of sudo vim, allow only exact commands with exact arguments, use the NOEXEC tag for programs that must not spawn children, and prefer small wrapper scripts you control. The site GTFOBins lists which binaries can be abused this way, and is useful when reviewing a sudoers file.
PAM (Pluggable Authentication Modules) is the layer that programs like sshd, login, su and sudo call to decide who may log in and under what rules. Each program has a config file in /etc/pam.d/ listing modules in order.
A PAM rule has four kinds of stack: auth (prove identity), account (is this account allowed now), password (changing passwords) and session (setup and cleanup, such as limits). Each line has a control flag: required, requisite, sufficient or optional.
# /etc/pam.d/sshd (simplified idea)auth required pam_faillock.so preauthaccount required pam_access.sosession required pam_limits.soUseful modules: pam_faillock (lock after failed tries), pam_pwquality (password rules), pam_limits (applies limits.conf), pam_access (restrict by user or host). The danger is that a typo can lock everyone out, including root. Always keep a root session open while testing PAM changes, and on RHEL use authselect instead of editing generated files by hand.
Use chage for aging and expiry, passwd -l or usermod -L to lock the password, and usermod -s /usr/sbin/nologin to remove shell access. These do different things, so choose the one that matches your goal.
sudo chage -l alice # show aging infosudo chage -M 90 -W 7 alice # max 90 days, warn 7 days beforesudo chage -E 0 alice # expire the account now (blocks login)sudo usermod -L alice # lock the password hashsudo usermod -s /usr/sbin/nologin svc-backup # no interactive shellsudo faillock --user alice --reset # clear failed-login lock (RHEL)The trap: locking the password usually does not stop SSH key login, because key authentication does not use the password. To truly disable a departing employee, expire the account (chage -E 0), remove their SSH keys, and kill their sessions (loginctl terminate-user alice).
System accounts are non-human accounts for running services, with low UIDs, usually no home directory and no login shell. Regular users are people, with UIDs starting at 1000 on most modern distributions (500 on older RHEL).
The ranges come from /etc/login.defs. Create a service account with useradd -r:
sudo useradd -r -s /usr/sbin/nologin -d /var/lib/myapp myappgetent passwd myappGiving each service its own account limits damage: if the web server is exploited, the attacker has only that account's file access, not the database's or the admin's. Do not share one account across services, and never run network-facing daemons as root. In systemd you can go further with User= or DynamicUser=yes, which creates a temporary account for the life of the service.
The Linux audit system records kernel-level events according to rules you define. You add a watch on the file, then search the audit log by key.
sudo auditctl -w /etc/passwd -p wa -k passwd_changes # watch writes and attribute changessudo auditctl -a always,exit -F arch=b64 -S execve -k exec_log # log every program runsudo ausearch -k passwd_changes -i # human-readable resultssudo aureport -au --summary # authentication summaryTo make rules survive reboots, put them in /etc/audit/rules.d/*.rules and load them with augenrules --load. The log records auid (the original login user), which stays the same even after sudo or su, so you can see which person was behind a root action. Trade-offs: logging every execve produces a lot of data, so scope rules carefully, forward logs to a central server, and consider locking rules with -e 2 so an attacker cannot change them without a reboot.
Capabilities split root's power into small separate privileges, so a program can have only the one it needs. CAP_NET_BIND_SERVICE lets a process bind to ports below 1024 without being full root.
sudo setcap cap_net_bind_service=+ep /usr/local/bin/myapp # file capabilitygetcap /usr/local/bin/myappcapsh --print # current process capabilitiesIn systemd you grant them per service:
[Service]User=myappAmbientCapabilities=CAP_NET_BIND_SERVICECapabilityBoundingSet=CAP_NET_BIND_SERVICEKnow the dangers. CAP_SYS_ADMIN is so broad it is called "the new root". Never setcap an interpreter such as python, because then every script run by it gets the power. File capabilities can be lost when files are copied without extended attributes. Containers drop most capabilities by default, which is part of their hardening.
SSH remembers each server's host key fingerprint in ~/.ssh/known_hosts the first time you connect (trust on first use). If the key later differs, SSH stops you because it cannot tell a legitimate change from a man-in-the-middle attack.
Harmless causes: the server was rebuilt, the IP address was reused by a different machine, or a load balancer now sends you to another backend. Dangerous cause: someone is intercepting the connection.
ssh-keygen -lf <(ssh-keyscan -t ed25519 host 2>/dev/null) # fingerprint it presents nowssh-keygen -R host # remove the old entry once verifiedThe right reaction is to verify the new fingerprint through another channel (cloud console, the server admin) before removing the old line. Do not make StrictHostKeyChecking=no your habit, because it turns the warning off for real attacks too. At scale, use SSH host certificates so clients trust a CA instead of each individual key.
Define hosts, users, keys and jump hosts once in ~/.ssh/config, and then ssh prod-db is enough. The file is read top to bottom and the first value found for each option wins, so put specific hosts before Host *.
Host bastion HostName bastion.example.com User alice IdentityFile ~/.ssh/id_ed25519 IdentitiesOnly yes Host prod-* User deploy ProxyJump bastion ServerAliveInterval 30 Host * ControlMaster auto ControlPath ~/.ssh/cm-%r@%h:%p ControlPersist 10mIdentitiesOnly yes stops SSH trying every key in your agent, which can hit the server's MaxAuthTries limit and cause "Too many authentication failures". ControlMaster reuses one connection for many sessions, so repeated ssh and scp calls are much faster. ProxyJump replaces older ProxyCommand tricks for reaching private hosts through a bastion.
Identify the disk, create a partition table and partition, make a filesystem, mount it, and add a UUID-based line to /etc/fstab. Test the fstab line before rebooting.
lsblk # find the NEW disk, e.g. /dev/sdb (check carefully!)sudo parted /dev/sdb --script mklabel gpt mkpart primary ext4 0% 100%sudo mkfs.ext4 /dev/sdb1sudo mkdir -p /datasudo blkid /dev/sdb1 # copy the UUIDecho 'UUID=1234-abcd /data ext4 defaults,nofail 0 2' | sudo tee -a /etc/fstabsudo mount -a # test: errors appear now, not at bootdf -h /datasudo chown appuser:appuser /dataNotes that show experience: confirm the device name with lsblk and size first, because formatting the wrong disk destroys data. Use the UUID, not /dev/sdb1, because names can change. nofail stops a missing non-essential disk from blocking boot. For something that will need resizing later, use LVM on the disk instead of a plain partition.
The journal is a log where the filesystem writes what it is about to change before changing it. After a crash, the filesystem replays or discards the log, so its structure is consistent again within seconds, without scanning the whole disk.
Without a journal, a power cut during a write could leave a half-updated directory or lost blocks, and a full fsck would have to read every inode, which takes a very long time on large disks.
On ext4, the default mode is data=ordered: only metadata (names, sizes, block maps) goes through the journal, but file data is written before the metadata that points to it. You will see recovering journal in the kernel log after an unclean shutdown.
dmesg | grep -i -E "journal|ext4|recover"sudo e2fsck -f /dev/sdb1 # force a full check (only on an UNMOUNTED filesystem)sudo tune2fs -l /dev/sdb1 | grep -i featuresImportant limit: the journal protects the filesystem's consistency, not your application's data. An application that must not lose data has to call fsync() to force it to disk, which is why databases do this carefully. Never run fsck on a mounted filesystem.
Ext filesystems reserve a share of the blocks (5% by default) for root and system daemons. df hides that reserved space from the "Avail" column, so normal users hit "no space left" while root can still write.
sudo tune2fs -l /dev/sda1 | grep -i "reserved block count"sudo tune2fs -m 1 /dev/sda1 # reduce the reserve to 1% (ext4)df -h /dataWhy the reserve exists: it keeps a system usable when users fill the disk, so root can log in, daemons can write logs and you can still repair things, and it also reduces fragmentation. On a 4 TB data disk 5% is 200 GB, so lowering it to 1% for a data-only filesystem is reasonable. Do not set it to 0 on the root filesystem. XFS reserves only a very small amount, so this behaviour is mainly seen on ext4.
This is also one more cause to check when du and df disagree, along with deleted-but-open files and inode exhaustion.
A snapshot is a copy-on-write view of a logical volume frozen at a moment in time. It stores only the blocks that change afterwards, so it is created almost instantly and takes little space at first.
sudo lvcreate -s -n data_snap -L 5G /dev/vg0/data # create the snapshotsudo mount -o ro /dev/vg0/data_snap /mnt/snap # read-only viewtar -czf /backup/data.tgz -C /mnt/snap . # back up a consistent copysudo umount /mnt/snap && sudo lvremove -y /dev/vg0/data_snapPoints to know:
fsfreeze.lvconvert --merge reverts the origin to the snapshot, which is useful before risky upgrades.LUKS (managed by cryptsetup) encrypts a block device with a master key that is itself protected by one or more passphrases or key files. Data is encrypted and decrypted on the fly, so applications see a normal filesystem.
sudo cryptsetup luksFormat /dev/sdb1 # DESTROYS existing data, sets passphrasesudo cryptsetup open /dev/sdb1 secure # creates /dev/mapper/securesudo mkfs.ext4 /dev/mapper/securesudo mount /dev/mapper/secure /mnt/securesudo cryptsetup luksDump /dev/sdb1 # key slots and settingssudo cryptsetup luksHeaderBackup /dev/sdb1 --header-backup-file sdb1.hdrFor automatic unlock at boot, list the device in /etc/crypttab with a key file, or use network or TPM-based unlocking (Clevis with Tang, or TPM2) so a server can restart unattended without a person typing a passphrase.
Key facts: LUKS has several key slots, so you can add and remove passphrases without re-encrypting data. Back up the header, because if it is damaged the data is unrecoverable. Encryption protects data at rest (a stolen disk), not a running, unlocked machine. On CPUs with AES-NI the performance cost is small.
Read the drive's SMART health data with smartctl, watch the kernel log for I/O errors, and set up alerts through smartd or monitoring. Early warning signs show up in a few specific counters.
sudo smartctl -H /dev/sda # overall health verdictsudo smartctl -a /dev/sda # all attributes and the error logsudo smartctl -t short /dev/sda # start a quick self-testsudo nvme smart-log /dev/nvme0 # NVMe drivesdmesg -T | grep -i -E "I/O error|ata[0-9]|medium error"Attributes to watch on hard disks: Reallocated_Sector_Ct, Current_Pending_Sector and Offline_Uncorrectable. Rising values mean the disk is running out of spare sectors. On NVMe watch media_errors, percentage_used and critical_warning.
Caveats: a "PASSED" verdict does not guarantee health, since many disks fail with no warning. Disks behind hardware RAID need a controller option such as -d megaraid,N. Cloud virtual disks have no SMART data, so you rely on cloud metrics and filesystem errors instead. Combine SMART with RAID or replication, so one failing disk is an alert and not an outage.
VSZ is all the virtual address space a process has mapped (often huge and not real memory), RSS is the part currently in RAM, and PSS is RSS with shared pages divided fairly among the processes that share them. PSS is the fairest number for "how much memory does this process cost".
ps -o pid,vsz,rss,cmd -p 1234cat /proc/1234/smaps_rollup | grep -E "^(Rss|Pss|Shared)"smem -r -k | head # per-process PSS summaryThe trap: RSS counts shared libraries and shared memory fully in every process, so adding up the RSS of all processes can exceed the machine's RAM, which looks impossible. VSZ is even less useful, because memory-mapped files, reserved but unused address ranges and thread stacks inflate it. For a whole-machine view use MemAvailable in /proc/meminfo, not "free". Page cache is not charged to a process's RSS unless the file is mapped, which is why cache can use gigabytes without any process owning it.
Overcommit means the kernel lets programs allocate more virtual memory than the machine really has, betting that they will not use it all at once. It is why a large process can fork() successfully, and also why the OOM killer exists.
| Mode | Behaviour |
|---|---|
| 0 (default) | Heuristic: refuse only obviously absurd requests |
| 1 | Always allow, never refuse an allocation |
| 2 | Strict: limit is swap plus overcommit_ratio percent of RAM |
sysctl vm.overcommit_memory vm.overcommit_ratiogrep -E "CommitLimit|Committed_AS" /proc/meminfoAllocation succeeds on paper, and the pages are only really taken when touched. If everyone then touches their memory, the kernel has no choice but to kill something.
Practical uses: Redis recommends mode 1, because its background save uses fork() and a strict check could make the fork fail on a big dataset. Mode 2 is safer for predictable systems, but it can break software (such as the JVM) that reserves large virtual ranges it never uses. Compare Committed_AS with CommitLimit to see how far you have promised beyond what exists.
Transparent Huge Pages (THP) let the kernel automatically use 2 MB memory pages instead of 4 KB pages, which reduces overhead for programs with large memory. The cost is occasional latency spikes and memory bloat, which hurts latency-sensitive databases.
cat /sys/kernel/mm/transparent_hugepage/enabled # [always] madvise neverecho madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled# persistent: add transparent_hugepage=madvise to the kernel command lineProblems seen with Redis, MongoDB and others: the kernel may stall a thread to compact memory and build a huge page, causing random pauses of milliseconds or more. A background process (khugepaged) also merges pages, and copy-on-write of a 2 MB page after fork() can copy far more than needed, inflating memory use during a Redis save.
A common recommendation is madvise, where only programs that ask for huge pages get them, or never for databases whose vendors advise it. Do not confuse THP (automatic) with explicit HugePages (vm.nr_hugepages), which are reserved in advance and used deliberately by systems like Oracle and PostgreSQL.
A page fault happens when a process touches memory that is not yet mapped to a physical page. A minor fault is cheap, because the data is already in RAM and only the mapping is fixed, while a major fault needs a disk read and is thousands of times slower.
ps -o pid,min_flt,maj_flt,cmd -p 1234/usr/bin/time -v ./myprogram 2>&1 | grep -i -E "major|minor"perf stat -e minor-faults,major-faults -p 1234 -- sleep 10How to use it:
si and so in vmstat), the system is short of memory.This explains why the first requests after a restart can be slow, and why a database that gets its cache evicted suddenly slows down. Fixes are more RAM, reducing memory use, pre-warming caches, or avoiding memory-mapping huge files you do not need.
A core dump is a snapshot of a process's memory and registers written when it crashes, so you can inspect the state at the moment of failure. You need it enabled, a place to store it, and a debugger.
ulimit -c unlimited # allow core files in this shellcat /proc/sys/kernel/core_pattern # where the kernel sends themcoredumpctl list # systemd-coredump: recent crashescoredumpctl info myapp # details of the latest crashcoredumpctl debug myapp # open it in gdbgdb /usr/bin/myapp core.1234(gdb) bt # backtrace: where it crashedsudo gcore -o /tmp/snap 1234 # snapshot a live process without killing itPractical points: install debug symbols (debuginfo or -dbgsym packages) or the backtrace is just addresses. Core files can be as big as the process's memory and can fill a disk, so limit and rotate them. They contain everything in memory, including passwords and keys, so protect them. In containers, the kernel's core_pattern is global to the host, so crashes may not appear where you expect, and Kubernetes workloads need a plan for collecting them.
fork() creates a new process that is a copy of the caller, and exec() replaces that new process's program with a different one. The shell runs every command by doing fork and then exec.
strace -f -e trace=clone,execve bash -c 'ls' 2>&1 | headYou will see a clone (the modern fork) followed by execve("/bin/ls", ...).
Copying a whole process would be slow, so Linux uses copy-on-write (COW): parent and child share the same memory pages marked read-only, and a page is only copied when one of them writes to it. That makes fork() fast even for a process using many gigabytes.
Why it matters in practice:
fork(): the child sees a frozen snapshot while the parent keeps serving, but heavy writes during the save cause pages to be copied and memory use to grow.fork() can still fail when memory is tightly limited (see overcommit).exec keeps the same PID and open file descriptors (unless marked close-on-exec), which is how redirections like > file reach the new program.The program was built to use a shared library (.so file) that the dynamic linker cannot find at run time. The fix is to install the package that provides it or tell the linker where it lives, not to copy files around randomly.
ldd /usr/local/bin/myapp | grep "not found" # which libraries are missingreadelf -d /usr/local/bin/myapp | grep -E "NEEDED|RPATH|RUNPATH"ldconfig -p | grep libssl # what the linker cache knowsapt-file search libfoo.so.2 # Debian: which package has itdnf provides '*/libfoo.so.2' # RHEL: same ideaecho /opt/myapp/lib | sudo tee /etc/ld.so.conf.d/myapp.conf && sudo ldconfigThe linker looks at RPATH/RUNPATH in the binary, LD_LIBRARY_PATH, the cache built by ldconfig (from /etc/ld.so.conf.d), then the default directories. Prefer a proper package or an ld.so.conf.d entry. Avoid setting LD_LIBRARY_PATH globally: it can make unrelated programs load the wrong library and is a security risk.
One more trap: ldd can actually execute the program in some cases, so do not run it on untrusted binaries. Use readelf -d or objdump -p instead.
systemd-analyze shows how long each boot stage took and which units were slowest, and critical-chain shows what the boot actually waited for. Start with the total, then drill down.
systemd-analyze # firmware, loader, kernel, userspace timessystemd-analyze blame | head -15 # slowest unitssystemd-analyze critical-chain # the chain of units that delayed the bootsystemd-analyze plot > boot.svg # a timeline you can open in a browserblame lists the time each unit took, but a slow unit that runs in parallel may not delay anything, so use critical-chain to see what really blocked the boot. Typical culprits:
NetworkManager-wait-online or systemd-networkd-wait-online waiting for a network that is not there./etc/fstab entry for a missing or slow device (NFS, a removed disk). Add nofail and x-systemd.device-timeout=10s.cloud-init stages, or a slow DNS or time-sync step.TimeoutStartSec.Fix by disabling units you do not need, correcting dependencies, or adding timeouts, and measure again after each change.
systemd refuses to restart a service that fails too often in a short time. By default, more than 5 starts within 10 seconds puts the unit in a failed state with start-limit-hit, until you reset it.
[Service]Restart=on-failureRestartSec=5[Unit]StartLimitIntervalSec=300StartLimitBurst=5systemctl status myapp # shows "start request repeated too quickly"journalctl -u myapp -n 50 # the REAL reason it keeps failingsystemctl show -p NRestarts myapp # how many restarts so farsudo systemctl reset-failed myapp # clear the limit, then start againRestart=on-failure restarts after a non-zero exit, a signal or a timeout, but not after a clean exit. Restart=always restarts even after a clean stop, which can hide a service that exits immediately by design. RestartSec adds a pause, which avoids hammering a dependency.
Raising StartLimitBurst only hides the problem. Read the journal and fix the cause, such as a bad config, a missing dependency, a wrong Type= (a daemon that forks started as simple), or a port already in use.
With socket activation, systemd opens the listening socket itself and starts the service only when the first connection arrives, handing the already-open socket to the process. The service does not need to be running first.
# myapp.socket[Socket]ListenStream=8080[Install]WantedBy=sockets.target # myapp.service (started on the first connection)[Service]ExecStart=/usr/local/bin/myappsudo systemctl enable --now myapp.socketsystemctl list-socketsBenefits:
The application must support receiving the socket (using the LISTEN_FDS convention, via a systemd library or a framework). sshd, Cockpit and some container tools use this pattern.
systemd can restrict a service with simple directives that use namespaces, capabilities and system call filters, so a compromised service can reach far less of the system. systemd-analyze security scores how exposed a unit is.
systemd-analyze security myapp.service # list of settings and an exposure score[Service]User=myappNoNewPrivileges=yesProtectSystem=strict # whole filesystem read-only except listed pathsReadWritePaths=/var/lib/myappProtectHome=yesPrivateTmp=yesPrivateDevices=yesRestrictAddressFamilies=AF_INET AF_INET6CapabilityBoundingSet=SystemCallFilter=@system-serviceAdd restrictions one at a time and restart, because each one can break an application that quietly writes to an unexpected path or needs a certain capability. Read the journal after each change to see what was denied. This gives you much of the isolation benefit of a container, with no image or runtime, which is valuable for services you run directly on the host.
Turn on IP forwarding so the kernel passes packets between interfaces, allow the traffic in the firewall's FORWARD chain, and add a masquerade (source NAT) rule on the outgoing interface so private addresses are hidden behind the gateway's address.
sudo sysctl -w net.ipv4.ip_forward=1echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-forward.conf # iptables version (eth0 = outside, eth1 = inside)sudo iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADEsudo iptables -A FORWARD -i eth1 -o eth0 -j ACCEPTsudo iptables -A FORWARD -i eth0 -o eth1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPTHow it works: a client sends a packet to the gateway, the gateway forwards it out and rewrites the source address to its own, and the connection tracking table (conntrack) remembers the mapping so replies are translated back.
Common failures: forwarding disabled or not made permanent, a default-DROP FORWARD policy with no allow rule, missing return-traffic rule, clients using the wrong default gateway, and rules lost at reboot because they were never saved. Check with ip route, sysctl net.ipv4.ip_forward, conntrack -L and tcpdump on both interfaces.
Every packet passes through hooks in a fixed order, and at each hook the matching chain's rules are checked top to bottom; the first rule that matches and has a final action (ACCEPT, DROP) decides. If no rule matches, the chain's default policy applies.
Key facts:
-I inserts at the top and -A appends to the bottom.-m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT.sudo iptables -L -n -v --line-numbers # rules with packet counterssudo nft list ruleset # the same on nftables systemsCounters are a great debugging tool: a rule whose counter does not rise is not matching. Container tools such as Docker add their own chains, which can surprise you.
Connect with openssl s_client to see exactly what certificate and chain the server sends, then check dates, names and the chain. This shows what a client sees, regardless of what a browser hides.
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \ | openssl x509 -noout -subject -issuer -dates -ext subjectAltName openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null | head -60curl -vI https://example.com 2>&1 | grep -i -E "SSL|certificate|expire"Common causes:
curl and many libraries cannot, so "works in the browser, fails in my script". The fix is to serve the full chain on the server.-servername, a server hosting many sites may return the wrong certificate.update-ca-certificates or update-ca-trust).Never "fix" it with curl -k in production code, since that turns off verification and removes the protection TLS provides.
The command resolves the name, finds the route, opens a TCP connection, performs a TLS handshake, then sends the HTTP request and reads the response. Each step has its own failure mode and its own tool.
curl can time each stage for you:
curl -o /dev/null -s -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} firstbyte=%{time_starttransfer} total=%{time_total}\n' https://example.comRead the numbers as cumulative: a large dns points to resolver trouble (getent hosts, /etc/resolv.conf), a large connect to routing or packet loss (ip route get, mtr), a large gap before tls to a slow handshake or an overloaded server, and a large firstbyte to a slow application. Then confirm with tcpdump, openssl s_client and the server logs. Knowing this flow is the best answer to the classic question "what happens when you type a URL".
A bridge works like a virtual switch that joins interfaces into one network, a bond combines several physical NICs into one logical link, and a VLAN interface carries one tagged virtual network over a physical link.
# bridge: used for VMs and containerssudo ip link add br0 type bridgesudo ip link set eth1 master br0 && sudo ip link set br0 up # VLAN 100 on eth0sudo ip link add link eth0 name eth0.100 type vlan id 100 # bond with two NICs (active-backup mode)sudo ip link add bond0 type bond mode active-backupsudo ip link set eth2 master bond0docker0) use them to connect guests to the LAN.active-backup gives failover with no switch support, and 802.3ad (LACP) aggregates links but needs matching switch configuration.A trap: LACP spreads traffic per flow, so one single TCP connection does not go faster than one NIC, and only many connections together use both links. Commands like these are temporary, so make them permanent through Netplan, NetworkManager or systemd-networkd.
A hard mount keeps retrying forever if the server stops answering, so any process touching it freezes (often in D state). A soft mount gives up after retries and returns an error, but risks data corruption for writes. "Stale file handle" means the client holds a reference to a file or export that no longer exists on the server.
mount | grep nfsnfsstat -m # options in use (hard, timeo, retrans ...)showmount -e nfs-server # exports offeredrpcinfo -p nfs-server # NFS services reachable?sudo umount -f /mnt/share # force, or umount -l for a lazy unmountsudo mount -o remount /mnt/share# /etc/fstab: do not block boot, mount on first usenfs-server:/export /mnt/share nfs4 _netdev,x-systemd.automount,x-systemd.idle-timeout=60,hard 0 0Stale handles appear when the exported directory is deleted and recreated, the server's filesystem is restored or the export changes. The fix is to remount on the client. Hangs usually mean the server or network is unreachable, so check connectivity first. Use hard for data you care about, and design applications so they tolerate a slow mount. Automount (x-systemd.automount) keeps a dead NFS server from blocking boot.
Forward logs from every host to a central collector over a reliable, encrypted channel, with local queues so nothing is lost when the collector is down. Do not rely on UDP syslog for important logs.
# /etc/rsyslog.d/50-forward.conf (on each client)action(type="omfwd" target="logs.example.com" port="6514" protocol="tcp" StreamDriver="gtls" StreamDriverMode="1" queue.type="LinkedList" queue.filename="fwd" queue.saveOnShutdown="on" action.resumeRetryCount="-1")logger -p auth.notice "test message" # send a test entrysudo systemctl restart rsyslogAlternatives include systemd-journal-remote and agents such as Fluent Bit, Vector or Promtail that ship to a log platform.
Common problems:
@ means UDP in rsyslog, @@ means TCP. UDP drops messages silently.RateLimitBurst), which hides the very flood you want to see.Check each resource for utilisation, saturation and errors (the USE method), plus a few things that fail silently, and alert on what hurts users rather than on every raw metric. Too many alerts teach people to ignore them.
| Resource | Watch | Typical alert |
|---|---|---|
| CPU | utilisation, run queue relative to cores, steal |
sustained saturation, high steal |
| Memory | MemAvailable, swap in and out, OOM kills |
low available memory, any OOM kill |
| Disk | space, inodes, latency (await), errors |
space about to run out, high latency |
| Network | errors, drops, retransmits, bandwidth | rising errors or drops |
| System | failed units, reboot needed, time sync, file descriptors | failed service, clock drift |
| Certificates and backups | expiry, last success | expiring soon, backup missed |
Alert on trends for disk, not just thresholds, since "full in 6 hours" is more useful than "80% full". For example in Prometheus with node_exporter:
predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[6h], 24*3600) < 0Use MemAvailable, not "free" memory, because Linux uses spare RAM for cache. Combine host metrics with application symptoms (error rate, latency) so you can tell a real incident from a harmless spike. Review and delete alerts that nobody acts on.
A production script fails loudly, cleans up after itself, cannot run twice at once, and can be tested without causing damage. The goal is predictable behaviour when something goes wrong, not clever code.
set -Eeuo pipefailIFS=$'\n\t' log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >&2; }die() { log "ERROR: $*"; exit 1; }tmp=$(mktemp -d); trap 'rm -rf -- "$tmp"' EXITtrap 'die "failed at line $LINENO"' ERR DRY_RUN=${DRY_RUN:-0}run() { if [ "$DRY_RUN" = 1 ]; then log "DRY: $*"; else "$@"; fi; } exec 9>/var/lock/deploy.lock; flock -n 9 || die "already running"[ $# -eq 1 ] || die "usage: $0 <version>" run rsync -a --delete "releases/$1/" /srv/app/Checklist behind it: strict mode, quoted variables, mktemp plus trap, a lock, input validation, a dry-run switch, timestamped logs to stderr, meaningful exit codes, absolute paths or an explicit PATH, and retries with a limit for flaky network calls. Also make steps idempotent, so a rerun after a half-failure is safe. Finally, lint with ShellCheck in CI and move to Python or a real tool once the script needs complex data handling.
Give people named accounts, grant access by role through groups, allow only exact commands, log everything, and keep a separate break-glass path for emergencies. Never hand out blanket ALL rights to a whole team.
# /etc/sudoers.d/webops (always edit with: visudo -f /etc/sudoers.d/webops)Cmnd_Alias WEB_SVC = /usr/bin/systemctl restart nginx, \ /usr/bin/systemctl status nginx, \ /usr/bin/journalctl -u nginx --no-pager%webops ALL=(root) WEB_SVCDefaults:%webops use_pty, log_outputDesign points:
systemctl restart * would allow restarting any service. Avoid wildcards, shells, editors and interpreters (see shell escapes).visudo -c, and review changes like code.sudo logs to syslog; add log_output for session recording and forward logs off the host.NOPASSWD for humans. Use it only for narrowly scoped automation accounts.The trade-off is friction versus control. Start strict for production and relax only with a documented reason.
Use a proper orchestration tool (Ansible, pssh, or your fleet manager) with inventory, timeouts and batching, try it on a canary first, and stop automatically if failures pile up. A plain for host in ...; do ssh loop is slow, serial and has no safety net.
# Ansible: 1 canary host, then batches of 25, abort if over 10% failansible web -m shell -a 'systemctl restart myapp' --limit canaryansible-playbook restart.yml -f 25 # in the play: serial: 25, max_fail_percentage: 10 # quick and rough alternative with xargsxargs -a hosts.txt -P 20 -I{} ssh -o ConnectTimeout=5 -n {} 'uptime'Plan it like this:
--check --diff).ConnectTimeout, command timeouts) so one dead host does not stall the run.The common mistake is running a risky command everywhere at once because "it worked on my machine". A staged rollout turns a potential outage into one failed batch.
Make internal servers reachable only through a hardened bastion, and have a certificate authority (CA) sign users' public keys for a short time. Servers trust the CA, so there are no authorized_keys files to maintain and access expires by itself.
# on the CA (a protected host or a vault), sign a user's key for 1 hourssh-keygen -s ca_key -I alice@2026-10-12 -n alice,deploy -V +1h id_ed25519.pub # on every server: sshd_configTrustedUserCAKeys /etc/ssh/ca_user_key.pubAuthorizedPrincipalsFile /etc/ssh/principals/%uRevokedKeys /etc/ssh/revoked_keysWhy this is better at scale:
authorized_keys works until someone finds it.For the bastion itself: only SSH open to the internet, key or certificate login only, MFA at the CA step, ProxyJump for users, no agent forwarding, LogLevel VERBOSE plus session logging, and internal servers allowing SSH only from the bastion's address. The weak point becomes the CA private key, so protect it in an HSM or a vault and audit every signing.
Keep secrets out of command lines and images, hand them to services as protected files or from a secrets manager, and use short-lived credentials. Environment variables and process arguments are more visible than most people expect.
How they leak:
mytool --password=abc is visible to every user in ps and in /proc/<PID>/cmdline./proc/<PID>/environ, inherited by every child process, and often dumped in crash reports and debug pages.set -x traces.Better options:
# systemd: file readable only by root, delivered securely to the service[Service]LoadCredential=dbpass:/etc/myapp/dbpass# the service reads $CREDENTIALS_DIRECTORY/dbpassinstall -m 0400 -o myapp -g myapp /dev/null /etc/myapp/token # file with tight modeHISTCONTROL=ignorespace # a command starting with a space is not saved in historyAt scale, use a secrets manager (Vault, a cloud KMS or secret store) with short-lived, rotated credentials and audit logs. Never bake secrets into container images or Git. Treat any secret that appeared in a log or command line as leaked and rotate it.
Attackers who get a low-privilege shell look for misconfigurations that turn it into root. Most paths are about too many privileges, writable places that root trusts, or missing patches.
Common paths and fixes:
| Path | Why it works | Fix |
|---|---|---|
| Unneeded SUID binaries | They run as owner (often root) | find / -xdev -perm -4000 and remove or restrict |
Loose sudo rules |
Editors, interpreters, wildcards give shells | Exact commands, sudoedit, review |
| Root-run cron or scripts in writable directories | The user edits what root executes | Root-owned, non-writable scripts and paths |
Wildcard injection (tar * in a root cron job) |
File names become options | Quote, use -- and absolute paths |
Writable systemd units or PATH directories |
Root runs attacker code | Correct ownership and modes |
docker group membership |
Can mount the host filesystem | Treat as root, avoid or use rootless |
no_root_squash NFS exports |
Remote root becomes local root | Use root_squash |
| Unpatched kernel or sudo | Known exploits | Patch and use live patching |
Defences work in layers: least privilege, patching, filesystem mount options (nosuid), MAC (SELinux or AppArmor), auditing with auditd, and regular scans with tools like lynis. A useful habit is to run an enumeration check yourself (sudo -l, find for SUID and writable paths) before an attacker does.
Assume a root attacker can edit or delete anything on the machine, so logs that exist only locally cannot be trusted. Ship them off the host in near real time to a system the attacker cannot reach.
Layers to combine:
rsyslog over TLS, systemd-journal-remote or an agent such as Fluent Bit or Vector to a central store with restricted write access and retention locks.chattr +a on important log files, tight permissions, and auditd in immutable mode (-e 2) so rules cannot be changed without a reboot.rsyslog or auditd stopping, log files being truncated, and time jumps.journalctl --verify # check journal file integritychattr +a /var/log/secure # append-only (rotation needs extra care)The key idea: a gap in logs is itself evidence. Central logging with alerts on silence turns "the logs were deleted" into "we know exactly when they stopped".
Put hard limits on CPU, memory, processes and disk per user with cgroups (through systemd) and quotas, and add isolation for temporary files. Soft habits like nice help, but they do not give a guarantee.
# /etc/systemd/system/user-.slice.d/50-limits.conf (applies to every user)[Slice]MemoryMax=4GCPUQuota=200%TasksMax=1024sudo systemctl daemon-reloadsudo systemctl set-property user-1001.slice MemoryMax=2G # one user, immediatelyOther controls:
/etc/security/limits.conf (applied by pam_limits): nproc, nofile, core.quotaon for ext4, and XFS project quotas for directories.pam_namespace for per-user /tmp, or PrivateTmp=yes for services.nice and ionice for background work.systemd-cgtop, loginctl, and alerts on per-user resource use.Use cgroup limits for fairness, because ulimit can be bypassed by launching many processes. The trade-off is that a memory limit that is too tight makes the kernel kill the user's legitimate job, so set limits from observed usage and communicate them.
Stream the file through awk and sort, keep only small summaries in memory, and set LC_ALL=C for speed. This works as long as the number of distinct keys stays reasonable. Assume an nginx log where the client IP is field 1, the time is field 4 and the request time in seconds is the last field.
export LC_ALL=C # top 10 client IPsawk '{c[$1]++} END{for(i in c) print c[i], i}' access.log | sort -rn | head # requests per minute (time looks like [12/Oct/2026:14:05:33)awk '{print substr($4,2,17)}' access.log | sort | uniq -c | sort -k2 | head # approximate 95th percentile of request timeawk '{print $NF}' access.log | sort -n | \ awk '{a[NR]=$1} END{print "p95:", a[int(NR*0.95)]}' # several rotated files, including compressed oneszcat -f access.log* | awk '$9 >= 500 {n++} END{print n " server errors"}'Why it works: each tool reads a stream, so memory use is small. The percentile needs a sorted list, so sorting is the heavy step, and GNU sort spills to temporary disk, so point it at a big disk with sort -T /bigdisk. Trade-offs: awk associative arrays grow with unique keys (millions of unique IPs can be heavy), and for repeated analysis, load the data into a proper tool (a database, ClickHouse, or your log platform) instead of re-scanning 50 GB each time.
Prepare the new version in a separate directory, then switch to it with one atomic operation: a rename. Never copy files over a live directory.
rel=/srv/app/releases/$(date +%Y%m%d%H%M%S)mkdir -p "$rel" && rsync -a build/ "$rel/" # 1. stage the new releaseln -s "$rel" /srv/app/current.new # 2. temporary symlinkmv -T /srv/app/current.new /srv/app/current # 3. atomic switchsystemctl reload myapp # 4. pick up the new codeKey points:
rename() (what mv uses) is atomic only inside one filesystem. Staging in the same filesystem is essential.ln -sfn new current is not atomic, because it removes the old link and then creates the new one. Create a temporary link and mv -T it over the old one instead.mv it into place.Trade-offs: symlink swaps need the app to resolve paths through the link each time, and shared state (uploads, sockets) must live outside the release directories. Database migrations are not covered by file atomicity, so use backward-compatible migrations.
Build the new server from code, copy the data in repeated passes while the old one is still serving, then do a short final sync during a brief write freeze and switch traffic. Keep the old server ready for rollback.
Plan:
rsync -aHAX --numeric-ids --info=progress2 old:/srv/data/ /srv/data/The trap is forgetting things that are not data: scheduled jobs, TLS certificates, monitoring, backups, and firewall allowlists on other systems that trust the old IP. A written checklist and a rehearsal on a copy prevent most surprises.
Idempotent means running the same automation repeatedly gives the same final state and changes nothing the second time. Drift is when a server no longer matches its intended configuration, usually because of manual changes.
In plain shell, write "check, then change":
id deploy >/dev/null 2>&1 || useradd -m deploy # create only if missingmkdir -p /srv/app # safe to repeatln -sfn /srv/app/releases/1.4 /srv/app/current # same result every timegrep -qxF 'vm.swappiness=10' /etc/sysctl.d/99.conf 2>/dev/null \ || echo 'vm.swappiness=10' >> /etc/sysctl.d/99.confDeclarative tools such as Ansible, Puppet or Chef describe the desired state (state: present) and only act on differences. They also report what would change:
ansible-playbook site.yml --check --diff # dry run: show drift without changing anythingTo control drift: run the configuration tool on a schedule and alert when it reports changes, restrict manual root access, and keep configuration in Git so every change is reviewed. Handlers should run only when something changed (restart a service only after its config changed). The alternative philosophy is immutable servers: never fix in place, rebuild from a new image, which removes drift by design. Test automation in disposable containers or VMs so you know a rerun is truly safe.
Stop writes to that filesystem immediately, because every new write can overwrite the freed blocks. Then recover from the safest source available, starting with backups and snapshots, and only then try risky undelete tools.
Order of attack:
lsof +L1 # deleted files still opencp /proc/<PID>/fd/<FD> /safe/place/recovered.datextundelete or debugfs sometimes work; XFS has no practical undelete. testdisk and photorec carve file contents without names. Always work on an image copy (ddrescue), never on the original disk. SSD TRIM and discard options often erase deleted data quickly, which lowers the odds a lot.Prevention: tested backups and regular snapshots, least privilege so few people can run recursive deletes as root, quoting and set -u in scripts, using ls or echo to preview, and separate production and staging terminals with clearly different prompts. The honest message to management is that after rm the odds are poor, which is why backups you have actually restored are the real protection.
D means uninterruptible sleep: the process is inside the kernel waiting for I/O and cannot receive signals until that I/O returns. So the real problem is whatever it is waiting on, usually storage or a network filesystem, not the process itself.
ps -eo pid,stat,wchan:32,cmd | awk '$2 ~ /^D/' # D-state tasks and what they wait insudo cat /proc/<PID>/stack # kernel stack: where it is blockeddmesg -T | grep -i "blocked for more than" # hung task warningsecho w | sudo tee /proc/sysrq-trigger # dump all blocked tasks to dmesgiostat -x 1 3 ; mount | grep nfs # is the disk or NFS server healthy?Typical causes and responses:
hard mount. Restore the server or network, or umount -f / umount -l the share.Newer kernels let some network waits be killable, which is why kill -9 sometimes works on NFS and sometimes not. Fix the cause of the stall, because rebooting is the last resort that also loses the evidence. Remember that many D-state tasks also raise the load average, which is why high load with idle CPU is a clue.
When the kernel itself fails, normal logs may never be written, so you prepare beforehand: a crash kernel (kdump) to save memory, a serial or cloud console to see messages, and settings that make failures observable instead of silent.
# enable kdump (RHEL: kexec-tools; Debian/Ubuntu: kdump-tools)# reserve memory on the kernel command line: crashkernel=256Msystemctl enable --now kdumpsysctl kernel.panic=10 # reboot 10 seconds after a panicsysctl kernel.softlockup_panic=1 # panic on a hard CPU lockup so kdump firesAfter a crash, kdump boots a small second kernel and writes a vmcore file (often under /var/crash). Analyse it with the crash tool:
crash /usr/lib/debug/lib/modules/$(uname -r)/vmlinux /var/crash/.../vmcorecrash> bt # backtrace of the crashing taskcrash> log # kernel messages before the crashOther tools:
echo 1 > /proc/sys/kernel/sysrq): keys such as w (blocked tasks), t (all tasks), s u b (sync, remount read-only, reboot). To test kdump you can use echo c > /proc/sysrq-trigger, but this crashes the machine deliberately, so do it only on a test host.console=ttyS0 on the kernel line, to read messages that never reach disk.Test the whole path before you need it, because a kdump that was never tested often fails when it matters.
Linux buffers writes in the page cache as "dirty" pages and flushes them to disk in the background. When too many pages are dirty, the kernel makes writing processes wait until enough has been flushed, and your application stalls.
grep -E "Dirty|Writeback" /proc/meminfo # how much is waiting to be writtenvmstat 1 # 'bo' = blocks written out; 'b' = blocked tasksiostat -x 1 # await and utilisation of the devicesysctl vm.dirty_background_ratio vm.dirty_ratiosysctl vm.dirty_background_bytes vm.dirty_bytesHow it works: when dirty data passes vm.dirty_background_ratio (default 10% of available memory), background flushing starts. When it passes vm.dirty_ratio (default 20%), writing processes are throttled and block. On a machine with a lot of RAM and a slow disk, 20% can be many gigabytes, so the kernel accumulates a huge backlog and then stalls everything while it drains.
Options:
vm.dirty_background_bytes and vm.dirty_bytes.fsync in small batches, not one huge flush.ionice or cgroup I/O limits for bulk jobs, so they do not starve latency-sensitive writers.Measure before and after, since the right values depend on disk throughput.
Yes. A CPU limit (a cgroup quota) lets the container use only a fixed amount of CPU time per period, for example 200 ms in every 100 ms window for 2 cores. A multi-threaded app can burn its whole quota in a few milliseconds, then be paused until the next period, even when the host has plenty of free CPU.
cat /sys/fs/cgroup/cpu.max # quota and period, e.g. "200000 100000"cat /sys/fs/cgroup/cpu.stat # look at nr_throttled and throttled_usecIn cpu.stat, a growing nr_throttled (and throttled_usec) next to nr_periods proves throttling. In Kubernetes the same signal is container_cpu_cfs_throttled_periods_total divided by total periods.
Causes and fixes:
GOMAXPROCS, JVM container support).cpu.max.burst on newer kernels, which lets short bursts borrow unused quota.The lesson for monitoring: average CPU usage hides throttling, so graph throttled time alongside latency. A container at 40% average CPU can still be heavily throttled.
A network namespace is an isolated copy of the network stack, with its own interfaces, routes, firewall rules and ports. A veth pair is a virtual cable with two ends: whatever enters one end comes out the other, so you can place one end inside the namespace and keep the other outside.
sudo ip netns add demosudo ip link add veth-host type veth peer name veth-demosudo ip link set veth-demo netns demo sudo ip addr add 10.10.0.1/24 dev veth-host && sudo ip link set veth-host upsudo ip netns exec demo ip addr add 10.10.0.2/24 dev veth-demosudo ip netns exec demo ip link set veth-demo upsudo ip netns exec demo ping -c1 10.10.0.1 # works through the veth pairA container runtime does this for every container: it creates a namespace, creates a veth pair, attaches the host end to a bridge (like docker0), gives the container end an IP, and adds NAT (masquerade) rules so containers can reach the outside.
Debugging tips:
sudo nsenter -t <PID> -n ss -tulpn # run host tools inside a container's network namespacesudo nsenter -t <PID> -n ip routeip -br link | grep veth # the host ends of container interfacesnsenter is very useful when the container image has no network tools. Typical failures are a missing forwarding rule or NAT rule, a firewall chain ordering problem, or overlapping IP ranges between the container network and the host network.
Each listening socket has two queues: a SYN queue for half-open connections, and an accept queue for completed handshakes waiting for the application to call accept(). If the accept queue is full because the application is too slow, new connections are dropped or delayed.
ss -ltn # LISTEN: Recv-Q = current accept queue, Send-Q = its maximumnstat -az TcpExtListenOverflows TcpExtListenDropsnetstat -s | grep -i -E "listen|SYN"sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog net.ipv4.tcp_syncookiesHow to read it: if Recv-Q is close to Send-Q on a LISTEN socket, or ListenOverflows keeps rising, the application is not accepting connections fast enough. The queue size is the smaller of the application's listen(backlog) value and net.core.somaxconn.
Fixes, in order of importance:
listen ... backlog=) and somaxconn.SYN cookies (tcp_syncookies) protect against SYN floods by avoiding state, but they do not fix a slow application. To tell a legitimate surge from an attack, look at the source addresses and whether handshakes complete (tcpdump, ss -s). Raising limits alone just moves the queue and increases latency.
If a packet arrives on one interface but the server's route to the sender goes out a different interface, reverse path filtering (rp_filter) may drop it as spoofed. Even without it, stateful firewalls on one path may drop replies that took a different path.
sysctl net.ipv4.conf.all.rp_filter net.ipv4.conf.eth1.rp_filter # 0 off, 1 strict, 2 looseip route get 203.0.113.50 from 10.0.1.10 # which interface would the reply use?sudo sysctl -w net.ipv4.conf.all.log_martians=1 # log packets dropped for bad sourcesudo tcpdump -ni any host 203.0.113.50 # does the packet arrive, and where does the reply leave?Typical setup: a server with two NICs (public and private) and one default gateway. A client reaches the private address, but the reply leaves through the public NIC by the default route, so the client or a firewall drops it.
Fixes:
ip route add default via 10.0.1.1 dev eth1 table 100ip rule add from 10.0.1.10 table 1002) where asymmetric paths are legitimate, instead of turning it off completely.The clue is that tcpdump shows the SYN arriving but no useful reply on the same path, or a reply on a different interface. This is a classic issue on cloud instances with several network interfaces.
Grow the volume in the cloud console or API first, then extend the partition, then any LVM layer, then the filesystem. Each layer must be told about the new size, in that order, and a snapshot comes first.
lsblk # disk is now bigger, partition still old sizesudo growpart /dev/nvme0n1 1 # extend partition 1 to fill the disk # plain partition with ext4 or XFS directly on it:sudo resize2fs /dev/nvme0n1p1 # ext4sudo xfs_growfs / # XFS (takes the mount point) # if LVM sits on the partition:sudo pvresize /dev/nvme0n1p1sudo lvextend -r -l +100%FREE /dev/vg0/root # -r also grows the filesystem df -h /Checks and cautions:
growpart handles that.cloud-init, but you may need to do it by hand on a running system.Also find out why the disk filled up (logs, caches, df -i), or you will repeat this in a month.
Identify the failed member, remove it, add a new disk that is partitioned the same way, and let the array rebuild while you watch it. The risky part is the rebuild window, when the array has no redundancy left.
cat /proc/mdstat # [UU] = healthy, [U_] = degradedsudo mdadm --detail /dev/md0 # which member failedsudo mdadm /dev/md0 --fail /dev/sdb1 --remove /dev/sdb1 # if not already marked failed # after replacing the physical disk:sudo sfdisk -d /dev/sda | sudo sfdisk /dev/sdb # copy the partition layoutsudo mdadm /dev/md0 --add /dev/sdb1 # rebuild startswatch cat /proc/mdstat # progresssudo grub-install /dev/sdb # boot loader on both disks (if boot array)Risks and good practice:
mdadm --monitor, mail alerts) so you hear about the first failure at once.mdadm.conf and the initramfs when the layout changes, and tune rebuild speed with dev.raid.speed_limit_min if the rebuild is too slow.RAID is not a backup: it protects against disk failure, not deletion, corruption or ransomware.
Start from what the business needs (how much data loss and downtime is acceptable), choose methods that meet it, keep copies in separate failure domains, and prove that restores work. A backup you have never restored is only a hope.
Key terms: RPO (recovery point objective, how much data you can lose) and RTO (recovery time objective, how long recovery may take).
Design points:
pg_dump, binary backups plus WAL), and volume or cloud snapshots for fast whole-machine recovery.fsfreeze with snapshots), otherwise the backup may not restore.restic -r s3:s3.amazonaws.com/my-backups backup /etc /srv --exclude-cachesrestic check # verify repository integrityrestic restore latest --target /tmp/restore-test # regular restore testSchedule restore drills (at least a sampled file restore monthly, a full rebuild a few times a year) and write the recovery steps down. Servers that can be rebuilt from code need only their data backed up, which shrinks RTO.
Treat it as a staged rollout: test, canary, small batches, with a tested way back. A kernel change touches drivers, networking and storage, so surprises show up only after the reboot.
Plan:
dkms status. A missing module can leave the server without its network or storage after reboot.uname -r # running kernelneeds-restarting -r # RHEL: is a reboot needed?ls /var/run/reboot-required # Ubuntu: exists when a reboot is neededsudo grub-reboot "Advanced options for Ubuntu>Ubuntu, with Linux 6.8.0-45-generic"sudo grubby --default-kernel # RHEL: show the defaultsystemctl --failed, mounts, network, application health) before continuing.For urgent security fixes, live patching (kpatch, Canonical Livepatch) can reduce emergency reboots, but it covers only some fixes, so schedule normal reboots too. Make sure the console (cloud serial console or iDRAC) works before you start, because a kernel that does not boot needs it.
Averages hide short stalls, so look at where time is spent per request: running on the CPU, waiting for the CPU, or blocked (off-CPU) on I/O, locks or the network. Modern tracing tools show this with low overhead.
Approach, from cheap to deep:
pidstat -u -d -w 1 (CPU, disk, context switches).runqlat (bcc) shows run-queue latency, which reveals CPU contention or cgroup throttling.offcputime shows where threads sleep, and biolatency shows disk I/O latency histograms.tcpretrans shows retransmits, and ss -ti shows RTT per connection.strace -T -tt -p PID timestamps each call (this slows the process a lot, so use it briefly or on a canary).perf record -g -p PID -- sleep 30, then perf report or a flame graph.execsnoop reveals commands that start and exit too fast to see in top.sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat { @[comm] = count(); }' # who opens files mostsudo perf record -F 99 -g -p 1234 -- sleep 30eBPF tools (bcc, bpftrace) run in the kernel with low overhead and need a recent kernel, root access and often matching kernel headers. Be careful in production: always know a tool's overhead, since strace can slow a process by an order of magnitude while perf sampling is light. The point to make aloud is the method: measure where time goes, form a hypothesis, then confirm it with a second tool.
The first process in a container is PID 1 in its own PID namespace, and Linux treats PID 1 specially. A normal program as PID 1 does not get the default signal behaviour, and it also becomes the parent that must clean up orphaned processes.
Two consequences:
SIGTERM, it simply ignores it. docker stop sends SIGTERM, waits 10 seconds, then sends SIGKILL, which is why shutdowns are slow and unclean.wait(), finished children stay as zombies and slowly fill the process table.Shell scripts make it worse: if the entrypoint is a shell script that starts the app as a child, the shell receives SIGTERM and does not pass it on.
Fixes:
# exec form: the app is PID 1 directly, no shell in betweenENTRYPOINT ["/app/server"]# entrypoint.sh: replace the shell with the app so signals arriveexec /app/server "$@"docker run --init myimage # adds a tiny init (tini) that forwards signals and reaps zombiesYou can also bake in tini or dumb-init, make the application handle SIGTERM (graceful shutdown), and set STOPSIGNAL if it expects a different signal. Kubernetes behaves the same way, so a good terminationGracePeriodSeconds plus proper signal handling avoids dropped requests during deploys.