SSD Nodes Learn 🎉 VPS od $5.50/mies.
Przewodniki Matt ConnorAutor: Matt Connor

Dlaczego systemd nie restartuje usługi po awarii

Dyrektywa Restart= monitoruje wyłącznie główny proces jednostki. Awaria procesu potomnego w cgroup nie wyzwala restartu. Dowiedz się, jak Type= i limity wpływają na proces.

Krótka odpowiedź: polityki restartu systemd monitorują jeden proces

Polityki restartu systemd monitorują jeden proces na jednostkę: proces główny. Restart= odczytuje kod wyjścia wyłącznie tego jednego procesu. Grupa kontrolna (cgroup) jednostki może zawierać dwadzieścia procesów; jeśli jeden z nich ulegnie awarii, jednostka pozostanie w stanie active (running), ponieważ proces główny nadal działa. Z punktu widzenia systemd nie wystąpiła żadna awaria, więc nie następuje restart.

systemd posiada wiedzę o pozostałych procesach. Zamyka je podczas zatrzymywania jednostki, wlicza ich pamięć do limitów jednostki, nakłada na nie limity CPU oraz wyświetla je w systemctl status. Systemd po prostu nigdy nie odczytuje ich kodu wyjścia. Logika restartu oraz cgroup to dwie odrębne kwestie, a niniejszy przewodnik w dużej mierze poświęcony jest różnicom między nimi.

Co zawiera cgroup i co odczytuje logika restartu

Cgroup (control group) to obiekt jądra systemu, który zarządza zestawem procesów. Każda jednostka usługi otrzymuje własną grupę, nazwaną zgodnie z nazwą jednostki. Proces nie może jej opuścić. Procesy potomne dziedziczą cgroup po procesie nadrzędnym, a proces bez uprawnień nie może przenieść się w inne miejsce. Dzięki temu systemd może skutecznie wyczyścić demona, który wykonuje podwójne forkowanie, czego stare skrypty init nigdy nie robiły w sposób niezawodny.

Należy zestawić oba fakty:

systemd-cgls --unit myapp.service
systemctl show -p MainPID -p NRestarts -p Restart -p RestartUSec myapp.service

systemd-cgls wyświetla każdy proces w jednostce. MainPID to pojedyncza liczba, którą odczytuje polityka restartu. Jeśli te dwie wartości są niezgodne z modelem mentalnym administratora, błąd leży w tej rozbieżności. MainPID=0 jest gorsze niż błędny PID: oznacza, że systemd nie śledzi niczego, więc żadna wartość Restart= nigdy nie zostanie wyzwolona.

Istnieje jeden rzeczywisty wyjątek od zasady głównego procesu. Jeśli mechanizm out-of-memory killer jądra systemu zabije jakikolwiek proces wewnątrz cgroup jednostki, systemd wykryje to, ponieważ monitoruje plik memory.events danej grupy. OOMPolicy= decyduje o dalszych krokach, a wartością domyślną jest stop: cała jednostka zostaje zatrzymana, wynik jest rejestrowany jako oom-kill i jest traktowany jako awaria, co wyzwala Restart=on-failure. Dziennik systemowy informuje o tym wprost.

myapp.service: A process of this unit has been killed by the OOM killer.
myapp.service: Failed with result 'oom-kill'.

Zatem proces potomny zabity z powodu braku pamięci powoduje zatrzymanie jednostki, podczas gdy ten sam proces potomny kończący się błędem segmentacji (segmentation fault) – nie. W przypadku ustawiania limitów pamięci dla jednostki, należy przeczytać w jaki sposób MemoryMax oraz CPUQuota odnoszą się do cgroup jednostki przed dostosowaniem polityki restartu, ponieważ te dwie funkcje spotykają się właśnie w tym miejscu.

Jak Type= wybiera główny proces

Type= w sekcji [Service] nie dotyczy wyłącznie kolejności uruchamiania. Jest to reguła decydująca o tym, który PID (identyfikator procesu) stanie się MainPID, co jest równoznaczne z określeniem, co Restart= może monitorować.

  • Type=simple jest wartością domyślną. Głównym procesem jest ten, który systemd uruchamia z ExecStart=. systemd oznacza jednostkę jako uruchomioną natychmiast, zanim jeszcze sprawdzi, czy exec zadziałało. Literówka w ścieżce do pliku binarnego powoduje, że zadanie startowe kończy się sukcesem, a Main process exited, code=exited, status=203/EXEC następuje chwilę później.
  • Type=exec zachowuje się jak simple, z tą różnicą, że zadanie startowe czeka, aż exec zakończy się powodzeniem. Dzięki temu wspomniana wcześniej literówka skutkuje uczciwym błędem uruchomienia. Wymaga to systemd w wersji 240 lub nowszej, co jest standardem w każdej wspieranej dystrybucji. Należy preferować to rozwiązanie zamiast simple.
  • Type=forking zakłada, że proces z ExecStart= uruchomi demona w tle, a następnie zakończy działanie. systemd czeka na zakończenie procesu nadrzędnego, a następnie szuka właściwego demona. Należy podać PIDFile=. Bez niego GuessMainPID= (włączone domyślnie) działa tylko wtedy, gdy w cgroup pozostaje dokładnie jeden proces. Pozostawienie dwóch procesów sprawia, że MainPID pozostaje 0.
  • Type=notify oznacza, że usługa wywołuje sd_notify(3) i wysyła READY=1, gdy jest gotowa do obsługi ruchu. Może również wysłać MAINPID=, aby przekazać systemd inny proces do śledzenia. NotifyAccess= domyślnie przyjmuje main, więc powiadomienie wysłane przez proces potomny jest ignorowane, a w dzienniku widnieje PID, z którego ono pochodzi.
  • Type=oneshot nie posiada trwałego głównego procesu. Jednostka przechodzi w stan nieaktywny, gdy tylko ExecStart= się zakończy, chyba że ustawiono RemainAfterExit=yes. Restart=always oraz Restart=on-success są tutaj odrzucane z komunikatem Service has Restart= set to either always or on-success, which isn't allowed for Type=oneshot services. Refusing.. Pozostałe wartości, w tym on-failure, są akceptowane.

Warto zapamiętać dwa błędy Type=forking, ponieważ każdy z nich pozostawia jednostkę w stanie, który wygląda na uszkodzony bez widocznej przyczyny:

myapp.service: Can't open PID file /run/myapp.pid (yet?) after start: No such file or directory
myapp.service: New main PID 4711 does not belong to service, and PID file is not owned by root. Refusing.

Pierwszy oznacza, że demon zapisuje plik PID w innym miejscu lub zapisuje go później, niż oczekuje tego systemd. Drugi oznacza, że plik PID wskazuje na proces spoza cgroup jednostki, czego systemd odmawia, ponieważ zapisywalny plik PID mógłby stać się sposobem na wymuszenie wysyłania sygnałów przez systemd do dowolnego procesu w systemie.

Dlaczego skrypt opakowujący maskuje zakończenie pracy procesów potomnych

Oto schemat, który prowadzi do pytania zawartego w tytule.

#!/bin/bash
/usr/local/bin/myapp-web &
/usr/local/bin/myapp-worker &
wait

Jednostką jest Type=simple, więc głównym procesem jest powłoka. wait bez argumentów kończy działanie dopiero po zakończeniu wszystkich procesów potomnych. Po zabiciu procesu roboczego powłoka nadal oczekuje na proces sieciowy, więc powłoka nie kończy pracy, przez co MainPID nie kończy działania, a Restart= nigdy nie jest sprawdzane. Grupa kontrolna (cgroup) zawiera teraz o jeden proces mniej, systemctl status wypisuje krótsze drzewo, a jednostka nadal ma status active (running). Żaden mechanizm w systemd nie monitoruje zmian w tym drzewie.

Druga wersja tego samego błędu jest mniej oczywista:

ExecStart=/bin/sh -c 'export APP_ENV=production; /usr/local/bin/myapp'

Głównym procesem jest powłoka, a nie myapp. W momencie systemctl stop, systemd wysyła sygnał SIGTERM do głównego procesu, a powłoka oczekująca na proces potomny w pierwszym planie nie przekazuje tego sygnału dalej. Zatrzymanie trwa wtedy pełne TimeoutStopSec, domyślnie 90 sekund, i kończy się w następujący sposób:

myapp.service: State 'stop-sigterm' timed out. Killing.
myapp.service: Killing process 4711 (myapp) with signal SIGKILL.

Rozwiązaniem jest exec. Należy użyć exec /usr/local/bin/myapp, dzięki czemu powłoka zostanie zastąpiona przez program, a MainPID będzie bezpośrednio tym programem, do którego dotrą sygnały. Jeszcze lepiej jest usunąć powłokę i użyć Environment= lub EnvironmentFile= w jednostce. Należy zauważyć, że ten błąd jest maskowany, gdy ciąg -c zawiera tylko jedno polecenie, ponieważ zarówno bash, jak i dash optymalizują ten przypadek do bezpośredniego exec. Dodanie drugiego polecenia do ciągu sprawia, że powłoka pozostaje aktywna przed uruchomionym programem.

Odtworzenie problemu na testowym VPS w dwie minuty

Zapisz powyższy skrypt opakowujący jako /usr/local/bin/two-children.sh, nadaj mu uprawnienia do wykonywania za pomocą chmod +x i zastąp dwie ścieżki programów za pomocą sleep 3600. Wskaż jednostkę na ten plik za pomocą Type=simple i Restart=on-failure, a następnie wykonaj systemctl daemon-reload i uruchom ją. Uruchom systemd-cgls --unit two-children.service i zanotuj trzy identyfikatory PID: powłokę oraz jej dwa procesy potomne. Zabij jeden proces potomny za pomocą sudo kill <pid>. Sprawdź ponownie jednostkę. Drzewo jest krótsze o jeden proces, stan to nadal active (running), a w dzienniku nie ma żadnych nowych wpisów. Teraz uruchom zamiast tego sudo kill -9 <shell pid>. Jednostka zgłasza błąd, pozostały proces potomny zostaje usunięty, ponieważ KillMode=control-group jest wartością domyślną, a dziennik pokazuje Scheduled restart job, restart counter is at 1.

Pełny zakres opcji Restart= oraz dlaczego on-failure jest lepsze niż always

Restart= przyjmuje jedną z siedmiu wartości, a kluczowe rozróżnienie polega na tym, co uznaje się za poprawne zakończenie pracy. systemd traktuje kod wyjścia 0, każdy kod wymieniony w SuccessExitStatus= oraz sygnały SIGHUP, SIGINT, SIGTERM i SIGPIPE jako poprawne zakończenie. Wszystko inne, w tym SIGKILL i SIGSEGV, jest uznawane za niepoprawne.

  • no to wartość domyślna. Jednostka nigdy nie restartuje się automatycznie, dlatego jednostka bez linii Restart= kończy działanie przy pierwszej awarii i pozostaje wyłączona.
  • on-success restartuje tylko po poprawnym zakończeniu pracy.
  • on-failure restartuje w przypadku niezerowego kodu wyjścia, niepoprawnego sygnału, przekroczenia limitu czasu startu lub stopu albo wygaśnięcia watchdog.
  • on-abnormal restartuje w przypadku niepoprawnego sygnału, przekroczenia limitu czasu lub wygaśnięcia watchdog, ale nigdy przy zwykłym niezerowym kodzie wyjścia.
  • on-abort restartuje tylko w przypadku niepoprawnego sygnału, co oznacza awarię.
  • on-watchdog restartuje tylko wtedy, gdy wygaśnie WatchdogSec=.
  • always restartuje w każdym z powyższych przypadków, w tym przy poprawnym zakończeniu z kodem 0.

on-failure jest właściwym ustawieniem domyślnym dla długo działającego demona. Przywraca usługę po awarii i pozostawia w spokoju proces zakończony celowo kodem exit 0. always jest przeznaczone dla programów, które kończą pracę poprawnie z przyczyn niezależnych, takich jak klient tunelu, który zwraca 0, gdy zdalny koniec rozłącza połączenie. Kosztem always jest maskowanie błędów: usługa, która startuje, odczytuje uszkodzony plik konfiguracyjny, loguje błąd i kończy pracę z kodem 0, będzie restartować się w nieskończoność, a jedynym objawem będzie rosnący licznik restartów.

SuccessExitStatus= przesuwa granicę między poprawnym a niepoprawnym zakończeniem. Borg zwraca 1 dla ostrzeżeń i 2 dla błędów, więc jednostka kopii zapasowej bez SuccessExitStatus=1 jest oznaczana jako nieudana za każdym razem, gdy pominie jeden nieczytelny plik. RestartPreventExitStatus= wymienia kody, które blokują restart nawet w trybie always, co jest poprawnym sposobem na poinformowanie programu, że nie powinien wznawiać pracy. RestartForceExitStatus= działa odwrotnie. Zadanie kopii zapasowej powinno znajdować się w jednostce Type=oneshot wyzwalanej przez timer, a nie w pętli restartów, a para usługi i timera uruchamiająca zadanie według harmonogramu jest wzorcem, który należy tam zastosować.

Jedno ostrzeżenie dotyczące testowania. Zatrzymanie usługi za pomocą zwykłego kill <pid> wysyła sygnał SIGTERM, który znajduje się na liście poprawnych zakończeń, więc Restart=on-failure słusznie nie podejmuje żadnych działań, co może prowadzić do błędnego wniosku, że konfiguracja jest uszkodzona. Zamiast tego należy użyć kill -9 <pid> lub systemctl kill -s SIGKILL myapp.service. Należy również pamiętać, że żadna wartość Restart= nie zadziała po systemctl stop lub gdy jednostka została zatrzymana, ponieważ zależność BindsTo= lub PartOf= przestała istnieć. Zadanie zatrzymania nie jest awarią.

RestartSec oraz domyślne 100 milisekund

RestartSec= to pauza między zatrzymaniem jednostki a jej ponownym uruchomieniem przez systemd; wartość domyślna wynosi 100 milisekund. Sprawdź, co faktycznie wczytała Twoja jednostka:

systemctl show -p RestartUSec -p StartLimitIntervalUSec -p StartLimitBurst myapp.service

Jednostka, w której nie ustawiono tej wartości, wyświetla RestartUSec=100ms. Wartość domyślna jest odpowiednia dla usługi, która ulega awarii sporadycznie i szybko wraca do działania. Jest ona jednak nieprawidłowa dla usługi, która w ogóle nie może wystartować, ponieważ pięć prób restartu nastąpi w ciągu pół sekundy, co wyzwala limit częstotliwości opisany poniżej. W przypadku każdej usługi oczekującej na bazę danych, punkt montowania lub trasę sieciową, ustaw RestartSec=5s lub więcej.

Od sierpnia 2026 roku systemd w wersji 254 i nowszych oferuje również RestartSteps= oraz RestartMaxDelaySec=, które zwiększają opóźnienie z poziomu RestartSec= aż do wartości maksymalnej w miarę kolejnych prób. Ubuntu 24.04 zawiera systemd 255 i obsługuje te parametry. Debian 12 zawiera systemd 252 i ich nie posiada. Zwiększające się opóźnienia są właściwym rozwiązaniem, gdy zależność może być niedostępna przez dłuższy czas.

Co w rzeczywistości oznacza komunikat "start request repeated too quickly"

Jest to stan, w którym systemd arbitralnie rezygnuje z uruchomienia usługi. Mechanizm opiera się na liczniku. Zasada jest następująca: jeśli jednostka zostanie uruchomiona więcej niż StartLimitBurst= razy w ciągu StartLimitIntervalSec=, systemd odmawia kolejnej próby i przełącza usługę w stan awarii (failed). Domyślne ustawienia to 5 uruchomień w ciągu 10 sekund.

Dziennik zdarzeń pokazuje następującą sekwencję:

myapp.service: Scheduled restart job, restart counter is at 5.
myapp.service: Start request repeated too quickly.
myapp.service: Failed with result 'start-limit-hit'.
Failed to start myapp.service - My application.

a systemctl start podaje rozwiązanie wprost:

Job for myapp.service failed because start of the service was attempted too often. See "systemctl status myapp.service" and "journalctl -xeu myapp.service" for details. To force a start use "systemctl reset-failed myapp.service" followed by "systemctl start myapp.service" again.

Polecenie systemctl reset-failed myapp.service zeruje licznik i czyści stan awarii. Żadne inne działanie tego nie zrobi, więc zwykłe systemctl start będzie odrzucane do momentu wykonania resetu. Ręczne uruchomienia również wliczają się do limitu, więc kilka niecierpliwych wywołań systemctl restart w trakcie edycji pliku konfiguracyjnego może wywołać ten stan bez żadnej awarii samej usługi.

Elementem wprowadzającym w błąd jest fakt, że start-limit-hit nigdy nie informuje, dlaczego usługa zawiodła. Komunikat wskazuje jedynie, że usługa zawiodła wielokrotnie i w krótkim czasie. Rzeczywista przyczyna znajduje się w liniach dziennika powyżej.

Oba ustawienia powinny znajdować się w sekcji [Unit]. Można natknąć się na przykłady umieszczające je w [Service], co starsze wersje systemd akceptowały, i stąd bierze się zamieszanie. Należy umieszczać je w [Unit], a następnie sprawdzić, co załadował systemd za pomocą systemctl show, ponieważ tylko załadowana wartość jest wiążąca.

[Unit]
Description=My application
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Type=exec
ExecStart=/usr/local/bin/myapp
Restart=on-failure
RestartSec=10s

Powyższa konfiguracja pozwala jednostce na pięć prób w ciągu pięciu minut, zanim systemd zrezygnuje. Ustawienie StartLimitIntervalSec=0 całkowicie wyłącza limit, jednak należy mieć świadomość konsekwencji: usługa, która nie może wystartować, będzie ponawiać próby w nieskończoność, zapisując każdorazowo wpis w dzienniku. Domyślne wartości dla całego systemu znajdują się w /etc/systemd/system.conf jako DefaultStartLimitIntervalSec= oraz DefaultStartLimitBurst=.

Jedno z powiązanych ustawień wymaga ostrzeżenia. StartLimitAction= określa, co dzieje się po osiągnięciu limitu, i akceptuje wartości takie jak reboot, reboot-force oraz poweroff. Wartością domyślną jest none, która oznacza przejście jednostki w stan awarii bez dalszych działań systemowych. W przypadku zdalnego serwera VPS, ustawienie poweroff może spowodować wyłączenie maszyny, która pozostanie niedostępna do momentu interwencji przez konsolę dostawcy.

Rozwiązanie pierwsze: jeden proces na jednostkę

Jest to odpowiedź w niemal każdym przypadku. Jeśli muszą działać dwa programy, należy utworzyć dwie jednostki. Każda z nich posiada wtedy rzeczywisty proces główny, rzeczywisty status zakończenia oraz własną politykę restartu. Otrzymuje się również oddzielne logi, oddzielne limity zasobów oraz oddzielne liczniki restartów, co jest pożądane o trzeciej nad ranem.

Relacje między jednostkami należy wyrazić w plikach jednostek, a nie w skrypcie powłoki.

  • After= określa tylko kolejność uruchamiania. Nie mówi nic o awariach.
  • Requires= uruchamia drugą jednostkę wraz z tą pierwszą i zatrzymuje tę pierwszą, jeśli druga zostanie jawnie zatrzymana.
  • BindsTo= to Requires= oraz przypadek, który jest istotny: ta jednostka zatrzymuje się, gdy druga zatrzyma się z jakiegokolwiek powodu, w tym z powodu awarii. Należy połączyć to z After=, w przeciwnym razie kolejność będzie niezdefiniowana.
  • PartOf= propaguje zatrzymanie i restart w dół, więc systemctl restart myapp.target dociera do każdej jednostki, która jest PartOf= względem niej.
  • Upholds= (systemd 249 i nowsze, czyli Ubuntu 22.04 i późniejsze) utrzymuje wskazaną jednostkę w stanie uruchomionym: jeśli się zatrzyma, systemd uruchamia ją ponownie. Podlega ona temu samemu limitowi częstotliwości uruchamiania co wszystko inne.

Proces roboczy, który nigdy nie może działać bez swojego serwera API i który systemd utrzymuje przy życiu, gdy API działa:

# /etc/systemd/system/myapp-api.service
[Unit]
Description=myapp API server
Wants=network-online.target
After=network-online.target
Upholds=myapp-worker.service

[Service]
Type=exec
User=myapp
ExecStart=/usr/local/bin/myapp serve
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target
# /etc/systemd/system/myapp-worker.service
[Unit]
Description=myapp background worker
BindsTo=myapp-api.service
After=myapp-api.service
StartLimitIntervalSec=120
StartLimitBurst=5

[Service]
Type=exec
User=myapp
ExecStart=/usr/local/bin/myapp worker
Restart=on-failure
RestartSec=5s

Proces roboczy nie posiada sekcji [Install] i nigdy nie jest włączany ręcznie. Jednostka API pobiera go za pomocą Upholds=, więc systemctl enable --now myapp-api.service jest jedynym poleceniem, które należy wykonać. Przeładuj i sprawdź, jak systemd zinterpretował tę parę:

sudo systemctl daemon-reload
systemd-analyze verify /etc/systemd/system/myapp-worker.service
systemctl list-dependencies myapp-api.service

systemd-analyze verify nie wyświetla niczego, gdy plik jest poprawny. Każdy komunikat wyjściowy oznacza problem, zazwyczaj klucz, którego systemd nie rozpoznaje w sekcji, w której został umieszczony, lub zależność od jednostki, która nie istnieje.

Poprawka druga: Type=notify, czyli systemd wie więcej niż tylko PID

Jeśli program obsługuje protokół powiadomień systemd, należy z niego skorzystać. Dzięki Type=notify usługa informuje systemd o gotowości, co sprawia, że kolejność uruchamiania staje się faktyczna, a nie oparta na założeniach. Pozwala to również użyć MAINPID=, aby wskazać systemd właściwy proces, zamiast polegać na procesie uruchamiającym.

WatchdogSec= to element warty wdrożenia. Po jego ustawieniu usługa musi wysyłać WATCHDOG=1 przez sd_notify(3) z określoną częstotliwością. Gdy komunikaty przestają napływać, systemd kończy działanie usługi za pomocą sygnału SIGABRT i oznacza ją jako nieudaną, co sprawia, że Restart=on-failure lub Restart=on-watchdog przywraca ją do działania. Jest to jedyny wbudowany sposób na zrestartowanie procesu, który działa, ale zawiesił się, czego nie wykryje żadna polityka oparta na kodzie wyjścia.

[Service]
Type=notify
NotifyAccess=main
ExecStart=/usr/local/bin/myapp serve
WatchdogSec=30s
Restart=on-failure
RestartSec=5s

Zadziałanie mechanizmu watchdog jest widoczne w dzienniku jako myapp.service: Watchdog timeout (limit 30s)!, po czym następuje przerwanie procesu. Jeśli jednostka pozostaje w stanie activating (start) do momentu upłynięcia TimeoutStartSec, oznacza to, że READY=1 nigdy nie dotarło: albo program nie obsługuje protokołu, albo NotifyAccess=main odrzuca powiadomienie pochodzące z procesu potomnego, co dziennik raportuje z uwzględnieniem obu PID-ów.

W przypadku oprogramowania udostępniającego punkt końcowy HTTP do sprawdzania stanu, ale pozbawionego obsługi sd_notify, uczciwymi rozwiązaniami są: mała jednostka typu timer, która odpytuje punkt końcowy i wywołuje systemctl restart, lub powierzenie sprawdzania środowisku uruchomieniowemu kontenerów, do czego służy Compose healthchecks and their restart behaviour.

Rozwiązanie trzecie: nadzorca wewnątrz jednostki, tylko w ostateczności

Niektóre oprogramowanie jest dostarczane jako pakiet procesów uruchamianych przez launcher, którego nie można rozdzielić. W takim przypadku należy uruchomić nadzorcę wewnątrz jednostki i zaakceptować konsekwencje: systemd monitoruje nadzorcę, nadzorca monitoruje resztę, a polityka restartu jest zdefiniowana w dwóch różnych plikach.

Typowym przykładem jest środowisko uruchomieniowe kontenerów. Jednostka docker compose lub podman realizuje dokładnie ten wzorzec, gdzie polityka restartu dla poszczególnych kontenerów jest określona w pliku Compose, a jednostka systemd odpowiada jedynie za utrzymanie działania środowiska uruchomieniowego. Jeśli taka jest struktura systemu, jednostka uruchamiająca stos Compose podczas startu przedstawia działającą wersję, wyjaśniając również, dlaczego Type=oneshot z RemainAfterExit=yes jest tam zazwyczaj właściwym wyborem.

Grupy kontrolne (cgroups) nadal działają na korzyść administratora. Wszystkie procesy uruchomione przez nadzorcę pozostają wewnątrz cgroup jednostki, więc MemoryMax=, CPUQuota= oraz czyszczenie zasobów podczas zatrzymywania nadal obejmują całe drzewo procesów. Delegowana jest jedynie decyzja o restarcie.

Niezależnie od wybranego nadzorcy, nie należy bez przemyślenia ustawiać Restart=always dla jednostki zewnętrznej przy jednoczesnym stosowaniu agresywnej polityki restartu wewnątrz niej. Dwie warstwy logiki restartu, z których każda posiada własny mechanizm opóźnień (backoff), prowadzą do niestabilnej pracy usługi przez wiele minut oraz dziennika zdarzeń, który nie wyjaśnia przyczyny problemu.

ExitType=cgroup nie oznacza "restartuj, gdy zginie dowolny proces"

ExitType= (systemd 250 i nowsze, dostępne w Ubuntu 24.04 oraz Debian 12) to ustawienie, które użytkownicy znajdują podczas szukania rozwiązania tego problemu, a działa ono odwrotnie, niż sugeruje nazwa. Wartość domyślna, ExitType=main, oznacza, że usługa jest uznawana za zatrzymaną, gdy kończy się proces główny. ExitType=cgroup oznacza, że usługa jest uznawana za działającą, dopóki nie zakończy się ostatni proces w cgroup.

Zatem ExitType=cgroup sprawia, że jednostka jest mniej, a nie bardziej wrażliwa na śmierć pojedynczego procesu. Jest to właściwe ustawienie dla programu, który uruchamia proces potomny (fork) i kończy proces nadrzędny bez zapisywania pliku PID, przez co Type=forking nie może odnaleźć demona. Jest to błędne ustawienie dla opisanego tutaj przypadku awarii.

Nie istnieje wartość Restart= oznaczająca "restartuj jednostkę, gdy zginie dowolny proces w cgroup". Jeśli wymagane jest takie zachowanie, należy przypisać jeden proces do jednej jednostki. Jeśli nie można rozdzielić programu, a użytkownik zarządza skryptem opakowującym (wrapper), najbliższym rozwiązaniem jest wait -n, który kończy działanie natychmiast po wyjściu pierwszego procesu potomnego:

#!/bin/bash
/usr/local/bin/myapp-web &
/usr/local/bin/myapp-worker &
wait -n
exit 1

Śmierć dowolnego procesu potomnego powoduje teraz zakończenie skryptu opakowującego z niezerowym statusem, dzięki czemu Restart=on-failure podejmuje działanie. Jest to kompromis, a nie poprawka. Nadal występuje jeden licznik restartów dla dwóch programów, jeden strumień logów oraz brak możliwości zrestartowania tylko tej części, która uległa awarii.

Jak sprawdzić, co faktycznie się wydarzyło

Cztery polecenia, w tej kolejności.

systemctl status myapp.service
systemd-cgls --unit myapp.service
systemctl show -p MainPID -p NRestarts -p Result -p ExecMainStatus myapp.service
journalctl -u myapp.service -b -o short-precise

systemctl status wyświetla stan, główny PID oraz drzewo cgroup na jednym ekranie. Poprawnie działająca jednostka ma stan Active: active (running) z linią Main PID: wskazującą na oczekiwany proces. Jeśli drzewo na dole zawiera nierozpoznane procesy lub brakuje w nim tych oczekiwanych, przyczyna problemu jest już znana.

systemd-cgls --unit wyświetla to samo drzewo bez skracania, co staje się istotne, gdy jednostka zarządza większą liczbą procesów.

systemctl show dostarcza dane w formacie czytelnym dla maszyny. NRestarts= to licznik restartów; jest to najszybszy sposób na odróżnienie usługi, która zrestartowała się czterdzieści razy, od tej, która działa od momentu uruchomienia systemu. Result= zawiera przyczynę ostatniej awarii: exit-code, signal, timeout, oom-kill, watchdog lub start-limit-hit. ExecMainStatus= to surowy kod wyjścia ostatniego głównego procesu.

Dziennik zdarzeń przechowuje sekwencję działań. Oto trzy linie, których należy szukać:

myapp.service: Main process exited, code=exited, status=1/FAILURE
myapp.service: Failed with result 'exit-code'.
myapp.service: Scheduled restart job, restart counter is at 1.

code=exited, status=N oznacza, że program zwrócił kod N, więc błąd leży po stronie programu lub jego konfiguracji. code=killed, signal=SEGV oznacza awarię (crash). code=killed, signal=TERM zazwyczaj oznacza, że inny proces wymusił zatrzymanie, co nie jest błędem i nie wywoła Restart=on-failure. code=dumped oznacza, że utworzono plik zrzutu pamięci (core file), który coredumpctl list wyświetli, jeśli zainstalowano systemd-coredump.

W przypadku wielu maszyn, NRestarts to wartość, którą warto zbierać według harmonogramu. Jednostka, której licznik rośnie każdego dnia, ulega awarii codziennie, niezależnie od tego, czy ktoś to zauważył. Gdy zarządzasz więcej niż dwoma lub trzema serwerami, spójny sposób na uruchomienie polecenia na każdym serwerze pozwala zamienić domysły w raport.

FAQ

Dlaczego systemctl zgłasza, że usługa jest aktywna, mimo że proces zakończył działanie?

systemd śledzi jeden proces na jednostkę usługi, czyli proces główny, a Restart= odczytuje tylko kod wyjścia tego konkretnego procesu. Wszystkie pozostałe procesy uruchomione przez jednostkę znajdują się w tej samej grupie kontrolnej (cgroup) i systemd zakończy je w momencie zatrzymania jednostki, jednak nie monitoruje ich pod kątem zakończenia pracy. Należy uruchomić systemctl show -p MainPID myapp.service i porównać wynik z systemd-cgls --unit myapp.service. Jeśli proces, który zakończył działanie, widnieje w drzewie, ale nie jest MainPID, systemd zachował się zgodnie z założeniami. Rozwiązaniem jest przypisanie jednego procesu do jednej jednostki oraz zdefiniowanie relacji między jednostkami za pomocą BindsTo= i Upholds=.

Co oznacza komunikat "start request repeated too quickly"?

Oznacza to, że jednostka była uruchamiana więcej niż StartLimitBurst= razy w ciągu StartLimitIntervalSec=. Wartości domyślne to 5 prób w ciągu 10 sekund, po których systemd zaprzestaje dalszych działań. Jest to limit częstotliwości, który nie informuje o przyczynie awarii usługi, dlatego należy sprawdzić wpisy w dzienniku powyżej tego komunikatu. Stan należy wyczyścić za pomocą systemctl reset-failed myapp.service, a następnie usunąć przyczynę awarii. Jeśli usługa oczekuje na powolne uruchomienie zasobu, należy zwiększyć RestartSec=, ponieważ domyślny odstęp 100 milisekund wyczerpuje limit pięciu prób w czasie krótszym niż sekunda.

Czy należy używać Restart=always czy Restart=on-failure?

W większości przypadków należy używać on-failure. Powoduje to restart w przypadku awarii, niezerowego kodu wyjścia, przekroczenia limitu czasu oraz błędu watchdog, jednocześnie ignorując celowe zakończenie pracy z kodem exit 0. Opcji always należy używać tylko wtedy, gdy program kończy działanie w sposób prawidłowy z przyczyn niezależnych, na przykład gdy klient zwraca 0 po rozłączeniu przez drugą stronę. Kosztem always jest sytuacja, w której usługa odczytująca błędną konfigurację, logująca jeden błąd i kończąca pracę z kodem 0, będzie restartowana w nieskończoność, a jedynym widocznym objawem będzie rosnąca wartość NRestarts w systemctl show.

Dlaczego ręczne zakończenie procesu nie wywołuje restartu?

Ponieważ systemd traktuje sygnały SIGHUP, SIGINT, SIGTERM oraz SIGPIPE jako poprawne zakończenie pracy, a standardowe polecenie kill <pid> wysyła sygnał SIGTERM. W ramach Restart=on-failure poprawne zakończenie nie jest traktowane jako awaria, więc usługa nie jest restartowana, co może sugerować błędną konfigurację, mimo że taka nie występuje. Testy należy przeprowadzać za pomocą kill -9 <pid> lub systemctl kill -s SIGKILL myapp.service, co stanowi nieprawidłowe zakończenie i wymusza zastosowanie polityki restartu. Ta sama zasada wyjaśnia, dlaczego systemctl stop nie koliduje z polityką restartu.

Gdzie umieścić StartLimitIntervalSec oraz StartLimitBurst?

W sekcji [Unit]. Starsze materiały oraz starsze wersje systemd umieszczają je w sekcji [Service], dlatego kopiowane przykłady bywają sprzeczne. Nie należy zgadywać, którą wersję obsługuje dany system. Po wykonaniu systemctl daemon-reload należy sprawdzić, co załadował systemd za pomocą systemctl show -p StartLimitBurst -p StartLimitIntervalUSec myapp.service i traktować te wartości jako wiążące. Polecenie systemd-analyze verify /etc/systemd/system/myapp.service wykrywa klucze, których systemd nie rozpoznaje, i nie wyświetla żadnych informacji, jeśli plik jest poprawny.

#systemd#restart#service-unit#cgroups#reliability