Nuxt3 i NGINX: Efektywne Serwowanie Plików Statycznych

W świecie nowoczesnych technologii webowych, najnowsza wersja Nuxt wprowadza znaczące innowacje pod kątem developer experience. 

Wykorzystując swój autorski serwer Nitro do renderowania aplikacji jak i serwowania plików statycznych, Nuxt zapewnia znaczące ułatwienie dla developerów. Dzięki tej integracji, programiści mogą skupić się na rozwoju aplikacji, nie martwiąc się o konfigurację i zarządzanie dodatkowym serwerem HTTP. Nitro automatyzuje i ujednolica procesy, które zwykle wymagałyby zewnętrznych rozwiązań lub szczegółowej konfiguracji serwera. To podejście ma kilka kluczowych zalet:

  • Eliminacja potrzeby konfiguracji dodatkowego serwera HTTP.
  • Skupienie na tworzeniu i doskonaleniu aplikacji zamiast zarządzania serwerem.
  • Uproszczony proces deploymentu dzięki zintegrowanym i zoptymalizowanym rozwiązaniom.
  • Spójny proces pracy, zwiększający produktywność i ułatwiający współpracę w zespołach.

Podczas pracy nad jednym z naszych projektów, który przyciąga znaczne ilości ruchu, zauważyliśmy, że choć wydajność Node.js jest bardzo dobra w wielu aspektach, to jednak w kontekście serwowania plików statycznych, Nginx wykazuje znacznie lepsze osiągi. Nasze aplikacje są już serwowane przez Nginx, co prowadzi nas do wniosku, że przekierowywanie ruchu na aplikację Node.js tylko po to, aby później zwrócić statyczne pliki, nie jest optymalnym rozwiązaniem.

Kluczowe uwagi w tej kwestii to:

Wyższa Wydajność Nginx w Serwowaniu Statycznych Plików: Nginx jest specjalnie zoptymalizowany do serwowania statycznych zasobów, takich jak HTML, CSS i obrazy. Dzięki swojej architekturze opartej na zdarzeniach, jest w stanie obsłużyć duże ilości ruchu przy minimalnym obciążeniu serwera.

Niepotrzebne Obciążenie Node.js: Kierowanie zapytań o statyczne pliki do Node.js, aby następnie były one serwowane przez Nginx, tworzy niepotrzebne obciążenie dla aplikacji Node.js. To nie tylko zużywa zasoby serwera, ale także może wpływać na wydajność ogólną aplikacji.

Co ważne! Każde zapytanie o nieistniejący plik statyczny, które kończyło się błędem 404, było niepotrzebnie przekierowywane do Node.js. To zużywało cenne zasoby serwera, które mogłyby być lepiej wykorzystane do obsługi innych, ważniejszych zapytań.

Dodatkowym problemem z jakim możemy się spotkać, to, że w trakcie działania aplikacji będziemy w stanie otworzyć tylko te pliki, które znajdowały się w aplikacji w momencie jej budowania. Wszystkie inne pliki dodane do folderu public podczas działania aplikacji nie będą dostępne. Możesz dowiedzieć się więcej tutaj: https://github.com/nuxt/nuxt/issues/15779 

Optymalizacja Ruchu Sieciowego: Bezpośrednie serwowanie statycznych plików przez Nginx eliminuje dodatkowe kroki przetwarzania przez Node.js. To sprawia, że ruch sieciowy jest bardziej efektywny, a czas ładowania dla użytkownika końcowego – krótszy.

Uproszczenie Architektury: Używanie Nginx jako bezpośredniego punktu serwowania plików statycznych upraszcza architekturę systemu. Pozwala to na bardziej przejrzystą konfigurację i łatwiejsze zarządzanie ruchem sieciowym.

Skupienie na Mocnych Stronach Node.js: Pozostawienie Node.js obsługi zadań, do których jest najlepiej przystosowany, takich jak dynamiczne renderowanie i logika aplikacji, pozwala w pełni wykorzystać jego potencjał, jednocześnie korzystając z wydajności Nginx w serwowaniu statycznych zasobów.

Jak serwować pliki statyczne za pomocą NGINX

Oto jak możemy skonfigurować NGINX do serwowania plików statycznych z aplikacji Nuxt. 

Tak wygląda przykładowa bazowa konfiguracja NGINX dla aplikacji Nuxt:

server {
    listen 80;

    server_name example.com;

    location / {
        proxy_pass http://localhost:3000; 
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

Ta konfiguracja NGINX działa jako reverse proxy, przekierowując wszystkie przychodzące requesty z adresu example.com na serwer Node.js działający lokalnie pod adresem http://localhost:3000. W ten sposób NGINX obsługuje cały ruch sieciowy, delegując faktyczne przetwarzanie żądań do serwera Node.js w tym plików statycznych.

Powyższą konfigurację możemy lekko stuningować powodując, że wszystkie pliki statyczne będą obsługiwane bezpośrednio przez NGINX, czyli requesty nie będą delegowane do aplikacji Node.js

   location /_nuxt/ {
       root /var/www/html/front/.output/public;
       try_files $uri $uri/ =404;
   }


   location / {
       root /var/www/html/front/.output/public;
       try_files $uri $uri @nuxt;
   }


   location @nuxt {
       proxy_pass http://localhost:3000;
       proxy_http_version 1.1;
       proxy_set_header Upgrade $http_upgrade;
       proxy_set_header Connection 'upgrade';
       proxy_set_header Host $host;
       proxy_cache_bypass $http_upgrade;
   }

location /_nuxt/

Pierwszy blok location /_nuxt/ w konfiguracji NGINX określa, jak serwer powinien obsługiwać requesty, które trafiają na ścieżkę zawierającą /_nuxt/. Jest to typowa ścieżka wykorzystywana w aplikacjach Nuxt do przechowywania skompilowanych zasobów, takich jak JavaScript, CSS, czy obrazy. Oto co robi ten blok:

  • root /var/www/html/front/.output/public;: Określa główny katalog (root), z którego NGINX powinien serwować pliki dla tej ścieżki. W tym przypadku jest to /var/www/html/front/.output/public. Oznacza to, że wszystkie requesty na /_nuxt/ będą szukać plików w tym katalogu.
     
  • try_files $uri $uri/ =404;: Dyrektywa try_files sprawdza, czy plik o ścieżce wskazanej przez $uri (czyli dokładnej ścieżce za /_nuxt/) istnieje w katalogu określonym przez root. Jeśli plik istnieje, zostanie on serwowany. Jeśli nie, NGINX sprawdzi następnie katalog ($uri/). Jeśli ani plik, ani katalog nie istnieją, NGINX zwróci błąd 404 (strona nie znaleziona). Tutaj widzimy dużą zaletę tej konfiguracji, ponieważ requesty 404 nie będą obciążać serwera Node.js 
  • Należy pamiętać aby blok location /_nuxt/ znajdował się nad blokiem location /

location /

Drugi blok location / w konfiguracji NGINX określa sposób obsługi wszystkich pozostałych requestów do serwera, które nie pasują do żadnych innych zdefiniowanych bloków location. Oto jego działanie:

  • root /var/www/html/front/.output/public;: Tak jak w poprzednim bloku, określa to katalog (root), z którego NGINX powinien serwować pliki. Dla wszystkich requestów pasujących do tego bloku, NGINX będzie szukał plików w /var/www/html/front/.output/public. W tym folderze Nuxt przechowuje pliki, które powinny być dostępne publicznie - czyli wszystkie pliki, które umieścimy w folderze public po skompilowaniu znajdą się w tym folderze.
  • try_files $uri $uri/ @nuxt;: Ta dyrektywa sprawdza, czy plik lub katalog określony przez $uri istnieje w katalogu wskazanym przez root. Jeśli tak, to NGINX serwuje ten plik lub katalog. Jeśli jednak plik lub katalog nie istnieje, kontrola jest przekazywana do identyfikatora @nuxt.
  • @nuxt: Jest to nazwa bloku location, który określa jak NGINX powinien przekazywać requesty do serwera aplikacji (w tym przypadku serwera Node.js z aplikacją Nuxt). Dzięki temu, żądania, które nie dotyczą bezpośrednio statycznych plików (jak na przykład żądania stron dynamicznych), są przekierowywane do backendu aplikacji do dalszej obsługi.

location @nuxt

Trzeci blok jest naszą wcześniejszą konfiguracją zapisaną w formie tak zwanej “named location”, do której NGINX będzie delegował obsługę, jeżeli wszystkie wcześniejsze warunki nie zostaną spełnione.

Jak wygląda ruch przed i po konfiguracji?

Za pomocą prostego pluginu Nitro możemy prześledzić jakie requesty obsługiwane są za pomocą naszej aplikacji:

export default defineNitroPlugin((nitroApp) => {
   nitroApp.hooks.hook('request', (event) => {
       console.log('nitro request:',event.path)
   })
})

Tak wygląda lista requestów jakie nasza aplikacja musi obsłużyć aby poprawnie wyrenderować naszą stronę. Jest to bardzo prosta aplikacja z zainstalowanym modułem nuxt-typo3. W realnym projekcie lista ta wydłuża się do setek plików podczas jednego requestu.

nitro request: /

nitro request: /_nuxt/entry.8dbc29b4.js

nitro request: /_nuxt/T3Page.ae78a545.js

nitro request: /_nuxt/T3BackendLayout.vue.e990d826.js

nitro request: /_nuxt/useT3DynamicComponent.9f5e9126.js

nitro request: /_nuxt/vue.f36acd1f.dd941d21.js

nitro request: /_nuxt/T3BackendLayout.7ab09a15.js

nitro request: /_nuxt/T3BlDefault.c0432432.js

nitro request: /_nuxt/T3Renderer.vue.72118a2e.js

nitro request: /_nuxt/T3Renderer.94a76c91.js

nitro request: /_nuxt/T3Frame.5eb6794d.js

nitro request: /_nuxt/T3CeTextpic.c2018245.js

nitro request: /_nuxt/T3CeTextpic.vue.fa80ffe1.js

nitro request: /_nuxt/T3CeHeader.vue.99047ba5.js

nitro request: /_nuxt/T3Link.vue.467931b2.js

nitro request: /_nuxt/nuxt-link.984d5a23.js

nitro request: /_nuxt/T3HtmlParser.vue.224ec59b.js

nitro request: /_nuxt/T3MediaGallery.vue.703aeb70.js

nitro request: /_nuxt/MediaFile.vue.bc0215d4.js

nitro request: /_nuxt/useMediaFile.dfba60a9.js

nitro request: /_nuxt/T3MediaGallery.cccbba3f.js

nitro request: /_nuxt/MediaFile.d8fbd6ee.js

nitro request: /_nuxt/T3CeHeader.5a4ad5c4.js

nitro request: /_nuxt/T3Link.8dd7a6f2.js

nitro request: /_nuxt/T3HtmlParser.2b7a107c.js

nitro request: /_nuxt/T3CeBullets.9fe1cd9d.js

nitro request: /_nuxt/T3CeMenuPages.d3cd596a.js

nitro request: /_nuxt/MediaImage.56fec44a.js

nitro request: /_nuxt/T3CeMenuPages.vue.7125810e.js

nitro request: /_nuxt/T3CeMenuPagesList.vue.4fa2fe8f.js

nitro request: /_nuxt/i18n.config.604a9d1c.js

nitro request: /_nuxt/i18n.config.604a9d1c.js

nitro request: /_nuxt/error-404.84c21dd9.js

nitro request: /_nuxt/error-500.fe3c6d26.js

nitro request: /_nuxt/builds/meta/176ec5cf-2b20-416b-96c4-bdb9cacced6e.json

nitro request: /favicon.ico

W powyższej liście widzimy zapytanie o stronę główną “nitro request: /” reszta requestów to pliki statyczne. 

Jak taki ruch wygląda po zaproponowanej przez nas konfiguracji? 

nitro request: /

Cała reszta requestów jest obsłużona na poziomie serwera NGINX. 

Dodatkowo w konfiguracji Nitro można wymusić wyłączenie serwowania plików statycznych przez Nitro jak i budowanie osobnego folderu .output/public (https://nitro.unjs.io/config#servestatic, https://nitro.unjs.io/config#nopublicdir) natomiast my tego nie robimy. Zostawiamy te opcje włączone ze względu na to, że w przypadku braku skonfigurowanego serwera NGINX lokalnie, aplikacja nadal może serwować pliki statyczne za pomocą Nitro. 

Szybkie porównanie

Poniżej przedstawiamy wyniki testu obciążającego wykonanego za pomocą narzędzia k6.js. Dzięki temu możemy porównać jak zachowuje się serwer przy zadanej liczbie jednoczesnych połączeń.

Konfiguracja testu:

 stages: [
     { duration: '15s', target: 1000 },
     { duration: '15s', target: 2000 },
     { duration: '15s', target: 3000 },
     { duration: '15s', target: 3500 },
 ],

Ta konfiguracja oznacza, że test zwiększa liczbę VU w czasie:

  • W pierwszych 15 sekundach liczba VU wzrasta do 1000.
  • W kolejnych 15 sekundach liczba ta wzrasta do 2000.
  • Następnie wzrasta do 3000 VU w kolejnych 15 sekundach.
  • W końcu osiąga 3500 VU w ostatnim 15-sekundowym etapie.

Każdy VU symuluje użytkownika wysyłającego żądania do serwera, zgodnie z definicją w skrypcie testowym. Całkowita liczba żądań wysyłanych podczas każdego etapu zależy od tego, jak szybko każdy VU może wysyłać żądania, na co ma wpływ czas odpowiedzi serwera oraz złożoność każdego requestu.

W teście wykonywaliśmy request o plik statyczny (favicon) serwowany za pomocą aplikacji Nuxt. Testy były wykonywane na tej samej maszynie, z tą samą aplikacją oraz tymi samymi ustawieniami cache dla plików statycznych - brak cachowania.

Wyniki w przypadku serwowania plików statycznych przez Nitro (Node.js)

Nitro (Node.js) HTTP performance test results showing response times and errors.

A tak dla NGINX

NGINX HTTP performance test results showing response times and errors.

Co możemy zauważyć:

  • Czas trwania żądania (średni): Serwer Nginx jest znacznie szybszy od serwera Node.js, z średnim czasem trwania żądania wynoszącym tylko 44.7 ms w porównaniu do 481.16 ms dla Node.js.
  • Czas oczekiwania (średni): Również w przypadku czasu oczekiwania, Nginx jest znacznie szybszy, z średnim czasem wynoszącym 43.74 ms w porównaniu do 462.61 ms dla Node.js.
  • Ilość żądań: Serwer Nginx przetworzył znacznie więcej żądań na sekundę, osiągając średnio 1257.75/s w porównaniu do 904/s dla Node.js.

Podsumowanie

Podsumowując, konfiguracja NGINX, którą omówiliśmy, znacząco poprawia wydajność aplikacji przez bezpośrednie serwowanie plików statycznych i efektywne zarządzanie ruchem sieciowym. Zmniejsza to obciążenie serwera Node.js, eliminuje niepotrzebne przetwarzanie requestów, szczególnie w przypadku błędów 404, i centralizuje zarządzanie cache oraz innymi ustawieniami wydajności. W rezultacie, cała aplikacja działa szybciej i bardziej efektywnie.

Dodatkowo, warto zaznaczyć, że w przypadku braku serwera NGINX, na przykład w lokalnych środowiskach deweloperskich, aplikacja nadal działa tak, jak działała wcześniej. Oznacza to, że jeśli NGINX nie jest skonfigurowany lub nie jest używany, serwer Node.js samodzielnie obsługuje wszystkie requesty, włącznie z serwowaniem plików statycznych i obsługą błędów 404. Ta elastyczność umożliwia łatwe przejście między środowiskami deweloperskim a produkcyjnym bez potrzeby zmiany logiki aplikacji.

Dla dociekliwych zapraszam do zapoznania się z ciekawym wątkiem na StackOverflow omawiającym przewagi Node.js vs NGINX w kontekście serwowania plików statycznych.

https://stackoverflow.com/questions/9967887/node-js-itself-or-nginx-frontend-for-serving-static-files