Docker, to bardzo użyteczne rozwiązanie, które pozwala nam na stworzenie środowiska uruchomieniowego identycznego na środowisku lokalnym testowym, jak i produkcyjnym. Myślę, że w dzisiejszych czasach jest już jednym z obowiązkowych zagadnień dla każdego programisty, niezależnie od stacku technologicznego, w którym pracuje. Użycie dockera wiąże się jednak z kilkoma problemami, jak np. to, że najlepiej jest posiadać rejestr do przechowywania obrazów dockera, czy to, że musimy odpowiednio taki obraz przygotować i skonfigurować jego budowanie. Tematowi tworzenia właśnie chcę się dziś przyjrzeć.

Dlaczego obraz powinien być lekki?

Budowanie obrazu dockera, to proces dość zasobożerny, dlatego nie powinniśmy go przeprowadzać na środowisku uruchomieniowym. Do budowania najlepiej użyć dedykowanego środowiska, które nie będzie wpływało na nasze środowisko produkcyjne. Możemy również obrazy budować lokalnie na naszym komputerze. Niezależnie które z tych podejść przyjmiemy, musimy ten obraz w jakiś sposób umieścić na serwerze produkcyjnym.

Biorąc pod uwagę powyższe wymogi, warto zadbać o to, aby waga obrazu dockera była możliwie najmniejsza. Pozwoli nam to zaoszczędzić na transferze danych, który często (np. w chmurach) bywa dodatkowo płatny. Waga obrazu jest również zależna od tego, jak dużo narzędzi czy bibliotek mamy w środku, co wpływa również na bezpieczeństwo.

Warstwy

Najprościej będzie przyrównać warstwy dockerowe do commitów w gicie. Jeśli do repozytorium gita, zacommitujemy jakiś plik, np. obrazek, to tracimy możliwość jego zmiany. Oczywiście możemy w kolejnym commicie np. go usunąć, czy podmienić, ale nie spowoduje to, że zniknie on z repozytorium, nadal możemy odzyskać go z historii zmian. Kiedy stworzymy commit lokalnie, możemy go wysłać do zdalnego repozytorium, jednak nie musimy wysyłać całego repozytorium za każdym razem. Git po użyciu komendy push porównuje identyfikatory commitów i wysyła tylko te zmiany, których nie ma w zdalnym repozytorium, czyli wysyła jedynie różnicę pomiędzy repozytoriami.

Warstwy w dockerze zachowują się bardzo podobnie. Nie musimy za każdym razem wysyłać całego obrazu, wystarczy, że wyślemy różnicę pomiędzy naszym obrazem a tym leżącym w repozytorium. Mechanizm ten nie działa 1:1 tak samo, ale jest to najprostsze porównanie, jakie znam.

Dzięki wykorzystaniu warstw możemy w prosty sposób zaoszczędzić na transferze naszych obrazów. Jeśli któreś z warstw nie ulegną zmianie, to docker wykorzysta te już istniejące i nie będzie ich wysyłał ani budował ponownie.

Warstwy są tworzone na każdej komendzie w pliku Dockerfile, który dokonuje zmiany w wynikowym obrazie.

Obraz bazowy

Wybór obrazu bazowego jest pierwszym parametrem, który ma wpływ na rozmiar naszego wynikowego obrazu. Z przyzwyczajenia wiele osób wybiera jako swój bazowy obraz np. ubuntu czy debiana albo obraz zbudowany na którymś z tych dwóch. Wybór tych obrazów jest być może wygodnym, ale moim zdaniem niezbyt dobrym pomysłem. Na starcie są one dość duże i zawierają sporo bibliotek, które być może wcale nie są nam potrzebne. Najprostszą, a zarazem najmniejszą opcją wydaje się Alpine. Bazowy skompresowany obraz wersji 3.12 waży 2.66MB (ubuntu w wersji 20.10, 29.89MB). Alpine linux jest dość popularny w świecie dockera, dzięki czemu ma duże wsparcie społeczności, a przy tym często możemy znaleźć gotowe obrazy zbudowane na jego bazie.

Multistage building

Przy budowaniu aplikacji bardzo często wymagane są pewne zależności, jak np. kompilator, które nie są potrzebne już w trakcie jej działania. W przypadku PHP nie mamy kompilatora, ale mamy np. menadżer zależności, jakim jest composer. Wygodnym wydaje się wrzucenie composera do obrazu z naszą aplikacją, jednak odradzam to rozwiązanie, ponieważ nie ma ono najmniejszego sensu. Pamiętajmy o tym, że kontenery muszą być bezstanowe. Wynika to z tego, że z jednego obrazu może powstać wiele kontenerów działających w tym samym momencie, dodatkowo mogą się one restartować. Każdy nowy kontener jest tworzony z dokładnie tego samego obrazu, dlatego wszelkie zmiany wykonane w działającym już kontenerze nie zostaną przeniesione na pozostałe kontenery. Jeśli spróbujemy np. wykonać composer update na działającym kontenerze, to wystarczy, że taki kontener się zrestartuje z jakiegoś powodu i nasza aktualizacja zniknie.

Żeby nie trzymać tego typu zależności w gotowym obrazie, a jednak mieć możliwość ich użycia w procesie budowania, można wykorzystać tzw. multistage building. Dzięki temu mechanizmowi część pracy w trakcie budowania zostanie wykonana na tymczasowym kontenerze, który zostanie zniszczony po całym procesie.

Praktyka

Zastosujmy teraz całą powyższą wiedzę w budowaniu obrazu. Poniżej wrzucam gotowy plik Dockerfile, który opiszę krok po kroku w dalszej części:

# STAGE 1
FROM composer:2.0 as builder
WORKDIR /app

# Copy dependincies description files
COPY composer.json
COPY composer.lock

# Install dependencies
RUN composer install --ignore-platform-reqs --prefer-dist --no-dev --no-scripts --no-autoloader

# Copy application code and build autoloader
COPY . .
RUN composer dump-autoload --no-scripts --no-dev --optimize

# STAGE 2
FROM php:7.4-fpm-alpine3.12
WORKDIR /app

# Setup configuration files
RUN mv $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini
COPY ./build/php/config/opcache.ini $PHP_INI_DIR/conf.d/opcache.ini

# Copy the built code from previous stage
COPY --from=builder /app .

STAGE 1

Jak widać w pierwszym stage, używam obrazu PHP, zawierającego composera. Podałem konkretną wersję, której chcę użyć, co jest bardzo dobrą praktyką. Już kilka razy zdziwiłem się, kiedy użyłem tagu latest i została wypuszczona niekompatybilna wersja obrazu :D, dlatego warto zawsze ustalić przynajmniej pozycje major oraz minor wersji obrazu, którego używamy. Nadaję również alias dla pierwszego stage’a, co pozwoli mi odnieść się do niego w drugim stage’u.

FROM composer:2.0 as builder

W kolejnej linii ustalam sobie katalog roboczy. Wygodnie jest to zrobić na początku, a potem odnosić się do niego przy pomocy kropki. Co więcej, kiedy będziemy chcieli uruchomić terminal na uruchomionym kontenerze, zostaniemy automatycznie przeniesieni do tego katalogu.

WORKDIR /app

W kolejnym kroku kopiuję dwa pliki z definicjami zależności dla composera. Dlaczego nie kopiuję od razu całej aplikacji? Robię to po to, żeby móc skorzystać z cacheowania warstw. Najczęściej nowe zmiany w aplikacji są wykonywane głównie w kodzie. Zależności są edytowane zdecydowanie rzadziej, a dodatkowo często dużo czasu zabiera ich pobranie. Docker jest w stanie porównać warstwy z tymi, które zbudował już kiedyś i nadal trzyma je lokalnie. Jeśli hash zmiany zgadza się z hashem warstwy, którą już mamy zbudowaną, docker pominie jej budowanie i użyje warstwy z cache. Jednak jeśli w wyższej warstwie pojawi jakaś zmiana, to wszystkie warstwy pod nią muszą zostać przebudowane, dlatego w pierwszej kolejności wykonujemy operacje, które zmieniają się najrzadziej.

COPY composer.json
COPY composer.lock

Następnie instalujemy wszystkie zależności. Composer jest bardzo podstawowym obrazem, dlatego może w nim brakować niektórych wymaganych przez zależności composera rozszerzeń PHP. Użycie flagi --ignore-platform-reqs pozwoli nam pominąć sprawdzenie zgodności. Kolejne dwie flagi są dość standardowe dla pobierania zależności, więc nie będę ich objaśnial. Flaga --no-scripts pozwoli nam pominąć skrypty uruchamiane w trakcie pobierania zależności, co znów pozwoli nam uniknąć problemów ze zgodnością. Ostatnia flaga pominie utworzenie autoloadera. Na tym etapie nie mamy jeszcze kodu aplikacji, więc nie ma sensu próbować go wygenerować.

RUN composer install --ignore-platform-reqs --prefer-dist --no-dev --no-scripts --no-autoloader

Dalej możemy już skopiować kod naszej aplikacji. Komenda COPY . . spowoduje skopiowanie wszystkich plików z katalogu kontekstu, do katalogu app w kontenerze. W tym miejscu warto wspomnieć, że możemy użyć pliku .dockerignore, który pozwoli nam wykluczyć niektóre niepotrzebne pliki z kopiowania. Działa on identycznie jak .gitignore. W razie wątpliwości polecam zajrzeć do dokumentacji :).

COPY . .

Ostatni krok pierwszego stage’a, to zbudowanie pliku autoloadera.

RUN composer dump-autoload --no-scripts --no-dev --optimize

STAGE 2

W tym momencie kończy się pierwszy etap i rozpoczyna się budowanie kolejnego, czyli budowanie właściwego obrazu. Jako obraz bazowy używam php 7.4 z fpm zbudowanego na alpine w wersji 3.12.

FROM php:7.4-fpm-alpine3.12

Następnie ustawiamy katalog roboczy na /app jak w poprzednim etapie.

WORKDIR /app

Budując kolejny stage, zaczynamy tworzyć warstwy obrazu od nowa, dlatego znów zaczynamy od działań najrzadziej się zmieniających. Z tego powodu najpierw kopiuję potrzebne pliki konfiguracyjne. W dokumentacji obrazu PHP możemy przeczytać, że w katalogu plikami ini, znajdziemy dwa pliki z domyślną konfiguracją. Jeden dla środowiska developerskiego i drugi dla produkcyjnego. Oczywiście kopiujemy ten drugi. Dodatkowo z plików kodu, kopiuję plik konfiguracyjny dla opcache.

RUN mv $PHP_INI_DIR/php.ini-production $PHP_INI_DIR/php.ini
COPY ./docker/opcache.ini $PHP_INI_DIR/conf.d/opcache.ini

Ostatnim krokiem jest przeniesienie “zbudowanego” kodu aplikacji z poprzedniego stage’a, do katalogu /app aktualnego stage’a.

COPY --from=builder /app .

I mamy gotowy lekki obraz z aplikacją PHP.

Podsumowanie

Powyższa konfiguracja jest efektem moich doświadczeń z budowaniem obrazów Dockera dla PHP. Zapewne mogliście się natknąć na podobne konstrukcje i przykłady, ponieważ właśnie na przykładach z internetu się wzorowałem. Wykorzystałem tutaj najlepsze wg. mnie praktyki budowania obrazów, które udało mi się poznać w przeciągu kilku już lat pracy z Dockerem. Powyższy przykład jest bardzo podstawowy, ale uważam, że jest świetną bazą do dalszego rozwoju tej konfiguracji już w konkretnym projekcie.