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 builda, 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!

Kamila

Dziękuję za poświęcony czas, będzie mi bardzo miło jak zostawisz komentarz :)