自宅に Linux の開発機がある。Ryzen 9 3900X、RTX 2060、NVMe 2本、メモリ64GB。Mac からは ssh で入り、tmux の中で作業する。常時起動で、ほとんどの時間はアイドルだ。

この機械の電気代を、月500円にしたい。そう決めた。

500円は「平均22W」のこと

31円/kWh、月730時間で逆算すると、月500円は平均22Wだ。

この構成で壁側のアイドル消費は70〜80Wが下限になる。省電力のチューニングで削れるのは、よくて10〜20W。常時起動を続ける限り、月1,500円を割れない。

パターン平均円/月
常時起動・現状80W1,810
常時起動・チューニング後(楽観)65W1,470
1日12時間だけ起動42W950
1日6時間だけ起動23W520
1日3時間だけ起動13.5W306

起動中80W・サスペンド中4Wの仮定で、目標は「1日の稼働を6時間以内に収める」と同義になる。500円はS3サスペンド運用でしか到達できない。

ここで正直に書いておくと、80Wも4Wも未実測の仮定だ。RAPLで見えるのはCPUパッケージだけ(アイドル9W)、nvidia-smi で見えるのはGPUだけ(アイドル9.4W)。足しても壁側にはならず、残り40〜60Wが見えない。壁側を測るにはワットチェッカーが要る。まだ買っていない。だから上の表は目安で、目標に届いたかどうかは、実は今も確かめられていない。代わりに Prometheus の up の日平均(寝ている間は0)を稼働率として見ている。

先にやった省電力チューニングと、その限界

サスペンドを決める前に、常時起動のまま削れるものは削った。

計測してみると、CPUは既に C2 に99%滞在していた。governor をいじる余地は無い。 GPUはヘッドレスで完全に遊んでいるのに9.4W。PCIe の ASPM は BIOS 既定で全リンク Disabled。

削ったのは、使っていないデバイスだ。

  • 内蔵の WiFi/Bluetooth をブラックリスト(有線しか使わない)
  • CPU クーラーの RGB 制御チップ(ヘッドレス機で光っていた)と、未使用の USB レシーバを runtime PM で落とす
  • ドライブ0台の SATA コントローラ×2を落とす。ここで一つ罠があって、配下の空の ata ポート側も auto にしないと、親のコントローラが D3 に落ちない
  • 未使用サービス(ModemManager、wpa_supplicant、multipathd)を止める

やらなかったことも決めた。GPUの物理的な取り外しは約10W減るが、このCPUに内蔵GPUが無いので映像出力が永久に失われる。LUKS + TPM 構成で起動トラブル時に BIOS 画面を見られなくなるリスクは取らない。pcie_aspm=force は NVMe が不安定になるとルートが飛ぶので、ヘッドレス機でやる博打ではない。

そして、これらを全部やっても壁側は10〜20Wしか変わらない見込みだ。500円には届かない。 だから次に進んだ。

ssh では起こせない

サスペンドの障害は「起こし方」だ。

サスペンド中は OS もネットワークスタックも止まっている。TCP の3ウェイハンドシェイクに応答する主体が居ない。 生きているのは NIC のパターンマッチャだけで、22番への SYN は投げても無視される。「ssh forge したら勝手に起きる」は、そのままでは成立しない。

Tailscale でも代替できない。寝た時点で tailscaled も止まり、DERP への常時接続が切れる。外から見ると、その IP はどこにも居ない。

現実的な手は Wake-on-LAN のマジックパケットだ。NIC(Intel I211)は対応している。ただしLAN 内からしか撃てない。外出先から起こすには、家の中に常時起動の中継が要る。うちには Raspberry Pi 5 が常時動いているので、そこに「マジックパケットを撃つだけの Python スクリプト」を置いた。

そして Mac の ~/.ssh/config に ProxyCommand を噛ませる。接続のたびに、forge の22番が開いているか1回だけ試し、閉じていれば Raspberry Pi 経由でマジックパケットを撃ち、22番が開くまで2秒おきに待つ。

体感は「ssh forge したら起きる」になる。実測は、コマンド投入から接続完了まで10秒だった(17:25:51 投入 → 17:25:57 S3 から復帰 → 17:26:01 接続)。起きているときは nc -z の1回だけなので、体感差はほぼ無い。

BIOS を触る前に、LUKS のパスフレーズを手元に出す

WoL を有効にするには BIOS の設定が2つ要る(「PCIE Devices Power On」を Enabled、「Deep Sleep」を Disabled)。

ここで怖いのは、BIOS 設定の変更が TPM の PCR7 を変える可能性があることだ。この機械は LUKS の解錠を TPM に預けていて、PCR7 が変わると起動時にパスフレーズを聞かれるようになる。ヘッドレス機でそれが起きると、モニタを繋ぐまで起動できない。

だから手順を決めた。作業前に PCR7 の値を控え、LUKS のパスフレーズを手元に出し、モニタを繋いだ状態で再起動する。パスフレーズを聞かれずに起動すれば無事。聞かれたら、その場で TPM の鍵穴を新しい PCR7 で入れ直す。

結果は、PCR7 は変化なし、パスフレーズも聞かれなかった。副産物として、再起動で /dev/nvme0n1 と nvme1n1 が入れ替わっていた。デバイス名で書いた手順は、これで壊れる。

「誰もいない」をどう判定するか

起こし方が決まっても、寝かせ方が難しい。CPU 使用率だけでは「無人」を判定できない。

Claude Code で作業している最中は、AI が考えている時間が長い。その間ローカルの CPU はほぼ遊んでいる。CPU が低いから寝かせる、では作業中に落ちる。

そこで、判定を決める前に1分ごとの稼働ログを10日ほど溜めた。CPU、load、SSH 接続数と相手、tmux のセッション数とアタッチ数、上位プロセス、作業系プロセスの本数、NIC と NVMe のスループット、GPU、温度、ファン。

溜めてみて分かったことがいくつかある。

  • who は当てにならない。 PTY を持たない SSH 接続(Claude Code など)は who に出ない。「SSH 確立2本・tmux アタッチ2・who は0」が普通に起きる
  • プロセスの識別は comm ではなく args で。 claude も npm も実体は node なので、comm では全部 node に潰れる
  • 通信量は永久にゼロにならない。 Raspberry Pi の Prometheus が定期的にスクレイプしに来るので、「通信が無ければアイドル」は使えない
  • SSH の接続本数も当てにならない。 Mac の ControlPersist 10m や、tmux を付けたまま放置した端末が夜通し残っていて、1週間あまりのログで03〜06時に無セッションの時間が0%だった。接続があるからといって人はいない
  • 常駐サービスの利用者が見えていなかった。 22番の接続しか数えておらず、開発中のサーバーに人が繋いでいても記録上は無人だった。あとで気づいて足した

決めた信号

30分間、次のどの信号も出なければ systemctl suspend。1分ごとに判定する。

信号何を見るか根拠
human人のキー入力。tmux の client_activity / session_activity、tmux 外の pts の atimeSSH の本数ではなく、入力そのもの
outputペインへの出力。tmux の window_activityClaude Code は作業中スピナーで常に出力し、プロンプト待ちでは止まる(実測: 待ち状態で8分間0バイト)。無人でも AI が自律作業中なら寝ない
cpu1分平均 CPU 使用率 > 5%稼働ログの無人サンプル約1,600件は全部5%未満だった。24スレッドの5% = 1.2コア
net / diskNIC > 200KB/s、NVMe > 10MB/sPrometheus のスクレイプは数十KB/s
gpuGPU プロセスの有無・使用率
unitactivating 状態の service(バックアップや apt など oneshot が実行中)
procrestic / rsync / npm / build / playwright / apt のプロセスが、直近1分で CPU を使っていること後述の理由で「存在」ではなく「CPU 使用」に変えた
inhibitsystemd-inhibit のブロック「寝るな」を宣言する手段
grace起動・復帰から10分未満ssh forge で起こしてから打ち始めるまでの余裕

方針は「判定を完璧にしてから始めるのではなく、確実に無人の時間帯を刈るところから入る。未知の状態は busy 側に倒す」。間違いを「寝そびれる」に寄せ、「使用中に落ちる」を作らない。

「常駐サーバーへの外部接続」は、いったん信号に入れて、翌日外した。開発用サーバーを起動してブラウザから見ることはあるが、ずっと見続けるのは稀で、ブラウザは閉じ忘れたタブでも接続を保つので、信号にすると一生寝なくなる。長く使うときは宣言する。

forge-awake 3h "負荷テスト"   # 3時間は寝ない
forge-awake                    # 今かかっているブロックの一覧
forge-awake off                # 外す

中身は systemd-run で systemd-inhibit --what=sleep を動かすだけだ。推測ではなく、確実に守れる。

最初の一巡は、20:46に全信号が静かになり、21:16に SUSPEND after quiet 1829s。翌07:28に ssh forge で復帰(WoL)、11秒後に human を検知。約10時間寝た。誤爆なし。

起床窓は夜中ではなく、太陽光のピークの12時

寝ている間に走らせたいジョブ(バックアップなど)がある。rtcwake で決まった時刻に起こす。

その時刻を12:00にした。夜中ではない。自宅にはソーラーパネルがあって、方針は「発電量が最も高い時間に起動する」だ。28日分の実測を時刻別に平均すると:

JST発電 kW消費 kW余剰が出た割合
100.550.7715%
110.630.8518%
120.610.8722%
130.580.8028%
140.500.8121%
150.390.856%

発電の山は11〜12時、余剰が出やすいのは12〜14時。両方に掛かる12:00にした。バックアップのタイマーは02:30から12:02に動かした。

systemd の sleep フックで、サスペンド直前に rtcwake -m no -t <次の12:00> を仕込み、復帰後に rtcwake -m disable する。RTC は UTC だが rtcwake が変換するので気にしなくていい。

ここに一つ、運用上の理解が要る。「毎朝08:45に走る」と読んではいけない。 Persistent=true のタイマーは、寝ていれば起床した最初のタイミングにまとめて走る。実測で、08:20指定のジョブが10:07に走った日がある。時刻を保証したいジョブは常時起動の Raspberry Pi に置き、開発機に置くものは「その日の最初の起床時、遅くとも12時」と理解する。

寝そびれ4件

導入後、寝なかった夜が何度かあった。判定ログに「なぜ起きていたか」が毎分残るので、原因は全部特定できた。

1. 放置した Claude Code の30分周期の再描画。 一晩(判定827回)一度も寝ず、理由は全回 output。出力時刻を復元すると毎時 :25:33 と :55:33 のきっかり30分周期で、発生源はプロンプト待ちで放置した Claude Code のペイン。自動アップデートチェックの再描画が最有力だ。IDLE が30分で周期も30分なので、静かな時間が1800秒に届く直前(最大1770秒)に毎回リセットされていた。「プロンプト待ちでは止まる」という当初の実測は、現行版では崩れている。対策候補は「ペインの内容ハッシュが変わらない再描画は出力と数えない」で、裏取りのデータを取っている。

2. 置き忘れた npm run dev。 一晩(592回)寝ず、理由は全回 proc。前日に起動した astro dev が、起動元の端末が消えて PID 1 の子になり、17時間生きていた。ハングした astro check も2本。原因は proc がプロセスの存在だけを見ていたこと。「直近の判定間隔で実際に CPU を使ったもの(プロセスの木で1コアの2%超)だけ数える」に変えた。放置 astro dev は0.25%、ハングは0.00%、本物の build は数十%なので、8倍の余裕がある。

3. wrangler dev 13本。 2の対処では防げない形で再発した。wrangler dev は workerd と esbuild を常駐させるぶん重く、木の合計で約1.2%、ピークで閾値を超える。同じポートに9本重なっていて、bind に失敗した8本が終了せず居座っていた。全部 PID 1 の子。掃除でメモリが8.4GBから3.1GBに減った。

閾値を上げる対処は取らない。「未知は busy 側に倒す」を崩すと作業中に寝る。代わりに残骸を消す側で対処した。毎晩20:00に、ppid == 1(孤児化)かつ起動から6時間以上の dev サーバーを、木の葉から順に SIGKILL する。作業中の dev サーバーは親がシェルなので絶対に対象にならない。落としたときは Slack に🧹を送る。黙って消えると「なぜ dev サーバーが無いのか」が分からなくなる。

ついでに分かったこと。npm run dev の PID を kill しても実体は死なない。 npm → sh -c → node の3段で、上2つが消えると node が PID 1 に再ペアレントされて、子の esbuild ごと生き残る。木ごと落とすには葉から。

4. NVIDIA ドライバの更新。 4晩寝ず、理由は毎分 gpu procs=11。CPU 0.3%、GPU は未使用。unattended-upgrades がドライバのユーザー空間を上げたが、ロード済みのカーネルモジュールは再起動まで古いまま。nvidia-smi が Driver/library version mismatch を stdout に出し、判定スクリプトが終了コードを見ずに .split() したため、エラーメッセージの11語を PID 11個と数えていた。終了コードが0以外なら busy にしない、PID は数字だけ数える、に直した。

寝そびれは電気代の問題で、作業を壊さない。逆(使用中に落ちる)は一度も起きていない。 「未知は busy 側」がそのまま効いている。

朝、寝ていなかったら知らせる

寝そびれに気づけるように、毎朝08:00に journal を見て、前夜20:00〜06:00の睡眠時間が3時間未満なら Slack に🚨を送る。本文には「起きていた理由」の内訳を添える。上の4件は、全部これで気づいた。

08:00に寝ていれば起床後に繰り越し実行され、その場合はよく寝ているので何も出ない。

S3 から復帰すると TPM が使えなくなる

サスペンド運用の副作用として見つかったもの。S3 から復帰すると /dev/tpm0 が I/O エラーを返すようになり、次のフル再起動まで直らない。

運用そのものは壊れない。LUKS の鍵は復帰時 RAM に残っているので、解錠は要らない。怖いのは「フル再起動したとき、TPM 解錠が効くか」だった。S3 を複数回挟んだ状態から再起動して、パスフレーズを聞かれずに起動し、PCR7 も変化なしを確認した。S3 で fTPM が壊れても、フル再起動で復活する。共存できる。

まとめ

  • 月500円 = 平均22W。壁側70〜80Wの機械では、常時起動を続ける限り届かない。S3 しか無い
  • 削れるデバイスは削ったが、それで変わるのは10〜20W。RAPL と nvidia-smi を足しても壁側にはならず、壁側は今も測れていない。稼働率で代用している
  • ssh では起こせない。 WoL のマジックパケットは LAN 内からしか撃てないので、常時起動の Raspberry Pi を中継にし、Mac の ProxyCommand で「ssh forge で起きる」にした。実測10秒
  • BIOS を触る前に PCR7 を控え、LUKS のパスフレーズを手元に出し、モニタを繋ぐ
  • CPU では無人を判定できない。 1分ごとの稼働ログを溜めてから信号を決めた。who も SSH 本数も通信量も当てにならない
  • 未知は busy 側に倒す。 寝そびれは電気代の問題、使用中に落ちるのは作業の問題。前者に寄せる
  • 起床窓は太陽光のピーク12時。Persistent=true のタイマーは「その日の最初の起床時」に走る
  • 寝そびれの原因は、放置した AI の再描画、孤児化した dev サーバー、bind に失敗して居座ったプロセス、ドライバ更新のエラー文字列。判定を緩めるのではなく、信号を直すか残骸を消す
  • 朝の睡眠チェックが無ければ、どれも気づかなかった

目標の500円に届いたかは、まだ確かめていない。届いたと言える日は、ワットチェッカーを買った日になる。