Deployment aplikacji internetowych

Zamiast przydługiego wstępu przejdę od razu do meritum. Dzięki webhookom jesteśmy w stanie wykonywać w prosty i przyjemny sposób automatyczny deployment aplikacji internetowych na serwery produkcyjne. Są wprawdzie możliwe inne metody, ale nie zawsze są one możliwe bądź stosowne do zaimplementowania. Tylko webhooki nas zadowolą, a i to nie wszystkie. Przyjrzyjmy się im zatem.

Czym są webhooki?

Wszyscy najwięksi gracze na rynku usług hostingowych, a więc Github, Gitlab, Bitbucket oraz Sourceforge oferują mechanizm powiadamiania przy pomocy protokołu HTTP o zajściu wewnątrz projektu lub repozytorium pewnych wydarzeń. Wysyłają oni na podany przez użytkownika adres pewną wiadomość, zazwyczaj w formacie JSON, która zawiera informacje na temat owego wydarzenia. Wiadomość ta może informować np. o spushowaniu kodu na brancha, albo o zaraportowaniu przez kogoś nowego błędu.

Format wiadomości w gruncie rzeczy jest bardzo podobny u każdego z dostawcow, gdyż nie da się tu napsuć i nawymyslać zbyt wiele. W przypadku repozytoriów GIT-owych będą to np. informacje o commicie (jego odcisk SHA, autor, commit message), o repozytorium (nazwie, adresie), o branchu, na którym zaszło zdarzenie itp. Nazwy poszczególnych pól mogą się różnić między np. Githubem i Bitbucketem, może się różnić format wysłanego JSON-a, ale hipotetyczna migracja z jednego systemu na inny nie powinna przysporzyć większych kłopotów.

Dzięki temu można łatwo poinformować zewnętrzną aplikację o wydarzeniu, które może inicjować pwene czynności, np. instalację nowej wersji aplikacji na serwerze.

Alternatywą dla użycia webhooków jest np. wykorzystanie serwera CI. Ma to sporo zalet (możemy np. odrzucić aktualizację i nie wykonać deploymentu w przypadku oblania testów), jednak zazwyczaj wadą tego rozwiązania jest to, że niemal zawsze będziemy musieli udostępnić na serwerze CI klucz prywatny SSH umożliwiający logowanie się na serwerze peodukcyjnym użytkownika z dostępem do katalogu z aplikacją. Nie podoba mi się ten pomysł, gdyż w ten sposób udostępniamy konto użytkownika z interaktywnym shellem (zwyczajowo np. użytkownik www-data, jako który występuje serwer HTTP, ma przypisany shell nologin). Pół biedy jeśli jest to oddzielny użytkownik z dostępem jedynie do katalogu z aplikacją. Gorzej jeśli jest to nasze konto, z dostępem do sudo.

Podoba mi się natomiast pomysł skonfigurowania kontenera Dockera z uruchomionym runnerem Gitlab CI, który współdzieli część zasobów (volume data) z kontenerem z naszą aplikacją chodzącą na tym samym hoście.

Użyjmy zatem HTTP

Wobec powyższego stanu rzeczy użyjemy webhooków. Od razu jednak powiem, że nie są one panaceum na wszystkie nasze problemy. Mają one pewne wady, które postaram się naświetlić w następnych akapitach i wiele tak naprawdę zależy od technologi, na które się zdecydowaliśmy w trakcie developmentu aplikacji. Jeśli np. jesteśmy w stanie się obejść bez SSH, nic nie stoi na przeszkodzie aby do celów deploymentu używać CI. Jeśli nasza aplikacja stoi np. na Heroku, możemy użyć aplikacji dpl. Przykładów można mnożyć i tak naprawdę każdy przypadek użycia należy rozważyć oddzielnie.

Jeśli chcemy uniknąć logowania się po SSH do innego serwera, możemy wybrać jedno z dwóch rozwiązań: skonfigurowanie dwóch kontenerów Dockera ze współdzielonym wolumenem (analogiczne do zaproponowanego rozwiązania z CI, z tą różnicą, że nie jest to rozwiązanie specyficzne dla Gitlaba), bądź postawienie osobnej aplikacji nasłuchującej żądań ze strony wybranego dostawcy i wykonującej pewne działania na serwerze. To drugie rozwiązanie ma tę wadę, że będzie propagowało uprawnienia użytkownika, który odpalił serwer obsługujący aplikację nasłuchującą (np. uWSGI). Często domyślnym użytkownikiem jest, o zgrozo, root, jednak z reguły można to przekonfigurować np. na użytkownika www-data. Oznacza to, że właścicielem plików aplikacji właściwej również będzie np. www-data (moglibyśmy go zmienić, lecz prawdopodobnie utrudniłoby to przyszłe instalacje). Musimy przy tym uważać, żeby nie dać mu dostępu do całego katalogu /var/www/, a jedynie do jego minimalnego poddrzewa.

Według mnie spośród czterech głównych graczy na rynku jedyną implementację webhooków zdatną do użycia w internecie1 oferuje w tej chwili Github, a to dlatego, że jako jedyny oferuje podpisywanie wysyłanych danych. Umożliwia nam to proste odfiltrowanie niepoprawnych bądź fałszywych zapytań.

Na upartego, jeśli poczynimy pewne zgrubne założenia, możemy odfiltrować każde takie zapytanie. Możemy na przykład stwierdzić, że w wyniku eventu push zawsze będziemy pobierać HEAD naszego repozytorium, którego adres mamy gdzieś zabity na pałę. Marnujemy jednak w ten sposób zasoby na fałszywe zapytania. Github pozwala nam natomiast ustawić token, który zostanie użyty jako klucz do utworzenia skrótu SHA1 z otrzymanego payloadu. Ów skrót jest wysyłany w jednym z headerów HTTP. W ten sposób możemy szybko odfiltrować niepoprawne zapytania od tych pożądanych.

Po sprawdzeniu klucza (i potencjalnie innych parametrów żądania) aplikacja obsługująca webhooka powinna wykonać instalację. Dobrym wzorcem projektowym jest pozostawienie szczegółów instalacji samej aplikacji. Serwisy CI takie jak Travis obsługują to w ten sposób, że w repozytorium z kodem przechowywane są pliki Yamlowe zawierające receptury wykonywane w różnych sytuacjach (np. receptury budowania, wykonywania testów). Warto pójść w tę samą stronę. Dla uproszczenia możemy ustalić, że my będziemy używać Make’a zamiast Yamla, a Makefile muszą po prostu zawierać odpowiednio nazwane targety. W końcu wywołanie make && make deploy (czyli “zbuduj i zainstaluj”) jest znacznie prostsze niż sparsowanie zawartości pliku pod kątem istniejących w nim reguł. Warto również przekazywać do Make’a w formie zmiennych środowiskkowych informacje, które mogą być interesujące dla autora Makefile’a. Może być to np. informacja o nazwie brancha, adresie serwera, na którym chcemy zainstalować naszą aplikację itp. Aby zminimalizować ryzyko wystąpienia konfliktu nazw oraz ułatwić ich odszukanie, powinno się im nadać jakiś wspólny prefix.

Implementacja

Na szybko stworzyłem dość prostą aplikację WSGI2, która jest w stanie obsługiwać deployment wielu aplikacji w generyczny sposób. Jest ona konfigurowana zwykłym plikiem Pythonowym zawierającym słownik z ustawieniami.

Walidacja tokena jest banalna, ponieważ Python posiada w swojej bibliotece standardowej wszystkie niezbędne komponenty:

from settings import webhook_settings

full_name = request.get("repository", dict()).get("full_name")
req_settings = webhook_settings.get(full_name)

def validate_request(signature, payload, req_settings):
    if signature is None:
        return False

    sha_name, signature = signature.split('=')
    if sha_name != "sha1":
        return False

    m = hmac.new(req_settings["github_secret"].encode("utf-8"),
                 msg=payload, digestmod=hashlib.sha1)
    return hmac.compare_digest(m.hexdigest(), signature)

Równie banalne jest odnalezienie Makefile’a i jego wykonanie. Nie martwię się przy tym o wykonywanie poleceń potomnych w shellu (shell=True), gdyż nie są one w żaden sposób powiązane z żądaniem pochodzącym z czeluści internetu, a jedynie z ustawieniami wprowadzonymi przez użytkownika. Użycie funkcji check_call spowoduje jednocześnie rzucenie wyjątku w przypadku niepowodzenia któregokolwiek wykonywanych poleceń. Jakikolwiek wyjątek powoduje zakończenie programu.

def call(cmd):
    return subprocess.check_call(cmd, shell=True)

checkout_dir = get_checkout_dir(req_settings)

try:
    set_env(req_settings)
    checkout(request, req_settings)
    call("cd '%s' && make" % checkout_dir)
    call("cd %s && make deploy" % checkout_dir)
except Exception as e:
    start_response('500 Internal Server Error', [('Content-Type', 'text/html')])
    return [(str(e).encode("utf-8"))]

Dość ciekawe jest ustawianie zmiennych środowiskowych dla Make’a. Otóż postanowiłem po prostu wpychać tam wszystkie parametry danej aplikacji z pliku konfiguracyjnego, poza tokenem Githuba. W obecnej wersji brakuje jeszcze przekazania kilku interesujących parametrów (nazwa brancha…), jednak ich dodanie jest trywialne, a ich tymczasowy brak wynika tylko z mojego pośpiechu (człowiek musi czasem spać).

def set_env(req_settings):
    for key, val in req_settings.items():
        if key.lower() == "github_secret":
            continue

        good_key = key.replace(" ", "_").upper()
        good_val = str(val)

        if good_val.lower().endswith("_dir"):
            good_val = os.path.abspath(os.path.expanduser(good_val))

        os.environ["WEBHOOK_%s" % good_key] = good_val

Czy to się sprawdza? Myślę, że tak. W końcu właśnie patrzysz, drogi Czytelniku, na efekt pracy owej aplikacji. :)


  1. Czyli w przypadku gdy serwer nasłuchujący webhooków nie jest odizolowany od sieci globalnej, co w praktyce oznacza, że posiadamy postawioną własną instancję Gitlaba, Githuba Enterprise lub Bitbucketa z dostępem do sieci wewnętrzej. 

  2. Much later I have created a generic webhook handler/listener: AnyWebHook, which is better in almost every way.