Konfiguracja aplikacji SPA za pomocą Dockera dla środowiska dev, prod i nie tylko

Aplikacje frontendowe posiadają wiele różnorakich zależności, o których powstały memy (np. często poruszane rozmiary node_modules) oraz wiele sposobów ich uruchamiania, deploy’owania i budowania — z każdym dniem pojawiają się nowe narzędzia zmieniające istniejące standardy. Jeszcze kilka lat temu pewnie każdy z Nas miał przyjemność wprowadzać zmiany przez FTP prosto na produkcji, które mieściły się w jednym pliku — na szczęście to już za nami, przynajmniej dla większości z Nas.


Marcin Łesek. QA Engineer w Ro, po godzinach Frontend i DevOps. Młody, proaktywny i odważny marzyciel. W branży od 4 lat, zdążył zahaczyć o wszystkie aspekty wytwarzania oprogramowania. Fan metodyk zwinnych, jakości i bezpieczeństwa softu. Zainteresowany nowoczesnymi metodami zarządzania, budowaniem zespołów i dzieleniem się wiedzą. Prywatnie miłośnik koszykówki, gier wideo i dobrej muzyki.


Source vs. Artifact

W tym artykule skupimy się na budowie środowiska developerskiego i produkcyjnego dla aplikacji stworzonej za pomocą Create React App, jednakże poznane tutaj informacje spokojnie pozwolą Wam budować kolejne środowiska w mniej niż 3 minuty. W takim razie, jaka jest różnica pomiędzy source, a artifact w aplikacji Single Page Application? Na początku przyjrzyjmy się strukturze plików wygenerowanej aplikacji.

Cały folder /src (source) to serce naszej aplikacji, główny kod, który rozwijamy. Z drugiej strony, folder /build (artifact), zawierający kod produkcyjny, który będziemy deploy’ować.

Ale co to ten Docker?

Skoro czytacie ten wpis, to zapewne wiecie czym jest i jak działa Docker, ale gdyby jednak tak nie było, pozwólcie, że zacytuję kilka zdań z artykułu What is a Container, który świetnie opisuje podstawowy element Dockera i zarazem ukazuje jego działanie – kontener.

A container is a standard unit of software that packages up code and all its dependencies so the application runs quickly and reliably from one computing environment to another. A Docker container image is a lightweight, standalone, executable package of software that includes everything needed to run an application: code, runtime, system tools, system libraries and settings.

Podkreślone zostały ważniejsze stwierdzenia, które podkreślają działanie i benefity jakie można uzyskać korzystając z tego rozwiązania. W skrócie: spakujmy całą Naszą aplikację wraz z jej zależnościami w lekki blok zwany kontenerem, który możemy błyskawicznie uruchomić na każdym środowisku.

Available for both Linux and Windows-based applications, the containerized software will always run the same, regardless of the infrastructure. Containers isolate software from its environment and ensure that it works uniformly despite differences for instance between development and staging.

Powyższy obrazek przedstawia strukturę działania Dockera i skonteneryzowanych aplikacji, dzięki czemu zawsze osiągamy ten sam wynik, niezależnie od infrastruktury lub środowiska, czy to nie brzmi pięknie? Jeżeli chcecie poczytać więcej, odsyłam do źródła.

Przygotujmy własne podwórko

Zaczniemy od skonfigurowania własnego środowiska developerskiego bazującego na Docker, docker-compose i wygenerowanej aplikacji create-react-app. Oczywiście rozwijane projekty posiadają większą ilość zależności, modułów i poziomu skomplikowania, ale sposób budowania środowisk w obu przypadkach jest taki sam.

Stwórzmy aplikację React

Generowanie aplikacji:

$ yarn create react-app docker-spa-setup

Powinniśmy otrzymać następujący komunikat:

Uruchomienie:

Po kilku sekundach powinniśmy otrzymać informację o poprawnym skompilowaniu i uruchomieniu lokalnego serwera pod adresem localhost:3000 serwującego naszą aplikację z wszystkimi potrzebnymi rzeczami do jej rozwoju. Cudnie!

Podstawowe narzędzia — Docker & docker-compose

Teraz w końcu zaczynamy prawdziwą zabawę. Będziemy również potrzebować docker oraz docker-compose (oh, captain obvious!) zainstalowane na naszym hoście. Podczas pisania tego poradnika używałem następujących wersji:

Nie będę tutaj opisywał procesu instalacji, bo nie o tym teraz, natomiast jeżeli musicie to jeszcze zrobić, to zerknijcie do świetnie opisanych poradników krok po kroku stworzonych przez Docker’a: Docker installation guide i docker-compose installation guide. Dzięki zastosowaniu przynajmniej tych wersji, które podałem wyżej, będziemy w stanie korzystać z plików compose w wersji API 3.6, o czym więcej później.

Stwórzmy teraz w naszym głównym folderze projektu katalog .docker, gdzie będziemy przechowywać wszystkie związane z nim pliki i konfiguracje.

Konfigurujemy środowisko developerskie

Dobra, mamy już całkiem sporo — naszą pokazową aplikację hello world, React! oraz zainstalowany i działający docker oraz docker-compose, więc możemy zacząć prawdziwą przygodę! Jeżeli znacie trochę Dockera i ominęliście powyższe podstawy, za pewne od razu pomyśleliście o Dockerfile — i jest to całkiem trafny strzał, ale…

Dockerfile w środowisku developerskim? Oh please no…

Pewnie cześć z Was się ze mną nie zgodzi (albo już to zrobiła w myślach lub na głos), ale spróbuję pokazać Wam mój punkt widzenia, który mówi, że używanie Dockerfile w środowisku developerskim to nie najlepsze rozwiązanie, które można zastosować, choć są też od tego wyjątki, czasem jest to must have, ale będąc szczerym, w większości przypadków, nie jest.

Plik Dockerfile to mówiąc kolokwialnie przepis na zbudowanie obrazu, z którego będziemy uruchamiać Nasz kontener. Pozwala na skorzystanie z już gotowych obrazów, dodając do nich Nasze własne zmiany i kroki w procesie budowania. Tak, tak, wiem — brzmi świetnie i takie też właśnie jest, dlatego będziemy tego używać, ale w konfiguracji produkcyjnej, gdzie liczy się optymalizacja, bezpieczeństwo i lekkość kontenerów.

Co za tym w konfiguracji developerskiej? Oczywiście, że docker-compose (plottwist: w konfiguracji produkcyjnej też go wykorzystamy), który pozwala zarządzać serwisami (kontenerami) z poziomu pliku docker-compose.yml. Stwierdzę nawet, że compose mocno upraszcza poziom skomplikowania konfiguracji, dzięki utrzymaniu wszystkich definicji Naszych serwisów w jednym pliku. Jeżeli chodzi o konfiguracje, posiada możliwość ustawienia portów, wykorzystywanych obrazów, zmiennych środowiskowych itp., co patrząc przez pryzmat mojego doświadczenia z małymi i średnimi projektami, całkowicie wystarczy.

A jakie benefity? Co dostaniemy za to, że pozbyliśmy się Dockerfile’a? Dużo mniejszy czas potrzebny na zbudowanie aplikacji (kluczowe zwłaszcza dla frontend’owców, którzy pracując z wykorzystaniem różnej maści watch’erów i hot-reload’ów) dzięki redukcji własnych kroków budowy własnego obrazu. Środowiska developerskie wymagają szybkości, elastyczności i lekkości, zwłaszcza w budowie aplikacji SPA, gdzie ciągle obserwujemy nasze zmiany.

Jeden by rządzić wszystkimi? Nie ta bajka — wiele plików compose

Ale co to znaczy wiele plików composedocker-compose domyślnie będzie wczytywał konfigurację z pliku docker-compose.yml, ale jako, że nie jesteśmy fanami plików, które mają po kilkaset linii, tak jak nie lubimy klas, które mają ich po kilka tysięcy, to wykorzystamy nasz plik docker-compose.yml jako bazę naszej konfiguracji, która będzie wspólna dla wszystkich naszych środowisk. Z takim podejściem compose będzie rozszerzał bazowy plik o kolejne docker-compose.override.yml, tworząc wynikową konfigurację środowiska. W przypadku gdybyśmy jakieś serwisy zadeklarowali w dwóch miejscach, będą one połączone (ich konfiguracja również). Takie podejście pozwoli utrzymać nam porządek i łatwość edycji konfiguracji oraz utrzymywanie plików specyficznych dla danego środowiska osobno.

Stwórzmy więc nasz podstawowy docker-compose.yml:

Co dzieje się powyżej? Dla osób pierwszy raz spotykających się z tym narzędziem opiszmy je krok po kroku:

  • version: 3.6 — wspierana wersja API docker-compose, dzięki czemu wiemy jaką składnię i jakie właściwości możemy użyć do konfiguracji,
  • services: — główna część naszej konfiguracji, tutaj rejestrujemy serwisu, które zostaną uruchomione zamiast wykorzystania komendy docker run ...,
  • client: — zarejestrowanie pierwszego serwisu o nazwie client, którym jest nasza aplikacja React’owa,
  • env_file: — wskazuje na plik .env, który zawiera zmienne globalne każdej konfiguracji, jak np. wartości wystawianych portów, typ środowiska, nazwa projektu dla compose itp., które można łatwo edytować ze względu na ich jedno miejsce.

Teraz dodamy pierwszy nadpisujący plik dla środowiska developerskiego: docker-compose.dev.yml

Opiszmy ciut tą powyższą magię:

  • image: — tutaj definiujemy jaki obraz chcemy użyć dla naszego serwisu, ja zdefiniowałem obraz node.js w wersji carbon (8.x). To właśnie bazując na tym obrazie Docker zbuduje kontener naszej aplikacji (client),
  • volumes: — pozwala nam zamontować dane z hosta (nie musimy kopiować żadnych danych do kontenera, gdyż montujemy je z hosta w kontenerze) w formacie HOST:CONTAINER. W tym przypadku montujemy .. (główny katalog projektu) do /opt/app w kontenerze. Dodaliśmy również :cached by usprawnić wydajność tego rozwiązania na komputerach Mac. Więcej o cache’owaniu wolumenów tutaj,
  • ports: — tutaj przypisujemy porty z Naszego hosta do portów w kontenerze (HOST:CONTAINER), dzięki czemu możemy mieć dostęp do naszego kontenera z zewnątrz, np. 3000:6000 mapuje port 3000 na hoście do portu 6000 w naszym kontenerze. Możemy również sparametryzować wartości tych portów, dzięki czemu nie musimy ich za każdym razem zmieniać w konfiguracji a tylko w pliku zmiennych środowiskowych,
  • working_dirL — określa główny katalog aplikacji, miejsce gdzie docker-compose będzie uruchamiał kontenery,
  • command: — komenda, którą uruchomimy nasz kontener, w naszym przypadku za pomocą shell’a (/bin/sh -c) przekazujemy nasze komendy, które najpierw zainstalują zależności z zamrożonym lockfile (nie generującym nowego pliku yarn.lock i pobierającego dokładnie te wersje zależności, które podane są w pliku package.json) i ustawia flagę środowiska produkcyjnego na false (by mieć pewność, że devDependencies też na pewno zostaną zainstalowane).

Domyślne zmienne środowiskowe

Aktualnie, nasz setup Docker’owy nie zrobi nic pożytecznego. Będąc szczerym, nawet jak spróbujecie go uruchomić — wyrzuci warn’a o braku zmiennych środowiskowych. Dodajmy je! Stworzymy plik, który będzie szablonem dla zmiennych środowiska developerskiego .env.dev.dist.

Mamy tutaj:

  • COMPOSE_PROJECT_NAME — ustawia nazwę naszego projektu, która będzie używana przez Docker’a jako prefix przy tworzeniu naszych kontenerów (dobrze nazwać go krótko i intuicyjnie),
  • COMPOSE_FILE — ta zmienna pozwala nam sztywno określić, jakich plików compose chcemy użyć, w jakiej kolejności (a zatem jakie mają zostać nadpisane). Dzięki takiemu rozwiązaniu, możemy je ustawić dla konkretnego środowiska. Składnia przedstawia się następująco: base:override1:override2 itd.,
  • NODE_ENV — tutaj określimy, w jakiej wersji będziemy budować naszą aplikację (developerskiej czy produkcyjnej),
  • NODE_PORT — pozwala łatwo określić port naszej aplikacji dla naszego serwisu w kontenerze i hoście, teraz ustawiony na 3000.

Nasz template jest gotowy, teraz kopiujemy go do prawidłowego pliku .env:

Odpalamy aplikację!

W końcu! Wraz z naszym docker-compose.dev.yml i skopiowanym .env możemy uruchomić nasz setup Dockerowy:

Gratulacje! Właśnie poprawnie skonfigurowałeś i uruchomiłeś swoje środowisko developerskie!

Czas na produkcje

Teraz śmiało możemy założyć, że minęło już kilka sprintów aktywnego development’u, a Wy stworzyliście swoje wymarzone MVP gotowe do wdrożenia na produkcję. Niestety, aktualnie działa tylko środowisko developerskie, które jest ciężkie, nie zoptymalizowane, zawierające dużo niepotrzebnych logów itd. Zmieńmy to!

Analogiczna konfiguracja

Na początku dodamy nowy plik docker-compose, który będzie nadpisywał nasz podstawowy dla build’a produkcyjnego — docker-compose.prod.yml:

Jak możecie zauważyć, dużo rzeczy wygląda tutaj inaczej niż w pliku opisującym środowisko developerskie. Ze względu na to, że nic się nie pokrywa, nie możemy nic wynieść na wyższy poziom do głównego pliku docker-compose.yml. Teraz wykorzystamy potencjał drzemiący w tworzeniu własnych Dockerfile’ów niż używania gotowych obrazów z Docker Hub’a oraz nginx do serwowania naszej aplikacji. Oczywiście, nginx nie jest wymogiem i moglibyśmy wykorzystać Apache lub innego serwera HTTP tego typu. Nasz serwis dalej nazywać się będzie client, tak samo jak w środowisku dev, dlatego, że budujemy dalej tą samą aplikację lecz w innej konfiguracji, więc nie chcemy popsuć jej połączenia z innymi serwisami (po więcej szczegółów odsyłam do artykuły o używaniu nazw serwisów zamiast IP’ków — co notabene jest dużo bardziej wygodne, patrząc na fakt, że domyślnie kontenery mają dynamiczne IP). Przyjrzyjmy się konfiguracji:

  • build: — tutaj określamy proces budowania naszego obrazu dla kontenera produkcyjnego,
    • context: — przekazujemy informację o głównym folderze aplikacji do daemon’a Docker’a (analogicznie jak wcześniej), więc wskazujemy jeden poziom wyżej. Domyślnie, Docker szuka pliku Dockerfile tam gdzie wskazany jest context, więc My podamy mu tą ścieżkę poniżej.
    • dockerfile: — ścieżka do Twojego Dockerfile,
  • ports: — tak samo jak poprzednio, konfiguracja portów, lecz teraz z inną zmienną środowiskową NGINX_PORT, którą ustawimy później,
  • restart: — polityka restartowania kontenera, jedno z ważniejszych ustawień w konfiguracji produkcyjnej. Nie chcemy przecież, by nasza produkcja była ubita przez długi okres czasu z powodu jakiś błędów aplikacji lub by nie wstała po restarcie instancji serwera — unless-stopped pokrywa te przypadki. W tym ustawieniu kontenery będą się uruchamiać automatycznie aż do momentu gdy sami ich nie zatrzymamy za pomocądocker-compose stop. Osobiście nie polecam używać tej opcji w konfiguracji deweloperskiej, gdyż uruchomi ona zawsze ze startem naszego hosta wszystkie serwisy aplikacji, a nie zawsze przecież pracujemy, prawda?

Przedstawiony powyżej docker-compose.prod.yml nie pokrywa wszystkiego, ale jest dobrą bazą do dobrego startu w konfiguracji produkcyjnej — wszystko zależy od specyfiki danej aplikacji, jej stack’u technologicznego, wymagań, potrzeb itp. Dla przykładu, nie uwzględniliśmy tutaj konfiguracji portów dla połączeń HTTPS, które aktualnie są wymaganym standardem (ich dodanie jest analogiczne i nie sprawia wielu kłopotów).

Stwórzmy teraz plik .env dla konfiguracji produkcyjnej. Ale przecież posiadamy już taki plik, który posiada konfigurację dla środowiska developerskiego! Dla wielu powodów, między innymi bezpieczeństwa, nie powinno trzymać się plików .env w repozytorium kodu. Podmiana ich na inną wersję jest banalnie prosta i nie wymaga zmiany żadnego kodu, a dodatkowo bardzo często przechowywane w nich są klucze lub hasła, które nie mogą być dostępne publicznie lub w historii repozytorium. Template’y plików .env powinny pokazywać nam, jakie zmienne są niezbędne do pracy aplikacji w danym środowisku, ale powinny również wymagać od nas ich wypełnienia manualnie (zwłaszcza dla kluczy prywatnych!). By być pewnym, że tego nie zrobimy, dodamy wpis do .gitignore:

Stwórzmy więc teraz .env.prod.dist, który będzie odpowiedzialny za zmienne wykorzystywane w środowisku produkcyjnym. Za każdym razem, gdy będziemy chcieli zmienić środowisko lub stworzyć je za pierwszym razem, będziemy musieli kopiować właściwy plik zmiennych środowiskowych do aktualnie używanego .env:

Nic wielkiego, ale trzeba o tym pamiętać.

Widzimy tutaj trzy różnice pomiędzy naszym plikiem z środowiska developerskiego:

  • zmieniliśmy nadpisujący plik docker-compose na wersję prod,
  • zmienna NODE_ENV jest ustawiona na środowisko production, jest to wymagany zabieg, gdyż wiele silników JavaScript, framework’ów i bibliotek sprawdza zawartość tej zmiennej i optymalizuje swój kod zależnie do niej,
  • NGINX_PORT — nowa zmienna, która zastąpiła miejsce NODE_PORT, użyta do określenia na jakim porcie chcemy by nasza aplikacja była serwowana przez nasz serwer nginx.

Nic trudnego, prawda?

Magia multi-stage build

To jest moment, na który czekali wszyscy (a przynajmniej Ci, którzy znają już trochę Docker’a i czekali na coś bardziej zaawansowanego) – tworzenie Dockerfile‘a za pomocą mutli stage build!

WOW! Co tu się dzieje? Zacznijmy od Stage 1, w którym napisaliśmy Nasz build process.

1. Początkowo pobieramy obraz node:carbon-alpine (alpine to lżejsza wersja danego obrazu, nie zawierająca niepotrzebnych narzędzi) i nazywamy go builder w naszym scope’ie.

2. W drugiej linii tworzymy folder /opt/app, który posłuży Nam jako root directory naszej aplikacji.

3. Mówimy Docker’owi, że to tutaj będzie pracował (wskazanie na główny folder aplikacji) i wykonywał wszystkie komendy (nie trzeba później podawać ścieżek absolutnych).

4. Kopiujemy pliki projektowe.

5. Instalujemy zależności a następnie budujemy wersję produkcyjną (możesz też oczywiście wykorzystać npm’a: npm i; npm run build, ale ja ostatnio jestem #teamYarn).

Pewnie teraz myślicie — dobra, wszystko fajnie, ale mam kontener z zbudowanym kodem aplikacji, dlaczego nie dodać by drugiego serwisu serwera HTTP do docker-compose.prod.yml, który będzie ją serwował? To jest właśnie miejsce, gdzie swój atut pokazuje multi-stage build! Oczywiście, macie racje — można tak zrobić, ale aktualnie w tym kontenerze mamy cały kod źródłowy aplikacji, zależności (node_modules, które jak wiemy jest większe niż słońce) i nic, co aktualnie może tą aplikację serwować (jeszcze…).

Natomiast multi-stage build może znacząco odchudzić nasz kontener, jednocześnie posiadając w nim nginx’a i to w jednym serwisie! Brzmi dobrze? Sprawdźmy to — czas na Stage 2.

6. Teraz pracujemy na obrazie nginx:alpine, który będzie serwował naszą aplikację, a w przyszłości może też posłużyć jako reverse proxy lub load balancer.

7. Następnie konfigurujemy nasz serwer za pomocą skopiowania wcześniej przygotowanego przez nas pliku konfiguracyjnego (który pokażę poniżej — jako, że konfiguracja nginx’a zasługuje na osobny post, jak nie całą serię, bo narzędzie to posiada ogrom możliwości, nie będę się na nim mocno skupiał).

8. Usuwamy wszystko z domyślnej lokalizacji z której nginx serwuje pliki (by mieć pewność, że nic tam nie ma przed wgraniem Naszej aplikacji).

9. Magia dzieje się teraz! Teraz kopiujemy tylko zbudowany kod produkcyjny z naszego builder‘a (Nasz kontener zbudowany na bazie obrazu node, gdzie budowaliśmy aplikację) do nowego kontenera z nginx’em do domyślnej lokalizacji, którą wykorzystuje nasz serwer HTTP. Po wszystkich krokach, Docker automatycznie usunie stary, nie używany dłużej kontener builder a My będziemy korzystać tylko z nowego kontenera zbudowanego na bazie nginx:alpine posiadającego tylko kod production-ready pod nazwą client. Świetne!

10. Komenda uruchamiająca kontener, w tym przypadku startująca daemon’a nginx’a w tle serwując naszą aplikację na podanym wcześniej porcie.

Prawie skończyliśmy!

Wchodzimy na scenę, niepokonani!

Ostatnia rzecz, którą polecam, to stworzenie przestrzeni na pliki związane z konfiguracją i zarządzaniem nginx’em (to dobra praktyka trzymać związane ze sobą pliki w jednym context’cie, dzięki czemu łatwo nam nimi zarządzać w przypadku większych projektów):

I stworzymy też teraz konfigurację, o której wspominałem powyżej jako default.conf do zarządzania naszym serwerem:

Obsługuje ona tylko zapytania HTTP i serwuje plik index.html, zmianie podlega zmiana wartości server_name na własny adres url. Po tym zabiegu zostaje tylko uruchomienie Waszej aplikacji w wersji produkcyjnej:

Waszym oczom powinny ukazać się logi Docker’a z wszystkimi krokami zawartymi w Dockerfile:

Możesz teraz sprawdzić swoją aplikację lokalnie, wpisując w przeglądarce localhost:{NGINX_PORT} i oficjalnie przywitać swoją produkcyjną aplikację! Whoooa!

Extra

Mam nadzieję, że ten post pomoże komuś zrozumieć jak działa Docker, docker-compose, multi-stage build i pozwoli usprawnić Wasze aplikacje SPA (a może przydać się też w budowaniu API, ale o tym kiedy indziej). Działający kod wraz z angielską wersją postu możecie znaleźć na moim GitHub, więc śmiało zerkajcie, gdybyście natrafili na jakieś problemy z konfiguracją, coś nie było do końca jasne lub po prostu dajcie mi znać na moim Twitterze — postaram się pomóc.

Dodaję też kilka ciekawych linków, które są świetnym uzupełnieniem zagadnień, o których pisałem powyżej (podstawy i rzeczy zaawansowane), ale jeżeli mam być naprawdę szczery, najlepszą opcją nauki Dockera jest korzystanie z niego wertując przy tym jego dokumentację — która muszę przyznać, jest świetnie opisana.

Patronujemy

 
 
Polecamy
Jak zadbać o wydajność frontendu. Devdebata