DevOps Mind
Métricas no Kubernetes: cAdvisor, kubelet e CRI na prática
Num ambiente que operei, um estudo de capacidade quase me fez desconfiar do meu próprio terminal: o kubectl top mostrava um consumo, o Prometheus mostrava outro, e a diferença não era detalhe. Na mesma época, um container de PHP-FPM vivia morrendo de OOMKilled enquanto o dashboard jurava que a memória estava tranquila. Deu ruim? Deu. E a raiz dos dois problemas era a mesma: eu estava olhando números sem saber de onde eles vinham.
Indo direto ao ponto: as métricas de container no Kubernetes nascem nos cgroups do kernel Linux. O kubelet coleta esses dados por dois caminhos (o cAdvisor, que roda embutido nele, e opcionalmente o CRI, direto do runtime) e expõe tudo em quatro endpoints HTTP. São esses endpoints que alimentam o metrics-server, o kubectl top, o HPA e o seu Prometheus. Bora destrinchar cada camada.
As quatro portas de métricas do kubelet
Todo nó do cluster expõe métricas pelo kubelet, e não é um endpoint só, são quatro, cada um com formato e público diferentes:
/metrics/cadvisor: métricas detalhadas de container em formato Prometheus, vindas do cAdvisor. É o que o kube-prometheus-stack raspa./metrics/resource: um resumo leve de CPU e memória por pod e container. É daqui que o metrics-server (0.6 em diante) tira o que abastece okubectl tope o HPA./stats/summary: a Summary API, em JSON, com visão agregada de nó, pods, volumes e filesystem./metrics: métricas internas do próprio kubelet (e é aqui que dá para conferir feature gates, guarda essa, vou usar já já).
Dá para inspecionar tudo isso sem instalar nada, só com um proxy para a API:
kubectl proxy --port=8001NODE=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}')
curl -sS "http://localhost:8001/api/v1/nodes/$NODE/proxy/metrics/resource" | head -20Quando bati de frente com a diferença entre kubectl top e Prometheus naquele estudo de capacidade, a explicação estava exatamente aqui: eu comparava fontes e métricas diferentes achando que eram a mesma coisa. O kubectl top mostra o working set instantâneo vindo do /metrics/resource; no Prometheus, eu olhava container_memory_usage_bytes, que inclui page cache. Nenhum dos dois estava errado. Eles medem coisas diferentes.
# a base do que o kubectl top mostra (working set, sem page cache)
container_memory_working_set_bytes{namespace="app", container!=""}
# o que muita gente compara por engano (inclui page cache)
container_memory_usage_bytes{namespace="app", container!=""}Se você faz capacity planning comparando essas duas séries, vai chegar a conclusões diferentes com as duas certas. O que muda é a pergunta que cada uma responde.
cgroups: onde os números nascem
Antes de qualquer endpoint, existe o kernel. O cgroup (control group) é o mecanismo do Linux que faz duas coisas ao mesmo tempo: impõe os limites de CPU e memória que você declara no manifest e contabiliza o consumo real de cada processo. Ou seja, o cgroup não é só o guarda que aplica o limite, ele é também a fonte primária de toda métrica de container que você consome depois. Se container e isolamento ainda são nebulosos para você, eu conto essa fundação no post sobre o que é Docker.
Hoje o assunto é cgroup v2: hierarquia unificada, um lugar só para todos os controladores. O cgroup v1, com suas árvores separadas por controlador, foi oficialmente deprecado no Kubernetes v1.35, e o kubelet por padrão nem sobe mais em nó com cgroup v1 (dá para forçar com failCgroupV1: false na configuração, mas é adiar o inevitável). Isso importa na prática quando você planeja upgrade de cluster; janela de versão é coisa séria, como mostrei nas pegadinhas do rollback de versão no EKS.
Com cgroup v2, o driver recomendado é o systemd, e o kubelet e o runtime precisam usar o mesmo driver, senão cada um enxerga uma hierarquia e a conta não fecha. Nas versões recentes essa dor sumiu: o kubelet descobre o driver automaticamente perguntando ao runtime via CRI (feature KubeletCgroupDriverFromCRI, GA na v1.34).
E onde cada pod cai nessa hierarquia? Depende da classe de QoS:
- Guaranteed: requests iguais aos limits em tudo. O pod ganha slice próprio direto em
kubepods.slice. - Burstable: tem requests, mas limits maiores (ou ausentes). Vai para
kubepods-burstable.slice. - BestEffort: sem requests nem limits. Vai para
kubepods-besteffort.slicee é o primeiro da fila do sacrifício quando falta recurso.
Dá para ver isso ao vivo num minikube:
minikube ssh -- ls /sys/fs/cgroup/kubepods.slice/
# saída típica:
# cpu.max memory.max kubepods-besteffort.slice kubepods-burstable.slice ...Cada arquivo desses é uma métrica nascendo. memory.current, cpu.stat, memory.max: é isso que o cAdvisor lê.
cAdvisor: o monitor embutido que se recusa a morrer
O cAdvisor nasceu na Google como projeto independente e foi incorporado ao kubelet há muitas versões. Ele varre o filesystem de cgroups do nó, descobre os containers e transforma aqueles arquivos crus em métricas com as labels que você conhece: pod, namespace, container, imagem. Sem ele, container_cpu_usage_seconds_total seria só um número solto num arquivo do kernel.
Só que tem pegadinha: o cAdvisor gera muita série. Cada container multiplica dezenas de métricas por todas as labels, e num cluster com centenas de pods isso vira uma cardinalidade que pesa no Prometheus. O senso comum manda coletar tudo “porque um dia pode precisar”. Minha experiência diz o contrário: comece filtrando o que você realmente consulta e abra exceção depois, não o inverso.
Outro ponto que confunde: mesmo nos setups mais modernos, o cAdvisor não foi removido. Ele continua respondendo pelas métricas de nó, de volume e de filesystem. O que está mudando de mãos é a parte de pod e container, e é aí que entra o CRI.
CRI: o caminho moderno para métricas de pod
O CRI (Container Runtime Interface) é o contrato gRPC entre o kubelet e o runtime (containerd, CRI-O). Além de criar e destruir containers, ele padroniza chamadas de estatísticas: ContainerStats, ListContainerStats, PodSandboxStats e ListPodSandboxStats. A ideia é simples: quem cria o container é quem melhor sabe medir o container, então deixa o runtime reportar direto, sem o cAdvisor redescobrir tudo pelo cgroupfs.
Esse caminho é ligado pelo feature gate PodAndContainerStatsFromCRI. E aqui vale honestidade: ele existe desde o Kubernetes 1.23 e continua alpha, desligado por padrão, até a v1.36. A migração anda devagar justamente porque o cAdvisor é difícil de matar. Para saber como está no seu cluster:
curl -sS "http://localhost:8001/api/v1/nodes/$NODE/proxy/metrics" \
| grep 'kubernetes_feature_enabled.*PodAndContainerStatsFromCRI'
# 0 = desligado (padrão), 1 = ligadoE para ver o runtime reportando estatísticas com os próprios olhos, direto pelo CRI:
minikube ssh -- sudo crictl statsO que PHP, Go e Python enxergam do limite do container
Agora a parte que quase ninguém conecta com o assunto de métricas: o runtime da sua linguagem pode não enxergar o limite do container do jeito que você imagina. E quando isso acontece, a métrica só conta a história depois do estrago.
Começo pelo PHP-FPM, que foi onde eu apanhei. O container morria de OOMKilled e o dashboard parecia saudável. A causa: pm.max_children dimensionado olhando para a memória do nó, não para o limit do container. Faça a conta comigo, com números de exemplo: limit de 512Mi, worker de PHP consumindo em média 70MB, pm.max_children = 50. No vale do tráfego, meia dúzia de workers ativos, consumo baixo, gráfico bonito. No pico, os 50 workers enchem e a demanda potencial passa de 3Gi contra um teto de 512Mi. O kernel mata o container sem dó, o kubectl describe pod mostra OOMKilled no lastState, e a média do gráfico segue dizendo que estava tudo bem. A correção é fazer a conta fechar:
; a conta que precisa caber no limit do container, com folga:
; pm.max_children x memoria media por worker < limit
pm = dynamic
pm.max_children = 6 ; para um limit de 512Mi com workers de ~70MB
pm.max_requests = 500 ; recicla o worker e segura leak de extensaoGo tem a pegadinha espelhada, só que de CPU. O runtime define GOMAXPROCS pelo total de cores do nó, não pelo limit do pod. Container com limit de 1 CPU num nó de 32 cores significa 32 threads de scheduler brigando por 1 core de quota: CPU throttling aparecendo na métrica container_cpu_cfs_throttled_periods_total e latência serrilhada sem nenhum erro no log. Até o Go 1.24, a correção clássica era o automaxprocs da Uber:
import _ "go.uber.org/automaxprocs" // ajusta GOMAXPROCS ao limite do cgroupA partir do Go 1.25 o runtime passou a respeitar o limite de CPU do cgroup sozinho. Se a sua imagem ainda compila com Go antigo, a pegadinha continua valendo (e vale conferir o GOMEMLIMIT para a memória, pelo mesmo motivo).
Python herda o problema pela via dos forks: os.cpu_count() devolve os cores do nó. A fórmula clássica do gunicorn, workers = 2 * cores + 1, num nó de 32 cores vira 65 workers dentro de um container com limit de 1 CPU e memória para meia dúzia. Resultado: o mesmo OOMKilled do PHP, com sotaque diferente.
import os
os.cpu_count() # errado no container: conta os cores do NO
len(os.sched_getaffinity(0)) # melhor: respeita o cpuset do processoE nem o sched_getaffinity resolve tudo, porque quota de CFS não aparece aí. O caminho seguro é definir o número de workers explicitamente na configuração do deployment, do lado do manifest, onde o limit já está declarado.
Na prática: valide de onde vêm as suas métricas
Teoria boa é teoria reproduzível. Num minikube, dá para percorrer o caminho inteiro em dez minutos:
minikube start
kubectl create deployment php-demo --image=php:8.3-apache --port=80
kubectl set resources deployment php-demo \
--requests=cpu=100m,memory=128Mi --limits=cpu=500m,memory=256Mi
kubectl top podEsse pod tem requests menores que os limits, então cai em Burstable. Confira o slice dele na hierarquia de cgroups, e depois compare as quatro portas do kubelet:
minikube ssh -- ls /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubectl proxy --port=8001 &
NODE=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}')
curl -sS "http://localhost:8001/api/v1/nodes/$NODE/proxy/metrics/cadvisor" | grep php-demo | head -5
curl -sS "http://localhost:8001/api/v1/nodes/$NODE/proxy/metrics/resource" | grep php-demo
curl -sS "http://localhost:8001/api/v1/nodes/$NODE/proxy/stats/summary" | head -40No dia a dia, o comando que eu mais uso continua sendo o kubectl top. A diferença é que agora você sabe exatamente o caminho que o número percorre até chegar nele: cgroup no kernel, cAdvisor (ou CRI), /metrics/resource, metrics-server, API server, seu terminal. Quando dois números divergirem, você sabe onde cada um nasceu. Para o passo seguinte, montar a coleta com Prometheus e Grafana em cima disso, já escrevi sobre monitoramento de clusters Kubernetes.
Minha leitura
O metrics-server é suficiente para kubectl top e HPA básico, e está tudo bem começar só com ele. O que me incomoda é o padrão que vejo se repetir: times instalando stack de observabilidade gigante antes de saberem responder de onde vem o número que ela mostra. Aí o Prometheus diverge do kubectl top, ninguém sabe explicar, e a reação é instalar mais uma ferramenta por cima.
Conheça a origem primeiro: cgroup, cAdvisor, CRI, os quatro endpoints. Depois escolha a ferramenta do tamanho do seu problema. E antes de culpar o Kubernetes pelo OOMKilled da sua aplicação, confira se o runtime dela conhece o limite do container. O meu PHP-FPM não conhecia, e a métrica estava certa o tempo todo. Quem estava errado era eu.
Perguntas frequentes
Por que o kubectl top mostra um valor diferente do Prometheus?
Porque as fontes e as métricas são diferentes. O kubectl top mostra o working set instantâneo vindo do /metrics/resource via metrics-server; no Prometheus é comum olhar container_memory_usage_bytes, que inclui page cache, ou médias sobre janelas de tempo. Compare container_memory_working_set_bytes com o kubectl top e os números se aproximam.
Preciso instalar o cAdvisor no cluster?
Não. O cAdvisor roda embutido no kubelet de cada nó desde sempre. O que você instala por fora é quem consome as métricas dele, como o metrics-server ou o Prometheus.
O que o PodAndContainerStatsFromCRI muda?
Com o feature gate ligado, o kubelet busca as estatísticas de pods e containers direto do runtime via CRI, em vez de o cAdvisor redescobrir tudo pelo filesystem de cgroups. O cAdvisor continua respondendo por métricas de nó, volume e filesystem. O gate é alpha desde o 1.23 e segue desligado por padrão.








