老 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 的原始設定。

Raspberry Pi 5 外接 USB SSD 無故凍死:RTL9210B + UAS 掉線的定位與修復

我有一台 Raspberry Pi 5,root 檔案系統跑在一顆外接 USB SSD 上,平常掛著一個常駐的 web 服務。某天它突然「連不上」——SSH、網頁全都沒反應,只能手動拔電重開。這篇記錄我怎麼一步步把原因鎖定到外接 SSD 的 USB 橋接晶片,以及最後怎麼修。

現象

  • 服務與 SSH 全部無回應,整台機器像從網路上消失。
  • 沒有任何預警,重開機後一切又正常。
  • 直覺會以為是「網路斷線」,但事實不是。

第一步:這是斷線,還是整台凍死?

關鍵證據在系統日誌。先看上一次開機(-1)的日誌尾端:

1
journalctl -b -1 --no-pager | tail -60

結果日誌在 20:12:30 戛然而止,停在服務每隔幾秒一次的正常輪詢中間,之後直到我手動重開(約 47 分鐘後)完全空白。再對照開關機記錄:

1
last -x reboot shutdown | head

兩次開機之間沒有 shutdown 記錄——代表系統不是正常關機,是被硬斷電的。

這兩點合起來說明:不是網路掉線,也不是單一服務崩潰(那樣其他服務還會繼續寫日誌),而是 journald 本身都停止寫入 = 整個作業系統凍死(hard freeze),所以才會「連不上」。

第二步:逐一排除常見死因

Pi 當機最常見的幾個原因,用日誌與現場狀態逐一排除:

嫌疑 檢查方式 結果
欠壓 / 電源不穩 vcgencmd get_throttled、journal 搜尋 under-voltage 0x0,全程無記錄 ❌
過熱降頻 vcgencmd measure_temp ~60°C,無 thermal throttle ❌
記憶體耗盡(OOM) journal 搜尋 oom-kill 記憶體充足、swap 沒動 ❌
Kernel panic / soft-lockup / RCU stall journalctl -b -1 -k 搜尋 完全沒有 ❌
磁碟寫滿 df -h / 只用 29% ❌
SSD 硬體故障 smartctl -H /dev/sda PASSED(碟本身健康)❌

軟體、電源、溫度、記憶體、磁碟空間、硬碟健康——全部排除。一個「什麼都沒記錄到」的完全凍死,開始把矛頭指向底層硬體。

第三步:鎖定根因

先看 root 到底掛在什麼裝置上:

1
2
lsblk -o NAME,SIZE,TYPE,TRAN,MODEL,MOUNTPOINT
lsusb

答案是:root(/dev/sda2)在一顆外接 USB SSD 上,透過 Realtek RTL9210B-CG(USB id 0bda:9210,一顆 M.2 NVMe → USB 橋接晶片)連接,且目前綁在 uas 驅動(USB Attached SCSI)上。

再看 USB 連結的健康狀況,兩次開機都各有幾筆:

1
2
usb 2-1: Enable of device-initiated U1 failed.
usb 2-1: Enable of device-initiated U2 failed.

這是這顆橋接晶片 USB3 連結電源狀態(LPM)協商不良的訊號。

RTL9210 / RTL9210B + UAS + Pi 是社群裡惡名昭彰的組合:這顆橋接在 UAS 模式下、連結不穩時會突然從 USB 匯流排掉線。而因為 root 就在這顆碟上,一旦碟消失,kernel 連日誌都寫不進去(記錄目標沒了),整台機器瞬間凍住、網路全死,只能硬斷電——這正好完美對上我們看到的指紋。

誠實說一句:因為是「凍死」,當下那一刻按定義寫不出任何 log,所以不存在一行「鐵證日誌」。以上是「排除法 + 硬體指紋 + 前後的 U1/U2 失敗」三者共同指向的高信心結論。

修復:停用這顆橋接的 UAS

最簡單有效的做法,是針對這顆橋接停用 UAS,退回較保守但穩定的 BOT(Bulk-Only Transport)模式。在 /boot/firmware/cmdline.txt(必須維持單行)結尾加上:

1
usb-storage.quirks=0bda:9210:u

0bda:9210 是這顆橋接的 vid:pid,:u 代表對它停用 UAS。改完重開機生效。

修改前務必備份(root 在這顆碟上,動開機參數要小心):

1
sudo cp -a /boot/firmware/cmdline.txt /boot/firmware/cmdline.txt.bak-$(date +%Y%m%d-%H%M%S)

重開機後驗證是否生效:

1
2
3
# 驅動應該從 uas 換成 usb-storage
ls /sys/bus/usb/drivers/uas/ # 應該沒有裝置
dmesg | grep -iE "UAS is ignored|Quirks match"

看到這兩行就對了:

1
2
usb 2-1: UAS is ignored for this device, using usb-storage instead
usb-storage 2-1:1.0: Quirks match for vid 0bda pid 9210: 800000

效能代價:實測 UAS vs BOT

停用 UAS 一定有代價,但到底掉多少?我用 fio 在改動前後各跑一次相同測試(測試檔寫在真正的 SSD 上、--direct=1 繞過 cache):

測項 UAS(前) BOT(後) 變化
循序讀 (1M, QD16) 389 MB/s 373 MB/s −4%
循序寫 (1M, QD16) 360 MB/s 349 MB/s −3%
隨機讀 4K QD32 32.4k IOPS 6.5k IOPS −80%
隨機寫 4K QD32 34.8k IOPS 7.4k IOPS −79%
隨機讀 4K QD1 6,302 IOPS 6,378 IOPS ~持平

解讀:

  • 循序讀寫幾乎無損(−3~4%):開機、複製大檔、備份完全無感。
  • QD1(一次一條操作)完全沒變:單執行緒、延遲受限的存取不受影響。
  • 深佇列隨機 IOPS 掉約 80%:這就是 UAS 指令佇列消失的代價。注意 BOT 的 QD32 成績幾乎等於它自己的 QD1——證明 BOT 把並發請求序列化成一次一條了。

換句話說,只有在「大量小 I/O 同時打」的場景(例如高並發資料庫)才會明顯感覺到。對低並發、以序列小讀寫為主的服務來說,日常幾乎無感。拿深佇列隨機 IOPS 換「不再整台凍死、不用拔電」,對一台要穩定常駐的機器絕對划算。

完整的測試腳本與原始數據我一併放在本文同目錄:ssd_bench.sh、bench_uas_baseline.txt、bench_bot_after.txt。

更徹底的方案

如果很在意那 80% 的隨機 IOPS,或掉線仍偶發,最終解是把 M.2 NVMe 從 USB 轉接盒移到 Pi 5 官方的 NVMe HAT,走原生 PCIe,完全繞開 USB 橋接——穩定性與速度兩者都贏,隨機效能不但拿回來還更快。

加一層監控

修完不能只靠「感覺沒再當」。我寫了一個小腳本掛 cron 每 5 分鐘檢查一次:SSD 溫度(smartctl -A)、SoC 溫度與 throttle 旗標(vcgencmd)、SMART 健康、以及最近的 kernel USB/儲存錯誤(掉線的前兆),任何異常就在日誌標上 [WARN] / [ALERT]:

1
*/5 * * * * ~/ssd_monitor.sh >/dev/null 2>&1

正常時每行長這樣,之後要回頭查有沒有過熱或掉線,grep 一下 WARN/ALERT 即可:

1
2026-08-22 21:32:44 [OK] ssd=36C soc=59.8C throttled=0x0 health=PASSED drv=usb-storage

小結

  1. 先分清楚是「斷線」還是「凍死」:看 journald 是否整個停止寫入、有沒有 shutdown 記錄,比什麼都關鍵。
  2. 用排除法縮小範圍:電源、溫度、OOM、panic、磁碟、SMART 一輪掃過。
  3. root 在外接 USB SSD 上時,橋接晶片掉線會拖垮整台機器——RTL9210 + UAS 是已知雷區,usb-storage.quirks=<vid>:<pid>:u 是便宜有效的解法。
  4. 代價心裡有數:循序幾乎無損,深佇列隨機 IOPS 是主要犧牲;真要效能就上 NVMe HAT。
  5. 修完補上監控,用數據確認問題真的走了。

AI 模型是不是越大越聰明?

很多人直覺上會覺得:AI 模型越大,參數越多,就一定越聰明。

這個判斷只對了一半。模型規模確實很重要,特別是在預訓練階段,更多參數、更多資料、更多算力通常能帶來更好的基礎能力。但到了今天,模型是否「聰明」已經不只看大小,還要看資料品質、架構設計、推理方法、工具使用能力,以及它被優化的任務場景。

換句話說,大模型不一定永遠贏;小模型也不一定只能做簡單任務。

為什麼「越大越聰明」不再夠用?

早期大型語言模型的發展很依賴 scaling laws:模型越大、資料越多、訓練算力越高,整體表現往往越好。這仍然是基礎模型發展的重要規律,但它不是唯一規律。

現在判斷一個模型強不強,至少要看下面幾件事。

1. 資料品質比資料數量更關鍵

一個 7B 參數的小模型,如果使用高品質、乾淨、結構良好的資料訓練,可能在特定任務上超過一個資料品質較差的 70B 模型。

原因很直觀:模型不是單純「吃越多文字越好」,它會學到資料中的推理方式、語言習慣、錯誤模式和偏見。如果訓練資料裡有大量重複內容、低品質網頁、錯誤答案或機器生成垃圾,模型也會把這些缺陷吸收進去。

所以近年的模型訓練越來越重視:

  • 資料清洗與去重
  • 高品質教科書、論文、程式碼與解題資料
  • 合成資料與人工驗證資料
  • 從強模型蒸餾出的高品質推理樣本

2. 架構會影響「有效參數」

有些模型採用 MoE(Mixture of Experts,混合專家)架構。MoE 模型可以擁有非常多的總參數,但每次推理時只啟用其中一部分專家。

這代表兩件事:

  • 模型的「總參數」不等於每次回答實際動用的「活躍參數」。
  • 一個模型可以在儲存上很大,但推理成本不一定等比例增加。

例如 DeepSeek-V3 / R1 系列公開資料顯示,它們是 671B 總參數的 MoE 模型,但每個 token 只啟用約 37B 參數。這種設計讓模型能兼顧容量與推理效率。

3. 推理能力和知識量不是同一件事

大模型通常有更強的知識覆蓋能力。它像一本更大的百科全書,能記住更多冷門事實、語言模式和跨領域資訊。

但推理模型的進步改變了另一件事:模型可以用更多「思考時間」換取更好的解題能力。像 OpenAI o 系列、DeepSeek-R1 這類 reasoning model,重點不只是把答案背出來,而是在回答前產生更長的中間推理、檢查、修正和嘗試。

這讓一些較小的模型在數學、程式碼、邏輯題等任務上,能接近甚至超過過去更大的通用模型。

4. 小模型在邊緣裝置上越來越實用

手機、筆記型電腦和一般工作站已經可以執行 3B、7B、14B 甚至更大的量化模型。這些模型不一定適合處理所有問題,但在摘要、改寫、分類、簡單問答、程式輔助、個人知識庫檢索等任務上,已經很有實用價值。

小模型的優勢包括:

  • 成本低
  • 延遲低
  • 可離線使用
  • 隱私更好
  • 可以針對特定任務微調

它們的短板則是:

  • 冷門知識較弱
  • 長上下文能力通常較弱
  • 複雜任務穩定性不如前沿大模型
  • 容易在知識不足時產生幻覺

Claude、GPT 這類前沿模型到底有多大?

OpenAI、Anthropic 等公司通常不公開前沿閉源模型的參數量、架構細節、訓練資料和硬體配置。外界有很多推估,例如從 API 價格、推理延遲、晶片成本、論文線索或模型行為反推,但這些都不是官方數字。

因此,像「某某模型有 9.7T 參數」、「某某 Claude 有 5T 參數」這種說法,要非常小心看待。它們可以作為市場傳聞或研究推測,但不適合寫成確定事實。

比較穩健的說法是:

  • 前沿閉源模型的具體參數量通常未公開。
  • 它們可能使用非常大的 dense 或 MoE 架構,也可能結合多模型路由、工具使用、檢索系統和測試時計算。
  • 真正影響使用者體驗的,不只是參數量,還包括後訓練、對齊、推理策略、上下文長度、工具調用、系統工程和服務端路由。

模型檔案大小怎麼估算?

如果只粗略估算模型權重大小,可以用下面的方式理解:

  • FP16 / BF16:每個參數約 2 bytes
  • INT8:每個參數約 1 byte
  • INT4:每個參數約 0.5 byte

所以一個 7B 模型:

  • FP16 約 14GB
  • INT8 約 7GB
  • INT4 約 3.5GB 到 5GB 左右,實際大小還會受格式、metadata、量化方法影響

一個 14B 模型:

  • FP16 約 28GB
  • INT8 約 14GB
  • INT4 約 7GB 到 9GB 左右

這也是為什麼「10GB 以下」通常對應到 7B 或 14B 等級的量化模型,而不是完整未壓縮的大模型。

有沒有可能用 10GB 以下做出接近 Claude Opus 的模型?

要看「接近」指的是什麼。

如果指的是所有能力都等同於 Claude Opus 這類前沿閉源模型,那目前不現實。10GB 以下的小模型無法同時擁有同等級的百科知識、長上下文穩定性、多語能力、工具整合、複雜任務泛化能力和安全對齊。

但如果只看特定任務,例如數學解題、程式碼補全、格式化輸出、客服分類、文件摘要、固定領域問答,那 10GB 以下的小模型完全可能接近甚至超過大型通用模型。

關鍵在於三個技術方向。

1. 用思考時間換模型體積

推理模型會生成較長的中間推理,透過嘗試、檢查和修正提升答案品質。這有點像讓一個較小的模型多花一點時間做題,而不是要求它立刻反射式回答。

這種方法在數學和程式任務上特別有效,因為答案可以被驗證:數學題有標準答案,程式碼可以跑測試,邏輯題可以檢查約束。

2. 用知識蒸餾濃縮能力

知識蒸餾的概念是讓強模型當老師,產生高品質解題過程、範例答案或任務資料,再讓小模型學習。

小模型不需要重新讀完整個網路,也不需要自己摸索所有推理路徑。它可以直接學習強模型留下的解題痕跡。DeepSeek-R1 發布時,就同時開源了多個從 R1 蒸餾而來的小型 dense models,包含 1.5B、7B、8B、14B、32B、70B 等版本。

3. 用量化降低硬體需求

量化可以把模型權重從 FP16 壓到 INT8、INT4,讓模型更容易放進一般顯示卡或 Mac 的統一記憶體裡。

不過量化不是免費午餐。壓得越狠,模型越可能出現精度下降、推理不穩、長上下文退化或特定任務能力受損。好的量化方法可以保留大部分能力,但不能保證所有模型、所有任務都維持同樣表現。

10GB 小模型適合什麼?

適合:

  • 個人離線助理
  • 文件摘要與改寫
  • 程式碼解釋與簡單除錯
  • 固定領域客服
  • 搭配 RAG 的知識庫問答
  • 資料抽取與格式化
  • 隱私敏感的本地工作流

不太適合:

  • 需要大量冷門知識的開放問答
  • 超長文件的精準跨段推理
  • 高風險醫療、法律、金融決策
  • 需要穩定多步工具調用的複雜代理任務
  • 對錯誤容忍度很低的生產決策

結論

AI 模型不是越大就一定越聰明。更準確的說法是:規模提供上限,但資料、架構、後訓練、推理策略和任務設計,決定模型能不能把這個上限發揮出來。

大模型像一個知識面極廣、泛化能力強的專家團隊;小模型則像一個被訓練得很專精、反應很快、成本很低的助手。

如果你要的是通用能力、長文本理解、跨領域穩定性,前沿大模型仍然有明顯優勢。
如果你要的是本地部署、低成本、隱私、固定任務和快速反應,小模型已經非常值得認真考慮。

參考資料

How to Set Up Minecraft Server At Home And Expose It To Public

我把 Minecraft Java 版伺服器放在家裡的電腦上,朋友從外網用一個固定的公網位址就能連進來。伺服器跑在虛擬機裡的 Docker 中,跟日常使用的系統隔開。外網那一端只放一台很小的 AWS 機器,負責把連接埠轉回家。遊戲行程和世界存檔都留在家裡。

這篇只涵蓋 Java 版。Java 版走 TCP 25565,反向 SSH 隧道轉得動。基岩版(Bedrock)走的是 UDP,同一條隧道轉不了。下面的遊戲版本和 AWS 免費額度,以 2026 年 10 月的情況來寫。

最後長這樣

1
2
3
4
5
6
7
8
9
10
朋友的 Minecraft 客戶端
│ TCP 25565
▼
AWS EC2(公網 IP,只轉流量)
│ 反向 SSH,由家裡主動連出
▼
家裡電腦上的虛擬機
│ 127.0.0.1:25565
▼
Docker 裡的 Minecraft Java 伺服器

家裡的虛擬機開機後自己連去 AWS。連線是從家裡打出去的,所以家用路由器不用做任何連接埠轉發,電信把用戶放在 CGNAT 後面也沒關係。朋友連的是 AWS 的位址。世界存檔留在家裡的硬碟上。

比較過的幾種做法

做法 它解決什麼 卡在哪
Port mapping(連接埠轉發) 路由器把外網的 25565 轉到家裡某台電腦 很多家用線路沒有真正的公網 IP,外面連不進來。連得進來時,家裡的連接埠也直接暴露在外網,IP 一變朋友存的位址就失效
Virtual Server(虛擬伺服器) 同上。這是不少家用路由器韌體裡的名字,背後還是連接埠轉發 跟 Port mapping 是同一件事
DDNS 給會變的家用 IP 一個固定網域名稱 只解決「位址會變」。連不進家裡、以及家裡連接埠對外開放,兩件事都還在
Reverse SSH tunnel(反向 SSH 隧道) 家裡主動連上一台有公網 IP 的小機器,由那台機器代收 25565 多一台常開的小機器,以及它的公網 IPv4 費用

我用反向 SSH 隧道。家裡沒有穩定的公網 IP,也不想為了一台遊戲伺服器去改路由器、把家裡網路露出去。

為什麼外面只放一台小機器

Minecraft 吃 CPU 和記憶體的地方在家裡。AWS 上那台機器只轉 TCP,t2.micro(1 vCPU、1 GiB 記憶體)就夠。它不跑 Java,也不存世界。

免費額度要看帳號是什麼時候開的,以 AWS 文件 為準:

帳號開立時間 額度
2025-07-15 之前 開通後 12 個月。每個月 750 小時的 t2.micro 或 t3.micro,以及搭配 EC2、使用中的公網 IPv4 750 小時
2025-07-15 當日或之後 改成點數制,最高約 200 美元,用完或滿 6 個月為止。主機要選當時標成 free tier eligible 的機型,新帳號通常是 t3.micro 這一代,不一定還有 t2.micro

公網 IPv4 從 2024-02-01 起每小時 0.005 美元,一個位址全月大約 3.65 美元。舊額度的 750 小時通常蓋得住「掛在一台運行中的 EC2 上的那一個位址」。下面兩種情況會計入帳單:

  • 執行個體停了,Elastic IP 還掛在上面
  • 帳號裡另外躺著沒在用的 Elastic IP

這台跳板要一直開著。朋友存的是它的位址,機關掉,伺服器就從外網消失。

任何有公網 IPv4 的小 VPS 都能擔任同一個角色。後面的 SSH 設定不依賴 AWS。

家裡:用虛擬機把伺服器隔開

Minecraft、Docker、以及連去 AWS 的金鑰都放在虛擬機裡。宿主機不開 25565,也不持有那把隧道金鑰。

VirtualBox VMware Workstation
取得方式 直接安裝 個人使用曾改為免費,授權條件時有變動,安裝前看一次目前條款
沒有桌面時怎麼跑 VBoxManage startvm "minecraft" --type headless 有自己的 headless 模式
這次的需求 客體要能主動連出外網 同左

我用 VirtualBox。客體用 NAT 就夠了:隧道是虛擬機自己連出去,VirtualBox 不用做連接埠轉發。

客體我裝 Ubuntu Server LTS,給它:

  • 2 顆 vCPU
  • 4 GB 記憶體(下面的 Java 堆疊用 2 GB,剩下留給系統和容器)
  • 30 GB 虛擬磁碟(世界和備份都放這裡,地圖大了再加)

宿主機開機後要把這台虛擬機以 headless 拉起來,裡面的 systemd 才會接著跑。Linux 宿主上就是開機時執行:

1
VBoxManage startvm "minecraft" --type headless

虛擬機裡用 Docker 跑伺服器

Docker 負責三件事:Java 版本不用自己管、世界資料放在一個目錄裡、升級時拉新映像再重建容器。

映像用 itzg/minecraft-server。先在客體裝 Docker Engine 和 Compose 外掛,步驟以 Docker 的 Ubuntu 安裝說明 為準,重點是最後要有 docker compose 這條指令,以及把使用者加入 docker 群組:

1
sudo usermod -aG docker "$USER"

登出再登入,群組才會生效。

在家目錄建一個目錄,寫入 compose.yaml:

1
mkdir -p ~/minecraft
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# ~/minecraft/compose.yaml
services:
mc:
image: itzg/minecraft-server
tty: true
stdin_open: true
restart: unless-stopped
ports:
# 只聽虛擬機自己。外面進來的路是下一節的 SSH 隧道。
- "127.0.0.1:25565:25565"
environment:
EULA: "TRUE"
TYPE: PAPER
# 改成朋友客戶端主畫面左下角的版本。兩邊不一致就會被踢出。
# 26.3 是 2026-09 的 Java 正式版。
VERSION: "26.3"
MEMORY: "2G"
ENABLE_RCON: "true"
RCON_PASSWORD: "replace-with-a-long-password"
TZ: "Asia/Taipei"
MOTD: "home"
volumes:
- ./data:/data

EULA: "TRUE" 是在接受 Minecraft EULA。不設的話容器會直接退出。這份 EULA 不允許靠伺服器本身向玩家收費;這裡是朋友一起玩的私人伺服器。

TYPE: PAPER 用的是 Paper。同樣的視距,它比官方伺服器省 CPU。想改回官方伺服器,拿掉 TYPE 這行即可,預設是 VANILLA。

RCON 只活在容器裡,compose 沒有把 25575 公布出去。密碼換掉,這個檔案不要再貼到別的地方。

啟動並看日誌,等到出現 Done:

1
2
3
cd ~/minecraft
docker compose up -d
docker compose logs -f

第一次會下載伺服器,多等幾分鐘。確認只聽在本機:

1
2
ss -lnt | grep 25565
# 要看到 127.0.0.1:25565

世界在 ~/minecraft/data。docker compose down 不會刪掉這個目錄。升級映像:

1
2
docker compose pull
docker compose up -d

版本號要升級時,先備份 data,再改 VERSION,然後執行上面兩行。

跳板:讓外網連得到 25565

在離玩家近的區域開一台 Amazon Linux 2023。舊免費額度用 t2.micro,新帳號改選當下標成 free tier eligible 的最小機型。安全群組先開兩個規則:

連接埠 來源 用途
TCP 22 家裡目前的 IP(主控台的 My IP) 自己登入
TCP 25565 0.0.0.0/0 朋友連 Minecraft

22 不要對全世界開。家裡 IP 變了導致登不進去時,從 AWS 主控台改這條規則即可,主控台不走這台機器的 SSH。

接著申請一個 Elastic IP 並掛到這台執行個體。沒掛之前,停止再開啟會換一個公網 IP,朋友存的位址就失效。掛上之後,原本自動配的那個公網 IP 會被放掉,帳號裡仍然只有一個位址。

SSH 預設把反向隧道綁在 127.0.0.1,那樣只有跳板自己連得到。要讓安全群組放進來的流量被接到隧道上,跳板的 sshd 得允許客戶端指定監聽位址。新增:

1
2
3
4
5
# /etc/ssh/sshd_config.d/99-minecraft-tunnel.conf
AllowTcpForwarding yes
GatewayPorts clientspecified
ClientAliveInterval 30
ClientAliveCountMax 3

GatewayPorts clientspecified 的意思是:客戶端有寫監聽位址才對外聽。沒寫的話仍然只聽 loopback。ClientAlive* 讓斷掉的隧道被清掉,避免 25565 被一條已經死掉的連線占住。

檢查語法、確認設定有被讀進去,再重載:

1
2
3
sudo sshd -t
sudo sshd -T | grep -i gatewayports
sudo systemctl reload sshd

輸出要是 gatewayports clientspecified。若仍是 gatewayports no,就是主設定檔裡已有 GatewayPorts no,而且比這個 drop-in 先被讀到。把那一行改成 GatewayPorts clientspecified 再查一次。sshd 同一項設定以先讀到的為準。

另外建一個只能轉連接埠的使用者,不拿來登入做別的事:

1
2
3
sudo useradd --create-home --shell /bin/bash tunnel
sudo mkdir -p /home/tunnel/.ssh
sudo chmod 700 /home/tunnel/.ssh

/home/tunnel/.ssh/authorized_keys 先留空,下一節把虛擬機的公鑰貼進來。那一行會限制這把金鑰只能轉 25565。

把隧道做成斷線會自己回來

在虛擬機裡產生一把沒有密碼的金鑰。沒有密碼是為了讓開機能自己連;權限靠跳板上的 authorized_keys 收緊,這把金鑰做不了別的事。

1
2
ssh-keygen -t ed25519 -f ~/.ssh/mc-tunnel -N ""
cat ~/.ssh/mc-tunnel.pub

把印出來的公鑰放到跳板的 /home/tunnel/.ssh/authorized_keys,整行是:

1
restrict,port-forwarding,permitlisten="*:25565" ssh-ed25519 AAAA... mc-tunnel

restrict 關掉終端、指令和轉發,後面的 port-forwarding 再把轉發打開,permitlisten 只放行 25565。檔案權限:

1
2
sudo chown -R tunnel:tunnel /home/tunnel/.ssh
sudo chmod 600 /home/tunnel/.ssh/authorized_keys

permitlisten 需要 OpenSSH 7.9 之後的版本。Amazon Linux 2023 和 Ubuntu 22.04 之後都有。

回到虛擬機,先手動試一次。203.0.113.10 換成自己的 Elastic IP:

1
2
3
4
5
6
7
8
ssh -N \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-o StrictHostKeyChecking=accept-new \
-i ~/.ssh/mc-tunnel \
-R 0.0.0.0:25565:127.0.0.1:25565 \
tunnel@203.0.113.10

-R 0.0.0.0:25565:127.0.0.1:25565 是整篇的關鍵:跳板在所有介面上聽 25565,流量送回虛擬機的 Minecraft。少寫前面的 0.0.0.0:,跳板就只聽它自己,朋友會一直逾時。ExitOnForwardFailure=yes 讓「連接埠沒綁上」變成直接失敗,而不是一條看起來還活著、其實沒在轉的連線。

在跳板上另開一個工作階段確認:

1
2
sudo ss -lntp | grep 25565
# 要看到 0.0.0.0:25565,行程是 sshd

再用手機網路,或任何不在家裡的機器:

1
nc -vz 203.0.113.10 25565

TCP 通了只代表隧道通了。再用 Minecraft 客戶端加這組位址,連接埠維持預設的 25565。

手動這條確認沒問題後,用 Ctrl-C 停掉,改由 systemd 接手。虛擬機裡新增 /etc/systemd/system/mc-tunnel.service。User= 和 /home/mc 換成客體裡實際的登入名稱(金鑰就是上一步 ssh-keygen 寫在那個家目錄的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[Unit]
Description=Minecraft reverse SSH tunnel
After=network-online.target docker.service
Wants=network-online.target

[Service]
User=mc
ExecStart=/usr/bin/ssh -N \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-o StrictHostKeyChecking=accept-new \
-i /home/mc/.ssh/mc-tunnel \
-R 0.0.0.0:25565:127.0.0.1:25565 \
tunnel@203.0.113.10
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
1
2
3
sudo systemctl daemon-reload
sudo systemctl enable --now mc-tunnel.service
systemctl status mc-tunnel.service

之後虛擬機重開,Docker 會把 Minecraft 拉起來(restart: unless-stopped),這個服務會把隧道重新連上。

朋友怎麼連

客戶端的伺服器位址填 Elastic IP。左下角的遊戲版本要和 VERSION 一致,否則會被拒絕進入。

白名單打開之前,不要把 IP 傳出去。虛擬機裡:

1
2
3
cd ~/minecraft
docker compose exec mc rcon-cli whitelist on
docker compose exec mc rcon-cli whitelist add 朋友的遊戲 ID

保持預設的正版驗證(online mode)。沒有登入 Mojang 帳號的客戶端連不上。

備份就是把 data 打包。打包前先停伺服器,避免抄到寫到一半的世界:

1
2
3
4
cd ~/minecraft
docker compose stop
tar -czf ~/mc-backup-$(date +%F).tar.gz data
docker compose start

隧道通了但人進不去時

依序看這四個地方:

  1. 跳板上 ss 顯示的是 127.0.0.1:25565。-R 少了 0.0.0.0:,或是 sshd 的 GatewayPorts 仍是預設的 no。
  2. 客戶端立刻失敗,日誌寫 remote port forwarding failed。25565 還被舊的 sshd 占著,或是 permitlisten 那一行和實際監聽位址不符。在跳板上把占住 25565 的那條 sshd 結束,虛擬機的 mc-tunnel 會自己重連。
  3. 客戶端說版本不符。把 VERSION 改成客戶端顯示的版本,備份 data 之後重建容器。
  4. 容器反覆重啟。docker compose logs 裡如果是 EULA,就是 EULA 沒設成 TRUE;如果是記憶體,把客體記憶體加大,或把 MEMORY 從 2G 降下來。

小結

  1. 伺服器放在家裡的 VirtualBox 客體裡,用 Docker 跑 Paper,連接埠只綁 127.0.0.1。
  2. 外網入口是一台很小的 EC2,只轉 TCP,不跑遊戲。
  3. 反向隧道由家裡主動連出,路由器不用開連接埠,也避開 CGNAT。
  4. 跳板的 sshd 要設 GatewayPorts clientspecified,客戶端的 -R 要寫 0.0.0.0:25565。
  5. 隧道金鑰在 authorized_keys 裡收成只能轉 25565,SSH 的 22 只對家裡的 IP 開放。
  6. 白名單先開,再把 Elastic IP 給朋友。世界資料在 ~/minecraft/data,停機後打包即可備份。

Resolving VSCode Crash After Update on macOS

Recently, I encountered a frustrating issue with Visual Studio Code (VSCode) on my macOS system. After performing an update, the application refused to open, effectively crashing on startup. If you’re facing a similar problem, you’re not alone, and there’s a solution available.

The Issue

The problem appears to be affecting macOS users who recently updated their VSCode installation. Upon attempting to launch the application, it fails to open, leaving users unable to access their development environment.
The problem I experienced is exactly the same as described in this Stack Overflow question:
https://stackoverflow.com/questions/78710474/cannot-open-vscode-on-macos-after-update

The Solution

After some research, I found that this issue has been reported and addressed by the VSCode team. Here’s how you can resolve it:

  1. Download VSCode version 1.90
    The key to fixing this problem is to revert to a previous, stable version of VSCode. Specifically, you’ll need to download and install VSCode version 1.90.

    You can find the download link for VSCode 1.90 on the official Visual Studio Code updates page:
    VSCode 1.90 Download

  2. Install the downloaded version
    Once you’ve downloaded VSCode 1.90, install it on your macOS system. This should replace the problematic updated version.

  3. Launch VSCode
    After installation, try launching VSCode again. It should now open without crashing.

Additional Information

For those interested in the technical details or ongoing discussions about this issue, you can refer to the following GitHub issue threads:

These threads contain valuable information about the nature of the problem and potential long-term fixes that the VSCode team may implement.

Conclusion

While it’s inconvenient to encounter such issues after an update, it’s reassuring to know that the VSCode community and development team are quick to identify and provide solutions. By reverting to version 1.90, you should be able to continue using VSCode without interruption.

Remember to keep an eye on future updates, as the VSCode team will likely address this issue in upcoming releases.

How to Use Git Tag

Git tags are used to mark specific points in your repository’s history. This is typically done to mark release points (e.g., v1.0, v2.0). There are two types of tags in Git: lightweight and annotated. Here’s how to use them:

Creating Tags

  1. Lightweight Tag:
    A lightweight tag is like a branch that doesn’t change. It’s just a pointer to a commit.

    1
    git tag <tagname>

    Example:

    1
    git tag v1.0
  2. Annotated Tag:
    Annotated tags are stored as full objects in the Git database. They can have a message, and metadata such as the tagger’s name, email, and date.

    1
    git tag -a <tagname> -m "message"

    Example:

    1
    git tag -a v1.0 -m "Release version 1.0"

Viewing Tags

To list all tags in your repository:

1
git tag

To view details of a specific tag:

1
git show <tagname>

Example:

1
git show v1.0

Sharing Tags

By default, git push does not push tags to remote repositories. To push a specific tag:

1
git push origin <tagname>

Example:

1
git push origin v1.0

To push all tags at once:

1
git push origin --tags

Deleting Tags

To delete a local tag:

1
git tag -d <tagname>

Example:

1
git tag -d v1.0

To delete a remote tag:

1
git push origin --delete <tagname>

Example:

1
git push origin --delete v1.0

Checking Out Tags

To checkout a specific tag (this puts you in a “detached HEAD” state, meaning you are not on a branch):

1
git checkout <tagname>

Example:

1
git checkout v1.0

Remember that when you are in a detached HEAD state, any changes you make are not associated with any branch unless you create a new branch from this state.

Summary

  • Create a lightweight tag: git tag <tagname>
  • Create an annotated tag: git tag -a <tagname> -m "message"
  • List tags: git tag
  • Show tag details: git show <tagname>
  • Push a specific tag: git push origin <tagname>
  • Push all tags: git push origin --tags
  • Delete a local tag: git tag -d <tagname>
  • Delete a remote tag: git push origin --delete <tagname>
  • Checkout a tag: git checkout <tagname>

Using these commands, you can effectively manage tags in your Git repositories.

Best Practices for Managing Side Effects in React with `useEffect`

When working with useEffect in React, the way you handle subscriptions is crucial for maintaining performance and avoiding memory leaks. Let’s explore two forms of useEffect for managing authentication state changes using Firebase’s onAuthStateChanged and understand why one approach is superior.

Handling Subscriptions with Cleanup

First, consider the following example where we properly manage the subscription by including an unsubscribe mechanism:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
useEffect(() => {
const auth = getAuth();
const unsubscribe = onAuthStateChanged(auth, (user) => {
if (user) {
const userProfile: UserInfo = user.providerData[0];
setUserInfo({
providerId: userProfile.providerId,
uid: userProfile.uid,
displayName: userProfile.displayName,
email: userProfile.email,
photoURL: userProfile.photoURL,
});
}
setLoading(false);
});

return () => unsubscribe();
}, []);

Handling Subscriptions Without Cleanup

Now, compare it with a version that does not manage the subscription cleanup:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
useEffect(() => {
const auth = getAuth();
onAuthStateChanged(auth, (user) => {
if (user) {
const userProfile: UserInfo = user.providerData[0];
setUserInfo({
providerId: userProfile.providerId,
uid: userProfile.uid,
displayName: userProfile.displayName,
email: userProfile.email,
photoURL: userProfile.photoURL,
});
}
setLoading(false);
});
}, []);

Why the Unsubscribe Form is Preferable

Using the form with unsubscribe is generally better for several reasons:

  1. Memory Management:

    • Subscriptions persist until explicitly cancelled. By calling unsubscribe in the cleanup phase of useEffect, you ensure the subscription is terminated when the component unmounts or the effect dependencies change, preventing memory leaks.
  2. Avoiding Multiple Subscriptions:

    • If the dependencies of the useEffect change, the effect re-runs. Without cleanup, each re-run creates a new subscription, leading to multiple active subscriptions and potential issues. The unsubscribe method ensures only one active subscription at a time.
  3. Best Practices:

    • Explicitly handling side effect cleanups is a best practice in React. It ensures your component releases resources correctly, leading to predictable behavior and preventing issues caused by stale listeners or handlers.

Conclusion

While the second form (without unsubscribe) may be adequate for simple scenarios or quick experiments, the first form (with unsubscribe) is more robust. It aligns with React’s best practices for managing side effects and resource cleanup, ensuring your component remains performant and reliable, particularly in larger or more complex applications. Always favor the approach that includes cleanup to maintain optimal performance and avoid potential pitfalls.

retro warning message

1
[W NNPACK.cpp:61] Could not initialize NNPACK! Reason: Unsupported hardware.

https://github.com/pytorch/pytorch/blob/70f4b3551c01230d4ab00da7bf453fa7c6b14eb9/aten/src/ATen/native/NNPACK.cpp#L52-L72

https://discuss.pytorch.org/t/bug-w-nnpack-cpp-80-could-not-initialize-nnpack-reason-unsupported-hardware/107518/23

Environment Architecture CPU requirements
Linux x86-64 AVX2 and 3-level cache hierarchy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
$ lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Address sizes: 36 bits physical, 48 bits virtual
Byte Order: Little Endian
CPU(s): 8
On-line CPU(s) list: 0-7
Vendor ID: GenuineIntel
Model name: Intel(R) Core(TM) i7-2600 CPU @ 3.40GHz
CPU family: 6
Model: 42
Thread(s) per core: 2
Core(s) per socket: 4
Socket(s): 1
Stepping: 7
CPU max MHz: 3800.0000
CPU min MHz: 1600.0000
BogoMIPS: 6784.75
Flags: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mc
a cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ht
tm pbe syscall nx rdtscp lm constant_tsc arch_perfmon p
ebs bts rep_good nopl xtopology nonstop_tsc cpuid aperf
mperf pni pclmulqdq dtes64 monitor ds_cpl vmx smx est t
m2 ssse3 cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic popcn
t tsc_deadline_timer aes xsave avx lahf_lm epb pti ssbd
ibrs ibpb stibp tpr_shadow vnmi flexpriority ept vpid
xsaveopt dtherm ida arat pln pts md_clear flush_l1d
Virtualization features:
Virtualisation: VT-x
Caches (sum of all):
L1d: 128 KiB (4 instances)
L1i: 128 KiB (4 instances)
L2: 1 MiB (4 instances)
L3: 8 MiB (1 instance)
NUMA:
NUMA node(s): 1
NUMA node0 CPU(s): 0-7
Vulnerabilities:
Gather data sampling: Not affected
Itlb multihit: KVM: Mitigation: VMX disabled
L1tf: Mitigation; PTE Inversion; VMX conditional cache flushe
s, SMT vulnerable
Mds: Mitigation; Clear CPU buffers; SMT vulnerable
Meltdown: Mitigation; PTI
Mmio stale data: Unknown: No mitigations
Retbleed: Not affected
Spec rstack overflow: Not affected
Spec store bypass: Mitigation; Speculative Store Bypass disabled via prctl
and seccomp
Spectre v1: Mitigation; usercopy/swapgs barriers and __user pointer
sanitization
Spectre v2: Mitigation; Retpolines; IBPB conditional; IBRS_FW; STIB
P conditional; RSB filling; PBRSB-eIBRS Not affected; B
HI Not affected
Srbds: Not affected
Tsx async abort: Not affected

AVX2 (Advanced Vector Extensions 2) is an expansion of the AVX instruction set introduced by Intel and AMD. It provides additional instructions to accelerate integer vector operations. AVX2 builds upon the foundation of AVX, offering higher performance and efficiency for certain computational workloads.

AVX2 introduces several new features, including:

  • Support for 256-bit integer vector operations: AVX2 extends the width of vector registers from 128 bits to 256 bits, allowing for more data to be processed simultaneously.
  • New integer vector instructions: AVX2 adds new instructions for integer vector arithmetic, bitwise operations, and vector shuffles, providing - enhanced capabilities for tasks such as image processing, cryptography, and scientific computing.
  • Enhanced gather and scatter operations: AVX2 includes new instructions for indexed memory operations, enabling more efficient data movement - between memory and vector registers.

CPUs that support AVX2 typically belong to newer generations and include various Intel and AMD processors released after 2013. Some examples of CPUs that support AVX2 include:

  • Intel Haswell (4th generation Core processors) and newer.
  • Intel Broadwell, Skylake, Kaby Lake, Coffee Lake, Comet Lake, and later microarchitectures.
  • AMD Ryzen processors (starting with the first-generation Ryzen CPUs).
  • AMD Ryzen Threadripper processors.
  • AMD EPYC processors.

These processors offer improved performance for workloads that can leverage AVX2 instructions, making them well-suited for tasks involving heavy computational workloads, such as machine learning, numerical simulations, and media processing.

pyenv install notes

https://github.com/pyenv/pyenv

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ curl https://pyenv.run | bash

Cloning into '~/.pyenv'...


WARNING: seems you still have not added 'pyenv' to the load path.

# Load pyenv automatically by appending
# the following to
# ~/.bash_profile if it exists, otherwise ~/.profile (for login shells)
# and ~/.bashrc (for interactive shells) :

export PYENV_ROOT="$HOME/.pyenv"
[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"

# Restart your shell for the changes to take effect.

# Load pyenv-virtualenv automatically by adding
# the following to ~/.bashrc:

eval "$(pyenv virtualenv-init -)"