Menu Zamknij
4 kwietnia 2022

Docker - Budowanie zoptymalizowanych obrazów

Wstęp

W trakcie mentoringu uczymy, jak korzystać z Dockera, konteneryzować aplikacje, czy też tworzyć własne mikroserwisy. Jednym z pierwszych poleceń, które poznają uczniowie jest komenda umożliwiająca budowanie obrazu - docker build -t name:tag .

Jednak proces budowania obrazu to jedno, a optymalizowanie tej czynności - tak, aby przebiegała ona jak najsprawniej, to zupełnie oddzielny temat. Wielu początkujących developerów nie jest świadoma tego, jak dobrze pisać pliki Dockerfile i budować z nich właściwe obrazy. Dlatego też w tym artykule wyjdę nieco poza podstawy i omówię, jak ulepszyć, wydawać by się mogło prostą czynność, budowanie obrazów - tak, aby przebiegało to szybko, bezpiecznie i bez zajmowania dużej ilości pamięci. 

Cache-owanie

Niektóre z zewnętrznych zasobów, które wykorzystujemy do budowania obrazu, jak np. externalowe biblioteki czy też zależności, rzadko kiedy ulegają zmianom i modyfikacjom. Przykładowo - o wiele częściej będziemy chcieli wprowadzić zmianę w kodzie i przebudować obraz, aniżeli zmodyfikować listę wykorzystywanych zależności. Tutaj pojawia się właśnie możliwość zoptymalizowania procesu budowania obrazu i wprowadzić cache-owanie odpowiednich zależności. W ten sposób utworzymy oddzielny cache na zewnętrzne dependencje i oddzielny na lokalne zmiany w kodzie. Dlatego w przypadku przebudowywania obrazu, nie będzie konieczne rebuildowanie każdej z warstw obrazu, ale tylko tej, w obrębie której zostały wprowadzone zmiany (lokalne lub związane z bibliotekami).

Aby lepiej zrozumieć sens tego procesu, przeanalizujmy poniższy, nie do końca dobrze napisany obraz:

FROM ... # dowolny obraz zewnętrzny, np. python:3.8

# Kopiowanie wszystkiego na raz
COPY . .

# Java
RUN mvn clean package
# Lub Python
RUN pip install -r requirements.txt
# Lub JavaScript
RUN npm install
# ...
CMD [ "..." ]

Problem z powyższym Dockerfilem dotyczy linii COPY . . Docker wykorzystuje bowiem cache w każdym kroku budowania, dopóki nie uruchomimy komendy, która tworzy nową warstwę w obrazie. Przytoczona wyżej linia powoduje skopiowanie wszystkich plików do oddzielnego layer. Mówiąc o “wszystkich plikach” mam na myśli - plik z listą bibliotek, np. Pythonowy requirements.txt, Javowy pom.xml, JavaScriptowy package.json, ale również pliki z kodem źródłowym. Przez takie podejście dowolna zmiana w kodzie źródłowym (który znajduje się w tej samej warstwie obrazu co niemodyfikowalne biblioteki), będzie wywoływała konieczność przebudowywania warstwy, w którym znajduje się też lista zewnętrznych zależności! 

Rozwiązaniem opisywanego problemu jest rozdzielenie procesu kopiowania na dwa etapy. Najpierw skopiujemy listę wszystkich potrzebnych zależności (która dzięki temu będzie znajdowała się w oddzielnej warstwie i obsługiwana będzie przez oddzielny cache). Następnie skopiowany zostanie kod źródłowy aplikacji. 

Poprawiony Dockerfile może prezentować się następująco:

FROM ... # dowolny obraz zewnętrzny, np. python:3.8

COPY pom.xml ./pom.xml # Java
COPY requirements.txt ./requirements.txt # Python
COPY package.json ./package.json # JavaScript

RUN mvn dependency:go-offline -B # Java
RUN pip install -r requirements.txt # Python
RUN npm install # JavaScript

COPY ./src ./src/ # skopiowanie kodu źródłowego aplikacji
# Pozostała część Dockerfile

Jak widzisz, oddzielnie kopiujemy pliki wskazujące na zależności, które chcemy zainstalować w obrazie oraz indywidualnie kopiujemy pliki źródłowe projektu. Dzięki temu następować będzie oddzielne cache-owanie plików zawierających wszystkie potrzebne zależności oraz niezależnie zasobów przechowujących źródło aplikacji. 

“Odchudzanie” obrazu

W niektórych sytuacjach musimy do naszego obrazu dołączać zewnętrzne, dużo ważące obrazy zewnętrzne. Oczywiste jest, że znacząco zwiększy to czas budowania obrazu, ale może zwiększyć potencjalne zagrożenie atakiem z wykorzystaniem podatności aplikacji. Im większa paczka zewnętrzna i więcej narzędzi i bibliotek ona dostarcza, przez co jesteśmy bardziej podatni na różne vulnerabilities. 

Pierwszym i najprostszym krokiem, które umożliwi tworzenie mniej ważących obrazów jest wykorzystywanie obrazów dystrybucji Alpine Linux zamiast Ubuntu czy też RHEL. Innym dobrym podejściem jest wykorzystanie wielokrokowego (multi-step) podejścia do budowania obrazów. Wówczas możemy wykorzystywać kilka różnych obrazów, np. większy obraz do budowania obrazu, a drugi - lższejszy - do uruchomienia aplikacji. 

Przykład poniżej:

# 332.88 MB
FROM python:3.8.7 AS builder

COPY requirements.txt /requirements.txt
RUN pip install --no-cache-dir -r /requirements.txt

# tylko 16.98 MB
FROM python:3.8.7-alpine3.12 as runner

# skopiuj zależność tylko z pierwszego obrazu
COPY --from=builder /venv /venv
COPY --from=builder ./src /app

CMD ["..."]

Powyższy przykład ukazuje, że możemy najpierw zainstalować aplikację i wszystkie jej zależności w podstawowej wersji Pythona 3.8.7, która jest stosunkowo duża - 332.88 MB. W tym obrazie instalujemy środowisko wirtualne i wszystkie potrzebne przez środowisko biblioteki. Następnie obraz zamieniamy na znacznie lżejszą wersję - Alpine, która waży zaledwie 16.98 MB. Jak widzisz, do tego obrazu kopiujemy całe środowisko wirtualne, które stworzyliśmy uprzednio. W ten sposób mamy dostosowane niewiele ważące środowisko z mniejszą ilością warstwą i narzędzi czy plików binarnych (które charakteryzują tak dużo ważącą wersję środowiska Pythonowego).

Następną dość istotną rzeczą, na którą powinniśmy zwracać uwagę jest ilość warstw obrazu produkowana podczas każdego buildu. FROM, COPY, RUN i CMD są czterema komendami, które tworzą oddzielne warstwy. Nie muszę chyba tłumaczyć, że więcej warstw to większa przestrzeń pamięci niezbędna do zarezerwowania oraz dłuższy czas budowania. Jak już wiesz - często będziemy chcieli tworzyć oddzielne warstwy obrazu do cache-owania czy oddzielnego przetwarzania zmian, jednak raczej chcielibyśmy uchronić się przed koniecznością tworzenia oddzielnej warstwy przy każdym wywołaniu RUN. Wówczas warto rozważyć przeprowadzenie instalacji kolejnych paczek, łącząc polecenia znakami &&

# Utworzenie 4 warstw - NOK!
RUN yum --disablerepo=* --enablerepo="epel"
RUN yum update
RUN yum install -y httpd
RUN yum clean all -y

# Utworzenie tylko 1 warstwy - OK!
RUN yum --disablerepo=* --enablerepo="epel" && \
    yum update && \
    yum install -y httpd && \
    yum clean all -y

Zwiększanie bezpieczeństwa obrazów

Szybkość i optymalny rozwój to główne czynniki, na których skupiają się developerzy, tworząc obrazy Dockerowe. Równie ważnym aspektem podczas budowania Dockerowych image-ów, na który powinniśmy zwracać uwagę jest bezpieczeństwo. 

Główną rekomendacją, aby zapewnić podstawowe bezpieczeństwo tworzonych image-ów jest “zablokowywanie” wersji wszystkich bibliotek, paczek, narzędzi czy bazowych obrazów. Jeśli bowiem używać będziemy tagu :latest przy zaciąganiu obrazów bazowych, czy też nie sprecyzujemy jasno wersji bibliotek w Pythonowym requirements.txt lub JavaScriptowym package.json, ryzykujemy, że pobrany podczas budowania obraz/biblioteka, może być niekompatybilny z kodem aplikacji czy też może wprowadzać do naszego kontenera szereg podatności (które pojawiły się ze świeżą wersją danej paczki, a twórcy nie zdążyli ich jeszcze załatać).

Bądź świadomy jednak też tego, że w momencie, gdy lockujemy (w requirementach aplikacji czy też Dockerfile-u) wersje bibliotek i obrazów, musimy mimo wszystko zadbać o to, aby co jakiś czas aktualizować wszystkie zależności, aby mieć pewność, że pracujemy na wszystkich łatkach i fixach bibliotek.

Nie zawsze jednak jesteśmy przewidzieć wszystkie potencjalne do wystąpienia zagrożenia i podatności. Dlatego też, aby ograniczyć możliwość ataku, najlepiej jest nie uruchamiać kontenerów jako root. Powinniśmy zatem zawrzeć w Dockerfile komendę USER 1001, aby wskazać, że aplikacje muszą być uruchamiane przez użytkowników o  nieadministratorskich uprawnieniach. Takie postępowanie może wymagać od Ciebie wprowadzenia nieco modyfikacji w aplikacji, jak i wybranie odpowiedniego base image-u, ponieważ przykładowo - nginx wymaga do uruchomienia uprawnień roota.

Powyższe rady i wskazówki mogą Cię jedynie w części uchronić przed tworzeniem podatnych na ataki kontenerów, prawda jest taka, że nigdy nie jesteśmy w stanie w pełni zabezpieczyć się przed lukami w zabezpieczeniach. Dlatego często warto jest korzystać z absolutnego minimum narzędzi i funkcjonalności w obrębie naszego kontenera. Z takiego założenia wychodzi również Google, który stworzył zestaw obrazów - Distroless. Tego typu obrazy ograniczają narzędzia systemowe do minimum - charakteryzują się bowiem brakiem shell-ów czy pakiet menedżerów. Dzięki temu są one o wiele bardziej bezpieczne niż rodzina Debian czy Alpine-based obrazów. 

Poniżej przykład wykorzystania omawianego typu obrazów w muli-step buildzie:

FROM ... AS builder
#...
# Python
FROM gcr.io/distroless/python3 AS runner
# Golang
FROM gcr.io/distroless/base AS runner
# NodeJS
FROM gcr.io/distroless/nodejs:10 AS runner
# Rust
FROM gcr.io/distroless/cc AS runner
# Java
FROM gcr.io/distroless/java:11 AS runner
# ...
# Przykłady na https://github.com/GoogleContainerTools/distroless/tree/master/examples

Z tego miejsca warto również zaznaczyć, że podatne na ataki nie są tylko i wyłącznie obrazy oraz powstające z nich kontenery, ale również Docker daemon, który używamy do budowania obrazów. Wówczas dobrym sposobem zabezpieczenia się jest niezezwalanie Dockerowi uruchamiania siebie w trybie root user, tylko w trybie rootless. Tutaj znajdziesz więcej informacji, jak tego dokonać: https://docs.docker.com/engine/security/rootless/ 

Dobrą alternatywą, która sprawdzi się, gdy nie chcesz przeprowadzać złożonego procesu konfiguracji może być podman, który domyślnie uruchamiany jest w rootless i daemonless trybie.

Optymalizowanie procesów budowania obrazów umożliwiają bardziej trywialne kroki związanych z pracą z Dockerem. Są to:

- Implementacja pliku .dockerignore

Plik ten umożliwia excludowanie plików i katalogów, które nie są niezbędne do budowania obrazów. 

- Sortowanie wielolinijkowych argumentów

Tam gdzie to możliwe, staraj się sortować argumenty przy wielolinijkowych poleceniach. Taki zabieg umożliwia uniknięcie duplikacji paczek i zapewnia lepszą zarządzalność plików.

RUN apt-get update && apt-get install -y \

  bzr \

  cvs \

  git \

  mercurial \ 

  subversion \

  && rm -rf /var/lib/apt/lists/*

- Wspomaganie się narzędziami zewnętrznymi

Nierzadko problemy mogą pojawiać się bezpośrednio z niewłaściwie napisanego Dockerfile-a, w którym kod oprzemy o antywzorce i niedopatrzenia. Tutaj z pomocą przychodzą takie narzędzia jak checkov, Conftest, trivy oraz hadolint, które są linterami dla plików Dockerfile-owych. Tego typu toole dostarczą Ci liczne wskazówki, które powinieneś rozważyć w konkteście poprawienia Dockerfile-a i napisania go zgodnie z najlepszymi wzorcami.

Sprawdź również nasz system mentorowania i outsourcowania specjalistów
  • Wyznaczona ścieżka od A do Z w kierunku przebranżowienia
  • Indywidualnie dostosowany materiał pod ucznia
  • Code Review ze strony mentora
  • Budowa portfolio
  • Intensywna praca z materiałami szkoleniowymi