Oskar Dudycz

Pragmatycznie o programowaniu

Dockery mają warstwy, czyli jak optymalnie budować swój projekt w Docker

2021-01-18 oskar dudyczDevOps

cover

Cześć!

Jakiś czas temu zorientowałem się, że w Internecie ciągle wiszą moje posty z 2011 roku o konfiguracji i działaniu w SCRUM przy pomocy TFS (zobacz tutaj i https://oskar-dudycz.pl/2011/11/09/scrum-i-team-foundation-system-cz1/, https://oskar-dudycz.pl/2011/11/22/scrum-i-team-foundation-server-cz4/). W zasadzie to raczej nie powinienem się może tym wielce chwalić, bo w obecnych czasach może to być nieco wstydliwe. Po co Ci o tym piszę? Nie wiem czy to przebija z moich maili, ale jestem dużym fanem Continuous Integration i Delivery.

W zasadzie przez moją karierę przewinąłem się przez wiele platform. Zaczynając przez Hudson (obecnie Jenkins), TFS, Bamboo (ochyda), Jenkins, Azure Devops, GitHub Actions itd. Jakoś tak się od początku składało, że nic nie irytowało mnie tak jak ręczne kopiowanie plików na FTP, uruchamianie jakiś magicznych skryptów i ogólnie taniec wdrożeniowy z prośbą o pomyślne zakończenie. Gdy zaczynałem w 2007 roku czasy były na wskroś dzikie. Niektórzy mówią, że “kiedyś to było, człowiek musiał wiedzieć jak się pisze skrypty i jak działa deployment, itd. itp.”. Z mojej perspektywy lepiej, że teraz nie trzeba tego wiedzieć i narzędzia są lepsze. Według mnie im więcej powtarzalnych, nudnych czynności gdzie ktoś manualnie musi wykonać tym większa szansa na to, że popełni błąd.

Co łączy moje upodobanie do architektur opartych na zdarzeniach i CI/CD? Skupienie na esencji - czyli zapewnieniu, żeby system działał w odpowiedni sposób. Zarówno biznesowo, jak i technicznie. Chodzi o to, żebyśmy mogli się skupić na dowożeniu wartości biznesowej, a nie na innych, mniej ważnych czynnościach.

Chciałbym się dzisiaj podzielić z Tobą konfiguracją pliku Dockera, którą zrobiłem niedawno dla swojego repozytorium z przykładami do Event Sourcing. Myślę, że trzy aspekty, które tam użyłem mogą przydać się i Tobie. Tutaj PR: https://github.com/oskardudycz/EventSourcing.NetCore/pull/35/files.

  1. Warstwy i dlaczego warto o nich pamiętać.
  2. Budowanie wielokrokowe (multi-stage build).
  3. Wdrożenie obrazu do repozytorium

W Docker rozróżniamy kilka podstawowych elementów (pozwól, że skupię się na rzeczach najważniejszych dla programisty i będe nieco upraszczał).

Definicji obrazu kontenera - czyli to co znajduje się w pliku DOCKERFILE. Możemy to porównać do instrukcji złożenia stołu. Po kolei opisujemy tam jakie kroki muszą być wykonane aby poszczególne elementy złożyć w jedną całość. Obraz kontenera jest to już zmontowany stół, który gdzieś tam leży w magazynie. Pobranie dockera (docker pull) w tym przykładzie to dostarczenie obrazu z magazynu do domu. Uruchomienie (docker run) to odpakowanie tego stołu i używanie go.

To co ważne (i również łączy się pośrednio z Event Sourcing) to to, że w kontenerach wszystko jest niezmienialne (immutable). Podobnie jak raz spakowany stół na magazynie nikt nie powinien ruszać dopóki nie otworzy go kupujący tak i obrazy nie powinny (i nie mogą) być modyfikowane.

To samo dotyczy też poszczególnych części. Jeżeli montujemy stół to zwykle polega to na tym, że “zmontuj najpierw jedną nogę, potem drugą, trzecią, czwartą, blat a na końcu skręć to w jedną całość”. Jak już to skleiliśmy lub skręciliśmy to też staramy się już raczej tego nie zmieniać bo możemy potem tego nie skręcić z powrotem.

Podobnie jest w Docker. Gdy definiujemy obraz kontenera to każda instrukcja (linia) tworzy nową “warstwę”. Warstwę w zbudowanym możemy porównać do takiej skręconego blatu, do którego będziemy potem przykręcać nogi.

Dlaczego o tym mówię? Bo takie warstwy warto układać po kolei od najrzadziej zmienialnych do najczęściej. Zwykle proces budowy projektu w Docker polega na:

  1. Skopiuj pliki projektu.
  2. Uruchom proces budowania.
  3. Użyj artefaktów i uruchom projekt.

W .NET możemy zbudować wszystko i wygenerować artefakty komendą dotnet publish. Warto jednak podzielić te kroki na osobne kroki. Czyli:

  • instalacja pakietów (dotnet restore),
  • zbudowanie projektu (dotnet build),
  • utworzenie artfektów/publish (dotnet publish).

Dlaczego? Instalacja pakietów nie wymaga całego projektu. Aby ją dokonać wystarczy nam tylko plik projektu. Dlaczego jest to ważne? Bo Docker jest na tyle sprytny, że jeśli najpierw skopiujemy plik projektu i uruchomimy instalację pakietów, to on przy kolejnych buildach będzie wywoływał ten krok tylko jeśli zmienił się skopiowany przez nas plik projektu (czyli np. dodaliśmy nowy pakiet, zaktualizowaliśmy jego wersję itd.). Możemy dzięki temu urwać cenne czasy z builda.

Dlatego właśnie najpierw w swoim PR pobieram wszystkie pliki projektów używanych w solucji, a następnie uruchamiam restore.

COPY ./Core/Core.csproj ./Core/
COPY ./Core.Marten/Core.Marten.csproj ./Core.Marten/
COPY ./Sample/Tickets/Tickets/Tickets.csproj ./Sample/Tickets/Tickets/
COPY ./Sample/Tickets/Tickets.Api/ ./Sample/Tickets/Tickets.Api/

# Restore nuget packages
RUN dotnet restore ./Sample/Tickets/Tickets.Api/Tickets.Api.csproj

Jeśli zmieni mi się jakiś plik w projekcie to ten fragment nie będzie już wywołany. Docker użyje już wczesniej wygenerowanej warstwy.

Dopiero potem kopiuje pliki projektów i uruchamiam build. Kluczowy tutaj jest parametr —no-restore, który spowoduje, że nie będą ponownie pobierane pakiety.

# Copy project files
COPY ./Core ./Core
COPY ./Core.Marten ./Core.Marten
COPY ./Sample/Tickets/Tickets ./Sample/Tickets/Tickets
COPY ./Sample/Tickets/Tickets.Api ./Sample/Tickets/Tickets.Api

# Build project with Release configuration
# and no restore, as we did it already
RUN dotnet build -c Release --no-restore ./Sample/Tickets/Tickets.Api/Tickets.Api.csproj

Na koniec wywołuję publish, również bez —no-build który generuje mi pliki binarne. Gdybym zmienił definicję tej komendy w pliku definicji (np. dodał inny przełącznik), a inne pliki się nie zmieniły to nie byłoby konieczności przebudowy wszystkiego od nowa.

Moglibyśmy w teorii takiego dockera wypuścić, ale taki obraz zawierałby wszystkie skopiowane przez nas pliki. Co więcej używałby obrazu bazowego, który więcej waży - ma bowiem zainstalowane SDK .NET.

FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine

Jak wiesz trochę to waży, nawet lokalny system na Twoim lapku zanim zaczniesz instalować wszystkie programistyczne narzędzia jest lżejszy. Co więcej do uruchomienia .NET nie potrzebujemy mieć SDK - wystarczy nam po prostu środowisko uruchomieniowe .NET (analogicznie jest z innymi platformami).

Na szczęście Docker ma możliwość wielokrokowego budowania. Na czym to polega? W skrócie - używając FROM mówimy jaki obraz ma być naszym obrazem bazowym. W dużym uproszczeniu możemy powiedziec, że taki obraz to po prostu definicja co już będziemy mieć zainstalowane i skonfigurowane na naszym systemie.

Takich FROM możemy mieć w naszej definicji builda więcej niż jeden. Gdy tak zrobimy to osiągniemy po prostu kolejny obraz definiowany “na boku”. To co jest fajne to ma on dostęp do wszystkich pozostałych, które zdefiniowaliśmy wcześniej.

Możemy zatem użyć cięższego obrazu z SDK, wygenerować w nim artefakty, a następnie użyć lżejszego obrazu, który ma minimalną liczbę zależności (np. tylko środowisko uruchomieniowe). Możemy dzięki temu skopiować do niego wygenerowane wcześniej pliki.

Tak właśnie robi ostateczna definicja

# pierwszy, cięższy obraz do budowania kodu
FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS builder

# (...)
RUN dotnet publish -c Release --no-build -o out

# drugi, ostateczny, lżejszy obraz
FROM mcr.microsoft.com/dotnet/aspnet:5.0-alpine

# Skopiuj wygenerowane pliki z obrazu buildera
COPY --from=builder /app/Sample/Tickets/Tickets.Api/out .

# uruchom aplikację
ENTRYPOINT ["dotnet", "Tickets.Api.dll"]

Jak pewnie widzisz, na koniec uruchamiamy nasz projekt. To też jedna z “najlepszych praktyk”, żeby ktoś mógł po prostu zrobić docker run i uruchomić projekt z domyślnymi ustawieniami.

Tak zbudowany obraz możemy używać do wdrożenia i uruchomienia w środowisku ostatecznym. Aby to zrobić potrzebujemy to wepchać do repozytorium. Domyślne i najpopularniejsze to DockeHub, jak opublikować obraz przy pomocy GitHub actions możesz zobaczyć szerzej w moim repo: https://github.com/oskardudycz/WebApiWith.NETCore#building-and-pushing-image-to-docker-registry-1

Możemy taki obraz również odpalać do testów zarówno manualnych jak i automatycznych (tak robiliśmy w jednym z moich poprzednich projektów, co miało swoje wady i zalety).

Podsumowując - dzięki kilku prostym trickom możemy sprawić, że czas budowy naszego obrazu znacząco się skróci. Dodatkowo nie będzie zawierał on masy niepotrzebnych rzeczy przez co jego pobieranie i uruchomienie będzie szybsze. Zasady te można zastosować do dowolnej platformy np. NodeJS, Java, itd. Czas to pieniądz!

Jeśli ten opis to za mało, to polecam odcinek Ostrej Piły z Łukaszem Kałużnym i Damianem Naprawą - świetnie z tym tematem się rozprawili https://ostrapila.pl/kontenery-dockery-i-inne.

Swoją drogą to nagrałem i ja odcinek u Jarka i Pawła - szykuj się, będzie to koło 4h (zobaczymy jak po miksie) - przemaglowaliśmy Architektury Oparte na Zdarzeniach!

Pozdrawiam! Oskar

p.s. jak co tydzień zachęcam do lektury nowego wpisu na blogu: “How (not) to cut microservices” - https://event-driven.io/en/how_to_cut_microservices/ p.s.2 …oraz nowego Architecture Weekly - https://github.com/oskardudycz/ArchitectureWeekly#18th-january-2021

  • © Oskar Dudycz 2019-2020