Kilka drobnych zadań na moim domowym serwerze siedziało kiedyś w jednym crontabie: skrypt, który co parę minut synchronizuje dokumentację do strony tego bloga, i codzienne zadanie sprawdzające nowe domeny pod kątem klasyfikatora reklam i trackerów, zanim coś trafi na blokliste. Pewnej nocy serwer zrestartował się z powodu aktualizacji jądra, gdzieś koło 3:40. Zadanie, które miało wystartować o 3:00, po prostu się nie odpaliło. Żadnego błędu, żadnego maila, nic w logach, na które akurat patrzyłem. Dowiedziałem się o tym dwa dni później, kiedy coś, co to zadanie miało odświeżać, było dwa dni nieaktualne.
Cron działa dokładnie tak, jak go zaprojektowano. Odpala się o konkretnej godzinie zegarowej. Jeśli maszyna w tym momencie nie żyje, zadanie się nie wykonuje, a cron nie pamięta, co przegapił. To nie jest błąd. Po prostu nie tego potrzebowałem.
Czego cron nie robi
Kilka braków, w kolejności, w jakiej na nie trafiałem:
- Przegapione uruchomienie zostaje przegapione. Nic go później nie nadrabia.
- Błędy lecą do lokalnej skrzynki mailowej, której nikt nie czyta, albo giną całkowicie, jeśli stdout jest przekierowany do
/dev/null, a tak wygląda większość jednolinijkowych wpisów w cronie. - Nie da się zadeklarować “nie startuj, dopóki Docker nie wstanie”. Po prostu liczysz na to, że compose skończy, zanim zadanie będzie tego potrzebować.
- Brak jittera. Pięć zadań ustawionych na
0 3 * * *walczy o ten sam CPU i dysk w tej samej sekundzie. - Sprawdzenie, czy zadanie się wykonało i co wypisało, oznacza przypominanie sobie, do którego pliku logów przekierowywałeś, o ile w ogóle przekierowywałeś.
Nic z tego nie ma znaczenia dla pojedynczego, jednorazowego skryptu na maszynie, która się nie restartuje. Zaczyna mieć znaczenie, kiedy zadań jest kilka, a serwer sam się restartuje z powodu aktualizacji na własnym harmonogramie.
Model dwóch plików
Systemd timer łączy jednostkę typu timer z jednostką typu service. Dla zadania synchronizacji dokumentacji wygląda to tak:
| |
| |
Dwa pliki to więcej ceremoniału niż jedna linijka w crontabie, i przy pierwszym takim wpisie trochę na to narzekałem. Ale jednostka service jest użyteczna sama w sobie. systemctl start docs-site-sync.service uruchamia zadanie od razu, z pełnym logowaniem, bez czekania na harmonogram, czego wpis w crontabie zrobić nie potrafi.
Harmonogram względny zamiast zegarowego
Ten konkretny timer w ogóle nie korzysta z wyrażenia kalendarzowego. OnBootSec=30 i OnUnitActiveSec=2min oznaczają “30 sekund po starcie systemu, potem 2 minuty po zakończeniu poprzedniego uruchomienia”. To inna gwarancja niż crontabowe */2 * * * *, które odpala się co dwie minuty zegarowo, niezależnie od tego, czy poprzednie uruchomienie jeszcze trwa.
Dla zadań, które faktycznie potrzebują kalendarza, jest OnCalendar:
| |
RandomizedDelaySec rozkłada realny moment startu na okno 30 minut zamiast pozwalać wszystkim codziennym zadaniom uderzyć o północy jednocześnie. Można sprawdzić, na co dane wyrażenie się faktycznie przekłada, bez czekania:
| |
Wypisuje kilka najbliższych momentów uruchomienia. Szkoda, że nie znałem tej komendy wcześniej, zamiast przez dekadę zgadywać składnię crona metodą prób i błędów.
Persistent=true to prawdziwa naprawa
To ta linijka rozwiązała pierwotny problem. Persistent=true mówi systemd, żeby zapamiętał ostatni moment uruchomienia timera, i jeśli maszyna była wyłączona, kiedy miał się odpalić ponownie, uruchomił go raz, jak tylko system wróci do życia. Zadanie klasyfikatora potrafiło po cichu zniknąć na cały dzień, jeśli restart akurat trafił w jego okno. Teraz przegapione uruchomienie o północy po prostu dzieje się, gdy tylko maszyna znów działa, czyli dokładnie to, czego przez cały ten czas chciałem od anacron w cronie i nigdy nie dostałem, głównie dlatego, że anacron też nigdy porządnie nie skonfigurowałem.
Zależności zamiast sleep i nadziei
Jednostka typu oneshot, która potrzebuje działającego kontenera, może to zadeklarować wprost, zamiast zgadywać:
| |
To sleep 15 nadal tam jest, bo “kontener działa” i “kontener jest gotowy przyjmować połączenia” to dwa różne fakty, a nie chciało mi się pisać prawdziwego sprawdzenia gotowości dla tego akurat skryptu. Systemd daje kolejność, nie wszechwiedzę. Restart=on-failure razem z RestartSec to coś, czego cron w ogóle nie potrafi, chyba że ręcznie opakujesz całe zadanie w pętlę ponawiającą.
Co realnie się poprawiło
systemctl list-timers pokazuje wszystkie timery z ostatnim i najbliższym zaplanowanym uruchomieniem w jednej tabeli. journalctl -u docs-site-sync.service pokazuje dokładnie, co zadanie wypisało i czy zakończyło się kodem innym niż zero, z prawdziwymi znacznikami czasu, przeszukiwalnie, i zachowane między restartami, jeśli journal jest trwały. Wcześniej grepowałem plik logów i liczyłem, że skrypt coś tam zapisał mniej więcej w formacie, który pamiętałem. Teraz nie muszę liczyć.
Gdzie cron nadal wygrywa
Nie wyrzuciłem crona z maszyny. Dla pojedynczego, jednorazowego wpisu typu “odpal to o 2:00, nie zależy mi, jeśli czasem się przegapi” linijka w crontabie to jedna linijka, a timer to dwa pliki. Ten narzut jest realny i sam sięgnąłbym po crona przy czymś naprawdę jednorazowym. Ale dla wszystkiego, co musi działać niezawodnie, nadrabiać po przestoju, czekać na realną zależność albo ponawiać po błędzie, te dwa pliki zwracają się już przy pierwszym zadaniu, które po cichu się nie wykonało, a ty zauważyłeś to dopiero po dwóch dniach.
Jeśli ciekawi cię, co dokładnie stoi za tymi dwoma przykładowymi zadaniami, klasyfikator domen reklamowych i trackerów to ten codzienny, a audyt backupów homelabu opisuje zadanie crona, które siedziało tuż obok niego, zanim wszystko przeniosłem.