What the host needs
Docker with Compose v2.15.0 and, where Runners judge, Engine 26; cgroup v2, the coreutils the scripts use, and disk for backups and volumes.
Docker with Compose v2.15.0 or later
The stack is one compose.yaml with profiles. Everything in this section is
docker compose, the plugin, not the older docker-compose script.
2.15.0 is the floor because of cgroup: host, which the Runner service sets
and which Compose first shipped in that version. up -d --wait, used by the
install and update scripts, needs 2.1.1.
On a host that runs Runners, the engine must be 26 or later
Podman 5 or later, equivalently; both are April 2024. A package is unpacked
once into the volume the Runners share, and each judged container mounts its
subdirectory out of there — a subpath mount, which arrived in Engine 26 /
API 1.45. An older daemon cannot express it at all, so the Runner refuses to
judge rather than judge against an empty directory.
docker version --format '{{.Server.APIVersion}}' # 1.45 or higherThere is no arrangement below that floor. preflight.sh refuses on a host
that would run Runners against an older daemon, rather than leaving a submission
to find out. Nothing else in the stack needs 26, so an application host with no
Runners is unaffected.
cgroup v2, and it is not optional
The Runner asks the container runtime for its cgroup version at startup and refuses to start on v1.
This is not about whether the sandbox holds: v1 confines a job container
perfectly well, and the whole adversarial test suite passes on it. What it
cannot do is measure. memory.peak and cpu.stat are v2 interfaces, and
there is no v1 equivalent that tells the truth about a process that has already
exited. The Runner's refusal says exactly this:
the limits are enforced on v1, but a time limit is decided on processor time read from cpu.stat, which is a v2 interface — so what cannot be done here is reach a verdict at all
That last clause is the whole reason. A time limit is processor time, read from the cgroup, so a Runner that cannot read one cannot judge. The refusal is not about a number missing from beside a verdict; it is about the verdict.
There is an override, AJ_Sandbox__AllowUnmeasured. It exists because a
developer's machine is often Docker Desktop, which may still report v1, and the
alternative is a development stack that cannot start at all. It only makes the
process start: such a Runner registers, answers the protocol, and then fails
every job it claims with an infrastructure error. When it is set the Runner logs
on every start, at ERROR:
STARTING BELOW SPECIFICATION — … A time limit is decided on processor time read
from this host's cgroups, so this Runner registers and answers the protocol and
then fails every job it claims.AJ_Sandbox__AllowCgroupV1 is the same switch under its older name and is
still read. preflight.sh refuses a host on v1 outright, and refuses a cgroup
driver that is neither cgroupfs nor systemd, rather than letting the stack
come up around a Runner that cannot judge.
The override cannot be reported to the panel
MachineDto is a closed shape and drops anything it does not name, so a Runner
running below specification looks exactly like any other Runner in the manager
panel. The only place it says so is its own log. Do not set this on an
installation anybody else uses.
Either cgroup driver, and a writable cgroup tree
Even on v2, the numbers come out sideways. The sandbox is started under a
cgroup that outlives it, with --cgroup-parent, and that cgroup is read once
the child is gone — because the runtime API reports no peak, and a container's
own cgroup is destroyed the moment its process exits.
What that cgroup is depends on the daemon's cgroup driver. The Runner asks at start and chooses; neither driver needs the daemon reconfigured.
cgroupfs | systemd | |
|---|---|---|
| Where it is the default | Docker Desktop | virtually every systemd Linux host |
| Who creates the cgroup | the Runner | systemd, when the daemon asks it |
| How many | one per run, removed afterwards | one per lane, for the Runner's whole life |
| A run's processor time | read from cpu.stat | the change in cpu.stat across the run |
| A judged run's peak memory | memory.peak of the cgroup holding the submission, made inside the judged container | the same — the driver decides nothing here |
| Any other run's peak memory | read from memory.peak | memory.peak reset at the start of the run and measured from what the slice already held, which needs Linux 6.12 |
| Kernel | 5.19 and later | 5.19 to judge; 6.12 only for the peak of a build, a checker or an interactor |
A native.cgroupdriver line in /etc/docker/daemon.json is neither needed nor
harmful.
What both need, and AlgoJudge-Ops supplies all three:
-v /sys/fs/cgroup:/sys/fs/cgroup # writable
--cgroupns=host # `cgroup: host` in Compose
--user 0:0 # the tree's directories are root'sBoth backends need them, and neither starts without them. A memory limit is enforced on a cgroup holding the submission alone, made inside the judged container, so the tree this mount exposes has to be writable whichever backend is in use. The Runner proves that at start under both: it makes a cgroup, removes it again, and refuses to start when it cannot — rather than registering, answering the protocol and then failing every job it claims.
Root sounds worse than it is: the
Runner holds the container runtime's socket, which is root-equivalent on the
host by itself, so the uid inside its container was never the boundary — and
nothing it starts gains anything, because a job container still runs as
65534:65534 with every capability dropped.
What a systemd host below Linux 6.12 loses is smaller than it sounds. That
excludes Ubuntu 24.04, which ships 6.8; Debian 13 ships 6.12 and is fine.
Neither a verdict nor a submission's own numbers are affected — a judged run's
peak is read from a cgroup made for it, which is fresh and needs no reset. What
is lost is the peak of the runs that are nobody's submission: a build, a checker,
an interactor, none of which any screen shows. The Runner says so at ERROR on
every start.
Cores and memory
A Runner judges one submission at a time, and as many of that submission's
tests at once as RUNNER_TESTS_AT_ONCE says — one lane each. Every test runs
under --cpus=1 — one processor's worth of time, whatever the program does with
threads, because the accounting sums the whole subtree. So the arithmetic is not
a throughput calculation: a Runner is one concurrent submission however many
lanes it has, one lane is one core's worth of work, and the question is only
how many cores you can spare.
That is a quota, not a pin. Which processors a submission actually runs on is the kernel's business, unless you divide the host yourself — see below.
Contention costs wall clock, and the wall clock is where the safety net is.
A limit is processor time and processor time does not move under load: two
submissions sharing a core each accrue their own correctly, however long they
wait for it. What does move is everything measured on the clock — and a run that
spends its time waiting for a processor looks, from outside, exactly like one
that has stopped computing. The sandbox stops such a run so that a genuinely
wedged program cannot hold a Runner forever, and a starved one reaches that stop
without ever having been slow. The verdict says Time limit exceeded.
Measured 2026-09-03 on sixteen processors — eight physical cores — running
twelve Runners with no division between them: fifteen submissions in a hundred
and fifty came back Time limit exceeded while inside their limits, including
solutions known to be correct.
So the ceiling is a rule about correctness rather than about throughput, and the cores counted below have to be cores nothing else is on:
| Physical cores | RUNNER_TESTS_AT_ONCE |
|---|---|
| 4 | 2 |
| 8 | 2 |
| 16 | 8 |
Two Runners throughout, each taking half the machine, so the column is the
lanes one Runner holds and the fleet is twice it. The runner profile starts
runner-1 and runner-2 and there is no third: a host with capacity to spare
widens the two rather than gaining another.
lscpu says how many cores there are — Core(s) per socket times Socket(s),
which is not the CPU count when a core carries two threads. To run fewer
lanes, lower RUNNER_TESTS_AT_ONCE; to run one Runner rather than two, remove
runner-2 from compose.yaml and clear RUNNER_2_CPUSET, because
preflight.sh counts a cpuset against the width whether or not a service is
still reading it.
| Component | Cores | Memory | Notes |
|---|---|---|---|
| Server | 1, shared with Postgres | 512 MiB | 228 MiB measured under judging load |
| Postgres | shared with the Server | 512 MiB | 115 MiB measured; give it more if the installation is large |
| Client | — | 32 MiB | nginx and a static bundle |
| nginx, the edge | — | 32 MiB | |
| External Runner | — | 64 MiB | it forwards to the external judge and waits on the network; it starts no containers |
| each judging lane | 1 | 0.6 GiB | a share of the Runner's own process, and the containers one lane holds at a time |
| the Docker daemon and the operating system | 1 | 750 MiB | not a rounding: several containers a second are created while judging |
The 0.6 GiB a lane is the Runner's own process and the largest container that lane will hold. A lane holds one judged container at the problem's memory limit plus 64 MiB, and a checker or interactor beside it at 256 MiB, so two lanes on 256 MiB problems is about 670 MiB for that Runner before inputs, and four about 1.3 GiB. The two builds stand outside the lanes — one at a time, capped at 512 MiB — and 0.6 GiB a lane is the figure that covers both shapes.
A worked example: four lanes
One Client, one Server, one External Runner, and four judging lanes — the two Runners at a width of two each:
| Cores | Memory | |
|---|---|---|
| four lanes | 4 | 2.4 GiB |
| Server and Postgres | 1 | 1 GiB |
| Client, edge, External Runner | — | 128 MiB |
| daemon and operating system | 1 | 750 MiB |
| total | 6 | ~4.3 GiB |
Six cores and 8 GiB, and the gap between 4.3 and 8 is not waste: it becomes page cache, and a problem whose tests are read once per submission and again for the next one is the difference between memory and a disk read per test.
The rule underneath, for a size not in the table: 2 GiB of base, 0.6 GiB per lane, and one core per lane on top of two for everything else.
An identity deployment is not part of this stack — it is its own installation — but if you run one on the same host, a Keycloak or Authentik wants another core and about 1 GiB.
Physical cores, not vCPU
Two hyperthreads of one physical core share execution units. The measurement stays honest — processor time is billed to each correctly — but the work done per processor-second falls when the sibling is busy, so the same solution measures differently depending on what is beside it. For a judge that is a reproducibility problem, and it is why contest hosts commonly disable SMT.
On a shared VPS the neighbor is invisible and nothing can be done about it.
Divide the host between the Runners
A Runner never chooses processors for itself, and that is deliberate: several of them choosing independently, with nothing to coordinate them, is worse than letting the host place the work. Given the whole machine it pins nothing and the scheduler decides, which is all it can do on a host nobody has divided.
Confine a Runner's container with cpuset and it carries that set to every
job container it starts. It has to: a job container is the daemon's child
rather than the Runner's, so it inherits no affinity and would otherwise spread
over the whole host, quietly undoing the division you drew.
The division worth making is one Runner per group of cores, and one lane per core in the group. Two different Runners on one core take each other's execution units, and a program then spends more processor time than it should for the same work — which is a wrong verdict, not a slow one.
Two lanes on the two threads of one core do exactly that, and two things now
check it. A Runner refuses a width its set cannot give one processor each,
and reads thread_siblings_list at start to warn once for every lane that got a
thread rather than a core. scripts/preflight.sh counts distinct cores in each
cpuset and warns — it does not refuse — when there are fewer than there are
lanes. A host publishing no topology has nothing to say either way. A lane to
a core is the clean division, and two lanes to a core is a submission answered
sooner at a real cost in the number its verdict is decided by. Write the sibling
pairs next to each other and let the cut fall between them —
0,8,1,9,2,10,3,11 at a width of four is four lanes of one core each where
cpu0's partner is cpu8. AlgoJudge-Ops exposes the two halves as
RUNNER_1_CPUSET and RUNNER_2_CPUSET and as RUNNER_TESTS_AT_ONCE, both
cpusets shipping empty, and a Runner given more lanes than
its set has processors refuses to start. The set is cut into lanes in the order
it is written, so read the pairs off the machine rather than copying them:
thread_siblings_list says 0-1 on one host and 0,8 on another, and a list
copied from an example is exactly wrong on the second.
What a pin does not buy is time. --cpus=1 is on every job container either
way, so a run cannot win processor time by spreading over cores. And a set
narrower than a core — which a lane is — takes away the one repair the host has,
because the kernel cannot then move a starved run to a free processor. Nothing
rescues a lane that is short of a processor, which is why more lanes than
processors is refused at start rather than judged around.
Disk is covered under backups below. Beyond that the Runners share a package cache bounded at 10 GiB and the External Runner one bounded at 256 MiB. That 10 GiB holds more than the downloads: a package sits there with the tests unpacked out of it and the checker it declares already compiled, prepared once rather than per submission, so the same ceiling holds fewer packages than it would have held archives.
A Runner also holds one test file in memory per lane while it judges it — the input it hands the submission, which is a sealed copy rather than a mount — so a host judging problems with very large tests wants room for the largest of them per lane, on top of the figures above.
What keeps it running
Every service is restart: unless-stopped. A container that exits for any
reason is started again, a crash included, and no supervisor of your own is
needed: the Docker daemon is the supervisor.
unless-stopped rather than always, and the difference is narrow: after a host
restart, always brings back even a container you had deliberately stopped.
unless-stopped respects the stop, which is what an operator taking a Runner out
for maintenance wants.
The daemon has to start at boot, and that part is not ours
restart: unless-stopped only applies if dockerd itself comes up. Packages
normally arrange it, but an installation whose daemon does not start at boot
simply does not come back, and nothing says why:
systemctl is-enabled dockerThree things it does not do, and each has a different answer:
| What happens | |
|---|---|
| A service that hangs rather than exits | Nothing restarts it: a restart policy acts on exit. A hung Runner's job is recovered by the Server's lease — it expires and the job returns to the queue — but the Runner stays hung until somebody looks |
| A container crash-looping | It is restarted forever, with the delay growing to a minute. Nothing escalates and nothing tells you. A flapping container is the first thing to look for when an installation is up but does nothing |
| A failed health check | Nothing. In Compose a health check reports and does not restart; that is an orchestrator's behavior, not this one's |
State survives a restart because the volumes are named. A Runner keeps its identity key across one, so it stays approved rather than arriving as a new Runner a manager has to admit again.
The commands the scripts use
bash and openssl are the two worth installing deliberately. The rest are on
any Linux host that has coreutils: awk, sed, stat, sha256sum, mktemp,
date, find, du, df, cut, tr, grep, sort, head, tail, wc.
sed -i and stat -c are the GNU spellings, so a BSD or macOS host is not
one of these hosts. git, flock and crontab are optional and each degrades
with a message rather than failing: no git means no revision in a backup's
manifest, no flock means two scripts can overlap, no crontab means the
schedule is yours to install.
Disk where Docker keeps its volumes
On a host that runs Runners, everything they hold lives under Docker's data
root — usually /var/lib/docker/volumes — rather than under a path you chose.
The shared package cache is the large one, bounded by the Runner at 10 GiB;
each Runner's scratch is one submission at a time.
docker system df -v | grep runnerOn a machine whose /var is small, move Docker's data root.
Disk for backups, ideally not the database's
Put the backup directory on a different filesystem from the PostgreSQL volume. On one filesystem no reserve setting can guarantee that a backup will not starve the database it is backing up.
preflight.sh writes BACKUP_MAX_TOTAL_GB for you if it is empty — 25% of the
filesystem the backups live on, never more than 50% — so that a count policy,
which does not bound disk usage at all, is not the only thing standing between
the backup directory and a full disk.
Container logs, which the stack bounds itself
Nothing to do before you install
The stack sets a log driver on every service it starts — Docker's local, sized
by LOG_MAX_SIZE and LOG_MAX_FILES in your .env — so it does not depend on
what your daemon is set to.
Docker's own default is a JSON file with no size limit, and it used to be the
commonest way one of these hosts filled its disk. Setting a bound on the daemon
is still worth doing for whatever else runs on the machine, and it is no
longer a step to take first. In /etc/docker/daemon.json:
{ "log-driver": "json-file", "log-opts": { "max-size": "50m", "max-file": "5" } }Then restart the daemon.
One consequence, if you ship logs somewhere. docker logs and docker compose logs read local exactly as they read json-file, so every command in
these pages works unchanged. But the file on disk is a compact binary format —
so a collector that tails *-json.log off the host filesystem will not find
these. If you run one, change logging.driver in compose.yaml back to
json-file; the size options mean the same thing under both.
gc.sh rotates the scripts' own logs under /var/log/algojudge/, and never
touches container logs: those are the daemon's, bounded by the block in
compose.yaml.
The group that owns the Docker socket
On a host running a Runner, DOCKER_GID should be the group that owns
/var/run/docker.sock. It is not load-bearing: the runner service runs as root
so that it can measure, and root opens the socket whatever groups it is in — so
preflight.sh warns rather than refusing. Set it correctly anyway; it is what
the service would need if it ever ran unprivileged.
getent group docker | cut -d: -f3On Docker Desktop the socket is root:root, so it is 0. preflight.sh reads
the real number — by asking a container, because on Docker Desktop and under WSL
the socket this shell can see is not the one the daemon serves — and warns if
yours disagrees. On an installation whose compose.yaml has been edited back to
an unprivileged Runner, a wrong value produces Permission denied (os error 13)
from deep inside an HTTP client, naming neither the socket nor the group.