Lab 27 — Agendamento sem a EC2 do cron
O problema, e a empresa que o tem
A Cadência é a mesma equipe de duas pessoas dos laboratórios anteriores. Desde antes do L01, uma t3.micro roda vinte e quatro horas por dia com um só propósito: hospedar cinco entradas de crontab herdadas de um servidor on-premises que ninguém mais lembra de ter existido. Nenhum dos cinco jobs leva mais que alguns minutos; a instância que os hospeda nunca para.
Os cinco jobs são: emitir a fatura mensal das trinta lojas, gerar o relatório diário de vendas, sincronizar estoque com o fornecedor a cada seis horas, limpar sessões expiradas a cada quinze minutos, e checar a saúde da integração com o parceiro de pagamento a cada cinco. Individualmente, cada um é trivial. Juntos, viraram uma instância que ninguém quer tocar — mexer no cron dá medo de derrubar os outros quatro junto com o que precisa de ajuste.
O evento que expôs o problema: a fatura de julho não saiu no dia 1º. Ninguém recebeu erro nenhum, porque não existe erro nenhum registrado em lugar nenhum — a instância tinha caído por manutenção automática da AWS às 8h58, dois minutos antes do job, e voltou às 9h04. O crontab disparou o job apenas na execução seguinte, um mês depois. O time financeiro só notou quando um cliente perguntou pela fatura.
O que este laboratório NÃO é
Não é sobre orquestrar passos dependentes — nenhum dos 5 jobs espera o resultado de outro. Quando um job passa a depender do anterior (ex.: só gerar a fatura depois de a sincronização de estoque confirmar), a peça certa é Step Functions, não mais agendamentos soltos. Isso fica fora do escopo aqui de propósito.
O que você vai conseguir fazer
Objetivos verificáveis: cada um se prova com um comando na seção de implantação, não com a sensação de ter entendido.
- Explicar por que a EC2 do cron falha em silêncio, e o que muda quando o agendamento vira um recurso monitorado.
- Escrever uma expressão cron e uma rate corretas para o EventBridge Scheduler, e justificar quando cada uma é mais clara que a outra.
- Declarar o fuso horário certo por agendamento, e explicar o que acontece com ele no horário de verão.
- Decidir quando uma janela de flexibilidade ajuda — porque espalha carga — e quando ela quebra um requisito de horário exato.
- Configurar retentativa com teto de tentativas e de idade do evento, e uma fila de mensagens não entregues por agendamento.
- Projetar uma chave de idempotência a partir do horário agendado, para que retentativa não duplique efeito.
- Migrar 5 agendamentos da EC2 para o Scheduler e desligar a instância, com prova de que nenhum job se perdeu na troca.
- Diagnosticar um agendamento que não dispara no horário esperado, distinguindo causa de fuso, de janela e de permissão.
O que a certificação cobra disto
| Conceito | Certificação | Como aparece aqui | O que dominar |
|---|---|---|---|
| Agendamento gerenciado vs. servidor dedicado | SOA-C02, SAA-C03 | EventBridge Scheduler substitui a EC2 do cron | por que "sempre ligado" custa mesmo com carga baixa, e o que substitui isso |
| Expressão cron vs. rate | SOA-C02, DVA-C02 | 5 jobs, cada expressão escolhida pelo motivo certo | cron quando o calendário importa; rate quando só o intervalo importa |
| Fuso horário e horário de verão | SOA-C02 | `ScheduleExpressionTimezone` declarado por agendamento | por que UTC fixo ou deslocamento calculado à mão quebra em algum cliente ou região |
| Janela de flexibilidade | SOA-C02 | `FlexibleTimeWindow` ligado só onde ajuda | o efeito colateral de ligar em todo agendamento, mesmo nos que exigem horário exato |
| Retentativa e DLQ do próprio agendamento | SOA-C02, DVA-C02 | `RetryPolicy` e `DeadLetterConfig` por alvo | que não é fire-and-forget, e como ler `EXHAUSTED_RETRY_CONDITION` para diagnosticar |
| EventBridge Scheduler vs. Rules clássicas | SOA-C02, SAP-C02 | migração de uma cota de 300 regras por bus para milhões de agendamentos | por que a Scheduler é o caminho recomendado hoje, e o que ela tem que a regra não tinha |
| Idempotência sob retentativa | DVA-C02 | chave derivada do horário agendado, gravada num ledger | por que retry sem idempotência duplica efeito, e onde a chave deve vir de fora do relógio local |
Onde isto costuma ser cobrado errado
A pergunta clássica dá uma expressão como `cron(0 9 1 * ? *)` sem fuso declarado e pede o comportamento em janeiro versus julho. Quem não sabe que a Scheduler aceita fuso IANA por agendamento assume UTC fixo e erra a resposta sobre "9h" — o padrão que este módulo trata como decisão explícita, não como suposição.
Requisitos, e como cada um muda o desenho
Requisito que não aparece numa linha de configuração é intenção. A coluna da direita é onde cada um deixou marca.
| Requisito | Valor declarado | O que ele decide no desenho |
|---|---|---|
| Zero servidor dedicado só para agendar | obrigatório | EventBridge Scheduler + Lambda, sem instância fixa — o item que fecha o laboratório |
| Cada job dispara no horário de negócio correto | fuso America/Sao_Paulo para os 2 jobs com calendário | `ScheduleExpressionTimezone` IANA por agendamento, nunca deslocamento fixo calculado à mão |
| Falha de execução vira evento observável | sem revisão manual de log | DLQ dedicada por agendamento + alarme de profundidade, não um alarme genérico compartilhado |
| Jobs que consultam sistema de terceiro não podem bater todos no mesmo segundo | estoque e futuros jobs de integração | `FlexibleTimeWindow` habilitado só nos agendamentos que toleram atraso de minutos |
| Ping de saúde precisa de cadência exata para medir SLA do parceiro | a cada 5 min, sem variação | `FlexibleTimeWindow` OFF nesse agendamento especificamente — o oposto do requisito anterior |
| Retentativa não pode duplicar efeito | nenhuma fatura em duplicidade | ledger de idempotência com chave derivada do horário agendado, não do relógio da execução |
| Escalar para dezenas de jobs no futuro sem reformular a arquitetura | não declarado por número, mas por intenção | um agendamento por recurso desde o início — o padrão da Scheduler, não das Rules clássicas |
Arquitetura mínima: a EC2 que virou dona do relógio
Este é o desenho que a Cadência tem hoje, e ele é legítimo como ponto de partida: roda de verdade, com uma instância e um arquivo de texto. O laboratório começa por nomear exatamente o que está ausente dele, porque é essa ausência — não uma configuração errada — que causa o incidente da fatura.
- → grava CSV da fatura ou do relatório, sem confirmação de leitura
- → ping e sincronização periódicos, sem retentativa automática
- Fora da AWS
- Compute
- Armazenamento
Este desenho existe de verdade em toda empresa que "só precisava rodar um script à noite". Ele publica CSV e chama parceiro, e é por isso que sobrevive anos sem revisão. Percorra os passos e repare no que NÃO está desenhado: nenhuma seta sai daqui para observabilidade. É esse vazio que o resto do módulo resolve.
- O crontab mora dentro da instância, invisível de fora. Cinco linhas de crontab não são um recurso da AWS: são texto dentro de um arquivo, dentro de um disco, dentro de uma instância. Nenhuma API externa sabe que esses cinco jobs existem, e é por isso que nada fora da instância pode alertar sobre eles.
- A instância cobra pelas 24 horas; os jobs usam minutos. O mais longo dos cinco jobs leva poucos minutos. A instância que os hospeda cobra por hora ligada, 720 horas por mês, independentemente de quanto trabalho real acontece dentro dela — o mesmo desperdício estrutural do Fargate ocioso do L21, agora em escala de uma máquina inteira dedicada só a isso.
- Se a instância cai na hora do job, o efeito simplesmente não acontece. Não há erro para ler, porque não há tentativa registrada em lugar nenhum. O único sintoma é o CSV que não apareceu no bucket — e alguém só nota isso quando o time financeiro pergunta pela fatura do mês.
- O parceiro só vê a chamada, ou a ausência dela. Do lado do parceiro de pagamento, um dia sem sincronização de estoque não parece diferente de um dia sem novidade — a ausência de chamada não distingue "nada mudou" de "o job não rodou". A Cadência descobre a diferença dias depois, quando o estoque exibido diverge do real.
- Nenhuma tentativa automática se o script falhar no meio. Um `curl` que expira, um `psql` que perde conexão: o script para onde parou, e o cron dispara a próxima execução na hora certa do dia seguinte, não agora. Não existe retentativa com espera crescente, porque não existe conceito de "tentativa" fora do que o próprio script decidir escrever no log local.
- O fuso horário é o do sistema operacional, decidido por quem criou a instância. Cinco linhas de crontab escritas às pressas quase sempre assumem UTC ou o fuso que a AMI trouxe por padrão. Ninguém decide o fuso do job de fatura conscientemente — ele herda o que já estava configurado na instância, e mudar isso é reconfigurar a instância inteira, não uma linha de um agendamento.
- Por que ninguém migrou isso ainda. Porque funciona, e porque "mexer no cron" dá medo de derrubar os outros quatro jobs junto com o que precisa de ajuste. A ausência de falha visível é lida como ausência de problema — e é exatamente o contrário: a falha existe, só não tem como aparecer.
O que está em jogo quando o job de fatura falha em silêncio
Se o job de fatura não roda, a fatura não é emitida — dinheiro deixa de ser cobrado, e ninguém percebe até o cliente perguntar ou o fechamento do mês acusar a diferença. Não é um incidente técnico abstrato: é receita que já foi entregue e ainda não foi cobrada, silenciosamente, por um mês inteiro.
Arquitetura para produção
Cada peça nova abaixo rastreia a uma linha da tabela de requisitos. Se você não conseguir apontar o requisito, a peça é adorno — e este desenho não tem nenhuma.
- → invoca com o horário agendado no evento
- → invoca em algum instante dentro da janela de 15 min
- → grava o CSV do período faturado
- → grava marca de idempotência antes de confirmar sucesso
- → consulta o estoque atual do fornecedor
- → grava marca de idempotência
- → evento não entregue após esgotar as tentativas
- → evento não entregue após esgotar as tentativas
- → profundidade da fila maior que zero
- → profundidade da fila maior que zero
- → alarme dispara notificação
- Fora da AWS
- Integração de apps
- Compute
- Armazenamento
- Banco de dados
- Gestão e governança
A troca central não é "trocar EC2 por Lambda": é que cada job vira um RECURSO com nome, fuso, janela, retentativa e fila de erro próprios. Este desenho mostra dois dos cinco — o de fatura (cron, fuso, sem janela) e o de estoque (rate, com janela flexível) — porque juntos cobrem as duas decisões que mais aparecem em prova. Percorra os passos: cada peça nova rastreia a um requisito da seção anterior.
- Cada job é um recurso com agendamento próprio. Não existe mais um arquivo de crontab com cinco linhas: existem cinco agendamentos, cada um com nome, ARN e histórico próprios, que aparecem numa chamada de API e não numa sessão SSH.
- O agendamento decide QUANDO; a função decide O QUÊ. Separar as duas coisas é o que permite trocar o horário de um job sem tocar no código dele, e trocar o código sem tocar no horário. No cron, as duas decisões moram na mesma linha de texto.
- O fuso horário é propriedade do agendamento, não do sistema operacional. O agendamento da fatura declara `America/Sao_Paulo` explicitamente. Isso não muda o resultado hoje — o Brasil não observa horário de verão desde 2019 — mas declarar o fuso em vez de calcular um deslocamento fixo é o que impede o mesmo padrão de quebrar ao ser copiado para uma região que observa DST.
- A janela flexível espalha carga nos jobs que podem esperar. O agendamento de estoque não promete um instante exato — promete uma janela de 15 minutos, e a Scheduler escolhe quando dentro dela. Isso evita que a Cadência bata na API do fornecedor exatamente no mesmo segundo que dezenas de outros clientes dele, todos configurados "a cada 6 horas, em ponto".
- O identificador do horário agendado é o que torna a retentativa segura. A função recebe o horário agendado como parte do evento e grava essa chave no ledger antes de confirmar sucesso. Uma segunda tentativa do MESMO agendamento reconhece o trabalho já feito e não duplica a fatura.
- Falha deixa de ser silêncio: vira mensagem numa fila. Quando as tentativas se esgotam, o evento não desaparece — ele chega a uma DLQ com o motivo anexado. É a diferença entre "o job não rodou, descubra você mesmo por quê" e "aqui está exatamente o que falhou, e quantas vezes tentamos".
- O alarme mede a fila, não a aplicação. Isso importa porque pega exatamente a falha que passaria despercebida na EC2: não é preciso vasculhar log nenhum, a existência de uma mensagem na DLQ já é o sinal completo. As outras três funções (relatório, limpeza, ping de saúde) seguem este mesmo padrão — agendamento, DLQ e alarme próprios.
A diferença estrutural em relação ao desenho anterior não é "trocar EC2 por Lambda": é que cada job passa a ser um RECURSO da AWS, com nome, ARN, fuso, janela, retentativa e fila de erro próprios — em vez de uma linha de texto dentro de um arquivo que só existe dentro de uma instância.
O achado que sustenta o módulo inteiro
A Scheduler não elimina falha — jobs continuam podendo falhar. O que ela elimina é o SILÊNCIO ao redor da falha: toda execução tem um resultado registrado, e toda falha esgotada vira uma mensagem com o motivo anexado. É a diferença entre "descubra você mesmo por quê" e "aqui está exatamente o que faltou".
O caminho de uma invocação, ponta a ponta
Os nomes dos campos não são jargão: eles são a diferença entre um agendamento correto e um que quebra em algum cliente, em alguma região, duas vezes por ano. ScheduleExpressionTimezone é avaliado usando o banco de fusos horários da IANA, e a Scheduler ajusta automaticamente para horário de verão — quando o fuso declarado observa DST.
O achado sobre fuso horário, com precisão
O Brasil aboliu o horário de verão em 2019. Calcular "9h em São Paulo = 12h UTC" à mão FUNCIONA hoje, para essa empresa, nessa região — e é exatamente por isso que o hábito é perigoso: ele não quebra aqui, então parece correto. O erro não está no número; está em calcular um deslocamento fixo em vez de declarar o fuso IANA. O mesmo hábito, copiado para um cliente nos EUA ou na Europa — regiões que observam DST —, produz um agendamento que roda uma hora adiantado ou atrasado duas vezes por ano, sem nenhum alerta.
A documentação oficial é explícita sobre os dois lados da transição: quando o relógio avança na primavera, uma execução que cairia num horário inexistente é simplesmente pulada; quando o relógio volta no outono, a execução acontece uma vez só, sem repetir. Nenhum dos dois comportamentos gera erro — e é por isso que testar um agendamento sensível a fuso só em agosto não prova nada sobre como ele se comporta em março.
// Mensagem de dead-letter na DLQ da fatura, depois de esgotar as tentativas.
// E o que substitui "ninguem percebeu" por "aqui esta exatamente o que faltou".
{
"Body": "{\"job\":\"fatura-mensal\",\"scheduled_time\":\"2026-08-01T12:00:00Z\"}",
"MessageAttributes": {
"ERROR_CODE": { "StringValue": "Lambda.Unhandled", "DataType": "String" },
"ERROR_MESSAGE": {
"StringValue": "Task timed out after 60.03 seconds",
"DataType": "String"
},
"SCHEDULE_ARN": {
"StringValue": "arn:aws:scheduler:us-east-1:111122223333:schedule/ffv-lab/fatura-mensal",
"DataType": "String"
},
"TARGET_ARN": {
"StringValue": "arn:aws:lambda:us-east-1:111122223333:function:ffv-lab-agendador",
"DataType": "String"
},
// Diz POR QUE a Scheduler desistiu: por numero de tentativas, ou por idade do evento.
// E o dado que faltava na EC2 — la, "por que parou" nunca tinha resposta.
"EXHAUSTED_RETRY_CONDITION": { "StringValue": "MaximumRetryAttempts", "DataType": "String" },
"RETRY_ATTEMPTS": { "StringValue": "3", "DataType": "String" },
"SCHEDULED_TIME": { "StringValue": "2026-08-01T12:00:00Z", "DataType": "String" }
}
}
As decisões, e o que se perde em cada uma
📋 Migrar 5 jobs de manutenção — fatura mensal, relatório diário, sincronização de estoque, limpeza de sessões e ping de saúde de um parceiro de pagamento — para fora de uma EC2 dedicada, com equipe de duas pessoas e sem orçamento para plataforma nova.
Cada job já é independente dos outros quatro — não compartilha estado nem ordem de execução — então um recurso por job é o desenho mais simples que ainda é correto: falha de um não contamina o diagnóstico dos outros, e a DLQ isola exatamente qual execução faltou. O custo acompanha o uso real em vez de uma instância fixa, e a migração não pede orquestrador novo para um problema que não tem passos dependentes entre si.
Alt: Regra agendada do EventBridge clássico (Rules com schedule expression) — A própria AWS rotula como recurso legado e recomenda a Scheduler no lugar. Regras não têm fuso horário por regra — a expressão roda sempre em UTC —, não têm janela flexível nem retentativa/DLQ nativos por alvo, e competem pela mesma cota de 300 regras por bus de eventos que qualquer outra regra da conta usa.
Alt: Step Functions com estado de espera (`Wait`) — Resolve orquestração de PASSOS dependentes, que nenhum dos 5 jobs tem. Pagar por transição de estado para um job de uma etapa só é complexidade sem contrapartida — vale quando um job passa a depender do resultado do anterior.
Alt: AWS Batch com agendamento próprio — Pensado para trabalho que precisa de ambiente de execução maior que o teto do Lambda (15 minutos, memória até dezenas de GB) ou de fila de capacidade computacional. Nenhum dos 5 jobs chega perto desse teto.
Alt: Manter o agendador embutido no processo da API (padrão do L21, antes da correção) — Acopla o ciclo de vida do job ao da aplicação que hospeda — se a API reinicia, reimplanta ou escala, o relógio embutido vai junto, e cada instância da API rodando o mesmo temporizador dispara o job em duplicidade.
| Decisão | Escolha | Alternativas | Motivo | O que se perde |
|---|---|---|---|---|
| Um agendamento por job | 5 recursos independentes | um script único disparado por um agendamento só | falha de um job não contamina o diagnóstico dos outros; DLQ isola causa por execução | mais recursos para nomear e versionar — mitigado com `for_each` sobre um mapa |
| cron vs. rate | cron nos 2 jobs com calendário; rate nos 3 de intervalo puro | cron em tudo, simulando intervalo com `*/N` | rate expressa a intenção real ("a cada 15 min") sem fingir ser calendário | nada; a escolha certa por job não custa nada extra |
| Fuso por agendamento | IANA (`America/Sao_Paulo`) nos jobs de calendário | UTC fixo; deslocamento calculado à mão | sobrevive a mudança de regra de DST sem precisar editar o agendamento | nenhuma perda real; é uma linha a mais na declaração |
| Janela flexível seletiva | ligada em estoque e limpeza; desligada em fatura, relatório e ping | ligada ou desligada em todos igualmente | cada job tem uma resposta diferente para "pode esperar alguns minutos?" | mais uma decisão explícita por job, em vez de um padrão único para copiar |
| DLQ e alarme por agendamento | 5 filas, 5 alarmes | uma fila compartilhada para todos os jobs | a fila isola qual job falhou sem precisar ler o corpo de cada mensagem | mais recursos na fatura — centavos, e a tabela de custo mede isso |
| Idempotência via ledger com chave = job + horário agendado | DynamoDB, TTL de 25 h | sem idempotência; confiar que o job nunca vai duplicar | retentativa automática da Scheduler deixa de ser um risco de efeito duplicado | uma tabela a mais para operar, mesmo que pequena e barata |
A dívida que este módulo não paga
Os cinco jobs continuam independentes entre si. No dia em que um deles passar a depender do resultado de outro — por exemplo, só sincronizar estoque depois de a fatura confirmar que não há reembolso pendente —, agendamentos soltos deixam de bastar. Aí o desenho certo é Step Functions orquestrando os passos, com a Scheduler disparando a máquina de estados em vez da função isolada.
Construir: os cinco agendamentos, um mapa só
O `for_each` sobre um mapa de jobs é o que torna cinco agendamentos administráveis sem cinco blocos de recurso copiados e colados — e sem cinco chances de esquecer o `flexible_time_window` obrigatório numa delas.
# agendamentos.tf — cinco jobs, um agendamento por job, DLQ e alarme proprios
# Grupo dedicado: separa os agendamentos deste projeto dos agendamentos "default"
# de qualquer outra coisa na conta, e limita o raio de uma politica IAM por prefixo.
resource "aws_scheduler_schedule_group" "cadencia" {
name = "ffv-lab-cadencia"
}
# Os cinco jobs, com a decisao de cron/rate, fuso e janela ja tomada por cada um —
# nao existe um valor "certo" unico; cada linha e uma decisao rastreavel a um
# requisito (ver a tabela da secao anterior).
locals {
jobs = {
fatura-mensal = {
expressao = "cron(0 9 1 * ? *)" # dia 1 de cada mes, 9h no fuso abaixo
fuso = "America/Sao_Paulo"
janela_minutos = 0 # 0 = sem janela: o horario exato E o requisito
timeout = 60
}
relatorio-diario-vendas = {
expressao = "cron(0 7 * * ? *)" # todo dia, 7h — antes do horario comercial
fuso = "America/Sao_Paulo"
janela_minutos = 0 # o time de vendas le isso antes da reuniao das 8h
timeout = 60
}
sincronizacao-estoque = {
expressao = "rate(6 hours)" # nao ha semantica de calendario aqui, so intervalo
fuso = "UTC" # rate nao tem fuso relevante; UTC evita ambiguidade
janela_minutos = 15 # espalha a chamada para nao bater em ponto com outros clientes do parceiro
timeout = 30
}
limpeza-sessoes-expiradas = {
expressao = "rate(15 minutes)"
fuso = "UTC"
janela_minutos = 5 # espalha a carga de DELETE no RDS
timeout = 30
}
ping-saude-parceiro-pagamento = {
expressao = "rate(5 minutes)"
fuso = "UTC"
janela_minutos = 0 # cadencia exata: e o dado usado para medir SLA do parceiro
timeout = 10
}
}
}
resource "aws_dynamodb_table" "idempotencia" {
name = "ffv-lab-agendamentos-idempotencia"
billing_mode = "PAY_PER_REQUEST"
hash_key = "chave" # formato: "<job>#<scheduled_time>"
attribute {
name = "chave"
type = "S"
}
# TTL: uma marca de idempotencia so precisa sobreviver ao maior MaximumEventAgeInSeconds
# em uso (24 h). Sem TTL, a tabela cresce para sempre com marca que ninguem mais consulta.
ttl {
attribute_name = "expira_em"
enabled = true
}
}
resource "aws_sqs_queue" "dlq" {
for_each = local.jobs
name = "ffv-lab-${each.key}-dlq"
# Fila STANDARD: a Scheduler nao aceita FIFO como DLQ. Retencao generosa porque o
# objetivo da fila e dar tempo para investigar, nao para reprocessar automaticamente.
message_retention_seconds = 1209600 # 14 dias, o teto do SQS
}
# O papel que A FUNCAO assume para rodar — distinto do papel que a SCHEDULER
# assume para CHAMAR a funcao (definido mais abaixo). Confundir os dois e o erro
# mais comum desta secao: sao dois principals diferentes (lambda.amazonaws.com e
# scheduler.amazonaws.com), com politicas de confianca que nao se substituem.
data "aws_iam_policy_document" "confianca_lambda" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "execucao_lambda" {
name = "ffv-lab-lambda-execucao"
assume_role_policy = data.aws_iam_policy_document.confianca_lambda.json
}
data "aws_iam_policy_document" "lambda_le_e_grava" {
statement {
effect = "Allow"
actions = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
resources = ["arn:aws:logs:*:*:log-group:/aws/lambda/ffv-lab-*"]
}
statement {
effect = "Allow"
actions = ["dynamodb:GetItem", "dynamodb:PutItem"]
resources = [aws_dynamodb_table.idempotencia.arn]
}
}
resource "aws_iam_role_policy" "lambda_le_e_grava" {
role = aws_iam_role.execucao_lambda.id
policy = data.aws_iam_policy_document.lambda_le_e_grava.json
}
resource "aws_lambda_function" "agendador" {
for_each = local.jobs
function_name = "ffv-lab-${each.key}"
runtime = "dotnet8"
handler = "Agendador::Agendador.Handler::Executar"
filename = "${path.module}/dist/agendador.zip"
timeout = each.value.timeout
memory_size = 256
role = aws_iam_role.execucao_lambda.arn
environment {
variables = {
JOB_NAME = each.key
TABELA_IDEMPOT = aws_dynamodb_table.idempotencia.name
}
}
}
resource "aws_scheduler_schedule" "job" {
for_each = local.jobs
name = each.key
group_name = aws_scheduler_schedule_group.cadencia.name
schedule_expression = each.value.expressao
schedule_expression_timezone = each.value.fuso
# Obrigatorio mesmo quando esta OFF — a API rejeita a chamada sem este bloco,
# e "esqueci o flexible_time_window" e o erro de validacao mais comum da secao.
flexible_time_window {
mode = each.value.janela_minutos > 0 ? "FLEXIBLE" : "OFF"
maximum_window_in_minutes = each.value.janela_minutos > 0 ? each.value.janela_minutos : null
}
target {
arn = aws_lambda_function.agendador[each.key].arn
role_arn = aws_iam_role.execucao_scheduler.arn
# Atributos de contexto: viram texto literal substituido pela Scheduler na hora
# da chamada. E a base da chave de idempotencia gravada no handler.
input = jsonencode({
job = each.key
scheduled_time = "<aws.scheduler.scheduled-time>"
attempt_number = "<aws.scheduler.attempt-number>"
execution_id = "<aws.scheduler.execution-id>"
})
retry_policy {
maximum_retry_attempts = 3 # nao os 185 do teto — 3 e o que faz sentido para um job de segundos
maximum_event_age_in_seconds = 900 # 15 min: se ainda falha depois disso, esperar mais nao ajuda
}
dead_letter_config {
arn = aws_sqs_queue.dlq[each.key].arn
}
}
}
# Um alarme por DLQ: a fila com mensagem E o sinal completo, nao precisa de
# limiar sofisticado. "> 0" e a definicao exata de "algo faltou".
resource "aws_cloudwatch_metric_alarm" "dlq_com_mensagem" {
for_each = local.jobs
alarm_name = "ffv-lab-${each.key}-dlq-com-mensagem"
namespace = "AWS/SQS"
metric_name = "ApproximateNumberOfMessagesVisible"
statistic = "Maximum"
period = 300
evaluation_periods = 1
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
dimensions = {
QueueName = aws_sqs_queue.dlq[each.key].name
}
alarm_actions = [aws_sns_topic.plantao.arn]
}
resource "aws_sns_topic" "plantao" {
name = "ffv-lab-agendamentos-plantao"
}
# O papel que A SCHEDULER assume para invocar o alvo E escrever na DLQ em caso
# de falha — distinto do papel de EXECUCAO da propria funcao (abaixo).
data "aws_iam_policy_document" "confianca_scheduler" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["scheduler.amazonaws.com"]
}
}
}
resource "aws_iam_role" "execucao_scheduler" {
name = "ffv-lab-scheduler-execucao"
assume_role_policy = data.aws_iam_policy_document.confianca_scheduler.json
}
data "aws_iam_policy_document" "scheduler_invoca_e_avisa" {
statement {
effect = "Allow"
actions = ["lambda:InvokeFunction"]
resources = [for f in aws_lambda_function.agendador : f.arn]
}
statement {
effect = "Allow"
actions = ["sqs:SendMessage"]
# Restrito as 5 DLQs deste projeto — o exemplo da documentacao da AWS usa
# Resource "*" porque cobre o caso generico; aqui sabemos exatamente quais
# 5 filas existem, entao restringir custa uma linha e nao um "*".
resources = [for q in aws_sqs_queue.dlq : q.arn]
}
}
resource "aws_iam_role_policy" "scheduler_invoca_e_avisa" {
role = aws_iam_role.execucao_scheduler.id
policy = data.aws_iam_policy_document.scheduler_invoca_e_avisa.json
}
output "agendamentos_criados" {
value = [for s in aws_scheduler_schedule.job : s.arn]
description = "Os 5 ARNs — e a prova de que a EC2 pode ser desligada"
}
O `*` que aparece na política de confiança, e por que ele não é o problema
A política de confiança do papel de execução da Scheduler usa `scheduler.amazonaws.com` como principal — isso não é um `Resource: "*"` de dado, é a identidade do próprio serviço assumindo o papel, e é assim que toda política de confiança de serviço da AWS funciona. O `*` que mereceria atenção seria em `sqs:SendMessage` ou `lambda:InvokeFunction` — e ali a política acima já restringe às 5 filas e às 5 funções deste projeto.
Janela de flexibilidade não é "sempre bom"
É tentador ligar `FlexibleTimeWindow` em tudo, como boa prática genérica de resiliência. Para o job de fatura e o de relatório, isso quebraria o requisito: o time financeiro precisa do CSV pronto ANTES de uma reunião às 9h, não "em algum momento entre 9h e 9h15". A janela ajuda quem pode esperar; para quem não pode, ela transforma um horário garantido num horário aproximado.
Dois papéis, dois principals — não é o mesmo papel duas vezes
O papel que a Scheduler assume para CHAMAR a função (`execucao_scheduler`, confiança em `scheduler.amazonaws.com`) e o papel que a própria FUNÇÃO assume para RODAR (`execucao_lambda`, confiança em `lambda.amazonaws.com`) são dois recursos IAM diferentes, com políticas de confiança que não se substituem. Apontar o `role` da função para o papel de execução da Scheduler falha na criação — os dois serviços não aceitam assumir o papel um do outro.
Construir: o handler em .NET 8, com idempotência derivada do agendamento
O runtime é o mesmo dotnet8 gerenciado do L21. A diferença aqui não é desempenho de partida — é que o evento recebido carrega o horário que a Scheduler PROMETEU executar, e é essa promessa, não o relógio local da execução, que vira a chave que impede duplicidade.
// Handler.cs — o mesmo runtime gerenciado dotnet8 do L21, agora recebendo o
// horario agendado como parte do evento, e usando isso para nao duplicar efeito.
using Amazon.Lambda.Core;
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.Model;
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
namespace Agendador;
public record EventoAgendamento(
string Job,
string ScheduledTime,
string AttemptNumber,
string ExecutionId);
public class Handler
{
private static readonly AmazonDynamoDBClient _ddb = new();
private static readonly string _tabela =
Environment.GetEnvironmentVariable("TABELA_IDEMPOT")!;
public async Task Executar(EventoAgendamento evento, ILambdaContext contexto)
{
// A chave nao usa DateTime.UtcNow: usa o horario que a Scheduler PROMETEU
// executar. E isso que torna duas tentativas do MESMO agendamento
// reconheciveis como o mesmo trabalho, mesmo que a segunda tentativa
// aconteca minutos depois da primeira.
var chave = $"{evento.Job}#{evento.ScheduledTime}";
var jaFeito = await _ddb.GetItemAsync(new GetItemRequest
{
TableName = _tabela,
Key = new() { ["chave"] = new AttributeValue { S = chave } },
});
if (jaFeito.Item.Count > 0)
{
// Nao e erro: e a retentativa fazendo exatamente o que deveria — nao
// repetir um trabalho que uma tentativa anterior ja completou.
contexto.Logger.LogInformation(
"{Chave} ja foi processada (tentativa {Tentativa}); pulando efeito colateral",
chave, evento.AttemptNumber);
return;
}
// O trabalho real do job. Cada valor de JOB_NAME executa uma rotina
// diferente — aqui simplificado para o caso da fatura, que e o que a
// secao de provas mede.
await RodarJob(evento.Job, contexto);
// Grava a marca DEPOIS do efeito ter sido concluido, com TTL de 25 h —
// maior que o teto de MaximumEventAgeInSeconds (24 h) de qualquer job
// desta locals.jobs, para nunca expirar antes de a Scheduler desistir.
await _ddb.PutItemAsync(new PutItemRequest
{
TableName = _tabela,
Item = new()
{
["chave"] = new AttributeValue { S = chave },
["expira_em"] = new AttributeValue
{
N = DateTimeOffset.UtcNow.AddHours(25).ToUnixTimeSeconds().ToString(),
},
},
});
}
private static Task RodarJob(string job, ILambdaContext contexto)
{
contexto.Logger.LogInformation("executando {Job}", job);
// Implementacao real por job fica fora do escopo deste laboratorio — o
// que ele ensina e o CONTORNO (idempotencia, DLQ, alarme), nao o corpo
// de cada rotina de negocio.
return Task.CompletedTask;
}
}
Duplicar cobrança é o risco real de retentativa sem idempotência
Sem a checagem contra o ledger, uma retentativa da Scheduler — que existe justamente para lidar com falha transitória — executaria o job de fatura pela segunda vez, gerando uma cobrança duplicada. O prejuízo de retentativa mal tratada não é técnico: é dinheiro saindo da conta do cliente duas vezes por um evento que deveria ser interno e invisível a ele.
Implantar e provar
Cinco provas. A quarta é a que a EC2 nunca conseguiu fazer: provar que executar o mesmo trabalho duas vezes produz um efeito só, não dois.
# provas.sh — cinco medicoes; a quarta e a que a EC2 nunca conseguiu fazer
PROJETO=ffv-lab
GRUPO=ffv-lab-cadencia
# ── Prova 1: os 5 agendamentos existem e a proxima execucao faz sentido ──────
aws scheduler list-schedules --group-name "$GRUPO" \
--query 'Schedules[].{nome:Name,expressao:Target}' --output table
for job in fatura-mensal relatorio-diario-vendas sincronizacao-estoque \
limpeza-sessoes-expiradas ping-saude-parceiro-pagamento; do
aws scheduler get-schedule --name "$job" --group-name "$GRUPO" \
--query '{nome:Name,expressao:ScheduleExpression,fuso:ScheduleExpressionTimezone,janela:FlexibleTimeWindow}'
done
# Esperado: cada job com o fuso e a janela que a tabela de decisoes declarou —
# nao o padrao herdado de copiar e colar o ultimo agendamento criado.
# ── Prova 2: uma falha provocada chega na DLQ com o motivo anexado ───────────
# Aponte temporariamente o alvo do job de fatura para um ARN que nao existe,
# aplique, e espere as retentativas se esgotarem (MaximumEventAgeInSeconds=900).
aws sqs receive-message \
--queue-url "$(aws sqs get-queue-url --queue-name ${PROJETO}-fatura-mensal-dlq --output text)" \
--message-attribute-names All --max-number-of-messages 1
# Esperado: uma mensagem com EXHAUSTED_RETRY_CONDITION = MaximumRetryAttempts
# ou MaximumEventAgeInSeconds. Fila vazia apos a janela de retentativa e FALHA
# da prova: ou o job nao falhou de verdade, ou a DLQ nao esta ligada ao alvo.
# ── Prova 3: o alarme dispara a partir da mensagem, sem intervencao manual ───
aws cloudwatch describe-alarms \
--alarm-names "${PROJETO}-fatura-mensal-dlq-com-mensagem" \
--query 'MetricAlarms[0].StateValue' --output text
# Esperado: ALARM, poucos minutos depois da prova 2. Se continuar OK, confira
# se o alarme aponta para o nome exato da fila (dimension QueueName).
# ── Prova 4: retentativa nao duplica efeito (a prova que a EC2 nao tinha) ────
# Invoque a funcao duas vezes manualmente com o MESMO scheduled_time.
aws lambda invoke --function-name ${PROJETO}-fatura-mensal \
--payload '{"job":"fatura-mensal","scheduled_time":"2026-09-01T12:00:00Z","attempt_number":"1","execution_id":"prova-1"}' \
/tmp/saida1.json
aws lambda invoke --function-name ${PROJETO}-fatura-mensal \
--payload '{"job":"fatura-mensal","scheduled_time":"2026-09-01T12:00:00Z","attempt_number":"2","execution_id":"prova-2"}' \
/tmp/saida2.json
aws dynamodb query --table-name ${PROJETO}-agendamentos-idempotencia \
--key-condition-expression "chave = :c" \
--expression-attribute-values '{":c":{"S":"fatura-mensal#2026-09-01T12:00:00Z"}}' \
--select COUNT
# Esperado: COUNT = 1, nao 2. Duas invocacoes, um efeito — a segunda leu o
# ledger e retornou sem repetir o trabalho.
# ── Prova 5: a EC2 esta desligada, e nada mais depende dela ──────────────────
aws ec2 describe-instances --filters "Name=tag:Projeto,Values=${PROJETO}-cron-legado" \
--query 'Reservations[].Instances[].State.Name' --output text
# Esperado: "stopped" ou "terminated". Se ainda "running", a migracao nao
# terminou — ela so termina quando a EC2 para de custar, nao quando o Scheduler
# comeca a funcionar em paralelo com ela.
| Prova | Comando | Resultado que aprova | O que reprova, e o que significa |
|---|---|---|---|
| 1 · Os 5 agendamentos existem com a configuração certa | `get-schedule` em cada um | fuso e janela batem com a tabela de decisões | divergência indica cópia de um agendamento para outro sem ajustar o que muda entre eles |
| 2 · Falha provocada chega na DLQ com o motivo | apontar o alvo para um ARN inválido e esperar | mensagem com `EXHAUSTED_RETRY_CONDITION` preenchido | fila vazia depois da janela de retentativa: ou o job não falhou de fato, ou a DLQ não está ligada ao alvo |
| 3 · O alarme dispara sozinho a partir da fila | `describe-alarms` após a prova 2 | `StateValue = ALARM` em poucos minutos | continuar `OK` indica dimensão errada no alarme — confira `QueueName` |
| 4 · Retentativa não duplica efeito | invocar a função 2× com o mesmo `scheduled_time` | uma linha só no ledger de idempotência | duas linhas significam que a checagem de idempotência não está lendo antes de escrever |
| 5 · A EC2 está desligada | `describe-instances` filtrando pela tag do projeto legado | `stopped` ou `terminated` | `running` significa que a migração não terminou — ela só termina quando o custo fixo para |
Quebrar de propósito: três falhas e o diagnóstico
As três acontecem de verdade, e as três parecem "o agendamento não funciona" à primeira vista. O que as separa é onde se olha.
| Falha | Como provocar | Sintoma | Onde olhar | Correção |
|---|---|---|---|---|
| Janela flexível num job que precisa de horário exato | ligue `FlexibleTimeWindow` de 15 min no agendamento de fatura | o CSV aparece em horários diferentes a cada mês, sempre dentro da janela | compare `SCHEDULED_TIME` (contexto do evento) com o instante real da invocação | `FlexibleTimeWindow.Mode = OFF` nos agendamentos com requisito de horário exato |
| Função sem idempotência sob retentativa | remova a checagem do ledger e force uma falha transitória seguida de sucesso na retentativa | efeito duplicado — duas faturas para o mesmo período | contagem de linhas no ledger para a mesma chave, ou efeito duplicado no sistema de destino | chave derivada do horário agendado, checada antes de qualquer efeito colateral |
| Papel de execução sem permissão de escrever na DLQ | remova `sqs:SendMessage` da política do papel de execução da Scheduler | a função falha, os `Errors` do Lambda sobem, mas a DLQ permanece vazia e o alarme nunca dispara | métricas do CloudWatch do Lambda (que mostram o erro) comparadas à DLQ (que não recebe nada) | incluir `sqs:SendMessage` restrito ao ARN da DLQ na política do papel de execução |
A falha que mais se parece com o problema original
Não confirmado com precisão na documentação: o comportamento exato quando o papel de execução não tem permissão para escrever na DLQ. O esperado, e o que a prova acima verifica, é que a entrega ao DLQ falha do ponto de vista do seu monitoramento — a função errou, mas nada chega à fila, e o alarme nunca dispara. É a mesma falha em silêncio da EC2 original, só que agora escondida atrás de uma arquitetura que parece ter resolvido o problema. Teste isso explicitamente na sua conta antes de confiar na permissão que você configurou.
Segurança: o que muda quando cinco linhas de crontab viram cinco recursos
A EC2 tinha um papel de instância provavelmente amplo, porque "precisava fazer um pouco de tudo". Separar em cinco recursos é também uma oportunidade de separar cinco papéis estreitos — e desperdiçá-la copiando a mesma política ampla para os cinco é o antipadrão que este laboratório existe para evitar.
| Risco | Probabilidade | Impacto | Controle preventivo | Detecção | Resposta |
|---|---|---|---|---|---|
| Segredo do parceiro de pagamento embutido em texto claro no `Input` | média | alto | referência ao Secrets Manager lida dentro do handler, nunca literal no `Input` do agendamento | `describe-schedule` mostrando segredo em texto claro no console ou na API | rotacionar o segredo; mover para referência por ARN |
| Papel de execução do Scheduler com permissão de invocar qualquer Lambda da conta | média | alto | `Resource` restrito à lista dos 5 ARNs de função deste projeto | IAM Access Analyzer sobre uso real do papel | derivar a política do uso medido — é o L41 |
| Alteração de horário sem trilha de auditoria | média | médio | mudança de agendamento só via pipeline com aprovação, não console direto em produção | CloudTrail em `UpdateSchedule` fora da identidade do pipeline | reverter para o valor do estado do Terraform; investigar quem mudou e por quê |
| DLQ acumulando payload sensível sem política restrita | baixa | médio | política da fila restrita à conta e ao papel de execução da Scheduler | CloudTrail em `ReceiveMessage` de identidade inesperada | restringir a política da fila; girar qualquer dado sensível exposto |
| Retentativa duplicando efeito por falta de idempotência | média | alto | ledger de idempotência checado antes de qualquer efeito colateral | contagem de linhas do ledger por chave, maior que 1 | reverter o efeito duplicado manualmente; corrigir a checagem antes de reprocessar |
O `*` que resta na política, e por que ele se justifica
A política de confiança do papel de execução usa `scheduler.amazonaws.com` como principal — não é um recurso de dado, é a identidade do serviço. Nas ações que tocam recurso de fato (`lambda:InvokeFunction`, `sqs:SendMessage`), a política deste laboratório já restringe ao ARN de cada uma das 5 peças, porque elas são conhecidas de antemão. A regra não é "nunca use `*`" — é "todo `*` tem de ter uma frase explicando por que não pode ser mais estreito".
Uma empresa em São Paulo agenda a emissão de fatura com `cron(0 12 1 * ? *)` e fuso UTC, calculando "9h local = 12h UTC" manualmente, em vez de declarar `America/Sao_Paulo`. O agendamento funciona corretamente há dois anos. Por que isso é considerado um antipadrão mesmo funcionando?
Observabilidade: as perguntas que o painel tem de responder
Um painel de agendamento tem uma função estreita: dizer se cada um dos 5 jobs fez o que deveria, sem exigir que alguém entre em cada log manualmente.
| Pergunta | Métrica ou consulta | O que significa mudar | Limiar inicial |
|---|---|---|---|
| Algum job falhou de vez? | profundidade da DLQ de cada agendamento | a execução esgotou as tentativas e o efeito não aconteceu | qualquer valor > 0 |
| O agendamento está tentando com frequência incomum? | `RETRY_ATTEMPTS` nas mensagens da DLQ | falha persistente, não transitória isolada | tentativas no teto configurado (3, neste módulo) |
| A latência entre horário previsto e execução real está normal? | diferença entre `SCHEDULED_TIME` e o timestamp do log | acima da janela flexível configurada indica atraso na própria Scheduler ou no alvo | > janela declarada + 60 s |
| Algum job está sendo executado em duplicidade? | contagem de linhas do ledger por chave | mais de uma linha para a mesma chave é falha de idempotência | > 1 para qualquer chave |
| Alguém mudou um agendamento fora do pipeline? | CloudTrail em `UpdateSchedule` e `DeleteSchedule` | mudança manual em produção | qualquer identidade fora da esperada |
| A EC2 legada voltou a subir? | estado da instância marcada como legada | alguém reverteu a migração, ou um processo automatizado a reiniciou | estado diferente de `stopped` |
A métrica que engana logo depois da migração
Nos primeiros dias, `Invocations` de cada Lambda vai parecer baixo comparado ao que se esperava — porque jobs de segundos, mesmo frequentes, não geram volume visível num painel dimensionado para tráfego de API. Não é sinal de que o agendamento não está disparando; é o formato normal de um job de manutenção. Confirme pela contagem de invocações, não pela impressão visual do gráfico.
Escala: 5, 50, 5 mil agendamentos
| Volume | O que acontece | O que passa a doer | O que fazer |
|---|---|---|---|
| 5 agendamentos, 1 grupo | nada muda; é o cenário deste laboratório | nada | nada |
| 50 agendamentos, times diferentes usando o mesmo grupo | ainda folgado na cota de 10 milhões de agendamentos por região da Scheduler | organização: sem grupos por time, fica difícil saber de quem é cada agendamento | um `schedule_group` por time ou por domínio, não um grupo único compartilhado |
| 5 mil agendamentos, todos com Lambda dedicada | ainda dentro da cota de agendamentos, mas a criação em lote pode esbarrar na taxa de `CreateSchedule` | 5.000 requisições por segundo é o padrão em regiões primárias — criação em lote muito rápida pode ser limitada antes disso | criar em lotes espaçados ou usar backoff no script de importação |
| Comparação com o limite das Rules clássicas | uma regra agendada por recurso esbarraria na cota de 300 regras por bus de eventos em poucas centenas de agendamentos | é exatamente por isso que a Scheduler existe: ela escala a um agendamento por recurso sem competir por uma cota de centenas | nenhuma ação — é o argumento a favor da escolha já feita neste módulo |
| Falha de uma zona de disponibilidade | EventBridge Scheduler e Lambda são serviços regionais e multi-AZ por padrão, sem configuração adicional deste laboratório | nada específico deste módulo — a camada de agendamento não tem uma "história de AZ" própria para contar | nenhuma; inventar Multi-AZ aqui seria decoração sobre uma camada que já não tem esse risco |
O número que costuma decidir a questão de prova
Regras clássicas do EventBridge competem por 300 regras por barramento de eventos na maioria das regiões (100 em algumas). A Scheduler tem cota separada de 10 milhões de agendamentos por região, ajustável a bilhões. Uma questão que pede "qual serviço usar para agendar uma tarefa por CLIENTE, com potencial de milhares de clientes" está testando exatamente essa diferença de ordem de grandeza, não uma preferência estilística.
Custo: o que este laboratório troca na fatura
A troca central é de dimensão: de "horas ligadas, fixo" para "execuções e mensagens, proporcional ao uso". Use o AWS Pricing Calculator para o valor exato na sua região — aqui o que importa é a dimensão, não o número.
| Cenário | Volume | O que muda | Tendência |
|---|---|---|---|
| Antes (EC2 dedicada) | 1 instância t3.micro, 24 h/dia | custo fixo de instância, independentemente de quantos jobs rodam dentro dela | constante, e cresce se alguém "aproveitar" a instância para mais um job |
| Depois (Scheduler + Lambda) | 5 jobs, de centenas a milhares de execuções por mês no total | custo por invocação de Lambda, por mensagem de SQS e por alarme — cada dimensão proporcional ao uso real | baixa e previsível; cresce linearmente se mais jobs forem adicionados |
| Se um job passar a rodar por segundo (hipotético) | volume muito acima do atual | a cota de invocações da Scheduler (milhares de transações por segundo, ajustável) e o custo por invocação do Lambda passam a dominar | este módulo não cobre esse regime — nenhum dos 5 jobs reais chega perto dele |
| Dimensão | Cobra por | Cuidado |
|---|---|---|
| EventBridge Scheduler | invocação, além de uma faixa gratuita | volume dos 5 jobs somados é pequeno o bastante para não aparecer como linha relevante |
| Lambda | GB-segundo e número de invocações | jobs de segundos e memória de 256 MB mantêm isso desprezível para este volume |
| SQS (as 5 DLQs) | requisição e armazenamento retido | fila vazia na maior parte do tempo não custa quase nada — ela só acumula quando algo falha |
| CloudWatch (5 alarmes) | por alarme-mês | valor pequeno e fixo; não é onde se otimiza |
| DynamoDB (ledger) | sob demanda: leitura e escrita por requisição | TTL remove item expirado sem custo de escrita adicional — sem TTL, a tabela cresceria para sempre |
O ganho que não aparece na fatura de infraestrutura
A instância desligada é a linha visível. A invisível é o incidente da fatura de julho não se repetir: o custo real daquele silêncio não foi a EC2 — foi o dia gasto reconstruindo manualmente o que deveria ter sido automático, mais o cliente que esperou a fatura mais do que deveria.
Well-Architected nos seis pilares
| Pilar | Situação ao fim deste laboratório | Risco que fica | Melhoria | Prioridade |
|---|---|---|---|---|
| Excelência operacional | cada falha vira mensagem com motivo anexado, sem log manual para ler | ainda não há playbook escrito para quem recebe o alarme do plantão | runbook por tipo de `EXHAUSTED_RETRY_CONDITION` | alta |
| Segurança | papéis de execução separados e restritos por ARN, sem segredo literal no `Input` | mudança de agendamento ainda pode ser feita direto no console, sem aprovação | agendamento só via pipeline com revisão (aproxima do L54) | média |
| Confiabilidade | retentativa com backoff, DLQ e idempotência cobrindo falha transitória e duplicidade | os 5 jobs continuam sem coordenação entre si — nenhum sabe do estado do outro | Step Functions no dia em que um job passar a depender de outro | média |
| Eficiência de performance | cada job usa só a memória e o tempo que precisa, medidos, não herdados de um padrão único | nenhuma; 5 jobs de segundos não têm gargalo de performance relevante | nenhuma ação necessária agora | baixa |
| Otimização de custos | custo proporcional ao uso, substituindo custo fixo de instância | DLQ, alarme e ledger por job multiplicam recursos pequenos — ainda assim, ordens de grandeza abaixo do custo fixo anterior | revisar se algum job de baixíssimo risco realmente precisa de DLQ e alarme dedicados | baixa |
| Sustentabilidade | instância desligada 24 h/dia deixa de consumir capacidade computacional ociosa | nenhum: é ganho líquido sem nova dívida | nenhuma ação necessária agora | baixa |
Evolução em níveis: o que muda, e o que passa a doer
A terceira arquitetura não é um desenho: é a resposta a QUANDO trocar de desenho de novo. Cada nível resolve um risco e compra outro.
EC2 com crontab, todos os 5 jobs num script, sem monitoramento. É onde a Cadência estava, e continua legítimo para um script pessoal de desenvolvimento.5 agendamentos no EventBridge Scheduler, cada um com Lambda, retentativa, DLQ e alarme próprios.Jobs de múltiplos passos (ex.: fatura → validação → notificação) viram Step Functions, e a Scheduler dispara a máquina de estados em vez da função isolada.Grupos de agendamento por time, com cota e política por grupo.Mudança de agendamento só via pipeline com aprovação e trilha no CloudTrail; segredo do `Input` sempre por referência.O histórico de execuções (duração, sucesso, tentativas) vira dado para decidir quais jobs toleram sair do relógio de parede e migrar para processamento em lote — é o L95, classificando volume grande numa janela de horas em vez de tempo real.A ordem não é negociável, e o motivo é concreto
Orquestrar passos no nível 3 sem a idempotência do nível 2 propaga o mesmo risco de duplicidade para dentro da máquina de estados. E decidir migrar um job para lote no nível 6 sem o histórico de execuções do nível 2 é decisão sem dado — exatamente a armadilha que a seção de IA deste módulo evita.
Onde IA entra nesta arquitetura, e onde não entra
Neste módulo, IA não resolve o problema central, e forçá-la seria o antipadrão que a própria série critica. "Quando este job deve rodar" tem resposta determinística: calendário ou intervalo, fuso declarado, janela sim ou não conforme o requisito. Nenhuma dessas quatro decisões melhora com um modelo — são configuração, não previsão.
Há um lugar adjacente onde IA acrescentaria valor real, e ele não é neste laboratório: decidir QUAIS jobs, entre dezenas ou centenas, toleram sair do relógio de parede e migrar para processamento em lote. Isso é o L95, e mesmo lá a resposta certa começa por uma regra simples antes de qualquer modelo.
| Pergunta | Resposta honesta para este módulo |
|---|---|
| Qual problema a IA resolveria? | nenhum, diretamente aqui — quando rodar é decisão de requisito de negócio, não de padrão a ser aprendido |
| Onde ela ajudaria, então? | a decisão adjacente de QUAIS jobs, entre muitos, são candidatos a sair do tempo real e ir para lote — é o L95 |
| Por que uma regra não bastaria ali? | uma regra simples bastaria para começar: "job cujo resultado ninguém espera em tempo real e que processa volume grande" cobre a maior parte dos candidatos |
| De onde viriam os dados, se um dia justificasse modelo? | o histórico de execuções deste próprio laboratório — duração, sucesso, tentativas — é a base |
| Por que não agora? | com 5 jobs, um modelo é superstição com aparência de estatística; a decisão certa aqui é olhar a tabela de requisitos e decidir manualmente |
O uso de IA que parece atraente e é armadilha aqui
Pedir a um modelo para "prever a melhor hora de rodar cada job e evitar pico no parceiro" troca uma solução determinística e já correta — a janela de flexibilidade, declarada uma vez — por um sistema probabilístico que precisa ser treinado, avaliado e monitorado, para resolver um problema que uma linha de configuração já resolve.
Anti-padrões deste laboratório
| Anti-padrão | Por que alguém faz | Por que é problema | Sintoma em produção | Forma correta | Quando é aceitável |
|---|---|---|---|---|---|
| Cron dentro de uma instância "porque já existe" | parece grátis, porque a instância já estava paga por outro motivo, ou já existia antes da conta atual | falha vira silêncio, e o custo fixo de 24 h ignora que o trabalho real usa minutos | fatura de EC2 sem relação nenhuma com o trabalho real que ela hospeda | EventBridge Scheduler + Lambda, um agendamento por job | nunca em produção; script pessoal de desenvolvimento, talvez |
| Um agendamento único disparando um script que roda todos os jobs em sequência | menos peças para configurar — um agendamento em vez de cinco | falha de um job trava ou atrasa os outros, e a DLQ mistura causas diferentes numa mensagem só | "o job de estoque não rodou" quando na verdade quem travou foi a fatura, três jobs antes na fila | um agendamento por job, cada um independente | só quando os jobs realmente têm ordem de dependência — aí o certo é Step Functions, não um script sequencial |
| Calcular deslocamento fixo de UTC à mão em vez de declarar fuso IANA | parece mais simples: "é só somar 3 horas" | quebra silenciosamente com a regra de DST em qualquer região ou cliente que a observe | agendamento correto na maior parte do ano e uma hora fora em duas janelas específicas | `ScheduleExpressionTimezone` com o nome IANA do fuso | só se o agendamento é permanentemente e comprovadamente em UTC, sem nenhuma interpretação de horário local |
| Janela de flexibilidade ligada em todo agendamento "para garantir robustez" | parece sempre boa prática de resiliência, como retry com backoff | agendamento com requisito de horário exato perde exatamente a garantia que precisava | CSV da fatura chegando em horários diferentes a cada execução, dentro da janela | ligar só nos jobs que genuinamente toleram atraso de minutos | jobs de manutenção sem consumidor esperando um horário específico |
| Copiar o teto de retentativa (185 tentativas, 24 h) sem pensar no job | parece "mais seguro tentar de novo" — usar o máximo permitido soa cauteloso | para um job não idempotente, mais tentativas é mais chance de duplicar o efeito | efeito repetido (fatura, notificação) muito depois da falha original, quando ninguém mais está olhando | teto pequeno (poucas tentativas, minutos de idade) para jobs de efeito único, coerente com idempotência | jobs genuinamente idempotentes e sem custo de tentativa repetida, como um ping de leitura |
| Configurar a DLQ e o alarme e nunca escrever o que fazer quando ele dispara | "configurei o alarme, terminei" — o trabalho parece concluído com o recurso criado | a fila vira lixo esquecido: acumula mensagem, ninguém lê, o alarme perde credibilidade | alarme "sempre em ALARM" que o time aprendeu a ignorar | runbook curto por `EXHAUSTED_RETRY_CONDITION`, testado com a falha provocada deste módulo | nunca; alarme sem playbook é pior que não ter alarme, porque simula segurança |
Quando algo não funciona
| Sintoma | Causa provável | Como investigar | Onde olhar | Correção |
|---|---|---|---|---|
| Job não roda no horário esperado | fuso errado, ou expressão cron mal formada | compare a próxima execução calculada com a esperada | `get-schedule` → `ScheduleExpression` e `ScheduleExpressionTimezone` | validar a expressão e o fuso antes de confiar; nunca assumir UTC por padrão |
| Job roda, mas em horário levemente diferente a cada execução | `FlexibleTimeWindow` ligado sem necessidade real | compare `SCHEDULED_TIME` do contexto com o instante real de invocação | `FlexibleTimeWindow.Mode` do agendamento | `Mode = OFF` para jobs sensíveis a horário exato |
| DLQ cheia, alarme disparando | alvo falhando persistentemente | leia `ERROR_CODE` e `ERROR_MESSAGE` da mensagem na fila | atributos da mensagem na DLQ | corrigir a causa raiz no alvo; reprocessar manualmente depois |
| Alarme nunca dispara mesmo com falha confirmada | papel sem `sqs:SendMessage` na DLQ, ou `MaximumEventAgeInSeconds` grande demais para o teste | confira a política do papel de execução e os valores da `RetryPolicy` | IAM do papel de execução da Scheduler; `RetryPolicy` do alvo | adicionar a permissão de escrita na fila; reduzir o teto se fizer sentido para o job |
| Job executa duas vezes para o mesmo período | função não idempotente sob retentativa | consulte o ledger pela chave do job e do horário agendado | lógica do handler, antes do efeito colateral | checar e gravar no ledger com a chave derivada do horário agendado, não do relógio local |
| `ValidationException` ao criar o agendamento | campo obrigatório ausente — `FlexibleTimeWindow` é exigido mesmo quando `OFF` | leia a mensagem de erro completa da API, não só o código | corpo da chamada `CreateSchedule` | sempre declarar `FlexibleTimeWindow` explicitamente, mesmo como `{"Mode": "OFF"}` |
| Agendamento não aparece na lista esperada | caiu no grupo `default` em vez do grupo do projeto | liste agendamentos sem filtro de grupo e procure pelo nome | `GroupName` na criação | declarar `GroupName` explicitamente em todo `create-schedule` |
A pergunta que resolve metade destes casos
Antes de mexer em qualquer parâmetro, pergunte: o agendamento disparou no horário certo, ou a função falhou depois de ser chamada? A primeira dúvida se resolve olhando `ScheduleExpression` e fuso; a segunda, olhando a DLQ e as métricas do Lambda. As duas investigações não se sobrepõem, e confundi-las faz alguém mexer no fuso quando o problema estava na permissão.
Limpeza: o que o destroy não leva
Este laboratório troca um recurso caro-e-esquecido (a EC2) por vários recursos baratos-e-fáceis-de-esquecer. Nenhum deles cobra muito sozinho; a soma de recursos esquecidos em vários laboratórios é o que realmente pesa.
# 1. Derrube o que o Terraform administra: agendamentos, Lambdas, DLQs, alarmes,
# topico SNS, tabela DynamoDB e o grupo de agendamento.
terraform destroy -auto-approve
# 2. A EC2 legada e o ponto central deste laboratorio: ela precisa estar
# REALMENTE desligada, nao so parada temporariamente para o teste.
aws ec2 describe-instances --filters "Name=tag:Projeto,Values=ffv-lab-cron-legado" \
--query "Reservations[].Instances[].{id:InstanceId,estado:State.Name}" --output table
# Se ainda existir e nao for mais necessaria como referencia, termine-a:
# aws ec2 terminate-instances --instance-ids <id>
# 3. Volumes EBS orfaos da instancia terminada nao saem sozinhos se o
# "delete on termination" nao estava marcado na instancia original.
aws ec2 describe-volumes --filters "Name=status,Values=available" \
--query "Volumes[].{id:VolumeId,gb:Size}" --output table
# 4. Grupos de logs do Lambda tem retencao propria e sobrevivem ao destroy
# da funcao que os alimentava.
for job in fatura-mensal relatorio-diario-vendas sincronizacao-estoque \
limpeza-sessoes-expiradas ping-saude-parceiro-pagamento; do
aws logs delete-log-group --log-group-name "/aws/lambda/ffv-lab-$job" 2>/dev/null || true
done
# 5. Prova final: nada com o nome do projeto de pe, nem na Scheduler nem na EC2.
aws scheduler list-schedules --group-name ffv-lab-cadencia
aws resourcegroupstaggingapi get-resources \
--tag-filters Key=Projeto,Values=ffv-lab \
--query "ResourceTagMappingList[].ResourceARN" --output tableO grupo de agendamento não some sozinho
Apagar os 5 agendamentos com `terraform destroy` não apaga automaticamente o `schedule_group` se ele foi criado fora do mesmo `apply`, ou se algum agendamento foi criado manualmente dentro dele fora do estado do Terraform. Confira com `list-schedules` antes de considerar a limpeza concluída.
| Recurso | Sai no destroy? | Cobra parado? | Por que fica |
|---|---|---|---|
| Os 5 agendamentos e o grupo | sim | não | Scheduler não cobra por agendamento parado, só por invocação |
| As 5 funções Lambda | sim | não | sem cobrança por função parada, só por invocação e armazenamento de código |
| As 5 DLQs (SQS) | sim | sim, centavos se houver mensagem retida | fila com mensagem não lida continua contando armazenamento |
| Tabela DynamoDB de idempotência | sim | não em modo sob demanda | sem tráfego, sem cobrança de capacidade |
| Alarmes e tópico SNS | sim se em Terraform | centavos | alarme criado à mão no console não aparece no estado |
| EC2 legada | não — está fora do Terraform deste módulo | sim, por hora, é o item mais caro da lista | ela nunca foi gerenciada por este código; desligá-la é uma ação manual e é O objetivo do laboratório |
| Volumes EBS órfãos da EC2 | não | sim, por GB-mês | sobrevivem à instância se `delete on termination` não estava marcado |
| Grupos de log do Lambda | depende de `skip_destroy` | sim, retenção | ciclo próprio, sobrevive à função que os alimentava |
Resumo: problema, peça e motivo
| Problema | Peça | Por que ela, e não outra |
|---|---|---|
| Servidor ligado 24 h para rodar minutos de trabalho | EventBridge Scheduler + Lambda | custo passa a acompanhar o uso real, e nenhum recurso fica ocioso à espera do próximo job |
| Falha sem log nenhum para ler | DLQ por agendamento com `EXHAUSTED_RETRY_CONDITION` | a mensagem carrega o motivo — não é preciso reproduzir a falha para diagnosticá-la |
| "9h" que pode significar horas diferentes dependendo do calendário | `ScheduleExpressionTimezone` IANA | sobrevive a mudança de regra de horário de verão sem editar o agendamento |
| Chamada simultânea no parceiro junto com outros clientes dele | `FlexibleTimeWindow` seletivo | espalha carga só nos jobs que genuinamente toleram atraso de minutos |
| Retentativa automática podendo duplicar efeito | ledger de idempotência com chave = job + horário agendado | a segunda tentativa reconhece o trabalho já feito, em vez de repeti-lo |
| Falha isolada de um job contaminando o diagnóstico dos outros | um agendamento por job, não um script único | cada job tem seu próprio histórico, sua própria DLQ e seu próprio alarme |
| Falha | O que a protege | O que ela NÃO protege |
|---|---|---|
| Job que falha por instabilidade transitória | retentativa com backoff | falha persistente na causa raiz — aí a DLQ é quem avisa, não a retentativa |
| Falha esgotada e ninguém percebendo | alarme sobre profundidade da DLQ | alarme sem runbook — disparar não é o mesmo que ser resolvido |
| Retentativa duplicando efeito | ledger de idempotência | efeito não idempotente que já foi gravado ANTES da checagem existir |
| Agendamento com fuso ou expressão errada | validação manual na implantação | mudança futura feita fora do pipeline, sem revisão |
| Segredo do parceiro exposto | referência ao Secrets Manager em vez de valor literal | segredo já vazado em execução anterior, antes da correção |
- Cada um dos 5 agendamentos é avaliado no fuso horário que ele declara.
- Se houver janela flexível, a Scheduler escolhe um instante dentro dela; senão, invoca com precisão de 60 s.
- A Scheduler invoca o alvo usando o papel de execução do próprio agendamento.
- A função recebe o horário agendado como parte do evento, e checa esse valor contra o ledger antes de agir.
- Se o efeito já foi gravado para aquela chave, a execução retorna sem repetir o trabalho.
- Se a função falha, a Scheduler tenta de novo sozinha, com backoff, até o teto configurado.
- Se as tentativas se esgotam, o evento vai para a DLQ com o motivo anexado.
- O alarme de profundidade da fila dispara, e o plantão é notificado — não o time financeiro, semanas depois.
- A EC2 legada, sem mais nenhum job para hospedar, é desligada.
Perguntas frequentes
❓ Qual a diferença entre cron e rate no EventBridge Scheduler?
❓ Como funciona o fuso horário no EventBridge Scheduler?
❓ O que é a janela de flexibilidade (flexible time window) e quando devo usar?
❓ O que acontece se meu Lambda falhar num agendamento do EventBridge Scheduler?
❓ EventBridge Scheduler substitui as regras agendadas (Rules) do EventBridge clássico?
❓ Preciso pagar por uma instância EC2 rodando 24 horas só para agendar tarefas?
❓ Por que meu agendamento não roda exatamente no segundo configurado?
❓ Como reprocesso uma mensagem que caiu na fila de mensagens não entregues (DLQ)?
Fixando
Um agendamento de sincronização com um fornecedor usa `rate(6 hours)` com `FlexibleTimeWindow` de 15 minutos. Um agendamento de relatório diário usa `cron(0 7 * * ? *)` sem janela flexível. Por que a mesma peça — janela flexível — é usada num e evitada no outro?
Um agendamento invoca uma função duas vezes para o mesmo `scheduled_time`, porque a primeira tentativa expirou por timeout antes de confirmar sucesso e a Scheduler tentou de novo automaticamente. A função gerou o efeito colateral (uma fatura) nas duas execuções. Qual é a causa raiz, e não apenas o sintoma?
Conhecimentos, próximo módulo e documentação
| Item | Conteúdo |
|---|---|
| Conhecimentos anteriores necessários | L21 concluído (Lambda .NET 8, EventBridge Scheduler básico para um único job), Terraform básico |
| Conhecimentos adquiridos | cron vs. rate e quando cada um é mais claro; fuso IANA por agendamento e o comportamento em DST; janela de flexibilidade como decisão por job, não padrão único; retentativa com backoff e DLQ do próprio agendamento; idempotência derivada do horário agendado; a diferença de escala entre Rules clássicas e Scheduler |
| Limitação que fica | os 5 jobs continuam sem coordenação entre si — nenhum sabe do estado do outro; e a mudança de agendamento ainda pode ser feita direto no console, sem trilha de aprovação |
| Próximo exemplo recomendado | L22 — fila com DLQ e idempotência sob pico de eventos, para quando o gatilho deixa de ser um relógio e passa a ser volume que a API não aguenta receber de forma síncrona |
| Também habilitado por este módulo | L95 (enriquecimento em lote do acervo) reaproveita o histórico de execuções deste laboratório para decidir quais jobs toleram sair do tempo real; L98 (plataforma multi-time) reaproveita o padrão de grupo de agendamento por time |
| Data da última validação técnica | 7 de agosto de 2026 |
Documentação oficial consultada: Schedule types on EventBridge Scheduler — sintaxe de cron e rate, fuso horário e o comportamento exato em horário de verão, que é a fonte do achado central deste módulo; RetryPolicy e FlexibleTimeWindow na referência da API — faixas exatas de `MaximumRetryAttempts` (0 a 185), `MaximumEventAgeInSeconds` (60 a 86.400 s) e `MaximumWindowInMinutes` (1 a 1.440); Configuring a schedule's dead-letter queue — exigência de fila padrão (sem FIFO), permissão `sqs:SendMessage` e os atributos da mensagem morta, incluindo `EXHAUSTED_RETRY_CONDITION`; Adding context attributes — os placeholders `<aws.scheduler.scheduled-time>` e `<aws.scheduler.attempt-number>` usados na chave de idempotência; e as páginas de cota da Scheduler e do EventBridge clássico, que sustentam a comparação de escala (10 milhões de agendamentos por região contra 300 regras por barramento de eventos). Os valores de preço não aparecem neste módulo por decisão: use o AWS Pricing Calculator, porque preço varia por região e envelhece mais rápido que o conteúdo.
O que não foi verificado, e você deve conferir na sua conta
O comportamento exato quando o papel de execução da Scheduler não tem permissão para escrever na DLQ não está documentado com precisão suficiente para afirmar aqui — a seção "Quebrar de propósito" trata isso como algo a testar explicitamente, não como fato garantido. Da mesma forma, o valor padrão aplicado quando `RetryPolicy` é omitida inteiramente não é itemizado na referência da API consultada; declare `MaximumRetryAttempts` e `MaximumEventAgeInSeconds` explicitamente em todo agendamento de produção, em vez de depender de um padrão implícito.
Terminou de ler?
Marcar como concluído registra o XP, mantém sua sequência e coloca 3 cartas deste módulo na fila de revisão espaçada.
Próximos passos sugeridos
Temas deste módulo
Discussão
Carregando comentários…