<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Locust 的實作紀錄 on Tarragon</title><link>https://tarrragon.github.io/blog/backend/06-reliability/vendors/locust/hands-on/</link><description>Recent content in Locust 的實作紀錄 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 28 Oct 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/backend/06-reliability/vendors/locust/hands-on/index.xml" rel="self" type="application/rss+xml"/><item><title>Locust 在本機跑負載測試的實測紀錄：受測服務、locustfile、headless 執行與 CSV、內建的 exit code、quitting 事件裡的門檻判定、--reset-stats 與 on_start、master 與 worker 的分散執行</title><link>https://tarrragon.github.io/blog/backend/06-reliability/vendors/locust/hands-on/locust-local-lab/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/06-reliability/vendors/locust/hands-on/locust-local-lab/</guid><description>&lt;p>實測日期是 2026-10-28，環境是一台 macOS 筆電上的 Docker（server 29.8.2，Docker 可用 8 顆 CPU 與 8 GB 記憶體），Locust 2.47.0 跑在 &lt;code>python:3.12-slim&lt;/code>（Python 3.12.15）的 container 裡，受測服務是同一個 Docker network 裡另一個 container 跑的 Python 標準函式庫程式；這次的操作是從寫 locustfile 開始，在本機跑 headless 的負載測試，接著驗證 exit code 在幾種情形下的值，最後開一個 master 與兩個 worker 分散執行。&lt;/p>
&lt;p>這篇記錄在本機用 Docker 跑 Locust 的完整操作：一個有正常、回錯誤兩種 endpoint 的受測服務；裝了 Locust 的 image；locustfile 裡的 &lt;code>wait_time&lt;/code>、&lt;code>on_start&lt;/code> 登入、task 的權重、把不同網址併成同一列統計的 &lt;code>name&lt;/code> 與從 CSV 讀的測試資料；headless 執行的參數、終端機的統計表怎麼讀、&lt;code>--csv&lt;/code> 寫出的幾個檔案；內建的 exit code 在有失敗、沒有失敗但變慢兩種情形的值；在 &lt;code>quitting&lt;/code> 事件裡判定延遲與錯誤率的門檻，以及它怎麼蓋掉內建的判定；&lt;code>--reset-stats&lt;/code> 之後 &lt;code>on_start&lt;/code> 的請求為什麼還在統計裡；一個 master 加兩個 worker 的啟動方式、門檻判定為什麼只在 master 做，以及這次遇到的 worker heartbeat 失敗；最後是清理。受測服務與負載模型（每個使用者登入一次、商品頁與會失敗的 endpoint 四比一、每一輪停 1 到 3 秒）跟 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/vendors/jmeter/hands-on/jmeter-local-lab/" data-link-title="JMeter 在本機跑負載測試的實測紀錄：Docker image、test plan 的元件、CSV Data Set、CLI mode 與 JTL、dashboard 與 statistics.json、JMeter 之外的門檻判定、remote testing 的 client 與 server" data-link-desc="要在本機用 Docker 照一次實測跑 JMeter：建 image、寫 test plan、從 CSV 餵測試資料、用 CLI mode 跑出 JTL 與 dashboard、讀 statistics.json 判定門檻、開 client 與多台 server 做 remote testing 時讀；也用在 sample 失敗了 exit code 還是 0、CSV 裡某些值從來沒被請求到、-J 傳的屬性在 server 上沒有作用、-o 回報資料夾不是空的時候">JMeter 在本機跑負載測試的實測紀錄&lt;/a> 相同，兩個工具的輸出可以並排比較。這些操作背後的判斷，也就是 CI 用這次的量測擋下效能退化（效能 regression gate，下文簡稱 gate）時門檻怎麼設、預熱的請求怎麼處理、gate 失敗時依什麼順序排除誤判，寫在 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/vendors/locust/locust-performance-regression-gate/" data-link-title="Locust 在效能 regression gate 的用法：exit code 與門檻判定、預熱與 on_start 的請求、closed model 下的比較、custom client 回報的請求範圍、PR 與 nightly 的分工、證據保存與 gate 失敗的排查順序" data-link-desc="用 Locust 實作效能 regression gate 時的判斷：內建 exit code 之外的門檻怎麼在 quitting 事件裡判定、登入與預熱的請求怎麼混進統計、closed model 讓退化落在延遲還是吞吐量，以及 gate 失敗時怎麼把誤判和真正的退化分開">Locust 在效能 regression gate 的用法：exit code 與門檻判定、預熱與 on_start 的請求、closed model 下的比較、custom client 回報的請求範圍、PR 與 nightly 的分工、證據保存與 gate 失敗的排查順序&lt;/a>；拿 Locust 逐級加壓找飽和點的做法寫在 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/vendors/locust/locust-capacity-probing/" data-link-title="Locust 在容量探測的用法：closed model 下的逐級加壓、吞吐量與延遲的換算、pacing 的上限、worker 的檢查與排查順序" data-link-desc="Locust 只有 closed model 時找飽和點的判斷：逐級增加使用者、從每秒 task 數與延遲換算受測服務的狀態、到達率型服務的延遲低估與 pacing 的上限、worker 與 client 的排除條件，以及容量結論的寫法">Locust 在容量探測的用法：closed model 下的逐級加壓、吞吐量與延遲的換算、pacing 的上限、worker 的檢查與排查順序&lt;/a>。Web UI 的操作、Kubernetes 上的部署與 Locust Cloud 不在這次的實測裡。&lt;/p></description><content:encoded><![CDATA[<p>實測日期是 2026-10-28，環境是一台 macOS 筆電上的 Docker（server 29.8.2，Docker 可用 8 顆 CPU 與 8 GB 記憶體），Locust 2.47.0 跑在 <code>python:3.12-slim</code>（Python 3.12.15）的 container 裡，受測服務是同一個 Docker network 裡另一個 container 跑的 Python 標準函式庫程式；這次的操作是從寫 locustfile 開始，在本機跑 headless 的負載測試，接著驗證 exit code 在幾種情形下的值，最後開一個 master 與兩個 worker 分散執行。</p>
<p>這篇記錄在本機用 Docker 跑 Locust 的完整操作：一個有正常、回錯誤兩種 endpoint 的受測服務；裝了 Locust 的 image；locustfile 裡的 <code>wait_time</code>、<code>on_start</code> 登入、task 的權重、把不同網址併成同一列統計的 <code>name</code> 與從 CSV 讀的測試資料；headless 執行的參數、終端機的統計表怎麼讀、<code>--csv</code> 寫出的幾個檔案；內建的 exit code 在有失敗、沒有失敗但變慢兩種情形的值；在 <code>quitting</code> 事件裡判定延遲與錯誤率的門檻，以及它怎麼蓋掉內建的判定；<code>--reset-stats</code> 之後 <code>on_start</code> 的請求為什麼還在統計裡；一個 master 加兩個 worker 的啟動方式、門檻判定為什麼只在 master 做，以及這次遇到的 worker heartbeat 失敗；最後是清理。受測服務與負載模型（每個使用者登入一次、商品頁與會失敗的 endpoint 四比一、每一輪停 1 到 3 秒）跟 <a href="/blog/backend/06-reliability/vendors/jmeter/hands-on/jmeter-local-lab/" data-link-title="JMeter 在本機跑負載測試的實測紀錄：Docker image、test plan 的元件、CSV Data Set、CLI mode 與 JTL、dashboard 與 statistics.json、JMeter 之外的門檻判定、remote testing 的 client 與 server" data-link-desc="要在本機用 Docker 照一次實測跑 JMeter：建 image、寫 test plan、從 CSV 餵測試資料、用 CLI mode 跑出 JTL 與 dashboard、讀 statistics.json 判定門檻、開 client 與多台 server 做 remote testing 時讀；也用在 sample 失敗了 exit code 還是 0、CSV 裡某些值從來沒被請求到、-J 傳的屬性在 server 上沒有作用、-o 回報資料夾不是空的時候">JMeter 在本機跑負載測試的實測紀錄</a> 相同，兩個工具的輸出可以並排比較。這些操作背後的判斷，也就是 CI 用這次的量測擋下效能退化（效能 regression gate，下文簡稱 gate）時門檻怎麼設、預熱的請求怎麼處理、gate 失敗時依什麼順序排除誤判，寫在 <a href="/blog/backend/06-reliability/vendors/locust/locust-performance-regression-gate/" data-link-title="Locust 在效能 regression gate 的用法：exit code 與門檻判定、預熱與 on_start 的請求、closed model 下的比較、custom client 回報的請求範圍、PR 與 nightly 的分工、證據保存與 gate 失敗的排查順序" data-link-desc="用 Locust 實作效能 regression gate 時的判斷：內建 exit code 之外的門檻怎麼在 quitting 事件裡判定、登入與預熱的請求怎麼混進統計、closed model 讓退化落在延遲還是吞吐量，以及 gate 失敗時怎麼把誤判和真正的退化分開">Locust 在效能 regression gate 的用法：exit code 與門檻判定、預熱與 on_start 的請求、closed model 下的比較、custom client 回報的請求範圍、PR 與 nightly 的分工、證據保存與 gate 失敗的排查順序</a>；拿 Locust 逐級加壓找飽和點的做法寫在 <a href="/blog/backend/09-performance-capacity/vendors/locust/locust-capacity-probing/" data-link-title="Locust 在容量探測的用法：closed model 下的逐級加壓、吞吐量與延遲的換算、pacing 的上限、worker 的檢查與排查順序" data-link-desc="Locust 只有 closed model 時找飽和點的判斷：逐級增加使用者、從每秒 task 數與延遲換算受測服務的狀態、到達率型服務的延遲低估與 pacing 的上限、worker 與 client 的排除條件，以及容量結論的寫法">Locust 在容量探測的用法：closed model 下的逐級加壓、吞吐量與延遲的換算、pacing 的上限、worker 的檢查與排查順序</a>。Web UI 的操作、Kubernetes 上的部署與 Locust Cloud 不在這次的實測裡。</p>
<h2 id="受測服務標準函式庫寫的-http-程式">受測服務：標準函式庫寫的 HTTP 程式</h2>
<p>受測服務的職責是讓延遲與錯誤可以控制：負載工具量到的值變了，讀的人要知道那是受測服務被改了，還是工具或環境的雜訊。這次用一支只依賴 Python 標準函式庫的程式，放在 <code>target/app.py</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 受測服務：只用標準函式庫，讓負載工具量得到可控的延遲與錯誤。</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">#   GET /items/&lt;id&gt;   正常回應；DELAY_MS 環境變數加上固定延遲</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1">#   GET /flaky        每 ERROR_EVERY 個請求回一次 500</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">#   POST /login       回一個 token</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kn">import</span> <span class="nn">json</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kn">import</span> <span class="nn">threading</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kn">import</span> <span class="nn">time</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kn">from</span> <span class="nn">http.server</span> <span class="kn">import</span> <span class="n">BaseHTTPRequestHandler</span><span class="p">,</span> <span class="n">ThreadingHTTPServer</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">DELAY_MS</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;DELAY_MS&#34;</span><span class="p">,</span> <span class="s2">&#34;0&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">ERROR_EVERY</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;ERROR_EVERY&#34;</span><span class="p">,</span> <span class="s2">&#34;10&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">_count</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">_lock</span> <span class="o">=</span> <span class="n">threading</span><span class="o">.</span><span class="n">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="k">class</span> <span class="nc">Handler</span><span class="p">(</span><span class="n">BaseHTTPRequestHandler</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="n">protocol_version</span> <span class="o">=</span> <span class="s2">&#34;HTTP/1.1&#34;</span>   <span class="c1"># 讓用戶端重用連線（keep-alive）</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">def</span> <span class="nf">_send</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">status</span><span class="p">,</span> <span class="n">body</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="n">data</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">body</span><span class="p">)</span><span class="o">.</span><span class="n">encode</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">send_response</span><span class="p">(</span><span class="n">status</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">send_header</span><span class="p">(</span><span class="s2">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s2">&#34;application/json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">send_header</span><span class="p">(</span><span class="s2">&#34;Content-Length&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">data</span><span class="p">)))</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">end_headers</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">wfile</span><span class="o">.</span><span class="n">write</span><span class="p">(</span><span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="k">def</span> <span class="nf">do_GET</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">        <span class="k">global</span> <span class="n">_count</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="k">if</span> <span class="bp">self</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;/items/&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">            <span class="n">time</span><span class="o">.</span><span class="n">sleep</span><span class="p">(</span><span class="n">DELAY_MS</span> <span class="o">/</span> <span class="mi">1000</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">_send</span><span class="p">(</span><span class="mi">200</span><span class="p">,</span> <span class="p">{</span><span class="s2">&#34;id&#34;</span><span class="p">:</span> <span class="bp">self</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">rsplit</span><span class="p">(</span><span class="s2">&#34;/&#34;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)[</span><span class="o">-</span><span class="mi">1</span><span class="p">]})</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="k">elif</span> <span class="bp">self</span><span class="o">.</span><span class="n">path</span> <span class="o">==</span> <span class="s2">&#34;/flaky&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">            <span class="k">with</span> <span class="n">_lock</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">                <span class="n">_count</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">                <span class="n">n</span> <span class="o">=</span> <span class="n">_count</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">_send</span><span class="p">(</span><span class="mi">500</span> <span class="k">if</span> <span class="n">n</span> <span class="o">%</span> <span class="n">ERROR_EVERY</span> <span class="o">==</span> <span class="mi">0</span> <span class="k">else</span> <span class="mi">200</span><span class="p">,</span> <span class="p">{</span><span class="s2">&#34;n&#34;</span><span class="p">:</span> <span class="n">n</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">        <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">_send</span><span class="p">(</span><span class="mi">404</span><span class="p">,</span> <span class="p">{</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;not found&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">
</span></span><span class="line"><span class="ln">41</span><span class="cl">    <span class="k">def</span> <span class="nf">do_POST</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">        <span class="n">length</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">headers</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;Content-Length&#34;</span><span class="p">,</span> <span class="s2">&#34;0&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">rfile</span><span class="o">.</span><span class="n">read</span><span class="p">(</span><span class="n">length</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">        <span class="k">if</span> <span class="bp">self</span><span class="o">.</span><span class="n">path</span> <span class="o">==</span> <span class="s2">&#34;/login&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">_send</span><span class="p">(</span><span class="mi">200</span><span class="p">,</span> <span class="p">{</span><span class="s2">&#34;token&#34;</span><span class="p">:</span> <span class="s2">&#34;t-&#34;</span> <span class="o">+</span> <span class="nb">str</span><span class="p">(</span><span class="n">time</span><span class="o">.</span><span class="n">time_ns</span><span class="p">())})</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">        <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">_send</span><span class="p">(</span><span class="mi">404</span><span class="p">,</span> <span class="p">{</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;not found&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">
</span></span><span class="line"><span class="ln">49</span><span class="cl">    <span class="k">def</span> <span class="nf">log_message</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">        <span class="k">pass</span>                          <span class="c1"># 不印每個請求的 log，避免 I/O 影響延遲</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">
</span></span><span class="line"><span class="ln">52</span><span class="cl">
</span></span><span class="line"><span class="ln">53</span><span class="cl"><span class="n">ThreadingHTTPServer</span><span class="p">((</span><span class="s2">&#34;0.0.0.0&#34;</span><span class="p">,</span> <span class="mi">8080</span><span class="p">),</span> <span class="n">Handler</span><span class="p">)</span><span class="o">.</span><span class="n">serve_forever</span><span class="p">()</span></span></span></code></pre></div><p>這篇的程式碼區塊裡，<code>#</code> 開頭的那幾行是說明或預期的輸出；macOS 內建的 zsh 在互動模式下預設不把 <code>#</code> 當成註解，貼上之前先在同一個終端機分頁執行一次 <code>setopt interactivecomments</code>（只對這個 shell 有效），否則那幾行會印出 <code>command not found: #</code>；用 bash 時不需要這一步，bash 沒有 <code>setopt</code> 這個指令。<code>-v</code> 掛載本機的目錄，在 macOS 的 Docker Desktop 上這個目錄要在 Settings 的 File sharing 列出的路徑裡（預設是 <code>/Users</code>、<code>/Volumes</code>、<code>/private</code>、<code>/tmp</code>、<code>/var/folders</code>），不在的話 <code>docker run</code> 回 <code>Mounts denied</code>（依 Docker Desktop 官方文件的設定說明）；OrbStack 不限定路徑。在 Linux 主機上，container 以 root 寫出的 <code>out/</code> 歸 root 所有，刪除時要 sudo（這兩點這次都沒有實跑）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">docker network create loadlab
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker run -d --rm --name target --network loadlab <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">/target:/app:ro&#34;</span> python:3.12-slim python /app/app.py
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 從同一個 network 裡確認服務在回應</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">docker run --rm --network loadlab curlimages/curl:8.10.1 -s http://target:8080/items/42</span></span></code></pre></div><p>最後一條指令印出 <code>{&quot;id&quot;: &quot;42&quot;}</code> 就是服務起來了。這篇的指令都在 <code>target/</code>、<code>locust/</code> 兩個目錄的上一層執行。<code>loadlab</code> 是這次建的 Docker network 名稱，<code>target</code> 是受測服務的 container 名稱，也是 Locust 在同一個 network 裡連它用的主機名；<code>$PWD/target</code> 是放 <code>app.py</code> 的目錄，換成讀者自己的路徑。自建的 network 讓同一個 network 裡的 container 用名稱互相連線，所以後面的指令寫得出 <code>http://target:8080</code> 與 <code>--master-host lmaster</code>；改用 Docker 預設的 bridge network 時，container 之間不能用名稱連，要改填 container 的 IP，這次沒有實跑。受測服務與 Locust 各自一個 container，是讓兩邊不共用同一個程序的 CPU；兩者仍然在同一台機器上，Docker 的 8 顆 CPU 是共用的，這台機器量到的延遲包含兩邊爭用 CPU 的時間（<a href="/blog/backend/knowledge-cards/load-generator-saturation/" data-link-title="Load Generator Saturation（施壓端飽和）" data-link-desc="壓測的吞吐量停在某個值、延遲上升，要確認瓶頸在受測服務還是在執行負載工具的那幾台機器時讀">Load Generator Saturation</a>）。</p>
<p>受測服務可以換成讀者自己的服務：下面每一步的 <code>--host</code> 換成那個服務的位址，locustfile 裡的三個路徑（<code>/login</code>、<code>/items/&lt;id&gt;</code>、<code>/flaky</code>）、登入送出的 body 與取 token 的那一行（<code>resp.json()[&quot;token&quot;]</code>）也要換成那個服務的 endpoint 與回應格式，只換 <code>--host</code> 的話，請求會回 404，或在取 token 時拋出例外。換掉之後少的是「延遲與錯誤可控」這一點，後面驗證 exit code 的幾步要自己找出會回錯誤、會變慢的 endpoint。<code>ERROR_EVERY</code> 用預設值 10，沒有比較其他值。<a href="/blog/backend/06-reliability/vendors/jmeter/hands-on/jmeter-local-lab/" data-link-title="JMeter 在本機跑負載測試的實測紀錄：Docker image、test plan 的元件、CSV Data Set、CLI mode 與 JTL、dashboard 與 statistics.json、JMeter 之外的門檻判定、remote testing 的 client 與 server" data-link-desc="要在本機用 Docker 照一次實測跑 JMeter：建 image、寫 test plan、從 CSV 餵測試資料、用 CLI mode 跑出 JTL 與 dashboard、讀 statistics.json 判定門檻、開 client 與多台 server 做 remote testing 時讀；也用在 sample 失敗了 exit code 還是 0、CSV 裡某些值從來沒被請求到、-J 傳的屬性在 server 上沒有作用、-o 回報資料夾不是空的時候">JMeter 在本機跑負載測試的實測紀錄</a> 沿用這支程式，改動它時，那一篇的輸出也要重新核對。</p>
<h2 id="裝了-locust-的-image">裝了 Locust 的 image</h2>
<p>下面的內容存成 <code>locust/Dockerfile</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">FROM</span><span class="s"> python:3.12-slim</span><span class="err">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="err"></span><span class="k">RUN</span> pip install --no-cache-dir <span class="nv">locust</span><span class="o">==</span>2.47.0<span class="err">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="err"></span><span class="k">WORKDIR</span><span class="s"> /mnt/locust</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">docker build -t lab-locust locust/
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker run --rm lab-locust locust --version
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># locust 2.47.0 from /usr/local/lib/python3.12/site-packages/locust (Python 3.12.15)</span></span></span></code></pre></div><p>Dockerfile 把版本寫死成這次實測的 2.47.0：下文引用的預設值與原始碼常數都是這個版本的，換版本時先改這一行，再核對那幾處；拿掉 <code>==2.47.0</code> 時 pip 裝到當時最新的版本，兩次執行的 Locust 可能不同。基底的 <code>python:3.12-slim</code> 這個 tag 會跟著 Python 的 patch 版更新，這次是 3.12.15；要讓兩次執行的基底也相同，改用帶完整版本的 tag 或 <code>@sha256:&lt;建 image 當天的 digest&gt;</code>，這次沒有實跑。<code>locust/</code> 是放 Dockerfile 與 locustfile 的目錄，<code>WORKDIR /mnt/locust</code> 是之後把這個目錄掛進 container 的位置，locustfile 裡用相對路徑讀的 <code>items.csv</code> 從這裡找。</p>
<p>這一步有兩種替代做法，這次都沒有實跑。一種是在本機的 Python 虛擬環境裡 <code>pip install locust</code>，換到的是不必經過 Docker、改 locustfile 之後直接重跑，付出的是本機的 Python 版本與套件會影響結果，交給 CI 時要再做一次環境；另一種是官方的 <code>locustio/locust</code> image（官方文件〈Running in Docker〉），換到的是不必自己寫 Dockerfile，付出的是要額外的 Python 套件時仍然要以它為基礎另建一個 image。</p>
<h2 id="locustfilewait_timeon_starttask-的權重與測試資料">locustfile：wait_time、on_start、task 的權重與測試資料</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">csv</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">import</span> <span class="nn">random</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kn">from</span> <span class="nn">locust</span> <span class="kn">import</span> <span class="n">HttpUser</span><span class="p">,</span> <span class="n">between</span><span class="p">,</span> <span class="n">events</span><span class="p">,</span> <span class="n">tag</span><span class="p">,</span> <span class="n">task</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kn">from</span> <span class="nn">locust.runners</span> <span class="kn">import</span> <span class="n">WorkerRunner</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 測試資料：每一列一個商品編號，所有使用者共用同一份清單</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="s2">&#34;items.csv&#34;</span><span class="p">,</span> <span class="n">newline</span><span class="o">=</span><span class="s2">&#34;&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">ITEM_IDS</span> <span class="o">=</span> <span class="p">[</span><span class="n">row</span><span class="p">[</span><span class="s2">&#34;item_id&#34;</span><span class="p">]</span> <span class="k">for</span> <span class="n">row</span> <span class="ow">in</span> <span class="n">csv</span><span class="o">.</span><span class="n">DictReader</span><span class="p">(</span><span class="n">f</span><span class="p">)]</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="k">class</span> <span class="nc">Shopper</span><span class="p">(</span><span class="n">HttpUser</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="c1"># 每個 task 跑完之後停 1 到 3 秒，模擬使用者的停頓</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="n">wait_time</span> <span class="o">=</span> <span class="n">between</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">def</span> <span class="nf">on_start</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="c1"># 每個使用者啟動時登入一次，token 存在這個使用者自己的 instance 上</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="n">resp</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">client</span><span class="o">.</span><span class="n">post</span><span class="p">(</span><span class="s2">&#34;/login&#34;</span><span class="p">,</span> <span class="n">json</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;user&#34;</span><span class="p">:</span> <span class="s2">&#34;load-test&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">token</span> <span class="o">=</span> <span class="n">resp</span><span class="o">.</span><span class="n">json</span><span class="p">()[</span><span class="s2">&#34;token&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nd">@task</span><span class="p">(</span><span class="mi">4</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">def</span> <span class="nf">view_item</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="n">item_id</span> <span class="o">=</span> <span class="n">random</span><span class="o">.</span><span class="n">choice</span><span class="p">(</span><span class="n">ITEM_IDS</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="c1"># name 把不同 id 的請求併成同一列統計，否則每個 id 各占一列</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">client</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;/items/</span><span class="si">{</span><span class="n">item_id</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="s2">&#34;/items/[id]&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">                        <span class="n">headers</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Authorization&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;Bearer </span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">token</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="nd">@tag</span><span class="p">(</span><span class="s2">&#34;flaky&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="nd">@task</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="k">def</span> <span class="nf">flaky</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">client</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;/flaky&#34;</span><span class="p">)</span></span></span></code></pre></div><p>上面的程式存成 <code>locust/locustfile.py</code>，同一個目錄放 <code>items.csv</code>：第一行是表頭 <code>item_id</code>，下面五行是 <code>101</code> 到 <code>105</code>。這份檔案最後還要加一段 <code>quitting</code> 事件的處理函式（listener），在〈quitting 事件裡的門檻判定〉那一節說明；那一節之前的幾步跑的是 <code>locust/locustfile_basic.py</code>，它是上面這段程式去掉只有 listener 用到的 <code>import os</code>、<code>from locust.runners import WorkerRunner</code> 兩行，並把 <code>events</code> 從 <code>from locust import</code> 那一行拿掉的版本。分成兩個檔，是因為〈內建的 exit code〉要看沒有 listener 時 Locust 自己算出的 exit code；用加了 listener 的 <code>locustfile.py</code> 跑那幾步，exit code 由 listener 決定，量不到內建的判定。</p>
<p>Locust 的一個使用者是 <code>Shopper</code> 類別的一個 instance，task 是帶 <code>@task</code> 的方法；每個使用者反覆挑一個 task 執行、停頓、再挑下一個。各段的作用：</p>
<ul>
<li><strong><code>wait_time = between(1, 3)</code></strong>：每個使用者跑完一個 task 之後停 1 到 3 秒的隨機時間，再挑下一個 task。使用者等到回應、停完才送下一個請求，所以這份 locustfile 下的 Locust 是 closed model：受測服務變慢時每秒請求數跟著下降（<a href="/blog/backend/knowledge-cards/open-closed-workload-model/" data-link-title="Open and Closed Workload Model（開放與封閉負載模型）" data-link-desc="設計壓測的注入方式、要在固定到達率與固定同時在線人數之間選一種，或壓測量到的延遲低於 production、懷疑負載工具少送了請求時讀">Open and Closed Workload Model</a>）。不寫 <code>wait_time</code> 時，預設是 <code>constant(0)</code>（2.47.0 的原始碼 <code>locust/user/users.py</code>），不停頓、task 一個接一個跑，每個使用者送出請求的速率只受受測服務回應的快慢限制，量到的是受測服務的吞吐上限，跟有停頓的使用者行為不同。另外兩種寫法這次沒有實跑：<code>constant_pacing(n)</code> 讓每個使用者每 n 秒開始一個 task，扣掉 task 花掉的時間才停頓；<code>constant_throughput(r)</code> 是同一件事的倒數寫法，每個使用者每秒至多 r 個 task。兩者都是 pacing，受測服務變慢、一個 task 花掉的時間超過間隔時，速率就低於設定值，仍然是 closed model（<a href="/blog/backend/knowledge-cards/open-closed-workload-model/" data-link-title="Open and Closed Workload Model（開放與封閉負載模型）" data-link-desc="設計壓測的注入方式、要在固定到達率與固定同時在線人數之間選一種，或壓測量到的延遲低於 production、懷疑負載工具少送了請求時讀">Open and Closed Workload Model</a> 的 pacing 那一段）。</li>
<li><strong><code>on_start</code></strong>：每個使用者啟動時跑一次，這裡用來登入、拿 token。登入寫在 task 裡的話，每次挑到那個 task 都登入一次，登入的 endpoint 變成主要的負載來源。<code>on_start</code> 裡的請求照樣算進統計，下面的統計表裡 <code>POST /login</code> 有自己的一列。<code>HttpUser</code> 預設每個使用者一個連線池，連線在同一個使用者的各個 task 之間重用（官方文件〈Writing a locustfile〉），所以每個使用者只有第一個請求、也就是 <code>on_start</code> 的登入要建立連線，<code>POST /login</code> 的延遲包含建立連線的時間（審查時另外實跑過：在受測服務裡數連線，3 個使用者跑 15 秒、送出 22 個請求，連線正好 3 條；前提是受測服務用 HTTP/1.1，<code>app.py</code> 設了 <code>protocol_version = &quot;HTTP/1.1&quot;</code>。建立連線的時間沒有從延遲裡分開量）；受測服務換成 HTTPS 時，這個設定決定握手的次數（<a href="/blog/backend/knowledge-cards/tls-handshake-cost/" data-link-title="TLS Handshake（TLS 握手）的成本與連線重用" data-link-desc="壓測量到的上限落在負載平衡器或反向代理的 CPU、Connect Time 隨負載上升，或要判斷負載工具該不該在每一輪重建連線時讀">TLS Handshake（TLS 握手）的成本與連線重用</a>）。</li>
<li><strong><code>@task(4)</code> 與 <code>@task(1)</code></strong>：權重是相對值，使用者挑下一個 task 時，<code>view_item</code> 被挑中的機率是 4/5，<code>flaky</code> 是 1/5。</li>
<li><strong><code>name=&quot;/items/[id]&quot;</code></strong>：Locust 依請求的名稱分列統計，名稱預設是網址的路徑；不給 <code>name</code> 時 <code>/items/101</code> 到 <code>/items/105</code> 各占一列，每一列的樣本少、百分位數不穩。<code>[id]</code> 只是給人讀的名稱，Locust 不解析它。</li>
<li><strong><code>@tag(&quot;flaky&quot;)</code></strong>：讓命令列可以用 <code>--exclude-tags flaky</code> 排除這個 task，後面驗證「沒有失敗但變慢」的情形時用到。</li>
<li><strong>測試資料在模組層讀一次</strong>：所有使用者共用 <code>ITEM_IDS</code>，每個使用者用 <code>random.choice</code> 挑。這次的五個值只用來示範；資料的種類太少時，受測服務的快取命中率比 production 高，量到的延遲偏低，資料怎麼取得接近 production 的分佈見 <a href="/blog/backend/09-performance-capacity/workload-modeling/" data-link-title="9.2 Workload Modeling" data-link-desc="把 production traffic shape 翻成可重播的壓測模型">9.2 Workload Modeling</a>。</li>
</ul>
<h2 id="headless-執行終端機的統計表與-csv">headless 執行、終端機的統計表與 CSV</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">docker run --rm --network loadlab -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">/locust:/mnt/locust&#34;</span> lab-locust <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  locust -f locustfile_basic.py --headless -u <span class="m">20</span> -r <span class="m">5</span> -t 30s <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --host http://target:8080 --csv out/run1 --html out/run1.html --only-summary
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;exit=</span><span class="nv">$?</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># exit=1（原因在〈內建的 exit code〉）</span></span></span></code></pre></div><p>參數的意思：<code>--headless</code> 不開 Web UI、直接開始；<code>-u 20</code> 是同時在線的使用者數；Locust 照 <code>-r 5</code> 每秒啟動 5 個使用者，第一批在第 0 秒啟動，這次 20 個在第 3.1 秒全部啟動；<code>-t 30s</code> 是從開始到結束的時間，包含啟動的那 3 秒；<code>--host</code> 是受測服務的位址，換成讀者自己的服務；Locust 依 <code>--csv out/run1</code> 在工作目錄 <code>/mnt/locust</code> 底下的 <code>out/</code>（也就是本機的 <code>locust/out/</code>，Locust 會自己建這個資料夾）寫出以 <code>run1</code> 開頭的幾個 CSV；<code>--html</code> 寫一份 HTML 報告；<code>--only-summary</code> 只在結束時印一次統計表，不在執行中每幾秒印一次。這次的使用者數、啟動速率與時間是示範用的值，沒有比較其他值；<code>-u</code> 與 <code>-t</code> 決定這次量的是哪一級負載、樣本有多少，gate 怎麼取見 <a href="/blog/backend/06-reliability/vendors/locust/locust-performance-regression-gate/" data-link-title="Locust 在效能 regression gate 的用法：exit code 與門檻判定、預熱與 on_start 的請求、closed model 下的比較、custom client 回報的請求範圍、PR 與 nightly 的分工、證據保存與 gate 失敗的排查順序" data-link-desc="用 Locust 實作效能 regression gate 時的判斷：內建 exit code 之外的門檻怎麼在 quitting 事件裡判定、登入與預熱的請求怎麼混進統計、closed model 讓退化落在延遲還是吞吐量，以及 gate 失敗時怎麼把誤判和真正的退化分開">Locust 在效能 regression gate 的用法</a> 的〈PR 與 nightly 兩種執行的分工〉，容量探測怎麼逐級加壓見 <a href="/blog/backend/09-performance-capacity/vendors/locust/locust-capacity-probing/" data-link-title="Locust 在容量探測的用法：closed model 下的逐級加壓、吞吐量與延遲的換算、pacing 的上限、worker 的檢查與排查順序" data-link-desc="Locust 只有 closed model 時找飽和點的判斷：逐級增加使用者、從每秒 task 數與延遲換算受測服務的狀態、到達率型服務的延遲低估與 pacing 的上限、worker 與 client 的排除條件，以及容量結論的寫法">Locust 在容量探測的用法</a> 的〈closed model 下的逐級加壓：LoadTestShape 的 tick〉。</p>
<p>結束時終端機印出兩張統計表（這次的輸出，欄寬縮短），有失敗的請求時接著再印一張依錯誤訊息計數的 Error report：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">Type     Name           # reqs      # fails |    Avg     Min     Max    Med |   req/s  failures/s
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">GET      /flaky             55    6(10.91%) |      9       1      52      4 |    1.92        0.21
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">GET      /items/[id]       225     0(0.00%) |      8       1     107      5 |    7.87        0.00
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">POST     /login             20     0(0.00%) |     73      15     236     34 |    0.70        0.00
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">         Aggregated        300     6(2.00%) |     13       1     236      5 |   10.49        0.21
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">Response time percentiles (approximated)
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">Type     Name               50%    66%    75%    80%    90%    95%    98%    99%  99.9% 99.99%   100% # reqs
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">GET      /flaky               4      6      7      9     34     47     48     53     53     53     53     55
</span></span><span class="line"><span class="ln">10</span><span class="cl">GET      /items/[id]          5      6      7      8     12     46     52     57    110    110    110    225
</span></span><span class="line"><span class="ln">11</span><span class="cl">POST     /login              34     40    200    210    220    240    240    240    240    240    240     20
</span></span><span class="line"><span class="ln">12</span><span class="cl">         Aggregated           5      7      9     10     39     49    110    210    240    240    240    300</span></span></code></pre></div><p>上面那張表每一列是一個請求名稱：<code># fails</code> 是失敗的請求數與比例，<code>Avg</code>、<code>Min</code>、<code>Max</code>、<code>Med</code> 是毫秒，<code>req/s</code> 是整段時間的平均每秒請求數。下面那張是百分位數，標題寫著 approximated：Locust 把回應時間分桶計數，百分位數是從桶算出來的近似值。<code>Aggregated</code> 是全部請求合起來的那一列，後面門檻判定用的 <code>environment.stats.total</code> 就是它。</p>
<p>讀這張表時有三件事要分開看。一是 <code>POST /login</code> 剛好 20 筆，每個使用者的 <code>on_start</code> 各一筆，登入的延遲算進了 <code>Aggregated</code>；二是 <code>/flaky</code> 的失敗率 10.91% 接近受測服務每 10 個請求回一次 500 的設定，<code>Aggregated</code> 被 <code>/items/[id]</code> 的大量成功請求攤成 2.00%，所以錯誤率的門檻只看 <code>Aggregated</code> 時，單一 endpoint 的高錯誤率可能過得了門檻；三是這張表的延遲是這台機器、這一次的值，受測服務、Locust 與 Docker 共用同一台筆電的 CPU。這次實測裡 <code>-u</code> 10 到 20、有沒有 <code>--reset-stats</code> 的幾次執行，Aggregated 的 p95 在 48 到 380 毫秒之間，同樣的 <code>-u 20 -r 5 -t 30s</code> 在不同時間跑兩次，一次 380、一次 49，所以這裡的數字只用來看表的形狀，不能拿來當門檻；門檻要從讀者自己的環境重跑幾次的波動範圍取（<a href="/blog/backend/06-reliability/performance-regression-gate/" data-link-title="6.13 Performance Regression Gate" data-link-desc="把效能 baseline 從一次性壓測變成持續對齊的 release gate，涵蓋 baseline 設定、判讀方法、variance 控制與退化定位">6.13 Performance Regression Gate</a>〈Regression 判讀方法〉）。</p>
<p><code>--csv out/run1</code> 寫出四個檔案：<code>run1_stats.csv</code> 每個請求名稱一列，欄位跟終端機的兩張表相同（多一欄 <code>Average Content Size</code>），但它是執行中每秒覆寫一次的快照，最後一次寫在結束之前，這次的 Aggregated 是 296 筆，終端機的表是 300 筆；<code>run1_stats_history.csv</code> 是執行中每秒一列的快照（2.47.0 的原始碼 <code>locust/stats.py</code> 的 <code>CSV_STATS_INTERVAL_SEC = 1</code>）；<code>run1_failures.csv</code> 依錯誤訊息計數；<code>run1_exceptions.csv</code> 記 locustfile 裡沒有處理的例外。</p>
<p>這次沒有加 <code>--csv-full-history</code>，所以 <code>_stats_history.csv</code> 每秒只有 Aggregated 一列，<code>Requests/s</code> 是最近十秒左右的平均，百分位數與以 Total 開頭的欄位（<code>Total Average Response Time</code> 這類）都從執行開始累積（原始碼 <code>StatsCSV._stats_history_data_rows</code> 只在 full history 時改用最近一段時間的百分位數）；加了 <code>--csv-full-history</code> 時，每個請求名稱各一列、百分位數用最近一段時間的視窗。累積的平均延遲兩列直接相減得不到那一段時間的平均，要用兩列的總筆數加權換算，換算寫在 <a href="/blog/backend/09-performance-capacity/vendors/locust/locust-capacity-probing/" data-link-title="Locust 在容量探測的用法：closed model 下的逐級加壓、吞吐量與延遲的換算、pacing 的上限、worker 的檢查與排查順序" data-link-desc="Locust 只有 closed model 時找飽和點的判斷：逐級增加使用者、從每秒 task 數與延遲換算受測服務的狀態、到達率型服務的延遲低估與 pacing 的上限、worker 與 client 的排除條件，以及容量結論的寫法">Locust 在容量探測的用法：closed model 下的逐級加壓、吞吐量與延遲的換算、pacing 的上限、worker 的檢查與排查順序</a> 的〈從每秒請求數與延遲判讀受測服務的狀態〉。</p>
<p>CI 的後處理讀 <code>_stats.csv</code> 時，量到的是結束前最後一次寫入的快照，筆數與百分位數可能比終端機的表少一截；門檻要跟終端機一致時，判定寫在〈quitting 事件裡的門檻判定〉的 listener 裡。</p>
<h2 id="內建的-exit-code有失敗沒有失敗但變慢">內建的 exit code：有失敗、沒有失敗但變慢</h2>
<p>〈headless 執行、終端機的統計表與 CSV〉那一次執行的 exit code 是 1。Locust 結束時，locustfile 沒有自己設定 exit code 的話，有任何失敗的請求或 task 裡未處理的例外時回 1（這個值可以用 <code>--exit-code-on-error</code> 換），listener 這類事件處理裡未處理的例外回 2，都沒有時回 0（官方文件〈Running without the web UI〉的〈Controlling the exit code of the Locust process〉；2.47.0 的原始碼 <code>locust/main.py</code> 的 <code>shutdown()</code>）。JMeter 的約定相反：同一個受測服務、同樣有失敗的請求，JMeter 的 CLI mode 回 0（<a href="/blog/backend/06-reliability/vendors/jmeter/hands-on/jmeter-local-lab/" data-link-title="JMeter 在本機跑負載測試的實測紀錄：Docker image、test plan 的元件、CSV Data Set、CLI mode 與 JTL、dashboard 與 statistics.json、JMeter 之外的門檻判定、remote testing 的 client 與 server" data-link-desc="要在本機用 Docker 照一次實測跑 JMeter：建 image、寫 test plan、從 CSV 餵測試資料、用 CLI mode 跑出 JTL 與 dashboard、讀 statistics.json 判定門檻、開 client 與多台 server 做 remote testing 時讀；也用在 sample 失敗了 exit code 還是 0、CSV 裡某些值從來沒被請求到、-J 傳的屬性在 server 上沒有作用、-o 回報資料夾不是空的時候">JMeter 在本機跑負載測試的實測紀錄</a> 的〈CLI mode 執行與 JTL〉）。這次分三種情形實跑：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 有失敗的請求：/flaky 會回 500</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">docker run --rm --network loadlab -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">/locust:/mnt/locust&#34;</span> lab-locust <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  locust -f locustfile_basic.py --headless -u <span class="m">10</span> -r <span class="m">5</span> -t 15s <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  --host http://target:8080 --only-summary
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># exit=1</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># 排除 /flaky：沒有失敗的請求</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">docker run --rm --network loadlab -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">/locust:/mnt/locust&#34;</span> lab-locust <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  locust -f locustfile_basic.py --headless -u <span class="m">10</span> -r <span class="m">5</span> -t 15s <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --host http://target:8080 --only-summary --exclude-tags flaky
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># exit=0</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># 有失敗時改回 3</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">docker run --rm --network loadlab -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">/locust:/mnt/locust&#34;</span> lab-locust <span class="se">\
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="se"></span>  locust -f locustfile_basic.py --headless -u <span class="m">10</span> -r <span class="m">5</span> -t 10s <span class="se">\
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="se"></span>  --host http://target:8080 --only-summary --exit-code-on-error <span class="m">3</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"># exit=3</span></span></span></code></pre></div><p>接著讓受測服務每個請求多等 400 毫秒，再跑一次排除 <code>/flaky</code> 的那一條：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">docker rm -f target
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker run -d --rm --name target --network loadlab -e <span class="nv">DELAY_MS</span><span class="o">=</span><span class="m">400</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">/target:/app:ro&#34;</span> python:3.12-slim python /app/app.py
</span></span><span class="line"><span class="ln">4</span><span class="cl">docker run --rm --network loadlab -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">/locust:/mnt/locust&#34;</span> lab-locust <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>  locust -f locustfile_basic.py --headless -u <span class="m">10</span> -r <span class="m">5</span> -t 15s <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  --host http://target:8080 --only-summary --exclude-tags flaky
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">#          Aggregated   65     0(0.00%) |    456      45    1879    420 | ...</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"># exit=0</span></span></span></code></pre></div><p>平均延遲從前一次的幾十毫秒升到 456 毫秒，exit code 仍然是 0：內建的判定只看有沒有失敗，不看延遲。CI 只看 exit code 的話，這一次變慢的執行照樣讓 job 通過，所以延遲的門檻要另外判定，寫法在〈quitting 事件裡的門檻判定〉。另一種做法是在 Locust 之外用 shell 或一支程式讀 <code>_stats.csv</code> 判定（這次沒有實跑），換到的是判定跟 locustfile 分開、同一支程式可以給別的工具用，付出的是 CI 的指令要多一步、判定與 locustfile 分兩處版控；寫在 locustfile 裡時兩者一起版控，CI 的指令不必改。</p>
<h2 id="quitting-事件裡的門檻判定">quitting 事件裡的門檻判定</h2>
<p><code>quitting</code> 事件在 Locust 結束、算出 exit code 之前觸發；listener 設了 <code>environment.process_exit_code</code>，Locust 就用這個值當 exit code，不再看有沒有失敗。locustfile 最後加上這一段：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nd">@events.quitting.add_listener</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">def</span> <span class="nf">check_thresholds</span><span class="p">(</span><span class="n">environment</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="c1"># 內建的 exit code 只看有沒有失敗；延遲與錯誤率的門檻在這裡自己判定</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">environment</span><span class="o">.</span><span class="n">runner</span><span class="p">,</span> <span class="n">WorkerRunner</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">return</span>  <span class="c1"># worker 每 3 秒把統計送給 master 並清空，判定只在 master 或單機做</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">max_fail_ratio</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;GATE_MAX_FAIL_RATIO&#34;</span><span class="p">,</span> <span class="s2">&#34;0.05&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">max_p95_ms</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;GATE_MAX_P95_MS&#34;</span><span class="p">,</span> <span class="s2">&#34;300&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">total</span> <span class="o">=</span> <span class="n">environment</span><span class="o">.</span><span class="n">stats</span><span class="o">.</span><span class="n">total</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="n">total</span><span class="o">.</span><span class="n">num_requests</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;GATE ERROR: no requests&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="n">environment</span><span class="o">.</span><span class="n">process_exit_code</span> <span class="o">=</span> <span class="mi">4</span>  <span class="c1"># 沒有樣本：測試本身沒跑成，跟門檻不成立分開</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="n">p95</span> <span class="o">=</span> <span class="n">total</span><span class="o">.</span><span class="n">get_response_time_percentile</span><span class="p">(</span><span class="mf">0.95</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="n">total</span><span class="o">.</span><span class="n">fail_ratio</span> <span class="o">&gt;</span> <span class="n">max_fail_ratio</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;GATE FAIL: fail ratio </span><span class="si">{</span><span class="n">total</span><span class="o">.</span><span class="n">fail_ratio</span><span class="si">:</span><span class="s2">.3f</span><span class="si">}</span><span class="s2"> &gt; </span><span class="si">{</span><span class="n">max_fail_ratio</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="n">environment</span><span class="o">.</span><span class="n">process_exit_code</span> <span class="o">=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">elif</span> <span class="n">p95</span> <span class="o">&gt;</span> <span class="n">max_p95_ms</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;GATE FAIL: p95 </span><span class="si">{</span><span class="n">p95</span><span class="si">}</span><span class="s2"> ms &gt; </span><span class="si">{</span><span class="n">max_p95_ms</span><span class="si">}</span><span class="s2"> ms&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="n">environment</span><span class="o">.</span><span class="n">process_exit_code</span> <span class="o">=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;GATE PASS: fail ratio </span><span class="si">{</span><span class="n">total</span><span class="o">.</span><span class="n">fail_ratio</span><span class="si">:</span><span class="s2">.3f</span><span class="si">}</span><span class="s2">, p95 </span><span class="si">{</span><span class="n">p95</span><span class="si">}</span><span class="s2"> ms&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="n">environment</span><span class="o">.</span><span class="n">process_exit_code</span> <span class="o">=</span> <span class="mi">0</span></span></span></code></pre></div><p>兩個門檻從環境變數讀，CI 的 job 用 <code>-e GATE_MAX_P95_MS=...</code> 傳進去，門檻改了不必改 locustfile；<code>0.05</code> 與 <code>300</code> 是佔位的預設值，實際的值從基準版本在同一個環境量到的結果取（<a href="/blog/backend/06-reliability/performance-regression-gate/" data-link-title="6.13 Performance Regression Gate" data-link-desc="把效能 baseline 從一次性壓測變成持續對齊的 release gate，涵蓋 baseline 設定、判讀方法、variance 控制與退化定位">6.13 Performance Regression Gate</a>〈Baseline 設定〉）。這次的環境沒有加延遲時 p95 就到過 380 毫秒，300 會連正常的執行一起擋下；下面加 400 毫秒延遲的那一次判成 FAIL，只說明 listener 的判定路徑有作用，不說明 300 是這個環境適用的門檻。<code>WorkerRunner</code> 那一行在分散執行時才有作用，原因在〈master 與兩個 worker〉那一節。<code>num_requests == 0</code> 那一段擋的是一次沒有送出任何請求的執行：沒有它時，空的統計裡錯誤率是 0、p95 是 0，兩個門檻都通過，listener 回 0，CI 放行一次什麼都沒量到的執行；這次分散執行時實際遇到過這種情形（〈master 與兩個 worker〉的 heartbeat 那一段），加了這一段之後那條路徑沒有重跑，回 4 是照程式推出的結果。</p>
<p><code>num_requests == 0</code> 那一段涵蓋不到最常見的一種沒跑成：受測服務整個連不上時，<code>on_start</code> 的登入就失敗，請求數不是 0、錯誤率是 100%，listener 回的是 1，跟退化分不開（審查時另外實跑過：<code>--host</code> 指向不存在的 container，<code>POST /login</code> 5 筆全部失敗，listener 印 <code>GATE FAIL: fail ratio 1.000 &gt; 0.05</code>）；要把它也歸到 4，另外判錯誤率是不是 100%，或在測試之前先打一次健康檢查。受測環境只有一部分故障時（一台 replica 掛掉、連線被重設），健康檢查會通過，錯誤率落在 0 與 100% 之間，這兩種做法都把它判成退化（審查時讓受測服務對一部分連線不回應就關閉，Error report 是 <code>RemoteDisconnected('Remote end closed connection without response')</code>，失敗率 14%，listener 回 1）；分辨的依據是 Error report 裡錯誤的類別，連線層的錯誤（<code>RemoteDisconnected</code>、<code>ConnectionRefusedError</code>）指向受測環境，HTTP 的錯誤狀態碼才可能是這一版程式的退化，錯誤率本身分不出來（把錯誤類別寫進判定這次沒有實跑）。門檻的環境變數不是數字時，<code>float()</code> 在 listener 裡拋出例外，exit code 沒被設定，回的是內建判定的 1 或 2。4 是這支 listener 自己的約定，<a href="/blog/backend/06-reliability/vendors/jmeter/hands-on/jmeter-local-lab/" data-link-title="JMeter 在本機跑負載測試的實測紀錄：Docker image、test plan 的元件、CSV Data Set、CLI mode 與 JTL、dashboard 與 statistics.json、JMeter 之外的門檻判定、remote testing 的 client 與 server" data-link-desc="要在本機用 Docker 照一次實測跑 JMeter：建 image、寫 test plan、從 CSV 餵測試資料、用 CLI mode 跑出 JTL 與 dashboard、讀 statistics.json 判定門檻、開 client 與多台 server 做 remote testing 時讀；也用在 sample 失敗了 exit code 還是 0、CSV 裡某些值從來沒被請求到、-J 傳的屬性在 server 上沒有作用、-o 回報資料夾不是空的時候">JMeter 在本機跑負載測試的實測紀錄</a> 的 <code>gate.py</code> 也用 4 表示「測試本身沒跑成」。</p>
<p>拿受測服務多等 400 毫秒的那個狀態跑：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">docker run --rm --network loadlab -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">/locust:/mnt/locust&#34;</span> lab-locust <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  locust -f locustfile.py --headless -u <span class="m">10</span> -r <span class="m">5</span> -t 15s <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --host http://target:8080 --only-summary --exclude-tags flaky
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># GATE FAIL: p95 480 ms &gt; 300.0 ms</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># exit=1</span></span></span></code></pre></div><p>同一個狀態，內建的判定回 0，listener 判出 p95 超過門檻、回 1。listener 在門檻通過時把 exit code 設成 0，這時即使有失敗的請求，內建的判定也被蓋掉；下面讓受測服務恢復成不延遲、放寬延遲門檻再跑一次，看這個情形：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 受測服務恢復成不延遲：不帶 DELAY_MS 重新啟動</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">docker rm -f target
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">docker run -d --rm --name target --network loadlab <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>  -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">/target:/app:ro&#34;</span> python:3.12-slim python /app/app.py
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># -e 是 docker run 的參數，要寫在 image 名稱 lab-locust 之前；</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"># 寫在 image 名稱之後，docker 會把它當成要在 container 裡執行的指令（exit 127）</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">docker run --rm --network loadlab -e <span class="nv">GATE_MAX_P95_MS</span><span class="o">=</span><span class="m">5000</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>  -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">/locust:/mnt/locust&#34;</span> lab-locust <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>  locust -f locustfile.py --headless -u <span class="m">10</span> -r <span class="m">5</span> -t 15s <span class="se">\
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="se"></span>  --host http://target:8080 --only-summary
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">#          Aggregated   77     1(1.30%) | ...</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># GATE PASS: fail ratio 0.013, p95 80 ms</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># exit=0</span></span></span></code></pre></div><p>受測服務剛重啟，<code>/flaky</code> 要累積到第 10 個請求才回第一個 500，15 秒裡送出的 <code>/flaky</code> 少於 10 個時失敗數是 0，看不到這個情形，那時把 <code>-t</code> 拉長到 30 秒重跑。這次有 1 個失敗的請求，失敗率 1.3% 低於 5% 的門檻，exit code 是 0：錯誤率門檻允許一定比例的失敗，listener 判它通過，同時把內建的「有失敗就回 1」蓋掉了。要讓任何失敗都擋下時，把 <code>GATE_MAX_FAIL_RATIO</code> 設成 0，或在 listener 裡另外看 <code>total.num_failures</code>。</p>
<p>這一段 listener 是最小的寫法，照抄進 CI 之前有三件事要知道。一是門檻不成立時設 1，跟沒有 listener 時內建判定「有失敗的請求」用的 1 是同一個值，CI 換過 locustfile、同一條 job 跑兩種 locustfile，或 listener 本身拋出例外而又有失敗的請求時，分不出這次的 1 是門檻不成立還是零星的失敗；二是門檻通過時設 0 會蓋掉內建判定，task 裡未處理的例外也一起被蓋掉；三是只判 <code>stats.total</code>，〈headless 執行、終端機的統計表與 CSV〉那一次已經看到單一 endpoint 的高錯誤率被攤平，要逐個請求名稱判定時改讀 <code>environment.stats.get(&lt;name&gt;, &lt;method&gt;)</code>（這次沒有實跑）。門檻不成立改用另一個 exit code、通過時不設 0、依關鍵流程各自判定的寫法與理由，見 <a href="/blog/backend/06-reliability/vendors/locust/locust-performance-regression-gate/" data-link-title="Locust 在效能 regression gate 的用法：exit code 與門檻判定、預熱與 on_start 的請求、closed model 下的比較、custom client 回報的請求範圍、PR 與 nightly 的分工、證據保存與 gate 失敗的排查順序" data-link-desc="用 Locust 實作效能 regression gate 時的判斷：內建 exit code 之外的門檻怎麼在 quitting 事件裡判定、登入與預熱的請求怎麼混進統計、closed model 讓退化落在延遲還是吞吐量，以及 gate 失敗時怎麼把誤判和真正的退化分開">Locust 在效能 regression gate 的用法：exit code 與門檻判定、預熱與 on_start 的請求、closed model 下的比較、custom client 回報的請求範圍、PR 與 nightly 的分工、證據保存與 gate 失敗的排查順序</a> 的〈quitting listener 的寫法：統計的範圍、百分位數的分桶與回應的驗證〉。</p>
<h2 id="reset-stats-與-on_start-的請求">&ndash;reset-stats 與 on_start 的請求</h2>
<p>統計表裡的 <code>POST /login</code> 是 <code>on_start</code> 送的，登入只在啟動時發生，它的延遲跟穩態下的請求混在一起。<code>--reset-stats</code> 讓 Locust 在使用者全部啟動之後清空一次統計；下面這次執行看清空之後，<code>on_start</code> 送的 <code>POST /login</code> 還留下幾筆：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">docker run --rm --network loadlab -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">/locust:/mnt/locust&#34;</span> lab-locust <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  locust -f locustfile.py --headless -u <span class="m">20</span> -r <span class="m">5</span> -t 30s <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --host http://target:8080 --only-summary --reset-stats
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># ... locust.runners: Resetting stats</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># POST     /login     5     0(0.00%) | ...</span></span></span></code></pre></div><p>清空之後 <code>POST /login</code> 還剩 5 筆，不是 0。<code>-r 5</code> 每秒啟動 5 個使用者，最後一秒啟動的那 5 個，它們的 <code>on_start</code> 在統計清空的時候還沒跑完：「使用者全部啟動」這件事在最後一批使用者被建立時就成立，不等它們的 <code>on_start</code> 結束。所以 <code>--reset-stats</code> 排除得了大部分的登入，排除不了最後一批；要完全排除時，門檻只看量測的那幾個請求名稱、不看 Aggregated，或 listener 判定時從統計裡去掉 <code>/login</code> 那一列（兩種這次都沒有實跑）。預熱的請求還有哪些處理方式、各自的代價，見 <a href="/blog/backend/06-reliability/vendors/locust/locust-performance-regression-gate/" data-link-title="Locust 在效能 regression gate 的用法：exit code 與門檻判定、預熱與 on_start 的請求、closed model 下的比較、custom client 回報的請求範圍、PR 與 nightly 的分工、證據保存與 gate 失敗的排查順序" data-link-desc="用 Locust 實作效能 regression gate 時的判斷：內建 exit code 之外的門檻怎麼在 quitting 事件裡判定、登入與預熱的請求怎麼混進統計、closed model 讓退化落在延遲還是吞吐量，以及 gate 失敗時怎麼把誤判和真正的退化分開">Locust 在效能 regression gate 的用法：exit code 與門檻判定、預熱與 on_start 的請求、closed model 下的比較、custom client 回報的請求範圍、PR 與 nightly 的分工、證據保存與 gate 失敗的排查順序</a> 的〈預熱與量測區間：&ndash;reset-stats 與 on_start 裡的請求〉。</p>
<h2 id="master-與兩個-worker">master 與兩個 worker</h2>
<p>Locust 用 gevent 在一個作業系統執行緒裡輪流執行所有使用者，所以一個 Locust 程序只用得到一顆 CPU 核心（<a href="/blog/backend/knowledge-cards/gevent-monkey-patching/" data-link-title="Gevent Monkey Patching（gevent 的猴子補丁）" data-link-desc="Locust 的吞吐量從加壓一開始就不隨使用者數增加而 worker 的 CPU 很低、一個 worker 程序的 CPU 到頂，或要判斷某個 Python 函式庫是否會卡住 Locust 的 worker 時讀">Gevent Monkey Patching</a>），施壓量不夠時要開多個 worker 程序，由一個 master 分配使用者、收集統計。這次在同一台機器上開一個 master 與兩個 worker：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 重跑時先刪掉上一次留下的 container，名稱才能重用（否則回 Conflict）</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">docker rm -f lmaster lw1 lw2 2&gt;/dev/null
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"># master：不跑使用者，等兩個 worker 都連上才開始</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">docker run -d --name lmaster --network loadlab -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">/locust:/mnt/locust&#34;</span> lab-locust <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>  locust -f locustfile.py --master --headless --expect-workers <span class="m">2</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>  -u <span class="m">20</span> -r <span class="m">5</span> -t 20s --host http://target:8080 --csv out/dist --only-summary <span class="se">\
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="se"></span>  --logfile out/master.log
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 兩個 worker：連到 master 的主機名</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="k">for</span> i in <span class="m">1</span> 2<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  docker run -d --name lw<span class="nv">$i</span> --network loadlab -v <span class="s2">&#34;</span><span class="nv">$PWD</span><span class="s2">/locust:/mnt/locust&#34;</span> lab-locust <span class="se">\
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="se"></span>    locust -f locustfile.py --worker --master-host lmaster
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># 等 master 結束，印出它的 exit code</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">docker <span class="nb">wait</span> lmaster
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"># listener 的判定與統計表只寫到 master 的 stdout，從 container 的 log 讀</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">docker logs lmaster 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> grep GATE
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"># GATE PASS: fail ratio 0.014, p95 49 ms</span></span></span></code></pre></div><p><code>--expect-workers 2</code> 讓 master 等到兩個 worker 都連上才開始；不給時預設是 1（2.47.0 的原始碼 <code>locust/argument_parser.py</code>），master 等到第一個 worker 連上就開始、把使用者全部派給它；第二個 worker 晚幾秒連上時，master 重新把使用者分給兩個 worker：原始碼 <code>MasterRunner</code> 處理 worker 連上的那一段，在測試執行中會再呼叫一次 <code>start()</code>。<code>--enable-rebalancing</code> 管的是另一件事，worker 失聯時要不要把它的使用者分給其他 worker。所以開頭那幾秒（從原始碼推出，這次沒有實跑不給 <code>--expect-workers</code> 的情形）的負載全在一個 worker 程序上，統計裡混著一段單一 worker 送出的數據，它的那一顆 CPU 核心先到頂的話，那一段量到的是施壓端的上限；<code>--expect-workers</code> 設成 worker 的數量，才從第一個請求起就由兩個 worker 分擔。</p>
<p><code>--expect-workers</code> 的代價是有 worker 一直沒連上時（還沒連上就被殺掉、image 拉不下來），master 會一直等下去，<code>-t</code> 管不到這段等待，<code>docker wait lmaster</code> 跟著卡住，CI 要等到 job 逾時；<code>--expect-workers-max-wait &lt;秒數&gt;</code> 設定等待的上限，<code>locust --help</code> 寫明不給時是一直等。審查時另外實跑過：只開一個 worker 時 master 每秒印 <code>Waiting for workers to be ready, 1 of 2 connected</code>；加 <code>--expect-workers-max-wait 8</code> 之後 master 印 <code>Gave up waiting for workers to connect</code> 並回 1，沒有 <code>GATE</code> 那一行。這個 1 是測試沒跑成，listener 沒有機會判定，跟門檻不成立的 1 是同一個值，CI 要分開時從 log 找 <code>Gave up waiting</code>。上限取多少看 worker 的 container 平常多久連得上，這次沒有量。</p>
<p><code>lmaster</code> 是 master 的 container 名稱，worker 用 <code>--master-host</code> 指向它；<code>-u 20</code> 是全部 worker 合計的使用者數，master 把它平均分給兩個 worker，加 worker 不會讓合計增加；JMeter 的 remote testing 是反過來的，每台 server 都跑完整的 test plan，負載隨台數倍增（<a href="/blog/backend/06-reliability/vendors/jmeter/hands-on/jmeter-local-lab/" data-link-title="JMeter 在本機跑負載測試的實測紀錄：Docker image、test plan 的元件、CSV Data Set、CLI mode 與 JTL、dashboard 與 statistics.json、JMeter 之外的門檻判定、remote testing 的 client 與 server" data-link-desc="要在本機用 Docker 照一次實測跑 JMeter：建 image、寫 test plan、從 CSV 餵測試資料、用 CLI mode 跑出 JTL 與 dashboard、讀 statistics.json 判定門檻、開 client 與多台 server 做 remote testing 時讀；也用在 sample 失敗了 exit code 還是 0、CSV 裡某些值從來沒被請求到、-J 傳的屬性在 server 上沒有作用、-o 回報資料夾不是空的時候">JMeter 在本機跑負載測試的實測紀錄</a> 的〈remote testing：一台 client 與兩台 server〉）。master 這次放在背景跑，所以統計用 <code>--csv</code> 寫進掛載的目錄，Locust 自己的 log 用 <code>--logfile</code> 寫成 <code>out/master.log</code>；listener 印的 <code>GATE</code> 那一行與 <code>--only-summary</code> 的統計表寫到 stdout，不進 <code>--logfile</code>，所以 master 與 worker 都沒有加 <code>--rm</code>，結束之後還能用 <code>docker logs lmaster</code>、<code>docker logs lw1</code> 讀；加了 <code>--rm</code> 的話，container 結束時 stdout 一起消失，只拿得到 <code>docker wait</code> 印的 exit code，看不到判定的理由。CI 裡要用 master 的結果當 job 的結果時，取 <code>docker wait</code> 印出的值（<code>exit &quot;$(docker wait lmaster)&quot;</code>），不是 <code>grep</code> 那一條的 exit code。master 送出結束訊息之後只等 0.5 秒收 worker 的最後一份統計就觸發 <code>quitting</code>（2.47.0 的 <code>MasterRunner.quit()</code>）；加了 <code>--stop-timeout</code> 時，worker 要等使用者停完才送，最後一段可能不在判定裡（這一點從原始碼推出，沒有實跑）。</p>
<p>master 的 log 裡看得到兩個 worker 連上、使用者被派出：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">... (index 0) reported as ready. 1 workers connected.
</span></span><span class="line"><span class="ln">2</span><span class="cl">... (index 1) reported as ready. 2 workers connected.
</span></span><span class="line"><span class="ln">3</span><span class="cl">... Sending spawn jobs of 20 users at 5.00 spawn rate to 2 ready workers</span></span></code></pre></div><p>門檻判定只在 master 做的原因，是 worker 每 3 秒把手上的統計送給 master，送出之後就清空（2.47.0 的原始碼 <code>locust/runners.py</code> 的 <code>WORKER_REPORT_INTERVAL = 3.0</code>），結束時也先送最後一次再觸發 <code>quitting</code>：listener 還沒加 <code>WorkerRunner</code> 那一行時，worker 的 log 在結束時印出 <code>GATE PASS: fail ratio 0.000, p95 0 ms</code>，它手上的統計是空的。加了那一行之後，worker 不再判定，exit code 由 master 決定。</p>
<p>worker 的 heartbeat 失敗是這次遇到、成因沒有查出來的現象。最早的兩次分散執行，master 在派出使用者大約 4 秒後印出 <code>Worker ... failed to send heartbeat, setting state to missing.</code>。第一次接著印出 <code>The last worker went missing, stopping test.</code>，整次執行 0 個請求，而那時的 listener 還沒有 <code>num_requests == 0</code> 那一段，判了 <code>GATE PASS</code>、exit code 0；第二次跑完了 20 秒，但 <code>POST /login</code> 的最大延遲到 4 秒。之後改了受測服務的位址與 master、worker 的啟動順序重跑五次，都沒有重現。</p>
<p>master 判定 worker 失聯的機制是心跳訊息（heartbeat）：worker 每秒送一次 heartbeat 告訴 master 自己還在；master 替每個 worker 記一個計數，收到 heartbeat 時設回 3，之後每秒減 1，減到負數時判它失聯，所以一個 worker 大約 4 秒沒送 heartbeat 就被判失聯（2.47.0 的原始碼 <code>locust/runners.py</code> 的 <code>HEARTBEAT_INTERVAL = 1</code>、<code>HEARTBEAT_LIVENESS = 3</code>）。這次兩次都是在派出使用者之後 4.1 到 4.2 秒判失聯，代表那段時間 worker 的程序停住了。從這個機制推得出的成因有三種：施壓機的 CPU 被別的工作占走、locustfile 裡有 gevent 管不到的在 C 裡做的 I/O，或 locustfile 裡長時間不讓出執行權的 CPU 運算（<a href="/blog/backend/knowledge-cards/gevent-monkey-patching/" data-link-title="Gevent Monkey Patching（gevent 的猴子補丁）" data-link-desc="Locust 的吞吐量從加壓一開始就不隨使用者數增加而 worker 的 CPU 很低、一個 worker 程序的 CPU 到頂，或要判斷某個 Python 函式庫是否會卡住 Locust 的 worker 時讀">Gevent Monkey Patching</a>）。這次的 locustfile 沒有在 C 裡做的 I/O，CPU 運算只有解析登入回應的一個小 JSON；施壓機的 CPU 有沒有被占走，那段時間沒有收集到指標，無法確認。之後照最早那兩次的條件（worker 先啟動、master 在前景、受測服務用名稱）在同一台機器上又重跑 10 次，都沒有重現，其中兩次用來驗證兩個假設。一次在 Docker 的 VM 裡跑 16 個占滿 CPU 的迴圈：<code>POST /login</code> 的中位數到 1300 毫秒、最大值 4560 毫秒，跟第二次的形狀相同，但 worker 沒有失聯，所以 VM 裡的 CPU 競爭解釋得了第二次的登入延遲，解釋不了 heartbeat 大約 4 秒都送不出去。另一次讓名稱解析卡住 8 秒：worker 用的是 gevent 的 thread resolver，查詢在另一個執行緒裡等，同一個程序裡每 0.1 秒醒一次的 greenlet，最大間隔是 0.12 秒，所以名稱解析變慢不會讓 heartbeat 停住。</p>
<p>出現這個訊息的那一次執行，結果不能用：worker 停住的那幾秒，請求延遲被拉高；被判失聯的 worker 沒有被叫停，它之後送回的統計照樣算進去（master 收到統計時不看 worker 的狀態），所以統計裡看不出哪一段是停住的，只有全部 worker 都失聯時 master 才停止測試。這次的 listener 判不出這種情形：只要請求數不是 0，有 worker 失聯的那一次照樣判 PASS 或 FAIL；CI 要擋下時，從 <code>--logfile</code> 寫出的 <code>locust/out/master.log</code> 找 <code>failed to send heartbeat</code>：這一行跟 Locust 其他的 log 一起寫進那個檔案，不在 stdout，讀 <code>GATE</code> 用的 <code>docker logs lmaster</code> 裡找不到（審查時另外實跑過：在執行中殺掉一個 worker，<code>master.log</code> 裡有這一行，<code>docker logs</code> 裡 0 筆，那一次 listener 照樣判 <code>GATE PASS</code>、exit 0）；或在 listener 裡另外查 runner 記錄的 worker 狀態（這次沒有實跑）；master 在 worker 失聯大約 60 秒之後刪掉它的紀錄（2.47.0 的 <code>HEARTBEAT_DEAD_INTERNAL = -60</code>），執行時間長、worker 失聯得早時，<code>quitting</code> 觸發時那筆紀錄已經不在，要在失聯的當下記下來。</p>
<p>分辨成因時（這一段的查法這次沒有在 heartbeat 失敗的當下跑過），<code>docker ps -a</code> 一條指令就分得出 worker 的程序是結束了還是被卡住，<code>-a</code> 讓已經結束的 container 也列出來。container 已經結束時，看它的結束碼。137 是程序被 SIGKILL 殺掉，<code>docker inspect -f '{{.State.OOMKilled}}' lw1</code> 印 <code>true</code> 時是記憶體不足被殺，這兩種情形 worker 的 log 裡沒有原因（審查時用 <code>docker kill -s KILL</code> 實跑過，被殺的 worker 的 log 只有 <code>Starting Locust 2.47.0</code> 一行）；其他結束碼的原因記在它的 log 裡。程序已經結束的這幾種情形，CPU 指標都不必查。container 還在跑時，程序是被卡住，要靠那段時間施壓機的 CPU 指標才分得出是不是 CPU 被占走。worker 要量哪些資源、worker 數怎麼決定，見 <a href="/blog/backend/09-performance-capacity/vendors/locust/locust-capacity-probing/" data-link-title="Locust 在容量探測的用法：closed model 下的逐級加壓、吞吐量與延遲的換算、pacing 的上限、worker 的檢查與排查順序" data-link-desc="Locust 只有 closed model 時找飽和點的判斷：逐級增加使用者、從每秒 task 數與延遲換算受測服務的狀態、到達率型服務的延遲低估與 pacing 的上限、worker 與 client 的排除條件，以及容量結論的寫法">Locust 在容量探測的用法</a> 的〈worker 程序的檢查與 worker 數的量法〉。</p>
<p>這次有幾次執行，worker 的 log 結尾出現 <code>Error resolving address: [Errno -2] Name or service not known</code>，另外幾次沒有；有出現時都在 master 結束之後：master 的 container 結束之後，Docker 的 DNS 不再解析它的名稱（container 只是停止、沒有刪掉也一樣），worker 收到結束訊息之後還在重連那個主機名，這一行不影響結果。</p>
<h2 id="清理-container-與-docker-network">清理 container 與 Docker network</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">docker rm -f lmaster lw1 lw2 target
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker network rm loadlab</span></span></code></pre></div><p>master 與 worker 沒有加 <code>--rm</code>，要自己刪；<code>target</code> 帶著 <code>--rm</code>，<code>docker rm -f</code> 停掉它時一起刪除。還有執行中的 container 連著 network 時，<code>docker network rm</code> 失敗，訊息是 <code>network loadlab has active endpoints</code>，後面列出那幾個 container；已經結束、還沒刪的 container（這裡的 worker）不在那份清單裡，不擋刪除，但不刪的話會留在 <code>docker ps -a</code> 裡。<code>out/</code> 底下的 CSV、HTML 與 log 留在本機的目錄，不隨 container 刪除。</p>
]]></content:encoded></item></channel></rss>