Utrzymanie dobrego performance aplikacji jest prawdziwą sztuką. Jako developerzy najczęściej poświęcamy swoją uwagę na to, w jaki sposób piszemy kod. I to jest okej, chociaż na tym przyśpieszanie aplikacji się nie kończy. Stąd właśnie mam kilka porad związanych z dobrym performance, które niekoniecznie skupiają się na tym, jaki kod piszemy 🙂
Performance z nieco szerszej perspektywy
My programiści piszemy kod. Piszemy dobry kod. Czytelny. Dobrze posegregowany. Stosujemy DDD, CQRSa oraz inne fajne architektury, które mają wpłynąć na działanie aplikacji w pozytywny sposób. I to jest super. Jak już napiszemy ten nasz kodzik to przychodzi pora na release. Z jednej strony jest to największe święto programistyczne – nasz długo wyczekiwany feature w końcu ujrzy światło dzienne. Ludzie z niego będą korzystali. Sztos. Z drugiej strony – kto to dalej będzie utrzymywał? Kto zadba o to, aby w momencie wypuszczenia ogromnego mailingu o zmianach aplikacja wytrzymała ruch?
My jako programiści nie lubimy tych tematów. Niestety, najczęściej wypychamy aplikację na jeden serwer, któremu robimy upgrade o nowy RAM czy lepszy procesor, kiedy biznes urośnie. Ale po pewnym czasie osoba decydująca, czyli CTO, zada pytania: czy nie da radę zrobić czegoś tańszego niż zapłacenie drugiego tyle za serwer co płacimy teraz? Jaką mamy gwarancję, że za pół roku nie będziemy musieli zapłacić jeszcze więcej? I to są bardzo trafne pytania, ponieważ jesteśmy w stanie zrobić coś niecodziennego, co będzie w stanie nam pomóc długofalowo.
PS. Ten wpis jest o poradach bardziej ogólnych związanych z aplikacjami internetowymi. Jeżeli chcecie przeczytać coś o lepszym performance z perspektywy kodu, to zachęcam Was to zaglądnięcia do wpisu Kilka porad na dobry performance aplikacji napisanej w Symfony.
Dobre praktyki serwerowe jako podstawa performance
Czy w waszym samolocie leci z Wami DevOps? Nie? To tak, jak u mnie. I to nie jest nic złego. Zatrudnienie DevOpsa to dodatkowy (niemały) koszt plus potrzeba zagwarantowania mu obłożenia pracą. Stąd nie każdy projekt może sobie na to pozwolić. Tym bardziej, jeżeli robimy go dla niezbyt dużego przedsiębiorstwa. Stąd właśnie przychodzi potrzeba obsługiwania serwerów przez nas, backendowców. Dlatego dobrze jest znać przynajmniej podstawowe, dobre praktyki serwerowe, które możemy stosować u siebie na maszynie produkcyjnej 🙂
Przejdź na HTTP 2.0, bo to na prawdę nie boli 😉
Jedną z najprostszych rzeczy, które możemy zrobić dla performance to przełączenie konfiguracji serwera HTTP, aby ten obsługiwał protokół HTTP 2.0. Nowa wersja protokołu przynosi ze sobą funkcję nazwaną Multiplexing, która zezwala na wysyłanie wielu równoległych żądań w obrębie jednego połączenia TCP. Jest to funkcjonalność protokołu, która bezpośrednio przekłada się na performance aplikacji, zwłaszcza, jeżeli chodzi o jej ładowanie. Jest to również funkcja, która lepiej pozwala wykorzystać zasoby naszego serwera.
Jak możecie przeczytać na stronie NGINX, protokół HTTP 2.0 jest wspierany od wersji 1.9.5 wydanej w… 2015 roku! 🙂 Wychodzi na to, że możecie przestać martwić się o to, czy Wasz NGINX wspiera ten protokół 🙂 A konfiguracja dla HTTP 2.0 jest bardzo prosta:
server {
listen 443 ssl http2 default_server;
ssl_certificate server.crt;
ssl_certificate_key server.key;
...
}
Słowem-kluczem tutaj jest http2
. Wystarczy dodać je w linijce sekcji listen
i po sprawie 🙂 Od tej pory możemy cieszyć się przyśpieszonym ładowaniem strony.
HTTP 2.0 Multiplexing vs HTTP 1.1 Pipelining
Protokół HTTP 1.1 wspiera funkcjonalność, która nazywa się Requests Pipelining. Jest to technika podobna do HTTP 2.0 Multiplexing, która również polega na tym, że serwer HTTP obsługuje żądania w sposób asynchroniczny. Pomimo zrównoleglenia żądań, odpowiedzi muszą zostać wysłane w tej samej kolejności, w której zostały wysłane ich żądania. Działa tu zasada FIFO (ang. First In, First Out). Czyli jest to takie zrównoleglenie pół na pół.
Dodatkową wadą HTTP 1.1 Pipelining jest to, że jest ono inicjowane przez klienta, który łączy się do serwera. Nie możemy tego skonfigurować w obrębie serwera i powiedzieć, że ma być zrównoleglone i koniec. I niestety wchodzi tutaj temat tego, że popularne przeglądarki wyłączyły tą opcję, nie można z niej skorzystać, bądź trzeba przeglądarkę skonfigurować, aby ją wspierała. Jest to bardzo słabe. Multiplexing, jako funkcja natywna protokołu HTTP 2.0 nie ma tego problemu, a protokół HTTP 2.0 jest już szeroko wspierany przez (chyba) wszystkie popularne przeglądarki.
Używaj CDNa
Drugą opcją związaną z performance aplikacji internetowej jest bardzo niepozorny CDN (ang. Content Delivery Network). W przeciwieństwie do poprzedniego punktu, nie jest to opcja konfiguracyjna serwera HTTP i podobnych. Jest to metoda polegająca na wydzieleniu osobnego serwera dla statycznej części aplikacji, czyli np. obrazków, video, CSSów czy JSów.
Teraz czas na pracę domową: policz sobie, ile obrazków masz na stronie. A teraz policz sobie, do ilu własnych skryptów JS linkujesz. Niezła sumka, co nie? Jeżeli by tak zliczyć to wszystko, to wyjdzie nam pewnie z 30 (to takie minimum). Wyobraźcie sobie, że to wszystko będzie leciało przez jeden serwer. I nie, to nie jest tak, że mając 30 obrazków na stronie, przeglądarka otwiera 30 połączeń TCP. Zazwyczaj mamy gdzieś koło 6 takich połączeń. Ale to nie znaczy, że jesteśmy bezpieczni, bo:
- jeżeli założymy, że każdy klient otwiera 6 połączeń, to nasz serwer HTTP obsłuży 6x mniej użytkowników w jednym czasie.
- jeżeli faktycznie mamy te 30-50 obrazków na stronie, to 6 połączeń spowoduje, że mniej więcej 5-6 obrazków będzie wysłane per połączenie. Czyli będziemy mieli nieźle dociążone połączenia. Tym dłużej będzie trwało zwrócenie wszystkiego przez serwer HTTP do przeglądarki.
I jedna i druga opcja powoduje, że w momencie dużego ruchu padniemy, albo będziemy bardzo powolni. I ładnie napisany kodzik tutaj nie pomoże. Dlatego powinniśmy wydzielić osobno serwer aplikacyjny, czyli ten przetwarzający żądania HTTP związane z naszą logiką aplikacyjną, oraz serwer zasobowy, czyli ten, który zwróci nam całą resztę. I nie, nie musimy pchać się tutaj w jakiekolwiek AWSy czy GoogleCloudy. Wystarczy, że skonfigurujemy sobie drugi serwer i popracujemy trochę nad deployem produkcyjnym.
Dodatkową zaletą posiadania CDNa jest trudniejszy DDoS. Najprostsze ataki tego typu można przeprowadzić przez wysłanie linka do obrazka w miejsce o dużym ruchu. Jeżeli w jakimś miejscu w sieci mamy mechanizm, który będzie fetchował obrazek z naszego serwera dla każdego użytkownika (a użytkowników będzie wielu), to nas nie ma. I naszej aplikacji również. Konfiguracja dodatkowego serwera spowoduje najwyżej, że komuś obrazki nieco dłużej będą się ładowały, a nie padnie nam cała infrastruktura.
Popracuj nad konfiguracją PHP-FPMa i włącz preloading
PHP jako technologia skoncentrowana na aplikacjach internetowych posiada zestaw opcji konfiguracyjnych, które mogą pomóc nam w lepszej pracy z naszym serwerem HTTP. Są to takie opcje jak:
- pm.max_children – maksymalna ilość procesów potomnych PHP-FPM,
- pm.start_servers – maksymalna ilość procesów dostępna po restarcie serwera,
- pm.min_spare_servers – minimalna ilość procesów dostępna w trybie 'iddle’, gotowa do obsługi nowego żądania
- pm.max_spare_servers – maksymalna liczba procesów oczekujących
- pm.max_requests – ilość żądań, po których PHP-FPM zrestartuje się
Wymienione wyżej oraz inne konfiguracje PHP-FPMa mogą pomóc nam lepiej zarządzać zasobami serwera, ale od strony PHPa. Poszczególne wartości musimy dobierać na podstawie eksperymentów oraz wcześniejszych doświadczeń.
Dodatkowymi rzeczami, które pomogą nam w zmaksymalizowaniu produkcyjnego performance jest włączenie Opcache. Jest to mechanizm, który nie wymusza nieustannej interpretacji kodu PHP od zera. W momencie napotkania kodu PHP, ten jest kompilowany, a następnie zapamiętane zostają w cache jego odpowiedniki w postaci kodów operacji. Oznacza to, że jeżeli produkcyjnie przeszliśmy już jakąś ścieżkę w kodzie PHP, to komendy tej ścieżki zostają umieszczone w cache. Od wersji PHP 5.5 Opcache jest dostępny i domyślnie włączony. Niemniej może okazać się, że jakiś hosting z którego korzystamy mógł wyłączyć tą opcję jako domyślną konfigurację.
W kontekście Opcache należy wspomnieć również o preloadingu. Jest to funkcjonalność Opcache dostępna od wersji 7.4, która polega na tym, że od razu wczytuje konkretne klasy (pliki) PHP do cache. Aby móc skonfigurować preloading, potrzebujemy wygenerować plik, który będzie „posiadał w sobie” wszystkie klasy, które chcemy wrzucić do cache. Symfony jako framework tworzy taki plik – config/preload.php
, którego zawartość wygląda następująco:
<?php
if (file_exists(dirname(__DIR__).'/var/cache/prod/srcApp_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/srcApp_KernelProdContainer.preload.php';
}
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}
Mamy tam w środku instrukcję require
, która jak nam wiadomo – „załącza plik”. W tym wypadku jest to plik skompilowanego kontenera, który wygląda w sposób podobny do tego:
<?php
// This file has been auto-generated by the Symfony Dependency Injection Component
// You can reference it in the "opcache.preload" php.ini setting on PHP >= 7.4 when preloading is desired
use Symfony\Component\DependencyInjection\Dumper\Preloader;
if (in_array(PHP_SAPI, ['cli', 'phpdbg'], true)) {
return;
}
require dirname(__DIR__, 3).'/vendor/autoload.php';
(require __DIR__.'/TheGame_KernelDevDebugContainer.php')->set(\ContainerPcxXDCv\TheGame_KernelDevDebugContainer::class, null);
require __DIR__.'/ContainerPcxXDCv/EntityManagerGhostCd64c12.php';
require __DIR__.'/ContainerPcxXDCv/RequestPayloadValueResolverGhost3384721.php';
require __DIR__.'/ContainerPcxXDCv/getWebProfiler_Controller_RouterService.php';
require __DIR__.'/ContainerPcxXDCv/getWebProfiler_Controller_ProfilerService.php';
require __DIR__.'/ContainerPcxXDCv/getWebProfiler_Controller_ExceptionPanelService.php';
require __DIR__.'/ContainerPcxXDCv/getValidator_WhenService.php';
// ...
Czyli mamy require w require. I w taki sposób PHP ładuje do cache wszystko, co tak na prawdę chcemy. Jeżeli są serwisy, których nie chcemy z jakiegoś powodu wrzucać do cache przez preloading, to powinniśmy skorzystać z odpowiedniego tagu:
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://symfony.com/schema/dic/services"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="App\Service\MyService">
<argument type="service" id="..."/>
<tag name="container.no_preload" />
</service>
</services>
</container>
A jeżeli będziemy chcieli w sposób jawny przekazać, że chcemy skorzystać z preloadingu w obrębie konkretnego serwisu, to powinniśmy skorzystać z taga container.preload
.
Odseparuj serwer aplikacyjny od serwera CLI
Kolejną, nieco krótszą w tekście, poradą będzie postawienie osobnego serwera dla procesów uruchamianych w trybie CLI. Serwer aplikacyjny, czyli ten obsługujący żądania HTTP, powinien specjalizować się wyłącznie w tej dziedzinie. Wszystko inne powinno odbywać się na osobnej jednostce, aby nie zaburzać pracy serwerom HTTP oraz PHP-FPM. Serwer aplikacyjny powinien mieć możliwie najwięcej zasobów, bo od jego sukcesu zależy sukces całej aplikacji.
Aby usprawiedliwić ten punkt, należałoby zadać sobie pytanie: „Ile to razy serwer Ci zamulił, bo odpalił się ciężki skrypt w CRONie?”. Albo: znowu jest mało pamięci dla FPMa, bo mamy peak na kolejkach. Dlaczego skrypty importu odpalasz o 4 rano? Musimy powiedzieć sobie raz na zawsze: CLI często robi rzeczy ciężkie, zabierające czas procesora, pamięć oraz zasoby PHP-FPM. I to jest powód, dlaczego wymaga osobnej jednostki.
Loguj tylko wtedy, kiedy potrzebujesz, czyli Monolog Fingers Crossed ?
O aplikacji developerskiej wiemy bardzo dużo. Mamy profiler, mamy APP_DEBUG
. I mamy logi. O tym, co dzieje się w aplikacji i z czego to działanie wynika wiemy tyle, ile potrzebujemy. Z aplikacją produkcyjną jest inaczej. Tam nie mamy ani profilera, ani debug mode. Ale za to mamy logi. Tak bardzo lubimy logować na produkcji 🙂 zwłaszcza, jeżeli mamy potem możliwość wysłania tych informacji do jakiegoś kontenera z Elastic Searchem w środku 🙂
My, developerzy powinniśmy zwrócić uwagę na to, w jaki sposób logujemy na produkcji. Jest to jeden z elementów mających bezpośredni wpływ na wydajność aplikacji. Bo jeżeli jeden request wymaga od nas 30-50 osobnych zapisów w pliku, to znaczy, że coś zrobiliśmy źle. Nie każda informacja musi być na tyle ważna, aby musiała być w miejscu zapisywana jako log w pliku. Możemy zbierać w pamięci kolejne logi i zalogować je dopiero wtedy, kiedy wystąpi jakiś ważniejszy czynnik, który jest konieczny do zalogowania. Może na produkcji, na której mamy 20 żądań na minutę nie zrobi to różnicy, ale na produkcji, gdzie obsługujemy 30 żądań na sekundę – to zrobi robotę.
Ponieważ pod spodem do logowania prawie każdy z nas korzysta z Monologa, to łapcie handler, który jest wyspecjalizowany do zadania, o którym pisałem wyżej: FingersCrossedHandler.php. Działa on w ten sposób, że zbiera w pamięci logi, dopóki nie osiągniemy poziomu loga, który wymagałby zapisu. Jeżeli taki log się pojawi, to hurtowo zapisywane są wszystkie dotąd zgromadzone w pamięci informacje.
Konfiguracja fingers-crossed w Symfony wygląda następująco:
monolog:
handlers:
log_errors_and_above:
type: fingers_crossed
action_level: error
handler: file_log
Powyższa konfiguracja to absolutne minimum. Więcej opcji dla handlera fingers-crossed znajdziecie w klasie konfiguracyjnej Monolog Bundle.
Projektuj lepiej swoje funkcjonalności
Ostatnim punktem dzisiejszego dnia jest nic innego, jak lepsze projektowanie funkcjonalności. Jest to etap przed developmentem, który powinien być dyskutowany z designerem oraz product ownerem. Całość istoty polega na tym, aby znać podstawowe miejsca będące bottle-neckami i im zaradzić.
Duże Twigi to nasz wróg
Nie powinniśmy dopuszczać do tego, aby aplikacja generowała bardzo duże widoki. Powinniśmy móc przewidzieć wszystkie miejsca, gdzie potencjalnie możemy mieć więcej elementów i podzielić je. Przykładem numer jeden będzie tutaj listing jakiejkolwiek encji w jakimkolwiek panelu. Im więcej wierszy chcemy wyświetlić naraz, tym dłużej będą rozwiązywały się nasze szablony. Dodatkowo, im więcej w naszych szablonach załączeń szablonów w szablonach, tym gorzej dla performance. Jeżeli połączymy to z dużymi formularzami z wykorzystaniem Symfony FormType, to dodatkowo będzie nam muliło.
Odpowiedzią na powyższe problemy będzie zastąpienie długich list paginacjami oraz lazy-loadingiem. Możemy wyświetlić klientowi np. 30 wierszy, a następną ilość doczytać po załadowaniu się strony. Ewentualnie, możemy wyświetlić paginację, bądź jeżeli to będzie miało sens, to możemy udoskonalić filtrowanie.
Co się tyczy API… 🙂
Jeżeli mowa o API, to również powiem: paginacja. W API najgorszym wrogiem jest serializacja danych. Im większy zbiór danych, tym gorzej dla performance. Dodatkowo, z im bardziej generycznego rozwiązania korzystamy, tym bardziej negatywnie wpłynie to na performance serializacji.
Paginacja może odbywać się w bardzo prosty sposób: w odpowiedzi na żądanie GET możemy wystawić pole np. pages
, które poinformuje nas o ilości stron wyników. Następnie, jeżeli chcemy zaczytać kolejne wyniki, to wysyłamy dokładnie to samo żądanie z końcówką w URLu: ?page=2
, gdzie dostaniemy kolejne wyniki.
Drugim, co możemy zrobić, aby nasze API działało lepiej, to wdrożenie lepszej zasobowości. Bo w zasobach jest prawdziwa moc RESTa. Przykładowo, jeżeli mamy produkt, który składa się w wariantów, to nie musimy pchać pełnych wariantów w pole variants
. Wystarczy wysłać odniesienie do wariantów – identyfikator (listę identyfikatorów), bądź jak to robi API Platform – zwrócić IRI zasobów. IRI to jest odnośnik, po którym możemy dostać się do interesującego nas obiektu. Efekt tego będzie taki, że ponownie – zyskamy na serializacji. Odchudzimy odpowiedź serwera czyli mniej danych będzie do przerobienia.
Comments are closed.