2022-03-13 / Bartłomiej Kurek
Nadzorowanie aplikacji przy użyciu Supervisor

Istnieje wiele możliwości nadzorowania procesów aplikacji. W zależności od systemu operacyjnego możemy zdać się na skrypty startowe (rc) lub konfigurację usług (unit files w systemd). Każde z rozwiązań ma swoje zalety i wady. W tym artykule omówimy użycie Supervisor do zarządzania procesami prostej aplikacji.

Omawiany kod przykładu i konfiguracji dostępne są w repozytorium git.

Czym jest Supervisor?

Supervisor to narzędzie stworzone w Python, które umożliwia zarządzanie procesami. Narzędzie to opiera się o architekturę klient-serwer, działa na większości systemów UNIX. Umożliwia m. in. startowanie i zatrzymywanie procesów oraz ich monitorowanie. Supervisor posiada też opcjonalny, wygodny interfejs HTTP umożliwiający wykonywanie akcji czy przeglądanie logów. Wsród funkcji znajdziemy również API XML-RPC, dzięki któremu możemy zautomatyzować zarządzanie lub dokonać integracji z własnymi rozwiązaniami/skryptami.

Nie jest to odpowiednik tradycyjnych programów typu init, możemy go używać niezależnie od systemu/dystrybucji, również z poziomu konta zwykłego użytkownika w systemie operacyjnym.

Przykładowa aplikacja

Do przykładu posłużymy się prostym serwerem sieciowym w Python. Aplikacja typu "echo server".
Program może otrzymać argument z numerem portu, na którym ma nasłuchiwać, a następnie startuje serwer. Klienci łaczą się do serwera i wysyłają komunikaty. Na każdy komunikat serwer odpowiada jego treścią.

Kod:

import asyncio


async def handle(reader: asyncio.StreamReader,
                 writer: asyncio.StreamWriter):
    print("Client:", writer.get_extra_info("peername"))
    while not reader.at_eof():
        msg = await reader.read(128)
        writer.write(msg)
        await writer.drain()
    print("Client:", writer.get_extra_info("peername"), "Disconnected")


async def main(ip4="127.0.0.1",
               port=10000):
    srv = await asyncio.start_server(handle, ip4, port)
    print("Server:", srv.sockets[0].getsockname())
    return await srv.serve_forever()


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument("-p", "--port", type=int, default=10000)

    args = parser.parse_args()
    asyncio.run(main(**args.__dict__))

Krótkie video aplikacji

Uruchamiam serwer, a następnie dwoje klientów łączy się i wysyła komunikaty.

Video: MP4, 319K, 1920x1080. Duration: 00:00:30 Link

Założenia wdrożenia aplikacji

Wdrożenie aplikacji wykonamy poprzez Supervisor, który wystartuje 5 kopii programu, każdą z osobnym (ale kolejnym) argumentem portu serwera.
Całość ma być zarządzana na uprawnieniach zwykłego użytkownika, który uruchomi supervisord. Uruchomimy również interfejs www Supervisor oraz udostępnimy interfejs konsolowy (supervisorctl). Oba interejsy zarządzania będą wymagać loginu i hasła.

Supervisor

Instalacja

Supervisor możemy zainstalować jako pakiet systemowy lub przy użyciu instalatora pakietów Python.

$ pip install supervisor

Konfiguracja

Konfiguracji dokonujemy w pliku w formacie *.ini. Nazwać możemy go dowolnie, domyślnie narzędzie supervisord szuka pliku o nazwie supervisord.conf. Nazwę pliku konfiguracyjnego można oczywiście przekazać do programu. Dla prostoty umieszczam całą konfigurację w pliku supervisord.conf.

Konfiguracja: sekcja supervisord

[supervisord]
nodaemon = true
logfile = /tmp/example-app/supervisord.log
logfile_maxbytes = 1MB
logfile_backups = 3
loglevel = info
pidfile = /tmp/example-app/supervisord.pid
umask = 077
childlogdir = /tmp/example-app

W sekcji umieszczamy konfigurację samego Supervisor. Logi rotowane są automatycznie. Wszystko umieszczam w jednym katalogu, dla prostoty: /tmp/example-app.
Znajdzie się tam również pidfile samego Supervisor. Logi procesów nadzorowanych przez Supervisor również zostaną umieszczone w tym katalogu, a ich nazwy Supervisor automatycznie obsłuży.

Konfiguracja: interfejs www

Konfiguracji dla interfejsu www Supervisor dokonujemy w sekcji inet_http_server. Interfejs będzie dostępny pod adresem http://127.0.0.1:9001 i wymagał będzie pary login/hasło: test/test123.

[inet_http_server]
host = 127.0.0.1
port = 9001
username = test
password = test123

Konfiguracja: interfejs konsolowy / supervisorctl

Aby narzędzie supervisorctl działało, musimy dodać konfigurację interfejsu dostępnego po sockecie typu UNIX. Tutaj również możemy dodać konieczność autentykacji parą login/hasło.

[unix_http_server]
file = /tmp/example-app/supervisor.sock
username = test
password = test123

Mając powyższą sekcję, możemy dodać sekcję dla supervisorctl. Jako serverurl podajemy adres (ścieżkę) gniazda UNIX.

[supervisorctl]
serverurl = unix:///tmp/example-app/supervisor.sock
prompt = example-app

Konfiguracja: api XML-RPC

Samo api możemy rozszerzać we własnym zakresie. W tym przykładzie zostawiamy domyślną implementację.

[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

Konfiguracja: nasza aplikacja

Procesy, którymi chcemy zarządzać, dodajemy jako sekcje [program:*]. Nasze założenia wobec procesów aplikacji spełnia poniższy fragment.
Supervisor wystartuje 5 procesów, każdy z nich będzie naszą aplikacją uruchomioną z kolejnym argumentem portu (począwszy od 10000). Wartość process_name pozwala nam przypisać własną nazwę dla każdego z procesów. Nazwa ta będzie również służyć jako prefix dla plików logów poszczególnych kopii programu.

[program:example-app]
numprocs = 5
numprocs_start = 10000
command = python app.py --port %(process_num)02d
process_name = %(program_name)s_%(process_num)02d

Uruchamiamy

Katalogi, które umieściliśmy w konfiguracji muszą istnieć. W przykładowej konfiguracji wszystko umieściliśmy w katalogu /tmp/example-app, zatem przed uruchomieniem supervisora tworzymy ten katalog:

$ mkdir /tmp/example-app

Teraz możemy już uruchomić supervisor.

$ supervisord -c supervisord.conf 
2022-03-13 19:40:00,357 INFO RPC interface 'supervisor' initialized
2022-03-13 19:40:00,357 INFO RPC interface 'supervisor' initialized
2022-03-13 19:40:00,357 INFO supervisord started with pid 3279827
2022-03-13 19:40:01,360 INFO spawned: 'example-app_10000' with pid 3279828
2022-03-13 19:40:01,363 INFO spawned: 'example-app_10001' with pid 3279829
2022-03-13 19:40:01,365 INFO spawned: 'example-app_10002' with pid 3279830
2022-03-13 19:40:01,368 INFO spawned: 'example-app_10003' with pid 3279831
2022-03-13 19:40:01,370 INFO spawned: 'example-app_10004' with pid 3279832
2022-03-13 19:40:02,372 INFO success: example-app_10000 entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)
2022-03-13 19:40:02,373 INFO success: example-app_10001 entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)
2022-03-13 19:40:02,373 INFO success: example-app_10002 entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)
2022-03-13 19:40:02,373 INFO success: example-app_10003 entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)
2022-03-13 19:40:02,373 INFO success: example-app_10004 entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)

Widzimy logi supervisor, ponieważ program nie odłączył się od terminala (nasza konfiguracja zawiera nodaemon=true).
W katalogu /tmp/example-app zobaczymy teraz wszystkie pliki logów, pidfile oraz socket interfejsu (gniazdo UNIX).

$ ls -la /tmp/example-app/
total 52
drwxr-xr-x  2 me   me    4096 2022-03-13 19:40 .
drwxrwxrwt 97 root root 36864 2022-03-13 19:40 ..
-rw-------  1 me   me       0 2022-03-13 19:40 example-app_10000-stderr---supervisor-uuhpzgni.log
-rw-------  1 me   me       0 2022-03-13 19:40 example-app_10000-stdout---supervisor-948inkm8.log
-rw-------  1 me   me       0 2022-03-13 19:40 example-app_10001-stderr---supervisor-r1olmlgt.log
-rw-------  1 me   me       0 2022-03-13 19:40 example-app_10001-stdout---supervisor-0fcncmwd.log
-rw-------  1 me   me       0 2022-03-13 19:40 example-app_10002-stderr---supervisor-91butqo4.log
-rw-------  1 me   me       0 2022-03-13 19:40 example-app_10002-stdout---supervisor-rbhi6o9x.log
-rw-------  1 me   me       0 2022-03-13 19:40 example-app_10003-stderr---supervisor-d9uwsmy_.log
-rw-------  1 me   me       0 2022-03-13 19:40 example-app_10003-stdout---supervisor-tv3qbb2q.log
-rw-------  1 me   me       0 2022-03-13 19:40 example-app_10004-stderr---supervisor-obhp5wu9.log
-rw-------  1 me   me       0 2022-03-13 19:40 example-app_10004-stdout---supervisor-r00n6kil.log
-rw-r--r--  1 me   me    1247 2022-03-13 19:40 supervisord.log
-rw-r--r--  1 me   me       8 2022-03-13 19:40 supervisord.pid
srwx------  1 me   me       0 2022-03-13 19:40 supervisor.sock

Interfejs www (video)

W poniższym, krótkim video widzimy:
- uruchomienie supervisord
- katalog z plikami (logi, socket, pidfile)
- interfejs www i wykonanie akcji
- zatrzymanie supervisord

Nasza aplikacja niczego nie loguje, dlatego też po klinięciu "Tail -f Stdout" interfejs niczego nie wyświetla. Jednak, interfejs WWW Supervisor strumieniuje logi, więc można je obserwować "na żywo" (zarówno stdout, jak i stderr).

Video: MP4, 1.5M, 1920x1080. Duration: 00:01:10 Link

Interfejs konsolowy (video)

Te same akcje możemy wykonać z poziomu konsoli supervisorctl. Krótki przegląd opcji:

Video: MP4, 1.5M, 1920x1080. Duration: 00:00:56 Link

Podsumowanie

Supervisor to wygodne narzędzie, które sprawdzi się zarówno podczas developmentu, jak i na produkcji.
Możemy go również zastosować do kontroli procesów w kontenerach typu "fat-container", kiedy w jednym obrazie umieszczamy wiele komponentów.
Program posiada bogatą dokumentację, opcje konfiguracyjne są dobrze opisane.
W repozytorium Git projektu znajdziemy również pełny, przykładowy plik konfiguracyjny, który łatwo dostosować samodzielne.
Supervisor posiada również wiele innych funkcjonalności (np. obsługa zmiennych środowiskowych). Użycie tego rozwiązania może znacząco uprościć proces wytwarzania, wdrażania i integracji oprogramowania.