-
Notifications
You must be signed in to change notification settings - Fork 233
feat(deploy): add HAProxy blue/green stack for zero-downtime relay updates #777
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Ferryx349
wants to merge
7
commits into
main
Choose a base branch
from
feat/haproxy-blue-green
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
967d4af
feat(deploy): add HAProxy blue/green compose stack
Ferryx349 ad0dc23
feat(relay): Redis stream fan-out for multi-instance live broadcasts
Ferryx349 9c924f1
Merge branch 'main' into feat/haproxy-blue-green
Ferryx349 3b971a3
fix(deploy): harden HAProxy rolling updates and client IP forwarding
Ferryx349 bcbb24c
fix(app): avoid shutdown deadline TDZ on fast worker exit
Ferryx349 48ecf1d
fix(deploy): address HAProxy and Redis fan-out review feedback
Ferryx349 f530b50
fix(deploy): address HAProxy review — config, IP trust, fan-out readi…
Ferryx349 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| "nostream": minor | ||
| --- | ||
|
|
||
| feat(deploy): add HAProxy blue/green compose stack | ||
|
|
||
| Two relays behind HAProxy with `/readyz` health checks and `option redispatch`, plus a rolling recreate script that replaces one relay at a time for zero-downtime image updates. Optional Redis stream fan-out (`RELAY_BROADCAST_FANOUT`) lets both relays share live WebSocket broadcasts while workers keep using cluster `process.send`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| # Blue/green alternative to docker-compose.prod.yml: two relays behind HAProxy. | ||
| # Keep the shared services below in sync with docker-compose.prod.yml. | ||
| # | ||
| # docker compose -f docker-compose.haproxy.yml up -d | ||
|
|
||
| networks: | ||
| nostream_haproxy: | ||
| ipam: | ||
| config: | ||
| - subnet: 172.28.0.0/24 | ||
|
|
||
| x-nostream-relay: &nostream-relay | ||
| image: ghcr.io/cameri/nostream:main | ||
| pull_policy: never | ||
| env_file: .env | ||
| environment: | ||
| RELAY_PORT: 8008 | ||
| NOSTR_CONFIG_DIR: /home/node/.nostr | ||
| DB_HOST: nostream-db | ||
| DB_PORT: 5432 | ||
| DB_USER: ${DB_USER} | ||
| DB_PASSWORD: ${DB_PASSWORD} | ||
| DB_NAME: ${DB_NAME} | ||
| DB_MIN_POOL_SIZE: ${DB_MIN_POOL_SIZE:-16} | ||
| DB_MAX_POOL_SIZE: ${DB_MAX_POOL_SIZE:-64} | ||
| DB_ACQUIRE_CONNECTION_TIMEOUT: ${DB_ACQUIRE_CONNECTION_TIMEOUT:-60000} | ||
| REDIS_HOST: nostream-cache | ||
| REDIS_PORT: 6379 | ||
| REDIS_USER: default | ||
| REDIS_PASSWORD: ${REDIS_PASSWORD} | ||
| READ_REPLICA_ENABLED: 'false' | ||
| # Required for blue/green; do not inherit RELAY_BROADCAST_FANOUT=false from bootstrap .env | ||
| RELAY_BROADCAST_FANOUT: 'true' | ||
| RELAY_BROADCAST_STREAM_KEY: ${RELAY_BROADCAST_STREAM_KEY:-nostream:relay:broadcast} | ||
| WORKER_COUNT: ${WORKER_COUNT:-2} | ||
| WS_DRAIN_TIMEOUT_MS: ${WS_DRAIN_TIMEOUT_MS:-30000} | ||
| user: node:node | ||
| volumes: | ||
| - ${PWD}/.nostr:/home/node/.nostr | ||
| depends_on: | ||
| nostream-cache: | ||
| condition: service_healthy | ||
| nostream-db: | ||
| condition: service_healthy | ||
| nostream-migrate: | ||
| condition: service_completed_successfully | ||
| restart: on-failure | ||
| stop_grace_period: ${STOP_GRACE_PERIOD:-45s} | ||
| networks: | ||
| - nostream_haproxy | ||
| # Gates `up --wait` during rolling recreate, so the next relay is only | ||
| # replaced once this one serves traffic. | ||
| healthcheck: | ||
| test: | ||
| [ | ||
| 'CMD-SHELL', | ||
| "node -e \"fetch('http://127.0.0.1:8008/readyz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"", | ||
| ] | ||
| interval: 5s | ||
| timeout: 5s | ||
| retries: 6 | ||
| start_period: 60s | ||
|
|
||
| services: | ||
| haproxy: | ||
| image: haproxy:3.0-alpine | ||
| container_name: nostream-haproxy | ||
| volumes: | ||
| - ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro | ||
| ports: | ||
| - 127.0.0.1:8008:8008 | ||
| depends_on: | ||
| - nostream-blue | ||
| - nostream-green | ||
| restart: on-failure | ||
| networks: | ||
| nostream_haproxy: | ||
| ipv4_address: 172.28.0.2 | ||
|
|
||
| nostream-blue: | ||
| <<: *nostream-relay | ||
| container_name: nostream-blue | ||
|
|
||
| nostream-green: | ||
| <<: *nostream-relay | ||
| container_name: nostream-green | ||
|
|
||
| nostream-db: | ||
| image: postgres:15 | ||
| container_name: nostream-db | ||
| networks: | ||
| - nostream_haproxy | ||
| environment: | ||
| POSTGRES_DB: ${DB_NAME} | ||
| POSTGRES_USER: ${DB_USER} | ||
| POSTGRES_PASSWORD: ${DB_PASSWORD} | ||
| volumes: | ||
| - ${PWD}/.nostr/data:/var/lib/postgresql/data | ||
| - ${PWD}/.nostr/db-logs:/var/log/postgresql | ||
| - ${PWD}/postgresql.conf:/postgresql.conf | ||
| command: postgres -c 'config_file=/postgresql.conf' | ||
| restart: always | ||
| healthcheck: | ||
| test: ['CMD-SHELL', 'pg_isready -U ${DB_USER}'] | ||
| interval: 5s | ||
| timeout: 5s | ||
| retries: 5 | ||
| start_period: 360s | ||
|
|
||
| nostream-cache: | ||
| image: redis:7.0.5-alpine3.16 | ||
| container_name: nostream-cache | ||
| networks: | ||
| - nostream_haproxy | ||
| environment: | ||
| REDIS_PASSWORD: ${REDIS_PASSWORD} | ||
| volumes: | ||
| - cache:/data | ||
| command: sh -c 'redis-server --loglevel warning --requirepass "$$REDIS_PASSWORD"' | ||
| restart: always | ||
| healthcheck: | ||
| test: ['CMD-SHELL', 'redis-cli -a "$$REDIS_PASSWORD" ping | grep PONG'] | ||
| interval: 2s | ||
| timeout: 5s | ||
| retries: 10 | ||
|
|
||
| nostream-migrate: | ||
| image: ghcr.io/cameri/nostream:main | ||
| pull_policy: never | ||
| container_name: nostream-migrate | ||
| networks: | ||
| - nostream_haproxy | ||
| user: node:node | ||
| command: ['node_modules/.bin/knex', 'migrate:latest'] | ||
| environment: | ||
| DB_HOST: nostream-db | ||
| DB_PORT: 5432 | ||
| DB_USER: ${DB_USER} | ||
| DB_PASSWORD: ${DB_PASSWORD} | ||
| DB_NAME: ${DB_NAME} | ||
| depends_on: | ||
| nostream-db: | ||
| condition: service_healthy | ||
|
|
||
| volumes: | ||
| cache: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| global | ||
| log stdout format raw local0 info | ||
| maxconn 4096 | ||
|
|
||
| defaults | ||
| mode http | ||
| log global | ||
| option httplog | ||
| timeout connect 5s | ||
| timeout client 30s | ||
| timeout server 30s | ||
| timeout tunnel 1h | ||
| timeout check 5s | ||
| retries 3 | ||
| option redispatch | ||
| retry-on conn-failure empty-response response-timeout 502 503 504 | ||
|
|
||
| # Docker's embedded DNS, so recreated relay containers are picked up by IP change. | ||
| resolvers docker | ||
| nameserver dns 127.0.0.11:53 | ||
| resolve_retries 3 | ||
| timeout resolve 1s | ||
| timeout retry 1s | ||
| hold valid 2s | ||
|
|
||
| frontend nostream_in | ||
| bind *:8008 | ||
| option forwardfor | ||
| default_backend nostream_relays | ||
|
|
||
| backend nostream_relays | ||
| balance roundrobin | ||
| option httpchk | ||
| http-check send meth GET uri /readyz | ||
| http-check expect status 200 | ||
|
|
||
| default-server check inter 2s fall 2 rise 1 resolvers docker resolve-prefer ipv4 init-addr libc,none | ||
|
|
||
| server blue nostream-blue:8008 | ||
| server green nostream-green:8008 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| #!/usr/bin/env bash | ||
| set -euo pipefail | ||
|
|
||
| # Rolling relay recreate for the HAProxy blue/green stack. Replaces one relay | ||
| # at a time so the other keeps serving traffic. | ||
| # | ||
| # Usage: | ||
| # ./rolling-relay-recreate.sh [/opt/nostream] | ||
| # | ||
| # Load the new image and run migrations before calling this. | ||
|
|
||
| TARGET="${1:-/opt/nostream}" | ||
| COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.haproxy.yml}" | ||
|
Copilot marked this conversation as resolved.
|
||
| RELAY_PORT="${RELAY_PORT:-8008}" | ||
|
|
||
| cd "$TARGET" | ||
|
|
||
| if [[ ! -f "$COMPOSE_FILE" ]]; then | ||
| echo "error: compose file not found: $TARGET/$COMPOSE_FILE" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| compose() { | ||
| docker compose -f "$COMPOSE_FILE" "$@" | ||
| } | ||
|
|
||
| relay_readyz_ok() { | ||
| local service=$1 | ||
| local cid | ||
| cid="$(compose ps -q "$service" 2>/dev/null || true)" | ||
| if [[ -z "$cid" ]]; then | ||
| return 1 | ||
| fi | ||
| docker exec "$cid" node -e \ | ||
| "fetch('http://127.0.0.1:${RELAY_PORT}/readyz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" | ||
| } | ||
|
|
||
| peer_for() { | ||
| case "$1" in | ||
| nostream-blue) echo nostream-green ;; | ||
| nostream-green) echo nostream-blue ;; | ||
| *) echo "error: unknown service $1" >&2; exit 1 ;; | ||
| esac | ||
| } | ||
|
|
||
| require_healthy_peer() { | ||
| local peer=$1 | ||
| if [[ -z "$(compose ps -q "$peer")" ]]; then | ||
| echo "error: peer $peer is not running; start it before replacing the other relay" >&2 | ||
| exit 1 | ||
| fi | ||
| if ! relay_readyz_ok "$peer"; then | ||
| echo "error: peer $peer /readyz is not healthy; fix it before continuing" >&2 | ||
| exit 1 | ||
| fi | ||
| } | ||
|
|
||
| for service in nostream-blue nostream-green; do | ||
| if [[ -z "$(compose ps -q "$service")" ]]; then | ||
| echo "Starting $service (not running)..." | ||
| compose rm -f "$service" >/dev/null 2>&1 || true | ||
| compose up -d --no-deps --wait "$service" | ||
| continue | ||
| fi | ||
|
Copilot marked this conversation as resolved.
|
||
|
|
||
| require_healthy_peer "$(peer_for "$service")" | ||
|
|
||
| echo "Replacing $service..." | ||
| compose stop "$service" | ||
| compose rm -f "$service" | ||
| compose up -d --no-deps --wait "$service" | ||
| done | ||
|
|
||
| echo "Rolling recreate complete" | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.