Table of Contents
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 最後勝出。但只要機器數開始成長、或開始有人半夜被打電話,就要升級。