README
¶
stressy
A CPU stress tool. It loads as many CPUs as you ask it for with bcrypt hashing and reports the rate, so the same run on two nodes is a comparison.
stress-ng has 300 stressors; stressy has one and deploys anywhere — a static
binary of about two megabytes for eight OS/architecture targets, and a
FROM scratch image with no base layer, no package manager, no shell and a
non-root UID.
Install
go install github.com/felipeneuwald/stressy@latest
docker pull ghcr.io/felipeneuwald/stressy:latest
Or a prebuilt archive from the
releases — tar.gz per
unix target, zip for windows, each holding the binary, the licence and this
README:
# It extracts flat, so naming the binary keeps its LICENSE and README out of the way
tar -xzf stressy_Linux_x86_64.tar.gz stressy
./stressy -t 5s
Usage
# One worker until interrupted
stressy
# Four workers for five minutes
stressy -w 4 -t 5m
# Any Go duration
stressy -t 1h30m
# A progress line every 30 seconds
stressy -t 30m -r 30s
Every setting is a flag, and none of them is inferred. -w defaults to 1, so
a bare stressy loads one CPU on a laptop, in a container and in a pod alike;
to load the whole machine, say so — stressy -w $(nproc) on Linux and FreeBSD,
stressy -w $(sysctl -n hw.ncpu) on macOS, which has no nproc, and
stressy -w $env:NUMBER_OF_PROCESSORS in PowerShell on Windows. stressy reads
no environment variable, no config file and no positional argument, so a command
line is the whole of what a run was given.
Output
A run says what it is about to do, why it stopped, and what it did:
$ stressy -w 4 -t 60s
Starting CPU stress test with 4 workers for 1m0s
Timer expired, shutting down; waiting for every worker to finish the hash it is on...
Computed 1324 hashes in 1m0.101s (22.0 hashes/s, 4 workers)
A run with no -t says how to stop it, and still reports what it managed:
$ stressy -w 4
Starting CPU stress test with 4 workers indefinitely
Press Ctrl+C or send SIGTERM to stop. Use --help for additional information
^C
Received SIGINT, shutting down; waiting for every worker to finish the hash it is on...
Computed 412 hashes in 18.734s (22.0 hashes/s, 4 workers)
That wait is the gap between the last two lines, and it is worth knowing how
long it can get. A run does not stop until every worker has finished the bcrypt
hash it is inside: one hash while the workers fit in the CPUs on offer, and
roughly --workers divided by that many where they do not, because from there
the hashes in flight finish in series. Measured on 18 cores, -w 18 stopped
about 0.2s after the signal and -w 2000 took about twenty seconds. Once the
shutdown line has printed, a second signal is not caught — it ends the process
where it stands — so a drain you are not prepared to wait out is one more
Ctrl-C, at the cost of the summary line. That line is the gate, and the
goroutine printing it is one the workers are starving: on the same 18 cores it
followed the signal by 0.05s at -w 18 and by 2.6s at -w 1440. A signal sent
before it is absorbed.
Because bcrypt at a fixed cost is constant work per hash, that rate is a crude cross-node benchmark: a node hashing 30% slower is a finding.
That cost is 12, and it is fixed for the whole of 1.x. It is the unit every
number above is quoted in, so a figure recorded today and a figure recorded a
year from now are the same measurement; moving it would halve or double every
published number, which makes it a major version bump and nothing less. It is
not bcrypt.MaxCost, which is about 26 hours a hash: no harder on a core, and
long enough that a worker would never notice the run had ended.
Between those lines a run says nothing, so -r, --report fills the gap:
$ stressy -w 4 -t 2m --report 1m
Starting CPU stress test with 4 workers for 2m0s
1m0.001s elapsed, 1320 hashes, 22.0 hashes/s
2m0.001s elapsed, 2636 hashes, 22.0 hashes/s
Timer expired, shutting down; waiting for every worker to finish the hash it is on...
Computed 2640 hashes in 2m0.093s (22.0 hashes/s, 4 workers)
The interval has to be 1s or longer, and no longer than --timeout where
there is one: below a second a run spends itself formatting rather than hashing,
and past the timeout the ticker never fires, which is what -r 1s mistyped as
-r 1m looks like — three lines, exit 0 and nothing to correct it by. Both are
rejected before any worker starts. A run with no -t outlives every interval,
so it takes any: stressy -r 5m reports until you stop it.
The output is the interface
There is no --json. The lines above are what a script reads, and their wording
is stable for 1.x: rewording one is a breaking change and takes a major version
bump. Two of them carry a rate, so a script after the figure for the whole run
matches the summary — the line that starts with Computed — rather than
hashes/s, which every progress line carries too.
Containers
# Bounded run: 30 seconds, then the container exits on its own
docker run --rm ghcr.io/felipeneuwald/stressy:latest -t 30s
# Two CPUs' worth of load for five minutes
docker run --rm --cpus 2 ghcr.io/felipeneuwald/stressy:latest -w 2 -t 5m
Both are bounded, deliberately: with no timeout, docker run -d leaves a
container hashing until somebody runs docker stop.
The worker count is -w and nothing else — no CPU quota is read, so --cpus 2
without a -w 2 loads one CPU and pays for two. Match -w to the limit: above
it the extra workers buy throttling rather than load, below it the limit is
partly idle.
In Kubernetes a Job is the shape this fits: one pod, run to exit 0, recorded as
finished — which makes -t and the exit codes load-bearing, since
a pod evicted, preempted or kubectl deleted part-way through exits 143.
apiVersion: batch/v1
kind: Job
metadata:
name: stressy
spec:
# A run that failed should not be retried indefinitely at full CPU.
backoffLimit: 0
# Clear the finished Job an hour after it completes.
ttlSecondsAfterFinished: 3600
template:
spec:
restartPolicy: Never
containers:
- name: stressy
image: ghcr.io/felipeneuwald/stressy:latest
# -w matches the cpu limit below; nothing derives one from the other.
args: ["-w", "2", "-t", "60s"]
resources:
limits:
cpu: "2"
memory: 64Mi
requests:
cpu: "2"
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
securityContext:
runAsNonRoot: true
kubectl apply -f stressy-job.yaml
kubectl logs -l batch.kubernetes.io/job-name=stressy
Those four securityContext fields are what the restricted Pod Security
Standard requires, and the whole of it: runAsUser is not among them, because
the image already runs as UID/GID 65532. It is FROM scratch, so there is no
shell to kubectl exec in with and stdout is the only view you have of a run —
which is what --report is for, and why the shutdown line names the signal: an
evicted pod logs Received SIGTERM, which is the 143 it goes on to exit with.
:latest only ever points at a full release.
That 143 has a deadline on it, and it is the wait described above.
A pod's default terminationGracePeriodSeconds is 30 and docker stop's
default grace is 10, against a wait that grows with --workers: a -w far
above the CPU limit can outlast either, and the container is then SIGKILLed
part-way through it — 137, no shutdown line and no summary, and none of the
143 this section is built on. Matching -w to the limit, as the manifest
does, keeps the wait to about one hash; a -w deliberately above it wants a
terminationGracePeriodSeconds long enough to cover the wait.
Available Flags
-w, --workers: Number of parallel workers (must be 1 or greater).1, the default, on every machine: nothing is read from the core count, the CPU affinity mask or a cgroup limit, so the number a run uses is the number you typed-t, --timeout: How long to run, as a duration such as30s,5mor1h30m.0, the default, runs until interrupted-r, --report: Print a progress line this often — elapsed time, hashes computed and rate. Takes the same duration spellings--timeoutdoes, no shorter than1sand, on a bounded run, no longer than--timeout.0, the default, prints none, which is what a run has always done-h, --help: Show help information-v, --version: Show version information
Exit codes
| Code | Meaning |
|---|---|
0 |
The run served the whole --timeout it was given |
1 |
The configuration was rejected — an unknown flag, an unparseable or out-of-range value, an unexpected argument — and no work was done |
130 |
SIGINT cut the run short, which is 128 + 2 and what Ctrl-C sends |
143 |
SIGTERM cut the run short, which is 128 + 15 and what docker stop, a kubectl delete pod and a node drain send |
Each of those is a code stressy chose and exited with. A second signal is not one: from the shutdown line on, stressy has stopped handling signals, so the next one kills the process outright and the status is whatever the system reports for a run that never exited — no summary line, and no code of stressy's own. That is the escape hatch for a drain too long to wait out, and the reason a run cannot become unstoppable.
Building from Source
git clone https://github.com/felipeneuwald/stressy.git
cd stressy
go build
./stressy -t 5s
Contributing
CONTRIBUTING.md has the checks CI runs and the conventions.
Security
Please do not open a public issue for a vulnerability. SECURITY.md says what is in scope and how to report privately.
License
MIT — see LICENSE.
Directories
¶
| Path | Synopsis |
|---|---|
|
internal
|
|
|
stressy
Package stressy is the stressy command: the flag grammar it publishes and the CPU stress test that grammar configures, which worker goroutines run by hashing bcrypt.
|
Package stressy is the stressy command: the flag grammar it publishes and the CPU stress test that grammar configures, which worker goroutines run by hashing bcrypt. |