It is 03:12. The phone buzzes: "The Meteora API is responding slowly. Customers have been complaining for a few minutes." There is no more information, nobody knows what changed, and the person who wrote the aggregator is on holiday. You have SSH access to meteo-01 and the rest is up to you.
This lesson is unlike every previous one. It introduces no new concepts: it uses all the ones you have learned. You are going to walk through a complete incident, with the real output of every command, the hypotheses that get ruled out, the two that turn out to be true and a third that appears without being looked for and that completely changes the nature of the problem. You will see how module 2's theory about disk access patterns turns into a line of iostat, how module 3's lock hierarchy turns into a thread hung in /proc/<pid>/stack, and how module 5 forces the performance investigation to stop dead.
At the end we will close the course: a map of what we have covered, the paths that open up and how to keep practicing.
Contents
- The response playbook
- First phase: the initial 60 seconds
- Second finding: the
aggregatorand the access pattern - Mitigation versus real fix
- Third finding: threads in state
Dthat never progress - The deadlock, its fix and the proof
- Fourth finding, unexpected: the incident changes its nature
- A blameless post-mortem
- Final exercises
- Closing the course
The response playbook
Before typing a single command, three decisions. They cost thirty seconds and they change the outcome of the incident.
First: bound the symptom with data, not with impressions. "It's slow" gives you no way to check whether anything improves. You need a number and a moment in time.
awk '$4 ~ /03:0[0-9]|03:1[0-9]/ {print $NF}' /var/log/meteora/meteo-api.log \
| sort -n | awk '{a[NR]=$1} END {printf "n=%d p50=%.3f p95=%.3f p99=%.3f\n", NR, a[int(NR*.5)], a[int(NR*.95)], a[int(NR*.99)]}'
# n=41208 p50=0.089 p95=2.914 p99=6.102Compare it with the baseline for an ordinary Tuesday (p50=0.031 p95=0.104 p99=0.198) and you already have the symptom quantified: the p99 has grown thirtyfold. Repeating the query minute by minute, the degradation begins between 03:04 and 03:06. That moment is your anchor: everything you investigate gets correlated against it.
Second: decide whether to mitigate or investigate first. They are goals in tension. Mitigating restores the service but destroys evidence: if you restart meteo-api, the state that explains the failure disappears. Investigating preserves the proof but prolongs the outage. The criterion is impact:
| Situation | What to do first |
|---|---|
| Service completely down, with growing damage | Mitigate, capturing the most volatile things first |
| Degraded but working, as here | Investigate for 10-15 minutes, then mitigate |
| Suspicion of a security compromise | Preserve, contain and escalate |
At 03:12 the API is responding, with bad latency but no mass errors. You decide to investigate, with an explicit time limit: fifteen minutes.
Third: write everything down with timestamps. Open an incident logbook and save every output:
mkdir -p /var/tmp/incident-2026-08-31
export LOG=/var/tmp/incident-2026-08-31/logbook.log
note() { printf '\n===== %s : %s =====\n' "$(date -Is)" "$*" >> "$LOG"; }
note "Start. Symptom: p99 6.1 s (baseline 0.2 s) since ~03:05"Why this matters so much. Three hours from now you will not remember whether aqu-sz was 18 or 27, and the post-mortem depends on those numbers. What is more, if the incident turns out to be a security matter —as it will— the logbook becomes part of the chain of custody from 05-04.
First phase: the initial 60 seconds
You run the whole 07-03 checklist, in order, skipping nothing.
What it rules out. Nothing yet, but it orients you: a load of 28 on an 8-core machine, with the 1-minute average far above the 15-minute one. The problem is recent and growing. There was no reboot (41 days of uptime), so we rule out a failed boot.
dmesg -T | tail -20 | tee -a "$LOG"
# [Mon Aug 31 02:41:03 2026] md0: recovery done.
# [Mon Aug 31 03:05:11 2026] meteo-api[1834]: segfault at 0 ip ... (does not appear)What it rules out. There are no OOM kills, no device errors, no degraded RAID, no segfault. That eliminates in one stroke the three most frequent catastrophic causes. The recovery done line at 02:41 comes from a RAID resync that has already finished: it could have been the cause, but it ended 24 minutes before the symptom. Noted as not entirely ruled out, because every temporal coincidence deserves a review.
vmstat 1 5 | tee -a "$LOG"
# procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
# r b swpd free buff cache si so bi bo in cs us sy id wa st
# 1 31 131072 402112 9104 5218836 0 0 16384 1024 5210 9821 4 3 4 89 0
# 2 29 131072 399820 9104 5219004 0 0 15872 896 5104 9740 3 2 5 90 0Here is the first big finding. r=1 (a single process ready to run) against b=31 (thirty-one processes blocked in uninterruptible I/O). si/so at zero: it is not memory. us+sy around 6% with wa at 89%: it is not CPU. The load of 28 is entirely explained by the processes in state D from 02-01, which on Linux —and only on Linux— count towards the load average.
mpstat -P ALL 1 3 | tail -10 | tee -a "$LOG"
# CPU %usr %nice %sys %iowait %irq %soft %steal %idle
# all 3.6 1.1 2.4 89.4 0.0 0.4 0.0 3.1
# 0 3.9 0.8 2.2 90.1 0.0 0.6 0.0 2.4
# 3 3.1 1.4 2.7 88.9 0.0 0.3 0.0 3.6What it rules out. The %iowait is spread evenly across the eight cores, so it is not one saturated core nor a badly distributed interrupt (02-07). And %steal at zero rules out the hypervisor (06-01): nobody is stealing CPU from us.
free -m | tee -a "$LOG"
# total used free shared buff/cache available
# Mem: 16037 9422 402 912 5218 6104
ss -s | head -3 | tee -a "$LOG"
# TCP: 1421 (estab 1188, closed 190, orphaned 0, timewait 189)
cat /proc/pressure/{cpu,io,memory} | tee -a "$LOG"
# cpu some avg10=6.11 avg60=4.02 avg300=2.10
# io some avg10=94.28 avg60=78.55 avg300=41.09
# io full avg10=71.62 avg60=58.31 avg300=28.44
# memory some avg10=0.00 avg60=0.00 avg300=0.00What they rule out. An available of 6.1 GB and memory pressure of zero: memory is definitively out. The established connections are in the normal range. And PSI is conclusive: an io full avg10 of 71.6% means that over the last ten seconds the machine has spent seven out of every ten unable to make progress because it was waiting on the disk, while CPU pressure is residual.
Conclusion of the first phase, at 03:17: the bottleneck is disk I/O. Three minutes have been enough, and CPU, memory, swapping, network, the hypervisor and a hardware failure have all been ruled out with data. Write it down and carry on.
Second finding: the aggregator and the access pattern
iostat -xz 1 3 | tail -12 | tee -a "$LOG"
# Device r/s w/s rkB/s wkB/s rrqm/s wrqm/s r_await w_await aqu-sz rareq-sz %util
# md0 4102.0 38.0 16408.0 912.0 0.0 2.0 52.10 3.04 27.40 4.00 99.90
# sda 2054.0 19.0 8216.0 456.0 0.0 1.0 51.88 3.01 13.72 4.00 99.80
# sdb 2048.0 19.0 8192.0 456.0 0.0 1.0 52.33 3.07 13.68 4.00 99.90A full reading of this output, which is the heart of the diagnosis. The bandwidth is derisory: 16 MB/s, something any twenty-year-old disk would deliver without breaking a sweat. But there are 4,102 operations per second, and rareq-sz is pinned at 4.00 KB: minimum-size requests. rrqm/s is zero, meaning the I/O scheduler (02-05) cannot merge anything, which only happens when the requested blocks are not contiguous. It is the exact signature of random reads.
A RAID 1 of two mechanical disks delivers on the order of 150-200 random IOPS per disk, some 300-400 on reads adding both together because the mirror spreads the reads. We are asking for more than ten times that capacity. Hence aqu-sz=27.4 —twenty-seven requests waiting on average, USE's saturation— and r_await=52 ms. The %util of 99.9% is true but not very informative: what hurts is the queue.
Who is causing it?
pidstat -d 1 3 | tee -a "$LOG"
# UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
# 990 1834 412.00 88.00 0.00 41 meteo-api
# 990 9127 15984.00 804.00 0.00 2914 aggregatorWhat it proves. The aggregator is reading almost 16 MB/s —the device's entire throughput— and is accumulating an enormous iodelay: it is the one saturating the disk. meteo-api barely reads, but its iodelay of 41 shows that it is waiting too, and that wait is the latency the user suffers. We have a culprit and a victim.
systemctl status aggregator.service | head -8 | tee -a "$LOG"
# Active: active (running) since Mon 2026-08-31 03:00:14 CEST; 17min ago
# CGroup: /system.slice/aggregator.service └─9127 /usr/local/bin/aggregator --previous-hourIt started at 03:00:14 from its timer and has been running for 17 minutes, when it normally takes two. That fits perfectly with the symptom starting at 03:05.
cat /proc/9127/io | tee -a "$LOG"
# rchar: 18402144256
# read_bytes: 17962827776
filefrag -v /var/lib/meteora/readings/2026-08-30.dat | tail -3 | tee -a "$LOG"
# ...
# /var/lib/meteora/readings/2026-08-30.dat: 5314 extents foundThe decisive finding. A single day's file takes up 17,280,000 bytes, about 16.5 MiB, and should fit in a handful of contiguous extents. It has 5,314. And read_bytes says the aggregator has read 17.9 GB from disk in order to process a 16.5 MB file: it is re-reading the same file more than a thousand times, or accessing it in a completely disordered way.
Why the access pattern matters so much, which is the theory of 02-05 and 04-05 made flesh. On a mechanical disk, a sequential read of 16.5 MB is a single head positioning followed by a continuous transfer: about 0.15 seconds. That same 16.5 MB read as 4,200 random 4 KB requests is 4,200 seeks of about 10 ms each: 42 seconds, nearly three hundred times more, with exactly the same bytes read. The disk is not slow; the pattern is bad. And the fragmentation into 5,314 extents turns even a "sequential" read of the file into something resembling random access, because the logically contiguous blocks are physically scattered: it is the consequence of the ingestor appending to the file over 24 hours while the file system allocated space wherever it could.
Mitigation versus real fix
It is 03:24 and the service has to be restored. Always keep the two things apart.
Immediate mitigation (minutes, reversible, does not fix the cause):
ionice -c 3 -p 9127 # idle class: it only reads if nobody else wants the disk
note "Applied ionice -c3 to PID 9127, the aggregator"What it does and why it works. ionice -c 3 moves the process into the I/O scheduler's idle class: its requests are only served when there is no other request outstanding. meteo-api stops competing on equal terms and its latency should drop immediately.
Verify, always, that the mitigation works:
sleep 60; iostat -xz 1 3 | grep md0
# md0 3980.0 36.0 15920.0 864.0 0.0 2.0 11.40 2.90 6.10 4.00 99.70
awk '$4 ~ /03:2[5-9]/ {print $NF}' /var/log/meteora/meteo-api.log | sort -n \
| awk '{a[NR]=$1} END {printf "p99=%.3f\n", a[int(NR*.99)]}'
# p99=0.940Partial result. r_await drops from 52 to 11 ms, aqu-sz from 27 to 6, and the API's p99 goes from 6.1 s to 0.94 s. An enormous improvement… but the baseline was 0.198 s. Something is still wrong, and that residue is what will lead to the third finding. Noting it is essential: the classic incident trap is to declare the case closed as soon as things improve enough.
Lasting mitigation, applying the cgroups from 06-02 at the unit level, so that it does not depend on somebody running ionice by hand:
[Service]
IOSchedulingClass=idle
IOWeight=10
IOReadBandwidthMax=/dev/md0 20M
MemoryMax=1G
CPUWeight=20systemctl daemon-reload
systemctl show aggregator.service -p IOWeight -p IOSchedulingClass
cat /sys/fs/cgroup/system.slice/aggregator.service/io.max # the limit exactly as the kernel sees itWhat it achieves. IOSchedulingClass=idle makes the ionice change permanent; IOWeight=10 against meteo-api's IOWeight=200 splits the disk twenty to one when both compete; and IOReadBandwidthMax puts a hard ceiling translated into io.max in the service's cgroup. It is the same kernel mechanism that limits a container, applied to a native service. An important note: MemoryMax=1G also bounds the damage of a possible leak, killing only the aggregator instead of letting the OOM killer pick a victim (02-04).
The real fix, which is not done at three in the morning but is recorded as a post-mortem action:
| Real problem | Real fix | Why |
|---|---|---|
The aggregator re-reads the file a thousand times |
A single sequential pass accumulating in memory | Turns 42 s of seeks into 0.15 s of transfer |
| Files in 5,314 extents | Preallocate with fallocate when the day's file is created |
The file system reserves contiguous space in one go |
| Random reads over historical data | An hourly index, or a columnar format | Read only what is needed |
| It competes with the service during traffic hours | Run it against a replica, or at an off-peak hour | Removes the competition at the source |
| Nobody noticed until customers complained | An alert on io full avg60 > 20% |
Detection before the user |
Third finding: threads in state D that never progress
It is 03:31. The disk is no longer saturated, but the p99 is still at 0.94 s, almost five times the baseline. You go back to meteo-api.
ps -eLo pid,tid,stat,wchan:24,comm | awk '$1==1834' | tee -a "$LOG"
# 1834 1834 Sl ep_poll meteo-api
# 1834 1841 Sl futex_wait_queue meteo-api
# 1834 1842 D flock_lock_inode_wait meteo-api
# 1834 1843 D flock_lock_inode_wait meteo-api
# 1834 1844 D flock_lock_inode_wait meteo-api
# 1834 1845 Sl futex_wait_queue meteo-apiWhat it reveals. ps -eLo lists threads (-L), not processes: without that option you would see none of this (03-02). Three threads are in state D and the wchan field —the kernel function they are sleeping in— says exactly what they are waiting for: flock_lock_inode_wait, that is, a file lock from 04-04. And two more are in futex_wait_queue, waiting on a user-space mutex (03-04).
for t in 1842 1843 1844; do echo "--- TID $t"; cat /proc/1834/task/$t/stack; done | tee -a "$LOG"
# --- TID 1842
# [<0>] flock_lock_inode_wait+0x11e/0x150
# [<0>] sys_flock+0x14a/0x1a0
# [<0>] do_syscall_64+0x5c/0xc0Confirmation from the kernel. The thread's in-kernel stack confirms it is inside the flock() call and not somewhere else. It is the same technique you learned in 03-06 for diagnosing a process stuck in D.
lsof -p 1834 | grep -E 'meteora|lock' | tee -a "$LOG"
# meteo-api 1834 meteora 7u REG 9,0 17280000 2621441 /var/lib/meteora/readings/2026-08-31.dat
# meteo-api 1834 meteora 9u REG 9,0 0 2621509 /run/meteora/cache.lock
# meteo-api 1834 meteora 11u REG 9,0 4194304 2621602 /var/log/meteora/meteo-api.log
cat /proc/locks | grep -E '2621441|2621509' | tee -a "$LOG"
# 12: FLOCK ADVISORY WRITE 1834 09:00:2621509 0 EOF
# 13: FLOCK ADVISORY WRITE 9127 09:00:2621441 0 EOFThe full picture. /proc/locks is the kernel's lock table, and there are two protagonists here: PID 1834 (meteo-api) holds the cache lock (cache.lock), and PID 9127 (aggregator) holds the data file lock. Each is waiting for the one the other holds.
sudo gdb -p 1834 -batch -ex 'thread apply all bt' 2>/dev/null | grep -A4 'Thread 4' | tee -a "$LOG"
# Thread 4 (Thread 0x7f2a... (LWP 1842)):
# #0 0x00007f2a... in flock () from /lib/x86_64-linux-gnu/libc.so.6
# #1 0x000055c1... in lock_readings_file () at store.c:214
# #2 0x000055c1... in refresh_cache_from_disk () at cache.c:96
# #3 0x000055c1... in handle_query () at api.c:341And here is the root cause. The user-space stack gives away the real acquisition order: refresh_cache_from_disk() takes the cache lock first and then asks for the file one. But the hierarchy agreed in 03-06 for the whole system is:
configuration → cache → file → log
Hold on: that code respects the hierarchy (cache before file). The one violating it is the other end. Looking at the aggregator:
sudo gdb -p 9127 -batch -ex bt 2>/dev/null | head -5 | tee -a "$LOG"
# #0 0x00007f4b... in flock () from /lib/x86_64-linux-gnu/libc.so.6
# #1 0x000055aa... in lock_cache () at cache.c:58
# #2 0x000055aa... in dump_averages () at aggregator.c:187Confirmed: a deadlock caused by a hierarchy violation. The aggregator took the file lock first and is now asking for the cache one; meteo-api took the cache one and is asking for the file one. It is the circular wait cycle, Coffman's fourth condition, in its purest form and with only two participants. gdb -batch with -ex 'thread apply all bt' is non-destructive if used carefully, but it stops the process while it runs: use it briefly and knowing that you are adding latency.
An important note on why this had not been seen before: with the aggregator finishing in two minutes, the overlap window was minimal. Once it stretched to 17 minutes because of the disk saturation, the probability of a collision shot up. The first problem uncovered the second, which had been latent for months. It is a common pattern: rare deadlocks become frequent as soon as something slows the system down.
The deadlock, its fix and the proof
Immediate mitigation, because the cycle does not break by itself:
note "Deadlock confirmed 1834<->9127. Terminating the aggregator to break the cycle."
kill -TERM 9127
sleep 5; ps -p 9127 || echo "aggregator terminated"
cat /proc/locks | grep -E '2621441|2621509'
# 12: FLOCK ADVISORY WRITE 1834 09:00:2621509 0 EOF (and it releases right away)Why kill the aggregator and not meteo-api. It is the recovery-by-termination of 03-06, choosing the victim on two criteria: the aggregator is re-runnable —its timer will launch it again, and with Persistent=true the run is not lost— whereas restarting meteo-api would cut off 1,188 established connections. What is more, SIGTERM rather than SIGKILL gives it the chance to close cleanly, avoiding exactly the truncated files that the size % 24 != 0 check from 07-01 would detect.
awk '$4 ~ /03:4[2-9]/ {print $NF}' /var/log/meteora/meteo-api.log | sort -n \
| awk '{a[NR]=$1} END {printf "n=%d p50=%.3f p99=%.3f\n", NR, a[int(NR*.5)], a[int(NR*.99)]}'
# n=39004 p50=0.033 p99=0.204Service restored at 03:44: a p99 of 0.204 s, indistinguishable from the 0.198 s baseline. And now for the real fix, which is a code change:
/* BAD — aggregator.c:187, violates the hierarchy: file → cache */
flock(fd_file, LOCK_EX);
flock(fd_cache, LOCK_EX); /* <-- order inverted */
/* GOOD — global hierarchy: configuration → cache → file → log */
flock(fd_cache, LOCK_EX);
flock(fd_file, LOCK_EX);And the proof that it no longer happens, which is the part almost everybody skips:
# 1. Targeted stress test in pre-production: force the overlap 500 times
for i in $(seq 1 500); do
systemctl start aggregator.service &
curl -s -o /dev/null "https://meteo-pre/v1/readings?station=EST-0142&refresh=1" &
wait
done
# 2. Automatic verification: no thread in D waiting on flock
watch -n5 'ps -eLo stat,wchan:24,comm | grep -c "^D.*flock"'
# 3. Permanent static check: a single acquisition point
grep -rn 'flock(' src/ | grep -v 'locks.c' # must be emptyWhy this way. The first test reproduces the scenario that was rare in production; if the deadlock persisted, it would show up within a few attempts. The second is a cheap continuous check that can be turned into a metric. The third is the most valuable in the long run: centralizing every lock acquisition in a single module that always takes them in the hierarchy's order turns the discipline into something the compiler and a review rule can police, instead of depending on every programmer remembering the agreement. It is 03-06's prevention put into practice.
Fourth finding, unexpected: the incident changes its nature
It is 03:52. The service is fine and you are gathering material for the post-mortem. You check whether the aggregator left anything behind:
You stop. That file has the setuid bit (rws), belongs to root, has a hidden name and a timestamp of 02:51. Nothing in Meteora's system creates that. And then you check the other thing that had struck you as odd:
awk '$4 ~ /02:[0-9][0-9]/ {split($4, t, ":"); print t[2]":"t[3]}' \
/var/log/meteora/meteo-api.log | uniq -c | awk '$1 < 50'
# 0 02:47
# 0 02:58
grep -c . /var/log/meteora/meteo-api.log
journalctl --since '02:40' --until '03:00' | tail -20There is an eleven-minute gap (02:47-02:58) in a log that writes thousands of lines per minute. A service that was blocked would leave fewer lines, not zero. A clean, exact gap in a log file is, until proven otherwise, tampering.
At this point the incident stops being a performance one. Two independent indicators —a root setuid binary that appeared at 02:51 in /tmp and a gap in the logs surrounding it— point to a possible compromise. From here on, everything you learned in module 7 becomes subordinate to module 5.
The first thing is what you do NOT do:
- Do not run the binary, not even with
--helpor on a "test" machine. It is root setuid. - Do not delete it. It is the main piece of evidence.
- Do not reboot the machine. You would lose all the memory, the connections and the processes, which is precisely the most valuable material.
- Do not carry on "investigating the performance". Every command you run modifies access times, journal entries and shell history, and contaminates the scene.
- Do not raise the alarm through a channel that might be compromised. If the attacker has access, they will read your messages.
Preserve in order of volatility (05-04), from the most ephemeral to the most durable:
| Order | What | How |
|---|---|---|
| 1 | RAM | A dump with LiME or avml to an external destination |
| 2 | Process and network state | ps -eLf, ss -tanp, lsof -n, /proc/<pid>/maps |
| 3 | Connections and ARP table | ss -tunap, ip neigh |
| 4 | Disks | A bit-for-bit image with dd, and a hash before and after |
| 5 | Remote logs and backups | The ones already off the machine |
# Metadata of the file WITHOUT executing or altering it
stat /tmp/.sysupd | tee -a "$LOG"
sha256sum /tmp/.sysupd | tee -a "$LOG"
# Forensic copy of the logs and the evidence, with verifiable integrity
tar -czf - /var/log/meteora /var/log/auth.log /tmp/.sysupd \
| tee /mnt/evidence/meteo-01-$(date +%s).tgz | sha256sum | tee -a "$LOG"
# System context
last -F | head -20 | tee -a "$LOG"
ausearch -ts 02:40 -te 03:00 -m EXECVE 2>/dev/null | tee -a "$LOG"
find / -xdev -perm -4000 -newermt '2026-08-30' -ls 2>/dev/null | tee -a "$LOG"What each one contributes. stat gives the inode's three timestamps without opening the file. The SHA-256 hash makes it possible to prove later that the evidence was not altered: it is the technical foundation of the chain of custody. last -F shows the logins with full dates. ausearch queries auditd —if it was running, which is why you install it before you need it— for the executions in that window. And the find looks for other recent setuid files across the whole system, because an attacker rarely leaves just one.
Contain without destroying, in this order:
- Isolate the network while keeping the machine powered on: nftables rules that allow only your administrative access. Powering it off destroys the memory; disconnecting it entirely may also tip off the attacker.
- Do not change credentials yet if that would warn the intruder, unless instructed by whoever is coordinating the response.
- Escalate immediately: the security lead, the service owner, management, and legal counsel and compliance. If personal data was accessed, in the EU the GDPR sets notification deadlines of 72 hours that start running from the moment the fact becomes known, and that decision is not made by whoever is at the console at four in the morning.
- Move to an out-of-band communication channel and document who knows what, and since when.
Explicit warning. This narrative is teaching material. A real incident response must follow your organization's formal procedure and must have legal advice. Decisions about preserving evidence, notifying authorities and customers, public communication and any eventual criminal complaint have legal and contractual consequences that go beyond the technical. If your organization does not have that procedure written down, writing it before the next incident is worth more than any tool.
And why the performance investigation stops here. For three solid reasons. First, evidence contamination: every command alters the scene and can render the forensic analysis useless. Second, a change of priority: high latency costs money, a compromise can cost the data of thousands of people. And third, a change of hypothesis: if there is an intruder, everything diagnosed becomes suspect —did the aggregator slow down on its own, or did somebody force it? does the log gap cover the moment the binary was placed?—. You do not know whether the performance problem was the cause, the consequence or the smokescreen. Investigating performance on a possibly compromised system is building on sand.
A blameless post-mortem
A blameless post-mortem starts from a proven premise: people act reasonably given the information they have at the time. If the goal is to point at somebody, people hide information and the same failure happens again. If the goal is to understand the system, you learn.
Timeline (all times CEST on 2026-08-31):
| Time | Event |
|---|---|
| 02:41 | A RAID resync finishes (ruled out as the cause) |
| 02:47-02:58 | Unexplained gap in meteo-api.log |
| 02:51 | /tmp/.sysupd appears, root setuid |
| 03:00:14 | The timer launches the aggregator |
| ~03:05 | The API's p99 starts degrading |
| 03:12 | Customer report |
| 03:17 | Narrowed to I/O: io full 71%, aqu-sz 27, rareq-sz 4 KB |
| 03:22 | The aggregator identified (pidstat -d, filefrag: 5,314 extents) |
| 03:24 | Mitigation with ionice; p99 drops from 6.1 s to 0.94 s |
| 03:31 | meteo-api threads in D waiting on flock |
| 03:40 | Deadlock from a hierarchy violation confirmed |
| 03:42 | SIGTERM to the aggregator; cycle broken |
| 03:44 | Service restored: p99 0.204 s |
| 03:52 | The setuid binary and the log gap found |
| 03:55 | The incident is reclassified as security; escalated |
Root cause versus contributing causes. The distinction is not academic: it determines where the effort goes.
- Root cause of the degradation: the
aggregatorreads the data with a 4 KB random pattern —re-reading 17.9 GB to process 16.5 MB— over files fragmented into more than 5,000 extents, which exceeds the RAID 1's IOPS capacity by an order of magnitude. - Contributor 1: there were no I/O limits in the
aggregator's unit, so it could consume 100% of the disk competing as an equal with the user-facing service. - Contributor 2: the
aggregatorviolates the agreed lock hierarchy. Latent for months, it surfaced once its run stretched out. It was not the cause, but without it the service would have returned to normal at minute 12 instead of minute 32. - Contributor 3: there was no alert on I/O pressure. The warning came from the customers, not from the system, seven minutes late.
- Contributor 4:
/tmpwas mounted withoutnoexecornosuid, which allowed a setuid binary to be executable there. - Contributor 5: the logs existed only on the machine, so the gap cannot be checked against any external copy.
Concrete preventive actions, each with an owner and a deadline:
| # | Action | Module | Deadline |
|---|---|---|---|
| 1 | IOWeight, IOReadBandwidthMax and MemoryMax limits in aggregator.service |
06-02, 07-02 | Done |
| 2 | Alert on io full avg60 > 20% for 5 min |
07-03 | 3 days |
| 3 | Rewrite the aggregator's reading as a single sequential pass |
02-05 | 2 weeks |
| 4 | Preallocate the day's files with fallocate |
04-05 | 2 weeks |
| 5 | Centralize flock in a single module that enforces the hierarchy |
03-06 | 3 weeks |
| 6 | Overlap stress test in continuous integration | — | 3 weeks |
| 7 | Remount /tmp with noexec,nosuid,nodev |
04-03 | 1 day |
| 8 | Periodic setuid file audit and an up-to-date AIDE database | 05-03 | 1 week |
| 9 | Ship the logs to an external append-only collector | 05-04 | 1 week |
| 10 | A written incident response procedure, with legal contacts | 05-04 | 1 month |
Notice the pattern: not one action is "be more careful". They are all changes to the system —limits, alerts, mount options, code structure— that make the failure impossible or make it detect itself. That is the test of whether a post-mortem was worth anything.
Common Mistakes and Tips
| Mistake | Consequence | What to do |
|---|---|---|
| Restarting the service "to see if it fixes itself" | Destroys the evidence and the problem comes back | Capture the state before mitigating |
| Stopping at the first finding | Here you would have left the p99 at 0.94 s | Always compare against the baseline |
| Applying several mitigations at once | You do not know which one worked | One at a time, measuring |
| Confusing mitigation with a fix | The incident repeats the following month | Record both separately |
| Not taking timestamped notes | The post-mortem is based on recollections | A logbook with tee from minute one |
strace on the service in production |
A 10× to 100× slowdown | perf, eBPF, or /proc stacks |
| Running a suspicious binary "to see what it does" | It may be the final step of the attack | Do not touch it; preserve and escalate |
| Carrying on with performance after signs of intrusion | You contaminate evidence and prioritize badly | Stop, contain, escalate |
| A post-mortem with people's names in it | People hide information | Blameless, about the system |
| Vague preventive actions | They change nothing | Concrete changes, with an owner and a deadline |
Final tips for the on-call shift: always start with dmesg; quantify before touching anything and measure again afterwards; put a time limit on the investigation phase and respect it; speak up early even when you do not have the answer, because people tolerate communicated uncertainty far better than silence; and distrust the first explanation that fits, because real incidents, like this one, usually have more than one cause.
Exercises
Exercise 1: the service that dies every night
Every night between 02:00 and 04:00, meteo-api stops responding for a few seconds. systemctl status says active (running) when you look at it in the morning, but Main PID has changed. The data collected:
dmesg -T | grep -i oom [Mon Aug 31 03:22:41 2026] aggregator invoked oom-killer: gfp_mask=0x140cca, order=0, oom_score_adj=0 [Mon Aug 31 03:22:41 2026] Out of memory: Killed process 1834 (meteo-api) total-vm:4210408kB, anon-rss:2914208kB, file-rss:0kB, shmem-rss:8192kB, UID:990 free -m (03:20): total 16037 used 15102 free 198 buff/cache 737 available 402 vmstat (03:20): r=3 b=2 si=1204 so=1890 cs=14022 /proc/pressure/memory: some avg60=58.11 full avg60=31.04
Diagnose it with the method from 07-03: who caused the problem, why the one that died was the one that died, and what mitigation and what real fix you would apply.
Exercise 2: the service that will not start after a change
After editing /etc/meteora/meteora.conf to add a new cache path, meteo-api will not start:
Active: failed (Result: exit-code) since Mon 2026-08-31 09:14:02 CEST; 30s ago Process: 20114 ExecStart=/usr/local/bin/meteo-api --config /etc/meteora/meteora.conf (code=exited, status=1/FAILURE) journalctl -u meteo-api -n 3: meteo-api[20114]: fatal: cannot create /var/cache/meteora/idx: Read-only file system
Run by hand as meteora, the binary starts with no problem at all. Explain the contradiction and give the correct solution, plus two incorrect ones that must be avoided and why.
Solutions
Solution 1
Diagnosis. The indicators are unambiguous and none of them is about I/O: an available of only 402 MB, si=1,204 and so=1,890 pages per second simultaneously —the system is pushing pages in and out at the same time, which is the definition of thrashing (02-04)— and a memory pressure of 58% (some) with 31% full: for almost a third of the time the whole machine makes no progress.
Who caused the problem and who died are not the same. The first line of dmesg says it literally: aggregator invoked oom-killer, that is, it was the aggregator's memory request that exhausted the system and triggered the mechanism. But the one chosen was meteo-api, with 2.9 GB of anon-rss. The reason lies in the oom_score, which is essentially proportional to resident memory: the OOM killer kills the biggest process, not the guilty one. And since meteo-api runs under systemd with Restart=on-failure, it restarts by itself, which explains the active (running) with a different Main PID in the morning and the fact that nobody noticed.
The 02:00-04:00 window coincides with the aggregator's nightly runs; the mechanism is memory accumulation in the aggregation process —most likely it loads the entire history into RAM instead of processing it in chunks.
Mitigation (tonight, without touching code):
With this, when the aggregator goes past 1 GB, the cgroup's OOM killer will kill it and only it, without touching the rest of the system; MemoryHigh adds an earlier step in which the kernel throttles it and reclaims pages aggressively before the hard limit is reached. It is exactly the mechanism from 06-02. It is also worth protecting the victim with MemoryMin=512M in meteo-api.service, which reserves memory the kernel will not reclaim from it.
Real fix: process the history in chunks with bounded, constant consumption, instead of loading it whole. And verification: track the aggregator's RSS during a run (while true; do awk '/VmRSS/{print $2}' /proc/$(pgrep -x aggregator)/status; sleep 10; done) to check that it stabilizes instead of growing linearly. Preventive alert: memory some avg60 > 20% for 5 minutes, which would have warned weeks before the first customer did.
What you must not do: lower vm.swappiness (the shortage is not of swap, it is of memory), add RAM without understanding the growth (that only delays the problem), or tweak meteo-api's oom_score_adj so it does not get chosen (you would just move the death to another innocent process).
Solution 2
The contradiction is only apparent, and its explanation is the unit's hardening. By hand, the binary runs with the normal file system and meteora can write wherever its permissions allow. Under systemd, the unit has ProtectSystem=strict, which mounts the entire system tree read-only inside the service's mount namespace, with the only exceptions being those declared in ReadWritePaths=: /var/lib/meteora, /var/log/meteora and /run/meteora. The new path, /var/cache/meteora, is not on that list, so the process sees a read-only file system and fails with EROFS. It is the isolation mechanism from 05-03 working exactly as it should.
The correct solution, which additionally delegates to systemd the creation of the directory with the right owner and mode:
systemctl daemon-reload && systemctl restart meteo-api.service
systemctl show meteo-api.service -p ReadWritePaths
journalctl -u meteo-api.service -n 20 --no-pagerCacheDirectory=meteora creates /var/cache/meteora owned by meteora:meteora, adds it automatically to the writable paths and manages it as part of the service's life cycle. The acceptable alternative, if the directory already exists and is managed by another process, is to add ReadWritePaths=/var/cache/meteora.
Two incorrect solutions and why:
- Removing
ProtectSystem=strict. It solves the symptom by disarming the protection: the service becomes able to write anywhere in the system,/etcand/usrincluded. You trade a five-line configuration problem for a permanent increase in attack surface, in precisely the service exposed to the Internet. Thesystemd-analyze securityscore would reflect it immediately. - Running the service as root (or giving the directory
chmod 777). It throws away the whole chain of decisions made through the course: the shell-less account, UID 990,CAP_NET_BIND_SERVICEinstead of root,NoNewPrivileges. And777would let any user on the system —including a compromised process— tamper with the service's cache.
General lesson: when a service works by hand and fails under systemd, the difference is almost always in the environment (PATH, working directory, variables) or in the hardening (ProtectSystem, ReadWritePaths, SystemCallFilter, capabilities). And the right answer is almost always to declare the specific exception, never to disable the protection.
Conclusion
You have resolved a complete incident, and in doing so you have used the entire course. It is worth looking at the map, because every piece of theory has ended up turned into a diagnostic tool.
From module 1 came the idea that the operating system is an extended machine and a resource manager, and that everything goes through system calls; without that, strace, wchan and /proc/<pid>/stack would be magic. From module 2 came the process states —D and its weight in the load average—, the disk access patterns that explain why 16.5 MB can take 42 seconds, the OOM killer choosing by size rather than by guilt, and RSS versus VSZ for detecting a leak. From module 3 came threads, flock, the futex and, above all, the lock hierarchy whose violation produced the deadlock and whose centralization prevents it. From module 4, inodes and /proc/locks, the extent fragmentation filefrag revealed, and the atomic writing that prevents truncated files. From module 5, everything that happened from 03:52 onwards: the setuid binary, the gap in the log, the order of volatility, the chain of custody, noexec on /tmp and the obligation to escalate. From module 6, the cgroups that limited the aggregator with io.max and MemoryMax. And from module 7, the shell that ran it all, systemd that governs it and the method that gave the investigation its order.
That is the central message of the course: theory is not a toll you pay before practice, it is what lets you interpret what you see. Two people run iostat -x and see the same numbers; only one knows that a rareq-sz of 4 KB with rrqm/s at zero means random access, that the queue matters more than the utilization, and that a RAID 1 doubles reads but not writes. The difference is not in the command, it is in modules 2 and 4.
The paths that open up from here:
| Path | What to go deeper into | Natural next step |
|---|---|---|
| Systems administration | Networking, storage, high availability, backups | LFCS/RHCSA certifications; build your own lab |
| DevOps and cloud | Infrastructure as code, CI/CD, Kubernetes, observability | Terraform, Ansible, a practice cluster |
| Security | Forensics, incident response, hardening, cryptography | Reverse engineering, CTFs, auditing real systems |
| Embedded and real-time systems | Yocto, FreeRTOS, Zephyr, drivers | A cheap board and a project with real deadlines |
| Kernel development | C, kernel data structures, subsystems | Linux Kernel Development; compile and patch a kernel |
They all share the same foundation: the one you have just finished.
How to keep practicing, which is the only thing that makes this stick:
- Build a lab virtual machine and break it on purpose. Provoke an OOM, saturate the disk with
fio, create a deadlock with two scripts andflock, delete a unit file and repair it. Nothing teaches like fixing something you broke yourself. - Read
/procout of curiosity. Every file in/proc/<pid>/is a window onto a kernel structure. Spend an afternoon walking throughstatus,maps,io,limits,stack,fd/andenvironof a real process: you will understand more than from many chapters. - Redo the course's exercises on your own machine, changing the numbers. The ones in module 3 and the labs in 07-03 pay off the most.
- Read logs even when nothing is happening. Getting familiar with what a normal
journalctllooks like is what will let you spot the abnormal in three seconds. - Write your own post-mortems, even when the incident is domestic and you are the only reader. Putting the timeline and the root cause in writing is what turns an experience into knowledge.
We began by defining the operating system as an extended machine that hides the complexity of the hardware. We end at four in the morning on a real server, reading that complexity through the windows the system itself offers us: /proc, the journal, the kernel's counters. Between the two points there are seven modules, but really there is a single idea repeated: the operating system is not a black box. It is a program, written by people, that makes comprehensible decisions about limited resources, and that leaves a trail of every one of them. Learning to read that trail is what separates somebody who uses a computer from somebody who understands it.
You know how to do it now. The next time the phone rings at three in the morning, you will not know the answer —nobody does— but you will know how to find it: quantify the symptom, rule things out with data, look for saturation rather than utilization, distrust the first explanation that fits, and write everything down so the next person has an easier time.
Thank you for making it this far. Now close this, open a terminal and break something.
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
