Dokumentacja AlgoJudge0.1

Kiedy coś nie działa

Awarie, które naprawdę się zdarzyły, a większość z nich wygląda na inny problem, niż jest.

Server nie startuje

The database has N pending migration(s)

Schemat jest za obrazem, a temu Serverowi nie powiedziano, że wolno mu to naprawić. Ustaw MIGRATE_ON_START=true w .envpo zrobieniu kopii — albo zastosuj migracje samodzielnie przed startem.

To mówi też świeża instalacja, jeśli przełącznik jest wyłączony: pusta baza ma wszystkie migracje oczekujące.

Kończy pracę, narzekając na Forwarded

TRUSTED_PROXY_NETWORKS jest nieustawione. Server odmawia startu, dopóki nie wskażesz mu, czyim informacjom o adresie odwiedzającego ma wierzyć. none to pełna odpowiedź dla Servera osiąganego bezpośrednio.

Nazywa sieć, której nie napisałeś

Napisałeś CIDR z ustawionymi bitami hosta — 172.28.0.5/24. .NET normalizuje to do 172.28.0.0/24 bez słowa, co zamienia „jedną maszynę” w „całą pracownię”, więc Server odmawia i nazywa adres, który powinieneś był napisać. preflight.sh łapie to wcześniej.

No storage is configured

STORAGE_KIND jest puste albo z literówką. Wartości to postgres, filesystem albo s3.

Nazywa ustawienie magazynu, które jest puste

filesystem potrzebuje STORAGE_PATH; s3 potrzebuje STORAGE_ENDPOINT, STORAGE_BUCKET, STORAGE_ACCESS_KEY i STORAGE_SECRET_KEY — wszystkich czterech. Server odmawia startu, nazywając to, którego mu zabrakło, a preflight.sh odmawia jeszcze wcześniej. Przy STORAGE_KIND=postgres żadne z nich nie jest czytane i dlatego wszystkie są puste.

API odpowiada 404, a jesteś pewien ścieżki

/health nie jest endpointem zdrowia. Jest nim /api/v1/health.

Każda instalacja serwuje API pod /api/v1, a Server odpowiada 404 w gołym korzeniu celowo — żeby Client wskazany na złą ścieżkę został poprawiony, a nie obsłużony. Monitor odpytujący /health zgłasza instalację jako niedostępną, choć ta serwuje bez zarzutu.

Przez dołączony nginx jest gorzej

/health nie jest tam nawet 404. location / kieruje na Client, który dla każdej nieznanej ścieżki serwuje index.html, więc curl https://twoja.domena/health zwraca 200 i stronę HTML. To router aplikacji, nie API.

/admin odpowiada 404 mimo poprawnego tokenu

I tak ma być. /api/v1/admin/** wymaga, żeby żądanie dotarło na własny interfejs pętli zwrotnej Servera, a:

  • przez nginx dociera jako brama mostka — 404;
  • przez opublikowane 127.0.0.1:8080 też dociera jako brama mostka, bo tak działa opublikowany port — 404.

Każda odmowa to celowo to samo 404, więc nic nie odróżnia złego tokenu od złej trasy. Nie 403: 403 potwierdziłoby, że endpoint tam jest i że wywołujący trafił jedną z dwóch połówek.

Wchodzi się tak:

docker compose exec -T server aj-admin status

Jeśli odpowie AJ_Admin__Token is not set in this container, token był pusty, gdy kontener startował. Ustaw go w .env i odtwórz Server.

Nikt nie może administrować instalacją

Zrestartuj Server. Przy każdym starcie sprawdza on, czy w instalacji jest jeszcze aktywne nadanie systemowe z kluczem system:administrator — czyjekolwiek, nie tylko na koncie zakładanym przy pierwszym starcie. Jeśli nie ma żadnego, przywraca kontu admin uprawnienia z szablonu admin:

docker compose restart server

W logu pojawia się wtedy Nobody administered this installation, so admin has been granted system:administrator again. Its password is unchanged. W tym ostatnim zdaniu mieści się całe zastrzeżenie: restart przywraca uprawnienia, a nie hasło. Jeśli hasła też nikt nie zna, ustaw je poleceniem aj-admin password.

Nadania zrobionego ręcznie Server nigdy nie nadpisze. Jeżeli konto admin ma już własne nadanie systemowe, które nie daje praw administratora — przycięte do krótszej listy albo przestawione na zaproszone — zostaje ono bez zmian, a log mówi grant system:administrator by hand. To jedyny przypadek, którego restart nie naprawia.

Dziś dojście do tego stanu wymaga już bazy starszej niż sama odmowa albo edycji wprost w bazie: odebranie instalacji ostatniego nadania z system:administrator — przez jego usunięcie, przepisanie bez tego klucza albo przestawienie na zaproszone — kończy się odpowiedzią 403 z kodem grant.administrator.last. aj-admin nie ma żadnego polecenia do nadań i dlatego jedyną drogą jest restart.

Nic nie jest oceniane

Od najczęstszej przyczyny:

  1. Runner nie został zatwierdzony. Jego log mówi waiting: this Runner has not been approved yet i będzie to mówił zawsze. Zatwierdź go w panelu pod Runnery.
  2. Obrazy sandboxa nie istnieją. Runner szuka wkompilowanych domyślnych algojudge/lang-*:local, które ma tylko host deweloperski. compose.yaml ustawia wszystkie cztery zmienne AJ_Sandbox__Image__* z REGISTRY i RUNNER_TAG; jeśli je nadpisałeś, sprawdź docker images.
  3. RUNNER_WORK_DIR jest zły. To najpaskudniejszy przypadek, bo zawodzi po cichu: demon Dockera dostaje tę ścieżkę wprost, a ścieżka, której nie potrafi otworzyć, daje pusty katalog, a nie błąd. Każde zlecenie działa wtedy na niczym. Musi być bezwzględna i musi być ścieżką, którą potrafi otworzyć demon, a nie tylko Twoja powłoka.
  4. Tagi. Runner z ustawionym RUNNER_TAGS wypada z puli ogólnej, a praca bez tagów idzie do puli ogólnej. Puste znaczy pulę ogólną po obu stronach. Tagi są czytane raz, przy pierwszej rejestracji — późniejsza zmiana zmiennej nie robi nic, a zmienia je ponowna rejestracja.

Każde zlecenie zawodzi z carries no /usr/local/bin/aj-shim

Runner mówi to o jednym z czterech obrazów językowych i ma rację: aj-shim jest tym, co czyni oceniany przebieg mierzalnym, więc obraz sprzed jego powstania nie oceni niczego.

Host ma starą kopię. Milczą o tym dwie rzeczy, obie zamierzone: docker compose pull nie pobiera obrazów językowych — to wartości przekazywane Runnerowi, a nie usługi — a Runner pobiera obraz wyłącznie wtedy, gdy hosta nie ma go wcale, nigdy dlatego, że istnieje nowszy.

docker pull ghcr.io/algojudge/lang-gcc:0     # oraz clang, python, pypy
docker compose restart runner-1              # każdy runner-N, który uruchamiasz

Restart nie jest opcjonalny

Runner bada każdy obraz raz, przy pierwszym użyciu, i pamięta odpowiedź przez całe życie procesu. Ten, który już uznał, że obraz nie ma shima, będzie zawodził na każdym zleceniu także wobec poprawionej kopii — dopóki nie powstanie na nowo.

scripts/update.sh pobiera wszystkie cztery, więc instalacja utrzymywana nim tego nie spotka.

Nic zewnętrznego nie jest oceniane

Lista jest inna, bo zwykłe przyczyny tutaj nie występują. Dwie pierwsze opisuje Zewnętrzny sędzia i obie zostawiają pustą kolejkę przy kontenerze, który wygląda zdrowo: ocenianie zewnętrzne jest w tej instalacji wyłączone oraz zewnętrzny Runner nie został zatwierdzony.

Pozostałe trzy:

  1. Zadanie nie powstało jako zewnętrzne. Musi mieć typ archiwum i znacznik zewnętrznego; zadanie typowane lokalnie nigdy nie trafia do Runnera, który przekazuje dalej.
  2. props wersji nie podaje numeru zadania w archiwum. To zostaje odrzucone po nazwie, zanim cokolwiek opuści tę instalację.
  3. Tagi, dokładnie jak przy każdym Runnerze, czytane raz przy pierwszej rejestracji.

Runner restartuje się co kilka sekund

restart: unless-stopped zamienia każdą odmowę przy starcie w pętlę, a preflight.sh większości z nich nie zobaczy, bo z konfiguracją wszystko jest w porządku. Czytaj log:

docker compose logs external-runner | tail -20
docker compose logs runner-1 | tail -5

Konto w systemie oceniającym jest puste albo błędne. Zewnętrzny Runner odmawia już przy czytaniu konfiguracji — przed kluczem tożsamości, przed rejestracją — i nazywa ustawienie. Przy włączonym profilu preflight.sh odmawia wcześniej, więc stos podnoszony skryptami nigdy tu nie dochodzi. Spacja na początku albo na końcu hasła nie jest przyczyną: hasło idzie dalej dokładnie tak, jak je zapisano, i to celowo, bo obcięcie spacji raz już zepsuło logowanie.

the Server refused with 403: runner.nonce.unknown to inna usterka o tym samym objawie i dotyczy obu Runnerów: ten obraz jest starszy niż Server, u którego się rejestruje. Pojawia się dopiero od drugiego startu, bo to wolumen tożsamości sprawia, że Server rozpoznaje klucz, a odmowy rejestracji Runner nie ponawia. Pobieraj oba obrazy z tego samego wydania, zamiast pozwalać ruchomym tagom się rozjechać — dlaczego muszą chodzić razem, mówi Aktualizacja i wycofanie.

Nazwane ustawienie odpytywania albo dzierżawy to trzecia przyczyna: Runner sprawdza swoje odstępy nawzajem, zanim cokolwiek zrobi. Żadnej z nich nie da się osiągnąć wartościami, które dostarcza ten stos — biorą się z własnego .env. AJ_Poll__WaitSeconds powyżej 300 jest odrzucane, bo 300 to własny sufit Servera, a Runner odróżnia trzymane pobranie pracy od natychmiastowej odpowiedzi po czasie, jaki minął — czytałby więc każde trzymane jako puste. Żeby odpytywać rzadziej, służy AJ_Poll__MaxSeconds.

Archiwum twierdzi, że takie konto nie istnieje

Sprawdź nazwę użytkownika w samym archiwum — to ta sama, którą podaje się przy logowaniu. Wyszukiwanie odmawia wprost, gdy identyfikator użytkownika wynosi 0, bo dla nieznanej nazwy archiwum odpowiada właśnie 0, a 0 daje się sparsować jako liczbę: zanim ta odmowa powstała, każde odpytanie po cichu nie zwracało niczego.

Permission denied (os error 13) w Runnerze

Dwie różne przyczyny dają to jedno zdanie, a ono nie nazywa żadnej z nich: komunikat przychodzi z głębi klienta HTTP albo z warstwy sandboxa, bez ścieżki i bez numeru. preflight.sh sprawdza obie.

Pierwsza: gniazdo demona. DOCKER_GID nie zgadza się z grupą, do której należy gniazdo.

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock alpine stat -c '%g' /var/run/docker.sock

Na Docker Desktop to 0; na hoście linuksowym to grupa docker. preflight.sh uruchamia dokładnie to i ostrzega przy niezgodności — pytając kontener, a nie Twoją powłokę, bo pod Docker Desktop i WSL gniazdo widziane przez tę powłokę nie jest tym, które obsługuje demon. Usługa Runnera działa jako root, więc jest to przyczyną tylko tam, gdzie ktoś usunął to z compose.yaml — wtedy Runner zawodzi przy starcie i nigdy nie bierze żadnego zlecenia.

Druga: kontener zadania nie potrafi odczytać RUNNER_WORK_DIR. Runner zapisuje tam pliki zgłoszenia jako root, a każdy kontener zadania montuje ten katalog tylko do odczytu i czyta go jako uid 65534. Katalog ręcznie zamknięty przed innymi przechodzi próbę Runnera i nie przechodzi próby zadania, więc gniazdo działa bez zarzutu, Runner rejestruje się, bierze zlecenie — i każde bez wyjątku zawodzi natychmiast, co właśnie odróżnia te dwie przyczyny.

sudo chmod 755 /srv/algojudge/runner-work

Każde zlecenie zawodzi, a Runner napisał coś o cgroup

O limicie czasu rozstrzyga czas procesora, odczytany z cgroup, w której uruchamiany jest sandbox. Runner, który nie potrafi jej odczytać, nie ocenia bez niej — to komunikat ze startu, a nie z pojedynczego zlecenia:

docker compose logs runner-1 | head -40
Co mówiCo zrobić
cgroup w wersji 1host wstaje z hierarchią hybrydową. Dopisz systemd.unified_cgroup_hierarchy=1 do wiersza poleceń jądra i zrestartuj maszynę
sterownik cgroup, którego nie znadocker info --format '{{.CgroupDriver}}' musi wypisać cgroupfs albo systemd. Cokolwiek innego znaczy, że cgroup są wyłączone
nie potrafi odczytać hierarchiiktoś zmienił compose.yaml: brakuje montażu /sys/fs/cgroup, cgroup: host albo user: "0:0" w usłudze runnera

Oba sterowniki cgroup działają i żaden nie wymaga przestawiania demona. Wpis native.cgroupdriver w /etc/docker/daemon.json nie szkodzi i niczego nie daje.

Werdykty są poprawne, ale nie ma pomiaru pamięci

Zdarza się to wyłącznie na hoście ze sterownikiem cgroup systemd i Runner mówi o tym na poziomie ERROR przy każdym starcie. Jedna slice obsługuje tam wszystkie przebiegi, więc szczyt bierze się z wyzerowania memory.peak — interfejsu jądra, który pojawił się w Linuksie 6.12, a więc już nie w Ubuntu 24.04, za to w Debianie 13. O limicie czasu rozstrzyga czas procesora, więc każdy werdykt pozostaje w mocy, a szczyt samego ocenianego zgłoszenia też nie cierpi: ten czyta się z cgroup założonej świeżo na ten przebieg. Brakuje szczytu przebiegów, które nie są niczyim zgłoszeniem — kompilacji, programu weryfikującego, interaktora.

uname -r

Na starszym jądrze albo się z tym pogódź, albo daj demonowi sterownik cgroupfs, gdzie każdy szczyt pochodzi ze świeżej cgroup i nie wymaga niczego poza Linuksem 5.19:

// /etc/docker/daemon.json
{ "exec-opts": ["native.cgroupdriver=cgroupfs"] }

nginx nie startuje

Uruchom ten sam test co CI:

docker run --rm --add-host server:127.0.0.1 --add-host client:127.0.0.1 \
  -v "$PWD/nginx/algojudge.conf:/etc/nginx/conf.d/default.conf:ro" \
  -v "$PWD/nginx/snippets:/etc/nginx/snippets:ro" \
  -v "$PWD/certs:/etc/nginx/certs:ro" \
  nginx:1.30-alpine nginx -t

--add-host jest potrzebne, bo nginx rozwiązuje każdy upstream przy parsowaniu konfiguracji, więc poza siecią Compose zawodzi z host not found in upstream "server:8080" na konfiguracji, która jest zupełnie poprawna.

Zdarzyły się tu naprawdę dwa takie przypadki: zduplikowany proxy_read_timeout, bo include wstawia dyrektywę do bloku włączającego, a nginx odrzuca duplikaty wprost, zamiast nadpisywać; oraz brakujący certyfikat, bo certs/ było puste.

PostgreSQL nie startuje po aktualizacji

Wersja 18 przeniosła miejsce danych. PGDATA to /var/lib/postgresql/18/docker, a wolumen należy o poziom wyżej, do /var/lib/postgresql. Każdy poradnik napisany przed 18 mówi /var/lib/postgresql/data, a zamontowanie tego sprawia, że kontener odmawia startu.

compose.yaml robi to poprawnie. Wersja główna jest przypięta celowo — nieprzypięty postgres:latest przechodzący na 18 to sposób, w jaki to znaleziono.

docker compose ps nie pokazuje zdrowia Runnera

Oba Runnery mają pustą kolumnę zdrowia i mają ją zawsze; w żadnym z nich nie jest to usterka. Obraz Runnera nie deklaruje testu zdrowia, a obraz zewnętrznego nie może go mieć — nie ma powłoki, nie ma portu, nie ma o co zapytać. Dlatego docker compose up --wait uznaje obu za gotowych, gdy tylko działają, a działają także wtedy, gdy jeden z nich nie potrafi się zalogować. Czytaj ich logi.

Stos stoi, a przeglądarka nic nie pokazuje

  • Ostrzeżenie o certyfikacie jest oczekiwane, jeśli uruchomiłeś render-tls.sh: jest samopodpisany.
  • Pusta strona i 404 na /assets/…: obraz Client wymieniono bez Servera albo proxy cache'uje index.html. Jest serwowany z no-store dokładnie dlatego.
  • Strona przerwy technicznej: Server jest w oknie. Uruchom ./scripts/maintenance.sh status, a jeśli tak być nie powinno — off.

Skrypt mówi, że działa inny

another AlgoJudge maintenance script is running (pid 1234, lock: …/state/algojudge.lock.d)

Jeśli pid 1234 naprawdę działa, poczekaj. Jeśli nie, następne uruchomienie samo przejmie blokadę i to powie — blokada nigdy nie zostaje zakleszczona na stałe. Żeby wyczyścić ją ręcznie, usuń state/algojudge.lock.d.

Kopia nie jest kompletną kopią

backup.sh ostrzega przy każdym uruchomieniu, gdy STORAGE_KIND nie jest postgres, a .meta obok każdego zrzutu zapisuje to jako coverage=INCOMPLETE. Jeśli odtworzysz sam taki zrzut, dostaniesz instalację, której wiersze wskazują na bajty, których nie ma — co objawia się jako 503 przy każdym pobraniu, bez niczego, co nazwałoby przyczynę.

Odtwarzanie wyglądało na udane i nic nie zmieniło

Przeczytaj state/restore.log.

Ta awaria zdarzyła się naprawdę, przy pisaniu repozytorium wdrożeniowego: wywołanie Compose zawiodło, jego jednolinijkowy błąd zaraportowano jako ostrzeżenie pg_restore, a odtwarzanie nie zrobiło zupełnie nic, podczas gdy każda kontrola po nim przechodziła na danych, których nikt nawet nie tknął.

restore.sh bierze tożsamość każdej tabeli przed i porównuje ją po: --clean --if-exists nadaje każdej odtworzonej tabeli nową, a odtwarzanie, które nic nie zrobiło, zostawia stare tam, gdzie były. Ale prawdziwym testem jest ten, który to wyłapał: najpierw coś zmień, potem odtwórz, potem sprawdź, że zmiana zniknęła.

Na tej stronie