Disk & network I/O

iostat, iotop, ss, and where time goes.

Advanced12 min · lesson 3 of 17

The USE method (Utilization, Saturation, Errors) tells you which resource is under pressure. It does not tell you which of your programs is causing it, or where the time is actually going. When the arrow points at the disk or the network, you switch tools and start hunting for the place the seconds are disappearing.

First, the mistake almost everyone makes. They watch throughput, the megabytes per second crossing the disk or the wire. It looks calm, so they decide the I/O (input/output, data moving to and from storage or the network) is healthy. Meanwhile the application is still crawling. Throughput was the wrong number to trust.

A supermarket checkout makes the difference obvious. Throughput is how many carts the store rings up every hour. Latency is how long you personally stand in line holding your milk. The store can scan carts at a furious pace (high throughput) while your one line barely moves (high latency), because twenty people are ahead of you. Users feel latency. They never once see throughput.

Reading iostat Without Fear

iostat (input/output statistics) reports what each disk device is doing. The extended view is wide and a little intimidating, which is why people bounce off it. You can ignore most of the columns. Four of them carry the whole story. The first report iostat prints covers everything since boot, so it blends a calm night and a busy afternoon into one average; the reports after it are the live interval you actually care about.

~/secopslog — bash
$ # -d device only, -x extended, -z hide idle devices, 1s interval, 2 reports iostat -dxz 1 2
Linux 5.15.0-125-generic (db01) 07/17/2026 _x86_64_ (8 CPU) Device r/s rkB/s rrqm/s %rrqm r_await rareq-sz w/s wkB/s wrqm/s %wrqm w_await wareq-sz d/s dkB/s drqm/s %drqm d_await dareq-sz f/s f_await aqu-sz %util sda 1.20 48.00 0.00 0.00 0.80 40.00 18.40 980.00 3.20 14.81 2.10 53.26 0.00 0.00 0.00 0.00 0.00 0.00 0.40 1.10 0.05 2.30 Device r/s rkB/s rrqm/s %rrqm r_await rareq-sz w/s wkB/s wrqm/s %wrqm w_await wareq-sz d/s dkB/s drqm/s %drqm d_await dareq-sz f/s f_await aqu-sz %util sda 12.00 512.00 0.00 0.00 18.20 42.67 340.00 44032.00 28.00 7.61 31.40 129.51 0.00 0.00 0.00 0.00 0.00 0.00 5.00 12.30 10.89 99.20

Read the second report, the live one. r/s and w/s are IOPS (input/output operations per second): 12 reads and 340 writes finished each second. r_await and w_await are the average time, in milliseconds, a request waited from the moment it was handed to the device until it came back, queue time plus service time together. Modern iostat splits the old single await column into a read number and a write number, and it dropped svctm (service time) entirely, so old cheat sheets that tell you to watch await and svctm are out of date. aqu-sz is the average queue length, the number of requests in flight or waiting at any instant. %util is the fraction of time the device had at least one request in flight.

Now the reading is easy. wkB/s says 43 MB/s, which sounds gentle. But %util is pinned at 99, aqu-sz sits near 11 (about eleven requests waiting or in flight at any instant), and every write waits 31 ms. That is a saturated disk. The throughput number stayed calm while the latency and the queue did the damage, exactly like your milk line.

%util near 100 does not mean saturated on SSDs
%util only measures whether the device had at least one request outstanding, not how many it could handle at once. A single spinning disk serves one request at a time, so 99% util plus rising await really is saturation. An SSD (solid-state drive), an NVMe (Non-Volatile Memory Express) drive, or a RAID array (RAID, several physical disks bundled to act as one) serves many requests in parallel, so it can show 100% util with plenty of headroom left. On those, trust aqu-sz and await against the device's known IOPS ceiling, not %util alone.

Which Process Is Holding the Disk Under

iostat tells you the disk is drowning. It does not tell you whose hand is holding it under. iotop (I/O top) is the culprit finder. It attributes live read and write bytes to each process. Run it as root, and add -o so it lists only the processes actually doing I/O right now instead of every idle thread on the box.

~/secopslog — bash
$ # -o only active, -b batch (non-interactive), -n 1 one sample sudo iotop -o -b -n 1
Total DISK READ: 0.00 B/s | Total DISK WRITE: 43.02 M/s Current DISK READ: 0.00 B/s | Current DISK WRITE: 41.55 M/s TID PRIO USER DISK READ DISK WRITE SWAPIN IO COMMAND 814 be/4 postgres 0.00 B/s 38.21 M/s 0.00 % 92.14 % postgres: 14/main: checkpointer 1043 be/4 postgres 0.00 B/s 4.81 M/s 0.00 % 10.98 % postgres: 14/main: background writer 902 be/4 root 0.00 B/s 12.00 K/s 0.00 % 0.31 % systemd-journald

The checkpointer thread is spending 92% of its time blocked waiting on I/O, and it is pushing most of that 43 MB/s. TID is the thread ID, the per-thread cousin of the PID (process ID). If you would rather log this over time than watch it live, pidstat -d 1 from the same sysstat package prints per-process disk stats every second without the full-screen interface.

iotop shows zeros? Turn on delay accounting
On kernels 5.14 and newer (Ubuntu 22.04 ships 5.15), per-task delay accounting is off by default, so iotop's IO and SWAPIN columns read 0.00% even under heavy load. Switch it on with sudo sysctl kernel.task_delayacct=1, and persist it in /etc/sysctl.d/ if you want it after reboot. Without it you get byte counts but not the wait percentages, which is exactly the signal you came for.

The Network Side: Connection States Are a Diagnosis

A telephone switchboard makes the network states click. Every call is a line in some state: ringing, connected, being hung up, or waiting a polite moment before the plug is finally pulled. TCP (Transmission Control Protocol, the reliable delivery layer under most traffic) works the same way, and a pile of connections stuck in one state is usually the whole clue. ss (socket statistics) is the switchboard operator's view. Start with the summary.

~/secopslog — bash
$ ss -s
Total: 923 TCP: 142 (estab 88, closed 40, orphaned 0, timewait 39) Transport Total IP IPv6 RAW 1 0 1 UDP 12 8 4 TCP 102 95 7 INET 115 103 12 FRAG 0 0 0

Three states earn their own attention. TIME-WAIT is normal: it is the polite pause (about 60 seconds on Linux) after your side closes a connection, and a huge count means high connection churn, short-lived connections opened and closed in a storm, which can exhaust the pool of local ports. CLOSE-WAIT means the other end hung up and your application never called close, so a growing count is almost always a file-descriptor leak in your own code. And SYN-RECV is the one a defender watches closely.

SYN (synchronize) is the first packet of the TCP handshake. SYN-RECV means your server answered a SYN, sent its reply, and is waiting for the final acknowledgement that never arrives. Picture a prank caller who dials, waits for you to say hello, then stays silent and ties up your line. Thousands of half-open connections from scattered, unrelated source addresses is a SYN flood.

~/secopslog — bash
$ # how many half-open connections right now? (-H drops the header so the count is exact) ss -Htn state syn-recv | wc -l # and who are they claiming to be from? ss -tn state syn-recv | head -n 4
1287 State Recv-Q Send-Q Local Address:Port Peer Address:Port SYN-RECV 0 0 10.0.4.12:443 203.0.113.7:41022 SYN-RECV 0 0 10.0.4.12:443 198.51.100.44:1337 SYN-RECV 0 0 10.0.4.12:443 192.0.2.88:52001

1287 half-open connections, each from a different, unrelated peer, all aimed at port 443. That is the shape of a spoofed SYN flood, not real users. The kernel's defense is SYN cookies, which you confirm with sysctl net.ipv4.tcp_syncookies (it should read 1 on a modern box). With cookies on, once the half-open backlog fills, the server stops storing state for each half-open connection and encodes it into the reply instead, so it rides out the flood.

Where Network Latency Actually Hides

Connection counts tell you the shape of the traffic. To see why a single connection feels slow, read the delivery receipt for that one socket. ss -i prints the kernel's internal TCP numbers per socket: RTT (round-trip time, how long a packet takes to go out and be acknowledged), the congestion window (how many packets the kernel dares to have in flight before it waits for acknowledgements), and the retransmit count (packets it had to send twice because the first copy was lost).

~/secopslog — bash
$ # t tcp, i internal info, n numeric; filter to one peer ss -tin dst 203.0.113.90
State Recv-Q Send-Q Local Address:Port Peer Address:Port ESTAB 0 0 10.0.4.12:44210 203.0.113.90:5432 cubic wscale:7,7 rto:1200 rtt:412.5/210.0 mss:1448 cwnd:2 ssthresh:4 bytes_sent:1841520 bytes_retrans:26064 bytes_acked:1815456 segs_out:1290 data_segs_out:1180 retrans:0/18 lost:2 send 56.2Kbps pacing_rate 112Kbps delivery_rate 61.0Kbps minrtt:88.4

This connection is sick. rtt is 412 ms with big variance, retrans:0/18 means eighteen retransmissions over its life, and cwnd has collapsed to 2 packets because the kernel keeps hitting loss and backing off. The path is lossy or congested, and no amount of staring at the database will fix a network dropping roughly one packet in seventy. For the system-wide version of that retransmit signal, nstat (network statistics) reads the kernel's TCP counters.

~/secopslog — bash
$ nstat -az | grep -iE 'Retrans|Drop'
TcpRetransSegs 4821 0.0 TcpExtTCPSynRetrans 338 0.0 TcpExtTCPLostRetransmit 12 0.0 TcpExtListenDrops 57 0.0

TcpRetransSegs climbing steadily means packet loss somewhere on your paths. TcpExtListenDrops ties straight back to the flood section: it counts connections the kernel had to drop because the listen queue was full, which is what an undersized listen backlog does under a SYN storm or a genuine traffic spike.

Errors and Drops at the Interface

A NIC (network interface card) is a mail sorting room with a small inbox. When packets arrive faster than the CPU can pick them up, the inbox (the driver's receive ring buffer) overflows and letters hit the floor. ip -s link shows those counts per interface.

~/secopslog — bash
$ ip -s link show eth0
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP mode DEFAULT group default qlen 1000 link/ether 06:1f:6a:2c:8e:3a brd ff:ff:ff:ff:ff:ff RX: bytes packets errors dropped overrun mcast 918273645210 8129340 0 0 142 88 TX: bytes packets errors dropped carrier collsns 455102938471 6901120 0 0 0 0

errors at 0 and dropped at 0, but overrun at 142, points at the inbox, not the cable. The overrun column is the card saying its receive ring (the small FIFO the hardware fills before the CPU drains it) ran out of room, so packets fell on the floor because the host could not keep up. Confirm it with the driver's own counters, ethtool -S eth0 (ethtool is the NIC's control tool; look for rx_fifo_errors, rx_missed_errors, or rx_no_buffer_count, depending on the driver), and if the number keeps climbing, enlarge the ring with ethtool -G eth0 rx 4096. Nonzero errors instead of overruns would point the other way, at the physical layer: a bad cable, a dying optic, CRC failures (CRC, the cyclic redundancy check that catches corrupted frames). For a live bandwidth picture while you work, iftop, nload, or bmon show per-connection and per-interface throughput in real time.

The Full Disk That du Cannot Explain

A library book gets pulled from the catalog, but nobody returns the physical copy, so its shelf slot stays occupied. The catalog swears the slot is free. This is the classic disk puzzle: df (disk free) says the filesystem is full, du (disk usage) adds up the visible files and finds far less. A file was deleted while a process still held it open, and Linux does not reclaim the blocks until the last open handle to it closes.

~/secopslog — bash
$ df -h /var du -shx /var 2>/dev/null sudo lsof -nP +L1 /var # lsof = list open files; +L1 = link count below 1 (deleted) but still held open
Filesystem Size Used Avail Use% Mounted on /dev/sda2 49G 47G 0 100% /var 21G /var COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME rsyslogd 812 syslog 7w REG 8,2 26843545600 0 131074 /var/log/app.log (deleted)

df sees 47G used, du can only find 21G, and lsof explains the gap. NLINK 0 (no directory entry points at this file anymore), the (deleted) tag, and a 25G size: a deleted log that rsyslogd still writes to on file descriptor 7 (fd, the small number the kernel uses to track an open file). Restarting the process frees the space. If you cannot restart it right now, you can truncate the file through the still-open descriptor in /proc (the kernel's live view of every process, laid out as files).

~/secopslog — bash
$ sudo truncate -s 0 /proc/812/fd/7 df -h /var | tail -1
/dev/sda2 49G 22G 25G 47% /var

For a defender, this same mechanism does double duty as an incident-response signal. A process whose binary shows up as (deleted) in ls -l /proc/<pid>/exe is a hallmark of malware that unlinks itself from disk to hide while it keeps running from memory. The blocks stay pinned exactly like that log did, and you can recover the evidence straight from the open handle with sudo cp /proc/<pid>/exe /tmp/recovered.bin before you kill the process.

Truncating a live file is not free
truncate -s 0 through /proc frees the blocks instantly, but it also destroys the log's contents and does not reset the writer's offset. The process keeps writing at byte 25000000000, so the file becomes sparse (a huge reported size backed by almost no real blocks) and any tool reading it may hit a wall of null bytes. Restarting the writer is the clean fix; reach for the /proc truncate only when you cannot afford a restart and you have already saved anything you need.
Where the time went: three I/O investigations
Disk is slow
iostat -dxz 1
watch %util, aqu-sz, r_await/w_await, not MB/s
iotop -o
which PID/TID is holding the disk under
saturation signal
await climbs, aqu-sz > 1, IOPS at the device ceiling
Network is slow
ss -s / ss -tin
states, plus rtt, cwnd, retrans per socket
ip -s link / nstat
overrun, errors, TcpRetransSegs, ListenDrops
trouble signal
SYN-RECV flood, CLOSE-WAIT leak, rising retransmits
Disk full, du can't find it
df vs du disagree
used far exceeds the sum of visible files
lsof +L1
NLINK 0, deleted file still held open
fix
restart the writer, or truncate /proc/PID/fd
Byte counters stay calm while latency, queue depth, and connection state do the damage. Read those first.
Quick check
01A Postgres box feels slow. iostat -dxz shows sda with w/s 340, wkB/s 44032 (about 43 MB/s), w_await 31 ms, aqu-sz 11, %util 99. A teammate says the disk is fine because 43 MB/s is nothing. What do you tell them?
Incorrect — A rated ceiling tells you what a device could move on a good day, not what it is doing to your callers right now. The wait column is the one that matches what users feel.
Correct — Depth multiplied by wait is where the seconds go. Eleven deep at 31 ms each is a device that cannot keep up, no matter how gentle the byte rate looks.
Incorrect — That caveat applies to SSD, NVMe and RAID, which serve many requests at once and can sit at 100 percent with headroom left. A single disk serves one request at a time, so 99 percent alongside a climbing await is real saturation.
Incorrect — w_await is measured on the block side, from the moment a request is handed to the device until it comes back. Network trouble never shows up in that column; you would go to ss -i or nstat for it.
02A long-running service shows its CLOSE-WAIT count climbing hour after hour and never dropping back. What does that pattern point at?
Incorrect — Half-finished handshakes wait in SYN-RECV for an acknowledgement that never arrives. Thousands of those from scattered, unrelated peers is a SYN flood, and it lands in a different state entirely.
Incorrect — The churn state is TIME-WAIT, the roughly sixty second pause after your side closes, and it drains on its own. A number that only ever goes up is a leak rather than load.
Incorrect — Running out of local ports comes from TIME-WAIT stacking up on short-lived outbound connections, and you notice it as failures to connect, not as a growing CLOSE-WAIT.
Correct — CLOSE-WAIT is your half of the shutdown that never happened. Go find the code path that returns without closing the socket, because every one of them burns a file descriptor for good.
03df -h /var reports the filesystem 100% full, but du -shx /var only adds up to 21G on a 49G disk. lsof +L1 shows rsyslogd holding /var/log/app.log (deleted), NLINK 0, size 25G, on fd 7. What is going on and what is the clean fix?
Correct — Linux hands back a deleted file's blocks only when the last descriptor pointing at it closes. That is why du cannot see the 25G while df keeps counting it.
Incorrect — du walks names in the directory tree, and the missing 25G no longer has a name. No flag makes a tree walk find a file that was already unlinked.
Incorrect — Nothing in the output suggests damage, and lsof has already accounted for the entire gap. Unmounting /var on a live box buys you an outage and fixes nothing.
Incorrect — That frees the blocks straight away, but it destroys the log and leaves the writer's offset far out at byte 25000000000, so the file goes sparse and readers hit a wall of nulls. Save it for when you cannot restart.

Build the reflex and it pays off on every incident. Before you blame the disk, read await and aqu-sz, not the byte rate. Before you blame the network, read the connection states and the retransmit counter, not the byte rate. And when df and du disagree, reach for lsof +L1 before you delete things at random. The throughput graphs will look calm and lie to you. The latency, the queue, and the connection state will not.

Try this

Work through “The Full Disk That du Cannot Explain” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: %util near 100 does not mean saturated on SSDs. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related