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.
Spis treści
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.