Métricas no Kubernetes: cAdvisor, kubelet e CRI na prática

De onde vêm as métricas do Kubernetes: cgroups, cAdvisor, kubelet e CRI na prática, e as pegadinhas de PHP-FPM, Go e Python em containers.

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 o kubectl top e 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=8001
NODE=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}')
curl -sS "http://localhost:8001/api/v1/nodes/$NODE/proxy/metrics/resource" | head -20

Quando 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.

Os quatro endpoints de métricas do kubelet: cadvisor, resource, stats summary e metrics, com formato, fonte e consumidor de cada um

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.slice e é 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 = ligado

E para ver o runtime reportando estatísticas com os próprios olhos, direto pelo CRI:

minikube ssh -- sudo crictl stats
Fluxo das métricas no Kubernetes: do cgroup no kernel, passando por cAdvisor e CRI, até os endpoints do kubelet, metrics-server, kubectl top e Prometheus

O 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 extensao

Go 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 cgroup

A 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 processo

E 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 pod

Esse 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 -40

No 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.

Compartilhe / Share
Fernando Müller Junior
Fernando Müller Junior

SRE Manager na Appmax. 16 anos de infraestrutura, de datacenter a cloud. Escrevo sobre o que opero em produção.

Artigos: 29

Receba as notícias por email / Receive news by email

Insira seu endereço de e-mail abaixo e assine nossa newsletter / Enter your email address below and subscribe to our newsletter

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *