Dzisiejsze ogłoszenie zasad płatności za Google AppEngine wywołało burzę w małej szklaneczce, jaką jest środowisko ludzi robiących aplikacje na AppEngine:
-
część (wygląda mi to na większość) cieszy się z tego, że może płacić, w tym także z tego, że za 3 miesiące będzie płacić za to, co do tej pory miała za darmo;
-
część (mniejszość) nazywa to po imieniu: vendor lock-in (najpierw skuś, potem zmień zasady i zmuś do płacenia za to, co do tej pory było za darmo).
Wyliczenia przeprowadzane tu i ówdzie (głównie na liście AppEngine) wskazują, że nowe limity będą odczuwalne przez wszystkie co najmniej przeciętnie popularne serwisy, a koszt może być znaczący.
Wysiłek włożony w zrobienie aplikacji, którą można wyjąć z GAE i włożyć na normalny serwer może się opłacić. A już na pewno można przestać myśleć o przenoszeniu aplikacji z normalnej platformy na GAE — to się po prostu nie opłaci.
Aplikacja, która jest moją piaskownicą na Google AppEngine z upływem czasu rozrosła się trochę (procesory kontekstu, middleware, takie tam...) i okazało się, że każdy request w przeglądarce logów świeci na żółto, to znaczy że według Google jego obsługa zjadła nadmierną ilość zasobów i powinien zostać w jakiś sposób zoptymalizowany. No i faktycznie, powinien — obsługa każdego z żądań do aplikacji zjadała ~1200 ms CPU (łącznie kod + storage). Coś było ewidentnie nie halo, więc musiałem podjąć pewne kroki zaradcze:
-
użycie googlowego cache gdzie się tylko da (porzucając tradycyjne pojęcie o tym, gdzie ma to sens);
-
optymalizacja sposobu dostępu do danych w datastore (wybieranie encji używając klucza/kluczy, a nie budując Query);
-
jak dla mnie najważniejsze: optymalizacja importów.
Zanim przejdę do omówienia poszczególnych optymalizacji... Warto było nad tym popracować, bo średni czas obsługi żądania spadł do ~120 ms CPU (z grubsza: 10x wzrost wydajności). Pomimo tego, co ja uznałem za najważniejsze, to nie optymalizacja importów dała największy zysk, a zmniejszenie ilości odczytów z datastore (dzięki użyciu memcache i wybierania danych przy użyciu kluczy). Co uważam za dobre w tym wszystkim to to, że AppEngine wymusza przemyślane zaplanowanie aplikacji i optymalizację każdego aspektu działania kodu (i to od samego początku). To nie jest zabawka dla niecierpliwych chłopców-pehapowców. ;)
Memcache gdzie się da
Memcache API było jedną z pierwszych rzeczy, jakie Google dodało do AppEngine po jego uruchomieniu w kwietniu 2008 roku. Jego użycie w aplikacji jest nie tyle optymalizacją, co po prostu koniecznością (szczególnie w świetle zapowiedzianego na koniec maja 2009 obniżenia limitów). O ile w zwykłych aplikacjach w cache umieszcza się rzeczy, które są albo kosztowne do wyliczenia, albo niemal statyczne, o tyle na AppEngine buforować trzeba niemal wszystko, bo pomimo twierdzeń googlarzy, że odczyty z datastore są tanie, to ta taniość jest względna (chyba względem kosztu zapisów) — a koszt obsługi żądania jest liczony jako suma kosztu wykonania kodu jako całości, wraz z kosztem pobrania danych (w limitach te wartości są liczone oddzielnie).
Na co zwrócić uwagę na początku? Na drobne rzeczy: profil użytkownika, listy ostatnio dodanych/popularnych obiektów, to, co pojawia się w kontekście w wyniku działania procesorów lub jest dodawane do obiektu request przez middleware. Po tych rzeczach można zająć się resztą, czyli każdą instancją modelu (i każdą wyliczoną wartością), która pojawia się w aplikacji. Czasem trzeba będzie podjąć decyzję, czy warto aktualizować pokazywane dane w czasie rzeczywistym, czy może da się przełknąć mały poślizg rzędu 15 minut... Bo obiekty wyjęte z cache nie zawsze zachowują się tak, jak byśmy tego oczekiwali (przynajmniej na razie).
Dostęp do danych
Tym, co bywa najtrudniejsze do przełknięcia przy robieniu aplikacji na AppEngine jest zupełnie inny model storage — nierelacyjny, bez złączeń i nastawiony na zupełnie inne użycie, niż bazy danych ogólnego stosowania, jak MySQL czy PostgreSQL (nie ujmując nic relacyjnym bazom danych). Oczywiście, można udawać, że się tego nie zauważa i próbować symulować relacyjność, ale efektem tego będzie obniżona wydajność. Z tego co zauważyłem w różnych artykułach i podpowiedziach tu i ówdzie, najważniejsze podpowiedzi można streścić w kilku punktach:
-
storage świetnie sprawdza się jako wielka tablica asocjacyjna, dostęp do danych przy użyciu kluczy jest najbardziej wydajny;
-
klucze są jedynymi unikalnymi atrybutami obiektów, można to wykorzystać do kilku celów;
-
przechowywanie listy kluczy (np. w atrybucie typu
db.ListProperty) jest równie wygodne jak złączenie wiele-do-wiele, a w większości wypadków wygodniejsze;
-
atrybuty indeksowane (
db.StringProperty) są bardziej kosztowne w aktualizacji niż nieindeksowane (db.TextProperty), warto wziąć to pod uwagę przy projektowaniu modelu.
Uwaga na importy
Czym się różni kod uruchamiany na AppEngine od kodu uruchamianego w zwykłym środowisku Pythona? Niczym, oprócz tego, że proces, który obsługuje żądanie żyje dokładnie tyle, ile trwa obsługa żądania. A to oznacza, że wszystkie moduły konieczne do wykonania kodu przy każdym żądaniu muszą zostać zaimportowane, co jest sporym obciążeniem. Aby trochę poprawić sytuację, AppEngine buforuje zaimportowane moduły przez jakiś czas (liczony raczej w sekundach niż w godzinach). Nie są buforowane moduły, które zostały zaimportowane przy użyciu funkcji __import__(), co oznacza tylko jedno: wszystkie wynalazki typu klass = import_string('mypackage.mymodule.MyClass') trzeba odłożyć na półkę. Nie zawsze da się tego całkiem uniknąć, ale w takich przypadkach trzeba przygotować swój kod na to, że może otrzymać obiekt wykonywalny lub ciąg znaków i zminimalizować ilość miejsc, gdzie wykonywany jest niebuforowany import.
Dużo tego, ale nikt nie mówił, że będzie lekko. :)
Google zapowiedziało, że za trzy miesiące zmniejszy darmowe limity na AppEngine, a od 24 lutego można sobie zwiększyć limity, dopłacając (drobne bo drobne, ale zawsze) parę dolarków. Moje aplikacje są w takim stadium, że tak naprawdę mnie to nie będzie dotyczyło, ale zmniejszenie limitów przy jednoczesnym wprowadzeniu opłat jak dla mnie coś oznacza — Google przygotowuje się na przetrwanie kryzysu w IT ograniczając wydatki. Może to być także oznaką tego, że uznali AppEngine za produkt na tyle dojrzały, że można za niego brać pieniądze (choć według mnie jeszcze sporo do tego brakuje). Redukcja limitów jest spora, bo w przypadku ilości przesłanych danych to jest 90% (z 10GB do 1GB/24h), a w przypadku obciążenia CPU 86% (z 46 godzin do 6.5 godziny/24h). Na blog czy inny maleńki serwis to może i wystarczy, ale nie daj Boże żeby ktoś się tym poważnie zainteresował, bo limit się wyczerpie w pół godziny. Biorąc pod uwagę dodatkowo fakt, że Google nie powiadamia o zbliżającym się wyczerpaniu limitów (np. po przekroczeniu 80% któregokolwiek z nich), można się nagle obudzić z ręką w nocniku. Nie zmienia to oczywiście faktu, że tą platformą nadal warto się interesować i nawet w wersji płatnej wciąż jest ciekawą propozycją do budowania aplikacji, choć już nie tak atrakcyjną.
Tak czy inaczej, chwilowo nie zamierzam się tym przejmować, choć trudno przewidzieć, jak sytuacja będzie się przedstawiała za trzy miesiące. Na razie staram się robić moje aplikacje w ten sposób, żeby przeniesienie ich na normalny hosting nie wymagało przepisywania całości (co kosztuje więcej zachodu, niż mogłoby się wydawać, ale o tym innym razem).
Z wielką determinacją staram się o to, by wreszcie nie musieć poruszać się po Warszawie i okolicach używając komunikacji publicznej. Nigdy nie przeżywałem orgazmu z powodu tego, że jestem nowoczesny, względnie eko, a zawsze przeszkadzało mi to, że komunikacja publiczna po prostu jest taka, jak cała nasza rzeczywistość — czyli z przymrużeniem oka. Bo komunikacja publiczna obsysa na maksa (i proszę mi nie opowiadać, że gdzieś w Polsce jest jeszcze gorzej, bo prawdę mówiąc inne miejsca w Polsce guzik mnie obchodzą). Jeżeli miałbym ją określić jednym słowem, to byłoby to "czekanie" — poruszanie się po Warszawie używając miejskiej komunikacji naziemnej polega głownie na czekaniu (wyjątkiem jest metro, ale od jakiegoś czasu nie zaliczam się do szczęściarzy, co dojeżdżają do pracy metrem). Bardzo proszę przykład:
-
wychodzę z domu o 07:20, żeby jechać pociągiem 07:31 do Warszawy Wileńskiej, zakładany realistycznie czas dotarcia do pracy to 08:30;
-
na dworcu około 07:30 słyszę skrzeczenie megafonu, a to oznacza, że pociąg będzie dopiero za 15 minut (nie mam co liczyć na to, że ten, który miał przyjechać o 07:41 będzie o czasie...);
-
pociąg przyjeżdża około 07:45, na szczęście luźny;
-
08:10 na dworcu Wileńskim, niecałe 15 minut opóźnienia, więc plany wcześniejszego przyjechania do pracy biorą w łeb, ale przynajmniej się nie spóźnię;
-
08:15 jestem na przystanku autobusowym i zaczyna się czekanie na autobus 135 lub 509 (dobrze, że są 2...);
-
około 08:30 przyjeżdża 135, szanse na to, że się nie spóźnię topnieją w oczach;
-
o 08:50 wysiadam ze 135 na przystanku przy CNPEP Radwar, od pracy dzieli mnie 2 przystanki ale długie, więc nie będę próbował zasuwać piechotą;
-
08:57 - przyjeżdża jakiś autobus, który zawiezie mnie do Promenady;
-
o 09:10 wysiadam z windy na moim piętrze, spóźniłem się 10 minut, ale jak się okazuje, wszyscy inni spóźnią się jeszcze więcej.
Z prawie dwóch godzin drogi do pracy, 40 minut spędziłem na czekaniu. Doliczając łażenie między dworcami i przystankami zrobi się z tego godzina. Biorąc pod uwagę, że na Pragę w okolicę ulicy Ząbkowskiej samochodem dojadę w 35-40 minut, to mam szansę zaoszczędzić co najmniej 45 minut, które do tej pory spędzam na czekaniu — zimą na mrozie, latem w upale, a o każdej porze roku ewentualnie w deszczu.
Niech wóz na bus zmieniają młodsi i ci, którym się nie spieszy.
Obiecałem, że będzie o procesorach kontekstu jak w Django (a może nawet trochę lepszych), więc proszę.
Procesory kontekstu
Procesor kontekstu wg. Django to kod wykonywalny, który przyjmując obiekt request zwraca słownik, który następnie jest dodawany do kontekstu przed renderowaniem szablonu. W ten sposób można zapewnić sobie dostępność pewnych danych w kontekście bez potrzeby pamiętania o tym, by te dane tam umieścić. W Django procesory kontekstu są wywoływane wtedy, gdy używa się klasy RequestContext. Pół biedy, gdy chodzi o mój kod — już ja o to zadbam, żeby tam był użyty RequestContext, ale problemem może być kod, którego autor o tym nie pomyślał.
Używając Jinja2 i Werkzeug nie możemy liczyć na tak silną integrację systemu szablonów z modelem żądania i odpowiedzi (założenia obydwu komponentów właśnie taką integrację wykluczają). Na szczęście zarówno Jinja2 jak i Werkzeug dostarczają nam wszystkiego czego potrzeba, żeby zaimplementować sobie taką funkcjonalność. Niestety, nie będzie tak prosto jak w przypadku middleware.
Werkzeug posiada coś, co jest nazywane local proxy — jest to wariant thread local storage, specjalnie przystosowany do potrzeb aplikacji webowych. Zazwyczaj umieszcza się w nim kod aplikacji WSGI (klasy lub funkcji), ale nie ma przeszkód, żeby umieścić tam cokolwiek innego, np. obiekt request:
def __call__(self, environ, start_response):
local.application = self
local.request = request = Request(environ)
Tak umieszczony obiekt request będzie następnie dostępny w dowolnej innej części aplikacji w trakcie jej czasu życia (czyli przetwarzania żądania i produkowania odpowiedzi). Teraz w okolicy mojego kodu renderującego szablon mogę zrobić taki myk:
def build_globals():
jinja_globals = {}
for item in getattr(settings, 'CONTEXT_PROCESSORS', []):
try:
processor = import_string(item)
jinja_globals.update(processor(local.request))
except (ImportError, AttributeError):
pass
return jinja_globals
jinja_env = Environment(loader=FileSystemLoader(settings.TEMPLATE_DIRS))
jinja_env.globals.update(build_globals())
Chodzi o to, że Jinja2 posiada tzw. globalne zasoby — można je zmodyfikować po utworzeniu środowiska a przed wyrenderowaniem pierwszego szablonu. Od teraz w każdym szablonie będzie dostępne to, co znajduje się w moim słowniku jinja_env.globals. A co się tam może znaleźć? Na przykład to:
def messages(request):
messages = request.environ['beaker.session'].get('messages', [])
request.environ['beaker.session']['messages'] = []
request.environ['beaker.session'].save()
return {
'MESSAGES': messages,
}
Czemu jest to lepsze rozwiązanie niż w Django? Ponieważ nie trzeba pamiętać o tym, żeby kontekst był obiektem klasy RequestContext. Dzięki buforowaniu środowiska przez Jinja2, narzut jest minimalny, a prostota rozwiązania kusząca. Czyżby więc było to rozwiązanie idealne? W moim przypadku nie. Trzeba bardzo uważać na to, by zaimportować ten kod w odpowiednim momencie (dla uproszczenia umieściłem go w przestrzeni globalnej). Ale działa i robi dokładnie to, co chciałem.
Ten kod nie powstałby, gdyby nie Ali Afshar (tak, ten od PIDA), który podał rozwiązanie tego problemu na StackOverflow.
Żartuję, Django jest na tyle dobre, żeby nie musieć go robić samemu. Chodzi mi raczej o sytuację, kiedy chce się mieć tyle Django, ile trzeba i ani trochę więcej (na przykład dlatego, że z całego oryginalnego Django wykorzystuje się tylko parę komponentów, bo na więcej nie pozwala AppEngine). Z pomocą przychodzi Werkzeug, Beaker i Jinja2 i parę innych bibliotek. Oprócz oczywistego zysku, jakim jest odchudzenie narzędziówki o kilka cennych setek plików, można zyskać bezcenną wiedzę, jak działa Django (im więcej wiem, tym większym podziwem darzę core devs, bo Django okazuje się jeszcze lepsze, niż wygląda na pierwszy rzut oka).
W paru kolejnych odcinkach opiszę rzeczy, które uznawałem za bardzo użyteczne w Django i zaimplementowałem sobie jako uzupełnienie narzędziówki (inna rzecz, że niełatwo jest się wyrzec niektórych przyzwyczajeń).
Middleware
Nie chodzi mi o middleware w rozumieniu WSGI, lecz takie, jak w Django — metody zwykłych klas, które są wywoływane z ustalonymi argumentami w określonych momentach przetwarzania żądania i odpowiedzi. Niespodzianka, Werkzeug ma już coś takiego! Nazywa się to Processor i klasa, która znajduje się w module werkzeug.contrib.kickstart pokazuje, jak powinien wyglądać kompletny interfejs takiej klasy i sygnatury jej metod. Leniwi mogą sobie z tej klasy po prostu odziedziczyć...
Pozostaje jedna rzecz, czyli podłączenie tego cuda do naszej aplikacji WSGI. Poniżej fragment mojej metody __call__() z klasy reprezentującej aplikację WSGI. Podłączam w niej tylko jeden rodzaj middleware (są jeszcze 3 inne możliwe).
for item in getattr(settings, 'MIDDLEWARES', []):
try:
klass = import_string(item)
middleware = klass()
response = middleware.process_request(request)
except (ImportError, AttributeError):
pass
if response is not None:
break
if response is None:
local.url_adapter = adapter = url_map.bind_to_environ(environ)
try:
endpoint, values = adapter.match()
handler = import_string(endpoint)
response = handler(request, **values)
except HTTPException, e:
response = e
Inny sposób na podłączenie do kodu aplikacji WSGI można znaleźć na wiki Werkzeug — jak zwykle pełna wolność wyboru metody (oczywiście, działa dobrze w każdym przypadku).
W następnym odcinku
A w następnym odcinku okaże się, jak zrobić procesory kontekstu (context processors) takie jak w Django, a nawet lepsze. Tym razem w użyciu będzie zarówno Werkzeug jak i Jinja.
Od dziś na AppEngine zniesiono jeden z najbardziej denerwujących limitów (bo zazwyczaj nie można było go kontrolować) - limit tzw. High CPU requests (czyli pików CPU). Do tej pory limit wynosił 2 na minutę, co łatwo było przekroczyć szczególnie gdy występowały jakieś lokalne błędy, np. z dostępem do datastore. Podobne problemy występują okresowo na platformie Google i jak do tej pory dotyczą szczególnie żądań HTTP przychodzących z Europy.
Z innych udogodnień: dopuszczalny czas odpowiedzi wzrósł z 10 sekund do 30 i dopuszczony został rozmiar odpowiedzi i upload zasobów większych niż dotychczasowe 1MB (obecnie limit wynosi 10MB).
Idzie ku lepszemu, ale jak dla mnie to wciąż mało. :)
Od nowej wersji (wydanej właśnie dzisiaj) możliwe jest wreszcie używanie operatorów IN i =! w metodzie filter(), właściwa dokumentacja została zaktualizowana. Co prawda pod spodem wykonywane jest kilka zapytań do datastore (co wpływa na zużycie limitów), ale wygoda jest o wiele większa. Fajnie.
Oblałem egzamin praktyczny na prawko po raz trzeci. Nie wiem jeszcze kiedy poprawka, bo dopiero we wtorek będę miał kwitek z doszkalania.
Tym razem już niewiele brakowało — już woziliśmy się na czas, bo program obowiązkowy miałem już zaliczony.