Kubernetes on EKS
deploy/kubernetes/eks holds
plain manifests for kubectl apply -k and GitOps tools that prefer them. They target AWS EKS with
an arm64 (Graviton) node pool, externally hosted Postgres (RDS/Aurora) and S3 access through IRSA.
The Helm chart deploys the same shapes from values.
| Directory | Deploys |
|---|---|
eks/ |
One obzly replica on a PVC, plus in-cluster Valkey |
eks/ha/ |
Three brokers, obzly-api, obzly-worker and obzly-querier |
eks/control-plane/ |
A multi-site control plane, in its own namespace |
Both eks/ and eks/ha/ share the namespace, service account, ConfigMap and Valkey in
eks/common/. Apply one or the other to a namespace, never both.
Before applying
Section titled “Before applying”Check System requirements for node sizes, sizing by ingest volume and which tiers can run on Spot.
- IAM role for S3 (IRSA): create an IAM role trusted by your cluster’s OIDC provider, with
s3:PutObject,s3:PutObjectTagging,s3:GetObject,s3:ListBucketands3:DeleteObjecton the bucket. Uploads carry object tags for bucket lifecycle rules, and S3 refuses a tagged upload withouts3:PutObjectTagging. Put the role ARN in theeks.amazonaws.com/role-arnannotation incommon/serviceaccount.yaml. - Bucket and region: set
OBJECT_STORAGE_URLincommon/configmap.yamltos3://harborline-obzly?region=eu-west-1. Leave outendpointanduse_path_style, which are for MinIO only. The bucket must already exist; Obzly never creates it. - Postgres: fill in
DATABASE_DSNinsecret.yaml. Obzly runs its own migrations on every startup. - Storage class:
pvc.yamlusesstorageClassName: gp3. Checkkubectl get storageclassand change it if your cluster names its EBS class differently. - Real client IPs: set
TRUSTED_PROXIESincommon/configmap.yamlto your VPC CIDR. An ALB or ingress controller connects from an address in that range and passes the client inX-Forwarded-For. Left empty, request logs, the audit log, sign-in rate limiting, project IP allow-lists and theclient_ipof ingested logs all show the load balancer’s private IP. An NLB with client IP preservation needs nothing here. Pods read the ConfigMap only at startup, so runkubectl -n obzly rollout restart deploymentafter changing it.
Single replica
Section titled “Single replica”cd deploy/kubernetes/eks
# Everything except secrets:kubectl apply -k .
# Secrets separately, after filling in secret.yaml:kubectl apply -f secret.yamlYou can swap the last step for your own secret manager (External Secrets Operator, Sealed Secrets, the AWS Secrets Manager CSI driver, …) as long as the Secret’s name, namespace and keys stay the same.
How this shape is built:
- One replica with a
ReadWriteOncePVC and theRecreatestrategy, so two pods never fight over the volume or run migrations at once. Each deploy has a short downtime window. Useha/for several replicas. - No Ingress:
service.yamlis a plainClusterIP. Point your own Ingress or ALB at it. - Valkey in the cluster, without a PVC: it only holds caches and rate limits. Point
VALKEY_HOST/VALKEY_PORTat an external instance (for example ElastiCache) if you prefer. nodeSelector: kubernetes.io/arch: arm64: pins pods to Graviton nodes. Releases from v0.47.0 are also published forlinux/amd64, so remove it on amd64 nodes.
High availability (ha/)
Section titled “High availability (ha/)”| Component | Runs |
|---|---|
obzly-broker |
SERVICE_MODE=broker: three-pod StatefulSet (one per zone, on-demand nodes, a 50 Gi PVC each), streams replicated three ways, a PodDisruptionBudget so brokers roll one at a time |
obzly-api |
SERVICE_MODE=api, 2 replicas, RollingUpdate, emptyDir disk. UI, query API, ingest (REST, OTLP HTTP and gRPC) and query coordination |
obzly-worker |
SERVICE_MODE=worker, 1 replica, no ports. Parquet writes, compaction, retention, quotas, uploads, reconciliation, alerting and the weekly integrity audit. 3 GiB memory limit |
obzly-querier |
SERVICE_MODE=querier, 2 replicas with a 20 Gi emptyDir segment cache. Stateless, so they suit Spot nodes |
networkpolicy-querier.yaml lets only obzly-api pods reach the executors’ result port (4319),
while metrics (9464) stay open to scrapers; it needs a CNI that enforces NetworkPolicy.
service.yaml points only at obzly-api.
Ingest. obzly-api acknowledges a batch only after the brokers hold it, and obzly-worker
writes it to Parquet, so losing an api pod, a worker or one broker loses no acknowledged data.
Rows are searchable while they wait. When the buffer is full, ingest answers 429 /
RESOURCE_EXHAUSTED and OTel Collectors retry. Each signal’s buffer holds up to 10 GiB
(INGEST_STREAM_MAX_BYTES); keep all three inside the broker PVC, sized for about an hour of ingest.
Queries. Each obzly-api replica splits a query into fragments for the executors and merges
the results. A segment keeps going to the same executor, so caches stay warm. A failed fragment is
retried on another executor, then in the api pod; with no executors running, api pods run
queries themselves.
Before applying ha/
Section titled “Before applying ha/”The same prerequisites as above, plus:
NATS_TOKENandQUERIER_TOKENinsecret.yaml: replace both<RANDOM_SECRET>placeholders with different long random strings (openssl rand -hex 32).- On-demand nodes for the brokers:
broker.yamlselectseks.amazonaws.com/capacityType: ON_DEMAND. Change it to your cluster’s on-demand label (for examplekarpenter.sh/capacity-type: on-demand). Brokers must never run on Spot. OBJECT_STORAGE_URLis required: each pod’s disk is its own, and object storage is how one replica reads segments another wrote.
QUERY_MAX_SCAN_GB (default 100) limits how much stored data one user query may read; raise it
in common/configmap.yaml if your largest legitimate queries need more.
cd deploy/kubernetes/eks/hakubectl apply -k .kubectl apply -f ../secret.yamlMoving from single replica to ha/
Section titled “Moving from single replica to ha/”Apply the ha/ kustomization. It creates the brokers, obzly-api, obzly-worker and
obzly-querier next to the running obzly Deployment. Once obzly-api is healthy and serving
traffic, remove the old pieces:
kubectl delete deployment obzly service obzly pvc obzly-data -n obzlyAn older ha/ install without brokers upgrades as a normal rolling update: the brokers start
first, the api and worker pods restart until the brokers answer, and existing segments stay
readable.
Scaling further
Section titled “Scaling further”obzly-api:kubectl scale deployment obzly-api -n obzly --replicas=N, or an HPA. Every replica is identical.obzly-worker: more replicas add ingest and compaction throughput (each signal’s 64 shards are shared out by lease); a lost worker’s shards move to the others when its lease expires.obzly-querier: add replicas for query throughput. Adding or removing one moves only about 1/N of the segments, so caches mostly stay warm.obzly-broker: stays at three. Size its PVCs andINGEST_STREAM_MAX_BYTESfor your ingest rate.
Enroll as a Site
Section titled “Enroll as a Site”Either shape can join a control plane while keeping its own data, projects, plans, API keys and root admin:
- Uncomment the
SITE_ID/SITE_NAME/CONTROL_PLANE_ISSUER/CONTROL_PLANE_JWKS_URL/CONTROL_PLANE_EGRESS_CIDRSblock incommon/configmap.yaml.SITE_IDmust match the slug you register the Site under in the control plane’s Obzly settings → Sites. CONTROL_PLANE_ISSUERmust be the control plane’s exact publicPUBLIC_URL.CONTROL_PLANE_JWKS_URLis the address this cluster can actually reach the control plane at: its public origin, or an internal DNS name if both run in the same or a peered network. It defaults to<CONTROL_PLANE_ISSUER>/.well-known/jwks.json.- Restrict the Site’s query and admin surface to the control plane at the ingress, for example
with an ALB
alb.ingress.kubernetes.io/inbound-cidrsallow-list or a WAF rule for the control plane’s egress addresses (and setCONTROL_PLANE_EGRESS_CIDRSto the same range), or with mTLS. Onha/, onlyobzly-apineeds this. Keep ingest open:/v1/logs,/v1/traces,/api/v1/logs/receivingand the OTLP gRPC port (4317) stay reachable by collectors directly. - The Site’s own root admin and local accounts keep working as a break-glass path.
Control plane manifests
Section titled “Control plane manifests”eks/control-plane/ deploys a control plane in its own obzly-control-plane namespace with
replicas: 2, no PVC, no object storage and no OTLP port. It needs its own Postgres (never shared
with a Site), PUBLIC_URL in configmap.yaml, and your own Ingress. See
Control plane & Sites.
Operate
Section titled “Operate”Every setting is in the configuration reference: add it to
common/configmap.yaml, or to secret.yaml for credentials. Email (SMTP) and notification
channels are not among them: set them up in Obzly settings (see
Alerts & notifications). If an older install still has ALERT_SMTP_* in
its ConfigMap or Secret, it was imported on the first start of v0.55.0 and can be removed. After a Postgres restore, reconcile
the catalog with the bucket from any Obzly pod:
kubectl -n obzly exec deploy/obzly -- ./main reconcile # report onlySee Upgrade, Backup & restore and
Monitoring Obzly. The pods expose metrics on a metrics container
port with prometheus.io/scrape|port|path annotations.