Kubernetes
La base Kustomize del Agent se publica junto con esta documentación, una por versión: Namespace,
ConfigMap, un StatefulSet con un volumen de identidad por Agent, probes exec y una NetworkPolicy. No trae
ningún valor de entorno: la imagen, las credenciales y la StorageClass vienen de tu overlay.
No hay Helm chart.
Los requisitos más abajo valen para la base y para quien prefiera escribir sus propios manifests.
Instalación
Sección titulada «Instalación»Descarga la base de la misma versión que la imagen que ejecutas:
VERSION=X.Y.Z # your image's tag, with or without the "v"BASE="https://docs.whystack.ai/kubernetes/v${VERSION#v}/base"mkdir -p baseFILES=$(curl -fsSL "$BASE/files.txt") && for f in $FILES; do curl -fsSL -o "base/$f" "$BASE/$f"; doneLa carpeta publicada lleva la v del tag del release (/kubernetes/v1.29.0/base/); el tag de la imagen no
(whystack-agent:1.29.0). ${VERSION#v} acepta ambas formas. Si la versión no tiene base publicada, el
primer curl falla con un 404 y no se descarga nada.
Junto a ella, crea el overlay con la imagen que recibiste, por registry y digest (ver La imagen):
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationresources: - baseimages: - name: whystack-agent newName: <address>/whystack-agent digest: sha256:<digest>Crea el Secret y aplica:
kubectl apply -f base/namespace.yamlkubectl -n whystack-agent create secret generic whystack-agent-env --from-env-file=agent.envkubectl apply -k .kubectl -n whystack-agent rollout status statefulset/whystack-agent --timeout=5magent.env lleva RUNNER_BOOTSTRAP_TOKEN y, con RUNNER_SECRET_STORE_MODE=env (el valor por defecto de
la base), las credenciales de los connectors. Después del primer hello accepted, quita
RUNNER_BOOTSTRAP_TOKEN del Secret: ya se consumió, y los reinicios renuevan la identidad a partir del
volumen.
Si la identidad se pierde (se borró el PVC, o expiró más allá del período de gracia de renovación, ver
Cuando se pierde la identidad), el boot falla con
RUNNER_ATTESTATION_MODE=bootstrap-token requires RUNNER_BOOTSTRAP_TOKEN y el pod entra en
CrashLoopBackOff. El token anterior no vuelve a funcionar: genera uno nuevo en Onboarding, vuelve a
ponerlo en el Secret y recrea el pod (kubectl -n whystack-agent delete pod whystack-agent-0), porque
el Secret solo se lee cuando el contenedor arranca.
Cambiar la configuración
Sección titulada «Cambiar la configuración»La configuración no secreta de la base sale de un configMapGenerator. Para cambiar una clave, haz el
merge en tu overlay; el nombre del ConfigMap lleva un hash de su contenido, y kubectl apply -k . recrea
el pod con el nuevo valor:
# kustomization.yaml (the same overlay as above)configMapGenerator: - name: whystack-agent-config behavior: merge literals: - RUNNER_SECRET_STORE_MODE=aws - RUNNER_SECRETS_MANAGER_REGION=us-east-1Más de un Agent
Sección titulada «Más de un Agent»N Agents son N instalaciones, no N réplicas. El bootstrap token es de un solo uso y hay un Secret por
instalación, así que escalar el StatefulSet hace que el segundo pod falle en el enrollment. Cada Agent
recibe un overlay con su propio namespace (Kustomize también renombra el Namespace de la base), su
propio Secret y su propio token:
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationnamespace: whystack-agent-bresources: - ../baseimages: - name: whystack-agent newName: <address>/whystack-agent digest: sha256:<digest>kubectl create namespace whystack-agent-bkubectl -n whystack-agent-b create secret generic whystack-agent-env --from-env-file=agent-b.envkubectl apply -k agent-bkubectl -n whystack-agent-b rollout status statefulset/whystack-agent --timeout=5magent-b.env lleva un bootstrap token nuevo, generado para este Agent: el del primero ya se consumió.
El RUNNER_INSTANCE_ID de la base es <pod>.<namespace>.<pod uid>: único también entre clústeres, así que
Agents en clústeres distintos con el mismo namespace aparecen por separado en la Flota. El UID cambia
cuando se recrea el pod: la entrada anterior aparece desconectada y sale de la Flota sola.
El contenedor
Sección titulada «El contenedor»La imagen se construye con apps/runner/Dockerfile, con la raíz del repositorio como contexto. El
entrypoint es apps/runner/docker-entrypoint.sh y el comando es:
node --import @whystack/otel/register apps/runner/dist/index.jsLa imagen corre como node (uid 1000), no como root, y por eso Compose monta ~/.aws en
/home/node/.aws. El destino del montaje está acoplado al USER de la imagen: la cadena por defecto del
SDK resuelve la caché de SSO a partir de $HOME, y ninguna variable de entorno redirige esa caché. Si
montas ~/.aws en otro lugar, móntalo en el $HOME del usuario con el que corre el pod.
Requisito 1: salud por exec, no por HTTP
Sección titulada «Requisito 1: salud por exec, no por HTTP»El runner es un cliente de salida. No escucha en ningún puerto, no expone /healthz y no tiene
endpoint de readiness. No configures httpGet en ningún probe: todos van a fallar.
La señal de salud es un archivo. El runner escribe en RUNNER_HEALTH_DIR (por defecto
/tmp/whystack-health) y la imagen trae whystack-health, que lo lee desde dentro del contenedor:
whystack-health live: el proceso está vivo y su event loop está corriendo (heartbeat de menos de 60 s);whystack-health ready: vivo y enrolado, con el túnel conectado y elhelloaceptado.
livenessProbe: exec: command: [whystack-health, live] periodSeconds: 20 timeoutSeconds: 5 readinessProbe: exec: command: [whystack-health, ready] periodSeconds: 10 timeoutSeconds: 5Nunca uses ready como liveness. El túnel se cae cuando se cae la red, y reconectar es tarea del backoff
del runner: reiniciar el pod por eso cambia un runner que se reconecta solo por uno que empieza de cero.
Con readOnlyRootFilesystem: true, monta un emptyDir en /tmp (o apunta RUNNER_HEALTH_DIR a un
volumen en el que pueda escribir el uid 1000). Sin dónde escribir, el probe falla, que es la forma honesta
de decir que la señal no existe. La salud por connector sigue en el handshake y en la Flota del
control-plane.
Requisito 2: gracia de terminación mayor que el drain
Sección titulada «Requisito 2: gracia de terminación mayor que el drain»El runner maneja SIGTERM drenando: deja de aceptar trabajo nuevo, termina lo que está en curso y solo
entonces sale. El tope es RUNNER_DRAIN_TIMEOUT_MS, que por defecto es 120 000 ms.
En Kubernetes, terminationGracePeriodSeconds es 30 s por defecto. Si lo dejas así, el SIGKILL llega
en medio del drain.
terminationGracePeriodSeconds: 150 # > RUNNER_DRAIN_TIMEOUT_MS (120s), with headroomUn reclaim duro sin drain (SIGKILL, OOM, spot) pierde el estado aprendido desde el último flush.
RUNNER_LEARNED_FLUSH_INTERVAL_MS (por defecto 60 s) existe para acotar esa pérdida, pero no la
elimina.
Requisito 3: identidad en un volumen, caché en memoria
Sección titulada «Requisito 3: identidad en un volumen, caché en memoria»La identidad mTLS persiste en RUNNER_IDENTITY_DIR: un reinicio renueva el certificado a partir de ella en
lugar de gastar otro bootstrap token. En Kubernetes eso es un PVC por Agent: el StatefulSet de la base
crea uno por ordinal, con permiso de escritura para el uid 1000 (fsGroup: 1000). Nunca compartas el
volumen entre Agents, porque cada boot sobrescribiría la identidad del otro.
La caché del learned-store es otra cosa y sigue en :memory: por defecto: lo que persiste es identidad,
no estado, y la fuente de verdad de lo aprendido es el control-plane. No apuntes
RUNNER_LEARNED_STORE_PATH al volumen de identidad.
Con atestación de plataforma (gke-oidc, aws-sts), el Agent vuelve a atestar en cada boot y el volumen
es opcional. Algunos proveedores tienen un tamaño mínimo de disco muy por encima de lo necesario (MGC y
GCE exigen 10 GiB, aunque la identidad ocupa pocos KiB), y la base ya pide ese tamaño. Revisa el
reclaimPolicy de tu StorageClass: con Retain, borrar el PVC no borra el disco.
Requisito 4: identidad sin secreto en la imagen
Sección titulada «Requisito 4: identidad sin secreto en la imagen»Aquí es donde Kubernetes cambia el diseño, y para mejor.
GKE: gke-oidc
Sección titulada «GKE: gke-oidc»El runner lee el token OIDC proyectado de la ServiceAccount de Kubernetes y lo envía como atestación. Nada secreto entra en la imagen: el token es un JWT de vida corta, ligado a una audiencia, que proyecta el kubelet.
La ruta por defecto es /var/run/secrets/tokens/gke-oidc/token, configurable con
RUNNER_GKE_TOKEN_PATH. Es decir, el pod necesita un volumen projected con un serviceAccountToken
montado exactamente ahí, proyectado para la audiencia whystack-enroll. Sobre la base, esto es un
patch en tu overlay; el contenedor de la base se llama agent, y Kustomize empareja los contenedores por
nombre:
apiVersion: apps/v1kind: StatefulSetmetadata: name: whystack-agentspec: template: spec: volumes: - name: whystack-token projected: sources: - serviceAccountToken: path: token audience: whystack-enroll # the control-plane requires this audience expirationSeconds: 3600 containers: - name: agent volumeMounts: - name: whystack-token mountPath: /var/run/secrets/tokens/gke-oidc readOnly: true# kustomization.yaml (the overlay)patches: - path: patch-gke-oidc.yamlconfigMapGenerator: - name: whystack-agent-config behavior: merge literals: - RUNNER_ATTESTATION_MODE=gke-oidcLa audiencia no es decoración: es lo que impide que un token de KSA proyectado para otro servicio se reutilice en un enrollment. El control-plane la verifica y rechaza todo lo que no coincide, incluidos los tokens proyectados sin ninguna audiencia.
Si el volumen no está montado, el error lo dice explícitamente:
RUNNER_ATTESTATION_MODE=gke-oidc: failed to read projected KSA token at/var/run/secrets/tokens/gke-oidc/token (is the Workload Identity token volume mounted?)Si la audiencia está mal, el volumen se monta, el runner lee el token y el enroll es rechazado. El
error llega como attestation_invalid, exactamente lo que produciría una credencial inválida. Antes de
investigar la KSA o Workload Identity, revisa el campo audience de arriba.
EKS: aws-sts, no eks-oidc
Sección titulada «EKS: aws-sts, no eks-oidc»aws-sts firma una solicitud GetCallerIdentity con la cadena de credenciales por defecto del SDK,
que en EKS es IRSA. Eso significa que basta una ServiceAccount anotada con un rol IAM, sin bootstrap token
y sin una persona en un navegador.
Exige AWS_REGION de forma explícita. Sin ella, el boot falla con
RUNNER_ATTESTATION_MODE=aws-sts requires AWS_REGION. La región no se adivina a propósito, porque el
control-plane solo acepta regiones de su propia allowlist, y una suposición aparecería como un
attestation_invalid que parece un problema de credenciales.
Requisito 5: secretos
Sección titulada «Requisito 5: secretos»Prefiere el secret manager que ya operas a los Secrets de Kubernetes:
RUNNER_SECRET_STORE_MODE=aws (AWS Secrets Manager) o vault (HashiCorp Vault KV v2). aws resuelve por
la cadena de workload identity, sin claves estáticas. vault se autentica con un token o AppRole: el token
de la ServiceAccount del pod todavía no es un método de login de Vault. Ver
Secret stores. La base usa env, con el Secret whystack-agent-env.
Para las credenciales de lectura de AWS, RUNNER_AWS_CRED_MODE=chain hace que el SDK resuelva directamente
por IRSA, sin que ninguna clave pase por el clúster.
Requisito 6: egress
Sección titulada «Requisito 6: egress»Solo de salida, nada de entrada. Si usas NetworkPolicy, la lista de destinos está en Requisitos. Recuerda que la allowlist declarada en los manifests de los connectors es documentación, no enforcement: para los connectors nativos, quien aplica el egress es el clúster.
Helm y dimensionamiento
Sección titulada «Helm y dimensionamiento»No hay Helm chart. Los resources de la base son un punto de partida, no un benchmark: mide CPU y memoria
con tus connectors reales antes de ajustar los límites. La elección de atestación por proveedor también le
corresponde a tu overlay (ver Cambiar la configuración): la base usa
bootstrap-token, que funciona en cualquier clúster.