📋 Contexto

Prometheus + Grafana es el stack estándar para monitoreo en DevOps, pero para un home lab con recursos limitados puede ser overkill. Prometheus consume ~200-400 MB RAM, Grafana otros ~200 MB, más configuración compleja y mantenimiento.

Para mi servidor (Lenovo G480 con 4GB RAM), quería algo más ligero: un dashboard simple que muestre CPU, RAM, disco, red, y estado de containers Docker, todo con menos de 20 MB de RAM.

🎯 Objetivo

  • Monitoreo en tiempo real de recursos del sistema
  • Dashboard terminal (TUI) para SSH
  • Dashboard web accesible desde navegador
  • Monitoreo de containers Docker
  • Consumo mínimo de recursos (<20 MB RAM)
  • Sin dependencias pesadas (no frameworks web)

🛠️ Implementación

Herramientas Utilizadas

  • psutil: Biblioteca Python para métricas del sistema
  • curses: TUI (Terminal User Interface) nativa de Python
  • docker: Docker SDK para Python
  • systemd: Para ejecutar el monitor como servicio

Script 1: Monitor Terminal (sysmon.py)

Monitor interactivo para usar vía SSH:

#!/usr/bin/env python3
import psutil
import curses
import time
from datetime import datetime

def bytes_to_human(bytes_val):
    """Convierte bytes a formato legible"""
    for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
        if bytes_val < 1024.0:
            return f"{bytes_val:.2f} {unit}"
        bytes_val /= 1024.0

def draw_bar(stdscr, y, x, label, percent, color_pair=1):
    """Dibuja barra de progreso"""
    bar_width = 50
    filled = int(bar_width * percent / 100)
    bar = '█' * filled + '░' * (bar_width - filled)
    stdscr.addstr(y, x, f"{label:<20} [{bar}] {percent:.1f}%", 
                  curses.color_pair(color_pair))

def main(stdscr):
    # Configurar colores
    curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK)
    curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK)
    curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK)
    curses.init_pair(4, curses.COLOR_CYAN, curses.COLOR_BLACK)
    
    curses.curs_set(0)  # Ocultar cursor
    stdscr.nodelay(1)   # Non-blocking input
    
    while True:
        stdscr.clear()
        y = 0
        
        # Header
        stdscr.addstr(y, 0, "═" * 70, curses.color_pair(4))
        y += 1
        stdscr.addstr(y, 0, "  SysDevOps-Pro v2.1: Monitor Sistema + Docker/n8n", 
                     curses.A_BOLD | curses.color_pair(4))
        y += 1
        stdscr.addstr(y, 0, f"  {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", 
                     curses.color_pair(2))
        y += 2
        
        # CPU
        cpu_percent = psutil.cpu_percent(interval=0.5)
        color = 1 if cpu_percent > 80 else 3 if cpu_percent > 50 else 2
        draw_bar(stdscr, y, 0, "CPU", cpu_percent, color)
        y += 2
        
        # RAM
        mem = psutil.virtual_memory()
        draw_bar(stdscr, y, 0, "RAM", mem.percent, 4)
        y += 1
        stdscr.addstr(y, 0, 
                     f"  Total: {bytes_to_human(mem.total)} | "
                     f"Usado: {bytes_to_human(mem.used)} | "
                     f"Disponible: {bytes_to_human(mem.available)}", 
                     curses.color_pair(2))
        y += 2
        
        # Disco
        disk = psutil.disk_usage('/')
        draw_bar(stdscr, y, 0, "Disco (Root)", disk.percent, 1)
        y += 1
        stdscr.addstr(y, 0, 
                     f"  Total: {bytes_to_human(disk.total)} | "
                     f"Usado: {bytes_to_human(disk.used)} | "
                     f"Disponible: {bytes_to_human(disk.free)}", 
                     curses.color_pair(2))
        y += 2
        
        # Network I/O
        net = psutil.net_io_counters()
        stdscr.addstr(y, 0, "Network I/O:", curses.A_BOLD)
        y += 1
        stdscr.addstr(y, 0, 
                     f"  ↑ Sent: {bytes_to_human(net.bytes_sent)} | "
                     f"↓ Recv: {bytes_to_human(net.bytes_recv)}", 
                     curses.color_pair(2))
        y += 2
        
        # Uptime
        boot_time = datetime.fromtimestamp(psutil.boot_time())
        uptime = datetime.now() - boot_time
        days, remainder = divmod(uptime.total_seconds(), 86400)
        hours, remainder = divmod(remainder, 3600)
        minutes, _ = divmod(remainder, 60)
        stdscr.addstr(y, 0, 
                     f"Uptime: {int(days)}d {int(hours)}h {int(minutes)}m", 
                     curses.color_pair(3))
        y += 2
        
        # Footer
        stdscr.addstr(y, 0, "Presiona 'q' para salir", curses.color_pair(3))
        
        stdscr.refresh()
        
        # Check for quit
        key = stdscr.getch()
        if key == ord('q'):
            break
        
        time.sleep(1)

if __name__ == "__main__":
    curses.wrapper(main)
💡 Por qué psutil es perfecto:
• Multiplataforma (Linux, Windows, Mac)
• Lightweight (sin dependencias C pesadas)
• API simple y Pythonic
• Mantenido activamente
• Documentación excelente

Script 2: Monitor Web (sysmon_web.py)

Genera HTML estático que se actualiza cada 30 segundos:

#!/usr/bin/env python3
import psutil
import docker
import time
from datetime import datetime

UPDATE_INTERVAL = 30
OUTPUT_FILE = "/var/www/html/status.html"

def bytes_to_human(bytes_val):
    for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
        if bytes_val < 1024.0:
            return f"{bytes_val:.2f} {unit}"
        bytes_val /= 1024.0

def get_docker_stats():
    try:
        client = docker.from_env()
        containers = client.containers.list()
        stats = []
        for container in containers:
            stats.append({
                'name': container.name,
                'status': container.status,
                'image': container.image.tags[0] if container.image.tags else 'none'
            })
        return stats
    except:
        return []

def generate_html():
    cpu = psutil.cpu_percent(interval=1)
    mem = psutil.virtual_memory()
    disk = psutil.disk_usage('/')
    net = psutil.net_io_counters()
    boot_time = datetime.fromtimestamp(psutil.boot_time())
    uptime = datetime.now() - boot_time
    docker_containers = get_docker_stats()
    
    html = f"""


    Server Status - MyTechZone
    
    
    






    

⚡ Server Status

Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} UTC

● System Online

CPU Usage: {cpu:.1f}%

RAM: {mem.percent:.1f}% ({bytes_to_human(mem.used)} / {bytes_to_human(mem.total)})

Disk: {disk.percent:.1f}% ({bytes_to_human(disk.used)} / {bytes_to_human(disk.total)})

Docker Containers ({len(docker_containers)})

    """ for container in docker_containers: status_class = "status-ok" if container['status'] == 'running' else "status-error" html += f"
  • {container['name']} - {container['status']}
  • \n" html += f"""

Network & Uptime

Total Sent ↑ {bytes_to_human(net.bytes_sent)}

Total Received ↓ {bytes_to_human(net.bytes_recv)}

System Uptime: {int(uptime.days)}d {int(uptime.seconds//3600)}h {int((uptime.seconds//60)%60)}m

← Back to Home
""" return html def main(): print(f"Starting sysmon_web...") print(f"Output: {OUTPUT_FILE}") print(f"Update interval: {UPDATE_INTERVAL}s") while True: try: html = generate_html() with open(OUTPUT_FILE, 'w') as f: f.write(html) print(f"[{datetime.now().strftime('%H:%M:%S')}] ✓ Status updated") time.sleep(UPDATE_INTERVAL) except KeyboardInterrupt: print("\nStopping sysmon_web...") break except Exception as e: print(f"Error: {e}") time.sleep(UPDATE_INTERVAL) if __name__ == "__main__": main()

Servicio Systemd

Para que el monitor web corra automáticamente:

# /etc/systemd/system/sysmon-web.service
[Unit]
Description=SysDevOps Web Monitor
After=network.target docker.service

[Service]
Type=simple
User=mytechzone
WorkingDirectory=/home/mytechzone/scripts
ExecStart=/usr/bin/python3 /home/mytechzone/scripts/sysmon_web.py
Restart=always
RestartSec=30

[Install]
WantedBy=multi-user.target
# Habilitar y iniciar
sudo systemctl daemon-reload
sudo systemctl enable sysmon-web
sudo systemctl start sysmon-web

# Ver logs
sudo journalctl -u sysmon-web -f

📊 Comparación: Custom vs Prometheus+Grafana

Aspecto Custom (Python) Prometheus+Grafana
RAM Consumida ~15-20 MB ~400-600 MB
Setup Time 30 minutos 2-3 horas
Métricas Históricas ❌ No (solo actual) ✅ Sí (configurable)
Alertas ⚠️ Requiere script adicional ✅ Built-in
Dashboards ⚠️ Básico ✅ Avanzado
Escalabilidad ❌ Un servidor ✅ Multi-servidor
Cuándo usar cada uno:
Custom Python: Home labs, servidores únicos, recursos limitados
Prometheus+Grafana: Producción, múltiples servidores, métricas históricas críticas

🚀 Mejoras Opcionales

1. Monitoreo de Temperatura del CPU ✅ Implementado (v2.0)

🎉 Update (Enero 2026): Esta feature fue implementada en sysmon_web v2.0 y está corriendo en producción en mytechzone.dev/status.html

El monitoreo de temperatura es crucial para hardware antiguo (como mi Lenovo 2013 con Celeron B970). Implementé lectura desde /sys/class/thermal/ con fallback a psutil:

def get_cpu_temperature():
    """Obtener temperatura del CPU."""
    try:
        # Método 1: Leer desde /sys/class/thermal (más confiable)
        thermal_zones = glob.glob('/sys/class/thermal/thermal_zone*/temp')
        if thermal_zones:
            temps = []
            for zone in thermal_zones:
                try:
                    with open(zone, 'r') as f:
                        # Convertir de milidegrees a degrees
                        temp = int(f.read().strip()) / 1000.0
                        temps.append(temp)
                except:
                    continue
            
            if temps:
                return max(temps)  # Retornar la más alta
        
        # Método 2: Usar psutil (fallback)
        temps = psutil.sensors_temperatures()
        if temps:
            for name, entries in temps.items():
                if 'coretemp' in name.lower() or 'cpu' in name.lower():
                    if entries:
                        return entries[0].current
        
        return None
    except Exception as e:
        return None

Sistema de alertas por color implementado:

  • 🟢 < 60°C: OK (verde)
  • 🟡 60-69°C: Warm (amarillo)
  • 🟠 70-79°C: Warning (naranja)
  • 🔴 ≥ 80°C: Critical (rojo)

Si la temperatura alcanza ≥ 75°C, el dashboard muestra automáticamente:

⚠️ High temperature - Consider opening lid for ventilation

Impacto en performance:

  • CPU adicional: < 0.1%
  • RAM adicional: ~1-2 KB
  • I/O: 1 lectura cada 30 segundos
  • Resultado: Impacto prácticamente nulo ✅
En producción desde Enero 2026: Monitoreo de temperatura activo en mytechzone.dev/status.html. Me ayuda a decidir cuándo abrir/cerrar la tapa de la notebook según el clima.

2. Alertas por Email

import smtplib
from email.message import EmailMessage

def send_alert(subject, body):
    msg = EmailMessage()
    msg['Subject'] = subject
    msg['From'] = '[email protected]'
    msg['To'] = '[email protected]'
    msg.set_content(body)
    
    with smtplib.SMTP('localhost') as s:
        s.send_message(msg)

# Usar en el loop
if cpu_percent > 90:
    send_alert("CPU Alert", f"CPU at {cpu_percent}%")

3. Métricas Históricas con SQLite

import sqlite3

def save_metrics(cpu, ram, disk):
    conn = sqlite3.connect('metrics.db')
    c = conn.cursor()
    c.execute('''CREATE TABLE IF NOT EXISTS metrics
                 (timestamp TEXT, cpu REAL, ram REAL, disk REAL)''')
    c.execute("INSERT INTO metrics VALUES (datetime('now'), ?, ?, ?)",
              (cpu, ram, disk))
    conn.commit()
    conn.close()

💡 Lecciones Aprendidas

  • psutil es suficiente: Para 90% de casos de uso, no necesitas Prometheus.
  • HTML estático es rápido: Generar HTML cada 30s es más eficiente que un servidor web con templating.
  • Systemd hace el trabajo pesado: Auto-restart, logging, y startup automático gratis.
  • Menos es más: 200 líneas de Python vs configurar 3 servicios complejos.
  • Docker SDK es fácil: Integrar métricas de containers es trivial con docker-py.

✅ Resultado Final

Mi dashboard: mytechzone.dev/status.html

Características (v2.0 - Enero 2026):
• Actualización cada 30 segundos
• CPU, RAM, Disco, Red, Uptime
• 🌡️ Temperatura del CPU con alertas por color (NUEVO)
• Estado de 4 containers Docker (nginx, n8n, postgres, cloudflared)
• Servicio systemd estable
• ~15-18 MB de RAM consumidos
• 0 downtime en 60+ días
• Warning automático si temperatura ≥ 75°C

💭 Conclusión

Para un home lab, un monitor custom con Python es perfecto: ligero, simple, y hace exactamente lo que necesitas sin overhead innecesario. No necesitas Prometheus+Grafana hasta que tengas múltiples servidores o requisitos de métricas históricas avanzadas.

Este proyecto me enseñó que a veces la mejor solución es la más simple. 200 líneas de Python bien escritas pueden reemplazar un stack complejo de monitoreo para casos de uso básicos.