Aktualizacja i wycofanie
Najpierw pobierz, zamykaj późno, i to jedno, czego wycofanie nie cofa.
./scripts/update.sh
./scripts/update.sh --dry-run
./scripts/update.sh --no-gitKolejność jest tu najważniejsza
- Najnowsze wydanie repozytorium — nowe pliki Compose i skrypty, nigdy nowe
wersje obrazów. Tylko tag
vX.Y.Zi tylko do przodu, nigdymain: z wydania na dowolne nowsze, a z gałęzimainwyłącznie na wydanie, które zawiera jej bieżący commit. Samo przejście na nowe wydanie też jest „czymś nowym”: podmiana stosuje jego pliki, nawet jeśli żaden obraz się nie zmienił, a nieudana cofa repozytorium razem z obrazami. docker compose pull— najdłuższy krok, i nie kosztuje przerwy.- Nic nowego? Koniec. Nic nie zostaje zamknięte.
- Zrób kopię zapasową.
- Zamknij i poczekaj na drenowanie.
up -d, poczekaj, aż stos będzie zdrowy.- Zdrowy: zapisz digesty. Niezdrowy: wycofaj.
- Otwórz z powrotem i usuń tylko te obrazy, które należą do tego projektu.
Przerwa to kroki od 5 do 7 — zmierzone na około osiemnaście sekund na małej instalacji, w większości drenowanie.
Krok 4 nie jest opcjonalny i skrypt go nie pominie: aktualizacja bez kopii przed nią nie jest aktualizacją, którą ten skrypt zrobi. Jeśli kopia przed aktualizacją zawiedzie, nic nie zostaje zaktualizowane.
Co znaczy „coś nowego”
Nie sam tag. SERVER_TAG=0.2 wskazuje po każdym wydaniu z tej linii inny obraz,
więc „czy tag jest ten sam” odpowiadałoby „tak” w nieskończoność. Skrypt pyta
Compose, jaki obraz uruchomiłby teraz dla każdej usługi, i zestawia go
z identyfikatorem obrazu, na którym kontener naprawdę działa. Różnica między nimi
to dokładnie „coś nowego”.
Pytany jest Compose, a nie kontener, bo kontener zna wyłącznie referencję,
z której powstał — więc operator, który przypiął 0.2.0, a później wpisał
0.2.1, usłyszałby, że nic się nie zmieniło, podczas gdy pobrany przed chwilą
obraz leżałby nieużywany.
Cztery obrazy językowe porównuje się osobno
lang-gcc, lang-clang, lang-python i lang-pypy nie są usługami, więc
docker compose pull nigdy ich nie pobiera i żadne porównanie usług ich nie
widzi. Aktualizacja pobiera wszystkie cztery po nazwie i zestawia je z tym, co
zapisał state/current.lock; nowy obraz liczy się jako „coś nowego”.
Runner przyjmuje podmieniony obraz bez odtwarzania kontenera: to, co o nim pamięta, jest zapisane pod identyfikatorem obrazu, a pobrany obraz ma inny identyfikator. Kontener i tak tu powstaje na nowo, i to się przydaje z innego powodu — Runner pobiera swoje obrazy przy starcie, więc restart jest też tym, co je przynosi.
Wersje to tagi; to, co działa, to digest
Tagi leżą w .env. Digesty leżą w dwóch plikach blokad. Tag mówi, o co
poproszono; digesty mówią, na jakie obrazy ostatecznie wskazał.
state/current.lock powstaje, kiedy aktualizacja okaże się zdrowa, i nazywa to,
co działa. state/previous.lock powstaje tuż przed podmianą i nazywa to, co
zostało zastąpione — i to jego przywraca wycofanie.
Digesty nie mogą leżeć w repozytorium. To produkt, który wiele organizacji wdraża niezależnie, i żadna z nich nie może do niego commitować.
W .env.example stoi SERVER_TAG=0.2 — linia minor, dla której napisano ten
stos, bez v. Wydanie otagowane v0.2.1 publikuje tagi obrazów 0.2.1,
0.2, 0 i latest, więc 0.2 bierze przy następnej aktualizacji każdą
kolejną poprawkę z tej linii i nic poza tym.
Nie ruchoma wersja główna 0. Poniżej 1.0 wersja minor może zmienić to, co
robiła poprzednia — 0.2 nie jest zgodne z 0.1 — więc 0 przeprowadziłby
instalację przez taką zmianę, nie pytając o zgodę. Kto chce decydować o każdej
wersji, wpisuje 0.2.1; stos dla 0.3 przyjdzie jako nowe wydanie tego
repozytorium, razem z tym, czego wymaga przejście na nie. Tagu latest stos nie
przewiduje: instalacja, która zmieniła wersję tylko dlatego, że ktoś pobrał
obrazy, nie jest wdrożeniem.
Przejście z linii 0.1
0.2 nie jest zgodne z 0.1, a to repozytorium jest linią 0.2. Cztery tagi
produktowe to 0.2, więc instalacja nie przekracza granicy wersji minor przez
samo pobranie obrazów: przekracza ją wtedy, gdy przestawisz ją na nowe wydanie
tego repozytorium — bo to właśnie tam zapisano, czego taka zmiana wymaga.
Instalacja postawiona z v0.1.0 ma update.sh z tamtego tagu, starszy niż
zasada, że instalacja podąża za wydaniami: wykonuje git pull, który na wydaniu
kończy się samym ostrzeżeniem i zostawia pliki, jak były. Taką instalację trzeba
raz przestawić ręcznie:
git fetch --tags
git checkout v0.2.0
./scripts/preflight.sh
./scripts/update.shDalej podąża za wydaniami już sama. Wynikają z tego trzy rzeczy i żadna nie gubi niczego, co uczestnik mógłby zobaczyć.
Katalog roboczy Runnerów przenosi się z katalogu na hoście do wolumenu.
Linia 0.1 ustawiała RUNNER_WORK_DIR i RUNNER_CACHE_DIR; tutaj takich ustawień
nie ma, a preflight.sh zgłasza ustawienie, którego nic nie czyta. Stare
katalogi zostają tam, gdzie były, i nikt do nich nie zagląda: usuń z .env oba
klucze, a kiedy nabierzesz pewności — także same katalogi.
sudo rm -rf /srv/algojudge/runner-cache /srv/algojudge/runner-workPamięć podręczna paczek startuje pusta. Pierwsze zgłoszenie do każdego zadania na nowo pobiera paczkę i buduje zadeklarowany w niej checker — minuty, raz, na zadanie. Nie aktualizuj w poranek zawodów choćby z tego jednego powodu.
Demon na hoście z Runnerami musi być w wersji Docker Engine 26 lub nowszej
(albo Podman 5) i nie ma układu, który by to obchodził. Kontener sędziego
montuje podkatalog wspólnego wolumenu pamięci podręcznej, czyli montowanie
subpath, które pojawiło się właśnie wtedy; na starszym demonie Runner odmawia
oceniania, a preflight.sh odmawia jeszcze wcześniej — pokazuje wersję API
demona wobec 1.45.
Wolumen zostawiony pod tą nazwą zostaje podłączony, a nie zastąpiony
Instalacja starsza niż 2026-09-15 może wciąż mieć wolumen o nazwie
algojudge_runner-cache z układem, którego żaden dzisiejszy Runner nie czyta.
Wolumen pamięci podręcznej nazywa się tak samo, więc Compose podłącza ten stary,
zamiast utworzyć świeży. Usuń go przed pierwszym startem.
docker volume ls
docker volume rm algojudge_runner-cacheA wycofanie na linię 0.1 zostawia nową pamięć podręczną w spokoju, i to
celowo. Runner tamtej generacji trzyma swoje wpisy w dwuznakowych katalogach
u góry, nowszy — pod packages/, gdzie starszy kod nigdy nie zagląda. Starszy Runner nie
znajduje więc nic, pobiera od nowa, i oba układy leżą obok siebie, zamiast żeby
jeden trafił w ręce drugiego — a to oznaczałoby każde zgłoszenie upadające na
paczce, której nie da się otworzyć. Zostaje martwy ciężar, który widzi tylko
nowszy Runner; instalacja, która zostaje wycofana na stałe, może opróżnić pamięć
podręczną za cenę jednego pobrania.
docker compose stop runner-1 runner-2
docker volume rm algojudge_runner-cache
docker compose start runner-1 runner-2Najpierw zatrzymaj Runnery: dopóki działają, wolumen jest w użyciu i volume rm
odmawia, zamiast zrobić to po połowie.
Wycofanie
./scripts/rollback.shPrzywraca dokładnie te obrazy, które są w state/previous.lock — te, które
zastąpiła ostatnia aktualizacja — niezależnie od tego, na co wskazują dziś tagi,
przez nakładkę zapisaną do state/rollback.compose.yaml. Dotyczy to także
czterech obrazów językowych:
wracają jako AJ_Sandbox__Image__* przy każdym Runnerze, bo stary Runner obok
dzisiejszych sandboksów to jedyne zestawienie, w którym Runnera się nie testuje.
Do czasu usunięcia przyczyny podnoś stos z obu plików
Zwykłe docker compose up nie czyta nakładki, więc wstawia nowe obrazy
z powrotem.
docker compose -f compose.yaml -f state/rollback.compose.yaml up -dInstalacja, która nigdy się nie aktualizowała, nie ma dokąd wracać:
state/previous.lock zapisuje update.sh tuż przed podmianą obrazów.
state/current.lock nie jest zamiennikiem — nazywa to, co działa, więc jego
przywrócenie niczego by nie zmieniło, i skrypt mówi to wprost, zamiast zgłosić
wycofanie, po którym nic się nie dzieje.
Cofa o jedną aktualizację i ani kroku dalej. Instalacja, która od
interesującej ją wersji zaktualizowała się dwa razy, tą drogą do niej nie wróci;
tamte obrazy wciąż są w rejestrze, więc prowadzi do nich tag w .env.
Wycofanie nie cofa migracji
Jeśli aktualizacja przesunęła schemat, state/last-migration to zapisuje,
a rollback.sh tego nie przemilczy. Starszy Server wobec nowszego schematu nie
wystartuje, a jedyna droga powrotu to odtworzyć zrzut zrobiony bezpośrednio
przed — a to gubi wszystko, co zapisano od tamtej pory. Skrypt nazywa ten
zrzut.
Aktualizuj wszystkie hosty w tym samym oknie
A obrazy językowe razem z Serverem. Server i Runner rozmawiają protokołem,
który zmienia się między wersjami, a tagi, które dostarcza ten stos, to tagi
ruchome, pobierane niezależnie — nic więc nie powstrzyma update.sh przed
postawieniem nowego Servera obok obrazu Runnera sprzed miesiąca. Runner zbyt
stary wobec Servera, u którego się rejestruje, dostaje odmowę, a odmowy
rejestracji się nie ponawia: proces kończy pracę, restart: unless-stopped
zamienia to w pętlę, a preflight.sh tego nie zobaczy, bo z konfiguracją
wszystko jest w porządku. W logu stoi the Server refused with 403: runner.nonce.unknown.
Najdotkliwiej dotyczy to układu T2, gdzie Runnery stoją na
własnych hostach. Te hosty mają własny update.sh, a ten, którego nie
zaktualizowano razem z resztą, cichnie dopiero przy kolejnym restarcie — czyli
w sposób, którego nikt nie zauważy aż do zawodów.
Server zbyt stary dla zewnętrznego Runnera
Kierunek opisany wyżej ma swoje odbicie. Zewnętrzny Runner odnawia dzierżawy
i oddaje pracę zbiorczo, więc sprawdza to, zanim cokolwiek weźmie: wysyła
puste odnowienie, a Server, który odpowiada na nie 404, to Server, u którego
ten Runner nie utrzyma dzierżawy.
Mówi o tym i kończy pracę, nazywając trasę —
this Server does not serve POST runner/jobs/leases — i dalej: że ten Server
jest starszy niż trasy dzierżaw zbiorczych, których ten Runner potrzebuje, więc
albo zaktualizuj Server, albo uruchom Runnera z wydania, które do niego pasuje.
Kontener, który nie wstaje, to lepsza połowa tej sytuacji. Dowiedzieć się później znaczy: pula dzierżaw po cichu wygasa za procesem, który wygląda zdrowo, a każde zgłoszenie z tej puli trafia do archiwum drugi raz, kiedy jego zlecenie wróci do kolejki.
Sprawdzenie odbywa się po zatwierdzeniu Runnera, więc ten, który wciąż na
zatwierdzenie czeka, pisze o zatwierdzeniu. Cokolwiek innego niż 404 — Server
nieosiągalny, przerwa techniczna — nie należy do tego sprawdzenia: Runner
ostrzega, idzie dalej i przeczekuje to tak jak zawsze.
Runner w trakcie aktualizacji
Po SIGTERM Runner oddaje swoje zlecenie. Zatrzymuje ocenianie, mówi
Serverowi, że zlecenie jest wolne, sprząta uruchomione przez siebie kontenery
i kończy pracę. Inny Runner może wziąć to zlecenie w tej samej chwili, a nie
dopiero po dziesięciu minutach, gdy wygaśnie dzierżawa — i doręczenie nie liczy
się zgłoszeniu do puli prób, więc operator restartujący flotę nie zużywa prób
uczestnika.
Trzy razy, a potem już się liczy. Trzy pierwsze oddania są darmowe, tak samo jak doręczenie, o którym nikt się nigdy nie odezwał; każde następne zabiera jedną z pięciu prób, bo dalej Runner w pętli restartów pod nadzorcą i operator restartujący flotę wyglądają tak samo i rozróżnia je tylko licznik.
Praca już wykonana na tym zgłoszeniu idzie do kosza i robi ją od nowa ten, kto weźmie zlecenie następny. Zmienia się tylko to, że dzieje się to w sekundach, a nie w dziesięć minut.
RUNNER_STOP_GRACE jest właśnie na te wywołania i 30s to z zapasem
wystarczająco na kilka żądań HTTP i usunięcie paru kontenerów. Skrócenie go
poniżej tego, ile one trwają, zamienia zatrzymanie z powrotem w zabicie,
a zabity Runner zostawia swoje zlecenie dzierżawie. Przy domyślnych dla Compose
300s docker compose down spędza za to pięć minut w ciszy: zmierzone na tym
stosie, 302 s wobec 32 s.
O tym, jaki limit naprawdę trzyma kontener, decydują dwie rzeczy
Docker zapisuje ten limit na kontenerze w chwili jego utworzenia, więc down
odczekuje starą wartość, dopóki kontenery nie zostaną utworzone na nowo —
przy pierwszym up po pobraniu obrazów albo przez up --force-recreate.
docker inspect -f '{{.Config.StopTimeout}}' mówi, którą wartość trzyma dany
kontener.
O samej wartości decyduje Twój .env, a nie wartość zapasowa
z compose.yaml. Zmieniaj ją tam.
Czyste drenowanie to nadal maintenance.sh on --wait-closed najpierw na
Serverze — i tak właśnie robi update.sh: wtedy do Runnera w ogóle nie
trafia nowa praca, co jest schludniejsze niż każdy Runner oddający to, co przed
chwilą dostał.
Zewnętrzny Runner też obsługuje SIGTERM, a jego margines to
EXTERNAL_RUNNER_STOP_GRACE — sześćdziesiąt sekund, dwa razy tyle co u Runnera
oceniającego, choć już nie z powodu liczby: całą pulę zgłoszeń w toku oddaje
jednym żądaniem. Kiedy zamiast tego zostaje zabity, traci nie ocenianie w toku, tylko listę
tego, na co archiwum jeszcze nie odpowiedziało — a każde takie zgłoszenie zostaje
wysłane drugi raz, kiedy zlecenie wróci do kolejki. Rachunek jest w Przerwie
technicznej.
Gdy zawiodą obie połowy
Jeśli nowe obrazy nie wstaną zdrowe w 120 sekund, skrypt sam się wycofuje
i zapisuje ostatnie pięćdziesiąt linii logu Servera do
state/failed-update.log. Jeśli wycofanie też zawiedzie, skrypt przestaje
próbować: instalacja zostaje zamknięta, a log i zrzut sprzed aktualizacji są
nazwane. To nie jest coś, co skrypt powinien powtarzać.
Aktualizacja nie jest za Ciebie zaplanowana
cron/algojudge.cron dostarcza wpis aktualizacji zakomentowany. Odkomentuj
go dopiero wtedy, gdy raz odtworzyłeś kopię i wiesz, że działa — automatyczna
aktualizacja z nieprzetestowaną kopią za plecami to nocna okazja do utraty
instalacji.