Ir al contenido

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.

Descarga la base de la misma versión que la imagen que ejecutas:

Ventana de terminal
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 base
FILES=$(curl -fsSL "$BASE/files.txt") &&
for f in $FILES; do curl -fsSL -o "base/$f" "$BASE/$f"; done

La 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):

kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- base
images:
- name: whystack-agent
newName: <address>/whystack-agent
digest: sha256:<digest>

Crea el Secret y aplica:

Ventana de terminal
kubectl apply -f base/namespace.yaml
kubectl -n whystack-agent create secret generic whystack-agent-env --from-env-file=agent.env
kubectl apply -k .
kubectl -n whystack-agent rollout status statefulset/whystack-agent --timeout=5m

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

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-1

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:

agent-b/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: whystack-agent-b
resources:
- ../base
images:
- name: whystack-agent
newName: <address>/whystack-agent
digest: sha256:<digest>
Ventana de terminal
kubectl create namespace whystack-agent-b
kubectl -n whystack-agent-b create secret generic whystack-agent-env --from-env-file=agent-b.env
kubectl apply -k agent-b
kubectl -n whystack-agent-b rollout status statefulset/whystack-agent --timeout=5m

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

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

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

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 el hello aceptado.
livenessProbe:
exec:
command: [whystack-health, live]
periodSeconds: 20
timeoutSeconds: 5
readinessProbe:
exec:
command: [whystack-health, ready]
periodSeconds: 10
timeoutSeconds: 5

Nunca 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 headroom

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

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:

patch-gke-oidc.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: whystack-agent
spec:
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.yaml
configMapGenerator:
- name: whystack-agent-config
behavior: merge
literals:
- RUNNER_ATTESTATION_MODE=gke-oidc

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

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.

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.

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.

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.