If your Unraid server lives in a separate room, you might not notice that your physical hard drives are failing to spin down. I only realised my array was running non-stop after sleeping in the spare bedroom where my server is located.
I assumed my data was strictly sitting on my NVMe cache, but Unraid was still waking up physical disks. A key quirk to keep in mind: Unraid does not clean up empty directories when moving files between array disks and the cache. If an app queries a directory structure that exists on a physical disk, Unraid spins up that disk just to list the folders, even if no files are read from it.
The popular "Open Files" plugin can be confusing to parse, so I used AI to write a custom python script that directly traces kernel I/O and maps real process activity to target drives.
Created using AI assistant.
Here is an example of the live console output:
========================================================================================================================
UNRAID PHYSICAL DISK I/O — KERNEL TRACE & UNRAID DISK RESOLVER
========================================================================================================================
Interval: 3.0s Physical disks: sda [disk5], sdb [parity], sdc [disk2], sdd [disk6], sde [disk1], sdf [flash], sdg [disk4], sdh [disk3]
========================================================================================================================
PHYSICAL DISKS
------------------------------------------------------------------------------------------------------------------------
DISK READ WRITE OPS
sda [disk5] 0.0 B/s 0.0 B/s 0
sdb [parity] 0.0 B/s 0.0 B/s 3
sdc [disk2] 8.1 MB/s 0.0 B/s 100
sdd [disk6] 0.0 B/s 0.0 B/s 3
sde [disk1] 34.7 KB/s 0.0 B/s 8
sdf [flash] 0.0 B/s 0.0 B/s 1
sdg [disk4] 0.0 B/s 0.0 B/s 3
sdh [disk3] 0.0 B/s 0.0 B/s 0
PHYSICAL KERNEL ISSUERS
------------------------------------------------------------------------------------------------------------------------
PID DOCKER / APP PROCESS DISK READ WRITE OPS
27197 HOST mdunraidd2 sdc [disk2] 8.1 MB/s 0.0 B/s 100
27196 HOST mdunraidd1 sde [disk1] 34.7 KB/s 0.0 B/s 8
REAL USER-SPACE APPLICATIONS & TARGET DISK LOCATION
------------------------------------------------------------------------------------------------------------------------
PID APP / DOCKER CONTAINER PROCESS TARGET DISK OPEN PATH
37178 HOST qemu-system-x86 cache /mnt/cache/domains/XUbuntu/vdisk1.qcow2
1766909 HOST smbd[192.168.0. disk1 /mnt/user/data
3270942 DOCKER: plex Plex Transcoder disk2 /mnt/user/data/media/anime tv/xx.mkv
I don't know why my block of code didn't get attached.
#!/usr/bin/env python3
"""
Unraid Physical Disk I/O — KERNEL TRACE & UNRAID DISK RESOLVER
Extends kernel block_rq_issue tracing by resolving open file handles (/proc/*/fd)
and physical kernel devices (sda-sdz) directly to Unraid Array labels (disk1, parity, etc.).
Run as root:
python3 unraid_disk_io_trace.py
"""
import argparse
import os
import re
import subprocess
import sys
import time
from collections import defaultdict
from pathlib import Path
TRACE_ROOTS = [
Path("/sys/kernel/tracing"),
Path("/sys/kernel/debug/tracing"),
]
def trace_root():
for p in TRACE_ROOTS:
if (p / "trace_pipe").exists() and (p / "events/block/block_rq_issue").exists():
return p
return None
def read(path):
try:
return path.read_text(errors="replace").strip()
except Exception:
return ""
def is_nvme(name):
return name.startswith("nvme")
def is_partition(name):
return bool(re.match(r"^(sd|hd|vd|xvd)[a-z]+\d+$|^mmcblk\d+p\d+$|^nvme\d+n\d+p\d+$", name))
def physical_disks():
result = {}
root = Path("/sys/class/block")
for p in root.iterdir():
name = p.name
if is_nvme(name) or is_partition(name) or name.startswith(("loop", "dm-", "md", "zram", "ram")):
continue
if not (p / "dev").exists() or not (p / "device").exists():
continue
dev = read(p / "dev")
if not dev:
continue
try:
major, minor = map(int, dev.split(":"))
result[(major, minor)] = name
except ValueError:
continue
return result
def get_unraid_disk_mappings(disks):
"""
Maps kernel names (e.g., 'sdh') to Unraid labels (e.g., 'disk3', 'parity')
using /proc/mounts and Unraid's disks.ini state file.
"""
dev_to_unraid = {}
# 1. Parse /proc/mounts for active mount points (/mnt/diskX, /mnt/cache, etc.)
try:
with open("/proc/mounts", "r") as f:
for line in f:
parts = line.split()
if len(parts) >= 2:
dev_path, mount_point = parts[0], parts[1]
if mount_point.startswith("/mnt/"):
label = mount_point.replace("/mnt/", "")
if label in ("user", "user0", "disks"):
continue
try:
st = os.stat(mount_point)
major = os.major(st.st_dev)
minor = os.minor(st.st_dev)
kernel_dev = disks.get((major, minor))
if kernel_dev:
dev_to_unraid[kernel_dev] = label
except Exception:
continue
except Exception:
pass
# 2. Parse Unraid's emhttp status file for assigned/unmounted/parity disks
disks_ini = Path("/var/local/emhttp/disks.ini")
if disks_ini.exists():
try:
content = disks_ini.read_text(errors="replace")
current_name = None
for line in content.splitlines():
line = line.strip()
if line.startswith("[") and line.endswith("]"):
current_name = line[1:-1].strip('"')
elif line.startswith("device=") and current_name:
dev_name = line.split("=", 1)[1].strip('"')
# Strip partition number if present (e.g. sdh1 -> sdh)
dev_name = re.sub(r"\d+$", "", dev_name)
if dev_name in disks.values() and dev_name not in dev_to_unraid:
dev_to_unraid[dev_name] = current_name
except Exception:
pass
return dev_to_unraid
def format_disk_label(dev_name, unraid_map):
"""Returns a string like 'sdh [disk3]' or 'sda [unassigned]'."""
label = unraid_map.get(dev_name)
if label:
return f"{dev_name} [{label}]"
return dev_name
def docker_containers():
result = {}
try:
out = subprocess.check_output(
["docker", "ps", "-a", "--no-trunc", "--format", "{{.ID}}\t{{.Names}}"],
stderr=subprocess.DEVNULL, text=True, timeout=5
)
for line in out.splitlines():
x = line.split("\t", 1)
if len(x) == 2:
result[x[0]] = x[1]
except Exception:
pass
return result
def container_for_pid(pid, containers):
try:
text = Path(f"/proc/{pid}/cgroup").read_text(errors="replace")
except Exception:
return None
ids = set(re.findall(r"[0-9a-f]{64}", text))
ids.update(re.findall(r"docker[-/]([0-9a-f]{12,64})", text))
for cid, name in containers.items():
for found in ids:
if cid.startswith(found) or found.startswith(cid):
return name
return None
def find_open_files_on_array(containers, disks, unraid_map):
active_apps = []
for pid_dir in Path("/proc").glob("[0-9]*"):
try:
pid = int(pid_dir.name)
fd_dir = pid_dir / "fd"
if not fd_dir.exists():
continue
comm = read(pid_dir / "comm")
if comm in ("shfs", "mdunraid") or comm.startswith("kworker"):
continue
app_name = "HOST"
container = container_for_pid(pid, containers)
if container:
app_name = f"DOCKER: {container}"
for fd in fd_dir.iterdir():
try:
target = os.readlink(fd)
if not target.startswith("/mnt/"):
continue
actual_disk = "UNKNOWN"
real_path = target
# If open path is a direct disk mount
if not target.startswith("/mnt/user/"):
for p in Path("/mnt").glob("*"):
if p.name in ("user", "user0", "disks"):
continue
if target.startswith(str(p) + "/"):
dev_name = get_device_for_mount(str(p), disks)
actual_disk = format_disk_label(dev_name, unraid_map)
break
else:
# Path opened via /mnt/user/ -> Find physical array location
relative_path = target.replace("/mnt/user/", "", 1)
found_on_array = False
# 1. Check physical array disks FIRST (/mnt/disk1..N)
for disk_path in sorted(Path("/mnt").glob("disk*")):
check_path = disk_path / relative_path
if check_path.exists():
dev_name = get_device_for_mount(str(disk_path), disks)
actual_disk = format_disk_label(dev_name, unraid_map)
real_path = str(check_path)
found_on_array = True
break
# 2. Check cache/pool mounts ONLY if NOT on array
if not found_on_array:
for pool in Path("/mnt").glob("*"):
if pool.name in ("user", "user0", "disks") or pool.name.startswith("disk"):
continue
check_path = pool / relative_path
if check_path.exists():
actual_disk = f"{pool.name} [POOL]"
real_path = str(check_path)
break
if actual_disk != "UNKNOWN":
active_apps.append({
"pid": pid,
"app": app_name,
"comm": comm,
"disk": actual_disk,
"path": target
})
break
except Exception:
continue
except Exception:
continue
return active_apps
def get_device_for_mount(mount_path, disks):
try:
st = os.stat(mount_path)
major = os.major(st.st_dev)
minor = os.minor(st.st_dev)
return disks.get((major, minor), mount_path.replace("/mnt/", ""))
except Exception:
return mount_path.replace("/mnt/", "")
def enable_trace(root):
try:
(root / "tracing_on").write_text("0")
(root / "trace").write_text("")
(root / "events/block/block_rq_issue/enable").write_text("1")
(root / "tracing_on").write_text("1")
except Exception as e:
raise RuntimeError(f"Cannot enable block tracing: {e}")
def disable_trace(root):
try:
(root / "tracing_on").write_text("0")
(root / "events/block/block_rq_issue/enable").write_text("0")
except Exception:
pass
def parse_trace_line(line):
if "block_rq_issue:" not in line:
return None
m = re.search(r"\S+-(\d+)\s+\[\d+\]", line) or re.search(r"\s(\d+)\s+\[\d+\]", line)
pid = int(m.group(1)) if m else -1
dm = re.search(r"\b(\d+),(\d+)\b", line)
if not dm:
return None
major, minor = int(dm.group(1)), int(dm.group(2))
after = line.split("block_rq_issue:", 1)[1]
rm = re.search(r"\b([RWDNSF]+)\b", after)
if not rm:
return None
rwbs = rm.group(1)
sm = re.search(r"\+\s*(\d+)", after)
size = int(sm.group(1)) * 512 if sm else 0
cm = re.match(r"^\s*([^\s-]+(?:/[^\s-]+)?)\s*-\d+", line)
comm = cm.group(1) if cm else "unknown"
return {"pid": pid, "major": major, "minor": minor, "rwbs": rwbs, "bytes": size, "comm": comm}
def clear():
print("\033[2J\033[H", end="")
def fmt_rate(v):
units = ["B/s", "KB/s", "MB/s", "GB/s", "TB/s"]
x = float(v)
for u in units:
if x < 1024 or u == units[-1]:
return f"{x:8.1f} {u}"
x /= 1024
return f"{x:8.1f} TB/s"
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--interval", type=float, default=3.0)
parser.add_argument("--min-mb", type=float, default=0.01)
parser.add_argument("--all", action="store_true")
parser.add_argument("--once", action="store_true")
args = parser.parse_args()
if os.geteuid() != 0:
print("ERROR: Run as root (e.g., sudo python3 unraid_disk_io_trace.py)")
sys.exit(1)
root = trace_root()
if not root:
print("ERROR: Linux block tracing unavailable.")
sys.exit(1)
disks = physical_disks()
unraid_map = get_unraid_disk_mappings(disks)
containers = docker_containers()
try:
enable_trace(root)
trace_pipe = open(root / "trace_pipe", "r", buffering=1, errors="replace")
import select
while True:
start = time.monotonic()
end = start + max(0.1, args.interval)
by_disk = defaultdict(lambda: {"read": 0, "write": 0, "ops": 0})
by_issuer = defaultdict(lambda: {
"read": 0, "write": 0, "ops": 0, "disk": set(),
"pid": -1, "comm": "", "container": None
})
while time.monotonic() < end:
remaining = end - time.monotonic()
if remaining <= 0:
break
ready, _, _ = select.select([trace_pipe], [], [], min(remaining, 0.2))
if not ready:
continue
line = trace_pipe.readline()
if not line:
continue
event = parse_trace_line(line)
if not event:
continue
disk = disks.get((event["major"], event["minor"]))
if not disk or is_nvme(disk):
continue
size = event["bytes"]
is_write = "W" in event["rwbs"]
is_read = "R" in event["rwbs"]
if is_write:
by_disk[disk]["write"] += size
elif is_read:
by_disk[disk]["read"] += size
by_disk[disk]["ops"] += 1
pid = event["pid"]
app = container_for_pid(pid, containers) or "HOST"
key = (pid, app, event["comm"])
row = by_issuer[key]
row["pid"] = pid
row["comm"] = event["comm"]
row["container"] = app
row["disk"].add(disk)
row["ops"] += 1
if is_write:
row["write"] += size
elif is_read:
row["read"] += size
if not args.once:
clear()
disk_summary = ", ".join([format_disk_label(d, unraid_map) for d in sorted(disks.values())])
print("=" * 120)
print(" UNRAID PHYSICAL DISK I/O — KERNEL TRACE & UNRAID DISK RESOLVER")
print("=" * 120)
print(f" Interval: {args.interval:.1f}s Physical disks: {disk_summary}")
print("=" * 120)
print("\nPHYSICAL DISKS")
print("-" * 120)
print(f"{'DISK':<18}{'READ':>18}{'WRITE':>18}{'OPS':>10}")
for disk in sorted(disks.values()):
x = by_disk[disk]
label_str = format_disk_label(disk, unraid_map)
print(f"{label_str:<18}{fmt_rate(x['read']/args.interval):>18}{fmt_rate(x['write']/args.interval):>18}{x['ops']:>10}")
print("\nPHYSICAL KERNEL ISSUERS")
print("-" * 120)
print(f"{'PID':<8}{'DOCKER / APP':<30}{'PROCESS':<20}{'DISK':<18}{'READ':>14}{'WRITE':>14}{'OPS':>10}")
rows = [x for x in by_issuer.values() if args.all or (x["read"] + x["write"]) >= args.min_mb * 1024 * 1024]
rows.sort(key=lambda x: x["read"] + x["write"], reverse=True)
for x in rows[:60]:
disks_text = ",".join([format_disk_label(d, unraid_map) for d in sorted(x["disk"])])
print(f"{x['pid']:<8}{str(x['container'])[:29]:<30}{x['comm'][:19]:<20}{disks_text[:17]:<18}{fmt_rate(x['read']/args.interval):>14}{fmt_rate(x['write']/args.interval):>14}{x['ops']:>10}")
print("\nREAL USER-SPACE APPLICATIONS & TARGET DISK LOCATION")
print("-" * 120)
resolved = find_open_files_on_array(containers, disks, unraid_map)
if resolved:
print(f"{'PID':<8}{'APP / DOCKER CONTAINER':<28}{'PROCESS':<16}{'TARGET DISK':<20}{'OPEN PATH'}")
for app in resolved[:10]:
print(f"{app['pid']:<8}{app['app']:<28}{app['comm']:<16}{app['disk']:<20}{app['path']}")
else:
print("No open file handles detected on /mnt/disk* or /mnt/user.")
if args.once:
break
except KeyboardInterrupt:
pass
finally:
try:
trace_pipe.close()
except Exception:
pass
disable_trace(root)
if __name__ == "__main__":
main()