Demonizacja procesu – linux

 
Ech… blog miał być o cyfrowym audio/wideo, a tu linux i demony.

Ukończyłem wstępną implementację pewnej aplikacji, która docelowo ma działać jako demon w systemie, czyli działać bezustannie jako usługa, bez bezpośredniej interakcji z poziomu terminala. Typowe aplikacje „konsolowe”, czy to interaktywne, czy nie, po uruchomieniu przypisane są po pierwsze do procesu macierzystego, czyli shella (np bash), a po drugie do terminala sterującego (controlling terminal), którym może być terminal lokalny (tty, konsole w trybie tekstowym komputera), terminal realizowany przez port szeregowy lub modem, czy też pseudoterminal kontrolowany czy to przez serwer zdalnego shella (telnet, ssh), czy przez okienkową aplikację terminala (np. gnome-terminal). Taka aplikacja jest aplikacją interaktywną, zamknięcie czy to shella, czy też terminala poprzez np. przerwanie połączenia powoduje, że shell wysyła do swoich potomków sygnał hang up (czyli SIGHUP) historycznie symbolizujący odłożenie słuchawki telefonu-modemu. Demon, to proces działający bez „pana”, jedynym zwierzchnikiem jest proces init, a demon nie korzysta z terminala, czyli nie posiada standardowego wyjścia, wejścia czy strumienia błędów (stdout, stdin, stderr). Jak przygotować aplikację do roli demona?

Bieda-demonizacja

  • Jeśli init obsługuje plik inittab, można zamieścić tam wpis z akcją 'once’ albo 'respawn’. W przypadku tej drugiej init będzie nieustannie wywoływał daną aplikację, podobnie jak wywołuje standardowe konsole tekstowe ilekroć zechcemy je ubić. Doświadczony sysadmin uzna coś takiego za bluźnierczą aberrację. Ja z kolei odczuwam pewien wstręt do inita w obecnym kształcie i wcale nie cieszą mnie wynalazki mające godnie go zastępować, typu upstart. Co w takim razie, gdyby zachciało się nam zawołać naszą aplikację z shella, w postaci 'demonicznej’?
  • Na pewno jest do tego jakieś narzędzie, w końcu to linux. Zatem mamy coś takiego jak nohup albo shellowe disown. Nohup przekierowuje standardowe wyjście do pliku i przeistacza się w wołaną aplikację poprzez exec, jednocześnie maskuje SIGHUP, przez co zamknięcie terminala nie powoduje zamknięcia aplikacji. Disown działa identycznie, poza tym, że jest poleceniem shella i tutaj to shell zostaje skłoniony do tego, żeby nie wysyłał SIGHUP do procesu, na którym wywołano disown. W obu przypadkach, po zamknięciu shella, aplikacja jako sierotka trafia pod skrzydła inita, zachowując ID grupy procesów i ID sesji. Nie jest w związku z tym ani liderem sesji ani liderem grupy. Nie dość, że sierotka, to do tego wyrzutek.
    Tutaj drzewko procesu atop uruchomionego z basha, któremu gnome-terminal przydzielił pseudoterminal pts/0: 

    > ps fx
    21842 ?        Sl     0:05  \_ gnome-terminal
    21851 ?        S      0:00  |   \_ gnome-pty-helper
    21852 pts/0    Ss     0:00  |   \_ bash
    24656 pts/0    S+     0:00  |   |   \_ atop 1
    24668 pts/5    Ss     0:00  |   \_ bash
    24733 pts/5    R+     0:00  |       \_ ps fx

    Co się dzieje w przypadku wywołania atop przez nohup?

    > nohup atop 1 &
    21842 ?        Sl     0:05  \_ gnome-terminal
    21851 ?        S      0:00  |   \_ gnome-pty-helper
    21852 pts/0    Ss+    0:00  |   \_ bash
    24845 pts/0    S      0:00  |   |   \_ atop 1
    24668 pts/5    Ss     0:00  |   \_ bash
    24847 pts/5    R+     0:00  |       \_ ps fx

    Nic. Poza tym, że nohup zamaskował odbieranie sygnału SIGHUP po czym wywołał za pomocą jednej z funkcji exec() atop, przez co przeistoczył się w atop, przekazując mu podmienione deskryptory stdin i stdout. I teraz atop nadaje bezustannie do nohup.out jako stdout i „przyjmuje” na stdin to co w pliku /dev/null, czyli nic.
    No to teraz siup z terminala (exit). Jak teraz wygląda atop 1 w drzewie?

     1825 ?        Ss     0:00 init --user
    (...)
    24845 ?        S      0:01  \_ atop 1

    I teraz nasz atop 1 wisi sobie pod procesem init. Jak pisałem wcześniej, nie jest liderem sesji i ma przypisaną grupę swojego macierzystego basha, zatem jest to taki bieda-demon, jak kto lubi. Analogicznie rzecz wygląda w przypadku komendy basha disown.

  • screen – mój ulubiony sposób bieda-demonizacji procesów. Screen udaje przed wołanym aplikacjami, że jest nieśmiertelnym terminalem, z którym mogą sobie konwersować do końca świata. Screen powołuje tyle pseudo-terminali, ile użytkownik sobie zażyczy i odpala dla każdego shella. A tak to wygląda na drzewku:
    > ps xf
    
    (...)
    21842 ?        Sl     0:03  \_ gnome-terminal
    24668 pts/6    Ss     0:00  |   \_ bash
    24814 pts/6    S+     0:00  |       \_ screen -r
    (...)
    22531 ?        Ss     0:00  \_ SCREEN
    22532 pts/5    Ss     0:00      \_ /bin/bash
    22693 pts/5    S+     0:00      |   \_ atop 1
    22641 pts/10   Ss     0:00      \_ /bin/bash
    22692 pts/10   S+     0:00          \_ top
    (...)

    Jednym zwinnym skrótem klawiszowym można się od screena odpiąć i cieszyć się z hasających w systemie bieda-demonów. Ale zanim się odepniemy, możemy zaobserwować na drzewie procesów naszego terminalowego klienta screena (screen -r) i serwer (SCREEN) z dwoma „oknami” basha.

Demonizacja

Można również napisać aplikację tak, aby demonem po prostu była i to jest najlepsze rozwiązanie, jeśli panuje się nad kodem źródłowym aplikacji.
W najbiedniejszym wydaniu wystarczy fork(), po którym nastąpi błyskawiczny exit() procesu macierzystego. Ale to strasznie nieeleganckie. Niestety w tym wydaniu niewiele się to różni od nohup i disown. Zatem poniżej przedstawię krótką listę kroków, które wypadałoby wykonać, żeby przemienić aplikację w demona w sposób dość przyzwoity.

  1. Fork procesu. Po forku proces macierzysty natychmiast kończy działanie.
    Init natychmiast przygarnia osierocony proces potomny.
  2. Utworzenie własnej sesji i grupy procesów  – setsid(). To nie tylko kwestia estetyki. Po pierwsze do grupy procesów można wysyłać sygnały. Po co nam ewentualne sygnały kierowane do naszego basha? Z drugiej strony może chcielibyśmy forkować procesy potomne w ramach naszej własnej grupy i wpływać na nie za pomocą sygnałów. Warto mieć wtedy własną sesję i grupę, żeby nie wpływać przypadkiem na macierzystą grupę.
  3. Zrobić coś z stdin/stdout. Najlepiej żeby skojarzyć je z /dev/null . Jeśli proces macierzysty (ten który zakończył pracę tuż po forku) posiadał jakieś otwarte pliki, to może wypadałoby je zamknąć.
  4. Być może chcemy, żeby nasz demon był publicznie osiągalnym serwerem TCP, wtedy aby zabezpieczyć się przed złośliwym użytkownikiem, chcielibyśmy, aby demon działał jako użytkownik nieuprzywilejowany. Stąd potrzeba wywołania setuid() .  Ale uwaga – pierwsze 1024 porty TCP może zająć jedynie superużytkownik, stąd warto zabindować sockety zanim odbierze się sobie generalskie dystynkcje.
  5. Być może warto kontrolować liczbę instancji procesu, stąd aby ograniczyć liczbę procesów do np. jednego, przydałby się tzw. lockfile. Niektórzy uważają, że jest to toporny koncept, ja do nich nie należę. Lock na pliku zakłada się za pomocą flock().

A teraz po kolei:

  1. Funkcja fork() może zakończyć się trojako:
     int pid = fork();

    w zależności od wartości zwracanej zmiennej pid

     a) if (pid < 0)

    fork() zakończył się błędem. No cóż, nie powinno się to zdarzyć. No cóż, exit z błędem jest tutaj chyba jedynym wyjściem.

     b) if (pid > 0)

    Po forku jesteśmy nadal w procesie macierzystym. W zmiennej pid zapisany jest PID procesu potomnego, który właśnie został przez system powołany do życia. Zgodnie z planem, proces macierzysty woła exit().

     c) if (pid == 0)

    Po forku obudziliśmy się w procesie potomnym. Proces potomny jest wierną kopią macierzystego. Oba procesy wykonują instrukcje bezpośrednio następujące po fork() i w momencie wyjścia z forka żaden z procesów nie zdaje sobie sprawy, czy jest procesem macierzystym czy potomnym, dopóki nie zbada wartości zmiennej pid. Podsumowując, do forka wchodzi jeden proces, ale następną instrukcję po forku wykonują już dwa procesy jednocześnie. Na drzewie procesów jeden jest rodzicem drugiego.

    A tak to wygląda razem:

        // Wchodzimy w fork
        int pid = fork();
    
        if (pid < 0)
            exit(1);
        if (pid > 0)
            exit(0); // Z forka wyszedł proces macierzysty, zatem kończy działanie, nie jest dłużej potrzebny
    
        // Skoro jesteśmy tutaj, to znaczy, że jesteśmy w procesie potomnym
        // zajmiemy się swoimi sprawami
        doSomething();

    A jak to po kolei wygląda na drzewie procesów (do kodu dodam sleep’y – jeden przed forkiem i drugi, zanim wywołam exit())

    (przed forkiem)
    25840 pts/0    Ss     0:00  |   \_ bash
    26064 pts/0    S+     0:00  |       \_ ./test
    (zaraz po forku)
    25840 pts/0    Ss     0:00  |   \_ bash
    26064 pts/0    S+     0:00  |       \_ ./test
    26074 pts/0    S+     0:00  |           \_ ./test
    (proces macierzysty zrobił exit(), proces potomny zrobił w tym czasie setsid())
    26074 ?        Ssl    0:02  \_ ./test

    Proces potomny został osierocony (jest teraz podpięty do inita).

  2. Proces potomny zawołał setsid(). Jest teraz liderem sesji (charakterystyczna flaga s w zestawie fflag stanu Ssl w ps), nie ma już przypisanego terminala (? w miejscu nazwy pliku terminala pts/0) i liderem grupy. Świadczy o tym następująca zależność: SID = PID = PGID = 26074. PPID to PID rodzica i wskazuje na init.
    > ps jx
    PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    1825 26074 26074 26074 ?           -1 Ssl   1000   0:59 ./test

    Tutaj mała dygresja – kiedyś, gdy init miał niezawodnie PID = 1, niektórzy programiści skracali sobie dróżkę sprawdzając, czy getppid() == 1, wtedy proces już na starcie w zasadzie był demonem, co pozwalało zrezygnować z forka. Obecnie nie należy robić takiego założenia, jak obrazują powyższe zrzuty z ps’a (init ma pid 1825).

  3. Odziedziczyliśmy otwarte stdin, stdout i stderr. Co z tym fantem zrobić? Proces wprawdzie formalnie odłączył się od terminala (ps pokaże ? w miejscu nazwy terminala), ale pamiętajmy, że stdout to w istocie otwarty do zapisu plik urządzenia terminala /dev/pts/0 . Pisanie do niego (np. printf()) spowoduje niezawodnie wypisanie tego czegoś na terminalu pts/0, czyli pomimo sforkowania, pomimo setsid, demon dalej będzie zaśmiecał swój macierzysty terminal. A nuż chcielibyśmy jeszcze z niego pokorzystać? A nuż jest to terminal na porcie szeregowym i nie możemy go ot tak sobie zamknąć. Wówczas może okazać się wręcz nieużywalny, jeśli nasz demon wpadnie w jakiś debugowy słowotok. Dlatego:
        // otwieramy devnulla
        int devnull = open("/dev/null", O_RDWR);
    
        // zamykamy standardowe strumienie
        close(0);
        close(1);
        close(2);
    
        // teraz podłączamy standardowe strumienie do devnulla
        // przez duplikację deskryptorów
        // wskazujemy funkcji dup numery deskryptorów odpowiadające stdin. stdout, stderr
        dup2(devnull, 0);
        dup2(devnull, 1);
        dup2(devnull, 2);
    
        // no i zamykamy oryginalny deskryptor devnulla, po co nam on?
        // zresztą jak tego nie zrobimy, to zwyczajnie "wycieknie", do końca działania aplikacji
        // a do wyciekania czegokolwiek - uchwytów, deskryptorów czy pamięci - nie wolno dopuszczać
        close(devnull);

    No dobra, ale nie wystarczyło jedynie zamknąć 0, 1 i 2? Hmm, no nie wiem, a co jeśli gdzieś dalej w trzewiach naszej aplikacji coś/ktoś zrobi open? Jeśli z open’a wyskoczy numer deskryptora odpowiadający stdout a nasza aplikacja namiętnie printfuje, to może być to kłopotliwe.

  4. Mój demon nie jest serwerem, który nasłuchiwałby na jakimś porcie, zatem nie będę się tutaj o tym rozpisywał. To temat na osobny wpis. Jeśli go popełnię, postaram się tutaj zalinkować.
  5. Lockfile w sposób atomowy zapewni nam szeregowanie wywołań naszego demona, dzięki czemu możemy chociażby utrzymywać wyłącznie jedną instancję procesu. W przypadku na przykład serwera sieciowego nasłuchującego na jednym określonym porcie TCP taki zabieg nie jest bezwzględnie konieczny. Rolę takiego lockfile’a mógłby wtedy spełniać socket, któremu nie powiedzie się bind() na już zajętym porcie. Zgodnie z pewną konwencją do lockfile’u można wpisać pid procesu, dzięki czemu można łatwo z poziomu terminala ustalić PID demona. Lockfile (albo pidfile) miałby też inne zalety, spróbuję je kiedyś opisać przy okazji opisywania koncepcji spawnera.
        // open a lockfile
        int lockfd = open(LOCK_FILE, O_RDWR|O_CREAT, 0640);
        if (lockfd < 0)
            exit(1); // Nie wyszło
        if (lockf(lockfd, F_TLOCK, 0) < 0) // robimy nieblokujący lock, czyli tzw trylock, stąd flaga F_TLOCK
            exit(0); // jeśli się nie udało, to znaczy, że już jeden taki proces trzyma lock, więc drugi musi ustąpić

    Powstał swego rodzaju singleton, jeśli proces przejdzie całą tę ścieżkę, wówczas można powiedzieć, że jest jedynym demonem swojego rodzaju w systemie i może w tej chwili spokojnie rozpocząć realizację swoich głównych zadań.

 

Spawner

Jednak programiści popełniają błędy. Czasem program kończy się niespodziewanie. Na przykład taki segmentation fault, czyli spadający jak grom z jasnego nieba sygnał SIGSEGV, a może dzielenie przez 0, tudzież abort() z wewnątrz jakiejś niezbyt stabilnej biblioteki? Wtedy demon przewraca się i znika. Kto miałby nad nim czuwać?
Otóż sam mógłby nad sobą czuwać. Wystarczyłoby, żeby demon jeszcze raz się sforkował i dopiero zanurkował w mroczne i niebezpieczne odmęty kodu realizującego właściwą funkcjonalność. A demon macierzysty czekałby na potomka, aż ten nabroi, żeby spłodzić kolejnego, z nadzieją, że pożyje dłużej niż poprzednik.
Ale o tym w innym wpisie…

Kod źródłowy

#include <unistd.h>
#include <fcntl.h>
#include <cstdlib>

#define LOCK_FILE "/tmp/test.lock"

void daemonize() {
    int pid = fork();
    if (pid < 0)
        exit(1); // fork failed
    if (pid > 0)
        exit(0); // still in parent process, so quit

    // in child process, obtain new session and process group at the same time
    setsid();

    int devnull = open("/dev/null", O_RDWR);

    // close stdin stdout and stderr which are connected to current terminal
    close(0);
    close(1);
    close(2);

    // attach stdin stdout and stderr to /dev/null
    // by dup-ing the devnull descriptor
    dup2(devnull, 0);
    dup2(devnull, 1);
    dup2(devnull, 2);

    // get rid of devnull
    close(devnull);

    // open a lockfile
    int lockfd = open(LOCK_FILE, O_RDWR|O_CREAT, 0640);
    if (lockfd < 0)
        exit(1); // Error opening the file
    if (lockf(lockfd, F_TLOCK, 0) < 0) // do a trylock
        exit(0); // if already locked or error, exit (ensures a single process in the system)
}

Żeby nie było, że wszystko sobie tak od początku sam wymyśliłem – tutaj artykuł, który można uznać za główne źródło.

2 komentarze do “Demonizacja procesu – linux

  1. Dodam od siebie, że istnieje kilka bardzo wygodnych narzędzi do wspomagania pisania demonów. Od czasu kiedy pisałem kiedyś pewien demon-serwer usług sieciowych, absolutnie zakochałem się w boost-asio (przykład demona z użyciem async socketów: http://www.boost.org/doc/libs/1_47_0/doc/html/boost_asio/example/fork/daemon.cpp) oraz boostowej puli wątków.
    Boost ma pewien próg wejścia, na początku potrafi nieco odpychać – ale warto wytrzymać.

  2. Muszę się kiedyś przeprosić z bioostem, mam wrażenie, że jestem zwyczajnie uprzedzony do tego suite’a. Ale chyba trudniej byłoby mi sie przekonac do C++11, który powoli staje się powszechny.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *