Czego wymaga host
Docker z Compose v2.15.0, a tam gdzie oceniają Runnery — Engine 26; cgroup v2, polecenia coreutils używane przez skrypty oraz dysk na kopie i wolumeny.
Docker z Compose v2.15.0 lub nowszym
Próg 2.15.0 bierze się z cgroup: host, które ustawia usługa Runnera i które
Compose dostał dopiero w tej wersji. up -d --wait, używane przez skrypty
instalacji i aktualizacji, wymaga 2.1.1.
Cały stos to jeden compose.yaml z profilami. Wszystko w tej sekcji to docker compose — wtyczka, nie starszy skrypt docker-compose.
Host, który uruchamia Runnery, potrzebuje silnika w wersji 26 lub nowszej
Równoważnie Podmana 5 lub nowszego; jedno i drugie to kwiecień 2024. Paczka
jest rozpakowywana raz do wspólnego wolumenu Runnerów, a każdy kontener
sędziego montuje stamtąd jej podkatalog — montowaniem subpath, które
pojawiło się w Engine 26 / API 1.45. Starszy demon nie potrafi tego w ogóle
wyrazić, więc Runner odmawia oceniania, zamiast oceniać na pustym katalogu.
docker version --format '{{.Server.APIVersion}}' # 1.45 albo wyżejPoniżej tego progu nie ma rozwiązania zastępczego. preflight.sh odmawia na
hoście, który miałby uruchamiać Runnery przy starszym demonie, zamiast zostawiać
to do odkrycia pierwszemu zgłoszeniu. Nic innego w stosie nie potrzebuje wersji
26, więc hosta aplikacji bez Runnerów to nie dotyczy.
cgroup v2, obowiązkowo
Runner przy starcie pyta środowisko uruchomieniowe kontenerów o wersję cgroup i odmawia uruchomienia na v1.
Nie chodzi o szczelność sandboksa: v1 zamyka kontener zadania całkowicie
poprawnie i cały zestaw testów adwersaryjnych na nim przechodzi. Nie potrafi
natomiast mierzyć. memory.peak i cpu.stat to interfejsy v2, a w v1 nie ma
odpowiednika, który powiedziałby prawdę o procesie, który już się zakończył.
Odmowa Runnera brzmi dokładnie tak:
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
Ta ostatnia część to cały powód. Limit czasu jest czasem procesora, odczytanym z cgroup, więc Runner, który nie potrafi jej odczytać, nie potrafi wydać werdyktu. Odmowa nie dotyczy liczby brakującej obok werdyktu, tylko samego werdyktu.
Jest obejście: AJ_Sandbox__AllowUnmeasured. Powstało, bo maszyna programisty to
często Docker Desktop, który wciąż może raportować v1, a inaczej stos
deweloperski w ogóle by nie wstał. Sprawia jednak tylko tyle, że proces
wstaje: taki Runner zarejestruje się, odpowie na protokół, a potem każde
przejęte zadanie zakończy awarią infrastruktury. Gdy je ustawisz, Runner przy
każdym starcie loguje na poziomie 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 to ten sam przełącznik pod starszą nazwą i nadal
jest czytany. preflight.sh wprost odmawia na hoście z v1 i odmawia przy
sterowniku cgroup innym niż cgroupfs albo systemd, zamiast pozwolić stosowi
wstać wokół Runnera, który nie umie oceniać.
Tego obejścia nie da się zgłosić do panelu
MachineDto to struktura zamknięta i pomija każde pole, którego sama nie
wymienia, więc Runner działający poniżej specyfikacji wygląda w panelu Zarządzanie
dokładnie jak każdy inny. Mówi o tym wyłącznie jego własny log. Nie ustawiaj
tego w instalacji, z której korzysta ktokolwiek poza Tobą.
Dowolny sterownik cgroup i zapisywalne drzewo
Nawet na v2 liczby zbiera się okrężnie. Sandbox uruchamiany jest w cgroup,
która go przeżyje, przez --cgroup-parent, i to ją odczytujemy, kiedy dziecka
już nie ma — bo API środowiska uruchomieniowego nie podaje szczytu, a własna
cgroup kontenera znika w chwili, w której kończy się jego proces.
To, czym jest ta cgroup, zależy od sterownika cgroup w demonie. Runner pyta o niego przy starcie i sam wybiera sposób pomiaru; żaden ze sterowników nie wymaga przestawiania demona.
cgroupfs | systemd | |
|---|---|---|
| Gdzie jest domyślny | Docker Desktop | praktycznie każdy linuksowy serwer z systemd |
| Kto tworzy cgroup | Runner | systemd, kiedy poprosi go o to demon |
| Ile ich jest | jedna na przebieg, potem usuwana | jedna na tor, przez cały czas działania Runnera |
| Czas procesora przebiegu | odczyt z cpu.stat | przyrost cpu.stat w czasie przebiegu |
| Szczyt pamięci ocenianego przebiegu | memory.peak cgroup mieszczącej samo zgłoszenie, zakładanej wewnątrz kontenera zadania | tak samo — tutaj sterownik o niczym nie decyduje |
| Szczyt pamięci pozostałych przebiegów | odczyt z memory.peak | memory.peak zerowany na starcie przebiegu i liczony od tego, co slice już trzymał, co wymaga Linuksa 6.12 |
| Jądro | 5.19 i nowsze | 5.19, żeby oceniać; 6.12 wyłącznie dla szczytu kompilacji, programu weryfikującego i interaktora |
Wpis native.cgroupdriver w /etc/docker/daemon.json nie jest ani potrzebny,
ani szkodliwy.
Jednego i drugiego dotyczą natomiast te same trzy warunki, a AlgoJudge-Ops
zapewnia je wszystkie:
-v /sys/fs/cgroup:/sys/fs/cgroup # z prawem zapisu
--cgroupns=host # w Compose: `cgroup: host`
--user 0:0 # katalogi tego drzewa należą do rootaPotrzebują ich oba i żaden bez nich nie wstanie. Limit pamięci jest egzekwowany na cgroup mieszczącej samo zgłoszenie, a tę zakłada shim wewnątrz kontenera zadania — drzewo widoczne przez ten montaż musi więc być zapisywalne przy każdym z backendów. Runner sprawdza to przy starcie pod jednym i drugim: zakłada cgroup, od razu ją usuwa i odmawia uruchomienia, jeżeli mu się nie uda — zamiast się zarejestrować, odpowiadać na protokół i przewracać się na każdym zadaniu, jakie weźmie.
Root brzmi groźniej, niż jest. Runner i tak trzyma gniazdo środowiska
uruchomieniowego kontenerów, co samo w sobie odpowiada rootowi na hoście, więc
identyfikator użytkownika w jego kontenerze nigdy nie był granicą. To, co Runner
uruchamia, nic na tym nie zyskuje: kontener zadania nadal działa jako
65534:65534, z odebranymi wszystkimi uprawnieniami (capabilities).
Host z systemd i jądrem starszym niż 6.12 traci mniej, niż to brzmi. Dotyczy
to Ubuntu 24.04, które ma 6.8; Debian 13 ma 6.12 i jest w porządku.
Ani werdykt, ani liczby samego zgłoszenia na tym nie cierpią — szczyt ocenianego
przebiegu czyta się z cgroup założonej na ten właśnie przebieg, więc świeżej i
niewymagającej zerowania. Brakuje szczytu tych przebiegów, które nie są niczyim zgłoszeniem:
kompilacji, programu weryfikującego i interaktora, a tych nie pokazuje żaden
ekran. Runner mówi o tym na poziomie ERROR przy każdym starcie.
Rdzenie i pamięć
Runner ocenia jedno zgłoszenie naraz, a w nim tyle testów równocześnie, ile
mówi RUNNER_TESTS_AT_ONCE — każdy na własnym torze. Każdy test dostaje
--cpus=1 — tyle czasu procesora, ile daje jeden rdzeń, niezależnie od tego, ile
wątków uruchomi program, bo rozliczenie sumuje całe poddrzewo. Rachunek nie
dotyczy więc przepustowości: Runner to jedno równoległe zgłoszenie niezależnie od
liczby torów, jeden tor to praca za jeden rdzeń, a pytanie brzmi tylko, ile
rdzeni możesz oddać.
To przydział, a nie przypięcie. Na których procesorach zgłoszenie naprawdę pobiegnie, decyduje jądro — chyba że sam podzielisz maszynę; patrz niżej.
Walka o rdzeń kosztuje czas zegarowy, a bezpiecznik stoi właśnie na zegarze.
Limit jest czasem procesora, a czas procesora nie zmienia się pod obciążeniem:
dwa zgłoszenia dzielące rdzeń naliczają swój czas poprawnie, choćby czekały na
niego długo. Zmienia się wszystko, co mierzy zegar — a przebieg, który spędza
czas w kolejce po procesor, wygląda z zewnątrz dokładnie jak taki, który
przestał liczyć. Sandbox zatrzymuje takie przebiegi, żeby program naprawdę
zawieszony nie trzymał Runnera bez końca — a zagłodzony dochodzi do tego samego
progu, choć ani razu nie zwolnił. Werdykt brzmi Time limit exceeded.
Zmierzone 2026-09-03 na szesnastu procesorach, czyli ośmiu rdzeniach fizycznych,
z dwunastoma Runnerami bez podziału między nimi: piętnaście zgłoszeń na sto
pięćdziesiąt wróciło jako Time limit exceeded, mieszcząc się w swoich
limitach — w tym rozwiązania, o których wiadomo, że są poprawne.
Górna granica jest więc regułą o poprawności, a nie o przepustowości, i rdzenie liczone niżej muszą być rdzeniami, na których nie ma nic innego:
| Rdzenie fizyczne | RUNNER_TESTS_AT_ONCE |
|---|---|
| 4 | 2 |
| 8 | 2 |
| 16 | 8 |
Zawsze dwa Runnery, każdy na połowie maszyny — w kolumnie stoi więc liczba
torów jednego Runnera, a cała flota ma ich dwa razy tyle. Profil runner
uruchamia runner-1 i runner-2, i trzeciego nie ma: host z zapasem mocy
poszerza te dwa, zamiast dokładać kolejny.
Ile jest rdzeni, powie lscpu: Core(s) per socket razy Socket(s), co nie
jest liczbą procesorów, gdy rdzeń niesie dwa wątki. Żeby mieć mniej torów,
zmniejsz RUNNER_TESTS_AT_ONCE; żeby zamiast dwóch Runnerów mieć jednego, usuń
runner-2 z compose.yaml i wyczyść RUNNER_2_CPUSET — preflight.sh zestawia
cpuset z szerokością niezależnie od tego, czy czyta go jeszcze jakaś usługa.
| Składnik | Rdzenie | Pamięć | Uwagi |
|---|---|---|---|
| Server | 1, wspólny z Postgresem | 512 MiB | zmierzone 228 MiB pod obciążeniem oceniania |
| Postgres | wspólny z Serverem | 512 MiB | zmierzone 115 MiB; przy dużej instalacji daj więcej |
| Client | — | 32 MiB | nginx i statyczne pliki |
| nginx na brzegu | — | 32 MiB | |
| External Runner | — | 64 MiB | przekazuje do zewnętrznego sędziego i czeka na sieć; nie uruchamia kontenerów |
| każdy tor oceniania | 1 | 0,6 GiB | część procesu samego Runnera i kontenery, które tor trzyma naraz |
| demon Dockera i system | 1 | 750 MiB | to nie jest zaokrąglenie: podczas oceniania powstaje kilka kontenerów na sekundę |
Te 0,6 GiB na tor to proces samego Runnera i największy kontener, jaki ten tor utrzyma. Tor trzyma jeden kontener oceniany, z limitem pamięci zadania powiększonym o 64 MiB, a obok niego program weryfikujący albo interaktor na 256 MiB — dwa tory przy zadaniach na 256 MiB to około 670 MiB na Runnera, jeszcze bez wejść, a cztery około 1,3 GiB. Budowania stoją poza torami, jedno naraz, ograniczone do 512 MiB, i 0,6 GiB na tor wystarcza na jedno i na drugie.
Przykład: cztery tory
Jeden Client, jeden Server, jeden External Runner i cztery tory oceniania — dwa Runnery, każdy o szerokości dwóch torów:
| Rdzenie | Pamięć | |
|---|---|---|
| cztery tory | 4 | 2,4 GiB |
| Server i Postgres | 1 | 1 GiB |
| Client, brzeg, External Runner | — | 128 MiB |
| demon i system | 1 | 750 MiB |
| razem | 6 | ~4,3 GiB |
Sześć rdzeni i 8 GiB, a różnica między 4,3 a 8 nie jest zmarnowana — staje się pamięcią podręczną plików. Przy zadaniu, którego testy czyta się raz na zgłoszenie i znów przy następnym, to jest różnica między pamięcią a odczytem z dysku na każdy test.
Dla rozmiaru spoza tabeli: 2 GiB podstawy i 0,6 GiB na tor, plus jeden rdzeń na tor ponad dwa na całą resztę.
Wdrożenie tożsamości nie należy do tego stosu — to osobna instalacja — ale jeśli postawisz je na tym samym hoście, Keycloak albo Authentik potrzebuje kolejnego rdzenia i około 1 GiB.
Rdzenie fizyczne, nie vCPU
Dwa wątki jednego rdzenia fizycznego dzielą jednostki wykonawcze. Pomiar pozostaje uczciwy — czas procesora nalicza się każdemu osobno — ale praca wykonana w jednej sekundzie procesora maleje, gdy sąsiedni wątek jest zajęty. To samo rozwiązanie zmierzy się więc różnie, zależnie od tego, co siedzi obok. Dla sędziego to problem z powtarzalnością i dlatego maszyny zawodów zwykle mają SMT wyłączone.
Na współdzielonym VPS-ie sąsiada nie widać i nie da się z tym nic zrobić.
Podziel maszynę między Runnery
Runner nigdy nie wybiera procesorów sam i jest to celowe: kilka Runnerów wybierających niezależnie, bez niczego, co by ich uzgadniało, wypada gorzej niż zostawienie rozmieszczenia hostowi. Mając całą maszynę, nie przypina niczego i decyduje planista — tyle tylko może zrobić na maszynie, której nikt nie podzielił.
Zamknij kontener Runnera w cpuset, a przeniesie ten zbiór na każdy
uruchomiony przez siebie kontener zadania. Musi: kontener zadania jest
dzieckiem demona, nie Runnera, więc nie dziedziczy żadnego powinowactwa i bez
tego rozlałby się po całej maszynie, po cichu znosząc narysowany przez ciebie
podział.
Podział, który warto zrobić, to jeden Runner na grupę rdzeni i jeden tor na rdzeń w tej grupie. Dwa różne Runnery na jednym rdzeniu odbierają sobie jednostki wykonawcze, a program zużywa wtedy więcej czasu procesora na tę samą pracę — czyli dostaje zły werdykt, a nie wolniejszy.
Dwa tory na dwóch wątkach jednego rdzenia robią dokładnie to samo i sprawdzają
to teraz dwie rzeczy. Runner odmawia szerokości, której jego zbiór nie pokryje
po procesorze na tor, a przy starcie czyta thread_siblings_list i ostrzega raz
za każdy tor, który dostał wątek zamiast rdzenia. scripts/preflight.sh liczy
osobne rdzenie w każdym zbiorze i ostrzega — nie odmawia — gdy jest ich mniej niż
torów. Host, który nie publikuje topologii, nie ma tu nic do powiedzenia. Tor na
rdzeń to podział czysty, a dwa tory na rdzeń to zgłoszenie ocenione szybciej,
kosztem liczby, na której opiera się werdykt. Zapisz pary bliźniacze obok
siebie, żeby cięcie wypadło między nimi — 0,8,1,9,2,10,3,11 przy czterech
torach to cztery tory po jednym rdzeniu tam, gdzie partnerem cpu0 jest cpu8.
AlgoJudge-Ops udostępnia obie połowy
jako RUNNER_1_CPUSET i RUNNER_2_CPUSET oraz jako RUNNER_TESTS_AT_ONCE —
oba cpusety dostarczamy puste — a Runner,
który dostał więcej torów niż procesorów w swoim zbiorze, odmawia startu. Zbiór
jest cięty na tory w takiej kolejności, w jakiej go zapisano, więc odczytaj
pary z maszyny, zamiast je przepisywać: thread_siblings_list mówi 0-1 na
jednym hoście, a 0,8 na innym — lista przepisana z przykładu jest na tym drugim
dokładnie odwrotna.
Czego przypięcie nie daje, to czasu. --cpus=1 obowiązuje na każdym kontenerze
zadania tak czy inaczej, więc przebieg nie zdobędzie czasu procesora przez
rozłożenie się na kilka rdzeni. A zbiór węższy niż rdzeń — a tor właśnie taki
jest — odbiera hostowi jedyną dostępną naprawę, bo jądro nie może wtedy przenieść
zagłodzonego przebiegu na wolny procesor. Toru, któremu brakuje procesora, nic
już nie uratuje i dlatego nadmiar torów kończy się odmową startu, a nie
ocenianiem mimo wszystko.
Dysk opisuje sekcja o kopiach zapasowych poniżej. Poza tym Runnery dzielą pamięć podręczną paczek ograniczoną do 10 GiB, a External Runner ma własną, ograniczoną do 256 MiB. Te 10 GiB mieści więcej niż same pobrania: obok archiwum leżą rozpakowane testy i skompilowany checker, przygotowane raz zamiast przy każdym zgłoszeniu — więc ten sam limit mieści mniej paczek, niż zmieściłby samych archiwów.
Runner trzyma też w pamięci jeden plik testowy na tor — to wejście, które podaje zgłoszeniu, i jest to zapieczętowana kopia, a nie montowanie. Host sprawdzający zadania z bardzo dużymi testami potrzebuje na to miejsca na każdy tor, ponad wartości podane wyżej.
Co wznawia usługi po awarii
Każda usługa ma restart: unless-stopped. Kontener, który zakończy się z
jakiegokolwiek powodu, zostaje uruchomiony ponownie — awaria też — i nie
potrzebujesz do tego własnego nadzorcy: nadzorcą jest demon Dockera.
unless-stopped, a nie always, i różnica jest wąska: po restarcie hosta
always przywróci nawet kontener, który celowo zatrzymałeś. unless-stopped
uszanuje to zatrzymanie, czego właśnie chce operator wyłączający Runnera na czas
prac.
Demon musi wstać przy starcie systemu, a to już nie nasza część
restart: unless-stopped obowiązuje tylko wtedy, gdy dockerd w ogóle wstanie.
Pakiety zwykle to załatwiają, ale instalacja, w której demon nie startuje przy
boocie, po prostu nie wraca i nic nie powie dlaczego:
systemctl is-enabled dockerTrzech rzeczy nie załatwia, a każda z nich ma inną odpowiedź:
| Co się dzieje | |
|---|---|
| Usługa, która zawiesza się zamiast zakończyć | Nic jej nie wznowi: polityka restartu reaguje na zakończenie. Zadanie zawieszonego Runnera odzyskuje dzierżawa Servera — wygasa i zadanie wraca do kolejki — ale sam Runner stoi, dopóki ktoś nie zajrzy |
| Kontener w pętli restartów | Wznawia się w nieskończoność, z opóźnieniem rosnącym do minuty. Nic tego nie eskaluje i nic o tym nie powie — a jest to pierwsza rzecz do sprawdzenia, gdy instalacja stoi, a nic nie robi |
| Nieudany healthcheck | Nic. W Compose healthcheck informuje i nie restartuje; restartowanie po nim to zachowanie orkiestratora, nie tego stosu |
Restart nie gubi stanu, bo wolumeny są nazwane. Runner zachowuje klucz tożsamości, więc pozostaje zatwierdzony, zamiast zgłosić się jako nowy, którego menedżer musi dopuścić od nowa.
Polecenia, których używają skrypty
bash i openssl to te dwa, które warto zainstalować świadomie. Reszta jest na
każdym linuksowym hoście z coreutils: awk, sed, stat, sha256sum,
mktemp, date, find, du, df, cut, tr, grep, sort, head,
tail, wc.
sed -i i stat -c to zapis GNU, więc host BSD ani macOS nie jest jednym
z tych hostów. git, flock i crontab są opcjonalne i każde ustępuje
z komunikatem, zamiast przerywać pracę: bez git w manifeście kopii nie ma
numeru rewizji, bez flock dwa skrypty mogą się nałożyć, bez crontab
harmonogram instalujesz sam.
Dysk tam, gdzie Docker trzyma wolumeny
Na hoście, który uruchamia Runnery, wszystko, co one trzymają, leży pod
katalogiem danych Dockera — zwykle /var/lib/docker/volumes — a nie pod
ścieżką, którą wybrałeś. Duża jest wspólna pamięć podręczna paczek,
ograniczona przez Runnera do 10 GiB; katalog roboczy każdego Runnera to
jedno zgłoszenie naraz.
docker system df -v | grep runnerNa maszynie, której /var jest mały, przenieś katalog danych Dockera.
Dysk na kopie, najlepiej nie ten od bazy
Umieść katalog kopii zapasowych na innym systemie plików niż wolumen PostgreSQL. Na jednym systemie plików żadne ustawienie rezerwy nie zagwarantuje, że kopia nie zagłodzi bazy, którą właśnie kopiuje.
preflight.sh zapisze za Ciebie BACKUP_MAX_TOTAL_GB, jeśli jest puste — 25%
systemu plików, na którym leżą kopie, i nigdy więcej niż 50% — żeby katalogu
kopii przed zapełnieniem dysku nie chroniła sama polityka ilościowa, która
zużycia dysku wcale nie ogranicza.
Logi kontenerów, które stack ogranicza sam
Przed instalacją nie musisz nic robić
Stack ustawia sterownik logów każdej usłudze, którą uruchamia — dockerowy
local, o rozmiarze z LOG_MAX_SIZE i LOG_MAX_FILES w Twoim .env — więc nie
zależy od tego, jak masz ustawionego demona.
Sam Docker domyślnie pisze do pliku JSON bez żadnego limitu i kiedyś to właśnie
tak taki host najczęściej zapełniał sobie dysk. Ograniczenie po stronie demona
nadal warto ustawić — ze względu na wszystko inne, co działa na tej maszynie —
ale nie jest to już pierwszy krok. W /etc/docker/daemon.json:
{ "log-driver": "json-file", "log-opts": { "max-size": "50m", "max-file": "5" } }Potem zrestartuj demona.
Jedna rzecz do wiedzenia, jeśli zbierasz logi gdzieś dalej. docker logs i
docker compose logs czytają local dokładnie tak samo jak json-file, więc
wszystkie polecenia z tych stron działają bez zmian. Ale plik na dysku ma
zwarty format binarny — więc kolektor, który zbiera pliki *-json.log wprost z
systemu plików hosta, nic tu nie znajdzie. Jeśli takiego używasz, zmień
logging.driver w compose.yaml z powrotem na json-file; opcje rozmiaru
znaczą pod obydwoma to samo.
gc.sh rotuje własne logi skryptów w /var/log/algojudge/ i nie dotyka logów
kontenerów — te należą do demona i ogranicza je blok w compose.yaml.
Grupa, do której należy gniazdo Dockera
Na hoście z Runnerem DOCKER_GID powinien być grupą, która jest właścicielem
/var/run/docker.sock, choć nic od tego nie zależy: usługa Runnera działa jako
root, żeby móc mierzyć, a root otwiera gniazdo niezależnie od tego, do jakich
grup należy. Ustaw ją mimo to poprawnie — będzie potrzebna, gdyby usługa
działała bez uprawnień roota.
getent group docker | cut -d: -f3Na Docker Desktop gniazdo należy do root:root, więc jest to 0.
preflight.sh odczytuje prawdziwy numer — pytając kontener, bo na Docker Desktop
i pod WSL gniazdo widziane przez tę powłokę nie jest tym, które obsługuje demon —
i ostrzega, jeśli Twój się nie zgadza. W instalacji, w której ktoś zmienił
compose.yaml z powrotem na Runnera bez roota, zła wartość daje Permission denied (os error 13) z głębi klienta HTTP, nie nazywając ani gniazda, ani grupy.