Cron NestJS no Kubernetes: CronJob em vez de cron em todo pod
O @nestjs/schedule roda cron dentro do processo Node. No Kubernetes, um Deployment com replicas: 3 são três schedulers independentes. À meia-noite cada pod dispara o mesmo @Cron() — três cobranças, três e-mails, três conciliações. Com HPA, o número muda sozinho; o bug escala junto.
Este estudo foca uma alternativa alinhada ao cluster: tirar o cron dos pods da API e disparar o trabalho com CronJob do Kubernetes, usando o application context standalone do Nest — DI sem listen() e sem ScheduleModule em toda réplica.
Monorepo só amplia o erro (pacote compartilhado com @Cron()), mas a causa em produção quase sempre é pod replicado, não TypeScript.
O que o Nest faz quando o pod sobe
Pelo guia de task scheduling:
ScheduleModule.forRoot()uma vez por aplicação Nest.forRoot()extra re-registra todo@Cron(),@Interval()e@Timeout()naquele processo.- Jobs entram no
onApplicationBootstrap, com o DI pronto. - Cada processo vivo é um scheduler completo. O Kubernetes não deduplica isso.
apiVersion: apps/v1
kind: Deployment
metadata:
name: billing-api
spec:
replicas: 3
template:
spec:
containers:
- name: api
image: billing-api:latest
# Mesma imagem = mesmo AppModule = mesmo @Cron() em todo podreplicas: 1 na API mata o sintoma e mata HA. disabled: process.env.IS_PRIMARY em pod “sortudo” quebra em restart e scale. A saída é separar deploy: API só HTTP; outro artefato dono do relógio.
A alternativa: CronJob no cluster + context Nest one-shot
Ideia: o Deployment da API não importa ScheduleModule. A lib expõe serviços (BillingService.chargeDueInvoices()). Um CronJob Kubernetes cria um pod novo no horário; o pod sobe um módulo Nest enxuto, executa uma vez, termina.
Com concurrencyPolicy: Forbid, você evita duas execuções sobrepostas do mesmo schedule.
apiVersion: batch/v1
kind: CronJob
metadata:
name: billing-charge-due
spec:
schedule: "0 1 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 3600
template:
spec:
restartPolicy: OnFailure
containers:
- name: job
image: billing-jobs:latest
command: ["node", "dist/charge-due.main.js"]
envFrom:
- secretRef:
name: billing-dbEntrypoint no padrão da doc standalone — bootstrap, trabalho, close:
// apps/billing-jobs/src/charge-due.main.ts
import { NestFactory } from '@nestjs/core';
import { ChargeDueModule } from './charge-due.module';
import { BillingService } from '@org/billing';
async function bootstrap() {
const app = await NestFactory.createApplicationContext(ChargeDueModule, {
logger: ['error', 'warn', 'log'],
});
try {
await app.get(BillingService).chargeDueInvoices();
} finally {
await app.close();
}
}
bootstrap().catch((err) => {
console.error(err);
process.exit(1);
});@Module({
imports: [BillingModule],
})
export class ChargeDueModule {}Por que este é o foco do estudo
| Preocupação | @Cron no Deployment da API | CronJob K8s + context Nest |
|-------------|----------------------------|----------------------------|
| N réplicas da API | N execuções por tick | 1 pod Job por tick |
| HPA na API | Mais duplicação | Igual |
| Falha | Perdida no log da API | Status do Job / backoff no kubectl |
| DI Nest | Sim | Sim, por pod de Job |
| Timer eterno na API | Sim | Nenhum na API |
A imagem pode ser o mesmo build do monorepo com command diferente, ou target billing-jobs enxuto. O essencial: container da API nunca chama ScheduleModule.forRoot().
Regra do monorepo (obrigatória)
@Cron() fora dos pacotes que a API importa:
packages/billing/ → BillingService, BillingModule (sem schedule)
apps/api/ → HTTP, SEM ScheduleModule
apps/billing-jobs/ → charge-due.main.ts + ChargeDueModuleSe apps/api ainda importar módulo com @Cron(), voltam N timers com N pods — mesmo com CronJob no cluster.
Quando ainda usar @nestjs/schedule
Tick sub-minuto, muitos schedules dinâmicos em SchedulerRegistry, ou paridade local: Deployment billing-scheduler com replicas: 1 e createApplicationContext long-lived (sem app.close()). Chart separado da API — não colocar scheduler no mesmo Deployment escalável.
Para batch diário/horário, CronJob + context one-shot costuma ser mais simples e fala a língua do Kubernetes.
Notas da doc Nest
waitForCompletionevita overlap só num processo; não corrige vários pods.- Jobs dinâmicos no
SchedulerRegistrysão voláteis; multiplicam por pod scheduler. - Um
forRoot()por app — não bootstraparAppModuleinteiro dentro de script filho com schedule.
Checklist
1. Deployment da API: escala livre, sem ScheduleModule.
2. Libs compartilhadas: sem @Cron() no grafo da API.
3. Trabalho agendado: CronJob → módulo Nest enxuto → app.get(Service).method() → app.close().
4. concurrencyPolicy: Forbid e activeDeadlineSeconds no Job.
5. Deployment scheduler replicas: 1 só se CronJob não der granularidade.
A doc Nest explica cron no processo. Kubernetes roda muitas cópias desse processo. A alternativa não é decorator inteligente — é schedule no control plane e Nest como container DI curto por execução, não timer escondido em todo pod de API.