Pular para o conteúdo

Kubernetes

A base Kustomize do Agent está publicada junto desta documentação, uma por versão: Namespace, ConfigMap, StatefulSet com um volume de identidade por Agent, probes exec e NetworkPolicy. Ela não traz valor de ambiente nenhum: a imagem, as credenciais e a StorageClass entram por um overlay seu. Não há Helm chart.

Os requisitos mais abaixo valem para a base e para quem preferir escrever os próprios manifests.

Baixe a base da mesma versão da imagem que você roda:

Janela do terminal
VERSAO=X.Y.Z # a tag da sua imagem, com ou sem o "v"
BASE="https://docs.whystack.ai/kubernetes/v${VERSAO#v}/base"
mkdir -p base
ARQUIVOS=$(curl -fsSL "$BASE/files.txt") &&
for f in $ARQUIVOS; do curl -fsSL -o "base/$f" "$BASE/$f"; done

A pasta publicada leva o v da tag do release (/kubernetes/v1.29.0/base/); a tag da imagem não (whystack-agent:1.29.0). O ${VERSAO#v} aceita as duas formas. Se a versão não tiver base publicada, o primeiro curl falha com 404 e nada é baixado.

Ao lado dela, crie o overlay com a imagem que você recebeu, por registry e digest (ver Imagem):

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

Crie o Secret e aplique:

Janela do 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

O agent.env leva o RUNNER_BOOTSTRAP_TOKEN e, com RUNNER_SECRET_STORE_MODE=env (o default da base), as credenciais dos connectors. Depois do primeiro hello accepted, remova o RUNNER_BOOTSTRAP_TOKEN do Secret: ele foi consumido, e os restarts renovam a identidade a partir do volume.

Se a identidade se perder (o PVC foi apagado, ou ela expirou além da graça de renovação, ver Quando a identidade se perde), o boot falha com RUNNER_ATTESTATION_MODE=bootstrap-token requires RUNNER_BOOTSTRAP_TOKEN e o pod entra em CrashLoopBackOff. O token antigo não volta a valer: minte um novo em Onboarding, recoloque-o no Secret e recrie o pod (kubectl -n whystack-agent delete pod whystack-agent-0), porque o Secret só é lido quando o contêiner nasce.

A configuração não secreta da base sai de um configMapGenerator. Para mudar uma chave, faça o merge no seu overlay; o nome do ConfigMap ganha o hash do conteúdo, e o kubectl apply -k . recria o pod com o valor novo:

# kustomization.yaml (o mesmo overlay de cima)
configMapGenerator:
- name: whystack-agent-config
behavior: merge
literals:
- RUNNER_SECRET_STORE_MODE=aws
- RUNNER_SECRETS_MANAGER_REGION=us-east-1

N Agents são N instalações, não N réplicas. O token de bootstrap é single-use e o Secret é um só por instalação, então escalar o StatefulSet faz o segundo pod falhar no enrollment. Cada Agent ganha um overlay com o próprio namespace (o Kustomize renomeia também o Namespace da base), o próprio Secret e o próprio token:

agent-b/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: whystack-agent-b
resources:
- ../base
images:
- name: whystack-agent
newName: <endereço>/whystack-agent
digest: sha256:<digest>
Janela do 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

O agent-b.env leva um bootstrap token novo, mintado para este Agent: o do primeiro já foi consumido.

O RUNNER_INSTANCE_ID da base é <pod>.<namespace>.<uid do pod>: único também entre clusters, então Agents em clusters diferentes com o mesmo namespace aparecem separados na Frota. O UID muda quando o pod é recriado: a entrada antiga aparece desconectada e sai da Frota sozinha.

A imagem é construída por apps/runner/Dockerfile, com contexto na raiz do repositório. O entrypoint é apps/runner/docker-entrypoint.sh e o comando é:

node --import @whystack/otel/register apps/runner/dist/index.js

A imagem roda como node (uid 1000), não root, e é por isso que o Compose monta ~/.aws em /home/node/.aws. O destino do mount é acoplado ao USER da imagem: a cadeia default do SDK resolve o cache do SSO a partir de $HOME, e não há env var que reaponte esse cache. Se você montar o ~/.aws em outro lugar, monte no $HOME do usuário que o pod roda.

Requisito 1: sinal de saúde por exec, não por HTTP

Seção intitulada “Requisito 1: sinal de saúde por exec, não por HTTP”

O runner é cliente de saída. Ele não escuta em porta nenhuma, não expõe /healthz e não tem endpoint de readiness. Não configure httpGet em probe nenhum: todos vão falhar.

O sinal de saúde é um arquivo. O runner grava em RUNNER_HEALTH_DIR (default /tmp/whystack-health) e a imagem traz o whystack-health, que o lê de dentro do contêiner:

  • whystack-health live: o processo está vivo e o event loop anda (batimento com menos de 60 s);
  • whystack-health ready: vivo e enrollado, com o túnel conectado e o hello aceito.
livenessProbe:
exec:
command: [whystack-health, live]
periodSeconds: 20
timeoutSeconds: 5
readinessProbe:
exec:
command: [whystack-health, ready]
periodSeconds: 10
timeoutSeconds: 5

Nunca use o ready como liveness. O túnel cai quando a rede cai, e quem reconecta é o backoff do runner: reiniciar o pod por isso troca um runner que reconecta sozinho por um que recomeça do zero.

Com readOnlyRootFilesystem: true, monte um emptyDir em /tmp (ou aponte RUNNER_HEALTH_DIR para um volume gravável pelo uid 1000). Sem onde gravar, o probe falha, que é a forma honesta de dizer que o sinal não existe. A saúde por connector continua no handshake e na Frota do control-plane.

Requisito 2: graça de terminação maior que o drain

Seção intitulada “Requisito 2: graça de terminação maior que o drain”

O runner trata SIGTERM drenando: ele para de aceitar trabalho novo, termina o que está em voo e só então sai. O teto disso é RUNNER_DRAIN_TIMEOUT_MS, cujo default é 120 000 ms.

O default do Kubernetes para terminationGracePeriodSeconds é 30 s. Se você deixar assim, o SIGKILL chega no meio do drain.

terminationGracePeriodSeconds: 150 # > RUNNER_DRAIN_TIMEOUT_MS (120s), com folga

Um reclaim duro sem drain (SIGKILL, OOM, spot) perde o estado aprendido desde o último flush. O RUNNER_LEARNED_FLUSH_INTERVAL_MS (default 60 s) existe justamente para limitar essa perda, mas não a elimina.

Requisito 3: identidade em volume, cache em memória

Seção intitulada “Requisito 3: identidade em volume, cache em memória”

A identidade mTLS persiste em RUNNER_IDENTITY_DIR: um restart renova o certificado a partir dela em vez de gastar outro bootstrap token. Em Kubernetes isso é um PVC por Agent: o StatefulSet da base cria um por ordinal, gravável pelo uid 1000 (fsGroup: 1000). Nunca compartilhe o volume entre Agents, porque cada boot sobrescreveria a identidade do outro.

O cache do learned-store é outra coisa e segue :memory: por default: o que persiste é identidade, não estado, e a fonte de verdade do que foi aprendido é o control-plane. Não aponte RUNNER_LEARNED_STORE_PATH para o volume da identidade.

Com atestação de plataforma (gke-oidc, aws-sts), o Agent re-atesta a cada boot e o volume é dispensável. Alguns provedores têm disco mínimo bem maior que o necessário (a MGC e o GCE exigem 10 GiB, mesmo que a identidade ocupe poucos KiB), e a base já pede esse tamanho. Confira o reclaimPolicy da sua StorageClass: com Retain, apagar o PVC não apaga o disco.

Este é o ponto em que Kubernetes muda o desenho, e para melhor.

O runner lê o token OIDC projetado da ServiceAccount do Kubernetes e o envia como atestação. Nada secreto entra na imagem: o token é um JWT de vida curta, com audiência fixa, que o kubelet projeta.

O caminho default é /var/run/secrets/tokens/gke-oidc/token, configurável por RUNNER_GKE_TOKEN_PATH. Ou seja, o pod precisa de um volume projected com serviceAccountToken montado exatamente ali e projetado na audiência whystack-enroll. Sobre a base, isso é um patch no overlay; o contêiner da base se chama agent, e o Kustomize casa contêineres pelo nome:

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 # o control-plane exige esta audiência
expirationSeconds: 3600
containers:
- name: agent
volumeMounts:
- name: whystack-token
mountPath: /var/run/secrets/tokens/gke-oidc
readOnly: true
# kustomization.yaml (o overlay)
patches:
- path: patch-gke-oidc.yaml
configMapGenerator:
- name: whystack-agent-config
behavior: merge
literals:
- RUNNER_ATTESTATION_MODE=gke-oidc

A audiência não é decoração: é o que impede que um token de KSA projetado para outro serviço seja reaproveitado num enroll. O control-plane a verifica e recusa quem não bate, inclusive quem projeta sem audiência nenhuma.

Se o volume não estiver montado, o erro é explícito e diz isso:

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?)

Se a audiência estiver errada, o volume monta, o runner lê o token e o enroll é recusado. O erro chega como attestation_invalid, o mesmo que uma credencial ruim produziria. Antes de investigar a KSA ou o Workload Identity, confira o campo audience acima.

O aws-sts assina um GetCallerIdentity com a cadeia de credenciais default do SDK, que no EKS é o IRSA. Isso significa que a ServiceAccount anotada com o role IAM já basta, sem token de bootstrap e sem humano no navegador.

Ele exige AWS_REGION explicitamente. Sem ela o boot falha com RUNNER_ATTESTATION_MODE=aws-sts requires AWS_REGION. A região não é adivinhada de propósito, porque o control-plane só aceita região da própria allowlist e um palpite viraria um attestation_invalid que se lê como problema de credencial.

Prefira o secret manager que você já opera a Secrets do Kubernetes: RUNNER_SECRET_STORE_MODE=aws (AWS Secrets Manager) ou vault (HashiCorp Vault KV v2). O aws resolve pela cadeia de workload identity, sem chave estática. O vault autentica por token ou AppRole: o token da ServiceAccount do pod ainda não é um método de login no Vault. Veja Secret stores. A base usa env, com o Secret whystack-agent-env.

Para as credenciais de leitura da AWS, RUNNER_AWS_CRED_MODE=chain faz o SDK resolver por IRSA direto, sem passar chave nenhuma pelo cluster.

Só saída, nada de entrada. Se você usa NetworkPolicy, a lista de destinos está em Pré-requisitos. Lembre que a allowlist declarada nos manifests dos connectors é documentação, não enforcement: para os connectors nativos, quem aplica egress control é o cluster.

Não há Helm chart. Os resources da base são ponto de partida, não benchmark: meça CPU e memória com os seus connectors reais antes de apertar os limites. A escolha de atestação por provedor também fica com o seu overlay (ver Mudar a configuração): a base usa bootstrap-token, que funciona em qualquer cluster.