Files
pixa/script/loadtest
T
clawbot 6bed1fda98
check / check (push) Failing after 2s
Check script/loadtest's duration before the build
A duration must be a whole number above zero followed by ms, s, m or
h; anything else prints the usage line and exits 2 before the image
build, as a bad number of clients already did. vegeta takes 0 to mean
no end. The load test's entry moves to the top of Completed Steps in
TODO.md.

Model: opus-5-5
2026-10-04 23:09:20 +00:00

176 lines
5.5 KiB
Bash
Executable File

#!/bin/sh
# script/loadtest: measure pixad's throughput, latency and peak memory.
#
# script/loadtest [duration [clients]] (defaults: 10s and 4)
#
# A benchmark, not a check: script/check does not run it. It needs Docker
# and Go. It builds the image with script/docker and builds vegeta, the
# load tool, from a pinned commit. Each scenario then gets a new pixad
# container and a new origin container (cmd/loadtest-origin, which answers
# every path with the same JPEG), and vegeta sends requests for <duration>
# from <clients> clients at once, each asking for an image resized to
# 400x300 WebP:
#
# hit the same image every time, put in the cache first
# miss a new source image every time
# herd each new source image once per client in a row, so that all
# clients ask for it at the same time
#
# For each, it prints vegeta's report, pixad's peak resident memory and
# how many requests reached the origin. README.md says how to read them.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
# vegeta v12.13.0, 2026-10-04
VEGETA_COMMIT=4b240c3089fa4aa10816542d64a74294d974211f
# pixad refuses upstream hosts with private or local addresses, so the
# containers share a network in 203.0.113.0/24, a range set aside for
# documentation (RFC 5737) that pixad does not refuse and that is never
# routed on the internet.
SUBNET=203.0.113.0/24
usage() {
echo "usage: script/loadtest [duration [clients]]" >&2
exit 2
}
main() {
duration="${1:-10s}"
clients="${2:-4}"
# The duration is a whole number, not zero (vegeta takes 0 to mean no
# end), followed by ms, s, m or h.
case "$duration" in
*ms) number="${duration%ms}" ;;
*s | *m | *h) number="${duration%?}" ;;
*) usage ;;
esac
case "$number" in
"" | *[!0-9]*) usage ;;
esac
[ "$number" -gt 0 ] || usage
case "$clients" in
*[!0-9]* | 0) usage ;;
esac
cd "$ROOT"
run="pixa-loadtest-$$"
tmp="$(mktemp -d)"
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
"$SCRIPT_DIR/docker"
# The image's ID, so a build elsewhere that moves the tag does not
# change what a later scenario starts.
image="$(docker image inspect --format '{{.Id}}' \
"$("$SCRIPT_DIR/projectname")")"
GOBIN="$tmp" go install "github.com/tsenart/vegeta/v12@$VEGETA_COMMIT"
# The origin runs in a container, so it is built for the Docker host.
CGO_ENABLED=0 GOOS=linux \
GOARCH="$(docker version --format '{{.Server.Arch}}')" \
go build -o "$tmp/loadtest-origin" ./cmd/loadtest-origin
docker network create --subnet "$SUBNET" "$run" >/dev/null
start_containers
# Put the image the hit scenario asks for in the cache.
docker exec "$run-pixad" wget -q -O /dev/null \
"http://localhost:8080/v1/image/origin/hit.jpg/400x300.webp"
attack hit hit_targets
stop_containers
start_containers
attack miss miss_targets
stop_containers
start_containers
attack herd herd_targets
stop_containers
}
# start_containers starts a new origin and a new pixad, and waits up to 30
# seconds for pixad's health check to pass.
start_containers() {
docker run -d --name "$run-origin" \
--network "$run" --network-alias origin \
-v "$tmp/loadtest-origin:/usr/local/bin/loadtest-origin:ro" \
--entrypoint /usr/local/bin/loadtest-origin "$image" >/dev/null
docker run -d --name "$run-pixad" \
--network "$run" -p 127.0.0.1::8080 --health-interval=1s \
-e PIXA_SIGNING_KEY="$(head -c 32 /dev/urandom | base64)" \
-e PIXA_ALLOWLIST_HOSTS=origin -e PIXA_ALLOW_HTTP=true \
"$image" >/dev/null
waited=0
until [ "$(docker inspect --format '{{.State.Health.Status}}' \
"$run-pixad")" = healthy ]; do
if [ "$waited" -ge 30 ]; then
echo "loadtest: pixad not healthy after 30 seconds; its log:" >&2
docker logs "$run-pixad" >&2
exit 1
fi
sleep 1
waited=$((waited + 1))
done
pixa="http://$(docker port "$run-pixad" 8080/tcp)"
}
stop_containers() {
docker rm -f "$run-pixad" "$run-origin" >/dev/null
}
# attack <scenario> <targets>: send the requests <targets> prints and
# report on them.
attack() {
echo
echo "== $1: $clients clients for $duration"
"$2" | "$tmp/vegeta" attack -lazy -rate 0 -workers "$clients" \
-max-workers "$clients" -duration "$duration" -max-body 0 |
"$tmp/vegeta" report
# pixad is process 1 in its container: the entrypoint execs it.
echo "pixad peak memory (VmHWM):" \
"$(docker exec "$run-pixad" awk '/^VmHWM:/ { print $2, $3 }' \
/proc/1/status)"
echo "requests to the origin:" \
"$(docker logs "$run-origin" 2>&1 | grep -c ' request ')"
}
# The targets functions print vegeta targets until vegeta stops reading.
hit_targets() {
while :; do
echo "GET $pixa/v1/image/origin/hit.jpg/400x300.webp"
done
}
miss_targets() {
i=0
while :; do
i=$((i + 1))
echo "GET $pixa/v1/image/origin/miss/$i.jpg/400x300.webp"
done
}
herd_targets() {
i=0
while :; do
i=$((i + 1))
n=0
while [ "$n" -lt "$clients" ]; do
n=$((n + 1))
echo "GET $pixa/v1/image/origin/herd/$i.jpg/400x300.webp"
done
done
}
cleanup() {
docker rm -f "$run-pixad" "$run-origin" >/dev/null 2>&1 || :
docker network rm "$run" >/dev/null 2>&1 || :
rm -rf "$tmp"
}
main "$@"