feat: add isolated scaling measurements
This commit is contained in:
parent
3a35af6782
commit
24e5e9ce82
|
|
@ -5,6 +5,12 @@ MAILPIT_PORT=8027
|
|||
# Comma-separated proxy IP/CIDR values whose X-Forwarded-* headers Traefik
|
||||
# accepts. Keep loopback locally; set the exact VPN proxy address for staging.
|
||||
TRAEFIK_TRUSTED_IPS=127.0.0.1/32
|
||||
# Docker-provider isolation and names. A second Compose project must use its
|
||||
# own project constraint, router/service name, Docker network, and Host rule.
|
||||
TRAEFIK_PROJECT_CONSTRAINT=who_need_help
|
||||
TRAEFIK_APP_NAME=who-need-help
|
||||
TRAEFIK_DOCKER_NETWORK=who_need_help_internal
|
||||
TRAEFIK_ROUTER_RULE='PathPrefix(`/`)'
|
||||
PHX_HOST=localhost
|
||||
PHX_SCHEME=http
|
||||
PHX_URL_PORT=4010
|
||||
|
|
|
|||
53
.env.load.example
Normal file
53
.env.load.example
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Copying is handled by scripts/ensure-local-load-env.sh, which replaces every
|
||||
# GENERATE_* marker with an independent local secret and restricts the result
|
||||
# to mode 600. This profile owns a separate Compose project and PostgreSQL
|
||||
# volume; it must never use the staging project name.
|
||||
LOAD_PROJECT=who_need_help_load
|
||||
LOAD_HOST=load.local
|
||||
HTTP_PORT=4110
|
||||
MAILPIT_PORT=8127
|
||||
PHX_HOST=load.local
|
||||
PHX_SCHEME=https
|
||||
PHX_URL_PORT=443
|
||||
TRAEFIK_TRUSTED_IPS=127.0.0.1/32
|
||||
TRAEFIK_PROJECT_CONSTRAINT=who_need_help_load
|
||||
TRAEFIK_APP_NAME=who-need-help-load
|
||||
TRAEFIK_DOCKER_NETWORK=who_need_help_load_internal
|
||||
TRAEFIK_ROUTER_RULE='Host(`load.local`)'
|
||||
|
||||
POSTGRES_DB=who_need_help_load
|
||||
POSTGRES_USER=wnh_load
|
||||
POSTGRES_PASSWORD=GENERATE_POSTGRES_PASSWORD
|
||||
DATABASE_URL=GENERATE_DATABASE_URL
|
||||
POOL_SIZE=10
|
||||
SECRET_KEY_BASE=GENERATE_SECRET_KEY_BASE
|
||||
HANDOVER_SECRET=GENERATE_HANDOVER_SECRET
|
||||
RELEASE_COOKIE=GENERATE_RELEASE_COOKIE
|
||||
METRICS_TOKEN=GENERATE_METRICS_TOKEN
|
||||
|
||||
SMTP_RELAY=mailpit
|
||||
SMTP_PORT=1025
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_AUTH=never
|
||||
SMTP_TLS=never
|
||||
SMTP_SSL=false
|
||||
EMAIL_FROM_NAME="Who Need Help load profile"
|
||||
EMAIL_FROM_ADDRESS=load-profile@example.invalid
|
||||
|
||||
CODEX_SESSION_ID=load-profile
|
||||
RATE_LIMIT_POLICIES_JSON={}
|
||||
MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
GITHUB_OAUTH_CLIENT_ID=
|
||||
GITHUB_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# These are reproducible experiment inputs, not capacity requirements,
|
||||
# production traffic forecasts, alert thresholds, or recommended limits.
|
||||
LOAD_WEB_REPLICAS=2
|
||||
LOAD_WORKER_REPLICAS=2
|
||||
LOAD_HTTP_VUS=40
|
||||
LOAD_WS_VUS=40
|
||||
LOAD_DURATION=30s
|
||||
LOAD_WS_HOLD_MS=5000
|
||||
LOAD_WS_CONNECT_TIMEOUT_MS=3000
|
||||
LOAD_HTTP_THINK_SECONDS=0.1
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -43,6 +43,7 @@ npm-debug.log
|
|||
/.env
|
||||
/.env.*
|
||||
!/.env.example
|
||||
!/.env.load.example
|
||||
|
||||
# Local browser automation state and generated verification artifacts.
|
||||
/.playwright-cli/
|
||||
|
|
|
|||
|
|
@ -232,6 +232,7 @@ and no fallback provider. Recommendations require a human moderator action.
|
|||
- [Architecture](docs/architecture.md)
|
||||
- [Trust and safety](docs/trust-safety.md)
|
||||
- [Operations runbook](docs/operations.md)
|
||||
- [Performance measurement](docs/performance.md)
|
||||
- [Implementation verification and known limits](docs/verification.md)
|
||||
- [Verified dependency baseline](docs/dependency-baseline.md)
|
||||
- [PostgreSQL/PostGIS ADR](docs/decisions/0001-postgresql-postgis-over-spacetimedb.md)
|
||||
|
|
|
|||
19
compose.load.yaml
Normal file
19
compose.load.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
services:
|
||||
proxy:
|
||||
networks:
|
||||
edge:
|
||||
aliases:
|
||||
- ${LOAD_HOST}
|
||||
internal: {}
|
||||
|
||||
migrate:
|
||||
image: who-need-help:load
|
||||
|
||||
web:
|
||||
image: who-need-help:load
|
||||
labels:
|
||||
- traefik.http.routers.${TRAEFIK_APP_NAME}.middlewares=${TRAEFIK_APP_NAME}-forwarded
|
||||
- traefik.http.middlewares.${TRAEFIK_APP_NAME}-forwarded.headers.customrequestheaders.X-Forwarded-Proto=https
|
||||
|
||||
worker:
|
||||
image: who-need-help:load
|
||||
10
compose.yaml
10
compose.yaml
|
|
@ -35,6 +35,7 @@ services:
|
|||
- --api.dashboard=false
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --providers.docker.constraints=Label(`com.docker.compose.project`,`${TRAEFIK_PROJECT_CONSTRAINT:-who_need_help}`)
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.web.forwardedheaders.trustedips=${TRAEFIK_TRUSTED_IPS:-127.0.0.1/32}
|
||||
ports:
|
||||
|
|
@ -92,10 +93,11 @@ services:
|
|||
condition: service_completed_successfully
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=who_need_help_internal
|
||||
- traefik.http.routers.who-need-help.rule=PathPrefix(`/`)
|
||||
- traefik.http.routers.who-need-help.entrypoints=web
|
||||
- traefik.http.services.who-need-help.loadbalancer.server.port=4000
|
||||
- traefik.docker.network=${TRAEFIK_DOCKER_NETWORK:-who_need_help_internal}
|
||||
- traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.rule=${TRAEFIK_ROUTER_RULE:-PathPrefix(`/`)}
|
||||
- traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.entrypoints=web
|
||||
- traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.service=${TRAEFIK_APP_NAME:-who-need-help}
|
||||
- traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.server.port=4000
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"]
|
||||
interval: 10s
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ package checksums are in `mix.lock` and `assets/package-lock.json`.
|
|||
| PostGIS | 3.6.4 |
|
||||
| Traefik | 3.7.8 |
|
||||
| Mailpit | 1.30.4 |
|
||||
| k6 load generator | 2.1.0 |
|
||||
| Debian builder/runner snapshot | trixie-20260713-slim |
|
||||
|
||||
Every external Compose/kind service image and every Dockerfile base image is
|
||||
|
|
@ -74,6 +75,7 @@ because the official SDK channel identifies it as a QPR beta.
|
|||
- [PostGIS release notes](https://postgis.net/docs/en/release_notes.html)
|
||||
- [Traefik releases](https://github.com/traefik/traefik/releases)
|
||||
- [Mailpit releases](https://github.com/axllent/mailpit/releases)
|
||||
- [k6 releases](https://github.com/grafana/k6/releases)
|
||||
- [Android Gradle Plugin 9.3.0 release notes](https://developer.android.com/build/releases/agp-9-3-0-release-notes)
|
||||
- [Android 17 SDK setup](https://developer.android.com/about/versions/17/setup-sdk)
|
||||
- [Gradle release notes](https://docs.gradle.org/current/release-notes.html)
|
||||
|
|
|
|||
121
docs/performance.md
Normal file
121
docs/performance.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# Performance measurement
|
||||
|
||||
No production capacity, minimum resource requirement, SLO, alert threshold,
|
||||
pool size, or autoscaling threshold is known yet. The repository therefore
|
||||
contains a reproducible measurement profile, not a capacity claim or blocking
|
||||
resource preflight.
|
||||
|
||||
The profile uses a separate Compose project, generated independent secrets, and
|
||||
a separate PostgreSQL volume. Its Traefik instance is constrained to that exact
|
||||
Compose project; its router, service, middleware, and Host rule are unique, so
|
||||
the load profile cannot join the staging router. It exercises:
|
||||
|
||||
- dynamic public home and Safety pages;
|
||||
- database-backed readiness queries;
|
||||
- Phoenix WebSocket upgrades and protocol heartbeats;
|
||||
- the configured number of web and worker replicas;
|
||||
- cross-node Phoenix PubSub after each run.
|
||||
|
||||
It does not authenticate users, create requests, send chat messages, update
|
||||
locations, render a real browser, or reproduce an unknown future production
|
||||
traffic mix. It also does not sample the k6 load-generator container itself.
|
||||
Its results only describe the recorded host, container versions, replica
|
||||
counts, database state, and experiment inputs.
|
||||
|
||||
## Create the isolated profile
|
||||
|
||||
```sh
|
||||
./scripts/ensure-local-load-env.sh
|
||||
./scripts/load-stack-up.sh
|
||||
```
|
||||
|
||||
The generated `.env.load` is ignored, restricted to mode 600, and contains
|
||||
independent PostgreSQL and application secrets. Edit its `LOAD_*` inputs to
|
||||
define a specific experiment. Values in `.env.load.example` are reproducible
|
||||
measurement points, not recommendations.
|
||||
|
||||
## Run and compare replica counts
|
||||
|
||||
```sh
|
||||
./scripts/load-run.sh two-web
|
||||
./scripts/load-stack-up.sh 3
|
||||
# Set LOAD_WEB_REPLICAS=3 in .env.load so the recorded expected topology
|
||||
# matches the running topology, then:
|
||||
./scripts/load-run.sh three-web
|
||||
```
|
||||
|
||||
Each run writes ignored evidence below `output/performance/<label>/`:
|
||||
|
||||
- the exact inputs and Docker host observation;
|
||||
- a machine-readable k6 summary and complete console report;
|
||||
- periodic Docker CPU/memory/network/block-I/O/PID samples for every load
|
||||
stack container;
|
||||
- before/after application table counts and their diff;
|
||||
- final Compose state, readiness response, application logs, and cross-node
|
||||
PubSub result.
|
||||
|
||||
The k6 image is pinned to version 2.1.0 by digest and runs locally with anonymous
|
||||
usage reporting disabled. No Grafana Cloud account, API token, OpenAI API, or
|
||||
usage-based service is involved.
|
||||
|
||||
## Observed local comparison
|
||||
|
||||
Observed on 2026-07-18 with Docker Engine 29.6.2 on the recorded 32-CPU,
|
||||
100289265664-byte Docker host. Neither application containers nor the load
|
||||
generator had CPU or memory limits. Both runs used 40 HTTP VUs, 40 WebSocket
|
||||
VUs, a 30-second duration, a 0.1-second HTTP think time, and five-second socket
|
||||
sessions. Each HTTP iteration requested the home page, Safety page, and
|
||||
database-backed readiness endpoint.
|
||||
|
||||
| Observation | 2 web replicas | 3 web replicas |
|
||||
| --- | ---: | ---: |
|
||||
| HTTP checks | 35,160 / 35,160 passed | 35,163 / 35,163 passed |
|
||||
| HTTP failed rate | 0% | 0% |
|
||||
| Observed HTTP rate | 1,169.12 requests/s | 1,169.42 requests/s |
|
||||
| HTTP duration p95 | 2.87 ms | 3.36 ms |
|
||||
| WebSocket opened / heartbeat replies | 240 / 240 | 240 / 240 |
|
||||
| WebSocket errors | 0 | 0 |
|
||||
| WebSocket connect p95 | 46.65 ms | 24.12 ms |
|
||||
| Average CPU per web container | 66.57%, 66.63% | 50.25%, 49.77%, 49.18% |
|
||||
| Maximum CPU per web container | 79.80%, 79.21% | 58.16%, 59.23%, 59.08% |
|
||||
| Maximum observed memory per web container | 268.3, 238.3 MiB | 221.0, 223.2, 219.4 MiB |
|
||||
|
||||
Each resource series contained 11 sequential `docker stats --no-stream`
|
||||
samples. Both before/after database diffs were empty, both cross-node PubSub
|
||||
probes passed, readiness remained healthy, and the captured proxy/web/worker
|
||||
logs contained no application error or warning. The public staging root and
|
||||
readiness endpoint also returned HTTP 200 while the isolated project was
|
||||
running.
|
||||
|
||||
An earlier harness smoke exposed that fixed Traefik router/service names are
|
||||
global to the Docker provider: starting a second Compose project temporarily
|
||||
made staging readiness return HTTP 404. The load project was stopped
|
||||
immediately and staging returned HTTP 200. The final Compose configuration
|
||||
parameterizes the project constraint, router/service name, Docker network, and
|
||||
router rule. Merged-config inspection found no shared router/service label key,
|
||||
the two running proxies showed different exact project constraints, and both
|
||||
origins returned HTTP 200 concurrently before the recorded final runs.
|
||||
|
||||
The nearly identical request rate is expected from the selected paced workload:
|
||||
40 HTTP VUs each issue three requests and then wait 0.1 seconds, giving an
|
||||
approximate workload ceiling of 1,200 requests/s before response time is added.
|
||||
The result therefore does not show the saturation point of two replicas and
|
||||
does not justify an HPA threshold. The lower per-container CPU and lower
|
||||
WebSocket connect p95 with three replicas are observations for this run only;
|
||||
the slightly higher HTTP p95 also shows why one short comparison is not a
|
||||
production sizing study.
|
||||
|
||||
Ignored evidence:
|
||||
|
||||
- `output/performance/two-web-final-20260718/`
|
||||
- `output/performance/three-web-final-20260718/`
|
||||
|
||||
Stop the isolated containers without deleting their database volume:
|
||||
|
||||
```sh
|
||||
./scripts/load-stack-stop.sh
|
||||
```
|
||||
|
||||
The stop script reads the actual `LOAD_PROJECT`, refuses the staging project
|
||||
name, and does not delete the volume. Volume deletion is intentionally not
|
||||
automated.
|
||||
|
|
@ -135,8 +135,11 @@ for that reverse lookup selected the index. The table contained no block rows,
|
|||
so this confirms query shape, not production performance.
|
||||
|
||||
Exact production capacity, minimum CPU/RAM, and scaling thresholds are unknown:
|
||||
there is no representative load dataset or target-environment measurement.
|
||||
The Helm chart therefore does not invent resource limits or an HPA policy.
|
||||
there is no representative load dataset or target-environment measurement. An
|
||||
isolated 40 HTTP VU / 40 WebSocket VU local comparison is recorded in
|
||||
`docs/performance.md`; it tested a paced public/readiness/heartbeat workload
|
||||
with 2 and 3 web replicas but did not find a saturation point. The Helm chart
|
||||
therefore does not invent resource limits or an HPA policy.
|
||||
|
||||
## Public staging observation
|
||||
|
||||
|
|
|
|||
154
load/k6/public-and-websocket.js
Normal file
154
load/k6/public-and-websocket.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { check, sleep } from "k6";
|
||||
import http from "k6/http";
|
||||
import { Counter } from "k6/metrics";
|
||||
import { WebSocket } from "k6/websockets";
|
||||
|
||||
function required(name) {
|
||||
const value = __ENV[name];
|
||||
|
||||
if (value === undefined || value === "") {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveInteger(name) {
|
||||
const value = Number(required(name));
|
||||
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegativeNumber(name) {
|
||||
const value = Number(required(name));
|
||||
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${name} must be a non-negative number`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
const baseUrl = required("BASE_URL").replace(/\/+$/, "");
|
||||
|
||||
if (!/^https?:\/\/[^/?#]+$/.test(baseUrl)) {
|
||||
throw new Error("BASE_URL must be an HTTP(S) origin without a path");
|
||||
}
|
||||
|
||||
const websocketUrl =
|
||||
baseUrl.replace(/^http/, "ws") + "/live/websocket?vsn=2.0.0";
|
||||
const websocketHoldMs = positiveInteger("WS_HOLD_MS");
|
||||
const websocketConnectTimeoutMs = positiveInteger("WS_CONNECT_TIMEOUT_MS");
|
||||
const httpThinkSeconds = nonNegativeNumber("HTTP_THINK_SECONDS");
|
||||
|
||||
const websocketOpened = new Counter("wnh_websocket_opened");
|
||||
const websocketErrors = new Counter("wnh_websocket_errors");
|
||||
const heartbeatReplies = new Counter("wnh_websocket_heartbeat_replies");
|
||||
|
||||
export const options = {
|
||||
discardResponseBodies: true,
|
||||
scenarios: {
|
||||
public_http: {
|
||||
executor: "constant-vus",
|
||||
exec: "publicHttp",
|
||||
vus: positiveInteger("HTTP_VUS"),
|
||||
duration: required("DURATION"),
|
||||
},
|
||||
phoenix_websocket: {
|
||||
executor: "constant-vus",
|
||||
exec: "phoenixWebsocket",
|
||||
vus: positiveInteger("WS_VUS"),
|
||||
duration: required("DURATION"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function publicHttp() {
|
||||
const responses = http.batch([
|
||||
{
|
||||
method: "GET",
|
||||
url: `${baseUrl}/`,
|
||||
tags: { endpoint: "home" },
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
url: `${baseUrl}/safety`,
|
||||
tags: { endpoint: "safety" },
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
url: `${baseUrl}/healthz/ready`,
|
||||
tags: { endpoint: "readiness" },
|
||||
},
|
||||
]);
|
||||
|
||||
check(responses[0], { "home returned 200": (response) => response.status === 200 });
|
||||
check(responses[1], { "safety returned 200": (response) => response.status === 200 });
|
||||
check(responses[2], {
|
||||
"readiness returned 200": (response) => response.status === 200,
|
||||
});
|
||||
|
||||
sleep(httpThinkSeconds);
|
||||
}
|
||||
|
||||
export function phoenixWebsocket() {
|
||||
const socket = new WebSocket(websocketUrl, [], {
|
||||
tags: { endpoint: "phoenix_live_socket" },
|
||||
});
|
||||
const ref = `${__VU}-${__ITER}`;
|
||||
let opened = false;
|
||||
let closeTimer;
|
||||
const connectTimer = setTimeout(() => {
|
||||
if (!opened) {
|
||||
websocketErrors.add(1);
|
||||
socket.close();
|
||||
}
|
||||
}, websocketConnectTimeoutMs);
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
opened = true;
|
||||
clearTimeout(connectTimer);
|
||||
websocketOpened.add(1);
|
||||
socket.send(JSON.stringify([null, ref, "phoenix", "heartbeat", {}]));
|
||||
closeTimer = setTimeout(() => socket.close(), websocketHoldMs);
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
let frame;
|
||||
|
||||
try {
|
||||
frame = JSON.parse(event.data);
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(frame) &&
|
||||
frame[1] === ref &&
|
||||
frame[2] === "phoenix" &&
|
||||
frame[3] === "phx_reply" &&
|
||||
frame[4] &&
|
||||
frame[4].status === "ok"
|
||||
) {
|
||||
heartbeatReplies.add(1);
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("error", () => {
|
||||
websocketErrors.add(1);
|
||||
clearTimeout(connectTimer);
|
||||
socket.close();
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
clearTimeout(connectTimer);
|
||||
|
||||
if (closeTimer !== undefined) {
|
||||
clearTimeout(closeTimer);
|
||||
}
|
||||
});
|
||||
}
|
||||
61
scripts/ensure-local-load-env.sh
Executable file
61
scripts/ensure-local-load-env.sh
Executable file
|
|
@ -0,0 +1,61 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
TEMPLATE="$ROOT/.env.load.example"
|
||||
ENV_FILE="$ROOT/.env.load"
|
||||
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
chmod 600 "$ENV_FILE"
|
||||
echo ".env.load already exists; no secret or experiment input was changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for command in openssl perl; do
|
||||
if ! command -v "$command" >/dev/null 2>&1; then
|
||||
echo "Required command is unavailable: $command" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ! -f "$TEMPLATE" ]; then
|
||||
echo "Missing tracked template: $TEMPLATE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
umask 077
|
||||
postgres_password=$(openssl rand -hex 32)
|
||||
secret_key_base=$(openssl rand -hex 64)
|
||||
handover_secret=$(openssl rand -hex 64)
|
||||
release_cookie=$(openssl rand -hex 64)
|
||||
metrics_token=$(openssl rand -hex 32)
|
||||
database_url="ecto://wnh_load:${postgres_password}@db/who_need_help_load"
|
||||
temporary=$(mktemp "${ENV_FILE}.XXXXXX")
|
||||
trap 'rm -f "$temporary"' EXIT HUP INT TERM
|
||||
|
||||
POSTGRES_PASSWORD_VALUE=$postgres_password \
|
||||
DATABASE_URL_VALUE=$database_url \
|
||||
SECRET_KEY_BASE_VALUE=$secret_key_base \
|
||||
HANDOVER_SECRET_VALUE=$handover_secret \
|
||||
RELEASE_COOKIE_VALUE=$release_cookie \
|
||||
METRICS_TOKEN_VALUE=$metrics_token \
|
||||
perl -0pe '
|
||||
s/GENERATE_POSTGRES_PASSWORD/$ENV{POSTGRES_PASSWORD_VALUE}/g;
|
||||
s/GENERATE_DATABASE_URL/$ENV{DATABASE_URL_VALUE}/g;
|
||||
s/GENERATE_SECRET_KEY_BASE/$ENV{SECRET_KEY_BASE_VALUE}/g;
|
||||
s/GENERATE_HANDOVER_SECRET/$ENV{HANDOVER_SECRET_VALUE}/g;
|
||||
s/GENERATE_RELEASE_COOKIE/$ENV{RELEASE_COOKIE_VALUE}/g;
|
||||
s/GENERATE_METRICS_TOKEN/$ENV{METRICS_TOKEN_VALUE}/g;
|
||||
' "$TEMPLATE" >"$temporary"
|
||||
|
||||
if grep -Eq '^[A-Z0-9_]+=GENERATE_' "$temporary"; then
|
||||
echo "A secret marker was not replaced; refusing to publish .env.load." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod 600 "$temporary"
|
||||
mv "$temporary" "$ENV_FILE"
|
||||
trap - EXIT HUP INT TERM
|
||||
unset postgres_password secret_key_base handover_secret release_cookie metrics_token database_url
|
||||
|
||||
echo "Generated independent load-profile secrets in ignored .env.load."
|
||||
204
scripts/load-run.sh
Executable file
204
scripts/load-run.sh
Executable file
|
|
@ -0,0 +1,204 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
ENV_FILE="$ROOT/.env.load"
|
||||
K6_IMAGE="grafana/k6@sha256:65c920dc067d5e2e00befbf982af6ad6ad0117034e8b1c65817c7975c52d4669"
|
||||
LABEL=${1:-"run-$(date -u +%Y%m%dT%H%M%SZ)"}
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Missing $ENV_FILE. Run scripts/ensure-local-load-env.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
for name in LOAD_PROJECT LOAD_HOST LOAD_WEB_REPLICAS LOAD_HTTP_VUS LOAD_WS_VUS \
|
||||
LOAD_DURATION LOAD_WS_HOLD_MS LOAD_WS_CONNECT_TIMEOUT_MS \
|
||||
LOAD_HTTP_THINK_SECONDS; do
|
||||
if [[ -z "${!name:-}" ]]; then
|
||||
echo "$name is missing from .env.load" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$LOAD_PROJECT" == "who_need_help" ]]; then
|
||||
echo "The load profile must not use the staging Compose project." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "$LABEL" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "Run label may contain only letters, numbers, dot, underscore, and dash." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
compose=(
|
||||
docker compose
|
||||
--env-file "$ENV_FILE"
|
||||
-p "$LOAD_PROJECT"
|
||||
-f compose.yaml
|
||||
-f compose.load.yaml
|
||||
)
|
||||
|
||||
mapfile -t web_containers < <("${compose[@]}" ps -q web)
|
||||
|
||||
if [[ "${#web_containers[@]}" -ne "$LOAD_WEB_REPLICAS" ]]; then
|
||||
echo "Expected $LOAD_WEB_REPLICAS running web replicas; observed ${#web_containers[@]}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
network_id=$(
|
||||
docker network ls \
|
||||
--filter "label=com.docker.compose.project=$LOAD_PROJECT" \
|
||||
--filter "label=com.docker.compose.network=edge" \
|
||||
--quiet |
|
||||
head -n 1
|
||||
)
|
||||
|
||||
if [[ -z "$network_id" ]]; then
|
||||
echo "The isolated load-profile edge network was not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mapfile -t measured_containers < <(
|
||||
"${compose[@]}" ps -q web worker db proxy
|
||||
)
|
||||
|
||||
if [[ "${#measured_containers[@]}" -lt 4 ]]; then
|
||||
echo "The isolated load stack is incomplete." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
output_dir="$ROOT/output/performance/$LABEL"
|
||||
mkdir -p "$output_dir"
|
||||
chmod 700 "$ROOT/output" "$ROOT/output/performance" "$output_dir"
|
||||
running_marker="$output_dir/.sampling"
|
||||
resource_log="$output_dir/docker-stats.jsonl"
|
||||
touch "$running_marker"
|
||||
|
||||
snapshot_database() {
|
||||
local destination=$1
|
||||
|
||||
"${compose[@]}" exec -T db sh -c \
|
||||
'psql --no-psqlrc --set ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' \
|
||||
>"$destination" <<'SQL'
|
||||
BEGIN READ ONLY;
|
||||
SELECT 'users' AS table_name, count(*) AS row_count FROM users
|
||||
UNION ALL SELECT 'help_requests', count(*) FROM help_requests
|
||||
UNION ALL SELECT 'messages', count(*) FROM messages
|
||||
UNION ALL SELECT 'categories', count(*) FROM categories
|
||||
UNION ALL SELECT 'help_assignments', count(*) FROM help_assignments
|
||||
UNION ALL SELECT 'activities', count(*) FROM activities
|
||||
UNION ALL SELECT 'reports', count(*) FROM reports
|
||||
UNION ALL SELECT 'social_identities', count(*) FROM social_identities
|
||||
UNION ALL SELECT 'tracking_sessions', count(*) FROM tracking_sessions
|
||||
ORDER BY table_name;
|
||||
COMMIT;
|
||||
SQL
|
||||
}
|
||||
|
||||
sample_resources() {
|
||||
while [[ -e "$running_marker" ]]; do
|
||||
observed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
docker stats --no-stream --format '{{json .}}' "${measured_containers[@]}" |
|
||||
jq -c --arg observed_at "$observed_at" '. + {ObservedAt: $observed_at}' \
|
||||
>>"$resource_log"
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
cleanup_sampler() {
|
||||
rm -f "$running_marker"
|
||||
|
||||
if [[ -n "${sampler_pid:-}" ]]; then
|
||||
wait "$sampler_pid" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup_sampler EXIT HUP INT TERM
|
||||
|
||||
{
|
||||
printf 'observed_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf 'k6_image=%s\n' "$K6_IMAGE"
|
||||
printf 'load_project=%s\n' "$LOAD_PROJECT"
|
||||
printf 'web_replicas=%s\n' "$LOAD_WEB_REPLICAS"
|
||||
printf 'http_vus=%s\n' "$LOAD_HTTP_VUS"
|
||||
printf 'websocket_vus=%s\n' "$LOAD_WS_VUS"
|
||||
printf 'duration=%s\n' "$LOAD_DURATION"
|
||||
printf 'websocket_hold_ms=%s\n' "$LOAD_WS_HOLD_MS"
|
||||
printf 'websocket_connect_timeout_ms=%s\n' "$LOAD_WS_CONNECT_TIMEOUT_MS"
|
||||
printf 'http_think_seconds=%s\n' "$LOAD_HTTP_THINK_SECONDS"
|
||||
docker info --format 'docker_cpus={{.NCPU}} docker_memory_bytes={{.MemTotal}} docker_server={{.ServerVersion}}'
|
||||
docker compose version
|
||||
uname -a
|
||||
} >"$output_dir/environment.txt"
|
||||
|
||||
snapshot_database "$output_dir/database-before.txt"
|
||||
sample_resources &
|
||||
sampler_pid=$!
|
||||
|
||||
set +e
|
||||
docker run --rm \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
--network "$network_id" \
|
||||
--volume "$ROOT/load/k6:/scripts:ro" \
|
||||
--volume "$output_dir:/output" \
|
||||
--env "BASE_URL=http://$LOAD_HOST" \
|
||||
--env "HTTP_VUS=$LOAD_HTTP_VUS" \
|
||||
--env "WS_VUS=$LOAD_WS_VUS" \
|
||||
--env "DURATION=$LOAD_DURATION" \
|
||||
--env "WS_HOLD_MS=$LOAD_WS_HOLD_MS" \
|
||||
--env "WS_CONNECT_TIMEOUT_MS=$LOAD_WS_CONNECT_TIMEOUT_MS" \
|
||||
--env "HTTP_THINK_SECONDS=$LOAD_HTTP_THINK_SECONDS" \
|
||||
"$K6_IMAGE" run \
|
||||
--no-usage-report \
|
||||
--summary-mode=full \
|
||||
--summary-export=/output/k6-summary.json \
|
||||
--new-machine-readable-summary \
|
||||
/scripts/public-and-websocket.js 2>&1 |
|
||||
tee "$output_dir/k6.log"
|
||||
k6_status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
cleanup_sampler
|
||||
sampler_pid=
|
||||
trap - EXIT HUP INT TERM
|
||||
|
||||
snapshot_database "$output_dir/database-after.txt"
|
||||
diff -u "$output_dir/database-before.txt" "$output_dir/database-after.txt" \
|
||||
>"$output_dir/database-diff.txt" || true
|
||||
|
||||
COMPOSE_PROJECT_NAME=$LOAD_PROJECT "$ROOT/scripts/verify-realtime-cluster.sh" compose \
|
||||
>"$output_dir/pubsub-probe.txt"
|
||||
curl --fail --silent --show-error \
|
||||
--header "Host: $LOAD_HOST" \
|
||||
"http://localhost:$HTTP_PORT/healthz/ready" \
|
||||
>"$output_dir/readiness-after.json"
|
||||
"${compose[@]}" ps -a >"$output_dir/compose-after.txt"
|
||||
"${compose[@]}" logs --since 10m proxy web worker >"$output_dir/application.log" 2>&1
|
||||
|
||||
if ! jq -e '
|
||||
def metric($name):
|
||||
([.results.metrics[] | select(.name == $name) | .values][0] // {});
|
||||
|
||||
(.results.checks.metrics[] |
|
||||
select(.name == "checks_failed") |
|
||||
.values.matches) == 0 and
|
||||
metric("http_req_failed").matches == 0 and
|
||||
metric("wnh_websocket_opened").count > 0 and
|
||||
metric("wnh_websocket_opened").count ==
|
||||
metric("wnh_websocket_heartbeat_replies").count and
|
||||
(metric("wnh_websocket_errors").count // 0) == 0
|
||||
' "$output_dir/k6-summary.json" >/dev/null; then
|
||||
echo "Functional load checks failed; evidence is in $output_dir." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$k6_status" -ne 0 ]]; then
|
||||
echo "k6 exited with status $k6_status; evidence is in $output_dir." >&2
|
||||
exit "$k6_status"
|
||||
fi
|
||||
|
||||
printf 'Load evidence: %s\n' "$output_dir"
|
||||
31
scripts/load-stack-stop.sh
Executable file
31
scripts/load-stack-stop.sh
Executable file
|
|
@ -0,0 +1,31 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
ENV_FILE="$ROOT/.env.load"
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "Missing $ENV_FILE; no load-profile project was selected." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
: "${LOAD_PROJECT:?LOAD_PROJECT is missing from .env.load}"
|
||||
|
||||
if [ "$LOAD_PROJECT" = who_need_help ]; then
|
||||
echo "Refusing to stop the staging Compose project." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
docker compose \
|
||||
--env-file "$ENV_FILE" \
|
||||
-p "$LOAD_PROJECT" \
|
||||
-f compose.yaml \
|
||||
-f compose.load.yaml \
|
||||
stop
|
||||
|
||||
echo "Stopped the isolated load-profile containers; its database volume remains."
|
||||
63
scripts/load-stack-up.sh
Executable file
63
scripts/load-stack-up.sh
Executable file
|
|
@ -0,0 +1,63 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
ENV_FILE="$ROOT/.env.load"
|
||||
REPLICAS=${1:-}
|
||||
|
||||
"$ROOT/scripts/ensure-local-load-env.sh"
|
||||
|
||||
set -a
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
: "${LOAD_PROJECT:?LOAD_PROJECT is missing from .env.load}"
|
||||
: "${LOAD_HOST:?LOAD_HOST is missing from .env.load}"
|
||||
: "${LOAD_WEB_REPLICAS:?LOAD_WEB_REPLICAS is missing from .env.load}"
|
||||
: "${LOAD_WORKER_REPLICAS:?LOAD_WORKER_REPLICAS is missing from .env.load}"
|
||||
|
||||
if [ "$LOAD_PROJECT" = who_need_help ]; then
|
||||
echo "The load profile must not use the staging Compose project." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$REPLICAS" ]; then
|
||||
LOAD_WEB_REPLICAS=$REPLICAS
|
||||
fi
|
||||
|
||||
case "$LOAD_WEB_REPLICAS:$LOAD_WORKER_REPLICAS" in
|
||||
*[!0-9:]* | 0:* | *:0 | :* | *:)
|
||||
echo "Web and worker replica counts must be positive integers." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
cd "$ROOT"
|
||||
docker compose \
|
||||
--env-file "$ENV_FILE" \
|
||||
-p "$LOAD_PROJECT" \
|
||||
-f compose.yaml \
|
||||
-f compose.load.yaml \
|
||||
up -d --build --wait \
|
||||
--scale "web=$LOAD_WEB_REPLICAS" \
|
||||
--scale "worker=$LOAD_WORKER_REPLICAS"
|
||||
|
||||
COMPOSE_PROJECT_NAME=$LOAD_PROJECT "$ROOT/scripts/verify-realtime-cluster.sh" compose
|
||||
status=$(
|
||||
curl --silent --show-error \
|
||||
--header "Host: $LOAD_HOST" \
|
||||
--output /dev/null \
|
||||
--write-out '%{http_code}' \
|
||||
"http://localhost:$HTTP_PORT/healthz/ready"
|
||||
)
|
||||
|
||||
if [ "$status" != 200 ]; then
|
||||
echo "Load-profile readiness returned HTTP $status instead of 200." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl --fail --silent --show-error \
|
||||
--header "Host: $LOAD_HOST" \
|
||||
"http://localhost:$HTTP_PORT/healthz/ready"
|
||||
printf '\nLoad profile is ready with %s web and %s worker replicas.\n' \
|
||||
"$LOAD_WEB_REPLICAS" "$LOAD_WORKER_REPLICAS"
|
||||
|
|
@ -3,10 +3,11 @@ set -eu
|
|||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
MODE=${1:-compose}
|
||||
COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME:-who_need_help}
|
||||
|
||||
case "$MODE" in
|
||||
compose)
|
||||
TARGET=$(docker compose -p who_need_help ps -q web | head -n 1)
|
||||
TARGET=$(docker compose -p "$COMPOSE_PROJECT_NAME" ps -q web | head -n 1)
|
||||
if [ -z "$TARGET" ]; then
|
||||
echo "No running Compose web replica was found." >&2
|
||||
exit 1
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user