Vercel Daemons 能取代 VPS 常駐 Worker 嗎?配額、設定與中斷處理
如果 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 對外 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 配額給 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 平台的重新啟動時間。
| 本機合成情境 | 處理嘗試次數 | 記錄的副作用次數 | 已確認 |
|---|---|---|---|
| 先執行副作用,再確認 | 1 | 1 | 是 |
| 執行後中斷,重送時依 Job ID 去重 | 2 | 1 | 是 |
| 執行後中斷,重送時不去重 | 2 | 2 | 是 |
| 先確認,執行前就中斷 | 1 | 0 | 是 |
四組對照均觀察到預期結果。最重要的是兩個反例:不去重就會重複執行;過早確認則可能直接遺失工作。
核心的去重邏輯只有幾行,`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');
}