安全かつスケーラブルなエージェントサンドボックス基盤の構築方法について
本文の状態
日本語全文を表示中
詳細モードで約10分の本文を読めます。
同じ出来事の情報源
この情報源を基点に整理
TLDR AI
コード実行機能を持つエージェントを隔離する際、ツール側ではなくエージェント自体を分離するアプローチを採用し、秘密情報の漏洩リスクを排除しつつ独立したスケーリングを実現した。
Source Article
元記事を日本語で読む
本文に関係しない購読案内、埋め込み通知、サイト内プロモーションは除いています。
AWS Lambda から制御プレーンアーキテクチャを備えた Unikraft マイクロ VM まで。
Browser Use では数百万の Web エージェントを運用しています。当初は AWS Lambda でブラウザ操作に特化したエージェントを実行していました。各呼び出しが完全に隔離され、スケーリングは瞬時に行われ、機密情報の管理も不要でした。
その後、コード実行機能を追加しました。エージェントは Python の記述と実行、シェルコマンドの実行、ファイルの作成が可能になります。これらはすべて、エージェントがツールとして呼び出す独立したサンドボックス内で動作するように構築しました。セキュリティ面では問題ありませんでした。コードはバックエンドではなく、あくまでサンドボックス内で実行されるからです。
しかし、エージェントループ自体は REST API と同じバックエンド上で動いていました。再デプロイすればすべての実行中のエージェントが停止し、メモリを大量に消費するエージェントが発生すると API の応答が遅延します。本質的に異なる 2 つのワークロードが、同じプロセス内で競合していたのです。
エージェントが任意のコードを実行できる場合、そのマシン上のあらゆるものへのアクセスが可能になります。環境変数、API キー、データベース認証情報、内部サービスなどです。これらはインフラや機密情報から完全に隔離される必要があります。そのためには主に 2 つのアプローチがあります。
パターン 1:ツールを隔離する。エージェントは自社のインフラ上で動作し、危険な操作(コード実行やターミナルアクセス)のみが別々のサンドボックスで実行されます。エージェントは HTTP を介してサンドボックスを呼び出し、コードは機密情報を漏洩させる余地のない環境で実行されます。
パターン 1 - ツールの隔離
パターン 2:エージェントを隔離する。エージェント全体をゼロトラストのサンドボックス内で動作させます。外部との通信は、すべての認証情報を管理する制御プレーンを介して行われます。
エージェントは使い捨てのものになります。盗まれる秘密も、保存すべき状態もありません。必要に応じて殺し、再起動し、独立してスケールさせることができます。真実はコントロールプレーンが保持します。
パターン 2:エージェントを隔離する
私たちはまずパターン 1 から始め、次にパターン 2 へと移行しました。
同じコンテナイメージをどこでも実行します。本番環境ではマイクロ VM として動作し、ローカル開発や評価用テストではコンテナとして動作します。プロビジョニングコードのパスを切り替えるのは、単一の設定スイッチ(sandbox_mode: 'docker' | 'ukc')だけです。
各エージェントには独自の Unikraft マイクロ VM が割り当てられ、起動は 1 秒未満で完了します。これらは AWS の専用ベアメタルマシン上で、Unikraft Cloud の REST API を経由してプロビジョニングされます。
サンドボックスが外部から受け取る環境変数は、セッショントークン(SESSION_TOKEN)、コントロールプレーンの URL(CONTROL_PLANE_URL)、そしてセッション ID(SESSION_ID)の 3 つだけです。AWS キーもデータベース認証情報も API トークンもありません。
Unikraft を使えば、スケール・トゥ・ゼロが標準で実現できます。サンドボックスがアイドル状態になると VM はサスペンドされ、次のリクエストが来れば即座に再開します。クエリ間の待機中のコストはほぼゼロですが、フォローアップタスクには瞬時に起動します。
単一のメトロがボトルネックになるのを防ぐため、サンドボックスは複数の Unikraft メトロに分散されます。
ローカル環境や評価パイプラインでは、同じイメージを Docker コンテナとして実行します。イメージもエントリーポイントもコントロールプレーンのプロトコルもすべて共通です。開発者のノート PC で正確に同じエージェントを実行し、評価用には数百を並列で起動し、本番環境では Unikraft にデプロイできます。
エージェントコードが実行される前に、サンドボックスは以下の処理を行います:
- バイトコードのみでの実行
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: ...本番環境では ControlPlaneGateway が制御プレーンに対して HTTP リクエストを送信します。一方、ローカル開発や評価用には DirectGateway が LLM に直接呼び出しを行い、履歴はメモリ上に保持されます。エージェント側のコードがどちらを使用しているかを意識する必要はありません。インターフェースも動作も同じで、バックエンドのみが異なります。
制御プレーンはステートレスです。トークンを検証し、処理を実行して結果を返すだけです。エージェントを増やしたければサンドボックスをさらに起動すればよく、スループットを上げたいのであればロードバランサーの背後に制御プレーンのインスタンスを追加するだけで済みます。各レイヤーはそれぞれのボトルネックに応じて独立してスケールします。
バックエンドは ALB の背後にあるプライベートサブネット上の ECS Fargate で稼働しています。制御プレーンは CPU 利用率に基づいて自動スケーリングし、サンドボックスは Unikraft を介して独立してスケールします。各セッションには専用の VM が割り当てられ、Unikraft がメトロ間でのスケジューリングを担います。
サービス(バックエンド、エージェント、制御プレーン)の独立したスケーリング
コード実行可能なエージェントをサンドボックス化するアプローチには主に 2 つあります。1 つ目はツールを隔離する方法で、コード実行のみをサンドボックス内で行い、エージェント自体は自社のバックエンド上に置きます。2 つ目はエージェント全体を隔離する方法で、外部との通信はすべて制御プレーンを介して行います。
私たちは後者のパターンを採用しました。制御プレーンがすべての認証情報を保持し、LLM の呼び出し、ファイルストレージ、請求処理など全ての窓口としてプロキシの役割を果たします。サンドボックスには 3 つの環境変数だけが渡され、それ以外のリソースへのアクセスは一切できません。本番環境では Unikraft のマイクロ VM として動作し、開発や評価用には Docker コンテナとして起動されます。どこでも同じイメージを使用しています。
各操作でネットワークホップが1つ増え、デプロイするサービスも1 つから3 つに増えるというトレードオフがあります。しかし実際には、LLM の応答時間に比べれば遅延は誤差の範囲であり、運用上の複雑さも、Ops チームがすでに慣れ親しんでいるレベルです。
重要なポイント:エージェントには、盗む価値のあるものも、守る価値のあるものも存在してはいけません。
原文を表示
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デイリーブリーフで今日の重要ニュースをまとめ読み