Postmortem: KubernetesPodLowMemory no kelbi-pg (14/10/2026)
A análise e as evidências estão completas, e a causa raiz está confirmada no cgroup do próprio container. Duas coisas seguem pendentes, e a segunda é mais urgente que o alerta que originou esta página:
- A correção do alerta não foi aplicada. Ele continua no ar como está, e o item correspondente do RFC 0003 precisa ser reescrito, porque a correção que ele propõe não resolve o problema.
- O backup do banco não tem teste de restauração. Investigar o gatilho deste alerta expôs um risco de perda de dado produtivo, sem relação com fadiga de alertas. A verificação na AWS mostrou 239 dias de dumps sem lacuna e com integridade conferida, mas ninguém nunca restaurou nenhum deles. Ver O backup, por dentro.
| Campo | Valor |
|---|---|
| Data do incidente | Crônico. Medido na janela de 30 dias encerrada em 14/10/2026; o alerta existe desde 21/03/2026 |
| Severidade | Nenhum impacto em serviço. Impacto no sistema de alertas: 109 dos 246 episódios de alerta do mês (44%) vêm de um pod que nunca passou de 10% do seu limite de memória. Investigar o gatilho expôs um risco separado: o backup deste banco não tem teste de restauração |
| Status | Causa raiz confirmada; correção pendente. O alerta segue disparando. O backup foi conferido na AWS (239 dias sem lacuna, integridade OK) e segue sem teste de restauração |
| Serviços afetados | Nenhum serviço degradou. O banco é produtivo, apesar do sufixo -dev no nome do StatefulSet; o afetado é o canal #alerting |
| Detectado por | Auditoria do RFC 0003, que classificou o alerta como defeito D4 em 08/10/2026 |
| Mitigado por | Ninguém. Cada episódio se resolve sozinho quando o kernel recupera cache |
| Investigação pós-incidente e escrita | Yslan Ramos |
| Postura | Blameless: a análise mira o sistema e o processo, não as pessoas |
Resumo
Durante 130 horas dos últimos 30 dias, o #alerting recebeu o aviso de que o PostgreSQL do kelbi-pg estava com menos de 50 MiB de memória disponível, e num dos episódios com 5,4 MiB, ou 1% do seu limite. No mesmo intervalo, a memória que o processo de fato usava não saiu de 49 MiB e nunca houve OOMKill: o contador oom_kill do cgroup está em zero, e o pod está de pé sem nenhum restart desde 07/10/2026. Nenhum usuário foi afetado em nenhum momento.
Este banco é produtivo. O StatefulSet se chama kelbi-pg-root-app-kelbi-pg-dev-0 e o nameOverride do chart é kelbi-pg-dev, mas o sufixo é resquício de nome e não descreve o ambiente. Quem vê o alerta no canal lê -dev e despriorizar é a reação natural, o que erra duas vezes: o alerta é falso positivo, e o recurso por trás dele atende produção.
A métrica do alerta mede outra coisa. O kubelet calcula k8s.pod.memory.available como limite − working_set, e working_set inclui o cache de página do kernel. Para o PostgreSQL, cache de página é o mecanismo de funcionamento: ele mantém shared_buffers em 128 MiB e delega o resto ao cache do sistema operacional. O cache cresce até encostar no limite do cgroup, o kernel recupera o que precisa quando precisa, e nada disso é falha. Hoje, dos 512 MiB do limite, 48 MiB são memória do processo, 140 MiB são shared_buffers e 306 MiB são cache de arquivo descartável.
O gatilho diário tem nome: o kelbi-pg-backup-cronjob, que roda às 07:00 UTC, lê os 1,1 GB do banco em menos de quatro minutos e deixa o cgroup lotado. O backup termina às 07:04, o alerta dispara às 07:20 e só resolve às 10:20, quando o kernel recupera as páginas.
Dois agravantes fecham o quadro. O limiar de 50 MiB cai no meio da faixa normal de operação do pod, que oscila entre 44 e 80 MiB, então o sinal atravessa a linha para cima e para baixo várias vezes por dia; foi assim que um único pod produziu 109 episódios e cerca de 226 mensagens no canal, 27% de todo o volume. E o alerta que mediria isso corretamente já existe no repositório, com severity: critical e a descrição "OOMKill imminent": ele nunca disparou porque a recording rule que o alimenta está vazia há sete meses.
Investigar o gatilho levou ao segundo assunto desta página, que não é sobre alertas: o cluster não tem como saber se o backup deste banco funciona. Cada execução deixa exatamente uma amostra no Prometheus, porque o Job é apagado dez segundos depois de terminar, e com ele vão o pod e os logs. Não há teste de restauração, não há métrica que persista, e o único alerta que pegaria uma falha esteve saturado em outra coisa durante 666 das últimas 720 horas.
A resposta estava na AWS, e é melhor do que a telemetria sugeria. O bucket tem 241 dumps cobrindo 239 dias consecutivos, sem uma única lacuna desde 19/02/2026, com tamanho crescendo de forma monótona. Baixei dois deles e o CRC32 do gzip passa nos dois, com header PGDMP válido do PostgreSQL 16. O dia 05/10, cuja única amostra no Prometheus registra sucesso 0 e falha 0, tem seu dump no bucket com 212,7 MiB. O backup funciona; o que não existe é o sinal que diria isso sem alguém ir olhar, e um teste de restauração que prove que o arquivo reconstrói o banco. Detalhes em O backup, por dentro.
Linha do tempo
A forma do problema é diária e idêntica. A linha do tempo abaixo é a do dia 14/10/2026, a última completa; as outras 29 do mês são variações da mesma.
| Horário (14/10/2026, UTC) | Evento |
|---|---|
| 06:30 | Estado de repouso: 58,9 MiB disponíveis, RSS 49,4 MiB. O pod opera a 9% do limite e o alerta está a 9 MiB de disparar |
| 07:00:00 | kelbi-pg-backup-cronjob é escalonado (schedule: 0 7 * * *) |
| 07:00-07:04 | O backup lê o banco inteiro. blks_read sobe de 795/s para 1.241/s e a taxa de acerto de shared_buffers desaba de 610/s para 140/s: o volume lido não cabe nos 128 MiB de buffer |
| 07:03:58 | Backup conclui com sucesso (lastSuccessfulTime). O gatilho já terminou aqui |
| 07:10 | 52,8 MiB disponíveis. O cache deixado pelo backup já consumiu 20 MiB da folga |
| 07:15 | Cruza os 50 MiB. Começa a contar o for: 5m |
| 07:20 | KubernetesPodLowMemory entra em firing e notifica o #alerting |
| 07:30 | 5,4 MiB disponíveis, 1% do limite. RSS: 49,4 MiB, o mesmo valor de uma hora antes |
| ⏳ 2h50 de alerta ativo | Nada acontece e nada precisa acontecer. O cgroup fica em memory.current ≈ 508 MiB porque cache de página não é devolvido antes de ser necessário. O banco responde normalmente, o oom_kill segue em zero e ninguém é acionado |
| 10:10 | 47,8 MiB. O kernel começou a recuperar |
| 10:20 | 77,4 MiB disponíveis. O alerta resolve e notifica o #alerting de novo |
| 10:20-23:59 | A faixa normal de 44 a 80 MiB produz mais quatro travessias do limiar no mesmo dia |
O que esses horários somam
- 3h58 de gatilho real (o backup), dos quais 0 geraram alerta.
- 2h50 de
firing, cobrindo o rastro que o gatilho deixou depois de terminar. - 0 segundo de risco para o serviço: o uso real do processo não variou 1 MiB em toda a janela.
- 5 episódios no dia, ou seja, ao menos 10 mensagens no canal.
Evidência
O RSS do processo não acompanha o alerta
A métrica do alerta ao lado do RSS do mesmo pod, na janela do backup:
| Horário (UTC) | available | RSS | Alerta |
|---|---|---|---|
| 06:30 | 58,9 MiB | 49,4 MiB | |
| 07:00 | 73,1 MiB | 49,4 MiB | |
| 07:20 | 45,3 MiB | 49,6 MiB | firing |
| 07:30 | 5,4 MiB | 49,4 MiB | firing |
| 08:30 | 5,4 MiB | 49,4 MiB | firing |
| 09:30 | 23,8 MiB | 49,4 MiB | firing |
| 10:10 | 47,8 MiB | 49,6 MiB | firing |
| 10:20 | 77,4 MiB | 50,4 MiB | resolvido |
A coluna do meio varia 14 vezes; a da direita varia 1 MiB. Em sete dias, o RSS tem p50 de 46,4 MiB, p95 de 50,0 MiB e máximo de 60,8 MiB, contra um limite de 512 MiB.
O que o cgroup mostra
Lido de dentro do container, em /sys/fs/cgroup:
memory.max 536870912 512,0 MiB o limite
memory.current 532328448 507,7 MiB permanentemente encostado
memory.peak 536870912 512,0 MiB
memory.stat
anon 50446336 48,1 MiB memória do processo
shmem 147292160 140,5 MiB shared_buffers
file 467955712 446,3 MiB cache de página (shmem incluído)
active_file 248733696 237,2 MiB
inactive_file 71929856 68,6 MiB
slab 5837704 5,6 MiB
memory.events
max 25 o cgroup encostou no limite 25 vezes
oom 0
oom_kill 0 e em nenhuma delas alguém foi morto
A conta que importa: file − shmem dá 305,8 MiB de cache de arquivo puro, descartável sem custo de correção. Somando o que o pod realmente não pode perder (48,1 de anon, 140,5 de shmem e 5,6 de slab), chega-se a 194 MiB, praticamente o requests.memory: 192Mi declarado no chart. Quem dimensionou o request acertou. O limite de 512 MiB são 318 MiB de espaço de cache, e é esse espaço que o alerta interpreta como memória em falta.
max 25 contra oom_kill 0 é a prova direta: 25 vezes o cgroup chegou ao teto e 25 vezes o kernel resolveu recuperando cache, que é o comportamento projetado.
O limiar está dentro da faixa normal
Sequência real de available em MiB, em intervalos de 5 minutos, numa janela qualquer sem backup:
78,9 78,6 78,4 80,8 76,5 72,5 76,6 72,0 76,5 59,9
45,5 45,7 45,9 46,5 46,7 44,5 44,4 44,6 44,3 52,1
52,1 51,9 52,1 52,0 48,7 47,1 49,3 47,6 50,8 50,6
61,4 61,1 58,0 60,9 61,0 60,6 60,0
Os valores 52,1 / 48,7 / 47,1 / 49,3 / 47,6 / 50,8 estão todos a menos de 3 MiB da linha de 50. Um limiar colocado no meio da banda de operação conta travessias em vez de separar estado bom de estado ruim. Com for: 5m, cada travessia que durar mais que isso é um episódio novo, com notificação de abertura e de fechamento.
O horário dos episódios coincide com o cronjob
Percentual do tempo abaixo do limiar, por hora do dia, nos últimos 14 dias:
| Hora (UTC) | Abaixo de 50 MiB | Hora (UTC) | Abaixo de 50 MiB | |
|---|---|---|---|---|
| 03 | 0,0% | 08 | 91,1% | |
| 04 | 0,0% | 09 | 92,9% | |
| 06 | 7,7% | 10 | 80,4% | |
| 07 | 45,8% | 11 | 46,4% | |
| 12-23 | 3% a 25% | 00-02 | 5% a 10% |
A janela 07:00-11:00 UTC concentra o problema, e 07:00 UTC é o schedule do backup. Fora dela o alerta ainda dispara, porque a faixa normal encosta no limiar.
O volume que isso produz no canal
Episódios de alerta nos 30 dias, por alertname, contados como travessias distintas e não como tempo em firing:
alertname | Episódios | Horas em firing |
|---|---|---|
KubernetesPodLowMemory | 117 | 143,7 |
KubernetesContainerHighRestarts | 22 | 1.860,2 |
InstanceDown | 15 | 2,1 |
PrometheusAllTargetsMissing | 14 | 2,0 |
PostgresqlNotEnoughConnections | 12 | 703,9 |
KubernetesAppPodsNotRunning | 11 | 661,0 |
KubernetesPodPendingTooLong | 11 | 660,9 |
| demais 16 alertas | 44 | |
| Total | 246 |
Dos 117 episódios de KubernetesPodLowMemory, 109 são do kelbi-pg (130,4h, mediana de 25 minutos, máximo de 5h25) e 7 são do pod do próprio OTel Collector. Com group_interval: 5m e repeat_interval: 4h no alertmanager.yml, e contando abertura, fechamento e as repetições dos 7 episódios que passaram de 4h, são cerca de 226 mensagens em 30 dias: 7,5 por dia, ou 27% das 28 notificações/dia que o canal recebe hoje.
A tabela também mostra por que medir fadiga por tempo em firing engana. KubernetesContainerHighRestarts tem 14 vezes mais horas e 5 vezes menos episódios, e é o episódio que gera notificação.
O alerta que mediria isso tem a regra vazia
O repositório já tem a regra que descreveria este pod corretamente:
- alert: KubernetesContainerMemoryExceedsLimit
expr: otel:container_memory_limit_utilization:ratio >= 1
labels:
severity: critical
annotations:
description: "... using {{ $value }}% of its memory limit — OOMKill imminent."
A recording rule por trás dela tem zero séries, porque k8s_container_memory_limit_utilization nunca chegou ao Prometheus: são métricas opcionais do receiver kubeletstats, desabilitadas por padrão, e o chart habilita o preset kubeletMetrics sem configurá-las. Quatro recording rules estão vazias pelo mesmo motivo, e sete alertas dependem delas:
| Alerta | Severidade | Pode disparar? |
|---|---|---|
KubernetesContainerMemoryNearLimit | warning | Não |
KubernetesContainerMemoryExceedsLimit | critical | Não |
KubernetesContainerCPUNearLimit | warning | Não |
KubernetesContainerMemoryRequestOverProvisioned | info | Não |
KubernetesContainerCPURequestOverProvisioned | info | Não |
KubernetesContainerMemoryExceedsRequest | warning | Não |
KubernetesContainerCPUExceedsRequest | warning | Não |
É o defeito D7 do RFC 0003 outra vez: regra carregada, aprovada pelo promtool, sem dado nenhum por baixo.
O dado para construir a versão correta já existe: k8s_pod_memory_rss_bytes (56 séries) e k8s_container_memory_limit_bytes (18 séries, uma por container com limite declarado). A razão entre as duas, para este pod, dá 9,3% do limite no mesmo instante em que o alerta atual reporta menos de 1% disponível.
Mitigação
Não houve. Cada episódio se resolve quando o kernel recupera cache, em mediana 25 minutos depois de abrir, sem intervenção. Não há o que mitigar porque não há falha: a condição de fundo é o modo normal de operação de um PostgreSQL com cache de página.
A condição que continua lá é o alerta. Nada foi alterado em prometheus/rules/alerts/kubernetes.yml, e ele vai disparar amanhã às 07:20 novamente.
Impacto
Nenhum impacto em serviço, verificado por três vias independentes: oom_kill em zero no cgroup, k8s_container_restarts em zero para os dois containers do namespace, e o pod Running sem restart desde 07/10/2026 00:52:53 UTC.
O impacto é no sistema de alertas, e é mensurável:
- 44% dos episódios de alerta do mês vêm deste pod.
- 27% do volume de notificações do
#alerting(7,5 de 28 por dia). - O canal é o mesmo que, no incidente de 06/10/2026, custou 9h48 de detecção porque ninguém mais o lia. O alerta correto disparou na hora certa e se perdeu no ruído. Uma fração grande desse ruído, agora quantificada, é este pod.
Esses 44% dos episódios vêm todos de um recurso cujo nome diz -dev, e isso agrava os dois lados. Para quem triava, o volume era alto e aparentemente descartável, o que torna o hábito de ignorar o canal mais fácil de adquirir. E se o pod algum dia tiver um problema de verdade, o alerta legítimo vai chegar com o mesmo nome, no mesmo canal, pedindo a mesma atenção que 109 falsos positivos ensinaram a não dar.
A investigação encontrou também um custo real de desempenho, sem relação com o alerta, que importa porque o banco é produtivo: está em Achados laterais.
Causa raiz
Gatilho
O kelbi-pg-backup-cronjob, criado em 19/02/2026, com schedule: 0 7 * * * e resources: {}. Ele roda um pg_dump -Fc do banco kelbi inteiro, e o pg_stat_activity_max_tx_duration mede do lado do servidor o que isso custa: sai de 0,0s para 85,2s às 07:02 e volta a zero. São 85 segundos de leitura sequencial de 1,1 GB dentro de uma única transação, e é essa leitura que preenche o cache de página do cgroup do PostgreSQL. O pg_dump roda no pod do cronjob, mas quem lê os arquivos é o backend do banco.
O gatilho é o backup, interno e desejado, e já estava sob suspeita: o postmortem de 06/10/2026 registrou uma ação para acompanhá-lo, porque consome 35% da CPU da VPS por ~5 minutos por dia sem supervisão. Ao abrir os repositórios que o implementam, a falta de supervisão mostrou-se maior do que aquela ação supunha, e está em O backup, por dentro.
Nos dias sem backup o alerta ainda dispara, só menos. O gatilho explica os episódios longos de três horas, e não os outros.
Mecanismo
Três camadas empilhadas, e só a terceira é um defeito nosso.
Primeira: a métrica mede o cache como se fosse consumo. O kubelet reporta memory.availableBytes de um pod como limite − working_set, e só o reporta para pods com limite declarado (daí as 18 séries). working_set, por sua vez, é usage − inactive_file: desconta o cache inativo, mas mantém os 237 MiB de active_file. Cache de página ativo é memória que o kernel devolve na hora em que alguém precisar dela, então contá-la como indisponível inverte o significado da palavra.
Segunda: o banco não cabe no limite, por projeto. O dataset tem 1,1 GB e o limite do pod é 512 MiB. Nenhuma configuração faz 1,1 GB caber em 512 MiB, então o cache vive saturado e memory.current fica permanentemente em ~508 MiB. O available de repouso, por consequência, é de ~70 MiB e não de 400: a folga aparente do pod é uma folga que o kernel já ocupou com cache útil.
Terceira: o limiar foi posto dentro da faixa de operação. < 52428800 são 50 MiB fixos, contra uma faixa normal de 44 a 80 MiB, o que corta a banda de ruído pelo meio. Com for: 5m, cada oscilação virou um episódio e cada episódio virou duas mensagens.
Por que isso virou 109 notificações em 30 dias
Por que o alerta dispara? Porque limite − working_set cai abaixo de 50 MiB.
Por que working_set é alto? Porque inclui o cache de página ativo, e o PostgreSQL usa cache de página como projeto, com apenas 128 MiB de shared_buffers.
Por que o cache enche até o teto? Porque o dataset é mais que o dobro do limite do pod. O kernel só recupera cache quando há pressão, então o estado normal deste cgroup é "cheio".
Por que isso produz tantos episódios, e não um só? Porque o limiar está a poucos MiB do valor de repouso, e nessa posição ele mede variância em vez de estado.
Por que ninguém corrigiu em sete meses? Porque o alerta veio de um pacote genérico adotado em bloco em 21/03/2026 (e66c7e2, "resource tuning alerts"), sem revisão item a item contra as cargas reais do cluster. É o defeito D3 do RFC 0003, e este é o alerta mais caro do pacote.
Por que o alerta correto não cobriu a lacuna? Porque ele foi escrito sobre uma métrica que o collector não envia. A regra existe, o promtool aprova, e o resultado é silêncio, sem nenhum sinal de que seja silêncio.
A parte controlável da causa raiz são três ausências:
- Nenhuma validação de que uma regra adotada produz séries. Quatro recording rules e sete alertas estão vazios há sete meses e nada reclamou.
- Nenhum critério para escolher o sinal antes de escolher o limiar.
availablefoi usada porque existia, e não porque descreve o que se queria saber. - Nenhuma revisão de pacote genérico contra a carga real. Um limiar absoluto de 50 MiB pode ser razoável para um sidecar de 64 MiB e é sem sentido para um banco de dados.
Por que não detectamos antes
São três situações diferentes, com correções diferentes.
O dado existia e ninguém olhou. k8s_pod_memory_rss_bytes é coletado desde março, pelo mesmo receiver e no mesmo scrape da métrica que o alerta usa. Bastava colocar as duas no mesmo gráfico para ver uma variar 14 vezes enquanto a outra ficava parada. A auditoria do RFC 0003 chegou perto em 08/10/2026: classificou o alerta como D4 e anotou que "para PostgreSQL, memória disponível baixa é operação normal". Acertou o diagnóstico e parou antes de abrir o cgroup, e foi por isso que prescreveu a correção errada (ver abaixo).
O dado não existia, e o vazio não avisou. As métricas de utilização por limite nunca chegaram. Não há nada no sistema que diferencie "a regra não dispara porque está tudo bem" de "a regra não dispara porque não tem dado". Essa assimetria é o que faz o D7 ser invisível: um alerta ruidoso incomoda até ser corrigido, e um alerta morto não incomoda nunca.
No caso do backup, o dado foi apagado antes de alguém poder olhar, e estávamos olhando no lugar errado. A telemetria estava configurada, o receiver coletava, e o ttl_seconds_after_finished: 10 destruiu a fonte dez segundos depois de cada execução. Ficou uma amostra por dia, insuficiente para distinguir sucesso de falha, e nada no sistema sinaliza que uma série de um ponto não serve para responder nada. Esse valor não veio do Terraform: foi uma edição manual pelo Lens, e o módulo continua declarando 600.
O que faltou perceber é que o registro existia do outro lado. O objeto no S3 é durável por natureza, carrega data, hora e tamanho no próprio nome, e nunca foi apagado: 239 dias de histórico estavam a uma listagem de distância enquanto a investigação tentava reconstruir o mesmo histórico a partir de uma métrica que durava dez segundos. O backup produz o seu próprio registro de sucesso, e ninguém o estava lendo.
Vale registrar à parte que a correção proposta no RFC não resolveria. O item deste alerta pede um "threshold relativo a limit/request", e working_set / limit neste pod é ~87% de forma constante, então um limiar relativo sobre o mesmo numerador dispararia igual, ou pior, de forma contínua em vez de intermitente. Trocar 50 MiB por 90% mantém o defeito e esconde a origem dele, que está no numerador.
O backup, por dentro
O gatilho é implementado por três repositórios: pg-backup-script, a imagem que roda no CronJob; pg-backup-terraform, o módulo que provisiona bucket, IAM, role no Postgres, Secret e o próprio CronJob; e infrastructure-kelbi, o Terragrunt que instancia o módulo com os valores reais.
O que o script faz
backup.py é curto e direto: monta pg_dump -h … -Fc -d $PG_DB, escreve a saída num arquivo, envia ao S3 e apaga o arquivo local. Dois detalhes importam aqui.
O dump é comprimido duas vezes. O formato -Fc do pg_dump já aplica compressão por padrão, e o script passa essa saída por gzip.open(..., compresslevel=6). Comprimir dado já comprimido gasta CPU e não reduz praticamente nada. Em qualquer outro lugar seria desperdício tolerável; neste nó, não. O postmortem de 06/10/2026 mostrou que a VPS opera com ~40% de CPU de base, que o provedor corta a CPU sem aviso quando acha que é demais, e que este backup é o maior pico diário de CPU da máquina.
O arquivo intermediário vai para /tmp do container. Com resources: {} no CronJob, o pod é da classe BestEffort: sem request e sem limit, ele é o primeiro candidato à evicção se o nó tiver pressão de memória, e nada limita quanto ele pode consumir enquanto escreve o dump. O comentário no código diz que o streaming evita carregar o dump em memória, e evita, mas o arquivo escrito ainda entra como cache sujo no cgroup do próprio pod de backup, que não tem teto.
O Terraform não descreve o que está rodando
O CronJob no cluster divergiu do que o código declara. A comparação, campo a campo, já considerando o que o Terragrunt passa como entrada:
| Campo | Declarado no código | Cluster | Estado |
|---|---|---|---|
concurrency_policy | Replace | Replace | igual |
backoff_limit | 2 | 2 | igual |
successful_jobs_history_limit | 10 | 10 | igual |
schedule | @daily, ou seja 0 0 * * * | 0 7 * * * | divergente |
failed_jobs_history_limit | 3 | 1 | divergente |
starting_deadline_seconds | 300 | 10 | divergente |
ttl_seconds_after_finished | 600 | 10 | divergente |
O schedule entrou na lista por causa do infrastructure-kelbi: o terragrunt.hcl não passa schedule nos inputs, então vale o default do módulo, que é @daily, meia-noite UTC. O backup roda às 07:00 UTC. Um terraform apply moveria o horário do backup em sete horas, além de reverter os outros três campos.
A origem da divergência está registrada no próprio objeto: ele carrega o label k8slens-edit-resource-version: v1 e está em generation: 2. O CronJob foi editado à mão pelo Lens depois do provisionamento. É o defeito D5 do RFC 0003, divergência entre o Git e o que roda, numa terceira frente.
O histórico do S3 datou essa edição e mostra que ela foi deliberada. Os nomes dos objetos carregam a hora de cada execução, e há um degrau claro:
| Período | Execuções | Hora |
|---|---|---|
| 19/02/2026 a 14/03/2026 | 25 | 00:00-01:58 UTC |
| 14/03/2026 a 15/10/2026 | 216 | 07:00 UTC |
No dia 14/03 há dois dumps, um às 00:01 e outro às 07:01, que é a assinatura da troca. Então @daily não é a intenção que o drift quebrou: era o comportamento original, e alguém o mudou de propósito para as 07:00 UTC. O módulo é que ficou desatualizado em relação à decisão.
Isso muda o que um terraform apply significa aqui. Ele não corrigiria um desvio; reverteria uma decisão de sete meses atrás, de volta para a meia-noite. E como o schedule não está nos inputs do Terragrunt, a decisão não está escrita em lugar nenhum: ela existe só no objeto do cluster, que é o que o apply sobrescreve.
Dos quatro campos divergentes, o starting_deadline_seconds é o que mais preocupa. Se o controlador do CronJob atrasar mais de 10 segundos para escalonar a execução, o Kubernetes considera o horário perdido e pula o backup do dia, em silêncio. Com os 300 segundos que o módulo declara, haveria cinco minutos de tolerância. O S3 mostra que em 239 dias nenhuma execução foi pulada, então a margem apertada nunca cobrou, num nó que já ficou dez horas e meia com a CPU limitada a 20%.
Nos três campos que não são o schedule, o valor que está no ar é pior que o declarado: prazo menor, histórico menor, retenção de Job menor. A edição manual apertou três folgas que o módulo tinha dado de propósito.
Por que a edição foi à mão
O infrastructure-kelbi responde isso, e a resposta muda a leitura do item acima. O Terragrunt declara o acesso ao cluster assim:
kubernetes = {
namespace = "kelbi-pg"
config_context = "arca"
config_path = "~/.kube/config.bk"
}
Nenhum dos dois existe. Não há ~/.kube/config.bk na máquina de operação, apenas config e um config.bak.20261012-121447, e o contexto arca não existe em nenhum kubeconfig; os disponíveis são hostinger e kind-kind. O módulo, como está declarado, não pode ser aplicado.
Quando terraform apply não roda, editar pelo Lens é a única forma de mudar o que está no ar, e o custo disso é que o código parou de descrever a realidade sem que nada reclamasse. A ação a tomar é tornar o apply possível e só então reconciliar.
Dois outros detalhes do mesmo arquivo:
- O módulo não está pinado. O
sourceaponta parapg-backup-terraform.git//?ref=main. Qualquer push namaindaquele repositório muda o que o próximo apply vai deployar, sem que nada aqui mude. Combinado com o apply quebrado, isso significa que o primeiro apply bem-sucedido vai aplicar de uma vez todo o acumulado desde fevereiro. - Há um input que não existe. O arquivo passa
default = "us-east-1", e o módulo não tem variáveldefault; a que existe éaws_region. O Terraform ignora variável não declarada com um aviso, eaws_regioncai no próprio default, que também éus-east-1. O efeito hoje é nulo, mas quem tentar mudar de região editando essa linha não vai mudar nada.
O histórico do backup no Prometheus: uma amostra por execução
| Data | Amostras no Prometheus | successful_pods | failed_pods |
|---|---|---|---|
| 01/10 a 04/10 | 1 por dia | 1 | 0 |
| 05/10 | 1 | 0 | 0 |
| 06/10 a 12/10 | 1 por dia | 1 | 0 |
| 13/10 | 1 | 1 | 0 |
| 14/10 | 1 | 1 | 0 |
Catorze execuções e catorze pontos. O ttl_seconds_after_finished: 10 apaga o objeto Job dez segundos depois de ele terminar, e o receiver não tem tempo de coletar uma segunda amostra. Com o Job vai o pod, e com o pod vão os logs, inclusive a linha Dump complete: … (X MB), que é o único lugar onde o tamanho do dump é registrado.
A consequência é que pelas métricas não se pode saber se um backup funcionou. A linha de 05/10 é a demonstração: successful_pods = 0 e failed_pods = 0. A única amostra pegou o Job em andamento, e não existe amostra seguinte. Sucesso, falha e substituição pela política Replace produzem exatamente o mesmo registro.
A resposta sobre 05/10 veio do S3, e é sucesso: o dump daquele dia está no bucket, com 212,7 MiB e CRC32 válido. Ver O que a AWS mostra.
Há ainda o agravante de nomenclatura: o Job herda o índice de escalonamento no nome (kelbi-pg-backup-cronjob-29866020), que muda todo dia. Cada execução é uma série nova, então não existe uma série que se possa consultar para perguntar "o backup de ontem passou?". São 237 séries distintas desde fevereiro, cada uma com um ponto. É o defeito D6 do RFC, aqui como perda da capacidade de medir, e não como custo de cardinalidade.
E o alerta que deveria cobrir isso não cobre. KubernetesJobHasFailedPods tem for: 0m e dispararia, se chegasse a ver a falha, mas o Job falhado também é apagado em 10 segundos. Pior: durante 12 dos últimos 14 dias esse alerta esteve em firing contínuo (666 horas na janela de 30 dias) sobre duas séries anônimas, sem job nem instance, de dois Jobs travados que nada tinham a ver com o backup. Um alerta já saturado não muda de estado, e sem mudança de estado não há notificação. Se o backup tivesse falhado nesse período, o sinal teria se somado a um alerta que já estava aceso e ninguém teria sido avisado.
Pelo lado do Kubernetes não há métrica de sucesso que persista mais de um ponto, não há alerta capaz de pegar uma falha, não há registro do tamanho do dump e os logs são destruídos dez segundos após cada execução. O que existe é um lastSuccessfulTime no objeto CronJob, que diz apenas que o processo saiu com código 0.
O bucket, por outro lado, guarda o histórico completo, e foi lá que a investigação terminou: O que a AWS mostra. O sinal que falta construir já existe, e é o objeto no S3.
Continua sem haver restauração. Procurei pg_restore nos 54 repositórios da organização: as únicas ocorrências estão em testhelpers_db.go do Erupe e do Erupeup, sem relação com estes backups. Não existe script, runbook, job ou teste de restauração em lugar nenhum, nem automatizado, nem manual e documentado.
O que a AWS mostra
Tudo acima descreve o que o cluster sabe. O bucket kelbi-pg, na conta 545888714756, guarda o histórico que o cluster apagou, e ele é bom:
| Medida | Valor |
|---|---|
| Objetos | 241 |
| Janela | 19/02/2026 a 15/10/2026 |
| Dias na janela | 239 |
| Dias com dump | 239 |
| Dias sem dump | 0 |
| Armazenado | 53,1 GB, todos em STANDARD |
| Tamanho do dump | 206,8 MiB no primeiro, 212,9 MiB no último |
Nenhum backup foi pulado em 239 dias. Os dois dias com dois objetos são 19/02, o dia da criação, e 14/03, pelo motivo da seção seguinte. O tamanho cresce 0,8 MiB por mês, de forma monótona, o que é o que se espera de um banco em uso e não de um dump truncado.
O dia 05/10 funcionou. kelbi_2026-10-05_07-00-09.dump.gz, 212,7 MiB, na mesma linha dos vizinhos. A pergunta que as métricas não podiam responder o bucket responde em uma listagem.
Verificação de integridade dos dumps
Com os arquivos em mão, verifiquei dois deles, o mais recente e o de 05/10:
| Verificação | 15/10/2026 | 05/10/2026 |
|---|---|---|
CRC32 do gzip (gunzip -t) | passa | passa |
Magic PGDMP | presente | presente |
| Versão do formato | 1.15.0 (PostgreSQL 16) | 1.15.0 (PostgreSQL 16) |
| Banco no header | kelbi | kelbi |
| Tamanho descomprimido | 214,9 MiB | 214,8 MiB |
O CRC32 do gzip cobre o arquivo inteiro, então ele passar prova que o objeto está completo e não corrompido, do primeiro ao último byte. O header prova que é um arquivo custom-format do PostgreSQL 16, do banco kelbi.
O que isso não prova: que uma restauração funciona. Ler o header e validar o CRC é muito mais do que "o processo saiu com código 0", e ainda não é um pg_restore concluído contra um banco vazio. A ação de testar a restauração continua aberta, e agora com uma pergunta menor: o arquivo está íntegro, falta saber se o conteúdo dele reconstrói o banco.
A verificação também mediu a compressão dupla. O arquivo comprimido tem 223.201.101 bytes e o descomprimido 225.349.312: o gzip externo economizou 0,95%. O header confirma o motivo, no byte de algoritmo de compressão: o -Fc já tinha comprimido o conteúdo com gzip. São duas passadas de compressão no maior pico diário de CPU da VPS para economizar 2 MiB em 215.
O que o módulo provisiona na AWS
Quatro observações sobre o Terraform, fora do escopo do alerta mas no mesmo componente, agora confirmadas contra a conta:
- Nenhuma política de lifecycle no bucket, confirmado por
NoSuchLifecycleConfiguration. Cada dump diário é um objeto novo emSTANDARD, sem expiração nem transição de classe. São 53,1 GB hoje, ou cerca de 1,22 USD/mês, crescendo 6,7 GB/mês. Em doze meses sem retenção chega a 135 GB e 3,10 USD/mês; com retenção de 90 dias estabilizaria em 20 GB e 0,46 USD/mês. O valor é pequeno, e o que falta não é dinheiro: é a decisão de por quanto tempo um backup deve ser guardado. - Nenhum versionamento, nenhum object lock e nenhuma replicação, os três confirmados na conta. A policy do IAM não concede
DeleteObject, o que é bom, mas não há proteção contra sobrescrita de um objeto existente, e não há cópia fora deus-east-1. - A encriptação em repouso existe, com SSE-S3 (
AES256). Ela não vem do módulo: é o padrão da S3 desde 2023. - Chave de acesso IAM estática no Secret do Kubernetes.
aws_iam_access_keygera credencial de longa duração, guardada no Secret e sem rotação prevista. Há também umaws_iam_rolecom trust policy paraec2.amazonaws.comque nada assume, já que o workload roda em k3s e não em EC2, então é recurso morto que só aumenta a superfície a entender.
Achados laterais
Três achados sem relação com o defeito do alerta, vindos do cgroup do container e das métricas pg_settings_* do postgres-exporter. Eles ficariam em segundo plano se o banco fosse de desenvolvimento; como é produtivo, cada um custa latência a usuário real e merece uma decisão explícita.
O cgroup faz thrashing de cache, e isso tem custo. Em 191 horas de vida do pod, o kernel varreu 4.344.833 páginas e recuperou 4.339.862, cerca de 16,5 GiB. Destas, 97,9% foram em direct reclaim (pgscan_direct), que é recuperação síncrona: acontece no momento da alocação e para quem está alocando. E workingset_refault_file está em 4.104.816 páginas, ou seja, praticamente tudo que foi recuperado precisou ser lido de novo. É a assinatura de um dataset que não cabe no cache disponível, e explica o blks_read de 795/s (≈6,2 MiB/s) sustentado em um banco com apenas 17 conexões e ~500 tuplas/s de retorno.
A correção é a oposta da que o alerta sugere: elevar o limits.memory do pod. O dataset tem 1,1 GB e o limite é 512 MiB, e qualquer valor abaixo do dataset mantém o thrashing, só mais fraco. Medindo o nó, isso não custa nada: são 8,62 GiB totais com 3,90 GiB em uso real e 2,67 GiB disponíveis, e limits é teto e não reserva, porque quem reserva é o requests, que fica nos 192Mi. Vale medir a latência antes e depois, para que a decisão seja tomada sobre um número.
effective_cache_size está em 4 GiB num pod de 512 MiB, e ninguém o configurou assim. O planejador de consultas acredita ter 4 GiB de cache de sistema operacional à disposição e precifica leituras como se fossem baratas, quando o valor real disponível é da ordem de 300 MiB. Planos escolhidos com essa premissa são sistematicamente mal custeados, o que em produção significa consultas escolhendo o caminho errado com regularidade, e contribui para o blks_read alto.
O importante é como esse valor chegou lá. No postgresql.conf da imagem Bitnami, a linha existe comentada (#effective_cache_size = 4GB na linha 409), e o mesmo vale para #shared_buffers = 128MB na linha 130. Comentado significa que vale o default compilado do PostgreSQL, e 4 GB e 128 MB são esses defaults. Então não houve decisão errada: não houve decisão. O chart entrega a configuração de upstream intocada, dimensionada para uma máquina com vários GB de RAM, e ela acabou governando um banco num cgroup de 512 MiB. É o defeito D3 do RFC 0003 fora do seu lugar de origem: a RFC o catalogou em pacote de alertas adotado em bloco, e aqui ele está na configuração do banco.
Ajustar para algo próximo de 256-384 MiB é mudança de uma linha no values do chart, mas muda plano de consulta e por isso não deve ser feita de passagem.
Os dois achados de configuração têm observabilidade muito diferente, e isso explica por que só um deles podia ter sido notado antes.
shared_buffers é memória compartilhada, então ele aparece no cgroup: o shmem de 147.292.160 bytes lido acima são 140,5 MiB, e os 134.217.728 do shared_buffers são 91% disso, com os 12,5 MiB restantes em lock tables, wal buffers e afins. Dá para olhar só o cgroup e inferir o valor, e foi essa concordância entre duas fontes independentes que permitiu afirmar, na leitura do cgroup, que 140 MiB dos 512 são shared_buffers.
effective_cache_size não aloca um byte. É um número que o planejador multiplica ao estimar custo de acesso, e não tem footprint em memory.stat, em /proc nem em lugar nenhum da memória. Uma configuração que aloca deixa rastro e pode ser pega por várias vias; uma que só entra numa conta de custo só aparece se alguém consultar pg_settings. E consultar o arquivo não bastaria, porque lá a linha está comentada: o valor efetivo não está escrito em lugar nenhum do repositório nem do container.
O nome não descreve o recurso. O StatefulSet produtivo se chama kelbi-pg-root-app-kelbi-pg-dev-0, pelo nameOverride: kelbi-pg-dev no chart. O custo imediato é de triagem: todo alerta deste banco chega ao canal parecendo descartável. O custo maior é que o RFC 0003 cataloga justamente o sufixo -dev como uma das convenções de nome a partir das quais se poderia derivar o ambiente, na falta de um label. Derivar daqui classificaria o banco produtivo como desenvolvimento e o rotearia para o canal errado, o inverso do que os itens 4 e 5 da RFC querem construir. Renomear um StatefulSet com volume é operação delicada; declarar o label de ambiente explicitamente resolve o roteamento sem tocar no nome.
Lições aprendidas
O que fomos bem
A telemetria permitiu fechar a causa raiz sem reproduzir nada. rss, working_set, usage, blks_read e blks_hit já estavam coletados, e a série de ALERTS do próprio Prometheus deu a contagem de episódios dos 30 dias. A investigação foi inteiramente retrospectiva.
A instrumentação do RFC 0003 funcionou como pretendido. hygiene:notifications:per_day caiu de 60 para 28 depois das correções da semana, e foi esse número que tornou possível dizer que este alerta é 27% do canal, em vez de "esse alerta parece disparar muito".
O request de memória estava certo. Os 192 MiB declarados no chart batem com os 194 MiB irredutíveis medidos no cgroup. Quem dimensionou não errou; o alerta é que não lê o request.
A auditoria do RFC tinha identificado o alerta antes deste postmortem. O defeito foi catalogado como D4 em 08/10/2026, com o diagnóstico correto na primeira frase. O que faltou foi descer um nível, até o cgroup.
O que não fomos bem
Escolhemos o limiar antes de escolher o sinal. A discussão foi sempre "50 MiB é pouco ou muito", e a pergunta certa era "available descreve o que eu quero saber?". Não descreve, e nenhum valor de limiar consertaria isso.
Adotamos um pacote de alertas em bloco e nunca o revisamos contra a carga real. Sete meses depois, um dos alertas desse pacote é a maior fonte de ruído do canal e sete outros nunca puderam disparar.
Default de upstream também é configuração adotada em bloco. O effective_cache_size de 4 GiB num pod de 512 MiB não foi escolha de ninguém: é o default compilado do PostgreSQL, com a linha comentada no postgresql.conf da imagem. Um banco em cgroup apertado herdou premissas de uma máquina com vários GB de RAM, e o mesmo defeito que a RFC catalogou em pacote de alerta estava na configuração do banco, sem ninguém ter olhado.
Regra vazia passa por regra boa. O promtool valida sintaxe, não existência de dado, e não temos nada que pergunte "esta expressão retorna séries?". Por isso o alerta critical que diria "OOMKill imminent" está morto há sete meses sem que ninguém notasse.
Deixamos o ruído conviver com um incidente real. O postmortem de 06/10/2026 já havia concluído que o #alerting tinha deixado de funcionar como canal. Entre aquela conclusão e esta investigação, este alerta continuou produzindo 7,5 mensagens por dia no mesmo canal.
Prescrevemos correção antes de entender o mecanismo. O item do RFC para este alerta está errado e teria sido implementado se a investigação não tivesse acontecido primeiro. O diagnóstico raso produziu uma ação que daria trabalho e manteria o defeito.
Deixamos um recurso produtivo com -dev no nome, e durante a investigação eu mesmo o tratei como ambiente de desenvolvimento até ser corrigido. O nome é o único rótulo de ambiente que este banco tem, e ele é falso, então por sete meses a prioridade de tudo o que ele emitia foi rebaixada em silêncio.
Automatizamos o backup e paramos ali. O módulo Terraform provisiona bucket, IAM, role, Secret e CronJob com cuidado, e nenhuma das cinco coisas que tornariam o backup confiável: teste de restauração, métrica de sucesso durável, alerta de falha, retenção no S3 e registro do tamanho do dump. A execução sempre funcionou, e são os 239 dias sem lacuna que mostram isso.
Fomos monitorar o executor em vez do resultado. Toda a instrumentação do backup aponta para o objeto Job do Kubernetes, que é efêmero por projeto, enquanto o artefato que importa é durável, datado e nomeado por tamanho no S3. A pergunta "o backup de ontem passou?" sempre teve resposta de um comando, no lugar onde o backup entrega o trabalho.
Deixamos o caminho de IaC quebrado e só descobrimos pelo rastro. O Terragrunt aponta para um kubeconfig e um contexto que não existem, então terraform apply não roda, e por isso quatro campos do CronJob foram para o Lens, um deles reduzindo de cinco minutos para dez segundos a tolerância antes de o Kubernetes pular o backup do dia. Nada no sistema sinaliza "este módulo não é mais aplicável"; descobre-se ao tentar.
O módulo também não está pinado, o que soma as duas coisas. O source aponta para ref=main, então o primeiro apply bem-sucedido vai aplicar tudo o que mudou na main do módulo desde fevereiro, mais a reversão dos quatro campos, de uma vez.
Onde tivemos sorte
O pod tem limite declarado. É por isso que o kubelet emite memory.availableBytes para ele e que foi possível medir o que o alerta vê. Nos 38 pods sem limite de memória do cluster a métrica simplesmente não existe, e o mesmo defeito de leitura estaria presente sem nenhum sinal.
O gatilho era diário e pontual. Um cronjob em horário fixo deixa uma assinatura óbvia na distribuição por hora. Se o cache fosse enchido por carga difusa, a correlação não apareceria e a investigação pararia no "é assim mesmo".
Sete meses de ruído num banco produtivo e nada aconteceu. O alerta que de fato indicaria falha de memória neste pod, o critical de OOMKill, está morto, e o que gritava todos os dias não media nada. Durante esse período o banco não teve um problema de memória real, e foi só a ausência de evento que manteve o custo em zero.
Nenhum backup falhou enquanto não havia como saber se falhou. São 239 dias consecutivos com dump no bucket, do primeiro dia ao dia desta escrita, e nenhuma lacuna. Isso não foi garantido por nada: o starting_deadline_seconds de 10 segundos permitia perder um dia em silêncio, e o alerta que avisaria estava saturado. Os oito meses sem um único teste de restauração também seguem sendo oito meses.
O backup grava num lugar que não apaga nada. Se o dump fosse para um volume do cluster, ou se o bucket tivesse lifecycle agressivo, o ttl_seconds_after_finished: 10 teria destruído a única evidência e esta investigação terminaria em "não é possível saber". A ausência de política de retenção, que é um achado, foi também o que preservou o histórico.
A edição manual deixou rastro. O label k8slens-edit-resource-version e o generation: 2 foram o que permitiu afirmar que a divergência veio do Lens e não do Terraform. Se a edição tivesse sido por kubectl edit, a conclusão teria parado em "os valores não batem, não se sabe por quê", e eu não teria ido olhar o infrastructure-kelbi, onde está a razão de a edição ter sido manual.
Ninguém rodou terraform apply nestes oito meses. Se alguém tivesse consertado o kubeconfig e aplicado sem olhar o diff, o backup teria mudado de 07:00 para meia-noite UTC e as três folgas teriam voltado, provavelmente sem que a mudança de horário fosse notada, porque não há métrica durável que mostre quando o backup roda.
O oom_kill em zero resolveu a dúvida em um comando. Sem o cgroup acessível, distinguir "não houve OOM" de "houve OOM e não vimos" dependeria de correlacionar restarts, eventos e logs do kubelet.
O postgres-exporter expõe pg_settings_*, e foi o único caminho até o effective_cache_size. O valor não aloca memória, então não aparece no cgroup, e no postgresql.conf está comentado. Se o exporter tivesse sido configurado sem essa coleção, o achado não existiria: um grep no arquivo volta vazio e levaria à conclusão oposta, de que ninguém configurou nada demais.
Ações de acompanhamento
Detectar encurta o tempo até perceber; Prevenir reduz a chance de acontecer de novo; Processo corrige como trabalhamos.
O número da coluna # é identificador estável, para citar uma ação em conversa ou commit. Ação nova entra no fim da tabela com o próximo número livre, e número de ação concluída não é reaproveitado, então a ordem das linhas pode mudar sem que a referência mude.
Quatro delas têm ordem obrigatória: A3 → A4 → A5 → A6. Enquanto o terraform apply não rodar (A3), reconciliar o CronJob (A6) não é possível; e aplicar antes de pinar o módulo (A4) e de fixar o schedule (A5) aplicaria sete meses de mudanças acumuladas e reverteria o horário do backup.
A ARCA não tem fins lucrativos e a infraestrutura sai do bolso de quem a mantém. A RFC 0003 já registrava uma decisão nessa linha: produção, desenvolvimento e infra compartilhada rodam no mesmo nó k3s porque separar exigiria uma segunda VPS. A coluna Custo existe para que nenhuma ação entre na fila sem que o efeito na conta esteja na mesa.
O balanço desta tabela é favorável, e vale dizer por quê: 22 das 25 ações não custam nada, porque são regra de alerta, correção de nome de métrica, campo de manifesto ou configuração de bucket que a AWS não cobra. Uma devolve recurso (A19, que remove uma compressão redundante na VPS que já foi limitada por CPU) e uma reduz a conta (A21, que corta 0,99 USD/mês e ainda aumenta o histórico). A única que aumenta a conta é A25, e por isso ela está marcada como não priorizada em vez de pendente.
Duas ações mudaram de leitura ao medir o custo em vez de supor. A16 parecia ser "elevar o limite do pod ou aceitar o custo", como se subir o limite fosse a opção cara; o nó tem 2,67 GiB disponíveis e limit é teto e não reserva, então é gratuita e ainda reduz CPU. E A21 parecia ser economia contra histórico, quando a política certa dá mais histórico por menos dinheiro.
Como no postmortem anterior, nenhuma ação tem responsável nem data. O homelab tem um operador, mas prazo não definido é prazo que não existe.
| # | Ação | Tipo | Custo | Dono | Prazo | Status |
|---|---|---|---|---|---|---|
| A1 | Testar a restauração do backup do kelbi a partir do S3, e repetir isso em cadência definida. É a única ação desta tabela cujo risco é perda de dado produtivo. A integridade de dois dumps está conferida (CRC32 e header), então falta a parte que nenhum desses testes cobre: um pg_restore concluído contra um banco vazio. Não há pg_restore em nenhum dos 54 repositórios da organização, nem script nem runbook | Prevenir | 0 | — | — | Pendente |
| A2 | Alertar sobre a idade do objeto mais recente no bucket kelbi-pg, em vez de instrumentar o Job. O S3 já é o registro durável que falta: 239 dias de histórico, com data, hora e tamanho no nome do objeto. Alertar se o dump mais novo passar de 26h, ou se o tamanho cair fora da faixa dos vizinhos. Substitui a ideia anterior de emitir métrica a partir do objeto Job | Detectar | 0 | — | — | Pendente |
| A3 | Consertar o caminho de IaC do infrastructure-kelbi antes de qualquer reconciliação: o config_path (~/.kube/config.bk) e o config_context (arca) não existem, então terraform apply não roda. É a causa de os quatro campos terem ido para o Lens | Processo | 0 | — | — | Pendente |
| A4 | Pinar o source do módulo em infrastructure-kelbi numa tag em vez de ref=main, antes do primeiro apply. Hoje o primeiro apply bem-sucedido aplicaria de uma vez tudo o que mudou na main desde fevereiro | Prevenir | 0 | — | — | Pendente |
| A5 | Passar schedule = "0 7 * * *" explicitamente nos inputs do Terragrunt. O input não existe e vale o default @daily do módulo, então um apply reverteria para meia-noite UTC uma decisão tomada em 14/03/2026 e que hoje só existe no objeto do cluster | Prevenir | 0 | — | — | Pendente |
| A6 | Reconciliar os quatro campos divergentes do CronJob, com ttl_seconds_after_finished e starting_deadline_seconds maiores que o atual, não iguais ao módulo. Os 10s de deadline fazem o Kubernetes pular o backup se o controlador atrasar, num nó que já ficou 10h30 com CPU limitada a 20%. Depende de A3, A4 e A5 | Prevenir | 0 | — | — | Pendente |
| A7 | Reescrever KubernetesPodLowMemory sobre k8s_pod_memory_rss_bytes / k8s_container_memory_limit_bytes, com limiar relativo. As duas métricas já existem. Hoje a razão dá 9,3% para o kelbi-pg | Prevenir | 0 | — | — | Pendente |
| A8 | Corrigir o item deste alerta no RFC 0003: a correção catalogada ("threshold relativo a limit/request") mantém o defeito, porque o numerador continua sendo working_set. O problema é o sinal, não o limiar | Processo | 0 | — | — | Pendente |
| A9 | Habilitar as métricas opcionais do kubeletstats (k8s.container.{cpu,memory}_{limit,request}_utilization) no chart kelbi-platform, ressuscitando as 4 recording rules e os 7 alertas que dependem delas, incluindo o critical de OOMKill | Detectar | 0 | — | — | Pendente |
| A10 | Guard rail de regra vazia: alertar quando uma recording rule carregada, ou uma expressão de alerta, produz zero séries por mais de 1h. É a correção estrutural do D7, e vale para os 7 alertas de A9 e para os 4 do pacote de Postgres de A11 | Detectar | 0 | — | — | Pendente |
| A11 | Corrigir os nomes de métrica nos alertas de Postgres: PostgresqlDeadLocks, PostgresqlHighRollbackRate e PostgresqlCommitRateLow leem pg_stat_database_deadlocks e pg_stat_database_xact_commit, mas o dado chega como ..._deadlocks_total e ..._xact_commit_total, o sufixo que a convenção OTel para Prometheus acrescenta a contadores. Os três nunca puderam disparar. PostgresqlLowXidConsumption depende de pg_txid_current, que o exporter não coleta nesta configuração | Detectar | 0 | — | — | Pendente |
| A12 | Alertar sobre o que de fato indica falha de memória: k8s_container_restarts com causa OOMKilled, e memory.events/oom_kill se for exposto. É o sinal que o canal não tem hoje | Detectar | 0 | — | — | Pendente |
| A13 | Revisar os outros alertas do pacote e66c7e2 item a item contra as cargas reais do cluster, em vez de corrigir sob demanda quando cada um incomoda | Processo | 0 | — | — | Pendente |
| A14 | Declarar o label de ambiente no kelbi-pg marcando-o como produção, e corrigir no RFC 0003 a tabela que trata o sufixo -dev como convenção de ambiente: aplicada a este banco, ela classificaria produção como desenvolvimento. Bloqueia os itens 4 e 5 da RFC | Prevenir | 0 | — | — | Pendente |
| A15 | Avaliar renomear o StatefulSet produtivo que hoje se chama kelbi-pg-root-app-kelbi-pg-dev-0 (nameOverride: kelbi-pg-dev). Envolve volume com dado de produção, então só depois do label de ambiente estar resolvendo o roteamento | Processo | 0 | — | — | Pendente |
| A16 | Elevar o limits.memory do kelbi-pg acima do dataset de 1,1 GB, mantendo o requests em 192Mi. Não custa nada: o nó tem 8,62 GiB, usa 3,90 GiB e tem 2,67 GiB disponíveis, e limit é teto e não reserva. Reduz o direct reclaim e, com ele, a leitura de disco e a CPU gasta no nó que já foi limitado pela Hostinger. Medir a latência antes e depois. Ver Achados laterais | Prevenir | 0 | — | — | Pendente |
| A17 | Ajustar effective_cache_size de 4 GiB para a ordem do que o pod realmente tem (~300 MiB). O valor é o default compilado do PostgreSQL, não uma escolha: no postgresql.conf da imagem a linha está comentada, então declará-lo passa a ser a primeira vez que alguém decide. Muda plano de consulta em produção, então precisa de medição antes e depois | Prevenir | 0 | — | — | Pendente |
| A18 | successful_pods = 0 e failed_pods = 0 | Detectar | 0 | — | — | Concluída: o dump está no S3 com 212,7 MiB e CRC32 válido. A execução funcionou, e a amostra pegou o Job em andamento. Ver O que a AWS mostra |
| A19 | Remover a compressão dupla do pg-backup-script: pg_dump -Fc já comprime com gzip, e a segunda passada economiza 0,95% (2 MiB em 215), medidos no dump de 15/10/2026 | Prevenir | devolve CPU | — | — | Pendente |
| A20 | Declarar resources no kelbi-pg-backup-cronjob, hoje em resources: {}, onde o pod é BestEffort e escreve o dump sem teto. Ele é também o candidato apontado pelo postmortem de 06/10/2026 a disparar a próxima limitação de CPU da Hostinger | Prevenir | 0 | — | — | Pendente |
| A21 | Lifecycle no bucket de backup: 30 dias em STANDARD e o resto em Glacier Deep Archive, expirando em 1 ano. Dá mais histórico que hoje por um quinto do preço, 0,23 contra 1,22 USD/mês, e trava o crescimento que levaria a 3,09 em doze meses e 10,58 em cinco anos. Os 30 dias quentes cobrem restauração urgente; o Deep Archive resgata em 12h e tem mínimo de 180 dias de cobrança, compatível com a expiração em 1 ano. Ver O que a AWS mostra | Processo | −0,99 USD/mês | — | — | Pendente |
| A22 | Ligar versionamento e object lock em modo GOVERNANCE no bucket. Não custa nada aqui: as chaves têm data e hora no nome e nunca se repetem, então não há versão antiga para armazenar, e object lock é gratuito. Protege contra o risco real, que é exclusão ou sobrescrita acidental. Combinar com NoncurrentVersionExpiration para não brigar com A21, e GOVERNANCE em vez de COMPLIANCE para que a expiração de 1 ano continue possível | Prevenir | 0 | — | — | Pendente |
| A23 | Avaliar rotação da aws_iam_access_key estática guardada no Secret, e remover o aws_iam_role com trust policy para ec2.amazonaws.com, que nada assume | Prevenir | 0 | — | — | Pendente |
| A24 | Passar a contar fadiga por episódios, e não por tempo em firing. As duas métricas ordenam os alertas de formas quase opostas, e é o episódio que gera notificação | Processo | 0 | — | — | Pendente |
| A25 | Avaliar uma cópia do bucket fora de us-east-1. É a única ação desta tabela que aumenta a conta: replicação dobra o armazenamento e cobra transferência, cerca de +0,23 USD/mês em regime mais 1,06 USD uma vez para os 53 GB atuais. Guarda contra perda de região, que é risco muito menor que erro humano, e esse já é coberto por A22 de graça. Fica registrada como opção consciente, não como pendência | Prevenir | +0,23 USD/mês | — | — | Não priorizada |