01
始まりは分離ではなく漏洩だった
外部MCPサーバーは、利用者がカタログから入れて承認すると、こちらのサーバーが子プロセスとして起動します。そのspawnが、ホストの環境変数を丸ごと子へ渡していました。データベース接続文字列、JWTの署名鍵、トークン暗号化鍵、LLMのAPIキーまで全部です。
直し方そのものは小さいものでした。MCP SDKのstdioトランスポートはPATHやHOMEといった安全な部分集合を既定として用意してくれるので、その上にホストの環境変数を丸ごと重ねる理由がありません。サーバーが明示的に設定した値だけを渡すようにしました。
ただしこれは意図した動作変更です。これまで暗黙にホストの値を受け取っていたサーバーは、必要なキーを自分の設定に書かなければ動きません。利便性を捨てて明示性を取りました。
02
方針が道具を選んだ
環境変数を塞いでも、プロセスは依然としてホストのファイルシステム上で動きます。リポジトリも読めますし、秘密のファイルも開けます。次の層が必要でした。
ここで既存の方針が引っかかりました。コンテナはインフラ限定という原則があったため、アプリケーション自身がコンテナを起動する形を避けようとしたのです。そこでbubblewrapを選びました。デーモンを必要とせずファイルシステムとプロセス、必要ならネットワークまで分離できる道具です。
実装は防御的に書きました。フラグが切れているか、Linuxでないか、バイナリが無ければ、元のコマンドをそのまま実行して警告だけ残します。分離できないからといってサービスを落とすべきではない、という判断でした。ユニット8件とMCPの回帰52件が通りました。
03
その防御的な設計が、分離を0にした
翌朝に判明したことは単純です。bubblewrapはLinux専用で、本番ホストはmacOSでした。Mac mini上で動いているサービスです。
つまり用意した安全弁が毎回作動していました。Linuxではないので静かに元のコマンドを実行し、警告を一行残して通り過ぎる。コードはあり、テストも通り、実際の分離は0でした。
これはバグではなく設計どおりの動作なので、なお悪いのです。テストはゲートが正しく開閉するかを検証しただけで、本番環境ではそのゲートが常に閉じているという事実は検証していませんでした。
04
一日で書き直した
Docker Desktopのコンテナはその中のLinux VMで動くため、macOSを含めdockerのあるどのホストでも実際に分離が効きます。方針を守るために選んだ道具が何も守っていなかったので、方針のほうを直しました。アプリケーション自体をコンテナに入れるのではなく、アプリケーションがspawnする外部プロセスを分離するのだ、と範囲を書き直したのです。
bubblewrapの実装はテストと設計文書ごと削除しました。一日ものの並行実装を残すと、次の人がどちらが本物か迷うだけです。
新しい実装はコマンドをコンテナ実行で包みます。権限をすべて落とし、権限昇格を止め、非rootで動かし、プロセス数とメモリに上限を掛けます。ホストのファイルシステムは一つもマウントしないので、秘密のファイルへ届く経路そのものがありません。環境変数はサーバー自身の設定にあるものだけがコンテナへ入ります。
- ネットワークはサーバーごとに、bridgeか完全遮断かを選びます
- コンテナ内で127.0.0.1を指す設定はホストのアドレスへ書き換えられ、ホスト上のデータベースへ繋ぐサーバーがそのまま動きます
- ランタイムイメージはnodeとuvを一つに収め、npx系とuvx系のサーバーをまとめて覆います
- イメージが無いまま有効化するとspawnが失敗するため、既定は無効です
05
実際に有効化すると、二つが塞いだ
テストが通ることと本番で立ち上がることは別の問題で、今回はその差をすぐに見つけました。
一つ目、イメージのビルドが失敗しました。ベースイメージにすでにuid 1000のユーザーがいるのに、同じ番号でもう一人作ろうとしていたためです。新しく作らず既存のユーザーを使うように変えました。
二つ目、コンテナは立ってもパッケージキャッシュに書けませんでした。名前付きボリュームはroot所有で作られるのに、コンテナは非rootで動くからです。イメージの中にキャッシュのディレクトリをそのユーザー所有で先に作っておくと、空のボリュームが初回マウントされるときに所有権を引き継ぎます。
この二つを直すと標準サーバー10件がコンテナから接続し、PostgreSQLのサーバーはアドレス書き換えのおかげでホストのデータベースへそのまま繋がりました。
06
分離できないサーバーを隠さなかった
3つのサーバーが残りました。どれもホストに直接入れたバイナリに依存していて、汎用のランタイムイメージの中では動きません。
ここには悪い選択肢が二つありました。分離を有効にしてそのサーバーを死なせるか、失敗を黙って飲み込んで分離されているように見せるかです。先の失敗がまさに二つ目の型だったので、代わりに三つ目を作りました。
サーバーごとのネットワーク方針の列に、値をもう一つ足しました。この値のときはコンテナを使わずホストで直接実行し、分離フラグの状態に関係なく無視します。非分離であることが設定に明示され、そのサーバーは分離のログにも現れないため、目で見て区別できます。
再起動後の本番で14サーバーが接続しました。11件はコンテナ分離、3件はホストで非分離です。この過程で、fetchのサーバーが存在しないnpmパッケージを指していたことも判明し、正しいものへ直すとコンテナで問題なく動きました。
07
最も危険なサーバーから中へ戻した
外に残った3つのうち一つがPython REPLでした。任意のコードを実行するサーバーです。最も危険なものが最も守られていない状態で残った形なので、そのままにはできませんでした。
答えは依存をホストではなくイメージ側へ移すことでした。パッケージをビルド時に自己完結した環境へ入れ、実行時にボリュームがマウントされるキャッシュの経路と分けます。実行時に何もダウンロードする必要がなくなるので、ネットワークを完全に切ったコンテナでも動きます。
本番の設定ではこのサーバーの実行コマンドをホストの絶対パスからコンテナ内の名前へ変え、ネットワーク方針を非分離から遮断へ移しました。ネットワークの無いオフラインのコンテナでMCPの初期化が正常に応答することを確認し、再起動後のコンテナの分布はbridgeが11件、ネットワーク無しが1件になりました。
任意コードを実行するサーバーが、OS分離とネットワーク遮断を同時に受けるようになりました。
08
コンテナが止められないもの
コンテナはプロセスが何を見られるかを制限します。誰がそのツールを呼べるかは制限しません。外部に公開され登録も開いているサービスでは、こちらのほうが差し迫った問題でした。
そこでサーバーごとの最小ロールのゲートを入れました。任意コード実行のサーバーは管理者だけ、ブラウザー自動化のサーバーはログイン利用者だけにツールが見えます。ゲストがそのサーバーを明示的に有効化してコード実行を求めても、実行回数は0でした。
すべてのMCPツール呼び出しは監査ログへ残します。ゲストには利用者の識別子が無いためその列は空にし、実行者は別のフィールドへ記録することで、外部キーを壊さずに誰が呼んだかを残しました。
- CPUの上限を掛け、一つのサーバーがホストを占有しないようにしました
- キャッシュのボリュームをサーバーごとに分け、あるサーバーのパッケージキャッシュが別のサーバーを汚さないようにしました
- ホストアドレスへのアクセス権は、実際に127.0.0.1を参照する一つのサーバーにだけ与えました
- 読み取り専用のルートファイルシステムは任意の設定として置きました
09
残したものと、残っているもの
意図的にやらなかったことが一つあります。パッケージのバージョン固定です。14サーバーのバージョンを一度に固定すると、どれが壊れたか分からないまま全部止まりかねないため、サプライチェーンのリスクを承知のうえで先送りしました。代わりにキャッシュをサーバーごとに分け、汚染が伝わる経路だけは先に断ちました。
いまも2つのサーバーはホストで非分離のまま動いています。どちらも公開パッケージではなくホストに入れたバイナリに依存しているため、汎用イメージでは覆えません。専用イメージを別に作るのが残った仕事で、それまでは設定にもログにも非分離だと書かれています。
この作業で最も長く残る教訓は、分離の方式そのものではありません。静かに作動するよう設計した安全弁は、ずっと作動していても誰も気づかない、ということです。いまは分離が効いたサーバーがログに並び、効いていないサーバーは設定にそう書かれています。
根拠資料
OpenMake