如果 VPS 上有一個長時間輪詢佇列的 Worker,你可能也想過:工作從外部佇列取得,處理結果寫回資料庫,為什麼還得為它維護一台常駐伺服器?Vercel 新推出的 Daemons 看起來正好對應這種需求:部署容器、指定執行個體數量,讓平台維持程序運作。

但「可以長時間執行容器」不等於「可以直接撤掉 VPS」。我比對了 Vercel 後端更新公告、Daemons 限制與管理文件,結論是:不接受外部連入、狀態儲存在外部,而且可以安全重試的拉取式 Worker,值得評估 Daemons;需要持久化本機檔案、監聽連接埠或完整 CI 執行環境的程序,不能直接平移。

2026 年 10 月 15 日,Vercel 同時發布 Python 後端 GA、容器化 Functions 與 Daemons Public Beta。以下依當天公告、官方文件及 JSON Schema 判斷架構限制,另外用本機 Node.js 子程序的 `SIGTERM` 對照,觀察工作確認與重新派送的取捨。平台行為以文件為準;本機實驗只展示確認順序的機制。

接收 HTTP 請求與主動拉取工作,是不同的運行模型

FastAPI、Flask、Django 這類對外 API,可以選擇已正式推出的 Python Functions。如果需要特殊系統函式庫或自己的 OCI 映像檔,容器化 Functions(Beta)處理的是傳入請求,並依流量擴縮。Daemons 則不同:它是維持固定數量的長駐程序,主動向佇列或資料庫取得工作。官方 Daemons 概覽明確指出目前不能接收入站 HTTP/TCP 流量。

現有工作負載較接近的運行方式搬遷前要確認
FastAPI / Flask 對外 APIPython Functions(GA)請求時間、相依套件、對外連線
接收 HTTP 的自訂 OCI 服務Container Functions(Beta)請求驅動擴縮與 Function 計費
消費佇列並寫入外部資料庫Daemons(Public Beta)固定執行個體、外部狀態、重試與冪等性
依賴持久化本機檔案或快取VPS 或支援持久化儲存的主機Daemons 目前沒有持久化本機磁碟
必須監聽入站 TCP 的服務VPS 或合適的 TCP 托管服務Daemons 不開放入站連接埠

GitHub Self-hosted Runner 也是一個容易誤判的例子。Runner 同樣會主動向 GitHub 取得 Job,但工作可能需要建立容器、保留工作目錄、使用快取,甚至持有部署憑證。同樣使用輪詢,不代表檔案系統、權限及執行隔離就相容。 是否適合遷移,還是得逐條看實際 Workflow 的需求。

Hobby 每月 10 vCPU-hours,不是免費全天候常駐

官方 Beta 配額給 Hobby 每個團隊每月 10 vCPU-hours。一個 1 vCPU 的執行個體持續運作,大約十小時便會耗盡額度。達到上限後,團隊的 Daemons 會停止,等下個月份或升級方案才能恢復。

單位是 配置的 vCPU 數量 × 運行時間,不是 CPU 使用率。即使 Worker 大部分時間都在等佇列訊息,也不代表它可以免費連續跑一整個月。Vercel 對請求驅動的 Container Functions 有 Active CPU 計費說明,但不能直接套用到 Daemons。

Pro 預設的同時使用上限為 16 vCPU、32 GiB 記憶體,這不是實際每月費用。比較 VPS 與 Daemons,至少得分別估算常駐資源、執行時數、網路和佇列費用,以及維運成本。

此外,Daemons 的 Beta 目前不支援 Preview Deployments、自動擴縮、持久化本機磁碟,也不會由 `vercel dev` 直接啟動。它隨正式部署運行,本機開發需自行啟動容器。如果既有流程依賴預覽環境或檔案快取,這些限制可能比改寫 Worker 程式碼更麻煩。其他根目錄配置限制可參考官方說明。

SIGTERM 之後,訊息要不要重新執行?

依 Vercel 管理文件,新版本部署時,新舊 Daemon 可能短暫同時運作;舊程序收到 `SIGTERM`,預設有十秒寬限時間。關鍵不只是能不能正常結束程序,而是退出時正在處理的那個 Job 會怎樣。

例如 Worker 已將訂單更新寫入資料庫,卻在向佇列確認之前被中止。如果訊息再次派送,沒有去重機制就可能重複產生副作用。反過來,若先確認、後寫入,程序中斷就可能留下「訊息已完成,實際工作卻沒做」的狀態。

我用 Node.js v22.16.0、Linux x64 做了一組本機合成佇列測試:JSON 檔案記錄假工作的確認狀態與模擬副作用,Node 子程序執行工作;三個中斷情境都實際收到 `SIGTERM`。這裡測的是執行與 Ack 的順序,不是 Vercel 平台的重新啟動時間。

本機合成情境處理嘗試次數記錄的副作用次數已確認
先執行副作用,再確認11是
執行後中斷,重送時依 Job ID 去重21是
執行後中斷,重送時不去重22是
先確認,執行前就中斷10是

四組對照均觀察到預期結果。最重要的是兩個反例:不去重就會重複執行;過早確認則可能直接遺失工作。

核心的去重邏輯只有幾行,`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` 去重時,第二次處理會發現記錄已存在;取消檢查,就會再新增一次副作用。另一個案例中,`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` 是 Vercel 的華盛頓區域 ID,並不代表自動擴縮。

專案 JSON Schema仍接受舊的 `replicas` 欄位,但已標記為 deprecated;若同時提供 `instances`,以 `instances` 為準。最新管理文件及設定參考都採用 `instances`,新專案直接使用即可。

`SIGTERM` 預設的十秒寬限時間之外,Schema 還有 `terminationGracePeriodSeconds` 欄位。增加等待時間不能修復重複副作用或錯誤的 Ack 順序;應先讓工作能安全中斷與重試,再於實際部署確認參數效果。

如果 Worker 只拉取外部佇列、業務資料儲存在外部,又能接受固定實例數,我會先拿非關鍵任務試 Daemons,確認部署切換、重新派送及帳單。需要持久工作目錄、複雜 CI 權限、入站連線或穩定 Preview 環境的服務,則仍會保留 VPS。

想進一步了解 Runner 的部署鏈,可以看 ChatGPT Web + GitHub Self-hosted Runner;若偏向自建 VPS 應用託管控制面,也可參考 PanelAlpha Engine 的實際部署記錄。

要重現四組本機對照,將以下程式儲存為 `queue-interruption-l1.mjs`,使用 Node.js 20 以上執行 `node queue-interruption-l1.mjs`,即可輸出完整結果。

展開:完整 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');
}