Backend

Najlepsze praktyki tworzenia Dockerfile

Narodziny Dockera możemy uznać jako kolejną rewolucję w świecie IT. Mocno przyczynił się do rozwoju konteneryzacji, która obecnie jest powszechnie znana i stosowana. Zaledwie w ciągu kilku lat od powstania Dockera zdobył zainteresowanie milionów użytkowników na całym świecie. Od czego zacząć przygodę z nim? Od stworzenia pliku Dockerfile!

Damian Naprawa. Senior Software Engineer w Capgemini. Praktykujący pasjonat konteneryzacji. Z Dockerem pracuje na co dzień od kilku lat. Prowadzi autorskie szkolenia i warsztaty oraz dzieli się wiedzą na blogu szkoladockera.pl. Uczestnik globalnego programu Docker Enablement. Odpowiedzialny za tworzenie i utrzymanie systemów działających w oparciu o kontenery. Fan automatyzacji oraz podejścia „As a Code”. Autor kompleksowego programu online dockermaestro.pl.


1. Każdy obraz powinien mieć pojedynczą odpowiedzialność

Projektując swoje obrazy (docker image), stosuj zasadę SRP (Single Responsibility Principle). Kontenery zostały stworzone do pakowania do nich pojedynczych komponentów/aplikacji.

Przykład: Tworzysz aplikację opartą o Node.js, ReactJS i MySQL. Nie należy “pakować” ich do jednego obrazu (a co się później z tym wiąże do jednego kontenera). Wprowadzi to trudności w debugowaniu, potencjalne problemy oraz w razie potrzeby, brak możliwości skalowania aplikacji Node.js / ReactJS.

Stwórz więc osobny obraz dla aplikacji Node.JS, osobny dla aplikacji frontendowej w ReactJS oraz użyj oficjalnego obrazu MySQL (dopóki nie masz konkretnych powodów aby stworzyć własny obraz).

2. Korzystaj z pliku .dockerignore

.git
node_modules
build

Na pewno kojarzysz i wiesz do czego służy plik .gitignore w przypadku systemu kontroli wersji Git. Aby wykluczyć niepotrzebne pliki z procesu budowania obrazu (bez konieczności zmian twojego katalogu), użyj pliku .dockeringore. Docker tworząc to rozwiązanie, wzorował się na .gitignore (które to podejście zapewne doskonale znasz). Przykładem mogą być zbudowane paczki lub pliki wykonywalne (.dll, .exe), utworzone lokalnie podczas developmentu aplikacji.

Przygotowując kontener, nie chcemy ich kopiować. Chcemy, by zostały one zbudowane na nowo wewnątrz kontenera.

3. Korzystaj z oficjalnych obrazów

Ten kod:

FROM ubuntu
RUN wget -qO- https://packages.microsoft.com/keys/microsoft.asc 
| gpg --dearmor > microsoft.asc.gpg 
&& sudo mv microsoft.asc.gpg /etc/apt/trusted.gpg.d/ 
&& wget -q https://packages.microsoft.com/config/ubuntu/18.04/prod.list 
&& sudo mv prod.list /etc/apt/sources.list.d/microsoft-prod.list 
&& sudo chown root:root /etc/apt/trusted.gpg.d/microsoft.asc.gpg 
&& sudo chown root:root /etc/apt/sources.list.d/microsoft-prod.list
RUN sudo apt-get install dotnet-sdk-3.1

Zastąp tym:

FROM mcr.microsoft.com/dotnet/core/runtime
COPY . /app
ENTRYPOINT [“dotnet”, “aspnetcore-app”.dll]

Zamiast samodzielnie tworzyć obrazy, jeżeli to tylko możliwe, używaj oficjalnych obrazów dostępnych na Docker Hubie. Zaoszczędzisz dzięki temu masę czasu. Z reguły oficjalne obrazy mają w sobie wszystko to czego potrzebujesz. Dodatkowo, gdy pracujesz nad wieloma projektami jednocześnie, mogą one korzystać z wspólnych warstw, ponieważ używają dokładnie tego samego obrazu bazowego.

4. Tag “latest” to zło

Ten kod:

FROM mcr.microsoft.com/dotnet/core/runtime:latest

Zastąp tym:

FROM mcr.microsoft.com/dotnet/core/runtime:3.1
COPY . /app
ENTRYPOINT [“dotnet”, “aspnetcore-app”.dll]

Pod żadnym pozorem nie opieraj się na obrazach z tagiem “latest” (no chyba, że lubisz ryzyko). Ta zasada ma zastosowanie w zasadzie w każdej dziedzinie programowania i każdy świadomy developer powinien również o niej pamiętać tworząc Dockerfile.

Dlaczego? Pod tagiem “latest” znajduje się najnowszy na daną chwilę obraz. Po jakimś czasie może się okazać, że Twoja aplikacja działająca wewnątrz kontenera przy kolejnym wdrożeniu działa inaczej, albo nie działa w ogóle.

Powód? Na Docker Hubie pojawiła się nowsza wersja obrazu i tag “latest” wskazuje na inną wersję, niż w momencie, w którym po raz pierwszy go używałeś.

5. Poszukaj minimalnego obrazu

Ten kod:

FROM mcr.microsoft.com/dotnet/core/runtime:3.1

Zastąp tym:

FROM mcr.microsoft.com/dotnet/core/runtime:3.1-buster-slim

COPY . /app
ENTRYPOINT [“dotnet”, “aspnetcore-app”.dll]

Ciekawym rozwiązaniem może wydawać się użycie obrazu bazującego na alpine. Co nam to daje? Rozmiar finalnego obrazu naszej aplikacji będzie kilkakrotnie mniejszy. Przykładowo zamiast 100+ MB, będzie to zaledwie 10-20 MB. Należy jednak na to uważać. W obrazach bazujących na alpine mogą pojawić się problemy wydajnościowe oraz podatności pod kątem bezpieczeństwa. Więcej na ten temat znajdziesz TUTAJ.

6. Unikaj kopiowania wszystkiego

Ten kod:

FROM mcr.microsoft.com/dotnet/core/runtime:3.1-buster-slim

COPY . /app

ENTRYPOINT [“dotnet”, “aspnetcore-app”.dll]

Zastąp tym:

FROM mcr.microsoft.com/dotnet/core/runtime:3.1-buster-slim

COPY /target/aspnetcore-app.dll /app

ENTRYPOINT [“dotnet”, “aspnetcore-app”.dll]

Kopiuj tylko to, co jest absolutnie potrzebne. Jeżeli to możliwe, unikaj polecenia „COPY .” (czyli kopiowania wszystkiego).

Każda zmiana w kopiowanych plikach spowoduje konieczność ponownego ich budowania, zamiast korzystania z cache.

7. Kolejność instrukcji ma znaczenie

Po pierwsze, finalny obraz składa się z wielu warstw. Po drugie, sposób w jaki działa mechanizm cache’owania można porównać do stosu.

Każda następna warstwa jest zależna od warstwy poprzedniej. Jeżeli zmieni nam się tylko ostatnia warstwa stosu, po prostu ją ściągamy i wstawiamy nową. Jeżeli jednak zmieni nam się pierwsza warstwa, musimy zdjąć ze stosu wszystkie pozostałe, by dostać się do tej na samym dole. W skrócie oznacza to, że element naszej aplikacji, który zmienia się najczęściej, powinien być ostatnim krokiem podczas budowania obrazu. Takim elementem najczęściej jest kod źródłowy naszej aplikacji.

8. Załaduj zależności w osobnym kroku

FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster
COPY ./src/aspnetcore-app.csproj ./
RUN dotnet restore # (załadowanie zależności w .NET)
COPY ./src ./
RUN dotnet publish -c Release -o out
WORKDIR /out
ENTRYPOINT [“dotnet”, “aspnetcoreapp”.dll]

Ponownie kłania się mechanizm cache’owania. Po pewnym czasie, podczas developmentu naszej aplikacji, dodajemy tylko nową logikę biznesową. Zależności nie zmieniają się tak często jak zmienia się kod źródłowy. Dobrą praktyka zatem jest załadowanie zależności w osobnym kroku (wcześniej niż kopiowanie kodu źródłowego).

9. Ogranicz ilość warstw

Ten kod:

RUN curl -SL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.gz" --output nodejs.tar.gz
RUN echo "$NODE_DOWNLOAD_SHA nodejs.tar.gz" | sha256sum -c -
RUN tar -xzf "nodejs.tar.gz" -C /usr/local --strip-components=1
RUN rm nodejs.tar.gz
RUN ln -s /usr/local/bin/node /usr/local/bin/nodejs

Zastąp tym:

RUN curl -SL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.gz" --output nodejs.tar.gz 
	&& echo "$NODE_DOWNLOAD_SHA nodejs.tar.gz" | sha256sum -c - 
	&& tar -xzf "nodejs.tar.gz" -C /usr/local --strip-components=1 
	&& rm nodejs.tar.gz 
	&& ln -s /usr/local/bin/node /usr/local/bin/nodejs

Każda instrukcja RUN znajdująca się wewnątrz Dockerfile może zostać potraktowana jako osobna warstwa. Gdy instalujemy nowe paczki używając package-managerów, zaleca się zrobić to w jednym kroku. Utworzą one wtedy jedną cache’owalną warstwę.

Dlaczego jest to takie ważne? Po zapisaniu obrazu na dysk, każda warstwa jest zapisywana osobno. Im więcej warstw, tym więcej miejsca będzie finalnie zajmował nasz obraz.

Więcej na ten temat budowy obrazu oraz przechowywania warstw na dysku możesz dowiedzieć się z tego video, w którym to dokładnie tłumaczę.

Używaj wieloetapowego budowania obrazów

FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build-env
WORKDIR /app
COPY *.csproj ./
RUN dotnet restore
COPY . ./
RUN dotnet publish -c Release -o out 
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
WORKDIR /app
COPY --from=build-env /app/out .
ENTRYPOINT ["dotnet", "aspnetapp.dll"]

Ostatnia, ale chyba najważniejszych praktyka. Multi-stage builds.

Jak go rozpoznać? Jeżeli polecenie FROM występuje w Dockerfile więcej niż raz. Każde FROM oznacza początek nowego etapu (stage’a). Mogą być one nazwane przy pomocy słowa AS. W tym przykładzie fazę budowania artefaktów nazwano „build-env”. Następnie zbudowane artefakty są przenoszone do finalnego obrazu za pomocą COPY --from=build-env.

Jakie są zalety multi-stage build?

Po pierwsze, budując/kompilując artefakty wewnątrz kontenera, nie jest się zależnym od środowiska/OS, gdzie obraz jest budowany.

Drugą istotną cechą jest „lekkość” finalnego obrazu.

Dlaczego? Każda instrukcja w pliku Dockerfile dodaje kolejną warstwę do obrazu. Kopiując gotowe artefakty ze pierwszego etapu, pozbywamy się całej reszty, która nie jest potrzebna w etapie drugim. Dzięki temu, nasz obraz produkcyjny będzie lekki – co skróci nam czas jego pobierania i wczytywania.

Podsumowanie

Jak widzisz, tworzenie Dockerfile może być proste i przyjemne, pod warunkiem, że wiemy jak to robić. Gorąco zachęcam do przejrzenia swoich dotychczasowych Dockerfile i podjęcia próby ich optymalizacji. Szczególnie warto przyjrzeć się obrazom bazowym, z których korzystasz. Mają one realny wpływ na rozmiar obrazu i finalne działanie aplikacji wewnątrz kontenera.


Chciałbyś dowiedzieć się więcej o Dockerze i ciekawych rozwiązaniach? Od początku roku 2020, regularnie raz w tygodniu publikuję nowe treści na blogu szkoladockera.pl i/lub na moim kanale na YouTube. Zachęcam również do śledzenia mnie w social media, gdzie informuję o moich publicznych wystąpieniach (zarówno online jak i offline). Do usłyszenia!

Zdjęcie główne artykułu pochodzi z unsplash.com.

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://justjoin.it/blog/najlepsze-praktyki-tworzenia-dockerfile" order_type="social" width="100%" count_of_comments="8" ]