hardware-dashboard/scripts/hwlog

439 lines
22 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/python3
"""Read-only hardware sampling and bounded, persistent event summaries."""
import argparse
import csv
import datetime as dt
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import time
BASE = Path('/var/lib/hardware-watch')
LOG = Path('/var/log/hardware-watch')
CONFIG = Path('/etc/hardware-watch.json')
VERSION = '1.0'
PATTERN = (r'NVRM|NVIDIA|AER:|PCIe Bus Error|BadTLP|BadDLLP|'
r'EDAC|Machine check|Hardware Error|mce:|'
r'I/O error|Buffer I/O|EXT4-fs error|BTRFS.*error|'
r'nvme.*(timeout|reset|fail|critical)|ata[0-9].*(error|fail|reset)|'
r'critical temperature|temperature above threshold|thermal.*(thrott|critical)')
NAMES = {
'power': '显卡辅助供电检测失败', 'gpu_init': '显卡驱动初始化失败',
'pcie_fatal': 'PCIe 不可纠正错误', 'pcie_correctable': 'PCIe 可纠正通信错误',
'gpu_xid': 'NVIDIA Xid 错误', 'memory_cpu': 'CPU/内存硬件报错',
'storage': '磁盘或存储通信错误', 'thermal': '硬件温度告警',
'gpu_missing': 'PCIe 显卡数量不足', 'gpu_driver': '显卡管理接口异常',
'gpu_identity': '预期显卡 UUID 缺失', 'gpu_temp': '显卡温度超过设定阈值',
'disk_missing': '预期磁盘缺失', 'smart': '磁盘 SMART 健康告警',
'sensor_temp': '传感器温度告警', 'journal': '系统日志采集异常',
'monitor': '硬件日志采集器', 'boot': '系统启动',
'clock': '系统时钟发生明显变化',
}
def stamp():
return dt.datetime.now().astimezone().isoformat(timespec='seconds')
def read(path, default=''):
try:
return Path(path).read_text().strip()
except OSError:
return default
def load(path, default):
try:
return json.loads(Path(path).read_text())
except (OSError, ValueError):
return default
def save(path, obj):
tmp = Path(str(path) + '.tmp')
tmp.write_text(json.dumps(obj, ensure_ascii=False, indent=2) + '\n')
os.chmod(tmp, 0o640)
os.replace(tmp, path)
def command(args, timeout=12):
try:
p = subprocess.run(args, text=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, timeout=timeout, env={**os.environ, 'LC_ALL': 'C'})
return p.returncode, p.stdout, p.stderr
except (OSError, subprocess.TimeoutExpired) as exc:
return 124, '', str(exc)
def emit(kind, severity='INFO', **values):
row = {'observed_at': stamp(), 'severity': severity, 'category': kind,
'summary': NAMES.get(kind, kind), **values}
path = LOG / ('events-' + dt.datetime.now().astimezone().strftime('%Y-%m-%d') + '.jsonl')
with path.open('a', encoding='utf-8') as f:
f.write(json.dumps(row, ensure_ascii=False) + '\n')
os.chmod(path, 0o640)
def classify(message):
low = message.lower()
if 'necessary power cables' in low:
return 'power', 'ERROR'
if 'rminitadapter failed' in low or 'rm_init_adapter failed' in low:
return 'gpu_init', 'ERROR'
if 'no nvidia gpu found' in low:
return 'gpu_missing', 'WARN'
if 'fallen off' in low or re.search(r'\bXid\b', message):
return 'gpu_xid', 'ERROR'
if any(x in low for x in ('severity=uncorrectable', 'aer: device recovery failed', 'non-functional downstream link')):
return 'pcie_fatal', 'ERROR'
if 'severity=correctable' in low or 'badtlp' in low or 'baddllp' in low or re.search(r'\[\s*12\]\s+Timeout', message):
return 'pcie_correctable', 'WARN'
if re.search(r'machine check|hardware error|edac.*(\b[cu]e\b|error)|mce:.*(error|bank)', low):
return 'memory_cpu', 'ERROR'
if re.search(r'i/o error|buffer i/o|ext4-fs error|btrfs.*error|nvme.*(timeout|reset|fail|critical)|ata[0-9].*(error|fail)', low):
return 'storage', 'ERROR'
if re.search(r'critical temperature|temperature above threshold|thermal.*(thrott|critical)', low):
return 'thermal', 'WARN'
return None
def kernel_events(state, cfg):
args = ['journalctl', '--no-pager', '-o', 'json', '_TRANSPORT=kernel',
'--grep=' + PATTERN, '--case-sensitive=no', '--lines=+20000']
cursor = state.get('cursor')
args.append('--after-cursor=' + cursor if cursor else '--since=' + cfg.get('history_since', '7 days ago'))
code, out, err = command(args, 22)
if code not in (0, 1):
state['journal_error'] = err[:1000] or f'journalctl exit {code}'
emit('journal', 'ERROR', detail=err[:1000], action='下次继续重试;原始 journal 未删除。')
if 'cursor' in err.lower() and 'cursor' in state:
state.pop('cursor')
return
state.pop('journal_error', None)
state['journal_last_success'] = stamp()
entries = []
for line in out.splitlines():
try:
entries.append(json.loads(line))
except ValueError:
continue
# A journal with a backwards clock step can replay records before its cursor.
recent = state.get('recent_source_ids', [])
seen = set(recent)
def source_id(entry):
return hashlib.sha256(entry.get('__CURSOR', '').encode()).hexdigest()[:24]
if not recent and cursor and entries and entries[-1].get('__CURSOR') == cursor:
recent = [source_id(entry) for entry in entries]
seen = set(recent)
emit('clock', 'WARN', detail='检测到历史校时造成 journal 游标重放;已跳过此前处理过的记录,避免误计为新故障。')
groups = {}
rows = 0
for entry in entries:
rows += 1
state['cursor'] = entry.get('__CURSOR', state.get('cursor'))
token = source_id(entry)
if token in seen:
continue
seen.add(token)
recent.append(token)
message = entry.get('MESSAGE', '')
if not isinstance(message, str):
continue
result = classify(message)
if not result:
continue
category, severity = result
match = re.search(r'(?:[0-9a-f]{4}:)?[0-9a-f]{2}:[0-9a-f]{2}\.[0-7]', message, re.I)
device = match.group(0) if match else ''
normalized = re.sub(r'0x[0-9a-f]+|\b[0-9]+\b', '#', message, flags=re.I)
key = (entry.get('_BOOT_ID', ''), category, device, normalized)
realtime = int(entry.get('__REALTIME_TIMESTAMP', 0)) / 1000000
when = dt.datetime.fromtimestamp(realtime).astimezone().isoformat(timespec='seconds')
observed = stamp()
monotonic = entry.get('__MONOTONIC_TIMESTAMP')
group = groups.setdefault(key, {'category': category, 'severity': severity,
'source': 'kernel', 'device': device, 'boot_id': entry.get('_BOOT_ID', ''),
'first_at': when, 'last_at': when, 'first_monotonic_us': entry.get('__MONOTONIC_TIMESTAMP'),
'last_monotonic_us': monotonic, 'first_observed_at': observed,
'last_observed_at': observed, 'count': 0, 'detail': message[:1600]})
group['count'] += 1
group['last_at'] = when
group['last_monotonic_us'] = monotonic
group['last_observed_at'] = observed
for row in groups.values():
emit(row.pop('category'), row.pop('severity'), **row)
state['recent_source_ids'] = recent[-4096:]
pending = rows >= 20000
if pending and not state.get('journal_backlog'):
emit('journal', 'WARN', detail='本轮读取 20000 条匹配记录;下轮从游标继续处理积压,未跳过中间记录。')
elif not pending and state.get('journal_backlog'):
emit('journal', 'INFO', detail='历史日志积压已处理完成,后续按游标增量采集。')
state['journal_backlog'] = pending
def pci_gpus():
items = []
for path in sorted(Path('/sys/bus/pci/devices').glob('*')):
if read(path / 'vendor') != '0x10de' or not read(path / 'class').startswith('0x03'):
continue
driver = path / 'driver'
items.append({'pci': path.name, 'device_id': read(path / 'device'),
'driver': driver.resolve().name if driver.exists() else None,
'link_speed': read(path / 'current_link_speed'), 'link_width': read(path / 'current_link_width')})
return items
def gpu_query():
args = ['nvidia-smi', '--query-gpu=pci.bus_id,name,uuid,temperature.gpu,power.draw,memory.total',
'--format=csv,noheader,nounits']
code, out, err = command(args)
result = []
if code == 0:
for row in csv.reader(out.splitlines(), skipinitialspace=True):
if len(row) != 6:
continue
try:
result.append({'pci': row[0], 'name': row[1], 'uuid': row[2],
'temperature_c': float(row[3]), 'power_w': float(row[4]), 'memory_mib': float(row[5])})
except ValueError:
result.append({'pci': row[0], 'name': row[1], 'uuid': row[2], 'raw': row})
return result, None if code == 0 and result else (err or out or 'nvidia-smi 未返回设备')[:1200]
def disk_names():
return sorted(p.name for p in Path('/sys/block').glob('*')
if not p.name.startswith(('loop', 'ram', 'zram', 'dm-', 'md')))
def sensors():
result = []
for path in sorted(Path('/sys/class/hwmon').glob('hwmon*')):
for inp in path.glob('temp*_input'):
try:
prefix = inp.name[:-6]
result.append({'chip': read(path / 'name'), 'label': read(path / (prefix + '_label'), prefix),
'celsius': int(read(inp)) / 1000,
'critical_c': int(read(path / (prefix + '_crit'), '0')) / 1000})
except ValueError:
pass
return result
def active_problem(problems, key, kind, detail, severity='ERROR'):
problems[key] = {'category': kind, 'severity': severity, 'detail': detail}
def smart_checks(state, problems, disks):
# Hourly, read-only SMART query. Never starts self-tests or changes disk settings.
cached = state.get('smart', {})
if time.time() - state.get('smart_at', 0) >= 3600:
cached = {}
for name in disks:
code, out, err = command(['smartctl', '-a', '-j', '/dev/' + name], 7)
try:
data = json.loads(out)
except ValueError:
cached[name] = {'available': False, 'detail': (err or out)[:500]}
continue
nv = data.get('nvme_smart_health_information_log', {})
cached[name] = {'available': 'smart_status' in data,
'passed': data.get('smart_status', {}).get('passed'),
'critical_warning': nv.get('critical_warning'), 'media_errors': nv.get('media_errors'),
'temperature_c': data.get('temperature', {}).get('current'),
'percentage_used': nv.get('percentage_used'), 'exit_status': code}
state['smart'], state['smart_at'] = cached, time.time()
for name, item in cached.items():
if not item.get('available'):
active_problem(problems, 'smart-unavailable:' + name, 'smart',
f'{name}: SMART 读取不可用,当前仅监控内核存储报错。{item.get("detail", "")}', 'WARN')
elif item.get('passed') is False or item.get('critical_warning', 0):
active_problem(problems, 'smart:' + name, 'smart', f'{name}: {json.dumps(item, ensure_ascii=False)}')
previous = state.get('smart_media', {}).get(name)
media = item.get('media_errors')
if media is not None and previous is not None and media > previous:
emit('smart', 'ERROR', detail=f'{name}: NVMe 介质错误计数从 {previous} 增加到 {media}')
if media is not None:
state.setdefault('smart_media', {})[name] = media
return cached
def collect(state, cfg):
now = stamp()
boot = read('/proc/sys/kernel/random/boot_id')
clock = {'wall': time.time(), 'monotonic': time.monotonic(), 'boot_id': boot}
old_clock = state.get('clock', {})
if old_clock.get('boot_id') == boot:
delta = (clock['wall'] - old_clock['wall']) - (clock['monotonic'] - old_clock['monotonic'])
if abs(delta) > 60:
emit('clock', 'WARN', detail=f'同一次启动中墙上时钟相对单调时钟变化 {delta:.1f} 秒;核对事件时间时应结合启动 ID 和单调时钟。')
state['clock'] = clock
if boot != state.get('boot_id'):
emit('boot', boot_id=boot, previous_boot_id=state.get('boot_id'),
detail='监控首次观察到本次启动;准确启动信息可通过 journalctl 查看。')
state['boot_id'] = boot
state['smart_at'] = 0
kernel_events(state, cfg)
pci = pci_gpus()
gpu, nv_error = gpu_query() if pci else ([], 'PCIe 总线没有 NVIDIA 显卡')
disks = disk_names()
temps = sensors()
problems = {}
if state.get('journal_error'):
active_problem(problems, 'journal-read', 'journal', state['journal_error'], 'WARN')
if len(pci) < cfg['expected_gpu_count']:
active_problem(problems, 'gpu-count', 'gpu_missing',
f'预期 {cfg["expected_gpu_count"]} 块 NVIDIA 显卡,PCIe 实际 {len(pci)} 块。')
if nv_error or len(gpu) < len(pci):
active_problem(problems, 'gpu-driver', 'gpu_driver', nv_error or f'PCIe {len(pci)} 块,nvidia-smi 仅 {len(gpu)} 块。')
if not nv_error:
missing = sorted(set(cfg.get('expected_gpu_uuids', [])) - {x['uuid'] for x in gpu})
if missing:
active_problem(problems, 'gpu-uuid', 'gpu_identity', '缺少预期 GPU:' + ', '.join(missing))
for item in gpu:
if item.get('temperature_c', 0) >= cfg.get('gpu_warn_c', 85):
active_problem(problems, 'gpu-temp:' + item['uuid'], 'gpu_temp',
f'{item["name"]} {item["pci"]}: {item["temperature_c"]}°C', 'WARN')
for name in set(cfg.get('expected_disks', [])) - set(disks):
active_problem(problems, 'disk:' + name, 'disk_missing', name)
for item in temps:
if item['critical_c'] > 0 and item['celsius'] >= item['critical_c']:
active_problem(problems, 'sensor:' + item['chip'] + item['label'], 'sensor_temp',
json.dumps(item, ensure_ascii=False), 'ERROR')
smart = smart_checks(state, problems, disks)
previous = state.get('active', {})
for key, value in problems.items():
old = previous.get(key)
value['since'] = old.get('since', now) if old else now
if not old or old.get('detail') != value['detail']:
emit(value['category'], value['severity'], event='FAULT', key=key, detail=value['detail'], since=value['since'])
for key, value in previous.items():
if key not in problems:
emit(value['category'], 'INFO', event='RECOVERED', key=key, detail='此前异常在本轮检查中已消失。', since=value.get('since'))
state['active'] = problems
status = {'checked_at': now, 'boot_id': boot, 'pci_gpus': pci, 'gpus': gpu,
'nvidia_error': nv_error, 'disks': disks, 'smart': smart, 'sensors': temps, 'active': problems,
'journal_last_success': state.get('journal_last_success'), 'journal_backlog': state.get('journal_backlog', False)}
save(BASE / 'status.json', status)
if time.time() - state.get('snapshot_at', 0) >= 900:
path = LOG / ('samples-' + dt.datetime.now().astimezone().strftime('%Y-%m-%d') + '.jsonl')
with path.open('a', encoding='utf-8') as f:
f.write(json.dumps(status, ensure_ascii=False) + '\n')
os.chmod(path, 0o640)
state['snapshot_at'] = time.time()
save(BASE / 'state.json', state)
# Age and total-size limits apply only to this monitor's own log files.
files = sorted(LOG.glob('*.jsonl'), key=lambda p: p.stat().st_mtime)
cutoff = time.time() - cfg.get('retention_days', 90) * 86400
total = sum(p.stat().st_size for p in files)
for path in files:
if path.stat().st_mtime < cutoff or total > 256 * 1024 * 1024:
if path.name.endswith(dt.datetime.now().strftime('%Y-%m-%d') + '.jsonl'):
continue
total -= path.stat().st_size
path.unlink()
def show_status():
status = load(BASE / 'status.json', {})
print('硬件管理日志 | 最近检查:' + status.get('checked_at', '尚未运行'))
age = (dt.datetime.now().astimezone() - dt.datetime.fromisoformat(status['checked_at'])).total_seconds() if status else 9999
if age > 180:
print('警告:最近检查超过 3 分钟,请检查 systemctl status hardware-watch.timer hardware-watch.service')
for item in status.get('pci_gpus', []):
print(f'PCIe {item["pci"]} | {item["link_speed"]} x{item["link_width"]} | {item["driver"]}')
for item in status.get('gpus', []):
print(f'{item["name"]} | {item["pci"]} | {item.get("temperature_c", "?")}°C | {item.get("power_w", "?")}W | {item["uuid"]}')
for name, item in status.get('smart', {}).items():
print(f'磁盘 {name} | SMART={item.get("passed")} | 温度={item.get("temperature_c")}°C | 介质错误={item.get("media_errors")}')
print('当前采样告警:' + str(len(status.get('active', {}))))
for value in status.get('active', {}).values():
print(f' [{value["severity"]}] {NAMES[value["category"]]}:{value["detail"]}')
if status.get('journal_backlog'):
print('历史内核日志正在补录,实时 GPU/磁盘采样已启用。')
print('PCIe 等内核历史事件另见 hwlog events;当前在线不代表此前没有通信错误。')
def show_events(args):
rows = []
seen = set()
status = load(BASE / 'status.json', {})
active = status.get('active', {})
since = dt.datetime.fromisoformat(args.since).astimezone() if args.since else None
for path in sorted(LOG.glob('events-*.jsonl')):
for line in path.read_text().splitlines():
try:
row = json.loads(line)
if row.get('source') == 'kernel':
key = (row.get('boot_id'), row.get('category'), row.get('device'),
row.get('first_monotonic_us'), row.get('last_monotonic_us'), row.get('detail'))
if key in seen:
continue
seen.add(key)
event_time = row.get('first_at', row['observed_at'])
source_last = row.get('last_at', event_time)
observed = row.get('observed_at', event_time)
if since and max(source_last, observed) < since.isoformat():
continue
if args.category and row['category'] != args.category:
continue
rows.append(row)
except (ValueError, KeyError):
continue
# Recently collected records remain visible even if old source clocks were ahead.
rows.sort(key=lambda x: x['observed_at'])
for row in rows[-max(1, args.limit):]:
if args.json:
print(json.dumps(row, ensure_ascii=False))
else:
print(f'{row.get("first_at", row["observed_at"])} [{row["severity"]}] {row["summary"]} {row.get("device", "")} {row.get("event", "")}')
if 'count' in row:
print(f' 原始故障时间:{row.get("first_at", "未知")} 至 {row.get("last_at", "未知")}')
print(f' 监控发现时间:{row.get("first_observed_at", row["observed_at"])} 至 {row.get("last_observed_at", row["observed_at"])}')
first_mono = row.get('first_monotonic_us')
last_mono = row.get('last_monotonic_us')
if first_mono and last_mono:
print(f' 本次启动经过:{int(first_mono)/1000000:.3f}s 至 {int(last_mono)/1000000:.3f}s;启动 {row.get("boot_id", "")}')
print(f' 本批重复次数:{row["count"]}')
current = False
for value in active.values():
detail = value.get('detail', '')
if value.get('category') == row.get('category') and (not row.get('device') or row.get('device') in detail):
current = True
break
print(' 当前判断:当前采样仍有同类告警' if current else ' 当前判断:当前采样未复现,属于历史记录或已恢复')
if row['last_at'] < row['first_at']:
print(' 时间提示:源日志时钟发生回拨,以上首末时间不能按日历顺序判断;请结合启动 ID、单调时钟及原始 journal。')
if row['first_at'] > row['observed_at']:
print(' 时间提示:源日志日期晚于采集日期,源系统时钟曾异常;采集时间为 ' + row['observed_at'])
print(' ' + row.get('detail', ''))
def main():
parser = argparse.ArgumentParser(description='硬件管理日志:每分钟采样,查看状态和历史故障。')
sub = parser.add_subparsers(dest='action')
sub.add_parser('collect')
sub.add_parser('status')
events = sub.add_parser('events')
events.add_argument('--since', help='例如 2026-11-02 或 2026-11-02T12:00:00+08:00')
events.add_argument('--category', choices=sorted(NAMES))
events.add_argument('--limit', type=int, default=50)
events.add_argument('--json', action='store_true')
sub.add_parser('self-test')
args = parser.parse_args()
if args.action == 'self-test':
cases = [('NVRM: GPU does not have the necessary power cables connected.', 'power'),
('NVRM: RmInitAdapter failed! (0x24:0x1c:1603)', 'gpu_init'),
('PCIe Bus Error: severity=Correctable, type=Data Link Layer', 'pcie_correctable'),
('PCIe Bus Error: severity=Uncorrectable (Fatal)', 'pcie_fatal'),
('NVRM: Xid (PCI:0000:03:00): 79, GPU has fallen off the bus.', 'gpu_xid'),
('nvme nvme0: I/O 10 timeout, reset controller', 'storage'),
('EDAC MC0: 1 CE memory read error', 'memory_cpu')]
for message, expected in cases:
assert classify(message)[0] == expected, message
assert classify('NVRM: loading NVIDIA UNIX x86_64 Kernel Module') is None
assert classify('nvidia: module verification failed: tainting kernel') is None
print('PASS: 7 hardware error cases and 2 benign driver messages')
return
if args.action == 'collect':
BASE.mkdir(parents=True, exist_ok=True)
LOG.mkdir(parents=True, exist_ok=True)
with (BASE / 'lock').open('w') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return
collect(load(BASE / 'state.json', {}), load(CONFIG, {}))
elif args.action == 'events':
show_events(args)
else:
show_status()
if __name__ == '__main__':
main()