Pular para o conteúdo

Instalar o Profiler

O WhyStack Profiler é o segundo artefato: opcional, privilegiado, e instalado por quem quer o eixo comportamental (o que mudou dentro do processo entre dois deploys, onde métrica e log mostram só o sintoma).

Ele é uma imagem diferente do Agent e exige privilégio que o Agent não pede. Antes de seguir, leia Segurança → WhyStack Profiler: é lá que estão a tabela de permissões, o que sai da sua infraestrutura e por que não existe um modo reduzido. Esta página é o “como”, e supõe que a decisão já foi tomada.

  1. Ligue o Profiler na organização. Em Configurações → WhyStack Profiler, no app. Sem isso o enrollment é recusado com 403 antes de qualquer coisa: o privilégio de kernel é concessão deliberada e registrada, não efeito colateral de um deploy.
  2. Escreva a lista de alvos. Ela é obrigatória e o Profiler não sobe sem ela. Ver abaixo.
  3. Tenha um bootstrap token do tenant, como no Agent.
Janela do terminal
# AWS (ECR)
docker pull <conta-whystack>.dkr.ecr.<região>.amazonaws.com/whystack-profiler:<versão>
# Google Cloud (Artifact Registry)
docker pull <região>-docker.pkg.dev/<projeto-whystack>/whystack/whystack-profiler:<versão>

Multi-arch (amd64 + arm64) e assinada com cosign, como a do Agent. O procedimento de verificação é o mesmo descrito em A imagem.

A imagem embarca o coletor (ptop) e o agregador (witness). O processo que fala com o control-plane roda como usuário não-root e sem capability nenhuma; quem carrega o privilégio é o binário do coletor, por file capability.

Um JSON, montado no container, apontado por RUNNER_PROFILE_TARGETS_FILE:

{
"targets": [
{ "workload": "checkout-api" },
{ "namespace": "producao", "workload": "pricing-engine" }
]
}

Ela é sua e nunca passa por nós. Não existe default que signifique “tudo o que estiver no nó”: o Profiler recusa subir com arquivo ausente, com lista vazia, ou com "workload": "*". O schema é estrito: qualquer chave desconhecida, inclusive um campo de comentário bem-intencionado, derruba o boot.

Arquivo e não variável de ambiente de propósito: no seu repositório de infraestrutura ele é diffável, entra numa revisão e alguém olha. É literalmente a lista de onde o container privilegiado pode encostar.

services:
profiler:
image: <conta-whystack>.dkr.ecr.<região>.amazonaws.com/whystack-profiler:<versão>
restart: unless-stopped
environment:
RUNNER_TENANT: sua-org
RUNNER_INSTANCE_ID: sua-org-profiler01
RUNNER_TUNNEL_URL: wss://tunnel.whystack.ai:8443
RUNNER_ENROLL_URL: https://app.whystack.ai
RUNNER_IDENTITY_DIR: /identity
RUNNER_PROFILE_TARGETS_FILE: /etc/whystack/profile-targets.json
# As cinco, e nem uma a mais. A ausência de qualquer uma derruba o boot com o nome dela e o que
# ela custa (ver a página de segurança).
cap_add: [BPF, PERFMON, SYS_PTRACE, DAC_READ_SEARCH, SYS_ADMIN]
# Visibilidade, não privilégio: sem isto o container só enxerga os próprios processos, e o alvo
# simplesmente não existe para o coletor.
pid: host
security_opt:
- apparmor=whystack-profiler # ver "AppArmor", abaixo
volumes:
- ./identity:/identity
- ./profile-targets.json:/etc/whystack/profile-targets.json:ro
# A biblioteca de eBPF procura os tracepoints aqui. Sem estes dois o coletor carrega e **não
# anexa**, e a captura sai com eixos faltando, que é pior que sair vazia.
- /sys/kernel/tracing:/sys/kernel/tracing
- /sys/kernel/debug:/sys/kernel/debug
mem_limit: 512m
memswap_limit: 512m # sem swap: ver "Memória", abaixo

O bootstrap token entra como no Agent (RUNNER_BOOTSTRAP_TOKEN, de preferência via secret store, não em texto no compose).

Se o seu host tem AppArmor ativo (Ubuntu e Debian têm, por default), o perfil docker-default bloqueia o que o Profiler precisa fazer: ele só permite ptrace entre containers do mesmo perfil, e o seu alvo normalmente não é um (um processo do host é unconfined; um container de outro runtime tem outro perfil).

Não use apparmor=unconfined: isso desfaz o confinamento inteiro por causa de uma regra. Carregue um perfil que é o docker-default mais uma linha:

#include <tunables/global>
profile whystack-profiler flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
# … o corpo do docker-default …
# A única diferença: ler /proc/<pid>/{exe,ns/*} de um alvo fora deste perfil.
ptrace (read) peer=unconfined,
}
Janela do terminal
sudo apparmor_parser -r -W /etc/apparmor.d/whystack-profiler

Como saber se é isso: uma captura volta com os coletores em permission denied sobre /proc/<pid>/ns/* ou /proc/<pid>/exe, e o kernel registra a negação:

apparmor="DENIED" operation="ptrace" profile="docker-default" comm="ptop" peer="unconfined"

O mem_limit do exemplo é dividido entre o Profiler e o coletor, e quem cresce é o coletor: para resolver as stacks ele lê a tabela de símbolos do executável do alvo, então a memória dele acompanha esse binário, e não a carga do processo.

A partir da imagem v1.25.0 (coletor ptop 1.8.0), a tabela de linhas do alvo é mapeada do arquivo, e não copiada para a memória do coletor. Numa captura contra um binário Go de ~400 MB (o otelcol-contrib), o coletor chegou a 256 MB e o container a 385 MB. Coube nos 512m do exemplo, com pouca folga, e o nosso Profiler roda esse alvo com 768m, o dobro do pico medido. Duas coisas ainda crescem com o alvo: a tabela de funções, uma por binário, e as páginas do executável mapeado, que entram na conta de memória do container, embora o kernel possa recuperá-las. Os mesmos números valem para o resources.limits.memory no Kubernetes.

Até a v1.24.0 (ptop 1.6.0), o mesmo alvo levava o container a até 1,6 GB, e o pico variava ~340 MB entre capturas. Com uma imagem anterior à v1.25.0, atualize-a ou suba o limite.

O memswap_limit igual ao mem_limit desliga o swap do container. Sem ele, o Docker deixa o container usar em swap outro tanto do limite: o coletor que estoura o teto demora a morrer, e o processo do Profiler vai para o swap junto com ele.

Como saber se é isso: a captura falha com um motivo que começa por

collector (ptop) was killed by SIGKILL — OOM kill recorded in the container cgroup

seguido dos tetos de memória do container, e o log do Profiler registra capture failed com o mesmo texto. A saída é subir o limite.

O pod precisa de:

  • hostPID: true (ou shareProcessNamespace se o alvo estiver no mesmo pod). Sem isso o coletor só enxerga a si mesmo.
  • As cinco capabilities em securityContext.capabilities.add.
  • /sys/kernel/tracing e /sys/kernel/debug montados do host, como hostPath.
  • Um perfil AppArmor que permita ptrace (read) contra o perfil do alvo. Em clusters com containerd, o default é cri-containerd.apparmor.d, e o mesmo raciocínio da seção acima se aplica: o perfil precisa ser carregado em cada nó e referenciado no securityContext.appArmorProfile.
  • Um volume para /identity, com a mesma vida do pod que o Agent precisa e não compartilhado com outro artefato, pelo motivo da caixa acima. Num Deployment isso quer dizer um PVC por réplica, ou um StatefulSet.

Nenhuma permissão da API do Kubernetes é necessária hoje. O gatilho automático (perceber o pod subir com a imagem nova) não existe. Quando existir, ele pedirá get/list/watch em pods, só nos namespaces da sua allowlist, e a página de segurança será atualizada antes.

DaemonSet: o roteamento existe, o cluster não foi exercitado

Seção intitulada “DaemonSet: o roteamento existe, o cluster não foi exercitado”

Subir não é medir, e a diferença é a que mais custa: um coletor que não anexa produz captura com eixos faltando, e um eixo faltando comparado contra uma captura completa se lê como comportamento que parou.

Duas checagens, nessa ordem:

  1. O boot. Ele declara a classe e quantos alvos leu, e recusa subir nomeando a capability que faltar. Não existe caminho em que ele suba verde e capture cego por falta de permissão.
  2. A primeira captura. Abra-a em Profiling, no app. A tela declara a instrumentação: se ela disser N de M probes em vez de instrumentação completa, o bloco abaixo nomeia cada coletor que não anexou e o motivo (normalmente AppArmor ou os mounts de tracefs).

Uma captura com instrumentação incompleta não é uma captura mais fraca: é uma captura com eixos que não existem, e o produto prefere dizer isso a comparar mundos diferentes.

O gatilho é explícito: o Profiler não decide sozinho quando medir. Quem pede é o seu pipeline, logo depois de publicar uma versão, ou alguém do seu time durante uma investigação. São duas formas, e elas têm portas diferentes porque a pressa é diferente.

Em Profiling, no topo da página, há um formulário: processo, versão e ambiente. Qualquer membro da organização pode pedir. É o mesmo acesso que abre a página, e a decisão de owner/admin já foi tomada antes, ao ligar o Profiler. O desfecho aparece no registro logo abaixo, com quem pediu ao lado.

Janela, warmup e captura de réplica não estão no formulário de propósito: quem precisa deles está escrevendo pipeline, e o pipeline tem a chamada abaixo.

Em Configurações → Tokens de Captura do Profiler, no app. Ele começa com rpcap_, é mostrado uma única vez na criação, e é distinto do token de MCP e do de ingress de incidentes: não lê nada, só enfileira um pedido de captura.

Dê um token a cada pipeline, e um separado para uso humano, se o seu time for pedir captura durante investigação. O nome é o que aparece no registro da captura, e é ele que responde “quem pediu isto”.

Janela do terminal
curl -X POST https://app.whystack.ai/api/profiling/captures \
-H "Authorization: Bearer $WHYSTACK_CAPTURE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"workload": "checkout-api",
"version": "v2.14.0",
"env": "producao",
"duration_sec": 60
}'
campo obrigatório o que é
workload sim o nome do processo, exatamente como está na sua lista de alvos. Fora dela, o Profiler recusa.
version sim a versão que acabou de subir. É a chave do diff: a captura de v2.14.0 é comparada com a de v2.13.0.
env não producao, staging… Vazio é legítimo: nem toda frota tem o conceito.
kind não (head) head é a captura da versão. replicate é a mesma versão medida de novo, e é o que sustenta o piso de ruído e a janela de confirmação. Chame algumas vezes se quiser um diff mais confiante.
duration_sec não (60) entre 5 e 300.
warmup_sec não (5) o tempo descartado antes de começar a medir, entre 0 e 60.

duration_sec/durationSec e warmup_sec/warmupSec são aceitos nas duas grafias, e o token também pode ir como ?token=, porque alguns passos declarativos de CI não deixam pôr header.

As respostas, e cada uma pede uma ação diferente:

  • 202: {"request_id": "…", "status": "queued", "deduped": false}. O pedido entrou na fila e é drenado em segundos. deduped: true quer dizer que já havia um pedido igual em voo e você recebeu o id dele: repetir a chamada não empilha capturas.
  • 400: o corpo não passou na validação, e a mensagem nomeia o campo.
  • 401: token ausente, inválido ou revogado.
  • 403: o Profiler não está habilitado na organização. É o opt-in, em Configurações.
  • 429: o teto é de 20 pedidos por hora por organização, e a resposta traz retry_after_seconds. O teto é por organização e não por IP de propósito: quem paga a conta é o processo observado, não a rede de onde o pedido saiu.

O 202 diz que o pedido entrou, não que a captura aconteceu. Pode não haver Profiler vivo naquele instante (o pedido espera), e o alvo pode não estar rodando em nó nenhum. O desfecho (pushed, nothing_to_push ou failed, com o motivo ao lado) aparece em Profiling, no app.

Dizemos isto aqui porque a alternativa é você descobrir sozinho:

  • Kubernetes, em nenhum cluster.
  • JVM: o eixo de símbolo para a JVM está implementado e testado unitariamente, e nunca rodou contra uma JVM viva.
  • Memória por alvo: há um ponto medido, não uma tabela. É o caso da seção Memória: com a imagem v1.25.0 em diante, um binário Go de ~400 MB levou o container a 385 MB numa captura. Até a v1.24.0, o mesmo alvo pediu até 1,6 GB.

Docker em amd64 e arm64 é o que está exercitado.