Niektóre nowości nie robią wrażenia, dopóki nie umieści się ich w odpowiednim historycznym kontekście. Tak jest ze AWS Lambda SnapStart. Dlatego postanowiłem przybliżyć dzisiaj drogę, którą cała społeczność przeszła, aby Amazon mógł się pochwalić na re:Invent nowym featurem.
No cóż, myślałem, że po zeszłotygodniowym wydaniu – w całości poświęconym Springowi – dzisiaj wezmę na warsztat jakieś mniejsze tematy (choćby nazbierało nam się parę releasów, min. nowy Gradle, ale nie tylko). „Niestety” odbywa się AWS re:Invent, czyli największa coroczna impreza Amazonu. Zwykle dla świata JVM ma ona znaczenie marginalne, ale jedno z ogłoszeń jest na tyle ciekawe, że skończyło się jak zwykle…
Amazon pokazał bowiem AWS Lambda SnapStart – czyli dedykowane dla Lambdy rozwiązanie problemu zimnego startu. Dlatego dzisiaj porozmawiamy sobie o Serverlessie… i o tym, dlaczego tak ciężko przez lata było go efektywnie używać z Javą
Co to jest „Cold Start Problem”?
Zakładam, że czytając ten tekst wiecie, czym są funkcje Serverless. Myślę, że choćby taka Lambda weszła już do programistycznego mainstreamu. Dla porządku jednak, mówimy tutaj o Świętym Graalu dekompozycji oprogramowania – platformie, w ramach której osobno skalujemy nie monolity, nie serwisy, ale pojedyncze funkcje. Przez to, że operujemy z artefaktami, które realnie mają pojedyncze wejście/wyjście, dostajemy (uwaga: duże uproszczenie) dwie duże zalety.
- Po pierwsze model płatności – płacimy per użycie konkretnej funkcji, co oznacza niedostępny w innych modelach sposób poziom auto-skalowania – nie ma ruchu, nie żadnych ma kosztów.
- Po drugie – brak narzutu na utrzymanie. To dostawca środowiska uruchomieniowego martwi się tym, żeby na każdego nowego klienta używającego Waszej aplikacji czekała moc obliczeniowa.
Oczywiście, żeby nie było za słodko, skoro były dwie zalety to czas na dwie wady. Po pierwsze, dekompozycja do atomowych wręcz elementów składowych powoduje, że naprawdę ciężko jest taką aplikacją zarządzać bez odpowiednich narzędzi. Po drugie (i na tym się skupimy w tym tekście), aplikacje serverless cechują się tak zwanym problemem „zimnego startu”. Sami wiecie, że aplikacje napisane w Javie – zanim jeszcze będą w stanie „przyjmować” ruch – zwykle potrzebują trochę czasu na rozruch. Jest to związane z modelem działania JVM i będziemy stopniowo sobie przez poszczególne problemy przechodzić. Na ten moment jednak ważne dla nas jest to, że każdorazowe odpalanie „zimnej” maszyny wirtualnej na potrzeby uruchomienia pojedynczej funkcji jest rozwiązaniem nieakceptowalnym z punktu widzenia opóźnień, jakie by się z tym wiązały. Teraz czas się przyglądnąć, jak to jest więc w ogóle możliwe, że istnieją jakieś funkcje Serverless napisane w Javie.
Zainstaluj teraz i czytaj tylko dobre teksty!
Jak Java dotychczasowo próbowała sobie z tym poradzić
Natywne rozwiązania AWS Lambda
To nie jest jednak tak, że wspomniany Cold Start zupełnie eliminuje JVM ze środowiska AWS Lambda. Po pierwsze, powinniśmy pamiętać, że zimny start dotyczy tylko wybranych wywołań funkcji Lambda. Funkcje Lambda mogą być po pierwszym uruchomieniu pozostają rozgrzane przez jakiś czas – znaczy to, że raz uruchomiony runtime jest dzielony między wykonaniami. Po jakimś okresie nieaktywności runtime jest „zabijany”, istnieją jednak sposoby na jego utrzymanie przy życiu.
Sam Amazon dzieli się sposobami na to, aby wycisnąć z javowych Lambda maksimum efektu w publikacji Optimizing AWS Lambda function performance for Java. Jako szczególnie przydatne wskazywane jest odpowiednie ustawienie poziomu kompilacji na taki zapewniający maksymalne możliwe do osiągnięcia efekty w krótkim okresie czasu dzięki ustawieniu zmiennej środowiskowej JAVA_TOOL_OPTIONS to “-XX:+TieredCompilation -XX:TieredStopAtLevel=1”
(Application) Class Data Sharing
To nie jest jednak tak, że przez lata byliśmy pozostawieni na pastwę „generycznych” rozwiązań, które zapewnia nam Amazon (jak utrzymanie rozgrzanych środowisk uruchomieniowych) lub od zawsze były w JVM (jak Tiered Compilation). Wręcz przeciwnie – twórcy JDK bardzo szybko (już w okolicach JDK 9) zrozumieli, że nisza serverless mocno im ucieka, dlatego zaczęli dbać o to, aby runtime języka stawał się jak najbardziej Cloud-Native, bo modne to i nowoczesne. Dlatego też w JDK 10 pojawił się tak zwany AppCDS – Application Class Data Sharing, którego uznaje za pierwszą próbę takiej optymalizacji.
Czym jest AppCDS? Funkcjonalność ta nadbudowuje nad istniejącym od czasu JDK 1.5 Class-Data Sharing. Nie jest bowiem tak, że to dopiero w dobie Serverlessa zaczęliśmy się przejmować czasem uruchomienia naszych aplikacji – po prostu kiedyś owoce wisiały nieco niżej. Za każdym razem bowiem, kiedy uruchamiana jest JVM, niezbędne jest załadowanie klas wchodzących w skład runtime języka – min. pakietu java.lang
, ale nie tylko. Te pozostają praktycznie takie same niezależnie od procesu, dlatego zamiast za każdym razem zajmować się ich inicjalizacja, można zrzucić efekt końcowy na dysk w formie archiwum i być w stanie odczytać go podczas uruchomienia. Początkowo dotyczyło to tylko i wyłącznie właśnie klas wewnętrznych Javy, ale wraz z JDK 10 mechanizm ten został rozszerzony również o klasy tworzone przez twórców aplikacji. Optymalizacja ta idealnie wpisuje się w powtarzalny proces releasów chmurowych, choć bardziej w wypadku „zwykłych” serwerów, niż funkcji Lambda. Pokazuje to jednak ścieżkę, którą będą używały kolejne przykłady – cache.
Więcej o CDS i AppCDS dowiedzieć możecie się z dev.java, gdzie zostały w przystępny sposób opisane. Bardzo dobrą publikację w temacie praktycznych użyć Application Class Data-Sharing stworzył zaś Nicolai Parlog
Class Pre-Initialization
Tutaj bardziej w formie ciekawostki, ale trochę nie mogłem się powstrzymać. We wrześniu tego roku Alibaba ogłosiła wspólną inicjatywę z Google – FastStartup Incubator project, czyli próbę użycia Class Pre-Initialization do optymalizacji zimnego startu. Tak jak wspomniałem w poprzedniej sekcji, większość metod optymalizacyjnych zimnego startu opiera się na jakiejś formie cache. Z cache jest jednak taki problem, że aby w bezpieczny sposób być go w stanie użyć, niezbędna jest pewność, że klasa nie ma jakichś side-effektów. Mając tą informację, potencjalnie jesteśmy w stanie dokonywać bardziej agresywnych optymalizacji. Z tego powodu istniejący mechanizm Pre-Inicjalizacji klas może być używany tylko do ściśle zdefiniowanej listy klas.
Dlatego też obie firmy chcą dać adnotację jdk.internal.vm.annotation.Preserve
lub/i możliwość przekazania listy klas, które mogą być bezpiecznie pre-initaljzowane. Dzięki temu obecne w Javie metody optymalizacyjne będą po prostu mogły być stosowane szerzej. FastStartup Incubator znajduje się we wczesnej fazie, ale zarówno Google, jak i Alibaba – jako, że obie firmy posiadają własne chmury – mają duże motywacje (💵) aby pchać go do przodu.
Natywne obrazy – Project Leyden, Ahead-of-Time Compilation, GraalVM
Skoro inicjalizacja klas i classpatha zajmuje tyle czasu, to może najlepiej się jej po prostu pozbyć i wszystko od razu wrzucać do binarki?
Idąc tym tropem kolejną, często używaną dziś metodą jest wykorzystanie Custom Runtime Lambdy i GraalVM do stworzenia natywnego obrazu. Takowe cechują się tym, że pełną wydajność osiągają od momentu uruchomienia, a czas rozruchu jest w nich mocno skrócony w stosunku do standardowych aplikacji javowych. W internecie znajdziecie wiele tekstów poświęconych temu podejściu, metoda jest dobrze opisana min. przez Arnolda Galovicsa w jego tekście Tackling Java Cold Startup Times On AWS Lambda With GraalVM. Jeżeli kiedykolwiek interesowaliście się optymalizacją zimnego startu w Javie, to prawodopodbnie właśnie ta metoda Wam się o uszy obiła.
Problem z kompilacją AoT jest taki, że o ile umożliwia ona osiągnięcie pewnego bazowego poziomu wydajności, to jednak nie jest to proces tak optymalny jak ten oparty o kompilacje Just-In-Time. Wynika to z faktu, że aplikacja już uruchomiona jest w stanie dokonywać optymalizacji specyficznych dla sposobu użycia – i robi to w oparciu o informacje, do których kompilator działający na zimno po prostu nie ma dostępu. To byłoby pewnie do przełknięcia w przypadku Serverless, ale GraalVM (i całe AoT) wymaga od użytkowników również zmiany we własnych przyzwyczajeniach, zwłaszcza jeśli chodzi np. o użycie Reflection API.
Myślę, że o AoT już wystarczy, bo ta metoda optymalizacji pozostaje mocno poza zakresem tego wydania i umieściłem ją tutaj głównie dla porządku. Więcej o powyższych możecie przeczytać w jednym z poprzednich wydań JVM Weekly: How committing GraalVM to OpenJDK changes the rules for Project Leyden
CRIU
Nieco kluczymy, ale powoli zbliżamy się już do meritum. Metody jak Fast Startup czy AppCDS służyły do cache’owania poszczególnych fragmentów runtime JVM-a. Checkpoint/Restore in Userspace (CRIU) to funkcja Linuxa, która umożliwia wykonanie „zrzutu” na dysk całego działającego procesu aplikacji. Następnie, kolejna instancja może być uruchomiona z tego punktu, w którym wykonano wspomniany zrzut, co skraca czas rozruchu.
Mam wśród czytelników jakichś retro-graczy? Jeśli tak, to całość działa w podobny sposób jak funkcjonalność Save State w emulatorach. Metody takie jak AppCDS przypominają klasyczne zapisywanie – wybieramy te fragmenty, które pozwolą nam później odtworzyć stan aplikacji, i tylko je zapisujemy. Save State nie bawi się w takie finezje – jako, że komputery poszły do przodu i mamy więcej przestrzeni dyskowej, to po prostu zrzucamy cały stan pamięci na dysk i potem sobie odtwarzamy 1:1 gdy jest to potrzebne.
CRIU posiada swoje problemy – zarówno z punktu widzenia bezpieczeństwa, jak i wygody użytkowania – mówimy tutaj bowiem o generycznej funkcji systemu operacyjnego, znajdującej się poza JVM. Każda aplikacja ma zaś nieco inną charakterystykę i w zależności od tego, czy mówimy o serwerze aplikacyjnym, aplikacji webowej czy batchowym jobie zapis na dysk powinien odbywać się w innym momencie, który może być trudny do wychwycenia bez kontekstu maszyny wirtualnej. Jak pisze OpenLiberty w swoim tekście Faster start-up for Java applications on Open Liberty with CRIU:
It would be useful to have an API so that the application can specify when it would like a snapshot to be taken; this would be a valuable addition to the Java specification.
No to teraz najwyższa pora przejść do CRaC!
PS: Jeżeli chcecie dowiedzieć się więcej o CRiU, Christine Flood z RedHata nagrała dogłębnego talka CRIU and Java opportunities and challenges o kontekście użycia tej technologii w Javie.
CRaC
No i dochodzimy do końcowej ścieżki naszej podróży po optymalizacjach JVM-a. CRaC (Coordinated Restore at Checkpoint) to właśnie bowiem API, o którym wspomniane było w poprzedniej sekcji. Pozwala on na utworzenie „checkpointu” – czyli wspomnianego zrzutu pamięci – w dowolnym momencie pracy aplikacji zdefiniowanym przez twórcę oprogramowania, za pomocą komendy:
jcmd target/spring-boot-0.0.1-SNAPSHOT.jar JDK.checkpoint
Pozwala również na obsłużenie, jak aplikacja ma się zachować w momencie tworzenia checkpointu i jego odtworzenia, pozwala więc na zarządzenie jej stanem.
import jdk.crac.Context;
import jdk.crac.Core;
import jdk.crac.Resource;
class ServerManager implements Resource {
...
@Override
public void beforeCheckpoint(Context<? extends Resource> context) throws Exception {
server.stop();
}
@Override
public void afterRestore(Context<? extends Resource> context) throws Exception {
server.start();
}
}
W kontekście tego tekstu, ważna jest geneza CRaC. Od bardzo wczesnych etapów zaangażowani byli w niego bowiem inżynierowie Amazona z zespołu Corretto (ichniejszej implementacji JDK). Lista mailingowa OpenJDK już od pewnego czasu zawierała więc przecieki o tym, że Amazon pracuje nad ulepszeniem wsparcia dla Javy w aplikacjach Serverless. W jaki sposób Lambda chce używać CRaC? Tutaj trzeba się przyglądnąć, jak AWS Lambda działa pod spodem.
Jak zbudowana jest AWS Lambda?
Swego czasu powstawało bardzo dużo projektów, które próbowały symulować działanie AWS Lambdy (i innych serverlessów) w ramach np. takiego Kubernetesa. Tak naprawdę jednak bardzo trudno zapewnić im było doświadczenie choćby zbliżone do tego zapewnianego przez produkt Amazonu. Wynika to z tego, że zamiast wykorzystywać generyczne kontenery, AWS postawił na dedykowane, zoptymalizowane pod Serverless środowisko uruchomieniowe. Z takiego właśnie podejścia wynika większość proponowanych przez firmę innowacji Lambdy, tak trudnych do zreplikowania przez generyczne rozwiązania.
Runtime Lambdy składa się z masy ściśle wyspecjalizowanych komponentów. Przykładowo, jako infrastruktura używane są EC2 Nitro. Są to maszyny bare-metal pozbawionych narzutu wirtualizacji (jak to miałoby miejsce w wypadku EC2 typu T, które są najczęściej używane w przypadku takiego Eleastic Container Storage). Na nich uruchamiany jest projekt Firecracker – wyspecjalizowane, przeznaczone pod zastosowania w serverless mikro-maszyny wirtualne. Zapewniają one izolacje procesów i są w stanie posiadać wspólny kontekst uruchomieniowy. Dzięki temu Amazon jest w stanie zapewnić, że po początkowym rozgrzaniu pierwszej instancji, kolejne będą uruchamiały się już znacznie szybciej. Firecracker zaś zarządza w granularny sposób tym, które fragmenty powinny być reużywane, a które muszą być każdorazowo tworzone od nowa.
To jest oczywiście przejście przez temat bardzo „po łebkach”, bo to też JVM-owy przegląd, a nie publikacja dla architektów własnej chmury Serverless. Jeśli jednak jesteście rządni większej ilości detali – Behind the scenes, Lambda to publikacja, której szukacie.
No dobra, to jak działa SnapStart
To skoro wszystkie pionki mamy ma już na planszy, to wreszcie porozmawiajmy sobie o najnowszej innowacji ze strony Amazona, jaką jest AWS Lambda SnapStart.
Wykonanie każdej funkcji Lambda składa się z trzech faz – Init (rządzącej się dość specyficznymi prawami), Invoke oraz Shutdown. Bootstrap środowiska w ramach pierwszej z nich polega na przygotowanie całego środowiska do stanu, w którym jest w stanie przyjmować ruch. SnapStart w tym momencie stan pamięci lambdy zapisuje i odkłada „na później”, gdy nasza funkcja nie będzie wystarczająco rozgrzana. Robi to za pomocą wspomnianego już CRIU, a w pierwszej wersji wspiera właśnie Javę dzięki CRaC API. Całość pod spodem używa zaś mechanizmu snapshottingu udostępnianego przez Firecracker, a wspomniane API CRaC pomaga w sytuacji, gdy niezbędne jest odświeżenie snapshotu – AWS Lambda udostępnia odpowiednie hooki.
Oczywiście, są tutaj pewne wyzwania o których trzeba pamiętać. Standardowa losowość w Javie generuje seeda w momencie inicjalizacji, jako że operujemy na zrzucie pamięci to wszystkie wyniki operacji pseudolosowych będą takie same, czyniąc java.util.Random
raczej bezużytecznym. Jednak Amazon zadbał o to, aby taki np. java.security.SecureRandom
zachowywał się prawidłowo, ale jeśli jakaś z używanych przez Was bibliotek używa klasycznego Random
…
Innym ciekawą możliwością, którą daje SnapStart, jest możliwość „rozgrzania” funkcji za pomocą kompilatora Just-in-Time na zewnętrznej maszynie i dostarczenia go funkcji Lambda razem z aplikacją. Umożliwia to zapewnienie im maksymalnej wydajności, przekraczającej tą, którą zapewnić może kompilacja Ahead-of-Time. Z pewnością jednak największe wrażenie robi skrócenie Cold Startu. Mówimy tutaj bowiem o aż 10-krotnym skróceniu czasu uruchomienia. Różnica między zimną, a rozgrzaną funkcją severless staje się w ten sposób praktycznie pomijalna w większości przypadków.
Amazon oczywiście dostarczył dokumentacje. Całość włącza się pojedynczą flagą w YAML’u, ale oczywiście możliwie jest dopasowanie procesu pod siebie, min. wspomnianymi Hookami. Dobrym punktem startu będzie oficjalny post wprowadzający od Amazonu.
Zainstaluj teraz i czytaj tylko dobre teksty!
Na zakończenie trochę detali – kto, na czym, kiedy?
Jeżeli chcecie dowiedzieć się więcej w temacie, chyba najlepszym obecnie miejsce wyjaśniającym zawiłości SnapStart jest Podcast Adama Biena airhack.fm, w którym rozmawia on z autorem powyższego wprowadzenia – Markiem Sailesem, który zajmuje w Amazonie stanowisko Specialist Solutions Architect for Serverless. Rozmowa dotyka nie tylko samego SnapStart, ale także innych wyzwań, które mogą spotkać chętnych do spróbowania świata Serverless.
Amazon pracował w sekrecie z partnerami i pierwszy z nich – Quarkus – już pochwalił się efektami. Zespół pracującym nad projektem podzielił się nie tylko rozszerzeniem umożliwiającym używanie SnapStart – które pojawić się ma już wersji eksperymentalnej w kolejnej edycji Quarkusa, wersji 2.15. Oprócz tego podzielili się oni swoimi wynikami, które zdają się potwierdzać wersję Amazona o dziesięciokrotnym przyspieszeniu. Skoro w miarę szybko startujący Quarkus może się pochwalić takimi wzrostami, ciekawe jak będzie to wyglądało w przypadku takiego (produkcyjnego, nie „hello world” jak w przykładzie od AWS) Springa.
Pewnym ograniczeniem może być dla niektórych fakt, że całość wspierane jest tylko przez Corretto (dystrybucja JDK by Amazon), i to wyłącznie w wersji 11. Nie powinno Was to jednak przerażać. Funkcje Lambda w tym momencie i tak nie wspierają JDK 17, a że całość i tak jest uruchamiana w kontrolowanym środowisku usług AWS, nie byłoby sensu udostępniać go na inne dystrybucje. Amazon nie dzieli się, czy w samym Corretto pojawiły się jakieś modyfikacje niezbędne do działania SnapStart.
Wiem ten artykuł ma trochę zaburzone proporcje wstępu-do-treści, bardzo chciałem uniknąć problemu poniższej sowy, tylko przeprowadziłem Was przez całość krok po kroku.
Mam nadzieje, że lepiej teraz rozumiecie, na plecach jakich gigantów stoi SnapSeed i co tam się po drodze musiało wydarzyć, żeby w 2022 Amazon mógł się chwalić programistom JVM nową funkcjonalnością swojej Lambdy. A za tydzień mam nadzieje podsumujemy sobie bieżączkę, bo takowej się trochę nazbierało – chyba, że zbliżająca się wielkimi krokami Rampdown JDK 20 czymś nas mocno zaskoczy.