<?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>Fastcgi on Tarragon</title><link>https://tarrragon.github.io/blog/tags/fastcgi/</link><description>Recent content in Fastcgi on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 05 Oct 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/fastcgi/index.xml" rel="self" type="application/rss+xml"/><item><title>FastCGI：讓 web 伺服器與常駐的 PHP 程序溝通的協定</title><link>https://tarrragon.github.io/blog/programming/php/01-server-runtime/fastcgi/</link><pubDate>Mon, 05 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/php/01-server-runtime/fastcgi/</guid><description>&lt;p>這篇說明 FastCGI 這個協定：它怎麼讓 web 伺服器與一個常駐的 PHP 程序溝通，以及這個分工帶來的一個安全邊界。它是 &lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/request-lifecycle-and-sapi/" data-link-title="PHP 的請求生命週期與 SAPI：直譯器的位置、程序壽命與狀態壽命" data-link-desc="說明一個 HTTP 請求怎麼變成一次 PHP 執行：PHP 在程序啟動與每個請求開始、結束時各做什麼，SAPI 這一層介面怎麼讓同一個直譯器接上 CGI、Apache、FastCGI 與常駐伺服器，以及用直譯器住在哪裡、執行它的程序活多久、應用程式的狀態活多久這組問題比較各種執行模型">PHP 的請求生命週期與 SAPI&lt;/a> 三個問題的一組答案：PHP 直譯器住在 web 伺服器外面的常駐程序裡。FastCGI 是協定，管理那群 PHP 程序的實作是 &lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/php-fpm/" data-link-title="PHP-FPM：常駐 PHP worker 的程序管理與主要的調校旋鈕" data-link-desc="說明 PHP-FPM 怎麼管理一群常駐的 PHP worker：pool 怎麼做到每個站台不同執行帳號、pm 的三種開程序策略、max_children 如何決定並發上限與請求排隊、max_requests 定期換掉 worker 控制記憶體、request_terminate_timeout 與 slowlog 怎麼處理跑太久的請求、status 頁怎麼讀，以及每一項對壓測數字的影響">PHP-FPM&lt;/a>，本篇只講協定本身與它的安全面。&lt;/p>
&lt;h2 id="fastcgi-要解決-cgi-的哪一件事">FastCGI 要解決 CGI 的哪一件事&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/cgi/" data-link-title="CGI：每個請求啟動一個 PHP 程序的執行模型" data-link-desc="說明 CGI 怎麼把一個 HTTP 請求交給外部程式：web 伺服器為每個請求啟動一個程序，用環境變數與標準輸入輸出傳遞請求和回應；PHP 以 php-cgi 執行時每個請求付出的啟動成本、OPcache 為什麼沒有作用、以每個站台的使用者身分執行帶來的隔離，以及請求資料與程式呼叫參數之間的邊界問題">CGI&lt;/a> 的成本集中在一句話：每個請求啟動一個新程序，請求結束程序就消失。載入 PHP 執行檔、讀設定、初始化擴充這些工作，每個請求都要重做一次，OPcache 也因為程序不續命而永遠命中不了。&lt;/p>
&lt;p>FastCGI 的原始規格由 Open Market 在 1996 年發布，開宗明義寫它「designed to support long-lived application processes」——支援長時間存活的應用程式程序。它保留 CGI 已經可行的部分（請求資訊用一組具名的參數傳遞，回應用資料流送回），只改掉程序的壽命：應用程式程序啟動一次後常駐，透過一條連線接收一個又一個請求。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>CGI&lt;/th>
 &lt;th>FastCGI&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>程序壽命&lt;/td>
 &lt;td>每個請求一個新程序&lt;/td>
 &lt;td>應用程式程序常駐，處理很多請求&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>傳遞請求的方式&lt;/td>
 &lt;td>環境變數加標準輸入&lt;/td>
 &lt;td>連線上的 &lt;code>FCGI_PARAMS&lt;/code> 與 &lt;code>FCGI_STDIN&lt;/code> 記錄&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>傳遞回應的方式&lt;/td>
 &lt;td>標準輸出&lt;/td>
 &lt;td>連線上的 &lt;code>FCGI_STDOUT&lt;/code> 記錄&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>web 伺服器與應用程式的連線&lt;/td>
 &lt;td>fork 出來的程序，父子關係&lt;/td>
 &lt;td>TCP（如 &lt;code>127.0.0.1:9000&lt;/code>）或 Unix socket&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="一個請求在-fastcgi-協定裡的樣子">一個請求在 FastCGI 協定裡的樣子&lt;/h2>
&lt;p>FastCGI 把一條連線上的資料切成一筆一筆的記錄（record），每筆記錄有型別。一個請求用到的主要型別：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>FCGI_PARAMS&lt;/code>&lt;/strong>：web 伺服器把請求的參數（名稱與值的配對）送給應用程式，裝的就是 CGI 那套 meta-variables——&lt;code>REQUEST_METHOD&lt;/code>、&lt;code>QUERY_STRING&lt;/code>、&lt;code>SCRIPT_FILENAME&lt;/code> 等。PHP 的 &lt;code>$_SERVER&lt;/code> 就是從這裡填出來的。&lt;/li>
&lt;li>&lt;strong>&lt;code>FCGI_STDIN&lt;/code>&lt;/strong>：請求的內容（POST 的資料）。&lt;/li>
&lt;li>&lt;strong>&lt;code>FCGI_STDOUT&lt;/code>&lt;/strong>：應用程式送回的回應，包含標頭與內容。&lt;/li>
&lt;li>&lt;strong>&lt;code>FCGI_END_REQUEST&lt;/code>&lt;/strong>：應用程式宣告這個請求處理完了。&lt;/li>
&lt;/ul>
&lt;p>FastCGI 的角色裡最常用的是 Responder，規格說它對應 CGI 原本做的事：接收一個 HTTP 請求的全部資訊，產生一個 HTTP 回應。PHP-FPM 實作的就是這個角色。&lt;/p>
&lt;p>這帶出一個分工上的事實：&lt;strong>web 伺服器收到 HTTP 請求之後，自己把它拆解成 &lt;code>FCGI_PARAMS&lt;/code> 裡的參數，再送給 PHP&lt;/strong>。PHP 收到的是 web 伺服器整理好的參數，原始的 HTTP 請求留在 web 伺服器那一側。其中一個參數決定了 PHP 要執行哪個檔案，而它由 web 伺服器填寫——這是下一節的安全邊界。&lt;/p>
&lt;h2 id="web-伺服器決定-php-執行哪個檔案">web 伺服器決定 PHP 執行哪個檔案&lt;/h2>
&lt;p>&lt;code>SCRIPT_FILENAME&lt;/code> 這個參數告訴 PHP 要執行磁碟上的哪一個檔案。在 Nginx 加 PHP-FPM 的設定裡，它由 Nginx 組出來：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-nginx" data-lang="nginx">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">location&lt;/span> &lt;span class="p">~&lt;/span> &lt;span class="sr">\.php$&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kn">fastcgi_pass&lt;/span> &lt;span class="n">fpm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="mi">9000&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kn">include&lt;/span> &lt;span class="s">fastcgi_params&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="kn">fastcgi_param&lt;/span> &lt;span class="s">SCRIPT_FILENAME&lt;/span> &lt;span class="nv">$document_root$fastcgi_script_name&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段設定要讀者改的是 &lt;code>fastcgi_pass&lt;/code> 後面的位址：&lt;code>fpm:9000&lt;/code> 是 PHP-FPM 在這個環境裡的主機名與埠，同一台機器上通常是 &lt;code>127.0.0.1:9000&lt;/code> 或一個 Unix socket 的路徑。&lt;code>$fastcgi_script_name&lt;/code> 是 Nginx 從請求路徑推出來的腳本路徑。問題出在 &lt;code>location ~ \.php$&lt;/code>：它只要求路徑以 &lt;code>.php&lt;/code> 結尾，沒有要求那個檔案存在。&lt;/p>
&lt;p>這一點配上 PHP 的 &lt;code>cgi.fix_pathinfo&lt;/code> 設定會變成一個漏洞。&lt;code>cgi.fix_pathinfo&lt;/code> 開著時（這是預設值），PHP 拿到一個不存在的檔案路徑，會往前找路徑裡存在的那一段當成要執行的腳本。把這幾件事擺在一起，一個常見的攻擊路徑是：使用者上傳一個內含 PHP 程式碼、副檔名卻是圖片的檔案（&lt;code>evil.jpg&lt;/code>），再用 &lt;code>/uploads/evil.jpg/x.php&lt;/code> 這個網址存取它。&lt;/p></description><content:encoded><![CDATA[<p>這篇說明 FastCGI 這個協定：它怎麼讓 web 伺服器與一個常駐的 PHP 程序溝通，以及這個分工帶來的一個安全邊界。它是 <a href="/blog/programming/php/01-server-runtime/request-lifecycle-and-sapi/" data-link-title="PHP 的請求生命週期與 SAPI：直譯器的位置、程序壽命與狀態壽命" data-link-desc="說明一個 HTTP 請求怎麼變成一次 PHP 執行：PHP 在程序啟動與每個請求開始、結束時各做什麼，SAPI 這一層介面怎麼讓同一個直譯器接上 CGI、Apache、FastCGI 與常駐伺服器，以及用直譯器住在哪裡、執行它的程序活多久、應用程式的狀態活多久這組問題比較各種執行模型">PHP 的請求生命週期與 SAPI</a> 三個問題的一組答案：PHP 直譯器住在 web 伺服器外面的常駐程序裡。FastCGI 是協定，管理那群 PHP 程序的實作是 <a href="/blog/programming/php/01-server-runtime/php-fpm/" data-link-title="PHP-FPM：常駐 PHP worker 的程序管理與主要的調校旋鈕" data-link-desc="說明 PHP-FPM 怎麼管理一群常駐的 PHP worker：pool 怎麼做到每個站台不同執行帳號、pm 的三種開程序策略、max_children 如何決定並發上限與請求排隊、max_requests 定期換掉 worker 控制記憶體、request_terminate_timeout 與 slowlog 怎麼處理跑太久的請求、status 頁怎麼讀，以及每一項對壓測數字的影響">PHP-FPM</a>，本篇只講協定本身與它的安全面。</p>
<h2 id="fastcgi-要解決-cgi-的哪一件事">FastCGI 要解決 CGI 的哪一件事</h2>
<p><a href="/blog/programming/php/01-server-runtime/cgi/" data-link-title="CGI：每個請求啟動一個 PHP 程序的執行模型" data-link-desc="說明 CGI 怎麼把一個 HTTP 請求交給外部程式：web 伺服器為每個請求啟動一個程序，用環境變數與標準輸入輸出傳遞請求和回應；PHP 以 php-cgi 執行時每個請求付出的啟動成本、OPcache 為什麼沒有作用、以每個站台的使用者身分執行帶來的隔離，以及請求資料與程式呼叫參數之間的邊界問題">CGI</a> 的成本集中在一句話：每個請求啟動一個新程序，請求結束程序就消失。載入 PHP 執行檔、讀設定、初始化擴充這些工作，每個請求都要重做一次，OPcache 也因為程序不續命而永遠命中不了。</p>
<p>FastCGI 的原始規格由 Open Market 在 1996 年發布，開宗明義寫它「designed to support long-lived application processes」——支援長時間存活的應用程式程序。它保留 CGI 已經可行的部分（請求資訊用一組具名的參數傳遞，回應用資料流送回），只改掉程序的壽命：應用程式程序啟動一次後常駐，透過一條連線接收一個又一個請求。</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>CGI</th>
          <th>FastCGI</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>程序壽命</td>
          <td>每個請求一個新程序</td>
          <td>應用程式程序常駐，處理很多請求</td>
      </tr>
      <tr>
          <td>傳遞請求的方式</td>
          <td>環境變數加標準輸入</td>
          <td>連線上的 <code>FCGI_PARAMS</code> 與 <code>FCGI_STDIN</code> 記錄</td>
      </tr>
      <tr>
          <td>傳遞回應的方式</td>
          <td>標準輸出</td>
          <td>連線上的 <code>FCGI_STDOUT</code> 記錄</td>
      </tr>
      <tr>
          <td>web 伺服器與應用程式的連線</td>
          <td>fork 出來的程序，父子關係</td>
          <td>TCP（如 <code>127.0.0.1:9000</code>）或 Unix socket</td>
      </tr>
  </tbody>
</table>
<h2 id="一個請求在-fastcgi-協定裡的樣子">一個請求在 FastCGI 協定裡的樣子</h2>
<p>FastCGI 把一條連線上的資料切成一筆一筆的記錄（record），每筆記錄有型別。一個請求用到的主要型別：</p>
<ul>
<li><strong><code>FCGI_PARAMS</code></strong>：web 伺服器把請求的參數（名稱與值的配對）送給應用程式，裝的就是 CGI 那套 meta-variables——<code>REQUEST_METHOD</code>、<code>QUERY_STRING</code>、<code>SCRIPT_FILENAME</code> 等。PHP 的 <code>$_SERVER</code> 就是從這裡填出來的。</li>
<li><strong><code>FCGI_STDIN</code></strong>：請求的內容（POST 的資料）。</li>
<li><strong><code>FCGI_STDOUT</code></strong>：應用程式送回的回應，包含標頭與內容。</li>
<li><strong><code>FCGI_END_REQUEST</code></strong>：應用程式宣告這個請求處理完了。</li>
</ul>
<p>FastCGI 的角色裡最常用的是 Responder，規格說它對應 CGI 原本做的事：接收一個 HTTP 請求的全部資訊，產生一個 HTTP 回應。PHP-FPM 實作的就是這個角色。</p>
<p>這帶出一個分工上的事實：<strong>web 伺服器收到 HTTP 請求之後，自己把它拆解成 <code>FCGI_PARAMS</code> 裡的參數，再送給 PHP</strong>。PHP 收到的是 web 伺服器整理好的參數，原始的 HTTP 請求留在 web 伺服器那一側。其中一個參數決定了 PHP 要執行哪個檔案，而它由 web 伺服器填寫——這是下一節的安全邊界。</p>
<h2 id="web-伺服器決定-php-執行哪個檔案">web 伺服器決定 PHP 執行哪個檔案</h2>
<p><code>SCRIPT_FILENAME</code> 這個參數告訴 PHP 要執行磁碟上的哪一個檔案。在 Nginx 加 PHP-FPM 的設定裡，它由 Nginx 組出來：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">location</span> <span class="p">~</span> <span class="sr">\.php$</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="kn">fastcgi_pass</span> <span class="n">fpm</span><span class="p">:</span><span class="mi">9000</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="kn">include</span> <span class="s">fastcgi_params</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="kn">fastcgi_param</span> <span class="s">SCRIPT_FILENAME</span> <span class="nv">$document_root$fastcgi_script_name</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段設定要讀者改的是 <code>fastcgi_pass</code> 後面的位址：<code>fpm:9000</code> 是 PHP-FPM 在這個環境裡的主機名與埠，同一台機器上通常是 <code>127.0.0.1:9000</code> 或一個 Unix socket 的路徑。<code>$fastcgi_script_name</code> 是 Nginx 從請求路徑推出來的腳本路徑。問題出在 <code>location ~ \.php$</code>：它只要求路徑以 <code>.php</code> 結尾，沒有要求那個檔案存在。</p>
<p>這一點配上 PHP 的 <code>cgi.fix_pathinfo</code> 設定會變成一個漏洞。<code>cgi.fix_pathinfo</code> 開著時（這是預設值），PHP 拿到一個不存在的檔案路徑，會往前找路徑裡存在的那一段當成要執行的腳本。把這幾件事擺在一起，一個常見的攻擊路徑是：使用者上傳一個內含 PHP 程式碼、副檔名卻是圖片的檔案（<code>evil.jpg</code>），再用 <code>/uploads/evil.jpg/x.php</code> 這個網址存取它。</p>
<p>在刻意寫成不安全版本（<code>location ~ \.php$</code> 沒有檢查檔案存在、<code>cgi.fix_pathinfo=1</code>、PHP-FPM 的 <code>security.limit_extensions</code> 放寬到容許 <code>.jpg</code>）的環境實測：</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">$ curl localhost/uploads/evil.jpg/x.php
</span></span><span class="line"><span class="ln">2</span><span class="cl">executed as PHP: /var/www/html/uploads/evil.jpg   [200]</span></span></code></pre></div><p>Nginx 看到路徑以 <code>.php</code> 結尾，把它交給 PHP；PHP 找不到 <code>x.php</code>，依 <code>cgi.fix_pathinfo</code> 往前找到存在的 <code>evil.jpg</code>，把這個圖片當成 PHP 執行了。上傳的內容因此變成在伺服器上執行的程式碼。</p>
<h3 id="三道各自獨立的防線">三道各自獨立的防線</h3>
<p>這個漏洞要同時打穿幾道設定才成立，所以每一道都能單獨擋下它。實測三種修法各自的效果：</p>
<p>PHP-FPM 的 <code>security.limit_extensions</code> 限制哪些副檔名可以被當成 PHP 執行，預設只有 <code>.php</code>。保持預設時，<code>evil.jpg</code> 根本不會被執行：</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">$ curl localhost/uploads/evil.jpg/x.php
</span></span><span class="line"><span class="ln">2</span><span class="cl">Access denied.   [403]
</span></span><span class="line"><span class="ln">3</span><span class="cl"># php-fpm log: Access to the script &#39;/var/www/html/uploads/evil.jpg&#39;
</span></span><span class="line"><span class="ln">4</span><span class="cl">#   has been denied (see security.limit_extensions)</span></span></code></pre></div><p>把 <code>cgi.fix_pathinfo</code> 關掉（<code>php_admin_value[cgi.fix_pathinfo] = 0</code>），PHP 不再往前找存在的檔案，找不到 <code>x.php</code> 就直接回報沒有輸入檔：</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">$ curl localhost/uploads/evil.jpg/x.php
</span></span><span class="line"><span class="ln">2</span><span class="cl">No input file specified.   [404]</span></span></code></pre></div><p>在 Nginx 的 <code>location ~ \.php$</code> 裡加 <code>try_files $uri =404</code>，要求檔案存在才交給 PHP，不存在的 <code>x.php</code> 在 Nginx 這一層就被擋下，根本到不了 PHP：</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">location ~ \.php$ {
</span></span><span class="line"><span class="ln">2</span><span class="cl">    try_files $uri =404;        # 檔案不存在就直接回 404，不轉給 PHP
</span></span><span class="line"><span class="ln">3</span><span class="cl">    fastcgi_pass fpm:9000;
</span></span><span class="line"><span class="ln">4</span><span class="cl">    include fastcgi_params;
</span></span><span class="line"><span class="ln">5</span><span class="cl">    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
</span></span><span class="line"><span class="ln">6</span><span class="cl">}</span></span></code></pre></div>




<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">$ curl localhost/uploads/evil.jpg/x.php
</span></span><span class="line"><span class="ln">2</span><span class="cl">[404]</span></span></code></pre></div><p>三道防線的位置不同：<code>try_files</code> 在 web 伺服器（請求還沒到 PHP）、<code>security.limit_extensions</code> 與 <code>cgi.fix_pathinfo</code> 在 PHP 端。預設值已經擋住這個攻擊（<code>security.limit_extensions</code> 預設 <code>.php</code>、而 <code>fix_pathinfo</code> 的風險要配上寬鬆的 <code>location</code> 才成立），要出事得有人主動放寬設定。三道一起設是縱深防禦：任何一道被改錯，另外兩道還在。</p>
<p>這個邊界的根源是本篇開頭那件事：<strong><code>SCRIPT_FILENAME</code> 由 web 伺服器決定</strong>，PHP 照著它執行。PHP 信任 web 伺服器交來的檔案路徑，所以 web 伺服器的路由規則寫得鬆，PHP 就會執行到不該執行的檔案。</p>
<h2 id="fastcgi-與-php-fpm-的關係">FastCGI 與 PHP-FPM 的關係</h2>
<p>FastCGI 是協定，規定 web 伺服器與應用程式程序之間怎麼傳遞請求和回應。它本身不規定那群常駐的應用程式程序由誰啟動、開幾個、一個掛了怎麼補、記憶體漲了怎麼辦。</p>
<p>這些是「程序管理」的工作，PHP 的答案是 PHP-FPM（FastCGI Process Manager）。它實作 FastCGI 的 Responder 角色，接 Nginx 或 Apache 用這個協定送來的請求，並管理那一群 PHP worker 程序。程序管理的每一個旋鈕，以及它們怎麼影響服務的行為與壓測數字，在 <a href="/blog/programming/php/01-server-runtime/php-fpm/" data-link-title="PHP-FPM：常駐 PHP worker 的程序管理與主要的調校旋鈕" data-link-desc="說明 PHP-FPM 怎麼管理一群常駐的 PHP worker：pool 怎麼做到每個站台不同執行帳號、pm 的三種開程序策略、max_children 如何決定並發上限與請求排隊、max_requests 定期換掉 worker 控制記憶體、request_terminate_timeout 與 slowlog 怎麼處理跑太久的請求、status 頁怎麼讀，以及每一項對壓測數字的影響">PHP-FPM</a> 展開。</p>
]]></content:encoded></item><item><title>PHP-FPM：常駐 PHP worker 的程序管理與主要的調校旋鈕</title><link>https://tarrragon.github.io/blog/programming/php/01-server-runtime/php-fpm/</link><pubDate>Mon, 05 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/php/01-server-runtime/php-fpm/</guid><description>&lt;p>這篇說明 PHP-FPM 要解決的問題，以及它主要的設定旋鈕在做什麼。PHP-FPM 是 &lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/fastcgi/" data-link-title="FastCGI：讓 web 伺服器與常駐的 PHP 程序溝通的協定" data-link-desc="說明 FastCGI 協定怎麼在 CGI 的基礎上去掉每個請求重啟程序這一點：常駐的應用程式程序、web 伺服器與它之間的連線、一個請求在協定裡的樣子，以及 SCRIPT_FILENAME 由 web 伺服器決定會帶來的安全邊界（cgi.fix_pathinfo 與 try_files）">FastCGI&lt;/a> 協定在 PHP 這一側的程序管理實作，全名 FastCGI Process Manager。前面幾種執行模型各自留下一個沒回答的問題：&lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/cgi/" data-link-title="CGI：每個請求啟動一個 PHP 程序的執行模型" data-link-desc="說明 CGI 怎麼把一個 HTTP 請求交給外部程式：web 伺服器為每個請求啟動一個程序，用環境變數與標準輸入輸出傳遞請求和回應；PHP 以 php-cgi 執行時每個請求付出的啟動成本、OPcache 為什麼沒有作用、以每個站台的使用者身分執行帶來的隔離，以及請求資料與程式呼叫參數之間的邊界問題">CGI&lt;/a> 每個請求啟動一個程序、成本太高；&lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/mod-php/" data-link-title="mod_php：PHP 直譯器嵌進 Apache 程序的執行模型" data-link-desc="說明 mod_php 怎麼把 PHP 直譯器載入 Apache 的每個子程序：為什麼它要搭配 prefork MPM、每個子程序都帶著 PHP 對記憶體與並發的影響、所有站台共用 Apache 的執行帳號、php.ini 與 .htaccess 的設定方式，以及從 mod_php 搬到 PHP-FPM 時 .htaccess 的 php_value 與 .user.ini 的差異">mod_php&lt;/a> 省掉啟動成本、卻讓 PHP 綁死在 web 伺服器的程序與執行帳號上。PHP-FPM 的答案是一群常駐的 PHP worker，而「常駐」帶出一個新問題：誰來管這群程序。文中的行為取自 &lt;code>php:8.5-fpm-alpine&lt;/code>。&lt;/p>
&lt;h2 id="為什麼常駐程序需要一個管理者">為什麼常駐程序需要一個管理者&lt;/h2>
&lt;p>FastCGI 要求應用程式程序常駐，於是出現幾個 CGI 下不存在的問題，因為 CGI 的程序活一個請求就結束、沒有機會累積：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>開幾個程序&lt;/strong>：太少，並發的請求要排隊；太多，記憶體吃光。&lt;/li>
&lt;li>&lt;strong>一個程序掛了誰補&lt;/strong>：常駐的程序可能因為程式錯誤或記憶體不足而死掉，要有人把它補回來。&lt;/li>
&lt;li>&lt;strong>記憶體隨時間漲&lt;/strong>：worker 活得久，C 擴充的記憶體洩漏會一點一點累積。&lt;/li>
&lt;li>&lt;strong>某個請求卡住&lt;/strong>：一個跑不完的請求會一直佔著一個 worker，佔住的 worker 越多，能處理新請求的就越少。&lt;/li>
&lt;/ul>
&lt;p>PHP-FPM 就是處理這幾件事的常駐服務（見 &lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/php-fpm/" data-link-title="PHP-FPM（FastCGI Process Manager）" data-link-desc="在 Nginx 或容器環境看到 php-fpm、9000 埠、pool 設定或 502 Bad Gateway，要知道 PHP-FPM 負責什麼、哪些設定決定並發與記憶體時">PHP-FPM&lt;/a>）。它的 PHP 版本記在官方 ChangeLog 裡：PHP 5.3.3（2010 年 7 月）的 ChangeLog 記著「Added FastCGI Process Manager (FPM) SAPI」，FPM 從這個版本起隨 PHP 本體發行。PHP-FPM 的架構是一個主程序（master）加一群 worker：master 不處理請求，只負責讀設定、開 worker、補死掉的 worker、轉送管理訊號；worker 才是實際執行 PHP 的程序。&lt;/p>
&lt;p>PHP 官方文件對它有一句明確的安全要求：&lt;strong>php-fpm must not be reachable from an untrusted network&lt;/strong>——FastCGI 的埠（預設 &lt;code>9000&lt;/code>）不能對不受信任的網路開放。FastCGI 協定本身沒有驗證，誰連得上這個埠，誰就能指定 &lt;code>SCRIPT_FILENAME&lt;/code> 要 PHP 執行哪個檔案（見 &lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/fastcgi/#web-%e4%bc%ba%e6%9c%8d%e5%99%a8%e6%b1%ba%e5%ae%9a-php-%e5%9f%b7%e8%a1%8c%e5%93%aa%e5%80%8b%e6%aa%94%e6%a1%88" data-link-title="FastCGI：讓 web 伺服器與常駐的 PHP 程序溝通的協定" data-link-desc="說明 FastCGI 協定怎麼在 CGI 的基礎上去掉每個請求重啟程序這一點：常駐的應用程式程序、web 伺服器與它之間的連線、一個請求在協定裡的樣子，以及 SCRIPT_FILENAME 由 web 伺服器決定會帶來的安全邊界（cgi.fix_pathinfo 與 try_files）">FastCGI 的安全邊界&lt;/a>）。所以它只聽 &lt;code>127.0.0.1&lt;/code> 或 Unix socket，或用 &lt;code>listen.allowed_clients&lt;/code> 限制來源。&lt;/p>
&lt;h2 id="pool一組-worker-與它的執行身分">pool：一組 worker 與它的執行身分&lt;/h2>
&lt;p>PHP-FPM 把 worker 分成一個或多個 &lt;strong>pool&lt;/strong>，每個 pool 是一組設定相同的 worker，可以指定自己的執行帳號（&lt;code>user&lt;/code> / &lt;code>group&lt;/code>）、監聽位址（&lt;code>listen&lt;/code>）、以及 PHP 設定。這一個機制解掉了 mod_php 最大的限制：&lt;/p>
&lt;p>mod_php 的所有站台都以 Apache 的帳號執行，彼此的檔案互相看得到。PHP-FPM 讓每個站台一個 pool、各自指定執行帳號：&lt;/p></description><content:encoded><![CDATA[<p>這篇說明 PHP-FPM 要解決的問題，以及它主要的設定旋鈕在做什麼。PHP-FPM 是 <a href="/blog/programming/php/01-server-runtime/fastcgi/" data-link-title="FastCGI：讓 web 伺服器與常駐的 PHP 程序溝通的協定" data-link-desc="說明 FastCGI 協定怎麼在 CGI 的基礎上去掉每個請求重啟程序這一點：常駐的應用程式程序、web 伺服器與它之間的連線、一個請求在協定裡的樣子，以及 SCRIPT_FILENAME 由 web 伺服器決定會帶來的安全邊界（cgi.fix_pathinfo 與 try_files）">FastCGI</a> 協定在 PHP 這一側的程序管理實作，全名 FastCGI Process Manager。前面幾種執行模型各自留下一個沒回答的問題：<a href="/blog/programming/php/01-server-runtime/cgi/" data-link-title="CGI：每個請求啟動一個 PHP 程序的執行模型" data-link-desc="說明 CGI 怎麼把一個 HTTP 請求交給外部程式：web 伺服器為每個請求啟動一個程序，用環境變數與標準輸入輸出傳遞請求和回應；PHP 以 php-cgi 執行時每個請求付出的啟動成本、OPcache 為什麼沒有作用、以每個站台的使用者身分執行帶來的隔離，以及請求資料與程式呼叫參數之間的邊界問題">CGI</a> 每個請求啟動一個程序、成本太高；<a href="/blog/programming/php/01-server-runtime/mod-php/" data-link-title="mod_php：PHP 直譯器嵌進 Apache 程序的執行模型" data-link-desc="說明 mod_php 怎麼把 PHP 直譯器載入 Apache 的每個子程序：為什麼它要搭配 prefork MPM、每個子程序都帶著 PHP 對記憶體與並發的影響、所有站台共用 Apache 的執行帳號、php.ini 與 .htaccess 的設定方式，以及從 mod_php 搬到 PHP-FPM 時 .htaccess 的 php_value 與 .user.ini 的差異">mod_php</a> 省掉啟動成本、卻讓 PHP 綁死在 web 伺服器的程序與執行帳號上。PHP-FPM 的答案是一群常駐的 PHP worker，而「常駐」帶出一個新問題：誰來管這群程序。文中的行為取自 <code>php:8.5-fpm-alpine</code>。</p>
<h2 id="為什麼常駐程序需要一個管理者">為什麼常駐程序需要一個管理者</h2>
<p>FastCGI 要求應用程式程序常駐，於是出現幾個 CGI 下不存在的問題，因為 CGI 的程序活一個請求就結束、沒有機會累積：</p>
<ul>
<li><strong>開幾個程序</strong>：太少，並發的請求要排隊；太多，記憶體吃光。</li>
<li><strong>一個程序掛了誰補</strong>：常駐的程序可能因為程式錯誤或記憶體不足而死掉，要有人把它補回來。</li>
<li><strong>記憶體隨時間漲</strong>：worker 活得久，C 擴充的記憶體洩漏會一點一點累積。</li>
<li><strong>某個請求卡住</strong>：一個跑不完的請求會一直佔著一個 worker，佔住的 worker 越多，能處理新請求的就越少。</li>
</ul>
<p>PHP-FPM 就是處理這幾件事的常駐服務（見 <a href="/blog/programming/php/knowledge-cards/php-fpm/" data-link-title="PHP-FPM（FastCGI Process Manager）" data-link-desc="在 Nginx 或容器環境看到 php-fpm、9000 埠、pool 設定或 502 Bad Gateway，要知道 PHP-FPM 負責什麼、哪些設定決定並發與記憶體時">PHP-FPM</a>）。它的 PHP 版本記在官方 ChangeLog 裡：PHP 5.3.3（2010 年 7 月）的 ChangeLog 記著「Added FastCGI Process Manager (FPM) SAPI」，FPM 從這個版本起隨 PHP 本體發行。PHP-FPM 的架構是一個主程序（master）加一群 worker：master 不處理請求，只負責讀設定、開 worker、補死掉的 worker、轉送管理訊號；worker 才是實際執行 PHP 的程序。</p>
<p>PHP 官方文件對它有一句明確的安全要求：<strong>php-fpm must not be reachable from an untrusted network</strong>——FastCGI 的埠（預設 <code>9000</code>）不能對不受信任的網路開放。FastCGI 協定本身沒有驗證，誰連得上這個埠，誰就能指定 <code>SCRIPT_FILENAME</code> 要 PHP 執行哪個檔案（見 <a href="/blog/programming/php/01-server-runtime/fastcgi/#web-%e4%bc%ba%e6%9c%8d%e5%99%a8%e6%b1%ba%e5%ae%9a-php-%e5%9f%b7%e8%a1%8c%e5%93%aa%e5%80%8b%e6%aa%94%e6%a1%88" data-link-title="FastCGI：讓 web 伺服器與常駐的 PHP 程序溝通的協定" data-link-desc="說明 FastCGI 協定怎麼在 CGI 的基礎上去掉每個請求重啟程序這一點：常駐的應用程式程序、web 伺服器與它之間的連線、一個請求在協定裡的樣子，以及 SCRIPT_FILENAME 由 web 伺服器決定會帶來的安全邊界（cgi.fix_pathinfo 與 try_files）">FastCGI 的安全邊界</a>）。所以它只聽 <code>127.0.0.1</code> 或 Unix socket，或用 <code>listen.allowed_clients</code> 限制來源。</p>
<h2 id="pool一組-worker-與它的執行身分">pool：一組 worker 與它的執行身分</h2>
<p>PHP-FPM 把 worker 分成一個或多個 <strong>pool</strong>，每個 pool 是一組設定相同的 worker，可以指定自己的執行帳號（<code>user</code> / <code>group</code>）、監聽位址（<code>listen</code>）、以及 PHP 設定。這一個機制解掉了 mod_php 最大的限制：</p>
<p>mod_php 的所有站台都以 Apache 的帳號執行，彼此的檔案互相看得到。PHP-FPM 讓每個站台一個 pool、各自指定執行帳號：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">[site_a]</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">user</span> <span class="o">=</span> <span class="s">site_a</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">group</span> <span class="o">=</span> <span class="s">site_a</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">listen</span> <span class="o">=</span> <span class="s">/run/php/site_a.sock</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">[site_b]</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="na">user</span> <span class="o">=</span> <span class="s">site_b</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="na">group</span> <span class="o">=</span> <span class="s">site_b</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="na">listen</span> <span class="o">=</span> <span class="s">/run/php/site_b.sock</span></span></span></code></pre></div><p><code>listen</code> 可以是 Unix socket 的路徑（如上）或 <code>IP:埠</code>。web 伺服器與 PHP-FPM 在同一台機器、同一個檔案系統時可以用 Unix socket，少走一層 TCP；分在不同容器或不同機器時要用 TCP，這也是容器環境預設 <code>listen = 9000</code> 的原因。</p>
<p><code>site_a</code> 的 PHP 以 <code>site_a</code> 的帳號執行，讀不到 <code>site_b</code> 的檔案，產生的檔案也屬於 <code>site_a</code>。這把 <a href="/blog/programming/php/01-server-runtime/cgi/#cgi-%e5%9c%a8%e5%85%b1%e4%ba%ab%e4%b8%bb%e6%a9%9f%e4%b8%8a%e7%95%99%e4%b8%8b%e4%be%86%e7%9a%84%e7%90%86%e7%94%b1" data-link-title="CGI：每個請求啟動一個 PHP 程序的執行模型" data-link-desc="說明 CGI 怎麼把一個 HTTP 請求交給外部程式：web 伺服器為每個請求啟動一個程序，用環境變數與標準輸入輸出傳遞請求和回應；PHP 以 php-cgi 執行時每個請求付出的啟動成本、OPcache 為什麼沒有作用、以每個站台的使用者身分執行帶來的隔離，以及請求資料與程式呼叫參數之間的邊界問題">CGI 加 suEXEC</a> 才有的隔離，做在了常駐程序上——既有隔離，又沒有每個請求啟動程序的成本。這是共享主機從 CGI 換到 PHP-FPM 的主因。</p>
<h2 id="pm開-worker-的三種策略">pm：開 worker 的三種策略</h2>
<p><code>pm</code> 決定 PHP-FPM 怎麼決定開幾個 worker，有三個值：</p>
<table>
  <thead>
      <tr>
          <th><code>pm</code> 的值</th>
          <th>行為</th>
          <th>適合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>static</code></td>
          <td>固定開 <code>pm.max_children</code> 個 worker，一直都在</td>
          <td>流量穩定、記憶體足夠、要最可預測的延遲</td>
      </tr>
      <tr>
          <td><code>dynamic</code></td>
          <td>在 <code>pm.min_spare_servers</code> 與 <code>pm.max_spare_servers</code> 之間，依閒置數量增減，上限是 <code>pm.max_children</code></td>
          <td>流量有高低起伏</td>
      </tr>
      <tr>
          <td><code>ondemand</code></td>
          <td>平常不開 worker，有請求才開，閒置超過 <code>pm.process_idle_timeout</code> 就收掉</td>
          <td>流量很低、或同一台機器上有很多不常用的 pool</td>
      </tr>
  </tbody>
</table>
<p>三者的上限都是 <code>pm.max_children</code>，差別只在平常保持幾個 worker 活著。<code>dynamic</code> 是預設，它的「動態」是 PHP-FPM 在常駐的 worker 群裡依閒置數量增減，不是每個請求開關程序，所以跟 CGI 的每請求一程序是兩回事。</p>
<p>容器裡的取捨跟整台伺服器不同。一個容器通常只跑一個 pool，而且 <code>max_children</code> 已經依容器的記憶體上限配好；這時 <code>dynamic</code> 省下的閒置記憶體別的程式也用不到，<code>static</code> 反而省掉流量上升時臨時開 worker 的延遲。</p>
<h2 id="max_children並發上限與排隊">max_children：並發上限與排隊</h2>
<p><code>pm.max_children</code> 是這個 pool 最多能同時存在的 worker 數量，也就是這個 pool 最多能同時處理幾個請求。一個 worker 一次只處理一個請求，所以超過這個數量的請求要排隊等。每個 worker 各自連資料庫，所以實例數 × <code>pm.max_children</code> 也是資料庫連線數的上限，要算進資料庫的 <code>max_connections</code>（各語言的連線模型對照見 <a href="/blog/backend/01-database/data-access-layers/" data-link-title="1.17 應用程式存取資料庫的工具分層：驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM" data-link-desc="應用程式存取關聯式資料庫的工作（寫 SQL、取得連線與交易、把結果對映成程式的值），驅動、結果對映、從 SQL 產生程式碼、Query Builder 與 ORM 各接手哪幾件、接手之後哪些行為看不見，ORM 的 N&#43;1 與交易邊界、Go、gunicorn 與 PHP-FPM 各自對資料庫的連線總數與 max_connections、唯一約束違反時各語言讀取約束名稱的位置、讀寫分離時各框架把查詢送到主庫或副本的規則、資料拆到多個資料庫時跨不過的邊界，以及 Go、PHP（Laravel）與 Python 的對應工具與選型軸">1.17 應用程式存取資料庫的工具分層</a>）。</p>
<p>這一點可以直接量。設 <code>pm = static</code>、<code>pm.max_children = 2</code>，然後同時送 6 個各需要 1 秒的請求：</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">各請求完成的秒數：[1.06, 1.06, 2.07, 2.07, 3.08, 3.09]  總時間 3.1 秒</span></span></code></pre></div><p>兩個 worker 同時處理兩個請求（約 1 秒完成），剩下的排隊：第三、四個等前兩個做完（約 2 秒），第五、六個再等一輪（約 3 秒）。這就是 <code>max_children</code> 太小時的樣子：請求都成功了，延遲隨排隊長度線性增加。</p>
<p><code>max_children</code> 要設多大，由兩件事夾出來：</p>
<ul>
<li><strong>上界是記憶體</strong>：每個 worker 都佔記憶體（框架越大佔越多）。<code>max_children</code> 乘上單一 worker 的記憶體，不能超過機器分給 PHP 的量，否則系統開始用 swap 或直接把程序殺掉。單一 worker 的記憶體要用<strong>最重的那一類請求的峰值</strong>估，不能用平均值：下面〈max_requests〉一節量到，worker 處理完一個重請求之後，記憶體會保留一段時間才降回來，而尖峰時可能每個 worker 剛好都處理過重請求。</li>
<li><strong>下界是並發需求</strong>：太小就像上面那樣排隊。需要多少並發，看請求把時間花在哪裡：大部分時間在等資料庫或外部 API 的請求，worker 等待時不佔 CPU，數量可以遠多於 CPU 核心數；大部分時間在計算的請求，worker 數超過核心數之後只增加切換成本，處理量不會再上升。</li>
</ul>
<p>這是 PHP-FPM 最常調、也最該記進壓測結果的一個值。壓測時換了機器、換了框架、改了這個值，數字就不能跟之前比。</p>
<h2 id="max_requests定期換掉-worker-以控制記憶體">max_requests：定期換掉 worker 以控制記憶體</h2>
<p><code>pm.max_requests</code> 讓一個 worker 處理滿指定數量的請求之後，自己結束，由 master 開一個新的 worker 補上。設 <code>pm.max_requests = 3</code>，對計數腳本連續請求，可以看到 PID 換掉：</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">pid=7 static=1 ...
</span></span><span class="line"><span class="ln">2</span><span class="cl">pid=7 static=1 ...
</span></span><span class="line"><span class="ln">3</span><span class="cl">pid=7 static=1 ...
</span></span><span class="line"><span class="ln">4</span><span class="cl">pid=8 static=1 ...     # PID 7 處理滿 3 個請求後，換成 PID 8</span></span></code></pre></div><p>它防的是哪一種記憶體成長，要先把 worker 的記憶體分成兩類，因為兩類跨請求的行為不同。</p>
<p><strong>PHP 程式碼用的記憶體不會隨處理過的請求數增長，但會暫時停在高點。</strong> PHP 程式碼建立的變數、陣列與物件，由 PHP 自己的記憶體管理器（Zend Memory Manager）配置，請求結束時全部釋放——這是 <a href="/blog/programming/php/knowledge-cards/shared-nothing-request/" data-link-title="Shared-nothing 請求模型" data-link-desc="要判斷 PHP 的全域變數、static 變數或類別靜態屬性會不會留到下一個請求，或評估換成常駐 worker（Octane、FrankenPHP worker）時哪些程式碼會出問題時">shared-nothing</a> 的一部分。不過「釋放」是釋放給記憶體管理器，不等於立刻還給作業系統，所以從作業系統看到的 RSS 不會馬上降。用一個只開一個 worker 的 pool，讓每個請求都落在同一個程序上，量它的 RSS（作業系統看到的實體記憶體佔用）：</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">初始                                  rss=10MB
</span></span><span class="line"><span class="ln">2</span><span class="cl">一個建立 50 萬筆小陣列的請求（PHP 峰值 210MB）
</span></span><span class="line"><span class="ln">3</span><span class="cl">之後的小請求，依序：                  rss=112MB → 62MB → 36MB → 24MB → 18MB → 14MB → 14MB</span></span></code></pre></div><p>請求早就結束了，RSS 卻停在 112MB，再隨後面的請求逐步降回約 14MB。原因在記憶體管理器的實作：它以 2MB 的 chunk 為單位向作業系統要記憶體，請求結束時不把 chunk 全部還回去，而是留下一部分給下一個請求用，省掉反覆向作業系統申請與歸還的成本。留下多少由一個移動平均決定，每個請求結束時更新成「上一次的平均與這次請求的峰值兩者的平均」，保留的 chunk 數跟著這個平均走。代入上面的量測：重請求之前平均接近 0，重請求的峰值約 210MB，這個請求結束時平均變成兩者的一半、約 105MB，所以保留了約 102MB；之後每個輕請求的峰值只有約 2MB，平均每次再減半。PHP 回報的記憶體依序是 102、52、26、14、8、4、2 MB，就是這個減半。這個調整只在請求結束時發生：worker 閒著沒有請求時，RSS 停在原來的值，不會隨時間自己降下來。單一一次配置超過約 2MB 的大區塊不走 chunk，請求結束時直接還給作業系統：一個配置 200MB 字串的請求結束後，RSS 立刻回到 12MB。</p>
<p>所以 worker 的 RSS 反映的是<strong>最近處理過的請求的峰值</strong>，不是它累計處理過幾個請求。重複「每 5 個輕請求夾 1 個重請求」六輪，每輪結束時的 RSS 都停在 14MB，沒有一輪比一輪高。</p>
<p><strong>真正會跨請求累積的，是 PHP 記憶體管理器以外的記憶體。</strong> C 寫的擴充與它們依賴的函式庫，有一部分記憶體直接向作業系統的配置器要，不經過 Zend Memory Manager，也不在請求結束時被清掉。這一類有洩漏時，worker 的 RSS 會隨處理過的請求數一路往上，不會降回來。<code>max_requests</code> 防的是這一類：與其逐一追查是哪個擴充在漏，讓 worker 處理一定數量的請求後整個換掉，舊程序累積的記憶體隨它結束一起還給作業系統。代價是新 worker 的 OPcache 命中要重新累積（見 <a href="/blog/programming/php/01-server-runtime/opcache/" data-link-title="OPcache 與程序壽命：編譯結果的快取在各種執行模型下的效果" data-link-desc="說明 OPcache 快取的是什麼、它存在哪裡，以及為什麼它的效果取決於執行 PHP 的程序活多久：CGI 下命中次數永遠為零、PHP-FPM 的 worker 共用同一份快取；框架請求開與關 OPcache 的延遲差、memory_consumption 與 max_accelerated_files 的估算，以及部署新程式碼時 validate_timestamps 與 revalidate_freq 決定舊版還會執行多久">OPcache 與程序壽命</a>），所以這個值不設太小。</p>
<p>這兩類的分別給出一個判讀方法：在穩定的負載下，觀察 worker 的 RSS <strong>在輕請求之後回落到的底部</strong>。底部固定，代表成長來自重請求的暫時保留，<code>max_requests</code> 不必設；底部隨時間一路往上，代表有 PHP 記憶體管理器以外的洩漏，這才是 <code>max_requests</code> 要處理的情形。</p>
<h2 id="request_terminate_timeout-與-slowlog跑太久的請求">request_terminate_timeout 與 slowlog：跑太久的請求</h2>
<p>一個卡住的請求會一直佔著一個 worker。佔住的 worker 越多，<code>max_children</code> 裡能處理新請求的名額就越少，到最後整個 pool 都卡在慢請求上，新請求全部排隊。PHP-FPM 有兩個設定處理這件事。</p>
<p><code>request_terminate_timeout</code> 是強制上限：一個請求跑超過這個時間，PHP-FPM 直接終止處理它的 worker。設 <code>request_terminate_timeout = 3s</code>，對一個會跑 5 秒的請求：</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">$ curl -o /dev/null -w &#34;%{http_code} after %{time_total}s&#34; localhost/sleep.php?s=5
</span></span><span class="line"><span class="ln">2</span><span class="cl">502 after 3.24s</span></span></code></pre></div><p>PHP-FPM 的 log 記下整個過程：</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">WARNING: child 11, script &#39;sleep.php&#39; execution timed out (3.2 sec), terminating
</span></span><span class="line"><span class="ln">2</span><span class="cl">WARNING: child 11 exited on signal 15 (SIGTERM) ...</span></span></code></pre></div><p>而 Nginx 這一側，因為 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">[error] recv() failed (104: Connection reset by peer) while reading
</span></span><span class="line"><span class="ln">2</span><span class="cl">  response header from upstream</span></span></code></pre></div><p>這解釋了一個常見的現象：前端收到 502 Bad Gateway，但 Nginx 的 log 寫的是「連線被 upstream 重設」——因為 PHP-FPM 為了保護 worker 名額，主動把那個卡住的 worker 殺掉了。5xx 還有另一個常見成因跟逾時無關：worker 全忙時新請求在佇列裡排隊，延遲一路上升，排得夠久會撞到 Nginx 自己的逾時。兩者在 status 頁分得開，後者的 <code>max listen queue</code> 會大於 0（見下面的〈status 頁〉一節）。<code>request_terminate_timeout</code> 跟 PHP 自己的 <code>max_execution_time</code> 量的不是同一種時間。PHP 官方文件寫明，<code>max_execution_time</code> 只計算腳本本身執行的時間，花在腳本以外的活動——系統呼叫、串流操作、資料庫查詢——不算進去（Windows 例外，那裡量的是實際經過的時間）。所以一個卡在等資料庫回應的請求，<code>max_execution_time</code> 可能永遠不會觸發。<code>request_terminate_timeout</code> 由 PHP-FPM 從外部計算實際經過的時間，卡在哪裡都會終止。</p>
<p><code>request_slowlog_timeout</code> 不終止請求，只在請求超過指定時間時把它當下的 PHP 呼叫堆疊記進 slowlog。設 <code>request_slowlog_timeout = 1s</code>，一個在 <code>loadReport()</code> 裡 sleep 的請求會留下：</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">[pool www] pid 6
</span></span><span class="line"><span class="ln">2</span><span class="cl">script_filename = /var/www/html/slow.php
</span></span><span class="line"><span class="ln">3</span><span class="cl">[0x...] usleep() /var/www/html/slow.php:2
</span></span><span class="line"><span class="ln">4</span><span class="cl">[0x...] loadReport() /var/www/html/slow.php:3
</span></span><span class="line"><span class="ln">5</span><span class="cl">[0x...] handle() /var/www/html/slow.php:4</span></span></code></pre></div><p>堆疊直接指出慢在哪一行、哪一層呼叫。這是找出慢請求根源最直接的工具，不必改程式加計時。</p>
<p>一個部署上的細節：PHP-FPM 取 worker 的堆疊是靠 <code>ptrace</code> 這個系統呼叫，在 Docker 預設的權限下會失敗（<code>failed to ptrace(ATTACH) ... Operation not permitted</code>），slowlog 因此是空的。容器裡要用 slowlog 得在啟動時加 <code>--cap-add SYS_PTRACE</code>。</p>
<h2 id="status-頁看這個-pool-現在的狀態">status 頁：看這個 pool 現在的狀態</h2>
<p><code>pm.status_path</code> 開一個狀態頁，列出 pool 當下的 worker 數量、排隊與累計計數。前面那個 <code>max_children = 2</code> 的排隊測試跑完後，狀態頁是：</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">pool:                 www
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">process manager:      static
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">accepted conn:        15
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">listen queue:         0
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">max listen queue:     4
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">idle processes:       1
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">active processes:     1
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">total processes:      2
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">max active processes: 2
</span></span><span class="line"><span class="ln">10</span><span class="cl">max children reached: 0
</span></span><span class="line"><span class="ln">11</span><span class="cl">slow requests:        0</span></span></code></pre></div><p>幾個欄位直接對應前面的設定：</p>
<ul>
<li><strong><code>max listen queue</code></strong>：等待空閒 worker 的請求，最多排到過幾個。上面是 4，正好是那次 6 個並發裡、扣掉 2 個正在處理的其餘 4 個。這個數字長期大於 0，代表 <code>max_children</code> 在尖峰時不夠用，請求要排隊等空閒的 worker。</li>
<li><strong><code>max children reached</code></strong>：PHP-FPM 想多開 worker、卻撞到 <code>max_children</code> 上限的次數。這個計數只在 <code>dynamic</code> 與 <code>ondemand</code> 下會動，因為只有這兩種模式會「想多開」：上面那次是 <code>static</code>，請求排了 4 個而它仍然是 0；同樣 6 個並發換成 <code>pm = dynamic</code>（<code>max_children = 2</code>）時，它變成 1、<code>max listen queue</code> 是 5。所以 <code>static</code> 模式要看 <code>max listen queue</code>，不能看這一欄。</li>
<li><strong><code>slow requests</code></strong>：超過 <code>request_slowlog_timeout</code> 的請求累計數。</li>
</ul>
<p>所以判斷 <code>max_children</code> 夠不夠，要先看 pool 用的是哪一種 <code>pm</code>：<strong><code>static</code> 只能看 <code>max listen queue</code></strong>（<code>max children reached</code> 在 <code>static</code> 下永遠是 0），<code>dynamic</code> 與 <code>ondemand</code> 兩欄都能看。<code>max listen queue</code> 長期大於 0 就該往上調，前提是記憶體容得下；長期是 0 代表還有餘裕。</p>
<h2 id="停機與重載的訊號">停機與重載的訊號</h2>
<p>PHP-FPM 的 master 依收到的訊號決定怎麼停或怎麼換掉 worker，PHP 原始碼附的 man page 列出四個：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>SIGINT</code>、<code>SIGTERM</code></td>
          <td>立即終止</td>
      </tr>
      <tr>
          <td><code>SIGQUIT</code></td>
          <td>graceful stop</td>
      </tr>
      <tr>
          <td><code>SIGUSR1</code></td>
          <td>重新開啟 log 檔</td>
      </tr>
      <tr>
          <td><code>SIGUSR2</code></td>
          <td>graceful reload：重讀設定、換掉所有 worker</td>
      </tr>
  </tbody>
</table>
<p>「graceful」要等正在處理的請求多久，由另一個設定 <code>process_control_timeout</code> 決定：master 送出停止訊號之後，最多等 worker 這麼久。它的預設值是 0，也就是<strong>不等</strong>。一個請求要跑 3 秒，在它跑到第 1 秒時對 master 送 <code>SIGUSR2</code>：</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">process_control_timeout = 0（預設）  →  502，在 1.07 秒時中斷
</span></span><span class="line"><span class="ln">2</span><span class="cl">process_control_timeout = 10         →  200，跑滿 3.0 秒才回應</span></span></code></pre></div><p><code>SIGQUIT</code> 一樣：預設值下 <code>docker stop</code> 一送出 PHP-FPM 就結束，進行中的請求拿到 502；設成 10 之後，<code>docker stop</code> 花了 2 秒，等那個請求做完。所以要讓 reload 與停機不中斷請求，<code>process_control_timeout</code> 要設成比最長的正常請求還長的秒數（它寫在設定檔的 <code>[global]</code> 段），而容器平台的停機逾時（<code>docker stop -t</code>、Kubernetes 的 <code>terminationGracePeriodSeconds</code>）要比它更長，否則平台會先強制終止。</p>
<p>設好之後，reload 只換 PHP 不動 Nginx。這跟 <a href="/blog/programming/php/01-server-runtime/mod-php/#%e8%a8%ad%e5%ae%9a%e7%9a%84%e4%bd%8d%e7%bd%ae%e8%88%87%e7%94%9f%e6%95%88%e6%99%82%e6%a9%9f" data-link-title="mod_php：PHP 直譯器嵌進 Apache 程序的執行模型" data-link-desc="說明 mod_php 怎麼把 PHP 直譯器載入 Apache 的每個子程序：為什麼它要搭配 prefork MPM、每個子程序都帶著 PHP 對記憶體與並發的影響、所有站台共用 Apache 的執行帳號、php.ini 與 .htaccess 的設定方式，以及從 mod_php 搬到 PHP-FPM 時 .htaccess 的 php_value 與 .user.ini 的差異">mod_php</a> 的「改 PHP 設定要重載 Apache」是不同的隔離層級：PHP-FPM 是獨立的服務，重載 PHP 時 Nginx 的程序與它和客戶端之間的連線都不受影響。這也是 PHP 官方建議「用 PHP-FPM 取代 mod_php」的理由之一——PHP 能獨立於 web 伺服器重啟。</p>
<h2 id="php-fpm-管的事情總覽">PHP-FPM 管的事情總覽</h2>
<p>回到開頭那幾個「常駐程序帶出的問題」，PHP-FPM 的每一個旋鈕各對應一個：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>對應的設定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>開幾個程序、怎麼增減</td>
          <td><code>pm</code>、<code>pm.max_children</code>、<code>pm.*_spare_servers</code></td>
      </tr>
      <tr>
          <td>並發上限與排隊</td>
          <td><code>pm.max_children</code>（配 status 的 <code>max listen queue</code> 判讀）</td>
      </tr>
      <tr>
          <td>記憶體隨時間漲（RSS 底部一路往上；重請求後暫時偏高不算）</td>
          <td><code>pm.max_requests</code></td>
      </tr>
      <tr>
          <td>某個請求卡住</td>
          <td><code>request_terminate_timeout</code>、<code>request_slowlog_timeout</code></td>
      </tr>
      <tr>
          <td>每個站台不同執行身分</td>
          <td>pool 的 <code>user</code> / <code>group</code> / <code>listen</code></td>
      </tr>
      <tr>
          <td>不中斷地改設定與停機</td>
          <td><code>SIGUSR2</code>（reload）、<code>SIGQUIT</code>，配 <code>process_control_timeout</code></td>
      </tr>
  </tbody>
</table>
<p>這些旋鈕都是因為 worker 常駐才需要。worker 常駐同時讓一件事變得有意義：OPcache。程序活得夠久，編譯好的 opcode 才有機會被下一個請求重用，重用的程度在 <a href="/blog/programming/php/01-server-runtime/opcache/" data-link-title="OPcache 與程序壽命：編譯結果的快取在各種執行模型下的效果" data-link-desc="說明 OPcache 快取的是什麼、它存在哪裡，以及為什麼它的效果取決於執行 PHP 的程序活多久：CGI 下命中次數永遠為零、PHP-FPM 的 worker 共用同一份快取；框架請求開與關 OPcache 的延遲差、memory_consumption 與 max_accelerated_files 的估算，以及部署新程式碼時 validate_timestamps 與 revalidate_freq 決定舊版還會執行多久">OPcache 與程序壽命</a> 實測。</p>
<p>如果連應用程式的狀態也想跨請求保留（框架只啟動一次），就要再往前走一步到常駐 worker 模型，那是另一組取捨，見 <a href="/blog/programming/php/01-server-runtime/long-running-workers/" data-link-title="常駐 worker 模型：FrankenPHP worker 模式、Laravel Octane 與跨請求殘留的狀態" data-link-desc="說明讓 PHP 應用程式只啟動一次、在記憶體裡連續處理請求的常駐 worker 模型：它省下的是每個請求重新啟動框架的成本，失去的是 shared-nothing 的隔離；以 FrankenPHP 的 worker 模式與 Laravel Octane 實測靜態屬性跨請求累積、記憶體隨請求數線性增長與 max-requests 重啟，以及寫給這個模型的程式要避開的寫法">常駐 worker 模型</a>。而把 Nginx 跟 PHP-FPM 放進容器時，兩個程序怎麼擺、要不要拆成兩個容器，見 <a href="/blog/programming/php/01-server-runtime/nginx-php-fpm-containers/" data-link-title="容器裡的 Nginx 與 PHP-FPM：拆成兩個容器與放進同一個容器的做法與各自的陷阱" data-link-desc="說明把 Nginx 與 PHP-FPM 放進容器的兩種擺法：官方 php-fpm 映像檔替容器改掉的預設值（log 導向、clear_env、監聽位址、停機訊號），拆成兩個容器時程式碼要兩邊都有、否則靜態檔會被 PHP 回成 200 的 HTML，以及放進同一個容器時要自己處理的程序監看、停機訊號轉送與 FastCGI 的監聽範圍">容器裡的 Nginx 與 PHP-FPM</a>。</p>
]]></content:encoded></item></channel></rss>