Dockerfile jest dla projektu tym, czym zapis nutowy jest dla utworu muzycznego — zapewnia, że zawsze wykonane zostaną te same operacje, w tej samej kolejności, dające ten sam efekt.

Czym jest Dockerfile

Wyobraź sobie plik README.md w projekcie lub stronę na Confluence, gdzie zawarto wszystkie kroki, jakie należy wykonać, aby zapewnić poprawne działanie aplikacji - Dockerfile jest właśnie taką instrukcją, tylko że zautomatyzowaną 😁. Jest to pewnego rodzaju uproszczenie, ale fakt jest taki, że Dockerfile zapewnia nam spójność środowiska uruchomieniowego, które dzięki temu plikowi będzie takie samo u każdej osoby pracującej z projektem.

Co zatem taki plik może definiować? Przykładowo:

  • system operacyjny: jest bazą do dalszych operacji i fundamentem działania środowiska.
  • instalację pakietów systemowych: korzystając z wbudowanego w system operacyjny menedżera paczek możemy zainstalować wszystko, co jest nam potrzebne do poprawnego funkcjonowania aplikacji.
  • środowisko dla języka programowania: wszystko, co potrzeba, by nasz kod mógł zostać poprawnie uruchomiony. Zazwyczaj języki programowania dostarczają oficjalne obrazy, które mają gotowe środowisko oraz narzędzia do jego rozszerzania (np. obrazy PHP zawierają pecl, czyli bibliotekę rozszerzeń, które można dodać na życzenie). Korzystając z nich, możemy łatwo rozwinąć domyślne środowisko o dodatkowe rozszerzenia oraz w miarę potrzeby dostarczyć niestandardowe konfiguracje.
  • nasłuchiwane porty: jest to swego rodzaju kontrakt ze światem zewnętrznym. Kontener uruchomiony na podstawie zbudowanego obrazu będzie mógł się komunikować poprzez te porty. Przykładowo obraz bazy danych MySQL wystawia na zewnątrz port 3306 i właśnie do niego później można się łączyć.

Oprócz powyżej wymienionych, fundamentalnych rzeczy, Dockerfile może definiować wiele innych operacji, ale nie da się ich wszystkich wymienić, ponieważ każda aplikacja jest inna i ma inne wymagania. Potrzebujesz stworzyć domyślną strukturę katalogów do późniejszego wykorzystania? Musisz zawrzeć w obrazie zestaw plików, z których później korzystać będzie aplikacja? Nie ma problemu 🙂. Możesz robić w zasadzie dowolne operacje — ograniczeniem jest jedynie składnia Dockerfile oraz system operacyjny wykorzystany jako baza dla naszego obrazu.

Składnia Dockerfile

Pełna dokumentacja składni znajduje się na oficjalnej stronie i gorąco zachęcam do zapoznania się z nią. W tym artykule skupimy się natomiast na podstawowych instrukcjach, jakie oferuje nam Dockerfile, co pozwoli nam przygotować się do bardziej zaawansowanych operacji, jakie zostaną omówione w kolejnych wpisach. Pozostałe zostaną omówione pobieżnie lub wręcz będą pominięte i w miarę potrzeby pojawią się w kolejnych wpisach z tej serii.

Generalnie Dockerfile to zestaw instrukcji, ułożonych w kolejności ich wykonywania. To, jak je ułożymy, determinuje jak taki obraz będzie zbudowany, a także jaka finalnie będzie jego zawartość. Optymalizacji tych instrukcji to temat na osobny wpis, w tym momencie skupimy się jedynie na tym, co robią poszczególne z nich.

FROM

Podstawową instrukcją jest FROM i to ona rozpoczyna nam każdą definicję build’u. Instrukcji tej możemy przypisać alias, definiując tym samym tzw. build target, a robi się to dodając as <alias>. Takich targetów w pliku Dockerfile może być wiele. Wykonując docker build możemy wskazać taki target przekazując flagę --target <nazwa>, a jeśli jej nie podamy jawnie, zbudowany zostanie ostatni target zdefiniowany w Dockerfile.

Instrukcja FROM jako argument może przyjmować kilka wartości:

  • FROM scratch: jak łatwo się domyślić, zaczynamy z “pustą kartką”, nasz obraz nie zawiera nic.
  • FROM php:8.2: bazą do dalszych instrukcji będzie obraz PHP w wersji 8.2. Po dwukropku podajemy oczekiwaną wersję, a listę tagów dla danego obrazu możemy znaleźć w rejestrze, z którego korzystamy, w tym przypadku w Docker Hubie. Jeśli wersja nie jest podana jawnie, wtedy domyślnie stosowany jest tag latest wskazujący na najnowszą wersję (a przynajmniej taka jest konwencja, bo istnienie tagu latest musi zagwarantować osoba/zespół odpowiedzialny za build i publikację).
  • FROM inny-target: w tym przypadku wskazujemy, że bazą do dalszych operacji jest inny target zdefiniowany w Dockerfile. Tego typu konstrukcje są automatycznie rozwiązywane przez silnik Dockera, więc wykonując build któregoś z targetów nie musimy wcześniej budować jego zależności (inny-target) — zostanie on zbudowany automatycznie, a następnie zostanie wykorzystany jako baza do kolejnego targetu. W przypadku, gdybyśmy odwoływali się do targetu, który nie istnieje, build się nie powiedzie, a my otrzymamy komunikat o błędzie.

Podsumowując, zapis FROM php:8.2-cli-alpine as php-base definiuje nam target php-base, który zostanie zbudowany na bazie oficjalnego obrazu php w wersji 8.2-cli-alpine, zawierającego runtime PHP dla CLI w systemie operacyjnym Alpine Linux.

RUN

Instrukcja RUN jest prawdopodobnie najczęściej stosowaną instrukcją w Dockerfile, choć to oczywiście zależy od jego specyfiki. W każdym razie jej przeznaczeniem jest wykonanie wskazanej komendy w obrębie środowiska dostępnego w trakcie procesu budowania obrazu. Jeśli zatem jako bazę stosujemy wspomniany wcześniej php:8.2-cli-alpine, to naszym środowiskiem jest Alpine Linux i każdy RUN może uruchamiać komendy dostępne w ramach tego systemu lub te, które sami zainstalujemy używając menedżera pakietów apk.

Warto myśleć o RUN jak o komendzie wykonywanej w CLI czy to na własnym komputerze, czy na zdalnym poprzez SSH. Po prostu mamy jakieś konkretne środowisko (zazwyczaj są to systemy operacyjne takie jak Debian czy wspomniany Alpine Linux) i w ramach niego wykonujemy wszelakie komendy tak, jakbyśmy to robili przygotowując lokalne środowisko czy serwer, aby zapewnić poprawne działanie naszej aplikacji.

Zatem RUN apk add git (Alpine) czy RUN apt-get install git (Debian) zainstaluje nam pakiet git, z którego później możemy korzystać w dalszej części procesu buildu lub w docelowym kontenerze uruchomionym na bazie zbudowanego obrazu.

COPY

Instrukcja COPY jest łącznikiem między lokalnym systemem plików (tym, z którego wykonywany jest build), a budowanym obrazem. Dzięki niej możemy dodawać do budowanego obrazu dowolne pliki, jakie są potrzebne do późniejszego działania. W przypadku aplikacji zazwyczaj kopiowany jest cały folder aplikacji, przy czym od razu warto zaznaczyć, że możemy ograniczyć ten kontekst poprzez zastosowanie pliku .dockerignore, w którym definiujemy jakie ścieżki mają być pominięte podczas kopiowania (dzięki czemu możemy pominąć takie foldery jak vendor czy node_modules). Zapis COPY . . mówi, by wszystkie pliki (bez tych, które są ignorowane) zostały przekopiowane do obrazu, do jego katalogu roboczego. Możliwe jest oczywiście wskazywanie konkretnych ścieżek, jak np. COPY ./bin/example /usr/bin/example, mamy w tej kwestii pełną elastyczność.

Wartym odnotowania jest fakt, że instrukcja COPY jako źródła może używać nie tylko lokalny system plików, ale również już zbudowane obrazy lub inne build targety. W tym celu należy wykorzystać zapis COPY --from=..., podając źródło, z jakiego mają być kopiowane pliki.

Kolejną bardzo pomocną flagą jest --chown, dzięki której już w momencie kopiowania plików możemy zdefiniować ich właściciela. Bardzo często nasza aplikacja musi być w pełni dostępna dla użytkownika, który służy do uruchamiania serwera www, zatem częstą praktyką jest wykonywanie COPY --chown=www-data:www-data. Dzięki tej fladze nie musimy wykonywać dwóch operacji: COPY oraz RUN chmod 🙂.

ADD

Instrukcja ADD w zasadzie robi to samo co COPY, jednak ma dodatkową funkcjonalność — potrafi kopiować pliki ze zdalnych lokalizacji i obsługuje pliki lokalne tar (źródło).

W przypadku kopiowania plików ze zdalnych lokalizacji istnieje możliwość weryfikacji sumy kontrolnej pliku, robi się to za pomocą opcji --checksum=<checksum>.

ENV

Instrukcja ENV służy do definiowania zmiennych środowiskowych, które są dostępne w trakcie kolejnych kroków budowania obrazu, a finalnie również w kontenerze uruchomionym z takiego obrazu. W związku z tym należy być ostrożnym przy definiowaniu tego typu zmiennych środowiskowych, ponieważ mogą one wpływać na działanie narzędzi zawartych w obrazie.

ARG

ARG ma działanie podobne do ENV, jednak różni się od tej instrukcji tym, że cykl życia ARG ogranicza się do procesu budowania obrazu. ARG może przyjmować wartości domyślne, które można później nadpisywać (albo po prostu dostarczać) z użyciem opcji --build-arg <name>=<value>.

Przekazane w ten sposób zmienne mogą wpływać na proces budowania oraz na wynikowy obraz. Przykładowo: robiąc ARG CLI_VERBOSITY='' moglibyśmy zadeklarować domyślną szczegółowość komunikatów zwracanych przez komenty CLI, a następnie zbudować obraz z użyciem docker build --build-arg CLI_VERBOSITY=-vvv. Wtedy zostaje już tylko użyć tej zmiennej w komendach, robiąc np. RUN bin/console cache:clear $CLI_VERBOSITY. Nie jest to może jakiś szczególnie życiowy przykład, ale pokazuje zasadę działania 😉.

WORKDIR

Ta prosta instrukcja wskazuje nam ścieżkę, w której kontekście wykonywane będą wszelakie instrukcje RUN, COPY, ADD, CMD i ENTRYPOINT, które są zdefiniowane po WORKDIR. W praktyce oznacza to, że jeśli zrobimy WORKDIR /app, a następnie RUN bin/console, to zakładamy, że w ścieżce /app/bin/console istnieje plik wykonywalny — jeśli nie istnieje, oczywiście ujrzymy błąd, a proces budowania zostanie przerwany.

CMD

CMD definiuje domyślną komendę uruchomieniową dla kontenera. Przykładowo dla php:8.2.3-fpm jest to CMD ["php-fpm"], co oznacza, że w momencie uruchamiania kontenera uruchomiony zostanie PHP-FPM.

Zazwyczaj budując obrazy dla naszych aplikacji nie musimy definiować CMD, ponieważ korzystając z obrazów bazowych takich jak PHP mamy to już zdefiniowane. Nic nie stoi jednak na przeszkodzie, by dostosować instrukcję wedle potrzeb.

ENTRYPOINT

Temat ENTRYPOINT jest dość skomplikowany i można by napisać cały dedykowany artykuł o nim i CMD, ale tak w skrócie instrukcja ta definiuje punkt startowy podczas uruchamiania kontenera. Upraszczając: sprawiamy, że można rozpatrywać kontener jako komendę (skrypt wykonywalny) i podczas jego uruchamiania możemy przekazać dodatkowe flagi/argumenty, które zostaną przekazane do entrypoint’u. Przykładowo docker run <image> -d przekaże -d do entrypoint’u.

USER

Jak sama nazwa sugeruje, USER służy do zdefiniowania użytkownika (i opcjonalnie grupy), który będzie używany do wykonywania wszelakich operacji w trakcie budowania obrazu, a także do uruchomienia ENTRYPOINT i CMD.

EXPOSE

Jeśli kontener ma udostępniać interfejs do komunikacji z zawartymi w nim usługami, powinniśmy użyć instrukcji EXPOSE. Definiuje ona porty, które są nasłuchiwane wewnątrz kontenera (wspierane są protokoły TCP oraz UDP, ten pierwszy jest domyślny).

Przykładowo w obrazach zawierających serwer www znaleźć można EXPOSE 80/tcp, co oznacza, że kontener nasłuchuje na port 80 w protokole TCP. Do takich portów można się później łączyć z zewnątrz robiąc docker run -p 80:80/tcp <image>.

VOLUME

Jeśli potrzebujemy punktu styku dla systemów plików między kontenerem a systemem, w którym kontener jest uruchomiony, możemy użyć instrukcji VOLUME. Definiuje ona tzw. mount point i sprawia, że pliki zawarte w takim wolumenie są synchronizowane do systemu operacyjnego. Więcej na temat wolumenów można znaleźć w tym artykule

HEALTHCHECK

Uruchomienie kontenera to jedno, ale monitorowanie czy cały czas działa poprawnie to zupełnie inna sprawa. Może nam w tym pomóc HEALTHCHECK, który definiuje sposób, w jaki kontener może zostać zweryfikowany pod kątem działania, dzięki czemu może również zostać automatycznie zatrzymany i uruchomiony ponownie.

Przykładowy Dockerfile

FROM php:8.2-cli-alpine

WORKDIR /app

# See: https://twitter.com/_Codito_/status/1587052303869267968 
COPY --from=composer/composer:2-bin /composer /usr/bin/composer

# Install some PHP extensions, then clean up things a bit.
# It's important to do it in the same `RUN`, so there are no leftovers in the image.
RUN apk add --no-cache icu \
    && apk add --no-cache --update \
      --virtual .build-deps \
      $PHPIZE_DEPS \
      icu-dev \
      linux-headers \
    && pecl install xdebug-3.2.0 \
    && docker-php-ext-install intl \
    && docker-php-ext-enable xdebug \
    && apk del -f .build-deps

# This will copy all local files (from where `Dockerfile` is used) \
# to `/app` (which was set as WORKDIR above).
COPY . .

# Prepare app's runtime by installing Composer dependencies.
RUN composer install --no-dev --no-scripts

Weryfikacja poprawności Dockerfile

Dobre IDE powinno nam pomagać podczas tworzenia Dockerfile pod kątem dozwolonych instrukcji i ich składni. Istnieją jednak narzędzia takie jak Hadolint, które pozwalają również pilnować dobrych praktyk przy tworzeniu Dockerfile. Informacje o tym jak używać hadolint znajdziecie na stronie projektu 🙂.

Podsumowanie

Wszystkie powyższe informacje w pierwszej chwili mogą przytłoczyć, zwłaszcza jeśli nie ma się żadnego doświadczenia z Dockerem. Prawda jest jednak taka, że nie trzeba tego wszystkiego wiedzieć, by zacząć przygodę z konteneryzacją 🙂. Część instrukcji dostępnych w Dockerfile możecie nawet nigdy nie potrzebować (np. nie przypominam sobie, bym osobiście używał VOLUME), część może się pojawić gdzieś na dalszym etapie, gdy projekt będzie rósł, a wraz z nim procesy CI/CD, które będą wymagały bardziej zaawansowanych implementacji.

W kolejnym wpisie z tej serii przyjrzymy się drugiemu plikowi, który jest niezmiernie istotny z perspektywy Dockera — chodzi oczywiście o Compose file (compose.yaml), który definiuje stack, w jakim uruchamiana jest aplikacja. Do usłyszenia!