MacBook Air(M3)で開発していると、どうしても気になることがあった。蓋を閉じると全部止まる

長いビルドを回している最中に移動したい。夜のうちに何か走らせておきたい。でもラップトップなので、閉じれば止まる。開いたままカバンに入れるわけにもいかない。

手元には使っていないデスクトップがあった。Ryzen 9 3900X、メモリ64GB、RTX 2060。ここにUbuntuを入れて、Macは「そこに繋ぐための端末」にしてしまえばいい。要はシンクライアント化だ。

やってみたら、想像していたより地雷が多かった。同じことをやる人のために記録しておく。


つくったもの

Mac新マシン
CPUApple M3 (4P+4E)Ryzen 9 3900X (12C/24T)
メモリ24GB64GB
GPUM3統合RTX 2060 (6GB)
ストレージ空き90GB1.6TB
OSmacOSUbuntu 26.04 LTS

名前は forge(鍛冶場)にした。Mac側の名前が workspace なので、「座って作業する場所」に対して「火と重機がある、モノができる場所」という対にしたかった。

余談だが、名前を考えている最中に古いSSDの中身を見たら、2022年まで使っていた前の開発機のホスト名が workshop だった。同じ発想を4年前の私もしていたらしい。


地雷1: TPMのエラーメッセージが嘘をつく

ディスクはLUKSで暗号化した。目的は「捨てるときに安全に捨てられること」だ。ただ、再起動のたびにパスワードを打つのは、ヘッドレスで動かすマシンでは致命的に不便になる。

そこで TPM(マザーボード上のセキュリティチップ)に鍵を封じ込める。TPMに入れた鍵は取り出せない設計になっているので、起動時にTPMが自動で解錠してくれるうえ、ディスクだけ抜き取られても読めない。廃棄時の安全という目的にはこれ以上なく合う。

で、実行するとこうなった。

$ sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=7 /dev/nvme1n1p3
TPM device not usable as it does not support the required functionality
(AES-128-CFB missing?)

「このTPMはAES-128-CFBに対応していない」と読める。AMDのfTPMは昔から不具合が多いので、そういうものかと諦めかけた。

だが確認したら、対応していた

$ sudo tpm2_testparms aes128cfb
(エラーなし=対応している)

systemdのTPM経路だけを叩くテストをしてみると、こちらは成功する。

$ echo test | sudo systemd-creds encrypt --with-key=tpm2 - -
(成功)

原因は別のところにあった。systemdはTPMを使うとき libtss2-rc.so.0 を実行時に動的読み込みする。このライブラリが入っていなかったので初期化に失敗し、その結果を「CFBがないのでは?」という見当違いのメッセージで報告していた。

tpm2-tools を入れると依存で libtss2-rc0 が入り、あっさり通った。

教訓: エラーメッセージが具体的なハードウェア機能を名指ししていても、鵜呑みにしない。同じ機能を別経路で叩いて切り分ける。

initramfsにも同じライブラリが要る

さらに罠がある。鍵の封入に成功しても、そのままだと起動時だけ解錠に失敗する。initramfs(起動初期の最小環境)の中にも libtss2 が必要だからだ。

しかもUbuntu 26.04は、initramfsの生成が従来の initramfs-tools から dracut に変わっている。設定ファイルの置き場所が違う。

echo 'add_dracutmodules+=" tpm2-tss "' | sudo tee /etc/dracut.conf.d/90-tpm2.conf
sudo dracut -f --kver $(uname -r)

# 確認(これをやらないと再起動して初めて気づくことになる)
sudo lsinitrd /boot/initrd.img-$(uname -r) | grep libtss2-rc

トレードオフは理解しておく

TPM自動解錠には明確な弱点がある。電源を入れれば勝手に解錠されるので、本体ごと盗まれたら無防備 だ。守れるのは「ディスク単体を抜かれたとき」と「廃棄するとき」だけ。

ちなみにMacのFileVaultは起動時にパスワードを要求するので、この点ではMacのほうが強い。顧客のプロジェクトなど、盗難まで想定すべきものはMac側に置いたままにした。

そしてもう一つ。TPMはマザーボードと一心同体 なので、マザボが壊れたら鍵は永久に失われる。パスワードの鍵穴は必ず残しておくこと。これは省略できない。


地雷2: NVMeのデバイス名は再起動で入れ替わる

これが一番ヒヤリとした。

古い方のSSD(931GB)を消去するとき、/dev/nvme0n1 が931GBであることを確認して blkdiscard した。ここまでは正しい。

その後、NVIDIAドライバの導入で再起動した。そして次の作業で同じ /dev/nvme0n1 を指定したら——入れ替わっていた

再起動前:  nvme0n1 = 931GB(消したいやつ)   nvme1n1 = 1.8TB(システム)
再起動後:  nvme0n1 = 1.8TB(システム)       nvme1n1 = 931GB

つまり cryptsetup luksFormat /dev/nvme0n1 が、システムディスクを指していた

助かったのは、cryptsetup が使用中のデバイスへのフォーマットを拒否したからだ。GPTもLUKSヘッダもファイルシステムも無傷だった。先頭バイトを確認したら eb 63 90(MBRのjmp命令)のままで、LUKSのマジックナンバー 4c 55 4b 53 ba be にはなっていなかった。

NVMeのデバイス名は列挙順で決まるので、順序は保証されない。シリアル番号から導出される by-id を使えばいい

$ ls -l /dev/disk/by-id/ | grep nvme-WDC
nvme-WDC_WDS100T2B0C-00PXH0_21155T475503 -> ../../nvme1n1   # 1TB
nvme-WDC_WDS200T2B0C-00PXH0_21190H442009 -> ../../nvme0n1   # 2TB

# こう書けば再起動しても同じディスクを指す
sudo cryptsetup luksFormat --type luks2 \
  /dev/disk/by-id/nvme-WDC_WDS100T2B0C-00PXH0_21155T475503

型番に容量が入っている(100T=1TB、200T=2TB)ので、目視でも間違えにくい。破壊的な操作をする前は、必ずこちらを使う。


地雷3: macOSとLinuxで日本語ファイル名が別物になる

これは知識としては知っていたが、実際に踏むと厄介さが違った。

rsyncで9GBほどを転送したあと、forge側でgitの状態を確認したら、あるゲームのリポジトリで96件のファイルが削除扱い になっていた。転送は成功しているのに、だ。

中身を見るとこうなっていた。

削除扱い:  "会社ロゴ.svg"      → ゴ = U+30B4(1文字)
未追跡:    "会社ロコ゛.svg"     → コ + ゛ = U+30B3 + U+3099(2文字)

macOSはファイル名をNFD(分解形)で保存する。「ゴ」を「コ」+「濁点」の2文字として持つということだ。HFS+時代は書き込み時に強制的にNFDへ正規化していた名残で、APFSになった今も多くのAPIがNFDを出力する。

一方Linuxのファイルシステムは正規化を一切しない。ファイル名を単なるバイト列として扱うだけだ。日本語環境のツールはたいていNFCを出力するので、結果としてLinux側はNFCで揃う。

macOS側のgitは core.precomposeunicode という設定で自動的にNFCに変換してくれるので、Macでは何の問題もなく見えていた。rsyncはファイル名をバイト列としてそのまま運ぶので、Linux側にNFDの名前が着地し、gitのインデックス(NFC)と一致しなくなった、という筋書きだ。

解決は convmv で正規化するだけ。

sudo apt install convmv

# まず確認(--notest を付けなければ変更しない)
convmv -f utf8 -t utf8 --nfc -r ~/dev/projects

# 実行
convmv -f utf8 -t utf8 --nfc -r --notest ~/dev/projects

96件の不一致がゼロになった。

おまけ: 4年間気づかなかったリポジトリのバグが出てきた

面白かったのはここからだ。正規化しても、別のリポジトリで2件だけ削除扱いが残った。調べたら、gitのインデックスに同じファイルがNFDとNFCの両方で登録されていた

2026/07/会社ロゴ.svg
  [NFD] blob=a68ebaaeac05
  [NFC] blob=a68ebaaeac05   ← 同じblob

blobのハッシュが同一、つまり中身は完全に同じファイルが、名前の表現形式違いで二重に登録されていた。

なぜ気づかなかったか。APFSは正規化を区別しない ので、macOS上では2つの登録が同じ1つの物理ファイルを指してしまい、何の矛盾も起きなかったからだ。Linuxに持ってきて初めて「片方のファイルが無い」と言われた。

blobが同一であることを確認してからNFD側の登録を除去した。おそらく数年前、正規化設定の違う環境からコミットされたものが残っていたのだと思う。


地雷4: node_modulesはコピーしてはいけない

15GBのうち6.6GBがnode_modulesだった。ファイル数でいうと全体の72%(277,640ファイル)。

転送速度の問題もあるが、それ以前にコピーしても動かない。esbuild、sharp、better-sqlite3のようなパッケージは、プラットフォーム固有のネイティブバイナリを含む。Macのnode_modulesに入っているのは darwin-arm64 向けで、Linux x86_64では実行できない。

package-lock.json があれば npm ci で正確に再現できるので、除外して転送し、向こうで入れ直すのが正しい。

rsync -a --exclude='node_modules/' --exclude='.DS_Store' \
  ~/dev/projects forge:dev/

# forge側で
cd ~/dev/projects/blog && npm ci

19プロジェクトで実行して全部成功、所要時間は1プロジェクトあたり数秒だった。24スレッドは伊達ではない。

ちなみに移行後に気づいたのだが、あるプロジェクトのビルドスクリプトが最初からこうなっていた。

"build": "npm i @rollup/rollup-linux-x64-gnu --no-save && astro build"

CI(Linux)で動かすために、過去の私が同じ問題に対処済みだったらしい。


地雷5: クリップボードの画像はSSH越しに貼れない

移行後、日常的に一番困ったのがこれだった。

Macでスクリーンショットを撮って、forge側で動かしているClaude Codeに見せたい。テキストは普通に貼れるのに、画像だけがどうやっても貼れない

最初は設定の問題だと思って探したが、そういう類のものではなかった。

ターミナルの「ペースト」は、クリップボードの中身を転送しているわけではない。ターミナルエミュレータがクリップボードからテキスト形式を読み出して、その文字をptyに流し込んでいるだけだ。クリップボードは複数の形式を同時に持っていて、画像をコピーするとimage/pngの枠は埋まるがtext/plainの枠は空になる。ターミナルはtextの枠しか見ないので、送るデータが最初から存在しない

仮にバイナリを流し込めたとしても、ptyが制御文字を解釈してしまう(0x03でSIGINTなど)。tmuxがクリップボードを跨がせるOSC 52も、仕様としてbase64エンコードされたテキストを運ぶもので、画像用の対応物は存在しない。

つまり設定で解決できる問題ではない。画像をファイルにして、そのパスを渡す形に変換するしかない。

ホットキーでやろうとして3回失敗した

「キーを押したらスクショを撮ってforgeに転送し、パスをクリップボードに残す」を作ろうとした。この方向が全部ダメだった。

pngpasteは動かない。 クリップボードの画像を取り出す定番ツールだが、0.2.3は2019年で更新が止まっていて、macOS 26では常にこうなる。

CGImageDestinationFinalize failed for output type 'public.png'
pngpaste: No image data found on the clipboard, or could not convert!

クリップボード側は正常だった(osascript -e 'clipboard info'«class PNGf»がある)ので、pngpaste側の非互換だ。OS標準のosascriptで代替できるうえ、依存が減る。

set theData to (the clipboard as «class PNGf»)
set fh to open for access (POSIX file outPath) with write permission
set eof fh to 0
write theData to fh
close access fh

Automatorのクイックアクションは呼び出されない。 .workflowはただのバンドル(ディレクトリ)なのでGUIを使わず生成でき、pbs -flushでサービス一覧にも載り、pbs.plistにホットキーの登録(key_equivalent)も正しく入る。automator <パス>.workflowとCLIから叩けばちゃんと動く。

なのに、キーを押しても実行ログが1件も出ない。macOSがサービスを呼び出していない。以前に作ってあったShortcuts.app側のサービスショートカットも同じく動いていなかったので、この機構自体が当てにならないと判断した。

skhdは動くが、権限が2つ要る。 常駐のホットキーデーモンで、これはちゃんと発火する。ただしキー入力を横取りするためのアクセシビリティと、screencaptureを起動するための画面収録が要る。

ここで気づいたのだが、画面収録の権限は「screencaptureを起動したプロセス」の責任アプリに要る。ホットキー経由なら常駐デーモン、ターミナルから叩くならターミナルアプリだ。権限がないとcould not create image from rectで失敗するか、エラーも出ずに無反応になる。しかもmacOSは権限を起動時にしか読まないので、付与後にそのアプリを再起動しないと反映されない。無反応の原因がこれだと気づくまでが長い。

結局ドラッグ&ドロップにした

ホットキーを諦めた瞬間、話が簡単になった。

AppleScriptのdropletを作り、そこにファイルを落とすとforgeへ転送してパスをクリップボードに残す。キャプチャをしないので画面収録は要らず、キーを横取りしないのでアクセシビリティも要らない。**必要な権限がゼロになった。**常駐プロセスも増えない。

dropletはGUIを使わずコンパイルできる。

osacompile -o ~/Applications/"forge に送る.app" droplet.applescript
on open theFiles
	set argList to ""
	repeat with f in theFiles
		set argList to argList & " " & quoted form of (POSIX path of f)
	end repeat
	do shell script "$HOME/bin/forge-send" & argList
end open

スクショを送りたいときは、標準のCmd+Shift+4でデスクトップに保存してからドロップすればいい。もともと使っているキーなので、覚えることが増えない。ホットキーに固執していたときは、ここが見えていなかった。

使うときはDockのアイコンに落として、forge側でCmd+VしてEnter。貼られるのはパスの文字列だけで、画像そのものはターミナルを通っていない。Claudeが受け取ったパスのファイルを自分で開いて読んでいる。

細かいが実用上効いた点をいくつか。

  • 複数ファイルの同時ドロップに対応させた。ただし1件のときはクリップボードの末尾に改行を付けない。改行付きでClaude Codeに貼ると、その場で送信されてしまう
  • ファイル名は<日時>-<元の名前>にした。日本語もそのまま通す。潰すのは空白・制御文字・スラッシュだけ
  • GUIから起動されるプロセスはPATHが最小限で、Homebrewの/opt/homebrew/binが入っていない。スクリプトの冒頭でexport PATHしておかないと、ターミナルからは動くのにドロップだと動かない、という切り分けにくい状態になる

最後に、仕組みとは関係ないが引っかかりやすい点を一つ。ファイルをターミナルのウィンドウにドロップすると、ターミナル自身の機能でMac側のローカルパスが文字として打ち込まれる。転送は起きない。落とす先はアプリのアイコンであって、ターミナルではない。


地雷6: macOSのLC_CTYPEはLinuxに存在しない

vimやlessで日本語が化ける、という症状が出た。

macOSのsshは既定のSendEnv LANG LC_*で環境変数を転送する。ところがmacOSのLC_CTYPEUTF-8というmacOS独自のロケール名で、Linuxには存在しない。forge側がこれを受け取るとLC_CTYPEの設定に失敗し、実質Cロケールに落ちる。

-を先頭に付けたパターンは送信リストからの除外を意味するので、~/.ssh/configにこう書けば止まる……と思っていた。

SendEnv -LC_CTYPE

これは効かない。 sshは設定ファイルを「~/.ssh/config(ユーザー)→ /etc/ssh/ssh_config(システム全体)」の順に読む。そしてmacOSのシステム側にはHost *SendEnv LANG LC_*が入っている。SendEnvは順に積み上げる方式なので、ユーザー設定での除外が先に処理され、そのからシステム設定のLC_*LC_CTYPEを再追加してしまう。

実効設定を見ると、除外が消えているのが分かる。

$ ssh -G forge | grep -i sendenv
sendenv LANG
sendenv LC_*

値を上書きするSetEnvなら確実に効く。

SetEnv LC_CTYPE=en_US.UTF-8

厄介だったのは、forge側の.zshenvに「UTF-8が来たら読み替える」保険を先に入れてあったせいで、素の確認では常に正常に見えていたことだ。保険が反応しない値を送って初めて、除外が効いていないと分かった。

$ LC_CTYPE=zz_ZZ.TEST ssh forge 'echo $LC_CTYPE'
zz_ZZ.TEST      # 除外が効いていれば届かないはず

受け側の保険は残してあるが、送る側で正すのが本来の直し方だ。


消しかけた1TBは、バックアップディスクにした

地雷2で危うくシステムディスクと取り違えるところだった1TBのSSD。あれは無事に消し終わって、いまは /backup としてホームディレクトリのバックアップ先になっている。記事の流れ上あとまわしにしたが、ここが今の構成の一部なので書いておく。

nvme0n1  1.8TB  LUKS → LVM → /        (システム)
nvme1n1  931GB  LUKS → ext4 → /backup (restic リポジトリ)

こちらもLUKS2で、システムディスクと同じくパスワードとTPMの2つの鍵穴を持たせた。TPMで起動時に自動解錠されるので普段は意識しなくていいし、廃棄時にディスクを抜かれても読めない。目的はシステムディスクのときとまったく同じだ。

中身は restic のリポジトリにした。ファイル単位の重複排除が効くのと、世代管理(forget --keep-daily 系)がコマンド一発で済むのが理由だ。実行はsystemdのタイマーで毎晩03:30、保持は日次7・週次4・月次6世代。

restic backup $HOME --exclude-file=/etc/restic/excludes.txt --tag home
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
restic check --read-data-subset=1%

除外しているのは .cache / .npm / node_modules / __pycache__ / .venv / .pytest_cache / ゴミ箱。地雷4で書いたとおり node_modules は復元しても動かないので、持っていても意味がない。最後の check --read-data-subset=1% は、毎晩データの1%を実際に読み直して壊れていないか確かめるためのもの。バックアップは「取れているつもり」が一番怖い。

罠1: systemdから起動するとresticのキャッシュが効かない

タイマー化して数日回したら、実行時間がじわじわ延びていった。

原因はsystemdがサービスに $HOME を渡さないことだった。resticはキャッシュを既定で ~/.cache/restic に置くので、$HOME が無いとキャッシュ先を見失い、毎回リポジトリのインデックスを読み直すことになる。スナップショットが増えるほど遅くなるわけだ。

ユニット側で明示すれば解決する。

CacheDirectory=restic
Environment=RESTIC_CACHE_DIR=/var/cache/restic

ついでに Nice=10IOSchedulingClass=idle も入れた。夜中とはいえ、裏で長いビルドを回していることがあるので。

罠2: nofail はマウント失敗を「成功」に見せる

/backup は起動を止めないよう nofail を付けている。ディスクが解錠できなくてもシステムは上がってほしいからだ。

ところがこれには裏がある。マウントされていなくても起動は成功するので、その状態でresticを走らせると、ルートディスク上に残った空の /backup ディレクトリへ書き込みにいく。1.6TBのルートを、バックアップデータで埋め尽くすことになる。

なのでスクリプトの先頭で必ず止める。ユニット側の RequiresMountsFor=/backup と二重にした。

if ! mountpoint -q /backup; then
    echo "ERROR: /backup がマウントされていません。バックアップを中止します。" >&2
    exit 1
fi

復旧手順は「復旧するディスク自身」に置く

/backup/RESTORE.md に、別のマシンで復元する手順を書いて置いてある。

自明なようで、これは意識してやる必要がある。復旧手順をforgeの中にだけ置いたら、forgeが起動しなくなった瞬間に読めなくなるからだ。ディスクのシリアル番号、cryptsetup open のコマンド、restic の restore の例まで、そのファイルだけ読めば復元できるように書いた。

resticのリポジトリパスワードも同じファイルに平文で置いている。乱暴に見えるが、このディスク自体がLUKSで暗号化されているので、抜き取られても読めない。逆に、暗号化された場所の外に置くほうが危ない。

限界: 同じ筐体の中にある

正直に書いておくと、この構成が守れるのは操作ミス・ファイル破損・「先週の状態に戻したい」までだ。バックアップディスクはforgeと同じPCケースの中にあるので、本体の故障・盗難・火災には何の役にも立たない。

オフサイトは別途必要で、これはまだ手を付けていない。


検証: 消す前にどこまで確認したか

移行後にMac側を消すつもりだったので、検証はしつこくやった。特に「コミット済みの作業が本当に全部GitHubにあるか」は、消したら取り返しがつかない。

最初は各リポジトリで git status と「現在のブランチの未push数」を見て、全部クリーンだと判断した。これが甘かった

現在のブランチしか見ていないので、別のブランチにしかないコミットを見落とす。正しくはこう。

# ローカルのどこかから到達できて、どのリモートからも到達できないコミット
git rev-list --count --all --not --remotes

これで数えたら、3つのリポジトリに19コミット が残っていた。作業用ブランチやバックアップ用ブランチに置いたまま、pushしていなかったものだ。全部pushして、最終的に全26リポジトリで0件になったのを確認してから消した。

ファイルの同一性は rsync --checksum のドライランで確認した。タイムスタンプやサイズではなく中身のハッシュで比較するので、これが通れば内容は同じだと言い切れる。

rsync -a --dry-run --checksum --itemize-changes \
  --exclude='node_modules/' ~/dev/projects forge:dev/

なお削除は rm -rf ではなくゴミ箱に入れた。しばらく向こうで作業して問題が出なければ空にする。15GBを取り戻すのは急がなくていい。


性能はどうなのか(実測)

気になるところなので、両方で同じベンチマークを回した。Node.jsで素数の篩とソートを回す、実際の開発作業(バンドラやコンパイラ)に近い整数演算中心の処理だ。

Mac (M3 Air)forge (3900X)
シングルコア45ms70ms
スループット45.2件/秒84.8件/秒
コア/スレッド824

1コアの速さはMacが1.56倍。M3のコアは2019年のZen 2に対して圧倒的に速い。

総処理量はforgeが1.88倍。24スレッドの物量で1コアの遅さを覆している。

ここで大事なのは、Node.jsはプロセスあたり単一スレッド だということだ。npm installtscastro build といった日常的に待たされる操作は、Macのほうが速い

正直に言うと、この結果は少し意外だった。「大きいマシンに移せば全部速くなる」と思っていたが、そうではない。

ただし補足がいる。この測定は数百ミリ秒の瞬間的な負荷なので、MacBook Airがファンレスであることの不利が出ていない。数分続くビルドではサーマルスロットリングで落ちるので、持続負荷では差が縮まる(あるいは逆転する)はずだ。


24時間動かすと電気代はどうなるのか(実測)

可用性のために常時起動にしたということは、このデスクトップが24時間動き続けるということでもある。Macなら蓋を閉じれば数ワットで済んでいたものが、そうはいかない。

気になったので測った。まず、forgeが実際にどれだけ働いているか。

$ sar -u | tail -3
10:40:00 AM     all      0.24      0.00      0.10      0.00      0.00     99.65
10:50:22 AM     all      0.20      0.00      0.06      0.00      0.00     99.74
Average:        all      0.63      0.01      0.10      0.00      0.00     99.25

99.25%がアイドル。当然といえば当然だ。1人で使う開発機なのだから、ビルドを回している数分以外は何もしていない。

CPUは既に限界まで寝ていた

「省電力といえばgovernor」だと思っていたので、そこから見た。

$ cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
schedutil
$ for s in /sys/devices/system/cpu/cpu0/cpuidle/state*; do
    echo "$(cat $s/name) $(cat $s/time)"
  done
POLL 144522
C1   281329681
C2   63244601606

稼働17時間47分(64,020秒)に対して、C2(最も深いアイドル状態)の滞在が63,245秒。約99%の時間、CPUは最深のスリープに入っている。ここに改善の余地はない。

消費電力そのものも測れる。RAPLという積算エネルギーカウンタがsysfsに出ている。パスが intel-rapl なのはドライバ名の都合で、AMDでも普通に読める。

E1=$(sudo cat /sys/class/powercap/intel-rapl:0/energy_uj); sleep 20
E2=$(sudo cat /sys/class/powercap/intel-rapl:0/energy_uj)
# 差分 ÷ 経過時間 → 9.0 W

12コア24スレッドのCPUが、アイドルで9.0W。つまりCPUは犯人ではなかった。governorをpowersaveにしてもEco Modeを入れても、ここからは何も出てこない。

犯人は「何もしていないのに通電しているもの」だった

デバイスを1つずつ見ていったら、こうなっていた。

GPUが9.4W。使っているプロセスはゼロ。

$ nvidia-smi --query-gpu=power.draw,utilization.gpu,memory.used --format=csv
9.52 W, 0 %, 17 MiB
$ nvidia-smi --query-compute-apps=pid,process_name --format=csv
(1件も出ない)

ヘッドレスなので画面出力もしていない。CUDA用に残しているとはいえ、使っていない間もCPUと同じだけ食っている

CPUクーラーのRGBが通電していた。

$ lsusb
Bus 001 Device 003: ID 2516:0051 Cooler Master Co., Ltd. AMD SR4 lamplight Control

純正クーラー(Wraith Prism)のLED制御チップだ。モニタも繋がっていないサーバーの中で、ケースを閉じたまま光っている。気づいたときは笑った。

ほかにも、有線で繋いでいるのにマザーボード内蔵のWiFi/Bluetooth(AX200)のドライバが載ったままだったり、ドライブが1台も繋がっていないSATAコントローラが2つフル通電していたり、といった具合だった。

そして一番効きそうなのがこれ。PCIeのASPM(省電力リンク状態)が全リンクで無効だった。

$ sudo lspci -s 0f:00.0 -vv | grep LnkCtl
    LnkCtl: ASPM Disabled; RCB 64 bytes, LnkDisable- CommClk+

GPUもNVMe 2枚も、リンクが常時フルパワーで待機している。これはマザーボード側の既定値なので、OSからは触らずBIOSで有効にするのが筋だ(pcie_aspm=force で押し切ることもできるが、NVMeが不安定になるとLUKSのルートが飛ぶ。ヘッドレス機でやる博打ではない)。

削った

「常時起動は維持したまま、使っていないものだけ落とす」方針で手を入れた。

未使用のWiFi/Bluetoothを止める。

# /etc/modprobe.d/99-forge-power.conf
blacklist iwlwifi
blacklist iwlmvm
blacklist btusb
blacklist bluetooth

未使用のUSBデバイスと空のSATAコントローラをautosuspendに落とす。

# /etc/udev/rules.d/99-forge-power.rules
ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="2516", ATTR{idProduct}=="0051", \
  TEST=="power/control", ATTR{power/control}="auto"
ACTION=="add", SUBSYSTEM=="pci", ATTR{vendor}=="0x1022", ATTR{device}=="0x7901", \
  TEST=="power/control", ATTR{power/control}="auto"

ここで1つ引っかかった。SATAコントローラは、コントローラ本体だけをautoにしても落ちない。ぶら下がっている空のataポート側もautoにして初めて、親がD3に行ける。

ACTION=="add", SUBSYSTEM=="ata_port", TEST=="../power/control", \
  ATTR{../power/control}="auto"

これでUSBは全デバイスがsuspendedになり、SATAコントローラ2つもD3に落ちた。あとはモデムのないマシンのModemManager、無線を使わないwpa_supplicant、マルチパスデバイスが0件のmultipathdを止めた。

測れないものは測れない、と認めておく

正直に書くと、ここまでやって「何ワット減ったか」は分かっていない

RAPLで見えるのはCPUパッケージだけで、今回削ったものはほぼ全部その外側にある。チップセット、GPU、USB、SATA、PCIeリンク。削った場所と、測れる場所が重なっていない

構成から概算すると、この機体の壁側の消費は75〜90Wというあたりだろう。31円/kWhで24時間365日なら年間およそ2万円。エアコンほどではないが、無視できる額でもない。

なので次にやるのはチューニングの続きではなく、ワットチェッカーを買うことだ。数千円の測定器がないと、この先は全部が推測になる。BIOSでASPMを有効にした効果も、GPUを抜く価値があるかも、測らなければ判断できない。

「測ってから直す」を守ったつもりで、実は測れる場所しか測っていなかった。今回いちばんの反省点はそこだった。


結論: これは速度の強化ではなく、可用性の強化

移行を終えて思うのは、forgeの価値は速さではない ということだ。

得られたのは、

  • 蓋を閉じても、回線が切れても、作業が止まらない(tmuxの中で動かしていれば繋ぎ直すだけ)
  • メモリ2.6倍、ストレージ18倍(Macは460GB中90GBしか空いていなかった)
  • 持続負荷で性能が落ちない
  • CUDAが使える
  • Macの発熱とバッテリー消費が減った

失ったものはない。単発のビルドの体感が少し遅くなるくらいだ。

そして最終的な使い方はこうなった。

ssh forge              # 自宅でも外出先でも同じ(Tailscale経由)
tmux new -s dev        # 初回
# ... 作業 ...
# Ctrl-a → d で切断

ssh forge
tmux a -t dev          # さっきの状態がそのまま

Macは本当にただの端末になった。これが欲しかった。


まとめ

同じことをやる人へ、ハマりどころだけ再掲する。

  1. TPMのエラーメッセージを信じない — ハードの機能名を名指ししていても、実際は共有ライブラリ不足のことがある。別経路で切り分ける
  2. initramfsにもTPMライブラリを入れる — 封入成功と起動時解錠成功は別問題。Ubuntu 26.04はdracutに変わっている
  3. ディスク操作は /dev/disk/by-id/ を使う/dev/nvmeXn1 は再起動で入れ替わる
  4. 日本語ファイル名はNFD/NFCで化けるconvmv --nfc で正規化。gitのインデックス側が壊れていることもある
  5. node_modulesは転送せず npm ci — ネイティブバイナリはプラットフォーム固有
  6. 消す前は git rev-list --all --not --remotes — 現在のブランチだけ見ていると別ブランチの作業を落とす
  7. TPM解錠にしたら盗難には無防備 — 守れるのはディスク単体の抜き取りと廃棄時だけ。パスワードの鍵穴は必ず残す
  8. resticをsystemdで回すなら RESTIC_CACHE_DIR を明示する — サービスに $HOME が渡らないのでキャッシュが効かず、スナップショットが増えるほど遅くなる
  9. nofail のマウント先に書き込む処理は mountpoint -q で守る — 未マウントのまま走ると、ルートディスク上の空ディレクトリを埋め尽くす
  10. 復旧手順はバックアップディスク自身に置く — 本体が起動しなくなった時点で、本体の中の手順書は読めない
  11. クリップボードの画像はSSH越しに貼れない — ターミナルのペーストはテキスト形式しか読まない。設定では解決できないので、ファイルにしてパスを渡す形に変換する
  12. macOSの SendEnv -LC_CTYPE は効かない — システムの ssh_config がユーザー設定の後に読まれ、LC_* が除外を打ち消す。SetEnv で値ごと上書きする

追記(2026-08-11): 光回線が死んだときの保険を足した

この構成の残る弱点は、家の回線が死ぬと forge が丸ごと孤立することだった。ここも塞いだ。

netplan に11行の設定を置いておくと、スマホを USB で挿してテザリングを ON にするだけで、forge がサーバー側の操作なしにモバイル回線へ切り替わる(抜けば自動で元に戻る)。Tailscale も再接続されるので、外出先からの ssh forge も復活する。詳しくは別記事にまとめた。

光回線が死んでも詰まないように、スマホを「挿すだけの予備回線」にしておく 13. 省電力の入口はgovernorではない — アイドル99%のマシンではCPUは既に最深のC-stateに入っている。削る相手は「使っていないのに通電しているデバイス」のほう 14. RAPLで測れるのはCPUパッケージだけ — チップセット・GPU・PCIeリンクは見えない。壁のワットチェッカーがないと、やった対策の効果を検証できない