P42-S.M.: Server-Monitoring-Agent als Docker-Container
Misst CPU, RAM, Swap, Platten, Netzwerk, Temperaturen und Prozesse des Hosts und liefert sie als JSON-API (/api/metrics, /api/history) für das P42-Dashboard. Nur Python-Standardbibliothek, Token-geschützt, Container ohne Root und mit schreibgeschütztem Dateisystem. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
c6eef176a2
11 files changed
+727
No files matched your search
@@ -0,0 +1,311 @@
|
||||
"""Liest die Auslastung des Hosts direkt aus /proc und /sys – ohne Fremdpakete.
|
||||
|
||||
Im Container sind die Host-Verzeichnisse eingebunden (siehe docker-compose.yml):
|
||||
HOST_PROC /proc des Hosts (Standard /host/proc)
|
||||
HOST_SYS /sys des Hosts (Standard /sys – sysfs ist im Container ohnehin das des Hosts)
|
||||
HOST_ROOT / des Hosts (Standard /host/root – für Plattenbelegung und os-release)
|
||||
Lokal ohne Docker: HOST_PROC=/proc HOST_ROOT=/ setzen.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
HOST_PROC = os.environ.get("HOST_PROC", "/host/proc")
|
||||
HOST_SYS = os.environ.get("HOST_SYS", "/sys")
|
||||
HOST_ROOT = os.environ.get("HOST_ROOT", "/host/root")
|
||||
|
||||
# Dateisysteme, die als echte Platten zählen. Netzlaufwerke (nfs, cifs) bewusst nicht:
|
||||
# statvfs kann dort bei Verbindungsproblemen hängen.
|
||||
DISK_FS = {"ext2", "ext3", "ext4", "xfs", "btrfs", "zfs", "vfat", "exfat", "ntfs", "ntfs3", "fuseblk", "f2fs", "jfs"}
|
||||
# Ganze Laufwerke ohne Zählwert für die Platten-Aktivität (virtuell oder doppelt gezählt)
|
||||
SKIP_BLOCK = re.compile(r"^(loop|ram|zram|sr|dm-|md)")
|
||||
# Virtuelle Netzwerk-Schnittstellen: werden gelistet, aber nicht in die Summe gerechnet,
|
||||
# sonst zählt Container-Verkehr doppelt (veth -> Bridge -> echte Karte)
|
||||
VIRTUAL_NET = re.compile(r"^(lo$|veth|docker|br-|virbr|cni|flannel|cali)")
|
||||
SECTOR = 512 # /proc/diskstats zählt immer in 512-Byte-Sektoren
|
||||
|
||||
|
||||
def _read(path: str) -> str:
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _read_or(path: str, default=None):
|
||||
try:
|
||||
return _read(path).strip()
|
||||
except OSError:
|
||||
return default
|
||||
|
||||
|
||||
def _host_path(path: str) -> str:
|
||||
return os.path.join(HOST_ROOT, path.lstrip("/"))
|
||||
|
||||
|
||||
# ---------- Rohwerte (für Raten werden zwei Messungen verglichen) ----------
|
||||
|
||||
def _cpu_times() -> dict:
|
||||
"""{'cpu': (busy, total), 'cpu0': ..., ...} aus /proc/stat."""
|
||||
out = {}
|
||||
for line in _read(f"{HOST_PROC}/stat").splitlines():
|
||||
if not line.startswith("cpu"):
|
||||
continue
|
||||
name, *vals = line.split()
|
||||
v = [int(x) for x in vals[:8]] # user nice system idle iowait irq softirq steal
|
||||
total = sum(v)
|
||||
idle = v[3] + v[4]
|
||||
out[name] = (total - idle, total, v[4])
|
||||
return out
|
||||
|
||||
|
||||
def _disk_bytes() -> tuple[int, int]:
|
||||
"""Gelesene/geschriebene Bytes aller echten Laufwerke (ohne Partitionen, loop, dm)."""
|
||||
try:
|
||||
disks = {d for d in os.listdir(f"{HOST_SYS}/block") if not SKIP_BLOCK.match(d)}
|
||||
except OSError:
|
||||
disks = None
|
||||
read = write = 0
|
||||
for line in _read(f"{HOST_PROC}/diskstats").splitlines():
|
||||
f = line.split()
|
||||
if len(f) < 10:
|
||||
continue
|
||||
name = f[2]
|
||||
if disks is not None and name not in disks:
|
||||
continue
|
||||
read += int(f[5]) * SECTOR
|
||||
write += int(f[9]) * SECTOR
|
||||
return read, write
|
||||
|
||||
|
||||
def _net_bytes() -> dict:
|
||||
"""{'eth0': (rx, tx), ...} – aus dem Netz-Namespace des Hosts (PID 1), nicht des Containers."""
|
||||
path = f"{HOST_PROC}/1/net/dev"
|
||||
if not os.path.exists(path):
|
||||
path = f"{HOST_PROC}/net/dev"
|
||||
out = {}
|
||||
for line in _read(path).splitlines()[2:]:
|
||||
name, _, data = line.partition(":")
|
||||
f = data.split()
|
||||
if len(f) >= 9:
|
||||
out[name.strip()] = (int(f[0]), int(f[8]))
|
||||
return out
|
||||
|
||||
|
||||
def raw_sample() -> dict:
|
||||
return {"time": time.monotonic(), "cpu": _cpu_times(), "disk": _disk_bytes(), "net": _net_bytes()}
|
||||
|
||||
|
||||
# ---------- Momentwerte ----------
|
||||
|
||||
def _meminfo() -> dict:
|
||||
info = {}
|
||||
for line in _read(f"{HOST_PROC}/meminfo").splitlines():
|
||||
key, _, rest = line.partition(":")
|
||||
info[key] = int(rest.split()[0]) * 1024
|
||||
total = info.get("MemTotal", 0)
|
||||
available = info.get("MemAvailable", info.get("MemFree", 0))
|
||||
swap_total = info.get("SwapTotal", 0)
|
||||
swap_used = swap_total - info.get("SwapFree", 0)
|
||||
return {
|
||||
"memory": {
|
||||
"totalBytes": total,
|
||||
"usedBytes": total - available,
|
||||
"availableBytes": available,
|
||||
"cachedBytes": info.get("Cached", 0) + info.get("Buffers", 0),
|
||||
"percent": _pct(total - available, total),
|
||||
},
|
||||
"swap": {"totalBytes": swap_total, "usedBytes": swap_used, "percent": _pct(swap_used, swap_total)},
|
||||
}
|
||||
|
||||
|
||||
def _unescape_mount(path: str) -> str:
|
||||
# /proc/mounts schreibt Leerzeichen usw. als \040
|
||||
return re.sub(r"\\([0-7]{3})", lambda m: chr(int(m[1], 8)), path)
|
||||
|
||||
|
||||
def _disks() -> list[dict]:
|
||||
path = f"{HOST_PROC}/1/mounts"
|
||||
if not os.path.exists(path):
|
||||
path = f"{HOST_PROC}/mounts"
|
||||
seen, out = set(), []
|
||||
for line in _read(path).splitlines():
|
||||
f = line.split()
|
||||
if len(f) < 3 or f[2] not in DISK_FS:
|
||||
continue
|
||||
device, mount, fstype = f[0], _unescape_mount(f[1]), f[2]
|
||||
if device in seen: # btrfs-Subvolumes, Bind-Mounts: dasselbe Laufwerk nur einmal
|
||||
continue
|
||||
try:
|
||||
st = os.statvfs(_host_path(mount))
|
||||
except OSError:
|
||||
continue
|
||||
seen.add(device)
|
||||
total = st.f_blocks * st.f_frsize
|
||||
used = (st.f_blocks - st.f_bfree) * st.f_frsize
|
||||
free = st.f_bavail * st.f_frsize
|
||||
out.append({
|
||||
"mount": mount,
|
||||
"device": device,
|
||||
"fs": fstype,
|
||||
"totalBytes": total,
|
||||
"usedBytes": used,
|
||||
"freeBytes": free,
|
||||
"percent": _pct(used, used + free), # wie df: reservierte Blöcke nicht mitgezählt
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _temperatures() -> list[dict]:
|
||||
out = []
|
||||
base = f"{HOST_SYS}/class/hwmon"
|
||||
try:
|
||||
sensors = sorted(os.listdir(base))
|
||||
except OSError:
|
||||
return out
|
||||
for hw in sensors:
|
||||
d = os.path.join(base, hw)
|
||||
name = _read_or(f"{d}/name", hw)
|
||||
try:
|
||||
inputs = sorted(f for f in os.listdir(d) if re.fullmatch(r"temp\d+_input", f))
|
||||
except OSError:
|
||||
continue
|
||||
for inp in inputs:
|
||||
prefix = inp[: -len("_input")]
|
||||
value = _read_or(f"{d}/{inp}")
|
||||
if value is None or not value.lstrip("-").isdigit():
|
||||
continue
|
||||
crit = _read_or(f"{d}/{prefix}_crit")
|
||||
out.append({
|
||||
"sensor": name,
|
||||
"label": _read_or(f"{d}/{prefix}_label", prefix),
|
||||
"celsius": int(value) / 1000,
|
||||
"criticalCelsius": int(crit) / 1000 if crit and crit.lstrip("-").isdigit() else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _cpu_model() -> str | None:
|
||||
for line in _read(f"{HOST_PROC}/cpuinfo").splitlines():
|
||||
if line.startswith("model name"):
|
||||
return line.partition(":")[2].strip()
|
||||
return None
|
||||
|
||||
|
||||
def _cpu_mhz() -> float | None:
|
||||
freqs = []
|
||||
try:
|
||||
cpus = os.listdir(f"{HOST_SYS}/devices/system/cpu")
|
||||
except OSError:
|
||||
return None
|
||||
for cpu in cpus:
|
||||
if re.fullmatch(r"cpu\d+", cpu):
|
||||
v = _read_or(f"{HOST_SYS}/devices/system/cpu/{cpu}/cpufreq/scaling_cur_freq")
|
||||
if v and v.isdigit():
|
||||
freqs.append(int(v) / 1000)
|
||||
return round(sum(freqs) / len(freqs)) if freqs else None
|
||||
|
||||
|
||||
def _os_name() -> str | None:
|
||||
for path in (_host_path("/etc/os-release"), _host_path("/usr/lib/os-release")):
|
||||
text = _read_or(path)
|
||||
if text:
|
||||
m = re.search(r'^PRETTY_NAME="?([^"\n]*)"?', text, re.M)
|
||||
if m:
|
||||
return m[1]
|
||||
return None
|
||||
|
||||
|
||||
def _pct(part: float, whole: float) -> float:
|
||||
return round(part / whole * 100, 1) if whole else 0.0
|
||||
|
||||
|
||||
def _rate(new: int, old: int, seconds: float) -> int:
|
||||
# Zähler können beim Neustart einer Schnittstelle zurückspringen
|
||||
return max(0, round((new - old) / seconds)) if seconds > 0 else 0
|
||||
|
||||
|
||||
# ---------- Gesamtbild ----------
|
||||
|
||||
def snapshot(prev: dict, cur: dict, interval: int) -> dict:
|
||||
"""Alle Werte zu einem JSON-fähigen Dict; Raten aus der Differenz prev -> cur."""
|
||||
seconds = cur["time"] - prev["time"]
|
||||
|
||||
cores, cpu_total, iowait = [], 0.0, 0.0
|
||||
for name, (busy, total, wait) in cur["cpu"].items():
|
||||
p_busy, p_total, p_wait = prev["cpu"].get(name, (busy, total, wait))
|
||||
pct = _pct(busy - p_busy, total - p_total)
|
||||
if name == "cpu":
|
||||
cpu_total = pct
|
||||
iowait = _pct(wait - p_wait, total - p_total)
|
||||
else:
|
||||
cores.append(pct)
|
||||
|
||||
net, rx_sum, tx_sum = [], 0, 0
|
||||
for name, (rx, tx) in sorted(cur["net"].items()):
|
||||
if name == "lo":
|
||||
continue
|
||||
p_rx, p_tx = prev["net"].get(name, (rx, tx))
|
||||
rx_rate, tx_rate = _rate(rx, p_rx, seconds), _rate(tx, p_tx, seconds)
|
||||
virtual = bool(VIRTUAL_NET.match(name))
|
||||
if not virtual:
|
||||
rx_sum += rx_rate
|
||||
tx_sum += tx_rate
|
||||
net.append({
|
||||
"interface": name,
|
||||
"virtual": virtual,
|
||||
"rxBytesPerSec": rx_rate,
|
||||
"txBytesPerSec": tx_rate,
|
||||
"rxBytesTotal": rx,
|
||||
"txBytesTotal": tx,
|
||||
})
|
||||
|
||||
load = _read(f"{HOST_PROC}/loadavg").split()
|
||||
stat = _read(f"{HOST_PROC}/stat")
|
||||
running = re.search(r"^procs_running (\d+)", stat, re.M)
|
||||
uptime = float(_read(f"{HOST_PROC}/uptime").split()[0])
|
||||
|
||||
return {
|
||||
"timestamp": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"intervalSeconds": interval,
|
||||
"host": {
|
||||
"hostname": _read_or(f"{HOST_PROC}/sys/kernel/hostname"),
|
||||
"os": _os_name(),
|
||||
"kernel": _read_or(f"{HOST_PROC}/sys/kernel/osrelease"),
|
||||
"uptimeSeconds": int(uptime),
|
||||
},
|
||||
"cpu": {
|
||||
"model": _cpu_model(),
|
||||
"threads": len(cores), # logische CPUs (mit Hyperthreading)
|
||||
"percent": cpu_total,
|
||||
"iowaitPercent": iowait,
|
||||
"perCorePercent": cores,
|
||||
"mhz": _cpu_mhz(),
|
||||
"load": {"1m": float(load[0]), "5m": float(load[1]), "15m": float(load[2])},
|
||||
},
|
||||
**_meminfo(),
|
||||
"disks": _disks(),
|
||||
"diskIo": {
|
||||
"readBytesPerSec": _rate(cur["disk"][0], prev["disk"][0], seconds),
|
||||
"writeBytesPerSec": _rate(cur["disk"][1], prev["disk"][1], seconds),
|
||||
},
|
||||
"network": {"rxBytesPerSec": rx_sum, "txBytesPerSec": tx_sum, "interfaces": net},
|
||||
"temperatures": _temperatures(),
|
||||
"processes": {
|
||||
"total": sum(1 for p in os.listdir(HOST_PROC) if p.isdigit()),
|
||||
"running": int(running[1]) if running else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def history_point(snap: dict) -> dict:
|
||||
"""Kurzform für den Verlauf (Diagramme)."""
|
||||
return {
|
||||
"timestamp": snap["timestamp"],
|
||||
"cpuPercent": snap["cpu"]["percent"],
|
||||
"memoryPercent": snap["memory"]["percent"],
|
||||
"load1m": snap["cpu"]["load"]["1m"],
|
||||
"rxBytesPerSec": snap["network"]["rxBytesPerSec"],
|
||||
"txBytesPerSec": snap["network"]["txBytesPerSec"],
|
||||
"readBytesPerSec": snap["diskIo"]["readBytesPerSec"],
|
||||
"writeBytesPerSec": snap["diskIo"]["writeBytesPerSec"],
|
||||
}
|
||||
Reference in new issue
Block a user