From 8b5e16f2e7845fd343ef6475ce36be3387fa747a Mon Sep 17 00:00:00 2001 From: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Date: Sat, 18 Jul 2026 13:26:47 -0400 Subject: [PATCH 1/3] feat(chart): add relay horizontal autoscaling Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> --- deploy/charts/buzz/Chart.yaml | 4 +- deploy/charts/buzz/README.md | 22 +++++++ deploy/charts/buzz/templates/_helpers.tpl | 11 +++- deploy/charts/buzz/templates/_validate.tpl | 22 +++++-- deploy/charts/buzz/templates/deployment.yaml | 4 +- deploy/charts/buzz/templates/hpa.yaml | 32 ++++++++++ deploy/charts/buzz/templates/pdb.yaml | 2 +- deploy/charts/buzz/tests/hpa_test.yaml | 63 +++++++++++++++++++ deploy/charts/buzz/tests/validation_test.yaml | 2 +- deploy/charts/buzz/values.yaml | 30 +++++++++ 10 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 deploy/charts/buzz/templates/hpa.yaml create mode 100644 deploy/charts/buzz/tests/hpa_test.yaml diff --git a/deploy/charts/buzz/Chart.yaml b/deploy/charts/buzz/Chart.yaml index 68743d8505..75a1a2db57 100644 --- a/deploy/charts/buzz/Chart.yaml +++ b/deploy/charts/buzz/Chart.yaml @@ -7,7 +7,7 @@ description: | PostgreSQL and Redis. Configurable for single-node evaluation (subcharts on) and HA production (external services, existingSecret). type: application -version: 0.1.3 +version: 0.1.4 appVersion: "0.1.0" home: https://github.com/block/buzz sources: @@ -24,7 +24,7 @@ maintainers: annotations: artifacthub.io/changes: | - kind: added - description: Initial chart for Buzz relay. + description: Optional CPU and per-pod WebSocket horizontal autoscaling for relay pods. artifacthub.io/license: Apache-2.0 # Optional eval-only subcharts. Production deploys disable both and point diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 81ab7ca096..447a393320 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -78,6 +78,28 @@ It does **not** require ReadWriteMany git storage. Git ref/object state is objec The chart **template-fails** if the Redis invariant is broken at `replicaCount > 1`. No silent degradation. +### Relay autoscaling + +The optional HPA scales the relay on the larger recommendation from CPU or +average active WebSockets per pod: + +```yaml +autoscaling: + enabled: true + minReplicas: 5 + maxReplicas: 15 + targetCPUUtilizationPercentage: 65 + websocketMetricName: buzz_ws_connections_active + targetWebsocketConnections: 5000 +``` + +CPU scaling requires Kubernetes Metrics Server. WebSocket scaling additionally +requires a custom-metrics adapter (for example Prometheus Adapter) configured to +expose the relay's `buzz_ws_connections_active` gauge as a pod metric with the +name in `websocketMetricName`. The chart creates the HPA but deliberately does +not install or configure a cluster-wide metrics adapter. Scale-down is gradual +by default so long-lived WebSocket connections have time to drain. + ## Upgrades Schema migrations are embedded in the relay binary via `sqlx::migrate!` and run at startup, gated by `BUZZ_AUTO_MIGRATE` (default `true`). Multiple replicas race-safely behind a Postgres advisory lock. `helm upgrade` is the entire upgrade procedure. diff --git a/deploy/charts/buzz/templates/_helpers.tpl b/deploy/charts/buzz/templates/_helpers.tpl index 6e3f52a580..ff070379eb 100644 --- a/deploy/charts/buzz/templates/_helpers.tpl +++ b/deploy/charts/buzz/templates/_helpers.tpl @@ -103,11 +103,20 @@ secrets.existingSecret, use that. Otherwise use the chart-managed one. {{- printf "http://%s.%s.svc.cluster.local:9000" (include "buzz.minioFullname" .) .Release.Namespace -}} {{- end -}} +{{/* Minimum number of relay replicas the release can run. */}} +{{- define "buzz.minimumReplicas" -}} +{{- if .Values.autoscaling.enabled -}} +{{- .Values.autoscaling.minReplicas -}} +{{- else -}} +{{- .Values.replicaCount -}} +{{- end -}} +{{- end -}} + {{/* Effective huddle-audio availability. Nil means safe chart default: on for one replica, off for multi-pod until an SFU/shared-room story exists. */}} {{- define "buzz.huddleAudioAvailable" -}} {{- if kindIs "invalid" .Values.relay.huddleAudioAvailable -}} -{{- if gt (.Values.replicaCount | int) 1 -}}false{{- else -}}true{{- end -}} +{{- if gt (include "buzz.minimumReplicas" . | int) 1 -}}false{{- else -}}true{{- end -}} {{- else -}} {{- .Values.relay.huddleAudioAvailable -}} {{- end -}} diff --git a/deploy/charts/buzz/templates/_validate.tpl b/deploy/charts/buzz/templates/_validate.tpl index 7ca5fe29af..9185e0e41c 100644 --- a/deploy/charts/buzz/templates/_validate.tpl +++ b/deploy/charts/buzz/templates/_validate.tpl @@ -10,14 +10,15 @@ surface at template time regardless of which manifest helm renders first. {{- fail "relayUrl is required: set --set relayUrl=wss://your.domain" -}} {{- end -}} -{{/* replicaCount > 1 requires Redis */}} -{{- if gt (.Values.replicaCount | int) 1 -}} +{{/* Multiple replicas require Redis, whether fixed or autoscaled. */}} +{{- $minimumReplicas := include "buzz.minimumReplicas" . | int -}} +{{- if gt $minimumReplicas 1 -}} {{- if and (not .Values.redis.enabled) (not .Values.externalRedis.url) (not .Values.secrets.existingSecret) -}} - {{- fail (printf "replicaCount=%d requires Redis for buzz-pubsub. Enable redis.enabled=true, set externalRedis.url, or provide secrets.existingSecret with key REDIS_URL." (.Values.replicaCount | int)) -}} + {{- fail (printf "minimum replica count %d requires Redis for buzz-pubsub. Enable redis.enabled=true, set externalRedis.url, or provide secrets.existingSecret with key REDIS_URL." $minimumReplicas) -}} {{- end -}} {{- end -}} -{{/* replicaCount > 1 does NOT require ReadWriteMany git storage. +{{/* Multiple replicas do NOT require ReadWriteMany git storage. Git ref/object state is object-store-backed: every read and write hydrates an ephemeral bare repo from S3-compatible storage per request, and writer @@ -32,6 +33,19 @@ surface at template time regardless of which manifest helm renders first. replicas") is no longer true. Redis (validated above) remains the real multi-pod requirement for buzz-pubsub. */}} +{{/* Autoscaling bounds must be coherent. */}} +{{- if .Values.autoscaling.enabled -}} + {{- if lt (.Values.autoscaling.minReplicas | int) 1 -}} + {{- fail "autoscaling.minReplicas must be at least 1" -}} + {{- end -}} + {{- if lt (.Values.autoscaling.maxReplicas | int) (.Values.autoscaling.minReplicas | int) -}} + {{- fail "autoscaling.maxReplicas must be greater than or equal to autoscaling.minReplicas" -}} + {{- end -}} + {{- if not .Values.autoscaling.websocketMetricName -}} + {{- fail "autoscaling.websocketMetricName is required when autoscaling.enabled=true" -}} + {{- end -}} +{{- end -}} + {{/* Owner pubkey required when requireRelayMembership */}} {{- if .Values.relay.requireRelayMembership -}} {{- if not .Values.ownerPubkey -}} diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 10ebd3b7a6..3977c3ab9c 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -6,7 +6,9 @@ metadata: labels: {{- include "buzz.labels" . | nindent 4 }} spec: + {{- if not .Values.autoscaling.enabled }} replicas: {{ .Values.replicaCount }} + {{- end }} strategy: type: RollingUpdate rollingUpdate: @@ -176,7 +178,7 @@ spec: secretKeyRef: name: {{ include "buzz.envSecretName" . }} key: REDIS_URL - optional: {{ and (eq (.Values.replicaCount | int) 1) (not .Values.redis.enabled) (not .Values.externalRedis.url) }} + optional: {{ and (eq (include "buzz.minimumReplicas" . | int) 1) (not .Values.redis.enabled) (not .Values.externalRedis.url) }} - name: BUZZ_S3_ACCESS_KEY valueFrom: secretKeyRef: diff --git a/deploy/charts/buzz/templates/hpa.yaml b/deploy/charts/buzz/templates/hpa.yaml new file mode 100644 index 0000000000..f9b2d0deea --- /dev/null +++ b/deploy/charts/buzz/templates/hpa.yaml @@ -0,0 +1,32 @@ +{{- include "buzz.validate" . -}} +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "buzz.fullname" . }} + labels: + {{- include "buzz.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "buzz.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + behavior: + {{- toYaml .Values.autoscaling.behavior | nindent 4 }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Pods + pods: + metric: + name: {{ .Values.autoscaling.websocketMetricName }} + target: + type: AverageValue + averageValue: {{ .Values.autoscaling.targetWebsocketConnections | quote }} +{{- end }} diff --git a/deploy/charts/buzz/templates/pdb.yaml b/deploy/charts/buzz/templates/pdb.yaml index ce2bc4d8e9..e13f050d37 100644 --- a/deploy/charts/buzz/templates/pdb.yaml +++ b/deploy/charts/buzz/templates/pdb.yaml @@ -1,5 +1,5 @@ {{- include "buzz.validate" . -}} -{{- if and .Values.podDisruptionBudget.enabled (gt (.Values.replicaCount | int) 1) -}} +{{- if and .Values.podDisruptionBudget.enabled (gt (include "buzz.minimumReplicas" . | int) 1) -}} apiVersion: policy/v1 kind: PodDisruptionBudget metadata: diff --git a/deploy/charts/buzz/tests/hpa_test.yaml b/deploy/charts/buzz/tests/hpa_test.yaml new file mode 100644 index 0000000000..e35957d808 --- /dev/null +++ b/deploy/charts/buzz/tests/hpa_test.yaml @@ -0,0 +1,63 @@ +suite: relay horizontal pod autoscaler +templates: + - templates/hpa.yaml + - templates/deployment.yaml + - templates/secret-chart.yaml +set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s +tests: + - it: omits the HPA by default + template: templates/hpa.yaml + asserts: + - hasDocuments: + count: 0 + + - it: scales the relay on either CPU or average pod WebSockets + template: templates/hpa.yaml + set: + autoscaling.enabled: true + asserts: + - isAPIVersion: + of: autoscaling/v2 + - isKind: + of: HorizontalPodAutoscaler + - equal: + path: spec.minReplicas + value: 5 + - equal: + path: spec.maxReplicas + value: 15 + - equal: + path: spec.scaleTargetRef.name + value: RELEASE-NAME-buzz + - equal: + path: spec.metrics[0].resource.target.averageUtilization + value: 65 + - equal: + path: spec.metrics[1].pods.metric.name + value: buzz_ws_connections_active + - equal: + path: spec.metrics[1].pods.target.averageValue + value: "5000" + - equal: + path: spec.behavior.scaleDown.stabilizationWindowSeconds + value: 600 + + - it: leaves Deployment replicas to the HPA and disables in-process huddle audio + template: templates/deployment.yaml + set: + autoscaling.enabled: true + asserts: + - notExists: + path: spec.replicas + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_HUDDLE_AUDIO_AVAILABLE + value: "false" diff --git a/deploy/charts/buzz/tests/validation_test.yaml b/deploy/charts/buzz/tests/validation_test.yaml index 3b9ed6f869..f0a3869795 100644 --- a/deploy/charts/buzz/tests/validation_test.yaml +++ b/deploy/charts/buzz/tests/validation_test.yaml @@ -37,7 +37,7 @@ tests: replicaCount: 3 asserts: - failedTemplate: - errorPattern: "replicaCount=3 requires Redis" + errorPattern: "minimum replica count 3 requires Redis" - it: fails when ingress and httproute both enabled set: diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 9edb534f1b..03929ad601 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -36,6 +36,36 @@ image: # in Postgres. Each replica can use its own ReadWriteOnce volume (or none). replicaCount: 1 +# ── Autoscaling ────────────────────────────────────────────────────────────── +# Requires Metrics Server for CPU and a custom-metrics adapter exposing the +# pod-level Prometheus gauge named by websocketMetricName. Kubernetes HPA uses +# the larger replica recommendation, so either CPU or WebSocket load scales out. +autoscaling: + enabled: false + minReplicas: 5 + maxReplicas: 15 + targetCPUUtilizationPercentage: 65 + websocketMetricName: buzz_ws_connections_active + targetWebsocketConnections: 5000 + behavior: + scaleUp: + stabilizationWindowSeconds: 0 + policies: + - type: Percent + value: 100 + periodSeconds: 60 + - type: Pods + value: 4 + periodSeconds: 60 + selectPolicy: Max + scaleDown: + stabilizationWindowSeconds: 600 + policies: + - type: Pods + value: 1 + periodSeconds: 120 + selectPolicy: Min + # ── Public URL ─────────────────────────────────────────────────────────────── # Required. The wss:// URL clients use to connect. Drives: # - RELAY_URL env (relay-side) From b3c47406ed55c987a8af52f26951389d8d7011d3 Mon Sep 17 00:00:00 2001 From: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Date: Sat, 18 Jul 2026 13:41:13 -0400 Subject: [PATCH 2/3] ci(chart): package PR chart artifact Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> --- .github/workflows/helm-chart.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml index 0fa156f6c7..739ea78d74 100644 --- a/.github/workflows/helm-chart.yml +++ b/.github/workflows/helm-chart.yml @@ -107,6 +107,17 @@ jobs: echo "::endgroup::" done + - name: Package chart + run: helm package deploy/charts/buzz --destination dist + + - name: Upload packaged chart + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.6.2 + with: + name: buzz-chart-${{ github.sha }} + path: dist/buzz-*.tgz + if-no-files-found: error + retention-days: 14 + install-on-kind: # Full end-to-end install requires the public ghcr.io/block/buzz image to # exist and to embed Max's startup migrations. Runs only after Sami's From 11ee2ac510b134515187514f0eebfaecbd66d6e1 Mon Sep 17 00:00:00 2001 From: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Date: Sat, 18 Jul 2026 13:43:39 -0400 Subject: [PATCH 3/3] Revert "ci(chart): package PR chart artifact" This reverts commit b3c47406ed55c987a8af52f26951389d8d7011d3. Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> --- .github/workflows/helm-chart.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml index 739ea78d74..0fa156f6c7 100644 --- a/.github/workflows/helm-chart.yml +++ b/.github/workflows/helm-chart.yml @@ -107,17 +107,6 @@ jobs: echo "::endgroup::" done - - name: Package chart - run: helm package deploy/charts/buzz --destination dist - - - name: Upload packaged chart - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.6.2 - with: - name: buzz-chart-${{ github.sha }} - path: dist/buzz-*.tgz - if-no-files-found: error - retention-days: 14 - install-on-kind: # Full end-to-end install requires the public ghcr.io/block/buzz image to # exist and to embed Max's startup migrations. Runs only after Sami's