Jeżeli Twoje środowisko dockerowe składa się z więcej niż jednego kontenera to zapoznanie się z docker-compose znacznie ułatwi Ci życie. Zresztą nawet przy jednym, no dobrze – dwóch kontenerach, okazuje się on być bardzo pomocnym narzędziem. To, co przy korzystaniu z „gołego” dockera może sprawiać pewną trudność, czyli mnogość opcji i parametrów podawanych przy uruchamianiu kontenerów w bardziej zaawansowanych (niż „hello world”) implementacjach, jest w dość przejrzysty i wygodny sposób znacząco uproszczone przez właśnie docker-compose. Dzięki temu narzędziu możemy zarządzać naszym środowiskiem za pomocą zdefiniowanych nazw/aliasów bez konieczności pamiętania (i ciągłego wpisywania) wszystkich szczegółów implementacji. Oczywiście nie jest to nic czego nie moglibyśmy dokonać również za pomocą sprytnych skryptów powłoki, jednak yaml (ten format pliku konfiguracyjnego wykorzystywany jest przez docker-compose) wydaje się być bardziej… elegancki, a elegancja – ważna rzecz.
Jak możemy przeczytać na stronie poświęconej docker-compose, jest to narzędzie do konfigurowania i zarządzania wielokontenerowymi środowiskami. „Konfiguracja” to wszelkie parametry polecenia docker, które normalnie musielibyśmy wpisywać w linii poleceń – tutaj zapisujemy je w pliku konfiguracyjnym. Po przygotowaniu już takiego pliku, praca z naszym środowiskiem (uruchamianie / restartowanie / zatrzymywanie kontenerów) ogranicza się do podania odpowiedniej komendy i nazwy kontenera – wszystkie parametry dodatkowe odczytywane są już z przygotowanego pliku, tym sposobem polecenia (wpisuję z głowy):
$ docker build –t rummager-service ./images/service $ docker run –name rum-rumsrv rummager-service \ -v ${SRC_RUMSRV}:/project/rumsrv \ -p ${LOCALHOST}:80:80/tcp \ --network rum-net –ip ${NETWORK_ADDRESS}.${HOST_IP_RUMSRV} \ –network-alias rumsrv.${DOMAIN_NAME} \ –network-alias rumsrv.local
(pomijam kwestię inicjalizowania wykorzystywanych zmiennych – SRC_RUMSRV, LOCALHOST, DOMAIN_NAME, itd., oraz stworzenia sieci „rum-net”).
Zastępujemy jednym poleceniem (oczywiście musi istnieć odpowiedni plik konfiguracyjny docker-compose.yml):
$ docker-compose up –d rum-rumsrv
Powyższa komenda odczyta zmienne z pliku „.env” oraz stworzy sieć „rum-net”, jeżeli ta jeszcze nie istnieje (wg konfiguracji zapisanej w pliku docker-compose.yml) - czyż to nie jest wygodniejsze?
Należy tutaj zaznaczyć jedną rzecz – docker-compose w żaden sposób nie zastępuje plików Dockerfile! Pliki te nadal pozostają głównym „narzędziem” do budowania „obrazów” (images). Docker-compose pozwala usprawnić zarządzanie, organizację pracy z samymi kontenerami (budowanymi na bazie obrazów) – czyli przychodzi z pomocą w miejscu, które jest już poza sferą wpływów plików Dockerfile (więcej szczegółów o samym dockerze można znaleźć w moim wcześniejszym artykule)
Przejdźmy do konkretów - pisząc o docker-compose wykorzystam jako przykład swój własny projekt – rummager, a raczej przygotowane dla niego środowisko deweloperskie. Przy czym nie będę się tutaj wdawał w szczegóły techniczne składni i poleceń samego pliku, chciałbym jedynie omówić „nakreśloną” w nim konfigurację – co zostało w nim zawarte i dlaczego.
Zaczynamy
Środowisko deweloperskie - artykuł ten opisuje to środowisko w wersji 1.2
Zmienne wykorzystane w konfiguracji całego środowiska zostały zapisane w pliku env-local, zmiany w samym pliku docker-compose.yml nie powinny być konieczne - jeżeli są, dajcie znać, bo to oznacza, że coś trzeba ewidentnie poprawić :) Docker-compose będzie automatycznie "szukał" pliku .env w katalogu, w którym znajduje się plik docker-compose.yml, a w tym przypadku .env jest dowiązaniem symbolicznym do pliku env-local (tylko dlatego, że pliki rozpoczynające się od kropki w systemach uniksowych/GNU to tzw. pliki ukryte, więc sam plik .env nie zawsze będzie widoczny). Odwołania do zmiennych w pliku docker-compose.yml (czyli nazwy rozpoczynające się od znaku $) dotyczą więc zmiennych zdefiniowanych w pliku env-local, czyli w zapisie:
- mysql.${DOMAIN_NAME}
w miejsce ${DOMAIN_NAME} docker-compose podstawia wartość DOMAIN_NAME z pliku env-local.
W przypadku aplikacji rummager plik docker-compose.yml składa się z dwóch sekcji – networks i services (możliwe jest jeszcze zdefiniowanie sekcji „volumes”, ale tutaj nie jest wykorzystywana). Spójrzmy teraz na same sekcje.
Sieć (sekcja: networks)
Konfiguracja projektu zakłada wykorzystanie sieci klasy C, której trzy pierwsze bajty określone zostały w pliku konfiguracyjnym env-local - tutaj: 172.29.4. W pliku docker-compose.yml podane zostały już tylko odpowiednie „uzupełnienia” (czwarty bajt, ewentualnie maska sieciowa w formacie CIDR) trzech wartości związanych z siecią:
- subnet - podsieć, tutaj: ${NETWORK_ADDRESS}.0/24, czyli w przypadku domyślnej wartości z env-local: NETWORK_ADDRESS=172.29.4, wykorzystana sieć to: 172.29.4.0/24
- gateway - adres bramy sieciowej.
- ip_range - to ustawienie określa pulę adresów do dynamicznego wykorzystania. Z tej puli pobierane są adresy dla wszystkich kontenerów przypisanych do danej sieci, którym nie został przydzielony adresu statyczny. W tym przypadku zakres ten jest określony jako: ${NETWORK_ADDRESS}.128/25 czyli dynamiczne adresy są przydzielane z zakresu od 172.29.4.128 do 172.29.4.254.
Kontenery (sekcja: services)
Całe środowisko składa się z kilku kontenerów, których krótki opis zamieszczam poniżej.
rum-mysql
Baza danych – kontener z serwerem MySQL bazujący na najnowszym, oficjalnym obrazie MySQL w wersji 5.x dostępnym w serwisie dockerhub.com. Kontener można zbudować i uruchomić poleceniem:
$ docker-compose up –d rum-mysql
Gdzie „rum-mysql” to nazwa kontenera zdefiniowana w pliku konfiguracyjnym, skąd odczytywane są parametry startowe tego kontenera. Należy pamiętać, iż używając docker-compose musimy znajdować się w tym samym katalogu co plik docker-compose.yml z którym pracujemy (o ile nie podajemy jawnie ścieżki do tego pliku). I jeszcze jedna istotna informacja – jeżeli sieć zdefiniowana w pliku konfiguracyjnym nie istnieje jeszcze w systemie hosta, to zostanie ona automatycznie utworzona wraz ze startem pierwszego kontenera.
W przypadku tego konkretnego środowiska dane serwera MySQL przechowywane są w systemie plików (warstwie) kontenera - to nie jest jedyne możliwe rozwiązanie, nie jest to w sumie nawet rozwiązanie zalecane! Bardziej „eleganckie” wydaje się umieszczenie danych poza kontenerem, co pozwoli nam na ich zachowanie po usunięciu kontenera (dokumentacja Dockera zaleca „bezstanowość” kontenerów). Tutaj jednak dane nie są specjalnie istotne i środowisko developerskie „startuje” zawsze z „czystą” bazą danych. Ponieważ więc nie odtwarzamy stanu bazy a jedynie jej strukturę, dlatego w opisywanej wersji środowiska postawiłem na prostsze rozwiązanie, bez wykorzystania zewnętrznej (względem kontenera) lokalizacji plików z danymi.
Spójrzmy na pełną konfigurację kontenera rum-mysql:
rum-mysql: image: mysql:5 container_name: rum-mysql networks: rum-net: aliases: - mysql.${DOMAIN_NAME} ipv4_address: ${NETWORK_ADDRESS}.${HOST_IP_MYSQL} ports: - "${LOCALHOST}:3306:3306/tcp" environment: MYSQL_ROOT_PASSWORD: root
Wartość parametru „image” już omówiliśmy, dodam tylko, że w przypadku pozostałych kontenerów (zdefiniowanych w ramach tego środowiska) ten parametr wykorzystywany jest w nieco inny sposób. Nie wskazuje on pliku „obrazu” na którym budujemy nasz kontener (i który musi zostać pobrany ze zdalnego repozytorium, domyślnie: dockerhub.com), a nazwę pod jaką zbudowany lokalnie obraz ma zostać zapisany w lokalnym repozytorium obrazów. Dzieje się tak dlatego, że we wszystkich pozostałych przypadkach podany jest również parametr „build” (wskazujący kontekst obrazu dockerowego, czyli katalog w którym powinien znajdować się plik Dockerfile). Czyli w zależności od tego czy parametr „build” zostanie podany czy nie, parametr „image” ma inne znaczenie.
„Environment” to zmienne ustawiane w sesji kontenera, MYSQL_ROOT_PASSWORD ustawia hasło użytkownika root (w sensie administratora serwera MySQL) – te zmienne, ich nazwy oraz znaczenie zależą oczywiście od samej aplikacji (ich opis można przeważnie znaleźć na stronie danego obrazu w serwisie dockerhub.org, w przypadku MySQL zobacz na tej stronie).
„Ports” - znaczenie tego parametru jest identyczne z opcją „-p” polecenia docker (choć możemy podać tutaj wiele mapowań jako kolejne elementy listy). Wybór nazwy wykorzystanej tu zmiennej - „LOCALHOST”, nie jest chyba zbyt trafny. Tak naprawdę możemy wykorzystywać nie tylko adresy z zakresu 127.0.0.0/8 – również adresy innych interfejsów, zdefiniowanych w systemie hosta.
„Networks” – konfiguracja sieci może ograniczyć się do podania jedynie nazwy jednej ze zdefiniowanych sieci (tutaj „run-net”), tak jak wygląda to w definicji kontenera „rum-worker”, bądź może być, jak widać, również bardziej rozbudowana. Ogólnie docker-compose pozwala nam na wykorzystanie wszystkich funkcji polecenia „docker create”, niekiedy, jak w przypadku konfiguracji sieci, organizując jednak parametry w większe struktury – szczegółów należy szukać w dokumentacji. Co warte zaznaczenia tutaj – tzw. „user defined networks” (jak w tym przypadku) wykorzystują Internal DNS (zdefiniowany i funkcjonujący w ramach aplikacji docker). Zarówno nazwy kontenerów (jak np. „rum-mysql”) oraz aliasy zdefiniowane w danej sieci (w tym przypadku mysql.${DOMAIN_NAME}) będą rozwiązywane przez Internas DNS i będą widoczne dla wszystkich kontenerów w obrębie tej sieci. Czyli do serwera MySQL możemy odwoływać się (z innych kontenerów zdefiniowanych w sieci „rum-net”) korzystając z jednej z tych dwóch nazw. Można to wykorzystywać oczywiście szerzej – również z poziomy systemu hosta możemy wykorzystywać Internal DNS systemu docker, jednak należy wtedy zatroszczyć się o dodatkową konfigurację (zarówno po stronie hosta jak i dockera, w tym drugim przypadku można np. wykorzystać kontener z dnsmasq) – aktualnie, omawiane środowisko, nie posiada takiej konfiguracji, choć nie wykluczam jej w przyszłości.
Jeżeli w strukturze konfiguracji sieci nie zostanie określony adres statyczny kontenera (ipv4_address) to będzie on przypisany dynamicznie z puli „ip_range” danej sieci.
rum-rumsrv
Kontener zawierający serwis wykorzystywany przez procesy "workerów" (zobacz kontener: rum-worker). Zawiera serwer HTTP z interpreterem php, kod samego serwisu (który wcześniej powinien zostać sklonowany do katalogu określonego w env-local przez SRC_RUMSRV) jest montowany wewnątrz kontenera w /project/rumsrv (zobatrz też plik README.md)
rum-smtp
To kontener wykorzystywany tylko na potrzeby testów - symuluje działanie serwerów SMTP "odpytywanych" przez aplikację. Wszystkie połączenia workerów z serwerami SMTP są przekierowywane na ten kontener (poprzez wpis w iptables kontenera rum-worker). Reguły iptables kontenera rum-smtp losowo realizują jeden z trzech scenariuszy połączenia:
- połączenie jest odrzucone z komunikatem REJECT
- połączenie jest odrzucone niejawnie (DROP)
- połączenie jest dopuszczone i rozpoczyna się zwykła sesja SMTP
rum-worker
Kontener w którym uruchamiana jest aplikacja rummager - czyli proces zarządzający wątkami workerów (każdy worker uruchamiany jest w oddzielnym wątku). Proces rummager jest uruchamiany automatycznie po starcie kontenera poprzez skrypt "start.sh" na który wskazuje polecenie CMD w pliku Dockerfile
CMD["/bin/bash","-c","/usr/local/bin/start.sh"]
rum-tech
Kontener techniczny nie realizujący żadnej funkcji w samym projekcie, ale zawierający oprogramowanie wymagane do zbudowania bądź przygotowania komponentów projektu, takich jak:
- baza danych - skrypt bin/createdb.sh korzysta z tego kontenera przy odtwarzaniu bazy danych
- instalacja zależności rum-rumsrv - composer (sam composer nie jest instalowany automatycznie w tym kontenerze, w tej wersji środowiska, jednak jeżeli użytkownik nie posiada zainstalowanego programu composer na swojej lokalnej maszynie to ten kontener jest jak najbardziej po to, aby zainstalować w nim aktualną wersię composera np. wg instrukcji z oficjalnej strony i używać go właśnie z tego miejsca)
Kilka słów podsumowania
Docker-compose ma moc, ale na pewno znajdą się tacy co będą woleli zarządzać swoim środowiskiem za pomocą skryptów powłoki bądź np. pythona (w którym docker-compose został zresztą chyba napisany). Python, wspierany odpowiednimi modułami, ma naprawdę duże możliwości i, wydaje się, brak jakichkolwiek ograniczeń, czego niestety nie można powiedzieć o docker-compose (sam aktualnie kombinuję z pythonem w kontekście dockera przy projekcie docker-image-builder). Jednak chcąc korzystać z bardziej zaawansowanych możliwości pythona trzeba oczywiście zacząć uczyć się programowania w tym języku – nie wszystkim to może odpowiadać. Pomijam fakt, że zawansowane możliwości przydają się głównie w zaawansowanych zastosowaniach - w przypadku zastosowań podstawowych (czy nawet średnio-zaawansowanych) docker-compose daje sobie świetnie radę! Zresztą, pewnie każde „żywe” środowisko będzie swojego rodzaju zlepkiem wszystkich wspomnianych tu technologii – czyli konfiguracji w docker-compose.yml (jako podstawy), skryptów powłoki do realizacji różnych zadań w obrębie naszego środowiska (przygotownie bazy danych, itd…) oraz być może programów pisanych w pythonie (jak np. docker-image-builder)… ale o tym wszystkim postaram się jeszcze napisać, na razie dziękuję za uwagę i miłej zabawy!