老 MacBook Pro(Catalina)環境大掃除:zsh 啟動 1.2 秒 → 0.04 秒、磁碟多出 21GB

我有一台 2012 年的 MacBook Pro(i7-3540M、8GB RAM),系統停在它能升級的最後一版 macOS Catalina 10.15。這台機器陪我寫了十年程式,期間裝過 Anaconda、MacPorts、Homebrew、Android Studio、Xcode……每一樣都在 shell 設定和開機項目裡留下一點痕跡。最近連開個終端機都要頓一下,磁碟也只剩 53GB,於是決定好好整理一次。整個過程都是從另一台機器 ssh 進去完成的。

成果一覽

項目 優化前 優化後
zsh 啟動時間 1.2 秒 0.04 秒
磁碟可用空間 53 GB 74 GB
多餘的開機自啟服務 26 個 全部停用(可還原)
python3 / pip3 分屬 MacPorts / Homebrew 統一為 MacPorts 3.14
shell 歷史紀錄上限 1,000 筆(已在截斷) 50,000 筆
git Apple git 2.24,缺 git-lfs git 2.55 + git-lfs 3.5.1

盤點前的小坑:ssh 進去 brew 竟然不存在?

第一次執行 ssh mbp 'brew --version' 得到 command not found,差點以為 Homebrew 壞了。其實是**非互動的 ssh 指令不會讀 .zprofile/.zshrc**,PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin。遠端檢查環境時要明確開一個 login + interactive shell:

1
ssh mbp 'zsh -lic "brew --version; command -v python3"'

盤點下來,主要問題有這些:

  • zsh 啟動要 1.2 秒。
  • PATH 混雜:MacPorts、Homebrew、Python 2.7 framework 全都在裡面,還有 4 個早已不存在的目錄。
  • Homebrew 和 MacPorts 有 222 個同名指令,誰生效全看 PATH 順序。
  • python3 和 pip3 不是同一個 Python。
  • 開機自啟一大堆:CleanMyMac 2、360、迅雷、搜狗、TeamViewer、MySQL……
  • 磁碟用了 92%。

一、zsh 啟動:1.2 秒 → 0.04 秒

先量測,再用 zprof 找兇手

1
2
/usr/bin/time zsh -lic exit
# 1.20 real 0.50 user 0.87 sys

zsh 內建的 profiler 可以直接看出時間花在哪:

1
zsh -c 'zmodload zsh/zprof; source ~/.zprofile; source ~/.zshrc; zprof' | head
1
2
3
4
5
6
7
8
num  calls                time                       self            name
-----------------------------------------------------------------------------------
1) 1 608.43 608.43 93.16% 238.72 238.72 36.55% nvm_auto
2) 2 340.46 170.23 52.13% 190.12 95.06 29.11% nvm
3) 1 137.49 137.49 21.05% 121.73 121.73 18.64% nvm_ensure_version_installed
4) 2 32.37 16.19 4.96% 32.37 16.19 4.96% compaudit
...
8) 1 44.61 44.61 6.83% 12.24 12.24 1.87% compinit

**nvm 佔了 93%**。source nvm.sh 時它會自動執行 nvm use default,要解析版本別名、檢查安裝狀態,在這台老機器上要花 0.6 秒,而且每開一個終端機分頁都要付一次。

延遲載入 nvm

思路是:shell 啟動時只把 default 版本的 bin 目錄放進 PATH,node、npm、npx 立刻可用;真正的 nvm 函式等第一次被呼叫時才載入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export NVM_DIR="$HOME/.nvm"
nvm() {
unfunction nvm
source /opt/local/share/nvm/init-nvm.sh
nvm "$@"
}
() {
local v=$(<$NVM_DIR/alias/default 2>/dev/null)
local -a bins=($NVM_DIR/versions/node/v${v#v}(|.*)/bin(Nn))
if (( $#bins )); then
path=($bins[-1] $path)
else
# default 是 lts/* 之類的別名,無法直接解析,退回原本的載入方式
source /opt/local/share/nvm/init-nvm.sh
fi
}

幾個細節:

  • ~/.nvm/alias/default 的內容是 20。glob v20(|.*) 只會匹配 v20 或 v20.x.y,不會誤中 v200。(Nn) 是 null-glob 加數字排序,[-1] 取最新的 patch 版。
  • default 如果是 lts/*、node 這類別名就無法這樣解析,這時直接退回原本的載入方式,確保不會壞掉。
  • 之後執行 nvm use <版本> 時,nvm 會自己從 PATH 移除舊的 node 路徑再加上新的,不會殘留。

我的 nvm 是用 MacPorts 安裝的,所以路徑是 /opt/local/share/nvm/;用官方腳本安裝的話,換成 $NVM_DIR/nvm.sh 即可。

順手修掉的幾件事

歷史紀錄被截斷:Catalina 的 /etc/zshrc 預設 SAVEHIST=1000,而我的 .zsh_history 已經 1015 行,舊紀錄早就在默默被丟掉了。

1
2
3
4
5
6
7
HISTSIZE=50000
SAVEHIST=50000
setopt EXTENDED_HISTORY # 記錄時間戳
setopt INC_APPEND_HISTORY # 指令執行後立即寫入
setopt HIST_IGNORE_DUPS # 連續重複只記一次
setopt HIST_IGNORE_SPACE # 空格開頭的指令不記錄(適合含密碼的指令)
setopt HIST_REDUCE_BLANKS

compinit 快取:compinit 每次啟動都會跑 compaudit 做權限檢查。改成 dump 檔超過 24 小時才做完整檢查,其餘時間用 -C 直接沿用:

1
2
3
4
5
6
7
8
autoload -U compinit
() {
if (( $# )); then
compinit && touch ${ZDOTDIR:-$HOME}/.zcompdump
else
compinit -C
fi
} ${ZDOTDIR:-$HOME}/.zcompdump(N.mh+24)

這裡利用了「匿名函式的參數會先做 glob 展開」:(N.mh+24) 只在檔案超過 24 小時時才匹配,所以 $# 非零就代表該重建了。要 touch 是因為 compinit 在內容沒變時不會改寫 dump;不手動更新 mtime 的話,之後每次啟動都會走完整檢查。

PATH 去重、剔除不存在的目錄:放在 .zshrc 的最後兩行:

1
2
typeset -U path
path=($^path(N-/))

typeset -U 讓 path 陣列自動去重。$^path(N-/) 對每個元素做 glob 檢查,只留下實際存在的目錄(-/ 也接受指向目錄的 symlink)。這樣不必刪掉任何原有的 PATH 設定:那些已經搬走的工具目錄,哪天恢復了會自動回到 PATH。

安全地測試新設定

直接覆蓋 .zshrc,萬一寫壞,下次登入可能連 shell 都開不起來。用 ZDOTDIR 指向暫存目錄,就能在不動正式設定的情況下完整測試:

1
2
3
mkdir -p /tmp/zt && cp ~/.zprofile /tmp/zt/ && cp new_zshrc /tmp/zt/.zshrc
for i in 1 2 3; do /usr/bin/time env ZDOTDIR=/tmp/zt zsh -lic exit; done
ZDOTDIR=/tmp/zt zsh -lic 'node -v; nvm use 24; echo $PATH'

結果穩定在 0.03~0.06 秒。(最後那行 nvm use 24 還順便炸出了另一個問題,見第三節。)

插曲:被 nvm 洩漏的 shell 選項

整理時我注意到 setopt 的輸出裡有 nonomatch,也就是 glob 沒有匹配時原樣傳遞,而不是報錯。我一開始以為是 nvm 載入時設的,就在延遲載入後補上了 setopt nonomatch,想「保留原本的行為」。後來寫這篇文章前重新驗證,才發現這個推論是錯的:

1
2
3
4
zsh -fic 'source nvm.sh; echo before: $(setopt | grep -c nonomatch)
nvm ls >/dev/null; echo after-nvm-ls: $(setopt | grep -c nonomatch)'
# before: 0
# after-nvm-ls: 1

真正的來源是 nvm 的 nvm_list_aliases() 裡這一行:

1
nvm_is_zsh && unsetopt local_options nomatch

作者的本意應該是「在這個函式內暫時關掉 nomatch」,但 unsetopt local_options nomatch 是**同時關掉 LOCAL_OPTIONS 和 NOMATCH**。local_options 自己被關掉了,函式返回時就不會還原,nomatch 的變更於是洩漏到整個 shell。正確寫法是 setopt local_options nonomatch,nvm 在別的函式裡就是這樣寫的。

所以真相是:shell 剛啟動時 nomatch 是正常開啟的,只有在執行過 nvm ls、nvm alias 之後才會變成 nonomatch。我當初會看到它,正是因為那條檢查指令裡剛好跑了 nvm ls。最後我把多加的 setopt nonomatch 拿掉,維持 zsh 的預設行為。

教訓:觀察 shell 狀態的指令,本身也可能改變狀態。

二、python3 和 pip3 不是同一個 Python

1
2
3
python3  /opt/local/bin/python3                              Python 3.14.7     ← MacPorts
pip3 /usr/local/bin/pip3 pip (python 3.14) ← Homebrew
pip /Library/Frameworks/Python.framework/.../2.7/bin/pip pip (python 2.7)

兩邊版本號都是 3.14,很容易被騙過去。但 pip3 install 裝進的是 Homebrew Python 的 site-packages,python3 跑的卻是 MacPorts 的直譯器,於是出現經典的「明明裝了卻 ModuleNotFoundError」。pip 則更離譜,指向早已 EOL 的 Python 2.7。

判斷這種錯配最快的方法:

1
2
python3 -c 'import sys; print(sys.prefix)'
pip3 -V # 看 "from ..." 後面的路徑是否在同一個 prefix 底下

統一到 MacPorts

Homebrew 早已不再為 Catalina 提供預編譯套件,什麼都得從原始碼編譯;MacPorts 則仍有 10.15 的預編譯套件。所以選 MacPorts 當主力:

1
2
3
sudo port install py314-pip
sudo port select --set pip pip314
sudo port select --set pip3 pip314

MacPorts 的 python3.14 沒有放 PEP 668 的 EXTERNALLY-MANAGED 標記,所以 pip install 可以直接使用(專案開發還是建議用 venv)。

Python 2.7:移到 PATH 最後,而不是刪掉

原本 .zprofile 把 /Library/Frameworks/Python.framework/Versions/2.7/bin 放在 PATH 最前面。移除之前,先檢查有哪些指令只存在那個目錄:

1
2
3
4
P=/opt/local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
for c in $(ls /Library/Frameworks/Python.framework/Versions/2.7/bin); do
r=$(PATH=$P command -v $c) && echo "$c -> $r" || echo "$c -> (只存在 2.7 目錄)"
done

結果 python2、pip2 等在 /usr/local/bin 都有連結,但 bCNC(CNC 控制軟體)、bokeh、watchmedo 等只存在於 2.7 目錄。所以改成放到 PATH 最後:

1
PATH="${PATH}:/Library/Frameworks/Python.framework/Versions/2.7/bin"

python、pip 由 MacPorts 優先提供,2.7 專屬的工具也還能直接執行。

三、新版 Node.js 在 Catalina 上根本跑不起來

前面測試 nvm use 24 時出現了這個錯誤:

1
2
3
dyld: Symbol not found: __ZNSt3__113basic_filebufIcNS_11char_traitsIcEEE4openEPKcj
Referenced from: ~/.nvm/versions/node/v24.16.0/bin/node (which was built for Mac OS X 13.5)
Expected in: /usr/lib/libc++.1.dylib

官方 Node 24 的 macOS binary 是針對 macOS 13.5 編譯的,用到了 Catalina 系統 libc++ 沒有的符號。逐一檢查已安裝的版本:

1
for v in ~/.nvm/versions/node/*; do printf "%s: " ${v##*/}; $v/bin/node -v 2>&1 | head -1; done
版本 結果
v20.15.0 ✅ 正常
v23.11.1 ✅ 正常
v24.16.0 ❌ dyld: Symbol not found
v26.3.0 ❌ dyld: Symbol not found

更糟的是,nvm 的 node/stable 別名會自動指向「已安裝的最新版」,也就是壞掉的 v26。用 nvm uninstall 移除 v24、v26 之後,別名自動回落到可用的 v23。

nvm 安裝時不會檢查 binary 能否在當前系統上執行。在老系統上裝新版 Node 之前,最好先確認它支援的最低 macOS 版本。

四、開機自啟項目:停用 26 個

盤點

macOS 的自啟來源分散在好幾個地方:

1
2
3
ls ~/Library/LaunchAgents /Library/LaunchAgents /Library/LaunchDaemons
launchctl list | grep -v com.apple # 目前已載入的第三方服務
osascript -e 'tell application "System Events" to get the name of every login item'

結果相當可觀,分類處理如下:

類別 項目 處理
過時軟體常駐 CleanMyMac 2、360 安全、迅雷、百度網盤、搜狐影音、Steam 清理 停用
輸入法常駐 搜狗(其實已經不在輸入來源清單裡) 停用
自動更新程式 Adobe ARMDC、Google Updater、Java Updater、Paragon Updater 停用
開發 / 遠端服務 MySQL、TeamViewer、VirtualBox、Zoom、Intel HAXM 停用(需要時手動開)
必要元件 網銀安全控制項、Paragon NTFS 驅動、smartd、XQuartz、Wireshark ChmodBPF 保留

停用輸入法的服務之前,先確認它還有沒有在用:

1
defaults read com.apple.HIToolbox AppleEnabledInputSources | grep -E "Bundle ID|Input Mode"

用 launchctl disable,不要刪 plist

很多教學會叫你直接刪掉或搬走 plist,但軟體更新時常常會把 plist 再寫回來。launchctl disable 是在 launchd 自己的資料庫裡加上「停用」標記,跟 plist 檔案無關,軟體重寫 plist 也不受影響;要還原只要 enable。

1
2
3
4
5
6
7
8
9
10
11
# 使用者 session 內的 agent(包含 /Library/LaunchAgents 裡的)
launchctl disable gui/$(id -u)/<label>
launchctl bootout gui/$(id -u)/<label> # 立即停止

# 系統 daemon(/Library/LaunchDaemons)
sudo launchctl disable system/<label>
sudo launchctl bootout system/<label>

# 確認
launchctl print-disabled gui/$(id -u)
sudo launchctl print-disabled system

兩個實用的細節:

  • /Library/LaunchAgents 裡的 agent 雖然 plist 屬於 root,但它跑在使用者的 gui/<uid> domain,停用不需要 sudo。只有 /Library/LaunchDaemons 的才需要。
  • 迅雷、搜狐影音這類 App 用 SMLoginItemSetEnabled 註冊的登入項目,不在上面三個目錄裡,但一樣會出現在 launchctl list,也一樣可以用 launchctl disable gui/<uid>/<label> 停用。

坑:Label 不一定等於檔名

launchctl 吃的是 plist 裡的 Label,不是檔名。我用 PlistBuddy 逐一核對,果然抓到幾個不一樣的:

1
2
3
4
5
for f in ~/Library/LaunchAgents/*.plist /Library/LaunchAgents/*.plist /Library/LaunchDaemons/*.plist; do
n=$(basename "$f" .plist)
l=$(/usr/libexec/PlistBuddy -c "Print :Label" "$f" 2>/dev/null)
[[ "$n" == "$l" ]] || echo "檔名 $n → Label $l"
done
檔名 實際 Label
com.teamviewer.teamviewer_service.plist com.teamviewer.service
com.teamviewer.teamviewer_desktop.plist com.teamviewer.desktop
org.virtualbox.vboxwebsrv.plist org.virtualbox.vboxwebsvc

拿檔名去 disable 等於停錯對象,服務照樣開機啟動。另外也發現 Google Keystone 的三個 plist 已經是空檔,Java Helper-Tool 則是指向不存在檔案的失效 symlink。launchd 根本不會載入它們,不需要處理。

寫成可還原的腳本

需要 sudo 的部分整理成腳本,並內建 --restore:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/bash
# 停用:sudo bash startup-cleanup.sh
# 還原:sudo bash startup-cleanup.sh --restore(重開機後生效)
[[ $EUID -eq 0 ]] || { echo "請用 sudo 執行"; exit 1; }
U=gui/${SUDO_UID:-501}

GUI=( com.macpaw.CleanMyMac2Helper.scheduledScan com.xunlei.Thunder.ThunderHelper
com.teamviewer.teamviewer com.teamviewer.desktop org.virtualbox.vboxwebsvc # …略
)
SYS=( com.macpaw.CleanMyMac2.Agent com.qihoo.360safe.daemon com.thunder.ASDaemonService
com.oracle.oss.mysql.mysqld com.teamviewer.service us.zoom.ZoomDaemon # …略
)

if [[ ${1:-} == --restore ]]; then
for l in "${GUI[@]}"; do launchctl enable "$U/$l"; done
for l in "${SYS[@]}"; do launchctl enable "system/$l"; done
exit 0
fi

for l in "${GUI[@]}"; do launchctl disable "$U/$l"; launchctl bootout "$U/$l" 2>/dev/null; done
for l in "${SYS[@]}"; do launchctl disable "system/$l"; launchctl bootout "system/$l" 2>/dev/null; done

用 SUDO_UID 是為了在 sudo 底下仍能找到原本使用者的 gui domain。

重開機驗證

1
2
launchctl list | grep -iE "sogou|macpaw|thunder|teamviewer|vbox"   # 應該沒有輸出
kextstat | grep -iE "virtualbox|haxm" # 核心擴充也不該載入

重開機後 26 個項目一個都沒有回來,開機後的可用記憶體是 83%。

五、磁碟:53GB → 74GB

先找出大戶:

1
2
cd ~ && du -sh Library/Caches Library/Developer/CoreSimulator/* Library/Developer/Xcode/* \
.npm .gradle .nvm /usr/local/Cellar /opt/local 2>/dev/null
項目 大小 清理方式
iOS 模擬器裝置 15 GB xcrun simctl delete all(Xcode 會自動重建空裝置)
模擬器 dyld 快取 4.9 GB 刪除 ~/Library/Developer/CoreSimulator/Caches/*
Homebrew 快取、日誌、舊 portable-ruby 1.1 GB brew cleanup --prune=all
pip 快取 1 GB pip3 cache purge
無法執行的 Node v24 / v26 416 MB nvm uninstall
MacPorts 舊版本(64 個) — sudo port uninstall inactive

幾個注意點:

  • brew cleanup 先用 -n 做 dry-run 看清單,確認沒有動到 Cellar 裡正在使用的版本。
  • MacPorts 每次升級都會把舊版留著(inactive),不清的話會越積越多。我這邊光 sqlite3 就留了 3 個舊版。
  • APFS 釋放空間是非同步的:刪掉 15GB 的模擬器後,df 只多了 3GB,幾分鐘後才陸續回來。如果一直沒回來,檢查是不是被 Time Machine 本機快照佔住:tmutil listlocalsnapshots /。

~/Downloads 還有 135GB,不過那是個人檔案,留給自己慢慢整理。

六、git-lfs:Catalina 上的版本陷阱

.gitconfig 裡設了 filter.lfs.required = true,git-lfs 卻根本沒裝,clone 任何用到 LFS 的 repo 都會在 checkout 時失敗。

MacPorts 編不起來

1
sudo port install git-lfs
1
2
go: go.mod requires go >= 1.25.0 (running go 1.24.13; GOTOOLCHAIN=local)
make: *** [commands/mancontent_gen.go] Error 1

git-lfs 3.8 需要 Go 1.25,但 MacPorts 在 Catalina 上只提供到 Go 1.24。

改用官方的舊版 binary

上游 Go 從 1.23 起要求 macOS 11,所以要找用較舊 Go 編譯的 git-lfs 版本。v3.5.1 是用 Go 1.21.7 編譯的,下載後先驗證 checksum:

1
2
3
4
5
6
7
8
V=v3.5.1
curl -fsSLO https://github.com/git-lfs/git-lfs/releases/download/$V/git-lfs-darwin-amd64-$V.zip
curl -fsSLO https://github.com/git-lfs/git-lfs/releases/download/$V/sha256sums.asc
grep "git-lfs-darwin-amd64-$V.zip" sha256sums.asc | shasum -a 256 -c -
unzip -q git-lfs-darwin-amd64-$V.zip
./git-lfs-3.5.1/git-lfs version
# git-lfs/3.5.1 (GitHub; darwin amd64; go 1.21.7; git e237bb3a)
install -m 755 git-lfs-3.5.1/git-lfs /usr/local/bin/git-lfs

sha256sums.asc 本身有 PGP 簽章,要更嚴謹的話可以再用 gpg 驗證。

有趣的是,otool -l 顯示這個 binary 的 minos 是 12.0,但它在 10.15 上照樣能跑。binary 標註的最低版本不一定等於實際需求,以實測為準。

用一個本機 repo 做來回測試,確認 clean 和 smudge 兩個方向都正常:

1
2
3
4
5
git init -q src && cd src
git lfs track "*.bin" && head -c 100000 /dev/urandom > a.bin
git add .gitattributes a.bin && git commit -qm test
git cat-file -p HEAD:a.bin | head -1 # version https://git-lfs.github.com/spec/v1 → 存進去的是指標
cd .. && git clone -q file://$PWD/src dst && cmp src/a.bin dst/a.bin && echo OK

意外收穫與清理

那次失敗的 port install git-lfs 並非全無作用:它先把依賴的 git 2.55 裝好了。/opt/local/bin 在 PATH 上排在 /usr/bin 前面,所以 git 直接從 Apple 的 2.24 升級到 2.55。MacPorts 的 git 預設用 osxkeychain 當 credential helper,已存的帳密照常可用。

不過它也留下了 go、asciidoctor、ronn-ng 這些只在建置時用得到的東西。用 leaves 可以找出「沒被任何套件依賴、也不是自己要求安裝」的 port:

1
2
3
port installed leaves
sudo port setrequested git # 標記 git 是我要的,避免被當成孤兒清掉
sudo port -N uninstall --follow-dependencies go asciidoctor rb33-ronn-ng

--follow-dependencies 會順便移除因此變成孤兒的依賴,這次連 MacPorts 版的 autoconf、automake 也被帶走了。好在 Homebrew 也有提供,指令仍然可用;但清理之前最好先確認會不會有這種連鎖反應。

還沒做的:Homebrew 與 MacPorts 二選一

兩套套件管理器有 222 個同名指令,誰生效完全取決於 PATH 順序,這次的 python/pip 錯配就是這樣來的。長期來看應該只留一套;在 Catalina 上,還有預編譯套件可用的 MacPorts 是比較實際的選擇。這是個大工程,留待下次。

小結

  1. 先量測再優化:/usr/bin/time zsh -lic exit 加上 zprof,一眼就看出 93% 的時間耗在 nvm。
  2. 延遲載入是 shell 提速最有效的手段:PATH 先放好,重量級的初始化等真的用到再做。
  3. 改設定前用 ZDOTDIR 在暫存目錄測試,不會把自己鎖在 shell 外面。
  4. **對照 python3 -c 'import sys; print(sys.prefix)' 和 pip3 -V**,三秒就能抓出 pip 錯配。
  5. **停用開機項目用 launchctl disable**:不刪檔、不怕軟體重寫 plist、可以還原。記得核對 Label;/Library/LaunchAgents 的項目停用不需要 sudo。
  6. 老系統上的新版軟體一定要實測:Node 24 報 dyld 錯誤、git-lfs 3.8 編不過,都是 Catalina 落後的現實;反過來,標著 minos 12.0 的 binary 卻能跑。
  7. 觀察狀態的指令也可能改變狀態:nvm 的 unsetopt local_options nomatch 讓我一度誤判了 shell 的原始設定。