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. :)
-
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. ↩
-
Much later I have created a generic webhook handler/listener: AnyWebHook, which is better in almost every way. ↩