Skip to content

Kubernetes API seed provider

KubernetesApiSeedProvider queries the K8s API for pods matching a label selector and returns their IPs as seeds. Works without DNS, without SRV, without manual seed-list maintenance — the selector is your contract.

import { Cluster, KubernetesApiSeedProvider } from 'actor-ts';
const provider = new KubernetesApiSeedProvider({
namespace: process.env.K8S_NAMESPACE!,
labelSelector: 'app=actor-ts',
containerPort: 2552,
});
const seeds = await provider.lookup();
await Cluster.join(system, {
host: process.env.POD_IP!,
port: 2552,
seeds,
});

For every pod matching app=actor-ts in the namespace, the provider returns <pod-ip>:2552.

interface KubernetesApiSeedProviderSettings {
namespace: string;
labelSelector: string;
containerPort: number;
apiBaseUrl?: string; // override in-cluster default
serviceAccountToken?: string; // override in-cluster default
}
FieldWhat
namespaceK8s namespace to query — typically your app’s namespace.
labelSelectorThe selector for matching pods (app=actor-ts, role=compute,env=prod, etc.).
containerPortThe cluster-transport port on each matching pod.
apiBaseUrlDefaults to https://kubernetes.default.svc (in-cluster).
serviceAccountTokenDefaults to the in-cluster token at /var/run/secrets/.../token.

For pods running in-cluster, only the first three are required. The framework reads the API URL and SA token from standard locations.

The pod’s ServiceAccount needs read access to pods in the namespace:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: actor-ts-pod-reader
namespace: my-app
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: actor-ts-pod-reader
namespace: my-app
subjects:
- kind: ServiceAccount
name: actor-ts
roleBinding:
kind: Role
name: actor-ts-pod-reader
apiGroup: rbac.authorization.k8s.io

Without these, the lookup gets a 403; Cluster.join retries indefinitely.

For each pod matching the selector:

  • Running pods with non-empty status.podIP → included as <podIP>:<containerPort>.
  • Pending / not-running pods → skipped (no IP yet).
  • This pod itself → may be included or not depending on startup race; the cluster handles self-as-seed correctly.
# Match all pods in the app:
labelSelector: 'app=actor-ts'
# Match only specific role:
labelSelector: 'app=actor-ts,role=compute'
# Match across multiple deployments:
labelSelector: 'cluster=my-app'

The selector is what defines your cluster’s membership at bootstrap. For most deployments, one selector per cluster — all pods carrying it bootstrap into one cluster.

For role-based asymmetric clusters (role=compute for workers, role=gateway for HTTP), a single selector (app=actor-ts) covers both; the role tag inside the cluster is separate from the discovery selector.

Pod starts → Cluster.join called
├── KubernetesApiSeedProvider.lookup()
│ └── GET /api/v1/namespaces/<ns>/pods?labelSelector=<sel>
│ └── filter for Running + non-empty podIP
│ └── return [...podIp:port]
├── Cluster.join tries each seed
│ ├── Existing cluster → join successfully
│ └── No cluster yet → self-bootstrap

If you’re the first pod up, the K8s API returns this pod only. The framework’s self-bootstrap logic handles this: Cluster.join with seeds containing only itself self-promotes to leader.

Subsequent pods see the existing pods and join through them.

Selector patternEffect
app=actor-tsOne cluster across the namespace.
app=actor-ts,env=prodSeparate prod and staging clusters in one namespace.
cluster=my-app-cluster-1Explicit cluster name; multiple clusters in one namespace.

Stable selector design matters — the cluster’s identity is implicit in the selector. Changing the selector splits the cluster.