Docker Compose dla początkujących
Dzień dobry!
Mam nadzieje, że jesteście zdrowi i chętni do nauki, bowiem ruszamy z kolejną częścią serii o konteneryzacji. Pierwszą możecie przeczytać tutaj gdzie wyjaśniam podstawy dockera :)
Oczywiście polecam mojego instagrama oraz staram się wrócić na Twittera ze śmieszną poezją developerską. Serio.
Segment reklamowy zakończony, więc kodujmy:
Zastanawiałam się jak ugryźć temat, żeby nie zasypać Was setką informacji i nie zrobić z tego wpisu książki. Szczególnie że ostatni wpis się udał. Inspiracja przyszła z pracy tak więc zaczynamy!
Dzisiejszy przypadek
Dzisiejszy use case będzie polegał na skomunikowaniu dwóch kontenerów. W jednym odpalimy aplikację nodejsową, a w drugim pobierzemy zwróconą informację z homepage'a :)
Jeden (yml) by rządzić wszystkimi
Skoro mamy dockera to możemy tworzyć dowolną ilość obrazów i kontenerów, więc po co nam dodatkowe narzędzie? Odpowiedź jest prosta: żeby było łatwiej.
Docker-compose to narzędzie, które pozwala nam na zarządzenie wieloma kontenerami włączając dzielenie zasobów. Wszystko dzieje się poprzez plik yml oraz proste komendy.
Dzisiejszy przykład w którym mamy dwa kontenery z pewnością nie byłoby dużym problemem, ale pomyślmy jaki chaos wprowadziłoby pięć czy siedem. Dlatego powstała usługa docker-compose.
Zacznijmy od instalacji dockera oraz docker-compose. Osobiście korzystam z snapów na Ubuntu:
sudo snap install docker
Można się też posiłkować dokumentacją dla apt-get tutaj, ale wymaga to trochę więcej pracy. Polecam snapy, więcej możecie przeczytać o nich w moim artykule na devenv.pl.
Zacznijmy od napisania pliku yml. Tradycyjnie nazywa się docker-compose.yml, ale nie musimy mieć tylko jednego, więc pewnie spotkacie docker-compose.tests.yml czy docker-compose.local.yml.
Konstrukcja pliku docker-compose.yml
Pierwsza linijka jest wersją formatu pliku konfiguracyjnego docker-compose.yml, która powinna się zgadzać z zainstalowaną wersją dockera (nie docker-compose).
Można to sprawdzić za pomocą poniższych komend:
docker-compose -v
// docker-compose version 1.25.5 <- nie chodzi o tę wersję
docker -v
// Docker version 19.03.11, build dd360c7 <- tak
Jak widzicie powyżej w chwili pisania tego posta mam wersję 19 i mogę korzystać z wersji pliku 3.8 lub mniejszej wg tabelki w dokumentacji.
Prawdę mówiąc zwykle to nie ma znaczenia, szczególnie w tak prostym tutorialu, ale powinniście mieć tego świadomość.
Następnie wylistujemy dwa serwisy, które - wyjątkowo - nazwiemy po polsku: aplikacja i szperacz, odpowiadające za dwa kontenery. Aplikacja będzie typowym serwisem webowym, a szperacz może być dosłownie wszystkim co powinno mieć dostęp do aplikacji (jakiś crawler, skrypt wykonujący testy e2e, czy jakiś health check).
Przykładowy plik YML:
version: "3.8"
services:
aplikacja:
tty: true
build:
context: .
dockerfile: ./Dockerfile.aplikacja
volumes:
- .:/usr/app/
- /usr/app/node_modules
szperacz:
tty: true
build:
context: .
dockerfile: ./Dockerfile.szperacz
depends_on:
- aplikacja
build
Sekcja odpowiedzialna za budowanie kontenera. Można ją opisać skrótowo umieszczając w niej sam kontekst (ścieżkę, której kontekst będzie dostępny w kontenerze) albo jako obiekt z ścieżką do kontekstu oraz ścieżkę do dockerfile'a.
ports
To sekcja w której możemy mapować i udostępniać porty z wewnątrz kontenera na zewnętrz w formacie: zewnętrzny_port:wewnętrzny_port np.: 5001:5002
. Jeśli chcemy zmapować ten sam port to możemy skorzystać z skrótu: 5000 (zamiast 5000:5000).
environment
Lista zmiennych środowiskowych (environment) jest odpowiedzialna za zmienne które z powłoki (np.: będziemy mogli je przekazać przy uruchomieniu) trafią do kontenera. Często wykorzystuje się je do przekazywania sekretów.
depends_on
Pozwala uruchamiać kontener w określonej kolejności. Jeśli serwis A jest zależy od serwisu B, to może kontener serwisu B powinien się uruchomić pierwszy? Zwrócę szczególną uwagę, że dotyczy to uruchamiania kontenerów, a nie aplikacji w kontenerze.
tty
Ten parametr odpowiada za wyświetlanie z którego kontenera pochodzi informacja w konsoli. Przydatne do nauki :)
Uruchamianie
Żeby nie przedobrzyć na początek musicie wiedzieć, że kontenery się tworzy i uruchamia. Tworzy się poprzez komendę docker-compose build
a uruchamia poprzez docker-compose up
. Nie wykańcza to tematu ale na razie to wystarczy.
Czego potrzebujemy aby zbudować naszą aplikację:
- docker-compose.yml - plik który pozwoli utworzyć i skomunikować dwa kontenery
- Dockerfile.aplikacja - plik odpowiadający za szczegóły kontenera naszej aplikacji
- Dockerfile.szperacz - plik odpowiadający za szczegóły naszego szperacza
- package.json - plik który skopiujemy do kontera, żeby prosto zainstalować zależności naszej aplikacji
- server.js - nasza wielce skomplikowana aplikacja z poprzedniego tutoriala
server.js
To prosty plik, który korzystając z frameworka express zwraca wiadomość Hello guys! Kamila is here
i słucha na portcie 8080.
// server.js
const express = require('express');
const app = express();
app.get('/', function (req, res) {
res.send('Hello guys! Kamila is here');
});
// 8080 to przykładowy port
app.listen(8080, function () {
console.log('Example app listening on port 8080!');
});
package.json
To prosty plik z jedną dependencją.
{
"name": "solutionchaser",
"version": "1.0.0",
"description": "Blog post",
"main": "server.js",
"author": "Kamila",
"license": "OhPlease",
"dependencies": {
"express": "^4.15.3"
}
}
Dockerfile
Dockerfile służy do zdefiniowania obrazu. Można zainstalować wszystko samemu, ale istnieją również gotowe rozwiązania. Możemy również używać gotowych rozwiązań i rozszerzyć je.
FROM
Komenda FROM
służy do rozszerzania obraz z innego. Działa to w prosty sposób: najpierw wykonuje się Dockerfile z polecenia FROM
. Obrazy mogą pochodzić z naszego repozytorium, z prywatnych plików lub - najczęściej - dockerhuba (taka biblioteka dockerowych obrazów).
WORKDIR
Zmienia katalog w którym pracuje/wykonujemy komendy/etc. To jest dosłownie work dir - katalog w którym pracujesz.
RUN
Odpala komendy podczas build
a, czyli tworzenia się kontenera (nazwanie tego instalacją to przesada, ale w czasie tworzenia kontenera zwykle instalujemy potrzebne rzeczy).
CMD
Odpala komendy podczas up
, czyli uruchamiania kontenera.
COPY
Kopiuje podany plik lub folder do katalogu w kontenerze. Składania jest prosta: pomiędzy jednym a drugim argumentem jest spacja. Na poniższym przykładzie mamy: skopiuj plik server.js z dostępnego kontekstu (context w docker-compose.yml) do katalogu w którym obecnie jestem w kontenerze.
COPY ./server.js .
Dockerfile.aplikacja
Podobnie jak w poprzednim tutorialu uruchomimy nasz serwer. Korzystamy z obrazu node:12-apline
, który zawiera bardzo mały system alpine i nodejs w wersji 12 :)
FROM node:12-alpine
WORKDIR /app
COPY ./server.js .
COPY ./package.json .
RUN npm i
CMD [ "node", "server.js"]
Potem zmieniamy katalog, kopiujemy dwa niezbędne pliki do głównego katalogu (wyznaczonego przez WORKDIR), instalujemy zależności, które utworzą node_modules, a potem odpalamy aplikację.
Dockerfile.szperacz
W tym Dockerfilu użyjemy obrazu nodejs z strechem, ponieważ zawiera curla, który potrafi odpytywać serwery HTTP. Nie jest to dobre wyjście, ale myślę, że ten tutorial na tym nie ucierpi.
Ciekawe jest to, że aby połączyć się do innego kontenera możemy użyć nazwy serwisu z docker-compose.yml.
FROM node:12-stretch
WORKDIR /app
CMD curl http://aplikacja:8080
docker-compose.yml
Oto nasz docker-compose.yml, gdzie można zobaczyć wszystkie opisane wcześniej rzeczy.
version: "3.8"
services:
aplikacja:
tty: true
build:
context: .
dockerfile: ./Dockerfile.aplikacja
szperacz:
tty: true
build:
context: .
dockerfile: ./Dockerfile.szperacz
depends_on:
- aplikacja
Wywołanie
Teraz wystarczy tylko:
sudo docker-compose build
sudo docker-compose up
Dlaczego z podwyższeniem uprawień? Na potrzeby tutoriala, taka jest natywna polityka dockera. Poprawnym wyjściem byłoby dodanie naszego użytkownika do grupy docker z odpowiednimi uprawieniami, ale nie chcę komplikować zbyt bardzo.
No i czekamy :)
aplikacja_1 | Example app listening on port 8080!
szperacz_1 | Hello guys! Kamila is here
dockersample_szperacz_1 exited with code 0
Co się stało?
Najpierw poprawnie zbudowały się oba kontenery, uruchomił serwis aplikacja, a potem szperacz. Aplikacja zwróciła informację o tym, że apka nodejs już działa, a potem szperacz zapytał naszą apkę o dane z głównego endpointa.
Na końcu szperacz się wyłączył (bowiem zakończył swoją pracę wykonując wszystkie komendy), a aplikacja wciąż działa (nieskończenie czeka na zapytania, bo komenda node server.js
nic nie zwróci).
Pełen sukces!
Warto jednak zauważyć, że przypadkiem jest, że serwer nodejs był gotowy gdy szperacz wywołał curl'a. Parametr depends_on
czeka na uruchomienie kontenera, a nie wszystkich wykonywanych w nim poleceń. Pozostaje więc pytanie: jak to bezpiecznie synchronizować? Zagadnienie opisuję kilka akapitów niżej :)
Problemy i ciekawe zagadnienia
Nie starczy mi czasu i miejsca - a Wam cierpliwości - żeby opisać wszystkie potencjalne problemy i rozwiązania. Nawet nie zakończyłam podstaw docker-compose i niestety w tym wpisie nie starczy mi czasu na pozostałe zagadnienia.
Kilka słów o debugowaniu: gdy coś nie działa, a informacja w konsoli nie wyjaśnia zbyt wiele warto dodać --verbose
do zapytania. Wtedy zobaczymy więcej szczegółów, na przykład: docker-compose --verbose up
.
Za mało miejsca
Prawdopodobnie podczas swojej nauki doświadczysz informacji o skończonym miejscu na dysku, chociaż twój komputer może mieć jeszcze pełnego miejsca. Chodzi tutaj konkretnie o miejsce przeznaczone na obrazy i kontenery
Docker ma ciekawą politykę, ponieważ nie usuwa sam nieużywanych kontenerów. Jeśli często robisz buildy (np.: przy nauce) to miejsce na dysku może się szybko skończyć. Jak temu zaradzić? Usunąć starocie :)
Ten błąd można wykryć komendą: docker system df
. Jeśli w wierszu Images
będzie bardzo duży rozmiar oraz wysoki procent pamięci do odzyskania (Reclaimable
) to w środowisku developerskim pomoże docker image prune -a
.
Zależność pomiędzy serwisami w kontenerach
Jak wspomniałam parametr depends_on
czeka na uruchomienie kontenera, a nie serwera nodejs. Szczególnie gdy aplikacja będzie większa to instalacja dependencji może chwilę czekać i curl
w drugim kontenerze odpali się przed uruchomieniem aplikacji. Zwróci wtedy błąd.
Jak to zrobić poprawnie? W poprzednich wersji dockera istniała opcja wbudowanego healthchecku podłączonego z depend_on, ale obecnie dokumentacja sugeruje ręczne sprawdzanie dostępności serwisu.
Bardzo popularnym rozwiązaniem jest skrypt wait-for-it.sh. Można go pobrać z repozytorium do projektu i skopiować do kontenera lub ściągnąć w kontenerze.
Potem zmienić uprawnienia i wywołać. W naszej aplikacji będzie to wyglądało tak:
RUN chmod +x ./wait-for-it.sh
CMD ["/bin/sh", "-c", "wait-for-it.sh aplikacja:8080 --timeout=120 --strict -- curl aplikacja:8080"]
W tej chwili wydaje się to absurdalne (bo trochę jest), ale curl jest tutaj tylko placeholderem.
Co się stało? Nadaliśmy plikowi pozwolenia na wykonywania, a potem wykonaliśmy skrypt wait-for-it.sh. Wykorzystaliśmy dwa parametry: timeout który mówi po ilu sekundach skrypt ma przestać czekać i strict
, który mówi o tym, że gdy serwis się nie odnajdzie to ma zwrócić błąd.
Podsumowanie
Mam nadzieje, że się podobało i z chęcią wybraliście się ze mną w dockerową podróż. Miałam z tego ogromną przyjemność i z radością wrócę do tematu (szczególnie zasobów; gdzie network
i volumes
?).
Jak zwykle zapraszam do komentowania, zostawiania reakcji i komentarzy, jeśli gdzieś źle się wyraziłam lub popełniłam błąd!