Un sistema de monitorización completo como Prometheus o Zabbix es la opción correcta cuando gestionas muchos servidores, pero para uno o dos servidores a veces basta con un script bien escrito que compruebe lo esencial y te avise cuando algo falla. En este tutorial escribirás en Ubuntu 24.04 un script Bash que vigila el espacio en disco, la memoria disponible, la carga, el estado de servicios de systemd y la respuesta de URLs, envía un aviso a Slack o Discord solo cuando el estado cambia, y se ejecuta cada cinco minutos con un temporizador de systemd.

Requisitos previos

  • Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath.
  • Un usuario no root con privilegios sudo.
  • Una URL de webhook entrante de Slack o de Discord (opcional: sin ella, los avisos solo quedan en el diario de systemd).
  • Conocimientos básicos de Bash.

Paso 1: Instalar las dependencias

El script usa curl para comprobar URLs y enviar avisos, y jq para construir el JSON del webhook sin problemas de comillas:

sudo apt update
sudo apt install curl jq

Comprueba que están disponibles:

jq --version
jq-1.7.1

Paso 2: Escribir el script

El script sigue unas reglas que evitan los fallos típicos de los scripts de monitorización:

  • set -euo pipefail para que un error inesperado detenga el script en lugar de producir un falso "todo bien".
  • Todos los umbrales se leen de variables de entorno con un valor por defecto, así no hay que editar el script para ajustarlos.
  • Cada problema tiene una clave fija (por ejemplo disk:/) y un mensaje con el detalle. Solo se avisa cuando cambia el conjunto de claves, no cada vez que el porcentaje sube un punto, así que no recibirás el mismo aviso cada cinco minutos.
  • No hay bucles infinitos: el script hace una pasada y termina. De repetirlo se encarga systemd.

Crea el fichero:

sudo nano /usr/local/bin/server-check
#!/usr/bin/env bash
# server-check: comprueba disco, memoria, carga, servicios y URLs
# y avisa por webhook cuando cambia el estado.
set -euo pipefail

DISK_MAX="${DISK_MAX:-85}"                  # % de uso máximo por sistema de ficheros
MEM_MIN_AVAILABLE="${MEM_MIN_AVAILABLE:-10}" # % mínimo de memoria disponible
LOAD_MAX_PER_CPU="${LOAD_MAX_PER_CPU:-2}"   # carga máxima (1 min) por vCPU, número entero
SERVICES="${SERVICES:-ssh}"                 # unidades de systemd separadas por espacios
CHECK_URLS="${CHECK_URLS:-}"                # URLs separadas por espacios
WEBHOOK_URL="${WEBHOOK_URL:-}"              # vacío = solo escribir en la salida
WEBHOOK_FIELD="${WEBHOOK_FIELD:-text}"      # text para Slack, content para Discord
STATE_FILE="${STATE_FILE:-/var/lib/server-check/last_status}"

host="$(hostname)"
keys=()
messages=()

problem() {
    keys+=("$1")
    messages+=("$2")
}

check_disk() {
    local use target
    while read -r use target; do
        use="${use%\%}"
        if (( use >= DISK_MAX )); then
            problem "disk:${target}" "Disco ${target} al ${use}% (umbral ${DISK_MAX}%)"
        fi
    done < <(df --output=pcent,target -x tmpfs -x devtmpfs -x squashfs -x overlay -x efivarfs | tail -n +2)
}

check_memory() {
    local available
    available="$(awk '/^MemTotal:/ {t=$2} /^MemAvailable:/ {a=$2} END {printf "%d", a * 100 / t}' /proc/meminfo)"
    if (( available < MEM_MIN_AVAILABLE )); then
        problem "memory" "Memoria disponible ${available}% (mínimo ${MEM_MIN_AVAILABLE}%)"
    fi
}

check_load() {
    local load1 rest cpus limit
    read -r load1 rest < /proc/loadavg
    cpus="$(nproc)"
    limit=$(( cpus * LOAD_MAX_PER_CPU ))
    if awk -v l="$load1" -v m="$limit" 'BEGIN { exit !(l > m) }'; then
        problem "load" "Carga ${load1} con ${cpus} vCPU (umbral ${limit})"
    fi
}

check_services() {
    local -a services
    local svc
    read -ra services <<< "$SERVICES"
    for svc in "${services[@]}"; do
        if ! systemctl is-active --quiet "$svc"; then
            problem "service:${svc}" "El servicio ${svc} no está activo"
        fi
    done
}

check_urls() {
    local -a urls
    local url
    read -ra urls <<< "$CHECK_URLS"
    for url in "${urls[@]}"; do
        if ! curl -fsS -o /dev/null --max-time 10 "$url" 2> /dev/null; then
            problem "url:${url}" "${url} no responde correctamente"
        fi
    done
}

notify() {
    local text="$1"
    printf '%s\n' "$text"
    if [[ -n "$WEBHOOK_URL" ]]; then
        if ! jq -n --arg k "$WEBHOOK_FIELD" --arg v "$text" '{($k): $v}' |
            curl -fsS --max-time 10 -H 'Content-Type: application/json' -d @- "$WEBHOOK_URL" > /dev/null; then
            echo "No se pudo enviar el aviso al webhook" >&2
        fi
    fi
}

check_disk
check_memory
check_load
check_services
check_urls

if (( ${#keys[@]} > 0 )); then
    status="$(printf '%s\n' "${keys[@]}" | sort)"
else
    status="OK"
fi

previous="OK"
if [[ -f "$STATE_FILE" ]]; then
    previous="$(< "$STATE_FILE")"
fi

if [[ "$status" == "$previous" ]]; then
    echo "Sin cambios: ${status//$'\n'/, }"
    exit 0
fi

if [[ "$status" == "OK" ]]; then
    notify "[${host}] RESUELTO: todas las comprobaciones vuelven a estar bien"
else
    notify "[${host}] ALERTA:"$'\n'"$(printf -- '- %s\n' "${messages[@]}")"
fi

mkdir -p "$(dirname "$STATE_FILE")"
printf '%s\n' "$status" > "$STATE_FILE"

Dale permisos de ejecución y revisa que no tenga errores de sintaxis:

sudo chmod 0755 /usr/local/bin/server-check
bash -n /usr/local/bin/server-check && echo "Sintaxis correcta"
Sintaxis correcta

Si quieres un análisis más estricto, instala shellcheck (sudo apt install shellcheck) y ejecuta shellcheck /usr/local/bin/server-check.

Algunas decisiones del script que conviene entender:

  • Disco: df --output=pcent,target devuelve solo el porcentaje y el punto de montaje, sin tener que recortar columnas con awk. Se excluyen los sistemas de ficheros virtuales (tmpfs, squashfs de snaps, overlay de contenedores), que darían falsos positivos.
  • Memoria: se usa MemAvailable de /proc/meminfo, que es la memoria que el kernel puede dar a nuevos procesos incluyendo la caché liberable. Usar la memoria "libre" daría alarmas constantes, porque Linux aprovecha toda la RAM libre como caché.
  • Carga: Bash no sabe comparar decimales, así que la comparación se delega en awk en lugar de usar bc.
  • Servicios: systemctl is-active --quiet devuelve un código de salida distinto de cero si la unidad no está en marcha, sin tener que analizar texto.

Paso 3: Probar el script manualmente

Ejecútalo tal cual. Con los valores por defecto y un servidor sano no debería avisar de nada:

sudo /usr/local/bin/server-check
Sin cambios: OK

Ahora fuerza una alerta bajando el umbral de disco al 1 % y añadiendo un servicio que no existe. Usa un fichero de estado temporal para no alterar el real:

sudo DISK_MAX=1 SERVICES="ssh servicio-inexistente" STATE_FILE=/tmp/server-check-test /usr/local/bin/server-check
[web01] ALERTA:
- Disco / al 23% (umbral 1%)
- Disco /boot al 11% (umbral 1%)
- El servicio servicio-inexistente no está activo

Si lo ejecutas otra vez con los mismos parámetros, no vuelve a avisar, porque el estado no ha cambiado:

Sin cambios: disk:/, disk:/boot, service:servicio-inexistente

Y si lo ejecutas con los valores normales sobre el mismo fichero de estado, recibes el aviso de resolución:

sudo STATE_FILE=/tmp/server-check-test /usr/local/bin/server-check
[web01] RESUELTO: todas las comprobaciones vuelven a estar bien

Borra el fichero de prueba:

sudo rm /tmp/server-check-test

Paso 4: Configurar los avisos por webhook

Guarda la configuración en un fichero aparte. Así puedes cambiar umbrales sin tocar el script, y la URL del webhook, que funciona como una contraseña, queda protegida:

sudo nano /etc/default/server-check
DISK_MAX=85
MEM_MIN_AVAILABLE=10
LOAD_MAX_PER_CPU=2
SERVICES="ssh nginx"
CHECK_URLS="https://your_domain/"
WEBHOOK_URL="https://hooks.slack.com/services/XXX/YYY/ZZZ"
WEBHOOK_FIELD=text

Sustituye your_domain por tu dominio y ajusta SERVICES a lo que ejecutes en el servidor. Para Discord, pega la URL del webhook de tu canal y cambia WEBHOOK_FIELD=content, que es el campo que espera Discord para el texto del mensaje.

Restringe los permisos del fichero:

sudo chmod 0600 /etc/default/server-check

Comprueba que el aviso llega a tu canal con una prueba forzada que lee la configuración:

sudo bash -c 'set -a; . /etc/default/server-check; DISK_MAX=1 STATE_FILE=/tmp/server-check-test /usr/local/bin/server-check; rm -f /tmp/server-check-test'

set -a exporta todas las variables que se cargan del fichero para que el script las vea. Debería aparecer el mensaje de alerta en Slack o Discord.

Paso 5: Ejecutar el script cada 5 minutos con systemd

Un temporizador de systemd es preferible a cron para esto: la salida queda en el diario, puedes ver cuándo fue la última ejecución y si falló, y systemd crea el directorio de estado por ti.

Crea el servicio:

sudo nano /etc/systemd/system/server-check.service
[Unit]
Description=Comprobaciones básicas del servidor
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/default/server-check
ExecStart=/usr/local/bin/server-check
StateDirectory=server-check

StateDirectory=server-check crea /var/lib/server-check, donde el script guarda el último estado. Type=oneshot indica que el servicio hace su trabajo y termina.

Crea el temporizador:

sudo nano /etc/systemd/system/server-check.timer
[Unit]
Description=Ejecuta server-check cada 5 minutos

[Timer]
OnCalendar=*:0/5
AccuracySec=30s

[Install]
WantedBy=timers.target

Recarga systemd y activa el temporizador:

sudo systemctl daemon-reload
sudo systemctl enable --now server-check.timer

Comprueba que está programado:

systemctl list-timers server-check.timer
NEXT                        LEFT          LAST PASSED UNIT               ACTIVATES
Thu 2026-09-25 11:05:00 UTC 3min 12s left -    -      server-check.timer server-check.service

Lanza una ejecución inmediata y revisa su salida en el diario:

sudo systemctl start server-check.service
journalctl -u server-check.service -n 5 --no-pager
Sep 25 11:01:52 web01 systemd[1]: Starting server-check.service - Comprobaciones básicas del servidor...
Sep 25 11:01:52 web01 server-check[4102]: Sin cambios: OK
Sep 25 11:01:52 web01 systemd[1]: server-check.service: Deactivated successfully.
Sep 25 11:01:52 web01 systemd[1]: Finished server-check.service - Comprobaciones básicas del servidor.

Si el script terminara con un error (por ejemplo, por un fallo inesperado de df), la unidad aparecería en systemctl --failed, así que también te enterarías de que la propia monitorización ha dejado de funcionar.

Paso 6: Añadir una comprobación nueva

La estructura hace que añadir comprobaciones sea sencillo: una función que llama a problem con una clave y un mensaje. Como ejemplo, esta función avisa cuando el certificado TLS de un dominio caduca en menos de 14 días. openssl x509 -checkend devuelve un código de error si el certificado caduca antes de los segundos indicados.

Edita el script:

sudo nano /usr/local/bin/server-check

Añade la función junto a las demás comprobaciones:

check_certs() {
    local -a domains
    local domain
    read -ra domains <<< "${CERT_DOMAINS:-}"
    for domain in "${domains[@]}"; do
        if ! openssl s_client -connect "${domain}:443" -servername "$domain" < /dev/null 2> /dev/null |
            openssl x509 -noout -checkend $(( 14 * 86400 )) > /dev/null 2>&1; then
            problem "cert:${domain}" "El certificado de ${domain} caduca en menos de 14 días o no se puede leer"
        fi
    done
}

Y llámala después de check_urls:

check_certs

Añade la variable a /etc/default/server-check:

CERT_DOMAINS="your_domain"

Comprueba la sintaxis y ejecuta el servicio para verificar que sigue funcionando:

bash -n /usr/local/bin/server-check && sudo systemctl start server-check.service
journalctl -u server-check.service -n 3 --no-pager

Solución de problemas

El script termina sin mensaje y la unidad queda en estado failed. Algún comando ha fallado y set -e ha detenido el script. Ejecútalo con traza para ver dónde: sudo bash -x /usr/local/bin/server-check.

No llegan los avisos al webhook y el diario muestra No se pudo enviar el aviso al webhook. Prueba la URL a mano con curl -fsS -H 'Content-Type: application/json' -d '{"text":"prueba"}' "$WEBHOOK_URL" (usa content en Discord). Un error 400 suele indicar un WEBHOOK_FIELD incorrecto; un 404, una URL revocada.

La alerta de carga aparece y desaparece continuamente. La carga de 1 minuto es muy variable. Sube LOAD_MAX_PER_CPU o cambia en check_load la lectura para usar la media de 5 minutos (read -r _ load1 rest < /proc/loadavg).

Tras cambiar /etc/default/server-check no se aplican los nuevos valores. No hace falta recargar nada: el fichero se lee en cada ejecución. Comprueba que no haya espacios alrededor del =, que el formato de EnvironmentFile no admite.

Conclusión

Tienes un script de monitorización que comprueba disco, memoria, carga, servicios, URLs y certificados, avisa por Slack o Discord solo cuando algo cambia y se ejecuta cada cinco minutos con systemd, con su salida en el diario. Es una buena solución para pocos servidores; cuando necesites histórico, gráficas o vigilar muchas máquinas, da el paso a Prometheus con node_exporter y Alertmanager, o a Netdata. Mientras tanto, complementa este script revisando los logs de /var/log y configurando logrotate para que los logs no llenen el disco.