<?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>伺服器上的 PHP 執行模型 on Tarragon</title><link>https://tarrragon.github.io/blog/programming/php/01-server-runtime/</link><description>Recent content in 伺服器上的 PHP 執行模型 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/programming/php/01-server-runtime/index.xml" rel="self" type="application/rss+xml"/><item><title>PHP 的請求生命週期與 SAPI：直譯器的位置、程序壽命與狀態壽命</title><link>https://tarrragon.github.io/blog/programming/php/01-server-runtime/request-lifecycle-and-sapi/</link><pubDate>Mon, 05 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/php/01-server-runtime/request-lifecycle-and-sapi/</guid><description>&lt;p>這篇說明 PHP 直譯器與 web 伺服器之間的那一層：一個 HTTP 請求進來之後，PHP 在什麼時候被啟動、哪些狀態在請求結束時被清掉、哪些資源留在程序裡。範圍不含 PHP 語法與框架；文中的行為取自 PHP 8.5，用官方 Docker 映像檔實測。&lt;/p>
&lt;h2 id="一個請求在-php-裡經過的階段">一個請求在 PHP 裡經過的階段&lt;/h2>
&lt;p>PHP 的執行分成兩層生命週期：&lt;strong>程序層&lt;/strong>與&lt;strong>請求層&lt;/strong>。程序層的工作每個程序只做一次，請求層的工作每個請求都做一次。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>層&lt;/th>
 &lt;th>階段&lt;/th>
 &lt;th>PHP 在這個階段做的事&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>程序層&lt;/td>
 &lt;td>模組啟動&lt;/td>
 &lt;td>讀 &lt;code>php.ini&lt;/code>、載入並初始化擴充（&lt;code>pdo_pgsql&lt;/code>、&lt;code>redis&lt;/code> 這類），每個擴充註冊自己的函式與類別&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>請求層&lt;/td>
 &lt;td>請求啟動&lt;/td>
 &lt;td>依這個請求建立 &lt;code>$_SERVER&lt;/code>、&lt;code>$_GET&lt;/code>、&lt;code>$_POST&lt;/code>、&lt;code>$_COOKIE&lt;/code> 等超全域變數&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>請求層&lt;/td>
 &lt;td>執行腳本&lt;/td>
 &lt;td>把 &lt;code>.php&lt;/code> 檔編譯成 opcode（Zend 引擎執行的指令），再執行它&lt;/td>
 &lt;/tr>
 &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;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>請求結束那一步的範圍很大：全域變數、函式內的 &lt;code>static&lt;/code> 變數、類別的靜態屬性、開過的檔案與一般的資料庫連線，全部在這一步釋放。下一個請求就算由同一個程序處理，也看不到上一個請求留下的任何 PHP 變數。這個設計叫 &lt;strong>shared-nothing&lt;/strong>：請求之間不共享應用程式的狀態，每個請求從空白開始（見 &lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/shared-nothing-request/" data-link-title="Shared-nothing 請求模型" data-link-desc="要判斷 PHP 的全域變數、static 變數或類別靜態屬性會不會留到下一個請求，或評估換成常駐 worker（Octane、FrankenPHP worker）時哪些程式碼會出問題時">Shared-nothing 請求模型&lt;/a>）。&lt;/p>
&lt;p>這件事可以直接量。下面這支腳本每次執行都把函式內的 &lt;code>static&lt;/code> 變數與一個全域變數加一，並印出處理它的程序編號：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-php" data-lang="php">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="o">&amp;lt;?&lt;/span>&lt;span class="nx">php&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">function&lt;/span> &lt;span class="nf">counter&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">static&lt;/span> &lt;span class="nv">$n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="o">++&lt;/span>&lt;span class="nv">$n&lt;/span>&lt;span class="p">;&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="nv">$GLOBALS&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;g&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nv">$GLOBALS&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;g&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">??&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">1&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="k">echo&lt;/span> &lt;span class="s2">&amp;#34;pid=&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">getmypid&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="s2">&amp;#34; static=&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">counter&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="s2">&amp;#34; global=&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nv">$GLOBALS&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;g&amp;#39;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="s2">&amp;#34; sapi=&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">PHP_SAPI&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>在 Nginx 加 PHP-FPM 的環境連續請求四次，輸出是：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">pid=7 static=1 global=1 sapi=fpm-fcgi
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">pid=6 static=1 global=1 sapi=fpm-fcgi
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">pid=7 static=1 global=1 sapi=fpm-fcgi
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">pid=6 static=1 global=1 sapi=fpm-fcgi&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩個程序（PID 6 與 7）輪流處理請求，同一個程序處理第二次時，兩個計數器仍然是 1。程序活了下來，PHP 變數沒有。&lt;/p>
&lt;h3 id="請求結束後留在程序裡的資源">請求結束後留在程序裡的資源&lt;/h3>
&lt;p>請求結束只清掉 PHP 程式碼建立的變數，程序本身持有的資源可以跨請求存在，而且只有程序活得夠久時才有意義：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OPcache&lt;/strong>：把編譯好的 opcode 存在共享記憶體裡，下一個請求不必重新編譯同一個檔案。它的效果完全取決於程序活多久，在 &lt;a href="https://tarrragon.github.io/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 與程序壽命&lt;/a> 展開。&lt;/li>
&lt;li>&lt;strong>持久連線&lt;/strong>：PDO 開了 &lt;code>PDO::ATTR_PERSISTENT&lt;/code> 的資料庫連線，請求結束時不關閉，留給同一個程序的下一個請求重用。&lt;/li>
&lt;li>&lt;strong>APCu 這類記憶體快取擴充&lt;/strong>：資料放在程序（或程序群共用）的記憶體裡，請求結束不清除。&lt;/li>
&lt;/ul>
&lt;p>所以 shared-nothing 的精確範圍是「PHP 程式碼建立的狀態」。程序層的資源由程序壽命決定，而程序壽命由下一節的 SAPI 決定。&lt;/p>
&lt;h2 id="sapiphp-直譯器與外部環境之間的介面">SAPI：PHP 直譯器與外部環境之間的介面&lt;/h2>
&lt;p>&lt;strong>SAPI&lt;/strong>（Server API）是 PHP 核心與外部環境之間的一層。PHP 的直譯器本身不知道請求從哪裡來：它可能被命令列啟動、被 web 伺服器 fork 出來、被嵌在 Apache 裡、或在一個常駐的伺服器裡被呼叫。SAPI 負責兩件事：把外部環境帶來的請求翻譯成 PHP 認得的形式（填好 &lt;code>$_SERVER&lt;/code>、&lt;code>$_GET&lt;/code>，把請求內容交給 &lt;code>php://input&lt;/code>），以及把 PHP 的輸出與回應標頭交回外部環境。同一份 PHP 核心配上不同的 SAPI，就是不同的執行方式（見 &lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/sapi/" data-link-title="SAPI（Server API）" data-link-desc="在 PHP_SAPI 或 phpinfo 看到 cli、fpm-fcgi、apache2handler、frankenphp 這類值，要知道它代表 PHP 以哪種方式被啟動、請求怎麼交進來時">SAPI&lt;/a>）。&lt;/p>
&lt;p>程式裡可以用 &lt;code>PHP_SAPI&lt;/code> 常數（或 &lt;code>php_sapi_name()&lt;/code>）讀到目前的 SAPI 名稱。上一節的輸出裡 &lt;code>sapi=fpm-fcgi&lt;/code> 就是這個值。實測過的幾種：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>&lt;code>PHP_SAPI&lt;/code> 的值&lt;/th>
 &lt;th>啟動方式&lt;/th>
 &lt;th>誰對外講 HTTP&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>cli&lt;/code>&lt;/td>
 &lt;td>在命令列執行 &lt;code>php script.php&lt;/code>&lt;/td>
 &lt;td>沒有 HTTP&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>cgi-fcgi&lt;/code>&lt;/td>
 &lt;td>&lt;code>php-cgi&lt;/code> 這支程式，被 web 伺服器以 CGI 啟動，或以 FastCGI 模式常駐&lt;/td>
 &lt;td>web 伺服器&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>apache2handler&lt;/code>&lt;/td>
 &lt;td>mod_php：PHP 以 Apache 模組的形式載入 Apache 的程序裡&lt;/td>
 &lt;td>Apache&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>fpm-fcgi&lt;/code>&lt;/td>
 &lt;td>PHP-FPM：一個常駐的主程序管理一群 worker，用 FastCGI 接請求&lt;/td>
 &lt;td>前面的 web 伺服器（Nginx、Apache）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>frankenphp&lt;/code>&lt;/td>
 &lt;td>FrankenPHP：PHP 嵌在一個用 Go 寫的 web 伺服器裡&lt;/td>
 &lt;td>FrankenPHP 自己&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>cgi-fcgi&lt;/code> 這個名字反映了一件事：&lt;code>php-cgi&lt;/code> 同一支程式能以兩種協定運作。CGI 與 FastCGI 是兩個不同的協定，FastCGI 沿用 CGI 傳遞請求資訊的那套參數名稱，改掉的是程序壽命。被 web 伺服器以 CGI 執行時，&lt;code>php-cgi&lt;/code> 處理完一個請求就結束；以 FastCGI 模式啟動時，它常駐下來接收多個請求。兩者的差別在 &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/fastcgi/" data-link-title="FastCGI：讓 web 伺服器與常駐的 PHP 程序溝通的協定" data-link-desc="說明 FastCGI 協定怎麼在 CGI 的基礎上去掉每個請求重啟程序這一點：常駐的應用程式程序、web 伺服器與它之間的連線、一個請求在協定裡的樣子，以及 SCRIPT_FILENAME 由 web 伺服器決定會帶來的安全邊界（cgi.fix_pathinfo 與 try_files）">FastCGI&lt;/a> 兩篇分別說明。&lt;/p></description><content:encoded><![CDATA[<p>這篇說明 PHP 直譯器與 web 伺服器之間的那一層：一個 HTTP 請求進來之後，PHP 在什麼時候被啟動、哪些狀態在請求結束時被清掉、哪些資源留在程序裡。範圍不含 PHP 語法與框架；文中的行為取自 PHP 8.5，用官方 Docker 映像檔實測。</p>
<h2 id="一個請求在-php-裡經過的階段">一個請求在 PHP 裡經過的階段</h2>
<p>PHP 的執行分成兩層生命週期：<strong>程序層</strong>與<strong>請求層</strong>。程序層的工作每個程序只做一次，請求層的工作每個請求都做一次。</p>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>階段</th>
          <th>PHP 在這個階段做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>程序層</td>
          <td>模組啟動</td>
          <td>讀 <code>php.ini</code>、載入並初始化擴充（<code>pdo_pgsql</code>、<code>redis</code> 這類），每個擴充註冊自己的函式與類別</td>
      </tr>
      <tr>
          <td>請求層</td>
          <td>請求啟動</td>
          <td>依這個請求建立 <code>$_SERVER</code>、<code>$_GET</code>、<code>$_POST</code>、<code>$_COOKIE</code> 等超全域變數</td>
      </tr>
      <tr>
          <td>請求層</td>
          <td>執行腳本</td>
          <td>把 <code>.php</code> 檔編譯成 opcode（Zend 引擎執行的指令），再執行它</td>
      </tr>
      <tr>
          <td>請求層</td>
          <td>請求結束</td>
          <td>釋放這個請求建立的所有變數、物件與資源，送出剩下的輸出</td>
      </tr>
      <tr>
          <td>程序層</td>
          <td>模組結束</td>
          <td>程序要結束時，各擴充釋放自己的資源</td>
      </tr>
  </tbody>
</table>
<p>請求結束那一步的範圍很大：全域變數、函式內的 <code>static</code> 變數、類別的靜態屬性、開過的檔案與一般的資料庫連線，全部在這一步釋放。下一個請求就算由同一個程序處理，也看不到上一個請求留下的任何 PHP 變數。這個設計叫 <strong>shared-nothing</strong>：請求之間不共享應用程式的狀態，每個請求從空白開始（見 <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>）。</p>
<p>這件事可以直接量。下面這支腳本每次執行都把函式內的 <code>static</code> 變數與一個全域變數加一，並印出處理它的程序編號：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="o">&lt;?</span><span class="nx">php</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">function</span> <span class="nf">counter</span><span class="p">()</span> <span class="p">{</span> <span class="k">static</span> <span class="nv">$n</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="k">return</span> <span class="o">++</span><span class="nv">$n</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nv">$GLOBALS</span><span class="p">[</span><span class="s1">&#39;g&#39;</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span><span class="nv">$GLOBALS</span><span class="p">[</span><span class="s1">&#39;g&#39;</span><span class="p">]</span> <span class="o">??</span> <span class="mi">0</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">4</span><span class="cl"><span class="k">echo</span> <span class="s2">&#34;pid=&#34;</span><span class="p">,</span> <span class="nx">getmypid</span><span class="p">(),</span> <span class="s2">&#34; static=&#34;</span><span class="p">,</span> <span class="nx">counter</span><span class="p">(),</span> <span class="s2">&#34; global=&#34;</span><span class="p">,</span> <span class="nv">$GLOBALS</span><span class="p">[</span><span class="s1">&#39;g&#39;</span><span class="p">],</span> <span class="s2">&#34; sapi=&#34;</span><span class="p">,</span> <span class="nx">PHP_SAPI</span><span class="p">,</span> <span class="s2">&#34;</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">;</span></span></span></code></pre></div><p>在 Nginx 加 PHP-FPM 的環境連續請求四次，輸出是：</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 global=1 sapi=fpm-fcgi
</span></span><span class="line"><span class="ln">2</span><span class="cl">pid=6 static=1 global=1 sapi=fpm-fcgi
</span></span><span class="line"><span class="ln">3</span><span class="cl">pid=7 static=1 global=1 sapi=fpm-fcgi
</span></span><span class="line"><span class="ln">4</span><span class="cl">pid=6 static=1 global=1 sapi=fpm-fcgi</span></span></code></pre></div><p>兩個程序（PID 6 與 7）輪流處理請求，同一個程序處理第二次時，兩個計數器仍然是 1。程序活了下來，PHP 變數沒有。</p>
<h3 id="請求結束後留在程序裡的資源">請求結束後留在程序裡的資源</h3>
<p>請求結束只清掉 PHP 程式碼建立的變數，程序本身持有的資源可以跨請求存在，而且只有程序活得夠久時才有意義：</p>
<ul>
<li><strong>OPcache</strong>：把編譯好的 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> 展開。</li>
<li><strong>持久連線</strong>：PDO 開了 <code>PDO::ATTR_PERSISTENT</code> 的資料庫連線，請求結束時不關閉，留給同一個程序的下一個請求重用。</li>
<li><strong>APCu 這類記憶體快取擴充</strong>：資料放在程序（或程序群共用）的記憶體裡，請求結束不清除。</li>
</ul>
<p>所以 shared-nothing 的精確範圍是「PHP 程式碼建立的狀態」。程序層的資源由程序壽命決定，而程序壽命由下一節的 SAPI 決定。</p>
<h2 id="sapiphp-直譯器與外部環境之間的介面">SAPI：PHP 直譯器與外部環境之間的介面</h2>
<p><strong>SAPI</strong>（Server API）是 PHP 核心與外部環境之間的一層。PHP 的直譯器本身不知道請求從哪裡來：它可能被命令列啟動、被 web 伺服器 fork 出來、被嵌在 Apache 裡、或在一個常駐的伺服器裡被呼叫。SAPI 負責兩件事：把外部環境帶來的請求翻譯成 PHP 認得的形式（填好 <code>$_SERVER</code>、<code>$_GET</code>，把請求內容交給 <code>php://input</code>），以及把 PHP 的輸出與回應標頭交回外部環境。同一份 PHP 核心配上不同的 SAPI，就是不同的執行方式（見 <a href="/blog/programming/php/knowledge-cards/sapi/" data-link-title="SAPI（Server API）" data-link-desc="在 PHP_SAPI 或 phpinfo 看到 cli、fpm-fcgi、apache2handler、frankenphp 這類值，要知道它代表 PHP 以哪種方式被啟動、請求怎麼交進來時">SAPI</a>）。</p>
<p>程式裡可以用 <code>PHP_SAPI</code> 常數（或 <code>php_sapi_name()</code>）讀到目前的 SAPI 名稱。上一節的輸出裡 <code>sapi=fpm-fcgi</code> 就是這個值。實測過的幾種：</p>
<table>
  <thead>
      <tr>
          <th><code>PHP_SAPI</code> 的值</th>
          <th>啟動方式</th>
          <th>誰對外講 HTTP</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>cli</code></td>
          <td>在命令列執行 <code>php script.php</code></td>
          <td>沒有 HTTP</td>
      </tr>
      <tr>
          <td><code>cgi-fcgi</code></td>
          <td><code>php-cgi</code> 這支程式，被 web 伺服器以 CGI 啟動，或以 FastCGI 模式常駐</td>
          <td>web 伺服器</td>
      </tr>
      <tr>
          <td><code>apache2handler</code></td>
          <td>mod_php：PHP 以 Apache 模組的形式載入 Apache 的程序裡</td>
          <td>Apache</td>
      </tr>
      <tr>
          <td><code>fpm-fcgi</code></td>
          <td>PHP-FPM：一個常駐的主程序管理一群 worker，用 FastCGI 接請求</td>
          <td>前面的 web 伺服器（Nginx、Apache）</td>
      </tr>
      <tr>
          <td><code>frankenphp</code></td>
          <td>FrankenPHP：PHP 嵌在一個用 Go 寫的 web 伺服器裡</td>
          <td>FrankenPHP 自己</td>
      </tr>
  </tbody>
</table>
<p><code>cgi-fcgi</code> 這個名字反映了一件事：<code>php-cgi</code> 同一支程式能以兩種協定運作。CGI 與 FastCGI 是兩個不同的協定，FastCGI 沿用 CGI 傳遞請求資訊的那套參數名稱，改掉的是程序壽命。被 web 伺服器以 CGI 執行時，<code>php-cgi</code> 處理完一個請求就結束；以 FastCGI 模式啟動時，它常駐下來接收多個請求。兩者的差別在 <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/fastcgi/" data-link-title="FastCGI：讓 web 伺服器與常駐的 PHP 程序溝通的協定" data-link-desc="說明 FastCGI 協定怎麼在 CGI 的基礎上去掉每個請求重啟程序這一點：常駐的應用程式程序、web 伺服器與它之間的連線、一個請求在協定裡的樣子，以及 SCRIPT_FILENAME 由 web 伺服器決定會帶來的安全邊界（cgi.fix_pathinfo 與 try_files）">FastCGI</a> 兩篇分別說明。</p>
<h2 id="比較執行模型的三個問題">比較執行模型的三個問題</h2>
<p>SAPI 的種類很多，而它們之間的差異可以歸到三個問題上。每一種執行模型對這三個問題各有一組答案，後面的行為差異幾乎都從這組答案推得出來。</p>
<p><strong>直譯器住在哪裡。</strong> PHP 直譯器可能住在 web 伺服器自己的程序裡（mod_php），也可能住在另一個獨立的程序裡（CGI、PHP-FPM），或住在某個程序的執行緒裡（FrankenPHP）。住在 web 伺服器外面時，web 伺服器與 PHP 之間需要一種溝通方式，CGI 與 FastCGI 就是這種方式。這也決定了誰對外講 HTTP：直譯器住在 web 伺服器外面時，PHP 這一側只收得到 web 伺服器翻譯過的請求。</p>
<p><strong>執行它的程序活多久。</strong> 程序可能只活一個請求（CGI），也可能活過成千上萬個請求（mod_php、PHP-FPM、FrankenPHP）。程序活得越久，程序層的工作（讀設定、載入擴充）攤在越多請求上，OPcache 這類程序層的快取也才有編譯結果可以重用。</p>
<p><strong>應用程式的狀態活多久。</strong> 在 shared-nothing 的模型裡，狀態只活一個請求，框架每個請求都要重新啟動一次：載入設定、註冊服務、建立路由表。常駐 worker 模式（FrankenPHP 的 worker 模式、Laravel Octane）讓應用程式啟動一次後留在記憶體裡，狀態活過很多個請求，省下每次重新啟動框架的成本，代價是上一個請求留下的變數與物件會被下一個請求看到：放在靜態屬性裡的「目前登入的使用者」會被下一個使用者的請求讀到，每個請求往靜態陣列加資料的程式會讓記憶體隨請求數一路增長。</p>
<table>
  <thead>
      <tr>
          <th>執行模型</th>
          <th>直譯器住在</th>
          <th>程序壽命</th>
          <th>應用程式狀態壽命</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CGI</td>
          <td>每個請求新啟動的程序</td>
          <td>一個請求</td>
          <td>一個請求</td>
      </tr>
      <tr>
          <td>mod_php</td>
          <td>Apache 的子程序</td>
          <td>很多請求</td>
          <td>一個請求</td>
      </tr>
      <tr>
          <td>FastCGI（PHP-FPM）</td>
          <td>獨立的常駐 worker 程序</td>
          <td>很多請求</td>
          <td>一個請求</td>
      </tr>
      <tr>
          <td>FrankenPHP 傳統模式</td>
          <td>常駐程序裡的執行緒</td>
          <td>很多請求</td>
          <td>一個請求</td>
      </tr>
      <tr>
          <td>常駐 worker（FrankenPHP worker、Octane）</td>
          <td>常駐的 worker</td>
          <td>很多請求</td>
          <td>很多請求</td>
      </tr>
  </tbody>
</table>
<p>這張表裡最容易混在一起的是程序壽命與應用程式狀態壽命這兩欄，而它們是兩件獨立的事：FrankenPHP 的傳統模式只有一個程序、而且一直不結束，同一支計數腳本在它上面跑三次，三次的 PID 都是 1、計數器三次都是 1；換成 worker 模式，PID 仍然是 1，計數器變成 1、2、3、4。程序一樣常駐，差在 PHP 有沒有在請求之間清空狀態。</p>
<h2 id="三個問題各自展開的位置">三個問題各自展開的位置</h2>
<p>這個模型的三個問題，每換掉一個答案就走到另一種執行模型：</p>
<ul>
<li><strong>把直譯器放在每個請求新啟動的程序裡</strong>，就是 <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 的成本。</li>
<li><strong>把直譯器放進 web 伺服器的程序裡</strong>，就是 <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>：省掉啟動成本，代價是 web 伺服器的每個程序都帶著整個 PHP，包括只負責送圖片的那些。</li>
<li><strong>把直譯器放在 web 伺服器外面、讓程序常駐</strong>，需要一個讓兩邊溝通的協定，就是 <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>；管理那群常駐程序的實作是 <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>。</li>
<li><strong>讓程序活得更久</strong>對效能的意義集中在 <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>：程序壽命從一個請求變成很多個請求時，被重用的是什麼。</li>
<li><strong>讓應用程式的狀態也活過很多請求</strong>，就是 <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>：框架只啟動一次，同時失去 shared-nothing 的隔離。</li>
</ul>
]]></content:encoded></item><item><title>CGI：每個請求啟動一個 PHP 程序的執行模型</title><link>https://tarrragon.github.io/blog/programming/php/01-server-runtime/cgi/</link><pubDate>Mon, 05 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/php/01-server-runtime/cgi/</guid><description>&lt;p>這篇說明 CGI（Common Gateway Interface）這種執行模型，以及 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 直譯器住在每個請求新啟動的程序裡，程序與應用程式狀態都只活一個請求。&lt;/p>
&lt;h2 id="cgi-怎麼把請求交給外部程式">CGI 怎麼把請求交給外部程式&lt;/h2>
&lt;p>CGI 是 web 伺服器呼叫外部程式來產生回應的介面，規格寫成 RFC 3875（CGI/1.1），RFC 自己記載這個介面從 1993 年起就在 web 上使用。它跟語言無關：被呼叫的可以是 Perl、C、shell script 或 PHP，早期網站的 &lt;code>cgi-bin/&lt;/code> 目錄放的就是這類程式（見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/cgi/" data-link-title="CGI（Common Gateway Interface）" data-link-desc="接手 cgi-bin 目錄或以 php-cgi 執行的舊站，或在 $_SERVER 看到 REQUEST_METHOD、QUERY_STRING 這類大寫鍵名，要知道它們從哪裡來時">CGI&lt;/a>）。&lt;/p>
&lt;p>一個請求經過 CGI 的過程：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>動作者&lt;/th>
 &lt;th>做的事&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>web 伺服器&lt;/td>
 &lt;td>收到請求，判斷這個路徑要交給哪一支程式&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>web 伺服器&lt;/td>
 &lt;td>啟動那支程式的一個新程序（fork 加 exec）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>web 伺服器&lt;/td>
 &lt;td>把請求的資訊放進這個程序的環境變數：&lt;code>REQUEST_METHOD&lt;/code>、&lt;code>QUERY_STRING&lt;/code>、&lt;code>CONTENT_TYPE&lt;/code>、&lt;code>CONTENT_LENGTH&lt;/code>、&lt;code>REMOTE_ADDR&lt;/code>，以及各個 HTTP 標頭&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>web 伺服器&lt;/td>
 &lt;td>把請求的內容（POST 的資料）寫進程序的標準輸入&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>外部程式&lt;/td>
 &lt;td>從環境變數與標準輸入讀到請求，把回應標頭、一個空行與回應內容寫到標準輸出&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>外部程式&lt;/td>
 &lt;td>結束程序&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>web 伺服器&lt;/td>
 &lt;td>把標準輸出的內容整理成 HTTP 回應送回客戶端&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>RFC 3875 把這些環境變數叫做 meta-variables。PHP 的 &lt;code>$_SERVER&lt;/code> 陣列裡那些大寫的鍵（&lt;code>REQUEST_METHOD&lt;/code>、&lt;code>QUERY_STRING&lt;/code>、&lt;code>SCRIPT_FILENAME&lt;/code>）就是從這裡來的，後來的 FastCGI 與 PHP-FPM 沿用了同一套名稱。&lt;/p>
&lt;h2 id="php-以-cgi-執行時的行為">PHP 以 CGI 執行時的行為&lt;/h2>
&lt;p>PHP 提供一支叫 &lt;code>php-cgi&lt;/code> 的程式給 CGI 使用，官方的 PHP Docker 映像檔（&lt;code>php:8.5-cli&lt;/code>）裡就有。下面用 lighttpd 當 web 伺服器，設定所有 &lt;code>.php&lt;/code> 結尾的請求都交給 &lt;code>php-cgi&lt;/code> 執行：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl"># lighttpd.conf
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">server.document-root = &amp;#34;/var/www/html&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">server.modules = ( &amp;#34;mod_cgi&amp;#34; )
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"># 副檔名對應到要執行的程式：每個 .php 請求都啟動一個 php-cgi 程序
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">cgi.assign = ( &amp;#34;.php&amp;#34; =&amp;gt; &amp;#34;/usr/local/bin/php-cgi&amp;#34; )&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>server.document-root&lt;/code> 是網站檔案所在的目錄，&lt;code>cgi.assign&lt;/code> 的右邊是 &lt;code>php-cgi&lt;/code> 在這個環境裡的實際路徑，換到別的系統要用 &lt;code>which php-cgi&lt;/code> 查。對一支印出程序編號與計數器的腳本（腳本內容見 &lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/request-lifecycle-and-sapi/#%e4%b8%80%e5%80%8b%e8%ab%8b%e6%b1%82%e5%9c%a8-php-%e8%a3%a1%e7%b6%93%e9%81%8e%e7%9a%84%e9%9a%8e%e6%ae%b5" data-link-title="PHP 的請求生命週期與 SAPI：直譯器的位置、程序壽命與狀態壽命" data-link-desc="說明一個 HTTP 請求怎麼變成一次 PHP 執行：PHP 在程序啟動與每個請求開始、結束時各做什麼，SAPI 這一層介面怎麼讓同一個直譯器接上 CGI、Apache、FastCGI 與常駐伺服器，以及用直譯器住在哪裡、執行它的程序活多久、應用程式的狀態活多久這組問題比較各種執行模型">PHP 的請求生命週期與 SAPI&lt;/a>）連續請求四次：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">pid=15 static=1 global=1 sapi=cgi-fcgi
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">pid=16 static=1 global=1 sapi=cgi-fcgi
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">pid=17 static=1 global=1 sapi=cgi-fcgi
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">pid=18 static=1 global=1 sapi=cgi-fcgi&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個請求都是一個新的程序。PHP 的兩層生命週期在這裡疊成一層：模組啟動（讀 &lt;code>php.ini&lt;/code>、初始化所有擴充）與請求啟動每個請求都各做一次，請求結束時程序也跟著結束。&lt;/p>
&lt;h3 id="每個請求付出的啟動成本">每個請求付出的啟動成本&lt;/h3>
&lt;p>在同一台電腦、同一支極小的腳本上，循序送 300 個請求，CGI 每個請求的平均時間大約是 PHP-FPM 的七倍（一次量測：8.7 ms 對 1.3 ms）。腳本本身幾乎不花時間，差距來自 CGI 每個請求都要做的事：作業系統建立一個新程序、載入 PHP 的執行檔與共用函式庫、讀設定、初始化每一個擴充。PHP-FPM 的 worker 是常駐的，這些工作在 worker 啟動時做過一次就不再重複。&lt;/p>
&lt;p>確切的倍數隨機器、載入的擴充數量與腳本大小變動，值得帶走的是成本的位置：CGI 的固定成本落在每一個請求上，常駐程序把它攤到很多個請求上。&lt;/p></description><content:encoded><![CDATA[<p>這篇說明 CGI（Common Gateway Interface）這種執行模型，以及 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 直譯器住在每個請求新啟動的程序裡，程序與應用程式狀態都只活一個請求。</p>
<h2 id="cgi-怎麼把請求交給外部程式">CGI 怎麼把請求交給外部程式</h2>
<p>CGI 是 web 伺服器呼叫外部程式來產生回應的介面，規格寫成 RFC 3875（CGI/1.1），RFC 自己記載這個介面從 1993 年起就在 web 上使用。它跟語言無關：被呼叫的可以是 Perl、C、shell script 或 PHP，早期網站的 <code>cgi-bin/</code> 目錄放的就是這類程式（見 <a href="/blog/devops/infra/knowledge-cards/cgi/" data-link-title="CGI（Common Gateway Interface）" data-link-desc="接手 cgi-bin 目錄或以 php-cgi 執行的舊站，或在 $_SERVER 看到 REQUEST_METHOD、QUERY_STRING 這類大寫鍵名，要知道它們從哪裡來時">CGI</a>）。</p>
<p>一個請求經過 CGI 的過程：</p>
<table>
  <thead>
      <tr>
          <th>動作者</th>
          <th>做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>web 伺服器</td>
          <td>收到請求，判斷這個路徑要交給哪一支程式</td>
      </tr>
      <tr>
          <td>web 伺服器</td>
          <td>啟動那支程式的一個新程序（fork 加 exec）</td>
      </tr>
      <tr>
          <td>web 伺服器</td>
          <td>把請求的資訊放進這個程序的環境變數：<code>REQUEST_METHOD</code>、<code>QUERY_STRING</code>、<code>CONTENT_TYPE</code>、<code>CONTENT_LENGTH</code>、<code>REMOTE_ADDR</code>，以及各個 HTTP 標頭</td>
      </tr>
      <tr>
          <td>web 伺服器</td>
          <td>把請求的內容（POST 的資料）寫進程序的標準輸入</td>
      </tr>
      <tr>
          <td>外部程式</td>
          <td>從環境變數與標準輸入讀到請求，把回應標頭、一個空行與回應內容寫到標準輸出</td>
      </tr>
      <tr>
          <td>外部程式</td>
          <td>結束程序</td>
      </tr>
      <tr>
          <td>web 伺服器</td>
          <td>把標準輸出的內容整理成 HTTP 回應送回客戶端</td>
      </tr>
  </tbody>
</table>
<p>RFC 3875 把這些環境變數叫做 meta-variables。PHP 的 <code>$_SERVER</code> 陣列裡那些大寫的鍵（<code>REQUEST_METHOD</code>、<code>QUERY_STRING</code>、<code>SCRIPT_FILENAME</code>）就是從這裡來的，後來的 FastCGI 與 PHP-FPM 沿用了同一套名稱。</p>
<h2 id="php-以-cgi-執行時的行為">PHP 以 CGI 執行時的行為</h2>
<p>PHP 提供一支叫 <code>php-cgi</code> 的程式給 CGI 使用，官方的 PHP Docker 映像檔（<code>php:8.5-cli</code>）裡就有。下面用 lighttpd 當 web 伺服器，設定所有 <code>.php</code> 結尾的請求都交給 <code>php-cgi</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"># lighttpd.conf
</span></span><span class="line"><span class="ln">2</span><span class="cl">server.document-root = &#34;/var/www/html&#34;
</span></span><span class="line"><span class="ln">3</span><span class="cl">server.modules = ( &#34;mod_cgi&#34; )
</span></span><span class="line"><span class="ln">4</span><span class="cl"># 副檔名對應到要執行的程式：每個 .php 請求都啟動一個 php-cgi 程序
</span></span><span class="line"><span class="ln">5</span><span class="cl">cgi.assign = ( &#34;.php&#34; =&gt; &#34;/usr/local/bin/php-cgi&#34; )</span></span></code></pre></div><p><code>server.document-root</code> 是網站檔案所在的目錄，<code>cgi.assign</code> 的右邊是 <code>php-cgi</code> 在這個環境裡的實際路徑，換到別的系統要用 <code>which php-cgi</code> 查。對一支印出程序編號與計數器的腳本（腳本內容見 <a href="/blog/programming/php/01-server-runtime/request-lifecycle-and-sapi/#%e4%b8%80%e5%80%8b%e8%ab%8b%e6%b1%82%e5%9c%a8-php-%e8%a3%a1%e7%b6%93%e9%81%8e%e7%9a%84%e9%9a%8e%e6%ae%b5" data-link-title="PHP 的請求生命週期與 SAPI：直譯器的位置、程序壽命與狀態壽命" data-link-desc="說明一個 HTTP 請求怎麼變成一次 PHP 執行：PHP 在程序啟動與每個請求開始、結束時各做什麼，SAPI 這一層介面怎麼讓同一個直譯器接上 CGI、Apache、FastCGI 與常駐伺服器，以及用直譯器住在哪裡、執行它的程序活多久、應用程式的狀態活多久這組問題比較各種執行模型">PHP 的請求生命週期與 SAPI</a>）連續請求四次：</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=15 static=1 global=1 sapi=cgi-fcgi
</span></span><span class="line"><span class="ln">2</span><span class="cl">pid=16 static=1 global=1 sapi=cgi-fcgi
</span></span><span class="line"><span class="ln">3</span><span class="cl">pid=17 static=1 global=1 sapi=cgi-fcgi
</span></span><span class="line"><span class="ln">4</span><span class="cl">pid=18 static=1 global=1 sapi=cgi-fcgi</span></span></code></pre></div><p>每個請求都是一個新的程序。PHP 的兩層生命週期在這裡疊成一層：模組啟動（讀 <code>php.ini</code>、初始化所有擴充）與請求啟動每個請求都各做一次，請求結束時程序也跟著結束。</p>
<h3 id="每個請求付出的啟動成本">每個請求付出的啟動成本</h3>
<p>在同一台電腦、同一支極小的腳本上，循序送 300 個請求，CGI 每個請求的平均時間大約是 PHP-FPM 的七倍（一次量測：8.7 ms 對 1.3 ms）。腳本本身幾乎不花時間，差距來自 CGI 每個請求都要做的事：作業系統建立一個新程序、載入 PHP 的執行檔與共用函式庫、讀設定、初始化每一個擴充。PHP-FPM 的 worker 是常駐的，這些工作在 worker 啟動時做過一次就不再重複。</p>
<p>確切的倍數隨機器、載入的擴充數量與腳本大小變動，值得帶走的是成本的位置：CGI 的固定成本落在每一個請求上，常駐程序把它攤到很多個請求上。</p>
<h3 id="opcache-在-cgi-下沒有作用">OPcache 在 CGI 下沒有作用</h3>
<p>OPcache 把 PHP 檔案編譯好的 opcode 存在共享記憶體裡，讓之後的請求跳過編譯。它的共享記憶體屬於啟動它的那個程序，程序結束，快取就跟著消失。在 CGI 下印出 OPcache 的統計：</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=19 opcache_enabled=true cached_scripts=1 hits=0
</span></span><span class="line"><span class="ln">2</span><span class="cl">pid=20 opcache_enabled=true cached_scripts=1 hits=0
</span></span><span class="line"><span class="ln">3</span><span class="cl">pid=21 opcache_enabled=true cached_scripts=1 hits=0</span></span></code></pre></div><p>OPcache 是開著的，每個程序也把腳本編譯進了快取（<code>cached_scripts=1</code>），可是命中次數永遠是 0：下一個請求是新程序，拿到的是一份全新、空白的快取。框架每個請求要載入數百個檔案，這些檔案在 CGI 下每次都要重新編譯。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>
<h2 id="cgi-在共享主機上留下來的理由">CGI 在共享主機上留下來的理由</h2>
<p>CGI 的成本這麼高，它在共享主機上用了很久，理由是隔離。每個請求都是一個獨立的程序，所以 web 伺服器可以用不同的使用者身分啟動它。Apache 的 suEXEC 讓 CGI 程式以站台擁有者的帳號執行，而不是 Apache 自己的帳號：</p>
<ul>
<li><strong>站台之間互相看不到檔案</strong>：A 站的 PHP 以 A 的帳號執行，讀不到 B 站的設定檔與資料庫密碼。</li>
<li><strong>檔案權限對得上</strong>：PHP 上傳或產生的檔案屬於站台擁有者，透過 FTP 登入的同一個帳號能修改與刪除它們。</li>
<li><strong>一個請求出錯只影響那一個程序</strong>：記憶體洩漏或當掉都隨程序結束而消失。</li>
</ul>
<p>這組需求是 <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> 在 Apache 官方的 MPM 下做不到的：mod_php 的 PHP 住在 Apache 的程序裡，所有站台都以同一個帳號執行。後來 <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> 用 pool（每個站台一組 worker、各自指定執行帳號）在常駐程序上做到同樣的隔離，共享主機才有理由離開 CGI。</p>
<h2 id="請求資料與程式呼叫參數之間的邊界">請求資料與程式呼叫參數之間的邊界</h2>
<p>CGI 用啟動一支程式的方式處理請求，所以請求的內容有機會被當成「啟動程式的方式」解讀。PHP 在這個邊界上出過一個嚴重的漏洞：CVE-2012-1823。依 NVD 的公告，PHP 5.3.12 與 5.4.2 之前，<code>php-cgi</code> 以 CGI 方式執行時沒有正確處理不含 <code>=</code> 的查詢字串，使查詢字串的內容可能被當成 <code>php-cgi</code> 的命令列選項，進而導致遠端執行程式碼。修正方式是升級到修補過的版本，而這個類型的問題只出現在 PHP 以 CGI 程式被呼叫的部署上。</p>
<p>這類問題在 FastCGI 下不存在同樣的形式：FastCGI 的 PHP 程序早就啟動好了，請求資料走的是協定裡的參數，不會變成程序的啟動參數。PHP 端還有兩個跟 CGI 有關的設定一直保留到今天，<code>cgi.force_redirect</code> 與 <code>cgi.fix_pathinfo</code>，其中 <code>cgi.fix_pathinfo</code> 在 FastCGI 環境仍然影響安全，在 <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> 說明。</p>
<h2 id="cgi-在今天的位置">CGI 在今天的位置</h2>
<p>新部署很少用 CGI 執行 PHP，原因是前面量到的兩件事：每個請求的啟動成本，以及 OPcache 沒有作用。它仍然出現在兩種地方：一些舊的共享主機，以及只需要偶爾執行一支腳本、不值得常駐一個程序的場合。</p>
<p>CGI 的設計有兩項比它本身活得久：環境變數形式的請求資訊（<code>$_SERVER</code> 的鍵名）、「web 伺服器負責 HTTP、外部程式負責產生內容」的分工，都被 FastCGI 原樣繼承。FastCGI 改掉的只有一件事：程序不再每個請求結束，見 <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>。</p>
]]></content:encoded></item><item><title>mod_php：PHP 直譯器嵌進 Apache 程序的執行模型</title><link>https://tarrragon.github.io/blog/programming/php/01-server-runtime/mod-php/</link><pubDate>Mon, 05 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/php/01-server-runtime/mod-php/</guid><description>&lt;p>這篇說明 mod_php 這種執行模型：PHP 以 Apache 模組的形式載入，直譯器住在 Apache 的子程序裡。用 &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> 的那組問題來看，它的答案是：直譯器住在 web 伺服器的程序裡，程序活過很多請求，應用程式狀態仍然只活一個請求。文中的行為取自官方的 &lt;code>php:8.5-apache&lt;/code> 映像檔。&lt;/p>
&lt;h2 id="php-以-apache-模組的形式載入">PHP 以 Apache 模組的形式載入&lt;/h2>
&lt;p>mod_php 是編譯成 Apache 模組的 PHP（Debian 系的套件裡檔名是 &lt;code>libphp.so&lt;/code> 一類），Apache 啟動時用 &lt;code>LoadModule&lt;/code> 把它載入，它的 SAPI 名稱是 &lt;code>apache2handler&lt;/code>（見 &lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/mod-php/" data-link-title="mod_php" data-link-desc="接手 Apache 加 mod_php 的舊站、評估要不要換成 PHP-FPM，或遇到 .htaccess 的 php_value 在新環境回 500 時">mod_php&lt;/a>）。載入之後，Apache 的每個子程序裡都有一份完整的 PHP 直譯器，收到 &lt;code>.php&lt;/code> 的請求時在自己的程序裡直接執行，不經過任何程序之間的溝通。&lt;/p>
&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> 每個請求都要啟動一個新程序的成本：子程序處理完一個請求之後留下來，接著處理下一個。在 &lt;code>php:8.5-apache&lt;/code> 上對同一支計數腳本送 10 個請求，請求平均落在 5 個子程序上，每個處理兩次：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl"> 2 pid=16 static=1 global=1 sapi=apache2handler
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> 2 pid=17 static=1 global=1 sapi=apache2handler
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> 2 pid=18 static=1 global=1 sapi=apache2handler
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> 2 pid=19 static=1 global=1 sapi=apache2handler
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> 2 pid=20 static=1 global=1 sapi=apache2handler&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>子程序被重複使用，&lt;code>static&lt;/code> 與全域變數仍然每次從 1 開始：程序活下來了，PHP 依舊在每個請求結束時清空應用程式的狀態。&lt;/p>
&lt;h2 id="mod_php-要搭配-prefork-mpm">mod_php 要搭配 prefork MPM&lt;/h2>
&lt;p>Apache 處理並發的方式由 MPM（Multi-Processing Module）決定，常見的有三種（見 &lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/apache-mpm/" data-link-title="Apache MPM（Multi-Processing Module）" data-link-desc="在 Apache 設定或 httpd -V 看到 prefork、worker、event，或要判斷 mod_php 為什麼讓 Apache 只能用 prefork、換成 PHP-FPM 之後能改用哪一種時">Apache MPM&lt;/a>）：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>MPM&lt;/th>
 &lt;th>並發的單位&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>prefork&lt;/td>
 &lt;td>每個連線佔用一個子程序，子程序裡沒有多執行緒&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>worker&lt;/td>
 &lt;td>每個子程序有多個執行緒，每個連線佔用一個執行緒&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>event&lt;/td>
 &lt;td>跟 worker 一樣用執行緒，另外把閒置的 keep-alive 連線交給專門的執行緒看管，不佔住處理請求的執行緒&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>PHP 官方的安裝文件寫明：除非 PHP 以執行緒安全（&lt;code>--enable-zts&lt;/code>）編譯，mod_php 只能搭配 prefork MPM，而這「significantly limits concurrency」。原因在 PHP 的實作：沒有開啟 ZTS（Zend Thread Safety）的 PHP 把每個請求的狀態放在全域變數裡，兩個執行緒同時執行 PHP 會互相覆寫；而 PHP 的擴充與它們依賴的 C 函式庫也不保證能在多執行緒下使用（見 &lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/zts/" data-link-title="ZTS（Zend Thread Safety）" data-link-desc="在 php -i 看到 Thread Safety 是 enabled 或 disabled，或要判斷某個 PHP 能不能跑在多執行緒的伺服器（Apache worker/event MPM、FrankenPHP）裡時">ZTS&lt;/a>）。多數發行版的 mod_php 套件沒有開 ZTS，&lt;code>php:8.5-apache&lt;/code> 也一樣：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">$ apache2ctl -M | grep -E &amp;#34;mpm|php&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> mpm_prefork_module (shared)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> php_module (shared)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">$ php -i | grep &amp;#34;Thread Safety&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">Thread Safety =&amp;gt; disabled&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Apache 2.4 自己的預設 MPM 是 event（官方 &lt;code>httpd:2.4&lt;/code> 映像檔回報 &lt;code>Server MPM: event&lt;/code>）。裝上 mod_php，Apache 就要退回 prefork。&lt;/p></description><content:encoded><![CDATA[<p>這篇說明 mod_php 這種執行模型：PHP 以 Apache 模組的形式載入，直譯器住在 Apache 的子程序裡。用 <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> 的那組問題來看，它的答案是：直譯器住在 web 伺服器的程序裡，程序活過很多請求，應用程式狀態仍然只活一個請求。文中的行為取自官方的 <code>php:8.5-apache</code> 映像檔。</p>
<h2 id="php-以-apache-模組的形式載入">PHP 以 Apache 模組的形式載入</h2>
<p>mod_php 是編譯成 Apache 模組的 PHP（Debian 系的套件裡檔名是 <code>libphp.so</code> 一類），Apache 啟動時用 <code>LoadModule</code> 把它載入，它的 SAPI 名稱是 <code>apache2handler</code>（見 <a href="/blog/programming/php/knowledge-cards/mod-php/" data-link-title="mod_php" data-link-desc="接手 Apache 加 mod_php 的舊站、評估要不要換成 PHP-FPM，或遇到 .htaccess 的 php_value 在新環境回 500 時">mod_php</a>）。載入之後，Apache 的每個子程序裡都有一份完整的 PHP 直譯器，收到 <code>.php</code> 的請求時在自己的程序裡直接執行，不經過任何程序之間的溝通。</p>
<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> 每個請求都要啟動一個新程序的成本：子程序處理完一個請求之後留下來，接著處理下一個。在 <code>php:8.5-apache</code> 上對同一支計數腳本送 10 個請求，請求平均落在 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">   2 pid=16 static=1 global=1 sapi=apache2handler
</span></span><span class="line"><span class="ln">2</span><span class="cl">   2 pid=17 static=1 global=1 sapi=apache2handler
</span></span><span class="line"><span class="ln">3</span><span class="cl">   2 pid=18 static=1 global=1 sapi=apache2handler
</span></span><span class="line"><span class="ln">4</span><span class="cl">   2 pid=19 static=1 global=1 sapi=apache2handler
</span></span><span class="line"><span class="ln">5</span><span class="cl">   2 pid=20 static=1 global=1 sapi=apache2handler</span></span></code></pre></div><p>子程序被重複使用，<code>static</code> 與全域變數仍然每次從 1 開始：程序活下來了，PHP 依舊在每個請求結束時清空應用程式的狀態。</p>
<h2 id="mod_php-要搭配-prefork-mpm">mod_php 要搭配 prefork MPM</h2>
<p>Apache 處理並發的方式由 MPM（Multi-Processing Module）決定，常見的有三種（見 <a href="/blog/devops/infra/knowledge-cards/apache-mpm/" data-link-title="Apache MPM（Multi-Processing Module）" data-link-desc="在 Apache 設定或 httpd -V 看到 prefork、worker、event，或要判斷 mod_php 為什麼讓 Apache 只能用 prefork、換成 PHP-FPM 之後能改用哪一種時">Apache MPM</a>）：</p>
<table>
  <thead>
      <tr>
          <th>MPM</th>
          <th>並發的單位</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>prefork</td>
          <td>每個連線佔用一個子程序，子程序裡沒有多執行緒</td>
      </tr>
      <tr>
          <td>worker</td>
          <td>每個子程序有多個執行緒，每個連線佔用一個執行緒</td>
      </tr>
      <tr>
          <td>event</td>
          <td>跟 worker 一樣用執行緒，另外把閒置的 keep-alive 連線交給專門的執行緒看管，不佔住處理請求的執行緒</td>
      </tr>
  </tbody>
</table>
<p>PHP 官方的安裝文件寫明：除非 PHP 以執行緒安全（<code>--enable-zts</code>）編譯，mod_php 只能搭配 prefork MPM，而這「significantly limits concurrency」。原因在 PHP 的實作：沒有開啟 ZTS（Zend Thread Safety）的 PHP 把每個請求的狀態放在全域變數裡，兩個執行緒同時執行 PHP 會互相覆寫；而 PHP 的擴充與它們依賴的 C 函式庫也不保證能在多執行緒下使用（見 <a href="/blog/programming/php/knowledge-cards/zts/" data-link-title="ZTS（Zend Thread Safety）" data-link-desc="在 php -i 看到 Thread Safety 是 enabled 或 disabled，或要判斷某個 PHP 能不能跑在多執行緒的伺服器（Apache worker/event MPM、FrankenPHP）裡時">ZTS</a>）。多數發行版的 mod_php 套件沒有開 ZTS，<code>php:8.5-apache</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">$ apache2ctl -M | grep -E &#34;mpm|php&#34;
</span></span><span class="line"><span class="ln">2</span><span class="cl"> mpm_prefork_module (shared)
</span></span><span class="line"><span class="ln">3</span><span class="cl"> php_module (shared)
</span></span><span class="line"><span class="ln">4</span><span class="cl">$ php -i | grep &#34;Thread Safety&#34;
</span></span><span class="line"><span class="ln">5</span><span class="cl">Thread Safety =&gt; disabled</span></span></code></pre></div><p>Apache 2.4 自己的預設 MPM 是 event（官方 <code>httpd:2.4</code> 映像檔回報 <code>Server MPM: event</code>）。裝上 mod_php，Apache 就要退回 prefork。</p>
<h2 id="每個子程序都帶著-php">每個子程序都帶著 PHP</h2>
<p>prefork 加 mod_php 的組合，讓 PHP 直譯器出現在每一個子程序裡，不論這個子程序正在處理什麼請求。在容器裡列出 Apache 的程序與它們映射的 <code>libphp</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">pid=1  user=root     rss_kb=29112 libphp_mapped=6
</span></span><span class="line"><span class="ln">2</span><span class="cl">pid=16 user=www-data rss_kb=9928  libphp_mapped=6
</span></span><span class="line"><span class="ln">3</span><span class="cl">pid=17 user=www-data rss_kb=10640 libphp_mapped=6
</span></span><span class="line"><span class="ln">4</span><span class="cl">pid=18 user=www-data rss_kb=9928  libphp_mapped=6</span></span></code></pre></div><p>（讀其他程序的 <code>/proc/&lt;pid&gt;/maps</code> 需要 <code>SYS_PTRACE</code> 權限，在 Docker 裡要用 <code>--cap-add SYS_PTRACE</code> 啟動容器才讀得到。）</p>
<p>剛處理過一個靜態檔 <code>hello.txt</code> 請求的子程序，也映射著 <code>libphp</code>。這帶出 mod_php 的三個代價，它們的根源相同：</p>
<p><strong>送靜態檔的程序也背著 PHP。</strong> 一個頁面載入一支 PHP 與數十個圖片、CSS、JS，每個請求都佔用一個帶著 PHP 的子程序。能同時存在的子程序數量受主機記憶體約束，而每個子程序的記憶體裡都有一份 PHP，所以同樣的記憶體能開的子程序比純送靜態檔的 web 伺服器少。</p>
<p><strong>並發數等於子程序數。</strong> prefork 一個連線佔一個子程序，同時能處理的連線數上限就是子程序的上限（<code>MaxRequestWorkers</code>）。網路慢的客戶端、或是在 keep-alive 時間內沒有送下一個請求的連線，都會佔住一個帶著 PHP 的子程序，而它在這段時間什麼都沒做。</p>
<p><strong>所有站台以同一個帳號執行。</strong> 子程序的執行帳號是 Apache 的帳號（上面的 <code>www-data</code>）。同一台伺服器上的每個站台，PHP 都以這個帳號執行，A 站的 PHP 讀得到 B 站的設定檔；PHP 產生的檔案屬於 <code>www-data</code>，站台擁有者用自己的 FTP 帳號刪不掉。共享主機因此長期用 <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</a> 加 suEXEC，而不用 mod_php。例外是第三方的 MPM <code>mpm-itk</code>：它讓每個虛擬主機以自己的帳號執行 mod_php，使用的人較少，Apache 官方發行的 prefork、worker、event 都沒有這個能力。</p>
<h2 id="設定的位置與生效時機">設定的位置與生效時機</h2>
<p>mod_php 的 PHP 跟 Apache 是同一個程序，所以 PHP 的設定跟 Apache 綁在一起：</p>
<ul>
<li><strong><code>php.ini</code> 改了要重新載入 Apache</strong>：設定在子程序啟動時讀入，改了檔案要 <code>apachectl graceful</code> 讓 Apache 換掉子程序才生效。重啟 PHP 就是重啟 Apache。</li>
<li><strong><code>.htaccess</code> 可以改 PHP 設定</strong>：mod_php 在 Apache 裡註冊了 <code>php_value</code>、<code>php_flag</code> 這些指令，站台在自己目錄的 <a href="/blog/devops/infra/knowledge-cards/htaccess/" data-link-title=".htaccess" data-link-desc="Apache Web Server 的目錄層級設定檔，控制 URL rewrite、存取權限、PHP 設定覆寫與安全標頭">.htaccess</a> 裡寫 <code>php_value upload_max_filesize 64M</code> 就能覆寫設定，不必有伺服器的管理權限。這是共享主機時代最常用的調整方式。</li>
</ul>
<h2 id="從-mod_php-換到-php-fpm-時-htaccess-的變化">從 mod_php 換到 PHP-FPM 時 .htaccess 的變化</h2>
<p>PHP 官方文件現在對 Apache 的建議是：新的部署用 PHP-FPM 搭配 Apache 的 <code>mod_proxy_fcgi</code>，這樣 Apache 可以用 event MPM，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>）。換過去時，<code>.htaccess</code> 裡的 PHP 設定是最常撞到的問題。</p>
<p><code>php_value</code> 這個指令是 mod_php 註冊的，Apache 沒載入 mod_php 時不認得它。在官方的 <code>httpd:2.4</code> 上放一個只有一行 <code>php_value upload_max_filesize 64M</code> 的 <code>.htaccess</code>，連同目錄下的靜態檔 <code>index.html</code> 都回 500，錯誤 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">[core:alert] ... /usr/local/apache2/htdocs/.htaccess: Invalid command &#39;php_value&#39;, perhaps misspelled or defined by a module not included in the server configuration</span></span></code></pre></div><p><code>.htaccess</code> 一旦有 Apache 不認得的指令，整份檔案都不能用，所以壞掉的是整個目錄，不只是 PHP 的請求。</p>
<p>PHP-FPM（以及 CGI）下對應的機制是 <code>.user.ini</code>：放在站台目錄裡、語法跟 <code>php.ini</code> 相同的檔案，PHP 官方文件寫明它只由 CGI/FastCGI SAPI 處理（見 <a href="/blog/devops/infra/knowledge-cards/php-ini/" data-link-title="php.ini / .user.ini" data-link-desc="PHP 的執行期設定檔，控制記憶體上限、上傳大小、錯誤報告等 runtime 行為">php.ini / .user.ini</a>）。所以搬家時要把 <code>.htaccess</code> 裡的 <code>php_value</code> 與 <code>php_flag</code> 移到 <code>.user.ini</code>，並刪掉 <code>.htaccess</code> 裡的那幾行。<code>.user.ini</code> 不是每個請求都重讀：PHP 依 <code>user_ini.cache_ttl</code>（預設 300 秒）快取它，改了之後最多要等這段時間才生效。</p>
<h2 id="mod_php-適合的場合">mod_php 適合的場合</h2>
<p>mod_php 的優點是簡單：一個程序、一份設定，Apache 與 PHP 之間沒有任何協定要設定，也沒有第二個服務要監看。單一站台、流量不大、靜態檔由 CDN 或另一台伺服器處理時，它的代價不明顯。</p>
<p>它的代價集中在兩件事上：每個子程序都帶著 PHP，以及 PHP 的壽命與身分綁在 Apache 上。把 PHP 從 web 伺服器的程序裡搬出去、同時保留常駐程序的好處，需要一個讓 web 伺服器與 PHP 程序溝通的協定，那就是 <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>。</p>
]]></content:encoded></item><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><item><title>OPcache 與程序壽命：編譯結果的快取在各種執行模型下的效果</title><link>https://tarrragon.github.io/blog/programming/php/01-server-runtime/opcache/</link><pubDate>Mon, 05 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/php/01-server-runtime/opcache/</guid><description>&lt;p>這篇說明 OPcache 這個 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 的程序活多久。OPcache 是程序層的資源，程序活得越久，它重用得越多。文中的預設值與行為取自 &lt;code>php:8.5-fpm-alpine&lt;/code>。&lt;/p>
&lt;h2 id="opcache-快取的是編譯結果">OPcache 快取的是編譯結果&lt;/h2>
&lt;p>PHP 每次執行一個 &lt;code>.php&lt;/code> 檔，都要先把原始碼編譯成 opcode——Zend 引擎實際執行的指令——再執行它（見 &lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/opcache/" data-link-title="OPcache" data-link-desc="部署新版程式碼之後頁面還在跑舊版、評估 OPcache 的大小設定，或比較不同執行模型下 PHP 的效能差距時">OPcache&lt;/a>）。沒有快取時，每個請求的每個檔案都要重新讀檔、解析、編譯。&lt;/p>
&lt;p>OPcache 把編譯好的 opcode 存進一塊&lt;strong>共享記憶體&lt;/strong>，下一次要執行同一個檔案時，直接從共享記憶體拿編譯結果，跳過讀檔與編譯。它快取的是程式碼，不是資料：變數的值、查詢結果、請求之間的狀態都不在裡面，&lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/shared-nothing-request/" data-link-title="Shared-nothing 請求模型" data-link-desc="要判斷 PHP 的全域變數、static 變數或類別靜態屬性會不會留到下一個請求，或評估換成常駐 worker（Octane、FrankenPHP worker）時哪些程式碼會出問題時">shared-nothing&lt;/a> 不受影響。PHP 5.5 起 OPcache 隨 PHP 一起發行；PHP 8.5 依〈Make OPcache required〉這份 RFC 把它靜態編進 PHP 本體，不必也不能再另外安裝，開關仍然由 &lt;code>opcache.enable&lt;/code> 控制。&lt;/p>
&lt;p>框架讓這件事的份量變得很大。Laravel 13 處理一個最簡單的健康檢查請求，&lt;code>get_included_files()&lt;/code> 回報這個請求載入了 484 個檔案——設定、服務提供者、路由、中介層、框架本身的類別。這 484 個檔案每個請求都要用到，所以有沒有快取它們的編譯結果，差距是每個請求都要重付一次的成本。&lt;/p>
&lt;p>在同一個 Laravel 容器（Nginx 加 PHP-FPM）上循序請求 &lt;code>/healthz&lt;/code>，只切換 OPcache 的開關：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>OPcache&lt;/th>
 &lt;th>每個請求的平均時間&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>開&lt;/td>
 &lt;td>3.8 ms&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>關&lt;/td>
 &lt;td>49.3 ms&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>相差十幾倍，而請求做的事情完全一樣。差出來的時間幾乎都花在編譯那 484 個檔案上。&lt;/p>
&lt;h2 id="快取效果與程序壽命的關係">快取效果與程序壽命的關係&lt;/h2>
&lt;p>OPcache 的共享記憶體在 PHP 啟動時建立，屬於那個程序（以及它 fork 出來的子程序）。程序結束，那塊記憶體就跟著釋放，快取也就消失。所以同一個 OPcache，在不同的執行模型下效果完全不同。&lt;/p>
&lt;p>&lt;strong>CGI：命中次數永遠是零。&lt;/strong> &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> 每個請求啟動一個新程序。每個程序都建立自己的 OPcache、把這次用到的檔案編譯進去，然後隨請求結束一起消失。在 CGI 下印出 OPcache 的統計，&lt;code>cached_scripts&lt;/code> 每次都是 1（這次編譯進去了），&lt;code>hits&lt;/code> 每次都是 0（從來沒有被重用過）。OPcache 是開著的，只是沒有作用。&lt;/p>
&lt;p>&lt;strong>PHP-FPM：worker 共用同一份快取。&lt;/strong> &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> 的 master 在啟動時建立 OPcache 的共享記憶體，再 fork 出 worker，worker 繼承了這塊記憶體。所以一個 pool 裡所有 worker 讀寫的是同一份快取。對兩個 worker 輪流處理的請求印出統計：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">pid=6 opcache_enabled=true cached_scripts=1 hits=0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">pid=7 opcache_enabled=true cached_scripts=1 hits=1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">pid=6 opcache_enabled=true cached_scripts=1 hits=2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">pid=7 opcache_enabled=true cached_scripts=1 hits=3&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>PID 6 第一次執行時把腳本編譯進快取，PID 7 接下來直接命中——它自己沒有編譯過這個檔案，用的是 PID 6 編譯好的結果。命中次數跨 worker 累加。這帶出兩個推論：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>worker 被換掉不會清空快取。&lt;/strong> &lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/php-fpm/#max_requests%e5%ae%9a%e6%9c%9f%e6%8f%9b%e6%8e%89-worker-%e4%bb%a5%e6%8e%a7%e5%88%b6%e8%a8%98%e6%86%b6%e9%ab%94" 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 頁怎麼讀，以及每一項對壓測數字的影響">&lt;code>pm.max_requests&lt;/code>&lt;/a> 換掉的是 worker，快取住在 master 建立的共享記憶體裡，新 worker 一啟動就能用。要清空快取得重啟或 reload 整個 PHP-FPM。&lt;/li>
&lt;li>&lt;strong>同一台機器上的不同 pool 共用同一個 master 時，也共用這塊記憶體。&lt;/strong> 所以 &lt;code>memory_consumption&lt;/code> 要容得下所有 pool 的程式碼。&lt;/li>
&lt;/ul>
&lt;p>&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> 的情況相同：Apache 的父程序載入 PHP 時建立共享記憶體，子程序繼承它。在 &lt;code>php:8.5-apache&lt;/code> 上連續請求五次，五個不同的子程序（PID 16 到 20）回報的命中次數依序是 0、1、2、3、4。&lt;/p></description><content:encoded><![CDATA[<p>這篇說明 OPcache 這個 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 的程序活多久。OPcache 是程序層的資源，程序活得越久，它重用得越多。文中的預設值與行為取自 <code>php:8.5-fpm-alpine</code>。</p>
<h2 id="opcache-快取的是編譯結果">OPcache 快取的是編譯結果</h2>
<p>PHP 每次執行一個 <code>.php</code> 檔，都要先把原始碼編譯成 opcode——Zend 引擎實際執行的指令——再執行它（見 <a href="/blog/programming/php/knowledge-cards/opcache/" data-link-title="OPcache" data-link-desc="部署新版程式碼之後頁面還在跑舊版、評估 OPcache 的大小設定，或比較不同執行模型下 PHP 的效能差距時">OPcache</a>）。沒有快取時，每個請求的每個檔案都要重新讀檔、解析、編譯。</p>
<p>OPcache 把編譯好的 opcode 存進一塊<strong>共享記憶體</strong>，下一次要執行同一個檔案時，直接從共享記憶體拿編譯結果，跳過讀檔與編譯。它快取的是程式碼，不是資料：變數的值、查詢結果、請求之間的狀態都不在裡面，<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> 不受影響。PHP 5.5 起 OPcache 隨 PHP 一起發行；PHP 8.5 依〈Make OPcache required〉這份 RFC 把它靜態編進 PHP 本體，不必也不能再另外安裝，開關仍然由 <code>opcache.enable</code> 控制。</p>
<p>框架讓這件事的份量變得很大。Laravel 13 處理一個最簡單的健康檢查請求，<code>get_included_files()</code> 回報這個請求載入了 484 個檔案——設定、服務提供者、路由、中介層、框架本身的類別。這 484 個檔案每個請求都要用到，所以有沒有快取它們的編譯結果，差距是每個請求都要重付一次的成本。</p>
<p>在同一個 Laravel 容器（Nginx 加 PHP-FPM）上循序請求 <code>/healthz</code>，只切換 OPcache 的開關：</p>
<table>
  <thead>
      <tr>
          <th>OPcache</th>
          <th>每個請求的平均時間</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>開</td>
          <td>3.8 ms</td>
      </tr>
      <tr>
          <td>關</td>
          <td>49.3 ms</td>
      </tr>
  </tbody>
</table>
<p>相差十幾倍，而請求做的事情完全一樣。差出來的時間幾乎都花在編譯那 484 個檔案上。</p>
<h2 id="快取效果與程序壽命的關係">快取效果與程序壽命的關係</h2>
<p>OPcache 的共享記憶體在 PHP 啟動時建立，屬於那個程序（以及它 fork 出來的子程序）。程序結束，那塊記憶體就跟著釋放，快取也就消失。所以同一個 OPcache，在不同的執行模型下效果完全不同。</p>
<p><strong>CGI：命中次數永遠是零。</strong> <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> 每個請求啟動一個新程序。每個程序都建立自己的 OPcache、把這次用到的檔案編譯進去，然後隨請求結束一起消失。在 CGI 下印出 OPcache 的統計，<code>cached_scripts</code> 每次都是 1（這次編譯進去了），<code>hits</code> 每次都是 0（從來沒有被重用過）。OPcache 是開著的，只是沒有作用。</p>
<p><strong>PHP-FPM：worker 共用同一份快取。</strong> <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> 的 master 在啟動時建立 OPcache 的共享記憶體，再 fork 出 worker，worker 繼承了這塊記憶體。所以一個 pool 裡所有 worker 讀寫的是同一份快取。對兩個 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">pid=6 opcache_enabled=true cached_scripts=1 hits=0
</span></span><span class="line"><span class="ln">2</span><span class="cl">pid=7 opcache_enabled=true cached_scripts=1 hits=1
</span></span><span class="line"><span class="ln">3</span><span class="cl">pid=6 opcache_enabled=true cached_scripts=1 hits=2
</span></span><span class="line"><span class="ln">4</span><span class="cl">pid=7 opcache_enabled=true cached_scripts=1 hits=3</span></span></code></pre></div><p>PID 6 第一次執行時把腳本編譯進快取，PID 7 接下來直接命中——它自己沒有編譯過這個檔案，用的是 PID 6 編譯好的結果。命中次數跨 worker 累加。這帶出兩個推論：</p>
<ul>
<li><strong>worker 被換掉不會清空快取。</strong> <a href="/blog/programming/php/01-server-runtime/php-fpm/#max_requests%e5%ae%9a%e6%9c%9f%e6%8f%9b%e6%8e%89-worker-%e4%bb%a5%e6%8e%a7%e5%88%b6%e8%a8%98%e6%86%b6%e9%ab%94" 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 頁怎麼讀，以及每一項對壓測數字的影響"><code>pm.max_requests</code></a> 換掉的是 worker，快取住在 master 建立的共享記憶體裡，新 worker 一啟動就能用。要清空快取得重啟或 reload 整個 PHP-FPM。</li>
<li><strong>同一台機器上的不同 pool 共用同一個 master 時，也共用這塊記憶體。</strong> 所以 <code>memory_consumption</code> 要容得下所有 pool 的程式碼。</li>
</ul>
<p><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> 的情況相同：Apache 的父程序載入 PHP 時建立共享記憶體，子程序繼承它。在 <code>php:8.5-apache</code> 上連續請求五次，五個不同的子程序（PID 16 到 20）回報的命中次數依序是 0、1、2、3、4。</p>
<h2 id="快取的大小">快取的大小</h2>
<p>OPcache 的共享記憶體大小固定，由兩個設定決定，<code>php:8.5-fpm-alpine</code> 的預設值是：</p>
<table>
  <thead>
      <tr>
          <th>設定</th>
          <th>預設值</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>opcache.memory_consumption</code></td>
          <td>128</td>
          <td>共享記憶體的大小，單位 MB</td>
      </tr>
      <tr>
          <td><code>opcache.max_accelerated_files</code></td>
          <td>10000</td>
          <td>最多能快取幾個檔案</td>
      </tr>
  </tbody>
</table>
<p>Laravel 13 骨架處理過請求之後，OPcache 回報快取了 467 個腳本，用掉 22.0 MB，剩下 106.0 MB；另外 interned strings（PHP 把重複出現的字串只存一份的區塊）用掉 4.3 MB。一個空骨架用不到預設值的五分之一，但正式專案加上大量套件之後，<code>vendor/</code> 裡的檔案數可以到上萬個。</p>
<p>快取滿了時，OPcache 不會報錯讓請求失敗：放不進去的檔案就不被快取，每次重新編譯。檔案改過而留下的舊版本佔用的空間算是「浪費的記憶體」，剩餘空間不夠、而浪費的比例又超過 <code>opcache.max_wasted_percentage</code>（預設 5%）時，OPcache 會排定一次重啟，清空整份快取重新累積。兩種情形從外面看都只是請求變慢，所以要從 <code>opcache_get_status()</code> 讀：<code>free_memory</code> 接近零、或 <code>num_cached_scripts</code> 接近 <code>max_accelerated_files</code>，就是該調大的時候。手上的專案實際要多大，在部署環境跑過一輪主要的請求之後，讀這兩個值就知道。</p>
<h2 id="部署新程式碼時舊版還會執行多久">部署新程式碼時舊版還會執行多久</h2>
<p>OPcache 快取了編譯結果，所以檔案改了之後，PHP 什麼時候才去讀新版，由 <code>opcache.validate_timestamps</code> 與 <code>opcache.revalidate_freq</code> 決定。這是部署時最常撞到的問題。</p>
<p><code>opcache.validate_timestamps</code> 決定 OPcache 要不要檢查檔案有沒有改過。<code>opcache.revalidate_freq</code> 是檢查的間隔秒數。<code>php:8.5-fpm-alpine</code> 的預設值是 <code>validate_timestamps=On</code>、<code>revalidate_freq=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"># validate_timestamps=1, revalidate_freq=2（預設）
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">改檔之前  version=2
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">改檔之後 t+0s  version=2
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">         t+1s  version=2
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">         t+3s  version=3     # 間隔過了，OPcache 檢查到檔案改了，重新編譯
</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"># validate_timestamps=0
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">改檔之前  version=1
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">改檔之後  version=1          # 連續請求都還是舊版
</span></span><span class="line"><span class="ln">10</span><span class="cl">         version=1
</span></span><span class="line"><span class="ln">11</span><span class="cl">         version=1
</span></span><span class="line"><span class="ln">12</span><span class="cl">reload 之後 version=2        # kill -USR2 讓 PHP-FPM reload，快取清空才換成新版</span></span></code></pre></div><p>兩種設定各有用途：</p>
<ul>
<li><strong><code>validate_timestamps=1</code></strong>：改檔之後最多 <code>revalidate_freq</code> 秒就生效，代價是每隔這段時間 PHP 要對檔案做一次 <code>stat</code>。開發環境與「直接覆寫伺服器上的檔案」的部署方式適合這個。</li>
<li><strong><code>validate_timestamps=0</code></strong>：OPcache 完全不檢查檔案，省掉所有 <code>stat</code>；改了檔案永遠不會生效，部署流程必須在換完程式碼之後 reload 或重啟 PHP-FPM。程式碼包在容器映像檔裡的部署適合這個：映像檔裡的檔案不會在執行中被改，每次部署都是啟動新容器，新容器的快取在啟動時是空的。開發時用 bind mount 把主機上的原始碼掛進容器，雖然也是容器，檔案卻會被改，要算成上一種情形、留著 <code>validate_timestamps=1</code>；設成 0 的話，改了檔案頁面不會變，也沒有任何錯誤訊息。</li>
</ul>
<p><code>validate_timestamps=1</code> 下還有一個中間狀態要知道：部署是逐一覆寫檔案時，在 <code>revalidate_freq</code> 那幾秒內，有的檔案已經重新編譯成新版、有的還是舊版快取，同一個請求可能同時執行到新舊兩版的程式碼。把整個版本目錄準備好再一次切換（例如換一個符號連結），然後 reload，可以避開這個狀態。</p>
<h2 id="opcache-的-jit-與-preload">OPcache 的 JIT 與 preload</h2>
<p>OPcache 另外帶著 JIT 與 preload，兩者跟快取 opcode 相鄰，解決的是不同的問題：</p>
<ul>
<li><strong>JIT</strong>：把 opcode 進一步編譯成 CPU 的機器碼。它對計算密集的程式（數學運算、影像處理）有幫助，對多數時間在等資料庫與網路的 web 請求效果有限。<code>php:8.5-fpm-alpine</code> 預設 <code>opcache.jit=disable</code>。</li>
<li><strong>preload</strong>（<code>opcache.preload</code>）：PHP-FPM 啟動時先執行一支指定的腳本，把它載入的類別常駐在共享記憶體裡，之後每個請求不必再載入它們。被 preload 的檔案改了要重啟 PHP-FPM 才生效。</li>
</ul>
<h2 id="opcache-與執行模型的關係">OPcache 與執行模型的關係</h2>
<p>OPcache 的效果整個建立在程序壽命上：CGI 下沒有作用，常駐的 PHP-FPM worker 讓它重用到極致。但它只省掉「編譯」這一段。框架在每個請求開頭還要做另一件事——執行那 484 個檔案：建立服務容器、註冊服務、讀設定、建立路由表——這一段 OPcache 省不掉，因為 shared-nothing 要求每個請求從空白的狀態開始。</p>
<p>想連這一段也省掉，就要讓應用程式的狀態也跨請求保留，框架只啟動一次。那是 <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> 的做法，也是它要付出的代價的來源。</p>
]]></content:encoded></item><item><title>容器裡的 Nginx 與 PHP-FPM：拆成兩個容器與放進同一個容器的做法與各自的陷阱</title><link>https://tarrragon.github.io/blog/programming/php/01-server-runtime/nginx-php-fpm-containers/</link><pubDate>Mon, 05 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/php/01-server-runtime/nginx-php-fpm-containers/</guid><description>&lt;p>這篇說明在容器裡跑 Nginx 加 &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> 的兩種擺法，以及各自要處理的問題。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>，所以前面一定要有一個講 HTTP 的 web 伺服器，這兩個程序怎麼放進容器就成了部署時第一個要決定的事。文中的行為取自官方的 &lt;code>php:8.5-fpm-alpine&lt;/code> 與 &lt;code>nginx:1.30-alpine&lt;/code> 映像檔。&lt;/p>
&lt;h2 id="兩種擺法">兩種擺法&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>擺法&lt;/th>
 &lt;th>容器&lt;/th>
 &lt;th>對外講的協定&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>拆成兩個容器&lt;/td>
 &lt;td>一個 Nginx、一個 PHP-FPM，用容器網路連 FastCGI&lt;/td>
 &lt;td>Nginx 容器講 HTTP&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>放進同一個容器&lt;/td>
 &lt;td>一個容器裡同時跑 Nginx 與 PHP-FPM&lt;/td>
 &lt;td>這個容器自己講 HTTP&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>拆成兩個容器符合「一個容器一個職責」的慣例（見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/container-per-service/" data-link-title="Container-per-Service（一容器一職責）" data-link-desc="第一次發現一個 web app 要開好幾個 container、不懂為什麼不塞一個大 container、或不確定什麼該獨立成一個 container 時回來讀 — 一容器一職責的理由與邊界">Container per Service&lt;/a>）：Nginx 與 PHP-FPM 可以各自更新映像檔、各自看 log、各自重啟。放進同一個容器則讓這個 PHP 服務對外就是一個講 HTTP 的單位，這在兩種情況下有用：平台只接受「一個容器、一個 HTTP 埠」的服務（多數 PaaS 與 serverless 容器平台），以及要把 PHP 服務跟其他講 HTTP 的後端一起放進同一個負載平衡的 upstream——一個 upstream 只能用一種協定轉送，Nginx 的 &lt;code>proxy_pass&lt;/code>（HTTP）與 &lt;code>fastcgi_pass&lt;/code>（FastCGI）不能混在同一個 upstream 裡。&lt;/p>
&lt;p>「拆成兩個容器」指的是兩個容器仍然部署在一起：同一個 compose 專案，或 Kubernetes 的同一個 pod。同一個 pod 裡的容器共用網路（Nginx 用 &lt;code>127.0.0.1:9000&lt;/code> 連 PHP-FPM），也能共用一個 volume 放程式碼，由 Kubernetes 分別監看兩個容器，不必手寫下面〈放進同一個容器〉那一節的腳本。拆成兩個獨立部署、跨 pod 連 FastCGI 也做得到，但 9000 就得在叢集網路上開放，要另外用 NetworkPolicy 之類的機制限制只有 Nginx 連得到。&lt;/p>
&lt;p>兩種擺法共用同一個起點：官方的 php-fpm 映像檔。&lt;/p>
&lt;h2 id="官方-php-fpm-映像檔替容器改掉的預設值">官方 php-fpm 映像檔替容器改掉的預設值&lt;/h2>
&lt;p>PHP-FPM 原本是為「裝在一台伺服器上、由 systemd 管理」設計的，它的預設值有幾項在容器裡不適用。官方映像檔用一個 &lt;code>php-fpm.d/docker.conf&lt;/code> 把它們改掉：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ini" data-lang="ini">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">[global]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="na">error_log&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s">/proc/self/fd/2 ; 錯誤 log 寫到 stderr，docker logs 才看得到&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="k">[www]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="na">access.log&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s">/proc/self/fd/2 ; 存取 log 也寫到 stderr（PHP-FPM 啟動時會關掉 stdout）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="na">clear_env&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s">no ; 把容器的環境變數交給 worker&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="na">catch_workers_output&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s">yes ; worker 的輸出也收進主 log&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="na">listen&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s">9000 ; 聽所有網路介面的 9000&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>另外映像檔設了 &lt;code>STOPSIGNAL SIGQUIT&lt;/code>，&lt;code>docker stop&lt;/code> 送給它的是 SIGQUIT 而不是預設的 SIGTERM。SIGQUIT 是 PHP-FPM 的 graceful stop，SIGTERM 是立即終止。但 graceful stop 要等進行中的請求多久，由 &lt;code>process_control_timeout&lt;/code> 決定，&lt;strong>而它的預設值是 0&lt;/strong>：只靠 STOPSIGNAL，&lt;code>docker stop&lt;/code> 時進行中的請求仍然拿到 502。實測與設定方式見 &lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/php-fpm/#%e5%81%9c%e6%a9%9f%e8%88%87%e9%87%8d%e8%bc%89%e7%9a%84%e8%a8%8a%e8%99%9f" 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></description><content:encoded><![CDATA[<p>這篇說明在容器裡跑 Nginx 加 <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> 的兩種擺法，以及各自要處理的問題。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>，所以前面一定要有一個講 HTTP 的 web 伺服器，這兩個程序怎麼放進容器就成了部署時第一個要決定的事。文中的行為取自官方的 <code>php:8.5-fpm-alpine</code> 與 <code>nginx:1.30-alpine</code> 映像檔。</p>
<h2 id="兩種擺法">兩種擺法</h2>
<table>
  <thead>
      <tr>
          <th>擺法</th>
          <th>容器</th>
          <th>對外講的協定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>拆成兩個容器</td>
          <td>一個 Nginx、一個 PHP-FPM，用容器網路連 FastCGI</td>
          <td>Nginx 容器講 HTTP</td>
      </tr>
      <tr>
          <td>放進同一個容器</td>
          <td>一個容器裡同時跑 Nginx 與 PHP-FPM</td>
          <td>這個容器自己講 HTTP</td>
      </tr>
  </tbody>
</table>
<p>拆成兩個容器符合「一個容器一個職責」的慣例（見 <a href="/blog/backend/knowledge-cards/container-per-service/" data-link-title="Container-per-Service（一容器一職責）" data-link-desc="第一次發現一個 web app 要開好幾個 container、不懂為什麼不塞一個大 container、或不確定什麼該獨立成一個 container 時回來讀 — 一容器一職責的理由與邊界">Container per Service</a>）：Nginx 與 PHP-FPM 可以各自更新映像檔、各自看 log、各自重啟。放進同一個容器則讓這個 PHP 服務對外就是一個講 HTTP 的單位，這在兩種情況下有用：平台只接受「一個容器、一個 HTTP 埠」的服務（多數 PaaS 與 serverless 容器平台），以及要把 PHP 服務跟其他講 HTTP 的後端一起放進同一個負載平衡的 upstream——一個 upstream 只能用一種協定轉送，Nginx 的 <code>proxy_pass</code>（HTTP）與 <code>fastcgi_pass</code>（FastCGI）不能混在同一個 upstream 裡。</p>
<p>「拆成兩個容器」指的是兩個容器仍然部署在一起：同一個 compose 專案，或 Kubernetes 的同一個 pod。同一個 pod 裡的容器共用網路（Nginx 用 <code>127.0.0.1:9000</code> 連 PHP-FPM），也能共用一個 volume 放程式碼，由 Kubernetes 分別監看兩個容器，不必手寫下面〈放進同一個容器〉那一節的腳本。拆成兩個獨立部署、跨 pod 連 FastCGI 也做得到，但 9000 就得在叢集網路上開放，要另外用 NetworkPolicy 之類的機制限制只有 Nginx 連得到。</p>
<p>兩種擺法共用同一個起點：官方的 php-fpm 映像檔。</p>
<h2 id="官方-php-fpm-映像檔替容器改掉的預設值">官方 php-fpm 映像檔替容器改掉的預設值</h2>
<p>PHP-FPM 原本是為「裝在一台伺服器上、由 systemd 管理」設計的，它的預設值有幾項在容器裡不適用。官方映像檔用一個 <code>php-fpm.d/docker.conf</code> 把它們改掉：</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">[global]</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">error_log</span> <span class="o">=</span> <span class="s">/proc/self/fd/2      ; 錯誤 log 寫到 stderr，docker logs 才看得到</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">[www]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">access.log</span> <span class="o">=</span> <span class="s">/proc/self/fd/2     ; 存取 log 也寫到 stderr（PHP-FPM 啟動時會關掉 stdout）</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="na">clear_env</span> <span class="o">=</span> <span class="s">no                   ; 把容器的環境變數交給 worker</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="na">catch_workers_output</span> <span class="o">=</span> <span class="s">yes       ; worker 的輸出也收進主 log</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="na">listen</span> <span class="o">=</span> <span class="s">9000                    ; 聽所有網路介面的 9000</span></span></span></code></pre></div><p>另外映像檔設了 <code>STOPSIGNAL SIGQUIT</code>，<code>docker stop</code> 送給它的是 SIGQUIT 而不是預設的 SIGTERM。SIGQUIT 是 PHP-FPM 的 graceful stop，SIGTERM 是立即終止。但 graceful stop 要等進行中的請求多久，由 <code>process_control_timeout</code> 決定，<strong>而它的預設值是 0</strong>：只靠 STOPSIGNAL，<code>docker stop</code> 時進行中的請求仍然拿到 502。實測與設定方式見 <a href="/blog/programming/php/01-server-runtime/php-fpm/#%e5%81%9c%e6%a9%9f%e8%88%87%e9%87%8d%e8%bc%89%e7%9a%84%e8%a8%8a%e8%99%9f" 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>





<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">[global]</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">; 停機與 reload 時，最多等進行中的請求這麼久；要比最長的正常請求長，比平台的停機逾時短</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">process_control_timeout</span> <span class="o">=</span> <span class="s">10</span></span></span></code></pre></div><p>其中 <code>clear_env</code> 最容易在自己寫設定時改壞。PHP-FPM 自己的預設是 <code>clear_env = yes</code>：worker 啟動時清掉所有環境變數，PHP 程式用 <code>getenv()</code> 讀不到。而容器的設定慣例正是用環境變數傳進去（資料庫密碼、<code>APP_KEY</code>）。同一個容器帶著 <code>APP_KEY=secret-from-env</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">clear_env = no   →  getenv(&#34;APP_KEY&#34;) = &#39;secret-from-env&#39;
</span></span><span class="line"><span class="ln">2</span><span class="cl">clear_env = yes  →  getenv(&#34;APP_KEY&#34;) = false</span></span></code></pre></div><p>沒有任何錯誤訊息，程式讀到的是 <code>false</code>。自己寫 pool 設定或從非容器環境搬一份 <code>www.conf</code> 進來時，要確認這一行還是 <code>no</code>。</p>
<h2 id="拆成兩個容器nginx-這一側也要有程式碼">拆成兩個容器：Nginx 這一側也要有程式碼</h2>
<p>拆成兩個容器時，最容易漏的是<strong>程式碼要兩邊都有</strong>。理由是兩個容器各自要用到檔案，用途不同：</p>
<ul>
<li><strong>PHP-FPM 容器要有 PHP 檔</strong>：Nginx 送來的 <code>SCRIPT_FILENAME</code> 是一個路徑，PHP-FPM 在<strong>自己的</strong>檔案系統裡找這個路徑去執行。</li>
<li><strong>Nginx 容器要有靜態檔</strong>：CSS、JS、圖片由 Nginx 直接從<strong>自己的</strong>檔案系統回，不經過 PHP。<code>try_files</code> 判斷檔案存不存在，也是看 Nginx 自己的檔案系統。</li>
</ul>
<p>只把程式碼掛進 PHP-FPM 容器，而 Nginx 容器沒有，一般的 Laravel 式設定（<code>try_files $uri /index.php$is_args$args</code>）不會報錯，而是給出錯的內容。請求 <code>/app.css</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"># Nginx 容器沒有程式碼
</span></span><span class="line"><span class="ln">2</span><span class="cl">HTTP/1.1 200 OK
</span></span><span class="line"><span class="ln">3</span><span class="cl">Content-Type: text/html; charset=UTF-8
</span></span><span class="line"><span class="ln">4</span><span class="cl">（內容是 index.php 產生的頁面）
</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"># Nginx 容器也掛上同一份程式碼
</span></span><span class="line"><span class="ln">7</span><span class="cl">HTTP/1.1 200 OK
</span></span><span class="line"><span class="ln">8</span><span class="cl">Content-Type: text/css
</span></span><span class="line"><span class="ln">9</span><span class="cl">body{}</span></span></code></pre></div><p>Nginx 在自己的檔案系統找不到 <code>app.css</code>，<code>try_files</code> 就照設定退回給 <code>index.php</code>，於是 PHP 回了一頁 HTML，狀態碼 200。監控看到的是全部成功，瀏覽器看到的是樣式全部消失。做法有兩種：用同一個 volume 把程式碼同時掛進兩個容器；或者正式部署時在建置映像檔的階段，把 <code>public/</code> 底下的靜態檔複製一份進 Nginx 的映像檔。兩個容器裡的路徑也要一致（例如都是 <code>/var/www/html</code>），因為 Nginx 組出來的 <code>SCRIPT_FILENAME</code> 是拿自己的 <code>root</code> 拼的，PHP-FPM 要能在同一個路徑找到檔案。</p>
<p>另一件事是 FastCGI 埠的暴露範圍。映像檔的 <code>listen = 9000</code> 聽所有網路介面，在 compose 網路裡 Nginx 才連得到它。PHP 官方文件明寫 php-fpm 不能讓不受信任的網路連到——FastCGI 沒有驗證，連得到 9000 的人能指定 PHP 執行哪個檔案。所以 compose 裡 PHP-FPM 服務不寫 <code>ports:</code>，9000 只在容器網路內可達。</p>
<h2 id="放進同一個容器要自己做程序監看">放進同一個容器：要自己做程序監看</h2>
<p>同一個容器裡跑兩個程序，容器的主程序（PID 1）就不能直接是 <code>php-fpm</code> 或 <code>nginx</code>，要有一支腳本把兩個都啟動起來。這支腳本要負責三件事，而三件都是單一程序的容器不必想的。</p>
<p><strong>任一個程序結束，整個容器也要結束。</strong> 只剩一半在跑的容器最難排查：Nginx 還活著，PHP-FPM 已經掛了，每個請求都回 502，而容器的狀態仍是 running，平台不會重啟它。腳本要等待任一個子程序結束，然後停掉另一個、讓自己結束。shell 的 <code>wait -n</code> 做的就是「等任一個子程序結束」，但 Alpine 預設的 busybox sh（1.37）在這裡有一個陷阱：子程序被訊號殺掉時（<code>kill -9</code>、記憶體不足被系統終止），它的 <code>wait -n</code> 不會返回，正好漏掉崩潰這個最需要偵測的情況。換成 bash 的 <code>wait -n</code>，子程序被 <code>kill -9</code> 時會立刻返回、帶著狀態碼 137。</p>
<p><strong>把停機訊號轉給兩個程序。</strong> <code>docker stop</code> 只把訊號送給 PID 1，也就是這支腳本，shell 不會自動轉給子程序。腳本要接住訊號、自己轉送。這裡有一個連帶的細節：映像檔設了 <code>STOPSIGNAL SIGQUIT</code>，所以腳本收到的是 QUIT。只寫 <code>trap ... TERM</code> 的腳本接不到它，而 PID 1 對沒有註冊處理器的訊號一律忽略，結果 <code>docker stop</code> 要等滿 10 秒的逾時才強制終止——實測只接 TERM 時 <code>docker stop</code> 花 10 秒，加上 QUIT 之後 1 秒完成。</p>
<p><strong>把 FastCGI 只開給容器內部。</strong> 兩個程序在同一個容器裡，Nginx 用 <code>127.0.0.1:9000</code> 連 PHP-FPM 就夠了，FastCGI 不必聽在網路介面上。映像檔的 <code>listen = 9000</code> 設在 <code>docker.conf</code>，在 <code>php-fpm.d/</code> 底下放一個檔名排在它後面的設定檔（例如 <code>zzz-listen.conf</code>）覆蓋它：</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">[www]</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">listen</span> <span class="o">=</span> <span class="s">127.0.0.1:9000</span></span></span></code></pre></div><p><code>php-fpm.d/</code> 底下的檔案依檔名順序讀取，後讀到的設定覆蓋前面的，所以檔名要排在 <code>docker.conf</code> 之後。</p>
<p>這三件事寫成 entrypoint 腳本：</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="cp">#!/bin/bash
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="cp"></span><span class="c1"># 用 bash：busybox sh 的 wait -n 在子程序被訊號殺掉時不返回</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># docker stop 送來的是 SIGQUIT（映像檔的 STOPSIGNAL），一併接 TERM 與 INT</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">stop<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nb">kill</span> -QUIT <span class="s2">&#34;</span><span class="nv">$fpm</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$web</span><span class="s2">&#34;</span> 2&gt;/dev/null   <span class="c1"># 兩者的 graceful stop；PHP-FPM 要設 process_control_timeout 才會等</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="nb">trap</span> stop QUIT TERM INT
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">php-fpm -F <span class="p">&amp;</span>                               <span class="c1"># -F：前景執行，讓這支腳本當它的父程序</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="nv">fpm</span><span class="o">=</span><span class="nv">$!</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">nginx -e stderr -g <span class="s1">&#39;daemon off;&#39;</span> <span class="p">&amp;</span>         <span class="c1"># -e stderr：讀設定之前的錯誤 log 也寫到 stderr</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="nv">web</span><span class="o">=</span><span class="nv">$!</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="nb">wait</span> -n                                    <span class="c1"># 任一個結束就往下走</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="nv">status</span><span class="o">=</span><span class="nv">$?</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">stop                                       <span class="c1"># 停掉另一個</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="nb">wait</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="nb">exit</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span></span></span></code></pre></div><p>映像檔要另外裝 bash 與 nginx（<code>apk add bash nginx</code>）。以非 root 使用者執行時，Nginx 的 pid 檔與暫存目錄要放在那個使用者寫得進去的位置（例如 <code>/tmp</code>），監聽的埠也要用 1024 以上。</p>
<p>這組驗證值得在建好映像檔時跑一次，因為三件事出錯時容器都照樣啟動、照樣回應：用 <code>kill -9</code> 分別殺掉 PHP-FPM 與 Nginx 的 master，確認容器在幾秒內結束；量 <code>docker stop</code> 的時間，確認不是 10 秒；從容器外面連 9000，確認連線被拒。</p>
<h2 id="兩種擺法的選擇">兩種擺法的選擇</h2>
<p>兩種擺法的差別落在「誰負責程序監看」：拆成兩個容器時，平台（compose、Kubernetes）監看每個程序，每個容器只有一個主程序，失敗時平台看得到；放進同一個容器時，這份責任移進了 entrypoint 腳本，上面那三件事都要自己做對。</p>
<p>放進同一個容器時，手寫腳本之外也可以用 process supervisor（s6-overlay、supervisord）管理兩個程序，由它負責監看與回收結束的子程序，代價是映像檔多一個相依；手寫腳本的好處是沒有額外相依，三件事都看得到。</p>
<p>所以預設是拆成兩個容器；只有在平台只接受單一 HTTP 容器、或要讓 PHP 服務跟其他 HTTP 後端進同一個 upstream 時，才放進同一個容器，並把上面的驗證當成映像檔的驗收條件。兩種擺法都繼承 PHP-FPM 的 shared-nothing 模型；不再每個請求重新啟動框架的做法是另一個執行模型，見 <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>。</p>
]]></content:encoded></item><item><title>常駐 worker 模型：FrankenPHP worker 模式、Laravel Octane 與跨請求殘留的狀態</title><link>https://tarrragon.github.io/blog/programming/php/01-server-runtime/long-running-workers/</link><pubDate>Mon, 05 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/php/01-server-runtime/long-running-workers/</guid><description>&lt;p>這篇說明常駐 worker 模型：應用程式在 worker 啟動時載入一次，之後同一份記憶體裡的應用程式連續處理很多個請求。用 &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> 那組問題來看，它換掉的是第三個答案：應用程式的狀態不再只活一個請求。文中的行為取自 FrankenPHP 1.13（PHP 8.5.11）與 Laravel Octane 2.20（Laravel 13）。&lt;/p>
&lt;h2 id="常駐-worker-省下的是哪一段成本">常駐 worker 省下的是哪一段成本&lt;/h2>
&lt;p>&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> 的 worker 常駐，&lt;a href="https://tarrragon.github.io/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&lt;/a> 讓每個檔案只編譯一次，可是框架的啟動流程每個請求仍然要跑一次：建立服務容器、執行每個服務提供者的註冊、讀設定、建立路由表。這段流程的成本由框架的規模決定，跟請求要做什麼無關，是 &lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/shared-nothing-request/" data-link-title="Shared-nothing 請求模型" data-link-desc="要判斷 PHP 的全域變數、static 變數或類別靜態屬性會不會留到下一個請求，或評估換成常駐 worker（Octane、FrankenPHP worker）時哪些程式碼會出問題時">shared-nothing&lt;/a> 的直接代價——每個請求都從空白開始，就得每個請求都重新建立一次應用程式。&lt;/p>
&lt;p>常駐 worker 模型把這段流程移到 worker 啟動時：應用程式啟動一次之後留在記憶體裡，每個請求進來只執行「處理這個請求」那一段。Laravel Octane 的文件把這件事寫成一句話：Octane boots your application once, keeps it in memory, and then feeds it requests。&lt;/p>
&lt;p>這個模型要求執行 PHP 的程序或執行緒有能力在請求之間不清空狀態，所以它需要特定的伺服器，PHP-FPM 做不到（它在每個請求結束時一定清空）。Octane 支援的伺服器有 FrankenPHP、Swoole、Open Swoole 與 RoadRunner：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>伺服器&lt;/th>
 &lt;th>寫成什麼&lt;/th>
 &lt;th>怎麼讓 PHP 常駐&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>FrankenPHP&lt;/td>
 &lt;td>Go（嵌入 Caddy）&lt;/td>
 &lt;td>PHP 嵌在 Go 程式裡，worker 是執行緒&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RoadRunner&lt;/td>
 &lt;td>Go&lt;/td>
 &lt;td>Go 程式管理一群常駐的 PHP 程序，預設用標準管道（goridge 協定）交換請求與回應&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Swoole&lt;/td>
 &lt;td>PHP 的 C 擴充&lt;/td>
 &lt;td>PHP 程序自己開伺服器、處理事件迴圈與協程&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="frankenphp-的兩種模式">FrankenPHP 的兩種模式&lt;/h2>
&lt;p>FrankenPHP 是一個講 HTTP 的 web 伺服器，PHP 嵌在它的程序裡（SAPI 名稱是 &lt;code>frankenphp&lt;/code>），所以它不需要前面再接一個 Nginx。它有兩種模式，差別正好就是狀態壽命。&lt;/p>
&lt;p>&lt;strong>傳統模式&lt;/strong>：每個請求執行一次 PHP 腳本，執行完清空狀態，跟 PHP-FPM 一樣是 shared-nothing。同一支計數腳本（腳本內容見 &lt;a href="https://tarrragon.github.io/blog/programming/php/01-server-runtime/request-lifecycle-and-sapi/#%e4%b8%80%e5%80%8b%e8%ab%8b%e6%b1%82%e5%9c%a8-php-%e8%a3%a1%e7%b6%93%e9%81%8e%e7%9a%84%e9%9a%8e%e6%ae%b5" data-link-title="PHP 的請求生命週期與 SAPI：直譯器的位置、程序壽命與狀態壽命" data-link-desc="說明一個 HTTP 請求怎麼變成一次 PHP 執行：PHP 在程序啟動與每個請求開始、結束時各做什麼，SAPI 這一層介面怎麼讓同一個直譯器接上 CGI、Apache、FastCGI 與常駐伺服器，以及用直譯器住在哪裡、執行它的程序活多久、應用程式的狀態活多久這組問題比較各種執行模型">PHP 的請求生命週期與 SAPI&lt;/a>）請求三次：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">pid=1 static=1 global=1 sapi=frankenphp
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">pid=1 static=1 global=1 sapi=frankenphp
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">pid=1 static=1 global=1 sapi=frankenphp&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>PID 一直是 1，因為 FrankenPHP 只有一個程序，PHP 在它的執行緒裡跑（這個 PHP 是以執行緒安全編譯的，&lt;code>Thread Safety =&amp;gt; enabled&lt;/code>，見 &lt;a href="https://tarrragon.github.io/blog/programming/php/knowledge-cards/zts/" data-link-title="ZTS（Zend Thread Safety）" data-link-desc="在 php -i 看到 Thread Safety 是 enabled 或 disabled，或要判斷某個 PHP 能不能跑在多執行緒的伺服器（Apache worker/event MPM、FrankenPHP）裡時">ZTS&lt;/a>）。計數器每次都是 1。&lt;/p></description><content:encoded><![CDATA[<p>這篇說明常駐 worker 模型：應用程式在 worker 啟動時載入一次，之後同一份記憶體裡的應用程式連續處理很多個請求。用 <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> 那組問題來看，它換掉的是第三個答案：應用程式的狀態不再只活一個請求。文中的行為取自 FrankenPHP 1.13（PHP 8.5.11）與 Laravel Octane 2.20（Laravel 13）。</p>
<h2 id="常駐-worker-省下的是哪一段成本">常駐 worker 省下的是哪一段成本</h2>
<p><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> 的 worker 常駐，<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> 讓每個檔案只編譯一次，可是框架的啟動流程每個請求仍然要跑一次：建立服務容器、執行每個服務提供者的註冊、讀設定、建立路由表。這段流程的成本由框架的規模決定，跟請求要做什麼無關，是 <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> 的直接代價——每個請求都從空白開始，就得每個請求都重新建立一次應用程式。</p>
<p>常駐 worker 模型把這段流程移到 worker 啟動時：應用程式啟動一次之後留在記憶體裡，每個請求進來只執行「處理這個請求」那一段。Laravel Octane 的文件把這件事寫成一句話：Octane boots your application once, keeps it in memory, and then feeds it requests。</p>
<p>這個模型要求執行 PHP 的程序或執行緒有能力在請求之間不清空狀態，所以它需要特定的伺服器，PHP-FPM 做不到（它在每個請求結束時一定清空）。Octane 支援的伺服器有 FrankenPHP、Swoole、Open Swoole 與 RoadRunner：</p>
<table>
  <thead>
      <tr>
          <th>伺服器</th>
          <th>寫成什麼</th>
          <th>怎麼讓 PHP 常駐</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>FrankenPHP</td>
          <td>Go（嵌入 Caddy）</td>
          <td>PHP 嵌在 Go 程式裡，worker 是執行緒</td>
      </tr>
      <tr>
          <td>RoadRunner</td>
          <td>Go</td>
          <td>Go 程式管理一群常駐的 PHP 程序，預設用標準管道（goridge 協定）交換請求與回應</td>
      </tr>
      <tr>
          <td>Swoole</td>
          <td>PHP 的 C 擴充</td>
          <td>PHP 程序自己開伺服器、處理事件迴圈與協程</td>
      </tr>
  </tbody>
</table>
<h2 id="frankenphp-的兩種模式">FrankenPHP 的兩種模式</h2>
<p>FrankenPHP 是一個講 HTTP 的 web 伺服器，PHP 嵌在它的程序裡（SAPI 名稱是 <code>frankenphp</code>），所以它不需要前面再接一個 Nginx。它有兩種模式，差別正好就是狀態壽命。</p>
<p><strong>傳統模式</strong>：每個請求執行一次 PHP 腳本，執行完清空狀態，跟 PHP-FPM 一樣是 shared-nothing。同一支計數腳本（腳本內容見 <a href="/blog/programming/php/01-server-runtime/request-lifecycle-and-sapi/#%e4%b8%80%e5%80%8b%e8%ab%8b%e6%b1%82%e5%9c%a8-php-%e8%a3%a1%e7%b6%93%e9%81%8e%e7%9a%84%e9%9a%8e%e6%ae%b5" data-link-title="PHP 的請求生命週期與 SAPI：直譯器的位置、程序壽命與狀態壽命" data-link-desc="說明一個 HTTP 請求怎麼變成一次 PHP 執行：PHP 在程序啟動與每個請求開始、結束時各做什麼，SAPI 這一層介面怎麼讓同一個直譯器接上 CGI、Apache、FastCGI 與常駐伺服器，以及用直譯器住在哪裡、執行它的程序活多久、應用程式的狀態活多久這組問題比較各種執行模型">PHP 的請求生命週期與 SAPI</a>）請求三次：</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=1 static=1 global=1 sapi=frankenphp
</span></span><span class="line"><span class="ln">2</span><span class="cl">pid=1 static=1 global=1 sapi=frankenphp
</span></span><span class="line"><span class="ln">3</span><span class="cl">pid=1 static=1 global=1 sapi=frankenphp</span></span></code></pre></div><p>PID 一直是 1，因為 FrankenPHP 只有一個程序，PHP 在它的執行緒裡跑（這個 PHP 是以執行緒安全編譯的，<code>Thread Safety =&gt; enabled</code>，見 <a href="/blog/programming/php/knowledge-cards/zts/" data-link-title="ZTS（Zend Thread Safety）" data-link-desc="在 php -i 看到 Thread Safety 是 enabled 或 disabled，或要判斷某個 PHP 能不能跑在多執行緒的伺服器（Apache worker/event MPM、FrankenPHP）裡時">ZTS</a>）。計數器每次都是 1。</p>
<p><strong>worker 模式</strong>：一支 worker 腳本啟動一次，在一個迴圈裡反覆呼叫 <code>frankenphp_handle_request()</code>，每呼叫一次處理一個請求：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="o">&lt;?</span><span class="nx">php</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">// 這段只在 worker 啟動時執行一次：模擬框架啟動
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span><span class="nv">$bootedAt</span> <span class="o">=</span> <span class="nx">microtime</span><span class="p">(</span><span class="k">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="nv">$requestLog</span> <span class="o">=</span> <span class="p">[];</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="nv">$handler</span> <span class="o">=</span> <span class="k">static</span> <span class="k">function</span> <span class="p">()</span> <span class="k">use</span> <span class="p">(</span><span class="nv">$bootedAt</span><span class="p">,</span> <span class="o">&amp;</span><span class="nv">$requestLog</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">static</span> <span class="nv">$count</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nv">$count</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nv">$requestLog</span><span class="p">[]</span> <span class="o">=</span> <span class="nv">$_SERVER</span><span class="p">[</span><span class="s1">&#39;REQUEST_URI&#39;</span><span class="p">];</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">echo</span> <span class="s2">&#34;static=&#34;</span><span class="p">,</span> <span class="nv">$count</span><span class="p">,</span> <span class="s2">&#34; log_entries=&#34;</span><span class="p">,</span> <span class="nx">count</span><span class="p">(</span><span class="nv">$requestLog</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">         <span class="s2">&#34; booted=&#34;</span><span class="p">,</span> <span class="nx">round</span><span class="p">(</span><span class="nv">$bootedAt</span><span class="p">,</span> <span class="mi">3</span><span class="p">),</span> <span class="s2">&#34;</span><span class="se">\n</span><span class="s2">&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">};</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">// 每次迴圈處理一個請求；伺服器要這個 worker 結束時回傳 false
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"></span><span class="k">while</span> <span class="p">(</span><span class="nx">frankenphp_handle_request</span><span class="p">(</span><span class="nv">$handler</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">gc_collect_cycles</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>連續請求四次：</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">static=1 log_entries=1 booted=1791203186.703
</span></span><span class="line"><span class="ln">2</span><span class="cl">static=2 log_entries=2 booted=1791203186.703
</span></span><span class="line"><span class="ln">3</span><span class="cl">static=3 log_entries=3 booted=1791203186.703
</span></span><span class="line"><span class="ln">4</span><span class="cl">static=4 log_entries=4 booted=1791203186.703</span></span></code></pre></div><p><code>booted</code> 的時間戳四次都一樣，證明迴圈前的那段只跑了一次；<code>static</code> 與 <code>$requestLog</code> 一路累加，證明前一個請求留下的變數被下一個請求看到了。FrankenPHP 的文件對此寫得很直接：Static variables, class static properties, global variables, and in-memory caches all persist across requests。</p>
<h2 id="laravel-octane框架層的常駐-worker">Laravel Octane：框架層的常駐 worker</h2>
<p>自己寫 worker 迴圈要處理的事情很多：每個請求之間重設框架的哪些物件、例外怎麼處理、什麼時候重啟 worker。Laravel Octane 是 Laravel 官方的套件，把這些包好，讓既有的 Laravel 應用程式跑在上面那幾種伺服器上，用 <code>php artisan octane:frankenphp</code>（或 <code>octane:swoole</code>、<code>octane:roadrunner</code>）啟動。</p>
<p>Octane 會在每個請求之間重設 Laravel 自己的請求相關狀態，但應用程式程式碼留下的狀態它管不到。在 Laravel 骨架加一條路由，用一個類別的靜態屬性計數，並在每個請求往一個靜態陣列塞 100 KB 的資料——模擬把查詢結果或 log 暫存在靜態屬性裡這種在 PHP-FPM 下無害的寫法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">Route</span><span class="o">::</span><span class="na">get</span><span class="p">(</span><span class="s1">&#39;/state&#39;</span><span class="p">,</span> <span class="k">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">RequestLog</span><span class="o">::</span><span class="nv">$count</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">RequestLog</span><span class="o">::</span><span class="nv">$entries</span><span class="p">[]</span> <span class="o">=</span> <span class="nx">str_repeat</span><span class="p">(</span><span class="s1">&#39;x&#39;</span><span class="p">,</span> <span class="mi">100</span> <span class="o">*</span> <span class="mi">1024</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="s1">&#39;count&#39;</span> <span class="o">=&gt;</span> <span class="nx">RequestLog</span><span class="o">::</span><span class="nv">$count</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">            <span class="s1">&#39;php_mem_mb&#39;</span> <span class="o">=&gt;</span> <span class="nx">round</span><span class="p">(</span><span class="nx">memory_get_usage</span><span class="p">()</span> <span class="o">/</span> <span class="mi">1048576</span><span class="p">,</span> <span class="mi">1</span><span class="p">)];</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>在 Octane 加 FrankenPHP、一個 worker、<code>--max-requests=500</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">request #1   -&gt; count=1   php_mem=5.6MB
</span></span><span class="line"><span class="ln">2</span><span class="cl">request #100 -&gt; count=100 php_mem=15.8MB
</span></span><span class="line"><span class="ln">3</span><span class="cl">request #200 -&gt; count=200 php_mem=26.1MB
</span></span><span class="line"><span class="ln">4</span><span class="cl">request #400 -&gt; count=400 php_mem=46.5MB
</span></span><span class="line"><span class="ln">5</span><span class="cl">request #499 -&gt; count=499 php_mem=56.6MB
</span></span><span class="line"><span class="ln">6</span><span class="cl">request #500 -&gt; count=1   php_mem=1.2MB    # 處理滿 500 個請求，worker 重啟
</span></span><span class="line"><span class="ln">7</span><span class="cl">request #600 -&gt; count=101 php_mem=11.5MB</span></span></code></pre></div><p>同一段程式碼在 PHP-FPM 下，<code>count</code> 永遠是 1、記憶體在請求結束時全部釋放。在常駐 worker 下它變成兩個問題：</p>
<ul>
<li><strong>狀態洩漏到下一個請求</strong>：<code>count</code> 一路累加。換成「目前登入的使用者」「這個請求的語系」這類資料，就是前一個使用者的資料被下一個使用者看到。</li>
<li><strong>記憶體隨請求數線性增長</strong>：每個請求多 0.1 MB，<code>max-requests</code> 到了 worker 重啟才歸零。Octane 的文件寫明它預設在 worker 處理滿 500 個請求後 gracefully restart，理由正是 to help prevent stray memory leaks，並舉了同一個例子：adding data to a statically maintained array will result in a memory leak。</li>
</ul>
<p>這裡的記憶體增長跟 <a href="/blog/programming/php/01-server-runtime/php-fpm/#max_requests%e5%ae%9a%e6%9c%9f%e6%8f%9b%e6%8e%89-worker-%e4%bb%a5%e6%8e%a7%e5%88%b6%e8%a8%98%e6%86%b6%e9%ab%94" 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 那篇〈max_requests〉一節</a> 量到的不同。PHP-FPM 下 PHP 程式碼建立的記憶體每個請求都會釋放，worker 的記憶體反映最近一次請求的峰值；常駐 worker 下，靜態屬性裡的資料不會被釋放，記憶體跟著累計的請求數往上長。<code>max-requests</code> 在兩個模型下都存在，防的記憶體成長不一樣。在常駐 worker 下，它的值是兩個成本的取捨：設得低，洩漏累積到的上限低，代價是 worker 重啟頻繁、每次重啟都要重新啟動一次框架；設得高，重啟少，代價是洩漏能長到的量大。先用上面那種逐請求記錄記憶體的方式量出每個請求漲多少，再依可接受的記憶體上限回推。</p>
<p>worker 是程序還是執行緒，也決定一個 worker 崩潰時影響多大。RoadRunner 的 worker 是各自獨立的 PHP 程序，一個因為擴充的錯誤而 segfault 時只有它自己結束；FrankenPHP 與 Swoole 的 worker 跑在同一個程序裡，程序層級的崩潰會連同其他 worker 一起帶走。</p>
<p>FrankenPHP 的 worker 是執行緒，所以重啟時 PID 不變（這次實測一直是同一個 PID），換掉的是執行緒裡的 PHP 狀態。看 PID 判斷 worker 有沒有重啟，在這個伺服器上不成立。</p>
<h2 id="寫給常駐-worker-的程式要避開的寫法">寫給常駐 worker 的程式要避開的寫法</h2>
<p>Octane 的文件列出的幾類問題，根源都是「物件比請求活得久」：</p>
<ul>
<li><strong>靜態屬性與全域變數存放請求的資料</strong>：上面那個例子。請求的資料放在請求範圍的物件裡，或在請求結束時清掉。</li>
<li><strong>把服務容器或請求物件注入到長壽物件的建構子</strong>：Octane 文件的原文是 that object may have a stale version of the container or request on subsequent requests——一個 singleton 在第一個請求時拿到了那個請求的 <code>Request</code>，之後的請求它手上一直是第一個請求的版本。</li>
<li><strong>在 singleton 裡快取會變的值</strong>：設定、目前的使用者、語系，在 PHP-FPM 下每個請求都重新算，在常駐 worker 下只算一次。</li>
</ul>
<p>既有的 Laravel 應用程式搬上 Octane 之前，要把程式碼與用到的套件逐一檢查過這幾類寫法；套件作者沒有考慮常駐 worker 的話，問題會出在套件裡，而它們在 PHP-FPM 下從來不會顯現。</p>
<h2 id="速度差多少要在自己的應用程式上量">速度差多少要在自己的應用程式上量</h2>
<p>常駐 worker 省下的是框架的啟動流程，所以省下多少由框架啟動流程佔每個請求多少時間決定。在 Laravel 13 骨架的 <code>/healthz</code> 上，同一時段各循序送 300 個請求：</p>
<table>
  <thead>
      <tr>
          <th>執行方式</th>
          <th>中位數</th>
          <th>p95</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>PHP-FPM（容器內 Nginx 加 php-fpm，開 OPcache）</td>
          <td>3.20 ms</td>
          <td>7.57 ms</td>
      </tr>
      <tr>
          <td>Octane 加 FrankenPHP worker</td>
          <td>2.43 ms</td>
          <td>4.07 ms</td>
      </tr>
  </tbody>
</table>
<p>兩者的中位數只差不到一毫秒，而這個差距還包含 PHP-FPM 那一側多經過的容器內 Nginx 一跳，所以兩邊不完全對等。量測對象是一個幾乎沒有服務提供者的空骨架，框架啟動流程本來就短，開了 OPcache 之後能省的不多。正式專案的服務提供者、套件與設定檔越多，啟動流程越長，常駐 worker 省下的越多。所以這組數字只說明量測的方式，不說明手上的專案會差多少：在自己的應用程式、自己的主要請求上量一次，才知道省下的時間值不值得上一節那些改寫與檢查的成本。</p>
<h2 id="常駐-worker-在執行模型裡的位置">常駐 worker 在執行模型裡的位置</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> 的程序與狀態都只活一個請求；<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> 與 <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> 讓程序活過很多請求，<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> 因此有編譯結果可以重用；常駐 worker 讓應用程式的狀態也活過很多請求。</p>
<p>每往前一步，省下一段每個請求都要重付的成本，也失去一層隔離：CGI 的程序隔離讓一個請求的錯誤隨程序消失；shared-nothing 讓一個請求的資料不會被下一個請求看到。常駐 worker 走到最後一步，代價是這兩層隔離的責任從執行環境移到了程式碼身上——PHP-FPM 替程式清空的那些狀態，現在要程式自己不留下來。</p>
]]></content:encoded></item></channel></rss>