VPS 上でキューをポーリングし続ける Worker を運用していると、「ジョブは外部キューから取得し、結果もデータベースに保存するのに、なぜサーバーを1台維持する必要があるのか」と考えたくなります。Vercel が発表した Daemons は、この用途に合いそうです。コンテナをデプロイしてインスタンス数を決めれば、プロセスを継続稼働させられます。

ただし、それだけで VPS を廃止できるわけではありません。Vercel の発表、Daemons の制限、管理ドキュメントを確認したところ、判断基準は明確でした。外部状態を使い、安全に再試行できるプル型 Worker なら候補になる。一方、着信ポート、永続的なローカルファイル、完全な CI 実行環境が必要なサービスをそのまま移すものではありません。

2026年10月15日、Vercel は Python バックエンドの GA、コンテナ化した Functions、Daemons Public Beta を同時に発表しました。ここでは公式資料と JSON Schema に基づいて実行モデルを整理し、別途ローカルの Node.js 子プロセスへ `SIGTERM` を送る実験で、ジョブの確認順序による違いを確かめます。ローカル実験を Vercel 上の動作検証として扱うものではありません。

HTTP リクエストを受ける処理と、仕事を取りに行く処理

FastAPI、Flask、Django などの API には、GA となったリクエスト駆動の Python Functions が合います。独自の OCI イメージやシステム依存ライブラリが必要で、HTTP を受けるサービスなら Container Functions(Beta)を検討できます。これらはリクエスト量に応じて動く仕組みです。

一方、Daemons は固定数の常駐プロセスが外部キューやデータベースから仕事を取得します。公式概要によれば、現状は着信 HTTP/TCP を受け付けません。

ワークロード近い実行方式移行前の確認事項
FastAPI / Flask の公開 APIPython Functions(GA)リクエスト時間、依存関係、外部接続
HTTP を受ける独自 OCI サービスContainer Functions(Beta)リクエスト駆動のスケールと課金
キューを消費して外部 DB に書き込む処理Daemons(Public Beta)固定インスタンス、外部状態、再試行と冪等性
ローカルファイルやキャッシュを永続化したい処理VPS または永続ボリューム対応ホストDaemons には永続ローカルディスクがない
TCP ポートへの着信が必要なサービスVPS や適切な TCP ホスティングDaemons は着信ポートを公開しない

GitHub Self-hosted Runner も単純には移せません。Runner は GitHub からジョブを取りに行きますが、ジョブにはコンテナのビルド、作業ディレクトリ、キャッシュ、デプロイ権限が必要かもしれません。通信がプル型というだけで、ファイルシステムや権限分離まで互換になるわけではないため、実際の Workflow ごとに判断する必要があります。

Hobby の月10 vCPU-hoursでは常時無料稼働できない

Daemons の Beta 制限では、Hobby はチームあたり月 10 vCPU-hours。1 vCPU のインスタンスを動かし続けると、およそ10時間で月間枠を消費します。上限に達したチームの Daemons は、翌月かプランのアップグレードまで停止します。

この単位は 割り当てた vCPU 数 × 実行時間 であり、CPU 使用率ではありません。キューを待っている時間が長くても「無料で24時間365日稼働する Worker」とは考えられません。リクエスト駆動の Container Functions に関する Active CPU 課金の説明を、Daemons にそのまま適用するのも危険です。

Pro のデフォルト同時実行リソース上限は 16 vCPU、32 GiB RAM。これはドル建ての月額料金ではありません。VPS と費用を比べるなら、確保するリソース、常駐時間、ネットワークとキューの費用、運用負担をそれぞれ数える必要があります。

また現段階の Daemons Beta には Preview Deployments、自動スケーリング、永続ローカルストレージがなく、`vercel dev` で直接起動することもできません。正式なデプロイで稼働し、ローカル開発時は自分でコンテナを動かします。既存の検証やキャッシュ運用に Preview とディスクが欠かせないなら、コードの移行以前にここが壁になります。その他の制限にはプロジェクトのルートディレクトリ構成に関する条件もあります。

SIGTERM 後の再配信で何が起こるか

管理ドキュメントでは、新旧バージョンの Daemon がデプロイ時に一時的に並走することがあり、旧プロセスは `SIGTERM` を受けてデフォルト10秒の猶予を与えられます。難しいのは終了処理そのものより、途中まで実行したジョブの扱いです。

例えば注文処理をデータベースへ書き込んだ直後、キューへの Ack より先にプロセスが停止したとします。メッセージが再配信されれば、同じ副作用を二度起こすかもしれません。逆に Ack を先に行ってから更新しようとすると、実処理が終わる前に停止した場合、その仕事が失われる可能性があります。

そこで Node.js v22.16.0 / Linux x64 で小さなローカルの合成キューモデルを作りました。JSON ファイルに仮のジョブ状態と副作用を記録し、実際の Node 子プロセスを使って3種類の中断経路に `SIGTERM` を送ります。検証するのは Ack 順序であり、Vercel の可用性や起動時間ではありません。

ローカルの合成ケース処理回数記録された副作用Ack 済み
副作用を記録してから Ack11はい
記録後に中断、ジョブ ID で重複排除して再試行21はい
記録後に中断、重複排除なしで再試行22はい
副作用より先に Ackして中断10はい

4ケースとも期待した状態を実際に観測しました。特に問題なのは後半の2つです。重複排除しなければ副作用が二重になり、Ack を早めると仕事そのものが失われます。

重複排除の中心部分は数行です。`effects` はこの合成モデル内の記録を表します。

function recordEffect(s, dedupe) {
  const existing = s.effects.some((effect) => effect.jobId === s.job.id);
  if (dedupe && existing) return 'effect_deduplicated';
  s.effects.push({ jobId: s.job.id, effect: 'synthetic-committed-action' });
  return 'effect_applied';
}

`jobId` がすでに記録されていれば2回目は追加しません。重複排除を外すと、同じ仕事の効果が2件になります。Ack 先行のケースでは `acked: true` を見た再起動後の Worker が、効果がゼロ件でも仕事を飛ばしてしまいます。

本番のキューでは、可視性タイムアウト、トランザクション、配送保証なども関係します。この実験はそこまで実装していません。移行前には業務上の一意キーや冪等な書き込みを用意し、実際に使うキューの再配送ルールで試すべきです。

設定よりも実行契約を見極める

Daemons の設定リファレンスに基づく単一リージョン Worker の例です。`worker/` には Dockerfile とジョブ消費プログラムが別途必要です。

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "daemons": {
    "worker": {
      "root": "worker/",
      "entrypoint": "Dockerfile",
      "instances": { "iad1": 1 },
      "resources": { "requests": { "memory": "2Gi" } }
    }
  }
}

`instances` はリージョンごとの固定インスタンス数、`iad1` はワシントンのリージョン ID です。自動スケーリングの設定ではありません。

プロジェクト JSON Schemaでは古い `replicas` も受け付けますが、deprecated と明記されています。`instances` を同時に指定すると後者が優先されます。最新の設定・管理ドキュメントも `instances` に統一されているため、新規構成ではこちらを選びます。

ドキュメント上の `SIGTERM` デフォルト猶予は10秒で、Schema には `terminationGracePeriodSeconds` もあります。猶予を延ばしても二重更新や Ack の順序ミスは解決しません。まず安全な中断・再試行を設計し、猶予設定は実デプロイで確認します。

固定インスタンスで十分、ジョブは外部キューから取得し、状態は外部 DB に置けるなら、非重要処理で Daemons を評価する価値があります。一方、永続ワークスペース、複雑な CI 権限、着信ポート、安定した Preview が必要なら、当面は VPS を残します。別のホストへ Runner を移すだけでは、信頼できない Job とデプロイ秘密情報の境界もなくなりません。

関連する運用例として ChatGPT Web + GitHub Self-hosted Runner と PanelAlpha Engine の VPS ホスティング検証 も参考になります。

以下のスクリプトを `queue-interruption-l1.mjs` として保存し、Node.js 20 以上で `node queue-interruption-l1.mjs` を実行すると、上記4ケースの JSON 結果を再現できます。

展開:Node.js の合成 Worker テスト全コード
/**
 * First-party, offline, synthetic queue acknowledgement experiment.
 * Not a Vercel deployment, Vercel Daemon integration, or platform reliability test.
 * Dependencies: Node.js >= 20 standard library, no network, no cloud credentials.
 * Run: node queue-interruption-l1.mjs
 */
import assert from 'node:assert/strict';
import { fork } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, mkdtempSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const SCRIPT = fileURLToPath(import.meta.url);
const JOB = 'synthetic-job-001';
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
const readState = (file) => JSON.parse(readFileSync(file, 'utf8'));
function writeState(file, state) {
  const temp = `${file}.next`;
  writeFileSync(temp, JSON.stringify(state) + '\n', { mode: 0o600 });
  renameSync(temp, file);
}
function newState(file) {
  writeState(file, { job: { id: JOB, acked: false }, effects: [], attempt: 0 });
}
function recordEffect(s, dedupe) {
  const existing = s.effects.some((effect) => effect.jobId === s.job.id);
  if (dedupe && existing) return 'effect_deduplicated';
  s.effects.push({ jobId: s.job.id, effect: 'synthetic-committed-action' });
  return 'effect_applied';
}

// The worker is our own short-lived child process, not a provider/runtime process.
function worker(mode, stateFile) {
  const state = readState(stateFile);
  if (state.job.acked) {
    process.send({ phase: 'already_acknowledged' }, () => process.exit(0));
    return;
  }
  state.attempt += 1;
  if (mode === 'ack-before-effect') {
    state.job.acked = true;
    writeState(stateFile, state);
    process.send({ phase: 'ack_committed_effect_missing' });
    process.on('message', () => {}); // Keep child alive until real SIGTERM.
    return;
  }
  if (mode === 'effect-before-ack') {
    recordEffect(state, false);
    writeState(stateFile, state);
    process.send({ phase: 'effect_committed_ack_missing' });
    process.on('message', () => {}); // Keep child alive until real SIGTERM.
    return;
  }
  if (mode === 'complete' || mode === 'complete-without-dedupe') {
    const action = recordEffect(state, mode === 'complete');
    state.job.acked = true;
    writeState(stateFile, state);
    process.send({ phase: action }, () => process.exit(0));
    return;
  }
  throw Error(`Unknown first-party worker mode: ${mode}`);
}

async function runChild(mode, stateFile, expectedPhase, interrupt) {
  return await new Promise((resolve, reject) => {
    const child = fork(SCRIPT, ['--worker', mode, stateFile], {
      env: { PATH: process.env.PATH ?? '', NODE_ENV: 'test' },
      stdio: ['ignore', 'ignore', 'pipe', 'ipc']
    });
    let seen = null;
    let settled = false;
    let stderr = '';
    child.stderr.setEncoding('utf8');
    child.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-1200); });
    const timer = setTimeout(() => {
      child.kill('SIGKILL');
      if (!settled) { settled = true; reject(Error(`Worker timeout: ${mode}`)); }
    }, 7000);
    child.on('error', (error) => {
      clearTimeout(timer);
      if (!settled) { settled = true; reject(error); }
    });
    child.on('message', (event) => {
      if (seen !== null) return;
      seen = event.phase;
      if (interrupt) child.kill('SIGTERM');
    });
    child.on('exit', (code, signal) => {
      clearTimeout(timer);
      if (settled) return;
      settled = true;
      if (seen !== expectedPhase || (interrupt ? signal !== 'SIGTERM' : code !== 0)) {
        reject(Error(`Worker mismatch mode=${mode} seen=${seen} code=${code} signal=${signal}; stderr=${stderr}`));
      } else {
        resolve({ phase: seen, exit_code: code, exit_signal: signal });
      }
    });
  });
}

async function experiment() {
  const started = new Date().toISOString();
  const dir = mkdtempSync(path.join(tmpdir(), 'offline-queue-l1-'));
  const cases = [];
  let cleanup = false;
  try {
    const stateFile = path.join(dir, 'state.json');
    newState(stateFile);
    const normal = await runChild('complete', stateFile, 'effect_applied', false);
    let s = readState(stateFile);
    assert.equal(s.effects.length, 1);
    assert.equal(s.job.acked, true);
    cases.push({ id: 'control_effect_then_ack', passed: true, effects: s.effects.length, acked: s.job.acked, attempts: s.attempt, worker: normal });

    newState(stateFile);
    const interruptedSafe = await runChild('effect-before-ack', stateFile, 'effect_committed_ack_missing', true);
    s = readState(stateFile);
    assert.equal(s.effects.length, 1);
    assert.equal(s.job.acked, false);
    const replaySafe = await runChild('complete', stateFile, 'effect_deduplicated', false);
    s = readState(stateFile);
    assert.equal(s.effects.length, 1);
    assert.equal(s.job.acked, true);
    assert.equal(s.attempt, 2);
    cases.push({ id: 'sigterm_replay_with_dedupe', passed: true, effects: s.effects.length, acked: s.job.acked, attempts: s.attempt, workers: [interruptedSafe, replaySafe] });

    newState(stateFile);
    const interruptedUnsafe = await runChild('effect-before-ack', stateFile, 'effect_committed_ack_missing', true);
    const replayUnsafe = await runChild('complete-without-dedupe', stateFile, 'effect_applied', false);
    s = readState(stateFile);
    assert.equal(s.effects.length, 2); // Deliberate negative control: duplicate side effect.
    assert.equal(s.job.acked, true);
    cases.push({ id: 'sigterm_replay_without_dedupe_negative_control', passed: true, effects: s.effects.length, acked: s.job.acked, attempts: s.attempt, observed_risk: 'duplicate_side_effect', workers: [interruptedUnsafe, replayUnsafe] });

    newState(stateFile);
    const earlyAck = await runChild('ack-before-effect', stateFile, 'ack_committed_effect_missing', true);
    const recovery = await runChild('complete', stateFile, 'already_acknowledged', false);
    s = readState(stateFile);
    assert.equal(s.effects.length, 0);
    assert.equal(s.job.acked, true);
    assert.equal(s.attempt, 1);
    cases.push({ id: 'ack_before_effect_negative_control', passed: true, effects: s.effects.length, acked: s.job.acked, attempts: s.attempt, observed_risk: 'lost_work', workers: [earlyAck, recovery] });
  } finally {
    rmSync(dir, { recursive: true, force: true });
    cleanup = !existsSync(dir);
  }
  assert.equal(cases.length, 4);
  assert.ok(cases.every((entry) => entry.passed));
  assert.equal(cleanup, true);
  const receipt = {
    schema_version: 1,
    type: 'FIRST_PARTY_OFFLINE_SYNTHETIC_QUEUE_EXPERIMENT',
    scope: 'LOCAL_NODE_CHILD_PROCESSES_ONLY_NOT_VERCEL',
    source_sha256: sha256(readFileSync(SCRIPT)),
    node: process.version,
    platform: process.platform,
    architecture: process.arch,
    started_at: started,
    ended_at: new Date().toISOString(),
    case_count: cases.length,
    passed: cases.length,
    failed: 0,
    clean_up_verified: cleanup,
    cases,
    limitations: [
      'No Vercel account, Daemon, Vercel Functions, managed queue, container or API was used.',
      'Synthetic queue state is an isolated JSON file and effects are fake records; no external side effects.',
      'Real local child processes received SIGTERM, but signal timing/rollout/delivery semantics of Vercel were not measured.',
      'No performance/cost/availability benchmark; 4 illustrative cases are not a statistical reliability study.'
    ]
  };
  console.log(JSON.stringify(receipt, null, 2));
}

if (process.argv[2] === '--worker') {
  worker(process.argv[3], process.argv[4]);
} else if (process.argv.length === 2) {
  experiment().catch((error) => {
    console.error(`SYNTHETIC_L1_FAIL: ${error.message}`);
    process.exitCode = 1;
  });
} else {
  throw Error('Usage: node queue-interruption-l1.mjs');
}