安全かつスケーラブルなエージェントサンドボックス基盤の構築方法について
本記事は、コード実行機能を持つエージェントのセキュリティとスケーラビリティを確保するための「エージェント単位の隔離」アプローチの設計思想と実装メリットを詳述している。
キーポイント
2 つのサンドボックス戦略の比較
ツール単位での隔離とエージェント単位での隔離という 2 つのアプローチを対比し、後者の優位性を論じている。
エージェント隔離の設計原則
エージェントには盗まれるべき機密や保存すべき状態を持たせず、完全に無価値な環境として扱うという基本方針を示している。
スケーラビリティと運用効率
追加のネットワークホップが必要になるなどのコストはあるものの、エージェントを独立して停止・再起動・スケールできる点で優位性があると指摘している。
影響分析・編集コメントを表示
影響分析
このアプローチは、コード実行能力を持つ AI エージェントを本番環境で安全に運用するための重要な指針となる。特に、セキュリティリスクを最小化しつつ大規模なエージェント処理を実現するアーキテクチャ設計において、業界標準のベストプラクティスとして広く参照される可能性が高い。
編集コメント
コード実行機能を持つエージェントの普及に伴い、セキュリティとスケーラビリティの両立は喫緊の課題であり、本記事はそのための具体的なアーキテクチャ指針を提供する。
AWS Lambda から制御プレーンアーキテクチャを備えた Unikraft マイクロ VM まで。
私たちは Browser Use で数百万の Web エージェントを実行しています。当初は AWS Lambda 上でブラウザのみを使用するエージェントから始め、各呼び出しが隔離され、スケーリングが即座に行われ、秘密情報を気にする必要がない環境でした。
その後、コード実行機能を追加しました。エージェントは Python を記述・実行し、シェルコマンドを実行し、ファイルを作成できるようになりました。これはエージェントがツールとして呼び出す隔離されたサンドボックスとして構築されました。セキュリティ面では問題ありませんでした:コードはバックエンドではなく、サンドボックス内で実行されるからです。
しかし、エージェントのループ自体は、REST API と同じバックエンド上で動作していました。再デプロイ?すべての実行中のエージェントが停止します。メモリを大量に消費するエージェント?API のパフォーマンスが低下します。2 つの本質的に異なるワークロードが、同じプロセス内で共有されている状態です。
エージェントが任意のコードを実行できる場合、そのマシン上のあらゆるものにアクセスできます:環境変数、API キー、データベース認証情報、内部サービスなどです。これはインフラストラクチャや秘密情報から隔離される必要があります。これを実現する方法は 2 つあります。
パターン 1: ツールを隔離する。エージェントはお客様のインフラ上で実行されます。危険な操作(コード実行、ターミナルアクセス)は別のサンドボックスで実行されます。エージェントは HTTP を介してサンドボックスを呼び出します。コードは漏洩のリスクがない場所で実行されます。
パターン 1 - ツールを隔離する。
パターン 2: エージェントを隔離する。すべての秘密情報を持たない状態で、エージェント全体がサンドボックス内で実行されます。外部世界との通信は、すべての認証情報を保持する制御プレーンを通じて行われます。
エージェントは使い捨てになります。盗まれるシークレットも、保存すべき状態もなく、殺害・再起動・独立したスケーリングが可能です。真実はコントロールプレーンが保持します。
パターン 2 - エージェントを隔離する。
私たちはパターン 1 から始まり、パターン 2 へと移行しました。
同じコンテナイメージがあらゆる場所で実行されます。本番環境ではマイクロ VM (micro-VM) として動作し、ローカル開発や評価(evals)ではコンテナとして動作します。単一の設定スイッチ(sandbox_mode: 'docker' | 'ukc')によって、プロビジョニングコードがどのパスをたどるかが制御されます。
各エージェントには独自のユニクラフトマイクロ VM (Unikraft micro-VM) が割り当てられ、起動は 1 秒未満で完了します。これらは AWS の専用ベアメタルマシン上で、ユニクラフトクラウドの REST API を経由してプロビジョニングされます。
サンドボックスが外部世界から受け取る環境変数は、セッショントークン (SESSION_TOKEN)、コントロールプレーンの URL (CONTROL_PLANE_URL)、およびセッション ID (SESSION_ID) の 3 つのみです。AWS キーも、データベース認証情報も、API トークンもありません。
ユニクラフトは、スケール・トゥ・ゼロ(scale-to-zero)機能を標準で提供します。サンドボックスがアイドル状態になると VM はサスペンドされ、次のリクエストが入ると即座に再開されます。クエリの間の待機中のサンドボックスのコストはほぼゼロですが、フォローアップタスクに対しては瞬時に起動します。
単一のメトロ(都市圏データセンター)がボトルネックになるのを防ぐため、サンドボックスは複数のユニクラフトメトロに分散配置されています。
ローカル環境および評価パイプライン内では、同じイメージが Docker コンテナとして実行されます。同じイメージ、同じエントリーポイント、同じコントロールプレーンプロトコルです。開発用ラップトップ上で正確に同一のエージェントを実行し、評価用に数百を並列起動し、本番環境向けにユニクラフトへデプロイすることが可能です。
エージェントコードが実行される前に、サンドボックスは以下の処理を行います:
- バイトコードのみでの実行。Docker ビルド中にすべての Python ソースを .pyc バイトコードにコンパイルし、その後すべての .py ファイルを削除します。エージェントフレームワークのコードはルート権限でメモリに読み込まれます。一度読み込まれると、ソースコードは消去されます。
- 特権の降格。エントリーポイントはルートとして開始され(ルート所有のバイトコードを読み込むために必要)、直ちに
setuid/setgidを介してsandboxユーザーに特権を降格させます。それ以降、すべての処理は特権なしで実行されます。
- 環境の除去。SESSION_TOKEN、CONTROL_PLANE_URL、SESSION_ID を Python 変数に読み込んだ後、これらを os.environ から削除します。エージェントが環境を検査しても、これらの変数は存在しません。トークンはサンドボックスのネットワーク外では無効です。VM はプライベート VPC に配置されており、コントロールプレーンとの通信以外の権限は一切持ちません。
コントロールプレーンをプロキシサービスと捉えてください。サンドボックスは外部世界への直接アクセスを持っていません。すべてのリクエストはコントロールプレーンを介してホップする必要があります。LLM を呼び出したい場合も、コントロールプレーンを介します。S3 にファイルをアップロードしたい場合も、同様にコントロールプレーンを介します。これがエージェントが VM 外の何らかのものと通信できる唯一の方法です。
これはステートレスな FastAPI サービスです。サンドボックスからのすべてのリクエストには Bearer: {session_token} ヘッダーが含まれています。コントロールプレーンはトークンでセッションを検索し、まだ有効であることを検証した上で、実際の認証情報を用いて操作を実行します。
各 LLM コールにおいて、サンドボックスは新しいメッセージのみを送信します。制御プレーンはデータベース内の完全な会話履歴を所有し、各呼び出しでこれを再構築してプロバイダーへ完全なコンテキストとして転送します。これにより、サンドボックスの状態が非永続化(ステートレス)されます。サンドボックスを停止して新規に起動しても、会話は中断された地点から再開されます。
制御プレーンはまた、コスト上限の適用と請求処理も担当します。サンドボックスはタスクの実行にのみ集中します。
サンドボックスには、エージェントがファイルの読み書きを行う /workspace ディレクトリが存在します。ファイル同期サービスが変更を監視し、定期的に S3 へ同期しますが、サンドボックスが AWS クレデンシャル(認証情報)を直接取得することはありません。代わりに、サンドボックスは制御プレーンに対してプレサイン URL を要求します:
- サンドボックスが
/workspace内の変更されたファイルを検出 - サンドボックスがファイルパスと共に
POST /presigned-urlsを呼び出し - 制御プレーンがセッションにスコープ限定された S3 アップロード用のプレサイン URL を生成
- サンドボックスはこれらの URL を使用してファイルを直接 S3 にアップロード
ダウンロードも同様に逆の手順で行われます。サンドボックスは AWS クレデンシャルを保持することなく、直接スコープ限定された S3 アクセスを取得します。
サンドボックス内部では、エージェントは「Gateway」プロトコルを通じて制御プレーンと通信します:
class AgentGateway(Protocol):
async def invoke_llm(self, new_messages, tools, tool_choice) -> LLMResponse: ...
async def persist_messages(self, messages) -> None: ...必ず JSON 形式で返してください。translation フィールドのみ。他のフィールド (technical_terms 等) は一切追加しないこと — 余計なフィールドを書こうとして本文翻訳がトークン上限で打ち切られる事故を防ぐため:
{"translation": "各 LLM コールにおいて、サンドボックスは新しいメッセージのみを送信します。制御プレーンはデータベース内の完全な会話履歴を所有し、各呼び出しでこれを再構築してプロバイダーへ完全なコンテキストとして転送します。これにより、サンドボックスの状態が非永続化(ステートレス)されます。サンドボックスを停止して新規に起動しても、会話は中断された地点から再開されます。
制御プレーンはまた、コスト上限の適用と請求処理も担当します。サンドボックスはタスクの実行にのみ集中します。
サンドボックスには、エージェントがファイルの読み書きを行う /workspace ディレクトリが存在します。ファイル同期サービスが変更を監視し、定期的に S3 へ同期しますが、サンドボックスが AWS クレデンシャル(認証情報)を直接取得することはありません。代わりに、サンドボックスは制御プレーンに対してプレサイン URL を要求します:
- サンドボックスが
/workspace内の変更されたファイルを検出 - サンドボックスがファイルパスと共に
POST /presigned-urlsを呼び出し - 制御プレーンがセッションにスコープ限定された S3 アップロード用のプレサイン URL を生成
- サンドボックスはこれらの URL を使用してファイルを直接 S3 にアップロード
ダウンロードも同様に逆の手順で行われます。サンドボックスは AWS クレデンシャルを保持することなく、直接スコープ限定された S3 アクセスを取得します。
サンドボックス内部では、エージェントは「Gateway」プロトコルを通じて制御プレーンと通信します:
class AgentGateway(Protocol):
async def invoke_llm(self, new_messages, tools, tool_choice) -> LLMResponse: ...
async def persist_messages(self, messages) -> None: ...
```"}
本番環境では、ControlPlaneGateway が制御プレーンに対して HTTP リクエストを送信します。一方、ローカル開発や評価用には DirectGateway が LLM に直接呼び出しを行い、履歴はメモリ上に保持されます。エージェントコード自体がどちらを使用しているかを意識する必要はありません。同じインターフェースと動作を持ちつつ、バックエンドのみが異なります。
制御プレーンはステートレスです:トークンの検証を行い、処理を実行し、結果を返します。エージェントを増やしたい場合は、サンドボックスをさらに起動すればよいですし、スループットを増やしたい場合は、ロードバランサーの背後に制御プレーンインスタンスを追加すれば十分です。各レイヤーは、それぞれのボトルネックに応じて独立してスケールします。
当社のバックエンドは、ALB の背後にあるプライベートサブネット上で ECS Fargate 上で動作しています。制御プレーンは CPU 利用率に基づいて自動スケーリングし、サンドボックスは Unikraft を通じて独立してスケールします。各セッションには独自の VM が割り当てられ、Unikraft がメトロ間でのスケジューリングを処理します。
サービス(バックエンド、エージェント、制御プレーン)の独立したスケーリング
コードを実行できるエージェントをサンドボックス化するアプローチには 2 つあります。1 つ目はツールを隔離する方法で、コード実行はサンドボックス内で行い、エージェント自体は自社のバックエンド上に維持します。2 つ目はエージェント全体を隔離する方法で、エージェント全体をサンドボックス内に配置し、外部世界との通信は制御プレーンを介して行います。
私たちはパターン 2 を採用しました。制御プレーンがすべての認証情報を保持し、LLM の呼び出し、ファイルストレージ、請求処理などすべてに対するプロキシとして機能します。サンドボックスには 3 つの環境変数だけが渡され、それ以外のリソースへのアクセスは一切できません。本番環境では Unikraft のマイクロ VM として動作し、開発・評価用には Docker コンテナとして動作しますが、どこでも同じイメージを使用しています。
トレードオフは、すべての操作に追加のネットワークホップが発生し、1 つのサービスではなく 3 つのサービスをデプロイする必要がある点です。実際には、LLM の応答時間に比べれば遅延はノイズの域を出ず、運用上の複雑さは運用チームがすでに処理方法を知っているようなものです。
重要なポイント:エージェントには盗む価値のあるものも、守る価値のあるものもあってはいけません。原文を表示
From AWS Lambda to Unikraft micro-VMs with a control plane architecture.
We run millions of web agents at Browser Use. We started with browser-only agents on AWS Lambda, where each invocation is isolated, scaling is instant, and there are no secrets to worry about.
Then we added code execution. Agents could write and run Python, execute shell commands, create files. We built this as an isolated sandbox the agent called as a tool. Security was fine: the code ran in the sandbox, not on the backend.
But the agent loop still ran on the same backend as our REST API. Redeploy? All running agents die. Memory-hungry agent? The API slows down. Two fundamentally different workloads sharing the same process.
When an agent can run arbitrary code, it can access anything on the machine: environment variables, API keys, database credentials, internal services. It needs to be isolated from your infrastructure and secrets. There are two ways to do this.
Pattern 1: Isolate the tool. The agent runs on your infrastructure. Dangerous operations (code execution, terminal access) run in a separate sandbox. The agent calls the sandbox via HTTP. The code runs somewhere with nothing to leak.
Pattern 1 - Isolate the tool.
Pattern 2: Isolate the agent. The entire agent runs in a sandbox with zero secrets. It talks to the outside world through a control plane that holds all the credentials.
The agent becomes disposable. No secrets to steal, no state to preserve, you can kill it, restart it, scale it independently. The control plane holds the truth.
Pattern 2 - Isolate the agent.
We started with Pattern 1 and moved to Pattern 2.
The same container image runs everywhere. In production it runs as a
micro-VM. In local development and evals it runs as a
container. A single config switch (sandbox_mode: 'docker' | 'ukc') controls which path the provisioning code takes.
Each agent gets its own Unikraft micro-VM, booting in under a second. We provision them via Unikraft Cloud's REST API on dedicated bare metal machines in AWS.
The sandbox receives only three env variables from the outside world: SESSION_TOKEN, CONTROL_PLANE_URL, and SESSION_ID. No AWS keys, no database credentials, no API tokens.
Unikraft gives us scale-to-zero out of the box. When a sandbox is idle, the VM suspends. When the next request comes in, it resumes. A sandbox sitting between queries costs almost nothing but wakes up instantly for follow-up tasks.
We distribute sandboxes across multiple Unikraft metros to prevent any single metro from becoming a bottleneck.
Locally and in our eval pipelines, the same image runs as a Docker container. Same image, same entrypoint, same control plane protocol. We can run the exact same agent on a dev laptop, spin up hundreds in parallel for evals, and deploy to Unikraft for production.
The sandbox does several things before any agent code runs:
- Bytecode-only execution. During the Docker build we compile all Python source to .pyc bytecode, then delete every .py file. The agent framework code is loaded into memory as root. Once loaded, the source is gone.
- Privilege drop. The entrypoint starts as root (needed to read the root-owned bytecode), then immediately drops to a
sandboxuser viasetuid/setgid. From that point on, everything runs unprivileged.
- Environment stripping. After reading SESSION_TOKEN, CONTROL_PLANE_URL, and SESSION_ID into Python variables, we delete them from os.environ. If the agent inspects the environment, those variables are gone. The token is useless outside the sandbox's network anyway. The VM sits in a private VPC with no permissions other than talking to the control plane.
Think of the control plane as a proxy service. The sandbox has no direct access to the outside world. Every request has to hop through the control plane. Need to call an LLM? Goes through the control plane. Need to upload a file to S3? Goes through the control plane. It's the only way the agent can talk to anything outside its VM.
It's a stateless FastAPI service. Every request from the sandbox carries a Bearer: {session_token} header. The control plane looks up the session by token, validates that it's still active, and executes the operation with real credentials.
For each LLM call, the sandbox sends only the new messages. The control plane owns the full conversation history in the database, reconstructs it on each call, and forwards the complete context to the provider. This keeps the sandbox stateless. You can kill it and spin up a new one, and the conversation picks up where it left off.
The control plane also enforces cost caps and handles billing. The sandbox is only focused on the task.
The sandbox has a /workspace directory where the agent reads and writes files. A file sync service watches for changes and periodically syncs them to S3, but the sandbox never sees AWS credentials. Instead, it asks the control plane for presigned URLs:
- Sandbox detects changed files in /workspace
- Sandbox calls POST /presigned-urls with the file paths
- Control plane generates presigned S3 upload URLs (scoped to the session)
- Sandbox uploads files directly to S3 using those URLs
Downloads work the same way in reverse. The sandbox gets direct scoped S3 access without ever holding an AWS credential.
Inside the sandbox, the agent talks to the control plane through a "Gateway" protocol:
python
class AgentGateway(Protocol):
async def invoke_llm(self, new_messages, tools, tool_choice) -> LLMResponse: ...
async def persist_messages(self, messages) -> None: ...In production, ControlPlaneGateway sends HTTP requests to the control plane. For local development and evals, DirectGateway calls the LLM directly and keeps history in memory. The agent code doesn't know which one it's using. Same interface, same behavior, different backend.
The control plane is stateless: validate the token, do the work, return the result. Need more agents? Spin up more sandboxes. Need more throughput? Add control plane instances behind a load balancer. Each layer scales based on its own bottleneck.
Our backend runs on ECS Fargate in private subnets behind an ALB. The control plane auto-scales based on CPU utilization. Sandboxes scale independently through Unikraft. Each session gets its own VM, and Unikraft handles the scheduling across metros.
Scaling services independently (Backend, Agent and Control Plane)
There are two ways to sandbox an agent that can execute code. You can isolate the tool (run code execution in a sandbox, keep the agent on your backend) or isolate the agent (put the entire agent in a sandbox, talk to the outside world through a control plane).
We went with Pattern 2. The control plane holds all credentials and acts as a proxy for everything: LLM calls, file storage, billing. The sandbox receives three env variables and has no access to anything else. It runs as a Unikraft micro-VM in production and a Docker container in development and evals. Same image everywhere.
The tradeoff is an extra network hop on every operation and three services to deploy instead of one. In practice the latency is noise compared to LLM response times, and the operational complexity is the kind that ops teams already know how to handle.
The key takeaway: your agent should have nothing worth stealing and nothing worth preserving.
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み