<?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-Fpm on Tarragon</title><link>https://tarrragon.github.io/blog/tags/php-fpm/</link><description>Recent content in Php-Fpm on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 05 Oct 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/php-fpm/index.xml" rel="self" type="application/rss+xml"/><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>PHP-FPM（FastCGI Process Manager）</title><link>https://tarrragon.github.io/blog/programming/php/knowledge-cards/php-fpm/</link><pubDate>Mon, 05 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/programming/php/knowledge-cards/php-fpm/</guid><description>&lt;p>PHP-FPM 是 PHP 內建的 FastCGI 程序管理器：一個 master 程序加一群常駐的 worker，master 負責讀設定、開 worker、補死掉的 worker 與處理訊號，worker 實際執行 PHP，透過 FastCGI 協定接收 web 伺服器轉來的請求。它的 &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;code>fpm-fcgi&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> 與 &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;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>PHP-FPM 把直譯器放在 web 伺服器外面的常駐程序裡：程序活過很多請求，應用程式狀態仍然只活一個請求（&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>）。它只講 FastCGI，所以前面一定要有一個講 HTTP 的 web 伺服器（&lt;a href="https://tarrragon.github.io/blog/devops/infra/knowledge-cards/nginx/" data-link-title="nginx" data-link-desc="高效能 Web Server 與 Reverse Proxy，以集中設定檔取代 Apache 的 .htaccess 分散設定">nginx&lt;/a> 或 Apache 的 &lt;code>mod_proxy_fcgi&lt;/code>）。PHP 5.3.3 起隨 PHP 本體發行。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>&lt;code>pm.max_children&lt;/code> 決定並發上限&lt;/strong>：一個 worker 一次處理一個請求，超過上限的請求排隊，status 頁的 &lt;code>max listen queue&lt;/code> 會大於 0。&lt;/li>
&lt;li>&lt;strong>502 加上「Connection reset by peer」&lt;/strong>：worker 被 &lt;code>request_terminate_timeout&lt;/code> 終止、或 reload／停機時沒有等進行中的請求（&lt;code>process_control_timeout&lt;/code> 預設 0）。&lt;/li>
&lt;li>&lt;strong>預設監聽 &lt;code>9000&lt;/code>&lt;/strong>：FastCGI 沒有驗證，PHP 官方文件要求 php-fpm 不能讓不受信任的網路連到。&lt;/li>
&lt;li>&lt;strong>pool&lt;/strong>：每個 pool 可以指定執行帳號，讓不同站台的 PHP 互相讀不到檔案。&lt;/li>
&lt;/ul>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>部署 PHP-FPM 要決定 &lt;code>max_children&lt;/code>（以最重請求的記憶體峰值估）、&lt;code>max_requests&lt;/code>、&lt;code>request_terminate_timeout&lt;/code> 與 &lt;code>process_control_timeout&lt;/code>，並把 FastCGI 埠限制在信任的網路內。各設定的實測見 &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></description><content:encoded><![CDATA[<p>PHP-FPM 是 PHP 內建的 FastCGI 程序管理器：一個 master 程序加一群常駐的 worker，master 負責讀設定、開 worker、補死掉的 worker 與處理訊號，worker 實際執行 PHP，透過 FastCGI 協定接收 web 伺服器轉來的請求。它的 <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> 名稱是 <code>fpm-fcgi</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> 與 <a href="/blog/programming/php/knowledge-cards/opcache/" data-link-title="OPcache" data-link-desc="部署新版程式碼之後頁面還在跑舊版、評估 OPcache 的大小設定，或比較不同執行模型下 PHP 的效能差距時">OPcache</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>PHP-FPM 把直譯器放在 web 伺服器外面的常駐程序裡：程序活過很多請求，應用程式狀態仍然只活一個請求（<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>）。它只講 FastCGI，所以前面一定要有一個講 HTTP 的 web 伺服器（<a href="/blog/devops/infra/knowledge-cards/nginx/" data-link-title="nginx" data-link-desc="高效能 Web Server 與 Reverse Proxy，以集中設定檔取代 Apache 的 .htaccess 分散設定">nginx</a> 或 Apache 的 <code>mod_proxy_fcgi</code>）。PHP 5.3.3 起隨 PHP 本體發行。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<ul>
<li><strong><code>pm.max_children</code> 決定並發上限</strong>：一個 worker 一次處理一個請求，超過上限的請求排隊，status 頁的 <code>max listen queue</code> 會大於 0。</li>
<li><strong>502 加上「Connection reset by peer」</strong>：worker 被 <code>request_terminate_timeout</code> 終止、或 reload／停機時沒有等進行中的請求（<code>process_control_timeout</code> 預設 0）。</li>
<li><strong>預設監聽 <code>9000</code></strong>：FastCGI 沒有驗證，PHP 官方文件要求 php-fpm 不能讓不受信任的網路連到。</li>
<li><strong>pool</strong>：每個 pool 可以指定執行帳號，讓不同站台的 PHP 互相讀不到檔案。</li>
</ul>
<h2 id="設計責任">設計責任</h2>
<p>部署 PHP-FPM 要決定 <code>max_children</code>（以最重請求的記憶體峰值估）、<code>max_requests</code>、<code>request_terminate_timeout</code> 與 <code>process_control_timeout</code>，並把 FastCGI 埠限制在信任的網路內。各設定的實測見 <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>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></channel></rss>