Skip to main content

RFC 0003 — Fadiga de alertas no #alerting

CampoValor
StatusProposed, em execução
Escrita08/10/2026
Última revisão15/10/2026. O histórico completo está em Linha do tempo
Afetacontabo-monitoring-server (regras, Alertmanager), homelab (otel-collector, platform chart), canal Discord #alerting
Motivado porPostmortem — Limitação de CPU pela Hostinger (06/10/2026)
Depende deADR 0001 — Métricas k3s via OTel

Linha do tempo​

Esta RFC passou a ser executada em partes, com achados que corrigiram o próprio diagnóstico mais de uma vez. O registro abaixo existe para que a ordem fique legível sem reconstruí-la a partir das datas espalhadas pelo texto.

DataO que aconteceu
06/10Incidente de CPU na Hostinger. O postmortem aponta fadiga de alerta como achado principal: o alerta certo disparou na hora certa e se perdeu em ~47,8 notificações/dia
08/10RFC escrita, com diagnóstico de processo: falta de dono, de SLA e de revisão periódica
11/10Triagem no Prometheus e no Alertmanager. O diagnóstico estava errado: o volume vinha de sete regras que não podiam resolver, e a maioria apontava para um único resíduo de infraestrutura. Seis alertas crônicos medidos
12/10Execução do dia mais pesado: resíduos de Traefik removidos (item 1), Git reconciliado com a VPS (item 2, D5), D1 removido, node-exporter migrado para hostmetrics, e o critério de aceitação virou métrica hygiene:* (itens 9 e 10). Eliminou os quatro crônicos do Traefik e 24 das 40 notificações diárias
13/10Item 3 implementado no sender e medido: 679 séries k8s_* anônimas vão a 0. D7 encontrado no processo, com 23 regras de host agregando sem by(instance) e três vazias. Telemetria de host unificada nos dois servidores (item 13). Critério auditado contra o estado real, e três itens estavam marcados errado
14/10erupe excluído do PostgresqlNotEnoughConnections, o último crônico real, em firing há 143h: hygiene:alerts_chronic:count vai de 1 a 0. No mesmo dia, o postmortem do KubernetesPodLowMemory confirma o D4 no cgroup do container e mostra que a correção que esta RFC prescrevia para aquele alerta não resolveria, porque o defeito está no numerador e não no limiar
15/10Backup do kelbi conferido na AWS: 239 dias sem lacuna. No caminho, dois achados fora do escopo desta RFC, em registro próprio. E hygiene:alerts_chronic:count voltou a 4, com os quatro sendo guard rails desta RFC: a métrica havia passado a se contar. Corrigido no mesmo dia com kind!="hygiene", ver item 9
15/10Ainda em 15/10, ao escrever a RFC 0004: a cobertura de app.kubernetes.io/* não era de 100%, e os seis Deployments do kelbi-channel ficavam de fora. A premissa dos itens 4 e 5 estava errada, e a correção está na seção Não existe label de ambiente
15/10, noiteExperimento que desfez o item 3 e completou o item 4. A cadeia OTTL foi removida e as duas flags desligadas, para medir o que cada uma sustentava. A cadeia era redundante para pods e a única identidade para o resto. Voltou com dez statements, sem a prioridade de arca.io/app e app, e a extração virou allowlist explícita (kelbi-platform 1.16.0)
16/10A allowlist havia renomeado os labels em silêncio, de app_kubernetes_io_* para k8s_pod_labels_app_kubernetes_io_*, porque o preset declarava tag_name e a lista de chaves não. Corrigido com tag_name explícito, e a extração reduzida a component e part_of (kelbi-platform 1.19.0)
16/10Ambiente padronizado em environment (1.20.0), o que destravou um guard rail preso em 0 desde 11/10 por medir nome que nunca existiu. E removida a extração crua de resource.opentelemetry.io/*: toda annotation chegava duas vezes, e a justificativa da forma crua era o caminho alternativo do item 1 da RFC 0004, fechado no mesmo dia
16/10D8 nomeado, depois de a classe aparecer quatro vezes em três sistemas em dois dias: configuração correta escrita num lugar que nada lê. O caso mais caro não é de alerting: o deploy do mirim-bot grava num arquivo que nenhuma Application lê, e a release v2.0.0 está sem implantar desde 29/05/2026

Sete correções de rota em dez dias, todas no mesmo sentido: a medição estava certa e a interpretação estava errada. Vale como aviso de leitura para o resto do documento.


Contexto​

O postmortem do incidente de 2026-10-06 identificou fadiga de alerta como achado principal, não falta de telemetria. O canal #alerting recebia, em média, ~47,8 notificações/dia nos 30 dias anteriores ao incidente, e mesmo assim o alerta correspondente ao corte de CPU se perdeu no ruído. A detecção efetiva dependeu de um jogador reportando no Discord da comunidade às 21:14 — quase 10 horas depois do início do throttling às 11:26:30.

A primeira versão desta RFC tratou isso como problema de processo — falta de dono, de SLA, de revisão periódica. A triagem mostrou que o diagnóstico estava errado em dois níveis: o volume vem de sete regras que não podem resolver, e a maior parte dessas sete aponta para um único problema de infraestrutura nunca corrigido.


Como o alerting está estruturado hoje​

O caminho completo atravessa dois repos:

homelab (k3s)                            contabo-monitoring-server (VPS Contabo)
└── otel-collector (DaemonSet) └── otelcol ──► prometheus ──► alertmanager ──► Discord
presets: kubernetesAttributes, (OTLP/mTLS) (regras) (1 receiver) #alerting
hostMetrics, kubeletMetrics, ▲
clusterMetrics └── hostmetrics do próprio host
──OTLP/mTLS──► 164.68.119.39:4318 (item 13; antes era node-exporter
scrapeado pelo Prometheus)

O sender é o chart oficial opentelemetry-collector 0.150.1, instalado por ArgoCD via charts/kelbi-platform, em modo daemonset e configurado por presets. O receptor é um otelcol que faz prometheusremotewrite para o Prometheus — e é ele que converte os resource attributes do OTel em labels, com resource_to_telemetry_conversion: enabled: true.

As regras vivem em duas camadas:

prometheus/rules/
├── alerts/ 80 regras de alerta
│ ├── kubernetes.yml 17 métricas OTel do cluster k3s
│ ├── node.yml 7 1 sobre `up`, 6 sobre otel:node_* (host)
│ ├── otel.yml 5 saúde do próprio collector
│ ├── postgres-exporter.yml 20 pack genérico
│ ├── prometheus.yml 28 pack genérico
│ └── traefik.yml 3 pack genérico
└── recording/ 100 recording rules
├── otel-node.yml 23 otel: — normalização crua
├── otel-kubernetes.yml 28 otel: — normalização crua
├── node.yml 29 instance: — por host
└── app.yml 20 app: — camada semântica
Este é o estado de 2026-10-08, que é a base do diagnóstico

A estrutura mudou desde então. Hoje são 185 regras: alerts/hygiene.yml (12) e recording/hygiene.yml (25) entraram pelo item 10, KubernetesContainerHighRestarts saiu por D1, duas regras de pack saíram por D5 e recording/node.yml saiu inteiro no item 13.

A linha do node.yml de alertas também estava descrita errado: eram "7 do node-exporter da VPS de monitoring", quando na verdade InstanceDown é sobre up de qualquer target e os outros seis consomem otel:node_*, que é hostmetrics do nó k3s. É o mesmo erro corrigido em "O sinal que se perdeu".

A separação é deliberada e boa: otel: normaliza a métrica crua, app: constrói uma camada semântica agnóstica de implementação (namespace/app), e os alertas consomem a camada mais alta disponível. O cabeçalho do app.yml declara a convenção:

job      = "namespace/deployment"
instance = "namespace.pod_name.container_name"

Distribuição por severidade: 32 critical, 45 warning, 3 info. Dois terços (51 das 80) vieram de packs genéricos.

Fluxo de deploy do monitoring​

make deploy
├─ envsubst alertmanager/alertmanager.yml.tmpl → alertmanager.yml (gitignored)
├─ scp docker-compose.yml, configs, regras → VPS Contabo
├─ scp .env → VPS
└─ ssh docker compose up -d

O deploy sobrescreve as regras na VPS por cópia — o que tem consequência direta, tratada em D5.

Roteamento atual​

route:
group_by: ['alertname']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receiver: 'discord'

Um receiver, um canal, uma rota. Nenhum inhibit_rules, nenhuma rota por severidade, nenhum silence. Toda a lógica de "isso importa ou não" está nas 80 regras — o Alertmanager só repassa.


Triagem — 2026-10-11​

O canal não tem muitos alertas. Tem sete.​

O Alertmanager tem exatamente 7 alertas em firing, nenhum silence configurado e zero falhas de entrega (alertmanager_notifications_failed_total = 0 em todas as integrações). Com group_by: [alertname] e repeat_interval: 4h, cada alerta travado em firing rende 6 notificações/dia:

7 alertas firing × 6 notificações/dia = 42/dia   (previsto)
sum(increase(alertmanager_notifications_total{integration="discord"}[1d])) = 40,0/dia (medido)

Os ~47,8 notificações/dia do postmortem nunca foram atividade no cluster. Eram 7 regras travadas sendo reanunciadas a cada 4 horas.

Tempo em firing nos últimos 30 dias​

Janela de 720 h (o TSDB retém 30,7 dias), via sum by (alertname) (count_over_time(ALERTS{alertstate="firing"}[30d])) * 15 / 3600:

alertnamehoras em firing% da janelaseveridade
KubernetesContainerHighRestarts2.167 h·série¹100%warning
KubernetesJobHasFailedPods718,899,8%warning
PostgresqlNotEnoughConnections715,799,4%critical
KubernetesAppPodsNotRunning713,799,1%warning
KubernetesPodPendingTooLong713,799,1%warning
KubernetesJobNotCompleting711,598,8%warning
KubernetesPodLowMemory142,419,8%warning
HighCpuUsage8,11,1%warning
InstanceDown1,780,2%critical
PrometheusAllTargetsMissing1,780,2%critical
PrometheusRuleEvaluationFailures1,50,2%critical
PrometheusTargetMissing0,690,1%critical
PrometheusAlertmanagerNotificationFailing0,05<0,1%critical
TraefikHighHttp5xxErrorRateService0,03<0,1%critical
PostgresqlExporterError0,01<0,1%critical

¹ horas×série — chegou a 31 séries simultâneas no mesmo grupo, o que produz uma única mensagem no Discord com 31 blocos de labels.

Das 78 regras carregadas no servidor, 15 dispararam em 30 dias. Seis delas nunca mudaram de estado. Não são "alertas que disparam muito": são alertas que não têm como resolver.

O sinal que se perdeu​

HighCpuUsage disparou em exatamente uma janela nos 30 dias: 06/10, 11:51 → 21:51 (−03). O throttling começou às 11:26:30 e foi removido por volta das 22:00.

O alerta estava certo

Zero falso positivo no mês. A regra não estava mal calibrada nem faltando — acertou o incidente inteiro e nada além dele. Com repeat_interval: 4h, essas 8 horas renderam ~3 notificações dentro de ~1.200 no mês: 0,25% do canal.

O #alerting funcionou. Ninguém conseguiu ouvir.

Correção: a primeira versão desta RFC errou a fonte deste alerta

O texto afirmava que HighCpuUsage vinha do node-exporter da VPS de monitoring. Vinha do nó k3s na Hostinger, que é a máquina que sofreu o throttling: a regra é otel:node_cpu_usage:percent > 80, e essa recording rule é construída sobre system_cpu_time_seconds_total — hostmetrics do collector do homelab, não do node-exporter do Contabo.

O erro inverteu o sentido do achado. Um alerta de CPU medido na VPS de monitoring não teria como detectar um corte de CPU imposto pela Hostinger; o que salvou a detecção foi justamente a telemetria estar no nó certo. O prefixo otel: já dizia isso — por convenção do repo, otel: normaliza métrica OTel e instance: normaliza node-exporter —, e a leitura não foi feita.

O defeito de fundo que permitiu a confusão está em D7: as regras otel:node_* agregavam sem preservar instance, então nenhum alerta de host sabia dizer de qual máquina falava.


A causa raiz: dois resíduos de Traefik — e um deles servia produção​

Quatro dos seis alertas crônicos não são quatro problemas. Confirmado por kubectl em 2026-10-12, são dois resíduos.

Correção: a primeira versão desta RFC errou aqui

A triagem original afirmou que nenhum dos dois resíduos recebia tráfego, e concluiu que a remoção não tinha risco. Estava errado. O traefik-root-app atendia requisições de produção — 245 em 24h, dos mesmos hostnames que o Traefik ativo.

O erro tem uma causa identificável: a varredura procurou referências em IngressRoute, HTTPRoute, Gateway e Ingress, não encontrou nenhuma, e tratou isso como prova de que o objeto estava ocioso. A ausência de referência era verdadeira — o tráfego simplesmente não chegava por esse caminho. Ver "O órfão não estava ocioso", abaixo, e o item sobre fluxo de rede em "Fora do escopo".

O que existe de fato​

ObjetoChart / imagemGerenciado porEstado
deployment traefik + service/traefiktraefik-40.0.0 / v3.7.0ArgoCD, Application traefikAtivo — EXTERNAL-IP 89.116.225.172, portas 80/443
deployment traefik-root-app + service/traefik-root-apptraefik-39.0.8 / v3.6.13ninguémÓrfão — EXTERNAL-IP <none>, mas servindo tráfego de produção
HelmChart/traefik + traefik-crd (kube-system)39.0.701+up39.0.7k3s embutidojobs helm-install-traefik* com failed: 1, desde 2026-05-08

O traefik-root-app carrega argocd.argoproj.io/tracking-id: traefik-root-app:..., mas a Application traefik-root-app não existe (NotFound). É resíduo de uma Application renomeada para traefik, deletada sem cascade — os recursos ficaram no cluster sem ninguém os reconciliar.

E o k3s roda literalmente /usr/local/bin/k3s server, sem argumento algum e sem /etc/rancher/k3s/config.yaml. O Traefik embutido nunca foi desabilitado; seus jobs Helm falharam em maio e os Jobs seguem lá, com o contador de falha intacto.

O órfão não estava ocioso​

Existiam dois deployments de cloudflared registrados no mesmo túnel — mesmo TunnelID (2093c749…), mesmo secret tunnel-credentials, cada um apontando para um Traefik diferente:

Cloudflare edge  (túnel arcamh.com, 4 conexões)
├── cloudflare-tunnel-cloudflared 2 pods, cloudflared 2026.3.0 → traefik.traefik.svc
└── cloudflare-tunnel-root-app 2 pods, cloudflared 2023.5.1 → traefik-root-app.traefik.svc

O Cloudflare distribui as requisições entre as quatro conexões, então parte do tráfego de *.arcamh.com caía no Traefik órfão. Medido em 2026-10-12: 245 requisições em 24h no traefik-root-app, contra 1.449 no ativo — argocd.arcamh.com, admin.arcamh.com, halk-en-release.arcamh.com e o launcher do Kelbi, todas atendidas normalmente, com 200 e 304.

A confirmação veio dos IPs de origem no log de acesso do órfão: 10.42.0.82 e 10.42.0.67, que são exatamente os dois pods do cloudflare-tunnel-root-app.

Nenhum hostname dependia só dele — o par ativo servia o mesmo conjunto de rotas, via o mesmo Gateway traefik-gateway. Por isso a remoção continuou correta. O que mudou foi a ordem: tirar o Traefik antes do túnel deixaria duas das quatro conexões apontando para um Service inexistente, produzindo 502 intermitente em todos os hostnames.

Por que os alertas nunca resolveram​

service/traefik-root-app  (LoadBalancer, 80/443)        ← órfão, mas recebendo tráfego
└── svccontroller.k3s.cattle.io reconcilia
└── DaemonSet svclb-traefik-root-app-a3c9d85b ← sem ownerReferences
└── pod svclb-...-bdrq8 → Pending há 156 dias

O evento do scheduler, textual:

0/1 nodes are available: 1 node(s) didn't have free ports for the requested pod ports.

As portas 80/443 do nó já estão tomadas pelo svclb-traefik, do Traefik ativo. É o comportamento documentado do ServiceLB do k3s: os pods usam hostPort com a porta do Service, "hence they will only be deployed on nodes that have that port available. If there aren't any nodes with that port available, the LB will remain Pending". Em um cluster de nó único, o segundo Service a pedir 80/443 nunca agenda — e o EXTERNAL-IP <none> era consequência disso, não causa: o IP externo só é populado em status.loadBalancer.ingress quando o pod do ServiceLB roda.

Deletar o pod não resolve — e isso não é acidente

O DaemonSet svclb-* não tem ownerReferences. Ele é reconciliado pelo svccontroller.k3s.cattle.io, identificado por labels (svccontroller.k3s.cattle.io/svcname, svcnamespace). Nenhum garbage collector do Kubernetes o remove.

  • kubectl delete pod svclb-... → o DaemonSet recria o pod
  • kubectl delete ds svclb-... → o controlador do k3s recria o DaemonSet

A única forma de parar é remover o Service do tipo LoadBalancer que o origina — aqui, service/traefik-root-app. O mesmo vale para os jobs helm-install-traefik*: enquanto o HelmChart CRD existir, deletar o Job é tratar o sintoma.

O que isso produz no #alerting​

AlertaObjeto que o disparah em firingnotif/dia
KubernetesPodPendingTooLongsvclb-traefik-root-app713,76
KubernetesAppPodsNotRunningo mesmo pod (único não-Running do cluster)713,76
KubernetesJobHasFailedPodshelm-install-traefik + -crd718,86
KubernetesJobNotCompletingos mesmos dois jobs711,56
24/dia
60% do canal, de dois resíduos sem dono

Vinte e quatro das quarenta notificações diárias — 60% do #alerting — vêm de um Service órfão e de dois Jobs falhados em maio. Cinco meses, quatro alertas, zero impacto em produção.

"Zero impacto" aqui é preciso e limitado: o que os alertas apontavam — um pod que nunca agendou e dois Jobs falhados — de fato não degradava nada. Isso não significa que os objetos envolvidos estivessem ociosos; o deployment por trás do Service órfão servia requisições reais, como a seção acima mostra.

Os quatro alertas estavam certos desde o primeiro dia. Nenhum deles conseguia dizer de que objeto falava, por causa de D2.

O mesmo padrão, em outro addon — e é o que alimentava o órfão​

cloudflare-tunnel-root-app (imagem cloudflared:2023.5.1) rodava 2 réplicas desde 2025-07-29, sem tracking-id e sem Application — aplicado via kubectl apply e esquecido. O tunnel em uso é o cloudflare-tunnel-cloudflared (chart cloudflared-2.2.7, imagem 2026.3.0), da Application cloudflare-tunnel.

Esse não gerava alerta nenhum: estava Healthy. E é justamente aí que está a lição — o resíduo sem alerta era a peça que tornava o outro resíduo perigoso de remover. Enquanto os quatro alertas apontavam para sintomas inofensivos, o objeto silencioso era o que mantinha tráfego de produção fluindo por um Traefik de 2024 que ninguém gerenciava.

Dois pods rodando uma imagem de 2023, num nó que já estourou o teto de CPU uma vez, atendendo requisições reais sem nenhuma cobertura de monitoramento ou GitOps.

A lacuna que permitiu os cinco meses​

O ArgoCD é o mecanismo de entrega de todo o cluster e não é monitorado: zero métricas argocd* chegando ao Prometheus, zero regras de alerta mencionando ArgoCD nos seis arquivos de alerts/.

Hoje, quatro das 33 Applications estão fora do estado desejado sem que nada avise:

ApplicationSyncHealth
homepage-root-appOutOfSyncHealthy
kelbi-pg-root-appOutOfSyncHealthy
metrics-serverOutOfSyncProgressing
prod-halk-patch-serverUnknownHealthy

Um órfão de Application deletada é exatamente o tipo de coisa que a telemetria do ArgoCD mostraria de imediato. Sem ela, a única evidência eram quatro alertas anônimos num canal que ninguém lia.


Classes de defeito encontradas​

A triagem não encontrou "ruído" genérico. A classificação binária acionável × ruído da versão anterior desta RFC não distinguia "threshold errado" de "alerta que não sabe dizer de quem está falando" — e as duas coisas se consertam de formas opostas.

D1 — Regra redundante sobre contador monotônico​

KubernetesContainerHighRestarts, em alerts/kubernetes.yml:

- alert: KubernetesContainerHighRestarts
expr: k8s_container_restarts > 20
for: 0m

k8s_container_restarts é cumulativo e for: 0m. O opentelemetry-collector-agent tem 117 restarts acumulados, mas k8s_pod_start_time=2026-04-19 — está de pé há quase 6 meses. O alerta é matematicamente incapaz de resolver: só zera se o pod for recriado.

E os 117 restarts não são falha: o lastState.terminated do container registra reason: Completed, exitCode: 0. O processo encerra limpo e o kubelet o sobe de novo — cerca de um reinício a cada dia e meio ao longo de seis meses. O alerta não distingue "reiniciou 117 vezes porque está quebrado" de "reiniciou 117 vezes saindo com código 0".

Pendência separada

Por que o collector encerra com exit 0 de forma recorrente é uma questão legítima e fica de fora desta RFC — não há reloader.stakater.com/* no DaemonSet, então não é o Reloader, e os logs da encarnação anterior já não existem. Vale investigar pelo que é: comportamento do collector, não defeito de alerta.

O ponto decisivo está dez linhas acima, no mesmo arquivo:

- alert: KubernetesContainerCrashLooping
# k8s_container_restarts is a gauge — increase detects rapid restart bursts
expr: increase(k8s_container_restarts[15m]) > 3

O padrão correto já existe e já está documentado em comentário. CrashLooping cobre o caso que importa — um container reiniciando agora. HighRestarts só reporta um acumulado histórico que ninguém pode zerar.

Correção: remover, não consertar

Reescrever HighRestarts com increase() apenas duplicaria CrashLooping com outra janela. A regra não acrescenta nada e deve ser deletada.

D2 — 715 séries sem identidade, porque o sender só nomeia pods​

KubernetesJobHasFailedPods, KubernetesJobNotCompleting, KubernetesAppPodsNotRunning e KubernetesPodPendingTooLong produzem isto no Discord, literalmente:

Job  has 2 failed pod(s).
App / has 1 pods not in Running phase for more than 15 minutes.
Pod has been Pending for more than 15 minutes.

A causa não é um label_replace mal escrito. É que a identidade (job/instance) só existe para recursos que são pods ou containers.

O job do Prometheus vem de service.namespace/service.name, e o instance de service.instance.id. Quem preenche esses atributos é o preset kubernetesAttributes do collector, que associa cada datapoint a um pod para então derivar o serviço. Recurso que não é pod não tem pod ao qual associar — e sai sem nome.

Medido no Prometheus: 715 de 1.849 séries k8s_* (38,7%) não têm job, distribuídas em 38 métricas distintas:

Nível do recursojob/instance?SériesExemplos
Containersim69k8s_container_restarts
Podsim60 de 61k8s_pod_phase, k8s_pod_cpu_usage
ReplicaSetnão482k8s_replicaset_desired, k8s_replicaset_available
Deploymentnão88k8s_deployment_desired, k8s_deployment_available
Namespacenão21k8s_namespace_phase
DaemonSetnão44k8s_daemonset_ready_nodes
HPAnão44k8s_hpa_current_replicas
Jobnão10k8s_job_failed_pods
StatefulSetnão8k8s_statefulset_ready_pods
Nodenão10k8s_node_cpu_usage, k8s_node_condition_ready

Sobre essas séries, sum by (job, instance) colapsa tudo em uma série anônima e label_replace sobre job não casa nada — silenciosamente, sem erro de avaliação. As recording rules otel:job_* e app:pod_* fazem exatamente isso.

A exceção é a pior parte

Dos 61 pods do cluster, 60 têm identidade. O único que não tem é o svclb-traefik-root-app — o pod Pending.

Um pod que nunca foi agendado não tem IP nem container, então o enriquecimento não acontece. A métrica perde o nome do objeto exatamente no estado que o alerta existe para detectar. Ele tem service_namespace="kube-system", mas nenhum service_name.

O repo de monitoring já documenta essa fronteira, em dois de três lugares. Em recording/otel-kubernetes.yml:

# ─── DEPLOYMENTS ─────
# k8s_deployment_available e k8s_deployment_desired não possuem labels
# identificadores além de otel_scope_*. Regras mantidas como totais globais.

# ─── NODES ─────
# k8s_node_condition_ready via k8scluster não tem labels identificadores

E logo abaixo, na seção de jobs:

# ─── JOBS ─────
# Labels: job="namespace/deployment", instance identifica o k8s Job

Essa última afirmação é falsa, e é a origem dos três alertas anônimos. O diagnóstico foi feito corretamente para deployments e nodes, e assumido errado para jobs.

Isso é corrigível no sender. O cluster é nosso, o chart é nosso, e o OTel permite preencher service.name a partir do workload. Ver item 3 da proposta.

D3 — Pack genérico importado sem adaptação​

PostgresqlNotEnoughConnections, severidade critical:

sum by (datname) (pg_stat_activity_count{datname!~"template.*|postgres"}) < 5

O banco erupe tem 0 conexões; kelbi tem 17 e passa. A regra afirma que um banco deve ter mais de 5 conexões, o que não descreve falha alguma neste ambiente — descreve um banco sem uso. E erupe era um ambiente de teste, encerrado.

Verificado no cluster em 2026-10-12: do ambiente restam o namespace erupe, o secret/erupe-database-secret (166 dias) e o banco no Postgres. Nenhum deployment, service ou svclb — a limpeza foi feita pela metade, e o pedaço que sobrou é justamente o que mantém o alerta critical em firing.

A severidade critical já está queimada

Este é o único critical que disparou em 30 dias fora de incidente. A severidade mais alta do canal está hoje 99,4% ocupada por ruído.

Isso tem consequência direta sobre a proposta de separar critical em canal próprio: feita hoje, ela moveria o ruído para o canal novo e repetiria o problema em escala menor.

São 51 das 80 regras vindas de packs genéricos (postgres-exporter.yml 20, prometheus.yml 28, traefik.yml 3), nenhuma revisada contra este ambiente. PostgresqlNotEnoughConnections é a primeira a aparecer, não a única candidata.

D4 — Threshold absoluto em métrica dependente de contexto​

KubernetesPodLowMemory: otel:pod_memory_available:bytes < 52428800 — 50 MiB fixos, contra kelbi-pg em 13,5 MB. Para PostgreSQL, memória disponível baixa é operação normal (o banco usa o que tem para cache), não sintoma de falha. Um limiar absoluto não distingue um Postgres saudável de um app vazando memória.

É o único dos sete que não é crônico (19,8% da janela), o que confirma que o threshold está na faixa errada em vez de sempre verdadeiro. E alerts/kubernetes.yml já tem seis regras baseadas em utilização relativa a limit/request — o padrão certo também já existe no arquivo.

D5 — O servidor divergiu do Git, e a triagem já feita está só na VPS​

Último commit em main: 2026-03-24 (9b5874e). Arquivos na VPS: 18/04/2026 18:23. Quatro arquivos divergem:

ArquivoNo GitNa VPS
alerts/kubernetes.ymlk8s_container_restarts > 20mesma regra com exclusões por instance
alerts/prometheus.ymltem PrometheusAlertmanagerE2eDeadManSwitchremovido
alerts/traefik.ymltem TraefikHighHttp4xxErrorRateServiceremovido
otel/config.ymlmemory_limiter: limit_mib 128 / spike 32256 / 64

Nas regras: 80 no Git, 78 carregadas no servidor — 32 critical contra 30.

A quarta divergência não é ruído, é cegueira

As três primeiras, revertidas, produzem spam. A do otel/config.yml produz perda de telemetria.

O collector do monitoring opera hoje com ~296 MiB de RSS. Com limit_mib: 256 o memory_limiter não recusa nada — otelcol_processor_refused_metric_points_total está em 0 nos últimos 7 dias. Voltar para 128 o faz começar a descartar pontos de métrica.

O aumento na VPS foi correção de capacidade, não experimento. Reverter reintroduz exatamente o tipo de ponto cego que originou este postmortem — e desta vez no caminho de dados, não no canal de alerta.

As edições na VPS não são descuido. São triagem real, com justificativa escrita:

# cloudflare-tunnel é excluído intencionalmente: o container acumula restarts
# históricos altos por natureza (reconexões ao Cloudflare edge) sem indicar
# problema real. Não há como zerar o contador sem recriar o pod, e o serviço
# permanece funcional. Reavaliar se o tunnel começar a cair de forma contínua.

E as duas regras removidas eram ruído óbvio — PrometheusAlertmanagerE2eDeadManSwitch é literalmente expr: vector(1), um alerta critical que dispara para sempre, por design.

Um make deploy revertia tudo isso

O deploy copia os arquivos do repo para a VPS por scp. Rodado antes da reconciliação, reintroduzia um critical always-firing (vector(1)) e um alerta de 4xx como critical, apagava as três exclusões e reduzia o memory_limiter do collector pela metade — sem que ninguém tivesse mudado nada de propósito.

Como a divergência nasceu: o Makefile usa SERVER=monitoring-server, e esse alias não existia no ~/.ssh/config da máquina de trabalho. make deploy falhava antes de copiar qualquer coisa, então as correções foram feitas direto na VPS por ssh — e ficaram lá. A válvula que protegia seis meses de triagem era um alias ausente.

Resolvido em 2026-10-12

As quatro divergências foram trazidas para o repo com fidelidade byte a byte (commit a5af6ac), preservando os comentários de justificativa. Verificado depois: os 10 arquivos de regras pré-existentes, docker-compose.yml, prometheus.yml, otel/config.yml e o datasource do Grafana são idênticos entre repo e VPS, e não há arquivo órfão no servidor.

make deploy passou a ser no-op, exceto pela adição intencional de alerts/hygiene.yml e recording/hygiene.yml. O alias foi alinhado depois da reconciliação, não antes.

Há um ponto legítimo enterrado aqui: um dead man's switch é um padrão correto — serve para detectar que o pipeline de alerta parou. O erro é mandá-lo como critical para o mesmo canal; o uso certo é um receiver separado que alerta pela ausência da notificação, não pela presença.

D6 — Churn de cardinalidade, com efeito direto no volume de notificações​

O preset do collector está em modo máximo:

presets:
kubernetesAttributes:
enabled: true
extractAllPodLabels: true
extractAllPodAnnotations: true

Combinado com resource_to_telemetry_conversion: enabled: true no otelcol do monitoring, todo label e annotation de pod vira label de métrica. O resultado, medido:

LabelValores distintosNatureza
container_id2.481muda a cada recriação de container
k8s_pod_start_time118muda a cada restart
controller_revision_hash16muda a cada deploy
kubectl_kubernetes_io_restartedAt9annotation de rollout
checksum_config3muda a cada mudança de ConfigMap

São 15.049 séries no head do TSDB, para um cluster de ~60 pods. container_id sozinho tem 2.481 valores.

Isso é custo (RAM e disco do Prometheus na VPS que já estourou o teto de CPU uma vez), mas também é ruído de alerta: o fingerprint de um alerta no Alertmanager é derivado do seu conjunto de labels. Um alerta que carrega container_id ganha fingerprint novo a cada recriação do container — e o Alertmanager trata isso como alerta novo, notificando imediatamente em vez de respeitar o repeat_interval.

KubernetesContainerHighRestarts carrega container_id, k8s_pod_start_time, controller_revision_hash e checksum_config. Cada restart do container gerava uma notificação extra.

Vale notar também o que não é consumido: k8s_replicaset_* (482 séries, 26% das séries k8s_*), k8s_namespace_phase, k8s_daemonset_*, k8s_hpa_* e k8s_statefulset_* não aparecem em nenhuma regra de alerta ou recording — ~599 séries coletadas, transmitidas e armazenadas sem consumidor em regra. Checar os dashboards do Grafana antes de cortar.

D7 — Os sete alertas de host também não tinham identidade, e dois nunca podiam disparar​

Encontrado em 2026-10-13, ao implementar o item 3. D2 foi diagnosticado como problema das métricas de Kubernetes; os alertas de host tinham o mesmo defeito, por outra causa.

As sete regras do grupo host em alerts/node.yml consomem recording rules otel:node_*, e todas elas agregavam globalmente:

# otel:node_cpu_usage:percent — como estava
100 - (
sum(rate(system_cpu_time_seconds_total{state="idle"}[5m]))
/
sum(rate(system_cpu_time_seconds_total[5m])) * 100
)

sum() sem by descarta todos os labels. O resultado tinha apenas __name__ — nenhum instance. E as annotations dizem:

summary: "High CPU usage on {{ $labels.instance }}"

Então o alerta que detectou o incidente de 06/10 anunciava no Discord High CPU usage on , com o host em branco. Exatamente a mesma mensagem truncada de D2, no alerta que mais importava do mês. Com um host só era possível inferir; não era possível ler.

Duas regras estavam vazias — e o alerta que as consome nunca disparou

otel:node_load1_per_cpu:ratio, load5 e load15 eram:

system_cpu_load_average_1m / count(system_cpu_time_seconds_total{state="idle"})

O numerador é um vetor com labels (instance, job, otel_scope_*); o denominador, depois de count() sem by, é um vetor sem labels. Em PromQL uma operação binária entre dois vetores casa amostras por conjunto de labels idêntico — e conjuntos diferentes não casam nada. Resultado: zero séries, sem erro de avaliação. promtool check rules aprova, a regra aparece carregada, e não existe dado.

O alerta HighLoad (otel:node_load1_per_cpu:ratio > 1.5) consome esse vazio. Nunca pôde disparar desde que foi escrito.

É a mesma família de D1 — regra matematicamente incapaz de cumprir o que promete —, mas pior: D1 disparava para sempre e fazia ruído, o que ao menos é visível. Esta é silenciosa, e uma regra que nunca dispara é indistinguível de um sistema saudável.

A causa comum de D2 e D7 é a mesma decisão: agregar sem preservar a chave que identifica o objeto. Em D2 veio do sender, que não preenchia a identidade; em D7 veio do consumidor, que a jogava fora. O guard rail do item 11 cobre o primeiro caso e não cobria o segundo.

D8 — Configuração escrita num lugar que nada lê​

A classe apareceu quatro vezes entre 15 e 16/10/2026, em três sistemas diferentes, e só foi nomeada depois da quarta. O formato é sempre o mesmo: alguém declara uma configuração correta, num lugar que nenhum consumidor consulta. Não há erro, a validação passa, e o estado parece saudável.

OndeO que foi declaradoQuem deveria ler, e não liaTempo
Pod do Veleroprometheus.io/scrape e companhiaeste stack lê io.opentelemetry.discovery.metrics/*160 dias
Chart kelbi-app 5.9.0resource.opentelemetry.io/service.nameotel_annotations, que só preenche o que extract.metadata não reivindica1 dia
kelbi-platform 1.16.0allowlist de 5 labels sem tag_namenada: os labels chegaram, com outro nome1 dia
reusable-deploy.ymlapp-path: apps/${{ github.repository }}a Application, que lê outro diretório4 meses e meio

O quarto caso é o mais caro e o único com efeito em produção.

O deploy do mirim-bot escreve num arquivo que ninguém lê

O reusable-deploy.yml deriva o caminho do values do nome do repositório. O repo é mirim_bot, com underscore, e o diretório da app foi renomeado para mirim-bot no commit d1ea58a, "move folder to new pattern". Desde então o bot grava em apps/Invasor-de-Fronteiras/mirim_bot/values.yaml, e a Application prod-mirim-bot lê mirim-bot/values.yaml.

Medido em 16/10/2026:

tag no repo mirim_bot       v2.0.0
imagem no arquivo morto mirim_bot:2.0.0 escrita pelo bot em 29/05/2026
imagem rodando mirim_bot:sha-eb518a8 commit de 14/05/2026

A release v2.0.0 nunca foi implantada, e o deploy que a deveria implantar terminou com sucesso. Decidido em 16/10/2026 manter o sha-eb518a8 rodando: a v2.0.0 entra quando houver revisão do que mudou entre os dois builds.

O mesmo acontece com o halk, e lá por construção: um repo gera duas apps, halk-en-api e halk-jp-api, então um caminho derivado do nome do repo não pode casar com as duas. O matrix.suffix existe para a fan-out, mas daria halk-patch-server-en. O bot criou halk-patch-server/values.yaml em 23/03 e em 20/06/2026, e nas duas vezes uma pessoa apagou o arquivo e atualizou os dois values à mão no mesmo dia. O deploy do halk é manual desde março, limpando atrás da automação.

O conserto não é apagar o diretório órfão, porque o próximo push o recria. É dar ao reusable-deploy.yml um input de caminho explícito, com fallback no nome do repo, que é a única opção que também resolve a fan-out do halk. Fica registrado sem executar, por decisão de 16/10/2026.

O que distingue D8 de D7 é onde a verificação falharia. D7 é regra que avalia para vazio, e um guard rail que consulte o resultado de toda regra nova o pega. D8 é configuração que não tem avaliação nenhuma: o Velero anotado, a annotation que perde a disputa, o label renomeado e o values no caminho errado são todos sintaticamente válidos e inertes. O único detector é comparar o que foi declarado com o que chegou ao consumidor, e isso é verificação de ponta a ponta, não de sintaxe.


Ambientes coexistentes: a segunda dimensão​

O cluster k3s roda produção, desenvolvimento e infraestrutura compartilhada no mesmo nó, por decisão deliberada de FinOps — separar exigiria uma segunda VPS, e o postmortem já mostrou o quanto a margem de recurso é apertada.

Boa decisão para custo, ruim para alerting: hoje nada no sinal distingue um pod de dev de um pod de produção.

Não existe label de ambiente​

Existe um label stage nas métricas, mas vem de alertmanager_silences_matcher_compile_errors_total e não tem relação com ambiente. O que existe é convenção de nome, aplicada de forma inconsistente:

ConvençãoExemploOnde
Prefixo prod-prod-kelbi-api, prod-halk-jp-api13 Deployments no namespace apps
Sufixo -dev, e ele mentekelbi-pg-root-app-kelbi-pg-dev-0StatefulSet no namespace kelbi-pg, banco produtivo
Namespace de testeerupe — namespace, secret e banco ainda de péambiente encerrado, removido pela metade
Sem marcaçãonó, disco, Traefik, CoreDNS, ArgoCD, Velerotoda a infra compartilhada

Nenhuma delas chega às métricas como label consultável, então é impossível rotear por ambiente hoje.

A convenção de nome não é só inconsistente: ela é falsa

O único workload do cluster com sufixo -dev é produtivo. O nameOverride: kelbi-pg-dev do chart é resquício de nome, não descrição de ambiente, e o banco atende produção.

Isso elimina a opção de derivar ambiente a partir do nome, que era o caminho mais barato: aplicada a este caso, a regra classificaria o único banco produtivo com dado persistente como desenvolvimento e o rotearia para o canal de menor prioridade — o oposto do que os itens 4 e 5 pretendem. O label de ambiente tem que ser declarado explicitamente no workload, e o valor correto para o kelbi-pg é produção.

O custo disso já está pago há sete meses, sem ninguém notar: todo alerta desse banco chega ao #alerting parecendo descartável. Ver o postmortem de 2026-10-14, onde ele responde por 44% dos episódios de alerta do mês.

Levantamento dos labels presentes nos 32 Deployments e StatefulSets do cluster confirma que não há nada reaproveitável — nenhuma ocorrência de environment, env, stage ou tier:

LabelWorkloads
app.kubernetes.io/instance32
app.kubernetes.io/name32
app.kubernetes.io/managed-by29
app.kubernetes.io/version27
app.kubernetes.io/part-of8

O label de ambiente precisa ser criado, não descoberto.

A cobertura não é de 100%, e a diferença importa

A primeira versão desta seção concluía que app.kubernetes.io/instance e /name existiam em 100% dos workloads, e usava isso como base estável para extrair identidade sem depender de nome. A contagem acima, de 13/10/2026, era de 32 workloads com 32 ocorrências.

Remedido em 15/10/2026 ao escrever a RFC 0004: são 30 de 43 Deployments e StatefulSets, ou 70%. E a parte que invalida a conclusão é quem ficou de fora: os seis Deployments do kelbi-channel não têm nenhum dos dois labels. Eles carregam app=kelbi-channel, que é outra fonte.

Uma extração baseada só em app.kubernetes.io/name deixaria os servidores de jogo sem identidade de app, que é exatamente o produto. A conclusão da época era que os itens 4 e 5 precisavam de precedência entre as duas fontes.

Isso também caducou, em 16/10/2026. A identidade nunca dependeu desses labels: service.name sai de extract.metadata, pelo nome do Deployment, e os seis canais aparecem com os seis nomes de Deployment deles. Os dois labels saíram da extração por serem redundantes com service_name e service_instance_id, e o que ficou extraído, component e part_of, é classificação e não identidade.

A precedência entre fontes deixou de ser necessária porque a fonte passou a ser uma só.

O erro que a separação ingênua cometeria​

Ausência de ambiente nunca pode significar "ignorável"

HighCpuUsage — o único alerta que detectou o incidente de 06/10 — é um alerta de nó. Não tem ambiente e nunca vai ter.

Uma regra de roteamento do tipo "se não é prod, manda para o canal de dev" jogaria exatamente esse alerta no buraco, e recriaria o ponto cego do postmortem com um mecanismo novo.

O default precisa ser à prova de falha: sem label de ambiente ⇒ platform ⇒ tratado como produção.

Há uma segunda fronteira, consequência direta do cluster compartilhado: um workload de dev consumindo CPU afeta produção, porque o nó é o mesmo. Logo:

AlertaRoteamento
Sobre um workload em devPode esperar — canal de dev
Sobre o nó ou o clusterSempre produção, mesmo quando a causa é dev

Ambiente classifica o objeto do alerta, nunca a causa.


Princípio de design​

Regra central

Todo alerta no #alerting precisa ser acionável ou corrigido na origem — e "na origem" significa no Git, não na VPS. Não existe terceira categoria ("deixa acumular"), e silence não é correção: é anestesia com prazo.

Um alerta é acionável quando responde, sem consulta adicional, a três perguntas:

  1. Qual objeto está em falha (namespace, pod, job, banco — nomeado)
  2. Em qual ambiente ele está (prod, dev, platform)
  3. O que fazer (runbook, ou ação óbvia a partir da descrição)

Nenhum dos quatro alertas do Traefik responde à primeira. Foi o que permitiu que um problema simples durasse quatro meses.


Proposta​

1. Remover os resíduos de Traefik — feito​

Executado em 2026-10-12. Maior impacto único: eliminou os quatro alertas crônicos e 24 das 40 notificações diárias. O Traefik em uso (traefik, Application homônima, v3.7.0, chart 40.0.0) não foi tocado.

A ordem não é arbitrária — invertê-la causa 502 intermitente

O cloudflare-tunnel-root-app entregava tráfego ao traefik-root-app. Remover o Traefik primeiro deixaria duas das quatro conexões do túnel apontando para um Service inexistente, e o Cloudflare continuaria distribuindo requisições entre elas — 502 intermitente em todos os hostnames, não só nos do órfão.

O túnel sai primeiro. Só depois o Traefik.

Passo 1 — cloudflare-tunnel-root-app. deployment e configmap. As duas réplicas desregistram do túnel e o Cloudflare passa a usar apenas as conexões do cloudflared ativo, que roteia os mesmos *.arcamh.com.

Verificação entre os passos, antes de tocar no Traefik: 15 requisições atendidas pelo ativo, 0 pelo órfão, zero 5xx nos dois.

Passo 2 — órfão de Application deletada. O Service primeiro, porque é ele que faz o svccontroller do k3s reconciliar o svclb:

  1. service/traefik-root-app — o DaemonSet svclb-traefik-root-app-* e seu pod Pending de 156 dias saíram em cascata, sem precisar tocá-los
  2. deployment, serviceaccount, ingressroute/traefik-root-app-dashboard, ingressclass, clusterrole e clusterrolebinding

Resolveu KubernetesPodPendingTooLong e KubernetesAppPodsNotRunning.

Passo 3 — Traefik embutido do k3s. disable: [traefik] em /etc/rancher/k3s/config.yaml. No restart, o k3s removeu traefik.yaml do diretório de manifests e reescreveu todos os outros — prova de que o mecanismo funciona, já que ele reescreve esses arquivos a cada start. Depois disso, os HelmChart traefik/traefik-crd e os Jobs helm-install-traefik*.

Nenhum job de uninstall foi criado: não havia release Helm registrado no cluster para desinstalar.

Resolveu KubernetesJobHasFailedPods e KubernetesJobNotCompleting.

Passo 4 — o type: LoadBalancer que ninguém escolheu. Investigar por que o svclb-traefik existia levou a um achado fora do escopo original. O Service do Traefik era LoadBalancer por default do chart — o repo homelab nunca definiu service.spec.type. Isso publicava as portas 80/443 no nó e permitia contornar o Cloudflare:

curl -H "Host: argocd.arcamh.com" http://89.116.225.172/    →  HTTP 200

O ArgoCD, que gerencia o cluster, respondia pelo IP de origem em HTTP puro, sem WAF, sem rate limiting e sem TLS. Os 11 hostnames *.arcamh.com resolvem apenas para endereços do Cloudflare, e o cloudflared alcança o Traefik pelo ClusterIP — nenhum tráfego legítimo usava aquele caminho.

Corrigido com service.spec.type: ClusterIP no chart. O mesmo curl agora dá connection timeout, as portas 80/443 do host ficaram livres e o svclb-traefik foi removido pelo svccontroller. Os Service do Kelbi seguem LoadBalancer, porque ali a exposição é necessária: os clientes de jogo conectam direto no IP do nó.

Efeito lateral relevante para esta RFC: sem o svclb-traefik ocupando 80/443, o conflito de hostPort que manteve o svclb-traefik-root-app em Pending por 156 dias não pode mais acontecer.

Resultado medido

MétricaAntesDepois
hygiene:traefik_alerts_firing:count40
hygiene:traefik_instances:count21
hygiene:job_failed_pods:count20
hygiene:pods_not_running:count10
hygiene:alerts_chronic:count62

Os hostnames permaneceram em HTTP 200 em todas as etapas, verificados por curl a cada passo.

Pendente: versionar o provisionamento do k3s. O config.yaml existe apenas na VPS. Enquanto não estiver em repositório, uma reinstalação do nó recria o Traefik embutido e os dois alertas de Job — o mesmo mecanismo que deixou o problema cinco meses no ar.

2. Reconciliar o Git com a VPS — feito​

Nada mais fazia sentido sobre uma base divergente: corrigir regra no Git significaria reverter, sem perceber, seis meses de triagem feita na VPS.

  • Quatro divergências de D5 trazidas para o repo, com os comentários de justificativa preservados — eles são a memória da triagem
  • PrometheusAlertmanagerE2eDeadManSwitch e TraefikHighHttp4xxErrorRateService fora do repo
  • memory_limiter do collector em 256 MiB, como está em produção
  • make deploy validado como no-op, exceto pela adição dos dois arquivos de higiene
  • Dead man switch reimplementado como receiver que alerta por ausência de notificação — o conceito é válido, a implementação como critical no mesmo canal não era

3. Dar identidade a toda métrica, no sender — feito​

Executado em 2026-10-12/13, chart kelbi-platform 1.7.0 → 1.12.0. O cluster é nosso e o chart é nosso: nenhuma métrica precisa chegar anônima. A correção é um transform no collector do homelab, depois do k8sattributes, preenchendo service.name quando não houver pod para associar:

# charts/kelbi-platform/templates/cluster-addons/opentelemetry-collector.yaml
transform/service_identity:
error_mode: ignore
metric_statements:
- context: resource
statements:
- set(resource.attributes["service.namespace"], resource.attributes["k8s.namespace.name"]) where resource.attributes["service.namespace"] == nil
# arca.io/app e app sobrescrevem; o resto da cadeia só preenche lacuna
- set(resource.attributes["service.name"], resource.attributes["app"]) where resource.attributes["app"] != nil
- set(resource.attributes["service.name"], resource.attributes["arca.io/app"]) where resource.attributes["arca.io/app"] != nil
- set(resource.attributes["service.name"], resource.attributes["k8s.deployment.name"]) where resource.attributes["service.name"] == nil
- set(resource.attributes["service.name"], resource.attributes["k8s.statefulset.name"]) where resource.attributes["service.name"] == nil
- set(resource.attributes["service.name"], resource.attributes["k8s.daemonset.name"]) where resource.attributes["service.name"] == nil
- set(resource.attributes["service.name"], resource.attributes["k8s.cronjob.name"]) where resource.attributes["service.name"] == nil
- set(resource.attributes["service.name"], resource.attributes["k8s.job.name"]) where resource.attributes["service.name"] == nil
- set(resource.attributes["service.name"], resource.attributes["k8s.hpa.name"]) where resource.attributes["service.name"] == nil
- set(resource.attributes["service.name"], resource.attributes["k8s.replicaset.name"]) where resource.attributes["service.name"] == nil
- set(resource.attributes["service.name"], resource.attributes["k8s.pod.name"]) where resource.attributes["service.name"] == nil
- set(resource.attributes["service.name"], "hostinger-k3s-cluster-01") where resource.attributes["service.name"] == nil and resource.attributes["k8s.node.name"] != nil
- set(resource.attributes["service.name"], resource.attributes["k8s.namespace.name"]) where resource.attributes["service.name"] == nil
- set(resource.attributes["service.name"], "hostinger-k3s-cluster-01") where resource.attributes["service.name"] == nil
- set(resource.attributes["service.instance.id"], Concat([resource.attributes["service.namespace"], resource.attributes["service.name"]], ".")) where resource.attributes["service.instance.id"] == nil and resource.attributes["service.namespace"] != nil
- set(resource.attributes["service.instance.id"], "hostinger-k3s-cluster-01") where resource.attributes["service.instance.id"] == nil

O nome do host vem do parâmetro opentelemetryCollector.host.name, não literal — o trecho acima mostra o valor já renderizado.

Resultado medido: 679 séries k8s_* sem job em 32 métricas → 0. hygiene:series_without_identity:count = 0.

Cinco correções em relação ao que esta RFC propunha​

A proposta original estava certa no mecanismo e incompleta no detalhe. O que mudou, e por quê:

PropostoImplementadoMotivo
cadeia sem HPAk8s.hpa.name incluído44 séries k8s_hpa_* ficariam anônimas
cadeia sem namespacek8s.namespace.name como fonte de service.name20 séries k8s_namespace_phase só carregam o namespace
fallback literal "cluster"nome do host, por parâmetroexiste um namespace chamado cluster no cluster, e o literal colidiria com ele; além disso as séries que chegam nesse ponto são system_* do hostmetrics, cuja identidade correta é a máquina
attributes[...]resource.attributes[...]o OTTL 0.149 reescreve o path sem prefixo no startup e registra paths were modified to include their context prefix; num bump futuro do collector a linha de log viraria falha de inicialização
Concat num único setdois set condicionaissérie de nível nó/host não tem namespace, e Concat sobre nil não é bem definido

Dois acréscimos que não estavam na proposta:

  • arca.io/app e app tinham prioridade sobre toda a cadeia (chart 1.11.0), sobrescrevendo em vez de preencher lacuna. Removido em 15/10/2026, ver a correção abaixo.

    Corrigido em 15 e 16/10/2026: três afirmações deste item estavam erradas

    A prioridade de arca.io/app e app saiu do collector no experimento de 15/10, junto com o resto da cadeia antiga. Não voltou. E as duas descobertas que este item usava para argumentar contra arca.io/app, ambas de 13/10, estavam erradas:

    Primeira: app.kubernetes.io/name não existe em 32 de 32 workloads. Medido em 15/10: são 31 de 52 nos pods, e os seis Deployments do kelbi-channel não têm nem esse label nem o /instance.

    Segunda: o k8sattributes não deriva service.name de app.kubernetes.io/name. Ele o pega do nome do Deployment, porque o chart pede service.name na lista extract.metadata. Medido removendo a cadeia inteira: job, instance, service.name, service.namespace e service.instance.id ficaram idênticos.

    Terceira: a annotation resource.opentelemetry.io/service.name não define service.name. O otel_annotations: true só preenche atributo que a lista metadata não reivindica, e service.name está nela. O chart kelbi-app declara essa annotation desde a 5.9.0 e ela nunca venceu uma única vez.

    A conclusão de que arca.io/app não deveria existir continua certa. Os três motivos que a sustentavam, não.

  • Métricas de host levam job = instance = <provedor>-<servidor>, por parâmetro (chart 1.9.0–1.12.0). Recurso de nível nó é o único que recebe job sem barra, por não ter namespace.

Atenção ao pipeline

Os presets fazem prepend de k8sattributes por merge do chart, então a lista efetiva no pod começa por ele. A ordem importa: k8sattributes antes, transform depois.

A lista mudou duas vezes depois de 13/10. Era [k8sattributes, memory_limiter, transform/service_identity, batch]; hoje é [k8sattributes, memory_limiter, resource/host, filter/unconsumed, transform/identity, batch], conferida no ConfigMap em execução em 16/10/2026.

A dependência entre os itens 3 e 4 existia, e se resolveu ao contrário

Esta seção avisava que ao fazer o item 4 seria preciso pôr app e arca.io/app na lista explícita de extração, senão a prioridade pararia de funcionar em silêncio.

O experimento de 15/10 resolveu de outro jeito: a prioridade saiu, em vez de a extração ser preservada para alimentá-la. Com isso o item 4 deixou de ter essa amarra, e a lista explícita hoje contém só component e part_of.

O aviso continua valendo como classe de defeito, e foi exatamente o que aconteceu em 16/10 numa variante que ninguém previu: a allowlist não esqueceu uma chave, ela renomeou as quatro que extraía. Mesmo silêncio, causa diferente.

Com isso, D2 deixa de existir e as recording rules otel:job_* e app:pod_* passam a funcionar como foram escritas. Os comentários de recording/otel-kubernetes.yml foram corrigidos nas cinco seções em que descreviam a fronteira que deixou de existir — PODS, CONTAINERS, DEPLOYMENTS, JOBS e NODES.

4. Reduzir o churn de labels — parcial​

A parte dos labels de pod está entregue, em kelbi-platform 1.16.0 (15/10/2026) e ajustada na 1.19.0 (16/10/2026). O guard rail segue em 1039, por dois labels que esta seção atribuía à extração e que vêm de outro lugar. A medição está abaixo.

presets:
kubernetesAttributes:
enabled: true
extractAllPodLabels: false # era true
extractAllPodAnnotations: false # era true

Com a allowlist explícita no lugar:

k8sattributes:
extract:
labels:
- from: pod
key: app.kubernetes.io/component
tag_name: component
- from: pod
key: app.kubernetes.io/part-of
tag_name: part_of

Isso remove container_id, checksum_config, controller_revision_hash, kubectl.kubernetes.io/restartedAt e pod_template_generation do conjunto de labels, tirando de uma vez o custo de cardinalidade e o efeito de fingerprint novo a cada deploy descrito em D6.

O que esta seção prescrevia não é o que foi entregue

O texto original mandava extrair app.kubernetes.io/name, app.kubernetes.io/instance e o label de ambiente do item 5. Nenhum dos três está na allowlist, e cada um por um motivo medido:

  • name e instance saíram em 16/10/2026 por serem redundantes. Eles não alimentam job nem instance: quem alimenta é extract.metadata, que resolve service.name pelo nome do Deployment. Medido antes de remover: service_name em 801 séries e service_instance_id em 697, contra 34 de cada label. Nenhuma regra nem painel os lia.
  • O label de ambiente nunca existiu. O item 5 mudou de rota para a annotation resource.opentelemetry.io/deployment.environment.name, que é mecanismo independente da extração e não precisa de allowlist.

Então a allowlist encolheu para o que a RFC 0004 usa de verdade: classificação, não identidade. component diz que peça da arquitetura o workload é, part_of diz de que produto ele faz parte, e os dois são justamente o que nenhum outro atributo responde.

A allowlist renomeou os labels em silêncio

A troca de preset por lista de chaves, na 1.16.0, mudou o nome de quatro labels sem que isso fosse pedido nem notado. O preset gerava tag_name: $$1, que produz o nome do label cru; a lista de chaves sem tag_name cai no padrão do k8sattributes, que prefixa com k8s.pod.labels..

Medido em 16/10/2026: app_kubernetes_io_component, _name, _instance e _part_of em zero séries, com os valores vivendo em k8s_pod_labels_app_kubernetes_io_*.

Não quebrou nada, e isso não é mérito: nenhuma regra e nenhum painel lia esses labels. É um evento D6 que passou sem consequência por falta de consumidor, e a lição é que trocar o mecanismo de extração é mudar o conjunto de labels, mesmo quando as chaves de origem são as mesmas.

A coleta de k8s_replicaset_* também foi desligada, com um filter/unconsumed no collector: 478 séries, 239 identidades que mudavam a cada deploy, nenhum consumidor em regra ou painel.

O guard rail não fecha, e o item 4 não era onde eu pensava​

hygiene:series_with_volatile_labels:count tem alvo 0 e está em 1039, medido em 16/10/2026. Os cinco labels que a regra conta:

LabelSéries k8s_*De onde vem
k8s_pod_start_time1039extract.metadata, pedido pelo preset
container_id197atributo de recurso do receiver k8s_cluster
checksum_config0era label de pod, saiu com a allowlist
controller_revision_hash0idem
pod_template_generation0idem

Os três que esta seção citava por nome foram embora. Os dois que sobraram nunca foram labels de pod, e por isso desligar extractAllPodLabels não os tocou:

  • k8s_pod_start_time está na lista extract.metadata, que o preset kubernetesAttributes monta e que a allowlist não substitui. É volátil pelo pior motivo possível para o D6: muda a cada reinício de pod, então todo restart gera label set novo e reseta o estado do alerta. Tirá-lo é editar a lista metadata, não a de labels.
  • container_id vem do receiver, em k8s_container_ready, k8s_container_restarts e quatro outras. Muda a cada recriação de container.

O achado útil não é a contagem, é o diagnóstico: esta seção tratava churn de label como problema de extração de label, e dois terços do volume vinham de extract.metadata e de um receiver. O alvo 0 continua certo; o caminho descrito aqui nunca chegaria nele.

5. Criar o label de ambiente​

Antes de criar convenção: a semconv já cobre ambiente​

O collector roda com otel_annotations: true, verificado na config em execução. A annotation resource.opentelemetry.io/<atributo> vira o resource attribute <atributo> direto, por mecanismo padrão do k8sattributes — sem extração customizada, sem transform, sem convenção caseira. Nenhum workload do cluster usa isso hoje.

Para ambiente, o atributo de semconv é deployment.environment.name:

# pod template — sem nenhuma mudança no collector
metadata:
annotations:
resource.opentelemetry.io/deployment.environment.name: prod
A vantagem decisiva: imune ao item 4

otel_annotations é um mecanismo separado de extractAllPodLabels/extractAllPodAnnotations. Desligar os dois no item 4 não afeta em nada as annotations resource.opentelemetry.io/* — enquanto qualquer chave arca.io/* precisa entrar na extração explícita e morre em silêncio se esquecida, que é exatamente a armadilha documentada no item 3.

Em troca: é só de pod, então exige rollout dos 32 workloads e não aproveita o namespace; annotation não é selecionável por kubectl get -l; e o label no Prometheus fica deployment_environment_name, mais verboso que environment.

O terceiro custo deixou de existir em 16/10/2026. O kelbi-platform 1.20.0 renomeia o atributo para environment no collector, e o label na métrica fica curto. A annotation no pod segue sendo a da convenção. O princípio que saiu disso vale para os outros labels também: convenção na declaração, nome curto na métrica.

Label ou annotation, para as chaves próprias​

Se ainda assim o caminho escolhido for uma chave arca.io/* — pelo ganho de não fazer 32 rollouts, ou pela seleção via kubectl — a decisão de tipo é esta. Para identidade (environment, app, owner) é label: valores enumeráveis, de baixa cardinalidade, e que se quer poder selecionar (kubectl get pods -l arca.io/environment=prod). Annotation serve para o que não cabe em label, porque valor de label aceita no máximo 63 caracteres e só [a-zA-Z0-9._-]:

ChaveTipoPor quê
arca.io/environmentlabelprod/dev/platform; candidato também a NetworkPolicy, para isolar dev de prod no nó compartilhado
arca.io/applabelnome lógico do serviço, usado no job (ver item 3)
arca.io/ownerlabel se enumerável (tsugami/kushi/ryuzaki); annotation se for e-mailcharset de label não aceita @
arca.io/runbookannotation obrigatoriamenteURL tem : e /, inválidos em valor de label. Nunca extrair para métrica — serve para a pessoa ler no objeto
Onde colocar importa mais que label ou annotation

Label no pod template dispara rollout — ele entra no pod-template-hash. Marcar os 32 workloads significa 32 rollouts num nó que já estourou o teto de CPU uma vez. Annotation no pod template tem exatamente o mesmo efeito, então trocar o tipo não escapa disso.

Label no namespace não dispara nada, e o k8sattributes extrai de namespace também. Levantamento de 2026-10-13: 12 dos 13 namespaces com workload são homogêneos em ambiente — só kelbi-pg mistura (um StatefulSet -dev e o postgres-exporter). Então o namespace cobre quase tudo com 13 edições em vez de 32 rollouts.

Duas condições verificadas, porque namespace é objeto cluster-scoped: o ClusterRole do collector já concede get/watch/list em namespaces, e o informer de namespace já roda (é necessário para k8s.namespace.name), então from: namespace não acrescenta custo de memória nem exige mudança de RBAC. Isso não vale para from: deployment, replicaset, job ou cronjob, que iniciam informers adicionais ao serem usados em extração de label.

Label no namespace como default, e no pod só onde o namespace não basta:

# namespace (13 edições, nenhum rollout)
apiVersion: v1
kind: Namespace
metadata:
name: apps
labels:
arca.io/environment: prod # prod | dev | platform
# pod template — só onde divergir do namespace (hoje: kelbi-pg)
metadata:
labels:
arca.io/environment: dev

Duas extrações com tag_name distintos, porque o k8sattributes não tem noção de precedência entre fontes. O tag_name explícito não é cosmético: sem ele, o nome do atributo vem do default do processor, que mudou de k8s.pod.labels.<key> para k8s.pod.label.<key> entre as convenções v0 e v1 — e as duas feature gates que controlam isso (DontEmitV0K8sConventions, EmitV1K8sConventions) já estão ligadas por padrão no 0.149. Nomear explicitamente é o que torna a extração estável a bumps do collector.

# otel-collector (homelab)
processors:
k8sattributes:
extract:
labels:
- tag_name: arca.env.pod # override pontual
key: arca.io/environment
from: pod
- tag_name: arca.env.ns # default do namespace
key: arca.io/environment
from: namespace

E a resolução no mesmo transform do item 3, com o padrão de cadeia que já existe lá:

- set(resource.attributes["environment"], resource.attributes["arca.env.pod"]) where resource.attributes["arca.env.pod"] != nil
- set(resource.attributes["environment"], resource.attributes["arca.env.ns"]) where resource.attributes["environment"] == nil
- set(resource.attributes["environment"], "platform") where resource.attributes["environment"] == nil

O último set é o default à prova de falha exigido no fim desta seção. O otelcol do monitoring já roda com resource_to_telemetry_conversion: enabled: true, então o atributo chega ao Prometheus como label sem mudança adicional lá.

Nunca adicionar ao spec.selector

O selector de Deployment e StatefulSet é imutável. Se arca.io/environment entrar nele, mudar o ambiente de um workload passa a exigir recriar o objeto. Só em template.metadata.labels.

Por que não metric_relabel_configs

As métricas do k3s não chegam por scrape_config — chegam por prometheusremotewrite para /api/v1/write. metric_relabel_configs só se aplica a scrape e não teria efeito sobre elas. A derivação tem que acontecer no OTel.

Em ambos os casos, a ausência do label resolve para platform, nunca para dev.

6. Corrigir as regras restantes — 1 de 3​

Com os itens 1 e 3 feitos, sobram três:

AlertaDefeitoCorreçãoEstado
KubernetesContainerHighRestartsD1Remover. KubernetesContainerCrashLooping já cobre o caso realfeito — commit 11154cb
PostgresqlNotEnoughConnectionsD3Remover o que resta do ambiente de teste erupe (namespace e banco) e remover a regraaberto
KubernetesPodLowMemoryD4Threshold relativo a limit/request, seguindo o padrão de kubernetes:containers:resourcesaberto

Os quatro alertas do Traefik não precisam de mudança de regra — a condição deles está correta, e eles resolvem sozinhos quando a causa for removida.

Os dois que sobraram são os dois que ainda chegam ao #alerting

Verificado em 2026-10-13: dos nove alertas em firing, sete são info e vão para o triage. Os únicos que chegam ao canal são PostgresqlNotEnoughConnections — o único alerta crônico do sistema, em firing há 143h — e KubernetesPodLowMemory.

Ou seja, este item isolado é o que separa o #alerting de ficar silencioso. E o primeiro deles é o que mantém o item 7 bloqueado: criar #alerting-critical antes de remover o erupe moveria o ruído para o canal novo.

7. Rotear por severidade × ambiente​

prodplatformdev
critical#alerting-critical#alerting-critical#alerting-dev
warning#alerting#alerting#alerting-dev
info#alerting#alertingsuprimido

dev não é silenciado: vai para canal próprio, sem repeat_interval. Um ambiente de dev quebrado ainda consome recursos do nó compartilhado, e isso é informação de FinOps — só não é motivo para interromper ninguém.

Implementação no alertmanager/alertmanager.yml.tmpl, com dois webhooks novos no .env e no .env.example:

DISCORD_TOKEN=            # #alerting           (existente)
DISCORD_TOKEN_CRITICAL= # #alerting-critical
DISCORD_TOKEN_DEV= # #alerting-dev

#alerting-critical só pode ser criado depois dos itens 1, 2 e 6. Criado hoje, nasceria com PostgresqlNotEnoughConnections dentro.

8. Corrigir a configuração do Alertmanager​

MudançaDeParaPor quê
group_by[alertname][alertname, environment, k8s_namespace_name]Agrupar só por alertname é o que produz a mensagem de 31 blocos sem identificação
repeat_interval4h para tudo4h critical, 24h warning, nunca em dev4h em warning é o multiplicador que transforma 1 alerta travado em 180 mensagens/mês
inhibit_rulesnenhumaalerta de platform inibe os de workloadVer abaixo
O que inhibit_rules teria feito no incidente

Durante o incidente, o nó sem CPU gerou cascata de alertas de workload — 40 a 70 notificações/hora, pico de 71,3 às 14:18. Com inibição, o alerta de plataforma (HighCpuUsage) suprimiria os derivados: uma notificação apontando a causa, em vez de setenta apontando sintomas.

9. Detectar fadiga automaticamente, não por calendário — feito​

A versão anterior desta RFC propunha revisão periódica manual do volume. Isso depende de alguém lembrar, e foi exatamente o que não aconteceu nos 30 dias anteriores ao incidente.

Um alerta em firing contínuo por mais de 48 h é, por definição, um alerta que virou ruído. O Prometheus já expõe o dado:

# meta-alerta: alerta cronificado
time() - ALERTS_FOR_STATE{kind!="hygiene"} > 172800

Severidade info, roteado para o canal de triagem — nunca para o #alerting. Isso transforma fadiga de alerta em condição detectável, em vez de achado de postmortem. Os quatro alertas do Traefik teriam sido pegos no terceiro dia, não no quarto mês.

Implementado como AlertingChronicAlerts, no item 10.

O instrumento não pode se contar

O filtro kind!="hygiene" foi acrescentado em 15/10/2026 e corrige um defeito de medição desta própria RFC. Os doze alertas de alerts/hygiene.yml são um checklist dos itens em aberto: cada um fica em firing de propósito até o item correspondente ser feito, com repeat_interval de 7 dias e indo para o triage. Contá-los como alerta crônico mede o termômetro como se fosse febre.

O efeito prático era tornar o critério de aceitação inalcançável enquanto a RFC estivesse aberta. Em 13/10/2026, com a exclusão do erupe, hygiene:alerts_chronic:count chegou a 0 e o critério foi dado como atendido. Em 15/10/2026 o valor estava em 4, e os quatro eram guard rails da própria RFC: AlertingChannelVolumeHigh, ErupeRemnantsPresent, MetricsWithoutEnvironment e MetricsWithVolatileLabels. Nenhum alerta real havia cronificado; a métrica só havia passado a contar a si mesma depois de 48h.

A exclusão é por label e não por nome porque os doze não compartilham prefixo — TraefikDuplicated e ErupeRemnantsPresent não casariam um regex de Alerting|Metrics — e porque um guard rail novo passa a ser excluído só por declarar kind: hygiene. A mesma exclusão vale para hygiene:alerts_oldest:hours, pela mesma razão.

Isso não dispensa olhar os guard rails: eles continuam visíveis no painel e no triage, e um que fique semanas aberto segue sendo trabalho parado. O que muda é que ele deixa de ser contado como ruído do #alerting, que é o que a métrica existe para medir.

10. Instrumentar o próprio critério de aceitação — feito​

14 dos 40 itens do critério têm métrica hygiene:* dedicada. Em vez de acompanhá-los por checklist manual, eles viram métrica, painel e guard rail:

prometheus/rules/recording/hygiene.yml    25 regras hygiene:*
prometheus/rules/alerts/hygiene.yml 12 regras, em dois grupos
grafana/provisioning/dashboards/ dashboard provisionado, 23 painéis

A distinção que organiza os alertas:

GrupoPropósitoEstado em 2026-10-13
hygiene:guardrails (5)Detecta regressão — algo que estava bom piorou4 quiescentes; AlertingRulesChanged em firing pelo deploy do dia
hygiene:targets (7)Acompanha as metas em aberto da RFC5 em firing, de propósito; 2 já resolveram sozinhos

O grupo targets dispara enquanto os itens estão abertos; cada alerta resolve sozinho quando o item correspondente for concluído, e o grupo passa a funcionar como guard rail permanente sem precisar de mudança.

O mecanismo já se provou duas vezes

TraefikDuplicated resolveu com o item 1 e MetricsWithoutIdentity com o item 3 — nenhuma edição de regra, nenhum checkbox marcado à mão. Os cinco que seguem em firing (AlertingChannelVolumeHigh, AlertingChronicAlerts, MetricsWithVolatileLabels, MetricsWithoutEnvironment, ErupeRemnantsPresent) correspondem exatamente aos itens 4, 5, 6 e 8 em aberto.

Foi essa instrumentação que permitiu auditar o critério de aceitação contra o estado real em vez de contra o que este documento afirmava — e foi ela que acusou dois itens marcados como abertos que já estavam feitos.

Onde esses alertas não podem cair

Todos usam severity: info e são roteados para um receiver triage com repeat_interval: 7d. Nunca para o #alerting. Alertar sobre a saúde do alerting dentro do canal que se está tentando limpar seria exatamente o defeito que esta RFC documenta.

O guard rail mais importante é AlertingRulesChanged, sobre changes(hygiene:rules_loaded:count[1h]) > 0: ele detecta o evento do D5 — um deploy que altera o conjunto de regras carregadas. É o único jeito de saber que a reversão aconteceu no momento em que acontece, e não no próximo postmortem.

Os 26 itens restantes seguem como verificação manual — entre eles o provisionamento do k3s versionado, as divergências commitadas com os comentários preservados, os guard rails documentados, o dono de plantão e o SLA. Forçá-los em alerta produziria o alerta inacionável que a RFC combate: um alerta sobre trabalho pendente de processo é um item de backlog, não um sinal.

"Manual" aqui significa sem métrica dedicada, não impossível de medir. Vários desses itens são verificáveis por consulta pontual — count({__name__=~"argocd.*"}) para a coleta do ArgoCD, por exemplo, ou o label instance das métricas de host — e foi assim que a auditoria de 2026-10-13 os conferiu. O critério para merecer uma hygiene:* própria é poder regredir sem ninguém notar; o que é feito uma vez e fica feito não precisa de vigilância contínua.

Junto vêm dois alvos de validação, make check (Docker local) e make check-remote (usa o promtool do container da VPS), que materializam o item 11.

A lacuna mais barata de fechar: ArgoCD​

O ArgoCD entrega todo o cluster e não tem uma única métrica no Prometheus nem uma única regra de alerta. Quatro Applications estão fora do estado desejado agora, em silêncio, e um órfão de Application deletada sobreviveu cinco meses porque nada observa esse nível.

O argocd-application-controller já expõe /metrics — e o collector do homelab já tem o preset annotationDiscovery habilitado, então basta anotar o Service para a coleta começar. Dois alertas cobrem o essencial:

CondiçãoSeveridade
Application OutOfSync por mais de 1hwarning
Application com health Degraded ou sync Unknowncritical

Isso não é escopo original desta RFC, mas é a diferença entre descobrir um órfão em uma hora ou em cinco meses — e é o único item da lista que adiciona sinal em vez de remover ruído.

11. Guard rails para regras novas — parcial​

Os dez pontos abaixo estão definidos e os alvos make check / make check-remote existem e rodam, mas nada disso está escrito no repo de monitoring — vive só nesta RFC, que é um documento de decisão e não o lugar onde alguém vai procurar antes de escrever uma regra. Enquanto não estiver no README do repo, o guard rail depende de alguém lembrar de ler a RFC certa.

Os defeitos D1, D2, D3 e D7 não são azar: são a mesma falha de revisão quatro vezes. promtool check rules valida sintaxe e não pega nenhum dos quatro.

  1. Alerta sobre contador monotônico precisa de increase() ou rate() com janela explícita — e checar se já não existe regra cobrindo o mesmo sintoma

  2. Alerta não pode depender de agregação que remove a identidade do objeto; com o item 3 feito, sum by (job, instance) passa a ser seguro — sem ele, não é

  3. Toda annotation precisa renderizar com todos os campos preenchidos — Job has 2 failed pod(s) reprova na revisão

  4. Regra de pack genérico só entra depois de revisada contra este ambiente — 51 das 80 estão nessa condição

  5. Nenhuma edição de regra direto na VPS; o Git é a origem (ver D5)

  6. Label novo no conjunto de labels de um alerta precisa ser estável entre deploys (ver D6)

  7. Recording rule tem que ser consultada depois de criada, não só validada. promtool aprova expressão que retorna zero séries, e as três regras de load de D7 ficaram vazias desde que foram escritas sem que nada acusasse. Toda regra nova entra com uma consulta ao resultado

  8. Operação binária entre dois vetores exige conjuntos de labels compatíveis, ou on()/group_left explícito. Vetor com labels dividido por agregação sem by não casa nada e não gera erro — foi exatamente o que matou otel:node_load1_per_cpu:ratio

  9. Toda agregação preserva a chave do objeto de que o alerta fala — by(instance) para host, by(job, instance) para workload. D2 veio do sender não preencher a identidade; D7 veio do consumidor jogá-la fora

  10. Mudança de identidade de métrica é feita de uma vez, e com silence nas regras de predição. Trocar job/instance cria série nova sem histórico, e predict_linear/deriv sobre janela longa leem a rampa inicial como tendência — ver o risco correspondente

  11. Configuração declarada precisa ser conferida no consumidor, não na origem (ver D8). Annotation, label e caminho de arquivo são todos sintaticamente válidos quando escritos no lugar errado, e aí ficam inertes: 160 dias no Velero, quatro meses e meio no deploy do mirim-bot. A conferência é de ponta a ponta — declarou a annotation, consulte a série; apontou o deploy, veja a imagem no pod

O guard rail que falta é automatizável

Os pontos 7 e 8 são verificáveis por máquina: uma regra que avalia para zero séries logo depois de carregada é quase sempre defeito, não um sistema saudável. Um alerta info sobre absent(<regra>) para cada recording rule, ou uma varredura periódica comparando o conjunto de regras carregadas com o conjunto que tem dado, pegaria essa classe inteira — e é o mesmo princípio do item 9, detectar por métrica em vez de por calendário.

O ponto 11 é automatizável pelo mesmo princípio, e um dos casos já tem a métrica pronta: hygiene:series_unconsumed:count conta série coletada que nenhuma regra lê. O inverso, declaração que não produziu série, é o que falta.

make check-remote cobre os pontos 1 e 3 de forma automatizável (sintaxe PromQL e de template); os demais continuam dependendo de revisão.

12. Dono e SLA — depois​

Definir dono de plantão e SLA de resposta continua certo, mas não como primeiro passo. Com 40 mensagens/dia, qualquer SLA é teatro: o dono herdaria a obrigação de olhar spam e a fadiga voltaria pela mesma porta.

Depois dos itens 1 a 10, o #alerting deve estar em ~4 notificações/dia, e aí um SLA de 1 dia útil passa a significar algo.

13. Corrigir D7 e unificar a telemetria de host nos dois servidores — feito​

Executado em 2026-10-13. Não estava previsto nesta RFC; entrou porque corrigir D7 e unificar os dois hosts é o mesmo trabalho, e um não funciona sem o outro.

O estado anterior. Dois servidores alimentavam o mesmo Prometheus e cada um media o próprio host por um caminho diferente: o nó k3s na Hostinger via hostmetrics do OpenTelemetry (system_*), a VPS de monitoring no Contabo via node-exporter scrapeado pelo Prometheus (node_*). Dois conjuntos de nomes de métrica e duas camadas de recording rule descrevendo a mesma coisa — 23 regras otel:node_* sobre system_* e 29 regras instance:node_* sobre node_*.

Nada no dado dizia de qual servidor uma métrica vinha. A separação existia só como acidente de nomenclatura: system_* significava Hostinger porque era a única origem desse nome; node_* significava Contabo pelo mesmo motivo. E otelcol_* existia nos dois, distinguível apenas pelo job.

O que foi feito. O node-exporter saiu e a VPS passou a usar o hostmetrics do otelcol que já rodava nela, com os seis scrapers que alimentam as regras existentes — cpu, load, memory, disk, network, filesystem — e nada além: scraper que não alimenta regra é série coletada sem consumidor.

Três pipelines, e não é organização

O otelcol do Contabo recebe o OTLP do nó k3s e coleta o próprio host. Num pipeline único, o processor de resource que dá identidade local reescreveria as métricas do k3s como se fossem da VPS — apagando exatamente a identidade que o item 3 construiu no sender.

pipelines:
metrics: # OTLP do k3s — já chega identificado, passa sem processor de resource
receivers: [otlp]
metrics/host: # hostmetrics local → contabo-monitoring-01
receivers: [hostmetrics]
processors: [memory_limiter, resource/host, batch]
metrics/self: # self-telemetry do otelcol → fixa só o instance
receivers: [prometheus/self]
processors: [memory_limiter, resource/self, batch]

O metrics/self resolve de lado um churn que esta RFC não tinha mapeado: a self-telemetry do otelcol gera um service.instance.id novo (UUID) a cada processo, então cada restart criava um instance novo — 116 UUIDs acumulados em 30 dias de retenção. Fixar o instance no servidor encerra isso e preserva service.name, que é o que identifica o processo.

As 23 regras otel:node_* passaram a agregar por instance, o que corrige D7 e é pré-requisito da migração: com dois emissores de system_*, sum() sem by somaria os dois hosts num número só. As três regras de load foram corrigidas com sum by(instance) no numerador e saíram de 0 para 2 séries — HighLoad passou a ser um alerta funcional.

recording/node.yml saiu inteiro — 29 regras instance:node_* sem nenhum consumidor em alerta ou dashboard, e sem fonte depois da migração. As otel:node_* cobrem o mesmo terreno, agora com identidade e para os dois hosts.

Dois mecanismos de deploy tornavam a remoção incompleta e silenciosa

Ambos corrigidos no Makefile, e ambos são o padrão de resíduo sem dono que esta RFC documenta na causa raiz:

  • scp nunca remove. Regra deletada do repo seguiria carregada na VPS indefinidamente — o drift do D5 na direção oposta, e sem nenhum sinal de que aconteceu. Daí o target prune-remote, cuja lista fica como registro do que já foi retirado.
  • docker compose up -d não derruba serviço que não conhece mais. O node-exporter teria sobrevivido à própria remoção como container órfão, consumindo recursos de um nó que já estourou o teto de CPU uma vez. É o mesmo mecanismo do traefik-root-app. Daí o --remove-orphans.

Resultado medido

MétricaAntesDepois
job/instance de hostsrv503207 / node-exporter:9100hostinger-k3s-cluster-01 / contabo-monitoring-01
séries node_* ativas320
otel:node_load1_per_cpu:ratio0 séries2
séries de otel:node_* com instance0todas
regras carregadas214185

Fora do escopo​

  • Trocar o Alertmanager por outra ferramenta — nenhum dos seis defeitos é limitação da ferramenta

  • Separar ambientes em clusters distintos — decisão de FinOps já tomada; esta RFC trabalha dentro dela

  • Revisar as 51 regras de packs genéricos uma a uma — esta RFC trata a que está disparando e cria o guard rail; a varredura completa é trabalho próprio

  • Telemetria granular por processo/pod — lacuna de dado, tratada nas ações do postmortem

  • Provedor de VPS ou plano contratado — assunto do postmortem

  • Divergência entre o ADR 0001 e a implementação (o ADR previa OTLP receiver no Prometheus; o que existe é otelcol + prometheusremotewrite) — assunto do ADR

  • Alertas para o túnel Cloudflare — a coleta das métricas do cloudflared entrou em 2026-10-12 (job="cloudflare-tunnel/cloudflared", 23 métricas, por annotation discovery no repo homelab), mas nenhuma regra as consome. Os alertas pedem decisões próprias: que piso de cloudflared_tunnel_ha_connections caracteriza perda de redundância, como separar cloudflared_tunnel_request_errors_total — erro de borda — do erro de origem que o Traefik já reporta, e qual fatia de cloudflared_proxy_connect_latency é acionável. Até lá, a borda de entrada tem medição e não tem alerta. RFC própria.

  • Roteamento por dono — a terceira dimensão — esta RFC separa alerta por severidade e ambiente. Falta a dimensão que corresponde a quem age: o time tem três pessoas com domínios distintos — Tsugami na plataforma (o que faz o cluster funcionar: ArgoCD, Grafana, metrics-server, Traefik, collector), Kushi nos apps do jogo (kelbi-admin, kelbi-web, kelbi-api) e Ryuzaki no jogo principal (halk, mhfrontier-cq-tool), com Tsugami também habilitado a dar manutenção no jogo principal. Um alerta que chega para três pessoas quando só uma pode agir é fadiga por construção, mesmo com severidade e ambiente corretos.

    Dois pontos que o levantamento de 2026-10-12 já esclareceu e que a próxima RFC herda:

    Rotear por namespace não resolve. O namespace apps concentra 13 Applications dos três domínios — prod-halk-* e prod-mhfrontier-cq-tool-api de um lado, prod-kelbi-* de outro, mais os storage-* e prod-mirim-bot. A chave de roteamento precisa ser um label de dono no pod template, propagado à métrica pelo k8sattributes.

    D2 e D6 são pré-requisitos, não itens paralelos. Um alerta sem identidade não sabe de que objeto fala e portanto não sabe de quem é (D2). E a correção de D6, trocar extractAllPodLabels por extração explícita, é a mesma mudança que carrega o label de dono até o Alertmanager. Os dois ficam nesta RFC; o roteamento que eles habilitam, na próxima.

    Com a allowlist em vigor desde 16/10/2026, essa continuidade virou um custo concreto: um label de dono só chega à métrica se for acrescentado à allowlist, e esquecer falha em silêncio. A RFC de roteamento precisa abrir com essa edição, não terminar com ela.

    Questões abertas para resolver lá: a quem pertencem o namespace kelbi (os 9 servidores de jogo: channel-1..6, entranceserver, signserver, launcherserver), kelbi-pg, prod-mirim-bot e homepage; e se a cobertura de Tsugami no jogo principal entra como fallback de escalonamento ou como co-dono desde o início. RFC própria.

  • Fluxo de rede dos apps, ponta a ponta — não há documento que descreva o caminho Cloudflare → túnel → Traefik → Gateway API / IngressRoute → Service → pod. A ausência cobrou um preço nesta RFC: a triagem concluiu que o Traefik órfão não recebia tráfego, quando ele atendia parte das requisições de *.arcamh.com através de um segundo deployment de cloudflared registrado no mesmo túnel — ver a seção de causa raiz. Wiki, não RFC: é descrição do que existe, não decisão a tomar.


Riscos​

RiscoMitigação
Remover o Traefik órfão causa indisponibilidadeRisco real, não descartado — a primeira versão desta RFC o descartou com base em "zero referências em IngressRoute/HTTPRoute/Gateway/Ingress", e isso era insuficiente: o tráfego chegava por um segundo cloudflared no mesmo túnel. Mitigado pela ordem de remoção (túnel antes do Traefik) e por verificar a migração do tráfego entre os passos
Concluir que um objeto está ocioso por ausência de referência declarativaUma varredura de Ingress/HTTPRoute não cobre quem chama o Service pelo DNS interno. Verificar log de acesso e IP de origem antes de afirmar que algo não recebe tráfego
Deletar o pod ou o DaemonSet svclb e achar que resolveuO svccontroller do k3s recria os dois; remover o Service é o único passo que encerra a cadeia
Desabilitar o Traefik embutido derruba o k3s na reinicializaçãoO Traefik em uso é instalado por ArgoCD, não pelo k3s; o embutido nunca completou a instalação (jobs falhados desde maio)
Reconciliar o Git reverte a triagem feita na VPSItem 2 trata o conteúdo da VPS como fonte, não o Git
O transform no collector quebra o pipelineerror_mode: ignore; validar a config efetiva no pod e o volume de séries antes/depois. Verificado: num DaemonSet o pod antigo sai antes do novo entrar, então config inválida custa telemetria — validar com otelcol validate antes do commit, não depois do rollout
Dar identidade a 715 séries aumenta a cardinalidadeO item 4 remove bem mais do que o item 3 adiciona; medir prometheus_tsdb_head_series nas duas pontas. Medido: o head subiu de 12.840 para 17.574, mas as séries ativas por prefixo ficaram estáveis (k8s_* 1.762 → 1.732) — o salto era a convivência transitória dos label sets antigo e novo até a compactação do bloco de 2h, não custo permanente
Desligar extractAllPodLabels (item 4) quebra a precedência de arca.io/app/app do item 3Caducou em 15/10/2026: a precedência foi removida do collector, então não havia o que preservar. O risco real era outro e se concretizou: a allowlist renomeou os quatro labels que extraía, de app_kubernetes_io_* para k8s_pod_labels_app_kubernetes_io_*, porque o preset declarava tag_name e a lista de chaves não. Mesma falha silenciosa, por chave presente com nome trocado em vez de chave esquecida. Allowlist nova deve declarar tag_name sempre
Recording rule aprovada pelo promtool e vazia na práticaConsultar o resultado de toda regra nova, não só validar a sintaxe (guard rails 7 e 8 do item 11). Três regras de load ficaram vazias desde que foram escritas, e o alerta que as consumia nunca pôde disparar
Mudar a identidade de uma métrica dispara alerta de tendênciaAconteceu em 2026-10-13. Trocar job/instance não renomeia a série: cria uma série nova, que começa sem histórico. Uma regra de predição sobre janela longa lê a rampa inicial como tendência e extrapola absurdo — DiskWillFillIn24Hours disparou com predict_linear(...[3h]) prevendo −575 GB sobre uma série de 6 minutos, com 29 GB livres no disco real. Resolve sozinho quando as séries antigas expiram e a nova cobre a janela, mas durante a transição o #alerting recebe um warning falso. Mudança de identidade deve ser feita de uma vez, não em etapas (foram três aqui, e cada uma criou mais uma variante: 6 séries para 2 hosts), e regra de predição merece silence pela duração da janela
Cortar extractAllPodLabels quebra dashboardsChecar os dashboards do Grafana antes; a extração explícita cobre os labels em uso
Roteamento por ambiente cria ponto cego novoDefault platform = produção; ambiente classifica o objeto, nunca a causa
Remover KubernetesContainerHighRestarts perde coberturaCrashLooping cobre o burst; o acumulado histórico não é acionável por construção

Critério de aceitação​

A coluna Medido por nomeia a métrica hygiene:* que acompanha o item no dashboard "Alerting Hygiene". Itens sem métrica são verificação manual — ver o item 10 da proposta.

Auditoria de 2026-10-13 — 23 de 40 itens

Cada item mensurável foi conferido contra o Prometheus, o cluster e os repos, não contra o que este documento afirmava. A auditoria encontrou dois itens marcados como abertos que já estavam feitos (canal de triagem e deploy na VPS), um marcado como feito que não era verdade naquele momento (AlertingRulesChanged) e um resíduo não catalogado (ansible/).

SeçãoFeitos
Instrumentação7 / 7
Causa raiz e drift9 / 15
Identidade e cardinalidade4 / 6
Regras e ambiente3 / 8
Resultado0 / 4

A seção Resultado é a que ainda não saiu do zero, e isso é esperado: os dois critérios de volume dependem do item 6 (o erupe) e de uma janela sem deploy. A instrumentação do item 10 está completa e já está sendo usada para medir — foi ela que permitiu esta auditoria.

Instrumentação (pré-requisito dos demais)​

  • Recording rules hygiene:* medindo os itens mensuráveis — recording/hygiene.yml, 25 regras
  • Guard rails de regressão e metas — alerts/hygiene.yml, 12 regras em dois grupos
  • Rota severity: info → receiver triage, fora do #alerting
  • Dashboard provisionado e versionado, read-only na UI
  • make check / make check-remote validando as regras com promtool
  • Canal de triagem criado no Discord e DISCORD_TOKEN_TRIAGE preenchido no .env — webhook distinto do #alerting, verificado por hash; o Alertmanager em execução expõe os receivers discord e triage com a rota severity=info → triage e repeat_interval: 1w, e alertmanager_notifications_failed_total = 0
  • Deploy aplicado na VPS (make deploy) — alerting-hygiene.json e dashboards.yml presentes no servidor

Verificado em 2026-10-13: dos 9 alertas em firing, 7 são info e vão para o triage. Só dois chegam ao #alerting — PostgresqlNotEnoughConnections e um DiskWillFillIn24Hours falso-positivo transitório. A instrumentação está cumprindo o papel de não poluir o canal que a RFC quer limpar.

Causa raiz e drift​

  • cloudflare-tunnel-root-app removido antes do Traefik órfão, com a migração do tráfego verificada entre os passos — manual
  • service/traefik-root-app removido (o svclb Pending saiu com ele) — hygiene:pods_not_running:count = 0
  • deployment/traefik-root-app e recursos da Application inexistente removidos — hygiene:traefik_instances:count = 1
  • Traefik embutido do k3s desabilitado, e só então HelmChart + Jobs helm-install-traefik* removidos — hygiene:job_failed_pods:count = 0
  • Os quatro alertas do Traefik resolvidos sozinhos, sem edição de regra — hygiene:traefik_alerts_firing:count = 0
  • Traefik exposto apenas internamente (service.spec.type: ClusterIP), fechando o acesso direto pelo IP do nó — manual
  • Provisionamento do k3s versionado em repo — manual; o disable: [traefik] existe só na VPS
  • ArgoCD coletado e com alertas de OutOfSync / Degraded — hoje: 0 métricas, 0 regras
  • Git e VPS convergidos; make deploy deixou de reverter triagem feita no servidor — o Git é a origem, e nenhum arquivo órfão restou na VPS
  • AlertingRulesChanged quiescente — em firing desde o deploy de 2026-10-13 (214 → 185 regras). É o guard rail funcionando sobre mudança intencional, não drift, e resolve sozinho em 1h; o critério só é avaliável fora de janela de deploy
  • Resíduo de provisionamento removido do repo de monitoring — ansible/ aponta para o host hostinger com as roles node-exporter e kube-state-metrics, e nenhum dos dois existe: serviço inativo, porta 9100 livre, nenhum kube-state-metrics no cluster. Depois do item 13 ficou contraditório, porque provisiona justamente o que foi abandonado — o mesmo padrão de resíduo sem dono da causa raiz
  • Quatro divergências de D5 commitadas com os comentários de justificativa preservados — commit a5af6ac
  • PrometheusAlertmanagerE2eDeadManSwitch fora do repo — hygiene:deadmanswitch_present:count = 0
  • Dead man switch reimplementado como detecção por ausência de notificação — manual
  • chmod 600 em vez de 644 nos certs/*.key ao final do make sync — manual

Identidade e cardinalidade​

  • Nenhuma série k8s_* sem job e instance — hygiene:series_without_identity:count = 0 (eram 679 em 32 métricas; a RFC registrou 715/38 antes da remoção dos resíduos de Traefik)
  • Toda série identifica o servidor de origem — job = instance = hostinger-k3s-cluster-01 / contabo-monitoring-01 nas métricas de host; nas de workload o servidor é o do job de nó do mesmo cluster
  • Precedência de arca.io/app e app sobre a cadeia de workload no service.name — manual; arca.io/app ainda não existe em nenhum workload
  • extractAllPodLabels e extractAllPodAnnotations desligados, com allowlist de component e part_of (kelbi-platform 1.16.0 e 1.19.0). A ressalva sobre incluir app e arca.io/app caducou: a precedência que dependia deles foi removida, não preservada
  • hygiene:series_with_volatile_labels:count = 0. Em 1039 em 16/10/2026, e os dois labels que restam não vêm da extração de label: k8s_pod_start_time vem de extract.metadata e container_id do receiver k8s_cluster
  • Comentários de recording/otel-kubernetes.yml corrigidos nas cinco seções (PODS, CONTAINERS, DEPLOYMENTS, JOBS, NODES) — manual
  • instance dos scrapes locais do Contabo (prometheus, alertmanager) identificando o servidor — hoje localhost:9090 e alertmanager:9093

Regras e ambiente​

  • Ambiente de teste erupe removido (namespace, secret/erupe-database-secret e banco) — hygiene:erupe_remnants:count = 0
  • KubernetesContainerHighRestarts removida (D1) — commit 11154cb; 215 → 214 regras, alerta fora do firing
  • Regras otel:node_* preservando instance, e as três de load saindo de 0 séries (D7) — commit e793915; HighLoad deixou de ser um alerta que nunca podia disparar
  • Telemetria de host unificada em hostmetrics nos dois servidores, node-exporter e as 29 regras instance:node_* removidas — commit e793915; 214 → 185 regras
  • KubernetesPodLowMemory com threshold relativo (D4) — manual
  • Ambiente nas métricas, com default à prova de falha. O guard rail hygiene:environment_coverage:ratio passou a medir de verdade em 16/10/2026, com o label renomeado para environment no kelbi-platform 1.20.0: era 0 por construção, por contar um nome que o item 5 decidiu não criar, e lê 0,11 (286 de 2511 séries k8s_*). O alvo 1 segue longe porque só as 12 aplicações do kelbi-app declaram a annotation, e nenhum addon declara
  • Roteamento por severidade × ambiente no alertmanager.yml.tmpl, com webhooks novos no .env.example
  • group_by, repeat_interval e inhibit_rules ajustados conforme o item 8 — hygiene:alerts_suppressed:count > 0 comprova a inibição

Resultado​

  • Nenhum alerta em firing contínuo por mais de 48h, excluída a família kind: hygiene — hygiene:alerts_chronic:count = 0. Atendido em 13/10/2026 ao excluir o erupe do PostgresqlNotEnoughConnections, que era o único crônico real, em firing há 143h. Voltou a 4 em 15/10/2026, e os quatro eram guard rails desta RFC: a métrica estava se contando. Corrigido no mesmo dia com o filtro kind!="hygiene", descrito no item 9. O item 6 segue aberto pelo resto do seu escopo, não por este critério
  • Volume do #alerting abaixo de 10 notificações/dia por 7 dias consecutivos — hygiene:notifications:per_day = 60 (meta <10). Verificado que não é flapping (zero mudanças de estado em 6h): são 12 alertas distintos que passaram por firing, vários transitando por causa das mudanças dos itens 3 e 13. O critério não é avaliável dentro de janela de deploy — precisa de 24h estáveis
  • Guard rails do item 11 documentados no repo de monitoring — manual
  • Só então: dono de plantão e SLA definidos — manual

Onde retomar​

Ordenado por dependência, não por importância. O estado é o da auditoria de 2026-10-13.

1º — Item 6: os dois alertas que ainda chegam ao canal​

Dos nove alertas em firing, sete são info e vão para o triage. Os dois que chegam ao #alerting são exatamente os dois que faltam do item 6, então este item isolado é o que separa o canal de ficar silencioso:

  • PostgresqlNotEnoughConnections — remover o que resta do erupe (namespace, secret/erupe-database-secret, banco) e então a regra. É também o único alerta crônico do sistema, em firing há 143h, e é ele que bloqueia o item 7: criar #alerting-critical antes disso moveria o ruído para o canal novo.
  • KubernetesPodLowMemory — threshold relativo a limit/request (D4).

2º — Itens 4 e 5 juntos, porque são a mesma mudança​

Desligar extractAllPodLabels/extractAllPodAnnotations e passar à extração explícita é a mesma edição que carrega o label de ambiente até o Alertmanager. Fazer separado significa mexer no k8sattributes duas vezes.

Decidir primeiro: resource.opentelemetry.io/* ou chave arca.io/*

A leitura da documentação do k8sattributes em 2026-10-13 mostrou que o collector já roda com otel_annotations: true, e que nenhum workload usa isso. Antes de criar convenção própria, a escolha é entre dois caminhos — ver o item 5:

  • resource.opentelemetry.io/deployment.environment.name: padrão de semconv, zero configuração nova, e imune ao item 4 (não depende da extração de labels). Custa 32 rollouts e não é selecionável por kubectl get -l.
  • arca.io/environment no namespace: 13 edições sem rollout nenhum, selecionável, mas é convenção caseira que precisa entrar na extração explícita — e morre em silêncio se esquecida.

Se o caminho for arca.io/*, três coisas entram na mesma mudança:

  1. app e arca.io/app na extração explícita — senão a precedência do item 3 para de funcionar em silêncio, sem erro e sem série anônima, e todo job volta para a cadeia de workload.
  2. arca.io/environment de pod e de namespace, com tag_name distintos, mais os três set de resolução no transform.
  3. Decidir arca.io/environment vs arca.io/env antes de aplicar em 13 namespaces. Esta RFC usa environment.

Decisão relacionada, no item 3: arca.io/app provavelmente não deveria existir. app.kubernetes.io/name já cobre 32/32 workloads e é a fonte padrão de service.name, e resource.opentelemetry.io/service.name resolve o caso de divergência sem convenção nova. A precedência implementada fica como ponto de extensão — não custa nada, mas não deve ser propagada aos charts.

3º — Itens 7 e 8: roteamento e configuração do Alertmanager​

Só depois do item 6 (senão #alerting-critical nasce com ruído dentro) e do item 5 (o roteamento por ambiente precisa do label). O item 8 — group_by, repeat_interval por severidade, inhibit_rules — não depende de nada e pode ir junto; hygiene:alerts_suppressed:count > 0 comprova a inibição.

Independentes, a qualquer momento​

PendênciaEstado medido
ArgoCD coletado e com alertas0 métricas argocd_*, 0 regras. A lacuna mais barata: o Service só precisa de annotation, o preset annotationDiscovery já está ligado
Provisionamento do k3s versionadodisable: [traefik] existe só na VPS; uma reinstalação do nó recria os dois alertas de Job
Resíduo do ansible/ no repo de monitoringroles node-exporter e kube-state-metrics, nenhum dos dois existe; contraditório desde o item 13
Guard rails do item 11 no repoos dez pontos vivem só nesta RFC, não no README do monitoring
chmod 600 nos certs/*.keyainda 644 no Makefile e na VPS
Dead man switch por ausênciaconceito validado, implementação pendente
instance dos scrapes locais do Contabolocalhost:9090 e alertmanager:9093 não identificam o servidor

Verificar antes de medir resultado​

Dois critérios da seção Resultado não eram avaliáveis em 2026-10-13 e devem ser reconferidos fora de janela de deploy:

  • hygiene:notifications:per_day = 60 (meta <10). Não é flapping — verificado, zero mudanças de estado em 6h. São 12 alertas distintos que passaram por firing, vários transitando por causa dos itens 3 e 13. Precisa de 24h estáveis.
  • DiskWillFillIn24Hours deve ter resolvido sozinho. Era falso positivo da transição de identidade (predict_linear sobre série de 6 minutos prevendo −575 GB, com 29 GB livres). Se ainda estiver em firing, aí é disco de verdade.
  • As 47 falhas de avaliação no grupo PostgresExporter ficaram sem diagnóstico, confinadas à janela do deploy. A hipótese de duas instâncias do exporter foi testada e descartada. Se reaparecerem fora de deploy, investigar.

Consequências​

  • A provisão do k3s passa a ser versionada — hoje o nó roda k3s server sem argumento nenhum, e foi assim que o Traefik embutido nunca foi desabilitado
  • O ArgoCD passa a ser monitorado — é o mecanismo de entrega do cluster e hoje tem zero métricas e zero alertas
  • Recursos órfãos de Application deletada passam a ser um caso conhecido — o tracking-id aponta para uma Application que não existe; vale uma varredura periódica, já que o padrão apareceu duas vezes (Traefik e cloudflared)
  • O Git volta a ser a origem das regras, e o único lugar onde triagem é registrada
  • O collector do homelab ganha um transform e perde a extração indiscriminada de labels; a identidade de métrica passa a ser contrato do sender, não suposição do consumidor
  • Toda métrica passa a dizer de qual servidor vem — job = instance = <provedor>-<servidor> nas métricas de host, por parâmetro no chart. Antes a origem era inferível só pelo nome da métrica (system_* = Hostinger, node_* = Contabo), o que deixou de valer no momento em que os dois hosts passaram a emitir as mesmas séries
  • arca.io/app vira a fonte preferida do nome de serviço, acima de app e de qualquer nome de workload. A convenção está habilitada antes de existir: nenhum workload carrega a annotation ainda, e adotá-la é o que torna o job independente do nome do Deployment
  • Há uma só camada de normalização de host (otel:node_*), para os dois servidores, em vez de duas descrevendo a mesma coisa sobre fontes diferentes. O node-exporter sai do stack
  • Nome de host no monitoramento é lógico, não hostname — hostinger-k3s-cluster-01 em vez de srv503207. O nó k3s está registrado como objeto Node com o nome do provedor, e renomear o host de verdade quebraria esse registro; a divergência é deliberada
  • Deploy por scp passa a ter um passo de remoção explícito (prune-remote), porque cópia não apaga: sem ele, regra retirada do repo segue carregada na VPS sem sinal nenhum
  • 51 regras de packs genéricos ficam pendentes de revisão
  • Charts passam a carregar um label de ambiente, que vira requisito para workload novo
  • Novos canais no Discord (#alerting-critical, #alerting-dev) e duas variáveis novas no .env
  • Recurso devolvido ao nó e ao Prometheus com a remoção do erupe, do Traefik duplicado e do churn de cardinalidade
  • k8s_container_restarts deixa de ser usado como condição direta em qualquer regra
  • O README do repo de monitoring está defasado — documenta um prometheus/rules/alerts.yml único e lista 4 alertas, quando existem 80 em seis arquivos mais 100 recording rules