Site
全記事

公開日 2026年9月21日約19分で読了

信頼できないコードのゼロコールドスタート: Vinkius が MCP サーバーを V8 isolate で動かす理由

マルチテナントのエッジコードで V8 isolate がコンテナに勝つ理由:スナップショット復元約15ms、テナントごとの128MBハード上限、SSRF 防止の IP-pin fetch、そして 4 層のスナップショット整合性モデル(破損したエントリはキャッシュミスであり、クラッシュにはならない)、Vinkius runtime の中で。

Renato Marinho

著者 Renato Marinho

Founder · Vinkius

One Node host process: three per-tenant V8 isolates side by side, host-owned effects (fetch, timers, crypto, console) below the bridge, and a four-layer snapshot integrity model.

Vinkius Cloud で顧客が公開する MCP サーバーは、いずれにせよ、自社のインフラの上で動くサードパーティのコードだ。自社のものではない。私達が制御できるライブラリでもない。顧客側のだ。AI エージェント用のクラウドが意味を持つのは、顧客が自前のロジック、自前のコネクタ、LLM と自社のシステムを繋ぐ自前の接着剤を持ち込める場合のみだ。その能力が成立した瞬間、どの営業資料も答えられない問いが、エンジニアリング問題の全部になる。何千ものテナントの信頼できない JavaScript を、たった数台のサーバー上で、どれか一つが他者のサーバー、マシン、隣人に損害を与える道を、何一つ与えずに、どう動かすのか。

これは Vinkius runtime の V8 isolate 層の裏にある問いであり、アーキテクチャの中でも私が公開の場で最も語らない部分だ。だからこそ、この場に書き残す。以下は、今日のシステムの実動きの説明だ。並ぶ数字は、コードが強制している数字であり、私が望む数字ではない。

サーバーごとにコンテナを置かないのはなぜか

マルチテナントプラットフォームが最初に手を伸ばす答えは、サーバーごとに容器を一個だ。きれいで、理解されており、本物のカーネルレベルの分離が得られる。私も、まさにその設計に相当な時間を使っていた。

死んだのは密度だった。Vinkius Cloud の顧客は、どの SaaS で連携を繋ぐのと同じ感覚で MCP サーバーを繋ぐ。ワークスペースあたり数十個、作成は安価、削除も安価、大半は数日間アイドルのままだ。本物のランタイム(フレームワーク、SDK、polyfill、コネクションプール)を起動した Node.js プロセスは、役に立つ前に、固定で数十 MB のコストを抱える。それを一つの task に数百のサーバー分掛け算すれば、最初の tool call も出る前に、その task のメモリ上限と格闘することになる。さらにコールドスタートを掛け足せば、自分のサーバー起動に数百ミリ秒かかるプラットフォームができてしまう。

二つ目の自明な答えは、一つの容器の中に多数のプロセスを置き、テナントごとに一個の fork を持つことだ。もっと悪い。カーネル境界を、何の見返りもなく手放す。各プロセスは相変わらず自分の heap を持ち、自分のページテーブルを持ち、自分のガーベジコレクタを持ち、一プロセスのクラッシュは、共有ファイルディスクリプタと init プロセスを介して兄弟プロセスまで巻き込む。容器案と変わりないメモリを費やして、悪い方の failure mode を手に入れるだけだ。

中間案の数字も回した。ワークスペースごとに一個の Node プロセスで、テナントのサーバー間で共有する案だ。同じ二つの壁で倒れる。起動済み Node ランタイムの固定フットプリントは、共有するかしないかを問わず、テナント単位のコストであり、あるワークスペースのメモリリークや fork bomb は、そのワークスペース中の全サーバーを巻き込んで壊す。破壊半径は、局所化されない。

Cloudflare が Workers で示したのは第三の選択肢だ。テナントごとにプロセスでもなく、テナントごとに容器でもなく、isolate。V8 は、一つのブラウザプロセスの中で多数の信頼できないプログラムを動かすために作られてきた。ブラウザのタブがそのままテナントである、という、私達の問題の形に、まさに一致する。Workers のランタイムも同じプリミティブに乗っている。リクエストごとに isolate を作り、V8 snapshot から復元するため、人々が文句を言うコールドスタートは、実質 heap の復元でしかない。私達は彼らのプラットフォームを採用していない。採用できない。自社のゲートウェイは、page function がそうではない点と違い、長寿命で stateful だ。自社が AWS 上で自前の fleet を持ち、アップグレードのサイクルは私が支配している。採用したのはアイデアだ。Node.js の上で、Node プロセスに raw な V8 isolate を所有させる C++ バインディングである isolated-vm を使って、host 側を組んだ。

その一つの決断(「テナントは isolate であり、プロセスではない」)が、このランタイムの残りの部分の出発点だ。ここから先はその帰結である。

isolate が本当に得られるもの

isolate は heap だ。ガーベジコレクトされる一つのメモリ領域で、独自の microtask キューを持ち、独自の globals を持ち、生成時に置けるハード上限を持つ。isolated-vm がテナント用に生成する isolate は 128 MB を得る。アプリケーションコードで soft quota として強制し、違反で警告を log する類のものではない。エンジンに渡す memoryLimit だ。テナントの heap が上限に届くと、そのテナントの isolate はサービスされなくなる。他テナントは動き続ける。どのテナントの globalThis も、他のテナントの isolate から到達可能ではない。共有される JavaScript の境界が、そもそも存在しないからだ。境界を越えるのは、私達が明示的に bridge したトラフィックのみで、bridge しているのはほとんど何もない。

スケジューリングコストは、人々が思うより重要だ。プロセスの spawn はカーネルイベントであり、fork にはページテーブルの構築、exec、ガーベジコレクタのウォームアップがかかる。一方、一つの process 内での isolate 切替は、ポインタの交換で済む。Cloudflare がコールドスタートの隣に "zero" を並べられるのはそのためであり、自社が edge デプロイしたサーバーのレイテンシ下限が、新規 Node 起動で通常に達する 200 から 400 ms のレンジではなく、ミリ秒単位で測られるのも、そのためだ。

ライフサイクル層は、私がいえるより直接的に、分離の性質を言い表している。connection token ごとに runner が一個で、token の間には何の共有もない。資格情報の分離は絶対だ。ある connection を所有する runner は、その isolate、secrets snapshot、timers を所有し、プロセスグラフの中で、何一つがそれを越えて辿り着くことはない。

代償は、isolate が空の環境だということだ。V8 に標準ライブラリは付いてこない。fetchTextEncoderconsolesetTimeoutprocessBuffer もない。だからランタイムの最初の実際の仕事は、テナントコードを動かすことではない。テナントコードが動くための環境を作ることだ。

空の環境

テナント isolate の起動は、life-support 層と呼ばれるものから始まる。テナントコードが期待する Web と Node の API 面を回復させる、連結された polyfill バンドル一個だ。ロード順は意図的である。各層が、その下の層に依存しているからだ。まず console と process の shim。それ上位の polyfill が log を出し、process を見るためだ。次に、UTF-8 と surrogate pair 対応の TextEncoderTextDecoder。tool の引数に emoji が来るのは edge case ではないためだ。次に URL と、全 12 メソッドの URLSearchParams(changelog の書き出しは "axios, got, and @octokit now work correctly in edge bundles" で、それこそが狙いだった)。次に crypto、timers、ネットワークスタック:Headersfetch、そして async 専用の XMLHttpRequest shim。コネクタに貼られる古い HTTP ライブラリが、xhr is not defined で死なないよう備えている。

life-support スタック:左に guest polyfill 層、右に host が所有する効果、その間を繋ぐ bridge

本題は、何が実物で、何を委任しているかだ。外部世界に触れるものは、isolate の中になん一つ存在しない。テナントバンドル内の fetch は guest 側の polyfill だが、実際のリクエストは host 側で行われ、__host_fetch 経由で、境界を越えた C++ の ArrayBuffer コピーとして送られる。binary-safe なので、画像や gzip レスポンスが UTF-8 の謎文字列に壊されない。crypto.getRandomValues は host 側の randomBytes で、上限は 64 KB。crypto.subtle.digest と HMAC(JWT 検証に必要なもの)も host 側で、host は constant-time 比較で署名を検証する。timers は host 側の setTimeout 呼び出しで、登録済みの invoker を通して guest にコールバックし、上限は 30 秒。根底にあるルールは一つだ。polyfill が呼び出しをネイティブらしく見せ、host が効果を所有する。テナントは意図を表せる。OS まで届くことはない。

引数と結果は、JSON.stringify の代わりに structured cloning(isolated-vm API では copy: true)で境界を越える。シリアライズのコストは一度だけ、C++ で払い、両側に本物の ArrayBuffer と型付きオブジェクトが残る。1 MB のペイロードを動かす tool call を profile するまで、これは気づかない。JSON の round-trip は、ネットワークに次いで二番目に高いコストだった。

コンパイル側では、テナントコードはソースとしてランタイムに到達しない。バンドルパイプラインは server spec を取り、entrypoint を生成し、esbuild を通す(IIFE、browser platform、ES2022 target、minify)。isolate に落ちるのは、dynamic import のないフラットファイル一個だ。静的解析のサニタイザは、バンドルが受け入れられる前に、逃げ道を拒否する。__host_* ブリッジへの直接アクセス、globalThis[...] のブラケット記法でのすり抜け、Function.constructor を使ったバイパスパターンだ。バンドルは gzip され、SHA-256 化される。API は raw バンドルを最大 1.5 MB まで許し、ランタイムはストリーミングバイトカウンターで 2 MB のデコンプレッション上限を強制し、GZIP bomb が heap を満たす前に abort する。私が自信を持っているディテールがある。コンパイル済みバンドルは、全ブリッジがスタブされた使い捨ての isolate で一度実行され、その実行こそが tool manifest を抽出する。プログラムのコストは、compile 時に払い、request 時に払わない。

ここが、開発者に決して見えない部分だ。バンドルは、自分がクラウドにあることすら知らない。開発者のラップトップで stdio 経由でサーバーを起動するのと同じ startServer() の呼び出しが、ランタイムの __vinkius_edge_interceptor グローバルを検出し、その interceptor 経由で tool 定義を引き渡し、通常の transport setup を飛ばす。コードベースは一つで、ランタイムは二つ。フレームワークでローカル開発し、if (inCloud) の分岐を一つも書かずに、クラウドへデプロイできる。

プロトコルバージョニングは、バンドルではなくランタイムが持ち歩く。ゲートウェイは、stateless な MCP の 2026-07-28 リリースを主たる方言として話し、fallback として 2025 リリース、さらに 2024-11-05 の SSE transport に順に下げて交渉する。だから、一年前のクライアントでも、今朝公開したサーバーと会話を続けられる。セッションが最終的に落ちつく方言は、handshake の話であり、deploy 上の決定ではない。

guest がネットワークに直接触れない理由

テナントバンドルが実行できることで、最も危険なのは、HTTP リクエストを送ることだ。fetch は SSRF の普遍的な発生源である。不注意で、あるいは悪意のある bundle が tool を http://169.254.169.254/(AWS メタデータサービス)に向ければ、顧客の AI エージェントに、そのインスタンスの資格情報への読み取り経路を手渡したのと同じだ。だから、全 outgoing トラフィックは SSRF guard を通し、guard は一つの原則で動く。アドレスは、それを承認したポリシーが生存している間のみ有効だ。リクエスト前に DNS を解決し、プライベートレンジは即ブロックする(loopback、10/8172.16/12192.168/16、メタデータサービスである link-local の 169.254/160/8、そして IPv6 の双子)。次に、承認済み IP を undici の接続にピン留めし、rebinding 攻撃が審査後にアドレスを入れ替われないようにする。SNI と TLS もピン留めしたアドレスに合わせて維持する。name のピン留めなしに、socket レベルでだけピン留めすると、リクエストごとに CERT_ALTNAME_INVALID が飛ぶからだ。DNS キャッシュは 4096 エントリで上限で、所属するプールの接続より長くは生きない。

そのプールの agent の keep-alive 設定は、私が shipped してきた fix の中で最も退屈なものであり、こう書き残せて嬉しい。undici の既定の idle timeout は 4 秒で、会話の turn の合間に、プールの socket をバックグラウンドで閉じていた。だから、ほぼすべての実在 tool call が、コールドな TLS ハンドシェイクを払っていた。自社は 65 秒で回す。ALB の 60 秒 idle ウィンドウを少し超えた位置だ。プールの上限は、target あたり 100 sockets。outgoing 呼び出しの p50 が、ハンドシェイク一回分下がった。退屈な fix に p50 が付き、夢は叶う。

レスポンスは、稼働中のバイトカウンター付きで isolate にストリームされ、ハード上限は 10 MB。AbortController は、dispose 経路全体に配線されている。isolate が退役したら、その in-flight リクエストは同じ呼び出しの中で abort される。dispatch がタイムアウトした場合(30 秒の予算)、エラーは、汎用の「リクエストが長すぎた」にはならない。分類器が、違反の瞬間に isolate が何をしていたかを問う。upstream の I/O が in-flight なのか、guest が計算中なのか、メモリ圧力だったのか。エージェントに返る tool_error は、その答えに加えて、recovery ヒントも運ぶ。失敗の持ち主(テナントの upstream API、テナントのコード、あるいはプラットフォーム)は、深夜 3 時にログを grep して突き止めるものではない。

その分類器の出力は、32 slots を持つ upstream 帰属リングに入る。最新を先頭にする。運用画像は、dispatch が失敗しているだけでなく、誰の upstream かも示す。あるテナントの CRM API が深夜 2 時に沈んでも、ランタイムは、それがプラットフォームではなくそのテナントの CRM だとわかっている。リングは、incident チャンネルが証拠を読めるまで保持する。

一度だけ provision する: snapshot の原則

フル起動(バンドルのコンパイル、polyfill 層の実行、IIFE の実行)は、ある deploy との初接触時に、50 から 100 ms 前後かかる。リクエスト第一号なら、それでいい。問題なのはリクエスト 4000 番、すなわち実質、すべてのリクエストだ。

原則は、環境とプログラムは異なる artifact であり、頻繁に変わる片方は一つだけだ。polyfill 層は、与えられた deploy では静的だ。同じ V8 バージョン、同じ host 面。リクエストごとに変わるのは、テナントコードだ。だから、snapshot するのは環境であって、プログラムではない。host が、provision 済み環境に対して isolated-vm の snapshot builder を実行し、得られる heap blob をディスクにキャッシュする。キーは deploy ID、刻印はビルド時の V8 バージョン。restore 時、その blob から新しい isolate を生成する。エンジンによる復元がミリ秒一二、スタブのブリッジを実物と交換するのにさらに一二ミリ秒、その後 IIFE 本体が、復元されたコンテキストで再実行される。合計 15 から 25 ms。フル起動の 50 から 100 に対して、polyfill の仕事は、deploy あたり正確に一度だけ行われる。バンドルが snapshot されず再実行される理由は、Isolate.createSnapshot() が static メソッドで async コードを実行できないためだ。自社の IIFE はトップレベルで await を許す。

フル起動 vs snapshot 復元:50 から 100 ms がどこに消えるか、15 から 25 ms がどこから来るか

snapshot の難しい点は、速度ではない。failure mode だ。V8 のデシリアライズ失敗は SIGABRT。プロセスは死に、JavaScript は catch できない。ディスク上の壊れた blob は、起きるのを待っている crash loop だ。restore、abort、再起動、同じ blob を restore、再び abort。だから integrity モデルは、キャッシュを信頼できない入力で扱う。四層、順を追って。書き込みは原子的(blob は .tmp ファイルに書き、rename する。metadata を先に置くので、書き込み途中で kill されても、検証可能なミスマッチ対が残る。半端な blob にはならない)。各 blob は SHA-256 化され、hash は、バイトが V8 に届く前にチェックされる。V8 バージョンの刻印により、fleet の Node がアップグレードされた瞬間に、キャッシュは自己無効化する。プロセス起動時には、purge pass がディスク上のキャッシュ済み snapshot を検証し、不良品を削除する。そのすべてを裏で支える設計不変条件は一つだ。壊れたキャッシュエントリは、100 ms のコールド起動へ退化する。クラッシュへ退化することは、絶対にない。インシデントの全容が、それだ。あるリクエストに 80 ミリ秒余計にかかった、ということ。

snapshot 層は、hibernation としても機能する。stateful なバンドルは、1 秒のシリアライズ予算付きで getStatesetState フックを公開する。working set をメモリに置いているテナントは、状態を抽出され、isolate を退役され、次のリクエスト時に状態が再注入される。同じ機構で、向きが逆だ。

不変条件: フェーズ間での約束

上の層の正直さは、その不変条件の正直さと同じしかない。不変条件は、人の頭の中に住まわせていたら腐る。だから runner は、書かれた契約を持つ。34 規則が 4 フェーズ(boot、snapshot、dispatch、dispose)にまたがり、統制対象のコードの隣に置かれ、そのファイルを触るレビューのたびに読まれる。フェーズはライフサイクルで、規則は、一つ前のフェーズが次のフェーズに負うもの、そして大半は「しない」規則だ。snapshot にブリッジを入れない。host の timer は、isolate より長く生きていない。timeout の帰属を飛ばす dispatch はしない。

dispose は、この契約が報われるフェーズだ。順序を間違えると何かがリークするのは、dispose だけで、他のフェーズではない。isolate の退役は、固定の 5 ステップだ。まず AbortController を abort する。pending の HTTP が、abort と同時に死ぬように。次に host 側の timer をクリアする。guest が自分を再スケジューリングできないように。次に isolated-vm の参照ハンドルを、割当の逆順で解放する。次に context と isolate を解放する。最後に JavaScript 側の最後の参照を null にする。死んだ heap がプロセスに pin されないように。概念は所有権の移動だ。ブリッジは、指し示す isolate より長くは生きてはならない。一ステップ飛ばせば、残り heap、死んだ isolate に発火する timer、持ち主より長生きする request が得られる。順序は荷を担っており、契約はそれを、文字で言っている。

データ消失防止は、ライフサイクルのもう片側で、同じ扱いを受ける。payload がテナントコードに届く前に、boot 時にロードされる redaction pass を通る。lazy なし。まだロードしていない統制は、存在しない統制だからだ。in transit の資格情報も、秘密らしく見えるものも、log 行やブリッジ引数に触れる前にマスクされる。テナントコードの中で動く DLP は、テナントが切れる DLP だ。ブリッジの host 側で動く DLP は、そうではない。

設計による包含

ランタイムの制限は、magic な数字の壁ではない。一つファイル、Limits.ts に集められ、各定数の隣に、その数字の出どころが明記されている。30 秒の dispatch 予算は、ユーザー行動から来る。主流の MCP クライアントは、30 から 60 秒のどこかで tool call を諦めるため、30 秒を超えて処理し続けるのは、誰も読まない結果のためにリソースを燃やし続けているだけだ。65 秒の keep-alive は、ALB の idle ウィンドウを鏡写しにする。sweep の地平は、connection tracker の 30 分 idle 階層のものであり、制限は、ポリシーの足跡であり、定数ではない。ポリシーが変われば、変わるファイルはその一つで、導出は記録に残る。

資格情報は、同じ規律に従う。テナントの復号済み secret map は、deep copy として __vinkius_secrets に注入される。guest は読めるが、host には書き戻せない。runner が持つのは、map の SHA-256 フィンガープリントで、値ではない。変更がなければ、後続リクエストでの再注入は、安価な no-op になる。各 token は、自分の runner と自分の isolate を持ち、テナント同士で credential state を共有するプールの類は、まったくない。

isolate に到達しうる唯一の token の形は、vk_live_ 接頭語を持つ live connection token だ。プレビュー token、失効 token、別の deploy の token:いずれも、ブリッジが登録される前に、runner の境界で落ちる。guest が実際に持つ唯一の capability が VINKIUS_TOKEN で、catalog の tool code に公開される。tool が、その所有ユーザーとしてプラットフォームに認証し直す。スコープは、そのユーザーの catalog に限定される。モデルは capability であり、パスワードではない。境界の各越境は、名のある、監査される、失効可能な権利である。

バンドルができないことも、できることと同様に、肯定形で文書化される。ファイルシステムなし、process.env なし、ブリッジへの直接アクセスなし(サニタイザは compile time に拒否する)、heap は 128 MB、timer は 30 秒、console ブリッジは、1 秒あたり 10 log、メッセージあたり 1 KB、fetch の上限は 10 MB。テナントが金銭的な線を越えたとき、サーキットブレーカーは 429 を出して肩を竦めるのではない。quota 層が、AI 向けのネイティブなエラーを出す。LLM にリトライを止め、予算上限を人間のユーザーに提示するよう、素の言葉で伝えるもので、所有者が再開を承認できる Vinkius Cloud コンソールのリンク付きだ。リトライの嵐は、エージェントの原生 failure mode であり、プラットフォームは、その人の後ろの人間だけへの対応ではなく、エージェント自身に答えるべきだ。

プラットフォームが所有するもの

このすべては、小さく、意図的に x86-only の fleet で回す。x86 への pin は、私が文字で抱える制約だ。isolated-vm は native addon であり、その ARM ビルドは、私達が扱う依存の中で、最もふらつきが多いものだった。x86 なら退屈だ。他者のコードが住む層では、退屈であることが目標だ。

プロセスは、空で起動する。起動時に読み込む設定はない。ある token に対する最初の接続が、サーバー設定を Laravel API から on demand で引き、そのサーバーの isolate を、まさに必要なときに起動する。LRU sweep は 30 分アイドルでエントリを退役するため、一つの task が、多数のテナントの live state を保持できる。Redis が control plane を担う(invalidation、kill switches、tool 変更通知、quota update)。だからコンソールでの設定変更は、deploy なしで全 task に届く。

セキュリティの物語の最後の一片は、isolate の隣に置かれ、ここで閉じる。私達が意図的に引いた境界が、そこに見えてくるからだ。暗号監査経路だ。各 tool call イベントは、単一スレッドのストリーミング daemon に流れ、SHA-256 の hash チェーンを鋳造し、Ed25519 の session key で署名する。key は RAM に 24 時間保持され、boot 時に master key により認証される。crash 後、daemon は未 ACK のイベントを再処理するため、チェーンに穴は開かない。鋳造された各リンクは、Redis Streams の consumer グループに checkpoint され、crash はチェーンを再導出するのではなく、最後の ACK 済みイベントから再開する。SIEM アダプタは、その同じ stream を、独自のサーキットブレーカーの後ろで汲み出す。失敗した sink は、監査経路に逆流を許すのではなく、切り離される。この daemon が必須だった理由は、ソース一行で言い切れる。V8 は crypto に触れない。テナントコードは、host ブリッジ経由で hash を計算できるが、署名も、認証も、鋳造も、できない。監査証跡は、プラットフォームがランタイムに対して行うことであり、ランタイムのテナントが手の届くものではない。

次の方向

今日の isolate 層は、stateless、速く、ハードに上限あり、の物語だ。ミリ秒一二での起動、128 MB で上限、sweep で退役、重い部分は snapshot が持つ。開かれた方向は、stateful な方だ。hibernation フックに乗る、より長寿命のテナント state。task あたりの live isolate を、より密に詰めること。最終的には、チームが自社のコネクタ catalog を、自社の CI を待たずにマーケットプレイスへ持ち込めるようになること。

V8 isolate が、マルチテナント edge コードの最終解だとは思わない。このプラットフォームが持つ形に対する答えだ。形は、ここにとどまってほしい。安価なテナント、ハードな上限、ミリ秒スケールの起動。エージェントの忍耐は秒で測られ、信頼も、同じだからだ。Cloudflare の「zero cold start」は、賞賛して終わりになる標語ではなく、エンジニアリングできる文だ。isolate、snapshot、integrity chain、そして、timer を誰が所有するか、という地味な決断の一連のものだ。この記事のコードは、cloud.vinkius.com の MCP サーバーの背後にあるランタイムであり、その tool スパンを分散トレースに繋ぐ作業は、MCP Fusion 5.1.0 の記事で扱っている。 このプラットフォームが向かうべき stateful な方向は、セッション層そのものだ。SwarmGateway が重複するエージェントセッションを結合し、古い状態に因果的無効化を適用する仕組み。完全なアーキテクチャはSwarmGateway セッションの記事で説明している。

V8 isolateサンドボックスとその12ステップのブートシーケンスは、ソースコードでカバーされていますエンタープライズAI質問

トピックv8sandboxingruntimemcpedge