Add script/loadtest to measure throughput, latency and memory (closes #81)
check / check (push) Failing after 2s

script/loadtest [duration [clients]] (make loadtest, defaults 10s and 4)
measures pixad in three scenarios: hit (one cached image), miss (a new
source image every request) and herd (each new source image asked for
by all clients at once). For each it prints vegeta's report (requests
per second, p50/p95/p99 latency, status codes), pixad's peak resident
memory and how many requests reached the origin. It is a benchmark, not
run by script/check. The origin it uses, internal/loadtestorigin behind
cmd/loadtest-origin, answers every path with one generated JPEG. Bad
arguments are refused before the build. README.md says how to run and
read it and keeps 1-5k r/s as a target not yet measured at scale.

Model: opus-5-5
This commit was merged in pull request #209.
This commit is contained in:
2026-10-05 02:58:41 +02:00
parent c434581a54
commit 55cf7f4fac
7 changed files with 406 additions and 6 deletions
+177
View File
@@ -0,0 +1,177 @@
#!/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
# The number of clients is a whole number that does not start with 0,
# which also refuses zero: vegeta reads a leading 0 as octal.
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 "$@"