Długi czas oczekiwania na wynik pierwszego uruchomienia lambdy towarzyszy nam od momentu, gdy AWS zaczął wspierać platformę .NET core w swoim sztandarowym serwisie. Na przestrzeni lat, wraz ze wsparciem dla kolejnych wersji .NET core, sytuacja ulegała stopniowej poprawie, mimo wszystko ten uciążliwy problem wciąż występuje. Jednak w najnowszej wersji 3.1 dostaliśmy do dyspozycji nowe narzędzie, które, przy odrobinie wysiłku, pozwoli nam znacząco skrócić czas cold-startów.
Obrazy ReadyToRun
Jedną z przyczyn występowania cold-startów jest fakt wykorzystywania przez platformę .NET core techniki kompilacji podczas uruchomienia (Just-In-Time compilation). W tym procesie kod aplikacji tłumaczony jest z języka pośredniego CIL na kod binarny, który to dopiero może być uruchomiony na docelowej platformie sprzętowej. Zajęcie to jest wymagające obliczeniowo, a potrzebny czas rośnie proporcjonalnie do wielkości naszego kodu i wykorzystanych bibliotek.
Jedną z nowości wprowadzonych przez Microsoft w .NET core 3.0 jest opcja ReadyToRun (w skrócie R2R). Umożliwia ona, już na etapie kompilacji kodu źródłowego, przygotowanie zestawów (assemblies) w taki sposób, aby zawierały one docelowy kod binarny. Dzięki temu kompilacja JIT staje się zbędna, a aplikacja powinna, w teorii, uruchomić się zdecydowanie szybciej. Optymalizowany jest zarówno nasz kod, jak i wszystkie zewnętrzne zależności.
Niestety każda magia ma swoją cenę. W tym przypadku wynikowe pliki będą większe, bo dla kompatybilności z innymi platformami muszą zawierać również standardową wersję CIL. Nie ma również możliwości cross-kompilacji – wersji dla linuxa nie przygotujemy na windowsie i vice-versa.
Docker na ratunek
Jeśli programujemy w C#, to jest spora szansa, że robimy to na windowsie, natomiast kontenery, na których uruchamiane są lambdy, bazują na Amazon Linuxie. Szczęśliwie, problem ten możemy łatwo obejść korzystając z dockera. Wykorzystując poniższy Dockerfile
, stworzymy dockerowy obraz, który umożliwi nam budowanie lambdowych projektów z włączoną opcją ReadyToRun.
FROM amazonlinux:2
ENV PROJECT Functions
RUN yum -y update
RUN rpm -Uvh https://packages.microsoft.com/config/centos/7/packages-microsoft-prod.rpm
RUN yum -y install dotnet-sdk-3.1
RUN yum -y install zip
RUN dotnet tool install -g Amazon.Lambda.Tools
ENV PATH="/root/.dotnet/tools:${PATH}"
RUN mkdir /work
WORKDIR /work
CMD dotnet lambda package --project-location $PROJECT --msbuild-parameters "/p:PublishReadyToRun=true --self-contained false"
Bazujemy na dystrybucji linuxa od Amazona, na której zainstalowane zostaną dotnet SDK oraz pakiet Amazon.Lambda.Tools
. Projekt budowany będzie za pomocą standardowej komendy dotnet lambda package
z dodatkowym przełącznikiem --msbuild-parameters
.
Przed pierwszym użyciem musimy zbudować obraz i zapisać go w naszym lokalnym repozytorium
docker build -t rozchmurzeni/lambdadotnetr2r .
Następnie możemy budować nasz projekt za pomocą komendy
docker run --rm -v C:\Path\To\Solution:/work -e PROJECT=LambdaProjectName rozchmurzeni/lambdadotnetr2r
Za pomocą przełącznika -v
montujemy katalog naszej solucji do katalogu /work
wewnątrz kontenera, a do zmiennej PROJECT przypisujemy nazwę folderu zawierającego nasze lambdy. Dzięki przełącznikowi --rm
kontener zostanie usunięty zaraz po zbudowaniu projektu.
Tak zbudowany projekt możemy wdrożyć na AWS-ie wykonując w katalogu solucji polecenie
dotnet lambda deploy-serverless `
--project-location LambdaProjectName `
--package LambdaProjectName/bin/release/netcoreapp3.1/Functions.zip `
--stack-name my-r2r-stack `
--s3-bucket my-deploy-bucket `
--template LambdaProjectName/template.yaml `
--region eu-central-1 `
Porównując tę samą lambdę opublikowaną z wyłączonym i włączonym R2R zauważymy zdecydowaną różnicę w rozmiarze paczki. W moim przykładowym projekcie rozmiar wzrósł z 3.2MB do 7.9 MB.
Jak ReadyToRun wpływa na cold-start?
Na potrzeby testu stworzyłem prosty projekt z dwoma sporymi zależnościami: Entity Framework Core (najpopularniejszy ORM) oraz EPPlus (biblioteka do pracy z plikami Excela). Aby zależności nie zostały wycięte w procesie optymalizacji, lambda przy uruchomieniu tworzy bazę danych sqlite, wykonuje na niej zapytanie, a następnie tworzy arkusz Excela.
Cold-start zmierzyłem uruchamiając lambdę w regionie eu-central-1
i porównując czas odpowiedzi z zimnego i rozgrzanego kontenera. Start nowego kontenera wymuszałem zmieniając losowo opis w konfiguracji lamdby. Wynik ostateczny to średnia z 5 pomiarów. Wyniki prezentują się następująco:
Rozmiar pamięci (pośrednio moc CPU) | Cold-start bez R2R | Cold-start z R2R |
128 MB | 23,4606 s | 11,4732 s |
256 MB | 11,3722 s | 5,3842 s |
512 MB | 5,6064 s | 2,7544 s |
1024 MB | 2,7528 s | 1,451 s |
2048 MB | 1,629 s | 0,9834 s |
3008 MB | 1,686 s | 0,1958 s |
Jak widać wyniki są dość jednoznaczne, cold-starty zostały zredukowane o połowę prawie we wszystkich przypadkach.
Źródła testowego projektu oraz aplikacji do pomiaru cold-startów dostępne jest na naszym repozytorium.
Podsumowanie
Wyniki testu są bardzo obiecujące. Bez ingerencji w istniejący kod (poza ewentualną migracją do .NET core 3.1), jesteśmy w stanie skrócić cold-starty o połowę. To bardzo dobry wynik.
Konieczność budowania projektu na Linuxie jest pewną niedogodnością, ale jak pokazałem, można z tym sobie łatwo poradzić. Nie wiadomo natomiast czy użycie ReadyToRun jest zawsze bezproblemowe i nie powoduje jakichś problemów z kompatybilnością. Zapewne dowiemy się tego, gdy technika ta zacznie być szerzej wykorzystywana.