10分

分離を作ったのに、本番では何もしていなかった

外部MCPサーバーをOSレベルで分離する仕組みを一日で作り、その翌朝に丸ごと捨てました。選んだ道具が、実際に動かしているホストでは静かに何もしないと分かったからです。

  • MCP
  • サンドボックス
  • セキュリティ
  • Docker
インストール可能なMCPサーバーを並べたOpenMakeのMCPカタログ画面
カタログから入れたサーバーは、最終的に自分たちのホスト上のプロセスになります。問題は、そのプロセスが何を見られるかです。

SHIPPED / EVIDENCE

今回リリースしたもの

外部MCPのstdioサーバーを、権限をすべて落とした非rootのDockerコンテナで実行します。ホストのパスは一つもマウントせず、明示的に設定した環境変数だけを渡し、ネットワークはサーバーごとに決めます。分離できないサーバーは隠さず非分離として表示し、任意コードを実行するサーバーはイメージに焼き込んで、ネットワークを完全に切ったコンテナへ移しました。

最初の実装の実分離
0件
書き直すまで
8時間
コンテナ分離
14件中12件
任意コードサーバーのネットワーク
none

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つのサーバーはホストで非分離のまま動いています。どちらも公開パッケージではなくホストに入れたバイナリに依存しているため、汎用イメージでは覆えません。専用イメージを別に作るのが残った仕事で、それまでは設定にもログにも非分離だと書かれています。

この作業で最も長く残る教訓は、分離の方式そのものではありません。静かに作動するよう設計した安全弁は、ずっと作動していても誰も気づかない、ということです。いまは分離が効いたサーバーがログに並び、効いていないサーバーは設定にそう書かれています。

根拠資料

根拠資料

Engineering Logへ戻る