# Vercel Daemons로 VPS 상주 Worker를 대체할 수 있을까? 한도·SIGTERM·설정

> Canonical: https://vxi.me/ko/posts/vercel-daemons-vps-background-worker/
> Locale: ko-kr
> Published: 2026-10-16
> Updated: 2026-10-16
> Markdown: https://vxi.me/ko/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)를 비교해 보니 기준은 분명했습니다. **외부 상태 저장소를 쓰고 안전하게 재시도할 수 있는 pull 기반 Worker는 검토할 만하지만, 인바운드 포트·영구 로컬 파일·완전한 CI 실행 환경이 필요한 서비스는 그대로 옮길 수 없습니다.**

2026년 10월 15일 Vercel은 Python 백엔드 GA, 컨테이너 기반 Functions, Daemons Public Beta를 함께 발표했습니다. 여기서는 공식 문서와 JSON Schema로 각 실행 모델의 경계를 판단하고, 별도의 로컬 Node.js \`SIGTERM\` 실험으로 작업의 실행과 확인(Ack) 순서에서 어떤 문제가 생기는지 살펴봅니다. 로컬 실험이 곧 Vercel 운영 환경의 검증을 뜻하지는 않습니다.

## HTTP 요청을 처리하는 함수와 작업을 가져오는 Worker는 다르다

FastAPI·Flask·Django로 만든 HTTP API라면 GA가 된 Python Functions가 자연스러운 선택입니다. 자체 OCI 이미지나 시스템 라이브러리가 필요하고 HTTP 요청을 받는다면 Container Functions(Beta)를 생각할 수 있습니다. 이 둘은 요청을 중심으로 실행되고 트래픽에 맞춰 확장됩니다.

Daemons는 반대로 고정된 수의 장기 실행 프로세스가 큐나 데이터베이스에서 작업을 직접 가져옵니다. [공식 개요](https://vercel.com/docs/daemons)에 따르면 현재 인바운드 HTTP/TCP 연결을 받을 수 없습니다.

| 기존 작업 | 적합한 실행 모델 | 이동 전에 확인할 사항 |
| --- | --- | --- |
| 외부 FastAPI / Flask API | Python Functions(GA) | 요청 시간, 종속성, 외부 연결 |
| HTTP 요청을 받는 자체 OCI 서비스 | Container Functions(Beta) | 요청 기반 확장과 Function 요금 |
| 큐를 읽어 외부 DB에 결과 저장 | Daemons(Public Beta) | 고정 인스턴스, 외부 상태, 재시도·멱등성 |
| 로컬 파일이나 캐시를 유지해야 하는 서비스 | VPS 또는 영구 볼륨 지원 호스팅 | Daemons에는 영구 로컬 디스크가 없음 |
| 인바운드 TCP 리스너 | VPS 또는 별도 TCP 호스팅 | Daemons가 포트를 노출하지 않음 |

GitHub Self-hosted Runner도 바로 이전할 대상은 아닙니다. Runner 역시 GitHub에 작업을 요청하지만, 실제 Job에는 컨테이너 빌드, 작업 디렉터리, 캐시, 민감한 배포 권한이 필요할 수 있습니다. **연결 방식이 pull이라는 공통점만으로 파일 시스템과 권한 분리가 호환되는 것은 아닙니다.** 사용하는 Workflow별로 요구 사항을 확인해야 합니다.

## Hobby 월 10 vCPU-hours는 무료 상시 실행이 아니다

[Daemons Beta 할당량](https://vercel.com/docs/daemons/limits)에 따르면 Hobby는 팀당 월 **10 vCPU-hours**입니다. 1 vCPU 인스턴스 하나를 계속 실행하면 약 10시간 만에 월 사용량을 소진합니다. 한도에 이르면 팀의 Daemons가 중지되고 다음 달이나 플랜 업그레이드 이후 다시 실행할 수 있습니다.

계산 단위는 *할당한 vCPU 수 × 실행 시간*이지 실제 CPU 사용률이 아닙니다. 큐를 기다리는 시간이 길어도 무료 24시간 Worker로 생각하면 안 됩니다. 요청 중심의 Container Functions에서 쓰는 Active CPU 과금 설명을 Daemons에 그대로 적용하는 것도 근거가 없습니다.

Pro의 기본 동시 리소스 한도는 **16 vCPU, 32 GiB 메모리**입니다. 이는 월 요금이 아니라 동시 실행 제한입니다. VPS와 비용을 비교하려면 확보하는 리소스, 실행 시간, 네트워크·큐 요금과 유지보수 비용을 별도로 계산해야 합니다.

현재 Beta에서는 Preview Deployments, 자동 확장, 영구 로컬 디스크를 지원하지 않으며 \`vercel dev\`로 Daemon이 직접 시작되지도 않습니다. 정식 배포에서 실행되고 로컬 개발 중에는 컨테이너를 직접 띄워야 합니다. 기존 서비스가 미리보기 환경이나 디스크 기반 캐시에 의존한다면 코드보다 운영 흐름에서 문제가 먼저 생깁니다. 프로젝트의 중첩 루트 디렉터리 제약도 [공식 제한 문서](https://vercel.com/docs/daemons/limits)를 확인하세요.

## SIGTERM 이후 작업을 다시 받으면?

[관리 문서](https://vercel.com/docs/daemons/managing-daemons)는 새 버전을 배포할 때 기존 버전과 잠시 함께 실행될 수 있으며, 기존 프로세스는 \`SIGTERM\`을 받고 기본 10초의 종료 유예 시간을 가진다고 설명합니다. 중요한 것은 프로세스 종료 자체보다 처리 중인 작업의 상태입니다.

예를 들어 Worker가 주문 처리를 데이터베이스에 저장한 뒤 큐에 Ack를 보내기 전에 종료됐다고 합시다. 메시지가 다시 전달되면 비즈니스 부작용이 중복될 수 있습니다. 반대로 먼저 Ack를 보내고 나서 DB 작업을 하려다가 종료되면 실제 처리 없이 작업만 완료된 것으로 남을 수 있습니다.

이를 보기 위해 **Node.js v22.16.0 / Linux x64**에서 작은 로컬 합성 큐 모델을 만들었습니다. JSON 파일은 가짜 작업의 상태와 부작용 기록을 저장하고, 실제 Node 자식 프로세스를 실행해 세 중단 경로에 \`SIGTERM\`을 보냈습니다. 처리량이나 Vercel 재시작 속도가 아니라 Ack 순서를 비교하는 실험입니다.

| 로컬 합성 시나리오 | 처리 시도 횟수 | 기록된 부작용 횟수 | 최종 Ack |
| --- | ---: | ---: | --- |
| 부작용 실행 후 확인 | 1 | 1 | 완료 |
| 실행 후 중단, 작업 ID로 중복 제거 후 재시도 | 2 | 1 | 완료 |
| 실행 후 중단, 중복 제거 **없이** 재시도 | 2 | **2** | 완료 |
| 부작용보다 **먼저 Ack**, 이후 중단 | 1 | **0** | 완료 |

네 가지 시나리오 모두 예상했던 결과가 관찰됐습니다. 특히 실패 대조군 두 개가 핵심입니다. **중복 제거가 없으면 같은 작업을 두 번 실행하고, 너무 일찍 Ack하면 작업을 아예 잃을 수 있습니다.**

중복 제거의 핵심 코드는 다음과 같습니다. \`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\`가 이미 기록돼 있으면 두 번째 시도에서는 부작용을 다시 추가하지 않습니다. 조건을 제거하면 같은 기록이 두 개 생깁니다. 조기 Ack에서는 \`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\`은 워싱턴 리전 ID입니다. 자동 확장 설정이 아닙니다.

[프로젝트 JSON Schema](https://openapi.vercel.sh/vercel.json)는 구형 \`replicas\`를 아직 허용하지만 deprecated로 표시합니다. 두 값을 함께 지정하면 \`instances\`가 우선합니다. 최신 관리 및 설정 문서도 \`instances\`를 사용하므로 새 프로젝트에서는 그 필드를 쓰면 됩니다.

문서상의 기본 \`SIGTERM\` 유예 시간은 10초이고 Schema에 \`terminationGracePeriodSeconds\` 옵션도 있습니다. 유예 시간을 늘려도 중복 처리나 잘못된 Ack 순서를 해결하지는 못합니다. 안전하게 중단·재시도할 수 있도록 만들고, 실제 배포에서 해당 옵션이 의도대로 동작하는지 검증해야 합니다.

외부 큐에서 작업을 받고 영구 상태는 외부 DB에 저장하며 인스턴스 수가 고정돼도 괜찮은 경량 Worker라면, 비핵심 작업부터 Daemons를 시험해 볼 만합니다. 영구 작업 공간, 복잡한 CI 권한, 인바운드 연결, 안정적인 Preview 환경이 필요하다면 당분간 VPS를 유지하겠습니다. Runner를 다른 플랫폼으로 옮겨도 신뢰할 수 없는 Job과 배포 비밀 정보 사이의 보안 경계는 그대로 남습니다.

관련된 운영 사례로 [ChatGPT Web + GitHub Self-hosted Runner](/posts/chatgpt-web-github-self-hosted-runner/)와 [PanelAlpha Engine VPS 호스팅 테스트](/posts/panelalpha-engine-vps-hosting-control-plane/)를 참고할 수 있습니다.

아래 전체 스크립트를 \`queue-interruption-l1.mjs\`로 저장한 뒤 Node.js 20 이상에서 \`node queue-interruption-l1.mjs\`을 실행하면 네 가지 결과가 JSON으로 출력됩니다.

<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>
