Jak dobrać zasoby dla funkcji Lambda
Często na szkoleniach, które prowadzę, pada pytanie jak dobrać zasoby dla funkcji Lambda. To ważna kwestia. Przydzielona do funkcji pamięć, a wraz z nią, w proporcjonalnej wielkości zasoby CPU, mają wpływ na wydajność naszych aplikacji.
Jednak im więcej zasobów wykorzystamy, tym większy będzie nasz rachunek w AWS. Poza ilością uruchomienia naszej funkcji płacimy przecież także za zasoby. Dokładny i aktualny cennik Lambdy znajdziesz tutaj.
Korzystając z funkcji Lambda płacimy za okresy czasu w wielkości 100ms. W chwili gdy to piszę, w Irlandii, wygląda to następująco:
- 128 MB – 0,0000002083$
- 512 MB – 0.0000008333$
- 1024 MB – 0.0000016667$
- 2048 MB – 0.0000033333$
Jak więc dobrać te zasoby, aby było dobrze i tanio?
Na tak postawione pytanie nie ma jednej, prostej odpowiedzi. To znaczy jest, to zależy. 😉 Ale od czego? I jak do tego podejść?
Funkcja funkcji nie równa. Jak za chwilę zobaczysz, ilość pamięci i procesora raz ma wpływ na to jak działa nasza funkcja, a innym razem nie ma. To raz.
Dwa to decyzja, czy nasze rozwiązanie musi być szybkie, czy tanie. Być może, a nawet na pewno, w przypadku niektórych funkcji, czas zakończenia ich działania nie będzie krytyczny i mogą popracować trochę dłużej. Nie zawsze po drugiej stronie siedzi zniecierpliwiony klient. Ale tu też nie możesz przesadzić z oszczędzaniem na zasobach. Jeżeli jakaś funkcja przy przydzielonych 128MB będzie się wykonywała 8 razy dłużej niż przy 256MB, to i tak zapłacisz więcej.
Na początku dobrze jest więc podjąć decyzję czego tak naprawdę oczekujemy. Wydajności czy niskich kosztów. A może balansu pomiędzy jednym i drugim?
OK, wiemy już czego oczekujemy. Ale…
Jak szybko i łatwo sprawdzić jak zachowuje się nasza nowa funkcja?
Zanim jak, zacznijmy od tego co. Będziemy testowali dwie Lambdy. Jedna z nich zajmie się Fibonaccim (będzie więc mocno obciążała procesor),
func calculate(n int64) int64 { var ( a int64 = 1 b int64 = 0 tmp int64 = 0 ) for n > 0 { tmp = a a = a + b b = tmp n-- } return b }
a druga wykona tylko request HTTP i pobierze trochę danych.
Obie funkcje dostępne są w repozytorium, możecie więc samodzielnie powtórzyć testy.
Teraz możemy przejść już do tego jak. Możemy oczywiście przygotować kilka konfiguracji dla funkcji, wywołać każdą kilkadziesiąt razy, przejrzeć logi CloudWatch… Stop! Miało być szybko i łatwo. Do testów wykorzystamy więc narzędzie lumingo-cli, które pozwala między innymi na wygodne wykonanie takich testów.
Testujemy
Wszystkie polecenia, które wykorzystamy w trakcie testów są dostępne w tym pliku.
Na początek zbudujemy
go get github.com/aws/aws-lambda-go/lambda
GOOS=linux go build cpu_lambda.go
zip function.zip cpu_lambda
i uruchomimy w AWS
aws lambda create-function --function-name cpu_lambda_go --runtime go1.x \
--zip-file fileb://function.zip --handler cpu_lambda \
--region eu-west-1 \
--role arn:aws:iam::220107442686:role/Lambda-Simple
naszą pierwszą funkcję.
10 milionów
Możemy teraz wykorzystać lumigo-cli i wykonać pierwszy test. Policzmy wartość dla 10 milionów. Tym razem będzie nas interesowała konfiguracja, w której funkcja będzie najwydajniejsza. Dlatego ustawiona jest strategia speed.
lumigo-cli powertune-lambda -n cpu_lambda_go -r eu-west-1 --strategy speed -e 10000000
Po chwili dostaniemy wynik
Widać, że funkcja wykonała się najszybciej przy przydzielonych 3 GB pamięci RAM. Sprawdźmy jednak na wykresie jak wygląda zależność szybkości wykonania funkcji od ilości dostępnej dla niej pamięci.
Widać, że powyżej 512MB wykres mocno się wypłaszcza. Wartości dla poszczególnych zasobów wyglądają następująco:
- 128 – 56 ms
- 256 – 22 ms
- 512 – 10 ms
- 1024 – 7 ms
- 1536 – 7 ms
- 3008 – 7 ms
Widać, że udostępnienie funkcji więcej niż 1GB raczej mija się z celem. A i ta wartość jest dyskusyjna. Wszystko zależy od tego, czego oczekujemy. A to ustaliliśmy sobie wcześniej. 🙂
Z ciekawości przygotowałem podobną funkcję w Node.js. Spodziewałem się większych różnic w wydajności w porównaniu z Go.
lumigo-cli powertune-lambda -n cpu_lambda_node -r eu-west-1 --strategy speed -e 10000000
Wyniki wyszły jednak bardzo podobne. Powtórzyłem ten test kilka razy i uzyskiwane wartości różniły się od siebie bardzo nieznacznie.
- 128 – 62 ms
- 256 – 25 ms
- 512 – 9 ms
- 1024 – 7 ms
- 1536 – 5 ms
- 3008 – 7 ms
20 milionów
W kolejnym teście sprawdziłem jak zachowa się funkcja, gdy będziemy liczona wartość ciągu Fibonacciego dla liczby 20M.
lumigo-cli powertune-lambda -n cpu_lambda_go -r eu-west-1 --strategy speed -e 20000000
Także tutaj najwydajniej zachowała się instancja funkcji z przydzielonymi 3GB pamięci RAM.
Wykres wypłaszcza się podobnie, jednak trochę później
Jak widać, w tym przypadku można zastanowić się nad zwiększeniem pamięci z 512MB do 1GB. Ma to sens. Różnica w czasie wykonania jest prawie dwukrotna.
- 128 – 112 ms – 0,00000042$
- 256 – 69 ms – 0,00000042$
- 512 – 33 ms – 0,00000083$
- 1024 – 14 ms – 0,00000167$
- 1536 – 14 ms – 0,0000025$
- 3008 – 14 ms – 0,0000049$
lumigo-cli pozwala także na “wyszukanie” tak zwanych zbalansowanych ustawień dla funkcji. Ustawień, gdzie stosunek ceny do wydajności będzie najlepszy. W tym celu użyjemy strategii balanced
Polecenie
lumigo-cli powertune-lambda -n cpu_lambda_go -r eu-west-1 --strategy balanced -e 20000000
pokaże, że najlepszym rozwiązaniem będzie przydzielenie funkcji 512MB.
Coś lżejszego
Przetestujmy teraz funkcję, która nie wykorzystuje w znacznym stopniu procesora. Sprawdźmy jak zachowuje się Lambda, która ściąga tylko jakieś dane używając HTTP. Pobierzmy źródła strony www.google.com. Tutaj różnice powinny być mniejsze, ale weźmy pod uwagę, że wraz ze wzrostem ilości przydzielonej pamięci rośnie także wydajność sieci.
lumigo-cli powertune-lambda -n req_lambda_go -r eu-west-1 --strategy speed
Teoretycznie funkcja najwydajniej wykonała się przy 1024MB pamięci.
Jednak patrząc na wykres, myślę, że to bardziej aktualny stan sieci miał wpływ na wyniki. W tym przypadku można raczej pozostać na minimalnej ilości pamięci dla funkcji, czyli 128MB.
Czyli?
Nie ma prostej odpowiedzi na postawione w tytule pytanie. Odpowiedzi, która sprawdzi się w każdym przypadku.
Jeżeli jednak, na samym początku, odpowiemy sobie na pytanie czego chcemy, czy wydajności, czy redukcji kosztów, to wiemy już jak poradzić sobie z przydzielaniem zasobów dla naszych funkcji Lambda. Pamiętajmy jednak, że wydajność zależy nie tylko od tego co nasza funkcja robi. Zależeć będzie także np. od ilości danych, które będzie przetwarzała. Myślę, że wykonanie wielu testów, dla różnych obciążeń samej funkcji wskaże nam kierunek i pozwoli na wybranie odpowiednich ustawień.
Jest jednak jeszcze jedna ważna rzecz. Nie zawsze, a właściwie rzadko, to funkcje będą najkosztowniejszym składnikiem naszych rozwiązań. Często oszczędności można znaleźć w innym miejscu. Może warto zrezygnować z API Gateway na rzecz ALB. Może zamiast Kinesis wystarczy prosta kolejka SQS.
Zresztą, po jakimś czasie od wdrożenia i tak warto się przyjrzeć kosztom i być może zmienić to i owo.
Tylko nie szukajmy w nieskończoność minimalizacji kosztów w chmurze. Może okazać się, że czas (czyli koszt), który poświęcimy na optymalizację, może wielokrotnie przewyższyć to co odzyskamy od AWS. Lub innego vendora.