Linux CPU 使用率監控與異常程序追蹤指南
背景
主機偶爾會出現短暫的 CPU 尖峰,但一般監控圖只能看到「CPU 有升高」,無法得知當下是哪個程序、甚至是哪一個網址造成的。
因此我們建立一個 CPU Watcher(V3.4),整體流程是:
每 2 秒取樣 CPU
↓
CPU >= 15%
↓
找出 CPU >= 10% 的 PHP-FPM worker
↓
取得 PID
↓
用 PID 對照 PHP-FPM access log
↓
列出 Laravel 的 request_uri、執行時間、記憶體
↓
同時觀察 MariaDB / Nginx 等相關程序
PHP worker 的 CPU 不是等整體 CPU 超標才開始算,而是每次取樣都持續透過 /proc/<pid>/stat 計算。所以即使只持續一、兩秒的高 CPU request,在觸發時也已經有數值可以對照。
要做到這件事,需要三個東西配合:
- PHP-FPM access log:記錄每個 request 由哪個 PID 處理、真實網址是什麼。
- Log 讀取權限:Watcher 以
ploi身分執行,必須能讀取這份 log。 - Logrotate:Watcher 與 access log 都會持續成長,需要自動輪替。
版本演進
| 版本 | 做法 | 代價 |
|---|---|---|
| 初版 | 每 10 秒算一次 CPU,達標才記錄 | 只看得到偵測當下,看不到尖峰之前 |
| V2 | 每秒用 ps 保存程序快照,尖峰時一併寫出前約 10 秒 |
每秒執行一次 ps |
| V3 | 每秒掃描全部 /proc/PID/stat |
Watcher 自己實測佔 5~12% CPU,已棄用 |
| V3.1 | 平常只讀 /proc/stat,達標才用 pidstat 取樣 |
只知道哪個程序忙,無法對應到網址 |
| V3.4 | 平常讀 /proc/stat 與 PHP-FPM worker 的 CPU 時間,達標時用 PID 對照 access log 找出網址 |
需要設定 access log 與權限;移除了 Load、記憶體、連線、MySQL 程序等區塊 |
V3.4 的取捨是:放棄 V3.1 那份「通用的系統狀態快照」,換成能直接回答「哪個網址讓 PHP 吃 CPU」。V2、V3.1 的腳本都保留在這個站台的 git 歷史裡。
本文的環境與操作順序
環境假設
本文以下面這組環境為例,如果你的環境不同,相關的名稱與路徑要一併調整:
| 項目 | 本文的設定 | 換環境時要改的地方 |
|---|---|---|
| 作業系統 | Ubuntu / Debian(用 apt 安裝套件) |
安裝指令 |
| 執行帳號 | ploi,家目錄 /home/ploi |
logrotate 的路徑與 su ploi ploi、chown root:ploi、pkill 的路徑 |
| PHP | PHP 8.1 + PHP-FPM(服務名稱 php8.1-fpm) |
腳本裡的 php-fpm8.1、PHP_ACCESS_LOG、設定檔路徑 |
| 網站 | Nginx + MariaDB + Laravel | CURRENT RELATED PROCESSES 的程序名稱 |
後面的指令都請用 ploi 帳號登入後執行,需要管理員權限的已加上 sudo。
操作順序
照下面的順序做,每一步完成後都有可以確認的結果:
- 開啟 PHP-FPM access log,重新載入服務。
- 調整 access log 權限,讓
ploi讀得到。 - (建議)安裝
pidstat。 - 建立腳本、背景啟動,確認 Watcher 找得到 PHP-FPM。
- 跑一次驗證,親眼看到 Watcher 寫出紀錄(見〈驗證 Watcher 有在運作〉)。
- 設定 Logrotate。
- 設定開機自動啟動。
事前準備
PHP-FPM 現況設定
目前這台主機的 PHP-FPM pool 設定如下(僅供對照,不需要修改):
pm = dynamic
pm.max_children = 10
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 5
也就是最多可以同時有 10 個 PHP worker 處理 request。
確認服務狀態:
systemctl status php8.1-fpm --no-pager
開啟 PHP-FPM access log
編輯 pool 設定檔:
sudo nano /etc/php/8.1/fpm/pool.d/www.conf
加入:
access.log = /var/log/php8.1-fpm-access.log
access.format = "%t pid=%p method=%m request_uri=%{REQUEST_URI}e script=%r status=%s duration=%{milli}dms memory=%{kilo}MKB"
這樣每一個 PHP request 都會記錄:
| 欄位 | 內容 |
|---|---|
%t |
收到 request 的時間 |
pid |
處理這個 request 的 worker PID |
method |
HTTP Method |
request_uri |
真實的 request URI |
script |
PHP 入口腳本 |
status |
HTTP 狀態碼 |
duration |
執行時間(毫秒) |
memory |
記憶體使用量(KB) |
Laravel 的所有請求都會經過 index.php,所以只看 script 會永遠是 /index.php。真正的網址要靠 %{REQUEST_URI}e 取得,這也是這個設定的重點。
修改後先檢查設定:
sudo php-fpm8.1 -t
成功會看到:
configuration file /etc/php/8.1/fpm/php-fpm.conf test is successful
再重新載入:
sudo systemctl reload php8.1-fpm
查看紀錄:
sudo tail -f /var/log/php8.1-fpm-access.log
實際驗證可以看到類似:
02/Oct/2026:02:49:39 +0000 pid=4034905 method=GET request_uri=/backend/dashboard-v2 script=/index.php status=200 duration=703.398ms memory=14336KB
注意一:
access.log是每個 pool 各自生效的設定。 如果主機上有多個 pool(例如每個網站一個 pool),每個 pool 都要加;而且 V3.4 只會讀取上面設定的這一份 log。
注意二:
REQUEST_URI含查詢字串。 網址後面的參數(搜尋關鍵字、token 等)也會被寫進這份 log,所以下一節把權限收緊,並設定輪替避免長期保存。
讓 Watcher 能讀取 access log
PHP-FPM 建立的 access log,預設權限是 -rw------- root root,只有 root 讀得到。Watcher 以 ploi 身分執行,讀不到就會顯示 PHP access log unavailable,所以要讓 ploi 能唯讀這份 log:
sudo chown root:ploi /var/log/php8.1-fpm-access.log
sudo chmod 640 /var/log/php8.1-fpm-access.log
確認:
ls -l /var/log/php8.1-fpm-access.log
預期:
-rw-r----- 1 root ploi ...
用 ploi 身分測試,不加 sudo 能看到內容就代表 Watcher 讀得到:
tail -1 /var/log/php8.1-fpm-access.log
安裝 pidstat
CURRENT RELATED PROCESSES 區塊用 pidstat 取樣,它在 sysstat 套件裡,多數系統預設沒有安裝:
sudo apt install sysstat -y
確認:
pidstat -V
V3.4 沒有安裝 pidstat 時不會退回其他做法,這個區塊會直接是空的。其餘功能(尤其是 HOT PHP REQUESTS)不依賴它。
如果已經在跑舊版
請先停止舊版,再覆蓋腳本:
pkill -f '/home/ploi/cpu-watch.sh'
確認已停止:
pgrep -af cpu-watch.sh
沒有輸出就代表已停止。
Bash 是邊讀邊執行腳本的,在執行中直接覆蓋檔案,可能讓正在跑的舊程序讀到錯亂的內容,所以務必先停再蓋。
V2 留下的快照資料夾已經用不到,可以順手刪除:
rm -rf ~/perf-logs/cpu-snapshots
建立監控腳本(V3.4)
直接整段貼上:
cat > ~/cpu-watch.sh <<'EOF'
#!/usr/bin/env bash
LOG_DIR="$HOME/perf-logs"
LOG="$LOG_DIR/cpu-over-15.log"
PHP_ACCESS_LOG="/var/log/php8.1-fpm-access.log"
CPU_THRESHOLD=15
PHP_CPU_THRESHOLD=10
STEAL_THRESHOLD=20
SAMPLE_SECONDS=2
ALIVE_SECONDS=600
DETAIL_COOLDOWN=10
CLK_TCK=$(getconf CLK_TCK)
mkdir -p "$LOG_DIR"
echo "===== CPU WATCH V3.4 START $(date) =====" >> "$LOG"
declare -A PHP_PREV_TICKS
declare -A PHP_CURRENT_CPU
read_cpu() {
read -r _ user nice system idle iowait irq softirq steal guest guest_nice < /proc/stat
CPU_USER=$((user + nice))
CPU_SYSTEM=$system
CPU_IDLE=$idle
CPU_IOWAIT=$iowait
CPU_IRQ=$irq
CPU_SOFTIRQ=$softirq
CPU_STEAL=$steal
CPU_TOTAL=$((user + nice + system + idle + iowait + irq + softirq + steal))
}
get_php_workers() {
local master_pid
master_pid=$(pgrep -xo php-fpm8.1 2>/dev/null)
if [ -n "$master_pid" ]; then
pgrep -P "$master_pid" php-fpm8.1 2>/dev/null
fi
}
get_process_ticks() {
local pid="$1"
if [ -r "/proc/$pid/stat" ]; then
awk '{ print $14 + $15 }' "/proc/$pid/stat"
fi
}
init_php_ticks() {
local pid ticks
while read -r pid; do
[ -z "$pid" ] && continue
ticks=$(get_process_ticks "$pid")
if [ -n "$ticks" ]; then
PHP_PREV_TICKS["$pid"]="$ticks"
fi
done < <(get_php_workers)
}
sample_php_cpu() {
local elapsed="$1"
local pid ticks prev diff cpu
PHP_CURRENT_CPU=()
while read -r pid; do
[ -z "$pid" ] && continue
ticks=$(get_process_ticks "$pid")
[ -z "$ticks" ] && continue
prev="${PHP_PREV_TICKS[$pid]:-}"
if [ -n "$prev" ]; then
diff=$((ticks - prev))
cpu=$(awk \
-v ticks="$diff" \
-v hz="$CLK_TCK" \
-v seconds="$elapsed" \
'BEGIN {
if (seconds > 0)
printf "%.2f", (ticks / hz) * 100 / seconds
else
printf "0.00"
}')
PHP_CURRENT_CPU["$pid"]="$cpu"
fi
PHP_PREV_TICKS["$pid"]="$ticks"
done < <(get_php_workers)
for pid in "${!PHP_PREV_TICKS[@]}"; do
if [ ! -d "/proc/$pid" ]; then
unset 'PHP_PREV_TICKS[$pid]'
fi
done
}
find_php_request() {
local pid="$1"
if [ ! -r "$PHP_ACCESS_LOG" ]; then
echo "PHP access log unavailable"
return
fi
local match
match=$(
tail -n 1000 "$PHP_ACCESS_LOG" 2>/dev/null \
| grep -a "pid=${pid} " \
| tail -1
)
if [ -n "$match" ]; then
echo "$match"
else
echo "request_uri: no recent completed request found"
fi
}
read_cpu
init_php_ticks
prev_total=$CPU_TOTAL
prev_user=$CPU_USER
prev_system=$CPU_SYSTEM
prev_idle=$CPU_IDLE
prev_iowait=$CPU_IOWAIT
prev_irq=$CPU_IRQ
prev_softirq=$CPU_SOFTIRQ
prev_steal=$CPU_STEAL
last_alive=$(date +%s)
last_detail=0
last_sample=$(date +%s.%N)
while true; do
sleep "$SAMPLE_SECONDS"
current_sample=$(date +%s.%N)
elapsed=$(awk \
-v now="$current_sample" \
-v prev="$last_sample" \
'BEGIN { printf "%.3f", now-prev }')
last_sample="$current_sample"
read_cpu
sample_php_cpu "$elapsed"
diff_total=$((CPU_TOTAL - prev_total))
diff_user=$((CPU_USER - prev_user))
diff_system=$((CPU_SYSTEM - prev_system))
diff_idle=$((CPU_IDLE - prev_idle))
diff_iowait=$((CPU_IOWAIT - prev_iowait))
diff_irq=$((CPU_IRQ - prev_irq))
diff_softirq=$((CPU_SOFTIRQ - prev_softirq))
diff_steal=$((CPU_STEAL - prev_steal))
if [ "$diff_total" -gt 0 ]; then
cpu_used=$(awk -v t="$diff_total" \
-v idle="$diff_idle" \
-v wait="$diff_iowait" \
'BEGIN { printf "%.1f", (t-idle-wait)*100/t }')
cpu_user=$(awk -v t="$diff_total" -v v="$diff_user" \
'BEGIN { printf "%.1f", v*100/t }')
cpu_system=$(awk -v t="$diff_total" -v v="$diff_system" \
'BEGIN { printf "%.1f", v*100/t }')
cpu_iowait=$(awk -v t="$diff_total" -v v="$diff_iowait" \
'BEGIN { printf "%.1f", v*100/t }')
cpu_softirq=$(awk -v t="$diff_total" -v v="$diff_softirq" \
'BEGIN { printf "%.1f", v*100/t }')
cpu_steal=$(awk -v t="$diff_total" -v v="$diff_steal" \
'BEGIN { printf "%.1f", v*100/t }')
now=$(date +%s)
if [ $((now - last_alive)) -ge "$ALIVE_SECONDS" ]; then
echo "===== CPU WATCH V3.4 ALIVE $(date) CPU=${cpu_used}% STEAL=${cpu_steal}% =====" >> "$LOG"
last_alive=$now
fi
cpu_trigger=0
steal_trigger=0
if awk -v c="$cpu_used" -v t="$CPU_THRESHOLD" \
'BEGIN { exit !(c >= t) }'; then
cpu_trigger=1
fi
if awk -v c="$cpu_steal" -v t="$STEAL_THRESHOLD" \
'BEGIN { exit !(c >= t) }'; then
steal_trigger=1
fi
if { [ "$cpu_trigger" -eq 1 ] || [ "$steal_trigger" -eq 1 ]; } \
&& [ $((now - last_detail)) -ge "$DETAIL_COOLDOWN" ]; then
{
echo "=================================================="
echo "TIME: $(date)"
echo "CPU: ${cpu_used}%"
echo
echo "===== CPU BREAKDOWN ====="
echo "user: ${cpu_user}%"
echo "system: ${cpu_system}%"
echo "iowait: ${cpu_iowait}%"
echo "softirq: ${cpu_softirq}%"
echo "steal: ${cpu_steal}%"
echo
echo "===== HOT PHP REQUESTS ====="
hot_count=0
for pid in "${!PHP_CURRENT_CPU[@]}"; do
php_cpu="${PHP_CURRENT_CPU[$pid]}"
if awk \
-v cpu="$php_cpu" \
-v threshold="$PHP_CPU_THRESHOLD" \
'BEGIN { exit !(cpu >= threshold) }'; then
hot_count=$((hot_count + 1))
echo "PID=${pid} CPU=${php_cpu}%"
find_php_request "$pid"
echo
fi
done
if [ "$hot_count" -eq 0 ]; then
echo "No PHP-FPM worker >= ${PHP_CPU_THRESHOLD}% in trigger interval"
fi
echo
echo "===== CURRENT RELATED PROCESSES ====="
if command -v pidstat >/dev/null 2>&1; then
pidstat -u -p ALL 1 1 2>/dev/null \
| awk '
$0 ~ /(mariadbd|nginx|snapd|sshd)$/ &&
$1 != "Average:" &&
$(NF-2) + 0 >= 1 {
printf "%-15s PID=%-8s CPU=%s%%\n", $NF, $4, $(NF-2)
}
'
fi
echo
} >> "$LOG" 2>&1
last_detail=$now
fi
fi
prev_total=$CPU_TOTAL
prev_user=$CPU_USER
prev_system=$CPU_SYSTEM
prev_idle=$CPU_IDLE
prev_iowait=$CPU_IOWAIT
prev_irq=$CPU_IRQ
prev_softirq=$CPU_SOFTIRQ
prev_steal=$CPU_STEAL
done
EOF
可調整的參數
腳本開頭的變數可以依主機調整:
| 變數 | 預設 | 說明 |
|---|---|---|
CPU_THRESHOLD |
15 | 整體 CPU 達此百分比就觸發 |
PHP_CPU_THRESHOLD |
10 | 單一 PHP-FPM worker 達此百分比,才會被列進 HOT PHP REQUESTS |
STEAL_THRESHOLD |
20 | steal 達此百分比就觸發(V3.1 是 10) |
SAMPLE_SECONDS |
2 | 取樣間隔(秒) |
ALIVE_SECONDS |
600 | 存活紀錄的間隔(秒) |
DETAIL_COOLDOWN |
10 | 兩次詳細紀錄之間至少相隔的秒數 |
CPU_THRESHOLD 與 STEAL_THRESHOLD 要一起看:整體 CPU 的數值已經包含 steal(見〈判讀〉),所以只要 steal ≥ 20%,CPU 就必然 ≥ 20%,早就達到 15% 的門檻。目前這組設定下,STEAL_THRESHOLD 不會單獨觸發紀錄;只有把它調得比 CPU_THRESHOLD 低時才有作用。
另外,腳本裡有幾處是寫死的,換主機時要一併檢查:
php-fpm8.1(pgrep找 master 與 worker 用)與PHP_ACCESS_LOG的路徑,必須跟 PHP 版本、access log 設定一致。CURRENT RELATED PROCESSES只會列出名稱為mariadbd、nginx、snapd、sshd且 CPU ≥ 1% 的程序。其中snapd的對應案例見《Snap 自動更新時段調整說明》。
語法檢查與啟動
先檢查語法,沒有輸出代表正常:
bash -n ~/cpu-watch.sh
加上執行權限:
chmod +x ~/cpu-watch.sh
確認舊的 Watcher 已停止(見〈如果已經在跑舊版〉),然後背景啟動:
nohup ~/cpu-watch.sh >/dev/null 2>&1 &
即使 SSH 連線關閉,Watcher 仍會繼續執行。
確認是否正在執行,應該只有一個:
pgrep -af cpu-watch.sh
xxxxxxx bash /home/ploi/cpu-watch.sh
再確認 Watcher 找得到 PHP-FPM 的 master 程序,應該會輸出一個 PID:
pgrep -xo php-fpm8.1
如果沒有輸出,代表程序名稱對不上(例如 PHP 版本不是 8.1)。這時 Watcher 不會報錯,只會永遠找不到 PHP worker,HOT PHP REQUESTS 會一直是空的,所以務必先確認這一步。可以用 pgrep -a php-fpm 查實際的名稱,再回頭修改腳本。
查看啟動紀錄:
tail -20 ~/perf-logs/cpu-over-15.log
應該會看到:
===== CPU WATCH V3.4 START ...
之後每 10 分鐘會寫一筆存活紀錄,同時帶出當下的 CPU 與 steal:
===== CPU WATCH V3.4 ALIVE ... CPU=1.4% STEAL=0.0% =====
驗證 Watcher 有在運作
在安靜的主機上,Watcher 平常只會留下兩種紀錄:啟動時的 START,以及每 10 分鐘一次的 ALIVE。真正的詳細紀錄只在 CPU 超標時才會出現,所以不要乾等,主動製造一次,確認整條路徑都通。
先開一個視窗追蹤紀錄:
tail -f ~/perf-logs/cpu-over-15.log
步驟一:確認系統層級的觸發
另開一個視窗,讓每個 CPU 核心各跑一個 15 秒的滿載程序:
for i in $(seq "$(nproc)"); do timeout 15 yes > /dev/null & done; wait
幾秒內,追蹤視窗應該出現一筆詳細紀錄:CPU 很高、CPU BREAKDOWN 的 user 偏高,而 HOT PHP REQUESTS 在 PHP 沒有在忙的時候會顯示:
No PHP-FPM worker >= 10% in trigger interval
看到這筆,就代表取樣、門檻判斷、寫入紀錄都正常。因為有 10 秒的冷卻時間,15 秒內大約會有 1~2 筆。
步驟二:確認 PHP worker 對照到網址
這一步驗證「PHP worker → PID → request_uri」整條路徑。在網站的 public 目錄建立一個暫時的測試檔:
nano <網站目錄>/public/cpu-test.php
內容:
<?php
$end = microtime(true) + 1.0;
while (microtime(true) < $end) {
}
echo "ok\n";
每個請求會讓 PHP worker 滿載約 1 秒。接著對它發出幾組並行請求(把網址換成你的):
for j in 1 2 3; do (for i in $(seq 8); do curl -s -o /dev/null "https://你的網址/cpu-test.php?n=$j-$i"; done) & done; wait
追蹤視窗應該出現類似這樣的內容:
===== HOT PHP REQUESTS =====
PID=<某個 worker 的 PID> CPU=<偏高>%
<時間> +0000 pid=<同一個 PID> method=GET request_uri=/cpu-test.php?n=... status=200 duration=約 1000ms memory=...
看到 request_uri=/cpu-test.php 對上同一個 PID,就代表整條路徑都通了。
測完務必刪除測試檔,它是公開的,任何人都能呼叫它來消耗你的 CPU:
rm <網站目錄>/public/cpu-test.php
設計上有兩點要注意:
- 請求要短、要多次。 access log 是在請求結束時才寫入,一個還在執行的長請求,在 Watcher 取樣時還沒有紀錄,會讓你誤以為對照失敗(原因見〈怎麼讀
HOT PHP REQUESTS〉)。所以用「每個約 1 秒、連續送很多個」的方式。 - 並行數量要讓整體 CPU 達到 15%。 每個滿載的 worker 約佔整體 CPU 的「100 ÷ 核心數」%(核心數用
nproc查,原因見〈判讀〉)。上面 3 組並行約等於 300 ÷ 核心數 %,核心數超過 20 就不夠,這時可以增加並行數,或暫時調低CPU_THRESHOLD來測試。
如果網站設定只允許執行 index.php,這個測試檔會回 404,這時改成多次重新整理一個比較慢的頁面。
沒有看到預期的紀錄時
| 現象 | 可能原因 | 檢查方式 |
|---|---|---|
連 START 都沒有 |
Watcher 沒有在執行,或寫不進 ~/perf-logs |
pgrep -af cpu-watch.sh、ls -ld ~/perf-logs |
| 步驟一沒有任何詳細紀錄 | 滿載程序沒有真的跑起來,或 Watcher 已經停了 | 另開視窗看 top 的 CPU 是否真的升高、pgrep -af cpu-watch.sh |
HOT PHP REQUESTS 一直是 No PHP-FPM worker |
Watcher 找不到 PHP-FPM 程序 | pgrep -xo php-fpm8.1 要有輸出 |
PHP access log unavailable |
ploi 讀不到 access log |
ls -l /var/log/php8.1-fpm-access.log,並用 ploi 身分執行 tail -1 該檔案 |
no recent completed request found |
access log 裡沒有該 PID 的紀錄 | sudo tail -f /var/log/php8.1-fpm-access.log 看有沒有新的行 |
| access log 完全沒有新的行 | access.log 設定沒生效,或這個網站不在你設定的 pool |
grep -n 'access.log' /etc/php/8.1/fpm/pool.d/*.conf、sudo php-fpm8.1 -t、再 reload |
觸發時的紀錄
Watcher 的紀錄檔在:
/home/ploi/perf-logs/cpu-over-15.log
當 CPU 超過 15%,例如某個 ERP 頁面造成 PHP 高負載,紀錄會是這樣(範例):
==================================================
TIME: Fri Oct 2 03:01:22 UTC 2026
CPU: 31.9%
===== CPU BREAKDOWN =====
user: 28.0%
system: 2.9%
iowait: 0.0%
softirq: 0.5%
steal: 0.5%
===== HOT PHP REQUESTS =====
PID=4034905 CPU=67.50%
02/Oct/2026:03:01:21 +0000 pid=4034905 method=GET request_uri=/backend/dashboard-v2 script=/index.php status=200 duration=703.398ms memory=14336KB
===== CURRENT RELATED PROCESSES =====
mariadbd PID=43036 CPU=15.00%
nginx PID=122495 CPU=13.00%
各區塊的意思:
- 時間與當下的 CPU 使用率。
CPU BREAKDOWN:CPU 使用率的細項拆解。HOT PHP REQUESTS:取樣區間內 CPU ≥ 10% 的 PHP-FPM worker,以及它最近處理過的 request。CURRENT RELATED PROCESSES:同一時間 MariaDB、Nginx 等相關程序的 CPU 使用率。
這樣就把「CPU 高 → 哪個 PHP worker → 哪個網址 → 執行多久、用多少記憶體 → 資料庫與 Nginx 是否也在忙」串在同一筆紀錄裡。
SSH 暴力登入造成尖峰的案例,見《SSH 暴力登入與 Fail2ban 防護》。
實際案例:/backend/sales/order
Watcher 曾經抓到這樣的情況:
| 項目 | 內容 |
|---|---|
| 整體 CPU | 36.8% |
| PHP-FPM worker | PID=4034905,CPU=51.96% |
| MariaDB | CPU=13% |
同一時間,PHP-FPM access log 裡該 PID 的 request 是 GET /backend/sales/order。同一個功能在 access log 裡還出現過下面這些紀錄:
| 來源 | request_uri | duration | memory |
|---|---|---|---|
| Watcher 抓到的那次(03:04:40) | /backend/sales/order |
1322.854 ms | 38912 KB(約 38 MB) |
| access log 另見 | /backend/sales/order?ajax=1... |
2574.913 ms | 105688 KB(約 103 MB) |
| access log 另見 | /backend/sales/order?... |
2873.612 ms | 103640 KB(約 101 MB) |
目前觀察到的是:/backend/sales/order 這類 request 會伴隨較明顯的 PHP CPU 與 MariaDB 負載,執行時間約 1.3~2.9 秒,帶查詢參數的版本記憶體用量也高出不少。
這只是「同時出現」的相關性,還不是根因。Watcher 的工作到「找出是哪個網址」為止,所以接下來不需要再強化 Watcher,而是針對這個反覆出現的高負載網址,往 Laravel 層查:
- Controller 的處理流程
- SQL query(有沒有慢查詢、重複查詢)
- DataTable 的查詢
- 關聯資料的載入成本
判讀
「CPU」數值包含 steal
腳本算出的整體 CPU 是「100% 減去 idle 與 iowait」,所以 steal 也算在裡面。
steal 是虛擬機(VM)才有的數值,代表這台 VM 想用 CPU,卻被底層的宿主機排不到、被別的 VM 搶走的時間。只要 steal 偏高,就算你的程式什麼都沒做,「CPU」也會被墊高、觸發紀錄。
看懂 CPU 細項
| 偏高的欄位 | 通常代表 |
|---|---|
user |
應用程式在運算(PHP、MySQL 查詢等) |
system |
核心層的工作(網路、系統呼叫、檔案操作等) |
iowait |
在等磁碟 I/O |
softirq |
網路封包處理 |
steal |
宿主機資源不足,瓶頸在 VM 之外 |
如果 steal 很高而 user 很低,問題多半出在主機商的宿主機,而不是你的程式,這時往下追查程序沒有意義,應該改去向主機商反映,或評估更換主機。
想手動確認 steal,可以執行:
vmstat 1 5
看最右邊附近的 st 欄。
整體 CPU 與 PHP worker CPU 的基準不同
紀錄裡有兩種 CPU 百分比,算法不一樣:
| 數值 | 基準 |
|---|---|
整體 CPU(CPU:) |
所有核心的平均,100% 代表全部核心都滿載 |
PHP worker CPU(PID=... CPU=...) |
以單一核心為 100%,一個滿載的 worker 約 100% |
所以在 N 核的主機上,一個滿載的 PHP worker 只會讓整體 CPU 升高約「100 ÷ N」%。例如 4 核主機,一個 worker 跑到 100%,整體 CPU 只升高約 25%。核心數可以用 nproc 查。
這也代表兩個門檻的角色不同:是否觸發紀錄,看的是整體 CPU(CPU_THRESHOLD);PHP_CPU_THRESHOLD 只決定「觸發之後,要把哪些 worker 列出來」。核心數很多的主機,單一 worker 吃滿 CPU 也可能不會讓整體 CPU 達到 15%,這時 Watcher 不會留下紀錄。
怎麼讀 HOT PHP REQUESTS
PHP-FPM access log 是在 request 結束時才寫入一行,%t 記錄的則是收到 request 的時間。所以 Watcher 找到的,是這個 worker「最近一筆已經完成」的 request,這代表:
- 最常見的情況:造成尖峰的 request 很短(例如這個例子的 703 ms),在 Watcher 查詢時已經結束,這時找到的就是兇手。
- 要小心的情況:如果某個 request 還在執行中(例如一個要跑 30 秒的報表),它還沒寫進 log,這時看到的會是同一個 worker「上一個」request,不是正在吃 CPU 的那個。
因此請把時間與 duration 一起看:request 的收到時間,加上 duration,應該要落在尖峰發生的時間附近;如果對不上,就要懷疑那是上一筆。
出現 request_uri: no recent completed request found 時,常見原因有:
- 這個 worker 最近的 request 還沒完成(尖峰期間常見)。
- 流量大,Watcher 只查看 access log 最後 1000 行,該 worker 的最後一筆已經被擠出範圍。
- 該 PID 不是由這份 access log 對應的 pool 處理(見前面關於多個 pool 的注意事項)。
出現 PHP access log unavailable 代表 ploi 讀不到這份 log,回頭檢查權限。
出現 No PHP-FPM worker >= 10% in trigger interval 則代表這次尖峰不是 PHP 造成的,往 CPU BREAKDOWN 與 CURRENT RELATED PROCESSES 找。
紀錄裡出現 binary file matches
如果 Watcher 的紀錄裡出現:
grep: (standard input): binary file matches
同時 request_uri 又顯示找不到,代表 access log 裡有 grep 判定為非文字的內容(例如請求網址帶有無效編碼的位元組,掃描程式的探測請求常會這樣),grep 就只回報「有符合」,而不輸出那一行。新版 grep 會把這則訊息寫到 stderr,所以 request 查不到;較舊的 grep 則會把 Binary file (standard input) matches 當成一般輸出,結果這句話會被當成 request 印在紀錄裡。
腳本裡的 grep -a 就是為了這件事:強制把 access log 當文字處理,才找得到 request。如果你是從舊版腳本升級,請確認 find_php_request 裡用的是 grep -a。
其他限制
HOT PHP REQUESTS的順序是依 PID 任意排列,不是依 CPU 由高到低。- PHP-FPM 是
dynamic模式,worker 會視負載新增與消失。兩次取樣之間才出現、又消失的 worker,不會被看到;新出現的 worker 也要到下一次取樣才有數值。 CURRENT RELATED PROCESSES的 PID 取自pidstat輸出的第 4 欄,這只在時間欄位是HH:MM:SS AM/PM(佔兩欄)的格式下才正確;如果主機的語系讓時間只佔一欄,PID 欄位會取成%usr的數值。看到 PID 明顯不對時,先檢查這點。
Logrotate
Watcher 的紀錄與 PHP access log 都會持續成長,兩個都要設定輪替。以下這組設定已在實際主機上測試成功。
Watcher 紀錄
建立設定檔:
sudo nano /etc/logrotate.d/cpu-watch
內容:
/home/ploi/perf-logs/cpu-over-15.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
copytruncate
maxsize 20M
su ploi ploi
}
PHP-FPM access log
建立設定檔:
sudo nano /etc/logrotate.d/php8.1-fpm-access
內容:
/var/log/php8.1-fpm-access.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
copytruncate
maxsize 50M
su root root
}
各項設定的意思
| 設定 | 作用 |
|---|---|
daily / rotate 14 |
每天輪替,保留 14 份 |
compress / delaycompress |
舊檔壓縮,但最新的一份先不壓縮 |
missingok / notifempty |
檔案不存在或是空的就跳過 |
copytruncate |
複製後把原檔清空,不需要讓 Watcher 或 PHP-FPM 重新開檔,不會中斷 |
su ploi ploi |
以 ploi 身分輪替,因為 Watcher 的紀錄放在 ploi 的家目錄 |
maxsize |
超過指定大小時,在下一次 logrotate 執行時輪替 |
maxsize不會讓檔案在一天之內提前輪替。 logrotate 預設由系統排程每天只執行一次,maxsize只在它執行的那一刻才會檢查,所以在daily的設定下,它實際上沒有額外效果。如果想真的限制單日內的大小,需要讓 logrotate 更頻繁地執行(例如另外排一個每小時執行的工作),或是調高CPU_THRESHOLD、拉長DETAIL_COOLDOWN來減少 Watcher 的寫入量。
可以用下面的指令確認 logrotate 的執行排程:
systemctl list-timers | grep logrotate
測試
先用 dry-run 檢查設定(不會真的輪替):
sudo logrotate -d /etc/logrotate.d/cpu-watch
sudo logrotate -d /etc/logrotate.d/php8.1-fpm-access
需要時再強制執行一次:
sudo logrotate -f /etc/logrotate.d/cpu-watch
sudo logrotate -f /etc/logrotate.d/php8.1-fpm-access
查看輪替後的檔案:
sudo ls -lh /var/log/php8.1-fpm-access*
即時查看
平常不用盯著畫面,讓 Watcher 在背景執行即可。
如果正在測試,或客戶反映「現在很卡」,可以用下面的指令即時追蹤:
tail -f ~/perf-logs/cpu-over-15.log
有新的異常紀錄時,會直接顯示在畫面上。
要離開時按 Ctrl + C。這只會停止 tail -f,不會停止背景的 Watcher。
事後查看
看最後 100 行:
tail -n 100 ~/perf-logs/cpu-over-15.log
看最後 200 行:
tail -n 200 ~/perf-logs/cpu-over-15.log
只想看有哪些 PHP request 被抓到:
grep -A1 '^PID=' ~/perf-logs/cpu-over-15.log
確認 Watcher 還活著,最後幾筆 ALIVE 的時間應該接近現在,間隔約 10 分鐘:
grep ALIVE ~/perf-logs/cpu-over-15.log | tail -3
如果時間停在很久以前,代表 Watcher 已經停了(例如主機重新開機後沒有重新啟動)。紀錄剛輪替完的幾分鐘內,新檔還沒有 ALIVE 是正常的。
停止 Watcher
pkill -f '/home/ploi/cpu-watch.sh'
確認是否已停止:
pgrep -af cpu-watch.sh
沒有輸出就代表已停止。
重新啟動
nohup ~/cpu-watch.sh >/dev/null 2>&1 &
再確認一次:
pgrep -af cpu-watch.sh
開機自動啟動
用 nohup 啟動的 Watcher,在主機重新開機後不會自動回來,監控會無聲地停掉。建議用 ploi 帳號的 crontab 讓它開機時自動啟動:
crontab -e
加入這一行:
@reboot /home/ploi/cpu-watch.sh >/dev/null 2>&1
由 cron 在開機時啟動,不需要 nohup 與 &。
重新開機後,用 pgrep -af cpu-watch.sh 確認 Watcher 在執行,並且在紀錄檔裡看到一行新的 START。
設定之後,不要在同一次開機又手動啟動第二份,否則會同時有兩個 Watcher,同樣的紀錄會被寫兩次。
用途
這個 Watcher 不只是用來查 SSH 攻擊,它的目的是:
當 CPU 異常升高時,自動保留當下是哪個 PHP request、哪個程序在使用 CPU,以及資源是被自己用掉、還是被宿主機搶走。
可用來追查:
- 哪個 Laravel 網址造成 PHP 高負載
- MySQL / MariaDB 高負載
- Nginx、SSH 等相關程序
- 宿主機資源不足(steal)
建議的使用方式:
- 平常讓 Watcher 在背景執行。
- 客戶反映卡頓時,用
tail -f ~/perf-logs/cpu-over-15.log即時查看。 - 先看
CPU BREAKDOWN:如果steal高,瓶頸在主機商那一層。 - 如果
steal不高,看HOT PHP REQUESTS找出是哪個網址,並比對duration與時間。 - 沒有 PHP worker 偏高時,再看
CURRENT RELATED PROCESSES的 MariaDB、Nginx 往下追查。 - 同一個網址反覆出現時,就不再是 Watcher 的問題,改往 Laravel 的 controller、SQL 與資料載入查(見〈實際案例〉)。