Docker i docker-compose: wprowadzenie, instalacja

Z Dockerem mam doświadczenie z dwóch stron. Pierwsza to użytkownik dużych, komercyjnych systemów zarządzanych przy pomocy tej platformy. Pracując w sprzedaży, odpowiadam za zarządzanie swoimi środowiskami demonstracyjnymi, w tym ich inicjalizację, aktualizację i monitorowanie za pomocą Docker’a. Z drugiej strony sam piszę rozmaite aplikacje – amatorskie, niekomercyjne, pracując w pojedynkę. Chcę w prosty sposób zarządzać środowiskiem, integracjami i interfejsami, wykorzystaniem zasobów sprzętowych/chmurowych czy stagingiem. W tym wpisie znajdziesz krótkie wprowadzenie do Dockera. Mam nadzieję że pozwoli Ci ono zdobyć pewne intuicyjne zrozumienie tej technologii, a konkretnie poleceń docker i docker-compose. Napiszę też kilka uwag na temat procesu instalacji obu narzędzi pod Linuxem.

Wprowadzenie

Czym jest Docker?

Docker to platforma służąca do konteneryzacji. Kontenery są uruchomioną instancją obrazu zawierającego Twoją (lub cudzą) aplikację wraz ze wszystkimi zależnościami. Ja przypisuję kontenerom następujące cechy, wynikają one z mojego, dosyć powierzchownego, zrozumienia tej technologii, ale przydają się w rozmowach z bardziej technicznymi koleżankami i kolegami czy zrozumieniu tutoriali, dokumentacji i cudzego kodu. Kontenery Docker’a są:

  • szczelne – a więc jasno definiujesz ich wejścia i wyjścia (np. wolumeny, czyli pamięć, lub porty sieciowe). Wszystko co jest na zewnątrz kontenera, w tym inne kontenery z tej samej aplikacji, muszą z nich korzystać w komunikacji. Docker posiada mocną izolację (już przy domyślnych ustawieniach), co pozwala, w prosty sposób, na uzyskanie bezpiecznego i przejrzystego środowiska.
  • samowystarczalne – zawierają system operacyjny i wszystkie niezbędne do uruchomienia Twojej aplikacji zależności (choć w przeciwieństwie do maszyn wirtualnych Docker korzysta z jądra systemu operacyjnego maszyny na której jest uruchomiony).
  • zarządzalne – możesz je uruchamiać, zatrzymywać, kasować i przenosić obrazy kontenerów. Oznacza to m.in. proste tworzenie, aktualizowanie i nadzór nad środowiskami. Docker świetnie nadaje się do stagingu oraz testów.
  • nietrwałe – o ile o to odpowiednio nie zadbasz, kontenery nie będą persystowały żadnych danych. W tym celu musisz skorzystać z wolumenów danych lub odrębnej bazy danych, kolejki czy innej metody persystencji. Warto wspomnieć że taka np. baza danych, jeżeli również uruchomisz ją w kontenerze, będzie i tak musiała polegać na wolumenach aby persystować dane. Tutaj mała uwaga na marginesie: nie uruchamiaj produkcyjnych baz danych na Dockerze!

Aby zbudować obraz który później możemy uruchomić, tworząc z niego kontener (zaznaczę to jeszcze raz, kontener to uruchomiona instancja, jeżeli coś „leży” to jest to obraz) możesz skorzystać z pliku Dockerfile. Więcej o tym pliku później. Tutaj, lub w innym wpisie.

Jedno zadanie

Z wymienionych powyżej względów kontener Dockera zazwyczaj tworzymy w taki sposób, aby realizował jedno, podstawowe zadania. Wtedy maksymalnie wykorzystujemy zalety tej technologii. Oznacza to natomiast, że nasza aplikacja zazwyczaj posiada wiele kontenerów. Jeden na back-end, jeden na bazę danych (ponownie, raczej nie produkcyjną), jeden na InfluxDB, jeden na Grafanę etc.. Proces podziału aplikacji na kontenery odpowiedzialne za jedno, jasno określone zadanie określane jest jako decouplingu (oddzielenia). W rezultacie, poza stworzeniem obrazu kontenera, zbudowaniem go oraz uruchomieniem będziemy musieli zarządzać także ustawieniami wielu innych kontenerów tworzących aplikację Dockerową. Do tego służy właśnie Docker Compose.

Czym jest Docker Compose?

Docker Compose to narzędzie pozwalające w prosty sposób zarządzać aplikacjami wymagającymi wielu kontenerów. Ma dwa najważniejsze elementy.

Pierwszy z nich to komenda docker-compose. Pozwala ona zarządzać stanem aplikacji Dockera – a więc zestawem wszystkich kontenerów opisanych w pliku z definicją. Tym poleceniem startujemy, zatrzymujemy czy sprawdzamy stan aplikacji, analogicznie do polecenia docker dla pojedynczego kontenera.

Drugi element to plik docker-compose.yml który definiuje wszystkie usługi oraz ich konfigurację w ramach jednej aplikacji Dockera. W zasadzie jego nazwa może być dowolna, natomiast jeśli użyjesz właśnie docker-compose.yml to będziesz ją mógł pomijać korzystając z polecenia docker-compose co jest 1) wygodne 2) nie powoduje konfliktów, gdyż w folderze nadrzędnym aplikacji i tak zazwyczaj chcemy mieć tylko jeden plik z ustawieniami Docker Compose. Plik ten (co podpowiada nam rozszerzenie) używa notacji YAML. Ciekawostka – YAML jest rekursywnym akronimem frazy „YAML Ain’t Markup Language”.

Jeżeli nie znasz notacji YAML, polecam po prostu poszukać przykładowych plików docker-compose.yml. Zobaczysz, że notacja jest dosyć prosta – ważne są indentacje (czyli wcięcia kodu, a’la Python), składnia jest intuicyjna, listy działają tak jak w JSON. Spójrzmy na przykładowy plik docker-compose.yml zawarty w dokumentacji:

version: '2.0'
services:
  web:
    build: .
    ports:
    - "5000:5000"
    volumes:
    - .:/code
    - logvolume01:/var/log
    links:
    - redis
  redis:
    image: redis
volumes:
  logvolume01: {}

Oto kilka wniosków które można wyciągnąć z jego lektury.

  • Mamy trzy sekcje: versions (obowiązkowo w każdym docker-compose), services (mięso), volumes (persystencja danych).
  • Mamy dwie usługi, a więc w rezultacie uruchomienia tego pliku powstaną dwa kontenery: web, a więc zapewne jakaś aplikacja webowa oraz redis, czyli baza danych używająca modelu klucz-wartość.
  • Usługa web bazuje na budowanym obrazie. Atrybut build ma wartość . (folder bieżący), oznacza to że w folderze z plikiem docker-compose.yml znajduje się również plik Dockerfile przy pomocy którego ten obraz zbudujemy.
  • Usługa redis posiada tylko wskazany obraz (image: redis). Co to oznacza? Jeżeli nie posiadamy zbudowanego obrazu o nazwie redis, Docker wyszuka taką nazwę w serwisie Docker Hub (o którym kilka słów poniżej). W tym konkretnie przypadku pobierze prawdopodobnie oficjalny obraz bazy danych Redis.
  • Oba kontenery są ze sobą połączone, natomiast tutaj istotna uwaga. Mechanizm, z którego tu skorzystano, oparty jest na atrybucie links. Ten atrybut jest deprecjonowany, czyli przestanie być wspierany przez którąś z kolejnych wersji Dockera. Docker zaleca korzystanie z user-defined networks, czyli w skrócie polegamy na portach wystawianych przez poszczególne usługi oraz, jeśli to konieczne, odrębnych, nazwanych sieciach wirtualnych.

Poza deprecjonowanym atrybutem uwagę zwraca też Dockerfile usługi web zlokalizowany w folderze nadrzędnym (na tym samym poziomie co docker-compose.yml). Nie jest to miejsce na analizę struktury aplikacji ale w moich projektach Dockerfile jest zawsze w podfolderze nazwanym tak samo jak usługa. W tym przykładzie miałby ścieżkę ~/web/Dockerfile a parametr build usługi web wyglądałby tak: build: ./web.

Docker Hub

Kilka słów na temat wspomnianego Docker Hub, bo moim zdaniem warto tą platformę poznać. Jest to platforma która dostarcza gotowe obrazy Dockera oraz pozwala je przechowywać. Można go porównać do GitHuba dla obrazów Dockera – również składania poleceń jest taka sama (docker pull <nazwa obrazu>, docker push <nazwa obrazu>). Dzięki dużej liczbie dostępnych obrazów pozwala na budowanie środowiska naszej aplikacji, uruchamianie dodatkowych usług czy serwisów (np. diagnostycznych) czy po prostu realizację szybkich projektów. Obrazy możesz także uruchamiać z polecenia docker, tworząc samodzielne kontenery z danego obrazu. Nie przestaje mnie to zaskakiwać – wystarczy jeden wiersz w pliku compose aby, jak w przykładzie z dokumentacji, zainstalować i uruchomić Redis’a wraz z wszystkimi niezbędnymi zależnościami i ustawieniami. Co więcej, wiele dostępnych obrazów posiada przystępnie wystawione zmienne środowiskowe za pomocą których możesz je dokładnie dostosować do swoich zastosowań. Osobnym zagadnieniem, o którym być może kiedyś napiszę, jest zarządzanie zmiennymi środowiskowymi w Dockerze, np. za pomocą plików .env – w moich projektach znacząco usprawniają one pracę.

Wracając do Docker Hub – polecam Tobie zapoznanie się z listą dostępnych obrazów chociażby na tej stronie i wykorzystywanie ich w swoich aplikacjach Dockera.

Instalacja

docker

W skrócie – aby zainstalować Dockera polecam skorzystać z bardzo intuicyjnej i zrozumiałej instrukcji zawartej w dokumentacji Dockera. Ja często do niej wracam, opisuje jasno procedurę dla wielu systemów operacyjnych. Czego dokładnie szukać? Chociażby tego artykułu: Install Docker Engine on Ubuntu, przynajmniej jeśli tak jak ja pracujesz na Linuxie Mint (czyli dystrybucji bazującej na Ubuntu). Zwróć uwagę że chcemy zainstalować Docker Engine – czyli mechanizm pozwalający korzystać z technologii Docker. Odbywa się to za pomocą deamon‚a dockerd czyli pracującego „w tle” procesu obsługującego rozmaite zadania – te od Ciebie i innych aplikacji wykorzystujących Dockera. Z demonem dockerd komunikujemy się poprzez API oraz CLI.

Dla Windowsa oraz macOS Docker Engine dostępny jest w pakiecie Docker Desktop, wcześniej znanego jako Docker for Windows i Docker for Mac. Moje doświadczenia z Docker Desktop są kiepskie, chociaż dawno już nie musiałem z tego rozwiązania korzystać. Jeżeli nie masz Linuxa polecam odpalenie wirtualnej maszyny w chmurze Google Cloud Platform, Microsoft Azure czy Amazon Web Services. O chmurze Amazona pisałem dwukrotnie, w temacie prywatnego, darmowego serwera VPN oraz zdalnego repozytorium GIT. W obu wpisach znajdziesz informacje jak stworzyć i połączyć się z instancją EC2 na chmurze Amazona.

Problem z dystrybucją Linuxa

Kiedy instalujesz Dockera na Linuxie jest jedno zastrzeżenie. Dodając repozytorium Docker’a (Set up the repository, krok 3 z instrukcji powyżej) masz wywołać następujące polecenie:

$ sudo add-apt-repository 
  "deb [arch=amd64] https://download.docker.com/linux/ubuntu 
  (lsb_release -cs) 
  stable

To polecenie doda dwa wiersze do pliku /etc/apt/sources.list.d/additional-repositories.list zawierającego listę dodatkowych repozytoriów. U mnie ten plik wygląda tak:

kuba@local:~$ cat /etc/apt/sources.list.d/additional-repositories.list
deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable
deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic test

W obu wierszach musiałem ręcznie zmienić codename dystrybucji (tutaj wartość poprawna – bionic). W moim Linuxie Mint rezultat polecenia lsb_release -cs podającego tzw. krótki codename dystrybucji to tricia. Dla tej dystrybucji Docker nie przygotował wydania, dlatego należy odszukać właściwy codename dla dystrybucji nadrzędnej (Ubuntu). Jeżeli tak jak ja korzystasz z Mint – sprawdź tutaj na jakim distro Ubuntu bazuje Twoja wersja. Jeżeli chcesz zobaczyć dla jakich dystrybucji wydawany jest docker wejdź tutaj).

docker-compose

Instalacja Docker Compose jest prostsza i również polecam zapoznanie się z właściwą instrukcją na stronie oficjalnej dokumentacji. Polecenie curl która pobiera właściwy plik binarny na Twoją maszynę korzysta z komend uname -s i uname -m które zwracają odpowiednio nazwę kernela (u mnie Linux) oraz architektury (x86_64). A więc nie ma wspomnianego problemu z dystrybucjami.

Po instalacji

Jeżeli pracujesz na Linuxie i nie chcesz dodawać sudo dla każdego wywołania komendy docker i docker-compose to powinieneś zadbać o możliwość wykonywania Dockera przez zwykłego użytkownika (nie root‚a). Tutaj również oficjalna dokumentacja jest bardzo pomocna. Wystarczy że zrobisz to dla samego Dockera, Docker Compose domyślnie nie wymaga uprawnień root‚a.

Zweryfikuj że Docker jest zainstalowany poprawnie uruchamiając obraz „hello-world” :

kuba@local:~$ docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
0e03bdcc26d7: Pull complete 
Digest: sha256:4cf9c47f86df71d48364001ede3a4fcd85ae80ce02ebad74156906caff5378bc
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.
(...)

Teraz przetestujmy jeszcze Docker Compose. Stwórz nowy folder oraz dodaj w nim następujący plik docker-compose.yml:

version: '2.0'
services:
  hello:
    image: hello-world

Jak widzisz ma on bardzo prostą strukturę, z jedną usługą (a w zasadzie aplikacją) – opartą również na obrazie testowym hello-world. Teraz możemy wywołać komendę docker-compose up (pomijamy zazwyczaj wykorzystywany parametr -d, ponieważ 1) chcemy na bieżąco zobaczyć output kontenera hello a 2) po wydrukowaniu powitalnego tekstu aplikacja się zatrzyma):

kuba@local:~/test$ docker-compose up
Creating network "test2_default" with the default driver
Creating test2_hello_1 ... done
Attaching to test2_hello_1
hello_1  | 
hello_1  | Hello from Docker!
hello_1  | This message shows that your installation appears to be working correctly.
(...)
hello_1  | For more examples and ideas, visit:
hello_1  |  https://docs.docker.com/get-started/
hello_1  | 
test2_hello_1 exited with code 0
kuba@local:~/test$ cat docker-compose.yml 

Jeżeli u Ciebie wygląda to podobnie to gratulacje! 🎉 Wiem że jest to nieco powierzchowne wprowadzenie ale mam nadzieję znalazłeś tu coś pomocnego.

Co dalej?

Dockerfile

Dockerfile o którym już wcześniej wspominałem jest sposobem na tworzenie własnych obrazów, a w zasadzie pisanie instrukcji ich tworzenia. Naturalne skojarzenie z Makefile (jeśli kiedyś miałeś tą przyjemność) jest zatem jak najbardziej trafne. W oparciu o instrukcję zawartą w Dockerfile polecenie docker build zbuduje Twój obraz który po uruchomieniu stanie się kontenerem. Nie jest to artykuł o Dockerfile natomiast wspomnę tu o dwóch jego najważniejszych aspektach – strukturze i optymalizacji.

Struktura Dockerfile

Dockerfile to instrukcja wykonania, krok-po-kroku. Spójrzmy na przykładowy plik (na marginesie, wyjęty z moje własnego projektu więc nie jest to state-of-the-art):

# pull official base image
FROM python:3.8.3-alpine

# set work directory
WORKDIR /projekt

ADD ./requirements.txt /projekt/requirements.txt
RUN apk --update add --virtual  build-dependencies libffi-dev openssl-dev python3-dev py-pip build-base
RUN pip install -r requirements.txt
ADD . /projekt

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

CMD ["gunicorn", "-c", "gunicorn_config.py", "run:app"]

Każda linia to jedna instrukcja którą Docker zrealizuje w trakcie budowania. Cały plik opisuje proces od skopiowania obrazu python:alpine (a więc popularnego, generycznego obrazu z minimalną dystrybucją Linuxa i Pythonem) do uruchomienia mojej własnej aplikacji na serwerze Gunicorn.

Jeżeli interesuje Cię tworzenie obrazów z aplikacjami Pythona to warto świadomie podejść do wyboru obrazu bazowego. Alpine jest bardzo chudy, co ma swoje zalety (o czym za chwilę) ale ma też wiele wad. Niektórzy wręcz wprost odradzają użycie Alpine do tworzenia obrazów w Pythonie.

Optymalizacja i multi-stage build

W Dockerze ważna jest optymalizacja rozmiaru obrazu (w rezultacie również czasu jego budowania). Każda instrukcja tworzy warstwę (layer) w obrazie. Jeżeli w folderze z aplikacją wprowadzimy drobną zmianę, a folder ten skopiowaliśmy w instrukcji nr 3 z 15, to pomimo braku zależności pomiędzy tą zmianą i kolejnymi instrukcjami Docker i tak przebuduje 12 następnych warstw. Dlatego kiedy piszemy Dockerfile warto wczytać się w zagadnienie optymalizacji i wieloetapowego budowania (multi-stage build) dzięki którym oszczędzimy sobie wiele czasu i zasobów. W moim przykładowym pliku mam prostą, zdawałoby się oczywistą, optymalizację. Obecnie fragment instrukcji realizuje następującą sekwencję:

  1. Skopiuj plik requirements.txt (wymagane biblioteki których użyłem) z folderu aplikacji
  2. Doinstaluj to i owo ze względu na ubogie wyposażenie Linuxa Alpine (nieistotne, nie wchodźmy tu w szczegóły 🤦🏻‍♂️)
  3. Pobierz i zainstaluje biblioteki z pliku requirements.txt
  4. Skopiuj resztę folderu aplikacji, ustaw zmienne środowiskowe, uruchom serwer Gunicorn.

Oznacz to że jeżeli zmienię kod aplikacji (nie zmieniając użytych modułów) to przy budowaniu obrazu (a więc przeniesieniu wprowadzonych zmian na aplikację Dockera) zostanie wykonany tylko krok 4. Nie muszę chyba pisać że te 3 wcześniejsze trwają zdecydowanie dłużej. Mój proces przed tą zmianą wyglądał tak:

  1. Skopiuj cały folder aplikacji (w tym requirements.txt)
  2. Doinstaluj, pobierz, zainstaluj etc..
  3. Ustaw zmienne i uruchom serwer Gunicorn

Jak się łatwo domyślić – najdrobniejsza zmiana w kodzie powodowała przebudowanie całego obrazu „od zera”, ponowne pobranie dziesiątek bibliotek itd.. Zmiana pozwoliła mi to skrócić czas z kilku minut do kilku sekund.

Multi-stage z kolei, to proces w którym, w dużym skrócie, nasz Dockerfile wielokrotnie używa słowa kluczowego FROM. A więc tworzymy obraz w wersji full tylko po to aby np. zbudować w nim aplikację i skopiować ją do drugiego, odchudzonego obrazu. Możemy w ten sposób pozbyć się wielu artefaktów. Gdybym przepisał swój własny plik zgodnie z podejściem multi-stage, mógłbym np. wyeliminować wszystkie zależności których użyłem do zbudowania bibliotek z finalnego obrazu. Ale to chyba zagadnienie na inny artykuł… 🙅🏻‍♂️

Orkiestracja

Tematem którego w tym wpisie nie poruszyłem jest tzw. orkiestracja (orchestration). Tak jak Docker Compose pozwala nam zarządzać aplikacjami złożonymi z wielu kontenerów uruchomionych na jednej maszynie (lub, aby być bardziej precyzyjnym, na jednym Docker Engine), tak orkiestracja jest technologią pozwalającą na zarządzanie aplikacjami Docker działającymi na wielu maszynach, w rozproszonych środowiskach. Jest ona więc kluczowa do osiągnięcia skalowalności i wysokiej dostępności Twojej aplikacji. W związku z tym ten temat ma znacznie jeżeli Twoje aplikacje są już „na produkcji”.

Najpopularniejszymi narzędziami służącymi do orkiestracji są Docker Swarm (a więc kolejny system autorstwa Dockera) oraz Kubernetes (w skrócie – K8s). Kubernetes wywodzi się ze stajni Google, natomiast obecnie jest zarządzany przez Linux Foundation.

Kubernetes posiada opinie systemu bardzo złożonego i raczej trudnego w opanowaniu, zwłaszcza bez wcześniejszej wiedzy z dziedziny wirtualizacji, konteneryzacji czy właśnie orkiestracji. Docker Swarm, przynajmniej sądząc po lekturze dokumentacji, posiada nieco mniejszą barierę wejścia.

Na koniec wspomnę że jeżeli jesteś użytkownikiem chmury, to każdy z większych dostawców posiada swoje własne narzędzia do zarządzania orkiestracją kontenerów:

  • Amazon Elastic Container Service (Amazon ECS )
  • Google Kubernetes Engine (GKI)
  • Azure Container Instances, Azure Container Services czy Azure Kubernetes Services (ACI, ACS, AKS) – tutaj, jak to bywa z Azure, bałagan 😉

Oczywiście pod maską (co jest ewidentne patrząc na ich nazwy) te usługi korzystają często z Swarm lub K8s, natomiast ich przewagą jest ergonomia, integracja oraz jednolity, w odniesieniu do pozostałych modułów chmury, interfejs użytkownika.

TL;DR?

  • Naucz się podstaw komend docker i docker-compose, jeśli chcesz iść dalej i budować własne obrazy to szukaj w google o plikach Dockerfile
  • Czytaj oficjalną dokumentację Dockera – jest premium ⭐⭐⭐⭐⭐
  • Eksperymentuj z Docker Hub. Będziesz zaskoczony jak szybko można zbudować złożone aplikacje dzięki dostępnym tam obrazom i odrobiną znajomości docker-compose
  • Sprawdzaj listę najpopularniejszych repozytoriów na GitHubie. Możesz ją filtrować po języku – zarówno programistycznym jak i tym mówionym. Jest to o tyle ważne że ostatnio spora część czołówki to pliki w języku chińskim.

Na koniec warto wspomnieć o repozytorium awesome-docker na GitHubie w którym znajdziesz całą masę produktów, obrazów, źródeł, zasobów itd. przydatnych w pracy i nauce Dockera. W tej chwili repozytorium posiada ponad 18000 ⭐ więc z pewnością jest tam wiele cennych informacji.

P.S.

Zdjęcie tytułowe zrobiłem aparatem Nikon D200 gdzieś w koło 2010 roku, w okolicach Alamosa w Kolorado, USA ❤️ 🇺🇸

Avatar

Autor: kuba

Pracuję w IT, sprzedając oprogramowanie klasy APS/MES firmom produkcyjnym. Nie jestem software developerem ale odkąd zacząłem w Turbo Pascalu zawsze coś piszę - ostatnio w Pythonie. Lubię technologie, mocną kawę i dobre zdjęcia 🤙

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *