如果 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 对外 APIPython 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。对比的不是处理速度,而是副作用执行与任务确认的先后顺序。

实际保存的四组运行结果是:

离线合成场景处理尝试次数记录中的副作用次数最终确认状态
正常:先执行再确认11已确认
执行后中断,重投递时按任务 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 时需要追问的任务语义:进程退出后,消息是否重新出现?重复执行会不会造成实际业务副作用?上面的本地模型只演示了确认顺序,真实队列还涉及可见性超时、事务边界和重投递策略。生产环境应使用业务唯一键或幂等写入,并在实际队列上验证这些行为。

配置并不难,难的是知道它能保证什么

参考 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');
}