某天早上一套 Graylog 叢集突然變得很慢,查下去發現其中一台 OpenSearch 節點 CPU 一直 100%,身上掛著 900 多個跑不完的搜尋 task。用 cancel API 去取消,API 回傳成功,task 卻一個都沒少。
這種「取消不掉、也不會自己結束」的 task,我習慣叫它 zombie task。這篇把當天的處理過程,以及之後整理的診斷、處理與預防方式記錄下來。環境是 Graylog 7.0.6 + OpenSearch 2.14.0,兩個節點。
Zombie task 的症狀
OpenSearch 的 search、msearch、search[phase/query] 這些 task,在異常狀況下可能一直執行、無法正常結束,持續佔用 CPU 與 heap。常見症狀:
_cat/tasks裡的running_time超過 10 分鐘,甚至好幾個小時- 單一節點 CPU 長時間滿載
- JVM 的 parent circuit breaker 頻繁觸發
- cancel API 回 200,但 task 還在
本次事件的成因
事後回頭看,是好幾個因素疊在一起:
- 大量規則同時執行
115 條 Graylog Event Definition 都是每 5 分鐘執行一次,而且同時起跑,對 OpenSearch 造成瞬間的峰值壓力 - heap 不足
其中一台節點 heap 只有 4 GB,聚合查詢觸發 circuit breaker,查詢失敗但 task 沒有乾淨結束 - shard lock
前一台節點剛重新啟動,叢集在大量 recovery,部分 replica shard 處於關閉中的狀態並持有 shard lock,打到這些 shard 的查詢就卡住 - 跨節點的孤兒 task
一個節點的 graylog-server 停掉後,parent task 消失了,但另一個節點上的 child task 沒收到取消訊號 - 取消只是「做記號」
OpenSearch 的取消是合作式的,task 要自己檢查記號才會結束,某些階段檢查不到,就永遠取消不掉(後面詳細說明)
診斷方式
以下指令的 $ES 代表 OpenSearch 位址。有開安全外掛的話要改用 https,並加上 -u admin:your-password(或用 .netrc,不要把密碼寫死在腳本裡)。
ES=https://opensearch.example.com:9200
叢集狀態
curl -s "$ES/_cluster/health?pretty"
red 代表有 primary shard 不見了、yellow 是 replica 缺少、green 是正常。
列出 task
# 全部 task
curl -s "$ES/_cat/tasks?v&detailed=true"
# 依執行時間排序,看最久的幾筆
curl -s "$ES/_cat/tasks?v&detailed=true&s=running_time:desc" | head -20
# 只看搜尋類
curl -s "$ES/_tasks?actions=*search*&detailed=true&pretty"
# 某個 parent 底下的 child task
curl -s "$ES/_tasks?parent_task_id=<node_id>:<task_id>&detailed=true&pretty"
要算出「跑超過 N 分鐘的 task 有幾個」,用 _tasks 的 JSON 搭配 jq 比用 awk 比對 _cat 的文字可靠,running_time_in_nanos 是精確的數字:
# 跑超過 10 分鐘的搜尋 task 數量
curl -s "$ES/_tasks?actions=*search*&detailed=true" | \
jq '[.nodes[].tasks[] | select(.running_time_in_nanos > 600e9)] | length'
# 列出 task ID、執行秒數與查詢內容(截前 120 字)
curl -s "$ES/_tasks?actions=*search*&detailed=true" | \
jq -r '.nodes[] as $n | .nodes[].tasks[] | select(.running_time_in_nanos > 600e9)
| "\(.node):\(.id)\t\(.running_time_in_nanos/1e9|floor)s\t\(.description[0:120])"'
找出吃 CPU 的執行緒
curl -s "$ES/_nodes/opensearch-node2/hot_threads?threads=5"
hot threads 會列出最忙的執行緒在做什麼,看到一堆 search 執行緒卡在聚合或 Lucene 的呼叫裡,大概就是這個問題。
其他
# 叢集等待中的工作
curl -s "$ES/_cluster/pending_tasks?pretty"
# 未分配的 shard 原因(常常是 zombie 的源頭)
curl -s "$ES/_cluster/allocation/explain?pretty"
# 各節點的 heap 使用率
curl -s "$ES/_cat/nodes?v&h=name,heap.percent,heap.max,cpu,load_1m"
終止 zombie task
取消 task
# 取消單一 task
curl -X POST "$ES/_tasks/<node_id>:<task_id>/_cancel"
# 取消某個 parent 底下全部的 child
curl -X POST "$ES/_tasks/_cancel?parent_task_id=<node_id>:<task_id>"
# 取消某個節點上所有搜尋 task
curl -X POST "$ES/_tasks/_cancel?nodes=opensearch-node2&actions=*search*"
批次取消超過 10 分鐘的搜尋 task:
curl -s "$ES/_tasks?actions=*search*&detailed=true" | \
jq -r '.nodes[].tasks[] | select(.running_time_in_nanos > 600e9) | "\(.node):\(.id)"' | \
while read tid; do
curl -s -X POST "$ES/_tasks/$tid/_cancel" -o /dev/null
echo "canceled $tid"
done
注意:取消之前務必先停掉 graylog-server(叢集的每一台都要停),不然這邊取消、那邊又一直送新查詢進來,永遠清不完。我當天就是先停了一台,才發現另一台也在跑。
最後手段:重新啟動節點
如果取消之後等了 10 分鐘還是不退,直接重新啟動那個 OpenSearch 節點是最快、最乾淨的做法:
systemctl restart opensearch
重新啟動的代價:
- 該節點上的 shard 離線,叢集短暫變成
yellow或red,之後自動重新分配 - 節點上所有執行中的 task 強制終止
- 兩個節點的叢集,一次只重新啟動一台,等叢集回到
green再處理下一台
重新啟動前先確認:
- graylog-server 已經停掉,不會有新查詢進來
- primary shard 沒有全部集中在要重新啟動的那台
- 盡量在維護時段進行
重新啟動之後如果有 shard 一直分配不上去,可以參考〈OpenSearch Unassigned Shard 重試分配〉這篇。
取消不一定有用的原因
OpenSearch 的取消機制是合作式(cooperative),不是強制終止:
- 呼叫 cancel API,只是在 task 上設一個「已取消」的記號
- task 在執行過程中,到特定的檢查點才會去看這個記號
- 看到了才丟出
TaskCancelledException結束
搜尋 task 在每個 shard 查詢開始前、收集結果、聚合的各個階段之間都會檢查,但如果是一個很重的聚合卡在某個 shard 上,或卡在 Lucene 讀取 segment 的迴圈裡,可能很久都走不到下一個檢查點,取消就等於沒發。這種情況唯一乾淨的方式就是終止 JVM,也就是重新啟動節點。
所以要記得:cancel 只是禮貌地請它停下來,不保證真的會停。
預防
設定搜尋逾時與自動取消
這幾個都是動態設定,用 _cluster/settings 修改就會生效,不用重新啟動:
curl -X PUT "$ES/_cluster/settings" -H 'Content-Type: application/json' -d '{
"persistent": {
"search.default_search_timeout": "60s",
"search.cancel_after_time_interval": "5m"
}
}'
| 設定 | 預設 | 作用 |
|---|---|---|
search.default_search_timeout | 不限制 | shard 層級的查詢逾時,時間到傳回部分結果 |
search.cancel_after_time_interval | 不限制 | 協調節點層級,時間到直接取消整個搜尋與所有相關 task |
search.default_allow_partial_results | true | 逾時或單一 shard 失敗時,仍傳回其他 shard 的結果 |
search.max_open_scroll_context | 500 | 每個節點可同時開啟的 scroll context 數量 |
後兩個用預設值就好,不需要特別設定;但不要把 partial results 改成 false,否則一個 shard 出問題就會拖垮整個查詢。
注意:逾時時間不要設得太短,Graylog 搜尋大範圍時間或做複雜聚合時本來就需要比較久,設太短使用者會一直看到不完整的結果。建議先觀察平常查詢的執行時間再決定。
開啟 Search Backpressure
OpenSearch 2.4 起內建 search backpressure,會監控每個搜尋 task 的 CPU、heap 與執行時間,節點壓力過大時自動取消最耗資源的 task。預設是 monitor_only(只記錄不取消),可以改成 enforced:
curl -X PUT "$ES/_cluster/settings" -H 'Content-Type: application/json' -d '{
"persistent": { "search_backpressure.mode": "enforced" }
}'
# 看目前的統計
curl -s "$ES/_nodes/stats/search_backpressure?pretty"
建議先維持 monitor_only 觀察一段時間,看統計裡哪些 task 會被判定為要取消,確認不會誤殺正常查詢再切成 enforced。
heap 要足夠
- heap 一般設實體記憶體的 50% 以內,最多約 31 GB(超過會失去 compressed oops 的好處)
heap.percent長期超過 80% 就該加大- 這次事件,其中一台從 4 GB 調到 8 GB 後,circuit breaker 就不再頻繁觸發
# /etc/opensearch/jvm.options(或 jvm.options.d/heap.options)
-Xms8g
-Xmx8g
-Xms 與 -Xmx 要設一樣,調整後需要重新啟動 OpenSearch。
Graylog Event Definition 錯開執行
115 條規則同一時間起跑是這次的峰值元兇。Graylog 的排程依每條規則的執行間隔計算,如果大量規則是同一時間建立或批次匯入,就會一直同步起跑。可以用 API 把每條的 execute_every_ms 加上一點隨機偏移,讓它們慢慢錯開:
import random
base = 300_000 # 5 分鐘
jitter = random.randint(-30_000, 30_000)
event_definition["config"]["execute_every_ms"] = base + jitter
規則本身簡化
- 避免巨大的正規表示式,Lucene 預設子句數上限
maxClauseCount是 1024,太複雜的查詢會直接失敗 - 聚合的
group_by欄位要用keyword型別,避免觸發 fielddata - 複雜條件拆成多條簡單規則
監控告警
設排程定時檢查,長時間執行的 task 數量異常時通知:
#!/bin/bash
# /usr/local/bin/os-longtask-check.sh
ES=https://localhost:9200
THRESHOLD=50
CNT=$(curl -s --netrc "$ES/_tasks?actions=*search*&detailed=true" | \
jq '[.nodes[].tasks[] | select(.running_time_in_nanos > 300e9)] | length')
if [ "$CNT" -gt "$THRESHOLD" ]; then
echo "OpenSearch long-running search tasks: $CNT" | \
mail -s "OpenSearch long-running tasks > $THRESHOLD" ops@example.com
fi
*/5 * * * * /usr/local/bin/os-longtask-check.sh
有在用 LibreNMS 或其他監控系統的話,也可以把這個數字當成自訂監控項目,比寄信更好追蹤趨勢。
注意:網路上有人用排程定期取消所有長時間 task,我不建議這樣做,會連正常的大範圍查詢一起砍掉。要自動處理,用上面的 search.cancel_after_time_interval 或 search backpressure 比較有依據。
處理流程
發現 OpenSearch 節點 CPU 滿載、反應很慢時,我會照這個順序處理:
| 步驟 | 動作 | 指令 |
|---|---|---|
| 1 | 看叢集狀態 | curl -s $ES/_cluster/health |
| 2 | 計算長時間 task 數量 | 見「診斷方式」的 jq 指令 |
| 3 | 看最久的幾筆是什麼 | _cat/tasks?v&detailed=true&s=running_time:desc |
| 4 | 停掉所有 graylog-server | systemctl stop graylog-server |
| 5 | 批次取消長時間 task | 見「終止 zombie task」 |
| 6 | 等 3 分鐘觀察 | uptime、_cluster/health |
| 7 | 還是很多就重新啟動節點 | systemctl restart opensearch |
| 8 | 等叢集回到 yellow/green | curl -s $ES/_cluster/health |
| 9 | 啟動 graylog-server | systemctl start graylog-server |
本次事件時間軸
| 時間 | 事件 |
|---|---|
| 09:22 | node1 的 OpenSearch heap 從 4 GB 調到 8 GB,重新啟動 |
| 09:24 | node1 啟動完成,叢集開始大量 recovery |
| 09:25 | node2 負載 8.95、CPU 滿載,zombie task 開始累積 |
| 09:32 | 停掉 node1 的 graylog-server |
| 09:35 | 停掉 node2 的 graylog-server(這時才發現兩邊都在跑) |
| 09:39 | 批次取消 918 個 task,無效 |
| 09:46 | 重新啟動 node2 的 OpenSearch |
| 09:48 | node2 負載降到 4.61,zombie task 歸零 |
結論
這次學到最重要的一件事:OpenSearch 的 cancel 不保證會停,等超過 10 分鐘還不退,就直接重新啟動節點,不要一直在那邊取消。
但真正該做的還是預防:設好搜尋逾時與自動取消、heap 給足、規則簡化並錯開執行。這幾項做好之後,同一套環境到現在沒有再發生過。
參考資料
- OpenSearch 文件:Tasks API
https://docs.opensearch.org/latest/api-reference/tasks/ - OpenSearch 文件:Search settings
https://docs.opensearch.org/latest/install-and-configure/configuring-opensearch/search-settings/ - OpenSearch 文件:Search backpressure
https://docs.opensearch.org/latest/tuning-your-cluster/availability-and-recovery/search-backpressure/ - OpenSearch 文件:Cluster allocation explain
https://docs.opensearch.org/latest/api-reference/cluster-api/cluster-allocation/ - OpenSearch Unassigned Shard 重試分配
https://blog.jason.tools/2026/04/opensearch-shard-retry-failed.html



