litellm/scripts/monitor_docker_stats.py

277 lines
8.1 KiB
Python

#!/usr/bin/env python3
import argparse
import csv
import datetime as dt
import math
import re
import statistics
import subprocess
import sys
import time
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class Sample:
timestamp: dt.datetime
elapsed_s: float
cpu_percent: float
mem_used_bytes: float
UNIT_FACTORS = {
"b": 1,
"kb": 1000,
"kib": 1024,
"mb": 1000**2,
"mib": 1024**2,
"gb": 1000**3,
"gib": 1024**3,
"tb": 1000**4,
"tib": 1024**4,
}
def parse_size_to_bytes(size_str: str) -> float:
match = re.fullmatch(r"\s*([0-9]*\.?[0-9]+)\s*([A-Za-z]+)\s*", size_str)
if match is None:
raise ValueError(f"Unable to parse size: {size_str}")
value = float(match.group(1))
unit = match.group(2).lower()
if unit not in UNIT_FACTORS:
raise ValueError(f"Unsupported size unit: {unit}")
return value * UNIT_FACTORS[unit]
def bytes_to_gib(value_bytes: float) -> float:
return value_bytes / (1024**3)
def run_stats_command(compose_cmd: str, service: str) -> str:
cmd = compose_cmd.split() + ["stats", service, "--no-stream"]
completed = subprocess.run(cmd, capture_output=True, text=True, check=False)
if completed.returncode != 0:
stderr = (completed.stderr or "").strip()
stdout = (completed.stdout or "").strip()
msg = stderr or stdout or "Unknown error"
raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{msg}")
return completed.stdout
def parse_stats_output(raw_output: str) -> tuple[float, float]:
lines = [line for line in raw_output.splitlines() if line.strip()]
if len(lines) < 2:
raise ValueError("Unexpected docker stats output (no data row found)")
data_line = lines[-1]
cpu_match = re.search(r"([0-9]*\.?[0-9]+)%", data_line)
mem_match = re.search(
r"([0-9]*\.?[0-9]+[A-Za-z]+)\s*/\s*([0-9]*\.?[0-9]+[A-Za-z]+)",
data_line,
)
if cpu_match is None or mem_match is None:
raise ValueError(f"Unable to parse stats row: {data_line}")
cpu_percent = float(cpu_match.group(1))
mem_used_bytes = parse_size_to_bytes(mem_match.group(1))
return cpu_percent, mem_used_bytes
def summarize(samples: List[Sample]) -> dict:
if not samples:
raise ValueError("No samples collected")
cpu_values = [s.cpu_percent for s in samples]
mem_gib_values = [bytes_to_gib(s.mem_used_bytes) for s in samples]
return {
"samples": len(samples),
"duration_seconds": samples[-1].elapsed_s,
"cpu_avg_percent": statistics.mean(cpu_values),
"cpu_peak_percent": max(cpu_values),
"cpu_variance": statistics.pvariance(cpu_values) if len(cpu_values) > 1 else 0.0,
"mem_avg_gib": statistics.mean(mem_gib_values),
"mem_peak_gib": max(mem_gib_values),
"mem_variance_gib2": (
statistics.pvariance(mem_gib_values) if len(mem_gib_values) > 1 else 0.0
),
}
def print_summary(summary: dict) -> None:
print("\n=== Docker Stats Summary ===")
print(f"Samples: {summary['samples']}")
print(f"Duration: {summary['duration_seconds']:.1f}s")
print(f"Average CPU: {summary['cpu_avg_percent']:.2f}%")
print(f"Peak CPU: {summary['cpu_peak_percent']:.2f}%")
print(f"CPU Variance: {summary['cpu_variance']:.4f} (%^2)")
print(f"Average Memory: {summary['mem_avg_gib']:.3f} GiB")
print(f"Peak Memory: {summary['mem_peak_gib']:.3f} GiB")
print(f"Memory Variance: {summary['mem_variance_gib2']:.6f} (GiB^2)")
def write_csv(samples: List[Sample], path: str) -> None:
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(
[
"timestamp",
"elapsed_seconds",
"cpu_percent",
"mem_used_gib",
]
)
for s in samples:
writer.writerow(
[
s.timestamp.isoformat(),
f"{s.elapsed_s:.3f}",
f"{s.cpu_percent:.4f}",
f"{bytes_to_gib(s.mem_used_bytes):.6f}",
]
)
def plot_memory(samples: List[Sample], output_path: str) -> None:
try:
import matplotlib.pyplot as plt
except Exception as e: # pragma: no cover
raise RuntimeError(
"matplotlib is required for plotting. Install with: pip install matplotlib"
) from e
x = [s.elapsed_s for s in samples]
y = [bytes_to_gib(s.mem_used_bytes) for s in samples]
plt.figure(figsize=(10, 5))
plt.plot(x, y, linewidth=2)
plt.xlabel("Time (seconds)")
plt.ylabel("Memory usage (GiB)")
plt.title("Memory Usage vs Time")
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(output_path, dpi=160)
plt.close()
def should_stop(start_monotonic: float, duration_s: Optional[float]) -> bool:
if duration_s is not None and (time.monotonic() - start_monotonic) >= duration_s:
return True
return False
def main() -> int:
parser = argparse.ArgumentParser(
description="Sample 'docker compose stats <service> --no-stream' and summarize usage",
)
parser.add_argument(
"--interval",
type=float,
default=45.0,
help="Sampling interval in seconds (default: 45)",
)
parser.add_argument(
"--duration",
type=float,
default=None,
help="Total duration in seconds. If omitted, runs until Ctrl+C.",
)
parser.add_argument(
"--service",
type=str,
default="litellm",
help="Compose service name (default: litellm)",
)
parser.add_argument(
"--compose-cmd",
type=str,
default="docker compose",
help="Compose command prefix (default: 'docker compose')",
)
parser.add_argument(
"--csv",
type=str,
default=None,
help="Optional output CSV path for raw samples",
)
parser.add_argument(
"--plot",
type=str,
default=None,
help="Optional PNG path for memory-vs-time plot",
)
args = parser.parse_args()
if args.interval <= 0:
print("--interval must be > 0", file=sys.stderr)
return 2
if args.duration is None:
print("Running until Ctrl+C. Use --duration for automatic stop.")
samples: List[Sample] = []
start_monotonic = time.monotonic()
next_run = start_monotonic
try:
while True:
now = time.monotonic()
if now < next_run:
time.sleep(next_run - now)
timestamp = dt.datetime.now(dt.timezone.utc)
elapsed_s = time.monotonic() - start_monotonic
try:
output = run_stats_command(args.compose_cmd, args.service)
cpu, mem_used_bytes = parse_stats_output(output)
sample = Sample(
timestamp=timestamp,
elapsed_s=elapsed_s,
cpu_percent=cpu,
mem_used_bytes=mem_used_bytes,
)
samples.append(sample)
print(
f"[{timestamp.isoformat()}] sample={len(samples)} "
f"cpu={cpu:.2f}% mem={bytes_to_gib(mem_used_bytes):.3f}GiB"
)
except Exception as e:
print(f"Warning: failed to sample stats: {e}", file=sys.stderr)
if should_stop(start_monotonic, args.duration):
break
next_run += args.interval
except KeyboardInterrupt:
print("\nStopping on Ctrl+C...")
if not samples:
print("No valid samples collected.", file=sys.stderr)
return 1
summary = summarize(samples)
print_summary(summary)
if args.csv:
write_csv(samples, args.csv)
print(f"Wrote samples CSV: {args.csv}")
if args.plot:
try:
plot_memory(samples, args.plot)
print(f"Wrote memory plot: {args.plot}")
except Exception as e:
print(f"Failed to generate plot: {e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())