這一篇整理負載超過容量時,服務實際呈現的幾種結果:每一種怎麼判斷、通常由什麼造成、怎麼應對,以及服務端監控的哪一層看得到它。負載落在飽和曲線的哪一段(利用率與 linear、knee、cliff)在 9.4 Saturation Discovery;卸除、准入控制、重試預算這些應對手段的做法在 9.15 無預警瞬時大流量;監控各層的做法在 4.0 服務端監控的分層。這一篇處理它們之間的對應:同一次過載,在使用者那一側是哪一種結果,在監控上由哪一層看到,應對又該挑哪一種手段。

例子以服務端監控的分層(4.0)裡的短網址服務為底:一台主機上的入口 Nginx、後端程式、PostgreSQL 與 Valkey,訪客點短網址時由後端查出原本的網址並轉址。這一篇另外假設它把點擊事件寫進佇列做統計,談到負載平衡器時假設後端跑成多個實例;4.0 的版本兩者都沒有。

負載的位置與服務的結果:兩條分開量測的軸

Saturation Point 卡與 9.4 Saturation Discovery 以利用率把負載分成 linear、knee、cliff 三段:利用率低時吞吐量隨負載線性上升,到 knee 之後延遲開始上升而吞吐量的成長變慢,到 cliff 之後吞吐量下降、延遲失控。這三段量的是輸入那一側:負載壓在系統上的位置。服務呈現的結果是輸出那一側:使用者拿到的是一個慢的回應(變慢)、一個錯誤(請求失敗)、一個逾時(無回應,或請求沒有抵達),還是連線被拒(程序終止,連接埠上沒有程式在接受連線)。這是用戶端直接連服務時的樣子;前面有反向代理時,代理把無回應與程序終止轉成 504 與 502,見〈無回應:程序還在,請求等到逾時也沒有結果〉。

兩條軸要分開寫,理由是同一個位置會走到不同的結果。進入 cliff 之後,一個連線池設了等待上限的服務呈現請求失敗,同一個服務把等待上限拿掉就呈現無回應,記憶體先耗盡則呈現程序終止。只寫「進入 cliff」說不出使用者看到什麼,也說不出該用哪一種應對;只寫「回應變慢」說不出離 cliff 還有多遠。描述一次過載時兩條軸都寫:負載在哪一段、呈現哪一種結果。各種結果的應對不同,所以挑應對之前要先判出是哪一種結果;從監控上判的方法見〈各監控層看得到哪一種結果〉,先耗盡的資源怎麼決定結果見〈變慢之後走向哪一種結果:先耗盡的資源與等待是否設有上限〉。

6.2 load test 的〈Saturation 與瓶頸定位〉另把越過 knee(6.2 稱為 inflection point)之後的走法分成逐漸退化型與崩落型:前者延遲緩慢爬升,後者延遲在某一點突然跳升數倍。兩型的差別在走到結果的速度:逐漸退化型會在變慢停留較久,崩落型多半由連線池或執行緒池耗盡造成,很快走到請求失敗或無回應,所以要在比延遲跳升點更低的負載就開始限流。

變慢:延遲超過門檻,請求仍然完成

變慢指請求仍然完成,而完成所需的時間超過服務承諾的門檻。判斷標準寫在 SLO 上:延遲的某個百分位數(p95、p99)在一段時間內超過門檻。看平均值會把尾端延遲蓋掉,看單次請求會被偶發的慢請求誤導;百分位數也要依影響範圍(租戶、分區、端點)分開算,理由與錯誤率相同,見〈請求失敗〉。

非同步的工作沒有回應時間可量,它的變慢是工作被接受、完成時間延後:短網址服務把點擊事件寫進佇列、由背景程式彙總成統計,過載時統計頁上的數字晚了半小時(數字是示意),而每一次點擊都轉址成功。非同步的變慢要量佇列裡最舊的一筆等了多久或消費者落後多少(Queue Depth),量請求延遲看不到它。

變慢的成因是排隊:請求在某個瓶頸前面等,瓶頸可能是 CPU、資料庫的鎖、連線池。CPU 不足時所有請求一起變慢,鎖競爭或單一資源不足時只有用到它的請求變慢。一個請求扇出到多個後端並等全部回來時,任一個後端的尾端延遲都成為這個請求的延遲,所以一個後端過載會讓扇出到它的每一個請求變慢(Fan-out)。應對是先找出瓶頸在哪一層(9.5 瓶頸定位流程),再擴充瓶頸所在的元件(9.6 容量規劃模型、9.15〈自動擴展(autoscaling)的反應時間與緩衝〉),或關掉非核心功能讓核心請求先過。瓶頸是單一 key 或分區時,擴充整個元件不會分散它的負載,那個 key 仍落在同一組節點上;應對是降低打到它的並行量,例如合併同一個 key 的重複讀取(request coalescing),或把 key 拆開。

請求失敗:抵達後被拒或失敗,與根本沒有抵達

請求失敗指一部分請求得到錯誤,其餘仍正常完成。它有兩種形態,監控上看到的地方完全不同。

請求抵達服務之後被拒或失敗。一種是服務主動拒絕:入口或後端依負載卸除或限流規則回 429 或 503,讓被拒的請求立即得到錯誤回應、其餘請求維持原本的延遲,這是設計負載卸除(Load Shedding)時刻意選擇的結果。另一種是資源耗盡造成的失敗:請求拿不到資料庫連線、等待到了上限而失敗,依賴的服務逾時,佇列已滿而寫不進去。拒絕也可能發生在後端之前:負載平衡器的佇列已滿時,請求到了負載平衡器而沒有到後端,由負載平衡器自己回 503 並計數(例如 AWS Classic Load Balancer 的 SpilloverCount),這些錯誤不在後端的錯誤率裡,要看負載平衡器的指標。

判斷標準是錯誤率在一段時間內超過門檻,主動拒絕與資源耗盡造成的失敗都算請求失敗。錯誤率除了算全體,也要依影響範圍(租戶、分區、端點)分開算:只佔總請求一小部分的範圍全部失敗時,全體的錯誤率仍然低於門檻。DynamoDB 2015 年 9 月的事故說明 寫到,主要問題在週日解決之後,殘留的整體錯誤率在 0.15% 到 0.25% 之間,部分客戶的錯誤率卻不成比例地高;隔天陸續有客戶開支援案件,AWS 寫到沒有及早察覺這個落差。要分開的是兩者的成因:429 是卸除與限流規則回的拒絕,503 則兩種成因都有,卸除規則與資源耗盡(例如等資料庫連線等到上限)都可能回 503。所以卸除與限流規則回的每一個拒絕,不論狀態碼是 429 還是 503,都在 log 欄位或指標標籤上標出拒絕它的規則,和其他 5xx 分開計算,才分得出拒絕是卸除在作用,還是服務本身出了錯。

請求根本沒有抵達服務。入口的網路頻寬塞滿、雲端主機超過每秒封包數的上限而默默丟包(9.4〈Resource saturation 的六個維度〉),這些請求不會進到後端程式;用戶端那一側看到的是封包重送之後的連線逾時,和無回應長得一樣。兩者的差別在入口記錄到的請求:沒有抵達時,入口的請求率比平常低;無回應時,請求照常抵達,進行中的請求數上升而完成的請求數下降。兩種情形下程序都還在執行,健康檢查不經過卡住的資源時也都成功,所以從程序與健康檢查分不出兩者。這些請求不在後端錯誤率的分子,也不在分母(分母是後端收到的請求總數,見 4.0〈應用層訊號:請求指標、log 與 trace〉)。判斷標準換成入口的請求率(每秒進來的請求數)比平常低,或從外面發出的探測失敗;這一篇說的「平常」都指同一時段的歷史值(例如上週同一天同一個小時)。主機網卡驅動的計數也看得到丟包(AWS ENA 在 ethtool -S 裡的 pps_allowance_exceeded、bw_in_allowance_exceeded)。

應對依形態而定。資源耗盡造成的失敗,先找出耗盡的是哪一種資源,見〈變慢之後走向哪一種結果:先耗盡的資源與等待是否設有上限〉;主動拒絕本身就是應對,要確認被拒的是低優先的請求(9.15〈負載卸除與限流的分工、請求的優先等級〉);用戶端的重試會讓失敗的請求再送一次、加重負載,所以要配重試預算與退避(Retry Storm),寫入路徑的重試還要帶冪等鍵(見〈資料上的結果:重複寫入、接受後遺失與讀到舊資料〉)。沒有抵達的請求,服務端能做的有限,要在入口之前擴充頻寬或交給邊緣網路清洗(9.15〈體積型攻擊(DDoS)的處置:流量清洗與邊緣網路〉)。

無回應:程序還在,請求等到逾時也沒有結果

無回應指服務的程序仍在執行,而請求在用戶端設定的逾時之內等不到任何回應。判斷時用發出那個請求的用戶端自己的逾時:瀏覽器、上游服務與健康檢查各有各的逾時,健康檢查的逾時通常比較短,所以服務變慢時健康檢查可能先逾時:readiness 檢查(決定是否接流量)逾時,負載平衡器把實例移出;liveness 檢查(決定程序要不要重啟)逾時,平台重啟容器,見〈程序終止:被系統結束、當機或反覆重啟〉。健康檢查不經過卡住的那個資源(例如不查資料庫)時情形相反:轉址請求全部逾時,健康檢查仍然成功。無回應與請求失敗的差別在等待由哪一方結束:請求失敗是服務端在用戶端的逾時之前回了一個錯誤,無回應是用戶端的逾時先到、由用戶端自己放棄。

用戶端與服務之間有反向代理時,每一跳各自判一次:入口 Nginx 等後端等到 proxy_read_timeout(預設 60 秒)就回 504,後端連線被拒或連線被中途關閉時回 502。所以後端的無回應在瀏覽器那一側是 504 的請求失敗,後端的程序終止是 502;入口存取紀錄裡 502 與 504 的數量,是分辨後端這兩種結果的第一個訊號(各層逾時的大小關係見 Load Balancer 契約)。入口後面有多個後端實例時,Nginx 預設(proxy_next_upstream error timeout)會把 GET 這類冪等請求改送下一台,最終狀態碼可能是 200,每一次嘗試的狀態碼記在 $upstream_status,要從那個欄位數。

成因多半是沒有上限的等待:連線池、鎖或對依賴的呼叫沒有設逾時,每一個請求都卡在等待裡,執行緒或 goroutine 被佔滿之後,新的請求連被處理的機會都沒有。GC 壓力逐漸加重時,延遲緩慢爬升而請求仍然完成,呈現變慢(6.2 稱為逐漸退化型);記憶體將滿、GC 連續執行時,程序大部分時間停在回收上,延遲超過發出請求那一方的逾時時就呈現無回應;記憶體繼續上升到上限就被 OOM 終止,所以同一次過載常是先無回應、再程序終止。

無回應會改變影響範圍。負載平衡器以健康檢查判斷實例能不能接流量,一台實例無回應時被移出,剩下的實例分到更多負載;9.15 無預警瞬時大流量 的〈負載卸除與限流的分工、請求的優先等級〉引用 Amazon 的說法,健康檢查回應不及時,機器被移出,機群因此變小。剩下的實例如果也接近各自的上限,多分到的負載會讓它們跟著進入無回應。

應對的第一步是由服務端替自己的等待加上限(Timeout、Deadline):等資料庫連線、等鎖、等依賴回應都設上限,到上限就由服務端回錯誤,這個錯誤在用戶端的逾時之前送到,結果從無回應變成請求失敗。使用者得到錯誤而不是乾等,佔著的資源被釋放,健康檢查能在逾時內回應。加上限的同時,呼叫端的重試要受重試預算限制,否則逾時的請求會再送一次,成為停在失效的持續效應(Retry Storm)。依賴已經無回應時,用 Circuit Breaker 停止呼叫它,不再讓每一個請求等到逾時。重啟不改變負載:重啟回來的實例接到的負載和重啟前一樣,負載沒降下來時,它很快回到無回應。

程序終止:被系統結束、當機或反覆重啟

程序終止指服務的程序結束:被作業系統以記憶體不足(OOM)終止、遇到錯誤當機,接著被容器平台或 systemd 重啟,在負載沒有下降時一再重啟。在 Kubernetes 上,被 OOM 終止的容器終止原因記成 OOMKilled、退出碼 137,反覆重啟時狀態顯示 CrashLoopBackOff。

最常見的成因是記憶體。記憶體的佔用來自兩處:進行中的請求(請求與回應的物件、連線的緩衝區),排隊的請求越多佔得越多;以及服務持有的狀態(快取、session、監控後端的時間序列),隨狀態的數量成長。任一處到了上限,程序就被終止。

主機的記憶體耗盡時另有兩種走法。主機有 swap 時,記憶體耗盡先表現為大量換頁,整台主機的請求一起變慢到無回應,OOM killer 很晚才介入。主機層級的 OOM killer 依各程序用掉的記憶體(常駐記憶體加 swap)佔可用記憶體的比例評分,再加上 oom_score_adj,結束分數最高的程序;同一台主機上的資料庫或快取常駐記憶體大,常先被挑中,後端看到的是依賴失敗,也就是請求失敗(共用主機的資源爭用)。

另一個成因是平台的存活檢查:Kubernetes 的 liveness probe 連續逾時達到門檻時,kubelet 重啟容器;過載讓 probe 回應變慢,程序沒有壞也會被重啟,重啟期間其餘實例分到更多負載而跟著被重啟。終止原因沒有 OOMKilled、事件裡有 liveness probe 失敗,就是這一種。應對是讓 liveness 只檢查程序內部是否卡死、不經過依賴,逾時設得比過載時的延遲寬(Liveness、Liveness 與 Readiness〈探針靈敏度是雙向的取捨〉)。

重啟讓程序回來,而負載沒有變。重啟期間其他實例分到這一台的流量;重啟回來時程序內的快取是空的,原本由它回應的讀取全部打到資料庫,所以在負載下重啟常讓其他實例與資料庫跟著過載。應對依佔用的來源而定:進行中的請求佔掉的記憶體與進行中的請求數成正比,要限制進行中的請求數(上限可以固定,也可以依量到的延遲自動調整,即 adaptive concurrency limit)並卸除負載,讓重啟回來的程序不會再被推到記憶體上限;狀態佔掉的記憶體要限制狀態的數量(例如時間序列數的上限),限制進行中的請求數不會讓它下降。平台的自動重啟本身不會通知任何人,所以連續重啟要由一條告警看到(4.0〈容器與程序的自檢〉)。重啟回來的實例快取是空的,readiness 等快取預熱完成才回報就緒(Readiness、Cache Warmup),負載平衡器加回它時讓流量分段增加(slow start)。

尖峰過去或應對切換之後仍持續的情形:停在失效、擺盪與第二波湧入

變慢、請求失敗、無回應與程序終止,在負載下降之後通常會跟著消失;以下三種情形在尖峰過去、依賴恢復或應對切換之後仍然持續。

停在失效:觸發(流量尖峰、依賴故障、重新部署)已經消失、使用者發起的請求率回到平常,服務仍然大量逾時或出錯,原因是過載本身產生了額外的工作(重試、處理沒人在等的舊請求、快取變空之後改打資料庫的讀取),額外的工作又維持過載;這種讓過載自我維持的機制叫持續效應(sustaining effect),停在失效就是文獻裡的 Metastable Failure。9.15〈負載卸除與限流的分工、請求的優先等級〉描述的「過載因此變成穩定狀態」就是這一種。

停在失效的判斷標準是比對兩組數字:使用者發起的請求率(扣掉重試)已經回到平常,而每秒成功完成的請求數(goodput)沒有回到平常、錯誤率仍高;總請求率可能因為重試而高於平常,重試請求佔全部請求的比例也比平常高。瀏覽器的重新整理、入口反向代理的改送與多數自己寫的重試沒有標記,送到服務時和新請求長得一樣,所以扣掉重試的請求率要從入口分得出來的量取:不同使用者或工作階段的數量,或帶冪等鍵的請求以鍵去重後的數量;gRPC 的重試帶 grpc-previous-rpc-attempts,Envoy 開啟 include_request_attempt_count 後帶 x-envoy-attempt-count,有這些標記時可以直接扣掉。另外服務的資源要仍然飽和:等待連線的請求數、CPU 或資料庫讀取量高於平常;自己量得到的資源沒有飽和時,先看依賴回的錯誤,依賴的配額被重試用完時(第三方 API 回 429),飽和的是對方的配額,持續效應一樣存在(9.5 瓶頸定位流程〈逐層定位流程〉的外部 API 配額)。自己的資源沒有飽和、依賴也沒有回 429 時,goodput 低落的成因不是過載(例如資料庫切換後應用程式還連著舊的位址),要回到部署、設定與依賴的變更去找。

判斷之前還要排除觸發仍在的情形:依賴的錯誤率與延遲回到平常,被移出或終止的實例已經補回,部署已經完成或回滾。觸發是依賴故障或容量減少時,使用者的請求率可能一直在平常的範圍,請求率平常、goodput 低、資源飽和這三項在觸發仍在時同樣成立;那時是正在進行的過載,應對照它呈現的結果處理,並處理觸發本身(故障的依賴、減少的容量)。新部署的版本讓每個請求的成本變高時,三項也都成立而觸發仍在:處置是回滾;只把負載壓低再放回,放回之後每個請求仍然一樣貴,服務會再度過載。分辨的方法是看每個成功請求消耗的資源(每請求的 CPU 時間、每請求的資料庫查詢數)是否比觸發前高,而且高出的部分不是來自重試或快取未命中。

Slack 2021 年 1 月 4 日的事故可以拿來對照觸發仍在的過載:假期後第一個上班日流量上升,服務之間的託管網路元件(AWS Transit Gateway)來不及擴展而丟包,錯誤一直持續到網路恢復,那段期間要處理的是網路元件,不是持續效應。

確認是停在失效之後,應對是把負載壓到平常的量以下,讓持續效應停止(重試停下、舊請求清掉、快取預熱),佇列在過載時改成後進先出或依排隊時間丟棄,讓舊請求不再佔用處理時間,再逐步放回流量,做法見 Metastable Failure 卡。

非同步的消費端也會停在失效:入列速率回到平常,消費端成功處理的速率仍低於入列速率,落後量持續增加(Consumer Lag);先進先出的佇列先處理已經過期的舊訊息時最容易這樣。

擺盪:卸除或擴縮的切換太快,服務在兩個狀態之間來回:卸除一啟動負載就下降,卸除解除後又過載;或擴充之後利用率下降、隨即縮減、延遲又超過門檻。9.15 引用 Stripe 的經驗寫到卸除削得太快會擺盪,擴展的觸發與縮回 以冷卻時間避免擴縮的抖動。應對是讓卸除與恢復都分段進行,擴縮設冷卻時間,縮減的門檻低於擴充的門檻,讓兩個切換點之間留出間隔。健康檢查也會造成擺盪:實例過載被移出,負載降下來後恢復、被加回,加回後又過載;應對是加回時讓流量分段增加(負載平衡器的 slow start),移出與加回用不同的連續次數門檻。

恢復時的第二波湧入:一個依賴故障期間,呼叫它的請求在用戶端或佇列裡累積;依賴恢復的那一刻,累積的請求與重試一起到達,讓剛恢復的依賴再過載一次(突發流量的分類 的〈不可預期突發〉)。第二波湧入本身是一次尖峰,累積的請求送完就結束;再過載之後如果重試維持住過載,就轉成停在失效。應對是讓累積的請求分散到達:用戶端的重試加退避與隨機延遲(jitter)、寫入的重試帶冪等鍵,Circuit Breaker 在半開狀態只放少量請求試探,佇列的消費端恢復後逐步提高消費速率,已經過了 deadline 的累積請求直接丟掉。

降級回應:回應成功而內容縮減

降級回應是應對選出來的結果,過載本身不會產生它:服務在過載時關掉推薦、回舊的快取資料、改成唯讀,回應照樣成功、延遲照樣正常。它把原本會走到請求失敗或無回應的情形,換成使用者拿得到、內容比較少的回應。使用者在過載期間拿到的是它,所以監控上也要分得出它(Degradation)。

延遲與錯誤率都看不到它,所以降級的程式路徑要自己計數:每一次回應走了哪一條降級路徑,記成一個計數指標,降級時才看得到有多少回應是縮減過的,以及降級在負載退去之後有沒有解除。降級也可能發生在服務之外:CDN 依 stale-if-error 在源站出錯時回舊內容(各家 CDN 的支援不同),這些回應要看 CDN 記錄裡的快取狀態(Stale-If-Error)。

資料上的結果:重複寫入、接受後遺失與讀到舊資料

變慢、請求失敗、無回應、程序終止與降級回應,是按使用者拿到的回應分類;過載在資料上另有三種結果,回應碼與延遲都看不出來。

重複寫入:寫入在用戶端放棄之後才完成,或服務端的 deadline 在資料庫已經提交之後才觸發,使用者拿到錯誤而寫入已經發生,用戶端重試就寫第二次。服務端替等待加上限(〈無回應〉的應對)會讓「回了錯誤而資料庫已經提交」更常發生,所以寫入路徑的重試要帶冪等鍵,服務端以鍵判斷是否已經處理過(Idempotency Key、接收方的重試決策)。訊號是同一個冪等鍵被重複送達的次數;還沒有冪等鍵時,訊號是業務上的重複(同一使用者、同一內容在短時間內寫進兩筆)。

接受後遺失:非同步路徑把事件放進程序內的緩衝就回應成功(API 回 202;短網址照常回轉址),程序被 OOM 終止時,緩衝裡還沒寫進佇列的事件跟著消失。短網址的點擊事件如果先放在後端程序內再批次寫入佇列,就是這一種。訊號是入口存取紀錄的請求數(或其他不在同一個程序裡的計數)與寫進佇列的事件數之間的差;接受數由同一個程序計數時,程序被終止的那一段會跟著消失。應對是讓回應在事件寫進持久的佇列之後才送出,或接受遺失並在統計上標明。

讀到舊資料:讀取走資料庫複本時,主庫的寫入量超過複本套用的速度,複寫延遲變長,剛建立的短網址從複本讀不到而回 404。錯誤率只算 5xx 時看不到它,延遲也正常;端點的 404 比例上升與複寫延遲是它的訊號,應對是剛寫入的資料從主庫讀,或讀取時帶上寫入的版本(Replication Lag、Read-After-Write)。

變慢之後走向哪一種結果:先耗盡的資源與等待是否設有上限

過載時,多數資源在耗盡前都先讓請求排隊,先出現的是變慢;入口頻寬與每秒封包數例外,超過上限的封包直接被丟掉。變慢持續下去會轉成哪一種結果,由最先耗盡的資源,以及等待那個資源時是否設有上限決定。先耗盡的資源指請求在這個服務裡等待的那一個;它的上游成因(例如服務之間的網路丟包)另外寫。下表是常見的對應:

先耗盡的資源等待是否設上限等待到頭之後呈現的結果
CPU不適用變慢:所有請求一起變慢
CPU 配額(容器的 CPU 上限,cgroup 的 cpu.max)不適用變慢,而且是週期性的停頓:配額在一個週期(預設 100 毫秒)內用完,剩下的時間整個容器暫停,主機的 CPU 不一定滿;延遲尾端跳升,健康檢查也可能逾時
連線池、執行緒池有上限,且短於呼叫方的逾時請求失敗:等到上限就得到錯誤
連線池、執行緒池有上限,但長於呼叫方的逾時無回應:呼叫方先放棄,服務端仍佔著資源等到上限
連線池、執行緒池沒有上限無回應:請求一直等,佔滿之後新請求進不來
記憶體不適用程序終止:容器設了記憶體上限時被 OOM 終止、接著重啟;主機有 swap 時先大量換頁、整台主機變慢到無回應;主機層級的 OOM killer 常先結束同機的資料庫或快取,後端看到依賴失敗
檔案描述子依連線的方向程序開不了新的連線與檔案:對外的連線(例如連資料庫)立即失敗,是請求失敗;進來的連線多半停在 accept 佇列裡等不到程序接受,用戶端等到逾時,是無回應;健康檢查也可能失敗而被移出
listen socket 的 accept 佇列(上限是 listen() 的 backlog 與 somaxconn 取小者)依用戶端的連線逾時佇列滿時核心丟掉新連線(預設不回 reset),用戶端重送後逾時;應用程式沒有紀錄,經過同一個連接埠的健康檢查一起失敗;計數在 nstat 的 TcpExtListenOverflows
入口頻寬、每秒封包數不適用請求失敗(沒有抵達):應用程式沒有任何錯誤紀錄
服務之間的網路(託管路由元件、交換器的頻寬與丟包)依呼叫端的等待是否設上限呼叫依賴變慢,等待落在呼叫端的執行緒池或連線池,結果照〈連線池、執行緒池〉幾列依等待上限判;請求已經抵達,應用程式有紀錄
依賴的服務(資料庫、外部 API)依呼叫是否設逾時呼叫設了逾時是請求失敗,沒有設是無回應

先耗盡的是哪一個資源,看元件指標的飽和度(USE Method、9.5 瓶頸定位流程);容器的 CPU 要看被暫停的時間(container_cpu_cfs_throttled_seconds_total,見 cgroup),只看主機的 CPU 會把它排除。CPU(含配額)與記憶體標為不適用,是因為等 CPU 的排程佇列與記憶體配置都沒有應用程式能設的等待上限。

同一個機制可以走到不同的結果。Queue 緩衝 寫的「goroutine 積壓 → 記憶體上升 → 最終 OOM 或 response timeout」就是兩條路:記憶體先到上限就是程序終止,用戶端的逾時先到就是無回應。9.14 連線池放大解法 的〈連線池放大的物理本質〉也是三種結果並存:記憶體耗盡而被 OOM 終止、行程切換變多而變慢、超過資料庫最大連線數而連線建立失敗。壓測時除了記下在多少負載越過 SLO,還要記下先耗盡的是哪一個資源、呈現的是哪一種結果,9.4〈Saturation 文件化:容量地圖〉的清單裡列了這一項。

影響範圍:從單一 key 到依賴與上游

結果之外,另一個要寫的是範圍:受影響的是一個 key 或分區、一個租戶、一個優先等級、一個端點、一個實例、同一台主機上的幾個服務、整個服務,還是已經擴散到依賴與呼叫它的上游(Cascading Failure、Blast Radius)。

範圍與結果會在時間上互相推動。一台實例無回應而被移出,剩下的實例負載上升,範圍從一個實例擴大到整個服務;後端無回應時,呼叫它的上游也在等待中耗盡自己的連線池,範圍擴散到上游。縮小範圍的手段是隔離:依租戶或功能分開資源池(Bulkhead)、依優先等級卸除,讓過載停在一個範圍裡。

觀察者自己也在範圍之內。監控程式與服務共用一個過載的資源時(同一台主機、同一段網路路徑、同一個資料庫),監控的抓取先逾時、資料先缺,判斷結果所需的訊號在最需要的時候先失真,見 4.27 主機過載時的監控告警。

各監控層看得到哪一種結果

服務端監控的分層(4.0)把監控分成自檢、服務網路內的探測、元件指標、應用層訊號、告警鏈的監看與合成監控等層,每一層看得到的結果不同:

結果看得到它的監控層訊號
變慢(同步)應用層訊號、服務網路內的探測、使用者路徑的合成監控請求耗時的直方圖(histogram)百分位數(不含入口排隊時間,見表下);探測耗時與慢探測的比例
變慢(非同步)應用層訊號、元件指標佇列最舊一筆的等待時間、消費者落後量
請求失敗(抵達後)應用層訊號;負載平衡器回的部分看入口的請求指標;應用層訊號與服務共用過載的資源時,由使用者路徑的合成監控接手錯誤率,卸除與限流的拒絕(以標籤區分)與其他 5xx 分開;對每個依賴的請求錯誤率;負載平衡器的拒絕計數
請求失敗(沒有抵達)入口的請求指標與存取紀錄、使用者路徑的合成監控入口的請求率比平常低;外部探測失敗;網卡驅動的丟包計數
無回應容器與程序的自檢、服務網路內的探測、使用者路徑的合成監控健康檢查逾時(健康檢查經過卡住的資源時);探測逾時;應用層看到完成的請求數下降、錯誤率不升;入口回的 504 計數
程序終止容器與程序的自檢、元件指標;整台主機停掉或終止的是監控程式自己時,是告警鏈的監看(dead man’s switch)與合成監控重啟計數(kube_pod_container_status_restarts_total、node_systemd_service_restart_total);終止原因(kube_pod_container_status_last_terminated_reason 的 reason 標籤、node_exporter 的 node_vmstat_oom_kill);入口回的 502 計數
停在失效(觸發消失、請求率回到平常之後仍不恢復)沒有單一層看得到比對使用者發起的請求率與 goodput(每秒成功完成的請求數);重試請求的比例;資源仍飽和;依賴與容量已經恢復
擺盪(卸除或擴縮反覆切換)應用層訊號、元件指標請求率與錯誤率週期性來回;卸除或擴縮的狀態反覆切換
第二波湧入(依賴恢復時累積的請求一起到達)入口的請求指標、應用層訊號依賴恢復那一刻的請求率尖峰;重試請求的比例
降級回應應用層訊號(要自己計數)降級路徑的計數指標
資料上的結果(重複寫入、接受後遺失、讀到舊資料)應用層訊號、元件指標(要自己計數)同一冪等鍵重複送達的次數;接受與寫進佇列的事件數差;複寫延遲
前兆(還沒有任何結果)元件指標USE Method 的飽和度:排隊長度、等待連線的請求數;每個請求的大小或工作量的趨勢(回應大小、清單類資料的大小)

表裡變慢(同步)與停在失效兩列的訊號要另外補。histogram 只量得到請求進到程式之後的時間,請求在入口或系統的佇列裡排隊的時間不在裡面,所以過載時應用層的延遲會低報,主機過載時的監控告警那一篇(4.27)改用從外面發的慢探測比例補上這一段。停在失效這一種沒有任何一層直接看得到,要另外寫一條比對規則:使用者發起的請求率已經回到平常的範圍、依賴與容量已經恢復,而 goodput 仍低於平常、元件指標的飽和度高於平常,而且 goodput 在這段時間內沒有上升趨勢(快取逐漸回熱、goodput 一路上升的是自己在恢復),持續一段時間就送出。另外,程序終止那一列的 node_systemd_service_restart_total 由 node_exporter 的 systemd collector 產生,那個 collector 預設關閉,重啟計數另有開關(--collector.systemd.enable-restarts-metrics),沒開過的主機上查不到這個指標。

描述一次過載時要寫出的內容

壓測報告、事故紀錄與容量地圖描述一次過載時,「撐不住」只說了有一個上限被越過,讀的人不知道使用者看到什麼、哪一個資源先用完、該用哪一種應對。改寫成五項:負載量與它的量法(每秒多少請求、哪一種請求組合、每個請求的大小或工作量)、呈現的結果(使用者拿到的是變慢、請求失敗、無回應、程序終止還是降級回應;觸發消失之後是否仍然持續,是停在失效、擺盪還是第二波湧入;資料上有沒有重複寫入、接受後遺失或讀到舊資料)、先耗盡的資源、影響範圍、監控從哪一層看到。例如:「每秒轉址請求超過壓測的上限之後,資料庫連線池先耗盡,連線池設了等待上限,所以呈現的是約一成的轉址請求失敗(503),其餘延遲不變;應用層的錯誤率告警先送出,健康檢查一直正常。」(這個例子的數字是示意。)實際記錄時,數字取自壓測或事故本身並寫明出處。

延伸閱讀:飽和曲線、應對手段與監控層