<?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>Dockerfile on Tarragon</title><link>https://tarrragon.github.io/blog/tags/dockerfile/</link><description>Recent content in Dockerfile 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/tags/dockerfile/index.xml" rel="self" type="application/rss+xml"/><item><title>Dockerfile 設計：指令、layer 與 multi-stage</title><link>https://tarrragon.github.io/blog/backend/05-deployment-platform/vendors/docker/dockerfile-design/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/05-deployment-platform/vendors/docker/dockerfile-design/</guid><description>&lt;p>這篇假設你已經知道 Docker 是什麼、為什麼要用它（&lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/vendors/docker/" data-link-title="Docker" data-link-desc="Container runtime / image 標準">Docker vendor overview&lt;/a>），往下寫 Dockerfile 本身怎麼設計。目標是讓你看得懂每個指令會變成什麼、build 慢或 image 大時知道從哪查。&lt;/p>
&lt;h2 id="問題情境三個不懂-layer的症狀">問題情境：三個「不懂 layer」的症狀&lt;/h2>
&lt;p>建一個對齊線上的 PHP runtime，第一版 Dockerfile 常長成這樣，然後撞上三個症狀：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>image 五百 MB 起跳&lt;/strong>：明明只跑一個 PHP 程式，image 卻塞滿編譯工具、apt 快取、中間產物。&lt;/li>
&lt;li>&lt;strong>改一行 code 就整包重 build&lt;/strong>：每次 &lt;code>docker build&lt;/code> 都從頭跑一次 &lt;code>apt install&lt;/code>，明明套件根本沒動。&lt;/li>
&lt;li>&lt;strong>multi-stage 複製 binary 後 container 起不來&lt;/strong>：&lt;code>COPY --from=build&lt;/code> 把執行檔搬到精簡的 runtime image，跑起來卻報 &lt;code>Error loading shared library&lt;/code>。&lt;/li>
&lt;/ul>
&lt;p>三個症狀的共同根因是同一件事：不清楚 Dockerfile 的每個指令會變成什麼、build cache 以什麼為單位。把這個模型建起來，三個症狀都能對症。&lt;/p>
&lt;h2 id="核心概念每個指令是一層唯讀-layer">核心概念：每個指令是一層唯讀 layer&lt;/h2>
&lt;p>Dockerfile 的每個指令產生一層唯讀的 image layer，layer 由上往下疊成最終 image。這個模型解釋了 Docker 幾乎所有 build 行為：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>image 是 layer 的疊加&lt;/strong>：&lt;code>FROM&lt;/code> 給你底層 layer，之後每個 &lt;code>RUN&lt;/code> / &lt;code>COPY&lt;/code> / &lt;code>ADD&lt;/code> 各加一層，只記錄「這層相對上一層改了什麼」。&lt;/li>
&lt;li>&lt;strong>build cache 以 layer 為單位&lt;/strong>：build 時 Docker 逐層檢查「這個指令跟它的輸入有沒有變」，沒變就直接用快取的那層、跳過執行。&lt;/li>
&lt;li>&lt;strong>一層變，它與之後全部要重建&lt;/strong>：某層的指令或輸入變了，那層以下所有 layer 的快取全部失效、必須重跑。layer 的&lt;strong>順序&lt;/strong>因此直接決定 build 快不快。&lt;/li>
&lt;/ul>
&lt;p>記住「指令 = layer、cache 以 layer 為單位、一層變則後面全垮」這三句，後面的設計原則都是它的推論。&lt;/p>
&lt;h2 id="配置逐指令怎麼寫">配置：逐指令怎麼寫&lt;/h2>
&lt;p>以下用一個版本凍結的 PHP runtime 為例（實際跑過的形態），逐指令說明。&lt;/p>
&lt;h3 id="from起點與凍結">FROM：起點與凍結&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dockerfile" data-lang="dockerfile">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">FROM&lt;/span>&lt;span class="s"> php:7.2-fpm-buster&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>FROM&lt;/code> 指定 base image，是整個 image 的地基。tag 要釘到夠精確——&lt;code>php:7.2-fpm-buster&lt;/code> 把 PHP 版本連同底層 Debian 世代一起凍結，而不是用會漂的 &lt;code>php:7.2-fpm&lt;/code>。為什麼 tag 精確度是可重現性的關鍵，見 &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;/p>
&lt;h3 id="run每個-run-一層合併與清理要同層">RUN：每個 RUN 一層，合併與清理要同層&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dockerfile" data-lang="dockerfile">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">RUN&lt;/span> apt-get update &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> apt-get install -y --no-install-recommends libzip-dev libpng-dev &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> docker-php-ext-install pdo_mysql mysqli gd zip &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> rm -rf /var/lib/apt/lists/*&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個 &lt;code>RUN&lt;/code> 產生一層。上面把 update、install、清理用 &lt;code>&amp;amp;&amp;amp;&lt;/code> 串在&lt;strong>同一個&lt;/strong> &lt;code>RUN&lt;/code> 裡是刻意的：如果拆成三個 &lt;code>RUN&lt;/code>，&lt;code>apt-get update&lt;/code> 抓下來的 index、以及安裝產生的快取，會留在中間層裡——就算最後一層 &lt;code>rm&lt;/code> 掉，前面層已經記錄了那些檔案，image 照樣變大。layer 是疊加的，後面的層刪不掉前面層已經寫進去的東西。清理必須跟產生它的指令同層。&lt;/p>
&lt;h3 id="copy放在變動頻率的正確位置">COPY：放在變動頻率的正確位置&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dockerfile" data-lang="dockerfile">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">COPY&lt;/span> composer.json composer.lock ./&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">RUN&lt;/span> composer install --no-dev&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="err">&lt;/span>&lt;span class="k">COPY&lt;/span> . .&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>COPY&lt;/code> 把 build context 的檔案加一層進 image。這裡的順序是 build 快不快的關鍵：先只 &lt;code>COPY&lt;/code> 依賴清單、裝完依賴，再 &lt;code>COPY&lt;/code> 全部原始碼。因為原始碼幾乎每次都變、依賴清單很少變——把「常變的」放在「少變的」後面，改 code 時依賴那層的 cache 還在，不用重裝。順序反過來（先 &lt;code>COPY . .&lt;/code> 再裝依賴）就是前面「改一行 code 整包重 build」的直接成因。&lt;/p></description><content:encoded><![CDATA[<p>這篇假設你已經知道 Docker 是什麼、為什麼要用它（<a href="/blog/backend/05-deployment-platform/vendors/docker/" data-link-title="Docker" data-link-desc="Container runtime / image 標準">Docker vendor overview</a>），往下寫 Dockerfile 本身怎麼設計。目標是讓你看得懂每個指令會變成什麼、build 慢或 image 大時知道從哪查。</p>
<h2 id="問題情境三個不懂-layer的症狀">問題情境：三個「不懂 layer」的症狀</h2>
<p>建一個對齊線上的 PHP runtime，第一版 Dockerfile 常長成這樣，然後撞上三個症狀：</p>
<ul>
<li><strong>image 五百 MB 起跳</strong>：明明只跑一個 PHP 程式，image 卻塞滿編譯工具、apt 快取、中間產物。</li>
<li><strong>改一行 code 就整包重 build</strong>：每次 <code>docker build</code> 都從頭跑一次 <code>apt install</code>，明明套件根本沒動。</li>
<li><strong>multi-stage 複製 binary 後 container 起不來</strong>：<code>COPY --from=build</code> 把執行檔搬到精簡的 runtime image，跑起來卻報 <code>Error loading shared library</code>。</li>
</ul>
<p>三個症狀的共同根因是同一件事：不清楚 Dockerfile 的每個指令會變成什麼、build cache 以什麼為單位。把這個模型建起來，三個症狀都能對症。</p>
<h2 id="核心概念每個指令是一層唯讀-layer">核心概念：每個指令是一層唯讀 layer</h2>
<p>Dockerfile 的每個指令產生一層唯讀的 image layer，layer 由上往下疊成最終 image。這個模型解釋了 Docker 幾乎所有 build 行為：</p>
<ul>
<li><strong>image 是 layer 的疊加</strong>：<code>FROM</code> 給你底層 layer，之後每個 <code>RUN</code> / <code>COPY</code> / <code>ADD</code> 各加一層，只記錄「這層相對上一層改了什麼」。</li>
<li><strong>build cache 以 layer 為單位</strong>：build 時 Docker 逐層檢查「這個指令跟它的輸入有沒有變」，沒變就直接用快取的那層、跳過執行。</li>
<li><strong>一層變，它與之後全部要重建</strong>：某層的指令或輸入變了，那層以下所有 layer 的快取全部失效、必須重跑。layer 的<strong>順序</strong>因此直接決定 build 快不快。</li>
</ul>
<p>記住「指令 = layer、cache 以 layer 為單位、一層變則後面全垮」這三句，後面的設計原則都是它的推論。</p>
<h2 id="配置逐指令怎麼寫">配置：逐指令怎麼寫</h2>
<p>以下用一個版本凍結的 PHP runtime 為例（實際跑過的形態），逐指令說明。</p>
<h3 id="from起點與凍結">FROM：起點與凍結</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">FROM</span><span class="s"> php:7.2-fpm-buster</span></span></span></code></pre></div><p><code>FROM</code> 指定 base image，是整個 image 的地基。tag 要釘到夠精確——<code>php:7.2-fpm-buster</code> 把 PHP 版本連同底層 Debian 世代一起凍結，而不是用會漂的 <code>php:7.2-fpm</code>。為什麼 tag 精確度是可重現性的關鍵，見 <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>。</p>
<h3 id="run每個-run-一層合併與清理要同層">RUN：每個 RUN 一層，合併與清理要同層</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">RUN</span> apt-get update <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>    <span class="o">&amp;&amp;</span> apt-get install -y --no-install-recommends libzip-dev libpng-dev <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>    <span class="o">&amp;&amp;</span> docker-php-ext-install pdo_mysql mysqli gd zip <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>    <span class="o">&amp;&amp;</span> rm -rf /var/lib/apt/lists/*</span></span></code></pre></div><p>每個 <code>RUN</code> 產生一層。上面把 update、install、清理用 <code>&amp;&amp;</code> 串在<strong>同一個</strong> <code>RUN</code> 裡是刻意的：如果拆成三個 <code>RUN</code>，<code>apt-get update</code> 抓下來的 index、以及安裝產生的快取，會留在中間層裡——就算最後一層 <code>rm</code> 掉，前面層已經記錄了那些檔案，image 照樣變大。layer 是疊加的，後面的層刪不掉前面層已經寫進去的東西。清理必須跟產生它的指令同層。</p>
<h3 id="copy放在變動頻率的正確位置">COPY：放在變動頻率的正確位置</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">COPY</span> composer.json composer.lock ./<span class="err">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="err"></span><span class="k">RUN</span> composer install --no-dev<span class="err">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="err"></span><span class="k">COPY</span> . .</span></span></code></pre></div><p><code>COPY</code> 把 build context 的檔案加一層進 image。這裡的順序是 build 快不快的關鍵：先只 <code>COPY</code> 依賴清單、裝完依賴，再 <code>COPY</code> 全部原始碼。因為原始碼幾乎每次都變、依賴清單很少變——把「常變的」放在「少變的」後面，改 code 時依賴那層的 cache 還在，不用重裝。順序反過來（先 <code>COPY . .</code> 再裝依賴）就是前面「改一行 code 整包重 build」的直接成因。</p>
<p><code>COPY</code> 跟 <code>ADD</code> 的差別：<code>ADD</code> 會多做「自動解壓 tar、支援 URL」兩件事，但那兩件事讓行為變得不透明。預設用 <code>COPY</code>，需要解壓時自己 <code>RUN tar</code> 講清楚。</p>
<h3 id="cmd-與-entrypoint預設命令與固定入口">CMD 與 ENTRYPOINT：預設命令與固定入口</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">ENTRYPOINT</span> <span class="p">[</span><span class="s2">&#34;php-fpm&#34;</span><span class="p">]</span><span class="err">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="err"></span><span class="k">CMD</span> <span class="p">[</span><span class="s2">&#34;--nodaemonize&#34;</span><span class="p">]</span></span></span></code></pre></div><p>兩者都定義 container 啟動跑什麼，但角色不同：<code>ENTRYPOINT</code> 是「這個 image 固定是幹嘛的」（固定入口），<code>CMD</code> 是「預設參數」（可被 <code>docker run</code> 後面的參數覆蓋）。上面的組合表示這個 image 就是跑 <code>php-fpm</code>、預設帶 <code>--nodaemonize</code>，但 <code>docker run image --version</code> 會把 <code>--version</code> 覆蓋掉 CMD。只寫 <code>CMD [&quot;php-fpm&quot;]</code> 也能跑，但 <code>docker run image bash</code> 會整個換掉命令——要不要讓人輕易換命令，決定你用哪個。</p>
<h3 id="multi-stagebuild-與-runtime-分離">multi-stage：build 與 runtime 分離</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">FROM</span><span class="s"> golang:1.22 AS build</span><span class="err">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="err"></span><span class="k">WORKDIR</span><span class="s"> /src</span><span class="err">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="err"></span><span class="k">COPY</span> . .<span class="err">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="err"></span><span class="k">RUN</span> go build -o /app ./cmd/server<span class="err">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="err"></span><span class="k">FROM</span><span class="s"> gcr.io/distroless/base-debian12</span><span class="err">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="err"></span><span class="k">COPY</span> --from<span class="o">=</span>build /app /app<span class="err">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="err"></span><span class="k">ENTRYPOINT</span> <span class="p">[</span><span class="s2">&#34;/app&#34;</span><span class="p">]</span></span></span></code></pre></div><p>multi-stage 用多個 <code>FROM</code> 切成幾個 stage，最終 image 只保留最後一個 stage。上面第一 stage 有整套 Go 編譯工具鏈（幾百 MB），第二 stage 是極精簡的 runtime，只用 <code>COPY --from=build</code> 把編譯出的單一 binary 搬過來。最終 image 不含編譯器、不含原始碼，只有跑得起來需要的東西。這是解決「image 五百 MB」的主要手段：把 build-time 才需要的東西留在被丟棄的 stage。</p>
<h2 id="故障演練從-build-失敗到-image-失控">故障演練：從 build 失敗到 image 失控</h2>
<p>deep article 的價值在這段。以下每個都是實跑撞到的。</p>
<h3 id="apt-update-在凍結舊-base-image-上-404">apt update 在凍結舊 base image 上 404</h3>
<p>用 <code>php:7.2-fpm-buster</code> 這種舊 base image build 時，<code>apt-get update</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">Err:5 http://deb.debian.org/debian buster Release
</span></span><span class="line"><span class="ln">2</span><span class="cl">  404  Not Found
</span></span><span class="line"><span class="ln">3</span><span class="cl">E: The repository &#39;http://deb.debian.org/debian buster Release&#39; does not have a Release file.</span></span></code></pre></div><p>徵兆是 build 停在第一個 <code>RUN</code> 的 <code>apt-get update</code>、exit code 100。根因是 Debian buster 已 EOL，套件庫從主 mirror 移到 <code>archive.debian.org</code>，原本的 mirror 路徑不存在了。修法是在 <code>RUN</code> 開頭改寫 apt source 指向 archive、並關掉過期檢查：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">RUN</span> <span class="nb">printf</span> <span class="s1">&#39;%s\n&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>        <span class="s1">&#39;deb http://archive.debian.org/debian buster main&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>        <span class="s1">&#39;deb http://archive.debian.org/debian-security buster/updates main&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>        &gt; /etc/apt/sources.list <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span>    <span class="o">&amp;&amp;</span> apt-get -o Acquire::Check-Valid-Until<span class="o">=</span><span class="nb">false</span> update <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>    <span class="o">&amp;&amp;</span> apt-get install -y --no-install-recommends libzip-dev <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>    <span class="o">&amp;&amp;</span> rm -rf /var/lib/apt/lists/*</span></span></code></pre></div><p>這是用退役 base image 的固有稅——預設 mirror 隨發行版過保而失效，任何釘在舊發行版世代的 image 都會遇到。</p>
<h3 id="multi-stage-複製-binary-卻漏了它的-library">multi-stage 複製 binary 卻漏了它的 library</h3>
<p>這是 multi-stage 最常見的失誤。把一個動態連結的 binary 從 build stage 複製到精簡 runtime stage，只 <code>COPY</code> 執行檔本身：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">FROM</span><span class="s"> alpine:3.19 AS build</span><span class="err">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="err"></span><span class="k">RUN</span> apk add --no-cache jq<span class="err">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="err"></span><span class="k">FROM</span><span class="s"> alpine:3.19</span><span class="err">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="err"></span><span class="k">COPY</span> --from<span class="o">=</span>build /usr/bin/jq /usr/bin/jq   <span class="c1"># 只複製 binary</span><span class="err">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="err"></span><span class="k">CMD</span> <span class="p">[</span><span class="s2">&#34;jq&#34;</span><span class="p">,</span> <span class="s2">&#34;--version&#34;</span><span class="p">]</span></span></span></code></pre></div><p>build 會<strong>成功</strong>，但 <code>docker run</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">Error loading shared library libonig.so.5: No such file or directory (needed by /usr/bin/jq)
</span></span><span class="line"><span class="ln">2</span><span class="cl">Error relocating /usr/bin/jq: onig_search: symbol not found</span></span></code></pre></div><p>關鍵在 build 會過、<code>docker run</code> 才炸：<code>jq</code> 動態連結到 <code>libonig.so.5</code>，而那個 <code>.so</code> 留在 build stage、沒跟著搬過來，runtime 才在載入時報「找不到 shared library」。把依賴庫一起帶上就解決：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">COPY</span> --from<span class="o">=</span>build /usr/bin/jq /usr/bin/jq<span class="err">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="err"></span><span class="k">COPY</span> --from<span class="o">=</span>build /usr/lib/libonig.so.5 /usr/lib/libonig.so.5</span></span></code></pre></div><p>另外兩種做法：讓 build stage 產出靜態連結的 binary（<code>CGO_ENABLED=0</code> 的 Go、musl 靜態編譯），就沒有 runtime library 依賴；或 runtime stage 不用 scratch / distroless-static，改用本來就自帶 libc 與常見庫的 base（debian-slim 或 <code>distroless/base</code>），用稍大的 image 換掉「逐一搬 <code>.so</code>」的麻煩。判讀哪些 <code>.so</code> 要帶，用 <code>ldd &lt;binary&gt;</code> 列出動態依賴。這個失誤的深層原因跟 <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> 相關——binary 對哪套 libc 連結，決定它在目標 image 找不找得到符號。</p>
<h3 id="layer-cache-明明該命中卻一直重跑">layer cache 明明該命中卻一直重跑</h3>
<p>改一行 code、<code>docker build</code> 卻從 <code>apt install</code> 開始整包重跑。用 <code>--progress=plain</code> 看是哪層失效：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">docker build --progress<span class="o">=</span>plain -t app .   <span class="c1"># 逐層輸出，看哪層開始不是 CACHED</span></span></span></code></pre></div><p>若看到 <code>COPY . .</code> 之後的每一層都重跑，根因通常是 <code>COPY . .</code> 放太前面——它一層把所有原始碼灌進來，任何 code 變動都讓這層及其後全部 cache 失效。修法見前面 COPY 段：依賴清單先 COPY、原始碼後 COPY。</p>
<h3 id="run-拆太多行層數爆炸">RUN 拆太多行，層數爆炸</h3>
<p>把每個指令拆成獨立 <code>RUN</code>（<code>RUN apt update</code>、<code>RUN apt install a</code>、<code>RUN apt install b</code>…）會產生一堆 layer，且中間層的殘留檔案清不掉。<code>docker history &lt;image&gt;</code> 看每層大小能直接看出哪些層在囤東西。修法是把邏輯相關、且需要一起清理的指令合併到同一個 <code>RUN</code>。</p>
<h2 id="容量什麼規模需要哪種手段">容量：什麼規模需要哪種手段</h2>
<p>image 設計的規模判讀，不是每個專案都要做到極致精簡：</p>
<ul>
<li><strong>單一 binary 服務（Go / Rust）</strong>：multi-stage + distroless 或 scratch，最終 image 可壓到十幾 MB。編譯型語言最吃這套。</li>
<li><strong>直譯型 runtime（PHP / Python / Node）</strong>：需要語言 runtime 在 image 裡，多階段收益較小，重點放在 base image 選擇（slim 版）與 layer 順序。</li>
<li><strong>base image 選型</strong>（沿 musl↔glibc、大小、可 debug 三軸權衡，常見幾類）：alpine（musl，最小但可能有相容性差異，見 <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>）、debian-slim / ubuntu（glibc，相容性穩、稍大）、Wolfi / Chainguard（glibc 但極小，瓦解「要小就得選 musl」的取捨）、distroless（無 shell、無套件管理器，攻擊面最小但難 debug）。</li>
</ul>
<p>判準用 <code>docker history &lt;image&gt;</code> 看每層大小定位肥的來源，再決定值不值得為它加 multi-stage。過早為一個內部工具 image 追求極致精簡是浪費。</p>
<p>寫好之後 build 成 image 並跑一次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">docker build -t app .        <span class="c1"># 依當前目錄的 Dockerfile build</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker run --rm app          <span class="c1"># 跑起來；-d 背景跑、-p 對 port</span></span></span></code></pre></div><h2 id="整合與下一步">整合與下一步</h2>
<ul>
<li>多個 container（app + DB + cache）怎麼一起編排，見 <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>。</li>
<li>build 慢、要同時出 amd64 / arm64、build 時要塞 secret 或快取套件下載，見 <a href="/blog/backend/05-deployment-platform/vendors/docker/buildkit-cross-platform/" data-link-title="BuildKit 與跨平台 build" data-link-desc="docker build 每次重下載套件很慢、要同時出 amd64 與 arm64 image、或 build 時要用私有憑證卻不想烤進 image 時回來讀 — BuildKit 的 cache/secret mount 與 buildx 跨平台 build">BuildKit 與跨平台 build</a>。</li>
<li>一個完整的「對齊 client 線上舊環境」的實作，把 Dockerfile 放進 compose 三件套，見 <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>production 編排離開 Docker、走 Kubernetes 時，image 是不變的可攜介面，見 <a href="/blog/backend/05-deployment-platform/kubernetes-deployment/" data-link-title="5.2 Kubernetes 部署策略" data-link-desc="整理 deployment、probe 與 rolling update">Kubernetes deployment</a>。</li>
</ul>
]]></content:encoded></item></channel></rss>