fix: drain replicas and clean isolated load cycles

This commit is contained in:
SimpleTest 2026-07-23 05:11:14 +03:00
parent 4a1af1b658
commit 0e81ee7f36
16 changed files with 378 additions and 22 deletions

View File

@ -51,6 +51,10 @@ DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID
# accepts. Keep loopback locally; set the exact VPN proxy address for staging. # accepts. Keep loopback locally; set the exact VPN proxy address for staging.
TRAEFIK_TRUSTED_IPS=127.0.0.1/32 TRAEFIK_TRUSTED_IPS=127.0.0.1/32
TRAEFIK_RETRY_ATTEMPTS=3 TRAEFIK_RETRY_ATTEMPTS=3
# Keep false for ordinary deployments. The isolated load profile enables the
# unbound port-8080 API only inside its private Compose networks so its rolling
# drill can observe when a drained backend leaves service.
TRAEFIK_API_INSECURE=false
# Docker-provider isolation and names. A second Compose project must use its # Docker-provider isolation and names. A second Compose project must use its
# own project constraint, router/service name, Docker network, and Host rule. # own project constraint, router/service name, Docker network, and Host rule.
TRAEFIK_PROJECT_CONSTRAINT=who_need_help TRAEFIK_PROJECT_CONSTRAINT=who_need_help

View File

@ -21,6 +21,7 @@ PHX_SCHEME=https
PHX_URL_PORT=443 PHX_URL_PORT=443
TRAEFIK_TRUSTED_IPS=127.0.0.1/32 TRAEFIK_TRUSTED_IPS=127.0.0.1/32
TRAEFIK_RETRY_ATTEMPTS=3 TRAEFIK_RETRY_ATTEMPTS=3
TRAEFIK_API_INSECURE=true
TRAEFIK_PROJECT_CONSTRAINT=who_need_help_load TRAEFIK_PROJECT_CONSTRAINT=who_need_help_load
TRAEFIK_APP_NAME=who-need-help-load TRAEFIK_APP_NAME=who-need-help-load
TRAEFIK_DOCKER_NETWORK=who_need_help_load_ingress TRAEFIK_DOCKER_NETWORK=who_need_help_load_ingress

View File

@ -18,10 +18,10 @@ services:
ingress: {} ingress: {}
migrate: migrate:
image: who-need-help:load image: ${APP_IMAGE:?Set APP_IMAGE in the isolated load environment}
web: web:
image: who-need-help:load image: ${APP_IMAGE:?Set APP_IMAGE in the isolated load environment}
labels: labels:
- traefik.http.routers.${TRAEFIK_APP_NAME}.middlewares=${TRAEFIK_APP_NAME}-forwarded,${TRAEFIK_APP_NAME}-retry - traefik.http.routers.${TRAEFIK_APP_NAME}.middlewares=${TRAEFIK_APP_NAME}-forwarded,${TRAEFIK_APP_NAME}-retry
- traefik.http.middlewares.${TRAEFIK_APP_NAME}-forwarded.headers.customrequestheaders.X-Forwarded-Proto=https - traefik.http.middlewares.${TRAEFIK_APP_NAME}-forwarded.headers.customrequestheaders.X-Forwarded-Proto=https
@ -32,4 +32,4 @@ services:
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.tls=true - traefik.http.routers.${TRAEFIK_APP_NAME}-tls.tls=true
worker: worker:
image: who-need-help:load image: ${APP_IMAGE:?Set APP_IMAGE in the isolated load environment}

View File

@ -94,7 +94,8 @@ services:
context: . context: .
dockerfile: Dockerfile.traefik dockerfile: Dockerfile.traefik
command: command:
- --api.dashboard=false - --api.dashboard=${TRAEFIK_API_INSECURE:-false}
- --api.insecure=${TRAEFIK_API_INSECURE:-false}
- --providers.docker=true - --providers.docker=true
- --providers.docker.endpoint=tcp://docker-api-proxy:2375 - --providers.docker.endpoint=tcp://docker-api-proxy:2375
- --providers.docker.exposedbydefault=false - --providers.docker.exposedbydefault=false
@ -241,7 +242,11 @@ services:
- traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.service=${TRAEFIK_APP_NAME:-who-need-help} - traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.service=${TRAEFIK_APP_NAME:-who-need-help}
- traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.middlewares=${TRAEFIK_APP_NAME:-who-need-help}-retry - traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.middlewares=${TRAEFIK_APP_NAME:-who-need-help}-retry
- traefik.http.middlewares.${TRAEFIK_APP_NAME:-who-need-help}-retry.retry.attempts=${TRAEFIK_RETRY_ATTEMPTS:-3} - traefik.http.middlewares.${TRAEFIK_APP_NAME:-who-need-help}-retry.retry.attempts=${TRAEFIK_RETRY_ATTEMPTS:-3}
- traefik.http.middlewares.${TRAEFIK_APP_NAME:-who-need-help}-retry.retry.status=500-599
- traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.server.port=4000 - traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.server.port=4000
- traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.healthcheck.path=/healthz/ready
- traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.healthcheck.interval=10s
- traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.healthcheck.timeout=3s
healthcheck: healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"] test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"]
interval: 10s interval: 10s

View File

@ -521,10 +521,15 @@ The isolated load project can exercise process crashes, sequential container
replacement, and a real Oban retry without touching the normal Compose project: replacement, and a real Oban retry without touching the normal Compose project:
```bash ```bash
./scripts/load-stack-up.sh ./scripts/load-cycle.sh local-resilience resilience
./scripts/load-resilience-run.sh local-resilience
``` ```
The lifecycle wrapper assigns unique Compose and image names and removes that
exact run's containers, networks, named volumes, temporary environment, and
image tags on success, failure, or interruption. Use `load-stack-up.sh` plus
`load-resilience-run.sh` only when the isolated stack must remain available for
manual inspection.
The resilience script refuses `LOAD_PROJECT=who_need_help` and verifies the The resilience script refuses `LOAD_PROJECT=who_need_help` and verifies the
Compose project/service labels of every container before stopping it. It: Compose project/service labels of every container before stopping it. It:

View File

@ -84,6 +84,21 @@ counts, database state, and experiment inputs.
## Create the isolated profile ## Create the isolated profile
For an ordinary one-run measurement, prefer the lifecycle wrapper. It creates
unique Compose and image names, then removes only that run's containers,
networks, named volumes, temporary environment, and image tags on success,
failure, or interruption:
```sh
./scripts/load-cycle.sh local-load load
./scripts/load-cycle.sh local-resilience resilience
./scripts/load-cycle.sh local-complete both
```
Evidence remains under `output/performance/` and `output/resilience/`. The
commands below are the manual diagnostic workflow; unlike the lifecycle
wrapper, it deliberately keeps the isolated database volume between commands.
```sh ```sh
./scripts/ensure-local-load-env.sh ./scripts/ensure-local-load-env.sh
./scripts/load-stack-up.sh ./scripts/load-stack-up.sh

View File

@ -9,6 +9,8 @@ defmodule WhoNeedHelp.Application do
@impl true @impl true
def start(_type, _args) do def start(_type, _args) do
WhoNeedHelpWeb.DrainState.reset()
if @e2e_routes do if @e2e_routes do
boot_id = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false) boot_id = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false)
:persistent_term.put({WhoNeedHelp, :e2e_boot_id}, boot_id) :persistent_term.put({WhoNeedHelp, :e2e_boot_id}, boot_id)

View File

@ -4,9 +4,13 @@ defmodule WhoNeedHelpWeb.HealthController do
def live(conn, _params), do: json(conn, %{status: "ok"}) def live(conn, _params), do: json(conn, %{status: "ok"})
def ready(conn, _params) do def ready(conn, _params) do
case Ecto.Adapters.SQL.query(WhoNeedHelp.Repo, "SELECT 1", []) do if WhoNeedHelpWeb.DrainState.draining?() do
{:ok, _} -> json(conn, %{status: "ready"}) conn |> put_status(:service_unavailable) |> json(%{status: "draining"})
{:error, _} -> conn |> put_status(:service_unavailable) |> json(%{status: "not_ready"}) else
case Ecto.Adapters.SQL.query(WhoNeedHelp.Repo, "SELECT 1", []) do
{:ok, _} -> json(conn, %{status: "ready"})
{:error, _} -> conn |> put_status(:service_unavailable) |> json(%{status: "not_ready"})
end
end end
end end
end end

View File

@ -0,0 +1,26 @@
defmodule WhoNeedHelpWeb.DrainState do
@moduledoc """
Tracks whether the current web node is draining before a planned shutdown.
The flag is node-local by design. Operators can mark one replica as draining
through a release RPC; readiness then fails on that replica while liveness
remains available.
"""
@key {__MODULE__, :draining}
@spec reset() :: :ok
def reset do
:persistent_term.put(@key, false)
:ok
end
@spec begin_drain() :: :ok
def begin_drain do
:persistent_term.put(@key, true)
:ok
end
@spec draining?() :: boolean()
def draining?, do: :persistent_term.get(@key, false)
end

View File

@ -59,6 +59,7 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
needs_resilience_interval=true needs_resilience_interval=true
needs_resilience_request_timeout=true needs_resilience_request_timeout=true
needs_traefik_retry_attempts=true needs_traefik_retry_attempts=true
needs_traefik_api_insecure=true
needs_observability_prometheus_port=true needs_observability_prometheus_port=true
needs_observability_alertmanager_port=true needs_observability_alertmanager_port=true
needs_observability_grafana_port=true needs_observability_grafana_port=true
@ -90,6 +91,7 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
grep -q '^LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS=' "$ENV_FILE" && grep -q '^LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS=' "$ENV_FILE" &&
needs_resilience_request_timeout=false needs_resilience_request_timeout=false
grep -q '^TRAEFIK_RETRY_ATTEMPTS=' "$ENV_FILE" && needs_traefik_retry_attempts=false grep -q '^TRAEFIK_RETRY_ATTEMPTS=' "$ENV_FILE" && needs_traefik_retry_attempts=false
grep -q '^TRAEFIK_API_INSECURE=' "$ENV_FILE" && needs_traefik_api_insecure=false
grep -q '^OBSERVABILITY_PROMETHEUS_PORT=' "$ENV_FILE" && grep -q '^OBSERVABILITY_PROMETHEUS_PORT=' "$ENV_FILE" &&
needs_observability_prometheus_port=false needs_observability_prometheus_port=false
grep -q '^OBSERVABILITY_ALERTMANAGER_PORT=' "$ENV_FILE" && grep -q '^OBSERVABILITY_ALERTMANAGER_PORT=' "$ENV_FILE" &&
@ -135,6 +137,7 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
[ "$needs_resilience_interval" = false ] && [ "$needs_resilience_interval" = false ] &&
[ "$needs_resilience_request_timeout" = false ] && [ "$needs_resilience_request_timeout" = false ] &&
[ "$needs_traefik_retry_attempts" = false ] && [ "$needs_traefik_retry_attempts" = false ] &&
[ "$needs_traefik_api_insecure" = false ] &&
[ "$needs_observability_prometheus_port" = false ] && [ "$needs_observability_prometheus_port" = false ] &&
[ "$needs_observability_alertmanager_port" = false ] && [ "$needs_observability_alertmanager_port" = false ] &&
[ "$needs_observability_grafana_port" = false ] && [ "$needs_observability_grafana_port" = false ] &&
@ -235,6 +238,10 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
printf 'TRAEFIK_RETRY_ATTEMPTS=3\n' printf 'TRAEFIK_RETRY_ATTEMPTS=3\n'
fi fi
if [ "$needs_traefik_api_insecure" = true ]; then
printf 'TRAEFIK_API_INSECURE=true\n'
fi
if [ "$needs_observability_prometheus_port" = true ] || if [ "$needs_observability_prometheus_port" = true ] ||
[ "$needs_observability_alertmanager_port" = true ] || [ "$needs_observability_alertmanager_port" = true ] ||
[ "$needs_observability_grafana_port" = true ] || [ "$needs_observability_grafana_port" = true ] ||
@ -338,7 +345,8 @@ GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS
needs_fixture_password needs_oban_maintenance_concurrency \ needs_fixture_password needs_oban_maintenance_concurrency \
needs_oban_push_concurrency needs_resilience_timeout \ needs_oban_push_concurrency needs_resilience_timeout \
needs_resilience_interval needs_resilience_request_timeout \ needs_resilience_interval needs_resilience_request_timeout \
needs_traefik_retry_attempts needs_observability_prometheus_port \ needs_traefik_retry_attempts needs_traefik_api_insecure \
needs_observability_prometheus_port \
needs_observability_alertmanager_port needs_observability_grafana_port \ needs_observability_alertmanager_port needs_observability_grafana_port \
needs_observability_scrape_interval needs_observability_evaluation_interval \ needs_observability_scrape_interval needs_observability_evaluation_interval \
needs_observability_timeout needs_observability_grafana_user \ needs_observability_timeout needs_observability_grafana_user \

153
scripts/load-cycle.sh Executable file
View File

@ -0,0 +1,153 @@
#!/usr/bin/env bash
set -euo pipefail
umask 077
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
BASE_ENV=${WNH_LOAD_BASE_ENV_FILE:-"$ROOT/.env.load"}
LABEL=${1:-"cycle-$(date -u +%Y%m%dT%H%M%SZ)"}
MODE=${2:-load}
if [[ ! "$LABEL" =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "Run label may contain only letters, numbers, dot, underscore, and dash." >&2
exit 1
fi
case "$MODE" in
load | resilience | both) ;;
*)
echo "Mode must be load, resilience, or both." >&2
exit 1
;;
esac
WNH_LOAD_ENV_FILE="$BASE_ENV" "$ROOT/scripts/ensure-local-load-env.sh"
if [[ ! -f "$BASE_ENV" ]]; then
echo "Missing base load environment: $BASE_ENV." >&2
exit 1
fi
run_id="$(date -u +%Y%m%d%H%M%S)-$$"
safe_id=$(printf '%s' "$run_id" | tr -cd 'A-Za-z0-9')
project="who_need_help_load_$safe_id"
temporary_env=$(mktemp "${TMPDIR:-/tmp}/wnh-load-cycle.XXXXXX.env")
export WNH_LOAD_ENV_FILE=$temporary_env
export WNH_LOAD_TOOLS_IMAGE="who-need-help:load-tools-$safe_id"
cp "$BASE_ENV" "$temporary_env"
chmod 600 "$temporary_env"
set_env_value() {
local name=$1
local value=$2
local replacement="$name=$value"
if grep -q "^${name}=" "$temporary_env"; then
sed -i "s|^${name}=.*|$replacement|" "$temporary_env"
else
printf '%s\n' "$replacement" >>"$temporary_env"
fi
}
set_env_value LOAD_PROJECT "$project"
set_env_value COMPOSE_PROJECT_NAME "$project"
set_env_value TRAEFIK_PROJECT_CONSTRAINT "$project"
set_env_value TRAEFIK_DOCKER_NETWORK "${project}_ingress"
set_env_value APP_IMAGE "who-need-help:load-$safe_id"
set_env_value SOCKET_PROXY_IMAGE "who-need-help:socket-proxy-load-$safe_id"
set_env_value POSTGIS_IMAGE "who-need-help:postgis-load-$safe_id"
set -a
# shellcheck source=/dev/null
. "$temporary_env"
set +a
for name in APP_IMAGE SOCKET_PROXY_IMAGE POSTGIS_IMAGE; do
if [[ -z "${!name:-}" ]]; then
echo "$name is missing from the generated cycle environment." >&2
exit 1
fi
done
if [[ -n "$(docker ps -aq --filter "label=com.docker.compose.project=$project")" ]] ||
[[ -n "$(docker network ls -q --filter "label=com.docker.compose.project=$project")" ]] ||
[[ -n "$(docker volume ls -q --filter "label=com.docker.compose.project=$project")" ]]; then
echo "Generated load-cycle project already owns Docker resources: $project." >&2
exit 1
fi
started=0
cleanup() {
local status=$?
local cleanup_status=0
trap - EXIT HUP INT TERM
if [[ "$started" -eq 1 ]] ||
[[ -n "$(docker ps -aq --filter "label=com.docker.compose.project=$project")" ]] ||
[[ -n "$(docker network ls -q --filter "label=com.docker.compose.project=$project")" ]]; then
"$ROOT/scripts/load-stack-stop.sh" >/dev/null 2>&1 || cleanup_status=1
fi
while IFS= read -r volume; do
[[ -n "$volume" ]] || continue
observed_project=$(docker volume inspect --format \
'{{index .Labels "com.docker.compose.project"}}' "$volume")
references=$(docker ps -aq --filter "volume=$volume")
if [[ "$observed_project" != "$project" ]] || [[ -n "$references" ]]; then
echo "Refusing unexpected or referenced load-cycle volume: $volume" >&2
cleanup_status=1
continue
fi
docker volume rm "$volume" >/dev/null || cleanup_status=1
done < <(docker volume ls -q --filter "label=com.docker.compose.project=$project")
for image in "$APP_IMAGE" "$SOCKET_PROXY_IMAGE" "$POSTGIS_IMAGE" \
"$WNH_LOAD_TOOLS_IMAGE"; do
if image_id=$(docker image inspect --format '{{.Id}}' "$image" 2>/dev/null); then
if [[ -n "$(docker ps -aq --filter "ancestor=$image_id")" ]]; then
echo "Refusing referenced load-cycle image: $image" >&2
cleanup_status=1
else
docker image rm "$image" >/dev/null || cleanup_status=1
fi
fi
done
rm -f "$temporary_env"
if [[ -n "$(docker ps -aq --filter "label=com.docker.compose.project=$project")" ]] ||
[[ -n "$(docker network ls -q --filter "label=com.docker.compose.project=$project")" ]] ||
[[ -n "$(docker volume ls -q --filter "label=com.docker.compose.project=$project")" ]]; then
echo "Load-cycle cleanup left project resources behind: $project." >&2
cleanup_status=1
fi
if [[ "$status" -eq 0 && "$cleanup_status" -ne 0 ]]; then
status=$cleanup_status
fi
exit "$status"
}
trap cleanup EXIT HUP INT TERM
"$ROOT/scripts/load-stack-up.sh"
started=1
case "$MODE" in
load)
"$ROOT/scripts/load-run.sh" "$LABEL"
;;
resilience)
"$ROOT/scripts/load-resilience-run.sh" "$LABEL"
;;
both)
"$ROOT/scripts/load-run.sh" "$LABEL-load"
"$ROOT/scripts/load-resilience-run.sh" "$LABEL-resilience"
;;
esac
echo "Load cycle completed; its project resources will now be removed: $project"

View File

@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT/.env.load" ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"}
LABEL=${1:-"resilience-$(date -u +%Y%m%dT%H%M%SZ)"} LABEL=${1:-"resilience-$(date -u +%Y%m%dT%H%M%SZ)"}
if [[ ! -f "$ENV_FILE" ]]; then if [[ ! -f "$ENV_FILE" ]]; then
@ -19,13 +19,18 @@ for name in LOAD_PROJECT LOAD_HOST LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS \
LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS \ LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS \
LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS \ LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS \
LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS TRAEFIK_RETRY_ATTEMPTS \ LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS TRAEFIK_RETRY_ATTEMPTS \
HTTP_PORT POSTGRES_DB METRICS_TOKEN; do TRAEFIK_API_INSECURE HTTP_PORT POSTGRES_DB METRICS_TOKEN; do
if [[ -z "${!name:-}" ]]; then if [[ -z "${!name:-}" ]]; then
echo "$name is missing from .env.load" >&2 echo "$name is missing from .env.load" >&2
exit 1 exit 1
fi fi
done done
if [[ "$TRAEFIK_API_INSECURE" != "true" ]]; then
echo "The isolated resilience profile requires TRAEFIK_API_INSECURE=true." >&2
exit 1
fi
if [[ "$LOAD_PROJECT" == "who_need_help" ]]; then if [[ "$LOAD_PROJECT" == "who_need_help" ]]; then
echo "The resilience profile must not use the staging Compose project." >&2 echo "The resilience profile must not use the staging Compose project." >&2
exit 1 exit 1
@ -291,11 +296,109 @@ delete_retry_job() {
restore_topology() { restore_topology() {
"${compose[@]}" up -d --no-deps \ "${compose[@]}" up -d --no-deps \
--force-recreate \
--scale "web=$LOAD_WEB_REPLICAS" \ --scale "web=$LOAD_WEB_REPLICAS" \
--scale "worker=$LOAD_WORKER_REPLICAS" \ --scale "worker=$LOAD_WORKER_REPLICAS" \
web worker >/dev/null 2>&1 || true web worker >/dev/null 2>&1 || true
} }
traefik_service_json() {
local proxy_id edge_network proxy_ip
proxy_id=$(service_ids proxy | head -n 1)
assert_scope "$proxy_id" proxy
edge_network="${LOAD_PROJECT}_edge"
proxy_ip=$(
docker inspect --format \
"{{(index .NetworkSettings.Networks \"$edge_network\").IPAddress}}" \
"$proxy_id"
)
if [[ -z "$proxy_ip" ]]; then
echo "The isolated Traefik proxy has no address on $edge_network." >&2
return 1
fi
curl --silent --show-error --fail \
--max-time "$LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS" \
"http://$proxy_ip:8080/api/http/services/${TRAEFIK_APP_NAME}@docker"
}
drain_web_from_traefik() {
local container_id=$1
local destination=$2
local ingress_network observed_project observed_role ingress_ip server_url
local deadline status
ingress_network=$(
docker inspect --format \
'{{index .Config.Labels "traefik.docker.network"}}' \
"$container_id"
)
if [[ -z "$ingress_network" ]]; then
echo "Container $container_id has no declared Traefik ingress network." >&2
return 1
fi
if ! docker inspect --format '{{json .NetworkSettings.Networks}}' \
"$container_id" | jq -e --arg network "$ingress_network" \
'has($network)' >/dev/null; then
echo "Container $container_id is not attached to $ingress_network." >&2
return 1
fi
observed_project=$(
docker network inspect --format \
'{{index .Labels "com.docker.compose.project"}}' \
"$ingress_network"
)
observed_role=$(
docker network inspect --format \
'{{index .Labels "com.docker.compose.network"}}' \
"$ingress_network"
)
if [[ "$observed_project" != "$LOAD_PROJECT" ||
"$observed_role" != "ingress" ]]; then
echo "Ingress network scope mismatch for $ingress_network." >&2
return 1
fi
ingress_ip=$(
docker inspect --format \
"{{(index .NetworkSettings.Networks \"$ingress_network\").IPAddress}}" \
"$container_id"
)
server_url="http://$ingress_ip:4000"
docker exec "$container_id" /app/bin/who_need_help rpc \
'IO.inspect(WhoNeedHelpWeb.DrainState.begin_drain())' >"$destination"
deadline=$((SECONDS + LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS))
while ((SECONDS < deadline)); do
status=$(
traefik_service_json |
jq -r --arg server_url "$server_url" \
'.serverStatus[$server_url] // "MISSING"'
)
printf 'server=%s status=%s observed_at=%s\n' \
"$server_url" "$status" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
>>"$destination"
if [[ "$status" == "DOWN" ]]; then
return 0
fi
sleep 1
done
echo "Timed out waiting for Traefik to drain $server_url." >&2
return 1
}
cleanup() { cleanup() {
local status=$? local status=$?
local cleanup_status=0 local cleanup_status=0
@ -376,8 +479,19 @@ docker inspect "$worker_target" |
mapfile -t original_web_ids < <(service_ids web) mapfile -t original_web_ids < <(service_ids web)
traefik_service_json |
jq -e --argjson expected "$LOAD_WEB_REPLICAS" '
.status == "enabled" and
(.loadBalancer.healthCheck.path == "/healthz/ready") and
([.serverStatus[]] | length) == $expected and
([.serverStatus[]] | all(. == "UP"))
' >"$output_dir/traefik-before-replacements.json"
for container_id in "${original_web_ids[@]}"; do for container_id in "${original_web_ids[@]}"; do
assert_scope "$container_id" web assert_scope "$container_id" web
drain_web_from_traefik \
"$container_id" \
"$output_dir/web-drain-${container_id:0:12}.txt"
{ {
docker stop --timeout 30 "$container_id" docker stop --timeout 30 "$container_id"
docker rm "$container_id" docker rm "$container_id"

View File

@ -2,7 +2,8 @@
set -euo pipefail set -euo pipefail
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT/.env.load" ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"}
LOAD_TOOLS_IMAGE=${WNH_LOAD_TOOLS_IMAGE:-who-need-help:load-tools}
K6_IMAGE="grafana/k6@sha256:65c920dc067d5e2e00befbf982af6ad6ad0117034e8b1c65817c7975c52d4669" K6_IMAGE="grafana/k6@sha256:65c920dc067d5e2e00befbf982af6ad6ad0117034e8b1c65817c7975c52d4669"
PYTHON_IMAGE="python:3.14.6-alpine3.23@sha256:b165067c5afc37fa5608a3c05609cc3d51aafd808a30fbfd822ee594fef55ad4" PYTHON_IMAGE="python:3.14.6-alpine3.23@sha256:b165067c5afc37fa5608a3c05609cc3d51aafd808a30fbfd822ee594fef55ad4"
LABEL=${1:-"run-$(date -u +%Y%m%dT%H%M%SZ)"} LABEL=${1:-"run-$(date -u +%Y%m%dT%H%M%SZ)"}
@ -106,7 +107,7 @@ if [[ -z "$internal_network_id" ]]; then
exit 1 exit 1
fi fi
if ! docker image inspect who-need-help:load-tools >/dev/null 2>&1; then if ! docker image inspect "$LOAD_TOOLS_IMAGE" >/dev/null 2>&1; then
echo "The isolated load-tools image is missing. Run scripts/load-stack-up.sh first." >&2 echo "The isolated load-tools image is missing. Run scripts/load-stack-up.sh first." >&2
exit 1 exit 1
fi fi
@ -649,7 +650,7 @@ run_fixture_tool() {
--env "WNH_LOAD_FIXTURE_COUNT=$fixture_count" \ --env "WNH_LOAD_FIXTURE_COUNT=$fixture_count" \
--env "WNH_LOAD_FIXTURE_PATH=/output/fixtures.json" \ --env "WNH_LOAD_FIXTURE_PATH=/output/fixtures.json" \
--volume "$output_dir:/output" \ --volume "$output_dir:/output" \
who-need-help:load-tools \ "$LOAD_TOOLS_IMAGE" \
mix wnh.load_fixtures "$action" mix wnh.load_fixtures "$action"
} }

View File

@ -2,7 +2,8 @@
set -eu set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT/.env.load" ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"}
LOAD_TOOLS_IMAGE=${WNH_LOAD_TOOLS_IMAGE:-who-need-help:load-tools}
if [ ! -f "$ENV_FILE" ]; then if [ ! -f "$ENV_FILE" ]; then
echo "Missing $ENV_FILE; no load-profile project was selected." >&2 echo "Missing $ENV_FILE; no load-profile project was selected." >&2
@ -46,9 +47,9 @@ for network_id in $(docker network ls -q \
docker network rm "$network_id" >/dev/null docker network rm "$network_id" >/dev/null
done done
if image_id=$(docker image inspect --format '{{.Id}}' who-need-help:load-tools 2>/dev/null); then if image_id=$(docker image inspect --format '{{.Id}}' "$LOAD_TOOLS_IMAGE" 2>/dev/null); then
if [ -z "$(docker ps -aq --filter "ancestor=$image_id")" ]; then if [ -z "$(docker ps -aq --filter "ancestor=$image_id")" ]; then
docker image rm who-need-help:load-tools >/dev/null docker image rm "$LOAD_TOOLS_IMAGE" >/dev/null
fi fi
fi fi

View File

@ -2,7 +2,8 @@
set -eu set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT/.env.load" ENV_FILE=${WNH_LOAD_ENV_FILE:-"$ROOT/.env.load"}
LOAD_TOOLS_IMAGE=${WNH_LOAD_TOOLS_IMAGE:-who-need-help:load-tools}
REPLICAS=${1:-} REPLICAS=${1:-}
"$ROOT/scripts/ensure-local-load-env.sh" "$ROOT/scripts/ensure-local-load-env.sh"
@ -67,9 +68,9 @@ cleanup_failed_start() {
sleep 1 sleep 1
done done
if image_id=$(docker image inspect --format '{{.Id}}' who-need-help:load-tools 2>/dev/null); then if image_id=$(docker image inspect --format '{{.Id}}' "$LOAD_TOOLS_IMAGE" 2>/dev/null); then
if [ -z "$(docker ps -aq --filter "ancestor=$image_id")" ]; then if [ -z "$(docker ps -aq --filter "ancestor=$image_id")" ]; then
docker image rm who-need-help:load-tools >/dev/null 2>&1 || true docker image rm "$LOAD_TOOLS_IMAGE" >/dev/null 2>&1 || true
fi fi
fi fi
@ -85,7 +86,7 @@ cleanup_failed_start() {
trap cleanup_failed_start EXIT HUP INT TERM trap cleanup_failed_start EXIT HUP INT TERM
docker build --target load_tools --tag who-need-help:load-tools . docker build --target load_tools --tag "$LOAD_TOOLS_IMAGE" .
compose up -d --build --wait \ compose up -d --build --wait \
--scale "web=$LOAD_WEB_REPLICAS" \ --scale "web=$LOAD_WEB_REPLICAS" \

View File

@ -124,6 +124,22 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
|> json_response(200) |> json_response(200)
end end
test "a draining web node fails readiness but remains live", %{conn: conn} do
on_exit(&WhoNeedHelpWeb.DrainState.reset/0)
assert :ok = WhoNeedHelpWeb.DrainState.begin_drain()
assert %{"status" => "draining"} =
conn
|> get(~p"/healthz/ready")
|> json_response(503)
assert %{"status" => "ok"} =
conn
|> recycle()
|> get(~p"/healthz/live")
|> json_response(200)
end
test "GET /safety selects Ukrainian locale and keeps it in the session", %{conn: conn} do test "GET /safety selects Ukrainian locale and keeps it in the session", %{conn: conn} do
conn = get(conn, ~p"/safety?locale=uk") conn = get(conn, ~p"/safety?locale=uk")
assert html_response(conn, 200) =~ "Правила безпеки" assert html_response(conn, 200) =~ "Правила безпеки"