Vercel Daemons può sostituire un Worker su VPS? Limiti, SIGTERM e configurazione
Se hai un Worker su VPS che controlla continuamente una coda, prima o poi viene spontaneo chiedersi perché mantenere un intero server. I job arrivano da una coda esterna e i risultati finiscono in un database: Vercel Daemons sembra una buona alternativa, con container persistenti e un numero definito di istanze.
Ma questo non significa che il VPS sia diventato superfluo. Dopo aver confrontato l’annuncio di Vercel, i limiti dei Daemons e la guida operativa, il confine è chiaro: un Worker pull-based con stato esterno e retry sicuri è un candidato sensato; un servizio che richiede porte in ingresso, file locali persistenti o un ambiente CI completo non si trasferisce così com’è.
Il 15 ottobre 2026 Vercel ha annunciato Python backend GA, Container Functions e Daemons Public Beta. Qui valuto i limiti della piattaforma attraverso documentazione e JSON Schema ufficiali, affiancando un piccolo esperimento locale Node.js con veri segnali `SIGTERM` per osservare i rischi dell’ordine tra conferma del job e scrittura degli effetti. Un test locale non è una misura di Daemons in produzione.
Rispondere a HTTP e prelevare job sono modelli diversi
Per API FastAPI, Flask o Django, le Python Functions ora GA funzionano su richieste. Per servizi HTTP con immagini OCI personalizzate o dipendenze di sistema, le Container Functions (Beta) possono essere adatte e scalano in base al traffico. I Daemons sono invece processi longevi in numero fisso, che prelevano i job da code o database. La panoramica ufficiale chiarisce che non accettano connessioni HTTP/TCP in ingresso.
| Carico esistente | Modello più adatto | Da verificare prima della migrazione |
|---|---|---|
| API FastAPI / Flask pubblica | Python Functions (GA) | Durata richiesta, dipendenze, connessioni |
| Servizio OCI che riceve HTTP | Container Functions (Beta) | Scaling su richieste e fatturazione Functions |
| Consumatore di coda con DB esterno | Daemons (Public Beta) | Istanze fisse, stato esterno, retry e idempotenza |
| Servizio con file o cache locali durevoli | VPS o host con volume persistente | I Daemons non hanno disco locale persistente |
| Listener TCP in ingresso | VPS o hosting TCP compatibile | Nessuna porta in ingresso per Daemons |
Anche i GitHub Self-hosted Runner richiedono cautela. Un Runner preleva i job, ma questi possono esigere build di container, cartelle di lavoro, cache e privilegi sensibili di deployment. Usare entrambi un modello pull non garantisce compatibilità di filesystem, isolamento o permessi. Prima di migrare bisogna controllare ogni workflow reale.
10 vCPU-hours Hobby al mese non sono un Worker gratuito 24/7
Nei limiti della Beta, Hobby dispone di 10 vCPU-hours al mese per team. Un’istanza da 1 vCPU sempre in esecuzione consuma la quota in circa dieci ore. Raggiunto il limite, i Daemons del team vengono fermati fino al mese successivo o a un upgrade.
L’unità è numero di vCPU allocate × ore di esecuzione, non percentuale d’uso della CPU. Anche un Worker che aspetta quasi sempre nuovi messaggi non diventa una macchina gratuita sempre accesa. Non bisogna applicare automaticamente ai Daemons il modello Active CPU delle Container Functions basate su richiesta.
Il limite di risorse simultanee predefinito per Pro è 16 vCPU e 32 GiB di RAM. Non si tratta di un prezzo mensile. Un confronto corretto con il VPS considera risorse allocate, ore di funzionamento, traffico e code, oltre all’impegno di manutenzione.
Nella Beta mancano anche Preview Deployments, autoscaling, dischi locali persistenti e l’avvio diretto tramite `vercel dev`. I Daemons partono con i deploy di produzione; in locale devi eseguire il container da solo. Se il servizio dipende da ambienti Preview o cache persistenti, questi vincoli possono essere più importanti della modifica del codice. La pagina dei limiti cita anche restrizioni sulla root del progetto in sottocartelle.
SIGTERM e il momento in cui confermare un job
Secondo la guida di gestione, durante un deploy le versioni vecchie e nuove dei Daemons possono coesistere brevemente. Il processo precedente riceve `SIGTERM` e ha per impostazione predefinita dieci secondi per terminare. La difficoltà vera è gestire un job interrotto a metà.
Immagina un Worker che aggiorna un ordine nel database e poi si ferma prima di inviare Ack alla coda. Una nuova consegna può ripetere lo stesso effetto. Se invece conferma prima dell’aggiornamento, un’interruzione può lasciare il messaggio segnato come completato, pur senza lavoro eseguito.
Per mostrare la differenza ho eseguito un piccolo modello di coda sintetica su Node.js v22.16.0 e Linux x64. Lo stato del job e gli effetti fittizi sono registrati in JSON; veri processi figli di Node ricevono `SIGTERM` in tre scenari di interruzione. L’obiettivo non è misurare la disponibilità di Vercel, ma confrontare l’ordine delle operazioni.
| Scenario sintetico locale | Tentativi | Effetti registrati | Ack finale |
|---|---|---|---|
| Scrittura dell’effetto prima di Ack | 1 | 1 | Sì |
| Interruzione dopo l’effetto, retry con deduplicazione per ID | 2 | 1 | Sì |
| Interruzione dopo l’effetto, retry senza deduplicazione | 2 | 2 | Sì |
| Ack prima dell’effetto e successiva interruzione | 1 | 0 | Sì |
Tutti e quattro i casi hanno prodotto l’osservazione prevista. I due controlli negativi mostrano il punto: senza deduplicazione un effetto viene eseguito due volte; un Ack prematuro può far perdere del tutto il lavoro.
La logica essenziale usa poche righe. Qui `effects` è una lista di record sintetici:
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';
}
Con il controllo su `jobId`, il secondo tentativo riconosce un effetto già presente; togliendolo aggiunge un duplicato. Nel caso di Ack anticipato, il Worker che riparte legge `acked: true` e salta il job, anche quando nessun effetto è stato registrato.
Una coda reale aggiunge visibility timeout, transazioni, garanzie di consegna e nuove consegne dei messaggi. Questo semplice modello JSON non li riproduce. Prima della migrazione servono chiavi di business univoche o scritture idempotenti e prove di retry con la coda realmente adottata.
Il JSON è semplice; conta ciò che la piattaforma garantisce
La documentazione di configurazione prevede un Worker in una regione come nell’esempio. La directory `worker/` deve contenere anche Dockerfile e codice di consumo della coda:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"daemons": {
"worker": {
"root": "worker/",
"entrypoint": "Dockerfile",
"instances": { "iad1": 1 },
"resources": { "requests": { "memory": "2Gi" } }
}
}
}
`instances` indica il numero fisso di istanze per regione; `iad1` identifica la regione Vercel di Washington. Non abilita l’autoscaling.
Lo schema JSON del progetto accetta ancora il vecchio campo `replicas`, ma lo contrassegna come deprecated. Se compaiono entrambi, prevale `instances`. La documentazione aggiornata di configurazione e gestione usa `instances`: è la scelta naturale nei progetti nuovi.
Il periodo di grazia `SIGTERM` documentato è di dieci secondi per impostazione predefinita; lo Schema espone anche `terminationGracePeriodSeconds`. Aumentare l’attesa non elimina effetti duplicati o una conferma nell’ordine sbagliato. Occorre prima rendere sicuri interruzione e retry, poi verificare il parametro su un deploy reale.
Per Worker leggeri che leggono da una coda esterna, persistono lo stato fuori dal container e accettano un numero fisso di istanze, proverei Daemons inizialmente su attività non critiche. Terrei invece il VPS per workspace persistenti, privilegi CI complessi, porte in ingresso o ambienti Preview affidabili. Spostare un Runner non cancella la separazione di sicurezza tra codice non attendibile e segreti di deployment.
Per approfondire il lato Runner puoi leggere la mia pipeline ChatGPT Web + GitHub Self-hosted Runner, oppure la prova di PanelAlpha Engine su VPS per un diverso approccio all’hosting autogestito.
Per riprodurre i quattro scenari, salva lo script completo come `queue-interruption-l1.mjs` ed esegui `node queue-interruption-l1.mjs` con Node.js 20 o superiore. L’output JSON mostra i quattro risultati.
Apri il codice completo del test Node.js sintetico
/**
* 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');
}