Ir al contenido

Instalar el Profiler

El WhyStack Profiler es el segundo artefacto: opcional, privilegiado, y lo instala quien quiere el eje de comportamiento (lo que cambió dentro del proceso entre dos deploys, donde las métricas y los logs solo muestran el síntoma).

Es una imagen distinta de la del Agent y requiere privilegios que el Agent no pide. Antes de seguir, lee Seguridad → WhyStack Profiler: esa página tiene la tabla de permisos, qué sale de tu infraestructura y por qué no hay un modo reducido. Esta página es el “cómo”, y supone que la decisión ya está tomada.

  1. Habilita el Profiler para la organización, en Configuración → WhyStack Profiler en la app. Sin eso, el enrollment se rechaza con un 403 antes que nada: el privilegio de kernel es una concesión deliberada y registrada, no un efecto secundario de un deploy.
  2. Escribe la lista de objetivos. Es obligatoria y el Profiler no arranca sin ella. Ver abajo.
  3. Ten un bootstrap token del tenant, como con el Agent.
Ventana de terminal
# AWS (ECR)
docker pull <whystack-account>.dkr.ecr.<region>.amazonaws.com/whystack-profiler:<version>
# Google Cloud (Artifact Registry)
docker pull <region>-docker.pkg.dev/<whystack-project>/whystack/whystack-profiler:<version>

Multiarquitectura (amd64 + arm64) y firmada con cosign, como la del Agent. El procedimiento de verificación es el mismo que se describe en La imagen.

La imagen incluye el colector (ptop) y el agregador (witness). El proceso que habla con el control-plane corre como usuario no root y sin ninguna capability; lo que lleva el privilegio es el binario del colector, mediante file capabilities.

Un archivo JSON montado en el contenedor, al que apunta RUNNER_PROFILE_TARGETS_FILE:

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

Es tuya y nunca pasa por nosotros. No existe un valor por defecto que signifique “todo lo que hay en el nodo”: el Profiler se niega a arrancar si falta el archivo, si la lista está vacía o con "workload": "*". El schema es estricto: cualquier clave desconocida, incluido un campo de comentario bienintencionado, hace fallar el boot.

Un archivo y no una variable de entorno, a propósito: en tu repositorio de infraestructura es diffable, pasa por una revisión y alguien lo mira. Es literalmente la lista de dónde puede tocar el contenedor privilegiado.

services:
profiler:
image: <whystack-account>.dkr.ecr.<region>.amazonaws.com/whystack-profiler:<version>
restart: unless-stopped
environment:
RUNNER_TENANT: your-org
RUNNER_INSTANCE_ID: your-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
# All five, and not one more. Missing any of them fails the boot naming it and what it costs
# (see the security page).
cap_add: [BPF, PERFMON, SYS_PTRACE, DAC_READ_SEARCH, SYS_ADMIN]
# Visibility, not privilege: without it the container only sees its own processes, and the target
# simply does not exist as far as the collector is concerned.
pid: host
security_opt:
- apparmor=whystack-profiler # see "AppArmor" below
volumes:
- ./identity:/identity
- ./profile-targets.json:/etc/whystack/profile-targets.json:ro
# The eBPF library looks for tracepoints here. Without these two the collector loads and **does
# not attach**, and the capture comes back with axes missing, which is worse than empty.
- /sys/kernel/tracing:/sys/kernel/tracing
- /sys/kernel/debug:/sys/kernel/debug
mem_limit: 512m
memswap_limit: 512m # no swap: see "Memory", below

El bootstrap token entra igual que en el Agent (RUNNER_BOOTSTRAP_TOKEN, preferiblemente a través de un secret store en lugar de texto plano en el archivo compose).

Si tu host tiene AppArmor habilitado (Ubuntu y Debian lo tienen por defecto), el perfil docker-default bloquea lo que el Profiler necesita hacer: solo permite ptrace entre contenedores con el mismo perfil, y tu objetivo normalmente no lo tiene (un proceso del host es unconfined; un contenedor de otro runtime tiene un perfil distinto).

No uses apparmor=unconfined: eso deshace todo el confinamiento por una sola regla. Carga un perfil que sea docker-default más una línea:

#include <tunables/global>
profile whystack-profiler flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
# … the docker-default body …
# The only difference: read /proc/<pid>/{exe,ns/*} of a target outside this profile.
ptrace (read) peer=unconfined,
}
Ventana de terminal
sudo apparmor_parser -r -W /etc/apparmor.d/whystack-profiler

Cómo saber si este es el problema: una captura vuelve con colectores en permission denied sobre /proc/<pid>/ns/* o /proc/<pid>/exe, y el kernel registra la denegación:

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

El mem_limit del ejemplo se comparte entre el Profiler y el colector, y el que crece es el colector: para resolver stacks lee la tabla de símbolos del ejecutable del objetivo, así que su memoria sigue a ese binario, no a la carga del proceso.

A partir de la imagen v1.25.0 (colector ptop 1.8.0), la tabla de líneas del objetivo se mapea desde el archivo en lugar de copiarse a la memoria del colector. En una captura contra un binario Go de ~400 MB (otelcol-contrib), el colector llegó a 256 MB y el contenedor a 385 MB. Eso cupo en los 512m del ejemplo con poco margen, y nuestro propio Profiler ejecuta este objetivo con 768m, el doble del pico medido. Dos cosas todavía crecen con el objetivo: la tabla de funciones, una por binario, y las páginas del ejecutable mapeado, que cuentan para la memoria del contenedor aunque el kernel pueda recuperarlas. Los mismos números aplican a resources.limits.memory en Kubernetes.

Hasta v1.24.0 (ptop 1.6.0), el mismo objetivo llevaba el contenedor hasta 1.6 GB, y el pico variaba ~340 MB entre capturas. Con una imagen anterior a v1.25.0, actualízala o sube el límite.

Poner memswap_limit igual a mem_limit desactiva el swap del contenedor. Sin eso, Docker deja que el contenedor use en swap otro tanto como su límite: un colector que se pasa del límite tarda más en morir, y el propio proceso del Profiler se va a swap con él.

Cómo saber si este es el problema: la captura falla con un motivo que empieza con

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

seguido de los límites de memoria del contenedor, y el log del Profiler registra capture failed con el mismo texto. La solución es subir el límite.

El pod necesita:

  • hostPID: true (o shareProcessNamespace si el objetivo está en el mismo pod). Sin eso el colector solo se ve a sí mismo.
  • Las cinco capabilities en securityContext.capabilities.add.
  • /sys/kernel/tracing y /sys/kernel/debug montados desde el host como hostPath.
  • Un perfil de AppArmor que permita ptrace (read) contra el perfil del objetivo. En clústeres con containerd el valor por defecto es cri-containerd.apparmor.d, y aplica el mismo razonamiento de arriba: el perfil tiene que estar cargado en cada nodo y referenciado en securityContext.appArmorProfile.
  • Un volumen para /identity, con la misma vida útil que necesita el Agent y no compartido con otro artefacto, por la razón del recuadro de arriba. En un Deployment eso significa un PVC por réplica, o un StatefulSet.

Hoy no se requiere ningún permiso de la API de Kubernetes. El disparador automático (notar que el pod levanta con una imagen nueva) no existe; cuando exista, pedirá get/list/watch sobre pods, solo en los namespaces de tu allowlist, y la página de seguridad se actualizará primero.

DaemonSet: el enrutamiento existe, el clúster no se ha ejercitado

Sección titulada «DaemonSet: el enrutamiento existe, el clúster no se ha ejercitado»

Arrancar no es medir, y la diferencia es la que sale cara: un colector que no se acopla produce una captura con ejes faltantes, y un eje faltante comparado con una captura completa se lee como un comportamiento que se detuvo.

Dos verificaciones, en este orden:

  1. El boot. Declara la clase y cuántos objetivos leyó, y se niega a arrancar nombrando la capability que falta. No hay ningún camino en el que levante en verde y capture a ciegas por falta de permiso.
  2. La primera captura. Ábrela en Profiling en la app. La pantalla declara la instrumentación: si dice N of M probes en lugar de fully instrumented, el bloque de abajo nombra cada colector que no se acopló y por qué (normalmente AppArmor o los montajes de tracefs).

Una captura con instrumentación incompleta no es una captura más débil: es una captura con ejes que no existen, y el producto prefiere decirlo antes que comparar mundos distintos.

El disparador es explícito: el Profiler no decide por su cuenta cuándo medir. La solicitud viene de tu pipeline, justo después de publicar una versión, o de alguien de tu equipo durante una investigación. Dos caminos, y tienen puertas distintas porque la urgencia es distinta.

En Profiling, arriba de la página, hay un formulario: proceso, versión, entorno. Cualquier miembro de la organización puede pedir una captura. Es el mismo acceso que abre la página, y la decisión de owner/admin ya se tomó antes, cuando se activó el Profiler. El resultado aparece en el registro justo debajo, con quién la pidió al lado.

La ventana, el warmup y las capturas de réplica no están en el formulario a propósito: quien los necesita está escribiendo un pipeline, y el pipeline tiene la llamada de abajo.

En Configuración → Tokens de captura del Profiler, en la app. Empieza con rpcap_, se muestra una sola vez al crearlo, y es distinto del token de MCP y del token de ingress de incidentes: no lee nada, solo encola una solicitud de captura.

Dale a cada pipeline su propio token, y uno aparte para uso humano, si tu equipo va a pedir capturas durante investigaciones. El nombre es lo que aparece en el registro de la captura, y es lo que responde “¿quién pidió esto?”.

Ventana de 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": "production",
"duration_sec": 60
}'
campo obligatorio qué es
workload sí el nombre del proceso, exactamente como aparece en tu lista de objetivos. Fuera de esa lista, el Profiler lo rechaza.
version sí la versión que acaba de salir. Es la clave del diff: la captura de v2.14.0 se compara con la de v2.13.0.
env no production, staging… Vacío es legítimo: no toda flota tiene el concepto.
kind no (head) head es la captura de la versión. replicate es la misma versión medida de nuevo, y es lo que sostiene el piso de ruido y la ventana de confirmación. Llámala varias veces para un diff más confiable.
duration_sec no (60) entre 5 y 300.
warmup_sec no (5) el tiempo que se descarta antes de que empiece la medición, entre 0 y 60.

Se aceptan tanto duration_sec/durationSec como warmup_sec/warmupSec, y el token también puede ir como ?token=, porque algunos pasos declarativos de CI no te dejan definir un header.

Las respuestas, y cada una pide una acción distinta:

  • 202: {"request_id": "…", "status": "queued", "deduped": false}. La solicitud queda en cola y se drena en segundos. deduped: true significa que ya había una solicitud idéntica en curso y recibiste su id: repetir la llamada no apila capturas.
  • 400: el cuerpo no pasó la validación, y el mensaje nombra el campo.
  • 401: token ausente, inválido o revocado.
  • 403: el Profiler no está habilitado para la organización. Es el opt-in, en Configuración.
  • 429: el techo es de 20 solicitudes por hora por organización, y la respuesta trae retry_after_seconds. Por organización y no por IP, a propósito: lo que paga la cuenta es el proceso observado, no la red de donde vino la solicitud.

Un 202 dice que la solicitud llegó, no que la captura ocurrió. Puede que no haya ningún Profiler vivo en ese momento (la solicitud espera), y puede que el objetivo no esté corriendo en ningún nodo. El resultado (pushed, nothing_to_push o failed, con el motivo al lado) aparece en Profiling en la app.

Lo decimos aquí porque la alternativa es que lo descubras por tu cuenta:

  • Kubernetes, en ningún clúster.
  • JVM: el eje de símbolos para la JVM está implementado y tiene tests unitarios, y nunca se ha ejecutado contra una JVM viva.
  • Memoria por objetivo: hay un punto medido, no una tabla. Es el caso de Memoria: a partir de la imagen v1.25.0, un binario Go de ~400 MB llevó el contenedor a 385 MB en una captura. Hasta v1.24.0, el mismo objetivo necesitaba hasta 1.6 GB.

Docker en amd64 y arm64 es lo que está ejercitado.