Hero Image
- Mark

自己蓋一套 Server Monitoring:Bash 抓資料、PHP 收 log — WinkHosting 做法解析與我會改的地方

WinkHosting 上個月丟了一篇 Server Monitoring with PHP and Bash Scripts,標題一看就是「自己蓋」的派別:被監控端跑一支 Bash,metrics 寫成檔案,curl 丟到中央的 PHP 端點,PHP 把檔案存進 /files。作者 Camilo 自己也在文末補了一句「presentation of information 留給你想像」 — 等於交了一半的解。

這種輕量做法其實很對我的胃口。我手上管的機器不多,但又不想被 SaaS monitor 鎖住資料。所以這篇不是照翻,是把原文照著走一遍,再加一份「要拿來跑之前我會改的清單」。如果你只是要一份 sample code,文末會附完整版。

WinkHosting 那篇在做什麼

先把他們的設計翻成人話:

┌──────────────┐    curl (multipart POST)    ┌──────────────┐
│  servermon.sh │  ─────────────────────────▶ │ /storeinfo/   │
│  (每台機器跑)  │     token + 暫存檔          │   index.php   │
│              │                              │   → /files/   │
└──────────────┘                              └──────────────┘
   uptime / df -h                                  純檔案
   free -m
   ps -eo pcpu

每台機器跑一個 while true 迴圈,每秒 tick 一次,當某個 ticker 達標就抓對應的 metrics、寫成 temp 檔、curl 出去、刪掉。原文鎖了四個指標:

指標 原文宣稱 實際 script 為什麼抓這個
Load & uptime 每 10 秒 UPTIMETIMER=5 系統忙不忙、有沒有人 reboot
Storage 每 24 小時 STORAGETIMER=3600(1 小時) 磁碟滿了就是死
RAM 每 30 秒 RAMTIMER=60(1 分鐘) OOM 前兆
Top 10 CPU process 每 5 分鐘 CPUTOPTIMER=300 誰在吃資源

順便提一下:原文文章內文寫的 timer 值跟 script 裡的值對不上 — storage 說 24h 但 script 是 1h,RAM 說 30s 但 script 是 60s。不影響架構,但代表這個 sample 是示意性質,不是 production-tuned 的數字。

PHP 端點的版本更極簡:收 token、收檔案、用日期當前綴存檔,沒有資料庫、沒有 authentication framework、沒有 rate limit:

<?php
//Security token to authenticate the access to this script.
$_TOKEN = "f66dc5…7985";
$_SAVEPATH = __DIR__ . "/files";
// … 略,token check + move_uploaded_file

作者也很誠實地說「接下來怎麼顯示資料、怎麼分析,留給你自己」。所以這篇的價值不在於他的 code(太薄),在於這個架構圖 — 它揭示了一個「client push → server store」的監控資料流。

照抄之前的問題盤點

問題分三類:安全可靠可觀察性。原文都沒處理,所以直接拿去上 prod 就會出事。

安全

1. Token 比對用 != 有 timing attack。 PHP 的 != 比對字串是 byte-by-byte,遇到第一個不同 byte 就回傳 false — 攻擊者可以用統計方法慢慢把 token 推出來。應該改用 hash_equals()

// 原文
if ($_POST["token"] != $_TOKEN) { … }
// 改
if (!hash_equals($_TOKEN, $_POST["token"] ?? '')) { … }

2. Token 是 global 的。 原文一支 token 對所有 agent。意思是只要任何一台被偷(機器被 root、被同事的 CI 撞到),你就要 rotate token,全部機器一起失效。應該每台機器一個 token,或至少 per-env(staging / prod 分開)。

3. 沒限制檔案大小與副檔名。 攻擊者拿著合法 token 就可以丟 5GB 的檔案把你磁碟塞滿,檔名叫 .php 還可能會讓 Apache / nginx 執行(看你 /files 怎麼掛)。要加:

// 限制大小
if ($_FILES['data']['size'] > 1 * 1024 * 1024) { /* reject */ }
// 限制副檔名
if (!preg_match('/\.(log|txt|gz)$/i', $_FILES['data']['name'])) { /* reject */ }

4. _DIR_ 是未定義常數。 原文寫 $_SAVEPATH = _DIR_ . "/files" — 正確應該是 __DIR__(兩個底線)。這是 PHP 的 magic constant,_DIR_ 會被當成字串 "_DIR_",在某些版本會 silent fail、有些版本會 fatal error。改寫時順手修掉。

可靠

5. 沒 auto-restart。 bash 用 while true; do ...; sleep 1,crash 之後就沒了 — kill -9、記憶體 OOM、磁碟滿了寫不進 temp 檔,都會讓它靜悄悄死掉。應該包成 systemd service,至少 Restart=on-failure

[Unit]
Description=Server monitoring agent
After=network-online.target

[Service]
Type=simple
ExecStart=/opt/servermon/servermon.sh
Restart=on-failure
RestartSec=10
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

順便補一個 flock 防止有人不小心跑兩次:

exec 9>/var/lock/servermon.lock
flock -n 9 || { echo "already running"; exit 1; }

6. curl 失敗 log 就沒了。 原文流程是「寫 temp → curl → 成功就 rm」,curl 失敗(中央 PHP 502、網路斷)時資料直接消失。應該:curl 失敗就先留著,下一個 tick 再送;如果 temp 檔已經堆到 N 個就 alert 或 drop 最舊的。

可觀察性

7. 沒 hostname 標記。 所有 box 一起 push 進同一個 /files,檔名前綴只有日期沒有機器名 — 後面你想分析「是哪台機器滿了」會哭。要嘛在檔名加 hostname、要嘛把 hostname 寫進內容第一行。

HOST=$(hostname -s)
{ echo "host=$HOST"; df -h; } > $DIRTMP/storage.log

改寫版

把上面 7 點全部修進來,完整 code:

Bash:/opt/servermon/servermon.sh

#!/bin/bash
set -u

DIRTMP=/var/tmp/servermon
mkdir -p "$DIRTMP"
HOST=$(hostname -s)

# Central endpoint
URL="https://monitor.internal.example.com/storeinfo/index.php"
TOKEN_FILE=/opt/servermon/token   # 個別 token,chmod 600
TOKEN=$(cat "$TOKEN_FILE")

# Timers (seconds)
declare -i STORAGE_TIMER=3600
declare -i RAM_TIMER=60
declare -i CPU_TIMER=300
declare -i UPTIME_TIMER=10

declare -i STORAGE_TICK=0 RAM_TICK=0 CPU_TICK=0 UPTIME_TICK=0

send() {
  local f=$1
  # gzip + retry × 3
  gzip -c "$f" > "$f.gz"
  for i in 1 2 3; do
    if curl -fsS --max-time 10 \
         -F "host=$HOST" \
         -F "data=@$f.gz;filename=$(basename $f).gz" \
         -F "token=$TOKEN" "$URL"; then
      rm -f "$f" "$f.gz"
      return 0
    fi
    sleep $((i*2))
  done
  echo "[$(date -Is)] send failed, kept $f" >> /var/log/servermon.err
  return 1
}

trap 'echo "[$(date -Is)] stopping"; exit 0' INT TERM

while true; do
  ((STORAGE_TICK++)); ((RAM_TICK++)); ((CPU_TICK++)); ((UPTIME_TICK++))

  if (( STORAGE_TICK >= STORAGE_TIMER )); then
    STORAGE_TICK=0
    { echo "host=$HOST"; df -h; } > $DIRTMP/storage.log
    send $DIRTMP/storage.log
  fi
  if (( RAM_TICK >= RAM_TIMER )); then
    RAM_TICK=0
    { echo "host=$HOST"; free -m; } > $DIRTMP/memory.log
    send $DIRTMP/memory.log
  fi
  if (( CPU_TICK >= CPU_TIMER )); then
    CPU_TICK=0
    { echo "host=$HOST"; ps -eo pcpu,pid,user,args | sort -k1 -r | head -11; } \
      > $DIRTMP/topcpu.log
    send $DIRTMP/topcpu.log
  fi
  if (( UPTIME_TICK >= UPTIME_TIMER )); then
    UPTIME_TICK=0
    { echo "host=$HOST"; uptime; } > $DIRTMP/uptime.log
    send $DIRTMP/uptime.log
  fi

  sleep 1
done

差異:

  • token 從檔案讀、不寫死在 script(chmod 600)
  • 每筆資料帶 host= 欄位,PHP 端可以解析
  • gzip 壓縮 + retry × 3
  • 失敗留著、寫 error log,不靜默丟資料
  • timer 數值跟我自己對齊(uptime 10s、storage 1h、RAM 1m、CPU 5m)

PHP:/var/www/storeinfo/index.php

<?php
declare(strict_types=1);

$TOKEN_FILE = '/etc/storeinfo/token';
$_TOKEN = trim(file_get_contents($TOKEN_FILE));
$_SAVEPATH = __DIR__ . '/files';

if (!is_dir($_SAVEPATH)) { mkdir($_SAVEPATH, 0750, true); }

function reject(string $msg): void {
    http_response_code(400);
    echo $msg;
    exit;
}

$token = $_POST['token'] ?? '';
if (!hash_equals($_TOKEN, $token)) reject('invalid token');

if (!isset($_FILES['data'])) reject('no file');

$f = $_FILES['data'];
if ($f['error'] !== UPLOAD_ERR_OK) reject('upload error');
if ($f['size'] > 1 * 1024 * 1024) reject('file too large');
if (!is_uploaded_file($f['tmp_name'])) reject('bad tmp');

// 只收 .log / .txt / .gz
$orig = basename($f['name']);
if (!preg_match('/\.(log|txt|gz)$/i', $orig)) reject('bad extension');

$host = preg_replace('/[^a-z0-9\-]/i', '', $_POST['host'] ?? 'unknown');
$name = gmdate('Ymd\THis') . '_' . $host . '_' . $orig;
$dest = $_SAVEPATH . '/' . $name;

if (!move_uploaded_file($f['tmp_name'], $dest)) reject('move failed');

chmod($dest, 0640);
echo "saved $name";

差異:

  • __DIR__ 取代錯誤的 _DIR_
  • token 從檔案讀、用 hash_equals 比對
  • 限制 size 1MB、副檔名 allowlist、is_uploaded_file() 檢查
  • 檔名加 hostname,UTC ISO-ish timestamp
  • chmod 0640,預防 web 端意外 expose

systemd unit:/etc/systemd/system/servermon.service

[Unit]
Description=Server monitoring agent (bash)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/opt/servermon/servermon.sh
Restart=on-failure
RestartSec=10
LimitNOFILE=65536
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

裝好之後:

systemctl daemon-reload
systemctl enable --now servermon
journalctl -u servermon -f

自己蓋 vs 用 SaaS

這套東西適合誰?

自己蓋就夠的場景:

  • 1–5 台 server,沒有跨雲需求
  • 你想理解「監控資料怎麼流」、想自己控制資料格式
  • 學習 / 個人 side project,不在意 SLA
  • 已經有中央 log server,這套就只是補 server-side 的 metrics

應該直接接 SaaS / Prometheus 的場景:

  • 超過 10 台、開始跨 region
  • 需要 alert routing(PagerDuty、Slack、Line Notify)
  • 需要歷史查詢、dashboard、correlation(這套 PHP 純檔案方案根本做不來)
  • 合規要求 log 要可搜尋、可保留 N 年

我自己的結論: 拿這個版本當起點很好 — 你會真的理解資料流、知道 push 跟 pull 的差別、知道為什麼 Prometheus 最後勝出。但只要機器數開始成長、或開始有人半夜被打電話,就要升級。

延伸閱讀:原文 — Server Monitoring with PHP and Bash Scripts

Other Related Posts:

Classic Modern Architecture

今天試裝之前為了研究 linux 系統 而有小玩的 windows wsl 系統,這次嘗試,是因為在公司mac 上玩了 alacritty 這個新的終端工具,想基於他的跨平台性能,iterm 2 以下安裝為非乾淨的 windows 安裝,有一些指令,可能會稍微有誤差

使用管理員執行 Powershell

Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux

安裝 Ubuntu 18.04 url

sudo apt-get update
sudo apt-get...
18th Sep 2020