Skip to content

System topology

Substrate runs on Kubernetes, but it deliberately keeps the Kubernetes scheduler out of the hot path. Kubernetes schedules worker pods ahead of time, and when an actor needs to run, ate-api-server assigns it to one of those pods in milliseconds (rather than waiting on kube-scheduler to place a new pod, which takes seconds). That split between a control plane that decides and a data plane that already has capacity waiting is the thing to keep in mind as we walk through the rest of this page.

In three pictures

The hero diagram on the Overview is the whole system on one whiteboard. Here we zoom in on the three regions that matter most, what’s inside each plane, and how a request actually reaches an actor. Let’s start with the control plane, since that’s where our decisions about actors get made.

1 · Control plane

flowchart LR
  KAPI[(K8s API)]
  ATECTRL["ate-controller<br/>(Deployment)<br/>WorkerPools · worker syncer"]
  ATEAPI["ate-api-server<br/>(Deployment)<br/>gRPC :443"]
  PG[("PostgreSQL<br/>atespaces · templates · actors<br/>workers · tags · leases")]
  OBJ[("Object storage<br/>GCS / S3")]

  ATECTRL -- "reconciles WorkerPool<br/>→ worker Deployments" --> KAPI
  ATECTRL -- "creates and retires<br/>worker records" --> ATEAPI
  ATEAPI <--> PG
  ATEAPI -. "objectstore-plugin<br/>sidecar" .-> OBJ

  click ATEAPI "/components/ateapi/" "ateapi"
  click ATECTRL "/components/atecontroller/" "atecontroller"
  click PG "/components/storage/" "Storage"
  click OBJ "/components/storage/" "Storage"

Kubernetes only holds the capacity side of the system. WorkerPool, SandboxConfig, and CSIDriverConfig are the CRDs, and that’s it. Everything about the actors themselves (atespaces, ActorTemplates, actors, tags, worker records, etc) lives in PostgreSQL behind ate-api-server, which also runs the golden-snapshot reconciler. As the diagram above shows, ate-controller turns WorkerPools into worker Deployments and keeps a worker record in step with every worker pod, so the API server always knows what capacity it can hand out.

2 · Data plane (one worker node)

flowchart LR
  ATEAPI["ate-api-server"]

  subgraph NODE["Worker node"]
    direction LR
    ATELET["atelet<br/>(DaemonSet)<br/>mTLS gRPC :21998"]
    PLUG["objectstore-plugin<br/>(atelet sidecar)"]

    subgraph WPOD["Worker pod (gVisor pool shown)"]
      direction TB
      WORKER["ateworker-gvisor<br/>gRPC on worker.sock"]
      TUN["atunnel<br/>:8443 CONNECT · actor egress"]
      SB1[["Actor sandbox"]]
      SB2[["Actor sandbox"]]
      WORKER -- "runsc" --> SB1
      WORKER -- "runsc" --> SB2
    end
  end

  OBJ[("GCS / S3<br/>snapshots")]

  ATEAPI -- "Run · Checkpoint · Restore" --> ATELET
  ATELET -- "worker.sock" --> WORKER
  WORKER -- "register · crash reports<br/>(WorkerSupport)" --> ATELET
  ATELET --> PLUG
  PLUG <-- "upload / download" --> OBJ

  click ATELET "/components/atelet/" "atelet"
  click WORKER "/components/ateworker-gvisor/" "ateworker-gvisor"
  click WPOD "/components/workers/" "Workers"
  click OBJ "/components/storage/" "Storage"

Now let’s look at a single worker node, which is where those decisions turn into running sandboxes. Every node runs one atelet and some number of worker pods. A worker pod hosts many actors (as many as its capacity allows), and each actor gets its own sandbox, cgroup, and network namespaces. You’ll notice in the diagram that atelet never talks to object storage directly. It holds no storage credentials, so snapshot bytes move through its objectstore-plugin sidecar instead.

There are two sandbox runtimes, and a pool picks one through its sandboxClasses entry and the SandboxConfig that entry names. The gvisor class runs ateworker-gvisor, which drives runsc, and the microvm class runs ateworker-microvm, which drives a Cloud Hypervisor micro-VM and needs /dev/kvm on the node.

So can an actor move between the two? Once it has a snapshot it can’t, because a WorkerPool is single-class and a gVisor checkpoint is a completely different format from a micro-VM snapshot (we can’t restore one on the other). From then on, placement treats the actor’s sandbox class as a hard gate, and a memory restore also needs a matching runtime. An actor that has never run boots on whatever class its template’s workerSelector picks (see ActorTemplate and Workers).

Whichever runtime a pool uses, atelet drives it over the same RunWorkload / CheckpointWorkload / RestoreWorkload gRPC on a Unix socket. We cover each runtime on its own page, see ateworker-gvisor and ateworker-microvm.

3 · Request flow

flowchart LR
  Client([Client])
  ROUTER["atenet-router<br/>AgentGateway (preferred)<br/>or Envoy (default)"]
  ATEAPI["ate-api-server"]
  TUN["atunnel<br/>in the worker pod"]
  ACTOR[["Actor"]]

  Client -- "1. HTTP request<br/>ate-target-actor: atespace/actor" --> ROUTER
  ROUTER -- "2. ResumeActor" --> ATEAPI
  ATEAPI -- "3. worker + IP<br/>(restores if suspended)" --> ROUTER
  ROUTER -- "4. mTLS CONNECT :8443" --> TUN
  TUN -- "5. actor :80" --> ACTOR

  click ROUTER "/components/atenet/" "atenet"
  click ATEAPI "/components/ateapi/" "ateapi"
  click TUN "/components/workers/" "Workers"
  click ACTOR "/concepts/actor/" "Actor"

Finally, let’s follow a request in from a client. How does it find an actor that may not even be running? The router resolves it at the moment the request arrives. The client names the actor in the ate-target-actor header, and there are no per-actor DNS records, no sidecars in the actor, and no scheduler involved. The router asks ate-api-server where the actor lives (which resumes it if it’s suspended) and then tunnels the request to atunnel in that worker pod. AgentGateway caches the assignment for a few seconds and parks requests while an actor resumes. Envoy, which is the install default, asks ate-api-server on every request. We walk through the full sequence in Request path and Resume actor.

Outbound traffic takes the mirror-image path. The actor dials a destination, its connection gets redirected to atunnel, and atunnel opens an mTLS CONNECT to the egress gateway (atenet-egress), presenting a worker-for-actor certificate for that actor. The atenet page covers the egress side in detail.

What each component is for

ComponentKindRole
ate-api-serverDeploymentSource of truth for atespaces, templates, actors, tags, and workers. Serves the public Control API and the node-only WorkerService. Runs the suspend, resume, revert, and delete workflows and the golden-snapshot reconciler.
PostgreSQLStatefulSet (bundled) or externalHolds every record, plus the leases that serialize per-actor workflows. Bundled by default; bring-your-own and Cloud SQL are supported.
ate-controllerDeploymentReconciles WorkerPool into worker Deployments, resolves and protects SandboxConfigs, and runs the worker syncer that creates and retires worker records.
ate-idp-serverDeploymentPublishes OIDC discovery and the JWKS for actor JWTs, so outside services can verify them.
atenet-routerDeploymentIngress gateway. Reads ate-target-actor, gets the assignment from ate-api-server, parks requests during resume, and tunnels to the worker’s atunnel. Runs AgentGateway, or Envoy with atenet ext_proc and xDS.
atenet-egressDeploymentEgress gateway. Terminates atunnel’s CONNECT, identifies the actor, applies its egress policy, and injects credentials.
k8s-credential-providerDeployment (optional)The bundled provider of secrets for egress credential injection, backed by Kubernetes Secrets. You can point the egress gateway at your own provider instead, or turn injection off.
ateletDaemonSetOne per worker node. Takes Run/Checkpoint/Restore from ate-api-server, stages runtimes and images, drives the worker over a Unix socket, relays the worker’s registration and crash reports, and moves snapshots through its objectstore-plugin sidecar.
Worker podsDeployment (from a WorkerPool)Pre-started gVisor or micro-VM capacity. Each runs ateworker-* and atunnel and hosts many actor sandboxes.
objectstore-pluginSidecar (atelet, ate-api-server)Holds the object-storage credentials. Uploads and downloads snapshot files to GCS or S3.
podcertificate-controllerDeploymentSigns pod certificates (service DNS and SPIFFE pod identity) for the system components. Not on the hot path.

Ports at a glance

ServicePortProtocol
ate-api-server (api)443gRPC over mTLS
atelet21998gRPC over mTLS (ate-api-server only)
atelet WorkerSupportUnix socketgRPC from worker pods on the same node
ateworkerUnix socketgRPC - /var/lib/ate/workers/{pod-uid}/worker.sock
atenet-router443 → 8443, 8444HTTPS, CONNECT over TLS
atenet-router (AgentGateway only)80 → 8080, 8081Plaintext HTTP, CONNECT
atenet-egress443mTLS CONNECT from atunnel
atunnel8443 / 443 / 15001Ingress CONNECT / HTTPS / egress intercept
ate-idp-server (idp)443HTTPS (OIDC discovery, JWKS)
PostgreSQL5432TLS, client-certificate auth

Keeping kube-scheduler off the hot path

When an actor needs to wake up, ate-api-server picks a worker with room from its worker records and tells that node’s atelet to restore the actor into it. The Kubernetes scheduler never sits on that path, and that’s how Substrate gets sub-second resumes while running far more actors than pods. Kubernetes still does what it’s good at (scheduling and healing the worker pods), it just does it ahead of time.