Główne logo strony
📅

Docker – Dockerfile i tworzenie obrazów

Docker i jego podstawy – ciąg dalszy 🙂. W poprzednim poście dowiedzieliśmy się, czym jest obraz i w jaki sposób możemy tworzyć z niego kontenery. Dzisiaj pójdziemy krok dalej i nauczymy się, w jaki sposób możemy stworzyć taki obraz samodzielnie i następnie wykorzystywać go do uruchamiania kontenerów. Jako frontend-owcy jesteśmy zaznajomieni z JavaScript, dlatego też stworzymy obraz za pomocą Dockerfile w oparciu o Node.js i framework Express.js.

Co tak w ogóle chcemy osiągnąć?

Jak już wiemy z poprzednich wpisów, obrazami Dockera mogą być całe aplikacje, które następnie możemy uruchamiać wszędzie tam, gdzie zainstalowany jest Docker. Obrazy zawierają również wszystkie wymagane pliki, które to finalnie składają się na naszą aplikację. W tym poście będziemy chcieli przygotować obraz z podstawową aplikacją zbudowaną w Express.js.

Zanim jednak przejdziemy do Dockera pomyślmy co musimy zrobić, aby taka aplikacja mogła działać:

  • Express.js jest frameworkiem działającym w Node.js, więc potrzebujemy tego właśnie środowiska,
  • gdy będziemy posiadać już Node.js, będziemy mogli skorzystać z npm (jest on instalowany wraz z Node.js), a więc damy radę zainstalować Express.js (npm install ...),
  • mając Express.js, możemy w końcu zacząć pisać kod,
  • napisany kod musimy uruchomić za pomocą jakiegoś skryptu,
  • weryfikacja w przeglądarce, czy nasza aplikacja jest dostępna pod wskazanym w kodzie portem.

Wiemy już jak uruchomilibyśmy taką aplikację bez Dockera, spróbujmy teraz dokonać jej „konteneryzacji”.

Dockerfile

Zdecydowaną większość z kroków wymienionych w poprzednim rozdziale wykonamy za pomocą tzw. Dockerfile. Jest to zwykły plik tekstowy o nazwie Dockerfile (brak rozszerzeń, sama nazwa), który zawiera szereg instrukcji, dzięki którym damy radę stworzyć w pełni funkcjonalny obraz. Obraz ten będzie zawierał wszystko, co jest nam potrzebne do uruchomienia opisanej aplikacji.

Dockerfile składa się z par instrukcja ⬅️➡️ argumenty dla instrukcji. Opisuje on dokładnie, z jakich elementów powinno składać się środowisko wykonawcze dla umieszczonej w obrazie aplikacji. Zobaczmy teraz, jak może wyglądać przykładowy prosty plik Dockerfile:

# Obraz bazowy
FROM node:alpine
 
# Instalacja paczek
RUN npm i -g serve
 
# Domyślna komenda startowa
CMD ["serve", "--help"]

W pliku powyższym widzimy trzy instrukcje – FROM, RUN oraz CMD. Instrukcje te są również wykonywane z przypisanymi im argumentami, odpowiednio – node:alpine, npm install oraz ["npm", "start"]. Przyjrzyjmy się teraz tym trzem krokom nieco bliżej.

Obraz bazowy

Obraz bazowy możemy traktować jak swego rodzaju system operacyjny dla tworzonego przez nas obrazu. Jest to baza, do której będziemy dodawać kolejne aplikacje (np. biblioteki) oraz nasz własny kod. To od nas zależy, z jakiego punktu zaczniemy tworzenie naszego obrazu właśnie poprzez określenie obrazu bazowego.

Jako obrazu bazowego możemy użyć „gołego” systemu operacyjnego, np. Ubuntu lub Alpine. W takim przypadku musimy samodzielnie zainstalować wszystkie potrzebne nam narzędzia (np. Node.js). Lepszym rozwiązaniem może być skorzystanie z gotowego obrazu, który to posiada już większość tych narzędzie preinstalowanych. Dzięki temu nasz plik Dockerfile będzie dużo mniejszy i łatwiejszy do zarządzania. Skąd tylko wziąć te obrazy? 🤔

Najłatwiejszym sposobem na wyszukanie obrazów bazowych jest DockerHub. Tam znajdziemy większość obrazów, których będziemy mogli użyć jako punkt startowy do naszej pracy. Wspomniane powyżej systemy operacyjne znajdziemy pod tym linkami: Ubuntu, Alpine, natomiast Node.js dostępny jest tutaj. Gdy zajrzymy na tę stronę, będziemy mogli zauważyć, iż większość obrazów Node bazuje na systemie Alpine, a więc twórcy tego obrazu postanowili skorzystać z obrazu Alpine, jako obrazu bazowego dla obrazu Node.

Instalacje paczek

Wszystkie instrukcje RUN wykonywane są wewnątrz wybranego przez nas obrazu bazowego. To tutaj możemy np. zainstalować dodatkowe narzędzia i dokonać ich konfiguracji. Argumenty tej instrukcji zależą tylko i wyłączenie od używanego przez nas systemu operacyjnego. Gdybyśmy jako obraz bazowy wybrali Alpine, wtedy wywołanie komendy npm -i -g serve pokazałoby nam błąd, gdyż domyślnie Alpine nie zna takiej składni. W takim wypadku w pierwszej kolejności musielibyśmy zainstalować Node.js:

FROM alpine
RUN apk add --update nodejs npm
RUN npm i -g serve
# reszta pliku...

W naszym przykładowym Dockerfile skorzystamy z menadżera npm i za jego pomocą zainstalujemy paczkę serve.

Domyślna komenda startowa

Komenda ta zostanie automatycznie wywołana w momencie, gdy uruchomimy kontener. Najczęściej będzie to uruchomienie aplikacji. Warto tutaj pamiętać o tym, iż komendę oraz argumenty podajemy jako pojedyncze elementy tablicy. W naszym prostym przykładzie uruchomimy jedynie komendę serve --help, która to powinna wyświetlić nam pomoc dla zainstalowanej wcześniej paczki z npm.

Budowanie obrazu z pliku Dockerfile

Zbudowanie obrazu z pliku Dockerfile jest dość prostym zadaniem i wymaga od nas jedynie wywołania komendy: docker build . (należy zwrócić uwagę na to, że na samym końcu tej komendy podajemy kropkę .).

W wyniku wywołania powyższej komendy na naszym przykładowym pliku powinniśmy finalnie otrzymać następujący wynik (przepraszam za ciemno-niebieską czcionkę, ale mój wiersz poleceń mocno się na nią uparł 🙂):

Ostatnia linijka w wierszu poleceń ujawnia nam ID nowo stworzonego obrazu. Obraz ten możemy teraz uruchomić za pomocą znanej nam już komendy docker run ID:

Jak pamiętamy, w naszym pliku Dockerfile ustawiliśmy jako komendę startową wartość serve --help, a więc wyświetlenie pomocy dla zainstalowanej wcześniej paczki (zainstalowaliśmy ją za pomocą instrukcji RUN). Widzimy teraz, iż uruchomienie kontenera faktycznie pokazało nam pomoc dla paczki serve, a więc udało nam się poprawnie zbudować obraz oraz stworzyć z niego kontener 🥳.

W naszym prostym przykładzie skorzystaliśmy jedynie z trzech najbardziej podstawowych instrukcji – FROM, RUN oraz CMD. Jest to dobry punkt startowy, żeby zapoznać się z tworzeniem obrazów, jednak w świecie rzeczywistym tych instrukcji będzie znacznie więcej. Przypomnę jeszcze raz, iż wszystkie instrukcje znajdziemy w oficjalnej dokumentacji Dockera.

Dwa słowa o budowaniu obrazu

Celem tej serii wpisów na temat Dockera jest jak najszybsze i jak najłatwiejsze zapoznanie czytelnika z Dockerem. Tym razem powiemy sobie dwa dodatkowe słowa na temat tego, co dzieje się „za kulisami” podczas budowania obrazu. Dzięki temu będzie nam łatwiej zrozumieć dalszą część artykułu.

Każda z instrukcji znajdujących się w Dockerfile kończy swoje działanie, tworząc „tymczasowy” obraz. Obraz ten następnie jest używany niejako w roli „obrazu bazowego” dla kolejnej instrukcji, która to ponownie na samym końcu tworzy nowy „tymczasowy” obraz i podaje go dalej. W ten sposób kolejne obrazy są „nadbudowywane” o zmiany wprowadzane przez kolejne instrukcje.

Jedną z rzeczy, która sprawia, iż Docker jest dzisiaj tak popularny, jest jego szybkość działania. Jeżeli na swoim komputerze stworzyłeś/stworzyłaś już plik Dockerfile z przykładu umieszczonego powyżej i uruchomiłeś po raz pierwszy komendę docker build ., budowanie obrazu trwało pewnie kilkanaście sekund. Spróbuj wykonać teraz tę komendę jeszcze raz 🙂. Build teraz wykonał się natychmiastowo. Dlaczego?

owiedzieliśmy już sobie, że podczas wykonywania kolejnych instrukcji Docker tworzy „tymczasowe” obrazy, które przekazuje kolejnym instrukcjom w Dockerfile. Obrazy te zostają również zachowane przez Dockera w pamięci „cache” i gdy tylko będzie taka możliwość, obrazy te zostaną użyte ponownie. Dzięki temu oszczędzamy czas, który musielibyśmy poświęcić na ponowne ich budowanie. O tym, czy dana instrukcja została wykonana w całości, czy został wykorzystany cache, dowiemy się, obserwując terminal:

Obrazy te jednak zostaną użyte tylko wtedy, gdy nie zmienimy niczego w pliku Dockerfile. Gdy taka zmiana nastąpi, Docker będzie musiał tworzyć obrazy od początku. Dobra wiadomość jest taka, iż czytając plik Dockerfile „od góry”⬇, Docker będzie używał obrazów z cache tak długo, aż trafi na pierwszą zmianę w pliku. Tak więc, gdybyśmy w naszym przykładzie umieścili nową komendę RUN w linii nr 4, wtedy wszystkie następujące po niej instrukcje musiałaby zostać przebudowane od zera. Gdybyśmy jednak umieścili nową instrukcję w linii nr 6, wtedy instrukcja z linii nr 5 mogłaby skorzystać z cache.

Jeżeli więc chcemy jak najrzadziej dokonywać pełnych buildów pamiętajmy, aby nowe instrukcje umieszczać możliwie „nisko” w pliku Dockerfile.

Aplikacja Express.js

Skoro wiemy już, czym jest Dockerfile i jak za jego pomocą tworzyć obrazy, stwórzmy teraz wspomnianą już na początku aplikację. Projekt będzie mały, ponieważ nie o sam kod tutaj chodzi, ale o jego poprawne „dockeryzację”.

Kroki, które za chwilę wykonamy będą wyglądały nasępująco:

  • Stworzenie aplikacji webowej przy użyciu Node.js oraz Express.js.
  • Stworzenie pliku Dockerfile.
  • Zbudowanie obrazu z pliku.
  • Uruchomienie obrazu jako kontener.
  • Przetestowanie aplikacji w przeglądarce.

Aplikacja

Sama aplikacja jest maksymalnie prosta i myślę, że jej zrozumienie nie powinno sprawić nikomu problemów. Pliki źródłowe znajdziemy tutaj: Web App Gist.

Dockerfile

Przejdźmy do meritum tego przykładu, czyli pliku Dockerfile. Plik ten umieszczamy w głównym katalogu projektu. Aplikację tworzymy w środowisku Node.js, a więc skorzystamy ze znanego już nam bazowego obrazu – Node. Mając pliki package.json oraz index.js, musimy w pierwszej kolejności zainstalować sobie wszystkie zależności za pomocą npm install, a następnie uruchomić skrypt uruchamiający naszą aplikację – npm start. Wydaje się więc, że Dockerfile będzie dosyć prosty:

# Obraz bazowy
FROM node:alpine
 
# Instalacja paczek
RUN npm install
 
# Komenda startowa
CMD ["npm", "start"]`

Jeżeli już zauważyłeś, że z takim plikiem Dockerfile nie uruchomimy poprawnie naszej aplikacji, to znaczy, że dokładnie przeczytałeś dwa poprzednie wpisy 💪. Instrukcje RUN oraz CMD zostaną wykonane na plikach znajdujących się wewnątrz obrazu Node. Nie ma tam jeszcze napisanego przez nas kodu. Musimy więc w jakiś sposób umieścić go w nowo tworzonym obrazie.

Kopiowanie plików wykonamy za pomocą prostej komendy COPY. Dobrze jest w tym momencie określić również katalog roboczy. Dzięki temu wszystkie przyszłe komendy będą wykonywane właśnie z tego poziomu. Te katalog również będzie wybrany w przypadku wykonywania komend z „kropką” ., gdzie kropka właśnie najczęściej oznacza nasz aktualny katalog roboczy. W pliku Dockerfile będzie wyglądało to następująco:

# Obraz bazowy
FROM node:alpine
 
# Ustawiamy katalog roboczy w obrazie
WORKDIR /usr/nodeWebApp
 
# Kopiujemy pliki z aktualnego katalogu (nasz kod) do katalogu roboczego
COPY ./ ./
 
# Instalacja paczek (wykonana w katalogu roboczym czyli "/usr/nodeWebApp")
RUN npm install
 
# Komenda startowa (wykonana w katalogu roboczym czyli "/usr/nodeWebApp")
CMD ["npm", "start"]

Gdybyśmy w powyższym przykładzie nie ustawili katalogu roboczego za pomocą instrukcji WORKDIR, wtedy pliki zostałyby skopiowane do nadrzędnego katalogu systemu operacyjnego, czyli tak gdzie w przypadku Linuxa znajdują się foldery bin, etc, lib itp.

Docker w VS Code

VS Code posiada rozszerzenie, dzięki któremu możemy ułatwić sobie znacznie tworzenie plików Dockerfile: https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-docker

Budowa obrazu i uruchomienie kontenera

Posiadamy teraz już poprawny plik Dockerfile, spróbujmy więc zbudować obraz i uruchomić na jego podstawie kontener. Wykonujemy znaną nam już komendę docker build . (pamiętamy o kropce na samym końcu):

Mając obraz, możemy teraz zbudować nowy kontener, podając kilka pierwszych znaków ID (np. bcb3d44). Jednak ciągłe kopiowanie i wklejanie ID jest dość… nudne, możemy nadać naszemu obrazowi nazwę. W nomenklaturze Dockera – tagujemy obraz. Możemy wykonać to teraz na stworzonym już obrazie za pomocą komendy docker tag Id Name, czyli w naszym przykładzie docker tag bcb3d44 frontstack/webapp.

Nazwę taką możemy nadać również podczas budowania obrazu, używając flagi -t Name. Komenda ta będzie wtedy wyglądała następująco: docker build -t frontstack/nodeapp:latest. Widzimy tutaj dodatkowy człon :latest. De facto to jest właściwy „tag” obrazu. Umieszczamy go po dwukropku : i jeżeli nie ustawimy go samodzielnie, to zostanie mu przypisana domyślna wartość latest. Wszystko to, co znajduje się przed dwukropkiem, jest nazwą obrazu. Wykonajmy teraz build obrazu z flagą -t frontstack/nodeapp:latest:

Aby odpocząć trochę od wiersza poleceń, nasze dwa nowo zbudowane obrazy możemy podejrzeć za pomocą Docker Desktop:

Zostało nam już teraz tylko uruchomić jeden ze zbudowanych obrazów za pomocą docker run Name.

Testowanie w przeglądarce

Czy teraz nasza praca jest już skończona? Oczywiście, że nie 😉. Pamiętajmy, że kontenery są całkowicie odizolowane od reszty systemu operacyjnego działającego na naszym komputerze. Dotyczy to również sieci. Jeżeli teraz spróbujemy odwiedzić w przeglądarce adres localhost:8080, otrzymamy informację, iż ta witryna jest nieosiągalna. W celu przekierowania ruchu z naszego systemu do wnętrza kontenera musimy odpowiednio ustawić przekierowania portów. W tym celu posłużymy się flagą -p IncomingPort:ContainerPort. Flaga ta przyjmuje dwa numery portów rozdzielone dwukropkiem :. Pierwszym portem jest port na naszym lokalnym systemie, drugim natomiast jest port wewnątrz kontenera. Nasza aplikacja uruchomiona jest wewnątrz kontenera i nasłuchuje na porcie :8080, więc ten port będzie podany jako drugi. Pierwszym portem jest port, który podamy w przeglądarce podczas wykonywania zapytania. Nie musi to być ten sam port co w kontenerze.

W takim przypadku uruchamiając kontener za pomocą docker run -p 8050:8080, nasza aplikacja będzie dostępna dla nas pod adresem localhost:8050.

Zmiany w kodzie

Artykuł ten zakończymy omówieniem, w jaki sposób możemy teraz dokonywać zmian w kodzie i widzieć te zmiany w przeglądarce. W tym momencie aplikacja jest uruchomiona w kontenerze i kontener ten został uruchomiony z obrazu zawierającego nasz kod. Wszelkie zmiany, które teraz wykonamy w kodzie, oczywiście nie będą widoczne od razu w przeglądarce. W najprostszym podejściu, po zakończeniu kodowania musimy zbudować nowy obraz, do którego to zostaną przesłane nowe pliki i następnie obraz ten uruchomić jako kontener.

Podczas builda Docker zauważy w plikach i wykona od nowa instrukcję COPY (nie skorzysta z cache) oraz wszystkie instrukcje występujące po niej. Dla nas oznacza to, iż zmiana w pliku index.js spowoduje, iż ponownie zostanie uruchomiona komenda npm install. Nie jest to najlepsze rozwiązanie, ponieważ stracimy czas na instalowanie paczek, mimo iż nie dokonaliśmy żadnych zmian w package.json.

Możemy temu zaradzić, wprowadzając delikatne zmiany w pliku Dockerfile:

# Obraz bazowy
FROM node:alpine
 
# Ustawiamy katalog roboczy w obrazie
WORKDIR /usr/nodeWebApp
 
# Kopiujemy tylko plik package.json
COPY ./package.json ./
 
# Instalacja paczek
RUN npm install
 
# Kopiujemy pliki z aktualnego katalogu (nasz kod) do katalogu roboczego
COPY ./ ./
 
# Komenda startowa
CMD ["npm", "start"]

Pamiętacie jeszcze, kiedy Docker skorzysta z cache, a kiedy nie? 🙂 Zmiana, którą teraz wprowadziliśmy, spowoduje, iż Docker wykona instrukcję RUN tylko wtedy, gdy nastąpi zmiana w pliku package.json. To tam trafiają wszystkie nowe zależności. Jeżeli natomiast zmiana zostanie wykonana tylko w plikach źródłowych z kodem, wtedy podczas budowania obrazu, zamiast wykonania od nowa instrukcji RUN, zostanie użyty cache ⚡.

.dockerignore

Poza plikiem Dockerfile możemy stworzyć również plik .dockerignore. Jeżeli jesteś zaznajomiony/zaznajomiona z gitem, to pewnie wiesz, jaka będzie jego rola. To tutaj możemy określić które pliki nie powinny być kopiowane do obrazu pliku. W przypadku aplikacji tworzonych w Node.js będziemy chcieli uniknąć przesłania folderu node_modules.

.dockerignore
node_modules

Wszystkie zależności zainstalujemy sobie podczas uruchamiania aplikacji, dzięki temu obraz będzie dużo mniejszy. Tak przygotowany obraz możemy umieścić np. na naszym koncie DockerHub lub AWS ECR i udostępnić go szerszej publiczności.

Podsumowanie

Mam nadzieję, że po tym wpisie Docker i związane z nim tematy staną jeszcze bardziej zrozumiałe. Wiemy już teraz jak samodzielnie tworzyć obrazy i uruchamiać z nich kontenery. Zbudowane przez nas obrazy możemy następnie uruchamiać wszędzie tam, gdzie będzie zainstalowany Docker. Ten sposób uruchamiana aplikacji zapewnia, iż aplikacja będzie zachowywała się na każdym serwerze dokładnie tak samo, a stwierdzenie „dziwne, u mnie działa” przestanie już być wytłumaczeniem 😉

Masz uwagi lub sugestie do tego wpisu?

discord iconPrzejdź na Discord