Vercel Daemons 能替代 VPS 常驻 Worker 吗?配额、配置与任务中断
如果 VPS 上跑着一个长期轮询队列的 Worker,你可能也会动过这样的念头:任务本来就从外部队列取,结果也写回数据库,为什么还要自己守着一台服务器?Vercel 新发布的 Daemons 看起来正好解决了这件事——部署一个容器,指定实例数,平台负责把进程维持在运行状态。
但这还不能得出“VPS 可以撤了”。我查了 Vercel 的后端更新公告、Daemons 限制和实例管理文档。我的判断是:无入站请求、任务状态在外部、能够安全重试的拉取式 Worker,可以把 Daemons 放进候选名单;需要持久本地文件、直接监听端口或完整 CI 执行环境的进程,不宜把它当成 VPS 的直接替身。
2026 年 10 月 15 日,Vercel 同时发布了 Python 后端 GA、容器化 Functions 和 Daemons Public Beta。本文基于当天的官方公告、Daemons 文档和 JSON Schema 判断运行边界,另用一组本地 Node.js SIGTERM 子进程对照解释任务中断后的确认与重投递风险。文档说明平台提供什么,本地实验说明这些任务处理顺序可能产生什么后果。
同样是后端,接请求与主动拉任务是两回事
要做 FastAPI、Flask、Django 这样的 HTTP API,Python on Vercel 已经 GA,仍走请求驱动的 Functions 体验。如果有系统依赖或者自己的 OCI 镜像,可以考虑容器化 Functions:它们需要处理传入请求,按流量扩缩。这和 Daemons 长期运行、自己向队列或数据库索取任务的方式不同。Vercel 官方概览明确把 Daemons 定义为拉取式工作负载,当前不能接收入站 HTTP/TCP 流量。
| 手里的任务 | 更接近的运行方式 | 搬迁前要核对 |
|---|---|---|
| FastAPI / Flask 对外 API | Python Functions(GA) | 请求时长、依赖和外部连接 |
| 接收 HTTP 的自定义 OCI 服务 | Container Functions(Beta) | 请求触发、扩缩容和 Function 计费 |
| 从队列拉消息、处理后写数据库 | Daemons(Public Beta) | 固定实例、外部状态、重试和任务幂等性 |
| 本地缓存、文件必须持久保留的后台服务 | VPS 或提供持久卷的其他运行环境 | Daemons 当前没有持久本地存储 |
| 需要直接监听 TCP 的服务 | VPS / 合适的 HTTP 或 TCP 托管方案 | Daemons 不提供入站端口 |
这也是我不建议把 GitHub Self-hosted Runner 一概归入“适合搬到 Daemons”的原因。Runner 虽然也会向 GitHub 主动领取任务,但具体 job 可能要构建容器、访问本地工作区、保留缓存,甚至拥有敏感的部署权限。拉取式连接形态相似,不等于执行权限、文件系统与作业隔离已经兼容。 这些能力需要按实际 workflow 逐项检查,官方支持运行某类 Worker,也不等于替某一套现有 Runner 完成了迁移测试。
每月 10 vCPU-hours,先别把它理解成免费常驻
Daemons 的 Beta 配额表里,Hobby 是每个团队每月 10 vCPU-hours。文档还写得很具体:一个 1 vCPU 实例只要持续运行,大约十小时就会用完当月额度;到限额时平台会停止该团队的 Daemons,等下一个月或升级计划后恢复。
这里的单位是 vCPU 数 × 运行小时数。即使 Worker 大部分时间只是在等队列,没有持续吃满 CPU,也不能把它当成一个免费在线整月的进程。Vercel 对请求驱动的 Container Functions 介绍过 Active CPU 计费,但不能未经核对就把那套“等待 I/O 不计活动 CPU”的规则套到 Daemons 上。
Pro 的默认并发资源上限为 16 vCPU、32 GiB 内存,与实际付费单价是两回事。比较 VPS 和 Daemons 的成本时,要把持续占用的资源、运行时间、队列与网络费用分别计算;只看每月套餐价格很容易算错。
除此之外,Daemons 还没有 Preview Deployment、自动扩缩容、持久本地盘,也不会被 vercel dev 直接启动。它当前只随生产部署运行;本地开发需要自己跑容器。把一个旧的后台服务直接搬过去,最先卡住的可能不是代码,而是你平常依赖的预览验证、落盘缓存和手工伸缩操作。完整限制还包括项目根目录设在子文件夹时的支持边界。
Worker 收到 SIGTERM,重启不是最麻烦的环节
官方管理文档说,发布新版本时,新旧两个版本可能短暂并行;旧实例会收到 SIGTERM,默认有十秒处理当前工作的宽限时间。既然实例可能退出,队列消费者就要考虑同一条任务再次出现时会发生什么。
假设一个 Worker 已经把“订单处理成功”写进业务数据库,但还没来得及向队列确认,进程就被终止。消息之后如果再次交给消费者处理,单纯把同一条数据库更新再执行一次可能产生重复副作用。反过来,如果先确认队列消息,再写业务结果,中途退出就可能留下“消息已处理,实际操作没发生”的空洞。
我用 Node.js v22.16.0、Linux x64 做了一个小型本地队列模型:JSON 文件记录任务状态和模拟副作用,Node 子进程负责执行,三个中断场景都实际收到了 SIGTERM。对比的不是处理速度,而是副作用执行与任务确认的先后顺序。
实际保存的四组运行结果是:
| 离线合成场景 | 处理尝试次数 | 记录中的副作用次数 | 最终确认状态 |
|---|---|---|---|
| 正常:先执行再确认 | 1 | 1 | 已确认 |
| 执行后中断,重投递时按任务 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 时需要追问的任务语义:进程退出后,消息是否重新出现?重复执行会不会造成实际业务副作用?上面的本地模型只演示了确认顺序,真实队列还涉及可见性超时、事务边界和重投递策略。生产环境应使用业务唯一键或幂等写入,并在实际队列上验证这些行为。
配置并不难,难的是知道它能保证什么
参考 Daemon 配置文档,一个单区域 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。它控制常驻 Worker 的实例数,而不是自动扩缩容阈值。
配置还有个容易踩到的兼容细节:当前 JSON Schema仍接受旧字段 replicas,但已标记为 deprecated;如果同时设置 instances,以后者为准。最新配置参考和管理文档都使用 instances,新项目直接采用这个字段即可。
官方文档给出的 SIGTERM 默认宽限时间是十秒,JSON Schema 还列出 terminationGracePeriodSeconds 配置项。延长等待时间可以给任务留出收尾窗口,却不能消除重复处理或错误确认的风险。先保证任务可中断、可重试,再在实际部署中验证宽限时间的效果。
对只消费外部队列、业务状态全部在数据库、可以容忍固定实例数量的轻量 Worker,我会考虑在隔离的非关键任务上试 Daemons,然后观察真实队列重投递、发布切换和使用账单。对需要持久工作目录、复杂 CI 作业权限、入站连接或稳定预览环境的服务,我暂时仍会保留 VPS。尤其是自己的生产 Runner,换托管平台不会自动消除不受信任代码与部署凭据的安全边界。
如果你关心 Runner 这一侧的取舍,可以接着看 ChatGPT Web + GitHub + Self-hosted Runner 的实际发布链;如果更倾向于在 VPS 上自建应用托管控制面,也可以参考之前的 PanelAlpha Engine VPS 安装实测。
想复现上述四组对照,可以展开下面的完整脚本,保存为 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');
}