跳到主要內容
節省工具箱Jason Tools
經驗分享

OpenSearch Zombie Task 處理與預防

Jason Cheng 發布 瀏覽 1 次
OpenSearch Zombie Task 處理與預防
本文目錄
  1. Zombie task 的症狀
  2. 本次事件的成因
  3. 診斷方式
  4. 終止 zombie task
  5. 取消不一定有用的原因
  6. 預防
  7. 處理流程
  8. 本次事件時間軸
  9. 結論
  10. 參考資料

某天早上一套 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 還在

本次事件的成因

事後回頭看,是好幾個因素疊在一起:

  1. 大量規則同時執行
    115 條 Graylog Event Definition 都是每 5 分鐘執行一次,而且同時起跑,對 OpenSearch 造成瞬間的峰值壓力
  2. heap 不足
    其中一台節點 heap 只有 4 GB,聚合查詢觸發 circuit breaker,查詢失敗但 task 沒有乾淨結束
  3. shard lock
    前一台節點剛重新啟動,叢集在大量 recovery,部分 replica shard 處於關閉中的狀態並持有 shard lock,打到這些 shard 的查詢就卡住
  4. 跨節點的孤兒 task
    一個節點的 graylog-server 停掉後,parent task 消失了,但另一個節點上的 child task 沒收到取消訊號
  5. 取消只是「做記號」
    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),不是強制終止:

  1. 呼叫 cancel API,只是在 task 上設一個「已取消」的記號
  2. task 在執行過程中,到特定的檢查點才會去看這個記號
  3. 看到了才丟出 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_resultstrue逾時或單一 shard 失敗時,仍傳回其他 shard 的結果
search.max_open_scroll_context500每個節點可同時開啟的 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-serversystemctl stop graylog-server
5批次取消長時間 task見「終止 zombie task」
6等 3 分鐘觀察uptime、_cluster/health
7還是很多就重新啟動節點systemctl restart opensearch
8等叢集回到 yellow/greencurl -s $ES/_cluster/health
9啟動 graylog-serversystemctl start graylog-server

本次事件時間軸

時間事件
09:22node1 的 OpenSearch heap 從 4 GB 調到 8 GB,重新啟動
09:24node1 啟動完成,叢集開始大量 recovery
09:25node2 負載 8.95、CPU 滿載,zombie task 開始累積
09:32停掉 node1 的 graylog-server
09:35停掉 node2 的 graylog-server(這時才發現兩邊都在跑)
09:39批次取消 918 個 task,無效
09:46重新啟動 node2 的 OpenSearch
09:48node2 負載降到 4.61,zombie task 歸零

結論

這次學到最重要的一件事:OpenSearch 的 cancel 不保證會停,等超過 10 分鐘還不退,就直接重新啟動節點,不要一直在那邊取消。

但真正該做的還是預防:設好搜尋逾時與自動取消、heap 給足、規則簡化並錯開執行。這幾項做好之後,同一套環境到現在沒有再發生過。

參考資料

Jason Cheng

Jason Cheng

企業開源技術顧問,專注架構規劃與實務導入

關於作者 →

延伸閱讀