Jak tworzyć wieloetapowe obrazy Docker
Utrzymywanie wielu wersji pliku Dockerfile w projekcie na cele developmentu, testowania i produkcji, jest dość uciążliwe. Na szczęście mamy możliwość wieloetapowej budowy obrazów Docker. Wykorzystując poszczególne etapy, możemy następnie budować różne wersje programu oraz tworzyć podstawowe procesy CI wewnątrz pliku Dockerfile.
Wiele wersji pliku, czyli co nas czeka bez Multistage Dockerfile
Weźmy na tapet aplikację napisaną w React.
Jest to prosta aplikacja internetowa "Shopping List" i na jej potrzeby stworzymy dwa obrazy:
- wersja developerska- w procesie developmentu musimy uruchomić proces do obserwowania zmian plików i przebudowy aplikacji
npm start
- wersja produkcyjna- potrzebujemy zbudować aplikację i zaserwować ją z wykorzystaniem paczki serve i uruchomienie procesu
serve -s -l 3000
Przy podejściu utrzymywania wielu plików Dockerfile, mielibyśmy takie dwa pliki:
Pierwszy plik:
FROM node:14-alpine
RUN apk update && apk upgrade
RUN mkdir /code
WORKDIR /code
EXPOSE 3000
CMD ls && \
npm i && \
npm start
Drugi plik:
FROM node:14-alpine
RUN apk update && apk upgrade
RUN npm install -g serve && \
mkdir /code && \
mkdir /code/tmp
COPY ./ /code/tmp
RUN cd /code/tmp && \
npm i && \
npm run build && \
mv /code/tmp/build/* /code && \
rm -r /code/tmp
WORKDIR /code
EXPOSE 3000
CMD serve -s -l 3000
Tworzenie Multistage Dockerfile
Spróbujmy teraz połączyć to w jeden plik z wieloetapową budową.
Trzeba pamiętać, że w pliku Dockerfile może być wiele sekcji FROM
i każda sekcja jest wykonywana po kolei, dzięki temu możemy tworzyć obrazy pośrednie wykorzystywane przez kolejne etapy.
Jeżeli na jednym z etapów dojdzie do błędu, to kolejne kroki nie zostaną uruchomione i cały proces zostanie przerwany, więc mamy pewność, że jeżeli np. testy nie przejdą pozytywnie, to nie zostanie zbudowany nowy obraz z aplikacją.
Przeanalizujmy kolejne etapy w takim pliku:
- Pierwszy etap
FROM node:14-alpine as base-image
RUN apk update && apk upgrade && \
npm install -g serve
RUN mkdir /code
WORKDIR /code
Pobieramy obraz bazowy i nazywamy ten etap jako **base-image**, dzięki czemu na kolejnych etapach będziemy mogli jako bazę wybrać to, co teraz zbudowaliśmy.
Dodatkowo w obrazie bazowym zainstalowaliśmy niezbędne elementy i przygotowaliśmy katalog na nasz kod z aplikacją.
- Drugi etap
FROM base-image as code
COPY . /code
RUN npm i
Wykorzystaliśmy poprzedni obraz i skopiowaliśmy kod naszej aplikacji, a następnie zainstalowaliśmy dla niej niezbędne pakiety. Dzięki temu na kolejnych etapach będziemy mieli przygotowany kod wraz z zależnościami oraz będziemy mieli pewność, że na każdym etapie CI będziemy wykorzystywać ten sam kod i pakiety.
- Trzeci etap
FROM code as dev-image
CMD npm start
Tutaj przygotowujemy sobie możliwość zbudowania obrazu developerskiego - dla procesu CI jest to zbędny krok, ale - jak widać - nie jest to długi proces, bo bazuje na zbudowanym wcześniej obrazie. Dokładamy tylko odpowiednie polecenie do uruchomienia procesu przy starcie kontenera.
- Czwarty etap
FROM code as test-image
ENV CI=true
RUN npm run test
Kolejny etap to uruchomienie testów.
W przypadku aplikacji Reactowej konieczne jest dla tego procesu ustawienie zmiennej środowiskowej CI na true, aby proces się zakończył po wykonaniu testów.
- Piąty etap
FROM code as tmp-build
RUN npm build
Gdy testy przeszły, pora zbudować wersję docelową w oparciu o przetestowany kod.
Robimy to w osobnym kroku, ponieważ chcemy, aby w końcowym obrazie były tylko niezbędne pliki.
- Szósty etap
FROM base-image as prod-image
COPY --from=tmp-build /code/build /code
EXPOSE 3000
CMD serve -s -l 3000
Ostatni etap to już zbudowanie docelowego obrazu, gdzie kopiujemy z poprzedniego kroku tylko wymagane pliki, oraz ustalamy jaki proces ma się uruchomić przy starcie kontenera.
Mając tak przygotowany plik Dockerfile, mamy pewność, że przy aktualizacji procesu budowy nie przegapimy aktualizacji któregoś z plików do budowy obrazów.
Budowanie obrazu Docker z wyborem docelowego etapu
Przejdźmy teraz do procesu budowy, bo - jak zostało już wspomniane - możemy w oparciu o ten plik zbudować różne obrazy, i tak np. w celu zbudowania tylko obrazu developerskiego możemy w poleceniu docker build
podać docelowy etap budowy:
docker build --target dev-image -t shopping-list-dev
Oczywiście w celu ułatwienia pracy w praktyce wykorzystamy np. docker-compose
:
version: '3'
services:
shopping-list:
build:
context: ..
dockerfile: .docker-multistage/Dockerfile
target: dev-image
container_name: shopping-list-dev
ports:
- "80:3000"
restart: always
tty: true
volumes:
- ../:/code
Oczywiście, gdy będziemy chcieli uruchomić cały proces to uruchamiamy polecenie docker build
już bez parametru --target
.
Wieloetapowa konfiguracja Dockerfile - podsumowanie
Jak widać na powyższym przykładzie, wykorzystanie wieloetapowej konfiguracji Dockerfile pozwala w przyjemny sposób zarządzać procesem tworzenia obrazów.
Oczywiście nie jest to podejście w 100% idealne, bo niestety nie pozwala na np. równoległe uruchamianie testów w trakcie procesu CI/CD.
W sytuacji, gdy należy uruchomić dużo testów, można rozbudować powyższy proces o podejście GitOps, czyli budowanie pipelinów w oparciu o np. akcje na Githubie, czy CI/CD na Gitlabie. To podejście z wykorzystaniem plików konfiguracyjnych yaml usprawni i zautomatyzuje procesy CI/CD. Jest to jednak osobny temat. Poruszamy go na kursie: Docker - wstęp do konteneryzacji.
Wszystkie pliki można znaleźć w repozytorium na gałęzi docker-localStorage
w odpowiednich katalogach zaczynających się od .docker-
.