Wstęp

W pracy każdego programisty przychodzi kiedyś moment, kiedy okazuje się, że sporo czasu poświęcane jest nie na programowanie, a konfigurację środowiska. Zwłaszcza w dużych projektach i zespołach istotne jest to, aby wszyscy pracowali w jednolitym środowisku i nie było sytuacji, że u kogoś po prostu coś "nie działa".

Rozwiązywanie takich problemów bywa czasochłonne, zwłaszcza gdy przyczyna problemów jest trudna do zidentyfikowania. Samo rozwiązanie może być trywialne, ale czas na jego znalezienie jest często czasem bezpowrotnie straconym. Jak sobie z tym poradzić i zminimalizować ryzyko wystąpienia takich nieprzewidzianych sytuacji, które blokują nam możliwość faktycznej pracy i rozwijania projektu? Z pomocą przychodzi nam Docker.

Czym jest Docker?

Docker jest narzędziem pozwalającym na tworzenie kontenerów, które zawierają wszystkie niezbędne dla naszej aplikacji zależności i biblioteki. Dzięki temu mamy pewność, że nasza aplikacja będzie zachowywać się tak samo niezależnie od środowiska, w którym pracujemy. Jeżeli nasuwają się wam skojarzenia z maszyną wirtualną, to bardzo dobrze. Jednak Docker jest czymś więcej, a tak naprawdę mniej :) Zamiast uruchamiania całej ogromnej maszyny wirtualnej, uruchamiamy jedynie kontenery bazujące na np. na Alpine Linux - bardzo niewielkiej dystrybucji, która zapewnia niewielkie obciążenie i małe rozmiary obrazów.

Kontenery są od siebie całkowicie niezależne - każdy ma przydzieloną swoją pamięć, swoje miejsce na dysku na obraz oraz własny interfejs sieciowy. Dzięki temu działanie jednego kontenera nie wpływa na inne. Oczywiście kontenery mogą się ze sobą komunikować na różne sposoby. W efekcie otrzymujemy rozwiązanie, które pozwala na rozdzielenie poszczególnych obszarów naszej aplikacji i ich niezależne skalowanie. Docker jest więc potężnym narzędziem nie tylko do lokalnego developmentu, ale przede wszystkim do tworzenia złożonych systemów oraz łatwego i szybkiego dostarczania oprogramowania na środowiska produkcyjne. W tym artykule skupimy się jednak na korzyściach płynących dla programistów.

Czego nauczymy się w tym artykule?

Głównym celem jest pokazanie, jak Docker może pomóc w stworzeniu lokalnego środowiska developerskiego dla aplikacji wykorzystującej NodeJS oraz MongoDB. Stworzymy dwa kontenery - jeden dla Node'a, a drugi dla bazy danych - oraz umożliwimy komunikację między nimi. Dzięki temu, będziemy mieli możliwość połączenia się z bazą oraz zapisywania i odczytywania z niej danych bez konieczności instalowania Mongo w naszym systemie.

Jakie ma to korzyści? Przede wszystkim będziemy mogli uruchomić naszą aplikację na wybranej przez nas wersji Node'a, która może być inna od tej, zainstalowanej przez nas w systemie. To samo tyczy się Mongo - będziemy pracować na wybranej przez nas wersji, a ponadto pliki bazy danych umieścimy w wybranym przez nas katalogu. Dzięki temu nasz kontener z bazą danych będzie zawierać wyłącznie kolekcje powiązane z naszą aplikacją. Pozwoli to nam uniknąć zagrożeń wynikających ze współdzielenia jednego serwera bazodanowego przez kilka aplikacji - jak choćby przypadkowego usunięcia danych używanych w innym projekcie. Zaczynamy!

Krok 1 - instalacja Dockera

Pobieramy aplikację Docker Desktop dla Windowsa lub Maca albo Dockera dla Linuxa. Wpisujemy docker w terminalu, aby potwierdzić, że instalacja przebiegła pomyślnie. Powinniśmy otrzymać informację podobną do tej:

$ docker

Usage:	docker [OPTIONS] COMMAND

A self-sufficient runtime for containers

Options:
      --config string      Location of client config files (default "/Users/Wojtek/.docker")
  -D, --debug              Enable debug mode
  -H, --host list          Daemon socket(s) to connect to
  -l, --log-level string   Set the logging level ("debug"|"info"|"warn"|"error"|"fatal") (default "info")
      --tls                Use TLS; implied by --tlsverify
      --tlscacert string   Trust certs signed only by this CA (default "/Users/user/.docker/ca.pem")
      --tlscert string     Path to TLS certificate file (default "/Users/user/.docker/cert.pem")
      --tlskey string      Path to TLS key file (default "/Users/user/.docker/key.pem")
      --tlsverify          Use TLS and verify the remote
  -v, --version            Print version information and quit

...

Krok 2 - przygotowanie plików aplikacji

Tworzymy nowy katalog, a w nim plik package.json o następującej treści:

{
  "name": "rossmann-docker",
  "version": "1.0.0",
  "license": "UNLICENSED",
  "scripts": {
    "start": "nodemon src/app.js",
    "test": "exit 0"
  },
  "dependencies": {
    "express": "^4.16.4",
    "mongoose": "^5.6.4"
  },
  "devDependencies": {
    "eslint": "^5.15.0",
    "nodemon": "^1.19.1",
    "prettier": "^1.16.4",
    "prettier-eslint": "^8.8.2"
  }
}

Tworzymy też plik src/app.js, w którym umieszczamy:

const express = require('express');
const app = express();
const port = 3000;

app.get('/', (req, res) => res.send('Hello World!'));

app.listen(port, () => console.log(`Example app listening on port ${port}!`));

Na razie wszystko wygląda jak w klasycznym projekcie - mamy plik z naszymi zależnościami oraz główny plik naszej aplikacji, gdzie dzieje się cała magia. Pora, aby do akcji wkroczył Docker!

Krok 3 - Dockerfile

W katalogu naszej aplikacji tworzymy plik o nazwie Dockerfile. Zawiera on zestaw instrukcji dla Dockera dotyczących budowania naszego obrazu. A czym jest obraz?

Najprościej rzecz ujmując, obraz zawiera wszystkie niezbędne dane do uruchomienia aplikacji. Znajduje się w nim kod aplikacji, biblioteki, pliki konfiguracyjne, zmienne środowiskowe i środowisko uruchomieniowe. Mając zatem obraz, możemy uruchomić na jego podstawie kontener. Jest to podstawowa informacja - aby móc uruchomić aplikację z wykorzystaniem Dockera, musimy posiadać jej obraz. Kontener jest uruchomioną, działającą instancją obrazu.

Docker posiada bardzo obszerne repozytorium gotowych obrazów - Docker Hub - które możemy wykorzystać po prostu do tworzenia naszych kontenerów lub jako bazę do stworzenia własnego obrazu. Przykładem obrazu, który posłuży nam jako baza dla obrazu naszej aplikacji jest oficjalny obraz Node'a.

W pliku Dockerfile umieszczamy:

FROM node:10.15.3-alpine

RUN mkdir -p /app

WORKDIR /app

COPY package*.json ./

RUN npm install

ADD . .

EXPOSE 3000

CMD ["npm", "start"]

Każda linijka zawiera instrukcję, która mówi o tym, co należy zrobić, aby zbudować gotowy obraz.

  1. FROM node:10.15.3-alpine - rozpoczyna proces budowania obrazu i mówi o tym, jakiego obrazu bazowego użyjemy. W tym przypadku jest to Node w wersji 10.15.3 wykorzystujący Alpine Linux. Mając do dyspozycji ten obraz, bo jego zbudowaniu i uruchomieniu kontenera, będziemy mieli dostępną komendę node oraz npm. Po prostu. Bez żdanej dodatkowej konfiguracji, ustawiania ścieżek itp.

  2. RUN mkdir -p /app - instrukcja RUN uruchamia wybraną przez nas komendę. W tym przypadku jest to stworzenie katalogu /app, w którym będą znajdowały się pliki naszej aplikacji. Dockerfile może zawierać tyle instrukcji RUN, ile tylko chcemy. Może to być nie tylko stworzenie katalogu, ale również instalacja zależności - do czego za chwilę dojdziemy.

  3. WORKDIR /app - ustawiamy nasz katalog roboczy na /app, w którym to będziemy wykonywać dalsze komendy.

  4. COPY package*.json ./ - kopiujemy pliki package.json oraz package-lock.json do katalogu, w którym się znajdujemy (/app). Zwróćmy uwagę, na gwiazdkę, dzięki której wszystkie pliki pasujące do wyrażenia package*.json zostaną skopiowane do naszego obrazu. Składnia instrukcji jest następująca COPY [lokalna ścieżka] [ścieżka wewnątrz obrazu]. W tym przypadku skopiujemy pliki do katalogu /app wewnątrz obrazu, który jest ustawiony jako nasz katalog roboczy.

  5. RUN npm install - kolejnym krokiem uruchomienie komendy npm install, która zainstaluje nam wszystkie zależności określone w pliku package.json. Są one potrzebne do działania naszej aplikacji. Działanie npm install jest identyczne jak w przypadku uruchomienia tej komendy na naszym komputerze w terminalu - z tą różnicą, że katalog node_modules wraz z zawartością znajdzie się wewnątrz obrazu, a nie w katalogu na naszym komputerze.

  6. ADD . . - składnia instrukcji jest identyczna jak w przypadku COPY. Jej działanie jest również bardzo podobne, ale istnieje kilka różnic między tymi instrukcjami, z którymi można się zapoznać w oficjalnej dokumentacji Dockera. Na razie nie musimy się w owe różnice zagłębiać, jednak dociekliwi mogą się z nimi zapoznać. Zapis . . powoduje, że cała zawartość katalogu, w którym znajduje się Dockerfile zostanie umieszczona wewnątrz obrazu, jednak - jeśli chcemy - możemy dodać jedynie poszczególne katalogi.

  7. EXPOSE 3000 - ta instrukcja mówi nam, na którym porcie ma nasłuchiwać po uruchomieniu nasz kontener.

  8. CMD ["npm", "start"] - ostatnia instrukcja zawiera informację o tym, jaka komenda ma zostać uruchomiona wraz ze startem kontenera. W naszym przypadku jest to npm start, która została zdefiniowana również w pliku package.json. Równie dobrze moglibyśmy napisać CMD node src/app.js lub CMD ["node", "src/app.js"].

Krok 4 - budowanie obrazu i uruchomienie kontenera

Zanim uruchomimy naszą aplikację w kontenerze, musimy zbudować jej obraz. W naszym katalogu głównym, gdzie znajduje się Dockerfile wykonujemy polecenie:

docker build . -t rossmann

W ten sposób mówimy Dockerowi, aby w bieżącym katalogu (.) zbudował obraz na podstawie obecnego tam Dockerfile i otagował go jako rossmann. Dzięki temu łatwo będzie nam odwołać się do naszego obrazu.

Teraz wystarczy uruchomić:

docker run -p 3000:3000 rossmann

I wpisać w przeglądarce adres http://localhost:3000. Powinniśmy zobaczyć napis Hello World!. Opcja -p 3000:3000 spowoduje, że port 3000 kontenera zostanie zmapowany do portu 3000 na komputerze hosta. Spróbujmy skorzystać jeszcze z jednej ciekawej i szczególnie przydatnej developerom opcji, jaką jest montowanie wolumenów. Uruchamiamy:

docker run -p 3000:3000 -v /workspace/rossmann:/app rossmann

Opcja -v służy właśnie do montowania wolumenów. Składnia jest następująca [sciezka na komputrze hosta]:[sciezka wewnątrz kontenera]. W tym wypadku montujemy nasz główny katalog aplikacji do katalogu /app, w którym i tak znajduje się już nasza aplikacja. Co nam to daje? Otóż dzięki temu zabiegowi oraz wykorzystaniu paczki nodemon (zobacz plik package.json) możemy dynamicznie reagować na zmiany naszego kodu bez konieczności ponownego budowania obrazu i restartowania kontenera. W pliku app.js zmieńmy tekst Hello World! na Hello Node!, a następnie odświeżmy otwartą stronę w przeglądarce. Wprowadzona w kodzie zmiana będzie widoczna od razu.

Krok 5 - docker-compose

Wiemy już jak zbudować obraz i uruchomić kontener. Teraz dołożymy do naszej aplikacji kolejny kontener z serwerem bazy danych.

Tworzymy plik docker-compose.yml o następującej treści:

version: "3"
services:
  rossmann:
    container_name: rossmann
    image: rossmann:latest
    restart: always
    build: .
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    depends_on:
      - mongo
  mongo:
    container_name: mongo
    image: mongo
    volumes:
      - ./data/db:/data/db
    ports:
      - "27017:27017"

Definiujemy w nim dwa serwisy: rossmann oraz mongo. rossmann jest serwisem wykorzystującym nasz obraz. Definiujemy w nim nazwę kontenera, jakiej chcemy używać, wersję obrazu, autorestart na wypadek błędu i kontekst budowania. Lista ports zawiera mapowania portów - w tym wypadku, podobnie jak przy docker run, mapujemy port 3000 hosta do portu 3000 kontenera. Montujemy również wolumeny. Zapis .:/app mówi nam o tym, że chcemy zamontować bieżący katalog w katalogu /app, aby uzyskać opisaną wyżej sytuację - automatyczny restart aplikacji z wykorzystaniem nodemon. Natomiast /app/node_modules powoduje, że katalog node_modules z naszego komputera nie zostanie zamontowany w kontenerze. Dlaczego jest to takie ważne?

Pomimo wykorzystania Dockera, możemy chcieć uruchomić komendę npm install na naszym komputerze i mieć katalog node_modules ze wszystkimi zależnościami. Niektóre IDE właśnie dzięki obecności katalogu node_modules są w stanie podpowiadać nam kod, dlatego warto mieć ten katalog. Ale przecież wersja Node'a zainstalowana na naszym komputerze może być inna od tej użytej w kontenerze. Możemy korzystać również z całkiem innego systemu, niż nasze kontenery, a wtedy niektóre paczki po prostu nie będą nam działać. Z pomocą przychodzi utworzenie wolumenu /app/node_modules, w którym znajdować się będą zależności właściwe dla naszego kontenera - te zainstalowane z użyciem komendy npm install w pliku Dockerfile.

Ostatnią rzeczą definiowaną dla serwisu rossmann są kontenery, od których zależy kontener rossmann. Dzięki temu mamy pewność, że przy odpalaniu kontenera rossmann, kontener mongo będzie już czekać w gotowości - czyli nasz serwer bazy danych będzie gotów na obsłużenie połączenia.

Dużo prostsza jest kwestia kontenera mongo, w którym definiujemy jego nazwę, obraz, który chcemy wykorzystać oraz wolumeny i porty. Zwróćmy uwagę, że jako obraz podajemy jedynie nazwę: image: mongo. W tej sytuacji Docker pobiera odpowiedni obraz z repozytorium - obraz mongo. Możemy też określić wersję obrazu, z której chcemy skorzystać, np. image: mongo:3.4-xenial.

Mongo domyślnie trzyma pliki baz danych w lokalizacji /data/db, jednak jest to lokalizacja wewnątrz kontenera. Jeżeli chcemy mieć łatwy dostęp do plików bazy i mieć pewność, że przez przypadek ich nie usuniemy (np. podczas usuwania kontenera wraz z wolumenami), warto zmapować katalog w naszym systemie, w którym znajdą się owe dane. W tym przypadku jest to katalog data/db, który znajduje się w katalogu naszej aplikacji, lecz wybrać możemy dowolny katalog na naszym dysku - wystarczy podać do niego ścieżkę. Pamiętajmy tylko, że jeżeli zdecydujemy się na opisane tutaj rozwiązanie, należy dodać katalog data do .gitignore - nie potrzebujemy przecież plików bazy w repozytorium.

Pora uruchomić nasze kontenery! Robimy to za pomocą polecenia:

docker-compose up

Polecenie docker-compose tworzy nasze kontenery zgodnie z instrukcjami zawartymi w pliku docker-compose.yml.

Mamy więc już działający kontener naszej aplikacji oraz serwera baz danych. Pora, aby się ze sobą dogadały!

Krok 6 - Łączenie się z bazą danych

Do obsługi połączenia z bazą danych i operacji wykorzystamy paczkę Mongoose. Zmieńmy zawartość pliku app.js na poniższą:

const express = require('express');
const path = require('path');
const mongoose = require('mongoose');

const app = express();
const port = 3000;
app.use(express.urlencoded());

mongoose.connect('mongodb://mongo/rossmann', { useNewUrlParser: true });

const userSchema = new mongoose.Schema({
	name: String,
	password: String,
	email: String
});

const User = mongoose.model('User', userSchema);

app.get('/', (req, res) => res.send('Hello World!'));

app.get('/add', (req, res) => res.sendFile(path.join(__dirname + '/add.html')));

app.post('/add', async (req, res) => {
	const { name, password, email } = req.body;
	await User.create({ name, password, email });
	res.redirect('/add');
});

app.get('/users', async (req, res) => {
	const users = await User.find({});

	const content = users.reduce((render, user) => {
		render += `<tr><td>${user.name}</td><td>${user.password}</td><td>${user.email}</td></tr>`;
		return render;
	}, '');

	const table = `<table border="1">${content}</table>`;
	res.send(table);
});

app.listen(port, () => console.log(`Example app listening on port ${port}!`));

Oraz stwórzmy plik add.html zawierający prosty formularz dodawania użytkowników:

<html>
	<body>
		<form action="/add" method="post">
			<label>Name: </label> <input type="text" name="name" /><br />
			<label>Password: </label> <input type="password" name="password" /><br />
			<label>Email: </label> <input type="email" name="email" /><br />
			<button type="submit">Save</button>
		</form>
	</body>
</html>

Jak widać kod jest do bólu prosty i nie zawiera żadnych skomplikowanych konstrukcji. Zwróćmy jednak uwagę na linię w której mamy kod odpowiedzialny za łączenie się z bazą danych:

mongoose.connect('mongodb://mongo/rossmann', { useNewUrlParser: true });

Adresem naszego serwera baz danych jest nazwa kontenera określona w pliku docker-compose.yml, czyli w tym przypadku mongo. Pamiętajmy, że przed uruchomieniem aplikacji musimy sami stworzyć bazę rossmann, aby móc się z nią połączyć. Wystarczy nam do tego aplikacja Robo 3T lub MongoDB Compass. Łącząc się z serwerem z poziomu aplikacji na naszym komputerze jako adres podajemy localhost i port 27017, ponieważ takie mapowanie portów określiliśmy w pliku docker-compose.yml.

Spróbujmy więc przejść na stronę http://localhost:300/add. Naszym oczom powinien ukazać się prosty formularz dodawania użytkowników. Po jego wysłaniu możemy przejść pod adres http://localhost:300/users, gdzie znajdziemy tabelkę z listą rekordów.

Podsumowanie

W 6 krokach udało nam się stworzyć działające środowisko developerskie. Jak widać wystarczyło nam stworzenie dwóch plików: Dockerfiledocker-compose.yml. Kolejnym krokiem w kierunku lepszego poznania Dockera i jego możliwości może być na przykład stworzenie prostego systemu uploadu plików i taka konfiguracja, aby pliki były zapisywane nie w katalogu wewnątrz kontenera, ale na dysku hosta. Możemy też spróbować dołożyć kolejne kontenery i serwisy - na przykład Redisa czy nginxa.

Docker to jednak nie tylko narzędzie ułatwiające development. Nasze gotowe obrazy możemy publikować i współdzielić z innymi, tak jak miało to miejsce w przypadku mongo, które wykorzystaliśmy. W końcu możemy uruchamiać obrazy naszych aplikacji na serwerze - bez obaw, że wystąpią jakieś błędy związane z niezgodnością środowisk.

Zachęcam do samodzielnego poznawania Dockera i jego możliwości. Są one naprawdę przeogromne, a dokumentacja bogata w przykłady. Poniżej znajdziecie listę kilku przydatnych komend, które z pewnością będą wykorzystywane podczas pracy.

  1. docker logs [nazwa kontenera] - wyświetla logi z danego kontenera
  2. docker attach [nazwa kontenera] - podłącza standardowe wejście/wyjście do wybranego kontenera
  3. docker container ls - wyświetla listę kontenerów
  4. docker image ls - wyświetla listę obrazów
  5. docker-compose up -d --build - buduje obrazy przed uruchomieniem kontenerów
  6. docker-compose down - wyłącza kontenery

Informacje dodatkowe

Kod opisanego rozwiązania dostępny jest w repozytorium pod adresem: https://github.com/RossmannTech/docker-node-mongo

Wiele plików docker-compose

Warto wspomnieć, że plików konfiguracyjnych możemy mieć wiele - na przykład dla każdego środowiska: lokalnego, stagingowego i produkcyjnego inny, zawierający nawet inny zestaw kontenerów. Domyślnie wykorzystywany jest właśnie plik docker-compose.yml, ale możemy utworzyć plik docker-compose.dev.yml i użyć właśnie jego:

docker-compose -f docker-compose.dev.yml up

Detached mode

Do komendy up możemy dołożyć opcję -d, która sprawi, że kontenery zostaną uruchomione w tle - nie zobaczymy ich outputu w konsoli.

Aby zatrzymać nasze kontenery używamy polecenia docker-compose down.

Aby zobaczyć, jaki jest stan naszych kontenerów wpisujemy docker ps. Powinniśmy otrzymać podobny wynik:

CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                      NAMES
6dce93252d97        rossmann:latest     "npm start"              39 minutes ago      Up 39 minutes       0.0.0.0:3000->3000/tcp     rossmann
97bfb015b975        mongo               "docker-entrypoint.s…"   39 minutes ago      Up 39 minutes       0.0.0.0:27017->27017/tcp   mongo

Warto tutaj wspomnieć o tym, że możemy restartować i wyłączać pojedyncze kontenery z wykorzystaniem poleceń docker stop [NAZWA KONTENERA] oraz docker restart [NAZWA KONTENERA]. Analogicznie możemy włączyć nieaktywny kontener: docker start [NAZWA KONTENERA].