W poprzednim artykule o ReadyToRun opisywałem możliwości skracania cold-startów dotnetowych lambd przy użyciu techniki pre-jittingu.
Kontynuując niejako ten temat, postaram się przybliżyć podobną technikę, która zadebiutowała na AWS-ie nieco wcześniej, bo już w czasach .NET Core 2.1.
Mowa tutaj o usłudze Lambda Layers i dodatkowych możliwościach optymalizacji, jakie daje Amazon Lambda Tools, gdy korzystamy z ww. warstw.
Czym jest Lambda Layer?
Lambda Layer to najprościej mówiąc dodatkowy zestaw plików, który możemy zapisać w serwisie AWS Lambda, a następnie wskazać go tworząc własne funkcje lambda.
Przy uruchamianiu lambdy, zawartość warstwy zostaje wypakowana do folderu /opt
wewnątrz kontenera i jest dostępna dla naszego kodu.
Typowe użycie warstwy polega na wydzieleniu jakiejś części wspólnej dla wielu lambd (np. zewnętrzne biblioteki, pliki binarne) i zredukowanie w ten sposób rozmiaru paczki, którą musimy zuploadować przy wdrażaniu, a jak wiadomo mniejsza paczka oznacza szybsze wdrożenie. W przypadku języków interpretowanych, użycie warstwy umożliwia również ominięcie limitu wielkości kodu, do którego możemy używać webowego edytora.
Jak Lambda Layer może skrócić cold-start?
.NET Core począwszy od wersji 2.0 udostępnia mechanizm Runtime package store. Umożliwia on stworzenie zewnętrznego magazynu na biblioteki, do którego może odwoływać się nasza aplikacja. Dzięki temu możliwe jest tworzenie mniejszych pakietów wdrożeniowych, pozbawionych zależności zlokalizowanych w magazynie. Dodatkowo tworząc magazyn, możemy zoptymalizować biblioteki pod docelową platformę uruchomieniową, czyli wykonać pre-jitting.
Amazon Lambda Tools – standardowy pakiet komend dla Dotnet CLI, począwszy od wersji 3.2.0 udostępnia polecenia, które potrafią stworzyć Lambda Layer z zależnościami danego projektu, wykorzystując pod spodem wspomniany wcześniej Runtime package store z .NET Core SDK. Następnie, publikując lambdę, wskazujemy ARN utworzonej warstwy, a dotnet lambda
automatycznie wycina z deployment package’u zależności, które znajdują się już w warstwie. Jeśli, tworzenie warstwy przeprowadzimy na Linuksie, otrzymamy dodatkowo możliwość przeprowadzenia optymalizacji. Co ciekawe, nawet jeśli samą lambdę zbudujemy i opublikujemy pod Windowsem, to w dalszym ciągu będzie ona mogła skorzystać z optymalizacji wynikających z użycia warstwy.
Lambda Layer w praktyce
Layer tworzymy uruchamiając w katalogu z projektem następującą komendę:
dotnet lambda publish-layer LayerName
--layer-type runtime-package-store `
--s3-bucket bucket `
--framework netcoreapp2.1 `
--enable-package-optimization true
Narzędzie automatycznie znajdzie plik .csproj i wykorzysta go do sporządzenia listy bibliotek. Następnie stworzy magazyn i opublikuje go w formie Lambda Layer na naszym koncie AWS. Dodatkowo na wskazanym buckecie S3 zostanie zapisany plik z manifestem zawierającym listę bibliotek, który będzie potrzebny w momencie publikowania lambdy. Na koniec zwrócony zostanie ARN naszej warstwy.
Niestety już tutaj pojawiają się pierwsze zgrzyty. dotnet lambda
potrafi tylko znaleźć biblioteki zdefiniowane bezpośrednio w danym projekcie, jeśli projekt z lambdami jest powiązany zależnością z innym projektem w solucji, biblioteki z powiązanego projektu nie zostaną uwzględnione. Pozostaje nam więc albo wylistować jeszcze raz wszystkie bilioteki w docelowym projekcie, albo stworzyć osobny plik manifestu zawierający taką listę. Co ciekawe, składnia polecenia dotnet store
, które pod spodem jest wykorzystywane, pozwala na stworzenie magazynu z listy projektów, jednak funkcjonalność ta nie jest wspierana przez dotnet lambda
.
Również automatyzacja aktualizacji już utworzonych warstw jest utrudniona. Nie ma żadnego mechanizmu, który sprawdzałby czy publikowanie kolejnej wersji warstwy ma sens. Każde uruchomienie polecenia tworzy nową warstwę, niezależnie czy pojawiły się jakieś zmiany w projekcie czy nie.
Kolejnym problemem jest fakt, że tworzenie warstw na ten moment nie działa w ogóle dla projektów .NET Core 3.1. Wynika to z błędnego działania polecenia dotnet store
dla tej platformy. W szczegółach opisuje to zgłoszenie na githubie, które od trzech miesięcy pozostaje otwarte.
Publikacja lambdy
Ostatnim krokiem jest wdrożenie samej lambdy. Jedyne co musimy zrobić, to wskazać ARN utworzonej wcześniej warstwy. Jeśli korzystamy CloudFormation dopisujemy ją po prostu w sekcji Layers
. Co ciekawe, polecenie dotnet lambda publish-serverless
przed publikacją przegląda templatkę CloudFormation i jeśli znajdzie tam powiązanie lambdy z warstwą, ściąga plik manifestu warstwy z S3 i na jego podstawie wycina biblioteki z pakietu wdrożeniowego.
Jednak i tutaj pojawia się pewien problem. Tworząc proces CI/CD zapewne chcielibyśmy sparametryzować adres warstwy i przekazywać go do templatki jako parametr. Z punktu widzenia CloudFormation możemy to bez problemu zrobić, jednak dotnet lambda
nie przetwarza w żaden sposób pliku templatki i traktuje go dosłownie. W efekcie zobaczymy następujący błąd:
Inspecting Lambda layers for runtime package store manifests
... { { Fn::If, [ UseLambdaLayer, LayerArn, AWS::NoValue ] } }: Skipped, error inspecting layer.
Error parsing layer version arn into layer name and version number
🤦♂️
Trafiłem na jeszcze jeden, bardziej subtelny problem. Mianowicie, wygląda na to, że tworzenie warstwy przy użyciu .NET Core SDK 2.1, teoretycznie działa, ale w praktyce niezbyt. Objawy są takie, że dotnet lambda
pobiera plik manifestu, analizuje go, po czym stwierdza, że lambda i tak wymaga wszystkich zależności, a więc warstwa okazuje się bezużyteczna. Kłopot w tym, że wszystko to odbywa się po cichu, bez żadnego wyraźnego komunikatu o błędzie, a przyczyny trzeba szukać na własną rękę analizując kolejne artefakty powstające w procesie. Rozwiązaniem okazało się podniesienie SDK do wersji 3.1.
Wpływ użycia Lambda Layer na cold-starty
Skuteczność rozwiązania w ogarniczaniu cold-startów zmierzyłem w taki sam sposób, jak poprzednio, modyfikując jedynie projekty by działały na .NET Core 2.1. Całość rozwiązania jest dostępna na naszym repozytorium.
Cold-starty zmierzyłem dla 3 przypadków: standardowa lambda bez warstwy, warstwa bez optymalizacji oraz zoptymalizowana warstwa. Wyniki prezentują się następująco:
Rozmiar pamięci (pośrednio moc CPU) | Brak warstwy | Bez optymalizacji | Z optymalizacją |
128 MB | 32,745 s | 32,5308 s | 12,083 s |
256 MB | 16,0994 s | 16,4036 s | 6,1084 s |
512 MB | 7,7816 s | 7,9274 s | 3,2336 s |
1024 MB | 3,9982 s | 4,0146 s | 1,6638 s |
2048 MB | 2,2898 s | 2,3556 s | 1,131 s |
3008 MB | 2,291 s | 2,3698 s | 1,1618 s |
Jak widać użycie niezoptymalizowanej warstwy nie ma żadnego wpływu na cold-starty. Różnica w czasach mieści się w granicach błędu statystycznego.
Jeśli zaś chodzi o lambdę korzystającą ze zoptymalizowanej warstwy, widzimy wyraźną redukcję czasów. Wynosi ona około 60%. Jest to porównywalne z tym, co udało mi się uzyskać w poprzednim artykule, wykorzystując technikę ReadyToRun. Nieco lepsze wyniki uzyskane w przypadku warstw, wynikają prawdopodobnie z wykorzystania innej wersji platformy .NET Core. Niestety na ten moment nie jestem w stanie tego potwierdzić.
Podsumowanie
Pomimo znaczącej redukcji cold-startów, trudno polecić opisywane rozwiązanie. Na obecnym etapie rozwoju, wykorzystywane narzędzia są toporne, niedopracowane i bardzo zawodne. Zmuszenie wszystkiego do działania zajęło mi zdecydowanie za dużo czasu i wymagało żmudnego analizowania poszczególnych kroków całego procesu, a pamiętajmy, że pracowałem jedynie z bardzo małym przykładowym projektem.
Jeśli mamy lambdy w .NET Core 2.1, które wymagają optymalizacji, najprawdopodobniej lepszym rozwiązaniem będzie przeportowanie ich na wersję 3.1 i skorzystanie z techniki ReadyToRun. Rozwiązanie to będzie równie skuteczne, a przy tym o wiele łatwiejsze w utrzymaniu.