Carmen Chung
Carmen ChungSoftware Engineer @ Valiant Finance

Jak zmniejszyliśmy o 50% zużycie pamięci w aplikacji Rails dzięki Jemalloc

Zobacz, czym jest Jemalloc i jak pomógł zoptymalizować wydajność oraz zużycie pamięci w aplikacji napisanej w Railsach.
18.09.20196 min
Jak zmniejszyliśmy o 50% zużycie pamięci w aplikacji Rails dzięki Jemalloc

Jednym z pierwszych projektów, w których brałam udział w Valiant, była próba zoptymalizowania wydajności i zużycia pamięci w aplikacji napisanej w Railsach. Chociaż słyszałam odwieczne skargi na to, że aplikacje napisane w Rails są powolne, masywne i obciążają pamięć, nie znalazłem wcześniej żadnych praktycznych i łatwych do wprowadzenia rozwiązań tego problemu.

Dopóki nie odkryliśmy jemalloc.

W tym artykule opiszę w skrócie czym jest jemalloc; jak sprawdzić aktualną wydajność aplikacji i jej zużycie pamięci (w tym przetestować czy nie ma wycieków pamięci) oraz jak zainstalować jemalloc lokalnie i na produkcji. A na koniec pokażę, jakie były nasze wyniki po przejściu na jemalloc (uwaga spoiler: udało nam się na produkcji zmniejszyć zużycie pamięci o połowę!)

Czym jest jemalloc?

Ruby używa tradycyjnie funkcji malloc języka C do dynamicznego przydzielania, zwalniania i ponownego przydzielania pamięci by przechowywać obiekty. Jemalloc to implementacja malloc(3) opracowana przez Jasona Evansa (stąd dwie pierwsze litery „je” przed malloc), która wydaje się być bardziej skuteczna w alokacji pamięci w porównaniu z innymi sposobami, głównie ze względu na skoncentrowaniu się na unikaniu fragmentacji i skalowalnej obsłudze współbieżności.

Krok 1: Sprawdź zużycie pamięci twojej aplikacji

Aby ustalić czy przejście na jemalloc faktycznie ma jakikolwiek pozytywny wpływ na twoją aplikację Rails, najpierw musisz wiedzieć ile pamięci zużywa aplikacja i jak szybko strona odpowiada. Aby sprawdzić to lokalnie dodałem następujące gemy do Gemfile (i uruchomić bundle install):

gem "memory_profiler"
gem "derailed_benchmarks"​
(Uwaga: możesz uruchomić testy z tymi gemami na środowisku developerskim, testowym i produkcyjnym. Jeśli chcesz je uruchomić na środowisku developerskim/testowym, upewnij się, że usunąłeś gem dotenv-rails z Gemfile)

Aby określić całkowite zużycie pamięci przez każdy gem w pliku Gemfile, uruchom:

bundle exec derailed bundle:mem​

Aby wyświetlić tylko pliki, które zużyły za dużo pamięci dodaj CUT_OFF=0.3 (lub dowolną liczbę, która cię interesuje). Zauważ, że Ruby wczytuje plik raz, więc jeżeli kilka bibliotek będzie wymagać tego samego pliku, zostanie on wczytany tylko w pierwszej bibliotece (duplikaty będą zawierać listę wszystkich rodziców, do których należą).

Na przykład tak wyglądał krótki fragment naszych wyników:

TOP: 70.2617 MiB
  rails/all: 16.4805 MiB
    rails: 6.1523 MiB (Also required by: active_record/railtie, active_model/railtie, and 8 others)
      rails/application: 4.707 MiB
        rails/engine: 3.543 MiB (Also required by: coffee/rails/engine)
          rails/railtie: 3.293 MiB (Also required by: global_id/railtie, sprockets/railtie, and 3 others)
            rails/configuration: 3.1484 MiB (Also required by: rails/railtie/configuration)
              active_support/core_ext/object: 3.0469 MiB (Also required by: paper_trail/has_paper_trail)
                active_support/core_ext/object/conversions: 2.5078 MiB
                  active_support/core_ext/hash/conversions: 1.8945 MiB (Also required by: active_record/serializers/xml_serializer, active_model/serializers/xml)
                    active_support/time: 1.7031 MiB (Also required by: active_record/base)
                      active_support/core_ext/time: 1.625 MiB
                        active_support/core_ext/time/calculations: 1.5391 MiB (Also required by: active_support/core_ext/numeric/time, active_support/core_ext/string/conversions)
                          active_support/core_ext/time/conversions: 1.1094 MiB (Also required by: active_support/core_ext/time, active_support/core_ext/date_time/conversions)
                            active_support/values/time_zone: 1.0664 MiB (Also required by: active_support/time_with_zone, active_support/core_ext/date_time/conversions)
                              tzinfo: 0.8438 MiB (Also required by: et-orbi)
                                tzinfo/timezone: 0.3867 MiB

(Uwaga: 1 Mebibajt (MiB) = około 1,05 MB)

Ponadto, na środowisku produkcyjnym można zobaczyć liczbę utworzonych obiektów (według lokalizacji, a także według gemów), kiedy wymagane są zależności przy uruchamianiu:

bundle exec derailed bundle:objects​
Poniżej znajduję się próbka obiektów stworzonych przez nasze gemy:
348351 activesupport
66931 erubis
54842 json
23655 addressable
15078 bundler
14833 heroics
13313 ruby
13034 haml
7186 actionpack
6370 sass​
Aby sprawdzić, czy są wycieki pamięci w środowisku produkcyjnym, możesz uruchomić:
bundle exec derailed exec perf:mem_over_time​
Wskazówka: Aby ustawić liczbę testów, które chcesz uruchomić, tak by nie trwały w nieskończoność, użyj: TEST_COUNT=20_000 bundle exec derailed exec perf:mem_over_time.

Polecenie to wysyła wiele zapytań do aplikacji i w tym czasie profiluje zużycie pamięci, jeżeli masz luki w pamięci, jej zużycie będzie rosnąć. W normalnych warunkach zauważysz, że zużycie pamięci aplikacji rośnie, aż osiągnie plateau a następnie zacznie spadać.

Pamiętaj, że wyniki mogą być różne (i będą wydawać się różne dla 2000 testów w porównaniu do 20000 testów). Uruchamiając testy kilkukrotnie, odkryliśmy, że średnie plateau naszej aplikacji wynosi około 1,7-1,8 MiB. Mniej więcej odpowiada to naszym logom z Heroku, które pokazywały wartość 1,6 MiB.

Krok 2: Sprawdź wydajność i szybkość aplikacji

Ogólną wydajność aplikacji możesz sprawdzić, wywołując określone endpointy, używając benchmark-ips (czyli sprawdzać wydajność iteracji kodu na sekundę) za pomocą tego polecenia:

bundle exec derailed exec perf:ips​
Im wyższa wartość, tym lepiej, ponieważ oznacza to więcej iteracji na sekundę. Niektóre z naszych wyników są następujące:

Warming up --------------------------------------
                 ips     1.000  i/100ms
Calculating -------------------------------------
                 ips      5.070  (± 0.0%) i/s -     26.000  in   5.141956s
                 
Warming up --------------------------------------
                 ips     1.000  i/100ms
Calculating -------------------------------------
                 ips      5.162  (± 0.0%) i/s -     26.000  in   5.051505s
           
Warming up --------------------------------------
                 ips     1.000  i/100ms
Calculating -------------------------------------
                 ips      4.741  (± 0.0%) i/s -     24.000  in   5.125214s​


Krok 3. Zainstaluj Jemalloc (lokalnie i na produkcji)

Aby zainstalować jemalloc lokalnie, po prostu dodaj poniższy kod do Gemfile i wywołaj bundle install:

gem 'jemalloc'​
Uwaga: jeśli używasz rvm (i już zainstalowałeś Ruby 2.4.1) uruchom rvm reinstall 2.4.1 –C –with-jemalloc aby przeinstalować Ruby z jemalloc.

Aby sprawdzić, czy twoja wersja Ruby używa jemalloc uruchom:
ruby -r rbconfig -e "puts RbConfig::CONFIG['LIBS']".​
Odpowiedź powinna wyglądać mniej więcej tak:
-lpthread -ljemalloc -ldl -lobjc​
(-ljemalloc oznacza, że jemalloc jest ładowane razem z Ruby).

Jak to wygląda na produkcji? Istnieje kilka sposobów dodania jemalloc na Heroku, ale okazało się, że najłatwiejszym sposobem było dodanie buildpacka za pomocą tej komendy:

heroku buildpacks:add --index 1 https://github.com/mojodna/heroku-buildpack-jemalloc.git --app [your app name here]​
Aby potwierdzić, że jemalloc został zainstalowany uruchom heroku buildpacks –app [nazwa twojej aplikacji], powinna ci się wyświetlić lista buildpacków.

Możesz także dodać buildpack wchodząc w Ustawienia->Buildpacks->Dodaj buildpack w panelu Heroku.

Krok 4. Sprawdź wyniki lokalnie

Uruchom te same polecenia co w Krok 1. aby sprawdzić zużycie pamięci i szybkość aplikacji po zainstalowaniu jemalloc. Nasze wyniki wykazały 8,6953 MiB (9,117 MB) – co stanowi 12,38% - oszczędności pamięci w całej aplikacji. Na alokacji obiektów zaoszczędziliśmy 5,064 MiB (5,310 MB) pamięci.

Krok 5. Sprawdź wyniki na produkcji

Użyliśmy Siege, narzędzia do testowania i porównywania obciążeń http, aby przeciążyć nasze aplikacje zapytaniami. Odkryliśmy, że bez jemalloc wykonywaliśmy średnio około 2,5 operacji na sekundę, przy średnio 160 nieudanych w ciągu 20 minut. Podczas gdy z jemalloc uzyskaliśmy średnio 6,6 operacji na sekundę i tylko 1,5 nieudanych w ciągu 20 minut.

Najbardziej imponujące było to, że platforma Heroku wykazała niezwykłe, niemal natychmiastowe ulepszenia. Przed użyciem jemalloc zużycie pamięci osiągnęło 2 GB, wtedy musieliśmy automatycznie ją zresetować. Po użyciu jemalloc zużycie spadło do 1 GB i całkowicie uniknęliśmy wymuszonych restartów.

Heroku memory consumption pre and post-jemalloc.

Podobnie było w przypadku czasu reakcji, odnotowaliśmy gwałtowny spadek, gdzie przed jemalloc niektóre zapytania trwały po 30 sekund. Po instalacji jemalloc czas ten spadł do około 5-10 sekund.

Heroku response time pre and post-jemalloc.



Podsumowanie

Po wdrożeniu jemalloc dostrzegliśmy niesamowite korzyści w zakresie wydajności, jak i zużycia pamięci. Być może nie poprawi to reputacji aplikacji w Rails wśród programistów, aczkolwiek nam z pewnością pomogło zoptymalizować naszą platformę.
<p>Loading...</p>