Blog
NestJSKubernetesCronTypeScript

Cron NestJS no Kubernetes: CronJob em vez de cron em todo pod

·8 min de leitura

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.
Este manifesto multiplica execução:

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 pod

replicas: 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-db

Entrypoint 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 + ChargeDueModule

Se 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

  • waitForCompletion evita overlap só num processo; não corrige vários pods.
  • Jobs dinâmicos no SchedulerRegistry são voláteis; multiplicam por pod scheduler.
  • Um forRoot() por app — não bootstrapar AppModule inteiro 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.