ガンバラナイ

PCを再起動してもClaude Codeの続きに戻る — WSL2・tmux・1コマンド起動の常駐化

PCを再起動してもClaude Codeの続きに戻る — WSL2・tmux・1コマンド起動の常駐化

はじめに

自宅のWindows PCのWSL2に、外出先からSSHで入って作業できるようにしました。接続経路はCloudflare Tunnelなのですが、本記事の主題はそこではありません。繋がるようにしたあとに残った、こちらの問題のほうです。

  • Windowsを再起動すると、WSLは自動では起動しません。当然SSHも死にます
  • 起動させても、やり方を間違えると約30秒でWSLごと止められます
  • 繋がっても、前回の作業は残っていません。毎回「tmuxを作る → プロジェクトへcd → Claude Codeを起動」を手で打つことになります

要するに、サーバとして常時起きていてほしいのに、WSLはデスクトップの一機能として作られているので放っておくと消える、という話です。最終的に次の3層で解決しました。

  • 第1層 — Windows タスクスケジューラ。WSL を起こし、居座らせる(sleep infinity を掴ませる)
  • 第2層 — systemd の user ユニット。tmux セッション main を常駐させる(RemainAfterExit + linger)
  • 第3層 — bash 関数 c <project>。tmux 作成 → cd → claude 起動を1コマンドに

WSL2常駐化の全体構成。Windowsのタスクスケジューラがwsl.exe経由でsleep infinityを掴み、WSL内ではsystemdがsshdとcloudflaredを、user managerがtmux-mainとrss-watchを常駐させる

結果として、出先からssh ssh.example.comで入ってc ganbaranai-hpと打てば、そのプロジェクトのフォルダでClaude Codeが動いているセッションに入れます。回線が切れても同じコマンドで戻れます。

環境は次のとおりです。

  • Windows 11 / WSL 2.6.3.0 / Ubuntu 24.04(systemd=true
  • ホストの物理メモリ 23.3GB
  • 作業日は2026-08-07〜08-08

以下、層ごとに「何をしたか」と「どこで転んだか」を書きます。転んだ箇所のほうが多いので、そちらが本題かもしれません。

第1層: WSLを起こして、居座らせる

WSL2はWindows起動時に自動では上がらない

まずここが癖です。WSL内でsystemdが有効(/etc/wsl.confsystemd=true)になっていれば、ssh.socket(接続が来たらsshdを起動するsystemdの仕組み)やcloudflaredenableしておくだけで、WSLさえ起動すればサービスは勝手に上がります。問題は、そのWSL自体を誰も起動しないことです。

そこで、Windows側のタスクスケジューラからWSLを叩き起こします。

# ログオン前から走らせるので、Windows アカウントの資格情報を先に取っておく。
# ここで対話的に入力させれば、パスワードはコマンドラインにも履歴にも残らない。
$cred = Get-Credential -UserName "$env:COMPUTERNAME\$env:USERNAME" `
                       -Message 'Windows アカウントのパスワード'

$action = New-ScheduledTaskAction -Execute 'C:\Windows\System32\wsl.exe' `
                                  -Argument '-d Ubuntu -u root --exec /usr/bin/sleep infinity'
$tStartup = New-ScheduledTaskTrigger -AtStartup ; $tStartup.Delay = 'PT20S'
# 常駐が万一落ちたとき用に、5分間隔の再試行を付ける
$tStartup.Repetition = (New-ScheduledTaskTrigger -Once -At '2026-01-01T00:00:00' `
                          -RepetitionInterval (New-TimeSpan -Minutes 5)).Repetition
$tLogon   = New-ScheduledTaskTrigger -AtLogOn
# ExecutionTimeLimit の [TimeSpan]::Zero は、タスクの XML でいう PT0S(無制限)にあたる。
# 既定の PT5M のままだと常駐プロセスが5分で殺される。
$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew `
              -ExecutionTimeLimit ([TimeSpan]::Zero) `
              -StartWhenAvailable -DontStopOnIdleEnd
Register-ScheduledTask -TaskName 'WSL-Autostart' `
  -Action $action -Trigger @($tStartup, $tLogon) -Settings $settings `
  -User $cred.UserName `
  -Password $cred.GetNetworkCredential().Password -RunLevel Highest

トリガーは2つ登録しています。「システム起動時」(ログオン不要)と「ログオン時」(保険)です。ログオンしていなくても実行するにはWindowsアカウントのパスワード保存が必須で、登録後にLogonTypePasswordになっていれば成功しています(S4Uだと保存されていません)。

そして、この構成で一番大事なのがsleep infinityの部分です。

wsl -e /bin/true では起動しても居座らない

これが一番ハマりました。しかも構築の翌日、実機を再起動して初めて発覚しました

症状はこうです。外からアクセスするとCloudflareのエラー画面になります。

Unable to connect to origin. Please confirm that the tunnel is set up
correctly and the origin is healthy.

トンネルの設定が壊れたように見えますが、Cloudflare側は何も悪くありません。あとからWSLの中に入ってjournal(systemdが集めているログ。journalctlで読めます)を見ると、一目瞭然でした。

16:23:22  Windows 起動
16:23:38  WSL カーネル起動 → systemd → ssh.socket / cloudflared が上がる
16:24:09  systemd がシャットダウン開始 ← 31秒後に WSL ごと消える
16:29:35  (人間がターミナルを開いて)復活

最初はタスクの引数を-e /bin/trueにしていました。「WSLを起こすだけでいい。あとはsystemdが引き継ぐ」という発想です。ところが/bin/trueは即座に終了するので、WSLに接続しているクライアントが1つも無い状態になります。するとWSLはそのディストロをアイドルとみなし、まるごと停止させます(本記事ではこれを「回収」と書きます)。中でsystemdが動いていようが、sshdがlistenしていようが関係ありません。

journalの該当箇所です。session-1wsl -u rootで作られたセッションにあたります。

systemd[1]: Stopping session-1.scope - Session 1 of User root...
systemd[1]: Stopped target multi-user.target - Multi-User System.
cloudflared[154]: ERR no more connections active and exiting

保険のつもりで.wslconfigに入れていたvmIdleTimeout=-1効きませんでした。あれは軽量ユーティリティVM側のアイドル停止を止める設定で、ディストロ単位の回収は防げません。「保険を入れてあるから大丈夫」という思い込みが、そのまま落とし穴になりました。

解決策はシンプルで、終了しないプロセスを掴ませます

# NG: 即終了 → 約30秒後にディストロごと回収される
-Argument '-d Ubuntu -u root -e /bin/true'

# OK: セッションを掴んだまま常駐する
-Argument '-d Ubuntu -u root --exec /usr/bin/sleep infinity'

念のため書いておくと、-e--execは同じオプションです。直したのはフラグではなく、渡しているコマンドのほうです。

並べるとこうなります。

NGとOKの時系列比較。/bin/trueは即終了するためWSL起動の31秒後に回収されるが、sleep infinityはセッションを掴み続けるのでWSLの稼働が継続する

あわせてExecutionTimeLimit を PT0S(無制限)にしますPT5MPT0SはISO 8601の時間表記で、それぞれ5分・ゼロを意味します(タスクスケジューラではゼロ=無制限の扱い)。PowerShellから登録するときは[TimeSpan]::Zeroがこれにあたります。既定のPT5Mのままだと、タスクスケジューラが5分でsleepを殺しに来て、結局同じことになります。

LastTaskResult = 0 は正常のサインではない

切り分けを難しくしたのがこれです。壊れていた当時、タスクスケジューラ側は完全に正常に見えていました

PS> Get-ScheduledTaskInfo -TaskName WSL-Autostart
LastRunTime        : 2026/08/07 16:23:51
LastTaskResult     : 0            # 成功しているのに SSH できない

タスクは成功していて、その後にWSLが死んでいる。つまりLastTaskResultを見ているかぎり、異常はどこにも表示されません。

そして修正後は、この値の意味が逆転します。sleep infinityで常駐するようになったので、タスクは終了しなくなり、こうなります。

PS> Get-ScheduledTask -TaskName WSL-Autostart | Get-ScheduledTaskInfo | Select LastTaskResult
LastTaskResult : 2147946720   # 「オペレーターまたは管理者が要求を拒否しました」

メッセージだけ読むと失敗にしか見えませんが、これが正常な状態です。見るべき指標はStateと常駐プロセスの生死に変わります。

PS> Get-ScheduledTask -TaskName WSL-Autostart | Select-Object -Exp State
Running                     # Ready ではなく Running のままが正解
$ pgrep -af 'sleep infinity'
19536 /usr/bin/sleep infinity
$ loginctl list-sessions --no-legend
5    0 root - pts/3 active no    # タスクが掴んでいる root セッション

見るべき指標が入れ替わるので、表にしておきます。

修正前(-e /bin/true 修正後(sleep infinity
タスクの State Ready(走り終えている) Running(掴んだまま)
LastTaskResult 0 = 成功 2147946720 = 失敗に見える
実際の状態 WSL は31秒で落ちている WSL は起き続けている
何を見て判断するか State と常駐プロセスの生死

同じLastTaskResultという指標が、修正の前後で「異常を隠す」から「異常に見える」へ反転したわけです。切り分け時の手がかりをそのまま運用の監視項目に持ち込むと、今度は逆向きに判断を誤ります。

PowerShellスクリプトはBOM付きUTF-8で保存する

上のタスク登録は.ps1にして管理者PowerShellから実行しました。日本語コメント入りのスクリプトをBOM無しUTF-8で保存したところ、こうなりました。

発生場所 C:\Users\me\setup-wsl-autostart.ps1:36 文字:62
+                        -Message 'Windows 繧「繧ォ繧ヲ繝ウ繝医・繝代せ繝ッ繝シ繝・
式の終わりの ')' が存在しません。

Windows PowerShell 5.1はBOMの無い.ps1をCP932として読みます。日本語が壊れ、文字列の閉じクォートまで消えて構文エラーになります。BOMを付ければ解決します。

BOM(Byte Order Mark)はファイルの先頭に置く数バイトの目印で、これがあると「このファイルはUTF-8だ」と判別されます。CP932はWindows日本語版が昔から使ってきた文字コード(Shift_JIS系)です。つまり目印が無いので、UTF-8のファイルを昔ながらの文字コードだと思って読まれたということです。

printf '\xEF\xBB\xBF' > out.ps1
sed 's/$/\r/' in.ps1 >> out.ps1     # 改行も CRLF にしておく

実行前に構文だけ検証できるので、これをやっておくと事故が減ります。

$e=$null
[System.Management.Automation.Language.Parser]::ParseFile("C:\path\to.ps1",[ref]$null,[ref]$e)
if($e){ $e | % { $_.Message } } else { "構文OK" }

パスワード保存タスクは、中身を書き換えるのにもパスワードが要る

-e /bin/trueからsleep infinityへの修正を当てようとして、また転びました。

PS> Set-ScheduledTask -TaskName 'WSL-Autostart' -Action $action
Set-ScheduledTask : アクセスが拒否されました。

まず管理者昇格が要ります(ルートフォルダのタスクで、かつRunLevel = Highestのため)。昇格して再実行すると、今度は別のエラーになります。

Set-ScheduledTask : ユーザー名またはパスワードが正しくありません。

パスワードなど渡していないのに「正しくありません」と言われるので紛らわしいのですが、これは LogonType = Password のタスクを更新するにはパスワードを再提示しないといけない、という意味です。タスクの更新は内部的に再登録なので、資格情報が要ります。アクションを1文字変えるだけでも同じです。

Set-ScheduledTask -TaskName 'WSL-Autostart' -Action $action -Trigger $t -Settings $s `
  -User $cred.UserName -Password $cred.GetNetworkCredential().Password

ここが「ログオンしていなくても実行」を選んだことのコストです。S4U(パスワードを保存しない)ならこの手間はありませんが、ログオン前のブート時起動の確実性が落ちます。今回はSSHで遠隔から入ることが目的なので、パスワード保存のままを選びました。

なお、WSLの中のbashからWindows側を昇格させるにはこうします。UACダイアログと資格情報ダイアログがWindowsのデスクトップに出ます。

powershell.exe -NoProfile -Command \
  "Start-Process powershell.exe -Verb RunAs -ArgumentList '-NoProfile','-File','C:\Users\me\fix.ps1'"

スクリプト側でGet-Credentialを呼べば、パスワードはコマンドラインにもシェル履歴にも残りません

ちなみにEnable-ScheduledTask / Disable-ScheduledTaskも管理者昇格が必要です(非昇格だとHRESULT 0x80070005でアクセス拒否)。ただしStart-ScheduledTask(手動実行)は非昇格でも通るので、常駐プロセスを立て直すだけならこちらで足ります。

第2層: tmuxセッションを常駐させる

サーバが起きていることと、そこでやりかけの作業が残っていることは別の話です。SSHで長い作業をするならtmuxを挟みます。

用語をひとつだけ先に説明しておきます。tmux は端末の中に、切り離して繋ぎ直せる作業画面(セッション)を作るツールです。SSHが切れてもその中のプロセスは動き続けます。 入り直して attach すれば、さっきの画面がそのまま出てきます。これを常駐させておくのがこの層の目的です。

そして、セッションをsystemdのuser unitから作らせる場合に一つ罠があります。ここでいうuser unitとは、systemdが動かすサービスのうちOS全体のもの(system unit)ではなく、ログインユーザーごとのものを指します。後者は user manager と呼ばれるユーザー専用のsystemdが面倒を見ます。

# ~/.config/systemd/user/tmux-main.service
[Unit]
Description=Persistent tmux session "main" for remote work over SSH
After=default.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/tmux new-session -A -d -s main
ExecStop=/usr/bin/tmux kill-session -t main

[Install]
WantedBy=default.target

ポイントは3つです。

  • RemainAfterExit=yesが要ります。tmuxサーバはそれ自身がデーモン化する(起動したコマンドは即座に抜け、本体はバックグラウンドに残る)ので、systemdから見るとExecStartは即終了します。これが無いとユニットが非アクティブ扱いになり、cgroupごとプロセスが片付けられてセッションが消えます。「作ったはずなのに気づくと消えている」の正体はこれでした(cgroupは、プロセスをグループにまとめて資源や生死をまとめて管理するLinuxの仕組みです。systemdはこれを使ってサービスやログインセッションを区切っており、ユニットが止まればそのグループのプロセスも片付けられます)

  • -Aは「あれば再利用、無ければ作成」です。-dと併用して「セッションが1枚ある状態を保つ」だけの動きにしているので、systemctl --user startは何度叩いても既存セッションに影響しません(冪等)

  • ただしrestartは別物です。 ExecStopkill-sessionを置いているので、restartは stop → start の順でセッションを一度殺してから作り直します。手元で試すと、作成時刻が変わったうえにペインの内容も消えました

    起動直後    restart-test created=1786517990
    restart 後  restart-test created=1786517991   ← 作り直されている
    ペインに残っていたマーカー: 0 件              ← 中身は消えた

    ユニットファイルを直したあとに何気なくrestartすると、作業ごと飛びます。生きているセッションを残したまま定義だけ入れ替えたいなら、daemon-reloadだけにしてセッションには触らないことです

  • loginctl enable-linger <user>が前提です。lingerは「そのユーザーがログインしていなくても、ユーザー用のサービスを動かし続ける」設定です。これが無いとSSHセッションが切れた瞬間にuser managerごと落ち、当然tmuxも道連れになります。この構成では「誰もログインしていない時間帯」こそが本番なので、ここは必須でした

接続側は.bashrcにエイリアスを1本置くだけです。

# 常駐セッション main への接続。回線が切れても作業が残るので、出先ではこれを使う。
# -A は「あれば attach、無ければ作成」なので、WSL 再起動直後でも同じコマンドで済む。
alias m='tmux new-session -A -s main'

なお、このmainは雑用と様子見のための常設の1枚です。プロジェクトごとの作業はここでは行わず、次の層で別セッションに分けます。全部mainの中でやると、結局ウィンドウを探して回ることになるためです。

SSHログイン時に自動でattachさせる案もありましたが、既定では有効にしていません。scp / rsync / VS Code Remoteなど「素のシェルが返ってくること」を前提にする経路を壊しうるためです。.bashrcにはコメントアウトで置いてあります。

限界も書いておきます。これで永続化できるのはセッションという「箱」だけです。Windowsを再起動すればWSLのVMごと落ちるので、中で走っていたプロセスは当然死にます。再起動後に上がってくるのは空のmainが1枚です。「箱は勝手に用意されているので、繋いだらすぐ作業に戻れる」までが期待値で、プロセスごと復元したいならそれは別の仕組みの仕事になります。

第3層: c <project> でプロジェクトのClaude Codeへ直行

ここまでで「繋がる」「箱がある」までは自動になりました。残るのは中身です。出先から入ったあと、結局こう打っていました。

  1. tmuxセッションを作る
  2. プロジェクトへcdする
  3. claudeを起動する

毎回3手です。しかもリポジトリは~/<組織>/<リポジトリ>の2階層に散っていて、tokuiten_f03_ha-chita-cloudのように名前も長い。スマホやタブレットのSSHクライアントから打つには厳しい長さです。

そこで、この3手をc <project>の1コマンドにまとめました。

名前とパスの対応表を外出しする

まずマッピングを.bashrcの外に置きます。

# c <名前> で開くプロジェクト。1列目が名前 兼 tmux セッション名、2列目が ~ からのパス。
# 追加はこのファイルに1行足すだけでよい(.bashrc の編集も source も不要)。

minorisu       tokuiten/minorisu
chita          tokuiten/tokuiten_f03_ha-chita-cloud
ganbaranai-hp  ganbaranai/GanbaranaiHP-GatsbyStarterBlog
tools          ganbaranai/tools

~/.config/cprojに置いています。.bashrcに連想配列で持たせなかったのは、プロジェクトを増やすたびに.bashrcを編集してsourceし直すのが面倒だからです。後述のとおり毎回awkで読むので、1行足せば次のcから即使えます。

bash関数

CPROJ_CONF="$HOME/.config/cproj"

_cproj_path() {   # 名前 -> パス。見つからなければ空を返す
    [[ -r $CPROJ_CONF ]] || return
    awk -v k="$1" '!/^[[:space:]]*(#|$)/ && $1 == k { print $2; exit }' "$CPROJ_CONF"
}

c() {
    local proj="${1:-}" path dir session

    if [[ -z $proj ]]; then
        # 既定プロジェクトはあえて持たない。取り違えて別リポジトリで claude を
        # 走らせる事故のほうが、毎回名前を打つ手間より高くつく。
        echo "usage: c <project>   ($CPROJ_CONF)" >&2
        awk '!/^[[:space:]]*(#|$)/ { printf "  %-14s %s\n", $1, $2 }' "$CPROJ_CONF" >&2
        return 1
    fi

    path="$(_cproj_path "$proj")"
    if [[ -z $path ]]; then
        echo "c: unknown project: $proj   (add it to $CPROJ_CONF)" >&2
        return 1
    fi

    [[ $path == /* ]] && dir="$path" || dir="$HOME/$path"
    if [[ ! -d $dir ]]; then
        echo "c: $proj -> $dir does not exist" >&2
        return 1
    fi

    # tmux の target は session:window.pane なので、セッション名にドットを残さない。
    session="${proj//[.:]/-}"

    if [[ -n ${TMUX:-} ]]; then
        # 既に tmux の中(例: m で main にいる)。ネスト attach は失敗するので switch する。
        tmux has-session -t "=$session" 2>/dev/null \
            || tmux new-session -d -s "$session" -c "$dir" "bash -lc 'claude; exec bash -l'"
        tmux switch-client -t "=$session"
    else
        tmux new-session -A -s "$session" -c "$dir" "bash -lc 'claude; exec bash -l'"
    fi
}

# 対応表の1列目を補完する。プロジェクトが増えても .bashrc の編集は要らない。
_c_projects() {
    COMPREPLY=( $( compgen -W "$(awk '!/^[[:space:]]*(#|$)/ { print $1 }' "$CPROJ_CONF" 2>/dev/null)" \
                   -- "${COMP_WORDS[COMP_CWORD]}" ) )
}
complete -F _c_projects c

小さい関数ですが、細かい判断がいくつか入っています。

new-session -Aが効くのは「再接続」のためです。 -Aは既存セッションがあればattachしますが、そのとき末尾の起動コマンドは無視されます。つまりclaudeは起動し直されません。回線が切れて入り直しても、同じc chitaでさっきの画面に戻れます。新規作成と再接続を同じコマンドで書けるのがこの構成の肝です。

bash -lc 'claude; exec bash -l'の後半はセッションを生かすためです。 素のclaudeを渡すと、/exitした瞬間にウィンドウが閉じてセッションごと消え、直前のログも履歴も見返せなくなります。後ろにログインシェルを残しておけば、抜けたときシェルに落ちます。ログインシェル(-l)にしているのは、tmuxが起動するコマンドは.bashrcを読まない一方、~/.profile$HOME/.local/binをPATHに入れてくれるためです(claudeの実体はそこにあります)。

-t "=$session"=は完全一致指定です。 tmuxのターゲット指定は既定で前方一致なので、minorisuを指定したつもりでminorisu-subを掴む、といった事故を防ぎます。

引数なしのときに既定プロジェクトを開かせないのは意図的です。 一覧を出して終了します。「打つ手間が減る」より「別のリポジトリでうっかりClaude Codeを走らせる」ほうが高くつくためです。

対応表は毎回awkで読みます。 シェル起動時にメモリへ読み込まないので、~/.config/cprojに1行足せば新しいシェルを開かずに次のcから使えます。補完も同じファイルを見ています。

これで、出先からの再開はこうなりました。

$ ssh ssh.example.com
$ c ganbaranai-hp        # 該当フォルダで Claude Code が動いているセッションに入る

落とし穴: 「自動起動が犯人」だと思ったら、OOMだった

再起動テストが通って一段落した数時間後、またWSLが落ち始めました。しかも今度は数分おきで、前述の症状(31秒で回収)とは明らかに周期が違います。

last -Fで起動履歴を出すと、こうなっていました。

起動時刻 生存時間
16:45:18 1時間10分
17:55:33 4分
17:59:44 3分
18:02:30 3分
18:05:33 5分
18:10:46 継続

まず対症療法としてWSL-Autostartを無効化したところ、ぱたりと安定しました。ここで「やはりタスクが悪さをしている」と結論しかけたのですが、これは因果が逆でした。タスクは落ちる原因ではなく、落ちたあと5分間隔で起こし直して被害をループさせる増幅装置でした。 「常駐が落ちても復活するように」と入れたRepetitionが、そのまま裏目に出た形です。

真犯人はjournalに一行で書いてありました。なお以下のログは、数分おきに落ちては起き直していた時間帯のもので、行によってブートが異なります。時刻の連続性はあまり当てにしないでください。

Aug 07 17:49:49 kernel: claude invoked oom-killer: gfp_mask=0x140cca, order=0, oom_score_adj=0
Aug 07 17:49:49 kernel: oom-kill:constraint=CONSTRAINT_NONE, global_oom, task_memcg=/init.scope
Aug 07 17:49:49 kernel: Out of memory: Killed process 106507 (MainThread)
                        total-vm:12958972kB, anon-rss:9897992kB
Aug 07 17:55:33 systemd[1]: init.scope: A process of this unit has been killed by the OOM killer.
Aug 07 18:04:06 systemd[1]: init.scope: Failed with result 'oom-kill'.

OOMです。OOM(Out Of Memory)はメモリ不足のことで、Linuxはメモリを使い切ると、カーネルのOOM killerが「一番メモリを食っているプロセス」を選んで強制終了します。上のログのKilled process 106507 (MainThread)がその実行結果です。

同じjournalに出ているTasks stateテーブルを展開すると、内訳は極端でした。

MainThread     pid=106507  uid=1000  rss=9667 MB  vm=12655 MB  swap=1000 MB
beam.smp       pid=4164    uid=0     rss= 402 MB
beam.smp       pid=1873    uid=0     rss= 194 MB
next-server    pid=1585    uid=0     rss= 119 MB

rssは実際に物理メモリを占めている量、vmは確保している仮想メモリの量です(RSS = Resident Set Size)。犯人探しで見るのは前者です。

1プロセスが9.7GB(+ swap 1GB)を占め、2位以下は合計しても1GBに届きません。 WSLの割り当ては既定でホスト物理メモリの50%(この環境では23.3GB → 11.6GB)なので、これだけで使い切ります。ログのanon-rss:9897992kBと表のrss=9667MBは取得箇所が違うだけで、同じプロセスを指しています。

なぜ「1プロセスの暴走」でWSLごと落ちるのか

普通のLinuxならOOM killerは暴走プロセスを1つ殺して終わりで、OSは生き残ります。WSLで効いてくるのは、そのプロセスがどのcgroupに入っていたかでした。

手元で確認するとこうなっています。

$ cat /proc/self/cgroup                        # SSH → tmux 配下のシェル
0::/user.slice/user-1000.slice/[email protected]/app.slice/tmux-main.service

$ wsl.exe -d Ubuntu -e cat /proc/self/cgroup   # Windows 側から起動した場合
0::/init.scope

WSL内のcgroup配置。wsl.exe経由で起動したプロセスはinit.scope直下に入り、SSH経由のプロセスはuser.slice配下に入る。OOMが起きたのはinit.scope側だった

Windows TerminalやVS Code、タスクスケジューラからwsl.exe経由で起動したプロセスは、systemdのユーザーセッションを通らないのでinit.scope直下に入ります。 一方、SSHで入ってtmuxの中で動かしているものはuser.slice配下です。今回OOMで殺されたプロセスのログはtask_memcg=/init.scopeだったので、犯人はWindows側から起動されていたことになります。

init.scopeはPID 1のsystemd自身が入っている中核のスコープです。ここでFailed with result 'oom-kill'が立った直後にディストロが落ちているので、Windows側から起動したツールのメモリリークは、WSL全体のクラッシュに化けうると考えています。ただしこれはログの前後関係からの推定で、機序を追い切ったわけではありません。逆にSSH経由でuser.sliceに入っているプロセスなら影響範囲が違う可能性もありますが、そちらは試していません。

危うく犯人を取り違えるところだった

正直に書いておくと、最初このOOMログを読んだとき、1行目のclaude invoked oom-killerを見て「Claude Codeが9.7GB食っている」と誤読しました

invoked oom-killerは「メモリを要求して、カーネルに空きが無いと断られた側」であって、メモリを溜め込んでいた側ではありません。実際OOM時のclaudeプロセスはどれもRSS 100MB未満で、完全に巻き添えでした。加害者は同じログの後ろにあるKilled process行とTasks stateテーブルを展開して初めて分かります。

対策の方向も、誤読したままだと丸ごと外れます。「Claude Codeが犯人」ならNODE_OPTIONS=--max-old-space-sizeでメモリ上限を下げる話になりますが、それは全く効きません。OOMログは1行目で判断せず、必ずプロセス別の内訳まで開くことです。

犯人は特定できなかった。ただしNode製までは絞れる

内訳まで開いても、MainThreadが何なのかは分かりませんでした。カーネルのOOMログに残るのはcomm(15文字のプロセス名)だけで、cmdlineは記録されません。プロセスはすでに消えているので/procも引けません。

ただし、このMainThreadという名前自体がそれなりの手がかりでした。あとから手元で確かめると、こうなります。

$ node -e "console.log(require('fs').readFileSync('/proc/self/comm','utf8'))"
MainThread
$ python3 -c "print(open('/proc/self/comm').read())"
python3

Nodeは主スレッドのcommMainThreadにします(v24.18.0で確認)。一方Pythonはpython3のままなので、この名前からは外れます。実際、現時点でcomm=MainThreadのプロセスを引くと2つともnodeでした。つまり犯人はNode製の何かまでは絞れます。

そしてclaudeは自分でプロセスタイトルを書き換えるので、psでもcomm=claudeとして出ます。ここからも、9.7GBを抱えていたMainThreadはClaude Codeではないと裏が取れます。

当時動いていたNode製のもので言えば、ヘッドレスブラウザを制御していたデーモンが状況証拠としては怪しいところです。スクリーンショットや通信内容をBufferに溜める設計だと、その領域はV8(NodeのJavaScriptエンジン)のヒープ上限--max-old-space-size対象外なので、青天井に伸びます。ただし確証は無いので断定はしません。

そこで、再発時に捕まえる仕掛けのほうを置きました。30秒ごとに上位プロセスのRSSを記録し、2GBを超えたものは/proc/<pid>/cmdlineから全文を控えるだけのuser systemdサービスです。

# ~/.local/bin/rss-watch.sh の要点
ps -eo rss=,pid= --sort=-rss | head -20 |
	while read -r rss pid; do
		[ "$rss" -gt $((2 * 1024 * 1024)) ] || continue
		full=$(tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null)
		printf '  !! ALERT %dMB pid=%s\n     cmdline: %s\n' \
			$((rss / 1024)) "$pid" "${full:-<exited>}" >>"$LOG"
	done

これもtmux-main.serviceと同じくuserユニットで、loginctl enable-lingerが前提です。あわせて.wslconfigで割り当ても引き上げました。根治ではなく、OOMまでの猶予を稼ぐためのものです。

[wsl2]
memory=16GB    # 既定はホスト物理メモリの50%
swap=8GB       # 既定は memory の25%

なお.wslconfigの変更はwsl --shutdownしないと反映されません。

増幅装置になっていたRepetitionは、結局そのまま残した

切り分けのため一度はWSL-Autostartごと止めましたが、原因がタスクではないと分かったので元に戻しました。5分間隔のRepetitionも外していません。現在の状態はこうです。

State = Running
MSFT_TaskBootTrigger  rep = PT5M
ExecutionTimeLimit    = PT0S
LastTaskResult        = 2147946720

Repetitionは「常駐プロセスが本当に落ちたときに起こし直す」ための仕掛けで、これ自体は必要なものです。悪かったのは、その裏で数分おきにOOMが起きている状況で、原因側を放置したまま復活だけを繰り返していたことでした。監視(rss-watch)とメモリ引き上げを別に入れたうえで戻した、という順序です。

この節の教訓

  1. 症状が消えたことと、原因が消えたことは別です。 自動起動を切ったら安定したので、あやうくそこで終わりにするところでした。実際には犯人は別にいて、タスクは増幅していただけです
  2. OOMログは1行目で犯人を決めないことです。 invoked oom-killerは被害者側に出ます
  3. 事後に特定できない情報は、事前に採っておくしかありません。 今回cmdlineが無かったせいで犯人不明のまま終わりました。監視は「異常が起きてから入れる」のでは間に合いません

そして、前半の「31秒で回収される」問題とこのOOMを混同しないことです。vmIdleTimeoutsleep infinityの常駐も、このOOMには一切関係ありません。同じ「WSLが落ちる」でも、前者はクライアント不在による回収、後者はメモリ枯渇によるクラッシュで、原因も対処も別物です。

動いているかの確認方法

紛らわしい指標が多い構成なので、確認手順を1か所にまとめておきます。

見る対象 コマンド 正常な値
タスクの状態(Windows) (Get-ScheduledTask -TaskName WSL-Autostart).State RunningReady なら常駐が落ちている
タスクの終了コード(Windows) Get-ScheduledTaskInfo -TaskName WSL-Autostart LastTaskResult21479467200 のほうが異常
常駐プロセス pgrep -af 'sleep infinity' 1件見つかる
user ユニット systemctl --user is-enabled tmux-main.service rss-watch.service どちらも enabled
tmux セッション tmux ls main と、作業中のプロジェクトセッション
linger loginctl show-user "$USER" --property=Linger Linger=yes

userユニットはsystemctl --userで見ます。systemctl status tmux-main--user無し)だと“could not be found”になるので、ここで一度悩みました。

さらに、SSHで入った直後に状態を押し付ける仕掛けも入れています。出先から繋いだあとに「メモリを食っているプロセスは無いか」「前回OOMで落ちていないか」を思い出して手で確認する運用は続かないためです。

# .bashrc。対話シェルかつ SSH 経由かつ tmux の外のときだけ表示する
if [[ $- == *i* ]] && [[ -n "${SSH_CONNECTION:-}" ]] && [[ -z "${TMUX:-}" ]]; then
    [[ -x "$HOME/.local/bin/wsl-status.sh" ]] && "$HOME/.local/bin/wsl-status.sh"
fi

条件を3つ重ねているのは、非対話で動くツールやスクリプトの出力を汚さないためです。

wsl-status.shは自作の要約スクリプトで、rss-watchのログのピーク値とALERT件数、直近のOOMの有無、常駐サービスの稼働状況、トンネルの疎通履歴を1画面にまとめて出すだけのものです。凝ったことはしていませんが、「繋がった直後に否応なく目に入る」ことに意味があります。

まとめ

WSL2をサーバとして常時稼働させるうえで、覚えておくとよさそうな点を並べておきます。

  • 「WSLを起こす」と「WSLを起きたままにする」は別問題です。 wsl.exe <なにか>はコマンドが終わればセッションも終わるので、常駐させたいなら終了しないプロセスを渡します。vmIdleTimeout=-1では防げません
  • タスクスケジューラのExecutionTimeLimitPT0Sにします。 既定の5分で常駐プロセスが殺されます
  • LastTaskResultは常駐化した時点で意味が反転します。 見るべきはState = Runningと常駐プロセスの生死です
  • systemd userユニットはRemainAfterExitenable-lingerがセットです。 tmuxのように自分でデーモン化するものは、これが無いとユニット停止時にcgroupごと片付けられて消えます。ただしExecStopを書いた以上、restartは中身ごと作り直しになります
  • 再起動テストを「未検証」のまま残さないことです。 今回の/bin/trueのバグは、構築時のあらゆる確認(サービスのenable、タスクの手動実行、ローカルからのSSH)をすべてすり抜けました。実際に電源を入れ直すまで、誰も気づけない種類の不具合でした
  • Windows側から起動したツールのメモリリークは、WSL全体のクラッシュに化けます。 wsl.exe経由のプロセスはinit.scope直下に入るためです。常時稼働させるなら、RSSの監視は先に入れておくほうがよさそうです

最後のc <project>は些細な工夫ですが、体感としてはこれが一番効きました。出先のタブレットから3行打つ作業と1行打つ作業では、「ちょっと直すか」と思うかどうかが変わります。常駐化の目的は結局そこだった気がします。