System billingowy z wykorzystaniem AWS Lambda i Kotlina. Porównanie kosztów
Architektura serverless jest stosunkowo nowym podejściem. Na rynku nie utrwaliły się jeszcze dobre praktyki i sprawdzone zestawy narzędzi służących do implementacji rozwiązań w tej architekturze. Niektórzy próbują zaadaptować narzędzia i praktyki, których używali na co dzień przy budowie mikrousług lub wcześniej przy tworzeniu monolitycznych aplikacji web. Inni starają się nie używać frameworków i minimalizować liczbę zależności od zewnętrznych bibliotek.
W naszej firmie jesteśmy szczególnie uzależnieni od rozwiązań wstrzykujących zależności (ang. dependency injection) takich jak Spring Framework. W niniejszym artykule zbadamy czy tworząc rozwiązania serverless na platformie AWS Lambda, warto korzystać z takich narzędzi.
Kamil Helman. Lead Java Developer w Altkom Software & Consulting. Programuje od ponad 13 lat. Ciągle poszukuje lepszej drogi do tworzenia oprogramowania. Zainteresowany tematyką sztucznej inteligencji, IoT i szeroko rozumianym sprzętem elektronicznym.
Przyjrzymy się różnym scenariuszom:
- użycie frameworka Micronaut,
- użycie biblioteki Dagger 2,
- stworzenie funkcji bez użycia dodatkowych bibliotek.
Przeanalizujemy jaki wpływ ma wybrane podejście na łatwość tworzenia kodu, jak i na jego szybkość wykonania na platformie AWS Lambda.
Spis treści
Architektura przykładu testowego
Nasze testy przeprowadziliśmy na uproszczonej wersji systemu billingowego.
Jak to działa?
Użytkownik albo inny system wrzuca plik CSV z danymi do dedykowanego bucketa S3. To zdarzenie uruchamia pierwszą funkcję – Billing Items Generation. Funkcja ta na podstawie danych z pliku, nazwy pliku oraz danych z tabeli cen znajdującej się w bazie DynamoDB wylicza pozycje księgowe do zafakturowania. Wynikowe pozycje są zapisywane w bazie oraz na kolejkę SQS emitowana jest wiadomość zawierająca informacje o konieczności wygenerowania faktury.
Pojawienie się tej wiadomości na dedykowanej kolejce wyzwala kolejną funkcję – Invoice Generation, która na podstawie danych o pozycjach i danych klienta, generuje obiekt faktury. Obiekt ten jest zapisywany w bazie DynamoDB oraz wysyłane są dwie wiadomości na konkretne kolejki. Jedna o konieczności przygotowania wydruku, a druga o konieczności powiadomienia odbiorcy faktury.
Wiadomość o konieczności wydrukowania faktury wyzwala funkcję Invoice Printing, która wywołuje integrację z zewnętrznym systemem JS Report, który na podstawie przesłanego obiektu, generuje plik PDF, a następnie zwraca go w odpowiedzi. Powstały plik PDF odkładany jest w S3.
Druga z wiadomości (ta o konieczności powiadomienia odbiorcy) korzystając z usług SendGrid i Twilio, wysyła odpowiednio maila i smsa z informacją o przygotowanej fakturze.
Pełny kod znajduje się na naszym GitHubie.
Testowane implementacje
Do porównania przygotowaliśmy 3 wersje. Wszystkie bazują na tym samym kodzie napisanym w Kotlinie. Żaden z frameworków nie oferował gotowych integracji z usługami udostępnionymi przez AWS, toteż opieraliśmy się na narzędziach dostarczonych przez Amazona dla S3, SQS i DynamoDB.
Do integracji z DynamoDB wykorzystaliśmy dodatkowo DynamoDBMapper, pozwalający łatwo sterować mapowaniem z i do obiektów POJO.
Dla tabeli BillingItem o polach:
key: String, billingKey: String, amount: Number, beneficiary: String, productCode: String
Wystarczy utworzyć prostą klasę:
@DynamoDBTable(tableName = "BillingItem") data class BillingItem( @DynamoDBHashKey(attributeName = "key") var key: String, @DynamoDBRangeKey(attributeName = "billingKey") var billingKey: String, var beneficiary: String, var productCode: String, var amount: BigDecimal )
Wówczas możemy użyć repozytorium postaci:
class BillingItemRepository { private val client = AmazonDynamoDBClientBuilder.standard().build() private val mapper = DynamoDBMapper(client) fun save(billingItem: BillingItem) { return mapper.save(billingItem) } }
DynamoDBMapper sam zatroszczy się o konwersję danych.
Integracje z SendGrid i Twilio w funkcji NotifyInvoice zrealizowane są z użyciem gotowych klientów dostarczonych przez każdą z tych firm.
Dla Twillio (SMSy) wystarczy jednokrotnie wykonać:
Twilio.init(accountSid, authToken)
Dalej każdorazowo:
Message.creator( PhoneNumber("+15005550006"), //from test number PhoneNumber("+15005550006"), //to test number "You have new invoice ${request.invoice?.invoiceNumber} for ${request.invoice?.totalCost}.") .create()
Dla SendGrid inicjujemy jednokrotnie klienta przez:
val sg = SendGrid(apiKey)
Każdą wiadomość wysyłamy przez proste:
val from = Email("asc-lab@altkom.pl") val to = Email("kamil@helman.pl") val subject = "New Invoice - ${request.invoice?.invoiceNumber}" val content = Content("text/plain", "You have new invoice ${request.invoice?.invoiceNumber} for ${request.invoice?.totalCost}.") val mail = Mail(from, subject, to, content) with(Request()) { method = Method.POST endpoint = "mail/send" body = mail.build() val response = sg.api(this) }
Integracje z JS Report Online zrealizowaliśmy sami, gdyż nie znaleźliśmy odpowiedniego klienta w Javie. Niemniej integracja przebiegła bardzo bezboleśnie i sprowadza się do prostego wywołania POST z JSONem z kilkoma ustawieniami i danymi.
Dla uproszczenia stworzyliśmy sobie model zapytania:
data class JsReportRequest( val template: Template, val templateOptions: TemplateOptions, val data: Invoice ) data class Template( val name: String ) class TemplateOptions
A sam request realizujemy przez standardowe HttpURLConnection:
val request = JsReportRequest(Template(invoiceTemplateName), TemplateOptions(), invoice) with(URL(jsReportUrl).openConnection() as HttpURLConnection) { val enc = BASE64Encoder() val encodedAuthorization = enc.encode("$username:$password".toByteArray()) setRequestProperty("Authorization", "Basic $encodedAuthorization") setRequestProperty("Content-Type", "application/json") requestMethod = "POST" doOutput = true val wr = OutputStreamWriter(outputStream) wr.write(Jackson.toJsonPrettyString(request)) wr.flush() return inputStream }
AWS Lambda nie nakłada specjalnych wymogów na implementację. Wszystko zawsze zaczyna się od wywołania pojedynczej metody z naszego kodu.
Staraliśmy się całość przygotować zgodnie z AWS Best Practices.
Pierwsza wersja jest najbogatsza narzędziowo. Używa frameworka Micronaut zarówno do Dependency Injection jak i do napisanego przez nas mechanizmu generowania prostych klientów kolejek SQS z użyciem Introduction Advice. Całość została zainicjowana z użyciem Micronaut CLI i polecenia create-function.
Pozytywnym aspektem jest wbudowany w Micronaut’cie RequestHandler, który ma gotowe cache’owanie kontekstu. Upraszcza to tworzenie lambd, gdyż framework bierze na siebie całe spięcie swojej infrastruktury z mechanizmami AWS Lambda.
Druga wersja została oparta o narzędzie Dagger 2, które zapewnia nam wstrzykiwanie zależności compile-time. Jego wykorzystanie ogranicza się do zbudowania obiektu naszej funkcji wraz ze wszystkimi zależnościami.
Brak gotowej integracji wymusił napisanie własnego RequestHandlera – całe szczęście sprowadziło się to dosłownie do kilku linijek kodu.
Trzecia wersja używa Kotlina bez żadnych frameworków. Wszystko jest implementowane ręcznie. Nie ma też żadnego automatycznie generowanego kodu.
Założenia
Będziemy porównywać koszt miesięczny przy dwóch wariantach.
Pierwszy wariant to milion faktur miesięcznie, z których każda będzie mieć 10 pozycji. Zakładamy, że rozłoży się to równo na 21 dni roboczych w miesiącu, po 10 godzin na dzień. W efekcie będziemy musieli przetworzyć po 4761 faktur na godzinę.
Spowoduje to, że powstanie wiele instancji Lambdy, ale będzie stosunkowo mało cold startów. Do ostatniego problemu wrócimy później – podczas porównywania czasów wykonania poszczególnych funkcji w różnych etapach cyklu życia Lambdy.
Drugi wariant to 1000 faktur miesięcznie. Tak samo jak wcześniej, zakładamy 10 pozycji na fakturę. Tutaj rozluźniamy nieco ograniczenia czasowe. Przyjmujemy, że faktury będą spływały w taki sposób, że co 10 z nich będzie powodowała start nowej instancji Lambdy.
W obu przypadkach zakładamy, że plik CSV z danymi ma 10KB, a wynikowy PDF 1MB. Dodatkowo zakładamy, że każdy plik PDF zostanie pobrany raz.
Koszty inne niż sama AWS Lambda będą identyczne, niezależnie od implementacji.
Nie wliczamy tutaj kosztów powiązanych usług (JS Report Online, SendGrid i Twilio).
Ciekawostka: od października obserwujemy, że triggery Lambdy powiązane z kolejką SQS generują około 15 “empty request” na każdą minutę. Przy trzech kolejkach i trzech triggerach może to wygenerować koszt około $0.80 na miesiąc.
Jak widać największe różnice w czasach wywołań są w przypadku inicjalizacji instancji, czyli cold startów.
W przypadku użycia zainicjowanych wcześniej instancji (tak zwanych warm startów) różnice praktycznie znikają. Jedyna różnica jaką tu widzimy jest na funkcji NotifyInvoice, która ma największe wahania czasu wykonania. Bierze się to z różnych czasów odpowiedzi usług, z którymi się integrujemy.
Rozpoczynając nasze badania, używaliśmy najnowszej dostępnej wersji Micronaut, czyli 1.0.0. Niestety przykład z jego wykorzystaniem miał wyraźnie gorsze wyniki (dłuższe czasy wykonywania funkcji), również przy warm startach. Zaczęliśmy zagłębiać się, dlatego tak się dzieje. Okazało się, że Micronaut przy każdym zapytaniu, niepotrzebnie tworzył ponownie cały kontekst funkcji. Zgłosiliśmy ten problem i praktycznie w mgnieniu oka zostało to poprawione. Dzięki temu, testy Micronaut’a w wersji 1.0.1 wypadają już dużo lepiej.
Sumaryczny koszt wywołań AWS Lambda po uwzględnieniu Free Tier:
Co przekłada się na koszt Lambdy dla jednej faktury na poziomie:
Zatem sumując to z wcześniej wyliczonymi kosztami uzyskujemy:
Jak widać tego typu instalacja do 1000 faktur miesięcznie jest bezpłatna niezależnie od wybranego frameworka.
Co można jeszcze poprawić?
Bardzo duży wpływ na czas pierwszego uruchomienia instancji ma wielkość pliku jar lub zip. Można to zauważyć porównując czas pierwszego wykonania GenerateBillingItem (17MB) i NotifyInvoice (25MB). Dokładne wyeliminowanie wszystkich niepotrzebnych bibliotek z naszych funkcji ma w tym przypadku ogromne znaczenie. Oczywiście trzeba uważać, żeby nie usunąć czegoś wymaganego przez framework.
Drugi temat, który należy rozważyć to sposób pakowania. W naszych testach zebranie wszystkiego w plik zip z jar’ami w środku pozwalało zaoszczędzić około 6-9% z czasu cold startu. Testy powyżej bazują na domyślnym dla Micronaut’a pakowaniu z użyciem maven-shade-plugin, który tworzy jednego jara ze wszystkimi klasami.
Korzyści ze stosowania frameworków DI
Wstrzykiwanie zależności bardzo ułatwia tworzenie testów. Szczególnie wygodnie robi się to z użyciem Micronaut i jego adnotacji @Replaces. Dagger wymaga trochę więcej konfiguracji, ale dalej jest to dosyć wygodne.
W przypadku Micronaut wystarczy zrobić:
@Replaces(bean = S3Client::class) @Singleton class S3ClientMock : S3Client() {
i możemy stworzyć sobie dowolną zaślepkę. W kodzie testów nie musimy nic więcej zmieniać. Dostaniemy kontekst z nową implementacją.
W przypadku Daggera poza zdefiniowaniem zaślepki musimy jeszcze nadpisać sobie moduł, który nam zwróci instancję nowej klasy zamiast oryginalnej oraz ustawić ten moduł w testowanym komponencie.
class FunctionTestModule : FunctionModule() { override fun provideAmazonS3(): S3Client { return S3ClientMock() } ...
a w samym teście przykładowo musimy wywołać:
val function: GenerateBillingItemFunction = DaggerFunctionComponent.builder() .functionModule(FunctionTestModule()) .build() .provideFunction()
Micronaut pozwala również testować funkcje poprzez uruchomienie ich w pełnym kontekście i sprawdzeniu ich od deserializacji zapytania aż do serializacji odpowiedzi. Mamy wtedy większą pewność czy nasz kod zadziała po instalacji na AWS.
Używanie frameworków zbliża pisanie funkcji do tego, co robiliśmy w dotychczasowych architekturach. Mamy ten sam zestaw narzędzi i konwencji, więc przejście jest bardziej płynne. Równie sprawnie możemy zarządzać cyklem życia beanów czy sposobem ich tworzenia.
Bardzo przyjemnym aspektem korzystania z Micronaut’a była możliwość stworzenia projektu hello-world w ciągu minuty z użyciem Micronaut CLI. Pozwala to bardzo szybko tworzyć szkielet rozwiązania i zacząć pisanie bez zastanawiania się jak prawidłowo zbudować paczkę, czy co należy skonfigurować po stronie AWS.
Nie bez znaczenia jest też możliwość łatwego przeniesienia logiki z istniejących rozwiązań. Zarówno Micronaut jak i Dagger obsługują standardowe API JEE do wstrzykiwania zależności (JSR-330).
Micronaut dostarcza też bardzo wygodne narzędzia do integracji przez interfejsy REST. Jeżeli integrujemy się z zewnętrznymi zasobami czy innymi Lambdami, może nam to zdecydowanie ułatwić pracę.
Wady
Użycie frameworka Micronaut powoduje, że z założenia rozmiarowo niewielkie paczki z funkcjami rozrastają się. Niesie to ze sobą kilka niedogodności. Wyraźnie widać korelację między wielkością paczki, a czasem cold startów.
Drugim negatywnym skutkiem większych jar’ów lub zip’ów jest konieczność przydzielenia większej ilości pamięci do funkcji. Przestrzeń Metaspace jest w Lambdach ograniczona, a jej wielkość jest proporcjonalna do całej przydzielonej pamięci.
Nawet funkcji hello-world wygenerowanej z użyciem Micronaut CLI nie udało nam się uruchomić bez zaalokowania minimum 320 MB pamięci. Może to być nieoptymalne kosztowo. Szczególnie kiedy nie zależy nam bardzo na przyspieszaniu wykonania naszego kodu, gdy głównie czekamy na zewnętrzne usługi. Przykładem są funkcje PrintInvoice i NotifyInvoice, gdzie głównym składnikiem czasu wykonania jest komunikacja.
Frameworki znane są z apetytu na różne zależności. Micronaut dając nam wiele gotowych mechanizmów dokłada sporo bibliotek. Spring dołożyłby ich znacznie więcej, co zdyskwalifikowało go już na początku naszej przygody z AWS Lambda. Google Dagger broni się przed tym i poza API CDI nie dokłada nic od siebie.
Wszystkie te wady przekładają się na większe koszty używania Lambd. Zwiększony czas cold startu przekłada się nie tylko na jednorazowy koszt przy pierwszym użyciu. Jeżeli dostaniemy wiele zapytań równocześnie, to czekając na inicjalizację pierwszej instancji, AWS będzie tworzył kolejne. W efekcie powstaną kosztowne instancję, które zostaną użyte tylko raz, gdyż późniejsze czasy wykonania będą nawet kilkadziesiąt razy szybsze i nie będzie potrzeby utrzymywania aż tylu kopii jednocześnie. Pamiętajmy też o limitach jakie nakłada na nas AWS. Możemy mieć tylko 1000 instancji funkcji i niepotrzebne zużywanie tego limitu może nam przysporzyć kłopotów przy większych infrastrukturach.
Wnioski
Powyższe wyniki i wnioski pokazują, że musimy pójść na kompromis.
Im bardziej zaawansowany framework, tym łatwiej i szybciej będziemy mogli tworzyć kod funkcji i testy do nich. Dostajemy gotowe narzędzia do integracji i konstrukcji. Niestety będzie to okupione wolniejszym działaniem i większymi kosztami infrastruktury.
Żadne z opisywanych narzędzi nie zapewniało gotowych integracji do usług AWS, więc i tak czeka nas własnoręczne wykorzystanie bibliotek udostępnionych przez AWS.
W pewnych ekstremach jak funkcje, które przyjmują okazjonalnie wiele zapytań jednocześnie rachunek jest prosty. Tutaj powinniśmy trzymać się wytycznych AWS Lambda i ograniczyć do minimum wykorzystanie bibliotek.
Z drugiej strony, jeżeli nie zależy nam tak bardzo na czasie startu, a nasza aplikacja jest skomplikowana obliczeniowo, to nie ma przeszkód w stosowaniu Micronauta. Nie zmieni to znacząco kosztów infrastruktury ani wydajności rozwiązania, a dzięki możliwościom frameworka (np. generowanie klientów HTTP) uprościmy implementacje.
Dobrym kompromisem wydaje się być Google Dagger. Zapewnia nam kontener IoC, a jednocześnie nie narzuca własnych narzędzi i tylko bardzo nieznacznie wpływa na wielkość paczki i czas wykonania.
Zarówno Micronaut jak i Dagger pozwolą nam niewielkim kosztem przenieść kawałki logiki z istniejącego kodu. Nawet przejście ze Springa powinno być stosunkowo proste.
Zgodnie z najlepszymi praktykami powinniśmy dążyć do tego, aby nasze lambdy były jak najlżejsze. Osiągniemy to między innymi poprzez minimalizowanie złożoności naszych zależności. Należy zatem dla każdego przypadku stworzyć prosty rachunek zysków i strat.
Artykuł został pierwotnie opublikowany na altkomsoftware.pl. Zdjęcie główne artykułu pochodzi z stocksnap.io.