postimage

Jeżeli chociaż raz zastanawiałeś się, w jakim języku programowania napisać funkcję w Google Cloud, to w tym wpisie postaram się pomóc w podjęciu tej decyzji.

Na warsztat weźmiemy wszystkie dostępne na ten moment środowiska uruchomieniowe dla Google Cloud Functions i porównamy czasy odpowiedzi oraz zimne starty (tzw. cold starts). Porównamy nie tylko środowiska uruchomieniowe, ale również regiony w których osadzone są funkcje.

Motywacja

Pisząc swoją pierwszą funkcję w GCP zastanawiałem się, w jakim języku ją napisać? Przecież to prosta funkcja, mogę ją napisać w każdym dostępnym języku. Pisać w Javie, której używam na co dzień? A może w Node.js? Przecież TypeScript też jest dla mnie codziennością…

Motywacją do przeprowadzenia testów był przede wszystkim brak odpowiedzi na moje pytania oraz brak porównań środowisk uruchomieniowych dla Cloud Functions w Internecie.

Środowisko testowe

Google co chwilę rozszerza listę obsługiwanych środowisk uruchomieniowych, dlatego zależało mi na tym, żeby porównanie funkcji było łatwe do przeprowadzenia w przyszłości, z uwzględnieniem nowych języków. Chcąc zautomatyzować całą procedurę i środowisko testowe, wraz z kolegą Jackiem Grobelnym przygotowaliśmy projekt pt. Google Coud Functions Comparison.

Do automatyzacji wykorzystany został Terraform, za pomocą którego przygotowywane jest całe środowisko testowe. Wszystkie osadzane funkcje są definiowane w konfiguracji, dlatego w prosty sposób można uruchomić środowisko testujące wybrane języki oraz regiony.

Testy czasów odpowiedzi zostały napisane w Gatlingu, który listę funkcji odczytuje z tej samej konfiguracji, przez co nie wymaga żadnej dodatkowej ingerencji. Testy zimnych startów wykonywane są natomiast bezpośrednio przez kod napisany w Scali, a wyniki wyświetlane są w formie tabeli ASCII.

Kody wszystkich funkcji znajdują się w folderze /functions i są to podstawowe funkcje odpowiadające “Hello World”, takie same jak przykładowe funkcje tworzone z poziomu Cloud Console.

Projekt znajduję się na GitHubie - link do repozytorium.

Metodyka testowania

W testach wykorzystałem funkcje uruchomione w następujących środowiskach uruchomieniowych:

  • .NET Core 3.1
  • Go 1.13
  • Java 11
  • Node.js 14
  • PHP 7.4
  • Python 3.9
  • Ruby 2.7

Każda funkcja posiadała maksymalnie jedną instancję, przydzieloną pamięć 128 MB i została uruchomiona w regionach:

  • europe-west3 (Frankfurt, Germany, Europe)
  • us-central1 (Council Bluffs, Iowa, North America)
  • asia-east2 (Hong Kong, APAC)

W celu porównania czasów odpowiedzi, każda funkcja była wywoływana przez 10 minut i 20 równoległych użytkowników. Ilość wykonanych żądań jest zatem uzależniona od samej funkcji oraz Gatlinga (a w zasadzie, to mojego laptopa).

Testy zimnych startów zostały wykonane z zapewnieniem braku istnienia aktywnej instancji. Test polegał na wykonaniu 10 żądań do każdej z funkcji, a następnie porównaniu czasu pierwszej odpowiedzi do średniej arytmetycznej czasów pozostałych 9 odpowiedzi.

Każdy test uruchomiłem dwa razy, o tej samej godzinie czasu polskiego dla wszystkich regionów oraz o tej samej godzinie czasu lokalnego w danym regionie. Wszystkie żądania były wykonywane z mojej stacji roboczej w Poznaniu.

Przyjąłem nazewnictwo język interpretowany dla języków skryptowych i kompilowanych (nie korzystających z maszyny wirtualnej) oraz język uruchamiany w maszynie wirtualnej dla języków kompilowanych i uruchamianych w maszynie wirtualnej.

Czasy odpowiedzi

Godziny uruchomienia testów

  • Run #1 - środek tygodnia, 17-23 czasu polskiego
  • Run #2 - środek tygodnia, 20:00 czasu lokalnego w danym regionie:
    • europe-west3 (Frankfurt, Germany, Europe) - 20:00 o 20:00 czasu polskiego
    • us-central1 (Council Bluffs, Iowa, North America) - 20:00 o 03:00 czasu polskiego (-7h)
    • asia-east2 (Hong Kong, APAC) - 20:00 o 13:00 czasu polskiego (+7h)

Wyniki

Runtime Region Requests Min [ms] 95th pct [ms] Max [ms] Mean [ms] Std Dev [ms]
nodejs14 europe-west3 20796 85 909 1591 576 182
go113 europe-west3 25681 83 856 7607 466 213
java11 europe-west3 20408 83 1078 2675 587 259
python39 europe-west3 20810 83 893 1601 575 160
ruby27 europe-west3 25818 82 711 1791 464 156
dotnet3 europe-west3 17489 84 1003 3912 685 218
php74 europe-west3 25162 84 793 1494 476 160
nodejs14 us-central1 16341 192 1100 2789 733 244
go113 us-central1 19738 192 1017 1757 607 213
java11 us-central1 16545 190 1400 7796 724 339
python39 us-central1 14907 192 1200 2302 804 248
ruby27 us-central1 17968 193 1091 3559 667 229
dotnet3 us-central1 14444 192 1197 3407 830 240
php74 us-central1 16172 193 1104 2088 741 192
nodejs14 asia-east2 25112 352 780 2086 477 142
go113 asia-east2 28831 350 587 1604 415 112
java11 asia-east2 22617 352 1093 5098 530 292
python39 asia-east2 20441 373 889 1786 586 146
ruby27 asia-east2 28630 349 589 1872 418 106
dotnet3 asia-east2 21549 364 896 3197 556 184
php74 asia-east2 26001 351 715 1786 461 140
Runtime Region Requests Min [ms] 95th pct [ms] Max [ms] Mean [ms] Std Dev [ms]
nodejs14 europe-west3 20487 80 992 1516 585 191
go113 europe-west3 26846 83 796 2314 446 175
java11 europe-west3 22988 82 906 2400 521 224
python39 europe-west3 21902 85 806 1496 547 155
ruby27 europe-west3 27023 82 703 2064 443 163
dotnet3 europe-west3 17760 85 995 2800 675 189
php74 europe-west3 22418 82 890 1712 534 167
nodejs14 us-central1 18468 188 1006 1625 649 197
go113 us-central1 20845 185 905 1805 575 183
java11 us-central1 17753 188 1290 3199 675 303
python39 us-central1 16048 188 1197 2322 747 232
ruby27 us-central1 18306 185 1013 2020 655 218
dotnet3 us-central1 17756 187 1153 3825 676 290
php74 us-central1 16902 188 1098 2102 709 199
nodejs14 asia-east2 22854 349 835 2309 524 157
go113 asia-east2 28844 346 594 1749 415 112
java11 asia-east2 21243 347 1109 5398 564 316
python39 asia-east2 21013 367 805 1902 570 147
ruby27 asia-east2 27958 343 604 1849 428 127
dotnet3 asia-east2 21802 357 887 3104 549 172
php74 asia-east2 25581 348 711 2224 468 151

Regiony

Wyniki pierwszego testu nieco mnie zaskoczyły, ponieważ bardzo dobrze wypadły tutaj funkcje osadzone w Azji (a nie najbliżej mojej geolokalizacji, jak się spodziewałem).

Dlatego postanowiłem wykonać test ponownie, aby wykluczyć różnice w czasie (ponieważ o godzinie 20:00 czasu Polskiego, w Hong Kongu była godzina 03:00). Dzięki temu mogłem sprawdzić, czy wpływ na wyniki ma tutaj fakt, że w środku nocy obciążenie centrum danych może być mniejsze.

Drugi test wykluczył jednak kwestie godziny w danym regionie, ponieważ i tym razem Azja wypadła najlepiej. Z powodu odległości można zaobserwować znacznie wyższe minimalne i maksymalne czasy odpowiedzi, jednak średnio były one i tak nieco niższe niż w przypadku Frankfurtu. W ciągu 10 minut udało się wykonać sumarycznie więcej żądań.

Najgorzej wypadł region w USA, gdzie pomimo niższych minimalnych czasów odpowiedzi, średnio były one znacznie wyższe (co idealnie pokazuje kolumna z 95 percentylem). W efekcie funkcje uruchomione w USA obsłużyły zauważalnie mniejszą liczbę żądań.

Testy starałem się wykonać w środku tygodnia, aby były jak najbardziej wiarygodne. Pod uwagę należy wziąć jednak fakt, że funkcje były bardzo prymitywne - jedyne co robiły, to odpowiadały “Hello World”. Całkiem możliwe, że w przypadku funkcji do których wysyłamy lub które zwracają jakieś dane, wyniki byłyby zupełnie inne. Zależało mi jednak na sprawdzeniu prostych funkcji, ponieważ w tym przypadku łatwo jest porównać środowiska uruchomieniowe (w przypadku bardziej złożonych implementacji duży wpływ na wydajność mogłyby mieć wykorzystane zewnętrzne zależności czy biblioteki).

Podsumowując, gdybym chciał uruchomić prostą funkcję i zależałoby mi na tym, aby obsłużyła jak największy ruch, prawdopodobnie wybrałbym któryś z regionów w Azji.

Środowiska uruchomieniowe

W przypadku środowisk uruchomieniowych spodziewałem się, że języki interpretowane dają lepsze wyniki niż języki uruchamiane w wirtualnej maszynie.

Wyniki testu częściowo potwierdziły moje podejrzenia, ponieważ najszybciej odpowiadały funkcje napisane w Go, Ruby czy PHP. Dużym zaskoczeniem były dla mnie wyniki Node.js, które są dość przeciętne. Spodziewałem się że JavaScript uplasuje się w czołówce, jednak wyniki były bardziej zbliżone do języków uruchamianych w maszynie wirtualnej.

Kompletnie nie zdziwiły mnie za to wyniki funkcji napisanych w Javie czy .NET, jednak nie spisywałbym ich na straty. Środowiska uruchomieniowe wykorzystujące maszyny wirtualne (takie jak właśnie Java - JVM, czy .NET - CLR) potrafią optymalizować uruchomiony kod, jednak nie zrobią tego od razu, ponieważ potrzebują w tym celu zebrać odpowiednią ilość statystyk. Całkiem możliwe, że funkcje które obsługują bardzo dużo żądań w czasie ciągłym (czyli takim, dzięki któremu instancja funkcji będzie żyła bardzo długo) osiągnęłyby z czasem lepsze wyniki.

Jakie z tego wnioski? Jeżeli piszemy prostą funkcję i nie zależy nam na wydajności (albo spodziewamy się małego ruchu), śmiało możemy napisać ją w języku programowania, który znamy najlepiej. Jeżeli jednak zależy nam na obsłudze jak największej ilości żądań (i jednocześnie wiemy, że instancja funkcji nie będzie długowieczna), najlepszym wyborem będą języki, które nie są uruchamiane w wirtualnej maszynie.

Zimne starty

Godziny uruchomienia testów

  • Run #1 - środek tygodnia, 22 czasu polskiego
  • Run #2 - środek tygodnia, 20:00 czasu lokalnego w danym regionie:
    • europe-west3 (Frankfurt, Germany, Europe) - 20:00 o 20:00 czasu polskiego
    • us-central1 (Council Bluffs, Iowa, North America) - 20:00 o 03:00 czasu polskiego (-7h)
    • asia-east2 (Hong Kong, APAC) - 20:00 o 13:00 czasu polskiego (+7h)

Wyniki

Runtime Region 1st time [ms] avg remaining [ms] diff [ms]
nodejs14 europe-west3 1176 49 1127
go113 europe-west3 447 43 404
java11 europe-west3 54 101 -47
python39 europe-west3 895 59 836
ruby27 europe-west3 997 41 956
dotnet3 europe-west3 1054 95 959
php74 europe-west3 679 45 634
nodejs14 us-central1 1123 250 873
go113 us-central1 922 227 695
java11 us-central1 1638 284 1354
python39 us-central1 1254 259 995
ruby27 us-central1 1430 193 1237
dotnet3 us-central1 1536 261 1275
php74 us-central1 1271 211 1060
nodejs14 asia-east2 1228 381 847
go113 asia-east2 869 375 494
java11 asia-east2 1227 398 829
python39 asia-east2 1433 375 1058
ruby27 asia-east2 1126 388 738
dotnet3 asia-east2 1023 410 613
php74 asia-east2 904 420 484
Runtime Region 1st time [ms] avg remaining [ms] diff [ms]
nodejs14 europe-west3 409 49 360
go113 europe-west3 61 46 15
java11 europe-west3 164 120 44
python39 europe-west3 98 53 45
ruby27 europe-west3 53 45 8
dotnet3 europe-west3 325 95 230
php74 europe-west3 169 43 126
nodejs14 us-central1 1214 203 1011
go113 us-central1 269 230 39
java11 us-central1 819 284 535
python39 us-central1 411 249 162
ruby27 us-central1 310 215 95
dotnet3 us-central1 1022 284 738
php74 us-central1 1004 240 764
nodejs14 asia-east2 880 496 384
go113 asia-east2 394 409 -15
java11 asia-east2 409 454 -45
python39 asia-east2 406 387 19
ruby27 asia-east2 405 409 -4
dotnet3 asia-east2 510 450 60
php74 asia-east2 448 398 50

Regiony

W odróżnieniu od wyników czasów odpowiedzi, w przypadku zimnych startów najlepiej wypadł region, który był najbliżej mojej geolokalizacji. Zarówno pierwsza odpowiedź oraz średnia pozostałych, była najniższa w przypadku funkcji osadzonych we Frankfurcie. Najgorzej natomiast wypadła Azja, co pokrywałoby się z poprzednim testem, ponieważ funkcje w Azji miały najwyższy minimalny czas odpowiedzi.

Podobnie jak w poprzednim teście, różnice w czasie między regionami nie miały żadnego znaczenia.

Na co się zdecydować, biorąc pod uwagę wyniki tych testów? Jeżeli potrzebujemy funkcji, która jest rzadko wywoływana (jej instancja jest krótko żyjąca) i zależy nam na jak najszybszej odpowiedzi, najlepiej powinny sprawdzić się funkcje uruchomione najbliżej geolokalizacji użytkownika końcowego. Dzięki bliskości centrum danych, nie tracimy czasu na przesyłanie żądania i odpowiedzi, a wąskim gardłem jest tutaj zimny start, czyli czas potrzebny na uruchomienie instancji funkcji.

Środowiska uruchomieniowe

Tak samo jak w poprzednim teście, w przypadku różnic między środowiskami uruchomieniowymi spodziewałem się lepszych wyników w przypadku funkcji napisanych w językach interpretowanych, od tych uruchamianych w maszynie wirtualnej. Wyniki jednak mnie zaskoczyły, ponieważ czasami jakieś środowisko uruchomieniowe wypadało bardzo dobrze, a czasami dużo gorzej.

W przypadku zimnych startów na pewno większą rolę odgrywa region, w którym osadzimy funkcję, niż język w którym ją napiszemy. Języki programowania, po których spodziewałem się lepszych wyników (Python i Ruby) nie wypadły wcale lepiej od Javy i .NET, które w teorii powinny potrzebować więcej czasu na uruchomienie.

Patrząc na wyniki testu, nie potrafię jednoznacznie stwierdzić w jakim języku napisałbym funkcję, aby zapewnić jak najkrótszy zimny start. Sytuacja mogłaby ulec zmianie w przypadku bardziej złożonych implementacji i wykorzystania zewnętrznych zależności/bibliotek, ponieważ ich rozmiar i implementacja mogłyby odgrywać tutaj kluczową rolę.

Podsumowanie

Na zakończenie chciałbym zaznaczyć, że wykonane przeze mnie testy dotyczyły jedynie prostych implementacji funkcji, a wyniki mogłyby być inne w przypadku bardziej złożonych implementacji lub przesyłania większej ilości danych. Mimo wszystko najczęściej spotykam się z bardzo prostymi funkcjami i z tego też powodu przeprowadziłem takie testy. Starając się porównać środowiska uruchomieniowe, musiałem zapewnić zbliżoną implementację funkcji, aby wykluczyć wpływ dostępnych bibliotek i zależności na wyniki.

Zachęcam również do przeprowadzania własnych testów za pomocą narzędzia Google Coud Functions Comparison, ponieważ jak widać, wyniki potrafią być zaskakujące i nieoczywiste.