Passa al contenuto principale

Kubernetes

Come afe gira su Kubernetes oggi.

Quattro milestone sono arrivate. V6d ha aggiunto le revisioni della configurazione, i manifest compatibili con Kubernetes e gli endpoint operativi. V6e ha aggiunto l'immagine e l'ambiente kind. V7b ha aggiunto i Pod dei ticket, i bundle del workspace, netlock, l'RBAC del worker e i test su kind. V7c ha aggiunto l'operator, la custom resource AgentFlowEngine, le CRD, KEDA e l'Ingress.

Una custom resource descrive l'installazione, e l'operator la riconcilia. Requisiti: FR1, FR6, FR21 e FR25–FR28.

Cosa gira oggi​

  • Il plugin afe-kubernetes (sandboxes/kubernetes) implementa il port Sandbox sopra la CLI kubectl. Usa una PVC ReadWriteOnce e un Pod per ticket nel namespace sandbox. Li applica, aspetta Ready, ed esegue exec/run/stream/put/get. Li rimuove tramite l'etichetta afe.ticket.
  • deploy/kind/sandbox/ porta il namespace sandbox (Pod Security privileged). Porta anche la Role e la RoleBinding con namespace del worker (solo pods, pods/exec e PVC, mai secrets), e la NetworkPolicy di solo ingress come secondo livello. Il worker annota il suo Pod e la sua PVC con i marcatori di ciclo di vita, quindi la Role concede anche patch e update su entrambi.
  • netlock (docker/netlock) è l'init container che scarta ogni pacchetto in uscita dal Pod tranne quelli dello uid del proxy, su IPv4 e IPv6. docker/egress-proxy è il proxy squid allowlist nel Pod, raggiungibile dalla sandbox su localhost:3128.
  • Il workspace vive nel Pod ed è durevole. Dopo ogni nodo che l'ha usato, il motore fa il checkpoint del workspace e salva un bundle incrementale nello store. Un Pod perso viene ricreato e ripristinato dall'ultimo checkpoint. Vedi Workspace.
  • docker compose porta su tutto lo stack su una macchina con make compose-up. L'adattatore Docker tiene un volume e un container per ticket, lo stesso modello volume + exec (vedi Far girare tutto lo stack con Compose).
  • tests/kind (marker kind, make kind-test, fuori da make ci) lo dimostra su un cluster vero (vedi Distribuire su kind). Esegue un flow git + deep fino a done, e cancella un Pod del ticket dopo un checkpoint per controllare che si ripristini senza rieseguire un nodo completato. Controlla anche l'isolamento di rete con e senza allowlist, e che il Pod e la PVC siano spariti a fine ticket.
  • plugins/afe-operator/ è l'operator (afe-operator, modulo afe_plugins.operator). Osserva le risorse AgentFlowEngine del suo namespace, valida la configurazione tramite afe_core e riconcilia gli oggetti posseduti. Il suo status porta le condition e la revisione corrente.
  • deploy/operator/ è l'install kustomize: le CRD generate, il Deployment dell'operator, il suo ServiceAccount e due Role con namespace. make operator-install lo applica una volta. Ciò che l'operator non deve possedere resta in deploy/operator/admin/ (vedi Installare l'operator).

Il motore rimuove il Pod e la PVC di un ticket tramite l'etichetta afe.ticket quando il ticket finisce (done, failed, cancelled). Aprire il Pod di un ticket è idempotente: viene riusato finché immagine, limiti, nomi delle variabili d'ambiente ed egress non cambiano, e ricreato al cambio di firma (V7b R3).

Un crash può comunque lasciare un orfano. Il reaper dell'operator elenca gli oggetti della sandbox per le etichette afe.ticket e app.kubernetes.io/managed-by: afe. Cancella quelli terminal, e i running il cui heartbeat è più vecchio di sandbox.orphanGrace. Tiene un Pod paused, e non legge né store né API (PRD FR28).

Principi​

  • La scalabilità viene dal design che c'è già. afe serve serve solo l'API, i processi afe worker prendono i ticket da una coda Redis, e tutto ciò che serve a riprendere un ticket sta in Postgres. Su Kubernetes, più throughput vuol dire più repliche dei worker.
  • La web UI arriva con serve. afe serve --ui serve il bundle sullo stesso host e sulla stessa porta dell'API, quindi non c'è un Deployment, un'immagine o un Service UI separati.
  • Nessuno storage condiviso. I clienti possono avere volumi ReadWriteMany e rifiutarsi comunque di usarli, quindi nulla dipende da essi. Ogni ticket tiene i suoi file in un Pod tutto suo, su un volume locale ReadWriteOnce.
  • I worker non tengono file. Parlano con il Pod del ticket attraverso l'API exec di Kubernetes. Il token Git resta nel worker.
  • L'operator gestisce il ciclo di vita, non la scalabilità (V7c). Valida la configurazione, fa il deploy dei processi, gestisce o referenzia Postgres e Redis, e riporta lo stato.
  • Un motore per cluster. I tenant segregati per ora non sono un obiettivo.

Topologia​

kubectl apply ──▶ AgentFlowEngine + Flow, Agent, Schema… (afe.dev) ──watch──▶ afe-operator
│ valida,
│ pubblica la
│ revisione, deploy
┌─ namespace afe ──────────────────────────────────────────────────────────────▼─────────┐
│ │
│ Ingress (WebSocket + UI) ──▶ Service ──▶ Deployment serve ×N (la UI arriva con lui) │
│ │ accoda ▲ eventi │
│ ▼ │ │
│ Deployment worker ×1..M ◀── KEDA ScaledObject (lag coda) │
│ nessun volume; il token Git e le chiavi dei modelli │
│ restano qui, serve non tiene credenziali │
│ │
└───────────────────────────────────┬────────────────────────────────────────────────────┘
│ API Kubernetes: crea Pod e PVC, exec (stdin/stdout)
┌─ namespace afe-sandbox ───────────▼────────────────────────────────────────────────────┐
│ Pod afe-<ticket>-<uid> + PVC ReadWriteOnce (local-path, TopoLVM, un disco cloud) │
│ uno per ticket; l'RBAC del worker arriva solo a questo namespace │
└────────────────────────────────────────────────────────────────────────────────────────┘
┌─ namespace afe-data ───────────────────────────────────────────────────────────────────┐
│ Cluster CloudNativePG: ticket, ledger, checkpoint, revisioni, bundle dei workspace │
│ StatefulSet Redis (AOF): coda, lock, notifiche, heartbeat │
└────────────────────────────────────────────────────────────────────────────────────────┘
Langfuse o un qualsiasi collector OpenTelemetry: esterno, indicato da URL e Secret

I servizi dati stanno in un namespace a parte, così il permesso pods/exec del worker non li I servizi dati stanno in un namespace a parte, così il permesso pods/exec del worker non li raggiunge mai. L'operator crea i Deployment serve e worker, i Service, la ConfigMap di configurazione, l'Ingress, lo ScaledObject e uno store o broker managed. Non crea nessuno dei namespace, l'RBAC del worker, l'envSecret o i Secret dei dati. Quelli, e le classi di storage, runtime e ingress, restano i manifest dell'admin (deploy/operator/admin/).

Il Pod del ticket​

Pod afe-<ticket>-<uid> volume: PVC ReadWriteOnce, cancellato col ticket
├─ init netlock NET_ADMIN, gira una volta iptables: scarta ogni pacchetto in uscita,
│ tranne quelli dello uid 1337 (il proxy)
├─ sandbox uid 1000, nessuna capability /workspace/repo
│ file tool, git locale, /workspace/worktrees/<scope>
│ ws.exec, ws.test, agente acp
└─ proxy uid 1337, solo con egress squid con l'allowlist del progetto
nessun token del service account · seccomp RuntimeDefault · RuntimeClass runc (default) o Kata
  • Un Pod per ticket, non per scope. I worktree delle lane condividono un solo clone. I limiti di CPU e memoria valgono per l'intero ticket.
  • I file tool girano dentro il Pod. Su una sola macchina girano sull'host e devono confinare ogni percorso. Nel Pod non c'è nulla da proteggere, perché la sandbox non contiene segreti.
  • Variabili d'ambiente per nome. La chiave del modello di un agente acp viene da un Secret in afe-sandbox gestito dall'operator, referenziato con secretKeyRef. Il worker non tocca mai il valore.

Rete senza dipendere dal CNI​

Una NetworkPolicy Kubernetes da sola non basta, per due motivi:

  1. È solo un'API: la applica il plugin CNI, e alcuni (flannel, per esempio) la ignorano senza errori.
  2. Seleziona indirizzi e Pod, non nomi di host, quindi un'allowlist come openrouter.ai non si può scrivere.

Per questo l'init container netlock imposta regole iptables nel network namespace del Pod, la tecnica che Istio usa per il suo sidecar. Solo l'utente del proxy può aprire connessioni.

La sandbox raggiunge solo localhost:3128, dove il proxy applica l'allowlist. Nessuna query DNS esce direttamente dal Pod. La sandbox non ha NET_ADMIN, quindi non può annullare le regole. Un ticket senza egress non ha proxy, e non esce nulla.

Le regole si basano su netfilter e -m owner, che gVisor non implementa. Sotto gVisor, netlock sarebbe un no-op, e il Pod sembrerebbe isolato senza esserlo. Il manifest rifiuta quindi un runtimeClassName che nomina gVisor, e il runtime resta runc (default) o Kata.

Solo l'init container ha bisogno di NET_ADMIN, quindi il namespace afe-sandbox deve permetterlo (livello Pod Security privileged, o un'eccezione in Kyverno o Gatekeeper). Dove il CNI applica le policy, l'operator aggiunge una NetworkPolicy come secondo livello. Con spec.afe.sandbox.networkPolicy.manage a true (il default) crea afe-sandbox-ingress nel namespace sandbox: solo ingress, default deny. Con manage a false la policy è dell'admin e l'operator non ne crea alcuna.

Git tramite bundle​

Il Pod non riceve mai il token Git, e non ha mai bisogno della rete per Git.

clone worker: git clone con il token (cartella temporanea) ─▶ git bundle ─▶ exec stdin
─▶ Pod: git clone dal bundle
salva Pod: commit WIP ─▶ git bundle incrementale di afe/* ─▶ exec stdout ─▶ worker ─▶ store
ripristina worker: bundle dallo store ─▶ exec stdin ─▶ nuovo Pod: git fetch
push Pod: git bundle afe/<ticket>-<uid>/* ─▶ exec stdout ─▶ worker: git push con il token

Dopo ogni nodo che scrive file, il workspace viene committato e il suo bundle salvato nello store. La perdita di un nodo o di un Pod quindi non rompe la promessa di ripartire senza ripetere lavoro pagato. Il worker successivo ripristina il workspace su un Pod nuovo, e riparte dal checkpoint salvato.

Il limite è la dimensione del repository: un repository molto grande viene trasferito due volte al clone. Una cache dentro il cluster arriva se diventa un problema.

Un ticket dall'inizio alla fine​

client ── ticket.start ──▶ serve ── fissa la revisione corrente, accoda ──▶ Redis
worker ── prende il job, acquisisce il lock del ticket (Redis)
── carica la revisione e il checkpoint del ticket (Postgres)
── apre il Pod del ticket, o lo ripristina dall'ultimo bundle
── nodo agent: chiamate al modello dal worker; ws.* e ws.exec via exec nel Pod
── il nodo ha scritto file ──▶ commit WIP, bundle salvato nello store
── eventi ──▶ Postgres + una notifica Redis ──▶ serve ──▶ client WebSocket
── il ticket finisce ──▶ Pod e PVC cancellati tramite la label afe.ticket

un worker muore ▸ il suo lock scade ▸ un altro worker prende il job
▸ riprende dal token salvato e ritrova il Pod

Revisioni della configurazione​

cartella (afe serve -c) ─┐
├─▶ valida ─▶ normalizza (prompt inclusi, Runtime escluso)
ConfigMap indicata da configMapRef ─┘ ─▶ sha256 ─▶ tabella revisioni nello store (immutabile)

ticket.start ─▶ ticket.revision = la corrente ─▶ il worker la carica per hash ─▶ esegue
resume, recovery, la UI, un nuovo avvio dello stesso ticket ─▶ sempre la revisione del ticket
  • Una configurazione nuova non cambia mai un ticket in corso, e non richiede di riavviare i worker. I worker leggono dallo store la revisione di ogni ticket.
  • Una revisione congela la configurazione, non il codice. Un'immagine nuova del motore deve continuare a leggere i checkpoint dei ticket più vecchi. Il contratto è una finestra di una sola generazione del formato persistito (B25), e fuori da quella finestra il motore fallisce, senza rileggere in silenzio.
  • L'operator legge la ConfigMap indicata da spec.config.configMapRef nel namespace della CR. Scrive le chiavi come file, genera il Runtime da spec.afe e valida l'insieme con afe validate. Un insieme non valido non pubblica nulla e porta i messaggi per campo in ConfigValidated=False.
  • spec.config.revision è un pin opzionale. Quando corrisponde alla revisione calcolata i workload ruotano. Quando non corrisponde, RevisionPinned=False, reason RevisionMismatch, nomina entrambi gli hash e nulla ruota.

I manifest come custom resource​

V7c. I file che scrivi per una cartella sono gli stessi che passi a kubectl apply.

apiVersion: afe.dev/v1alpha1
kind: Schema
metadata:
name: text-request # una label DNS-1123: minuscole, cifre e `-`
annotations:
afe.dev/description: Cosa chiede l'utente
spec:
fields:
topic: string

Un oggetto Kubernetes porta alcune regole che un file semplice non ha.

RegolaPerché
Nomi e id dei nodi sono label DNS-1123 (al massimo 63 caratteri).Kubernetes rifiuta qualsiasi altro nome di oggetto.
La descrizione è l'annotation afe.dev/description.metadata.description non è un campo Kubernetes, e l'API server lo scarta.
Un prompt è testo inline, oppure un riferimento a un file che solo una cartella risolve (una ConfigMap su Kubernetes).Una custom resource non ha file accanto.
Nei when, - diventa _: text_request.topic == 'k8s'.text-request.topic verrebbe letto come una sottrazione.
Le custom resource hanno nomi completi (agents.afe.dev) e nomi brevi (afeagent).Agent, Flow e Plugin sono nomi di kind molto comuni.

Runtime non è una custom resource: l'operator lo genera da AgentFlowEngine.

L'operator​

Un AgentFlowEngine descrive l'installazione. spec.config nomina la ConfigMap sorgente e un pin opzionale alla revisione. spec.afe porta la semantica del Runtime (store, broker, tracer, sandbox). spec.deployment porta le manopole dei workload. Le chiavi sconosciute vengono rifiutate.

apiVersion: afe.dev/v1alpha1
kind: AgentFlowEngine
metadata:
name: afe
namespace: afe
spec:
config:
configMapRef:
name: afe-source # la ConfigMap con i manifest dei flow
revision: 3f2a91c0d4e8 # pin opzionale; un mismatch congela il rollout
afe:
store:
external:
secretRef:
name: afe-postgres
key: dsn
# oppure managed: { instances: 3, storage: 20Gi } # un Cluster CloudNativePG
broker:
external:
secretRef:
name: afe-redis
key: AFE_REDIS_URL
# oppure managed: { storage: 2Gi } # uno StatefulSet Redis, senza auth
tracer:
otlp:
endpoint: http://otel-collector:4318
headersSecretRef: { name: otlp-headers }
sandbox:
namespace: afe-sandbox
storage: 2Gi
runtimeClassName: kata # runc (default) o Kata; gVisor è rifiutato
envSecret: afe-sandbox-env
deployment:
image:
repository: ghcr.io/agentflowengine/afe
tag: "0.7.0" # oppure digest: sha256:...
api:
replicas: 2
apiTokenSecretRef:
name: afe-secrets
key: AFE_API_TOKEN
workers:
replicas: 2
concurrency: 4
# La chiave del modello e il token Git restano entrambi solo al worker; serve non detiene
# credenziali.
envSecretRefs:
- { name: afe-secrets, key: OPENROUTER_API_KEY }
- { name: afe-secrets, key: GITHUB_TOKEN }
autoscaling:
minReplicas: 1
maxReplicas: 20
triggerAuthentication: keda-redis # una TriggerAuthentication fornita dall'admin
ingress:
className: nginx
host: afe.internal
tls:
- secretName: afe-tls
  • managed è opt-in, mai un default. Uno store managed è un solo Cluster CloudNativePG, e l'operator referenzia il Secret -app che CloudNativePG scrive. Un broker managed è uno StatefulSet Redis senza autenticazione. Un servizio autenticato o esterno è external, e il suo secretRef viene proiettato nei workload solo per riferimento.
  • KEDA è opzionale. Con workers.autoscaling e KEDA presente l'operator possiede uno ScaledObject e non scrive replicas sul Deployment del worker. Senza KEDA i worker girano a maxReplicas, e AutoscalingReady=False, reason KEDAAbsent.
  • Gli orfani vengono ripuliti, non adottati. Il reaper rimuove gli oggetti terminal della sandbox e i running più vecchi di sandbox.orphanGrace (default 30m). Tiene i paused.
  • Cosa resta admin. I namespace, il ServiceAccount/Role/RoleBinding del worker, l'envSecret, i Secret dei dati e le classi di storage, runtime e ingress. L'installer li spedisce come esempi sotto deploy/operator/admin/.
  • Nessun valore di segreto entra mai nella CR. Ogni segreto è un *Ref (name più key opzionale), e l'operator lo nomina senza leggerlo.
  • serve non tiene credenziali. Non esegue alcun harness e risolve i provider senza chiedere una chiave, quindi l'operator non proietta su di lui né le chiavi dei modelli né il token forge: le voci di workers.envSecretRefs e i mount envFrom restano solo al worker, e serve — raggiungibile attraverso il Service dell'API — non è mai una via per raggiungere una credenziale del provider o del repository (AFE-293).

L'operator non adotta nulla. Se un oggetto desiderato esiste già con il nome giusto ma senza l'ownerReference e la label dell'operator, l'operator non lo sovrascrive. Mette Ready=False, reason ResourceConflict, ed emette un Evento che nomina l'oggetto estraneo. Il resto del reconcile prosegue.

L'operator ripete sei passi a ogni reconcile.

1. configurazione leggi configMapRef ─▶ afe validate ─✗─▶ ConfigValidated=False, nulla cambia
─✓─▶ pubblica la revisione
2. dati un Cluster CNPG managed o il Secret esterno · un Redis managed o esterno
3. processi Deployment serve e worker · Service · Ingress · la ConfigMap di configurazione
4. scalabilità uno ScaledObject KEDA sul lag della coda, se KEDA è installato
5. pulizia gli oggetti sandbox che il reaper rimuove, riportati in status.orphansRemoved
6. status condition, revisione corrente, componenti, endpoint e conteggio orfani

Cosa serve e cosa no​

Pochi pezzi portano peso vero. Molti pezzi Kubernetes comuni non servono affatto.

ServePerché
CloudNativePGserve e i worker si collegano al servizio -rw, con la chiave uri del Secret -app. I backup su S3 contano, perché checkpoint, ledger e bundle stanno lì.
Una storage class locale o ReadWriteOnceUn volume per ogni Pod di ticket.
KEDA (opzionale)Il suo scaler redis-streams legge il lag del consumer group, quindi il motore non esporta nulla in più per scalare.
Kata (consigliato). gVisor rifiutato.Nei Pod dei ticket gira codice scritto da un modello. netlock richiede netfilter, che gVisor non implementa.
Non servePerché
Storage ReadWriteManyI workspace stanno nei Pod dei ticket.
Calico o Ciliumnetlock isola il Pod. Una NetworkPolicy è solo un secondo livello.
CNPG Pooler (PgBouncer)Ogni processo ha il suo piccolo pool. Le migrazioni usano un advisory lock a livello di transazione, che il transaction mode di PgBouncer permette comunque.
Redis Sentinel o un operator RedisLa fonte di verità è Postgres, e all'avvio i worker rimettono in coda i ticket in corso. Basta uno StatefulSet con AOF. Funziona anche Valkey.
Argo, Tekton, un Job per ticketCoda, lock del ticket e token di resume fanno già da scheduler.
Un demone DockerI Pod dei ticket sostituiscono i container.

In locale con il solo Docker​

Due modalità girano senza un cluster vero.

ModoCosa giraPer
Dockerafe run --local, oppure make compose-up (un profilo Compose con serve --ui, un worker, Postgres, Redis e la UI). Un volume e un container per ticket.sviluppo e demo.
kind o k3dKubernetes dentro Docker: i Pod dei ticket, netlock, i namespace e l'RBAC di deploy/kind/, e l'operator da deploy/operator/.i test su kind (make kind-test) e un cluster simile alla produzione.

Le sandbox Docker e Kubernetes condividono lo stesso modello: un volume più exec, con il workspace dentro il container o il Pod del ticket. In Compose il worker ha bisogno del socket Docker, che equivale a root sull'host. È accettabile su un portatile, non in produzione.

Limiti​

  • Una CI che ha bisogno di Docker (testcontainers, per esempio) non può girare in un Pod di ticket senza privilegi.
  • I plugin stdio e i server MCP stdio girano nel container del worker: la sua immagine deve contenere i loro runtime, oppure girano come plugin HTTP in Deployment propri.
  • Esporre serve fuori dal cluster richiede l'autenticazione del browser decisa in V7.

Risoluzione dei problemi​

Un Pod di ticket non raggiunge mai Ready. Controlla che il livello Pod Security del namespace sandbox permetta il NET_ADMIN dell'init container, e che il nodo abbia capacità per sandbox.cpus/memory del ticket. afe-kubernetes aspetta Ready e poi solleva SandboxGone, che il motore tratta come una sandbox persa: rimuove Pod e PVC e rimette in coda il ticket.

Una chiamata dalla sandbox raggiunge un host fuori da sandbox.egress Controlla che il runtime non sia gVisor. netlock richiede netfilter e -m owner, che gVisor non implementa, quindi un ticket su gVisor sembrerebbe solo isolato. Per questo il manifest rifiuta un runtimeClassName che nomina gVisor.

Il Pod e la PVC di un ticket ci sono ancora dopo la fine Il motore li rimuove tramite l'etichetta afe.ticket quando il ticket raggiunge done, failed o cancelled. Un crash del worker tra la fine del ticket e la rimozione può lasciare un orfano. V7b non li cerca. L'operator di V7c riconcilia l'etichetta e rimuove e segnala gli orfani.

Vedi anche​