Plataforma
Plataforma
Hardware & cluster
The control-plane and worker nodes, K3s orchestration, priority classes, and how the cluster is shaped.
Nesta página
The cluster is intentionally small and self-hosted. This page documents the physical layout, the Kubernetes distribution choice, and the scheduling model every workload inherits.
Physical layout
The cluster has one control-plane node and a pool of worker nodes.
cp1is a 2-core ARM node that runs the K3s server. It is tainted, so first-party workloads do not schedule on it. Node labels use the keyrole, andcp1carriesrole=cp.- Workers carry
role=nodeand run every workload. NATS runs one member per worker (three), and most services run three replicas spread across them. Manifests exclude any node whoseroleiscporworkerfrom the production services (deploy/k8s/*.yaml,affinityblocks).
Workloads select nodes by role, never by hostname (deploy/k8s/workloads_test.go,
TestNoWorkloadSelectsNodesByHostname).
Images are built natively for both Intel and ARM on GitHub runners
(.github/workflows/publish-images.yml), so each node runs the image for its own architecture.
Pod and Service networking runs on Cilium in native routing mode with kube-proxy replacement
(deploy/cilium/values.yaml). Tailscale is reserved for private access: the Kubernetes API
server address in the Cilium values is a tailnet address, and the tailscale namespace
carries the private proxies. NATS, Valkey, RPC and telemetry stay on the pod network.
See Networking →.
Kubernetes distribution: K3s
We run K3s rather than upstream Kubernetes. Reasons:
- Single-binary install. Bootstrapping a node is one command.
- Lightweight footprint. The control plane is small enough to share a 2-core node.
- SQLite-backed datastore. The control plane is a single server, not an HA control plane.
The datastore is the K3s SQLite database (kine), and
deploy/k8s/backup-k3s.yamlsnapshots it withsqlite3 .backup, verifies it, and ships it off the cluster.
Scheduling model
Every first-party Deployment declares requests and limits in its manifest, and every
first-party pod carries one of four priority classes (deploy/k8s/priorityclasses.yaml):
| Priority class | Value | Used by |
|---|---|---|
bagel-data-plane |
900000 | Valkey and NATS |
bagel-service |
800000 | twitch-ingress, sesame, outgress, projector, gossip, and the database-backed services |
bagel-edge |
700000 | The dashboard and admin consoles |
bagel-operator |
600000 | Cluster tooling such as KEDA and clusterops |
The order is deliberate: services depend on the data plane, so the data plane is evicted last. A console that is briefly unschedulable costs an operator a page refresh, while evicting a runtime service drops live chat traffic.
OOM-kills are treated as a load signal, not as failures: the response is to raise the limit of the service that was killed, not to add slack everywhere.
Namespaces
| Namespace | Contents |
|---|---|
app |
twitch-ingress, sesame, outgress, the Discord services, gossip, the consoles |
db |
The database-backed services (users, commands, modules, loyalty, transactions, notifications, discord-data), the projector, and the backup jobs |
cache |
Valkey with Sentinel |
messaging |
NATS hubs and leaf nodes |
ops |
clusterops |
flux-system |
Flux source and kustomize controllers |
operators |
cloudflared and the Doppler operator |
networking |
Traefik |
tailscale, keda, observability |
Tailscale proxies, KEDA, and the New Relic agents |
Spreading and placement
- Hard spread. Multi-replica services use a
kubernetes.io/hostnametopology spread withDoNotSchedule, scoped bypod-template-hash, and nominDomains. Both choices come from incidents recorded inTestSpreadIsHardWithoutMinDomains. - NATS and Valkey run as StatefulSets with required anti-affinity, so a node loss costs one
member and never the quorum (
deploy/messaging/nats.yaml,deploy/cache/valkey.yaml). - cloudflared runs as a DaemonSet on every non-control-plane node
(
deploy/operators/cloudflared.yaml), so the loss of a node does not sever public ingress. - Pinned volumes are avoided. A local-path volume strands its pod when the node dies, so stateful members rebuild from replication instead.
What does not run on this cluster
- CI builds. GitHub Actions builds the images and pushes them to GHCR. The cluster only pulls. See Getting started for the local workflow.
- The relational database. MySQL HeatWave is a managed service
(ADR 0005). The
backup-mysqljob in thedbnamespace dumps it every six hours to object storage. - Observability storage. Logs and metrics ship to New Relic (ADR 0010), so a cluster outage stays debuggable.
Capacity headroom
Expansion decisions follow the gates in the performance roadmap: no new server, sharding or replacement until the verified operating envelope exceeds 70% for 15 minutes or a latency objective fails after tuning.
Where to next
- Networking →: Cilium, Tailscale and Cloudflare Tunnel details.
- ADR 0004 →: the hosting decision.
- ADR 0002 →: why Go services shape the resource model.