Module 6 ended with an uncomfortable promise: knowing what an operating system is counts for nothing at three in the morning if you don't know which command to type first. And all of those commands are typed in the same place: the shell. Before you learn to troubleshoot you need a solid grasp of the interface the troubleshooting flows through, because most of a junior administrator's mistakes are not analysis mistakes but shell mistakes: a filename with spaces that got split into two arguments, a pipe that reported success even though the first command failed, a badly set PATH that ran the wrong binary.
This lesson takes the shell apart from the inside. You will see that it is not part of the operating system: it is an ordinary user program that does exactly what you learned in 02-01 —fork, execve, wait— and whose only magic is turning text into system calls. You will understand why cd cannot be a program, why 2>&1 > file does not do what it looks like, and why set -euo pipefail should head every script you write. We will finish with a pipeline that pulls the busiest stations out of meteo-api.log and with a real script that checks Meteora's data.
Services and boot are the next lesson, and performance tooling the one after that; here we stay at the interface.
Contents
- What a shell really is
- Available shells and which one to use
- Interactive, non-interactive, login: which file gets read
- Anatomy of a command, and builtins versus external commands
- The expansion order, step by step
- Quoting and escaping: 80% of all failures
- Redirection and file descriptors
- Pipes, exit status and
pipefail - Shell variables, environment variables and
PATH - Job control and signals
- The essential text tools
- A complete pipeline over
meteo-api.log - Scripting: from a one-off command to a program
- Full script: verifying Meteora's data
What a shell really is
Plenty of people believe the shell "is Linux" or that it is part of the kernel. It isn't. /bin/bash is an ordinary ELF executable, like ls or like meteo-api, running in user mode (01-06) and able to do only what any other program can do: system calls. Its main loop, stripped of everything incidental, is this: print the prompt and read a line; parse the text (split it into words, expand it, detect redirections and pipes); if the command is a builtin, run it in the shell's own process; if it is external, fork() to create a child, apply the redirections in it, execve() the program and waitpid() in the parent; store the status in $? and start over.
You can watch it happen with the tool introduced in 01-06:
strace -f -e trace=clone,execve,wait4 -o /tmp/shell.log bash -c 'ls /etc/meteora'
grep -E 'clone|execve|wait4' /tmp/shell.log
# execve("/bin/bash", ["bash", "-c", "ls /etc/meteora"], 0x7ffd...) = 0
# clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|SIGCHLD, ...) = 4711
# [pid 4711] execve("/bin/ls", ["ls", "/etc/meteora"], 0x55f...) = 0
# wait4(-1, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 4711What this proves. The whole mechanism, with no mystery left: the shell starts (execve of bash), duplicates itself (clone, which is what implements fork on Linux), the child turns into ls (the second execve) and the parent waits (wait4). -f follows the children and -e trace= cuts out the noise. Everything you type in a terminal ends up as this sequence.
Three practical consequences follow from that nature. A shell cannot change anything in its parent process: if a script runs export PATH=..., that variable dies with the script, which is why configuration files are loaded with source (or .), which runs them in the current shell. Every external command costs a process: a loop that launches grep a million times performs a million fork+execve pairs, and at the tens of microseconds per pair we measured in 02-01 that is minutes of pure fork; hence the text tools are designed to process whole streams in one go. And the shell is replaceable: swapping bash for zsh does not change the operating system one bit.
Available shells and which one to use
| Shell | Usual path | Role | Notes |
|---|---|---|---|
sh |
/bin/sh → dash on Debian |
Minimal POSIX shell | Fast, no extensions; runs the system's scripts |
bash |
/bin/bash |
De facto standard on Linux | Arrays, [[ ]], PIPESTATUS, process substitution |
zsh |
/bin/zsh |
Advanced interactive use | Better completion, native recursive globbing |
fish |
/usr/bin/fish |
Friendly interactive use | Non-POSIX syntax: don't use it for scripts |
nologin |
/usr/sbin/nologin |
None | Refuses the login; it is the "shell" of meteora |
On Debian, /bin/sh points to dash, not to bash. Real consequence: a script that starts with #!/bin/sh but uses [[ ]] or arrays will fail on Debian and will work wherever /bin/sh is bash. It is the classic "works on my machine".
And remember from module 5 that getent passwd meteora returns meteora:x:990:990:Meteora service:/var/lib/meteora:/usr/sbin/nologin. The last field is the login shell, and nologin is a program that prints a message and exits with status 1. Since the login process does an execve of that shell, nobody can get an interactive interpreter as meteora even if they obtain its password: least privilege from 05-01 applied with a single word.
Interactive, non-interactive, login: which file gets read
Here lives one of the most frustrating beginner mistakes. Bash has two independent axes: interactive or not (does it read commands from a terminal with a human in front of it, or does it run a script?) and login or not (is it the first shell of the session, or one started inside an existing session?). Each combination reads different files:
| Situation | Login? | Interactive? | Files it reads |
|---|---|---|---|
ssh joan@meteo-01 |
Yes | Yes | /etc/profile and the first of ~/.bash_profile, ~/.bash_login, ~/.profile |
| Opening a terminal tab | No | Yes | /etc/bash.bashrc, ~/.bashrc |
bash script.sh |
No | No | None (except $BASH_ENV) |
ssh meteo-01 'command' |
No | No | None |
su - meteora |
Yes | Yes | The login files |
| A systemd service | No | No | None |
The classic mistake, in its exact form: you put export METEORA_HOME=/var/lib/meteora in ~/.bashrc, open a terminal and it works; then ssh meteo-01 'echo $METEORA_HOME' comes back empty and a service fails because the variable does not exist. It is not a problem with the variable: a non-interactive shell does not read ~/.bashrc.
The rule of thumb is simple. Whatever defines the environment (variables, PATH) goes in ~/.profile, which is read at login and inherited by everything you start from there. Whatever defines interactive comfort (aliases, prompt, colors, history) goes in ~/.bashrc, because it makes no sense in a script. And whatever a service needs goes in no shell file at all: it goes in its systemd unit with Environment= or EnvironmentFile= (07-02).
That is why Debian's default ~/.bashrc starts with case $- in *i*) ;; *) return;; esac: $- holds the active flags and the letter i appears only if the shell is interactive. If it isn't, return aborts the read. It prevents a real and hard-to-debug failure: if ~/.bashrc writes anything to standard output —a welcome banner, for example— it breaks scp and rsync, which expect a clean stream on that channel.
Anatomy of a command, and builtins versus external commands
A line is split into words separated by spaces or tabs: the first one is the command, the rest are arguments, and among them there are usually options.
| Form | Example | Detail |
|---|---|---|
| Short option | -n |
One letter; groupable: -in = -i -n |
| Short with a value | -o file or -ofile |
Depends on the program |
| Long option | --color=auto |
Readable; use it in scripts so they still make sense a year from now |
| End of options | -- |
Everything that follows is an operand, even if it starts with - |
The -- separator is not cosmetic. If you create ./-report.txt, the command rm -report.txt answers rm: invalid option -- 'r', because rm reads the name as a string of options; rm -- -report.txt works. It is a mandatory defense in scripts that handle names of external origin: anyone who can create files in a directory you walk can inject options into your commands.
Builtin versus external. A builtin command is compiled into the shell and creates no process at all; an external one is a file on disk executed with fork+execve.
type cd echo grep ll
# cd is a shell builtin
# echo is a shell builtin
# grep is /usr/bin/grep
# ll is aliased to `ls -alF'
which cd # (nothing: which only looks for files in the PATH)
type -a echo # echo is a shell builtin / echo is /usr/bin/echoWhat this proves. type knows the shell's entire world —aliases, functions, builtins and external commands— whereas which only walks the PATH and therefore lies about builtins: use type. Notice echo: there are two of them, the builtin and /usr/bin/echo, and they behave differently with options such as -e. That is one of the reasons serious scripts prefer printf.
And why can't cd be an external program? Because the working directory is an attribute of the process, stored in its task_struct and visible in /proc/<pid>/cwd (04-02). If cd were an executable, the shell would fork, the child would call chdir(), change its own directory and die; the parent would stay exactly where it was. The change has to happen in the shell's own process. The same logic explains export, umask, ulimit, exec and source.
The expansion order, step by step
Before running anything, bash transforms the line following a fixed, non-negotiable order, which explains almost all the "weird" behavior:
- Brace expansion:
{a,b},{1..5}— 2. Tilde:~,~meteora— 3. Parameters and variables:$VAR,${VAR:-def}— 4. Command substitution:$(command)— 5. Arithmetic:$(( 2 + 2 ))— 6. Word splitting according toIFS— 7. Globbing:*,?,[...]— 8. Quote removal.
mkdir -p /var/lib/meteora/readings/{2026,2025} # braces: generate text BEFORE anything exists
echo ~meteora # -> /var/lib/meteora (looks up /etc/passwd)
DATE=2026-08-31; echo /var/lib/meteora/readings/$DATE.dat
# -> /var/lib/meteora/readings/2026-08-31.dat
echo "Files: $(ls /var/lib/meteora/readings | wc -l)" # command substitution
echo "Bytes per reading: $(( 17280000 / 720000 ))" # -> 24, our constant throughout the courseWhy the order matters, with three demonstrations that would fail under any other order:
# (a) Globbing (7) happens AFTER variable expansion (3)
PATTERN='*.dat'
echo $PATTERN # -> 2026-08-30.dat 2026-08-31.dat (it expanded!)
echo "$PATTERN" # -> *.dat (literal)
# (b) Brace expansion (1) happens BEFORE variable expansion (3)
N=3
echo {1..$N} # -> {1..3} (not a valid range, so it is left as is)
seq 1 "$N" # -> 1 2 3 (the correct form)
# (c) Word splitting (6) happens AFTER command substitution (4)
DIR=$(echo '/tmp/august report')
ls $DIR # ls: cannot access '/tmp/august' ... nor 'report'
ls "$DIR" # correctExplanation. In (a), substituting $PATTERN gives us the text *.dat and, because globbing comes later, that text is subjected again to filename expansion; with quotes it is neither split nor globbed. In (b), when bash processes the braces it has not yet substituted $N, so it sees an invalid range: that is a structural limit of the order, not a bug. In (c), the result of $(...) contains a space and word splitting breaks it into two arguments; double quotes suppress steps 6 and 7. Measured against real incidents, (c) is the number one cause of broken scripts.
Quoting and escaping: 80% of all failures
| Form | What it protects | What it lets through |
|---|---|---|
'text' |
Everything, no exceptions | Nothing; you cannot even escape a single quote inside |
"text" |
Word splitting and globbing | $var, $(cmd), `cmd`, \ and ! (interactively) |
\c |
That single character | — |
| No quotes | Nothing | Everything is expanded and split |
The golden rule, with no caveats: quote every variable expansion unless you have an explicit reason not to, and that reason is rare. The case of names with spaces deserves the full example, because that is where the most damage is done:
# BAD: breaks with spaces and with names that start with a dash
for f in $(ls /var/lib/meteora/reports); do rm $f; done
# GOOD: direct glob, quotes and -- as a firewall
for f in /var/lib/meteora/reports/*; do
[ -e "$f" ] || continue # covers the "no files at all" case
rm -- "$f"
doneWhat changes. The bad version piles up three defects: it parses the output of ls (which formats for humans, not for machines), it loses the full path and, without quotes, it splits every name at its spaces, so that august report.pdf turns into two failed deletions. The good version uses the shell's globbing —which returns full paths and does not split on spaces—, checks that the pattern actually expanded (if the directory is empty, bash leaves the pattern literal) and uses --.
Redirection and file descriptors
We pick up 04-04 again: every process starts with three descriptors open —0 stdin, 1 stdout, 2 stderr— and the shell simply manipulates them between the fork and the execve, with open() and dup2(). That detail of timing is what makes the executed program none the wiser: by the time it starts, fd 1 already points where you said.
| Syntax | Effect | Syntax | Effect |
|---|---|---|---|
> f |
stdout to f, truncating it |
< f |
stdin from f |
>> f |
stdout to f, appending |
<<< 'txt' |
here-string as stdin |
2> f |
stderr to f |
<<EOF |
here-doc as stdin |
2>&1 |
stderr to wherever stdout points now |
&> f |
Both to f (bash) |
The counterintuitive order. command > out.log 2>&1 sends everything to the file; command 2>&1 > out.log sends stderr to the screen and only stdout to the file. The reason is that 2>&1 literally means "make fd 2 a copy of wherever fd 1 points at this instant": it is a snapshot, not a permanent link. In the first case fd 1 has already moved to the file by the time 2>&1 is processed; in the second it still points at the terminal. It is the dup2() of 04-04, with no added magic.
# Separate the streams, which is the professional thing to do in an automated script
/usr/local/bin/aggregator > /var/log/meteora/aggregator.out 2> /var/log/meteora/aggregator.err
find / -name 'meteora.conf' 2>/dev/null # discard the "Permission denied" lines
cat > /etc/meteora/limits.conf <<'EOF' # here-doc WITHOUT variable expansion
max_readings_hour=720000
data_path=/var/lib/meteora/readings
EOFWhat they do. The first one leaves separate traces and lets you alert only on the error file. The second takes advantage of find writing its warnings to stderr and its results to stdout, so discarding fd 2 leaves a clean list. In the third, look at the quotes around the delimiter: <<'EOF' does not expand variables inside the block, whereas <<EOF without quotes does; confusing the two produces configuration files with empty values.
Pipes, exit status and pipefail
A pipe connects one process's stdout to the next one's stdin through the pipe() of 03-03: a 64 KB buffer in the kernel, with automatic blocking when it fills up or empties. The processes are launched all at once, not one after another, so on a multi-CPU machine they really do run in parallel; and when head finishes and closes its end, the previous process receives SIGPIPE and dies, which is why head on a huge pipeline does not wait for everything to finish.
The exit status problem. By default, a pipeline's status is that of the last command, which hides failures:
cat /var/lib/meteora/readings/missing.dat | wc -l
echo $?
# cat: ...missing.dat: No such file or directory
# 0 <-- success! because wc finished fine
echo "${PIPESTATUS[@]}" # -> 1 0 (status of EACH command)
set -o pipefail # from here on, the pipeline returns 1Why this is serious. In a script with set -e, that pipeline does not abort execution: the script goes on believing everything is fine and processes zero lines as if they were valid data. It is the kind of silent failure that ends in "yesterday's report came out empty and nobody noticed". PIPESTATUS is a bash array with the status of every element; pipefail changes the rule so that the pipeline returns the status of the last command that failed. It is what you want in every non-trivial script.
tee duplicates a stream: aggregator 2>&1 | tee -a /var/log/meteora/aggregator.log | grep -i error stores everything in the log (with -a to append, not truncate) and at the same time passes a copy on to grep, which shows only the errors on screen. Without tee you would have to choose between seeing and storing.
Shell variables, environment variables and PATH
| Kind | How it is created | Do children inherit it? | Where it lives |
|---|---|---|---|
| Shell variable | VAR=value |
No | Only in the shell process |
| Environment variable | export VAR=value |
Yes | In the environment block, copied by execve |
LOCAL=here_only; export GLOBAL=inherited
bash -c 'echo "[$LOCAL] [$GLOBAL]"' # -> [] [inherited]
tr '\0' '\n' < /proc/$$/environ | grep GLOBAL # the process's REAL environmentWhat this proves. The child receives a copy of the environment block, which is the third argument to execve(); variables that were not exported never make it in there. And /proc/<pid>/environ —02-01 again— shows any process's environment, with the values separated by null bytes, hence the tr. A security note: never pass secrets through the environment, because it is readable by the process owner and shows up in core dumps; Meteora's secrets live in /etc/meteora/meteora.conf with mode 600 for exactly that reason.
PATH is the list of directories, separated by :, where external commands are looked up, left to right, stopping at the first match. Picking up 05-02 again: including . in the PATH, and above all putting it first, is a classic mistake; if an attacker drops a file called ls in /tmp and an administrator with sudo runs cd /tmp and types ls, they execute the attacker's program with root privileges. Rules: never include . or directories writable by others; in automated scripts use absolute paths or set PATH explicitly; and check with type -a what is actually going to run before you assume anything.
Job control and signals
A job is a complete pipeline launched from the interactive shell. The shell assigns it a number and can move it between the foreground (it receives the keyboard) and the background.
| Action | Effect | Signal |
|---|---|---|
command & |
Starts it in the background | — |
Ctrl-C |
Interrupts the foreground job | SIGINT (2) |
Ctrl-Z |
Suspends it | SIGTSTP (20) |
Ctrl-\ |
Terminates it with a core dump | SIGQUIT (3) |
Ctrl-D |
No signal at all: closes stdin (end of file) |
— |
jobs / fg %1 / bg %1 |
List, bring to the foreground, resume in the background | SIGCONT on fg/bg |
kill %1 |
Terminate the job | SIGTERM (15) |
gzip /var/lib/meteora/readings/2026-07-*.dat
^Z # [1]+ Stopped gzip ...
bg %1 # [1]+ gzip ... &
jobs -l # [1]+ 12934 Running gzip ... &What just happened. Ctrl-Z sent SIGTSTP to the foreground process group and the process moved into the T (stopped) state you saw in 02-01; bg sent it SIGCONT and left it running without the terminal. You can confirm it with ps -o pid,stat,cmd -p 12934.
SIGHUP and nohup. When you close an SSH session the terminal disappears and the kernel sends SIGHUP to the associated processes, which by default die. nohup cmd > log 2>&1 & makes the process ignore that signal; setsid cmd </dev/null >log 2>&1 goes further and creates a new session with no controlling terminal, so the signal is never even generated; and tmux is the preferable option in practice because it also lets you reconnect and see the output. For genuinely periodic tasks, none of the three: you use a systemd timer, the subject of 07-02.
The essential text tools
| Tool | What it is for | Most-used options |
|---|---|---|
grep |
Filter lines | -i, -v, -c, -n, -E, -o, -F, -r |
sed |
Substitute and edit line by line | s/a/b/g, -n '5,10p', -i |
awk |
Process by fields and compute | '{print $5}', -F:, END block |
cut |
Extract columns | -d' ' -f2, -c1-10 |
sort |
Sort | -n, -r, -k2, -u, -t: |
uniq |
Collapse duplicates (sort first!) | -c, -d |
wc |
Count | -l, -c, -w |
head/tail |
The ends of a stream | -n 20, tail -f, tail -F |
find |
Search by criteria | -name, -mtime, -size, -exec, -print0 |
xargs |
Turn input into arguments | -0, -n, -P, -I{} |
Basic regular expressions: ^ and $ are start and end of line; . is any character; [0-9] a digit; *, + and ? mean zero or more, one or more and optional (the last two require -E); {3} with -E is exactly three repetitions; and | with -E is alternation.
grep -E ' 5[0-9]{2} ' /var/log/meteora/meteo-api.log | wc -l # 5xx responses
grep -oE '^[0-9]{1,3}(\.[0-9]{1,3}){3}' /var/log/meteora/meteo-api.log | sort -u
tail -F /var/log/meteora/meteo-api.log | grep --line-buffered 'ERROR' # live tailingDetails that matter. -E turns on extended expressions and saves you escaping {}, | and +; -o prints only the matching part. tail -F (capital F) reopens the file if it is rotated —essential with the logrotate of 05-04—, whereas tail -f sits staring at an inode nobody writes to any more. And --line-buffered forces grep to write line by line: without it, it uses a 4 KB buffer as soon as it detects that its output is a pipe, and you would see the errors minutes late; it is exactly the buffering of 01-06.
find + xargs, safely:
# BAD: breaks with spaces or newlines in the names
find /var/lib/meteora/readings -name '*.dat' -mtime +90 | xargs rm
# GOOD: null separator at both ends
find /var/lib/meteora/readings -name '*.dat' -mtime +90 -print0 | xargs -0 --no-run-if-empty rm --
# Alternative without xargs, grouping into few invocations
find /var/lib/meteora/readings -name '*.dat' -mtime +90 -exec rm -- {} +Why. The null byte is the only character that cannot appear in a filename on Linux, so -print0 with xargs -0 is the only safe pairing; --no-run-if-empty stops rm from running with no arguments at all. In the third form, -exec ... + groups many files per invocation, whereas -exec ... \; launches one process per file: with 10,000 files the difference is a handful of processes versus 10,000 fork+execve pairs, that is, seconds versus minutes.
A complete pipeline over meteo-api.log
Line format in /var/log/meteora/meteo-api.log (combined log style, with the duration at the end):
10.20.3.41 - - [31/Aug/2026:03:12:07 +0200] "GET /v1/readings?station=EST-0142 HTTP/1.1" 200 8213 0.043
Goal: the 10 stations that generate the most requests.
grep -F 'GET /v1/readings' /var/log/meteora/meteo-api.log \
| grep -oE 'station=EST-[0-9]{4}' \
| cut -d= -f2 | sort | uniq -c | sort -rn | head -n 10
# 48213 EST-0142
# 31904 EST-0007
# 28755 EST-0311| Link | What it does | Why this way |
|---|---|---|
grep -F |
Filters the endpoint | -F searches for fixed text: faster, and / or ? are not interpreted |
grep -oE |
Extracts only the parameter | -o drops the rest; requiring 4 digits rules out malformed values |
cut -d= -f2 |
Keeps EST-0142 |
Delimiter =, field 2 |
sort |
Groups equal lines | Mandatory: uniq only collapses adjacent lines |
uniq -c |
Counts each group | Returns count value |
sort -rn |
Sorts by count | -n numeric (otherwise "9" would come after "48213"); -r descending |
head -n 10 |
Cuts the top 10 | It also closes the pipe and aborts the rest via SIGPIPE |
The awk version, which does the same in a single process:
awk -F'station=' '/GET \/v1\/readings/ && NF>1 { split($2, p, /[" &]/); count[p[1]]++ }
END { for (s in count) printf "%8d %s\n", count[s], s }' \
/var/log/meteora/meteo-api.log | sort -rn | head -n 10Why it is better. -F'station=' splits each line on that string, so that $2 begins with the identifier, and NF>1 discards the lines without the parameter; split cuts at the first character that is not part of the value and p[1] is left holding EST-0142; the associative array accumulates and the END block dumps the totals. On a 5-million-line log, the six-process pipeline with its two full sorts takes about 40 seconds and writes temporary files to disk; the awk version walks the file exactly once and only sorts a few hundred final lines: between 6 and 8 seconds. The general lesson is that sort over millions of lines is usually a pipeline's bottleneck, and that aggregating before sorting changes the order of magnitude.
Scripting: from a one-off command to a program
A script's first line, the shebang, tells the kernel which interpreter to use in the execve. The kernel reads the two bytes #!, takes the rest as an absolute path and runs interpreter path_to_script. Writing #!/bin/bash demands that bash be exactly there; #!/usr/bin/env bash looks it up in the PATH, which also works where it lives in /usr/local/bin. The trade-off is that dependency on PATH, so in scripts run with sudo or as a service it is wise to pin it.
The mandatory header, option by option:
| Option | Effect | Why |
|---|---|---|
-e |
Abort if a command returns a status ≠ 0 | Stops you working on top of a failure |
-u |
Error when an undefined variable is used | Turns rm -rf "$DIR/" with an empty DIR into an error, not a disaster |
-o pipefail |
The pipeline fails if any link fails | Without this, -e does not see failures inside pipelines |
IFS=$'\n\t' |
Split only on newline and tab | Stops a space from splitting a word |
set -e has well-known traps: it does not fire inside a function called in a condition, nor in the last command of a pipeline without pipefail, nor with local var=$(cmd) —the status that counts is local's, which is always 0—. That is why the correct way is to declare first (local output) and assign afterwards (output=$(command)).
if [[ -f "$FILE" && -s "$FILE" ]]; then ... ; fi # exists and is not empty
[[ "$n" -gt 100 ]] # numeric comparison
[[ "$s" == EST-* ]] # pattern (no quotes on the right-hand side)
[[ "$s" =~ ^EST-[0-9]+$ ]] # regular expression
while IFS= read -r line; do ...; done < file
check_size() {
local path="$1" expected="$2" actual
actual=$(stat -c '%s' "$path")
[[ "$actual" -eq "$expected" ]] # the last command's status is the return value
}Key details. [[ ]] is a bash construct that does not perform word splitting or globbing inside, so it is safer than [ ], which follows POSIX rules and fails with empty values. while IFS= read -r line is the canonical way to read lines: IFS= prevents trimming of leading and trailing spaces and -r stops \ from acting as an escape. A function returns the status of its last command, or whatever you give it with return N.
The positional arguments are $1, $2…, with $# for the count, "$@" for all of them each as one word (always use this form and not "$*", which joins them into a single string), $? for the last status and $$ for the PID. The exit status convention is the system's own: 0 success, 1 generic error, 2 usage error, and >2 specific errors that you define.
trap for cleanup, indispensable in any script that creates temporary files or takes a lock:
WORKDIR=$(mktemp -d)
cleanup() { local code=$?; rm -rf -- "$WORKDIR"; exit "$code"; }
trap cleanup EXIT INT TERMWhat it does. trap registers a handler for signals —the same ones from 03-03— and for the EXIT pseudo-event, which fires whenever the script ends, successfully or not, so that the temporary directory is removed even if it dies halfway. Saving $? at the top of the function and using it in exit preserves the original status, which the rm would otherwise overwrite.
Full script: verifying Meteora's data
This is the lesson's integration exercise: check that the files in /var/lib/meteora/readings/ are complete and up to date, and warn if the current day's file is missing.
#!/usr/bin/env bash
# check-readings.sh — Integrity and freshness of Meteora's data.
# Exits: 0 = OK | 1 = warnings | 2 = usage error | 3 = critical failure
set -euo pipefail
IFS=$'\n\t'
readonly DATA_DIR="/var/lib/meteora/readings"
readonly EXPECTED_SIZE=17280000 # 720,000 readings x 24 B
readonly TOLERANCE_PCT=2
readonly PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
warnings=0; criticals=0
log() { local l="$1"; shift; printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$l" "$*" >&2; }
warn() { log WARN "$@"; warnings=$((warnings + 1)); }
critical() { log ERROR "$@"; criticals=$((criticals + 1)); }
WORKDIR=$(mktemp -d -t meteora-check-XXXXXX)
cleanup() { local code=$?; rm -rf -- "$WORKDIR"; exit "$code"; }
trap cleanup EXIT INT TERM
verbose=0; directory="$DATA_DIR"
while getopts ':d:v' o; do
case "$o" in
d) directory="$OPTARG" ;;
v) verbose=1 ;;
*) log ERROR "Usage: ${0##*/} [-d DIRECTORY] [-v]"; exit 2 ;;
esac
done
[[ -d "$directory" && -r "$directory" ]] || { critical "Cannot read $directory"; exit 3; }
# --- 1. Is today's file there, and is it being written to? ---
today=$(date '+%Y-%m-%d'); today_file="$directory/$today.dat"
if [[ ! -f "$today_file" ]]; then
critical "MISSING today's file: $today_file"
else
age_min=$(( ( $(date +%s) - $(stat -c '%Y' "$today_file") ) / 60 ))
(( age_min > 10 )) && warn "No writes for $age_min min: is the ingestor stopped?"
fi
# --- 2. Size integrity of the files already closed ---
min=$(( EXPECTED_SIZE * (100 - TOLERANCE_PCT) / 100 ))
max=$(( EXPECTED_SIZE * (100 + TOLERANCE_PCT) / 100 ))
find "$directory" -maxdepth 1 -type f -name '*.dat' ! -name "$today.dat" -print0 \
| sort -z > "$WORKDIR/files.lst"
while IFS= read -r -d '' file; do
size=$(stat -c '%s' "$file"); base=$(basename -- "$file")
if (( size % 24 != 0 )); then
critical "$base: $size bytes is not a multiple of 24 (truncated file)"
elif (( size < min || size > max )); then
warn "$base: $size bytes, outside the range [$min, $max]"
elif (( verbose )); then
log INFO "$base: OK ($((size / 24)) readings)"
fi
done < "$WORKDIR/files.lst"
# --- 3. Gaps in the last 30 days of the series ---
for offset in $(seq 1 30); do
day=$(date -d "$offset days ago" '+%Y-%m-%d')
[[ -f "$directory/$day.dat" ]] || warn "Missing the file for day $day"
done
# --- 4. Free space ---
usage_pct=$(df --output=pcent "$directory" | tail -n1 | tr -dc '0-9')
if (( usage_pct >= 90 )); then critical "The file system is $usage_pct% full"
elif (( usage_pct >= 80 )); then warn "The file system is $usage_pct% full"
fi
log INFO "Check finished: $criticals criticals, $warnings warnings"
(( criticals > 0 )) && exit 3
(( warnings > 0 )) && exit 1
exit 0Design decisions, explained:
readonlyand a pinnedPATH. The script will run from a systemd timer, where there is no inheritedPATHand no predictable working directory; pinning it removes the command hijacking of 05-02.logwrites tostderr. That leaves standard output free in case the script ever produces data, and journald captures both streams anyway.mktemp -d+trap. Never use a fixed path such as/tmp/work: it is a race condition and a symlink attack vector.mktempcreates a directory with an unpredictable name and mode 700.find -print0 | sort -zwithread -r -d ''. The complete safety trio against odd names: the separator is the null byte from end to end.size % 24 != 0. It is the cheapest and most informative integrity check for this format: since eachReadingtakes exactly 24 bytes, a size that is not a multiple of 24 means a truncated write, most likely due to a power cut during awrite()with no subsequentfsync(04-05).- A 2% tolerance. A real day rarely has exactly 720,000 readings: a station can lose coverage for a few minutes. Alerting with zero tolerance would produce daily noise and the alert would end up ignored.
- Distinct exit statuses. They let systemd or the monitoring system tell "look at it tomorrow" (1) apart from "act now" (3).
Before putting any script into production, run the static analyzer over it: shellcheck check-readings.sh catches exactly the failures in this lesson —unquoted variables, $(ls), misused [ ], cd without a check, read without -r— and it is the highest benefit-to-effort tool in the shell ecosystem.
Common Mistakes and Tips
| Mistake | Why it happens | Fix |
|---|---|---|
for f in $(ls) |
ls formats for humans, not for machines |
for f in ./* |
| Unquoted variables | Word splitting is step 6 | "$var" always |
2>&1 > f instead of > f 2>&1 |
2>&1 copies fd 1's current target |
Redirect stdout first |
| A pipeline that "works" with empty data | The status is the last command's | set -o pipefail |
A ~/.bashrc variable invisible over non-interactive SSH |
That file is not read | ~/.profile or the systemd unit |
rm $DIR/* with an empty $DIR |
It becomes rm /* |
set -u and check [[ -n "$DIR" ]] |
[ $a == $b ] fails with empty values |
[ ] receives fewer arguments than expected |
[[ $a == $b ]] |
find ... -exec cmd \; painfully slow |
One process per file | -exec cmd {} + or xargs -0 |
uniq groups nothing |
It only collapses adjacent lines | sort first, always |
#!/bin/sh with bash syntax |
On Debian sh is dash |
#!/usr/bin/env bash |
| Works by hand and fails when automated | Different PATH, cwd and environment |
Absolute paths and a pinned PATH |
tail -f stops showing lines |
The file was rotated | tail -F |
Tips that save hours: before a mass rm or mv, replace the action with echo and review the whole output; use Ctrl-R to search the history instead of retyping long commands; debug with set -x or bash -x script.sh, which prints every command fully expanded before running it; comment the why, not the what; and try to make every automated script idempotent, because sooner or later somebody will retry it.
Exercises
Exercise 1: expansion and quoting
Without running anything, predict the exact output and say which expansion step determines it. Then check it.
cd /tmp && mkdir -p demo && cd demo
touch 'august report.dat' 'july-report.dat' '-weird.dat'
A='*.dat'; N=2
echo 1: $A
echo 2: "$A"
echo 3: {1..$N}
for f in *.dat; do echo "4: [$f]"; doneExercise 2: analyzing the API log
Using the meteo-api.log format from this lesson, write a single pipeline (or one awk) to: (1) count the requests with a 5xx status; (2) get the 5 IPs with the most requests and their counts; (3) compute the mean latency of /v1/readings in milliseconds; (4) list the hours of the day with more than 100,000 requests.
Exercise 3: an old-data rotation script
Write archive-readings.sh that compresses with gzip the .dat files in /var/lib/meteora/readings/ older than 90 days, skips the ones already compressed, never touches the current day's file, takes a lock so it cannot overlap with itself, logs what it did, supports a -n dry-run mode and returns a correct exit status. It must be safe against names with spaces and pass shellcheck.
Solutions
Solution 1
1: august report.dat july-report.dat -weird.dat
2: *.dat
3: {1..2}
4: [-weird.dat]
4: [august report.dat]
4: [july-report.dat]- Line 1.
$Ais substituted in step 3, giving the text*.datand, because globbing is step 7, that text is expanded again against the directory. That is the trap of storing patterns in variables. - Line 2. Double quotes suppress steps 6 and 7, so the literal value is printed.
- Line 3. Brace expansion is step 1, before variable expansion: bash sees
{1..$N}, which is not a valid range, and leaves it as is. The correct form isseq 1 "$N". - Line 4. The loop over the glob is correct: each name arrives whole, spaces included, and the order is the locale's (which is why
-weird.datcomes first). If you usedrm $finside without quotes,-weird.datwould be read as options: hencerm -- "$f".
Solution 2
# 1. 5xx requests
awk '$9 >= 500 && $9 < 600 {n++} END {print n+0}' /var/log/meteora/meteo-api.log
# 2. Top 5 IPs
awk '{c[$1]++} END {for (ip in c) printf "%8d %s\n", c[ip], ip}' \
/var/log/meteora/meteo-api.log | sort -rn | head -n 5
# 3. Mean latency in ms of /v1/readings
awk '/GET \/v1\/readings/ {sum += $NF; n++}
END {if (n) printf "mean=%.1f ms over %d requests\n", sum*1000/n, n; else print "no data"}' \
/var/log/meteora/meteo-api.log
# 4. Hours with more than 100,000 requests
awk '{split($4, t, ":"); c[t[2]]++}
END {for (h in c) if (c[h] > 100000) printf "%s:00 -> %d\n", h, c[h]}' \
/var/log/meteora/meteo-api.log | sortComments. In (1), $9 is the status code in the combined format and n+0 forces a 0 to be printed instead of an empty string when there are no matches, a detail that matters if the output feeds an alerting system. In (2) the file is walked exactly once and only the distinct IPs are sorted, not the millions of lines. In (3), $NF is the last field (duration in seconds) and the if (n) guard avoids division by zero, which is fatal in awk; remember that the mean hides the tail, and in 07-03 you will see why p99 is the metric that matters. In (4), field 4 is [31/Aug/2026:03:12:07, so splitting it on : makes t[2] the hour.
Solution 3
#!/usr/bin/env bash
# archive-readings.sh — Compresses readings older than 90 days.
# Exits: 0 = OK | 1 = with incidents | 2 = usage | 3 = critical
set -euo pipefail
IFS=$'\n\t'
readonly DIR="/var/lib/meteora/readings"
readonly DAYS=90
readonly LOCK="/run/meteora/archive.lock"
readonly PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
dry_run=0; failures=0; compressed=0
log() { printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$1" "${*:2}" >&2; }
while getopts ':n' o; do
case "$o" in
n) dry_run=1 ;;
*) log ERROR "Usage: ${0##*/} [-n]"; exit 2 ;;
esac
done
mkdir -p -- "$(dirname -- "$LOCK")"
exec 9>"$LOCK"
flock -n 9 || { log INFO "Another run is already in progress; skipping this one."; exit 0; }
trap 'flock -u 9' EXIT
[[ -d "$DIR" ]] || { log ERROR "$DIR does not exist"; exit 3; }
today=$(date '+%Y-%m-%d')
while IFS= read -r -d '' f; do
base=$(basename -- "$f")
[[ "$base" == "$today.dat" ]] && continue # never the current day's file
[[ -e "$f.gz" ]] && { log WARN "$base.gz already exists, skipping"; continue; }
(( dry_run )) && { log INFO "[dry run] gzip $base"; continue; }
if gzip -9 -- "$f"; then
compressed=$((compressed + 1)); log INFO "Compressed $base"
else
failures=$((failures + 1)); log ERROR "Failed to compress $base"
fi
done < <(find "$DIR" -maxdepth 1 -type f -name '*.dat' -mtime "+$DAYS" -print0)
log INFO "Finished: $compressed compressed, $failures failures (dry_run=$dry_run)"
(( failures > 0 )) && exit 1
exit 0Decisions, explained:
exec 9>"$LOCK"+flock -n 9. It opens the lock file on the script's own descriptor 9 and takes a non-blocking exclusive lock: the same mechanism from 04-04 that theaggregatoruses. If another instance holds it, we exit with status 0, because "it is already running" is not a system failure and must not fire an alert.done < <(find ...)instead offind ... | while. This is crucial: with a pipe, thewhilewould run in a subshell and the counters would be lost when it ended, leaving them permanently at zero.-mtime "+$DAYS"and-print0delegate the date filtering tofind, which reads the inode directly, and guarantee that no name with spaces or newlines breaks the loop.- Checking
-e "$f.gz"avoids losing data if a previous run was left half-finished;gzip -9is reasonable because the historical files are written once and read rarely, and on very regular 24-byte binary records it shrinks them by 60 to 70%. - A dry-run mode before any destructive operation, and logging to
stderrso journald picks it up as is.
Conclusion
The shell has stopped being a black box. You now know it is just another user program whose loop is read, expand, fork, execve, wait, and that this nature explains everything else: why cd has to be a builtin, why unexported variables never reach the children, why a script cannot change its parent's directory and why every external command costs a process.
You have seen the four mechanisms you simply have to master. The expansion order, with its eight steps, which explains why {1..$N} does not work and why a pattern stored in a variable gets expanded twice. Quoting, which is not decoration: it suppresses word splitting and globbing, and its absence is the leading cause of broken scripts. Redirection, which is dup2() with syntactic sugar, and from which the asymmetry of > f 2>&1 versus 2>&1 > f follows. And pipes, which are module 3's pipe() with its 64 KB buffer, its SIGPIPE and its exit-status trap that pipefail fixes.
On that foundation you have built real tools: a pipeline that pulls the busiest stations out of meteo-api.log —and its awk version, five times faster because it aggregates before sorting— and a verification script that applies every defense at once: set -euo pipefail, mktemp with trap, -print0 with read -d '', absolute paths, getopts, distinct exit statuses and integrity checks based on the real 24-bytes-per-reading format.
But a lone script is not a system. The aggregator must run every hour even if the machine was switched off, meteo-api must start only after /var/lib/meteora is mounted and restart if it falls over, and all of that has to survive a server reboot. The shell no longer solves that: the service manager does, the piece that starts as PID 1 and decides what runs, when, and under which limits.
That is the next topic: Services, Boot and systemd, where we will follow meteo-01 from the moment the power button is pressed until meteo-api accepts its first request on port 443.
Operating Systems Fundamentals
Module 1: Introduction to Operating Systems
- Basic Concepts of Operating Systems
- History and Evolution of Operating Systems
- Types of Operating Systems
- Main Functions of an Operating System
- Kernel Architecture: Monolithic, Microkernel and Hybrid
- User Mode, Kernel Mode and System Calls
Module 2: Resource Management
- Process Management
- CPU Scheduling
- Memory Management
- Virtual Memory and Paging
- Storage Management
- Device Management
- Drivers, Interrupts and I/O Operations
Module 3: Concurrency
- Concurrency Concepts
- Threads and Processes
- Inter-Process Communication (IPC)
- Synchronization and Mutual Exclusion
- Classic Concurrency Problems
- Deadlocks: Prevention, Detection and Recovery
Module 4: File Structures
- File Systems
- Directory Structures
- Partitions, Mounting and the Virtual File System
- File Management
- Space Allocation, Journaling and Integrity
- File Security and Permissions
Module 5: System Protection and Security
- Protection Principles and Access Control
- Users, Authentication and Privilege Escalation
- Common Threats and System Hardening
- Auditing, Logging and Incident Response
Module 6: Virtualization and Containers
- Virtualization: Hypervisors and Virtual Machines
- Containers: Namespaces and cgroups
- The Operating System in the Cloud
- Mobile and Real-Time Operating Systems
