# Vercel Daemons 能取代 VPS 常駐 Worker 嗎？配額、設定與中斷處理

> Canonical: https://vxi.me/zh-tw/posts/vercel-daemons-vps-background-worker/
> Locale: zh-tw
> Published: 2026-10-16
> Updated: 2026-10-16
> Markdown: https://vxi.me/zh-tw/posts/vercel-daemons-vps-background-worker.md

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

但「可以長時間執行容器」不等於「可以直接撤掉 VPS」。我比對了 [Vercel 後端更新公告](https://vercel.com/blog/backends-on-vercel-python-containers-and-daemons)、[Daemons 限制](https://vercel.com/docs/daemons/limits)與[管理文件](https://vercel.com/docs/daemons/managing-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 概覽](https://vercel.com/docs/daemons)明確指出目前不能接收入站 HTTP/TCP 流量。

| 現有工作負載 | 較接近的運行方式 | 搬遷前要確認 |
| --- | --- | --- |
| FastAPI / Flask 對外 API | Python 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 配額](https://vercel.com/docs/daemons/limits)給 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 程式碼更麻煩。其他根目錄配置限制可參考[官方說明](https://vercel.com/docs/daemons/limits)。

## SIGTERM 之後，訊息要不要重新執行？

依 [Vercel 管理文件](https://vercel.com/docs/daemons/managing-daemons)，新版本部署時，新舊 Daemon 可能短暫同時運作；舊程序收到 \`SIGTERM\`，預設有十秒寬限時間。關鍵不只是能不能正常結束程序，而是退出時正在處理的那個 Job 會怎樣。

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

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

| 本機合成情境 | 處理嘗試次數 | 記錄的副作用次數 | 已確認 |
| --- | ---: | ---: | --- |
| 先執行副作用，再確認 | 1 | 1 | 是 |
| 執行後中斷，重送時依 Job ID 去重 | 2 | 1 | 是 |
| 執行後中斷，重送時**不去重** | 2 | **2** | 是 |
| **先確認**，執行前就中斷 | 1 | **0** | 是 |

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

核心的去重邏輯只有幾行，\`effects\` 是這個合成模型內的記錄：

```js
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 設定參考](https://vercel.com/docs/daemons/config-reference)，單一區域的 Worker 可採用以下設定。目錄 \`worker/\` 仍需要 Dockerfile 與實際消費佇列的程序。

```json
{
  "$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](https://openapi.vercel.sh/vercel.json)仍接受舊的 \`replicas\` 欄位，但已標記為 deprecated；若同時提供 \`instances\`，以 \`instances\` 為準。最新管理文件及設定參考都採用 \`instances\`，新專案直接使用即可。

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

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

想進一步了解 Runner 的部署鏈，可以看 [ChatGPT Web + GitHub Self-hosted Runner](/posts/chatgpt-web-github-self-hosted-runner/)；若偏向自建 VPS 應用託管控制面，也可參考 [PanelAlpha Engine 的實際部署記錄](/posts/panelalpha-engine-vps-hosting-control-plane/)。

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

<details>
<summary>展開：完整 Node.js 合成 Worker 測試程式</summary>

```js
/**
 * 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');
}
```


</details>
