Update zfs_probe.py
This commit is contained in:
248
zfs_probe.py
248
zfs_probe.py
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
# example: python3 zfs_probe.py --duration 10 --interval 1 --track-files
|
# example: python3 zfs_probe.py --duration 120 --interval 5 --track-files --path-prefix /home
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import ast
|
import ast
|
||||||
@@ -223,6 +223,110 @@ def zfs_get_properties(pools):
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_zfs_mountpoints():
|
||||||
|
"""
|
||||||
|
Zwraca mountpointy filesystemów typu zfs widoczne w namespace procesu.
|
||||||
|
/proc/self/mountinfo opisuje mounty w bieżącym mount namespace.
|
||||||
|
"""
|
||||||
|
mountinfo = "/proc/self/mountinfo"
|
||||||
|
mounts = []
|
||||||
|
text = safe_read_text(mountinfo)
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or " - " not in line:
|
||||||
|
continue
|
||||||
|
left, right = line.split(" - ", 1)
|
||||||
|
lparts = left.split()
|
||||||
|
rparts = right.split()
|
||||||
|
if len(lparts) < 5 or len(rparts) < 1:
|
||||||
|
continue
|
||||||
|
fstype = rparts[0]
|
||||||
|
mountpoint = lparts[4]
|
||||||
|
if fstype == "zfs":
|
||||||
|
mounts.append(mountpoint)
|
||||||
|
|
||||||
|
# najdłuższe prefiksy najpierw, bez duplikatów
|
||||||
|
uniq = sorted(set(mounts), key=lambda x: (-len(x), x))
|
||||||
|
return uniq
|
||||||
|
|
||||||
|
|
||||||
|
def path_is_under_mountpoints(path, mountpoints):
|
||||||
|
if not path or not path.startswith("/"):
|
||||||
|
return False
|
||||||
|
norm = os.path.normpath(path)
|
||||||
|
for mp in mountpoints:
|
||||||
|
if mp == "/":
|
||||||
|
return True
|
||||||
|
if norm == mp or norm.startswith(mp.rstrip("/") + "/"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def filter_bpf_opens_to_zfs(opens_map, mountpoints):
|
||||||
|
kept = {}
|
||||||
|
dropped_relative = 0
|
||||||
|
dropped_non_zfs = 0
|
||||||
|
|
||||||
|
for path, value in opens_map.items():
|
||||||
|
if not isinstance(path, str):
|
||||||
|
continue
|
||||||
|
if not path.startswith("/"):
|
||||||
|
dropped_relative += value
|
||||||
|
continue
|
||||||
|
if path_is_under_mountpoints(path, mountpoints):
|
||||||
|
kept[path] = kept.get(path, 0) + value
|
||||||
|
else:
|
||||||
|
dropped_non_zfs += value
|
||||||
|
|
||||||
|
return kept, dropped_relative, dropped_non_zfs
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_prefixes(prefix_arg):
|
||||||
|
prefixes = []
|
||||||
|
for item in (prefix_arg or "").split(","):
|
||||||
|
item = item.strip()
|
||||||
|
if not item:
|
||||||
|
continue
|
||||||
|
norm = os.path.normpath(item)
|
||||||
|
if not norm.startswith("/"):
|
||||||
|
continue
|
||||||
|
prefixes.append(norm)
|
||||||
|
return sorted(set(prefixes), key=lambda x: (-len(x), x))
|
||||||
|
|
||||||
|
|
||||||
|
def path_matches_prefixes(path, prefixes):
|
||||||
|
if not path or not path.startswith("/"):
|
||||||
|
return False
|
||||||
|
norm = os.path.normpath(path)
|
||||||
|
for prefix in prefixes:
|
||||||
|
if prefix == "/":
|
||||||
|
return True
|
||||||
|
if norm == prefix or norm.startswith(prefix.rstrip("/") + "/"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def filter_bpf_opens_to_prefixes(opens_map, prefixes):
|
||||||
|
kept = {}
|
||||||
|
dropped_relative = 0
|
||||||
|
dropped_outside = 0
|
||||||
|
|
||||||
|
for path, value in opens_map.items():
|
||||||
|
if not isinstance(path, str):
|
||||||
|
continue
|
||||||
|
if not path.startswith("/"):
|
||||||
|
dropped_relative += value
|
||||||
|
continue
|
||||||
|
if path_matches_prefixes(path, prefixes):
|
||||||
|
kept[path] = kept.get(path, 0) + value
|
||||||
|
else:
|
||||||
|
dropped_outside += value
|
||||||
|
|
||||||
|
return kept, dropped_relative, dropped_outside
|
||||||
|
|
||||||
|
|
||||||
def parse_arcstats():
|
def parse_arcstats():
|
||||||
path = "/proc/spl/kstat/zfs/arcstats"
|
path = "/proc/spl/kstat/zfs/arcstats"
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
@@ -257,11 +361,6 @@ def arc_delta(prev, curr):
|
|||||||
|
|
||||||
|
|
||||||
def parse_zpool_iostat_once(pool_list, interval):
|
def parse_zpool_iostat_once(pool_list, interval):
|
||||||
"""
|
|
||||||
Działa z różnymi wersjami zpool iostat.
|
|
||||||
Próbuje najpierw z -l, potem bez -l.
|
|
||||||
Nie zakłada obecności timestampa.
|
|
||||||
"""
|
|
||||||
candidate_cmds = [
|
candidate_cmds = [
|
||||||
["zpool", "iostat", "-H", "-p", "-y", "-l"] + pool_list + [str(interval), "1"],
|
["zpool", "iostat", "-H", "-p", "-y", "-l"] + pool_list + [str(interval), "1"],
|
||||||
["zpool", "iostat", "-H", "-p", "-y"] + pool_list + [str(interval), "1"],
|
["zpool", "iostat", "-H", "-p", "-y"] + pool_list + [str(interval), "1"],
|
||||||
@@ -362,30 +461,75 @@ def top_entries(entries, key, topn):
|
|||||||
return vals[:topn]
|
return vals[:topn]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bpftrace_version(text):
|
||||||
|
m = re.search(r'v?(\d+)\.(\d+)(?:\.(\d+))?', text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
major = int(m.group(1))
|
||||||
|
minor = int(m.group(2))
|
||||||
|
patch = int(m.group(3) or 0)
|
||||||
|
return (major, minor, patch)
|
||||||
|
|
||||||
|
|
||||||
|
def choose_bpftrace_field_style():
|
||||||
|
"""
|
||||||
|
bpftrace 0.19 zmienił składnię z args->field na args.field.
|
||||||
|
Dla 0.16 używamy starej składni, dla nowszych nowych wersji - nowej.
|
||||||
|
"""
|
||||||
|
if not command_exists("bpftrace"):
|
||||||
|
return None, None, "bpftrace pominięty: brak polecenia bpftrace."
|
||||||
|
|
||||||
|
rc, out, err = run_cmd(["bpftrace", "--version"], timeout=10)
|
||||||
|
text = (out or err or "").strip()
|
||||||
|
version = parse_bpftrace_version(text)
|
||||||
|
if version is None:
|
||||||
|
return None, text, "bpftrace wykryty, ale nie udało się odczytać wersji z: {0}".format(text)
|
||||||
|
|
||||||
|
if version < (0, 19, 0):
|
||||||
|
return "arrow", text, None
|
||||||
|
return "dot", text, None
|
||||||
|
|
||||||
|
|
||||||
|
def build_bpftrace_program(track_files=False, field_style="dot"):
|
||||||
|
if field_style == "arrow":
|
||||||
|
ret_expr = "args->ret"
|
||||||
|
bytes_expr = "args->bytes"
|
||||||
|
filename_expr = "str(args->filename)"
|
||||||
|
else:
|
||||||
|
ret_expr = "args.ret"
|
||||||
|
bytes_expr = "args.bytes"
|
||||||
|
filename_expr = "str(args.filename)"
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append("tracepoint:syscalls:sys_exit_read /{0} > 0/ {{ @read_bytes_by_comm[comm] = sum({0}); @read_calls_by_comm[comm] = count(); }}".format(ret_expr))
|
||||||
|
lines.append("tracepoint:block:block_rq_issue {{ @block_bytes_by_comm[comm] = sum({0}); @block_ios_by_comm[comm] = count(); }}".format(bytes_expr))
|
||||||
|
if track_files:
|
||||||
|
lines.append("tracepoint:syscalls:sys_enter_openat {{ @opens[{0}] = count(); @opens_by_comm[comm, {0}] = count(); }}".format(filename_expr))
|
||||||
|
lines.append("END {")
|
||||||
|
lines.append(' printf("===READ_BYTES_BY_COMM===\\n"); print(@read_bytes_by_comm);')
|
||||||
|
lines.append(' printf("===READ_CALLS_BY_COMM===\\n"); print(@read_calls_by_comm);')
|
||||||
|
lines.append(' printf("===BLOCK_BYTES_BY_COMM===\\n"); print(@block_bytes_by_comm);')
|
||||||
|
lines.append(' printf("===BLOCK_IOS_BY_COMM===\\n"); print(@block_ios_by_comm);')
|
||||||
|
if track_files:
|
||||||
|
lines.append(' printf("===OPENS===\\n"); print(@opens);')
|
||||||
|
lines.append(' printf("===OPENS_BY_COMM===\\n"); print(@opens_by_comm);')
|
||||||
|
lines.append("}")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def start_bpftrace(outdir, track_files=False):
|
def start_bpftrace(outdir, track_files=False):
|
||||||
if os.geteuid() != 0:
|
if os.geteuid() != 0:
|
||||||
return None, "bpftrace pominięty: uruchom jako root."
|
return None, "bpftrace pominięty: uruchom jako root."
|
||||||
if not command_exists("bpftrace"):
|
|
||||||
return None, "bpftrace pominięty: brak polecenia bpftrace."
|
|
||||||
|
|
||||||
program = []
|
field_style, version_text, version_note = choose_bpftrace_field_style()
|
||||||
program.append('tracepoint:syscalls:sys_exit_read /args.ret > 0/ { @read_bytes_by_comm[comm] = sum(args.ret); @read_calls_by_comm[comm] = count(); }')
|
if version_note:
|
||||||
program.append('tracepoint:block:block_rq_issue { @block_bytes_by_comm[comm] = sum(args.bytes); @block_ios_by_comm[comm] = count(); }')
|
return None, version_note
|
||||||
if track_files:
|
|
||||||
program.append('tracepoint:syscalls:sys_enter_openat { @opens[str(args.filename)] = count(); @opens_by_comm[comm, str(args.filename)] = count(); }')
|
program = build_bpftrace_program(track_files=track_files, field_style=field_style)
|
||||||
program.append('END {')
|
|
||||||
program.append(' printf("===READ_BYTES_BY_COMM===\\n"); print(@read_bytes_by_comm);')
|
|
||||||
program.append(' printf("===READ_CALLS_BY_COMM===\\n"); print(@read_calls_by_comm);')
|
|
||||||
program.append(' printf("===BLOCK_BYTES_BY_COMM===\\n"); print(@block_bytes_by_comm);')
|
|
||||||
program.append(' printf("===BLOCK_IOS_BY_COMM===\\n"); print(@block_ios_by_comm);')
|
|
||||||
if track_files:
|
|
||||||
program.append(' printf("===OPENS===\\n"); print(@opens);')
|
|
||||||
program.append(' printf("===OPENS_BY_COMM===\\n"); print(@opens_by_comm);')
|
|
||||||
program.append('}')
|
|
||||||
|
|
||||||
bt_path = os.path.join(outdir, "trace.bt")
|
bt_path = os.path.join(outdir, "trace.bt")
|
||||||
with open(bt_path, "w") as f:
|
with open(bt_path, "w") as f:
|
||||||
f.write("\n".join(program) + "\n")
|
f.write(program)
|
||||||
|
|
||||||
out_path = os.path.join(outdir, "bpftrace.txt")
|
out_path = os.path.join(outdir, "bpftrace.txt")
|
||||||
out_f = open(out_path, "w")
|
out_f = open(out_path, "w")
|
||||||
@@ -395,7 +539,19 @@ def start_bpftrace(outdir, track_files=False):
|
|||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
universal_newlines=True,
|
universal_newlines=True,
|
||||||
)
|
)
|
||||||
return {"proc": proc, "out_f": out_f, "out_path": out_path, "bt_path": bt_path}, None
|
|
||||||
|
note = "bpftrace: {0}, skladnia: {1}".format(
|
||||||
|
version_text if version_text else "unknown",
|
||||||
|
"args->field" if field_style == "arrow" else "args.field",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"proc": proc,
|
||||||
|
"out_f": out_f,
|
||||||
|
"out_path": out_path,
|
||||||
|
"bt_path": bt_path,
|
||||||
|
"version_text": version_text,
|
||||||
|
"field_style": field_style,
|
||||||
|
}, note
|
||||||
|
|
||||||
|
|
||||||
SECTION_RE = re.compile(r"^===([A-Z0-9_]+)===$")
|
SECTION_RE = re.compile(r"^===([A-Z0-9_]+)===$")
|
||||||
@@ -461,7 +617,10 @@ def stop_bpftrace(handle):
|
|||||||
return
|
return
|
||||||
proc = handle["proc"]
|
proc = handle["proc"]
|
||||||
if proc.poll() is not None:
|
if proc.poll() is not None:
|
||||||
|
try:
|
||||||
handle["out_f"].close()
|
handle["out_f"].close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -491,6 +650,8 @@ def main():
|
|||||||
ap.add_argument("--top", type=int, default=15, help="Ile pozycji pokazać w topkach")
|
ap.add_argument("--top", type=int, default=15, help="Ile pozycji pokazać w topkach")
|
||||||
ap.add_argument("--outdir", default="", help="Katalog na logi i JSON")
|
ap.add_argument("--outdir", default="", help="Katalog na logi i JSON")
|
||||||
ap.add_argument("--track-files", action="store_true", help="Śledź top otwierane pliki przez bpftrace")
|
ap.add_argument("--track-files", action="store_true", help="Śledź top otwierane pliki przez bpftrace")
|
||||||
|
ap.add_argument("--zfs-files-only", action="store_true", help="W topce plików pokazuj tylko absolutne ścieżki leżące na mountpointach ZFS")
|
||||||
|
ap.add_argument("--path-prefix", default="", help="Pokaż tylko pliki spod tego prefiksu lub kilku prefiksów rozdzielonych przecinkami, np. /home/tank,/mnt/data")
|
||||||
ap.add_argument("--no-bpf", action="store_true", help="Nie uruchamiaj bpftrace nawet gdy jest dostępny")
|
ap.add_argument("--no-bpf", action="store_true", help="Nie uruchamiaj bpftrace nawet gdy jest dostępny")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
@@ -512,6 +673,12 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
oprint("Pule: {0}".format(", ".join(pools)))
|
oprint("Pule: {0}".format(", ".join(pools)))
|
||||||
|
zfs_mountpoints = get_zfs_mountpoints()
|
||||||
|
if zfs_mountpoints:
|
||||||
|
oprint("Mountpointy ZFS: {0}".format(", ".join(zfs_mountpoints)))
|
||||||
|
path_prefixes = normalize_prefixes(args.path_prefix)
|
||||||
|
if path_prefixes:
|
||||||
|
oprint("Filtr ścieżek: {0}".format(", ".join(path_prefixes)))
|
||||||
meta = {
|
meta = {
|
||||||
"started_at": datetime.now().isoformat(),
|
"started_at": datetime.now().isoformat(),
|
||||||
"duration": args.duration,
|
"duration": args.duration,
|
||||||
@@ -519,6 +686,8 @@ def main():
|
|||||||
"pools": pools,
|
"pools": pools,
|
||||||
"hostname": os.uname().nodename,
|
"hostname": os.uname().nodename,
|
||||||
"uid": os.geteuid(),
|
"uid": os.geteuid(),
|
||||||
|
"zfs_mountpoints": zfs_mountpoints,
|
||||||
|
"path_prefixes": path_prefixes,
|
||||||
}
|
}
|
||||||
with open(os.path.join(outdir, "meta.json"), "w") as f:
|
with open(os.path.join(outdir, "meta.json"), "w") as f:
|
||||||
json.dump(meta, f, indent=2)
|
json.dump(meta, f, indent=2)
|
||||||
@@ -529,6 +698,8 @@ def main():
|
|||||||
f.write(zpool_history_text(pools))
|
f.write(zpool_history_text(pools))
|
||||||
with open(os.path.join(outdir, "zfs_properties.json"), "w") as f:
|
with open(os.path.join(outdir, "zfs_properties.json"), "w") as f:
|
||||||
json.dump(zfs_get_properties(pools), f, indent=2)
|
json.dump(zfs_get_properties(pools), f, indent=2)
|
||||||
|
with open(os.path.join(outdir, "zfs_mountpoints.json"), "w") as f:
|
||||||
|
json.dump(zfs_mountpoints, f, indent=2)
|
||||||
|
|
||||||
bpf_handle = None
|
bpf_handle = None
|
||||||
bpf_note = None
|
bpf_note = None
|
||||||
@@ -618,6 +789,9 @@ def main():
|
|||||||
oprint()
|
oprint()
|
||||||
oprint("Wyniki zapisane w: {0}".format(outdir))
|
oprint("Wyniki zapisane w: {0}".format(outdir))
|
||||||
oprint("Pule: {0}".format(", ".join(pools)))
|
oprint("Pule: {0}".format(", ".join(pools)))
|
||||||
|
zfs_mountpoints = get_zfs_mountpoints()
|
||||||
|
if zfs_mountpoints:
|
||||||
|
oprint("Mountpointy ZFS: {0}".format(", ".join(zfs_mountpoints)))
|
||||||
oprint("Czas zbierania: {0}s, interwał: {1}s, próbek: {2}".format(
|
oprint("Czas zbierania: {0}s, interwał: {1}s, próbek: {2}".format(
|
||||||
args.duration, args.interval, sum(len(v) for v in samples_by_pool.values())
|
args.duration, args.interval, sum(len(v) for v in samples_by_pool.values())
|
||||||
))
|
))
|
||||||
@@ -701,10 +875,30 @@ def main():
|
|||||||
|
|
||||||
opens = bpf.get("OPENS", {})
|
opens = bpf.get("OPENS", {})
|
||||||
if opens:
|
if opens:
|
||||||
|
report_opens = opens
|
||||||
|
title = "Top otwierane pliki z bpftrace"
|
||||||
|
|
||||||
|
if path_prefixes:
|
||||||
|
report_opens, dropped_relative, dropped_outside = filter_bpf_opens_to_prefixes(opens, path_prefixes)
|
||||||
|
title = "Top otwierane pliki z bpftrace dla wskazanych ścieżek"
|
||||||
|
oprint("Filtrowanie plików po prefiksie:")
|
||||||
|
oprint(" zostawione wpisy: {0}".format(sum(report_opens.values()) if report_opens else 0))
|
||||||
|
oprint(" odrzucone ścieżki względne: {0}".format(dropped_relative))
|
||||||
|
oprint(" odrzucone poza prefiksem: {0}".format(dropped_outside))
|
||||||
|
oprint()
|
||||||
|
elif args.zfs_files_only:
|
||||||
|
report_opens, dropped_relative, dropped_non_zfs = filter_bpf_opens_to_zfs(opens, zfs_mountpoints)
|
||||||
|
title = "Top otwierane pliki na ZFS z bpftrace"
|
||||||
|
oprint("Filtrowanie plików:")
|
||||||
|
oprint(" zostawione wpisy na ZFS: {0}".format(sum(report_opens.values()) if report_opens else 0))
|
||||||
|
oprint(" odrzucone ścieżki względne: {0}".format(dropped_relative))
|
||||||
|
oprint(" odrzucone ścieżki poza ZFS: {0}".format(dropped_non_zfs))
|
||||||
|
oprint()
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for path, value in sorted(opens.items(), key=lambda kv: kv[1], reverse=True)[:args.top]:
|
for path, value in sorted(report_opens.items(), key=lambda kv: kv[1], reverse=True)[:args.top]:
|
||||||
rows.append({"path": path, "opens": value})
|
rows.append({"path": path, "opens": value})
|
||||||
print_table("Top otwierane pliki z bpftrace", rows, [
|
print_table(title, rows, [
|
||||||
("path", "path"),
|
("path", "path"),
|
||||||
("opens", "opens"),
|
("opens", "opens"),
|
||||||
])
|
])
|
||||||
@@ -715,10 +909,12 @@ def main():
|
|||||||
"zpool_status_end.txt",
|
"zpool_status_end.txt",
|
||||||
"zpool_history.txt",
|
"zpool_history.txt",
|
||||||
"zfs_properties.json",
|
"zfs_properties.json",
|
||||||
|
"zfs_mountpoints.json",
|
||||||
"samples_zpool.json",
|
"samples_zpool.json",
|
||||||
"samples_arc.json",
|
"samples_arc.json",
|
||||||
"proc_totals.json",
|
"proc_totals.json",
|
||||||
"bpftrace.txt",
|
"bpftrace.txt",
|
||||||
|
"trace.bt",
|
||||||
]:
|
]:
|
||||||
path = os.path.join(outdir, name)
|
path = os.path.join(outdir, name)
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
|
|||||||
Reference in New Issue
Block a user