<?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>模組十：Prod Parity — 跨發行版與線上環境對齊 on Tarragon</title><link>https://tarrragon.github.io/blog/linux/dotfile/10-prod-parity/</link><description>Recent content in 模組十：Prod Parity — 跨發行版與線上環境對齊 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 06 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/linux/dotfile/10-prod-parity/index.xml" rel="self" type="application/rss+xml"/><item><title>工作站 dotfile 跨發行版落地</title><link>https://tarrragon.github.io/blog/linux/dotfile/10-prod-parity/workstation-cross-distro/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/linux/dotfile/10-prod-parity/workstation-cross-distro/</guid><description>&lt;p>這篇的目的是讓一份照 Arch 寫的工作站 dotfile，能在 client 常見的 Debian/Ubuntu 機器上還原出同樣的環境。要改的部分比想像少：綁發行版的只有「用哪個 package manager 裝套件」這一層，config 檔內容、symlink 部署、shell 框架安裝全部可攜。原理見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/knowledge-cards/package-manager-abstraction/" data-link-title="Package Manager 抽象層" data-link-desc="同一份 dotfile 要從 Arch 跨到 Debian/macOS、不確定哪些要改哪些能共用時回來讀 — 綁 distro 的只有 package manager 這一層">Package Manager 抽象層&lt;/a>，這裡只講怎麼判斷跟怎麼落地。&lt;/p>
&lt;h2 id="哪些綁-distro哪些共用">哪些綁 distro、哪些共用&lt;/h2>
&lt;p>拿到一份 dotfile 要跨 distro 時，先把內容分成兩堆，判準是「換 distro 會不會變」：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>會變、要下沉到平台層&lt;/strong>：套件安裝指令（&lt;code>pacman&lt;/code> vs &lt;code>apt-get&lt;/code>）、套件清單、以及套件名分歧的工具。&lt;/li>
&lt;li>&lt;strong>不會變、留共通層&lt;/strong>：&lt;code>.zshrc&lt;/code> 內容、&lt;code>stow&lt;/code> 建 symlink 的邏輯、oh-my-zsh 的 &lt;code>git clone&lt;/code>、所有 config 檔本身。&lt;/li>
&lt;/ul>
&lt;p>判斷錯的代價是把套件名寫死在共通層——換 distro 就壞。正確形態是共通層只呼叫「裝套件」這個抽象動作，具體套件名交給各平台清單。&lt;/p>
&lt;h2 id="落地入口-detection--平台清單">落地：入口 detection + 平台清單&lt;/h2>
&lt;p>工作站的套件集在不同 distro 差異大，適合用入口 detection：安裝腳本開頭偵測 package manager，dispatch 到對應的平台腳本。dotfiles repo 的形態是 &lt;code>install.sh&lt;/code> 判斷 OS 後委派給 &lt;code>install-arch.sh&lt;/code> / &lt;code>install-macos.sh&lt;/code> / &lt;code>install-debian.sh&lt;/code>，套件清單各一份（&lt;code>packages/arch-*.txt&lt;/code>、&lt;code>packages/debian-*.txt&lt;/code>），共通的環境組裝（stow、框架 clone）留在 &lt;code>install.sh&lt;/code>。&lt;/p>
&lt;p>跨到 Debian 只需要補一支 &lt;code>install-debian.sh&lt;/code> 跟對應清單，共通層一行都不用動——這正是抽象層有效的證據。&lt;/p>
&lt;h2 id="套件名分歧裝好不等於叫得動">套件名分歧：裝好不等於叫得動&lt;/h2>
&lt;p>同一個工具在不同 distro 的名字有好幾種分歧（有哪些形態、為什麼，見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/knowledge-cards/package-manager-abstraction/" data-link-title="Package Manager 抽象層" data-link-desc="同一份 dotfile 要從 Arch 跨到 Debian/macOS、不確定哪些要改哪些能共用時回來讀 — 綁 distro 的只有 package manager 這一層">Package Manager 抽象層&lt;/a>）。落地時按形態各自處理：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>套件名不同&lt;/strong>（&lt;code>fd&lt;/code> → &lt;code>fd-find&lt;/code>、&lt;code>github-cli&lt;/code> → &lt;code>gh&lt;/code>）：靠各平台清單逐項對照，不假設同名。&lt;/li>
&lt;li>&lt;strong>binary 被改名&lt;/strong>（&lt;code>fd-find&lt;/code> 的 binary 是 &lt;code>fdfind&lt;/code>、&lt;code>bat&lt;/code> 是 &lt;code>batcat&lt;/code>）：&lt;code>.zshrc&lt;/code> 按平台補 alias（&lt;code>alias fd=fdfind&lt;/code>）——「套件裝好了」不等於「指令叫得動」。&lt;/li>
&lt;li>&lt;strong>名字撞到別的工具&lt;/strong>（&lt;code>apt install delta&lt;/code> 裝到的不是 &lt;code>.gitconfig&lt;/code> 要的 git-delta）：別照抄，確認同名同物。&lt;/li>
&lt;/ul>
&lt;p>&lt;code>ripgrep&lt;/code> 兩邊同名（binary 都是 &lt;code>rg&lt;/code>）不用處理，&lt;code>fd&lt;/code> / &lt;code>bat&lt;/code> 就要按上面吸收。&lt;/p>
&lt;h2 id="實作會遇到的狀況與排除">實作會遇到的狀況與排除&lt;/h2>
&lt;p>跨到 Debian 實跑時，除了名字分歧，還有幾個操作面的狀況會擋住 bootstrap。先知道怎麼排除：&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>全新映像連 sudo/git 都沒有&lt;/strong>。乾淨的 Debian 映像預裝極簡，&lt;code>install.sh&lt;/code> 要用到的 &lt;code>sudo&lt;/code>、&lt;code>git&lt;/code> 本身都缺。排除：bootstrap 第一步先 &lt;code>apt-get install -y sudo git&lt;/code>，才有辦法 clone repo、跑後續。這是最小映像的預期形態，不是壞掉。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>清單裡一個沒打包的名字讓整批全滅&lt;/strong>。&lt;code>apt-get install&lt;/code> 是一筆全有或全無的交易，清單塞一個 Debian 沒有的名字（實測 broot、zellij 在 bookworm 沒打包），apt 直接中止，同批存在的工具也一個都沒裝。排除：清單逐項對齊該 distro 實際有的套件，動手前用 &lt;code>apt-get install -s &amp;lt;pkg&amp;gt;&lt;/code> 逐一 dry-run 篩掉解不開的。原理見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/knowledge-cards/apt-transaction-atomicity/" data-link-title="apt 安裝的交易原子性" data-link-desc="apt-get install 一次帶多個套件、其中一個名字沒打包卻導致整批都沒裝時回來讀 — apt 為什麼全有或全無">apt 安裝的交易原子性&lt;/a>。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>某些工具 Debian 根本沒打包&lt;/strong>。broot、zellij、git-delta、lazygit、yazi 在 bookworm 預設 repo 都不存在（Arch 都有）。排除：把它們移出 apt 清單，改從 GitHub releases 裝——抓對應架構的預編譯檔（&lt;code>curl -L -O &amp;lt;release-url&amp;gt;&lt;/code>）、解壓、&lt;code>chmod +x&lt;/code>、&lt;code>mv&lt;/code> 到 &lt;code>~/.local/bin&lt;/code>（確認它在 &lt;code>PATH&lt;/code>）；若走 &lt;code>cargo install&lt;/code>，cargo 本身要先用 rustup 裝（裸 Debian 沒有）。原理見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/knowledge-cards/distro-package-granularity/" data-link-title="發行版打包粒度" data-link-desc="同一個 install 指令在不同發行版拉的套件數差一個量級、或某工具在某發行版根本沒打包時回來讀 — 發行版怎麼切套件顆粒度">發行版打包粒度&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>這篇的目的是讓一份照 Arch 寫的工作站 dotfile，能在 client 常見的 Debian/Ubuntu 機器上還原出同樣的環境。要改的部分比想像少：綁發行版的只有「用哪個 package manager 裝套件」這一層，config 檔內容、symlink 部署、shell 框架安裝全部可攜。原理見 <a href="/blog/linux/dotfile/knowledge-cards/package-manager-abstraction/" data-link-title="Package Manager 抽象層" data-link-desc="同一份 dotfile 要從 Arch 跨到 Debian/macOS、不確定哪些要改哪些能共用時回來讀 — 綁 distro 的只有 package manager 這一層">Package Manager 抽象層</a>，這裡只講怎麼判斷跟怎麼落地。</p>
<h2 id="哪些綁-distro哪些共用">哪些綁 distro、哪些共用</h2>
<p>拿到一份 dotfile 要跨 distro 時，先把內容分成兩堆，判準是「換 distro 會不會變」：</p>
<ul>
<li><strong>會變、要下沉到平台層</strong>：套件安裝指令（<code>pacman</code> vs <code>apt-get</code>）、套件清單、以及套件名分歧的工具。</li>
<li><strong>不會變、留共通層</strong>：<code>.zshrc</code> 內容、<code>stow</code> 建 symlink 的邏輯、oh-my-zsh 的 <code>git clone</code>、所有 config 檔本身。</li>
</ul>
<p>判斷錯的代價是把套件名寫死在共通層——換 distro 就壞。正確形態是共通層只呼叫「裝套件」這個抽象動作，具體套件名交給各平台清單。</p>
<h2 id="落地入口-detection--平台清單">落地：入口 detection + 平台清單</h2>
<p>工作站的套件集在不同 distro 差異大，適合用入口 detection：安裝腳本開頭偵測 package manager，dispatch 到對應的平台腳本。dotfiles repo 的形態是 <code>install.sh</code> 判斷 OS 後委派給 <code>install-arch.sh</code> / <code>install-macos.sh</code> / <code>install-debian.sh</code>，套件清單各一份（<code>packages/arch-*.txt</code>、<code>packages/debian-*.txt</code>），共通的環境組裝（stow、框架 clone）留在 <code>install.sh</code>。</p>
<p>跨到 Debian 只需要補一支 <code>install-debian.sh</code> 跟對應清單，共通層一行都不用動——這正是抽象層有效的證據。</p>
<h2 id="套件名分歧裝好不等於叫得動">套件名分歧：裝好不等於叫得動</h2>
<p>同一個工具在不同 distro 的名字有好幾種分歧（有哪些形態、為什麼，見 <a href="/blog/linux/dotfile/knowledge-cards/package-manager-abstraction/" data-link-title="Package Manager 抽象層" data-link-desc="同一份 dotfile 要從 Arch 跨到 Debian/macOS、不確定哪些要改哪些能共用時回來讀 — 綁 distro 的只有 package manager 這一層">Package Manager 抽象層</a>）。落地時按形態各自處理：</p>
<ul>
<li><strong>套件名不同</strong>（<code>fd</code> → <code>fd-find</code>、<code>github-cli</code> → <code>gh</code>）：靠各平台清單逐項對照，不假設同名。</li>
<li><strong>binary 被改名</strong>（<code>fd-find</code> 的 binary 是 <code>fdfind</code>、<code>bat</code> 是 <code>batcat</code>）：<code>.zshrc</code> 按平台補 alias（<code>alias fd=fdfind</code>）——「套件裝好了」不等於「指令叫得動」。</li>
<li><strong>名字撞到別的工具</strong>（<code>apt install delta</code> 裝到的不是 <code>.gitconfig</code> 要的 git-delta）：別照抄，確認同名同物。</li>
</ul>
<p><code>ripgrep</code> 兩邊同名（binary 都是 <code>rg</code>）不用處理，<code>fd</code> / <code>bat</code> 就要按上面吸收。</p>
<h2 id="實作會遇到的狀況與排除">實作會遇到的狀況與排除</h2>
<p>跨到 Debian 實跑時，除了名字分歧，還有幾個操作面的狀況會擋住 bootstrap。先知道怎麼排除：</p>
<ul>
<li>
<p><strong>全新映像連 sudo/git 都沒有</strong>。乾淨的 Debian 映像預裝極簡，<code>install.sh</code> 要用到的 <code>sudo</code>、<code>git</code> 本身都缺。排除：bootstrap 第一步先 <code>apt-get install -y sudo git</code>，才有辦法 clone repo、跑後續。這是最小映像的預期形態，不是壞掉。</p>
</li>
<li>
<p><strong>清單裡一個沒打包的名字讓整批全滅</strong>。<code>apt-get install</code> 是一筆全有或全無的交易，清單塞一個 Debian 沒有的名字（實測 broot、zellij 在 bookworm 沒打包），apt 直接中止，同批存在的工具也一個都沒裝。排除：清單逐項對齊該 distro 實際有的套件，動手前用 <code>apt-get install -s &lt;pkg&gt;</code> 逐一 dry-run 篩掉解不開的。原理見 <a href="/blog/linux/dotfile/knowledge-cards/apt-transaction-atomicity/" data-link-title="apt 安裝的交易原子性" data-link-desc="apt-get install 一次帶多個套件、其中一個名字沒打包卻導致整批都沒裝時回來讀 — apt 為什麼全有或全無">apt 安裝的交易原子性</a>。</p>
</li>
<li>
<p><strong>某些工具 Debian 根本沒打包</strong>。broot、zellij、git-delta、lazygit、yazi 在 bookworm 預設 repo 都不存在（Arch 都有）。排除：把它們移出 apt 清單，改從 GitHub releases 裝——抓對應架構的預編譯檔（<code>curl -L -O &lt;release-url&gt;</code>）、解壓、<code>chmod +x</code>、<code>mv</code> 到 <code>~/.local/bin</code>（確認它在 <code>PATH</code>）；若走 <code>cargo install</code>，cargo 本身要先用 rustup 裝（裸 Debian 沒有）。原理見 <a href="/blog/linux/dotfile/knowledge-cards/distro-package-granularity/" data-link-title="發行版打包粒度" data-link-desc="同一個 install 指令在不同發行版拉的套件數差一個量級、或某工具在某發行版根本沒打包時回來讀 — 發行版怎麼切套件顆粒度">發行版打包粒度</a>。</p>
</li>
<li>
<p><strong>裝個 node 拉進幾百個系統套件</strong>。<code>apt install nodejs npm</code> 在 Debian 會連帶 300 多個 <code>node-*</code> 套件（實測 bookworm 一次裝進 402 個新套件、其中 336 個是 node-*）。要避免的是拿系統套件管理器裝語言執行環境。排除：node / python / ruby 這類語言生態走 version manager（fnm / pyenv）在家目錄管，別放進 apt 清單。原理見 <a href="/blog/linux/dotfile/knowledge-cards/distro-package-granularity/" data-link-title="發行版打包粒度" data-link-desc="同一個 install 指令在不同發行版拉的套件數差一個量級、或某工具在某發行版根本沒打包時回來讀 — 發行版怎麼切套件顆粒度">發行版打包粒度</a>。</p>
</li>
</ul>
<h2 id="邊界與下一步">邊界與下一步</h2>
<p>桌面層（Hyprland 的 <a href="/blog/linux/dotfile/knowledge-cards/rice/" data-link-title="Rice（桌面視覺客製化）" data-link-desc="Linux 桌面文章裡看到 rice / ricing / ricer 不確定意思時回來讀">rice</a>，即桌面視覺客製化）的跨 distro 成本遠高於 CLI 層：Hyprland 在 Debian 要較新版才有打包，套件名與可用性都要重驗，所以工作站跨 distro 通常只做到 terminal 層，桌面留在實測過的 Arch。</p>
<p>工作站跨好之後，開發還缺一個對齊 client 線上的 runtime——那是下一篇 <a href="/blog/linux/dotfile/10-prod-parity/prod-parity-runtime/" data-link-title="對齊 prod 的 runtime container" data-link-desc="要開發一個線上跑 PHP 7.2 / MySQL 5.7 舊環境的專案、或要在本機重現線上事故時回來讀 — 對齊哪些維度、怎麼從線上抄設定、什麼時候值得">對齊 prod 的 runtime container</a>。工作站是你的機器、可以最新；runtime 要退回線上那個凍結的舊形狀，兩者對齊的目標相反。</p>
]]></content:encoded></item><item><title>對齊 prod 的 runtime container</title><link>https://tarrragon.github.io/blog/linux/dotfile/10-prod-parity/prod-parity-runtime/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/linux/dotfile/10-prod-parity/prod-parity-runtime/</guid><description>&lt;p>這篇的目的是建一個跟 client 線上逐項對齊的 runtime，讓「本機能跑」直接等於「線上能跑」。做法的核心是刻意把環境退回線上那個凍結的舊形狀，而非維持最新最乾淨——為什麼方向跟直覺相反、要對齊哪些維度，見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/knowledge-cards/prod-parity-principle/" data-link-title="Prod Parity Principle（生產環境對等原則）" data-link-desc="要建一個對齊 client 線上環境的本機 runtime、不知道該對齊到多細時回來讀 — parity 對齊的是凍結舊環境而非最新版">Prod Parity 原則&lt;/a>。Dockerfile 與 compose 本身怎麼運作（指令、layer、多 service 編排），見 Docker vendor 的 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/vendors/docker/dockerfile-design/" data-link-title="Dockerfile 設計：指令、layer 與 multi-stage" data-link-desc="image 大得離譜、code 一改就整包重 build、或 multi-stage 複製 binary 後 container 起不來說找不到 library 時回來讀 — Dockerfile 指令怎麼變成 layer、build 與 runtime 怎麼分離">Dockerfile 設計&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/vendors/docker/docker-compose/" data-link-title="Docker Compose：多 service dev 環境編排" data-link-desc="一個 app 要好幾個 container(DB / cache / web)、手動 docker run 串不起來、或 compose 起來後 app 連不到 DB 或 DB 還沒 ready 就被連時回來讀 — 多 service 怎麼宣告式編排">Docker Compose&lt;/a>。這裡只講怎麼判讀跟一個實測過的基線。&lt;/p>
&lt;h2 id="對齊的載體是-image-tag-跟-config不是主機">對齊的載體是 image tag 跟 config，不是主機&lt;/h2>
&lt;p>Parity 不靠「把主機裝成跟 prod 一樣」達成——你的主機是 Arch 或 macOS 都無所謂。對齊的載體是 container 的 image tag 跟掛進去的 config：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>image tag 釘到 OS 世代&lt;/strong>：用 &lt;code>php:7.2-fpm-buster&lt;/code> 而不是 &lt;code>php:7.2-fpm&lt;/code>，把 PHP 版本連同底層 Debian 世代一起凍結（見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/knowledge-cards/image-tag-pinning/" data-link-title="Image Tag Pinning" data-link-desc="本機跟線上跑同一份 code 卻行為不一致、或 image 隔幾週重 build 就變樣時回來讀 — 為什麼 tag 要釘到 OS 世代">Image Tag Pinning&lt;/a>）。&lt;/li>
&lt;li>&lt;strong>別為了省體積換 libc&lt;/strong>：prod 是 Debian（glibc）就別用 &lt;code>php:7.2-alpine&lt;/code>（musl），DNS 與原生擴充行為會分岔（見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/knowledge-cards/glibc-vs-musl/" data-link-title="glibc 與 musl" data-link-desc="考慮用 alpine image 縮小體積、或 PHP/Python 擴充在容器裡行為跟線上不同時回來讀 — 兩種 libc 的差異與怎麼選">glibc 與 musl&lt;/a>）。&lt;/li>
&lt;li>&lt;strong>服務設定抄 prod&lt;/strong>：MySQL 的 &lt;code>sql_mode&lt;/code>、時區，PHP 的擴充清單與 &lt;code>php.ini&lt;/code>，都從線上的權威來源抄、不憑印象填。SSH 進 prod 後用 &lt;code>mysql -N -e &amp;quot;SELECT @@sql_mode&amp;quot;&lt;/code> 抓 sql_mode、&lt;code>php -m&lt;/code> 列擴充、&lt;code>php -i&lt;/code>（或 web 端存一支 &lt;code>&amp;lt;?php phpinfo();&lt;/code>）看 &lt;code>php.ini&lt;/code> 值；抄回來分別填進 db 的 &lt;code>my.cnf&lt;/code>（&lt;code>sql_mode&lt;/code> / 時區）、Dockerfile 的 &lt;code>docker-php-ext-install&lt;/code> 清單（對照 &lt;code>php -m&lt;/code>）、掛進去的 &lt;code>php.ini&lt;/code>。&lt;/li>
&lt;/ul>
&lt;p>主機不變、只有這幾樣對齊——這是 parity 能在任何工作站上重現的原因。&lt;/p>
&lt;h2 id="要逐項比對的維度">要逐項比對的維度&lt;/h2>
&lt;p>跑起來後，用一個 probe 印出 runtime 的每個 parity 維度，跟 prod 逐行比對。每一行對應一個會影響行為的維度：&lt;/p>
&lt;ul>
&lt;li>PHP 主版 + patch 版&lt;/li>
&lt;li>時區（PHP 與 MySQL 各一份，跨時區的 &lt;code>NOW()&lt;/code> 是常見隱形 bug）&lt;/li>
&lt;li>擴充清單（要相等、不是涵蓋；多裝的擴充讓本機能跑、prod 掛掉）&lt;/li>
&lt;li>MySQL 版本與 &lt;code>sql_mode&lt;/code>（5.7 預設開 &lt;code>ONLY_FULL_GROUP_BY&lt;/code>，舊 app 的 &lt;code>SELECT ... GROUP BY&lt;/code> 未列全欄位會回 error 1055）&lt;/li>
&lt;/ul>
&lt;h2 id="實測基線">實測基線&lt;/h2>
&lt;p>dotfiles repo 的 &lt;code>runtimes/php72-mysql57/&lt;/code> 是一個跑得起來的 variant（PHP-FPM + MySQL 5.7 + nginx，docker-compose 組合）。&lt;code>docker compose up -d --build&lt;/code> 起來後 &lt;code>curl http://localhost:8080/&lt;/code>——probe 是一支 &lt;code>src/index.php&lt;/code>、由 nginx 服務，逐行印出每個 parity 維度。arm64 macOS 實測（2026-07）輸出：&lt;/p></description><content:encoded><![CDATA[<p>這篇的目的是建一個跟 client 線上逐項對齊的 runtime，讓「本機能跑」直接等於「線上能跑」。做法的核心是刻意把環境退回線上那個凍結的舊形狀，而非維持最新最乾淨——為什麼方向跟直覺相反、要對齊哪些維度，見 <a href="/blog/linux/dotfile/knowledge-cards/prod-parity-principle/" data-link-title="Prod Parity Principle（生產環境對等原則）" data-link-desc="要建一個對齊 client 線上環境的本機 runtime、不知道該對齊到多細時回來讀 — parity 對齊的是凍結舊環境而非最新版">Prod Parity 原則</a>。Dockerfile 與 compose 本身怎麼運作（指令、layer、多 service 編排），見 Docker vendor 的 <a href="/blog/backend/05-deployment-platform/vendors/docker/dockerfile-design/" data-link-title="Dockerfile 設計：指令、layer 與 multi-stage" data-link-desc="image 大得離譜、code 一改就整包重 build、或 multi-stage 複製 binary 後 container 起不來說找不到 library 時回來讀 — Dockerfile 指令怎麼變成 layer、build 與 runtime 怎麼分離">Dockerfile 設計</a> 與 <a href="/blog/backend/05-deployment-platform/vendors/docker/docker-compose/" data-link-title="Docker Compose：多 service dev 環境編排" data-link-desc="一個 app 要好幾個 container(DB / cache / web)、手動 docker run 串不起來、或 compose 起來後 app 連不到 DB 或 DB 還沒 ready 就被連時回來讀 — 多 service 怎麼宣告式編排">Docker Compose</a>。這裡只講怎麼判讀跟一個實測過的基線。</p>
<h2 id="對齊的載體是-image-tag-跟-config不是主機">對齊的載體是 image tag 跟 config，不是主機</h2>
<p>Parity 不靠「把主機裝成跟 prod 一樣」達成——你的主機是 Arch 或 macOS 都無所謂。對齊的載體是 container 的 image tag 跟掛進去的 config：</p>
<ul>
<li><strong>image tag 釘到 OS 世代</strong>：用 <code>php:7.2-fpm-buster</code> 而不是 <code>php:7.2-fpm</code>，把 PHP 版本連同底層 Debian 世代一起凍結（見 <a href="/blog/linux/dotfile/knowledge-cards/image-tag-pinning/" data-link-title="Image Tag Pinning" data-link-desc="本機跟線上跑同一份 code 卻行為不一致、或 image 隔幾週重 build 就變樣時回來讀 — 為什麼 tag 要釘到 OS 世代">Image Tag Pinning</a>）。</li>
<li><strong>別為了省體積換 libc</strong>：prod 是 Debian（glibc）就別用 <code>php:7.2-alpine</code>（musl），DNS 與原生擴充行為會分岔（見 <a href="/blog/linux/dotfile/knowledge-cards/glibc-vs-musl/" data-link-title="glibc 與 musl" data-link-desc="考慮用 alpine image 縮小體積、或 PHP/Python 擴充在容器裡行為跟線上不同時回來讀 — 兩種 libc 的差異與怎麼選">glibc 與 musl</a>）。</li>
<li><strong>服務設定抄 prod</strong>：MySQL 的 <code>sql_mode</code>、時區，PHP 的擴充清單與 <code>php.ini</code>，都從線上的權威來源抄、不憑印象填。SSH 進 prod 後用 <code>mysql -N -e &quot;SELECT @@sql_mode&quot;</code> 抓 sql_mode、<code>php -m</code> 列擴充、<code>php -i</code>（或 web 端存一支 <code>&lt;?php phpinfo();</code>）看 <code>php.ini</code> 值；抄回來分別填進 db 的 <code>my.cnf</code>（<code>sql_mode</code> / 時區）、Dockerfile 的 <code>docker-php-ext-install</code> 清單（對照 <code>php -m</code>）、掛進去的 <code>php.ini</code>。</li>
</ul>
<p>主機不變、只有這幾樣對齊——這是 parity 能在任何工作站上重現的原因。</p>
<h2 id="要逐項比對的維度">要逐項比對的維度</h2>
<p>跑起來後，用一個 probe 印出 runtime 的每個 parity 維度，跟 prod 逐行比對。每一行對應一個會影響行為的維度：</p>
<ul>
<li>PHP 主版 + patch 版</li>
<li>時區（PHP 與 MySQL 各一份，跨時區的 <code>NOW()</code> 是常見隱形 bug）</li>
<li>擴充清單（要相等、不是涵蓋；多裝的擴充讓本機能跑、prod 掛掉）</li>
<li>MySQL 版本與 <code>sql_mode</code>（5.7 預設開 <code>ONLY_FULL_GROUP_BY</code>，舊 app 的 <code>SELECT ... GROUP BY</code> 未列全欄位會回 error 1055）</li>
</ul>
<h2 id="實測基線">實測基線</h2>
<p>dotfiles repo 的 <code>runtimes/php72-mysql57/</code> 是一個跑得起來的 variant（PHP-FPM + MySQL 5.7 + nginx，docker-compose 組合）。<code>docker compose up -d --build</code> 起來後 <code>curl http://localhost:8080/</code>——probe 是一支 <code>src/index.php</code>、由 nginx 服務，逐行印出每個 parity 維度。arm64 macOS 實測（2026-07）輸出：</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">PHP version : 7.2.34
</span></span><span class="line"><span class="ln">2</span><span class="cl">timezone    : Asia/Taipei
</span></span><span class="line"><span class="ln">3</span><span class="cl">MySQL ver   : 5.7.44
</span></span><span class="line"><span class="ln">4</span><span class="cl">sql_mode    : ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,...
</span></span><span class="line"><span class="ln">5</span><span class="cl">db timezone : +08:00</span></span></code></pre></div><p>版本、時區、<code>sql_mode</code> 都跟 config 逐項對齊——這就是 parity 達成的樣子。</p>
<h2 id="凍結舊環境的兩個稅">凍結舊環境的兩個稅</h2>
<p>實跑會撞到兩個「照官方 docs 寫會 build 不起來、只有實機才知道」的問題，兩個都是凍結舊環境特有的：</p>
<ul>
<li><strong>Debian buster 已 EOL</strong>：套件庫從主 mirror 移到 <code>archive.debian.org</code>，Dockerfile 裡不改 apt source 直接 <code>apt-get update</code> 就 404（exit 100）。要改指 archive 並關掉過期檢查。</li>
<li><strong>mysql:5.7 無 arm64 原生 image</strong>：在 Apple Silicon 主機上要 <code>platform: linux/amd64</code> 走模擬（機制見 <a href="/blog/backend/knowledge-cards/qemu-binfmt-emulation/" data-link-title="QEMU binfmt Emulation（跨架構模擬）" data-link-desc="docker 跑非原生架構的 image 報 exec format error、mysql:5.7 在 Apple Silicon 要 platform: linux/amd64、或跨平台 build 特別慢時回來讀 — 非原生 image 怎麼被模擬跑起來">QEMU binfmt 跨架構模擬</a>）。這本身也是一種 parity——dev 是模擬 amd64、prod 是原生 amd64，架構對齊。</li>
</ul>
<p>這兩個稅是 <a href="/blog/linux/dotfile/knowledge-cards/prod-parity-principle/" data-link-title="Prod Parity Principle（生產環境對等原則）" data-link-desc="要建一個對齊 client 線上環境的本機 runtime、不知道該對齊到多細時回來讀 — parity 對齊的是凍結舊環境而非最新版">Prod Parity 原則</a> 講的「對齊凍結環境要付的代價」的具體形態：凍結舊版意味著也繼承了它 EOL 與架構支援退場的後果。</p>
<h2 id="什麼時候值得做到逐項">什麼時候值得做到逐項</h2>
<p>Parity 是有成本的紀律，不是每個專案都要做到逐項——值得與可放寬的完整判準見 <a href="/blog/linux/dotfile/knowledge-cards/prod-parity-principle/" data-link-title="Prod Parity Principle（生產環境對等原則）" data-link-desc="要建一個對齊 client 線上環境的本機 runtime、不知道該對齊到多細時回來讀 — parity 對齊的是凍結舊環境而非最新版">Prod Parity 原則</a> 的判讀訊號段。這篇的 PHP 7.2 場景屬「值得」那一類：有原生擴充、MySQL 嚴格模式、時區邏輯都在，本機不對齊就會在線上才發現行為不同。</p>
<h2 id="下一步">下一步</h2>
<p>runtime 對齊好之後，你會想在 container 裡用順手的 shell 跟 editor——但那不能直接塞進這個 image，否則 image 就不再等於 prod。怎麼把 ergonomics 帶進來又不污染 parity，見 <a href="/blog/linux/dotfile/10-prod-parity/container-ergonomics/" data-link-title="dotfile 跨進 runtime container" data-link-desc="想在對齊 prod 的 container 裡用自己順手的 shell / vim、又不想把工具裝進 image 破壞 parity 時回來讀 — 在 running container 裝開發工具、跟 runtime 分層">dotfile 跨進 runtime container</a>。</p>
]]></content:encoded></item><item><title>dotfile 跨進 runtime container</title><link>https://tarrragon.github.io/blog/linux/dotfile/10-prod-parity/container-ergonomics/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/linux/dotfile/10-prod-parity/container-ergonomics/</guid><description>&lt;p>有了對齊 prod 的 runtime，你會想在裡面用順手的 shell 跟 vim——但直接把這些裝進 image，它就不再跟線上逐項相同（parity 破功）。這篇把開發 ergonomics（shell、vim、git config）帶進 container、同時保住 parity，做法是把「這個 image 跟線上一不一樣」跟「我用得順不順手」當成兩層分開處理。&lt;/p>
&lt;h2 id="ergonomics-進可寫層不進-image">ergonomics 進可寫層，不進 image&lt;/h2>
&lt;p>最容易犯的錯是把 zsh、vim、個人 config 寫進 runtime 的 Dockerfile。一旦 ergonomics 進了 image，這個 image 就不再等於 prod——線上不會有你的 oh-my-zsh，parity 當場破功。判準很硬：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>runtime image 只裝 app 需要、prod 也有的套件&lt;/strong>（見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/10-prod-parity/prod-parity-runtime/" data-link-title="對齊 prod 的 runtime container" data-link-desc="要開發一個線上跑 PHP 7.2 / MySQL 5.7 舊環境的專案、或要在本機重現線上事故時回來讀 — 對齊哪些維度、怎麼從線上抄設定、什麼時候值得">對齊 prod 的 runtime container&lt;/a>）。&lt;/li>
&lt;li>&lt;strong>ergonomics 裝進「已啟動的 container」&lt;/strong>：&lt;code>docker compose exec &amp;lt;service&amp;gt; bash&lt;/code> 進去後才裝，或 &lt;code>docker compose cp&lt;/code> 把檔案送進去（已啟動的 container 無法事後 mount，掛載只能在 compose / &lt;code>docker run&lt;/code> 當下宣告）。它活在容器的可寫層、不進 image。&lt;/li>
&lt;/ul>
&lt;p>這條分層讓同一個 runtime image 既是「線上的忠實複製」又能「開發時很好用」，兩個需求不打架。&lt;/p>
&lt;h2 id="ergonomics-層自己也要可攜">ergonomics 層自己也要可攜&lt;/h2>
&lt;p>runtime 的 base 是 Debian，但你的 dotfile 是照 Arch 或 macOS 寫的。ergonomics 安裝腳本要能在 Debian container 裡跑，靠的是 package manager detection——同一支腳本用 &lt;code>command -v apt-get / pacman / apk&lt;/code> 分支，適配當下所在的環境。原理見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/knowledge-cards/package-manager-abstraction/" data-link-title="Package Manager 抽象層" data-link-desc="同一份 dotfile 要從 Arch 跨到 Debian/macOS、不確定哪些要改哪些能共用時回來讀 — 綁 distro 的只有 package manager 這一層">Package Manager 抽象層&lt;/a>。&lt;/p>
&lt;p>跟工作站的入口 detection 不同，container 內只裝少數幾個工具、不值得拆多檔，適合行內 detection：一支腳本內分支、共用同一份工具清單。dotfiles repo 的 &lt;code>runtimes/php72-mysql57/ergonomics/setup.sh&lt;/code> 就是這個形態。&lt;/p>
&lt;h2 id="落地形態">落地形態&lt;/h2>
&lt;p>裝套件那一步分 distro，config 部署完全共通：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>裝工具&lt;/strong>：把 &lt;code>setup.sh&lt;/code> 送進已啟動的 container 再跑——&lt;code>docker compose cp ergonomics/setup.sh php:/tmp/setup.sh &amp;amp;&amp;amp; docker compose exec php bash /tmp/setup.sh&lt;/code>，它偵測 package manager 裝 zsh/git/vim。&lt;/li>
&lt;li>&lt;strong>部署 config&lt;/strong>：dotfiles repo 送進去再 stow——&lt;code>docker compose cp ~/dotfiles php:/root/dotfiles &amp;amp;&amp;amp; docker compose exec php bash -c 'cd /root/dotfiles &amp;amp;&amp;amp; stow zsh git vim'&lt;/code>。config 本身可攜、不分 distro（見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/knowledge-cards/gnu-stow/" data-link-title="GNU Stow" data-link-desc="dotfile 管理文章裡提到 stow、symlink、package 看不懂時回來讀 — stow 的核心概念和常用指令">GNU Stow&lt;/a>）。&lt;/li>
&lt;/ul>
&lt;p>這樣 config 只維護一份，跟工作站共用；container 內只多了「裝套件」那一層 distro 分支。&lt;/p></description><content:encoded><![CDATA[<p>有了對齊 prod 的 runtime，你會想在裡面用順手的 shell 跟 vim——但直接把這些裝進 image，它就不再跟線上逐項相同（parity 破功）。這篇把開發 ergonomics（shell、vim、git config）帶進 container、同時保住 parity，做法是把「這個 image 跟線上一不一樣」跟「我用得順不順手」當成兩層分開處理。</p>
<h2 id="ergonomics-進可寫層不進-image">ergonomics 進可寫層，不進 image</h2>
<p>最容易犯的錯是把 zsh、vim、個人 config 寫進 runtime 的 Dockerfile。一旦 ergonomics 進了 image，這個 image 就不再等於 prod——線上不會有你的 oh-my-zsh，parity 當場破功。判準很硬：</p>
<ul>
<li><strong>runtime image 只裝 app 需要、prod 也有的套件</strong>（見 <a href="/blog/linux/dotfile/10-prod-parity/prod-parity-runtime/" data-link-title="對齊 prod 的 runtime container" data-link-desc="要開發一個線上跑 PHP 7.2 / MySQL 5.7 舊環境的專案、或要在本機重現線上事故時回來讀 — 對齊哪些維度、怎麼從線上抄設定、什麼時候值得">對齊 prod 的 runtime container</a>）。</li>
<li><strong>ergonomics 裝進「已啟動的 container」</strong>：<code>docker compose exec &lt;service&gt; bash</code> 進去後才裝，或 <code>docker compose cp</code> 把檔案送進去（已啟動的 container 無法事後 mount，掛載只能在 compose / <code>docker run</code> 當下宣告）。它活在容器的可寫層、不進 image。</li>
</ul>
<p>這條分層讓同一個 runtime image 既是「線上的忠實複製」又能「開發時很好用」，兩個需求不打架。</p>
<h2 id="ergonomics-層自己也要可攜">ergonomics 層自己也要可攜</h2>
<p>runtime 的 base 是 Debian，但你的 dotfile 是照 Arch 或 macOS 寫的。ergonomics 安裝腳本要能在 Debian container 裡跑，靠的是 package manager detection——同一支腳本用 <code>command -v apt-get / pacman / apk</code> 分支，適配當下所在的環境。原理見 <a href="/blog/linux/dotfile/knowledge-cards/package-manager-abstraction/" data-link-title="Package Manager 抽象層" data-link-desc="同一份 dotfile 要從 Arch 跨到 Debian/macOS、不確定哪些要改哪些能共用時回來讀 — 綁 distro 的只有 package manager 這一層">Package Manager 抽象層</a>。</p>
<p>跟工作站的入口 detection 不同，container 內只裝少數幾個工具、不值得拆多檔，適合行內 detection：一支腳本內分支、共用同一份工具清單。dotfiles repo 的 <code>runtimes/php72-mysql57/ergonomics/setup.sh</code> 就是這個形態。</p>
<h2 id="落地形態">落地形態</h2>
<p>裝套件那一步分 distro，config 部署完全共通：</p>
<ul>
<li><strong>裝工具</strong>：把 <code>setup.sh</code> 送進已啟動的 container 再跑——<code>docker compose cp ergonomics/setup.sh php:/tmp/setup.sh &amp;&amp; docker compose exec php bash /tmp/setup.sh</code>，它偵測 package manager 裝 zsh/git/vim。</li>
<li><strong>部署 config</strong>：dotfiles repo 送進去再 stow——<code>docker compose cp ~/dotfiles php:/root/dotfiles &amp;&amp; docker compose exec php bash -c 'cd /root/dotfiles &amp;&amp; stow zsh git vim'</code>。config 本身可攜、不分 distro（見 <a href="/blog/linux/dotfile/knowledge-cards/gnu-stow/" data-link-title="GNU Stow" data-link-desc="dotfile 管理文章裡提到 stow、symlink、package 看不懂時回來讀 — stow 的核心概念和常用指令">GNU Stow</a>）。</li>
</ul>
<p>這樣 config 只維護一份，跟工作站共用；container 內只多了「裝套件」那一層 distro 分支。</p>
<h2 id="邊界">邊界</h2>
<p>ergonomics 進可寫層的代價是「不持久」：container 重建就沒了，每次 <code>docker exec</code> 重來或寫成啟動時自動跑的腳本。這是刻意的取捨——為了保住 image 的 parity，換來 ergonomics 每次重建要重裝。想持久化 ergonomics 又不碰 runtime image，正解是走 devcontainer 那套（見 <a href="/blog/linux/dotfile/09-team-environment/devcontainer-nix/" data-link-title="Devcontainer 與 Nix：容器化和宣告式的開發環境" data-link-desc="團隊開發環境要標準化、或評估 devcontainer 和 nix 跟個人 dotfile 怎麼共存時回來讀">模組九：從個人到團隊</a>），它把開發環境跟 runtime image 明確拆成兩個 image。</p>
]]></content:encoded></item></channel></rss>