3つのエージェントに、同じ Next.js アプリの別々の機能を同時に任せた日のことです。
1つ目が UI コンポーネント、2つ目が API ルート、3つ目がスキーマ移行。Antigravity 2.0 のデスクトップで並列に走らせれば、単純計算で3倍速い。そう思っていました。
結果は逆でした。3つとも localhost:3000 を掴もうとして EADDRINUSE が連発し、移行が同じデータベースで競合してロック待ちになり、片方のエージェントのテストがもう片方のシードデータを消しました。壁時計で測ると、直列で回すより 約1.4倍遅い 。並列にしたのに遅くなる、という妙な光景でした。
原因は計算資源ではありません。共有された状態の副作用 です。ポート、devサーバ、データベース、移行、ファイル監視 — これらは1つしかないのに、3つのエージェントが同時に触ろうとする。真の並列実行は、この「1つしかない資源」を容赦なく表面化させます。
個人開発で複数のアプリを並行して回す中で、私自身がたどり着いた解が「1エージェントに1つの実行環境をリースする」という設計でした。以下、実装とともに整理します。
「速くならない並列」の正体
並列エージェントの失敗は、ほとんどが次の4層のどこかで起きます。
層 衝突の中身 症状
ネットワーク 同じポートを複数のdevサーバが奪い合う EADDRINUSE :::3000
データベース 同じテーブル・同じ行を書き換える/移行が競合する ロック待ち・デッドロック・データ破壊
ファイルシステム lockfile・生成物・キャッシュの同時書き込み 壊れた pnpm-lock.yaml・中途半端なビルド
OSリソース inotify 監視数・ファイルディスクリプタの枯渇 ENOSPC(監視上限)・HMRの無反応
共通する構造は「1つしかない資源を、複数の主体が調停なしに触る」ことです。人間が1人で開発しているときは、そもそも同時に2つのdevサーバを立てないので表面化しません。エージェントを並列にした瞬間、隠れていた前提が崩れます。
だから対策は「エージェントを賢くする」ことではなく、環境の側を分ける ことに尽きます。1エージェントに1つ、他と重ならない実行環境を渡す。これがリースの発想です。
リースとは何を渡すことか
リース(lease)は、エージェントに一定期間貸し出す「重ならない資源の束」です。私は最小構成として次の5つを1つのリースにまとめています。
資源 割り当て方 分離される衝突
ポート帯 エージェント番号から決定的にオフセット devサーバ・API・DBプロキシの奪い合い
DBスキーマ テンプレートから複製した専用スキーマ 行の破壊・移行競合
作業ディレクトリ git worktree ごとに分離 ファイル・lockfile・生成物の衝突
環境ファイル リース値を書き込んだ .env.agent 設定の取り違え
リースID 回収・掃除の起点になる一意な鍵 クラッシュ後の資源リーク
肝は、これらを エージェント番号から決定的に導く ことです。ランダムに空きポートを取る方式は一見スマートですが、後述の理由で並列運用ではむしろ扱いにくくなります。
実装1: リース割り当て器
まず、エージェント番号を受け取って1つのリースを組み立てる割り当て器です。ポート帯の計算、専用スキーマの作成、環境ファイルの書き出しまでを担います。
// lease.mjs — エージェント1体分の実行環境をリースする
import { writeFileSync } from 'node:fs' ;
import pg from 'pg' ;
// 1エージェントが使うポート数。dev(3000系)/api(4000系)/db-proxy(5000系)を
// まとめて1帯として確保する。10あれば周辺ツールの分も足りる。
const PORTS_PER_AGENT = 10 ;
const BASE = { dev: 3000 , api: 4000 , dbProxy: 5000 };
// エージェント番号(0,1,2,...)から決定的にポート帯を導く。
// 番号さえ固定すれば、再起動しても毎回同じポートに戻る点が重要。
function derivePorts ( agentIndex ) {
const offset = agentIndex * PORTS_PER_AGENT ;
return {
dev: BASE .dev + offset, // 例: agent0=3000, agent1=3010
api: BASE .api + offset,
dbProxy: BASE .dbProxy + offset,
};
}
// 専用スキーマを「移行済みテンプレート」から複製する。
// 毎回 migrate を走らせない。ここが並列化の最大のボトルネックだった。
async function provisionSchema ( client , schema , template = 'tpl_migrated' ) {
// 識別子は連結でSQLに埋め込むため、英数字とアンダースコアのみに制限する
if ( ! / ^ [a-z0-9_] +$ / . test (schema)) {
throw new Error ( `unsafe schema name: ${ schema }` );
}
// 既存が残っていれば掃除してから、テンプレートの構造を複製する
await client. query ( `DROP SCHEMA IF EXISTS ${ schema } CASCADE` );
await client. query ( `SELECT clone_schema('${ template }', '${ schema }')` );
return schema;
}
export async function acquireLease ( agentIndex , agentId ) {
const ports = derivePorts (agentIndex);
const schema = `agent_${ agentIndex }` ;
const client = new pg. Client ({ connectionString: process.env. ADMIN_DATABASE_URL });
await client. connect ();
try {
await provisionSchema (client, schema);
} finally {
await client. end ();
}
// search_path でスキーマを差し込む。アプリ側はテーブル名を変えなくてよい。
const dbUrl =
`${ process . env . APP_DATABASE_URL }?options=-c%20search_path%3D${ schema }` ;
const env = [
`PORT=${ ports . dev }` ,
`API_PORT=${ ports . api }` ,
`DB_PROXY_PORT=${ ports . dbProxy }` ,
`DATABASE_URL=${ dbUrl }` ,
`AGENT_ID=${ agentId }` ,
`LEASE_INDEX=${ agentIndex }` ,
]. join ( ' \n ' );
writeFileSync ( `.env.agent.${ agentIndex }` , env + ' \n ' );
return { agentIndex, agentId, schema, ports, envFile: `.env.agent.${ agentIndex }` };
}
ここで search_path を使うのが要点です。アプリのコードは users や orders といったテーブル名をそのまま書けばよく、接続時に注入したスキーマの中で解決されます。エージェントに渡すコードを一切書き換えずに、データの器だけを分けられます。
実装2: 移行済みスキーマをテンプレートから複製する
clone_schema は、移行を毎回走らせないための仕掛けです。あらかじめ移行を通した tpl_migrated スキーマを1つ用意しておき、エージェントにはその構造の複製 を渡します。
-- 移行済みテンプレートの構造を、別スキーマに複製する関数。
-- テーブル定義・制約・シーケンスを引き継ぎ、データは空で始める。
CREATE OR REPLACE FUNCTION clone_schema (src text , dst text )
RETURNS void AS $$
DECLARE
obj record;
BEGIN
EXECUTE format ( 'CREATE SCHEMA %I' , dst);
-- テーブル構造(制約・デフォルト込み)を複製。INCLUDING ALL が肝。
FOR obj IN
SELECT tablename FROM pg_tables WHERE schemaname = src
LOOP
EXECUTE format (
'CREATE TABLE %I.%I (LIKE %I.%I INCLUDING ALL)' ,
dst, obj . tablename , src, obj . tablename
);
END LOOP ;
END ;
$$ LANGUAGE plpgsql;
INCLUDING ALL を付けると、主キー・一意制約・デフォルト値・インデックスまで引き継がれます。移行を走らせた状態と等価な器が、SQL1本の複製で手に入る。私の環境では、この複製が 平均80ms で完了します。対して createdb で新規データベースを作って移行を流し直すと、小さなスキーマでも 900ms 前後 。3体・5体と増やすほど、この差が起動時間にそのまま乗ります。
直感に反した点1: スキーマ分離がデータベース分離より速い
最初は「1エージェント1データベースが一番きれいだろう」と考えました。名前空間が完全に独立し、権限も分けやすい。ところが実測すると、これが起動のボトルネックになりました。
方式 分離の強さ プロビジョン時間(実測) 移行の扱い
database-per-agent 最も強い(別DB) 約900ms〜 DBごとに毎回必要
schema-per-agent 強(別スキーマ) 約80ms テンプレ複製で不要
共有DB・行に agent 列 弱(論理分離のみ) ほぼ0ms 不要だが破壊リスク残
エージェントは頻繁に環境を作り直します。失敗してやり直す、途中で別タスクに切り替える。そのたびに900msかかると、並列の利得を起動コストが食いつぶします。分離は「別データベース」ほど強くなくてよく、「別スキーマ」で十分 というのが、手を動かして分かった結論です。共有DBに agent_id 列を足すだけの案は最速ですが、エージェントの誤った DELETE が全員に波及するので採りませんでした。分離の強さと起動速度の折り合いとして、schema-per-agent が中央値に来ます。
直感に反した点2: ランダムな空きポートは、むしろ運用を難しくする
「使われていない空きポートを動的に取ればいい」と考えるのが自然です。私も最初はそうしました。しかし並列エージェントでは、これが後始末を難しくします。
エージェントはdevサーバを何度も再起動します。前のプロセスがゾンビとして残ると、次の起動が別のランダムポートに逃げ、掃除の対象が毎回変わる。どのポートを誰が使っているかが非決定的 になり、健全性チェックの宛先も定まりません。
決定的ポートなら、後始末が1行で済みます。
# エージェント2の帯(3020)に残ったプロセスを確実に掃除する。
# 決定的ポートだからこそ、掃除の対象が毎回同じで迷わない。
lsof -ti:3020 | xargs -r kill -9
エージェント番号が同じなら、devサーバは毎回同じ3020に戻ります。監視も掃除も宛先が固定される。「ランダムで衝突を避ける」より「決定的で追跡できる」ほうが、自動運用では扱いやすい。ここは事前の予想と逆でした。
実装3: リースの回収 — クラッシュした資源を漏らさない
並列運用で地味に効くのが、リースの回収 です。エージェントがクラッシュすると、スキーマとポートが宙に浮きます。放っておくとスキーマが増え続け、ポートが埋まる。
リースIDを起点に、期限切れを掃除する仕組みを1つ用意しておきます。
// reap.mjs — 期限切れのリースを回収する(定期実行 or 起動時に走らせる)
import pg from 'pg' ;
import { execSync } from 'node:child_process' ;
const PORTS_PER_AGENT = 10 ;
const DEV_BASE = 3000 ;
export async function reapLease ( agentIndex ) {
// 1) スキーマを落とす
const client = new pg. Client ({ connectionString: process.env. ADMIN_DATABASE_URL });
await client. connect ();
try {
await client. query ( `DROP SCHEMA IF EXISTS agent_${ agentIndex } CASCADE` );
} finally {
await client. end ();
}
// 2) 帯に残ったプロセスを掃除する(決定的ポートなので対象が確定する)
const devPort = DEV_BASE + agentIndex * PORTS_PER_AGENT ;
try {
execSync ( `lsof -ti:${ devPort } | xargs -r kill -9` , { stdio: 'ignore' });
} catch {
// 掃除対象がなければ何もしない。ここで失敗しても回収は続行する
}
}
リースを「借りたら必ず返す」対象として扱うと、長時間の自動運用でも資源が単調増加しません。ここを最初に設計しておくと、3日連続でエージェントを回しても環境が汚れずに済みます。
公式ドキュメントに書かれていない運用の勘所
手を動かして初めて分かった、細かいけれど効く点をいくつか残します。
inotify の監視上限に先に当たる。 devサーバをN個立てると、それぞれが node_modules を監視して inotify のインスタンスを消費します。Linux では既定の上限が低く、3〜4体並列で ENOSPC(ディスクではなく監視上限)に達しました。各エージェントの監視対象を自分の worktree だけに絞るか、fs.inotify.max_user_watches を引き上げると解消します。
移行はリース取得の外で1回だけ走らせる。 テンプレート tpl_migrated の更新は、エージェントを起動する前に1回だけ。エージェントの中で migrate を呼ばせると、複製した意味がなくなり、しかも移行同士が競合します。移行はリースの前段、複製はリースの中、と役割を分けます。
リースIDはログの相関キーにする。 並列だと、どのエージェントのどの試行のログか分からなくなります。AGENT_ID と LEASE_INDEX を全ログ行に載せておくと、失敗を後から追えます。並列運用のデバッグ可能性は、ここでほぼ決まります。
どこから入れるか
一度に全部を作る必要はありません。衝突の痛みが強い順に、次の順番を推奨します。
まずポート帯の決定的割り当て。EADDRINUSE が消えるだけで、並列の体感が大きく変わります。
次にスキーマ分離とテンプレート複製。データ破壊のリスクが下がり、やり直しが軽くなります。
最後にリース回収。長時間の本番運用に踏み込むタイミングで入れれば十分です。
並列エージェントは、賢さより「重ならない環境」で決まる — というのが、遠回りの末に得た実感です。次にエージェントを2体以上同時に走らせるとき、まずポートを固定してみてください。そこが最初の分かれ道になります。
実装の設計判断の一助になれば幸いです。お読みいただき、ありがとうございました。