<?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>macOS on Tarragon</title><link>https://tarrragon.github.io/blog/macos/</link><description>Recent content in macOS on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 03 Aug 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/macos/index.xml" rel="self" type="application/rss+xml"/><item><title>殭屍程序與使用者程序上限：程序表格位的配置與回收</title><link>https://tarrragon.github.io/blog/macos/macos_process_limit_zombie_reaping/</link><pubDate>Mon, 03 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/macos/macos_process_limit_zombie_reaping/</guid><description>&lt;p>macOS 的核心維護一張程序表（process table），每個存在的程序佔用其中一個格位，而格位總數有上限。佔用格位的條件是「核心還保留著這個程序的紀錄」，跟這個程序是否還在執行沒有關係——已經結束、但紀錄尚未被回收的程序同樣佔一格。這條區分是理解 &lt;code>fork&lt;/code> 失敗的起點。&lt;code>fork&lt;/code> 是 Unix 建立新程序的系統呼叫，任何開新程序的動作——執行一條指令、跑一個腳本、編譯器開一個子行程——最終都經過它；可用格位耗盡時它回傳失敗，訊息是 &lt;code>Resource temporarily unavailable&lt;/code>，錯誤碼在 macOS 上是 35。看到這串字的時候，剩餘記憶體、CPU 使用率、以及正在執行的程序數量都可能完全正常。&lt;/p>
&lt;h2 id="兩道-fork-關卡與一道天花板">兩道 fork 關卡與一道天花板&lt;/h2>
&lt;p>程序數量的上限由三個參數共同決定，但它們的執行時機分成兩處：其中兩個由核心在每次 &lt;code>fork&lt;/code> 時檢查，第三個在 &lt;code>setrlimit&lt;/code> 時把可設定的範圍壓住。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">sysctl kern.maxproc kern.maxprocperuid
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">launchctl limit maxproc
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nb">ulimit&lt;/span> -u&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>一台 Apple Silicon Mac 上的實測輸出：&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">kern.maxproc: 4000
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">kern.maxprocperuid: 2666
&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">	maxproc 2666 4000
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">2666&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>fork&lt;/code> 實際檢查的兩道是：全系統程序總數對 &lt;code>kern.maxproc&lt;/code>，以及該 uid 的程序數對它自己的 &lt;code>RLIMIT_NPROC&lt;/code>（soft limit，也就是 &lt;code>ulimit -u&lt;/code> 讀到的那個值）。任一道不通過就回傳失敗。&lt;/p>
&lt;p>&lt;code>kern.maxprocperuid&lt;/code> 的執行點在第三處——&lt;code>setrlimit&lt;/code>，也就是程序調整自己資源上限的那個系統呼叫（&lt;code>ulimit&lt;/code> 這個 shell 指令是它的前端）。&lt;code>kern.maxprocperuid&lt;/code> 規定 &lt;code>RLIMIT_NPROC&lt;/code> 能被設到多高，因此它是天花板而非關卡：程序不會在 &lt;code>fork&lt;/code> 的時候撞到它，而是在嘗試把自己的上限調高時被它壓住。&lt;code>kern.maxproc&lt;/code> 由核心在開機時依機器規格決定，&lt;code>kern.maxprocperuid&lt;/code> 在本機是前者的三分之二，目的是讓單一使用者的失控程式仍留給系統其他部分足夠的格位運作；這個比值不必記，每台機器直接讀 &lt;code>sysctl&lt;/code> 就有。&lt;code>launchctl limit maxproc&lt;/code> 印出的兩欄分別是 soft 與 hard limit，兩者都會被子程序繼承。&lt;/p>
&lt;p>這個分工決定了調高上限的實際效果，而效果與直覺不同。把 &lt;code>ulimit -u&lt;/code> 設成超過 &lt;code>kern.maxprocperuid&lt;/code> 的值時，核心把它&lt;strong>靜默壓回&lt;/strong> 2666，並且把 hard limit 一併降到 2666——而降低 hard limit 是單向不可逆的：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">python3 -c &lt;span class="s2">&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="s2">import resource as r
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="s2">print(&amp;#39;before:&amp;#39;, r.getrlimit(r.RLIMIT_NPROC))
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="s2">r.setrlimit(r.RLIMIT_NPROC, (3000, 4000))
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="s2">print(&amp;#39;set 3000 -&amp;gt;&amp;#39;, r.getrlimit(r.RLIMIT_NPROC))
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&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">before: (2666, 4000)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">set 3000 -&amp;gt; (2666, 2666)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>再嘗試設回 4000 會直接失敗（&lt;code>not allowed to raise maximum limit&lt;/code>）——原本 4000 的 hard limit 額度就這樣消失了，恢復要重開 shell。&lt;/p>
&lt;p>這裡還有一個讀數陷阱值得單獨記下。macOS 的預設 shell 是 zsh，而 zsh 的 &lt;code>ulimit&lt;/code> builtin 回報的是&lt;strong>它想設的值&lt;/strong>，不是核心實際持有的值：&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">zsh: ulimit -u 3000 -&amp;gt; zsh 回報 soft=3000 hard=4000 | 核心持有 (2666, 2666)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">bash: ulimit -u 3000 -&amp;gt; bash 回報 soft=2666 hard=2666 | 核心持有 (2666, 2666)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>照著 zsh 的輸出判斷會得到「調高成功」的結論，而實際上限一點都沒動、hard limit 還少了三分之一。要確認核心持有什麼，讀 &lt;code>getrlimit&lt;/code> 而非 shell builtin。&lt;/p>
&lt;p>真正的天花板因此在 &lt;code>kern.maxprocperuid&lt;/code> 這一層，調整它需要 root 權限。無論從哪一層調高，那個動作的作用都是延後撞牆的時間點，已經被佔住的格位一個都沒有因此釋放。&lt;/p>
&lt;p>一般桌面使用的常駐程序數量落在幾百的量級，距離兩千多的上限有相當餘裕。當前用量可以直接量：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="k">$((&lt;/span> &lt;span class="k">$(&lt;/span>ps -u &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>id -un&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="p">|&lt;/span> wc -l&lt;span class="k">)&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="m">1&lt;/span> &lt;span class="k">))&lt;/span> &lt;span class="c1"># 扣掉表頭那一行&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="程序結束分成兩個階段">程序結束分成兩個階段&lt;/h2>
&lt;p>子程序呼叫 &lt;code>exit&lt;/code> 的那一刻，它並沒有從程序表上消失。核心先釋放它持有的資源，再把一小塊紀錄留在原地等父程序來取——殭屍狀態就是這兩個動作之間的空檔，而觸發它們的是兩個不同的角色。&lt;/p>
&lt;p>&lt;strong>第一階段由子程序自己觸發&lt;/strong>。子程序呼叫 &lt;code>exit&lt;/code>（或被信號終止）時，核心回收它的位址空間、開啟中的檔案描述子、以及其他持有的資源。這一階段結束後，這個程序已經不執行任何指令、不佔用記憶體、不排入 CPU 排程佇列。&lt;/p>
&lt;p>&lt;strong>第二階段由父程序觸發&lt;/strong>。核心在第一階段刻意保留一小塊紀錄，存放結束碼（exit status）與資源使用統計，因為父程序有權知道子程序是怎麼結束的。核心送出 &lt;code>SIGCHLD&lt;/code> 通知父程序，父程序呼叫 &lt;code>wait()&lt;/code> 或 &lt;code>waitpid()&lt;/code> 領取這塊紀錄，核心才釋放程序表格位。這個動作在 Unix 傳統上稱為收屍（reaping）。&lt;/p></description><content:encoded><![CDATA[<p>macOS 的核心維護一張程序表（process table），每個存在的程序佔用其中一個格位，而格位總數有上限。佔用格位的條件是「核心還保留著這個程序的紀錄」，跟這個程序是否還在執行沒有關係——已經結束、但紀錄尚未被回收的程序同樣佔一格。這條區分是理解 <code>fork</code> 失敗的起點。<code>fork</code> 是 Unix 建立新程序的系統呼叫，任何開新程序的動作——執行一條指令、跑一個腳本、編譯器開一個子行程——最終都經過它；可用格位耗盡時它回傳失敗，訊息是 <code>Resource temporarily unavailable</code>，錯誤碼在 macOS 上是 35。看到這串字的時候，剩餘記憶體、CPU 使用率、以及正在執行的程序數量都可能完全正常。</p>
<h2 id="兩道-fork-關卡與一道天花板">兩道 fork 關卡與一道天花板</h2>
<p>程序數量的上限由三個參數共同決定，但它們的執行時機分成兩處：其中兩個由核心在每次 <code>fork</code> 時檢查，第三個在 <code>setrlimit</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">sysctl kern.maxproc kern.maxprocperuid
</span></span><span class="line"><span class="ln">2</span><span class="cl">launchctl limit maxproc
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">ulimit</span> -u</span></span></code></pre></div><p>一台 Apple Silicon Mac 上的實測輸出：</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">kern.maxproc: 4000
</span></span><span class="line"><span class="ln">2</span><span class="cl">kern.maxprocperuid: 2666
</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">	maxproc     2666           4000
</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">2666</span></span></code></pre></div><p><code>fork</code> 實際檢查的兩道是：全系統程序總數對 <code>kern.maxproc</code>，以及該 uid 的程序數對它自己的 <code>RLIMIT_NPROC</code>（soft limit，也就是 <code>ulimit -u</code> 讀到的那個值）。任一道不通過就回傳失敗。</p>
<p><code>kern.maxprocperuid</code> 的執行點在第三處——<code>setrlimit</code>，也就是程序調整自己資源上限的那個系統呼叫（<code>ulimit</code> 這個 shell 指令是它的前端）。<code>kern.maxprocperuid</code> 規定 <code>RLIMIT_NPROC</code> 能被設到多高，因此它是天花板而非關卡：程序不會在 <code>fork</code> 的時候撞到它，而是在嘗試把自己的上限調高時被它壓住。<code>kern.maxproc</code> 由核心在開機時依機器規格決定，<code>kern.maxprocperuid</code> 在本機是前者的三分之二，目的是讓單一使用者的失控程式仍留給系統其他部分足夠的格位運作；這個比值不必記，每台機器直接讀 <code>sysctl</code> 就有。<code>launchctl limit maxproc</code> 印出的兩欄分別是 soft 與 hard limit，兩者都會被子程序繼承。</p>
<p>這個分工決定了調高上限的實際效果，而效果與直覺不同。把 <code>ulimit -u</code> 設成超過 <code>kern.maxprocperuid</code> 的值時，核心把它<strong>靜默壓回</strong> 2666，並且把 hard limit 一併降到 2666——而降低 hard limit 是單向不可逆的：</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">python3 -c <span class="s2">&#34;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s2">import resource as r
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s2">print(&#39;before:&#39;, r.getrlimit(r.RLIMIT_NPROC))
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s2">r.setrlimit(r.RLIMIT_NPROC, (3000, 4000))
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s2">print(&#39;set 3000 -&gt;&#39;, r.getrlimit(r.RLIMIT_NPROC))
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s2">&#34;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">before: (2666, 4000)
</span></span><span class="line"><span class="ln">2</span><span class="cl">set 3000 -&gt; (2666, 2666)</span></span></code></pre></div><p>再嘗試設回 4000 會直接失敗（<code>not allowed to raise maximum limit</code>）——原本 4000 的 hard limit 額度就這樣消失了，恢復要重開 shell。</p>
<p>這裡還有一個讀數陷阱值得單獨記下。macOS 的預設 shell 是 zsh，而 zsh 的 <code>ulimit</code> builtin 回報的是<strong>它想設的值</strong>，不是核心實際持有的值：</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">zsh:  ulimit -u 3000  -&gt;  zsh 回報 soft=3000 hard=4000  |  核心持有 (2666, 2666)
</span></span><span class="line"><span class="ln">2</span><span class="cl">bash: ulimit -u 3000  -&gt;  bash 回報 soft=2666 hard=2666  |  核心持有 (2666, 2666)</span></span></code></pre></div><p>照著 zsh 的輸出判斷會得到「調高成功」的結論，而實際上限一點都沒動、hard limit 還少了三分之一。要確認核心持有什麼，讀 <code>getrlimit</code> 而非 shell builtin。</p>
<p>真正的天花板因此在 <code>kern.maxprocperuid</code> 這一層，調整它需要 root 權限。無論從哪一層調高，那個動作的作用都是延後撞牆的時間點，已經被佔住的格位一個都沒有因此釋放。</p>
<p>一般桌面使用的常駐程序數量落在幾百的量級，距離兩千多的上限有相當餘裕。當前用量可以直接量：</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="nb">echo</span> <span class="k">$((</span> <span class="k">$(</span>ps -u <span class="s2">&#34;</span><span class="k">$(</span>id -un<span class="k">)</span><span class="s2">&#34;</span> <span class="p">|</span> wc -l<span class="k">)</span> <span class="o">-</span> <span class="m">1</span> <span class="k">))</span>   <span class="c1"># 扣掉表頭那一行</span></span></span></code></pre></div><h2 id="程序結束分成兩個階段">程序結束分成兩個階段</h2>
<p>子程序呼叫 <code>exit</code> 的那一刻，它並沒有從程序表上消失。核心先釋放它持有的資源，再把一小塊紀錄留在原地等父程序來取——殭屍狀態就是這兩個動作之間的空檔，而觸發它們的是兩個不同的角色。</p>
<p><strong>第一階段由子程序自己觸發</strong>。子程序呼叫 <code>exit</code>（或被信號終止）時，核心回收它的位址空間、開啟中的檔案描述子、以及其他持有的資源。這一階段結束後，這個程序已經不執行任何指令、不佔用記憶體、不排入 CPU 排程佇列。</p>
<p><strong>第二階段由父程序觸發</strong>。核心在第一階段刻意保留一小塊紀錄，存放結束碼（exit status）與資源使用統計，因為父程序有權知道子程序是怎麼結束的。核心送出 <code>SIGCHLD</code> 通知父程序，父程序呼叫 <code>wait()</code> 或 <code>waitpid()</code> 領取這塊紀錄，核心才釋放程序表格位。這個動作在 Unix 傳統上稱為收屍（reaping）。</p>
<p>處於兩階段之間的程序就是殭屍（zombie）。它在 <code>ps</code> 的 <code>STAT</code> 欄位顯示為 <code>Z</code>，<code>comm</code> 欄位顯示為 <code>&lt;defunct&gt;</code>。設計良好的父程序在毫秒級內完成領取，殭屍狀態短到幾乎觀察不到；殭屍在列表裡穩定存在，代表第二階段沒有發生。</p>
<p>殭屍難以察覺的原因就在第一階段已經把大部分資源還回去了。它不佔使用者記憶體、不耗 CPU、沒有開啟的檔案，因此記憶體監控、CPU 監控、活動監視器的資源排行全部看不到它。它唯一消耗的是程序表格位，而程序表格位剛好就是 <code>fork</code> 那道 per-uid 關卡在計數的單位。一具殭屍的資源成本接近零，一千具的資源成本仍然接近零，但額度已經去掉超過三分之一。</p>
<h2 id="父程序漏收的成因">父程序漏收的成因</h2>
<p>第二階段沒有發生的原因集中在父程序這一側，常見的有三種。</p>
<p><strong>未處理 <code>SIGCHLD</code></strong>：父程序沒有安裝 handler，也沒有在主迴圈裡週期性呼叫 <code>waitpid(-1, ..., WNOHANG)</code>——<code>WNOHANG</code> 這個旗標讓呼叫在沒有子程序可收時立刻回來，因此它適合放在主迴圈裡不阻塞地掃一遍。核心送出的通知無人接收，紀錄就留著。</p>
<p><strong>handler 存在但遲遲排不到執行</strong>：父程序卡在某個阻塞的系統呼叫、或主執行緒被長時間佔用，<code>SIGCHLD</code> 排隊等待處理。信號在傳統 Unix 語意下不排隊累積，短時間內多個子程序結束時，父程序可能只看到一次通知，若 handler 只呼叫一次 <code>wait()</code> 就會漏掉其餘的。正確的寫法是在 handler 內以迴圈呼叫 <code>waitpid</code> 直到回傳零或負值。</p>
<p><strong>父程序長時間存活</strong>：這是讓漏收從瑕疵變成故障的放大條件。父程序若在幾秒內結束，殘留的殭屍會立刻被收走（機制見下節）；父程序連續執行數天甚至數週時，同樣的漏收率會線性累積成上千具。</p>
<p>Unix 提供一個明示放棄結束狀態的機制：父程序把 <code>SIGCHLD</code> 設為 <code>SIG_IGN</code>，核心就直接回收子程序的紀錄、不再產生殭屍。應用程式若對子程序的結束碼沒有需求，這是最省事的做法。另一個常見的迴避手法是 double-fork——fork 出中介程序，中介程序再 fork 出真正的工作程序後立即結束，工作程序因此變成孤兒、由系統的 init 程序負責收屍。這個手法有一個容易漏掉的收尾：原父程序仍要對中介程序呼叫一次 <code>waitpid</code>。少了這一步，中介程序自己成為殭屍，一具換一具、什麼也沒省下。</p>
<h2 id="孤兒的-reparenting">孤兒的 reparenting</h2>
<p>父程序先於子程序結束時，子程序成為孤兒（orphan），核心把它的 ppid 改成 1，交由 <code>launchd</code> 接手。這個過程稱為 reparenting。</p>
<p><code>launchd</code> 是 macOS 的 PID 1，職責之一就是對收養來的子程序持續呼叫 <code>wait()</code>。這決定了一個實用的性質：終止一個堆積殭屍的父程序時，它底下的所有殭屍會在同一時間被 <code>launchd</code> 收養並立即收屍，格位一次全數釋放。回收量等於該父程序名下的殭屍總數，不需要逐一處理。</p>
<p>同一個性質也解釋了為什麼短命程式很少造成問題。一個執行三秒的腳本即使完全沒有收屍邏輯，它結束時所有遺留的殭屍都會被 <code>launchd</code> 清掉。殭屍的堆積需要「漏收」與「父程序長存」兩個條件同時成立。</p>
<h2 id="格位耗盡時的表現">格位耗盡時的表現</h2>
<p>可用格位耗盡時，<code>fork</code> 回傳 <code>EAGAIN</code>。在 macOS 上這個錯誤碼是 35，對應訊息字串為 <code>Resource temporarily unavailable</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">python3 -c <span class="s2">&#34;import errno, os; print(errno.EAGAIN, os.strerror(errno.EAGAIN))&#34;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">35 Resource temporarily unavailable</span></span></code></pre></div><p>錯誤碼的數值跨系統不同，這點會影響查表方向：Linux 的 <code>EAGAIN</code> 是 11，macOS 沿用 BSD 的 35。看到 <code>os error 35</code> 這串字時要對照的是 macOS 的表，用 Linux 的表查會得到 <code>EDEADLK</code>——而它的訊息字串是 <code>Resource deadlock avoided</code>，與 macOS 的 <code>Resource temporarily unavailable</code> 形狀相近、讀起來同樣像是「某項資源暫時有狀況」。查錯表的人因此不會察覺自己查錯了，而是被推去追鎖與死結。</p>
<p>額度見底之後，最先有反應的是那些每分鐘要開幾十次新程序的元件，而這個先後順序具有誤導性。已經在執行的常駐程序完全不受影響——瀏覽器、編輯器、正在跑的伺服器都照常運作，因為它們早就佔好了自己的格位。最先失敗的是高頻建立短命程序的元件：</p>
<p><strong>shell prompt 的狀態查詢</strong>：像 <code>gitstatusd</code> 這類每次按下 enter 就查一次 repo 狀態的元件，每次都要 fork。</p>
<p><strong>多層 shebang 的腳本</strong>：<code>#!/usr/bin/env -S uv run --script</code> 這種寫法執行一次會疊起兩個並存的程序——直譯器管理工具，加上它開出來的直譯器本身。（<code>env</code> 不算在內：它用 <code>execve</code> 把自己替換掉，不多佔一格。）剩餘額度只有十來個時，同時要拿到兩格的呼叫是最先失敗的一批。</p>
<p><strong>建置與測試工具鏈</strong>：編譯器、測試框架、任何會平行開子程序的工具。</p>
<p>失敗集中在這些元件上，容易讓人以為是該元件本身出了問題。判準是：失敗的操作彼此在功能上沒有關聯，唯一的共同點是都需要新開程序。符合這個形狀時，先量格位。</p>
<h2 id="診斷順序">診斷順序</h2>
<p>診斷用三個固定成本的查詢完成，每一步的輸出直接決定下一步，中間不需要推測。</p>
<p>先提一件反直覺的事：這些指令自己也要 fork。<code>ps | sort | uniq -c</code> 這種管線一次要開三到四個程序，而執行它的時機正好是格位最緊的時候。額度已經見底到指令跑不起來時，順序要倒過來——先從候選集裡關掉一個最大的常駐程式（模擬器、虛擬機），把格位騰出來，再回頭做診斷。關掉的那個若正好是元凶，殭屍會一併消失，這本身就是答案。</p>
<p><strong>第一步量總額</strong>，確認格位確實見底：</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="nb">echo</span> <span class="k">$((</span> <span class="k">$(</span>ps -u <span class="s2">&#34;</span><span class="k">$(</span>id -un<span class="k">)</span><span class="s2">&#34;</span> <span class="p">|</span> wc -l<span class="k">)</span> <span class="o">-</span> <span class="m">1</span> <span class="k">))</span>   <span class="c1"># 目前程序數</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">ulimit</span> -u                                       <span class="c1"># 上限</span></span></span></code></pre></div><p><strong>第二步分類</strong>，判斷佔額度的是活程序還是殭屍：</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">ps -u <span class="s2">&#34;</span><span class="k">$(</span>id -un<span class="k">)</span><span class="s2">&#34;</span> -o stat <span class="p">|</span> grep -c <span class="s1">&#39;^Z&#39;</span></span></span></code></pre></div><p>範圍要限定在同一個 uid。配額是 per-uid 的，而 <code>ps -eo stat</code> 掃的是全系統——兩個數字並置比較時基準不同，會把別的使用者（含系統服務）的殭屍算進自己的帳上。</p>
<p>殭屍數若接近第一步的總數，方向就確定了。殭屍數接近零而總數仍逼近上限時，代表確實有那麼多活程序在跑，這時改依執行檔聚合，看是哪一支開出了幾百份：</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">ps -u <span class="s2">&#34;</span><span class="k">$(</span>id -un<span class="k">)</span><span class="s2">&#34;</span> -o comm <span class="p">|</span> sort <span class="p">|</span> uniq -c <span class="p">|</span> sort -rn <span class="p">|</span> head -10</span></span></code></pre></div><p>正常的機器上這份清單的頭幾名是 shell、瀏覽器的 renderer、編輯器的 language server，數量落在數十。某一支衝到三位數就是那條線索——常見的是重複啟動的開發伺服器（每次熱重載留下一份舊的）、平行度設定過高的測試套件，或退出時沒有清乾淨的容器。</p>
<p><strong>第三步指認父程序</strong>，把所有殭屍依 ppid 聚合：</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">ps -u <span class="s2">&#34;</span><span class="k">$(</span>id -un<span class="k">)</span><span class="s2">&#34;</span> -o ppid,stat,comm <span class="p">|</span> awk <span class="s1">&#39;$2 ~ /^Z/ {print $1}&#39;</span> <span class="p">|</span> sort <span class="p">|</span> uniq -c <span class="p">|</span> sort -rn <span class="p">|</span> head -5</span></span></code></pre></div><p>輸出的每一行是「殭屍數量 + 父程序 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">2008 89668</span></span></code></pre></div><p>聚合結果集中在單一 PID 時，父程序已經確定，剩下的只有查它的身分：</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">ps -o pid,ppid,etime,command -p &lt;ppid&gt;</span></span></code></pre></div><p>一個實測例子的輸出：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">  PID  PPID     ELAPSED COMMAND
</span></span><span class="line"><span class="ln">2</span><span class="cl">89668     1 10-05:12:33 qemu-system-aarch64 -netdelay none -netspeed full -avd Medium_Tablet_2</span></span></code></pre></div><p><code>ELAPSED</code> 欄位讀作十天五小時十二分，這個數字補上了時間維度：殭屍是十天內線性長出來的，跟查詢當天執行了什麼操作沒有關係，只是當天正好跨過上限那條線。累積型故障的共同特徵就是發作時機與觸發原因在時間上完全脫鉤，<code>ELAPSED</code> 是把兩者接回去的欄位。</p>
<p>ppid 聚合值得排在任何因果推測之前，理由是成本結構：它是一行指令的定值查詢，直接指認父程序，不依賴關於「誰在耗資源」的任何假設。當一個定值查詢能取代一串推論時，先跑查詢。</p>
<p>這條順序有一個超出 macOS 的理由。本文案例裡的模擬器十天內 fork 了兩千多次、平均每小時八次，遠少於 hook 的呼叫頻率——它之所以能吃掉四分之三的額度，是因為這兩千多次沒有一次歸還。佔用量是申請頻率乘上持有時間，而申請頻率的差距以倍數計、持有時間的跨度沒有上界，於是佔用量的排序由持有時間主導，報錯的位置卻只跟申請頻率有關。檔案描述子、連線池、鎖與 rate limit 額度上都存在同一組落差，跨資源的歸因順序與判讀徵兆整理在 <a href="/blog/report/resource-exhaustion-symptom-vs-holder/" data-link-title="配額耗盡的症狀落在申請最頻繁的元件、成因在持有最久的那個" data-link-desc="共享配額耗盡型故障要歸因時使用。症狀的位置由申請頻率決定，佔用量由申請頻率乘持有時間決定，而持有時間主導後者；系統保存的持有者紀錄能把搜尋範圍縮到一個元件">#252 配額耗盡的症狀落在申請最頻繁的元件、成因在持有最久的那個</a>。</p>
<h2 id="回收格位的手段">回收格位的手段</h2>
<p>要拿回那些格位，動作全部落在父程序身上——殭屍自己已經沒有可以被操作的部分。對殭屍本身送信號沒有作用——信號的生效前提是接收方會再被排程執行一次，而殭屍在第一階段就已經停止執行，<code>kill -9</code> 送出後沒有任何指令會因此被執行。</p>
<p><strong>催父程序去領</strong>：</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="nb">kill</span> -CHLD &lt;parent-pid&gt;</span></span></code></pre></div><p>這會補送一次 <code>SIGCHLD</code>。父程序若有 handler 但錯過了先前的通知，可能因此開始回收。父程序沒有安裝 handler、或卡在阻塞呼叫時，這一步沒有反應，成本是一行指令，值得先試。</p>
<p><strong>終止父程序</strong>：</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="nb">kill</span> &lt;parent-pid&gt;          <span class="c1"># 先給正常關閉的機會</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">kill</span> -9 &lt;parent-pid&gt;       <span class="c1"># 未退出時再強制</span></span></span></code></pre></div><p>父程序結束後，reparenting 讓 <code>launchd</code> 一次收完所有殭屍。這是回收量最大、也最確定的手段。父程序是有使用者介面的應用程式時（模擬器、虛擬機、開發工具），從介面正常關閉的效果相同，且能讓它完成自己的清理流程。</p>
<p>重新開機同樣有效，但它是「終止父程序」的高成本超集。三步診斷已經指認出父程序的情況下，重開機只是把附帶損失擴大到整台機器。</p>
<h2 id="哪類程式會累積殭屍">哪類程式會累積殭屍</h2>
<p>會累積殭屍的程式有一組共同特徵：長時間常駐、且在生命週期內反覆建立子程序而沒有確實收屍。這組描述對應前面那兩個必要條件——後半句是「漏收」，前半句是「父程序長存」；反覆建立子程序本身只是讓漏收有機會反覆發生，正確收屍的程式同樣頻繁 fork、而格位用完就還。前面那個 qemu 就是這個形狀的典型：模擬器為了周邊功能反覆開子程序，而它被設計成開著就不關。同一個形狀在開發環境裡還有幾個常見的落點——開發伺服器與檔案監看器、language server、長跑的容器 runtime，共同處境都是「本來就設計成一直開著」，於是再低的漏收率也終究會累積到上限。</p>
<p>日常檢查的成本是一行指令。寫成只在超線時才輸出，就可以掛進週期任務或 shell 啟動檔——門檻要能主動找上人，否則它只是事後判讀的參考值，而事後才去量的時候故障已經發生了：</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="nv">z</span><span class="o">=</span><span class="k">$(</span>ps -u <span class="s2">&#34;</span><span class="k">$(</span>id -un<span class="k">)</span><span class="s2">&#34;</span> -o stat <span class="p">|</span> grep -c <span class="s1">&#39;^Z&#39;</span><span class="k">)</span><span class="p">;</span> <span class="nv">p</span><span class="o">=</span><span class="k">$((</span> <span class="k">$(</span>ps -u <span class="s2">&#34;</span><span class="k">$(</span>id -un<span class="k">)</span><span class="s2">&#34;</span> <span class="p">|</span> wc -l<span class="k">)</span> <span class="o">-</span> <span class="m">1</span> <span class="k">))</span><span class="p">;</span> <span class="nv">l</span><span class="o">=</span><span class="k">$(</span><span class="nb">ulimit</span> -u<span class="k">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="o">[</span> <span class="nv">$z</span> -gt <span class="k">$((</span>l/100<span class="k">))</span> <span class="o">]</span> <span class="o">||</span> <span class="o">[</span> <span class="nv">$p</span> -gt <span class="k">$((</span>l*7/10<span class="k">))</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">&#34;procs </span><span class="nv">$p</span><span class="s2">/</span><span class="nv">$l</span><span class="s2">, zombies </span><span class="nv">$z</span><span class="s2">&#34;</span></span></span></code></pre></div><p>判讀門檻用比值寫、不用絕對數，因為上限隨機器規格變動（本機 2666，別台機器可能是別的數）。兩條參考線：</p>
<p><strong>殭屍數超過上限的百分之一</strong>（本機約 27 具）就值得去找父程序是誰。個位數的殭屍是正常的瞬時狀態——父程序還沒輪到處理 <code>SIGCHLD</code>；累積到這個量級代表某個父程序的回收邏輯有缺陷，而它會繼續累積下去。這條線的依據是「正常狀態的殭屍數不隨時間成長」，因此門檻設在哪都行，只要它明顯高於瞬時波動。</p>
<p>第二條線看總數：上限的七成（本機 1866）。這條線刻意留得寬，而且它沒有精確的推導——剩下的三成在本機是 800 格，遠多於一次建置或一輪測試所需的數十到上百個短命程序。寬是有理由的：撞到這條線的代價只是提早查一次，而撞到真正上限的代價是建置中斷、測試莫名其妙掛掉，且那些失敗不會說出自己是資源不足。</p>
<p>要把這條線收窄成有依據的值，在自己最重的工作型態下量一次峰值即可——建置或測試跑到最忙時讀一次 <code>ps -u &quot;$(id -un)&quot; | wc -l</code>，把那個讀數當作必須保留的餘裕。平行度開得高的測試套件、本機同時跑好幾個容器、或一邊編譯一邊跑模擬器，量出來的峰值都會比上面那個範圍大。</p>
<p>操作面最有效的預防是把用完的模擬器與虛擬機關掉。長跑的 Android 模擬器另有一組完全不同的表現層症狀——半活狀態會讓 <code>flutter devices</code> 掛在 device property 查詢上，判讀訊號與恢復順序見 <a href="../../work-log/flutter_devices_hangs_on_zombie_android_emulator/">flutter devices 卡住的訊號</a>。兩者的來源同屬長跑程序，但一個表現為單一工具的查詢逾時，一個表現為全使用者範圍的 fork 失敗。</p>
<p>模擬器與虛擬機同時也是磁碟空間的大戶，常駐成本在空間與程序表兩邊各記一筆，磁碟側的排查流程見 <a href="../macos_disk_space_diagnosis/">磁碟空間診斷流程</a>。</p>
]]></content:encoded></item><item><title>macOS 的 BSD userland：GNU 習慣在這裡會撞的地方</title><link>https://tarrragon.github.io/blog/macos/macos_bsd_userland_vs_gnu/</link><pubDate>Thu, 09 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/macos/macos_bsd_userland_vs_gnu/</guid><description>&lt;p>macOS 的命令列 userland 是 BSD 系、不是 GNU。你在 Linux 養成的 GNU 工具習慣（GNU coreutils、較新的 bash）搬到 macOS 會在三個地方撞牆：工具根本不在、同名但行為不同、bash 版本太舊。這篇講的是同一個底層事實的三種表現——理解「macOS 給的是 BSD、加上被凍結的舊 GNU」這一件事，就能預期並繞過這些差異，而不是每撞一次查一次。&lt;/p>
&lt;h2 id="為什麼是-bsd而且是舊的-gnu">為什麼是 BSD、而且是舊的 GNU&lt;/h2>
&lt;p>macOS 的 Unix 底子（Darwin）來自 BSD，所以內建的命令列工具是 BSD 版，語意跟旗標跟著 BSD。至於 GNU 版本的工具，Apple 刻意停在舊版：GNU 的 coreutils、bash 等專案在某個時點把授權從 GPLv2 改成 GPLv3，而 GPLv3 的專利條款與反 Tivoization 條款（禁止廠商鎖死硬體、不准跑使用者修改過的軟體）是 Apple 不接受的，於是 Apple 把系統內建的 GNU 工具凍在最後一版 GPLv2、之後不再更新，整體改倚重 BSD 與自家工具。&lt;code>/bin/bash&lt;/code> 停在 3.2（2006 年的版本、最後的 GPLv2）就是這個決定最顯眼的標記。&lt;/p>
&lt;p>結論是：macOS 給你的是「BSD userland + 一套凍結在多年前的舊 GNU」。這不是缺陷或沒維護，是授權取捨下的刻意設計。知道成因，下面三種現象就都是可預期的。&lt;/p>
&lt;h2 id="形態一工具根本不在">形態一：工具根本不在&lt;/h2>
&lt;p>GNU-only 的工具在 macOS 預設沒有，呼叫直接 &lt;code>command not found&lt;/code>。最常撞的是 &lt;code>timeout&lt;/code>（GNU coreutils 的一員）——一支在 Linux 用 &lt;code>timeout 60 some-cmd&lt;/code> 包起來的腳本，搬到 macOS 第一步就掛。&lt;/p>
&lt;p>判讀很直接：一個在 Linux 上理所當然的指令，在 macOS 回 &lt;code>command not found&lt;/code>，先問「它是不是 GNU coreutils / GNU-only 的東西」。是的話，macOS 本來就沒有，要嘛裝（見下方對策）、要嘛換不依賴它的寫法。&lt;/p>
&lt;h2 id="形態二同名行為不同最陰險">形態二：同名、行為不同（最陰險）&lt;/h2>
&lt;p>比「工具不在」更難抓的是工具名一樣、但 BSD 版的旗標或語意跟 GNU 版不同——它不報錯，默默做出不一樣的結果。幾個天天會用到的：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>sed -i&lt;/code>&lt;/strong>：BSD 版的 &lt;code>-i&lt;/code> 後面&lt;strong>必須&lt;/strong>接一個備份檔後綴（&lt;code>sed -i '' 's/a/b/' file&lt;/code> 那個空字串就是「不留備份」）；GNU 版的 &lt;code>-i&lt;/code> 直接就地改、不接參數。把 GNU 寫法 &lt;code>sed -i 's/a/b/' file&lt;/code> 搬到 BSD，&lt;code>sed&lt;/code> 會把 &lt;code>s/a/b/&lt;/code> 當成備份後綴、把 &lt;code>file&lt;/code> 當成 script，行為全錯。&lt;/li>
&lt;li>&lt;strong>&lt;code>readlink -f&lt;/code>&lt;/strong>：解析符號連結的最終絕對路徑，GNU 有這個旗標，BSD 版長年沒有（macOS 較新版本才補上）。可攜的替代是 &lt;code>realpath&lt;/code>，或退回不依賴它的方式。&lt;/li>
&lt;li>&lt;strong>&lt;code>date&lt;/code>&lt;/strong>：算日期時間 GNU 用 &lt;code>date -d '...'&lt;/code>、BSD 用 &lt;code>date -v&lt;/code>（相對）或 &lt;code>date -j&lt;/code>（指定），旗標完全不同，照抄必錯。&lt;/li>
&lt;li>&lt;strong>&lt;code>getopt&lt;/code> / &lt;code>stat&lt;/code> / &lt;code>grep -P&lt;/code> / &lt;code>find&lt;/code>&lt;/strong>：&lt;code>getopt&lt;/code> BSD 版不支援長選項（&lt;code>--foo&lt;/code>）、GNU / util-linux 版才支援，靠它解析長參數的腳本在 macOS 整個失效；&lt;code>stat&lt;/code> 的格式旗標 GNU 是 &lt;code>-c&lt;/code>、BSD 是 &lt;code>-f&lt;/code>；&lt;code>grep -P&lt;/code>（PCRE）GNU 有、BSD 沒有；&lt;code>find&lt;/code> / &lt;code>xargs&lt;/code> 也有零星旗標差異。&lt;/li>
&lt;/ul>
&lt;p>跨平台腳本在 macOS 上結果怪、卻一個錯誤訊息都沒有時，先回頭盤點它用了哪些 BSD/GNU 行為分歧的指令（&lt;code>sed&lt;/code>、&lt;code>date&lt;/code>、&lt;code>stat&lt;/code>、&lt;code>grep&lt;/code>）。這一類的特徵正是不報錯、只默默做出不一樣的結果——比形態一的 &lt;code>command not found&lt;/code> 難抓，因為沒有紅字提醒你哪裡不對。&lt;/p>
&lt;h2 id="形態三bash-太舊而且預設-shell-已換">形態三：bash 太舊，而且預設 shell 已換&lt;/h2>
&lt;p>&lt;code>/bin/bash&lt;/code> 凍在 3.2，所以 bash 4 以後才有的語法在 macOS 的系統 bash 上全部不存在：關聯陣列（&lt;code>declare -A&lt;/code>）、&lt;code>${var,,}&lt;/code> / &lt;code>${var^^}&lt;/code> 大小寫轉換、&lt;code>readarray&lt;/code> / &lt;code>mapfile&lt;/code>、&lt;code>|&amp;amp;&lt;/code>。一支 &lt;code>#!/bin/bash&lt;/code> 又用了這些的腳本，在 macOS 會直接語法錯。&lt;/p></description><content:encoded><![CDATA[<p>macOS 的命令列 userland 是 BSD 系、不是 GNU。你在 Linux 養成的 GNU 工具習慣（GNU coreutils、較新的 bash）搬到 macOS 會在三個地方撞牆：工具根本不在、同名但行為不同、bash 版本太舊。這篇講的是同一個底層事實的三種表現——理解「macOS 給的是 BSD、加上被凍結的舊 GNU」這一件事，就能預期並繞過這些差異，而不是每撞一次查一次。</p>
<h2 id="為什麼是-bsd而且是舊的-gnu">為什麼是 BSD、而且是舊的 GNU</h2>
<p>macOS 的 Unix 底子（Darwin）來自 BSD，所以內建的命令列工具是 BSD 版，語意跟旗標跟著 BSD。至於 GNU 版本的工具，Apple 刻意停在舊版：GNU 的 coreutils、bash 等專案在某個時點把授權從 GPLv2 改成 GPLv3，而 GPLv3 的專利條款與反 Tivoization 條款（禁止廠商鎖死硬體、不准跑使用者修改過的軟體）是 Apple 不接受的，於是 Apple 把系統內建的 GNU 工具凍在最後一版 GPLv2、之後不再更新，整體改倚重 BSD 與自家工具。<code>/bin/bash</code> 停在 3.2（2006 年的版本、最後的 GPLv2）就是這個決定最顯眼的標記。</p>
<p>結論是：macOS 給你的是「BSD userland + 一套凍結在多年前的舊 GNU」。這不是缺陷或沒維護，是授權取捨下的刻意設計。知道成因，下面三種現象就都是可預期的。</p>
<h2 id="形態一工具根本不在">形態一：工具根本不在</h2>
<p>GNU-only 的工具在 macOS 預設沒有，呼叫直接 <code>command not found</code>。最常撞的是 <code>timeout</code>（GNU coreutils 的一員）——一支在 Linux 用 <code>timeout 60 some-cmd</code> 包起來的腳本，搬到 macOS 第一步就掛。</p>
<p>判讀很直接：一個在 Linux 上理所當然的指令，在 macOS 回 <code>command not found</code>，先問「它是不是 GNU coreutils / GNU-only 的東西」。是的話，macOS 本來就沒有，要嘛裝（見下方對策）、要嘛換不依賴它的寫法。</p>
<h2 id="形態二同名行為不同最陰險">形態二：同名、行為不同（最陰險）</h2>
<p>比「工具不在」更難抓的是工具名一樣、但 BSD 版的旗標或語意跟 GNU 版不同——它不報錯，默默做出不一樣的結果。幾個天天會用到的：</p>
<ul>
<li><strong><code>sed -i</code></strong>：BSD 版的 <code>-i</code> 後面<strong>必須</strong>接一個備份檔後綴（<code>sed -i '' 's/a/b/' file</code> 那個空字串就是「不留備份」）；GNU 版的 <code>-i</code> 直接就地改、不接參數。把 GNU 寫法 <code>sed -i 's/a/b/' file</code> 搬到 BSD，<code>sed</code> 會把 <code>s/a/b/</code> 當成備份後綴、把 <code>file</code> 當成 script，行為全錯。</li>
<li><strong><code>readlink -f</code></strong>：解析符號連結的最終絕對路徑，GNU 有這個旗標，BSD 版長年沒有（macOS 較新版本才補上）。可攜的替代是 <code>realpath</code>，或退回不依賴它的方式。</li>
<li><strong><code>date</code></strong>：算日期時間 GNU 用 <code>date -d '...'</code>、BSD 用 <code>date -v</code>（相對）或 <code>date -j</code>（指定），旗標完全不同，照抄必錯。</li>
<li><strong><code>getopt</code> / <code>stat</code> / <code>grep -P</code> / <code>find</code></strong>：<code>getopt</code> BSD 版不支援長選項（<code>--foo</code>）、GNU / util-linux 版才支援，靠它解析長參數的腳本在 macOS 整個失效；<code>stat</code> 的格式旗標 GNU 是 <code>-c</code>、BSD 是 <code>-f</code>；<code>grep -P</code>（PCRE）GNU 有、BSD 沒有；<code>find</code> / <code>xargs</code> 也有零星旗標差異。</li>
</ul>
<p>跨平台腳本在 macOS 上結果怪、卻一個錯誤訊息都沒有時，先回頭盤點它用了哪些 BSD/GNU 行為分歧的指令（<code>sed</code>、<code>date</code>、<code>stat</code>、<code>grep</code>）。這一類的特徵正是不報錯、只默默做出不一樣的結果——比形態一的 <code>command not found</code> 難抓，因為沒有紅字提醒你哪裡不對。</p>
<h2 id="形態三bash-太舊而且預設-shell-已換">形態三：bash 太舊，而且預設 shell 已換</h2>
<p><code>/bin/bash</code> 凍在 3.2，所以 bash 4 以後才有的語法在 macOS 的系統 bash 上全部不存在：關聯陣列（<code>declare -A</code>）、<code>${var,,}</code> / <code>${var^^}</code> 大小寫轉換、<code>readarray</code> / <code>mapfile</code>、<code>|&amp;</code>。一支 <code>#!/bin/bash</code> 又用了這些的腳本，在 macOS 會直接語法錯。</p>
<p>另外 macOS 的<strong>預設互動 shell 已經是 <code>zsh</code></strong>（從 Catalina 起），bash 要自己裝。所以「假設使用者的 shell 是 bash」這件事在 macOS 也不成立。</p>
<p>還有一個跟這個舊 bash 直接相關的細節：在 UTF-8 locale 下，bash 3.2 的多位元組解析不夠健全，shell 變數 <code>$var</code> 後面若緊跟一個多位元組字（例如中文全形括號），它可能把該字的首位元組吞進變數名、報 unbound（實測 macOS 3.2；C / POSIX locale 反而不會——那些按單位元組解析、變數名正好斷在 <code>$var</code>）。寫法上一律用 <code>${var}</code> 把變數名邊界標清楚就免疫，跟 locale 與 bash 版本無關。</p>
<h2 id="對策與取捨">對策與取捨</h2>
<p>三條路，依「這支腳本要給誰跑」選：</p>
<ul>
<li><strong>裝 GNU 工具（自己的互動環境）</strong>：<code>brew install coreutils gnu-sed findutils grep gawk bash</code>。這些 formula 各自裝 <strong>g-prefix</strong> 版、刻意不覆蓋 BSD 原生（<code>coreutils</code> 給 <code>gtimeout</code> / <code>greadlink</code> / <code>gdate</code>、<code>gnu-sed</code> 給 <code>gsed</code>、依此類推）——不覆蓋是因為系統與其他軟體可能依賴 BSD 行為，蓋掉會讓那些元件出錯。要讓無前綴的名字也走 GNU 版，把 <code>$(brew --prefix)/opt/coreutils/libexec/gnubin</code> 加進 PATH（那裡有無前綴 symlink 指向 GNU 版；但這只涵蓋 coreutils 那批，<code>sed</code> / <code>grep</code> / <code>find</code> / <code>awk</code> 要各自加它們對應的 <code>gnubin</code>）。這條 PATH 讓你的 shell 跟系統其他部分的 BSD 假設分歧，所以它屬於「自己的互動環境」這格、別設成全域（具體會怎麼壞：有些 configure / autotools 腳本靠 <code>sed --version</code> 之類的字串判斷手上的 <code>sed</code> 是 GNU 還是 BSD、據此決定加哪些旗標，PATH 被 gnubin 覆蓋後這個判斷跟系統其他部分的預期不一致，可能在編譯期出岔）。新 bash 裝到 <code>$(brew --prefix)/bin/bash</code>（<code>brew --prefix</code> 在 Apple Silicon 是 <code>/opt/homebrew</code>、Intel 是 <code>/usr/local</code>），腳本要用 <code>#!/usr/bin/env bash</code>（走 PATH）才抓得到它，<code>#!/bin/bash</code> 仍是系統的 3.2。</li>
<li><strong>只寫 POSIX 子集（要兩邊都跑的腳本）</strong>：避開所有 GNU-ism——<code>sed -i</code> 改用 tmp 檔加 <code>mv</code>、不用 <code>readlink -f</code>（用 <code>realpath</code> 或 <code>cd &amp;&amp; pwd</code>）、不用關聯陣列。最可攜、macOS / Linux / BSD 都動，代價是放棄 GNU 的便利語法。bootstrap 腳本、CI 腳本適用。</li>
<li><strong>偵測分支（有就用、沒有就退化）</strong>：<code>command -v gtimeout &gt;/dev/null &amp;&amp; T=gtimeout || { command -v timeout &gt;/dev/null &amp;&amp; T=timeout || T=&quot;&quot;; }</code>，用 <code>$T</code> 呼叫、空的就略過那層保護。給「這個工具是加分、缺了不致命」的用法。</li>
</ul>
<p>判準是：自己天天用的互動環境，裝 <code>gnubin</code> 圖方便沒問題；但<strong>要交付給別人或 CI 跑的腳本，不能假設對方裝了 GNU 工具</strong>——那種要嘛 POSIX 子集、要嘛偵測分支。這裡有個容易誤判的中間情境：<strong>你自己寫的 dotfiles / bootstrap 腳本歸「交付」那格、不是「互動環境」那格</strong>。它主觀是「自己用」，客觀上交付的對象包含換機器時的未來自己與團隊，而且執行時機常早於互動環境成形——bootstrap 腳本自己負責裝 <code>coreutils</code>，跑到它自己用 <code>sed</code> / <code>timeout</code> 那行時 PATH 裡根本還沒有 <code>gnubin</code>（先有雞）。把「我的機器剛好裝了 coreutils」當成腳本的前提，就是這篇一開始那些跨平台故障的來源。</p>
<p>（第四條路是跳出 shell：碰到 BSD/GNU 差異密集的邏輯，乾脆用 Python 之類的語言寫，讓問題不落在會撞差異的那一層。本文停在 shell 內談，是因為定位在「教 BSD vs GNU 這個機制」；真要選型換語言是另一個決策。）</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>一台新 Mac 從開箱到能開發、Homebrew / bash / 個人 bin 的設定順序：<a href="../macos_new_machine_setup/">新機基礎建設</a></li>
<li>跨 Linux 發行版與 macOS 的工具名、存在性、版本節奏分歧全景：<a href="/blog/linux/install/platform-divergence-map/" data-link-title="平台與發行版差異的判讀地圖" data-link-desc="跨 macOS / Linux 或跨發行版寫 bootstrap、某個 app 在 ARM/aarch64 裝不起來、或除錯時不確定該用哪個工具與套件名時回來讀">平台與發行版差異的判讀地圖</a></li>
<li>寫跨平台 bootstrap 腳本時「別硬編你這台剛好有的東西」（權限、工具、locale）：<a href="/blog/linux/dotfile/08-sync-bootstrap/bootstrap-script-packages/" data-link-title="Bootstrap Script 與套件清單管理" data-link-desc="寫 dotfile 的 install script、或整理「這台機器裝了什麼」的套件清單時回來讀">bootstrap 腳本的骨架與套件清單</a></li>
</ul>
]]></content:encoded></item><item><title>iOS App on Mac：Apple Silicon 跑 iOS App 的機制</title><link>https://tarrragon.github.io/blog/macos/macos_ios_app_on_mac/</link><pubDate>Sun, 05 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/macos/macos_ios_app_on_mac/</guid><description>&lt;p>Apple Silicon Mac（M1 及之後）可以直接執行 iPhone 和 iPad App，不需要模擬器。這個功能在 macOS 11 Big Sur 引入，讓使用者從 Mac App Store 安裝 iOS App 像安裝 Mac App 一樣簡單。但 iOS App on Mac 在系統裡的行為跟原生 Mac App 有幾個關鍵差異，這些差異影響磁碟空間排查和 App 管理。&lt;/p>
&lt;h2 id="執行機制">執行機制&lt;/h2>
&lt;p>Apple Silicon 的 CPU 架構（arm64）跟 iPhone/iPad 相同，所以 iOS App 的二進位可以直接在 Mac 上跑，不需要轉譯或模擬。macOS 提供一個相容層，把 iOS 的觸控 API 對應到滑鼠和鍵盤操作，讓 iOS App 在 Mac 上有基本的操作體驗。這個相容層跟 Mac Catalyst（開發者主動移植 iPad App 到 Mac 的框架）是不同的機制——iOS App on Mac 不需要開發者做任何修改。&lt;/p>
&lt;p>這跟 &lt;a href="../macos_ios_simulator_runtime_architecture/">iOS Simulator&lt;/a> 是完全不同的機制。Simulator 跑的是為 Mac CPU 重新編譯的 iOS 框架，用於開發測試；iOS App on Mac 跑的是 App Store 上為 iPhone 編譯的原始二進位，用於日常使用。&lt;/p>
&lt;h2 id="container-用-uuid-命名">Container 用 UUID 命名&lt;/h2>
&lt;p>原生 Mac App 的 &lt;a href="../macos_app_sandbox_container/">沙箱容器&lt;/a> 用 bundle ID 當目錄名（例如 &lt;code>com.amazon.Lassen&lt;/code>）。iOS App on Mac 的容器用 UUID 當目錄名（例如 &lt;code>D678BD0C-AEB0-4E05-B0D2-58F5C45F0207&lt;/code>），從名字完全看不出是哪個 App。&lt;/p>
&lt;p>UUID 命名的原因是 iOS App 的容器管理走的是 iOS 的路徑——iOS 上本來就用 UUID 組織 App 資料，macOS 的相容層沿用了這個設計。&lt;/p>
&lt;p>辨識 UUID 容器的方法是讀 &lt;code>.com.apple.containermanagerd.metadata.plist&lt;/code> 裡的 &lt;code>MCMMetadataIdentifier&lt;/code> 欄位，需要 Full Disk Access 權限。操作流程見 &lt;a href="../macos_identify_app_containers/">辨識 App 容器&lt;/a>。&lt;/p>
&lt;h2 id="遊戲是主要的空間消耗者">遊戲是主要的空間消耗者&lt;/h2>
&lt;p>iOS App on Mac 裡佔用最大的幾乎都是遊戲。手遊的語音包、素材、更新包動輒數 GB，這些資源下載到 Container 的 &lt;code>Data/Documents/&lt;/code> 裡。一台 256G Apple Silicon Mac 上的實際案例：&lt;/p>
&lt;ul>
&lt;li>Epic Seven（第七史詩，&lt;code>com.stove.epic7.ios&lt;/code>）：9.7G&lt;/li>
&lt;li>Arknights（明日方舟台服，&lt;code>tw.txwy.ios.arknights&lt;/code>）：8.7G&lt;/li>
&lt;/ul>
&lt;p>這些資源是可重新下載的衍生物，帳號進度在遊戲伺服器端。刪除本地容器不影響帳號，重裝時需要重新下載資源。&lt;/p>
&lt;h2 id="空間佔用在-data-卷上">空間佔用在 Data 卷上&lt;/h2>
&lt;p>iOS App on Mac 的容器跟所有 App 資料一樣，儲存在 &lt;a href="../macos_apfs_volume_structure/">APFS container&lt;/a> 的 Data 卷上，共用空間池。遊戲類 App 的容器動輒數 GB，直接壓縮 Data 卷的可用空間。&lt;/p>
&lt;h2 id="不出現在-applications">不出現在 /Applications&lt;/h2>
&lt;p>iOS App on Mac 不一定出現在 &lt;code>/Applications&lt;/code> 資料夾或 Finder 的應用程式列表裡。它們的 &lt;code>.app&lt;/code> 本體放在系統管理的位置（通常在 &lt;code>/Applications&lt;/code> 的子目錄或 App Store 的快取路徑），Launchpad 能看到但 Finder 的應用程式資料夾不一定列出。&lt;/p>
&lt;p>用 &lt;code>du -shx /Applications/*.app&lt;/code> 排查空間大戶時會完全漏掉 iOS App。它們的空間佔用只出現在 &lt;code>~/Library/Containers/&lt;/code> 裡，而且因為 UUID 命名，不展開辨識就不知道是哪個 App。&lt;/p></description><content:encoded><![CDATA[<p>Apple Silicon Mac（M1 及之後）可以直接執行 iPhone 和 iPad App，不需要模擬器。這個功能在 macOS 11 Big Sur 引入，讓使用者從 Mac App Store 安裝 iOS App 像安裝 Mac App 一樣簡單。但 iOS App on Mac 在系統裡的行為跟原生 Mac App 有幾個關鍵差異，這些差異影響磁碟空間排查和 App 管理。</p>
<h2 id="執行機制">執行機制</h2>
<p>Apple Silicon 的 CPU 架構（arm64）跟 iPhone/iPad 相同，所以 iOS App 的二進位可以直接在 Mac 上跑，不需要轉譯或模擬。macOS 提供一個相容層，把 iOS 的觸控 API 對應到滑鼠和鍵盤操作，讓 iOS App 在 Mac 上有基本的操作體驗。這個相容層跟 Mac Catalyst（開發者主動移植 iPad App 到 Mac 的框架）是不同的機制——iOS App on Mac 不需要開發者做任何修改。</p>
<p>這跟 <a href="../macos_ios_simulator_runtime_architecture/">iOS Simulator</a> 是完全不同的機制。Simulator 跑的是為 Mac CPU 重新編譯的 iOS 框架，用於開發測試；iOS App on Mac 跑的是 App Store 上為 iPhone 編譯的原始二進位，用於日常使用。</p>
<h2 id="container-用-uuid-命名">Container 用 UUID 命名</h2>
<p>原生 Mac App 的 <a href="../macos_app_sandbox_container/">沙箱容器</a> 用 bundle ID 當目錄名（例如 <code>com.amazon.Lassen</code>）。iOS App on Mac 的容器用 UUID 當目錄名（例如 <code>D678BD0C-AEB0-4E05-B0D2-58F5C45F0207</code>），從名字完全看不出是哪個 App。</p>
<p>UUID 命名的原因是 iOS App 的容器管理走的是 iOS 的路徑——iOS 上本來就用 UUID 組織 App 資料，macOS 的相容層沿用了這個設計。</p>
<p>辨識 UUID 容器的方法是讀 <code>.com.apple.containermanagerd.metadata.plist</code> 裡的 <code>MCMMetadataIdentifier</code> 欄位，需要 Full Disk Access 權限。操作流程見 <a href="../macos_identify_app_containers/">辨識 App 容器</a>。</p>
<h2 id="遊戲是主要的空間消耗者">遊戲是主要的空間消耗者</h2>
<p>iOS App on Mac 裡佔用最大的幾乎都是遊戲。手遊的語音包、素材、更新包動輒數 GB，這些資源下載到 Container 的 <code>Data/Documents/</code> 裡。一台 256G Apple Silicon Mac 上的實際案例：</p>
<ul>
<li>Epic Seven（第七史詩，<code>com.stove.epic7.ios</code>）：9.7G</li>
<li>Arknights（明日方舟台服，<code>tw.txwy.ios.arknights</code>）：8.7G</li>
</ul>
<p>這些資源是可重新下載的衍生物，帳號進度在遊戲伺服器端。刪除本地容器不影響帳號，重裝時需要重新下載資源。</p>
<h2 id="空間佔用在-data-卷上">空間佔用在 Data 卷上</h2>
<p>iOS App on Mac 的容器跟所有 App 資料一樣，儲存在 <a href="../macos_apfs_volume_structure/">APFS container</a> 的 Data 卷上，共用空間池。遊戲類 App 的容器動輒數 GB，直接壓縮 Data 卷的可用空間。</p>
<h2 id="不出現在-applications">不出現在 /Applications</h2>
<p>iOS App on Mac 不一定出現在 <code>/Applications</code> 資料夾或 Finder 的應用程式列表裡。它們的 <code>.app</code> 本體放在系統管理的位置（通常在 <code>/Applications</code> 的子目錄或 App Store 的快取路徑），Launchpad 能看到但 Finder 的應用程式資料夾不一定列出。</p>
<p>用 <code>du -shx /Applications/*.app</code> 排查空間大戶時會完全漏掉 iOS App。它們的空間佔用只出現在 <code>~/Library/Containers/</code> 裡，而且因為 UUID 命名，不展開辨識就不知道是哪個 App。</p>
<h2 id="移除後容器可能殘留">移除後容器可能殘留</h2>
<p>從 Launchpad 長按刪除或從 App Store「已購項目」移除 iOS App 後，它的 Container 目錄不一定跟著消失。macOS 的清理機制有時會延遲或跳過。殘留的 Container 佔用空間但不再有對應的 App。</p>
<p>確認殘留的方式：Container 的 plist 裡有 <code>MCMMetadataIdentifier</code>（bundle ID），但系統裡找不到對應的已安裝 App。這時整個 Container 目錄可以安全刪除。</p>
<h2 id="已下架-app-的風險">已下架 App 的風險</h2>
<p>iOS App Store 裡的 App 可能被開發者下架或因區域限制無法取得。如果一個 iOS App on Mac 已經從 App Store 移除，刪掉本地容器和 App 後就無法重新安裝。對於有替代品的 App 影響不大；對於特定地區限定的遊戲（例如只在日本或台灣 App Store 上架的版本），刪除前要考慮這個不可逆性。</p>
]]></content:encoded></item><item><title>iOS Simulator Runtime 架構：為什麼一組要 16G</title><link>https://tarrragon.github.io/blog/macos/macos_ios_simulator_runtime_architecture/</link><pubDate>Sun, 05 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/macos/macos_ios_simulator_runtime_architecture/</guid><description>&lt;p>iOS Simulator 讓開發者在 Mac 上跑 iOS App 而不需要實體裝置。每個 Simulator runtime 對應一個 iOS 版本——裝了 iOS 18.2 和 18.3.1 兩組 runtime，就能在兩個版本上測試。每組 runtime 約 16G，是 Mac 上最大的單一空間消耗者之一，但這個數字在日常開發中不容易察覺，直到磁碟空間不夠才會被發現。&lt;/p>
&lt;h2 id="一組-runtime-的組成">一組 Runtime 的組成&lt;/h2>
&lt;p>一組 runtime 的體積幾乎全部來自兩個 DMG（disk image）——Bundle 和 Runtime 各承擔不同的職責。&lt;/p>
&lt;p>&lt;strong>Bundle DMG&lt;/strong>（約 8G）：包含 Simulator 的 Cryptex 安全元件和啟動資料。儲存在 &lt;code>/Library/Developer/CoreSimulator/Cryptex/Images/bundle/&lt;/code> 下。&lt;/p>
&lt;p>&lt;strong>Runtime DMG&lt;/strong>（約 8G）：包含完整的 iOS 檔案系統——所有系統框架、內建 App、字型、語言資源。儲存在 &lt;code>/System/Library/AssetsV2/com_apple_MobileAsset_iOSSimulatorRuntime/&lt;/code> 下。&lt;/p>
&lt;p>兩個 DMG 合起來約 16G，是一組 runtime 的完整大小。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 查看已安裝的 runtime 和總大小&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">xcrun simctl runtime list&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&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">== Disk Images ==
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">-- iOS --
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">iOS 18.3.1 (22D8075) - 07012283-CA3D-4437-8A5F-89AE5346EEED (Ready)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">Total Disk Images: 1 (8.1G)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>simctl runtime list&lt;/code> 報的 8.1G 是 Runtime DMG 的大小，沒有包含 Bundle DMG。完整佔用要兩者相加。&lt;/p>
&lt;h2 id="dmg-掛載成獨立-apfs-container">DMG 掛載成獨立 APFS Container&lt;/h2>
&lt;p>使用 Simulator 時，macOS 把這兩個 DMG 掛載成獨立的 APFS Container。&lt;code>diskutil list&lt;/code> 會看到它們以 &lt;code>(disk image)&lt;/code> 標示：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">/dev/disk4 (disk image): → iOS 18.3.1 Simulator Bundle 9.0 GB
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">/dev/disk6 (disk image): → iOS 18.3.1 Simulator 19.8 GB&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這些 Container 的 Physical Store 是 DMG 檔案本身——實際的磁碟空間消耗在主 Container（disk3）的 Data 卷上。刪除 runtime 回收的空間會反映在 Data 卷的可用空間，而不是讓這些 disk image Container 消失（它們會自動卸載）。&lt;/p>
&lt;p>Container 顯示的大小（9.0G、19.8G）是 DMG 內部的邏輯容量，比 DMG 檔案的實際磁碟佔用（各約 8G）略大，因為檔案系統有自己的 metadata 開銷。判斷空間佔用時看 DMG 檔案的 &lt;code>du&lt;/code> 值，不看 Container 的 Size。&lt;/p>
&lt;h2 id="為什麼每組要-16g">為什麼每組要 16G&lt;/h2>
&lt;p>iOS Simulator 是完整的 iOS 使用者空間環境——App 在 Simulator 上跑時，呼叫的是真正的 iOS 系統框架（UIKit、Foundation、SwiftUI），這些框架需要完整打包在 runtime 裡。Simulator 跑的是 x86_64 或 arm64 原生碼（Apple Silicon Mac 上是 arm64），所以框架二進位跟實體裝置上的不同，必須另外編譯一套。&lt;/p>
&lt;p>16G 裡大約的分佈：&lt;/p>
&lt;ul>
&lt;li>系統框架和 dyld shared cache：佔最大宗，包含所有 iOS 框架的預連結版本&lt;/li>
&lt;li>內建 App（Safari、設定、照片等）：Simulator 需要完整的系統環境&lt;/li>
&lt;li>多語言資源：字型、語言包、輔助使用資源&lt;/li>
&lt;li>Cryptex 安全元件：跟 macOS 的 Cryptex 機制類似（見 &lt;a href="../macos_preboot_volume_check/">Preboot 卷&lt;/a>），讓 Simulator 的安全元件可以獨立更新&lt;/li>
&lt;/ul>
&lt;p>相鄰版本（如 18.2 和 18.3.1）共用大部分內容，但 Apple 目前不做跨版本 delta 壓縮——每組 runtime 是完整獨立的。這是為什麼裝兩組就是 32G、三組就是 48G，線性增長。&lt;/p></description><content:encoded><![CDATA[<p>iOS Simulator 讓開發者在 Mac 上跑 iOS App 而不需要實體裝置。每個 Simulator runtime 對應一個 iOS 版本——裝了 iOS 18.2 和 18.3.1 兩組 runtime，就能在兩個版本上測試。每組 runtime 約 16G，是 Mac 上最大的單一空間消耗者之一，但這個數字在日常開發中不容易察覺，直到磁碟空間不夠才會被發現。</p>
<h2 id="一組-runtime-的組成">一組 Runtime 的組成</h2>
<p>一組 runtime 的體積幾乎全部來自兩個 DMG（disk image）——Bundle 和 Runtime 各承擔不同的職責。</p>
<p><strong>Bundle DMG</strong>（約 8G）：包含 Simulator 的 Cryptex 安全元件和啟動資料。儲存在 <code>/Library/Developer/CoreSimulator/Cryptex/Images/bundle/</code> 下。</p>
<p><strong>Runtime DMG</strong>（約 8G）：包含完整的 iOS 檔案系統——所有系統框架、內建 App、字型、語言資源。儲存在 <code>/System/Library/AssetsV2/com_apple_MobileAsset_iOSSimulatorRuntime/</code> 下。</p>
<p>兩個 DMG 合起來約 16G，是一組 runtime 的完整大小。</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="c1"># 查看已安裝的 runtime 和總大小</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">xcrun simctl runtime list</span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">== Disk Images ==
</span></span><span class="line"><span class="ln">2</span><span class="cl">-- iOS --
</span></span><span class="line"><span class="ln">3</span><span class="cl">iOS 18.3.1 (22D8075) - 07012283-CA3D-4437-8A5F-89AE5346EEED (Ready)
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">Total Disk Images: 1 (8.1G)</span></span></code></pre></div><p><code>simctl runtime list</code> 報的 8.1G 是 Runtime DMG 的大小，沒有包含 Bundle DMG。完整佔用要兩者相加。</p>
<h2 id="dmg-掛載成獨立-apfs-container">DMG 掛載成獨立 APFS Container</h2>
<p>使用 Simulator 時，macOS 把這兩個 DMG 掛載成獨立的 APFS Container。<code>diskutil list</code> 會看到它們以 <code>(disk image)</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">/dev/disk4 (disk image):    →  iOS 18.3.1 Simulator Bundle    9.0 GB
</span></span><span class="line"><span class="ln">2</span><span class="cl">/dev/disk6 (disk image):    →  iOS 18.3.1 Simulator           19.8 GB</span></span></code></pre></div><p>這些 Container 的 Physical Store 是 DMG 檔案本身——實際的磁碟空間消耗在主 Container（disk3）的 Data 卷上。刪除 runtime 回收的空間會反映在 Data 卷的可用空間，而不是讓這些 disk image Container 消失（它們會自動卸載）。</p>
<p>Container 顯示的大小（9.0G、19.8G）是 DMG 內部的邏輯容量，比 DMG 檔案的實際磁碟佔用（各約 8G）略大，因為檔案系統有自己的 metadata 開銷。判斷空間佔用時看 DMG 檔案的 <code>du</code> 值，不看 Container 的 Size。</p>
<h2 id="為什麼每組要-16g">為什麼每組要 16G</h2>
<p>iOS Simulator 是完整的 iOS 使用者空間環境——App 在 Simulator 上跑時，呼叫的是真正的 iOS 系統框架（UIKit、Foundation、SwiftUI），這些框架需要完整打包在 runtime 裡。Simulator 跑的是 x86_64 或 arm64 原生碼（Apple Silicon Mac 上是 arm64），所以框架二進位跟實體裝置上的不同，必須另外編譯一套。</p>
<p>16G 裡大約的分佈：</p>
<ul>
<li>系統框架和 dyld shared cache：佔最大宗，包含所有 iOS 框架的預連結版本</li>
<li>內建 App（Safari、設定、照片等）：Simulator 需要完整的系統環境</li>
<li>多語言資源：字型、語言包、輔助使用資源</li>
<li>Cryptex 安全元件：跟 macOS 的 Cryptex 機制類似（見 <a href="../macos_preboot_volume_check/">Preboot 卷</a>），讓 Simulator 的安全元件可以獨立更新</li>
</ul>
<p>相鄰版本（如 18.2 和 18.3.1）共用大部分內容，但 Apple 目前不做跨版本 delta 壓縮——每組 runtime 是完整獨立的。這是為什麼裝兩組就是 32G、三組就是 48G，線性增長。</p>
<h2 id="裝幾組合理">裝幾組合理</h2>
<p>判準是開發工作實際需要測試的 iOS 版本數量：</p>
<p><strong>一組（最新版）</strong>：適合多數開發者。Flutter 和 SwiftUI 開發通常只需要在最新 iOS 上測試，舊版相容性問題靠 API availability check 處理，不需要實際跑 Simulator。</p>
<p><strong>兩組（最新 + 上一個大版本）</strong>：App 的 deployment target 設在上一個大版本（例如 iOS 17）時，需要在該版本上跑 UI 測試確認佈局和行為。</p>
<p><strong>三組以上</strong>：企業 App 支援更舊的 iOS 版本，或需要測試跨版本升級行為。這種情況下空間成本是已知的取捨。</p>
<p>不確定時，先裝最新版，需要舊版時再下載——Xcode 會在建置或開啟 Simulator 時提示，下載約需 30 分鐘（視網路速度）。</p>
<h2 id="跟磁碟空間排查的關係">跟磁碟空間排查的關係</h2>
<p>Simulator runtime 的空間佔用有兩個特徵讓它在排查時容易被忽略：</p>
<ol>
<li>
<p><strong>DMG 檔案不在家目錄下</strong>：<code>du -shx ~/*</code> 不會掃到它們，因為 DMG 儲存在 <code>/System/Library/AssetsV2/</code> 和 <code>/Library/Developer/CoreSimulator/</code>。家目錄層的排查會完全漏掉這塊。</p>
</li>
<li>
<p><strong>掛載後的 Container 看起來像獨立磁碟</strong>：<code>diskutil apfs list</code> 列出的 Simulator Container 容易被誤認為是獨立的磁碟空間佔用，但它們的 Physical Store 是 Data 卷上的 DMG 檔案。理解 APFS 的 Container/Physical Store 關係（見 <a href="../macos_apfs_volume_structure/">APFS 卷結構</a>）後，這個混淆就消除了。</p>
</li>
</ol>
<p><a href="https://github.com/tarrragon/scripts">disk-report</a> 腳本的完整診斷模式會列出已安裝的 Simulator runtime，多版本時標記 redundant。移除多餘版本的操作流程見 <a href="../macos_remove_redundant_ios_simulators/">移除多餘 iOS Simulator</a>。</p>
]]></content:encoded></item><item><title>macOS APFS 卷結構與空間池：為什麼 df 的數字跟你想的不一樣</title><link>https://tarrragon.github.io/blog/macos/macos_apfs_volume_structure/</link><pubDate>Sun, 05 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/macos/macos_apfs_volume_structure/</guid><description>&lt;p>macOS 的檔案系統從 HFS+ 換到 APFS（Apple File System）後，「磁碟空間」的概念從一對一（一個分割區對應一塊空間）變成多對一（多個卷共用一個空間池）。排查空間問題時，如果用 HFS+ 時代「每個分割區有自己的容量」的心智模型去讀 APFS 的數字，會得到矛盾的結果。&lt;/p>
&lt;h2 id="三層架構physical-store--container--volume">三層架構：Physical Store → Container → Volume&lt;/h2>
&lt;p>APFS 把一顆實體磁碟組織成三層：&lt;/p>
&lt;p>&lt;strong>Physical Store&lt;/strong> 是實體磁碟上的一個分割區。一台 Mac 的內建 SSD 通常有三個分割區：ISC（iBoot System Container，約 500MB，安全啟動用）、主分割區（幾乎佔滿整顆磁碟）、Recovery（約 5G）。主分割區裝載一個 APFS Container。&lt;/p>
&lt;p>&lt;strong>Container&lt;/strong>（APFS Container，磁碟空間管理單位，跟 &lt;a href="../macos_app_sandbox_container/">App Sandbox 的 Container&lt;/a> 是不同層級的概念）是空間管理的最上層單位。它從 Physical Store 拿到一塊空間，再分配給底下的 Volume。關鍵設計是：Container 裡的所有 Volume 共用同一個空間池，沒有預先劃分的配額。任何一個 Volume 都能用到整個 Container 的剩餘空間，但任何一個 Volume 膨脹也會壓縮其他 Volume 的可用空間。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 查看 Container 的空間分配&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">diskutil apfs list&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這台機器的 Container disk3 有 245.1GB，底下五個 Volume 共用這個池：&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">Container disk3 (245.1 GB)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── Macintosh HD (System) 11.2 GB — 唯讀系統卷，sealed
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">├── Preboot 8.5 GB — 開機前置資料 + [Cryptex](../macos_preboot_volume_check/)（安全更新元件）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">├── Recovery 2.2 GB — 復原模式
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">├── Data 207.7 GB — 使用者資料，日常操作都在這
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">└── VM 6.5 GB — 虛擬記憶體 swap
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> 剩餘 8.9 GB — Container 未分配空間&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Volume&lt;/strong> 是檔案系統的操作單位——目錄、檔案、權限都在這層。每個 Volume 有自己的 Role（System、Data、Preboot、Recovery、VM），決定它的用途和掛載方式。&lt;/p>
&lt;h2 id="volume-groupsystem-跟-data-的綁定關係">Volume Group：System 跟 Data 的綁定關係&lt;/h2>
&lt;p>macOS 把 System 卷和 Data 卷綁成一個 Volume Group。System 卷是唯讀的（sealed），放作業系統本體；Data 卷放使用者資料。開機時系統把兩者疊在一起，使用者看到的 &lt;code>/&lt;/code> 是 System 卷的內容，&lt;code>/Users&lt;/code>、&lt;code>/Applications&lt;/code> 等路徑透過 firmlink（APFS 特有的雙向連結，讓兩個卷的目錄看起來像同一棵樹）指向 Data 卷。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 確認 Volume Group&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">diskutil info /System/Volumes/Data &lt;span class="p">|&lt;/span> grep &lt;span class="s2">&amp;#34;APFS Volume Group&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Volume Group UUID 在排查 Preboot 時很重要——Preboot 卷裡的開機資料是按 Volume Group UUID 組織的，一個 UUID 對應一套完整的開機資料。正常情況只有一個 UUID（見 &lt;a href="../macos_preboot_volume_check/">Preboot 卷&lt;/a>）。&lt;/p>
&lt;h2 id="空間池對排查的影響">空間池對排查的影響&lt;/h2>
&lt;p>共用空間池意味著不能把「各 Volume 的 Capacity Consumed 加起來」當成已用空間——因為 APFS 有 clone（多個檔案共用底層資料區塊）和 snapshot（時間點凍結），這些機制讓「屬於 Volume A 的區塊」和「屬於 Volume B 的區塊」之間有共用關係。&lt;/p>
&lt;p>判斷「還剩多少可用空間」要看 Container 層的 Capacity Not Allocated，這是扣除所有 Volume 和 snapshot 的實際佔用後的真正剩餘：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># Container 層的未分配空間（真正的剩餘）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">diskutil apfs list &lt;span class="p">|&lt;/span> grep -A5 &lt;span class="s2">&amp;#34;Container disk3&amp;#34;&lt;/span> &lt;span class="p">|&lt;/span> grep &lt;span class="s2">&amp;#34;Capacity Not Allocated&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>df -h /&lt;/code> 或 &lt;code>df -h /System/Volumes/Data&lt;/code> 顯示的 Available 也反映 Container 層的可用空間，但呈現方式不同——&lt;code>df&lt;/code> 把整個 Container 的可用空間都算成 Data 卷的可用，因為 Data 卷可以用到池裡的任何剩餘空間。&lt;/p></description><content:encoded><![CDATA[<p>macOS 的檔案系統從 HFS+ 換到 APFS（Apple File System）後，「磁碟空間」的概念從一對一（一個分割區對應一塊空間）變成多對一（多個卷共用一個空間池）。排查空間問題時，如果用 HFS+ 時代「每個分割區有自己的容量」的心智模型去讀 APFS 的數字，會得到矛盾的結果。</p>
<h2 id="三層架構physical-store--container--volume">三層架構：Physical Store → Container → Volume</h2>
<p>APFS 把一顆實體磁碟組織成三層：</p>
<p><strong>Physical Store</strong> 是實體磁碟上的一個分割區。一台 Mac 的內建 SSD 通常有三個分割區：ISC（iBoot System Container，約 500MB，安全啟動用）、主分割區（幾乎佔滿整顆磁碟）、Recovery（約 5G）。主分割區裝載一個 APFS Container。</p>
<p><strong>Container</strong>（APFS Container，磁碟空間管理單位，跟 <a href="../macos_app_sandbox_container/">App Sandbox 的 Container</a> 是不同層級的概念）是空間管理的最上層單位。它從 Physical Store 拿到一塊空間，再分配給底下的 Volume。關鍵設計是：Container 裡的所有 Volume 共用同一個空間池，沒有預先劃分的配額。任何一個 Volume 都能用到整個 Container 的剩餘空間，但任何一個 Volume 膨脹也會壓縮其他 Volume 的可用空間。</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="c1"># 查看 Container 的空間分配</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">diskutil apfs list</span></span></code></pre></div><p>這台機器的 Container disk3 有 245.1GB，底下五個 Volume 共用這個池：</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">Container disk3 (245.1 GB)
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── Macintosh HD    (System)   11.2 GB  — 唯讀系統卷，sealed
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── Preboot                    8.5 GB  — 開機前置資料 + [Cryptex](../macos_preboot_volume_check/)（安全更新元件）
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── Recovery                   2.2 GB  — 復原模式
</span></span><span class="line"><span class="ln">5</span><span class="cl">├── Data                     207.7 GB  — 使用者資料，日常操作都在這
</span></span><span class="line"><span class="ln">6</span><span class="cl">└── VM                         6.5 GB  — 虛擬記憶體 swap
</span></span><span class="line"><span class="ln">7</span><span class="cl">                          剩餘  8.9 GB  — Container 未分配空間</span></span></code></pre></div><p><strong>Volume</strong> 是檔案系統的操作單位——目錄、檔案、權限都在這層。每個 Volume 有自己的 Role（System、Data、Preboot、Recovery、VM），決定它的用途和掛載方式。</p>
<h2 id="volume-groupsystem-跟-data-的綁定關係">Volume Group：System 跟 Data 的綁定關係</h2>
<p>macOS 把 System 卷和 Data 卷綁成一個 Volume Group。System 卷是唯讀的（sealed），放作業系統本體；Data 卷放使用者資料。開機時系統把兩者疊在一起，使用者看到的 <code>/</code> 是 System 卷的內容，<code>/Users</code>、<code>/Applications</code> 等路徑透過 firmlink（APFS 特有的雙向連結，讓兩個卷的目錄看起來像同一棵樹）指向 Data 卷。</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="c1"># 確認 Volume Group</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">diskutil info /System/Volumes/Data <span class="p">|</span> grep <span class="s2">&#34;APFS Volume Group&#34;</span></span></span></code></pre></div><p>Volume Group UUID 在排查 Preboot 時很重要——Preboot 卷裡的開機資料是按 Volume Group UUID 組織的，一個 UUID 對應一套完整的開機資料。正常情況只有一個 UUID（見 <a href="../macos_preboot_volume_check/">Preboot 卷</a>）。</p>
<h2 id="空間池對排查的影響">空間池對排查的影響</h2>
<p>共用空間池意味著不能把「各 Volume 的 Capacity Consumed 加起來」當成已用空間——因為 APFS 有 clone（多個檔案共用底層資料區塊）和 snapshot（時間點凍結），這些機制讓「屬於 Volume A 的區塊」和「屬於 Volume B 的區塊」之間有共用關係。</p>
<p>判斷「還剩多少可用空間」要看 Container 層的 Capacity Not Allocated，這是扣除所有 Volume 和 snapshot 的實際佔用後的真正剩餘：</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="c1"># Container 層的未分配空間（真正的剩餘）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">diskutil apfs list <span class="p">|</span> grep -A5 <span class="s2">&#34;Container disk3&#34;</span> <span class="p">|</span> grep <span class="s2">&#34;Capacity Not Allocated&#34;</span></span></span></code></pre></div><p><code>df -h /</code> 或 <code>df -h /System/Volumes/Data</code> 顯示的 Available 也反映 Container 層的可用空間，但呈現方式不同——<code>df</code> 把整個 Container 的可用空間都算成 Data 卷的可用，因為 Data 卷可以用到池裡的任何剩餘空間。</p>
<h2 id="dudfdiskutil-數字不同的原因">du、df、diskutil 數字不同的原因</h2>
<p>三個工具看磁碟空間的視角不同，數字各有適用場景：</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>看的是什麼</th>
          <th>APFS clone 的處理</th>
          <th>適用場景</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>du -skx</code></td>
          <td>逐檔累加實際佔用區塊</td>
          <td>每個檔案各算一次</td>
          <td>找哪個目錄/檔案佔最多</td>
      </tr>
      <tr>
          <td><code>df -h</code></td>
          <td>檔案系統層的 used/available</td>
          <td>反映 Container 層實態</td>
          <td>快速看還剩多少</td>
      </tr>
      <tr>
          <td><code>diskutil info</code></td>
          <td>Volume 或 Container 層的 metadata</td>
          <td>共用區塊只算一次</td>
          <td>精確看某個 Volume 的實際佔用</td>
      </tr>
  </tbody>
</table>
<p>差異在 APFS clone。Clone 讓多個檔案共用同一份底層資料區塊——修改其中一個時才複製被改的區塊（copy-on-write）。<code>du</code> 逐檔計算，每個 clone 各算一次；<code>diskutil</code> 看 Volume 層的實際區塊佔用，共用的只算一次。</p>
<p>這就是為什麼用 <code>du</code> 量 Preboot 卷會得到比 <code>diskutil info</code> 更大的數字（見 <a href="../macos_preboot_volume_check/">Preboot 卷</a>）——Preboot 裡的 <code>os.dmg</code> 和 <code>os.clone.dmg</code> 是 APFS clone，<code>du</code> 把兩個檔案各算一次報約 11G，但 <code>diskutil</code> 看實際區塊佔用只有約 5.4G（單一 os.dmg 的大小），整個 Preboot 卷的 <code>diskutil</code> 佔用是 8.5G（含其他開機資料）。</p>
<p><strong>Sparse 檔案</strong>是另一個差異來源。Sparse 檔案宣告了一個邏輯大小，但只有寫入過的區塊才真正佔磁碟。<code>ls -l</code> 和 <code>find -size</code> 看的是邏輯大小，<code>du</code> 看的是實際佔用。容器映像（OrbStack、VM 磁碟映像）常是 sparse 檔，邏輯大小可能是實際佔用的數十倍。排查空間大戶時一律用 <code>du</code>，不信 <code>ls</code> 的數字。</p>
<h2 id="額外的-containerios-simulator">額外的 Container：iOS Simulator</h2>
<p>iOS Simulator runtime 以 disk image（DMG）形式儲存在 Data 卷上，開機或使用時掛載成獨立的 APFS Container。這些 Container 出現在 <code>diskutil apfs list</code> 裡，看起來像是佔了額外的磁碟空間，但它們的 Physical Store 是 disk image 檔案——實際空間消耗在 Data 卷上，不是獨立的實體分割區。</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="c1"># Simulator 的 disk image Container</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">diskutil list <span class="p">|</span> grep <span class="s2">&#34;disk image&#34;</span></span></span></code></pre></div><p>刪除 Simulator runtime 回收的空間會反映在 Data 卷（和 Container 的未分配空間），而不是讓某個 Container 消失。詳見 <a href="../macos_ios_simulator_runtime_architecture/">iOS Simulator runtime 架構</a>。</p>
]]></content:encoded></item><item><title>macOS App Sandbox 與 ~/Library/Containers 架構</title><link>https://tarrragon.github.io/blog/macos/macos_app_sandbox_container/</link><pubDate>Sun, 05 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/macos/macos_app_sandbox_container/</guid><description>&lt;p>&lt;code>~/Library/Containers&lt;/code> 是 macOS 沙箱（App Sandbox）機制的產物。每個啟用沙箱的 App 在這裡有一個獨立的目錄，作為該 App 的隔離環境。排查磁碟空間時，Containers 常是 &lt;code>~/Library&lt;/code> 裡最大的子目錄之一，理解它的結構才能判斷哪些佔用是可以清除的快取、哪些是動不得的使用者資料。&lt;/p>
&lt;h2 id="沙箱的設計目的">沙箱的設計目的&lt;/h2>
&lt;p>App Sandbox 限制每個 App 只能存取自己的資料，不能碰其他 App 或使用者的檔案。這個隔離不是建議性的——沙箱由作業系統核心強制執行，App 程式碼裡寫了讀取其他 App 目錄的路徑，系統會拒絕。&lt;/p>
&lt;p>Mac App Store 上架的 App 必須啟用沙箱。非 App Store 分發的 App 可以選擇不啟用，但越來越多開發者主動啟用以獲得使用者信任。&lt;/p>
&lt;p>每個沙箱 App 拿到的是一個完整的家目錄副本——裡面有 &lt;code>Documents&lt;/code>、&lt;code>Library&lt;/code>、&lt;code>Downloads&lt;/code> 等跟使用者家目錄一樣的子目錄，但 App 看到的路徑是自己的副本，不是使用者真正的家目錄。這些副本就放在 &lt;code>~/Library/Containers/&amp;lt;bundle-id&amp;gt;/Data/&lt;/code> 下。&lt;/p>
&lt;h2 id="container-的內部結構">Container 的內部結構&lt;/h2>
&lt;p>每個 Container 的結構是固定的：&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">~/Library/Containers/&amp;lt;bundle-id&amp;gt;/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">├── .com.apple.containermanagerd.metadata.plist # 容器 metadata
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">└── Data/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> ├── Documents/ # App 的文件（使用者資料）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> ├── Library/ # App 的 Library（設定、快取、資料庫）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> │ ├── Caches/ # 快取（可安全清除）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> │ ├── Preferences/# 設定
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> │ └── ...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> ├── Downloads/ # 有的是 symlink 到使用者的 ~/Downloads
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> ├── Desktop/ # 通常是 symlink
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> ├── tmp/ # 暫存
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> └── StoreKit/ # App Store 相關&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>Downloads&lt;/code>、&lt;code>Desktop&lt;/code>、&lt;code>Movies&lt;/code>、&lt;code>Music&lt;/code>、&lt;code>Pictures&lt;/code> 多數是 symlink（符號連結）指向使用者的對應目錄——沙箱允許 App 透過使用者授權存取這些位置。&lt;code>du&lt;/code> 加 &lt;code>-x&lt;/code> 旗標時不會跨越 symlink 計入，所以不會重複計算。&lt;/p>
&lt;h2 id="命名慣例bundle-id-vs-uuid">命名慣例：bundle ID vs UUID&lt;/h2>
&lt;p>Container 目錄的命名方式取決於 App 的來源。&lt;/p>
&lt;p>&lt;strong>Bundle ID&lt;/strong>（App 的唯一識別碼，格式為反向域名，例如 &lt;code>com.amazon.Lassen&lt;/code>）：Mac 原生 App 使用 bundle ID 當目錄名。從名字就能辨識——&lt;code>com.amazon&lt;/code> 是 Amazon、&lt;code>com.docker.docker&lt;/code> 是 Docker。&lt;/p>
&lt;p>&lt;strong>UUID&lt;/strong>（例如 &lt;code>D678BD0C-AEB0-4E05-B0D2-58F5C45F0207&lt;/code>）：&lt;a href="../macos_ios_app_on_mac/">iOS App on Mac&lt;/a> 使用 UUID 當目錄名，從名字完全無法辨識是哪個 App。辨識方法是讀 Container 根部的 &lt;code>.com.apple.containermanagerd.metadata.plist&lt;/code>（見 &lt;a href="../macos_identify_app_containers/">辨識 App 容器&lt;/a>）。&lt;/p>
&lt;h2 id="佔用的兩類快取-vs-資料">佔用的兩類：快取 vs 資料&lt;/h2>
&lt;p>清理 Container 時要判斷佔用的性質——快取刪了會自動重建，資料刪了就消失。&lt;/p>
&lt;p>&lt;strong>快取類&lt;/strong>（&lt;code>Data/Library/Caches/&lt;/code>、&lt;code>Data/tmp/&lt;/code>）：App 產生的衍生物，刪除後 App 自動重建。清除零風險，最多讓 App 下次啟動慢一點或需要重新登入。&lt;/p>
&lt;p>&lt;strong>資料類&lt;/strong>（&lt;code>Data/Documents/&lt;/code>、&lt;code>Data/Library/Application Support/&lt;/code>）：使用者資料——遊戲的下載資源、電子書的離線書庫、聊天紀錄、筆記資料庫。刪除後資料消失，要從雲端重新下載（如果有雲端同步的話）。&lt;/p>
&lt;p>兩類的大小比例因 App 而異。遊戲類 App 的 Documents 常佔 90% 以上（語音包、素材、更新包），快取比例很低。瀏覽器類 App 則相反，快取常佔大宗。排查時逐 App 看 &lt;code>Data/Documents&lt;/code> 和 &lt;code>Data/Library/Caches&lt;/code> 的大小分佈，才能判斷清掉能回收多少、風險是什麼。&lt;/p>
&lt;h2 id="跟-application-support-的分工">跟 Application Support 的分工&lt;/h2>
&lt;p>沙箱 App 的資料全部在自己的 Container 裡。非沙箱 App 的資料則散在 &lt;code>~/Library&lt;/code> 的公共位置——&lt;code>Application Support&lt;/code>、&lt;code>Caches&lt;/code>、&lt;code>Preferences&lt;/code> 等。同一個開發商的 App 可能有些啟用沙箱（Container 裡一份完整資料）、有些沒有（散在公共位置），兩者的佔用要分開看。&lt;/p></description><content:encoded><![CDATA[<p><code>~/Library/Containers</code> 是 macOS 沙箱（App Sandbox）機制的產物。每個啟用沙箱的 App 在這裡有一個獨立的目錄，作為該 App 的隔離環境。排查磁碟空間時，Containers 常是 <code>~/Library</code> 裡最大的子目錄之一，理解它的結構才能判斷哪些佔用是可以清除的快取、哪些是動不得的使用者資料。</p>
<h2 id="沙箱的設計目的">沙箱的設計目的</h2>
<p>App Sandbox 限制每個 App 只能存取自己的資料，不能碰其他 App 或使用者的檔案。這個隔離不是建議性的——沙箱由作業系統核心強制執行，App 程式碼裡寫了讀取其他 App 目錄的路徑，系統會拒絕。</p>
<p>Mac App Store 上架的 App 必須啟用沙箱。非 App Store 分發的 App 可以選擇不啟用，但越來越多開發者主動啟用以獲得使用者信任。</p>
<p>每個沙箱 App 拿到的是一個完整的家目錄副本——裡面有 <code>Documents</code>、<code>Library</code>、<code>Downloads</code> 等跟使用者家目錄一樣的子目錄，但 App 看到的路徑是自己的副本，不是使用者真正的家目錄。這些副本就放在 <code>~/Library/Containers/&lt;bundle-id&gt;/Data/</code> 下。</p>
<h2 id="container-的內部結構">Container 的內部結構</h2>
<p>每個 Container 的結構是固定的：</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">~/Library/Containers/&lt;bundle-id&gt;/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── .com.apple.containermanagerd.metadata.plist  # 容器 metadata
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">└── Data/
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    ├── Documents/      # App 的文件（使用者資料）
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    ├── Library/        # App 的 Library（設定、快取、資料庫）
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    │   ├── Caches/     # 快取（可安全清除）
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    │   ├── Preferences/# 設定
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    │   └── ...
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    ├── Downloads/      # 有的是 symlink 到使用者的 ~/Downloads
</span></span><span class="line"><span class="ln">10</span><span class="cl">    ├── Desktop/        # 通常是 symlink
</span></span><span class="line"><span class="ln">11</span><span class="cl">    ├── tmp/            # 暫存
</span></span><span class="line"><span class="ln">12</span><span class="cl">    └── StoreKit/       # App Store 相關</span></span></code></pre></div><p><code>Downloads</code>、<code>Desktop</code>、<code>Movies</code>、<code>Music</code>、<code>Pictures</code> 多數是 symlink（符號連結）指向使用者的對應目錄——沙箱允許 App 透過使用者授權存取這些位置。<code>du</code> 加 <code>-x</code> 旗標時不會跨越 symlink 計入，所以不會重複計算。</p>
<h2 id="命名慣例bundle-id-vs-uuid">命名慣例：bundle ID vs UUID</h2>
<p>Container 目錄的命名方式取決於 App 的來源。</p>
<p><strong>Bundle ID</strong>（App 的唯一識別碼，格式為反向域名，例如 <code>com.amazon.Lassen</code>）：Mac 原生 App 使用 bundle ID 當目錄名。從名字就能辨識——<code>com.amazon</code> 是 Amazon、<code>com.docker.docker</code> 是 Docker。</p>
<p><strong>UUID</strong>（例如 <code>D678BD0C-AEB0-4E05-B0D2-58F5C45F0207</code>）：<a href="../macos_ios_app_on_mac/">iOS App on Mac</a> 使用 UUID 當目錄名，從名字完全無法辨識是哪個 App。辨識方法是讀 Container 根部的 <code>.com.apple.containermanagerd.metadata.plist</code>（見 <a href="../macos_identify_app_containers/">辨識 App 容器</a>）。</p>
<h2 id="佔用的兩類快取-vs-資料">佔用的兩類：快取 vs 資料</h2>
<p>清理 Container 時要判斷佔用的性質——快取刪了會自動重建，資料刪了就消失。</p>
<p><strong>快取類</strong>（<code>Data/Library/Caches/</code>、<code>Data/tmp/</code>）：App 產生的衍生物，刪除後 App 自動重建。清除零風險，最多讓 App 下次啟動慢一點或需要重新登入。</p>
<p><strong>資料類</strong>（<code>Data/Documents/</code>、<code>Data/Library/Application Support/</code>）：使用者資料——遊戲的下載資源、電子書的離線書庫、聊天紀錄、筆記資料庫。刪除後資料消失，要從雲端重新下載（如果有雲端同步的話）。</p>
<p>兩類的大小比例因 App 而異。遊戲類 App 的 Documents 常佔 90% 以上（語音包、素材、更新包），快取比例很低。瀏覽器類 App 則相反，快取常佔大宗。排查時逐 App 看 <code>Data/Documents</code> 和 <code>Data/Library/Caches</code> 的大小分佈，才能判斷清掉能回收多少、風險是什麼。</p>
<h2 id="跟-application-support-的分工">跟 Application Support 的分工</h2>
<p>沙箱 App 的資料全部在自己的 Container 裡。非沙箱 App 的資料則散在 <code>~/Library</code> 的公共位置——<code>Application Support</code>、<code>Caches</code>、<code>Preferences</code> 等。同一個開發商的 App 可能有些啟用沙箱（Container 裡一份完整資料）、有些沒有（散在公共位置），兩者的佔用要分開看。</p>
<p><a href="../macos_app_footprint_report/">App 聚合佔用報告</a> 的 <code>app-report</code> 腳本把 Container 和公共位置的佔用聚合回各 App，就是處理這個分散的問題。</p>
<h2 id="group-containers">Group Containers</h2>
<p><code>~/Library/Group Containers/</code> 是同一個開發商旗下多個 App 共享資料的位置。目錄名前面多一段 team ID（10 碼英數，像 <code>HUAQ24HBR6.dev.orbstack</code>）。清理時要注意：動一個 Group Container 可能影響同廠商的多個 App。</p>
]]></content:encoded></item><item><title>macOS Preboot 卷：從 Intel 的 1G 到 Apple Silicon 的 8G</title><link>https://tarrragon.github.io/blog/macos/macos_preboot_volume_check/</link><pubDate>Sun, 05 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/macos/macos_preboot_volume_check/</guid><description>&lt;p>磁碟空間排查到 APFS container 層級時，&lt;code>diskutil apfs list&lt;/code> 會列出每個卷的佔用。Preboot 卷在 Apple Silicon Mac 上通常顯示 8-10G，如果用 Intel Mac 時代 1-2G 的經驗來判讀，會誤以為有問題。這個差異來自 Apple Silicon 引入的 Cryptex 機制，理解它的運作方式後，判斷 Preboot 的大小是否合理就不需要查表。&lt;/p>
&lt;h2 id="preboot-在開機流程中的角色">Preboot 在開機流程中的角色&lt;/h2>
&lt;p>Preboot 是 &lt;a href="../macos_apfs_volume_structure/">APFS container&lt;/a> 裡的五個卷之一，專門存放開機前置資料——開機載入器、核心快取、安全驗證票證。這些東西必須在 Data 卷解鎖之前就能讀取，所以放在獨立的卷裡。所有卷共用同一個空間池，Preboot 佔多少 Data 就少多少。&lt;/p>
&lt;h2 id="intel-mac-的-preboot簡單的開機載入器">Intel Mac 的 Preboot：簡單的開機載入器&lt;/h2>
&lt;p>在 Intel Mac 上，Preboot 的內容很單純：一組 &lt;code>boot.efi&lt;/code>（EFI 開機載入器）、少量開機設定、以及 FileVault 解鎖介面的資源。這些加起來通常只有 1-2G，而且幾乎不會變動——裝完系統後大小就固定了，不會隨使用時間膨脹。&lt;/p>
&lt;p>這個時期建立的經驗是：Preboot 應該很小，超過幾 GB 就是有問題。&lt;/p>
&lt;h2 id="apple-silicon-的-prebootcryptex-改變了容量結構">Apple Silicon 的 Preboot：Cryptex 改變了容量結構&lt;/h2>
&lt;p>Apple Silicon Mac 從 macOS 13 開始引入 Cryptex（Cryptographically Sealed Extension），Preboot 的內容和大小因此完全改變。&lt;/p>
&lt;p>Cryptex 解決的問題是安全更新的部署速度。在 Cryptex 之前，修一個 Safari 漏洞需要推一整版 macOS 更新——整個系統卷要重新驗證、重新封印。Cryptex 把 Safari 和系統安全元件從系統卷抽出來，封裝成獨立的簽章 DMG，放在 Preboot 卷裡。開機時系統把 Cryptex DMG 掛載、疊加到系統卷上層，效果等同於系統卷的一部分，但更新時只要替換這個 DMG，不動系統卷本體。&lt;/p>
&lt;p>這個設計的代價是 Preboot 的體積。一組 Cryptex 的 &lt;code>os.dmg&lt;/code> 約 5.4G，是 Preboot 佔用的主力。加上 &lt;code>restore&lt;/code> 開機復原資料（約 800M-1G）、各機型的 &lt;code>kernelcache&lt;/code>（每個約 30M，十幾個機型合計數百 MB）、以及 &lt;code>boot&lt;/code> 載入器（約 50M），Apple Silicon 的 Preboot 基本盤就在 6-8G 左右。&lt;/p>
&lt;h3 id="restore-staged更新機制的暫存區">restore-staged：更新機制的暫存區&lt;/h3>
&lt;p>Preboot 裡還有一個 &lt;code>restore-staged&lt;/code> 目錄，是系統更新機制的暫存區。macOS 會在背景下載更新資料（韌體映像、加密的 DMG、核心快取），先 staging 到這裡，等使用者同意安裝或自動維護窗口到來時才套用。&lt;/p>
&lt;p>這個目錄的大小在 0-2G 之間浮動，取決於是否有待套用的更新。它的內容不一定對應「當前版本的下一版」——一台 macOS 15.3.1 機器上的實測中，&lt;code>restore-staged&lt;/code> 裡的 &lt;code>RestoreVersion.plist&lt;/code> 顯示的版本號是 26.3.1（這是 plist 內部的 restore 版本號，跟 macOS 行銷版本號不同），而當前系統是 macOS 15.3.1。這表示 staging 的資料可能是 Apple 為未來升級預先準備的，跟「有待套用的安全更新」是不同的 staging 類型。&lt;/p>
&lt;h2 id="du-跟-diskutil-數字不一致的原因">du 跟 diskutil 數字不一致的原因&lt;/h2>
&lt;p>用 &lt;code>du&lt;/code> 量 Preboot 會得到比 &lt;code>diskutil info&lt;/code> 更大的數字（實測數據：&lt;code>du&lt;/code> 報 13G，&lt;code>diskutil&lt;/code> 報 8.5G）。原因是 Preboot 裡的 &lt;code>os.dmg&lt;/code> 和 &lt;code>os.clone.dmg&lt;/code> 是 APFS clone，&lt;code>du&lt;/code> 各算一次、&lt;code>diskutil&lt;/code> 共用區塊只算一次。du 和 diskutil 的差異機制見 &lt;a href="../macos_apfs_volume_structure/">APFS 卷結構&lt;/a>。&lt;/p>
&lt;p>判斷 Preboot 佔用時用 &lt;code>diskutil info&lt;/code> 的 Volume Used Space：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">diskutil info /System/Volumes/Preboot &lt;span class="p">|&lt;/span> grep &lt;span class="s2">&amp;#34;Volume Used Space&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="正常的-preboot-結構">正常的 Preboot 結構&lt;/h2>
&lt;p>一台 Apple Silicon + macOS 14-16 的機器，Preboot 的典型內部結構：&lt;/p></description><content:encoded><![CDATA[<p>磁碟空間排查到 APFS container 層級時，<code>diskutil apfs list</code> 會列出每個卷的佔用。Preboot 卷在 Apple Silicon Mac 上通常顯示 8-10G，如果用 Intel Mac 時代 1-2G 的經驗來判讀，會誤以為有問題。這個差異來自 Apple Silicon 引入的 Cryptex 機制，理解它的運作方式後，判斷 Preboot 的大小是否合理就不需要查表。</p>
<h2 id="preboot-在開機流程中的角色">Preboot 在開機流程中的角色</h2>
<p>Preboot 是 <a href="../macos_apfs_volume_structure/">APFS container</a> 裡的五個卷之一，專門存放開機前置資料——開機載入器、核心快取、安全驗證票證。這些東西必須在 Data 卷解鎖之前就能讀取，所以放在獨立的卷裡。所有卷共用同一個空間池，Preboot 佔多少 Data 就少多少。</p>
<h2 id="intel-mac-的-preboot簡單的開機載入器">Intel Mac 的 Preboot：簡單的開機載入器</h2>
<p>在 Intel Mac 上，Preboot 的內容很單純：一組 <code>boot.efi</code>（EFI 開機載入器）、少量開機設定、以及 FileVault 解鎖介面的資源。這些加起來通常只有 1-2G，而且幾乎不會變動——裝完系統後大小就固定了，不會隨使用時間膨脹。</p>
<p>這個時期建立的經驗是：Preboot 應該很小，超過幾 GB 就是有問題。</p>
<h2 id="apple-silicon-的-prebootcryptex-改變了容量結構">Apple Silicon 的 Preboot：Cryptex 改變了容量結構</h2>
<p>Apple Silicon Mac 從 macOS 13 開始引入 Cryptex（Cryptographically Sealed Extension），Preboot 的內容和大小因此完全改變。</p>
<p>Cryptex 解決的問題是安全更新的部署速度。在 Cryptex 之前，修一個 Safari 漏洞需要推一整版 macOS 更新——整個系統卷要重新驗證、重新封印。Cryptex 把 Safari 和系統安全元件從系統卷抽出來，封裝成獨立的簽章 DMG，放在 Preboot 卷裡。開機時系統把 Cryptex DMG 掛載、疊加到系統卷上層，效果等同於系統卷的一部分，但更新時只要替換這個 DMG，不動系統卷本體。</p>
<p>這個設計的代價是 Preboot 的體積。一組 Cryptex 的 <code>os.dmg</code> 約 5.4G，是 Preboot 佔用的主力。加上 <code>restore</code> 開機復原資料（約 800M-1G）、各機型的 <code>kernelcache</code>（每個約 30M，十幾個機型合計數百 MB）、以及 <code>boot</code> 載入器（約 50M），Apple Silicon 的 Preboot 基本盤就在 6-8G 左右。</p>
<h3 id="restore-staged更新機制的暫存區">restore-staged：更新機制的暫存區</h3>
<p>Preboot 裡還有一個 <code>restore-staged</code> 目錄，是系統更新機制的暫存區。macOS 會在背景下載更新資料（韌體映像、加密的 DMG、核心快取），先 staging 到這裡，等使用者同意安裝或自動維護窗口到來時才套用。</p>
<p>這個目錄的大小在 0-2G 之間浮動，取決於是否有待套用的更新。它的內容不一定對應「當前版本的下一版」——一台 macOS 15.3.1 機器上的實測中，<code>restore-staged</code> 裡的 <code>RestoreVersion.plist</code> 顯示的版本號是 26.3.1（這是 plist 內部的 restore 版本號，跟 macOS 行銷版本號不同），而當前系統是 macOS 15.3.1。這表示 staging 的資料可能是 Apple 為未來升級預先準備的，跟「有待套用的安全更新」是不同的 staging 類型。</p>
<h2 id="du-跟-diskutil-數字不一致的原因">du 跟 diskutil 數字不一致的原因</h2>
<p>用 <code>du</code> 量 Preboot 會得到比 <code>diskutil info</code> 更大的數字（實測數據：<code>du</code> 報 13G，<code>diskutil</code> 報 8.5G）。原因是 Preboot 裡的 <code>os.dmg</code> 和 <code>os.clone.dmg</code> 是 APFS clone，<code>du</code> 各算一次、<code>diskutil</code> 共用區塊只算一次。du 和 diskutil 的差異機制見 <a href="../macos_apfs_volume_structure/">APFS 卷結構</a>。</p>
<p>判斷 Preboot 佔用時用 <code>diskutil info</code> 的 Volume Used Space：</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">diskutil info /System/Volumes/Preboot <span class="p">|</span> grep <span class="s2">&#34;Volume Used Space&#34;</span></span></span></code></pre></div><h2 id="正常的-preboot-結構">正常的 Preboot 結構</h2>
<p>一台 Apple Silicon + macOS 14-16 的機器，Preboot 的典型內部結構：</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">/System/Volumes/Preboot/&lt;Volume-Group-UUID&gt;/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── cryptex1/current/       # Cryptex 本體（5-7G）
</span></span><span class="line"><span class="ln">3</span><span class="cl">│   ├── os.dmg              # 系統安全元件
</span></span><span class="line"><span class="ln">4</span><span class="cl">│   ├── os.clone.dmg        # APFS clone，不額外佔空間
</span></span><span class="line"><span class="ln">5</span><span class="cl">│   └── app.dmg             # App 層元件（~20M）
</span></span><span class="line"><span class="ln">6</span><span class="cl">├── restore-staged/         # 更新暫存（0-2G，浮動）
</span></span><span class="line"><span class="ln">7</span><span class="cl">├── restore/                # 復原用開機資料（~800M）
</span></span><span class="line"><span class="ln">8</span><span class="cl">├── boot/                   # 開機載入器（~50M）
</span></span><span class="line"><span class="ln">9</span><span class="cl">└── var/, usr/, ...         # 開機輔助資料（~25M）</span></span></code></pre></div><p><code>cryptex1/current/</code> 裡的 <code>os.dmg</code> 和 <code>app.dmg</code> 分別承擔不同層級的安全更新：OS Cryptex（<code>os.dmg</code>，約 5.4G）包含核心安全元件與底層系統框架；App Cryptex（<code>app.dmg</code>，約 20M）包含 Safari、WebKit 等使用者空間元件。兩者獨立更新——修 Safari 漏洞只需替換 <code>app.dmg</code>，修核心安全問題才動 <code>os.dmg</code>。</p>
<p>Volume Group UUID 正常情況只有一個，對應當前系統的 Data 卷。確認方式：</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="c1"># Preboot 裡的 UUID</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ls /System/Volumes/Preboot/ <span class="p">|</span> grep -E <span class="s1">&#39;^[0-9A-F]{8}-&#39;</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"># 當前系統的 Volume Group UUID</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">diskutil info /System/Volumes/Data <span class="p">|</span> grep <span class="s2">&#34;APFS Volume Group&#34;</span></span></span></code></pre></div><h2 id="什麼情況才真的有問題">什麼情況才真的有問題</h2>
<p>基於上面的機制，判讀 Preboot 大小是否合理的依據是結構性指標，不是絕對大小。</p>
<p><strong>多個 Volume Group UUID</strong>：Preboot 裡出現多個 UUID 目錄，表示曾有過多重 macOS 安裝。每組 UUID 各帶一份完整的 Cryptex 和開機資料，所以 Preboot 會翻倍。只有對應當前 Data 卷 UUID 的那個是必要的，其他是舊安裝殘留。這是 Preboot 超過 15G 最常見的原因。</p>
<p><strong>restore-staged 異常堆積</strong>：超過 3G 且日期距今超過一個月，可能是下載了更新但套用失敗或被中斷。正常的處理方式是在系統設定裡完成或重新下載更新，更新完成後系統會自動清理這個目錄。</p>
<p><strong>Cryptex 沒有成功替換</strong>：<code>cryptex1/</code> 目錄裡除了 <code>current</code> 還有其他子目錄，可能是舊版 Cryptex 沒被正常替換。安裝最新的安全更新通常能解決。</p>
<p>這些情況都不應該用手動刪檔的方式處理。Preboot 是安全啟動鏈的一部分，APFS 的 snapshot 和 clone 關係讓手動刪除可能影響到看不見的依賴。正確路徑是透過系統更新、Recovery 模式重裝、或 Apple Support 來處理。</p>
<h2 id="disk-report-的-preboot-段落">disk-report 的 Preboot 段落</h2>
<p>這些判讀邏輯已整合進 <a href="https://github.com/tarrragon/scripts">disk-report</a> 腳本的 <code>--preboot</code> 模式，用 <code>diskutil info</code>（不是 <code>du</code>）取實際佔用，計算 Volume Group UUID 數量，檢查 <code>restore-staged</code> 的大小和日期，輸出一行結論。完整磁碟診斷時（<code>disk-report</code> 不帶參數）會自動包含這個段落。</p>
]]></content:encoded></item><item><title>macOS 移除多餘的 iOS Simulator Runtime</title><link>https://tarrragon.github.io/blog/macos/macos_remove_redundant_ios_simulators/</link><pubDate>Sun, 05 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/macos/macos_remove_redundant_ios_simulators/</guid><description>&lt;p>每組 &lt;a href="../macos_ios_simulator_runtime_architecture/">iOS Simulator runtime&lt;/a> 約 16G（Bundle DMG + Runtime DMG），裝了兩個版本就佔 32G 以上。Flutter 和 Xcode 開發通常只需要最新版，舊版可以安全移除。移除的正確路徑是透過 Xcode 或 &lt;code>simctl&lt;/code> 指令，不是手動刪除 &lt;code>~/Library/Containers&lt;/code> 裡的檔案。&lt;/p>
&lt;h2 id="確認目前裝了幾組">確認目前裝了幾組&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">xcrun simctl runtime list&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>輸出範例：&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">== Disk Images ==
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">-- iOS --
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">iOS 18.2 (22C150) - 6286F286-FA4F-4877-AAEF-62A162C6A2C4 (Ready)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">iOS 18.3.1 (22D8075) - 07012283-CA3D-4437-8A5F-89AE5346EEED (Ready)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">Total Disk Images: 2 (16.2G)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩組各自獨立，可以分開移除。&lt;/p>
&lt;h2 id="移除流程">移除流程&lt;/h2>
&lt;p>先清掉綁在舊 runtime 上的模擬器裝置，再刪 runtime 本體。順序顛倒的話，runtime 可能因為仍被裝置參照而拒絕刪除。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 1. 關閉所有正在跑的模擬器&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">xcrun simctl shutdown all
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">killall Simulator 2&amp;gt;/dev/null
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1"># 2. 清掉不可用的模擬器裝置（綁在已刪 runtime 或舊版本上的）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">xcrun simctl delete unavailable
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1"># 3. 刪除指定 runtime（用 runtime list 裡的 identifier）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">xcrun simctl runtime delete 6286F286-FA4F-4877-AAEF-62A162C6A2C4
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1"># 4. 確認&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">xcrun simctl runtime list&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果步驟 3 跑完沒報錯但 runtime 還在列表裡，通常是因為還有模擬器裝置綁在上面。回到步驟 1 和 2 重跑，或用 &lt;code>xcrun simctl list devices&lt;/code> 查看哪些裝置還在用那個 runtime，手動刪掉它們：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 列出所有模擬器裝置&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">xcrun simctl list devices
&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="c1"># 刪除特定裝置&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">xcrun simctl delete &amp;lt;裝置UDID&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="為什麼不能手動刪檔案">為什麼不能手動刪檔案&lt;/h2>
&lt;p>一組 runtime 的資料散在多個位置（Runtime DMG、Bundle DMG、裝置資料、快取），各位置的關係和儲存機制見 &lt;a href="../macos_ios_simulator_runtime_architecture/">runtime 架構&lt;/a>。手動刪其中一處只會刪掉一部分，留下孤兒資料，讓 CoreSimulator 的索引跟實際檔案不一致。之後可能出現模擬器列表裡有裝置但開不了、或空間沒真正釋放的情況。&lt;code>simctl runtime delete&lt;/code> 會同步清理所有相關位置。&lt;/p>
&lt;h2 id="xcode-gui-的限制">Xcode GUI 的限制&lt;/h2>
&lt;p>Xcode &amp;gt; Settings &amp;gt; Platforms 也可以管理 runtime，但它把相鄰版本打包成同一個下載單位顯示。例如 iOS 18.2 和 18.3.1 在介面上可能顯示為一個 8.71GB 的「iOS 18.2 + iOS 18.3.1 Simulator」項目，無法單獨刪除其中一個。這時用 &lt;code>simctl&lt;/code> 指令是更精確的選擇。&lt;/p>
&lt;h2 id="刪完之後">刪完之後&lt;/h2>
&lt;p>重跑 &lt;a href="../macos_disk_space_diagnosis/">disk-report&lt;/a> 確認空間釋放。如果日後需要被刪除的版本，Xcode 會在建置或開啟模擬器時提示下載，或手動從 Settings &amp;gt; Platforms 下載。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 驗證空間釋放&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">df -h /System/Volumes/Data
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">xcrun simctl runtime list&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div></description><content:encoded><![CDATA[<p>每組 <a href="../macos_ios_simulator_runtime_architecture/">iOS Simulator runtime</a> 約 16G（Bundle DMG + Runtime DMG），裝了兩個版本就佔 32G 以上。Flutter 和 Xcode 開發通常只需要最新版，舊版可以安全移除。移除的正確路徑是透過 Xcode 或 <code>simctl</code> 指令，不是手動刪除 <code>~/Library/Containers</code> 裡的檔案。</p>
<h2 id="確認目前裝了幾組">確認目前裝了幾組</h2>





<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">xcrun simctl runtime list</span></span></code></pre></div><p>輸出範例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">== Disk Images ==
</span></span><span class="line"><span class="ln">2</span><span class="cl">-- iOS --
</span></span><span class="line"><span class="ln">3</span><span class="cl">iOS 18.2 (22C150) - 6286F286-FA4F-4877-AAEF-62A162C6A2C4 (Ready)
</span></span><span class="line"><span class="ln">4</span><span class="cl">iOS 18.3.1 (22D8075) - 07012283-CA3D-4437-8A5F-89AE5346EEED (Ready)
</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">Total Disk Images: 2 (16.2G)</span></span></code></pre></div><p>兩組各自獨立，可以分開移除。</p>
<h2 id="移除流程">移除流程</h2>
<p>先清掉綁在舊 runtime 上的模擬器裝置，再刪 runtime 本體。順序顛倒的話，runtime 可能因為仍被裝置參照而拒絕刪除。</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="c1"># 1. 關閉所有正在跑的模擬器</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">xcrun simctl shutdown all
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">killall Simulator 2&gt;/dev/null
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># 2. 清掉不可用的模擬器裝置（綁在已刪 runtime 或舊版本上的）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">xcrun simctl delete unavailable
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 3. 刪除指定 runtime（用 runtime list 裡的 identifier）</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">xcrun simctl runtime delete 6286F286-FA4F-4877-AAEF-62A162C6A2C4
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># 4. 確認</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">xcrun simctl runtime list</span></span></code></pre></div><p>如果步驟 3 跑完沒報錯但 runtime 還在列表裡，通常是因為還有模擬器裝置綁在上面。回到步驟 1 和 2 重跑，或用 <code>xcrun simctl list devices</code> 查看哪些裝置還在用那個 runtime，手動刪掉它們：</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="c1"># 列出所有模擬器裝置</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">xcrun simctl list devices
</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"># 刪除特定裝置</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">xcrun simctl delete &lt;裝置UDID&gt;</span></span></code></pre></div><h2 id="為什麼不能手動刪檔案">為什麼不能手動刪檔案</h2>
<p>一組 runtime 的資料散在多個位置（Runtime DMG、Bundle DMG、裝置資料、快取），各位置的關係和儲存機制見 <a href="../macos_ios_simulator_runtime_architecture/">runtime 架構</a>。手動刪其中一處只會刪掉一部分，留下孤兒資料，讓 CoreSimulator 的索引跟實際檔案不一致。之後可能出現模擬器列表裡有裝置但開不了、或空間沒真正釋放的情況。<code>simctl runtime delete</code> 會同步清理所有相關位置。</p>
<h2 id="xcode-gui-的限制">Xcode GUI 的限制</h2>
<p>Xcode &gt; Settings &gt; Platforms 也可以管理 runtime，但它把相鄰版本打包成同一個下載單位顯示。例如 iOS 18.2 和 18.3.1 在介面上可能顯示為一個 8.71GB 的「iOS 18.2 + iOS 18.3.1 Simulator」項目，無法單獨刪除其中一個。這時用 <code>simctl</code> 指令是更精確的選擇。</p>
<h2 id="刪完之後">刪完之後</h2>
<p>重跑 <a href="../macos_disk_space_diagnosis/">disk-report</a> 確認空間釋放。如果日後需要被刪除的版本，Xcode 會在建置或開啟模擬器時提示下載，或手動從 Settings &gt; Platforms 下載。</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="c1"># 驗證空間釋放</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">df -h /System/Volumes/Data
</span></span><span class="line"><span class="ln">3</span><span class="cl">xcrun simctl runtime list</span></span></code></pre></div>]]></content:encoded></item><item><title>macOS 辨識 ~/Library/Containers 裡的 App 容器</title><link>https://tarrragon.github.io/blog/macos/macos_identify_app_containers/</link><pubDate>Sun, 05 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/macos/macos_identify_app_containers/</guid><description>&lt;p>&lt;a href="../macos_app_sandbox_container/">~/Library/Containers&lt;/a> 裡每個 App 一個目錄。原生 Mac App 用 bundle ID 命名（&lt;code>com.amazon.Lassen&lt;/code>），從名字就能辨識；&lt;a href="../macos_ios_app_on_mac/">iOS App on Mac&lt;/a> 用 UUID 命名（&lt;code>D678BD0C-AEB0-4E05-B0D2-58F5C45F0207&lt;/code>），&lt;code>du&lt;/code> 只看到一個佔了 10G 的 UUID，完全不知道是什麼。&lt;/p>
&lt;h2 id="讀-container-metadata-plist">讀 container metadata plist&lt;/h2>
&lt;p>每個容器目錄根部都有一個 &lt;code>.com.apple.containermanagerd.metadata.plist&lt;/code>，裡面的 &lt;code>MCMMetadataIdentifier&lt;/code> 欄位就是 bundle ID。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">plutil -extract MCMMetadataIdentifier raw &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> ~/Library/Containers/&amp;lt;目錄名&amp;gt;/.com.apple.containermanagerd.metadata.plist&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個指令需要 &lt;strong>Full Disk Access&lt;/strong> 權限。授權路徑：系統設定 &amp;gt; 隱私權與安全性 &amp;gt; 完整磁碟取用權限，把終端機加進去，完全退出再重開。&lt;/p>
&lt;p>取得 bundle ID 之後，用 App Store 搜尋或 Google 就能確認是哪個 App。&lt;/p>
&lt;h2 id="批次辨識所有-uuid-容器">批次辨識所有 UUID 容器&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">for&lt;/span> dir in ~/Library/Containers/*/&lt;span class="p">;&lt;/span> &lt;span class="k">do&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>basename &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$dir&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$name&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="p">|&lt;/span> grep -qE &lt;span class="s1">&amp;#39;^[0-9A-F]{8}-&amp;#39;&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nv">kb&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>du -skx &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$dir&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> 2&amp;gt;/dev/null &lt;span class="p">|&lt;/span> awk &lt;span class="s1">&amp;#39;{print $1}&amp;#39;&lt;/span>&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nv">bid&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>plutil -extract MCMMetadataIdentifier raw &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$dir&lt;/span>&lt;span class="s2">/.com.apple.containermanagerd.metadata.plist&amp;#34;&lt;/span> 2&amp;gt;/dev/null&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nv">ios&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>plutil -extract iOSAppOnMac raw &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$dir&lt;/span>&lt;span class="s2">/.com.apple.containermanagerd.metadata.plist&amp;#34;&lt;/span> 2&amp;gt;/dev/null&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nv">platform&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="o">[[&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$ios&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span> &lt;span class="o">]]&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nv">platform&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34; [iOS on Mac]&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nb">printf&lt;/span> &lt;span class="s1">&amp;#39;%6sM %-40s %s%s\n&amp;#39;&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$((&lt;/span>kb &lt;span class="o">/&lt;/span> &lt;span class="m">1024&lt;/span>&lt;span class="k">))&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nv">bid&lt;/span>&lt;span class="k">:-&lt;/span>&lt;span class="p">（無法讀取）&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$name&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$platform&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="k">done&lt;/span> &lt;span class="p">|&lt;/span> sort -rn&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>輸出範例：&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"> 9961M com.stove.epic7.ios D678BD0C-... [iOS on Mac]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> 8903M tw.txwy.ios.arknights 874686B0-... [iOS on Mac]&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="清除判斷">清除判斷&lt;/h2>
&lt;p>辨識出 App 之後，清除判斷依 &lt;a href="../macos_app_sandbox_container/">Container 的內部結構&lt;/a> 而定：&lt;/p>
&lt;ul>
&lt;li>&lt;code>Data/Library/Caches/&lt;/code> — 快取，清除零風險&lt;/li>
&lt;li>&lt;code>Data/Documents/&lt;/code> — 使用者資料或遊戲資源。遊戲資源是可重新下載的衍生物（帳號進度在伺服器端），確認不再玩可整個刪除。電子書庫、筆記資料庫等則需要確認有雲端備份&lt;/li>
&lt;li>&lt;a href="../macos_ios_app_on_mac/">iOS App on Mac&lt;/a> 的 App 如果已從 App Store 下架，刪除容器後可能無法重新安裝&lt;/li>
&lt;/ul>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 確認後刪除（不可逆）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">rm -rf ~/Library/Containers/&amp;lt;UUID&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="steam-遊戲的辨識">Steam 遊戲的辨識&lt;/h2>
&lt;p>Steam 遊戲的儲存機制跟 App Container 不同——資料集中在 &lt;code>~/Library/Application Support/Steam/steamapps/common/&lt;/code>，每個遊戲一個子目錄，目錄名就是遊戲名稱。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">du -shx ~/Library/Application&lt;span class="se">\ &lt;/span>Support/Steam/steamapps/common/* 2&amp;gt;/dev/null &lt;span class="p">|&lt;/span> sort -rh&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>回收空間從 Steam App 內解除安裝，手動刪 &lt;code>common/&lt;/code> 下的目錄會讓 Steam 的 manifest 跟實際檔案不一致。&lt;/p>
&lt;h2 id="content-probing-為什麼不可靠">Content probing 為什麼不可靠&lt;/h2>
&lt;p>實際發生過的誤判案例：兩個 UUID 容器被 content probing（探測目錄內容特徵）誤判為 HoYoverse 遊戲。實際讀 plist 後發現是 Epic Seven（第七史詩）和 Arknights（明日方舟），完全不同的廠商。&lt;/p>
&lt;p>Content probing 的根本問題是同樣的目錄名（&lt;code>data.pack&lt;/code>）在不同遊戲裡代表不同東西，遊戲引擎共用（Unity、Unreal）會產生相似的目錄結構。一個 heuristic 命中只代表結構相似，輸出一個看起來確定的名字會導致使用者基於錯誤資訊做決策。&lt;/p>
&lt;p>讀 plist 的 bundle ID 是唯一可靠的辨識方法。Content probing 只能作為 plist 讀不到時的 fallback，且必須標示「無法確認，僅供參考」。&lt;/p></description><content:encoded><![CDATA[<p><a href="../macos_app_sandbox_container/">~/Library/Containers</a> 裡每個 App 一個目錄。原生 Mac App 用 bundle ID 命名（<code>com.amazon.Lassen</code>），從名字就能辨識；<a href="../macos_ios_app_on_mac/">iOS App on Mac</a> 用 UUID 命名（<code>D678BD0C-AEB0-4E05-B0D2-58F5C45F0207</code>），<code>du</code> 只看到一個佔了 10G 的 UUID，完全不知道是什麼。</p>
<h2 id="讀-container-metadata-plist">讀 container metadata plist</h2>
<p>每個容器目錄根部都有一個 <code>.com.apple.containermanagerd.metadata.plist</code>，裡面的 <code>MCMMetadataIdentifier</code> 欄位就是 bundle ID。</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">plutil -extract MCMMetadataIdentifier raw <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  ~/Library/Containers/&lt;目錄名&gt;/.com.apple.containermanagerd.metadata.plist</span></span></code></pre></div><p>這個指令需要 <strong>Full Disk Access</strong> 權限。授權路徑：系統設定 &gt; 隱私權與安全性 &gt; 完整磁碟取用權限，把終端機加進去，完全退出再重開。</p>
<p>取得 bundle ID 之後，用 App Store 搜尋或 Google 就能確認是哪個 App。</p>
<h2 id="批次辨識所有-uuid-容器">批次辨識所有 UUID 容器</h2>





<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="k">for</span> dir in ~/Library/Containers/*/<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nv">name</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="nv">$dir</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$name</span><span class="s2">&#34;</span> <span class="p">|</span> grep -qE <span class="s1">&#39;^[0-9A-F]{8}-&#39;</span> <span class="o">||</span> <span class="k">continue</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nv">kb</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>du -skx <span class="s2">&#34;</span><span class="nv">$dir</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="p">|</span> awk <span class="s1">&#39;{print $1}&#39;</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nv">bid</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>plutil -extract MCMMetadataIdentifier raw <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="se"></span>    <span class="s2">&#34;</span><span class="nv">$dir</span><span class="s2">/.com.apple.containermanagerd.metadata.plist&#34;</span> 2&gt;/dev/null<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nv">ios</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>plutil -extract iOSAppOnMac raw <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span>    <span class="s2">&#34;</span><span class="nv">$dir</span><span class="s2">/.com.apple.containermanagerd.metadata.plist&#34;</span> 2&gt;/dev/null<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="nv">platform</span><span class="o">=</span><span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$ios</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;1&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nv">platform</span><span class="o">=</span><span class="s2">&#34; [iOS on Mac]&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="nb">printf</span> <span class="s1">&#39;%6sM  %-40s %s%s\n&#39;</span> <span class="s2">&#34;</span><span class="k">$((</span>kb <span class="o">/</span> <span class="m">1024</span><span class="k">))</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">bid</span><span class="k">:-</span><span class="p">（無法讀取）</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$name</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$platform</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">done</span> <span class="p">|</span> sort -rn</span></span></code></pre></div><p>輸出範例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl"> 9961M  com.stove.epic7.ios                       D678BD0C-... [iOS on Mac]
</span></span><span class="line"><span class="ln">2</span><span class="cl"> 8903M  tw.txwy.ios.arknights                     874686B0-... [iOS on Mac]</span></span></code></pre></div><h2 id="清除判斷">清除判斷</h2>
<p>辨識出 App 之後，清除判斷依 <a href="../macos_app_sandbox_container/">Container 的內部結構</a> 而定：</p>
<ul>
<li><code>Data/Library/Caches/</code> — 快取，清除零風險</li>
<li><code>Data/Documents/</code> — 使用者資料或遊戲資源。遊戲資源是可重新下載的衍生物（帳號進度在伺服器端），確認不再玩可整個刪除。電子書庫、筆記資料庫等則需要確認有雲端備份</li>
<li><a href="../macos_ios_app_on_mac/">iOS App on Mac</a> 的 App 如果已從 App Store 下架，刪除容器後可能無法重新安裝</li>
</ul>





<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="c1"># 確認後刪除（不可逆）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">rm -rf ~/Library/Containers/&lt;UUID&gt;</span></span></code></pre></div><h2 id="steam-遊戲的辨識">Steam 遊戲的辨識</h2>
<p>Steam 遊戲的儲存機制跟 App Container 不同——資料集中在 <code>~/Library/Application Support/Steam/steamapps/common/</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">du -shx ~/Library/Application<span class="se">\ </span>Support/Steam/steamapps/common/* 2&gt;/dev/null <span class="p">|</span> sort -rh</span></span></code></pre></div><p>回收空間從 Steam App 內解除安裝，手動刪 <code>common/</code> 下的目錄會讓 Steam 的 manifest 跟實際檔案不一致。</p>
<h2 id="content-probing-為什麼不可靠">Content probing 為什麼不可靠</h2>
<p>實際發生過的誤判案例：兩個 UUID 容器被 content probing（探測目錄內容特徵）誤判為 HoYoverse 遊戲。實際讀 plist 後發現是 Epic Seven（第七史詩）和 Arknights（明日方舟），完全不同的廠商。</p>
<p>Content probing 的根本問題是同樣的目錄名（<code>data.pack</code>）在不同遊戲裡代表不同東西，遊戲引擎共用（Unity、Unreal）會產生相似的目錄結構。一個 heuristic 命中只代表結構相似，輸出一個看起來確定的名字會導致使用者基於錯誤資訊做決策。</p>
<p>讀 plist 的 bundle ID 是唯一可靠的辨識方法。Content probing 只能作為 plist 讀不到時的 fallback，且必須標示「無法確認，僅供參考」。</p>
]]></content:encoded></item><item><title>macOS 每個 App 到底吃多少空間：聚合佔用的 app-report 腳本</title><link>https://tarrragon.github.io/blog/macos/macos_app_footprint_report/</link><pubDate>Sat, 27 Jun 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/macos/macos_app_footprint_report/</guid><description>&lt;p>&lt;code>du ~/Library/*&lt;/code> 只能列出 Caches、Containers 這些目錄各佔多少，算不出「Steam 這個 App 一共吃了多少」。原因是一個 App 的資料散落在 &lt;code>~/Library&lt;/code> 好幾個不同位置，按目錄統計就拆不回它名下。這篇記錄一個把這些散落佔用聚合回各 App 的 &lt;code>app-report&lt;/code> 腳本——搭配磁碟層的 &lt;a href="../macos_disk_space_diagnosis/">disk-report&lt;/a>，後者找出哪棵子樹最大，這篇把子樹拆到 App。&lt;/p>
&lt;h2 id="一個-app-的真實佔用不等於它的-app-大小">一個 App 的真實佔用不等於它的 .app 大小&lt;/h2>
&lt;p>判斷一個 App 吃多少空間，要算的是它的總足跡（footprint），而不是 &lt;code>/Applications&lt;/code> 裡那顆 &lt;code>.app&lt;/code> 的大小。&lt;code>.app&lt;/code> 只是程式本體，App 跑起來產生的資料（下載內容、快取、登入狀態、設定、日誌）絕大多數寫在 &lt;code>~/Library&lt;/code> 底下的好幾個不同位置，跟 &lt;code>.app&lt;/code> 完全分家。&lt;/p>
&lt;p>這台機器上最極端的例子是 Steam：它的 &lt;code>.app&lt;/code> 只有 10.8M，但遊戲資料佔了 8.1G，兩者差了近 800 倍。只看 &lt;code>/Applications&lt;/code> 的大小排序，Steam 會排在很後面，完全看不出它是全機第一大戶。同樣地，Amazon Kindle 的 &lt;code>.app&lt;/code> 才 138M，書庫卻在沙箱容器裡佔了 3.2G。這就是為什麼「按目錄統計」和「按 App 統計」會給出完全不同的排行；要回答「哪個 App 該清」，必須把佔用聚合回 App。&lt;/p>
&lt;h2 id="佔用散落在-library-的哪些地方">佔用散落在 ~/Library 的哪些地方&lt;/h2>
&lt;p>聚合的第一步是知道一個 App 會把資料寫到哪些固定位置。下表只列與空間相關的主要位置（非 &lt;code>~/Library&lt;/code> 全量），macOS 對它們有約定，每個位置承擔不同責任，也決定了它能不能安全清掉。&lt;/p>
&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;code>/Applications/*.app&lt;/code>&lt;/td>
 &lt;td>程式本體&lt;/td>
 &lt;td>等於移除 App&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>~/Library/Caches/&lt;/code>&lt;/td>
 &lt;td>快取&lt;/td>
 &lt;td>下次自動重建，安全&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>~/Library/HTTPStorages/&lt;/code>&lt;/td>
 &lt;td>網路快取（cookie / 暫存）&lt;/td>
 &lt;td>多半要重新登入，大致安全&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>~/Library/Application Support/&lt;/code>&lt;/td>
 &lt;td>設定與使用者資料&lt;/td>
 &lt;td>掉資料&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="../macos_app_sandbox_container/">&lt;code>~/Library/Containers/&lt;/code>&lt;/a>&lt;/td>
 &lt;td>沙箱 App 的完整家目錄&lt;/td>
 &lt;td>掉資料&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>~/Library/Group Containers/&lt;/code>&lt;/td>
 &lt;td>同廠商 App 共享的資料&lt;/td>
 &lt;td>掉資料、可能影響多個 App&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>~/Library/Saved Application State/&lt;/code>&lt;/td>
 &lt;td>視窗位置與復原狀態&lt;/td>
 &lt;td>下次開窗位置重置，無傷&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>~/Library/Logs/&lt;/code>&lt;/td>
 &lt;td>日誌&lt;/td>
 &lt;td>安全&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的關鍵分界是「快取」與「資料」。&lt;code>Caches&lt;/code> 和 &lt;code>HTTPStorages&lt;/code> 是純衍生物，清掉只是讓 App 下次重新下載或重建，最多重新登入一次，所以是回收空間時的首選。&lt;code>Application Support&lt;/code>、&lt;code>Containers&lt;/code>、&lt;code>Group Containers&lt;/code> 則是使用者資料，Steam 的遊戲、Kindle 的書庫、聊天記錄都在這裡，刪了就真的沒了。&lt;code>Group Containers&lt;/code> 還要多一層留意：它是同一個開發商旗下多個 App 共享的目錄，動它可能同時影響好幾個 App。&lt;/p>
&lt;p>腳本對每個 App 把上面這些位置全部找出來、用 &lt;code>du&lt;/code> 量實際佔用、加總成一個數字，再附上逐項明細，讓人一眼看出「這 4G 裡有多少是可清的快取、多少是動不得的資料」。&lt;/p>
&lt;h2 id="命名不一致是聚合的主要難點">命名不一致是聚合的主要難點&lt;/h2>
&lt;p>把資料夾正確歸給某個 App 的難點在於：macOS 對這些目錄沒有統一的命名規則。有些 App 用它的 bundle id（例如 &lt;code>com.valvesoftware.steam&lt;/code>）當目錄名，有些直接用 App 的顯示名稱（例如 &lt;code>Steam&lt;/code>），同一個 App 的不同位置甚至各用一種。&lt;a href="../macos_ios_app_on_mac/">iOS App on Mac&lt;/a> 的容器更用 UUID 命名，完全看不出是哪個 App（&lt;a href="../macos_identify_app_containers/">辨識方法&lt;/a>）。&lt;/p>
&lt;p>腳本的做法是對每個 App 先讀出它的 bundle id，然後 &lt;code>Caches&lt;/code>、&lt;code>Application Support&lt;/code>、&lt;code>Logs&lt;/code> 這幾個位置兩種命名都比對一次，bundle id 專屬的位置（&lt;code>Containers&lt;/code>、&lt;code>HTTPStorages&lt;/code>、&lt;code>Saved Application State&lt;/code>）則用 bundle id 找。&lt;code>Group Containers&lt;/code> 又是另一種格式，名稱前面多一段開發商的 team id（10 碼英數，像 &lt;code>ABCDE12345.group.com.foo&lt;/code>），因此改用 bundle id 做子字串比對。這套規則涵蓋了絕大多數 App，但用罕見自訂命名的資料仍可能漏抓，這是聚合式估算的固有邊界，腳本在輸出裡據實標明「可能漏抓」而不假裝是精確值。&lt;/p>
&lt;h2 id="homebrew-要分開算">Homebrew 要分開算&lt;/h2>
&lt;p>透過 Homebrew 裝的工具不在 &lt;code>/Applications&lt;/code>，需要獨立統計。佔用分兩類（概念詳見 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/homebrew/" data-link-title="Homebrew" data-link-desc="macOS 上社群維護的套件管理器、用一行指令安裝 CLI 工具與背景服務">Homebrew 知識卡&lt;/a>）：命令列工具與函式庫（&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/homebrew/" data-link-title="Homebrew" data-link-desc="macOS 上社群維護的套件管理器、用一行指令安裝 CLI 工具與背景服務">formula&lt;/a>）在 &lt;code>Cellar/&lt;/code>，GUI App 的下載 artifact 與 metadata（&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/homebrew/" data-link-title="Homebrew" data-link-desc="macOS 上社群維護的套件管理器、用一行指令安裝 CLI 工具與背景服務">cask&lt;/a>）在 &lt;code>Caskroom/&lt;/code>。cask 安裝的 &lt;code>.app&lt;/code> 本體實際放在 &lt;code>/Applications&lt;/code>，已被前面的 App 聚合排行計入；&lt;code>Caskroom/&lt;/code> 存的是安裝來源與版本資訊，體積通常遠小於 App 本體，兩邊不重複計。&lt;/p></description><content:encoded><![CDATA[<p><code>du ~/Library/*</code> 只能列出 Caches、Containers 這些目錄各佔多少，算不出「Steam 這個 App 一共吃了多少」。原因是一個 App 的資料散落在 <code>~/Library</code> 好幾個不同位置，按目錄統計就拆不回它名下。這篇記錄一個把這些散落佔用聚合回各 App 的 <code>app-report</code> 腳本——搭配磁碟層的 <a href="../macos_disk_space_diagnosis/">disk-report</a>，後者找出哪棵子樹最大，這篇把子樹拆到 App。</p>
<h2 id="一個-app-的真實佔用不等於它的-app-大小">一個 App 的真實佔用不等於它的 .app 大小</h2>
<p>判斷一個 App 吃多少空間，要算的是它的總足跡（footprint），而不是 <code>/Applications</code> 裡那顆 <code>.app</code> 的大小。<code>.app</code> 只是程式本體，App 跑起來產生的資料（下載內容、快取、登入狀態、設定、日誌）絕大多數寫在 <code>~/Library</code> 底下的好幾個不同位置，跟 <code>.app</code> 完全分家。</p>
<p>這台機器上最極端的例子是 Steam：它的 <code>.app</code> 只有 10.8M，但遊戲資料佔了 8.1G，兩者差了近 800 倍。只看 <code>/Applications</code> 的大小排序，Steam 會排在很後面，完全看不出它是全機第一大戶。同樣地，Amazon Kindle 的 <code>.app</code> 才 138M，書庫卻在沙箱容器裡佔了 3.2G。這就是為什麼「按目錄統計」和「按 App 統計」會給出完全不同的排行；要回答「哪個 App 該清」，必須把佔用聚合回 App。</p>
<h2 id="佔用散落在-library-的哪些地方">佔用散落在 ~/Library 的哪些地方</h2>
<p>聚合的第一步是知道一個 App 會把資料寫到哪些固定位置。下表只列與空間相關的主要位置（非 <code>~/Library</code> 全量），macOS 對它們有約定，每個位置承擔不同責任，也決定了它能不能安全清掉。</p>
<table>
  <thead>
      <tr>
          <th>位置</th>
          <th>放什麼</th>
          <th>清掉的後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>/Applications/*.app</code></td>
          <td>程式本體</td>
          <td>等於移除 App</td>
      </tr>
      <tr>
          <td><code>~/Library/Caches/</code></td>
          <td>快取</td>
          <td>下次自動重建，安全</td>
      </tr>
      <tr>
          <td><code>~/Library/HTTPStorages/</code></td>
          <td>網路快取（cookie / 暫存）</td>
          <td>多半要重新登入，大致安全</td>
      </tr>
      <tr>
          <td><code>~/Library/Application Support/</code></td>
          <td>設定與使用者資料</td>
          <td>掉資料</td>
      </tr>
      <tr>
          <td><a href="../macos_app_sandbox_container/"><code>~/Library/Containers/</code></a></td>
          <td>沙箱 App 的完整家目錄</td>
          <td>掉資料</td>
      </tr>
      <tr>
          <td><code>~/Library/Group Containers/</code></td>
          <td>同廠商 App 共享的資料</td>
          <td>掉資料、可能影響多個 App</td>
      </tr>
      <tr>
          <td><code>~/Library/Saved Application State/</code></td>
          <td>視窗位置與復原狀態</td>
          <td>下次開窗位置重置，無傷</td>
      </tr>
      <tr>
          <td><code>~/Library/Logs/</code></td>
          <td>日誌</td>
          <td>安全</td>
      </tr>
  </tbody>
</table>
<p>這張表的關鍵分界是「快取」與「資料」。<code>Caches</code> 和 <code>HTTPStorages</code> 是純衍生物，清掉只是讓 App 下次重新下載或重建，最多重新登入一次，所以是回收空間時的首選。<code>Application Support</code>、<code>Containers</code>、<code>Group Containers</code> 則是使用者資料，Steam 的遊戲、Kindle 的書庫、聊天記錄都在這裡，刪了就真的沒了。<code>Group Containers</code> 還要多一層留意：它是同一個開發商旗下多個 App 共享的目錄，動它可能同時影響好幾個 App。</p>
<p>腳本對每個 App 把上面這些位置全部找出來、用 <code>du</code> 量實際佔用、加總成一個數字，再附上逐項明細，讓人一眼看出「這 4G 裡有多少是可清的快取、多少是動不得的資料」。</p>
<h2 id="命名不一致是聚合的主要難點">命名不一致是聚合的主要難點</h2>
<p>把資料夾正確歸給某個 App 的難點在於：macOS 對這些目錄沒有統一的命名規則。有些 App 用它的 bundle id（例如 <code>com.valvesoftware.steam</code>）當目錄名，有些直接用 App 的顯示名稱（例如 <code>Steam</code>），同一個 App 的不同位置甚至各用一種。<a href="../macos_ios_app_on_mac/">iOS App on Mac</a> 的容器更用 UUID 命名，完全看不出是哪個 App（<a href="../macos_identify_app_containers/">辨識方法</a>）。</p>
<p>腳本的做法是對每個 App 先讀出它的 bundle id，然後 <code>Caches</code>、<code>Application Support</code>、<code>Logs</code> 這幾個位置兩種命名都比對一次，bundle id 專屬的位置（<code>Containers</code>、<code>HTTPStorages</code>、<code>Saved Application State</code>）則用 bundle id 找。<code>Group Containers</code> 又是另一種格式，名稱前面多一段開發商的 team id（10 碼英數，像 <code>ABCDE12345.group.com.foo</code>），因此改用 bundle id 做子字串比對。這套規則涵蓋了絕大多數 App，但用罕見自訂命名的資料仍可能漏抓，這是聚合式估算的固有邊界，腳本在輸出裡據實標明「可能漏抓」而不假裝是精確值。</p>
<h2 id="homebrew-要分開算">Homebrew 要分開算</h2>
<p>透過 Homebrew 裝的工具不在 <code>/Applications</code>，需要獨立統計。佔用分兩類（概念詳見 <a href="/blog/llm/knowledge-cards/homebrew/" data-link-title="Homebrew" data-link-desc="macOS 上社群維護的套件管理器、用一行指令安裝 CLI 工具與背景服務">Homebrew 知識卡</a>）：命令列工具與函式庫（<a href="/blog/llm/knowledge-cards/homebrew/" data-link-title="Homebrew" data-link-desc="macOS 上社群維護的套件管理器、用一行指令安裝 CLI 工具與背景服務">formula</a>）在 <code>Cellar/</code>，GUI App 的下載 artifact 與 metadata（<a href="/blog/llm/knowledge-cards/homebrew/" data-link-title="Homebrew" data-link-desc="macOS 上社群維護的套件管理器、用一行指令安裝 CLI 工具與背景服務">cask</a>）在 <code>Caskroom/</code>。cask 安裝的 <code>.app</code> 本體實際放在 <code>/Applications</code>，已被前面的 App 聚合排行計入；<code>Caskroom/</code> 存的是安裝來源與版本資訊，體積通常遠小於 App 本體，兩邊不重複計。</p>
<p>這台機器的 formula 前幾名是開發語言執行環境：<code>dotnet@9</code> 634M、兩個版本的 <code>openjdk</code> 合計 600M、<code>mysql</code> 292M、<code>go</code> 258M。formula 會多版本並存（例如 <code>python@3.13</code> 和 <code>python@3.14</code> 各算各的），所以腳本把整個 formula 目錄一起計。除了已安裝的部分，腳本還列出 <code>brew --cache</code> 的下載快取，以及 <code>brew cleanup -n</code> 預估可回收的舊版本（<code>-n</code> 是 dry-run，只報告不刪），跟整支腳本的唯讀原則一致。</p>
<h2 id="聚合一律用-du-取實際佔用">聚合一律用 du 取實際佔用</h2>
<p>App 各位置的聚合一律用 <code>du -skx</code> 取實際佔用，而不是 <code>ls</code> / <code>find -size</code> 的邏輯大小。sparse 檔（稀疏檔）只有寫入過的區塊才真正佔磁碟，宣告的邏輯大小可能是實際佔用的數十倍；容器與資料目錄裡正好常有 VM 映像、容器磁碟這類 sparse 檔，拿邏輯大小加總會把整份聚合排行灌水。完整的 sparse 踩坑案例見 <a href="../macos_disk_space_diagnosis/">disk-report 那篇</a>。</p>
<p><code>-x</code> 讓 <code>du</code> 不跨越檔案系統邊界，避免把掛載進來的卷重複計入；<code>-k</code> 統一用 KB 當單位，方便把各位置的數字加總後再換算成人類可讀的 G / M。</p>
<h2 id="實測結果">實測結果</h2>
<p>下面是這台機器的實測排行（名次因個人使用習慣而異）；要看的是聚合排行和「按目錄統計」給的印象差多少：</p>
<table>
  <thead>
      <tr>
          <th>App</th>
          <th>總佔用</th>
          <th>主要落點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Steam</td>
          <td>8.1G</td>
          <td>data 8.1G（<code>.app</code> 只有 10.8M）</td>
      </tr>
      <tr>
          <td>Xcode</td>
          <td>4.8G</td>
          <td>bundle 4.8G</td>
      </tr>
      <tr>
          <td>Readmoo 看書</td>
          <td>4.6G</td>
          <td>data 3.8G + bundle 816M</td>
      </tr>
      <tr>
          <td>Dia</td>
          <td>4.1G</td>
          <td>cache 1.6G + bundle 1.3G + data 1.1G</td>
      </tr>
      <tr>
          <td>Amazon Kindle</td>
          <td>3.3G</td>
          <td>container 3.2G（<code>.app</code> 才 138M）</td>
      </tr>
  </tbody>
</table>
<p>全機掃到 65 個 App、聚合總計 48.2G。這份排行的價值在於它直接指向「該從哪裡下手」，而判讀邏輯可以套到任何人的排行上：本體小、資料大的 App（這台是 Steam、Kindle）要回收就得處理書庫與遊戲本身；純快取大的（這台是 Dia 的 1.6G）清掉零風險；本體就大的開發工具（Xcode、Android Studio）除非不再開發否則動不得。同一個總數字底下，可清的比例天差地別，這正是逐項明細要回答的問題。</p>
<h2 id="聚合的邊界總計不等於整機">聚合的邊界：總計不等於整機</h2>
<p>這個 48.2G 是「能歸屬到已安裝 App 的部分」之和，不是 <code>~/Library</code> 的全量。<a href="../macos_disk_space_diagnosis/">disk-report 那篇</a>量到的 <code>~/Library</code> 約 70G，差額落在幾類刻意不歸進單一 App 的位置。</p>
<p>最大的一塊是 <code>~/Library/Developer</code>（這台約 5.5G，幾乎全是 Xcode 的 DerivedData、CoreSimulator 與 iOS DeviceSupport）。它們是 Xcode 與模擬器產生的共用產物，硬塞給 Xcode 會誇大這顆 App、塞給別人又不對，app-report 比照 Homebrew 把它單獨列成一段（<code>app-report --dev</code>）。也因為這樣，上面排行裡的 Xcode 只算到 <code>.app</code> 本體，它的建置產物要看 Developer 那段——這也是為什麼 disk-report 會把「Xcode DeviceSupport」列為大戶，而逐 App 排行卻看不到：那筆資料正住在這個不歸單一 App 的位置。</p>
<p>其餘排除的還有 iCloud 與雲端硬碟的本地鏡像（<code>Mobile Documents</code>、<code>CloudStorage</code>）、已移除 App 留下的孤兒資料夾、以及 <code>Preferences</code>。排行掃的是 <code>/Applications</code>、<code>~/Applications</code>、<code>/Applications/Utilities</code> 與 Setapp、Mac App Store 裝的 App；直接從 DMG 跑、沒搬進 Applications 的 App 不會出現在排行，但它的 <code>~/Library</code> 資料若命名對得上仍可能部分計入。</p>
<p>還有一個方向相反的誤差要記得：這是估算不是精算。同一份資料若以 APFS clone 出現在多個被聚合的位置，逐位置分開跑 <code>du</code> 會各自計入（<code>du</code> 只在單次執行內對硬連結以 inode 去重，對 APFS clone 不去重），聚合值可能偏高。要看整個 <code>~/Library</code> 到底多大、由誰佔，回到 disk-report 的逐層 <code>du</code>。</p>
<h2 id="固化成-app-report-腳本">固化成 app-report 腳本</h2>
<p>把這套聚合邏輯寫成腳本，往後想知道「誰在吃空間」就一行重跑，不必每次重想要比對哪些目錄、要怎麼處理命名差異。腳本和 <code>disk-report</code> 收在同一個公開 repo <a href="https://github.com/tarrragon/scripts">tarrragon/scripts</a> 裡，維持「跟專案無關的系統工具放個人 bin」的一致做法。</p>
<p>兩支腳本在同一個 repo；若已為 <code>disk-report</code> clone 過 <code>~/Projects/scripts</code>，跳過 clone、只做 symlink。首次安裝則把 repo clone 下來，再把腳本本體 symlink 到個人的 <code>~/.local/bin</code>，這樣本機呼叫的永遠是 repo 的最新版：</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">git clone https://github.com/tarrragon/scripts.git ~/Projects/scripts
</span></span><span class="line"><span class="ln">2</span><span class="cl">ln -s ~/Projects/scripts/app-report/app-report ~/.local/bin/app-report</span></span></code></pre></div><p>PATH 設定同 disk-report（見 <a href="../macos_new_machine_setup/">macOS 新機基礎建設</a>）。裝好後直接呼叫：</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">app-report           <span class="c1"># 完整報告：App 聚合排行 + Developer + Homebrew</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">app-report --apps    <span class="c1"># 只看 App 聚合排行（預設前 30）</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">app-report --apps <span class="m">50</span> <span class="c1"># 排行顯示前 50</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">app-report --dev     <span class="c1"># 只看 ~/Library/Developer 開發工具共用資料</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">app-report --brew    <span class="c1"># 只看 Homebrew</span></span></span></code></pre></div><p>要清哪個 App，看完明細再動手：移掉 <code>.app</code> 並清對應的 <code>~/Library</code> 資料夾（報告每個 App 下方列的路徑就是清除對象；先從 <code>Caches</code> / <code>HTTPStorages</code> 開始，確認再考慮資料目錄），Homebrew 用 <code>brew cleanup -s</code>。</p>
<h2 id="兩支腳本的分工">兩支腳本的分工</h2>
<p><code>disk-report</code> 與 <code>app-report</code> 是磁碟清理的兩個接力棒。前者在卷與目錄層找出最大的子樹，通常落在 <code>~/Library</code>；後者接手把那棵子樹拆到 App，看出具體是誰佔的、各自有多少是可清的快取。先 disk 找方向、再 app 定位到人，兩支都唯讀，回收的最後一步都留在人這一端。</p>
]]></content:encoded></item><item><title>macOS 新機基礎建設：套件管理與個人 bin 的設定順序</title><link>https://tarrragon.github.io/blog/macos/macos_new_machine_setup/</link><pubDate>Sat, 27 Jun 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/macos/macos_new_machine_setup/</guid><description>&lt;p>重灌或換機後要補的設定很多，但有個底層順序不能跳：套件管理工具要先到位，後面的補強才裝得起來。這篇聚焦最底層的三項基礎建設（Homebrew、bash、個人 bin），按依賴順序排列。重點不只是「裝什麼」，而是「為什麼這個順序」；之後遇到的新需求會接在後面繼續增補。&lt;/p>
&lt;h2 id="先裝-homebrew它是後面一切的基礎">先裝 Homebrew，它是後面一切的基礎&lt;/h2>
&lt;p>macOS 本身沒有內建的第三方套件管理工具，而後面幾乎每一項補強（命令列工具、開發語言、甚至部分 GUI App）都靠它安裝。沒有 Homebrew，這份清單的其他項目全部無從裝起，所以它排第一。&lt;/p>
&lt;p>安裝過程會要求輸入登入密碼（sudo），並自動下載 Xcode Command Line Tools，畫面可能停住數分鐘屬正常。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">/bin/bash -c &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>裝完還要把 Homebrew 的執行檔目錄加進 PATH，shell 才找得到 &lt;code>brew&lt;/code> 與之後用它裝的工具。Homebrew 的安裝前綴依晶片而異：Apple Silicon 機器裝在 &lt;code>/opt/homebrew&lt;/code>、Intel 機器裝在 &lt;code>/usr/local&lt;/code>。安裝腳本結尾會印出對應這台機器的設定指令，照它印的路徑寫進 &lt;code>~/.zprofile&lt;/code> 讓每次開 shell 都生效。以下以 Apple Silicon 為例，Intel 機器把前綴換成 &lt;code>/usr/local&lt;/code> 即可：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="s1">&amp;#39;eval &amp;#34;$(/opt/homebrew/bin/brew shellenv)&amp;#34;&amp;#39;&lt;/span> &amp;gt;&amp;gt; ~/.zprofile
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">eval&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>/opt/homebrew/bin/brew shellenv&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這一步做完，驗證安裝成功：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">brew --version &lt;span class="c1"># 應印出 Homebrew 4.x.x&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>版本號印出來，&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/homebrew/" data-link-title="Homebrew" data-link-desc="macOS 上社群維護的套件管理器、用一行指令安裝 CLI 工具與背景服務">Homebrew&lt;/a> 就能當後面所有項目的安裝來源。&lt;/p>
&lt;h2 id="把-bash-更新到-5x">把 bash 更新到 5.x&lt;/h2>
&lt;p>bash 是裝完 Homebrew 後最值得優先換掉的內建工具。macOS 的 &lt;code>/bin/bash&lt;/code> 長年凍結在 3.2 系列（2006 年釋出，目前是 patchlevel 57），Apple 不再更新它，原因是 bash 4 改用 GPLv3 授權、Apple 不願隨系統散布。對寫腳本的人來說，這代表 associative array、&lt;code>${var,,}&lt;/code> 大小寫轉換、&lt;code>mapfile&lt;/code> 等近二十年的語法都用不了。這個授權轉折的完整脈絡（反 Tivoization 條款），以及 BSD userland 帶來的另外兩種撞牆形態——GNU 工具缺席、同名工具行為不同——見 &lt;a href="../macos_bsd_userland_vs_gnu/">macOS 的 BSD userland&lt;/a>。&lt;/p>
&lt;p>正確做法是用 Homebrew 另外裝一份新版並排存在，而不是覆寫系統版。&lt;code>/bin/bash&lt;/code> 在唯讀的系統卷上、受 SIP（System Integrity Protection）保護，覆寫會被擋下：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">brew install bash&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這會把 bash 5.x 裝到 &lt;code>/opt/homebrew/bin/bash&lt;/code>，完全不碰 &lt;code>/bin/bash&lt;/code>。因為前一步已經把 &lt;code>/opt/homebrew/bin&lt;/code> 排在 PATH 前面，用 &lt;code>#!/usr/bin/env bash&lt;/code> 起手的腳本就會自動改用新版。裝完驗證一下版本確實切過去：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">env bash --version &lt;span class="c1"># 應顯示 5.x&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">/bin/bash --version &lt;span class="c1"># 系統版仍是 3.2.57，沒被動&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>要留意的是互動 shell 在現代 macOS 預設是 zsh，這一步不影響它。更新 bash 的目的是給 &lt;code>#!/usr/bin/env bash&lt;/code> 腳本一個現代執行環境，不是換登入 shell。真要把新版 bash 當登入 shell，才需要額外把它加進 &lt;code>/etc/shells&lt;/code> 再 &lt;code>chsh&lt;/code>。&lt;/p>
&lt;h2 id="把-localbin-加進-path放個人腳本">把 ~/.local/bin 加進 PATH，放個人腳本&lt;/h2>
&lt;p>跟專案無關的小工具（例如 &lt;a href="../macos_disk_space_diagnosis/">disk-report&lt;/a> 與 &lt;a href="../macos_app_footprint_report/">app-report&lt;/a> 這類系統診斷腳本）需要一個能在任何地方直接呼叫、又不污染專案 repo 的家。慣例是個人的 &lt;code>~/.local/bin&lt;/code>，把它建好並掛上 PATH。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">mkdir -p ~/.local/bin
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="s1">&amp;#39;export PATH=&amp;#34;$HOME/.local/bin:$PATH&amp;#34;&amp;#39;&lt;/span> &amp;gt;&amp;gt; ~/.zprofile
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">PATH&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$HOME&lt;/span>&lt;span class="s2">/.local/bin:&lt;/span>&lt;span class="nv">$PATH&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>目錄建好、PATH 掛上後，確認它確實生效：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PATH&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="p">|&lt;/span> tr &lt;span class="s1">&amp;#39;:&amp;#39;&lt;/span> &lt;span class="s1">&amp;#39;\n&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> grep &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$HOME&lt;/span>&lt;span class="s2">/.local/bin&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>之後把腳本 symlink 進這個目錄就能直接當指令用。&lt;/p>
&lt;h2 id="後續項目">後續項目&lt;/h2>
&lt;p>基礎建設到位後，第一個掛上去的實用腳本就是系統診斷：&lt;a href="../macos_disk_space_diagnosis/">磁碟空間診斷的 disk-report&lt;/a> 與 &lt;a href="../macos_app_footprint_report/">按 App 聚合佔用的 app-report&lt;/a>，兩支都 symlink 進 &lt;code>~/.local/bin&lt;/code> 直接當指令用。&lt;/p>
&lt;p>這份清單會隨著之後遇到的需求往下增補，新項目接在這裡。原則維持不變：基礎建設排前面，依賴它的補強排後面，每一項都寫清楚「為什麼要做」而不只是貼指令。&lt;/p></description><content:encoded><![CDATA[<p>重灌或換機後要補的設定很多，但有個底層順序不能跳：套件管理工具要先到位，後面的補強才裝得起來。這篇聚焦最底層的三項基礎建設（Homebrew、bash、個人 bin），按依賴順序排列。重點不只是「裝什麼」，而是「為什麼這個順序」；之後遇到的新需求會接在後面繼續增補。</p>
<h2 id="先裝-homebrew它是後面一切的基礎">先裝 Homebrew，它是後面一切的基礎</h2>
<p>macOS 本身沒有內建的第三方套件管理工具，而後面幾乎每一項補強（命令列工具、開發語言、甚至部分 GUI App）都靠它安裝。沒有 Homebrew，這份清單的其他項目全部無從裝起，所以它排第一。</p>
<p>安裝過程會要求輸入登入密碼（sudo），並自動下載 Xcode Command Line Tools，畫面可能停住數分鐘屬正常。</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">/bin/bash -c <span class="s2">&#34;</span><span class="k">$(</span>curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh<span class="k">)</span><span class="s2">&#34;</span></span></span></code></pre></div><p>裝完還要把 Homebrew 的執行檔目錄加進 PATH，shell 才找得到 <code>brew</code> 與之後用它裝的工具。Homebrew 的安裝前綴依晶片而異：Apple Silicon 機器裝在 <code>/opt/homebrew</code>、Intel 機器裝在 <code>/usr/local</code>。安裝腳本結尾會印出對應這台機器的設定指令，照它印的路徑寫進 <code>~/.zprofile</code> 讓每次開 shell 都生效。以下以 Apple Silicon 為例，Intel 機器把前綴換成 <code>/usr/local</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"><span class="nb">echo</span> <span class="s1">&#39;eval &#34;$(/opt/homebrew/bin/brew shellenv)&#34;&#39;</span> &gt;&gt; ~/.zprofile
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">eval</span> <span class="s2">&#34;</span><span class="k">$(</span>/opt/homebrew/bin/brew shellenv<span class="k">)</span><span class="s2">&#34;</span></span></span></code></pre></div><p>這一步做完，驗證安裝成功：</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">brew --version   <span class="c1"># 應印出 Homebrew 4.x.x</span></span></span></code></pre></div><p>版本號印出來，<a href="/blog/llm/knowledge-cards/homebrew/" data-link-title="Homebrew" data-link-desc="macOS 上社群維護的套件管理器、用一行指令安裝 CLI 工具與背景服務">Homebrew</a> 就能當後面所有項目的安裝來源。</p>
<h2 id="把-bash-更新到-5x">把 bash 更新到 5.x</h2>
<p>bash 是裝完 Homebrew 後最值得優先換掉的內建工具。macOS 的 <code>/bin/bash</code> 長年凍結在 3.2 系列（2006 年釋出，目前是 patchlevel 57），Apple 不再更新它，原因是 bash 4 改用 GPLv3 授權、Apple 不願隨系統散布。對寫腳本的人來說，這代表 associative array、<code>${var,,}</code> 大小寫轉換、<code>mapfile</code> 等近二十年的語法都用不了。這個授權轉折的完整脈絡（反 Tivoization 條款），以及 BSD userland 帶來的另外兩種撞牆形態——GNU 工具缺席、同名工具行為不同——見 <a href="../macos_bsd_userland_vs_gnu/">macOS 的 BSD userland</a>。</p>
<p>正確做法是用 Homebrew 另外裝一份新版並排存在，而不是覆寫系統版。<code>/bin/bash</code> 在唯讀的系統卷上、受 SIP（System Integrity Protection）保護，覆寫會被擋下：</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">brew install bash</span></span></code></pre></div><p>這會把 bash 5.x 裝到 <code>/opt/homebrew/bin/bash</code>，完全不碰 <code>/bin/bash</code>。因為前一步已經把 <code>/opt/homebrew/bin</code> 排在 PATH 前面，用 <code>#!/usr/bin/env bash</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">env bash --version   <span class="c1"># 應顯示 5.x</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">/bin/bash --version  <span class="c1"># 系統版仍是 3.2.57，沒被動</span></span></span></code></pre></div><p>要留意的是互動 shell 在現代 macOS 預設是 zsh，這一步不影響它。更新 bash 的目的是給 <code>#!/usr/bin/env bash</code> 腳本一個現代執行環境，不是換登入 shell。真要把新版 bash 當登入 shell，才需要額外把它加進 <code>/etc/shells</code> 再 <code>chsh</code>。</p>
<h2 id="把-localbin-加進-path放個人腳本">把 ~/.local/bin 加進 PATH，放個人腳本</h2>
<p>跟專案無關的小工具（例如 <a href="../macos_disk_space_diagnosis/">disk-report</a> 與 <a href="../macos_app_footprint_report/">app-report</a> 這類系統診斷腳本）需要一個能在任何地方直接呼叫、又不污染專案 repo 的家。慣例是個人的 <code>~/.local/bin</code>，把它建好並掛上 PATH。</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">mkdir -p ~/.local/bin
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;export PATH=&#34;$HOME/.local/bin:$PATH&#34;&#39;</span> &gt;&gt; ~/.zprofile
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nb">export</span> <span class="nv">PATH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">/.local/bin:</span><span class="nv">$PATH</span><span class="s2">&#34;</span></span></span></code></pre></div><p>目錄建好、PATH 掛上後，確認它確實生效：</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="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$PATH</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;:&#39;</span> <span class="s1">&#39;\n&#39;</span> <span class="p">|</span> grep <span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">/.local/bin&#34;</span></span></span></code></pre></div><p>之後把腳本 symlink 進這個目錄就能直接當指令用。</p>
<h2 id="後續項目">後續項目</h2>
<p>基礎建設到位後，第一個掛上去的實用腳本就是系統診斷：<a href="../macos_disk_space_diagnosis/">磁碟空間診斷的 disk-report</a> 與 <a href="../macos_app_footprint_report/">按 App 聚合佔用的 app-report</a>，兩支都 symlink 進 <code>~/.local/bin</code> 直接當指令用。</p>
<p>這份清單會隨著之後遇到的需求往下增補，新項目接在這裡。原則維持不變：基礎建設排前面，依賴它的補強排後面，每一項都寫清楚「為什麼要做」而不只是貼指令。</p>
]]></content:encoded></item><item><title>macOS 磁碟空間被吃光的診斷流程</title><link>https://tarrragon.github.io/blog/macos/macos_disk_space_diagnosis/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/macos/macos_disk_space_diagnosis/</guid><description>&lt;p>一台原本還有約 30G 餘裕的 Mac，使用幾小時後空間全部歸零，清過系統各種 cache 也沒有改善。這次排查的重點是順序與判讀依據：用什麼順序找、用哪個數字判斷，最後刪了什麼反而次要。順序對了，就能避開兩個讓人空轉的陷阱。&lt;/p>
&lt;p>最後把整套診斷固化成一個唯讀的 &lt;code>disk-report&lt;/code> 腳本，往後同類情況可以一行指令重跑。&lt;/p>
&lt;h2 id="先確認問題是真的滿還是浮動的假象">先確認問題是「真的滿」還是「浮動的假象」&lt;/h2>
&lt;p>排查磁碟的第一步是分辨空間到底去哪：是被真實檔案佔走，還是被系統的快照與 purgeable（系統可隨時回收的緩衝空間）暫時佔住。這兩者的處理方式完全不同，先分清楚才不會白清。&lt;/p>
&lt;p>在 &lt;a href="../macos_apfs_volume_structure/">APFS&lt;/a>（Apple File System，macOS 的預設檔案系統）上，根目錄 &lt;code>/&lt;/code> 是唯讀的系統封印卷，真正存放使用者資料的是 &lt;code>/System/Volumes/Data&lt;/code>，而它們和其他卷（&lt;a href="../macos_preboot_volume_check/">Preboot&lt;/a>、Recovery、VM、模擬器 runtime）共用同一個 container（容器，APFS 管理空間的最上層單位）的空間池。判斷「還剩多少」要看整個 container 的可用空間，而不是單一卷的數字。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">df -h /System/Volumes/Data
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">diskutil info /System/Volumes/Data &lt;span class="p">|&lt;/span> grep -iE &lt;span class="s2">&amp;#34;Container Free Space|Container Total Space&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這次的結果是資料卷 100% 滿、整個 container 只剩約 591MB。確認確實滿載、不是顯示誤差，後面才值得花力氣找佔用大戶。&lt;/p>
&lt;h2 id="空間掉了又回來的根因本地快照與-purgeable">「空間掉了又回來」的根因：本地快照與 purgeable&lt;/h2>
&lt;p>空間在幾小時內反覆消長、清 cache 卻無效，最常見的原因是 Time Machine 的本地快照（local snapshots）加上 macOS 的 purgeable 空間，而不是某個看得見的檔案。這是排查時要先排除的一條線。&lt;/p>
&lt;p>本地快照的運作方式是：Time Machine 啟用時，系統約每小時自動建立一張快照「凍結」當下狀態，好讓本地也能做時光機回溯。這些被凍結的資料，正是先前以為已刪除、卻怎麼清都不會釋放的空間。快照保留約 24 小時（Apple 的 thinning 策略，觀察值），或在磁碟空間壓力過大時提前清除；後者正是「過一陣子空間又回來」的來源。若從未設定 Time Machine，這條線可跳過——沒啟用就不會有 local snapshot。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">tmutil listlocalsnapshots /System/Volumes/Data&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這次查的時候快照數是 0，但這不代表它不是元兇——恰恰相反，是磁碟已經滿到讓系統把快照全數清光了。判讀訊號是：若這個指令平常列出多筆快照、且磁碟空間在數字上頻繁浮動，浮動量就來自這裡，跟手動清的 cache 無關。根治方向是把總用量降下來、讓磁碟保有餘裕，系統就不會一直貼著上限狂建狂清快照。&lt;/p>
&lt;p>purgeable 是同一條線的另一半，但它沒有好用的精確讀數。&lt;code>diskutil apfs list&lt;/code> 能看 container 層的概況，而 purgeable 主要由快照與系統快取構成、本來就會自己浮動。處理方式跟快照一樣：把總用量降下來、讓系統在空間有壓力時自行釋放，而不是找指令直接清它。「沒有直接讀數」本身就是判讀邊界——看到可用空間和「實際檔案總和」對不上時，差額多半就在這塊浮動緩衝，不必懷疑是哪個檔案在搞鬼。&lt;/p>
&lt;h2 id="用實際佔用值找大戶避開-sparse-假大小">用實際佔用值找大戶，避開 sparse 假大小&lt;/h2>
&lt;p>找佔用大戶要用 &lt;code>du&lt;/code>（實際佔用的磁碟區塊）排序，不能依賴 &lt;code>ls -l&lt;/code> 顯示、或 &lt;code>find -size&lt;/code> 篩選所用的邏輯大小。對一般檔案兩者相同，但對 sparse 檔（稀疏檔）差距可以是好幾十倍，誤判會追錯目標。&lt;/p>
&lt;p>這次就踩到這個陷阱。&lt;code>find&lt;/code> 列出近期修改的大檔時，OrbStack（一套容器與 VM 執行環境）的虛擬磁碟映像顯示為 228G，看起來像頭號兇手；但用 &lt;code>du&lt;/code> 一量，實際佔用只有 1.9G。同樣地，macOS Podcasts 在 tmp 塞的一堆 &lt;code>.tmp.resize.img&lt;/code> 顯示有數十個檔，實際只佔 3.5M。這些都是 sparse 檔：宣告了很大的邏輯大小，但只有寫入過的區塊才真正佔磁碟。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 實際佔用（正確）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">du -sh ~/some/large.img
&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="c1"># 顯示大小（對 sparse 檔會嚴重高估，誤判用）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">ls -lh ~/some/large.img&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>定位順序是由外往內逐層收斂：先看家目錄前 20 大，鎖定最大的子樹（這次是 &lt;code>~/Library&lt;/code> 70G 左右），再往下展開 &lt;code>~/Library/Application Support&lt;/code>、&lt;a href="../macos_app_sandbox_container/">&lt;code>~/Library/Containers&lt;/code>&lt;/a>，直到找到具體的檔案或目錄。Containers 裡的 UUID 目錄是 &lt;a href="../macos_ios_app_on_mac/">iOS App on Mac&lt;/a> 的容器，辨識方式見 &lt;a href="../macos_identify_app_containers/">Container 辨識&lt;/a>。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">du -shx ~/* ~/.&lt;span class="o">[&lt;/span>!.&lt;span class="o">]&lt;/span>* 2&amp;gt;/dev/null &lt;span class="p">|&lt;/span> sort -rh &lt;span class="p">|&lt;/span> head -20
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">du -shx ~/Library/* 2&amp;gt;/dev/null &lt;span class="p">|&lt;/span> sort -rh &lt;span class="p">|&lt;/span> head -12&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>-x&lt;/code> 讓 &lt;code>du&lt;/code> 不跨越檔案系統邊界，避免把掛載進來的唯讀卷（例如 iOS 模擬器 runtime）重複計入；&lt;code>~/.[!.]*&lt;/code> 這個寫法只展開以單一點開頭的隱藏檔，排除掉 &lt;code>.&lt;/code> 和 &lt;code>..&lt;/code> 兩個會被一般 &lt;code>.*&lt;/code> 誤抓進來、算出整個家目錄大小的假項目。&lt;/p>
&lt;h2 id="這次找到的佔用大戶與處理">這次找到的佔用大戶與處理&lt;/h2>
&lt;p>定位出來的大戶集中在開發工具鏈與閒置的本地資料，多數可逆、刪了之後需要時會自動重建或可重新下載。下面的項目與數字都是這台機器的實測，換一台機器組成會完全不同；值得帶走的是每一項背後的判讀問題，不是這份清單本身。具體刪除指令因工具而異（Android Studio GUI、&lt;code>rm -rf&lt;/code>、&lt;code>ollama rm&lt;/code>），本文只做診斷與定位，刪除操作留給各工具自身的文件。以下逐項說明判讀依據。&lt;/p>
&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>舊版 Android NDK&lt;/td>
 &lt;td>約 3G&lt;/td>
 &lt;td>裝了多版、保留專案實際引用的版本，刪最舊&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>用不到的 AVD + system-image&lt;/td>
 &lt;td>約 3G&lt;/td>
 &lt;td>一個 API 版本一組、停用的版本連 AVD 帶映像一起刪&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Claude 桌面 Cowork 沙箱 VM&lt;/td>
 &lt;td>約 11G&lt;/td>
 &lt;td>只在使用桌面 App 的本地 agent 功能時才佈建，不用則可刪&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ollama 本地模型&lt;/td>
 &lt;td>約 9G&lt;/td>
 &lt;td>改用雲端後閒置的大模型可刪，小的 embedding 模型常是依賴&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Xcode iOS DeviceSupport&lt;/td>
 &lt;td>約 4.5G&lt;/td>
 &lt;td>實體裝置接線除錯的符號快取，重連會自動重建&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Android NDK 的判讀要回到「誰在用它」：這次專案是 Flutter，NDK 版本由 &lt;code>flutter.ndkVersion&lt;/code> 決定，而不是專案自己 pin。查當前 Flutter 要求的版本後發現，本機裝的兩版都是舊 Flutter 留下的殘留，於是保留較新的一版、刪掉最舊的。判斷可不可刪的關鍵是先確認「現在到底用哪版」，而不是看修改日期就動手。&lt;/p></description><content:encoded><![CDATA[<p>一台原本還有約 30G 餘裕的 Mac，使用幾小時後空間全部歸零，清過系統各種 cache 也沒有改善。這次排查的重點是順序與判讀依據：用什麼順序找、用哪個數字判斷，最後刪了什麼反而次要。順序對了，就能避開兩個讓人空轉的陷阱。</p>
<p>最後把整套診斷固化成一個唯讀的 <code>disk-report</code> 腳本，往後同類情況可以一行指令重跑。</p>
<h2 id="先確認問題是真的滿還是浮動的假象">先確認問題是「真的滿」還是「浮動的假象」</h2>
<p>排查磁碟的第一步是分辨空間到底去哪：是被真實檔案佔走，還是被系統的快照與 purgeable（系統可隨時回收的緩衝空間）暫時佔住。這兩者的處理方式完全不同，先分清楚才不會白清。</p>
<p>在 <a href="../macos_apfs_volume_structure/">APFS</a>（Apple File System，macOS 的預設檔案系統）上，根目錄 <code>/</code> 是唯讀的系統封印卷，真正存放使用者資料的是 <code>/System/Volumes/Data</code>，而它們和其他卷（<a href="../macos_preboot_volume_check/">Preboot</a>、Recovery、VM、模擬器 runtime）共用同一個 container（容器，APFS 管理空間的最上層單位）的空間池。判斷「還剩多少」要看整個 container 的可用空間，而不是單一卷的數字。</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">df -h /System/Volumes/Data
</span></span><span class="line"><span class="ln">2</span><span class="cl">diskutil info /System/Volumes/Data <span class="p">|</span> grep -iE <span class="s2">&#34;Container Free Space|Container Total Space&#34;</span></span></span></code></pre></div><p>這次的結果是資料卷 100% 滿、整個 container 只剩約 591MB。確認確實滿載、不是顯示誤差，後面才值得花力氣找佔用大戶。</p>
<h2 id="空間掉了又回來的根因本地快照與-purgeable">「空間掉了又回來」的根因：本地快照與 purgeable</h2>
<p>空間在幾小時內反覆消長、清 cache 卻無效，最常見的原因是 Time Machine 的本地快照（local snapshots）加上 macOS 的 purgeable 空間，而不是某個看得見的檔案。這是排查時要先排除的一條線。</p>
<p>本地快照的運作方式是：Time Machine 啟用時，系統約每小時自動建立一張快照「凍結」當下狀態，好讓本地也能做時光機回溯。這些被凍結的資料，正是先前以為已刪除、卻怎麼清都不會釋放的空間。快照保留約 24 小時（Apple 的 thinning 策略，觀察值），或在磁碟空間壓力過大時提前清除；後者正是「過一陣子空間又回來」的來源。若從未設定 Time Machine，這條線可跳過——沒啟用就不會有 local snapshot。</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">tmutil listlocalsnapshots /System/Volumes/Data</span></span></code></pre></div><p>這次查的時候快照數是 0，但這不代表它不是元兇——恰恰相反，是磁碟已經滿到讓系統把快照全數清光了。判讀訊號是：若這個指令平常列出多筆快照、且磁碟空間在數字上頻繁浮動，浮動量就來自這裡，跟手動清的 cache 無關。根治方向是把總用量降下來、讓磁碟保有餘裕，系統就不會一直貼著上限狂建狂清快照。</p>
<p>purgeable 是同一條線的另一半，但它沒有好用的精確讀數。<code>diskutil apfs list</code> 能看 container 層的概況，而 purgeable 主要由快照與系統快取構成、本來就會自己浮動。處理方式跟快照一樣：把總用量降下來、讓系統在空間有壓力時自行釋放，而不是找指令直接清它。「沒有直接讀數」本身就是判讀邊界——看到可用空間和「實際檔案總和」對不上時，差額多半就在這塊浮動緩衝，不必懷疑是哪個檔案在搞鬼。</p>
<h2 id="用實際佔用值找大戶避開-sparse-假大小">用實際佔用值找大戶，避開 sparse 假大小</h2>
<p>找佔用大戶要用 <code>du</code>（實際佔用的磁碟區塊）排序，不能依賴 <code>ls -l</code> 顯示、或 <code>find -size</code> 篩選所用的邏輯大小。對一般檔案兩者相同，但對 sparse 檔（稀疏檔）差距可以是好幾十倍，誤判會追錯目標。</p>
<p>這次就踩到這個陷阱。<code>find</code> 列出近期修改的大檔時，OrbStack（一套容器與 VM 執行環境）的虛擬磁碟映像顯示為 228G，看起來像頭號兇手；但用 <code>du</code> 一量，實際佔用只有 1.9G。同樣地，macOS Podcasts 在 tmp 塞的一堆 <code>.tmp.resize.img</code> 顯示有數十個檔，實際只佔 3.5M。這些都是 sparse 檔：宣告了很大的邏輯大小，但只有寫入過的區塊才真正佔磁碟。</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="c1"># 實際佔用（正確）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">du -sh ~/some/large.img
</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"># 顯示大小（對 sparse 檔會嚴重高估，誤判用）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">ls -lh ~/some/large.img</span></span></code></pre></div><p>定位順序是由外往內逐層收斂：先看家目錄前 20 大，鎖定最大的子樹（這次是 <code>~/Library</code> 70G 左右），再往下展開 <code>~/Library/Application Support</code>、<a href="../macos_app_sandbox_container/"><code>~/Library/Containers</code></a>，直到找到具體的檔案或目錄。Containers 裡的 UUID 目錄是 <a href="../macos_ios_app_on_mac/">iOS App on Mac</a> 的容器，辨識方式見 <a href="../macos_identify_app_containers/">Container 辨識</a>。</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">du -shx ~/* ~/.<span class="o">[</span>!.<span class="o">]</span>* 2&gt;/dev/null <span class="p">|</span> sort -rh <span class="p">|</span> head -20
</span></span><span class="line"><span class="ln">2</span><span class="cl">du -shx ~/Library/* 2&gt;/dev/null <span class="p">|</span> sort -rh <span class="p">|</span> head -12</span></span></code></pre></div><p><code>-x</code> 讓 <code>du</code> 不跨越檔案系統邊界，避免把掛載進來的唯讀卷（例如 iOS 模擬器 runtime）重複計入；<code>~/.[!.]*</code> 這個寫法只展開以單一點開頭的隱藏檔，排除掉 <code>.</code> 和 <code>..</code> 兩個會被一般 <code>.*</code> 誤抓進來、算出整個家目錄大小的假項目。</p>
<h2 id="這次找到的佔用大戶與處理">這次找到的佔用大戶與處理</h2>
<p>定位出來的大戶集中在開發工具鏈與閒置的本地資料，多數可逆、刪了之後需要時會自動重建或可重新下載。下面的項目與數字都是這台機器的實測，換一台機器組成會完全不同；值得帶走的是每一項背後的判讀問題，不是這份清單本身。具體刪除指令因工具而異（Android Studio GUI、<code>rm -rf</code>、<code>ollama rm</code>），本文只做診斷與定位，刪除操作留給各工具自身的文件。以下逐項說明判讀依據。</p>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>實際佔用</th>
          <th>處理判斷</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>舊版 Android NDK</td>
          <td>約 3G</td>
          <td>裝了多版、保留專案實際引用的版本，刪最舊</td>
      </tr>
      <tr>
          <td>用不到的 AVD + system-image</td>
          <td>約 3G</td>
          <td>一個 API 版本一組、停用的版本連 AVD 帶映像一起刪</td>
      </tr>
      <tr>
          <td>Claude 桌面 Cowork 沙箱 VM</td>
          <td>約 11G</td>
          <td>只在使用桌面 App 的本地 agent 功能時才佈建，不用則可刪</td>
      </tr>
      <tr>
          <td>ollama 本地模型</td>
          <td>約 9G</td>
          <td>改用雲端後閒置的大模型可刪，小的 embedding 模型常是依賴</td>
      </tr>
      <tr>
          <td>Xcode iOS DeviceSupport</td>
          <td>約 4.5G</td>
          <td>實體裝置接線除錯的符號快取，重連會自動重建</td>
      </tr>
  </tbody>
</table>
<p>Android NDK 的判讀要回到「誰在用它」：這次專案是 Flutter，NDK 版本由 <code>flutter.ndkVersion</code> 決定，而不是專案自己 pin。查當前 Flutter 要求的版本後發現，本機裝的兩版都是舊 Flutter 留下的殘留，於是保留較新的一版、刪掉最舊的。判斷可不可刪的關鍵是先確認「現在到底用哪版」，而不是看修改日期就動手。</p>
<p>Claude 桌面的 <code>vm_bundles</code> 是最大單一項目（11G）。它是桌面 App 的 Cowork 功能在本地沙箱 VM 裡執行程式用的根檔案系統映像。關鍵判讀是：它不是每次開 App 就重建——映像的修改日期停在數月前，是一次性佈建、之後沿用。只有實際使用 Cowork 沙箱時才會佈建和更新。所以對只用終端機 CLI、桌面 App 僅拿來聊天的人，這 11G 是純佔用，可以安全刪除；唯一後果是哪天實際開了 Cowork session，它會重新佈建。</p>
<p>剩下三項的判讀各有自己的關鍵問題。閒置的 AVD 與 system-image 是「一個 API 版本一組」的綁定，停用某個 Android 版本時要連 AVD 帶它依賴的系統映像一起刪，只刪一邊會留下半套。ollama 本地模型的判斷是「改用雲端後還會不會在本地跑」，閒置的大模型可刪，但小的 embedding 模型常被其他工具當依賴、刪了會牽連（ollama 模型的累積速度與專屬清理 idiom，見 <a href="/blog/llm/01-local-llm-services/hands-on/resource-management/" data-link-title="Hands-on：LLM 運行中 &#43; 結束的資源管理" data-link-desc="RAM / 磁碟 / port 三個 dimension 的觀察跟釋放、Ollama keep_alive 跟 ComfyUI 兩種 lifecycle 對比、實測釋放數字">本地 LLM 的資源管理</a>）。Xcode 的 iOS DeviceSupport 則是實體裝置接線除錯時產生的符號快取，可以放心刪——下次接上同一台裝置除錯時 Xcode 會自動重建。</p>
<p>這幾項合計回收約 17G，可用空間從約 591MB 拉回到 18G，磁碟脫離滿載。</p>
<h2 id="把診斷固化成-disk-report-腳本">把診斷固化成 disk-report 腳本</h2>
<p>一次性查完之後，把這套順序寫成腳本的價值是：下次同類情況不必重新回想指令與判讀順序，一行就能重跑，而且固定先看快照、再用實際佔用值，不會又掉進 sparse 假大小的陷阱。</p>
<p>腳本收在公開 repo <a href="https://github.com/tarrragon/scripts">tarrragon/scripts</a>，而不是放進某個專案的 <code>bin/</code>。它跟任何專案無關，連到個人 bin 才能在任何地方直接呼叫，也不會污染專案 repo。安裝方式是 clone 下來、把腳本本體 symlink 到 <code>~/.local/bin</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">git clone https://github.com/tarrragon/scripts.git ~/Projects/scripts
</span></span><span class="line"><span class="ln">2</span><span class="cl">ln -s ~/Projects/scripts/disk-report/disk-report ~/.local/bin/disk-report</span></span></code></pre></div><p>這一步預設 <code>~/.local/bin</code> 已在 PATH 上。若還沒設定，做法見 <a href="../macos_new_machine_setup/">macOS 新機基礎建設</a> 的對應項目。腳本刻意設計成唯讀：只報告、不刪除，刪什麼由人看完報告再決定。</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">disk-report              <span class="c1"># 完整診斷：總覽 + 快照狀態 + 各層大戶 + 開發環境可清項</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">disk-report --growing    <span class="c1"># 只看過去 180 分鐘內長大的大檔（抓動態暴增最快）</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">disk-report --growing <span class="m">60</span> <span class="c1"># 改成過去 60 分鐘</span></span></span></code></pre></div><p><code>--growing</code> 模式對應的是本文開頭那個「幾小時內暴增」的情境：當空間正在快速消失、想抓現行犯時，直接列出近期被寫入的大檔，比逐層 <code>du</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">find <span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">&#34;</span> -type f -size +50M -mmin -180 2&gt;/dev/null <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  -exec du -h <span class="o">{}</span> <span class="se">\;</span> 2&gt;/dev/null <span class="p">|</span> sort -rh <span class="p">|</span> head -25</span></span></code></pre></div><p>50M 的下限是為了過濾日常小檔雜訊、鎖定單一大檔暴增；若懷疑是大量小檔累積吃空間（如快取碎片），這個門檻抓不到，要回逐層 <code>du</code> 看目錄總量。排序依據同樣是 <code>du</code> 的實際佔用值，而不是 <code>find -size</code> 的邏輯大小門檻，理由和前面一致：避免 sparse 檔的邏輯大小把排序帶歪。</p>
<h2 id="排查順序總結">排查順序總結</h2>
<p>這次的方法可以收斂成一條固定順序，往後遇到任何「磁碟莫名變滿」都先照這條走：</p>
<ol>
<li>先看 container 可用空間，確認是真滿還是顯示誤差。</li>
<li>再查本地快照與 purgeable，排除「掉了又回來」的浮動來源。</li>
<li>用 <code>du -shx</code> 由外往內逐層找大戶，全程以實際佔用值判斷，不信 <code>ls</code> / <code>find</code> 的顯示大小。</li>
<li>對每個大戶問「現在誰在用它」再決定刪不刪，可逆的優先清。</li>
<li>把整套順序固化成唯讀腳本，下次一行重跑。</li>
</ol>
<p>第 3 步若收斂到 <code>~/Library</code> 這種多個 App 共用的大目錄，按目錄統計只能看出 Caches、Containers 各多大，看不出是哪幾個 App 佔的。把這棵子樹再按 App 拆開的做法，見 <a href="../macos_app_footprint_report/">macOS App 聚合佔用報告</a>。</p>
]]></content:encoded></item><item><title>如果mac多桌面錯置如何重置</title><link>https://tarrragon.github.io/blog/macos/macos_muti_spaces/</link><pubDate>Thu, 18 Sep 2025 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/macos/macos_muti_spaces/</guid><description>&lt;h2 id="what-happen-">What happen ?&lt;/h2>
&lt;p>Macos 不同螢幕的多桌面跑到錯誤的螢幕。&lt;/p>
&lt;h2 id="why-">Why ?&lt;/h2>
&lt;p>因為我會用並行裝置連接平板當成額外的螢幕，但是如果中斷再連接有時候會造成MACOS辨識多桌面出錯，導致無法正常切換桌面。&lt;/p>
&lt;h2 id="how-to-solve-this-">How to solve this ?&lt;/h2>
&lt;h3 id="方法一重置-mission-control-偏好設定">方法一：重置 Mission Control 偏好設定&lt;/h3>
&lt;p>這會清除所有顯示器的 Spaces 配置（等於重置多桌面設定）。&lt;/p>
&lt;p>打開 終端機（Terminal）&lt;/p>
&lt;p>輸入以下指令並按 Enter：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">defaults delete com.apple.spaces &lt;span class="c1">#清除設定&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">killall Dock &lt;span class="c1">#重啟 Dock&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Dock 重啟後，Mission Control 的桌面配置會被重置，所有螢幕會只剩下「一個桌面」。
注意：這會刪除所有已建立的桌面（Spaces），但不會影響 App 或檔案。&lt;/p>
&lt;h3 id="方法二暫時停用顯示器有單獨的空間">方法二：暫時停用「顯示器有單獨的空間」&lt;/h3>
&lt;p>macOS 會為每個螢幕建立自己的桌面空間，這個設定有時會造成錯亂。&lt;/p>
&lt;p>前往 系統設定 &amp;gt; 桌面與 Dock&lt;/p>
&lt;p>找到 「顯示器有單獨的空間」（英文為 Displays have separate Spaces）&lt;/p>
&lt;p>取消勾選&lt;/p>
&lt;p>登出並重新登入你的帳號&lt;/p>
&lt;p>重新勾選回來（如果你平常需要它）&lt;/p></description><content:encoded><![CDATA[<h2 id="what-happen-">What happen ?</h2>
<p>Macos 不同螢幕的多桌面跑到錯誤的螢幕。</p>
<h2 id="why-">Why ?</h2>
<p>因為我會用並行裝置連接平板當成額外的螢幕，但是如果中斷再連接有時候會造成MACOS辨識多桌面出錯，導致無法正常切換桌面。</p>
<h2 id="how-to-solve-this-">How to solve this ?</h2>
<h3 id="方法一重置-mission-control-偏好設定">方法一：重置 Mission Control 偏好設定</h3>
<p>這會清除所有顯示器的 Spaces 配置（等於重置多桌面設定）。</p>
<p>打開 終端機（Terminal）</p>
<p>輸入以下指令並按 Enter：</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">defaults delete com.apple.spaces <span class="c1">#清除設定</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">killall Dock                     <span class="c1">#重啟 Dock</span></span></span></code></pre></div><p>Dock 重啟後，Mission Control 的桌面配置會被重置，所有螢幕會只剩下「一個桌面」。
注意：這會刪除所有已建立的桌面（Spaces），但不會影響 App 或檔案。</p>
<h3 id="方法二暫時停用顯示器有單獨的空間">方法二：暫時停用「顯示器有單獨的空間」</h3>
<p>macOS 會為每個螢幕建立自己的桌面空間，這個設定有時會造成錯亂。</p>
<p>前往 系統設定 &gt; 桌面與 Dock</p>
<p>找到 「顯示器有單獨的空間」（英文為 Displays have separate Spaces）</p>
<p>取消勾選</p>
<p>登出並重新登入你的帳號</p>
<p>重新勾選回來（如果你平常需要它）</p>
]]></content:encoded></item></channel></rss>