Skip to content

Run OpenBot on Kubernetes: Bots and all, proven on EKS - #235

Open
davidmckayv wants to merge 6 commits into
mainfrom
feat/helm-chart-kubernetes
Open

Run OpenBot on Kubernetes: Bots and all, proven on EKS#235
davidmckayv wants to merge 6 commits into
mainfrom
feat/helm-chart-kubernetes

Conversation

@davidmckayv

@davidmckayv davidmckayv commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Closes #216. Bots run on Kubernetes, proven on a real EKS cluster.

Proven, not argued

A real EKS cluster (1.34, two nodes, us-east-2), image built for amd64 and pushed to ECR:

openbot-computer-0                1/1  Running    ip-192-168-48-113
openbot-postgresql-0              1/1  Running    ip-192-168-8-53
openbot-server-57cbc9684b-4fp7f   1/1  Running    ip-192-168-48-113
openbot-server-57cbc9684b-kzcq5   1/1  Running    ip-192-168-8-53
openbot-migrations-1-6knkb        0/1  Completed

Real EBS volumes bound on gp3, replicas spread across both nodes. Then:

GET  /health                        -> 200
GET  /api/agents                    -> ["general-assistant","knowledge"]
GET  /api/computers/knowledge/status-> {"state":"ready"}
POST /api/computers/knowledge/navigate {"url":"https://example.com"}
  -> {"title":"Example Domain", ..., "elapsedMs":548}

A Bot opened a real page from inside AWS, a screenshot came back as a real PNG, and the audit trail recorded the decision:

computer.action_allowed | computer_navigate | knowledge | https://example.com

And the horizontal claim, on EKS: a channel created on replica A read straight back off replica B, same Intelligence thread id. Nothing held in one process.

The same install runs on a three-node kind cluster, so a clone can get here without a cloud account.

One chart, four targets

No cloud branching in any template. grep -i 'aws\|eks\|gke\|aks\|azure' across templates/ returns one comment and zero logic. Every place the clouds genuinely differ is a value whose default is what a plain self-hosted cluster does.

  • Identity is one serviceAccount.annotations map, because IRSA, Workload Identity and AKS workload identity are all annotations on a ServiceAccount.
  • Secrets are a plain Secret by default, an ExternalSecret against any backend when asked.
  • Gateway API sits beside Ingress rather than replacing it.
  • runtimeClassName unset by default, because GKE has managed gVisor, AKS offers Kata, and on EKS it is bring your own node configuration.

ci/ holds a values file per target. self-hosted-values.yaml turns nothing on, which is the test: if it ever needs to grow, a default is wrong.

A computer for each Bot

computers.mode picks the shape. shared runs one browser for every Bot and needs nothing installed; sandbox gives each Bot its own as a Sandbox from kubernetes-sigs/agent-sandbox, which is built for exactly this workload; external points at one somebody else runs.

Suspending is operatingMode: Suspended, which terminates the pod and keeps the volumes, so a computer comes back with its logins rather than signed out of everything.

What decides a computer is idle is the audit trail, not the browser. Asking the browser would wake it, so every computer anything asked about would come back up and the bill would never fall. That is the invisible way to lose scale-to-zero, and #216 names it.

Durable work, claimed by whichever replica gets there first

work_items plus select ... for update skip locked and a lease. No coordinator, no leader election, and a replica added is throughput added rather than contention added. Written once with all three of its users in view, as #193 asks: the idle culler here, scheduled routines, and a hop from one Bot to another.

A CronJob runs the sweep, because a timer in the API fires in every replica, and suspending a browser somebody just started using is not the audit-retention sweep. The decision is re-checked at the moment of acting, since somebody may have come back in between.

Nine integration tests against real PostgreSQL, including ten replicas racing for ten items and taking each exactly once. That test fails without skip locked — I removed the clause to check.

Five things only a real install could find

drizzle-kit migrate cannot run in the shipped image. It reads a TypeScript config, needing the esbuild bun install --production correctly leaves out. It prints one line, exits 1, and says nothing. So EMBEDDED_POSTGRES=on has been starting containers whose database was never migrated, and the first symptom is the API reporting that users does not exist. server/scripts/migrate.ts uses the migrator inside drizzle-orm, already a runtime dependency, and keeps the same journal.

sessionOf stopped checking as soon as there were two replicas. It answered from a map in whichever process started the computer. The replica that took a snapshot is usually not the one handling the click, and an unknown session means "no opinion", so the check that stops a ref from a replaced computer resolving against a live one was silently absent on exactly the shape it was written for. It now asks by listing, never ensuring, so asking cannot wake a computer that had stopped. This is the item #216 lists under "Not decided" as worth a test rather than an assumption.

A browser cannot live in a replicated API pod. The image's computer gets the same EMBEDDED_COMPUTER switch its database already has, defaulting on so the one-container image is unchanged. Asking for it with more than one replica is refused at install.

Migration hook ordering deadlocks both ways with a bundled database. pre-install runs before the subchart exists, so waiting cannot help; post-install with --wait never runs, because the Deployment cannot become ready against an empty schema. On a first install with the bundled database it is an ordinary Job, created in the same pass and waiting for the database itself. Every other case is a pre hook, because the database already exists.

A fresh EKS cluster very often has no default StorageClass. eksctl creates gp2, unmarked and on the in-tree kubernetes.io/aws-ebs provisioner that current Kubernetes no longer has. A volume asking for the default never binds, the computer sits Pending, and nothing says why. Verified on the real cluster and written down where somebody configuring one will read it.

Refused at install, not in a crash loop

No database or two of them; nobody who could sign in; nobody who would be an administrator; a KEY_ENCRYPTION_KEY of the wrong shape; a bundled database with no password that could be supplied again on upgrade; a computer with no token; external with no address; both routers; both secret sources; Okta without an issuer; a browser inside more than one replica. Each names the value to change, and I drove every one.

Where it runs

  • New state that outlives a request? work_items, which is the point: durable, claimed, leased, and the alternative is state in a process.
  • What happens on the second replica? The question this PR exists to answer. sessionOf is the one place the old answer was wrong, and the culler is built so the answer stays right.
  • Anything serialised? No.
  • Anything fanned out to a browser? No.
  • New listener, port, or schedule? The computer's port, inside the cluster. A CronJob, which is a schedule with a claim under it rather than a timer.

Boundary and audit

  • Every acting call still goes through the gateway. Untouched.
  • The sandbox provider's credential is a Role, not a ClusterRole, scoped to one namespace and to sandboxes alone. Smaller blast radius than the Docker supervisor, which holds a socket that is root-equivalent on its host.
  • Nothing new is trusted from the client.

Known and deliberate

The bundled database runs as the superuser, because migration 0000 creates the vector extension and 0010 drops it, and only a superuser or the owner can do either. That is what docker-compose.yml already does, and safe for the same reason. A managed database is not this, which the values file says where somebody configuring RDS will read it.

Full gate green: 1468 pass, 0 fail. helm lint clean on all four targets.

One chart for EKS, GKE, AKS and somebody's own cluster, with nothing but
values between them. No cloud branching in any template: every place the
clouds differ is a value whose default is what a plain self-hosted
cluster does. Identity is one annotations map, because that is all IRSA,
Workload Identity and AKS workload identity are. Secrets are a plain
Secret by default and an ExternalSecret against any backend when asked.

Two replicas by default, because horizontal is the point and one hides
every bug that is not. A bad install is refused at helm install naming
the value to change, rather than found in a crash loop.

Three things only a real install could find:

drizzle-kit cannot migrate in the shipped image. It reads a TypeScript
config, which needs the esbuild that bun install --production leaves out,
so it printed one line, exited 1 and said nothing. EMBEDDED_POSTGRES=on
was starting containers whose database was never migrated. The migrator
inside drizzle-orm is a runtime dependency already and keeps the same
journal.

sessionOf answered from a map in the process that started the computer,
which is right until there are two of them. The replica taking a snapshot
is usually not the one handling the click, and an unknown session skips
the generation check rather than failing it, so the check that stops a
ref from a replaced computer resolving against a live one was silently
absent on the shape it was written for. It now asks by listing, never by
ensuring, so asking cannot start a computer that had stopped.

A browser in an API pod cannot be replicated, so the image's computer
gets the same switch its database has.
@davidmckayv
davidmckayv force-pushed the feat/helm-chart-kubernetes branch from 846df9e to dc8e406 Compare August 24, 2026 19:57
The chart had no computer, so no Bot could do anything on a cluster. It
has one now, and a Bot has driven a real browser on real EKS with the
decision in the audit trail.

computers.mode picks the shape. shared runs one browser for every Bot and
needs nothing installed. sandbox gives each Bot its own as a Sandbox from
kubernetes-sigs/agent-sandbox, which is built for exactly this: an
isolated stateful singleton with a stable identity and persistent
storage, where suspending is a field that keeps the volumes, so a
computer comes back with its logins rather than signed out.

What decides a computer is idle is the audit trail, not the browser.
Asking the browser wakes it, so every computer anything asked about would
come back up and the bill would never fall.

The work is claimed and leased out of Postgres with for update skip
locked. Three features need that one mechanism, so it is written once
with all three in view: the culler here, routines, and a hop from one Bot
to another. A CronJob runs the sweep rather than a timer in the API,
because a timer fires in every replica and suspending a browser somebody
just started using is not something to do five times.

Also: a fresh EKS cluster very often has no default StorageClass. eksctl
creates gp2, unmarked and on the in-tree provisioner current Kubernetes
no longer has, so a volume asking for the default never binds and nothing
says why. Found on a real 1.34 cluster and written down where somebody
configuring one will read it.
@davidmckayv davidmckayv changed the title Run OpenBot on Kubernetes: a Helm chart for the stateless tier Run OpenBot on Kubernetes: Bots and all, proven on EKS Aug 24, 2026
computers.mode: sandbox creates Sandbox objects, which exist only once
the agent-sandbox controller is installed. Without it the install
succeeds, every pod is healthy, and the deployment looks finished right
up until the first Bot asks for a browser and the API server answers 404.
That is the worst moment to learn it.

The check reads the cluster rather than a value somebody has to remember
to set, and the message carries the one command that fixes it. Proven
both ways: refused on a cluster with no CRD, installs on the EKS cluster
that has one.

Also from driving it on real EKS: lost+found was listed as a Bot, because
an EBS volume is ext4 and arrives with that directory, which a bind mount
never does. The allow-list that stops a hostile id becoming a path
answers the other half of the question too.

The migration Job named a ServiceAccount that does not exist yet, since a
pre-install hook runs before the chart's own resources. It talks to a
database and never to the cluster, so it needs no account at all.

The API pod gets a cluster token only in sandbox mode, the pods roll when
the computer template changes, the Sandbox asks for a Service so it has
an address that survives a resume, and the cluster CA is actually used
when talking to the API server.
A resumed browser counts snapshot generations from one again, so a ref
the model still holds from before the suspend matches a row nothing has
overwritten, and the boundary decides about an element on a page that no
longer exists.

The first answer used the node and the pod address. Resuming a real
computer on EKS disproved it: a suspended sandbox is very often
rescheduled onto the same node and handed the same address back, and both
were identical across the cycle, so the check would have said same run
for the exact case it exists to catch. The Ready condition's transition
time moves whenever a computer starts serving again, needs no permission
beyond the sandbox already read, and is precisely the question.

Driven on EKS: a ref taken before a suspend is refused after the resume,
naming why, and a fresh ref from a new snapshot clicks through.
…e database

The NetworkPolicy allowed DNS and the bundled database. Nothing let the
API reach a Bot's computer, which it does for every browser action, and
nothing let it reach a managed database, whose address this chart cannot
know. On a cluster that enforces policy both are outages that read as
something else: the API looks broken rather than fenced.

The computers and the API server are allowed now, and turning the policy
on with an external database and no rule for it is refused with the shape
of the rule to add.

None of this showed up by installing it, because EKS runs its CNI with
--enable-network-policy=false and the policy is inert there. That is
worth knowing on its own, so it is written down: a policy that installs,
looks right, and does nothing is worse than one that is off.

Also driven on EKS: reset takes the volumes with it and the Bot gets a
clean profile afterwards, and the HPA reads real metrics.
Every turn in which a Bot used a tool vanished from the transcript on
reload. The sentence the Bot wrote stayed, the browsing that produced it
did not, the inline screen went with it, and the footer said some
messages could not be read.

The history store writes a tool call as {id, name, args}; AG-UI describes
{id, type: function, function: {name, arguments}}. The reader validated
against the second and treated the first as damage from an interrupted
run. It is not damage, it is how every tool call is stored, so a guard
written against one bad turn was deleting all the real ones.

Found by driving a real conversation on the EKS deployment rather than by
reading: two browsing turns, both counted unreadable, both well formed in
the store's own dialect.

Both spellings now read as the same thing. A mixed or unrecognised array
is still refused rather than half-translated, because a reader that
rewrites what it does not recognise is worse than one that refuses it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Run this on Kubernetes: a Helm chart, and computers that spin down when idle

1 participant