NASを再起動したらLXCが起動しなくなった——NFSv3のstale file handleと「見かけ上の正常性」という罠

NASを再起動しただけでコンテナが起動不能になった。エラーコードは116。findmntにはマウントが表示され、pvesm statusもactiveを返す。それでもlsは「Stale file handle」を返す。NFSv3のステートレス性が生む見かけ上の正常性と、その復旧手順、さらに復旧作業が引き起こした二次障害までを記録する。

NASを再起動した。月に一度あるかないかの、日常的な作業だった。

その後、Proxmox上のコンテナが起動しなくなった。ログに出たのは Script exited with status 116 という素っ気ない一行だけ。しかも厄介なことに、状態を確認するコマンドはどれも「正常」と答えてくる。findmnt にはマウントが表示され、pvesm status は active を返す。それでも実際にディレクトリを開こうとすると Stale file handle で撥ねられる。

この記事は、その切り分けと復旧の記録である。そして復旧作業そのものが引き起こした二次障害の話でもある。

背景:ホームラボのNFS構成

構成はシンプルだ。NAS(OpenMediaVault)がNFSでディレクトリをエクスポートし、Proxmoxホストがそれをストレージとしてマウントする。各LXCコンテナは、その配下をバインドマウント(mp0)で受け取る。コンテナ内のアプリケーションから見れば、ローカルのディレクトリと変わらない。

コンテナを作り直してもデータはNAS側に残るし、複数のコンテナから同じデータを参照できる。ホームラボでは定番の組み方だろう。

問題は、この鎖が NASの再起動というごく普通のイベントで切れる ことにある。しかも切れたことが分かりにくい形で。

status 116 の正体

まず起動ログを読む。

run_buffer: 569 Script exited with status 116
lxc_init: 1037 Failed to run lxc.hook.pre-start for container "112"
__lxc_start: 2208 Failed to initialize container "112"
TASK ERROR: startup for container '112' failed

status 116 に見覚えがなくても、推測する必要はない。LXCが返す終了ステータスは多くの場合 errno(システムコールのエラー番号) をそのまま反映している。

116番は ESTALE ——「Stale file handle(無効になったファイルハンドル)」だ。

つまり lxc.hook.pre-start が、起動前に mp0 のソースパスを解決しようとして、そのファイルハンドルが既に無効だったために失敗している。コンテナの中身にもDockerにも問題はない。原因はその手前、ホストのマウントにある。

LXCの起動失敗コードはerrnoとして読む。 これが最初の教訓だ。

見かけ上の正常性という罠

NFSv3は ステートレス なプロトコルだ。サーバはクライアントの接続状態を保持せず、クライアントはファイルハンドルという識別子を握って都度問い合わせる。

サーバが再起動すると、このハンドルは無効になる。そしてNFSv3には、それをクライアント側が回復する手段がない。ハンドルは死んでいるのに、マウントエントリそのものは生き続ける。

findmnt -t nfs,nfs4
# → エントリは表示される(正常に見える)

pvesm status
# → active と返る(正常に見える)

ls /mnt/pve/<storage-name>
# → ls: cannot access ...: Stale file handle (実は死んでいる)

私はこの罠に正面から嵌まった。findmnt にエントリが出ていたので「ホスト側は自動再マウント済み、問題はコンテナ側だけ」と判断し、バインドマウントを張り直す方向で時間を使ってしまった。前提が間違っていたので、その先の推論はすべて無駄だった。

Proxmoxのストレージ機能経由だと pvesm status まで active と答えるので、二重の見かけ上の正常性 が診断を遅らせる。

マウントエントリの存在は、マウントの健全性の証拠にならない。 判定手段は実アクセス(ls)だけである。

もうひとつ、恥ずかしい遠回りもした。実際のパスを確認せず「よくある構成」を仮定して推測でパス名を打ったのだ。当然 No such file or directory が返るだけで、これはstaleとはまったく別のエラーである。切り分けが2往復ぶん遅れた。障害対応の最初にやるべきは、推測ではなく実構成の確定だ。

pct config <CTID> | grep -E '^mp[0-9]'

この一行を先に打っていれば済んだ話だった。

復旧手順

原因が分かれば手順は短い。

# 1. ストレージデーモンがハンドルを掴んでいるので先に止める
systemctl stop pvestatd

# 2. lazy + force で剥がす
umount -l -f /mnt/pve/<storage-name>

# 3. findmntから消え、ディレクトリが空になったことを確認
findmnt -t nfs,nfs4
ls -la /mnt/pve/<storage-name>   # . と .. のみ

# 4. pvestatd がストレージ定義に従って自動で張り直す
systemctl start pvestatd
sleep 30

# 5. 確認
pvesm status
ls /mnt/pve/<storage-name> | head

ポイントは3つある。

umount には -l -f が要る。 stale なマウントへの通常の umount は、ESTALE や EBUSY で失敗する。-l(lazy)で名前空間から先に切り離し、-f(force)で待たせない。

pvestatd を先に止める。 このデーモンがハンドルを掴んだままだと umount が通らない。逆に、停止してから起動すればストレージ定義に従って自動で張り直してくれるので、手動で mount を打つ必要はない。

再マウントには時間がかかる。 最初 sleep 10 で確認したら何もマウントされておらず「復旧失敗」と早合点しかけた。30秒待ったら戻っていた。焦って再確認すると誤判定を生む。

剥がれない場合は fuser -vm で掴んでいるプロセスを特定し、pvedaemon や pveproxy も止めて再試行する。それでも駄目ならホスト再起動が早い。stale なv3ハンドルはカーネル内に残り続けることがあり、粘るだけ時間の無駄になる。

復旧作業が引き起こした二次障害

話はここで終わらなかった。同じNAS上のデータを使う別サービス(MCPサーバー)が、まだエラーを返し続けていた。同じくstaleだろうと判断してコンテナを再起動したところ、エラーの文言が変わった。

  • 再起動前: API error 500
  • 再起動後: fetch failed → Server unavailable

ログを見ると、まったく別の問題だった。

ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x,
where FastMCP was renamed to MCPServer ...

mcp パッケージが2.xに上がり、FastMCP が MCPServer にリネームされていた。コードは1.x前提のままだ。タイムスタンプが0.6→0.9→1.4→2.2→3.7→7→13→26→52秒と伸びており、Dockerの再起動バックオフに入っている。放置しても直らない。

これはNAS障害とは無関係の問題である。ただ コンテナの再起動は再ビルドの契機 であり、そこで初めて顕在化した。長期間再ビルドしていないコンテナほど、こういう未適用の破壊的変更を抱えている。

エラー文言の変化は、原因層の変化を示す

ここで効いたのが、メッセージの「種類」の違いだった。

  • 500 は「プロセスは生きていて、ファイルが読めない」
  • fetch failed は「プロセスが死んでいる」

同じ「使えない」でも見るべき場所がまったく違う。文言が変わった時点で「元の障害の継続」という仮説を捨てるべきだった。

ツールごとの成否パターンも切り分けに使えた。このMCPサーバーは全文検索だけが動き、ファイルの読み書き系がすべて失敗していた。検索インデックスはローカルディスク上のSQLiteにあり、本文はNAS上のファイルにある。つまり 「インデックスは読めるが実体が読めない」=ストレージ層の問題 と特定できる。

逆に言えば、検索が動くことをマウント復旧の判定に使ってはいけない。ローカルで完結するので、NASが死んでいても平然と動く。

ピン固定で戻すか、移行するか

最初に浮かぶ対処は mcp<2 へのピン固定だ。稼働を最速で取り戻せる。だが実際に変更範囲を調べたら、必要な修正は3箇所しかなかった。

箇所 1.x 2.x
import from mcp.server.fastmcp import FastMCP from mcp.server.mcpserver import MCPServer
サーバ生成 FastMCP(..., transport_security=...) MCPServer("...")(transport_security は定数に切り出し)
起動部 mcp.settings.host/port mcp.run(transport=..., host=..., port=..., transport_security=...)

重要なのは 変更が不要だった部分 のほうだ。ツールデコレータ(@mcp.tool(name=..., annotations={...}))も、Pydanticモデルを単一引数に取るシグネチャも2.xでそのまま動いた。結果として11個のツール定義は一行も変えずに済んだ。

ピン固定は後退であり、移行コストを先送りするだけだ。実測してみれば移行のほうが安いことは珍しくない。リネーム系の破壊的変更は、見た目より小さい。

影響と今後:NFSv4.1という本命

復旧手順は確立できたが、これは対症療法にすぎない。NASを再起動するたびに同じことが起きる。

根本的な解は NFSv4.1への移行 だ。v4系はステートフルなプロトコルで、サーバ再起動後にgrace periodが設けられ、その間にクライアントが状態を回復できる。stale file handleでコンテナが起動不能になるという事象そのものが消える。

showmount -e <NASのアドレス>        # サーバ側がv4を出しているか確認
pvesm set <storage-name> --options vers=4.1

注意点として、v4では擬似ファイルシステムのルートからの相対パスになるため、エクスポートパスの表記が変わる可能性がある。たとえば /export/foo が /foo になる、といった具合だ。バインドマウントの定義にも波及するので、本番外の時間帯にやるべき作業になる。

短期的には、上記の復旧手順をスクリプト化しておくのが現実的だろう。

もうひとつ、地味だが効く対策がある。マウントポイント直下にセンチネルファイル(.mounted など)を置き、アプリケーションの起動時チェックで存在を確認する ことだ。

なぜこれが要るかというと、マウントが外れている間、マウントポイントは 空ディレクトリとして正常に存在する からだ。この状態で書き込みを行うアプリケーションが動くと、本来NASに行くはずのファイルがコンテナのローカルディスクに書かれ、再マウント後に見えなくなる。ファイルが単一の正本(source of truth)である設計だと、これは最も避けたい事故になる。センチネルがなければ起動しない、という一行のチェックで構造的に防げる。

よくある質問

status 116 とは何のエラーなのか

Linuxのerrno 116、つまり ESTALE(Stale file handle)です。LXCの起動失敗ステータスはerrnoをそのまま反映しているため、数字をerrnoとして引けば原因層が分かります。116が出た場合、コンテナ本体ではなくバインドマウント元のストレージを疑ってください。

findmntにマウントが表示されているのに、なぜアクセスできないのか

NFSv3がステートレスなプロトコルだからです。サーバ再起動でファイルハンドルが無効化されても、クライアント側のマウントエントリ自体は残り続けます。エントリの存在は健全性を意味しません。判定するには ls <マウントポイント> で実際にアクセスし、Stale file handle が返るかどうかを見る必要があります。

通常の umount で剥がせないのはなぜか

stale なマウントへの umount はそのマウントポイントへのアクセスを伴うため ESTALE で失敗します。またストレージ状態を監視するデーモンがハンドルを掴んでいると EBUSY にもなります。-l(lazy)で名前空間から先に切り離し、-f(force)を併用し、かつデーモンを事前に停止する必要があります。

NFSv4.1に変えれば本当に再発しないのか

v4系はステートフルで、サーバ再起動後のgrace period中にクライアントが状態を回復するため、stale file handleによる起動不能はほぼ発生しなくなります。ただしエクスポートパスの表記がv3から変わる可能性があり、バインドマウントの定義修正が必要になる場合があります。切り替えは余裕のある時間帯に行ってください。

依存パッケージの破壊的変更は、ピン固定と移行のどちらを選ぶべきか

先に変更範囲を実測することをおすすめします。今回のケースでは、リネームに伴う修正は3箇所だけで、デコレータや関数シグネチャは互換性が保たれていました。ピン固定は稼働を早く戻せますが移行コストを先送りするだけなので、実測した結果が小さければ移行したほうが安く済みます。

まとめ

NASの再起動という日常的な操作が、コンテナの起動不能という形で跳ね返ってきた。技術的な原因はNFSv3のステートレス性という既知の性質で、目新しさはない。学びとして残ったのは、むしろ診断の作法のほうだった。

  • エラーコードはerrnoとして読む。 status 116 は推測ではなく ESTALE と特定できる
  • 見かけ上の正常性を信用しない。 findmnt も pvesm status も嘘をつく。実アクセスだけが判定手段になる
  • 推測パスで確認しない。 最初に実構成を確定させる。この一手間で切り分けが数往復短縮される
  • エラー文言が変わったら、別事象を疑う。 「500」と「fetch failed」は原因層が違う
  • ローカルで完結する機能が動くことは、リモートストレージの健全性を意味しない
  • 障害復旧の再起動は、休眠していた別の問題を起こす契機になる

そして本命の対策は、手順の洗練ではなくプロトコルの選択にある。NFSv4.1への移行を済ませていれば、この記事に書いたことの大半は不要だった。ホームラボでNFSv3をまだ使っているなら、次にNASを止める前に検討しておく価値はあると思う。

参考

関連記事