# Vercel Daemons 能替代 VPS 常驻 Worker 吗？配额、配置与任务中断

> Canonical: https://vxi.me/posts/vercel-daemons-vps-background-worker/
> Locale: zh-cn
> Published: 2026-10-16
> Updated: 2026-10-16
> Markdown: https://vxi.me/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 执行环境的进程，不宜把它当成 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 官方概览](https://vercel.com/docs/daemons)明确把 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 配额表](https://vercel.com/docs/daemons/limits)里，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` 直接启动。它当前只随生产部署运行；本地开发需要自己跑容器。把一个旧的后台服务直接搬过去，最先卡住的可能不是代码，而是你平常依赖的预览验证、落盘缓存和手工伸缩操作。[完整限制](https://vercel.com/docs/daemons/limits)还包括项目根目录设在子文件夹时的支持边界。

## Worker 收到 SIGTERM，重启不是最麻烦的环节

[官方管理文档](https://vercel.com/docs/daemons/managing-daemons)说，发布新版本时，新旧两个版本可能短暂并行；旧实例会收到 `SIGTERM`，默认有十秒处理当前工作的宽限时间。既然实例可能退出，队列消费者就要考虑同一条任务再次出现时会发生什么。

假设一个 Worker 已经把“订单处理成功”写进业务数据库，但还没来得及向队列确认，进程就被终止。消息之后如果再次交给消费者处理，单纯把同一条数据库更新再执行一次可能产生重复副作用。反过来，如果先确认队列消息，再写业务结果，中途退出就可能留下“消息已处理，实际操作没发生”的空洞。

我用 **Node.js v22.16.0、Linux x64** 做了一个小型本地队列模型：JSON 文件记录任务状态和模拟副作用，Node 子进程负责执行，三个中断场景都实际收到了 `SIGTERM`。对比的不是处理速度，而是**副作用执行与任务确认的先后顺序**。

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

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

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

参考 [Daemon 配置文档](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。它控制常驻 Worker 的实例数，而不是自动扩缩容阈值。

配置还有个容易踩到的兼容细节：[当前 JSON Schema](https://openapi.vercel.sh/vercel.json)仍接受旧字段 `replicas`，但已标记为 deprecated；如果同时设置 `instances`，以后者为准。最新配置参考和管理文档都使用 `instances`，新项目直接采用这个字段即可。

官方文档给出的 `SIGTERM` 默认宽限时间是十秒，JSON Schema 还列出 `terminationGracePeriodSeconds` 配置项。延长等待时间可以给任务留出收尾窗口，却不能消除重复处理或错误确认的风险。先保证任务可中断、可重试，再在实际部署中验证宽限时间的效果。

对只消费外部队列、业务状态全部在数据库、可以容忍固定实例数量的轻量 Worker，我会考虑在隔离的非关键任务上试 Daemons，然后观察真实队列重投递、发布切换和使用账单。对需要持久工作目录、复杂 CI 作业权限、入站连接或稳定预览环境的服务，我暂时仍会保留 VPS。尤其是自己的生产 Runner，换托管平台不会自动消除不受信任代码与部署凭据的安全边界。

如果你关心 Runner 这一侧的取舍，可以接着看 [ChatGPT Web + GitHub + Self-hosted Runner 的实际发布链](/posts/chatgpt-web-github-self-hosted-runner/)；如果更倾向于在 VPS 上自建应用托管控制面，也可以参考之前的 [PanelAlpha Engine VPS 安装实测](/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>
