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日連続でエージェントを回しても環境が汚れずに済みます。
実装4: 作業ディレクトリも同じリースに含める
ここまででポートとデータの器は分かれました。残るのはファイルです。同じチェックアウトを複数のエージェントが触っている限り、片方の pnpm install がもう片方の node_modules を入れ替え、片方のビルド生成物がもう片方のテストに紛れ込みます。
私は git worktree をリースの一部として払い出しています。ブランチごとに独立したディレクトリが手に入るうえ、.git の実体は共有されるのでディスク消費が膨らみません。リポジトリをエージェント数だけクローンする方式と違い、fetch が重複しない点も助かっています。
// lease.mjs に追記 — worktree もリースの一部として払い出す
import { execFileSync } from 'node:child_process' ;
import { existsSync } from 'node:fs' ;
// エージェント専用の worktree を用意する。
// ブランチ名も番号から決定的に導き、回収時に対象で迷わないようにする。
function provisionWorktree ( agentIndex , baseRef = 'origin/main' ) {
const dir = `../wt-agent-${ agentIndex }` ;
const branch = `agent/${ agentIndex }` ;
if ( existsSync (dir)) return dir; // 再取得は冪等にしておく
// -B は同名ブランチを作り直す。前回の残骸があっても baseRef の地点に戻る
execFileSync ( 'git' , [ 'worktree' , 'add' , '-B' , branch, dir, baseRef], {
stdio: 'inherit' ,
});
return dir;
}
-b ではなく -B を選んでいるのは、前回のリースが残したブランチをそのまま引き継ぎたくないからです。エージェントは途中で放棄されたコミットを平気で残します。次のリースが同じ番号で始まるなら、出発点は毎回 origin/main に揃っているほうが結果を読みやすくなります。
回収側にも1組の後始末を足します。
// reap.mjs に追記 — worktree を登録ごと外す
execFileSync ( 'git' , [ 'worktree' , 'remove' , '--force' , `../wt-agent-${ agentIndex }` ], {
stdio: 'ignore' ,
});
execFileSync ( 'git' , [ 'worktree' , 'prune' ], { stdio: 'ignore' });
worktree remove を飛ばしてディレクトリだけ rm -rf すると、git 側に幽霊の登録が残ります。次に同じパスへ worktree add した瞬間に拒否され、朝の自動起動が丸ごと止まったことがありました。回収は prune までを1組と考えるのが安全です。
リースを渡したあと — タスクを投げる前の1手間
リースを作った直後にタスクを投入すると、devサーバがまだ待ち受けていない状態でエージェントが動作確認に走ります。接続拒否を「自分の実装の失敗」と受け取って、正しく書けていたコードを直し始めたことが実際にありました。
そこで、リース取得とタスク投入のあいだに受け入れ確認を挟んでいます。
#!/usr/bin/env bash
# ready.sh <agentIndex> — devサーバが応答してからタスクを渡す
set -euo pipefail
IDX = " $1 " ; PORT = $(( 3000 + IDX * 10 )); DEADLINE = $(( SECONDS + 60 ))
until curl -sf "http://127.0.0.1:${ PORT }/api/health" > /dev/null ; do
if (( SECONDS > DEADLINE )); then
echo "agent ${ IDX }: dev server not ready on ${ PORT }" >&2
exit 1
fi
sleep 1
done
echo "agent ${ IDX }: ready on ${ PORT }"
60秒で打ち切っているのは、待ち続けるより早く失敗させたいためです。起動しないなら、たいていリース側の設定ミスであって、時間で解決する種類の問題ではありません。
もうひとつ、エージェントへ渡す指示の冒頭に、リースの値をそのまま書き出しています。
このタスクは .env.agent.2 を読み込んで起動してください。
dev サーバは http://127.0.0.1:3020 のみを使い、他のポートには退避しないでください。
データベースは search_path=agent_2 に固定済みです。
作業ディレクトリは ../wt-agent-2 です。
「他のポートに退避しない」と明示するのが効きます。指定ポートが埋まっていると判断したエージェントは、親切心から 3001 や 3002 へ勝手に逃げます。逃げられた瞬間、資源がリースの外へ漏れ、回収の対象から外れてしまいます。
直列・素朴な並列・リース並列を同じ課題で測る
冒頭の「並列にしたら約1.4倍遅くなった」の続きです。同じ3タスク(UIコンポーネントの追加・APIルートの追加・スキーマ移行)を3方式で回し、壁時計を取りました。M2 Pro・メモリ32GB、Next.js 15 のアプリ、PostgreSQL 16 はローカル、各方式3回ずつの中央値です。
方式 完了までの壁時計 やり直しの発生 待ち時間の主な中身
直列(1体ずつ) 18分40秒 0回 単純な順番待ち
素朴な並列(環境共有) 26分10秒 5回 EADDRINUSE の再試行・移行のロック待ち・原因調査
リース並列 8分05秒 0回 起動時のスキーマ複製(3体で約0.24秒)
素朴な並列が直列より遅くなる理由は、失敗そのものよりも 失敗したあとにエージェントが原因を調べる時間 にあります。EADDRINUSE を受け取ったエージェントは、設定ファイルを読み、ログを漁り、ときにコードを書き換えます。人間なら3秒で「ポートが被っただけ」と分かる事象に、数分を溶かします。並列度を上げるほど、この調査時間が全体に効いてきます。
リース並列の8分は、3タスクの中で最も重い1本(スキーマ移行)にほぼ張り付いた値です。つまり、ここから先を縮めたければ並列度ではなくタスクの切り方を変えるしかありません。並列化の効果が頭打ちになる地点が数字で見えたのは、私にとって収穫でした。
公式ドキュメントに書かれていない運用の勘所
手を動かして初めて分かった、細かいけれど効く点をいくつか残します。
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 が消えるだけで、並列の体感が大きく変わります。
次にスキーマ分離とテンプレート複製。データ破壊のリスクが下がり、やり直しが軽くなります。
続けて worktree の分離。ファイル由来の不可解な失敗が消え、失敗の切り分けが一段楽になります。
最後にリース回収と受け入れ確認。長時間の自動運用に踏み込むタイミングで入れれば十分です。
並列エージェントは、賢さより「重ならない環境」で決まる — というのが、遠回りの末に得た実感です。次にエージェントを2体以上同時に走らせるとき、まずポートを固定してみてください。そこが最初の分かれ道になります。
実装の設計判断の一助になれば幸いです。お読みいただき、ありがとうございました。