有玩過線上遊戲的朋友應該都聽過「萬人在線,全民公測」的口號,例如這個已經倒掉的《黃易群俠傳 2》:

問題是,怎麼確定服務做到能容納萬人在線呢?難道真的靠全民公測嗎?裝不下再「緊急加開伺服器」?(順便上一波廣告 🤔️)

「緊急加開」也好,「威猛加開」也罷,這些都只是行銷的手法,我輩開發者如果真的靠全民公測才知道服務的容納量的話,除了心臟要很強之外,務必還要多放幾包綠色乖乖,以及被館長幹爆的準備。

館長

來源:館長成吉思汗

如果不想把服務的穩定性寄託於命運之上,那最好還是了解一下負載測試

負載測試

負載測試,或稱為壓力測試,是模擬大量用戶使用服務下,觀察服務的承載能力的一種測試模式,除了線上遊戲外,EC 領域在 XX 購物節也常常會遇到爆量的人潮,因此 EC 也是負載測試應用的領域,其他像是訂票服務、熱門的 app 這些有人數爆量可能性的服務,也都是用得上負載測試的領域。

負載測試可以讓我們知道服務的承載量,是一種較為巨觀的指標,即便是針對單一 API 端點的負載測試,依然無法單靠負載測試來解析系統內部的效能瓶頸,若想更深入地解析系統內具體的效能瓶頸,那得需要搭配其他手段才有辦法,例如 APM 、資料庫效能分析、硬體資源分析、log 分析等等。

另外在較新的 Kubernetes 或任何其他會自動擴展的架構,也可以利用負載測試來驗證自動擴展的機制有沒有正確做動,當然這也是要搭配 Kubernetes 監測工具一起觀察才能做出正確的驗證。

負載測試的工具

用「負載測試」/「壓力測試」/「load testing」做搜尋,可以看到一票負載測試的工具或服務,比較常出現的大概是老牌的 ApacheBench 與 JMeter,但最後我們選擇的是 k6,因為 k6 具備一些老牌工具沒有的特性,依重視度排序如下:

  1. Integrable 可整合:可整合至 CI / CD 流程內,以及測試結果可發送至像 Grafana 這類的外部儀表板系統內。
  2. Scriptable 可制定腳本:可以用腳本語言撰寫測試行為,才能夠更真實的模擬用戶行為。
  3. Able to be a service 提供服務:除了基礎的 CLI 程式外,最好還能有額外的測試服務,才能夠在未來日益加重的測試需求時,能夠用 load testing as a service 的模式做測試,而不用自行建立測試機台。
  4. Well-documented 良好的文件:圖文並茂而不冗長的文件,而不是只有 CLI 的說明頁或那冗長的 man page。
  5. Actively maintained 有持續維護更新:有更新才有可能支援較新的標準,例如 HTTP/2、HTTP/3、WebSocket、GraphQL、TLS 1.3 等。
  6. Modern CLI design 現代化:最好有 subcommand 把子功能分門別類,並且 subcommand / option / parameter / argument 的命名具有意義,不要是不知所謂的簡寫,像 Tar 這種老式工具沒有 subcommand 的概念,tar --help 一下就噴好幾頁的說明是不及格的。
  7. No runtime 不需要 runtime:不要有 runtime 或其它系統套件依賴,例如 JDK / JRE / .NET / Microsoft C++ Redistrubutable 這些 runtime 都只增加了配置環境時的複雜度,另一方面,用 Docker 容器方案當然也是增加配置複雜度的兇手之一。

綜合以上需求,我們選擇了 k6。

關於負載測試工具的選擇,可以參考 k6 的這篇〈Open source load testing tool review 2020〉(中文摘錄版:〈開源性能測試工具評測 2020〉),作者雖然是 k6 的開發者,但內容頗為中肯,並沒有一昧自賣自誇。

k6 是以 Go 語言撰寫,編譯出的程式跨平台而且沒有 runtime,也只有單一執行檔,沒有週邊的函式庫,而 k6 的測試腳本語言是 JavaScript,我們可以在 JS 腳本內撰寫模擬用戶的流程,並且這些腳本也可以整合進大專案的版控與 CI / CD 流程中,JavaScript 也是我們正在進行的 EC 專案的開發語言,這讓整個專案都存在 JS 宇宙內,讓環境配置更單一,符合 KISS 原則

負載測試的分類

前面提到負載測試的簡單概念——模擬大量用戶湧入,並且觀察服務的承載能力,但測試實際上並不是粗暴的一次灌入萬人流量,負載測試本身還可以再做更深入的分類,參考 k6 的示意圖:

負載測試類別

來源:k6

Smoke Testing

驗證測試腳本與服務的功能或邏輯正確,服務的功能正確與否通常由自己的單元測試腳本來驗證,而負載測試自己的腳本邏輯也是需要驗證的,所以不會貿然的寫完測試腳本馬上灌流量,而是先執行 smoke tesing 驗證測試的腳本與目的是正確的。

Smoke tesing 的流量圖如下,只有 1 個 VU,VU 即 virtual-user(s),在 k6 裡面是發出的流量的數量,後面會再提到。

Smoke Testing

來源:k6

Load Testing

Load testing 是正常強度的負載驗證,所謂的正常強度是來自既有系統的經驗值,在不考慮行銷活動的高峰流量下,同一時間內正常流量的高峰值。

在 k6 文件內的範例情境:平均同時在線人數是 60 人,而最高會到 100 人。在這樣的情境下 load testing 應該要以 100 vu 為基準,流量圖如下:

Load Testing

來源:k6

注意到流量圖都不是瞬間就走到高原段,而是都會有前面的爬升段和尾部的下坡段,這樣的設計才更符合真實的情況,也讓我們在服務承受的負載在逐步上升的同時能觀察到硬體資源的佔用變化以及服務的回應時間變化,或是觀察服務的自動擴展機制是否生效等等。

Load testing 做為一種服務的基準負載水平驗證,適合整合進 CI / CD 流程,一旦改版後的 load testing 測試不過,就不發布到正式環境,而發出 issue 給開發人員請他們調查改善,通過與否的指標可以是回應時間與功能正常運作與否,在 k6 文件內的舉例:

  • 99% 的請求應該在五秒內回應
  • 95% 的請求應該在一秒內回應
  • 99% 的登入測試應該要能成功作業。

Stress Testing

Stress testing 用於驗證高負載下服務的狀態,例如模擬在 XX 購物節期間的同時最高在線流量,一樣是引用 k6 的範例流量圖如下:

Stress Testing

來源:k6

同樣的,流量是逐步往上提昇的,我們想知道的不僅是在最高原段服務的狀態,也想知道在流量逐步上升的期間服務的狀態,包括硬體資源的消耗、服務的功能是否正常以及失效的現象、服務的回應時間變化、自動擴展機制的反應等,這些都是在行銷活動開跑前要確認過的指標與觀察點。

Spike Testing

Spike testing 用於驗證更極端的流量條件下服務的表現:

Spike Testing

來源:k6

在分類上,spike testing 也算是 stress testing 的一種,要注意的是,stress tesing 或 spike testing 都不要拿正式環境來玩,用 spike testing 把自己打爆一點都不浪漫的!

浪漫突刺

來源:反正我很閒

Soak Testing

Soak testing 則像是 stress testing 的時間加長版,驗證服務在高流量且長時間下的表現,讓 memory leak、硬碟被 log 塞爆、資料庫空間被塞爆、外部服務被灌爆、race-condition 等平時難以察覺的錯誤都暴露出來。流量圖如下:

Soak Testing

來源:k6

k6

認識完負載測試,接著來玩 k6。

關於 k6 的安裝,在 macOS 是透過 Homebrew:

brew install k6

其他的作業系統大多也是類似的一行安裝,不用配置 Node.js 或其他任何依賴項目。

在動手之前先了解 k6 的 JavaScript 的幾個特性:

  • k6 的腳本語言 JavaScript 的引擎是由 Go 的 Goja 套件實現的,與 Node.js 無關,因此無法用 NPM 裝額外的 JS 套件,但可以用正宗的 import 引用外部模組,k6 也維護了一系列負載測試會用到的 JS 套件在 k6 JS Libraries,方便我們調用。
  • 承上,Node.js 提供的 osfs 等本機模組是不存在於 k6 JS 的世界的,另外 k6 也不是瀏覽器,因此瀏覽器的 window 等物件也是不存在於 k6 JS 的。
  • 另外一個特性是 k6 的 JavaScript 是有 IO blocking 的,因此在發出 HTTP 請求時,不需要用 await 控制流程,它會乖乖地在收到回應後才跑後面的程式。

來看最簡單的 k6 測試腳本:

import http from 'k6/http';
import { sleep } from 'k6';

export default function () {
    http.get('https://test.k6.io');
    sleep(1);
}

這幾行簡單的程式碼可以給我們一些聯想:

  • 看到 get(),那理所當然應該也會有 post() 等其他 HTTP 請求函式。
  • post(),那一定也有方法讓我們設定 HTTP header 和 payload。

跑測試也很簡單:

k6 run --vus 10 --duration 20s script1.js

上面給了兩個參數:

  • vus 即前面提過的 virtual-user(s),可以理解為請求的併發數。
  • duration 即測試執行的時間

解讀上面那句命令就是「同時開 10 根請求反覆跑,直到時間到 30 秒為止」。那總共發出幾次請求呢?不知道,要等測完才知道,因為這與受測方的回應時間長短有關,所謂的反覆跑就是收到回應後,再跑下一回合,直到 30 秒的時限到達,再注意到上面的範例程式內有 sleep(1),所以在兩次請求中間會休息一秒,而上面這樣的行為,總共有 10 根請求在各自進行。

跑完 k6 會印出測試數據:

  execution: local
     script: script1.js
     output: -

  scenarios: (100.00%) 1 scenario, 10 max VUs, 50s max duration (incl. graceful stop):
           * default: 10 looping VUs for 20s (gracefulStop: 30s)


running (20.2s), 00/10 VUs, 160 complete and 0 interrupted iterations
default ✓ [======================================] 10 VUs  20s

     data_received..................: 1.9 MB 92 kB/s
     data_sent......................: 29 kB  1.4 kB/s
     http_req_blocked...............: avg=49.91ms  min=0s       med=1µs      max=800.55ms p(90)=1µs      p(95)=796.95ms
     http_req_connecting............: avg=12.93ms  min=0s       med=0s       max=208.81ms p(90)=0s       p(95)=205.3ms 
     http_req_duration..............: avg=209.12ms min=205.18ms med=208.78ms max=219.51ms p(90)=211.6ms  p(95)=212.93ms
       { expected_response:true }...: avg=209.12ms min=205.18ms med=208.78ms max=219.51ms p(90)=211.6ms  p(95)=212.93ms
     http_req_failed................: 0.00%  ✓ 0    ✗ 160 
     http_req_receiving.............: avg=907.59µs min=50µs     med=895µs    max=4.52ms   p(90)=1.63ms   p(95)=1.88ms  
     http_req_sending...............: avg=72.01µs  min=22µs     med=63µs     max=382µs    p(90)=90.49µs  p(95)=129.64µs
     http_req_tls_handshaking.......: avg=36.84ms  min=0s       med=0s       max=590.97ms p(90)=0s       p(95)=588.58ms
     http_req_waiting...............: avg=208.14ms min=204.17ms med=207.86ms max=217.29ms p(90)=210.37ms p(95)=211.8ms 
     http_reqs......................: 160    7.927487/s
     iteration_duration.............: avg=1.25s    min=1.2s     med=1.2s     max=2.02s    p(90)=1.21s    p(95)=2s      
     iterations.....................: 160    7.927487/s
     vus............................: 10     min=10 max=10
     vus_max........................: 10     min=10 max=10

左邊是指標(metric),右邊是統計數據,比較會看的大概是這些:

  • http_req_duration,一次請求從發送到收到回應的總時間
  • http_req_failed,請求失敗的比例及數量
  • http_reqs,請求發送的總數量及換算後每秒發出的請求數量

下面再來個複雜點的例子。

如果想要更細緻的控制請求的走勢,那就要在腳本內定義:

import http from 'k6/http';
import { check, sleep } from 'k6';

export let options = {
    stages: [
        { duration: '30s', target: 10 },
        { duration: '1m30s', target: 30 },
        { duration: '20s', target: 0 },
    ],
};

export default function () {
    let res = http.get('https://httpbin.org/');
    check(res, { 'status was 200': (r) => r.status == 200 });
    sleep(1);
}

上面的 target 就是指定 UVs 的參數,而 stages 可想而知是定義階段的參數。但在 stages 內的運作方式與 CLI 略有不同,此處的第一階段(duration: '30s', target: 20)指的是「在 30 秒間逐步把 VUs 加到 20 根」而不是像 CLI 那樣是瞬間開出,依此類推,第二階段就是在一分半內逐步從 10 VUs 追加到 30 VUs;第三階段是在 20 秒內逐步從 30 VUs 降到 0 VUs。

前面在談負載測試的分類時,也有提過負載最好都是逐步增加和減少的,如此才比較好觀察到系統各個在各階段的狀態。

除了設定緩升緩降外,在主函式內我們也多了檢查 HTTP 狀態碼的敘述,確保收到的回應是成功的,而不是只是有回應。要注意的是 k6 的 check() 失敗最終並不會發出 exit code 1,僅會呈現在統計值上,若要引發錯誤得在 thresholds 內定義,這部分後面會再提到。

再聯想一下,看到 res.status 那應該就會有 res.body,是的,雖然 k6 並不是瀏覽器,但想要在測試內解析回應內容也是可以的,另外也有 res.headers,因此想要拿到 bearer token 模擬用戶登入的行為也是有可能的。

跑完的結果如下:

  execution: local
     script: script4.js
     output: -

  scenarios: (100.00%) 1 scenario, 30 max VUs, 2m50s max duration (incl. graceful stop):
           * default: Up to 30 looping VUs for 2m20s over 3 stages (gracefulRampDown: 30s, gracefulStop: 30s)


running (2m21.2s), 00/30 VUs, 1809 complete and 0 interrupted iterations
default ✓ [======================================] 00/30 VUs  2m20s

     ✓ status was 200

     checks.........................: 100.00% ✓ 1809 ✗ 0   
     data_received..................: 18 MB   127 kB/s
     data_sent......................: 247 kB  1.7 kB/s
     http_req_blocked...............: avg=10.54ms  min=0s       med=1µs      max=1.04s    p(90)=1µs      p(95)=1µs     
     http_req_connecting............: avg=3.41ms   min=0s       med=0s       max=216.55ms p(90)=0s       p(95)=0s      
     http_req_duration..............: avg=222.75ms min=205.88ms med=209.5ms  max=628.84ms p(90)=220.34ms p(95)=327.83ms
       { expected_response:true }...: avg=222.75ms min=205.88ms med=209.5ms  max=628.84ms p(90)=220.34ms p(95)=327.83ms
     http_req_failed................: 0.00%   ✓ 0    ✗ 1809
     http_req_receiving.............: avg=112.13µs min=52µs     med=107µs    max=546µs    p(90)=145µs    p(95)=159µs   
     http_req_sending...............: avg=66.79µs  min=22µs     med=62µs     max=426µs    p(90)=84.2µs   p(95)=102.59µs
     http_req_tls_handshaking.......: avg=6.99ms   min=0s       med=0s       max=586.46ms p(90)=0s       p(95)=0s      
     http_req_waiting...............: avg=222.57ms min=205.71ms med=209.33ms max=628.69ms p(90)=220.15ms p(95)=327.68ms
     http_reqs......................: 1809    12.815411/s
     iteration_duration.............: avg=1.23s    min=1.2s     med=1.21s    max=2.25s    p(90)=1.23s    p(95)=1.37s   
     iterations.....................: 1809    12.815411/s
     vus............................: 1       min=1  max=30
     vus_max........................: 30      min=30 max=30

注意到指標區多了 checks 的數據,這就來自程式內的 check() 函式。


如果把 k6 整合進 CI / CD 流程內,想要在測試失敗時發出 exit code 1,那就得在 thresholds 內定義關鍵指標(metric):

import http from 'k6/http';

export let options = {
    thresholds: {
        http_req_failed: ['rate<0.01'],   // http errors should be less than 1% 
        http_req_duration: ['p(95)<200'], // 95% of requests should be below 200ms
    },
};

export default function () {
    http.get('https://test-api.k6.io/public/crocodiles/1/');
}

上面的範例定義了兩項指標與目標,如果這兩個目標沒有達到,那跑完就會是 exit code 1,而 CI / CD 就可以藉此發出 build failure 的訊息。

至此我們應該可以掌握 k6 的基本操作。

結語

k6 是個入門簡單,但也功能強大的負載測試工具,本篇文章大多數內容也都是取材自 k6 的文件,但也僅止於自己有碰到的部分做粗略地介紹,其他較深入的用法還是請參考 k6 的文件,內容相當豐富,如果還有下篇的話那應該是把測試結果發送到外部儀表板的相關內容了吧!(最近也滿關注 Grafana 的)

最後談一下 k6 的故事,k6 的創辦人最早是在 2000 年為當時開發的線上遊戲做負載測試,因此而開展了負載測試顧問的服務,一直到 2008 年,SaaS 架構興起之後,他們認為負載測試有可能變成一種 SaaS 的商業模式時即開始開發 k6 至今,而最新的消息是 Grafana 宣佈併購 k6。

在商業模式方面,k6 的 CLI 工具是開源的,而 k6 Cloud 則是收費的服務,透過 k6 Cloud 可以省去自行配置發測端機台的功夫,還可以任選發測機台的地理位置,以及提供精美的圖表,可以預期的是 k6 和 Grafana 將會有更深度的整合,希望 k6 未來還能保持開源+商業的混合模式。

最後的最後私心地送上一張帥哥自拍照!

豆干