Motywacją do napisania tego artykułu były moje własne problemy przy wykorzystywaniu tego narzędzia w pracy oraz to, że w tamtym czasie (listopad 2020) nigdzie nie mogłem znaleźć konkretnych rozwiązań problemów jakie napotkałem. Dokumentacja projektu dopiero się tworzyła i w wielu miejscach była nie wystarczająca, a artykuły, które czytałem głównie skupiały się na "Happy path". Oczywiście nie jestem w stanie opisać wszystkich możliwych problemów jakie mogą wystąpić. Podzielę się tymi, które mi przysporzyły sporo nerwów oraz pokażę, jak udało mi się je rozwiązać.

A więc co może pójść nie tak?

Jeśli jeszcze tego nie zrobiłeś/łaś to zachęcam do przeczytania poprzedniego artykułu, w którym wyjaśniam co kryje się pod nazwą Cloud Native Buildpacks, jak to działa i co nam daje. Artykuł dostępny jest (tutaj).

Moja przygoda z CNB zaczęła się wraz z wprowadzeniem go do pluginu Spring Boot. Los chciał, że zbiegło się to w czasie z poszukiwaniem skutecznego sposobu na skonteneryzowanie kilkunastu aplikacji. W tamtym momencie do wyboru miałem Dockerfile, Jib i docker maven plugin(pewnie coś jeszcze by się znalazło) oraz wyżej wspomniany Cloud Native Buildpack, który stał się ciekawą ustrukturyzowaną alternatywą dla wcześniejszych opcji. Jednak technologia ta była/jest stosunkowo młoda i w niektórych przypadkach potrzebuje dodatkowej konfiguracji, aby działać poprawnie.

Brak dostępu do Internetu w trakcie budowania

Jakie było moje zdziwienie, gdy zbudowałem aplikacje lokalnie przeszła testy oraz code review i nagle dostaje maila, że build na serwerze CI się nie powiódł. Myślę sobie o co chodzi przecież "u mnie działa 😮", zajrzałem do logów budowania i moim oczom ukazało się coś takiego:

===> BUILDING   Paketo BellSoft Liberica Buildpack 8.2.0 https://github.com/paketo-buildpacks/bellsoft-liberica Build Configuration: $BP_JVM_VERSION 11 the Java version Launch Configuration: $BPL_JVM_HEAD_ROOM 0 the headroom in memory calculation $BPL_JVM_LOADED_CLASS_COUNT 35% of classes the number of loaded classes in memory calculation $BPL_JVM_THREAD_COUNT 250 the number of threads in memory calculation $JAVA_TOOL_OPTIONS the JVM launch flags BellSoft Liberica JDK 11.0.10: Contributing to layer Downloading https://github.com/bell-sw/Liberica/releases/download/11.0.12+7/bellsoft-jre11.0.12+7-linux-amd64.tar.gz unable to invoke layer creator unable to get dependency jre unable to download https://github.com/bell-sw/Liberica/releases/download/11.0.12+7/bellsoft-jre11.0.12+7-linux-amd64.tar.gz unable to request https://github.com/bell-sw/Liberica/releases/download/11.0.12+7/bellsoft-jre11.0.12+7-linux-amd64.tar.gz Get "https://github.com/bell-sw/Liberica/releases/download/11.0.12+7/bellsoft-jre11.0.12+7-linux-amd64.tar.gz": dial tcp 140.82.121.3:443: connect: connection timed out ERROR: failed to build: exit status 1 ERROR: failed to build: executing lifecycle. This may be the result of using an untrusted builder: failed with status code: 145

A więc problemem był timeout na połączeniu z serwerem GitHub. Pomyślałem, że pewnie jakiś problem z serwerem, ale szybko zrozumiałem, że przecież ze względów bezpieczeństwa ruch z maszyny jest odcięty od zewnętrznej sieci i standardowo zależności pobiera z wewnętrznego proxy np. nexus. W tym momencie zaczęły się schody. Wykorzystanie CNB miało ułatwiać konteneryzację, być szybkie, łatwe i bez ingerencji, a nie jest. Zacząłem szukać w dokumentacji pluginu Spring Boot(2.3.x) czy da się jakoś podmienić miejsce, z którego są pobierane zależności, ale niestety nie znalazłem takiej opcji. Wczytałem się w dokumentację CNB oraz implementację Paketo buildpack i znalazłem rozwiązanie, a nawet 2 (od wersji pluginu 2.5 nawet 3). Jedno rozwiązanie to obejście, drugie poprawne z wykorzystaniem odpowiedniego narzędzia i trzecie z wykorzystaniem konfiguracji pluginu springa (dostępne od wersji spring boot >= 2.5.x). Wszystkie przedstawię poniżej, ale najpierw opisze mechanizm bindowania, który będzie wykorzystywany w dalszych przykładach.

Mechanizm bindowania

Problem, który musiałem rozwiązać, żeby móc korzystać z Cloud Native Buildpacks to brak możliwości pobrania artefaktów hostowanych poza siecią wewnętrzną serwera CI. Jednym ze sposobów była podmiana artefaktów hostowanych na GitHubie na coś do czego serwer CI będzie mieć dostęp np. Nexus. Plan był prosty tylko jak go zrealizować?

Z pomocą przyszedł wbudowany w specyfikacje CNB mechanizm bindowania i mapowanie zależności, który pozwala na dodatkową konfigurację. Każdy buildpack opcjonalnie może udostępnić możliwość bindowania wpływającą na jego konfigurację. W pliku konfiguracyjnym buildpack.toml oprócz zmiennych środowiskowych, każdy buildpack definiuje to z jakich zależności korzysta i jaka jest ich suma kontrolna. Dlatego też większość (jak nie wszystkie) buildpacki obsługują podmianę tych zależności tzw. dependency-mapping. Oprócz mapowania zależności niektóre buildpacki oferują też inne formy bindowania (Opisane na stronie projektu konkretnego buildpacka w sekcji Bindings), ale wszystkie dostosowują się do określonego standardu:

  1. Folder o konkretnej nazwie identyfikującej konkretny binding, może być dowolna i nie wpływa na budowanie jest dla nas informacją co bindujemy np. maven-bindings, jdk-bindings etc.
  2. Type lub Kind. Tutaj wskazujemy jakiego typu jest mapowanie (powinniśmy to znaleźć w opisie buildpacka z którego korzystamy). Przykładowo zgodnie z dokumentacją maven buildpack, może to być typ maven(pliki konfiguracyjne) lub dependency-mapping (mapowanie lokalizacji zależności)
  3. Opcjonalnie można wskazać dostawce tworząc plik provider i w zawartości wpisać informacje o dostawcy
  4. Para Key-Value

Może to wydawać się trochę skomplikowane i sam na początku miałem kłopot jak to zrealizować, gdzie mam wpisać ten typ, a gdzie Key-Value w dokumentacji (przynajmniej dla mnie w lutym 2021) nie było to dobrze wyjaśnione. Znalazłem tylko wzmiankę o tym, że Bindings muszą być dostarczone do buildpacka jako katalogi. Dlatego na przykładzie poniżej postaram się to wyjaśnić oraz pokazać jak przygotować Binding dodający własny settings.xml do maven.

Zasada jest prosta wszystko co znajdzie się w obrazie budującym w folderze platform/bindings udostępnione jest dla buildpacków w czasie budowania, chcąc skonfigurować binding dla maven, trzeba wejść na GitHub projektu i zobaczyć czy buildpack ten udostępnia opcje bindowania. Widzimy tam, że dostępne są 2 typy, w tym przykładzie posłużę się typem związanym z konfiguracją maven dodamy settings.xml zmieniający źródło zależności na wewnętrzne repozytorium Nexus zamiast domyślnego maven-central.

  1. Tworzymy folder, nazywamy go roboczo maven-settings (nazwa jest dowolna)
  2. Tworzymy plik o nazwie type i w jego zawartości wpisujemy maven
  3. Kopiujemy/tworzymy plik settings.xml w folderze, w którym się znajdujemy ( maven-settings tam, gdzie przed chwilą stworzyliśmy plik type)

Powinniśmy otrzymać coś takiego:

/maven-settings ├── settings.xml └── type

Takim sposobem mamy utworzony nasz pierwszy binding konfigurujący narzędzie maven wewnątrz buildpacka wskazując mu, który plik settings.xml ma wykorzystać do swojej pracy. Jak poznamy zasadę działania to możemy zabrać się za rozwiązanie naszego problemu: braku dostępu do zewnętrznych serwerów GitHuba, o tym w następnym akapicie ;).

Bindowanie na etapie budowania

Problem na serwerze CI był z pobraniem Java z GitHub podczas budowania obrazu, gdyż serwer nie miał dostępu do Internetu. Miałem już plan, wiedziałem co jest mi potrzebne i jak to zrobić. Zacząłem od przygotowania folderu z mapowaniami dla Javy w moim przypadku zarówno JDK jak i JRE, ponieważ budowałem aplikację z kodu źródłowego. Po wejściu w plik konfiguracyjny buildpacka Javy widzimy jakie zależności wykorzystuje, ich adres do ściągnięcia i sumę kontrolną sha256, która będzie nam potrzebna przy bindowaniu.

Moja prosta aplikacja wykorzystuje Jave 11, więc szukam w pliku buildpack.toml sekcji poświęconej właśnie tej wersji.

[[metadata.dependencies]]
id      = "jdk"
name    = "BellSoft Liberica JDK"
version = "11.0.12"
uri     = "https://github.com/bell-sw/Liberica/releases/download/11.0.12+7/bellsoft-jdk11.0.12+7-linux-amd64.tar.gz"
sha256  = "7c38cbdd9f723ea3c4d1d99b5ad12ef84c7c4716898ed58e5b8a201d91c7fd97"
stacks  = [ "io.buildpacks.stacks.bionic", "org.cloudfoundry.stacks.cflinuxfs3" ]


[[metadata.dependencies]]
id      = "jre"
name    = "BellSoft Liberica JRE"
version = "11.0.12"
uri     = "https://github.com/bell-sw/Liberica/releases/download/11.0.12+7/bellsoft-jre11.0.12+7-linux-amd64.tar.gz"
sha256  = "b8ef03f5c6db0ecf1538865fbb615c28feec61a5814e3408ba4d168dc77451e3"
stacks  = [ "io.buildpacks.stacks.bionic", "org.cloudfoundry.stacks.cflinuxfs3" ]


[[metadata.dependencies]]
id      = "jvmkill"
name    = "JVMKill Agent"
version = "1.16.0"
uri     = "https://github.com/cloudfoundry/jvmkill/releases/download/v1.16.0.RELEASE/jvmkill-1.16.0-RELEASE.so"
sha256  = "a3092627b082cb3cdbbe4b255d35687126aa604e6b613dcda33be9f7e1277162"
stacks  = [ "io.buildpacks.stacks.bionic", "org.cloudfoundry.stacks.cflinuxfs3" ]

Gdy już znajdziemy interesujące nas zależności (wklejone powyżej), ściągamy pliki z pola uri i umieszczamy go gdzieś, gdzie nasz serwer CI będzie miał dostęp po http/https (do testów wykorzystam program serve pozwalający udostępniać pliki statyczne po http, ale łatwiej jest to zrobić na zewnętrznej maszynie, gdyż lokalnie trzeba zamiast localhost podać w URI adres hosta. Może być to równie dobrze firmowy nexus czy nawet nginx. Ważne, żeby maszyna budująca miała dostęp do tego zasobu). Następnie tworzymy katalog java-bindings (tutaj również nazwa jest dowolna), a w nim pliki, których nazwy to wartości z pól sha256 a zawartości to linki do zależności odpowiadającym sha (jedna zależność jeden plik) np.

mkdir java-bindings
cd java-bindings
echo http://ip-maszyny:port/bellsoft-jdk11.0.12+7-linux-amd64.tar.gz >> 7c38cbdd9f723ea3c4d1d99b5ad12ef84c7c4716898ed58e5b8a201d91c7fd97
echo http://ip-maszyny:port/bellsoft-jre11.0.12+7-linux-amd64.tar.gz >> b8ef03f5c6db0ecf1538865fbb615c28feec61a5814e3408ba4d168dc77451e3
echo http://ip-maszyny:port/jvmkill-1.16.0-RELEASE.so >> a3092627b082cb3cdbbe4b255d35687126aa604e6b613dcda33be9f7e1277162
echo dependency-mapping >> type

Po wykonaniu tych operacji powinniśmy otrzymać taki stan można też podejrzeć na github repo tutaj

/java-bindings ├── 7c38cbdd9f723ea3c4d1d99b5ad12ef84c7c4716898ed58e5b8a201d91c7fd97 ├── b8ef03f5c6db0ecf1538865fbb615c28feec61a5814e3408ba4d168dc77451e3 ├── a3092627b082cb3cdbbe4b255d35687126aa604e6b613dcda33be9f7e1277162 └── type

Tak skonfigurowane bindowania są gotowe do wykorzystania w trakcie budowania. Nasuwa się pytanie:" jak ich użyć?

Rozwiązanie typu obejście

Jeśli coś jest głupie, ale działa, to nie jest głupie. - Prawo Murphy'ego

Znając dokera, specyfikacje bindowania dla Buildpacków oraz wiedząc, że builder buildpacka, który wykorzystuje Spring można podmienić, jako obejście można stworzyć swój własny builder na podstawie domyślnego i przekopiowanie do niego wszystkich potrzebnych bindowań. Co prawda jest to pewnego rodzaju obejście, ale pozwoli pójść do przodu i wykorzystać plugin Spring do budowania obrazu aplikacji. Inne rozwiązanie wymaga zainstalowania dodatkowego narzędzia, które przedstawię później.
Update: W między czasie pojawiło się też kolejne rozwiązanie wykorzystujące konfiguracje springa, które także opisze w dalszej części artykułu

Do przerobienia oficjalnego buildera i dodania do niego naszych mapowań wystarczy prosty Dockerfile, który weźmie oficjalny bazowy builder paketo i przekopiuje do niego nasze mapowania.
Załóżmy, że Dockerfile znajduje się w folderze, w którym utworzyliśmy folder java-bindings. Będzie on wyglądał tak jak poniżej:

FROM paketobuildpacks/builder:0.1.136-base
 
COPY ./bindings/ /platform/bindings
 
CMD ["/bin/bash"]

Następnie musimy go zbudować poleceniem:

docker build . -t localhost:5000/java-builder-test:1

⚡Ważne⚡

Jeśli robisz to na Linuxie folder jak i pliki, które się w nim znajdują musza mieć odpowiednie uprawnienia, aby builder mógł z nich skorzystać. Chodzi o to, że przy przenoszeniu plików z Linuxa do kontenera(linuxowego) uprawnienia zostają takie same jak w hoście. Dlatego jak użytkownik w kontenerze (cnb z minimalnymi uprawnieniami) nie będzie mógł otworzyć folderu i przeczytać plików, to bindowania nie zadziałają.

Aby to rozwiązać do dockerfile można dodać np. RUN chmod -R o+xr /platform/bindings . Sprawi to, że wszystkie pliki folderu bindings wewnątrz buildera dostaną uprawnienia xr dla grupy other (można też spróbować nadać te uprawnienia tylko dla użytkownika cnb ale tego nie próbowałem, więc nie wiem czy na pewno zadziała tak samo). Zmieniony docker file będzie wyglądał tak:

FROM paketobuildpacks/builder:0.1.136-base
 
COPY ./bindings/ /platform/bindings
RUN chmod -R o+xr /platform/bindings
 
CMD ["/bin/bash"]

W Windowsie pliki przy przenoszeniu do kontenera Linuxa dostają uprawnienia wyższe, dlatego na Windowsie zadziała wcześniejszy dockerfile bez dodania polecenia chmod

Po zbudowaniu obrazu za pomocą tego dockerfile musimy wrzucić go do docker registry, aby plugin Spring Boot mógł z niego skorzystać. Nie musi to być zewnętrzne registry można na potrzeby testów/eksperymentów postawić lokalne registry za pomocą dokera. Jak to zrobić można zobaczyć tutaj Registry - Official Image | Docker Hub

Gdy postawimy już lokalnie docker registry wystarczy zrobić push ja wybrałem nazwę testową java-builder-test:1

docker run -d -p 5000:5000 registry
docker push localhost:5000/java-builder-test:1

Po wrzuceniu obrazu musimy skonfigurować plugin aby z niego skorzystał w Spring wcześniejszym niż 2.5.x:


    org.springframework.boot
    spring-boot-maven-plugin
    
        localhost:5000/java-builder-test:1
        
            true 
        
        <image>
            ${project.artifactId}:${project.version}
        </image>
    

Teraz gdy uruchomimy budowanie zauważymy, że zależności ściągają się z miejsca, które wskazaliśmy w plikach bindujących.
(Na samym dole niebieski kolor)

[INFO] --- spring-boot-maven-plugin:2.5.0:build-image (default-cli) @ demo --- [INFO] Building image 'docker.io/library/demo:0.0.1-SNAPSHOT' [INFO] [INFO] > Pulling builder image 'localhost:5000/java-builder-test:1' 100% [INFO] > Pulled builder image 'localhost:5000/java-builder-test@sha256:de076d284538b1273fe50d278c86882990ef7914a906767073f83fa3d44e05b7' [INFO] > Pulling run image 'docker.io/paketobuildpacks/run:base-cnb' 100% [INFO] > Pulled run image 'paketobuildpacks/run@sha256:8ee09c6154a8c9a99f5da07c317102f5c29ad9b1bf2b7742799eb51d9f1a2e56' [INFO] > Executing lifecycle version v0.11.4 [INFO] > Using build cache volume 'pack-cache-5cbe5692dbc4.build' [INFO] [INFO] > Running creator [INFO] [creator] ===> DETECTING [INFO] [creator] 5 of 18 buildpacks participating [INFO] [creator] paketo-buildpacks/ca-certificates 2.3.2 [INFO] [creator] paketo-buildpacks/bellsoft-liberica 8.2.0 [INFO] [creator] paketo-buildpacks/executable-jar 5.1.2 [INFO] [creator] paketo-buildpacks/dist-zip 4.1.2 [INFO] [creator] paketo-buildpacks/spring-boot 4.4.2 [INFO] [creator] ===> ANALYZING [INFO] [creator] Previous image with name "docker.io/library/demo:0.0.1-SNAPSHOT" not found [INFO] [creator] ===> RESTORING [INFO] [creator] ===> BUILDING [INFO] [creator] [INFO] [creator] Paketo CA Certificates Buildpack 2.3.2 [INFO] [creator] https://github.com/paketo-buildpacks/ca-certificates [INFO] [creator] Launch Helper: Contributing to layer [INFO] [creator] Creating /layers/paketo-buildpacks_ca-certificates/helper/exec.d/ca-certificates-helper [INFO] [creator] [INFO] [creator] Paketo BellSoft Liberica Buildpack 8.2.0 [INFO] [creator] https://github.com/paketo-buildpacks/bellsoft-liberica [INFO] [creator] Build Configuration: [INFO] [creator] $BP_JVM_VERSION 11.* the Java version [INFO] [creator] Launch Configuration: [INFO] [creator] $BPL_JVM_HEAD_ROOM 0 the headroom in memory calculation [INFO] [creator] $BPL_JVM_LOADED_CLASS_COUNT 35% of classes the number of loaded classes in memory calculation [INFO] [creator] $BPL_JVM_THREAD_COUNT 250 the number of threads in memory calculation [INFO] [creator] $JAVA_TOOL_OPTIONS the JVM launch flags [INFO] [creator] BellSoft Liberica JRE 11.0.12: Contributing to layer [INFO] [creator] Downloading from http://moje-ip:5555/bellsoft-jre11.0.12+7-linux-amd64.tar.gz

Rozwiązanie poprawne (Pack CLI)

Dużo lepszym rozwiązaniem problemu z zależnościami, a za razem oficjalnym jest wykorzystanie narzędzia Pack · Cloud Native Buildpacks. Jest to narzędzie typu OpenSource rozwijane i utrzymywane przez projekt CNB. Jednym z jego zadań jest ułatwienie konfiguracji builderów wykorzystywanych podczas budowania obrazów aplikacji. Dzięki niemu nie musimy pisać dockerfile, tworzyć własnych obrazów budujących i wrzucać ich do docker registry tak jak to zrobiliśmy sekcji wyżej. Nadal potrzebujemy stworzonych wcześniej mapowań(bindings), lecz dostarczamy je w bardziej przystępny sposób.

Pack może działać jako narzędzie linii komend (CLI) jak i biblioteka języka Go. instrukcja instalacji znajduje się (tutaj)

Gdy mamy już zainstalowane to narzędzie i jesteśmy w folderze z aplikacją wystarczy tylko wykonać polecenie:

pack build sample_app --path . --volume $(pwd)/bindings/:/platform/bindings --pull-policy if-not-present --builder paketobuildpacks/builder:0.1.136-base

sample_app - dowolna nazwa dla aplikacji
--path - ścieżka (. oznacza aktualny folder, w którym się znajdujemy)
--volume - montowanie mapowań(bindings). w miejsce $(pwd) wpisze się ścieżką do katalogu, w którym aktualnie się znajdujemy. można też wpisać ręcznie
--pull-policy if-not-present - opcjonalne, instruuje pack, żeby pobierał obraz buildera tylko wtedy, gdy nie ma go lokalnie (czasami rozwiązuje jakieś dziwne problemy na poblokowanych serwerach ze ściągnięciem obrazu przez pack)
--builder - nazwa i tag oficjalnego buildera, z którego korzystamy, dla którego stworzyliśmy mapowania. Jak chcemy zmienić builder, mapowania też musimy zmienić

⚡Ważne⚡

Takie wywołanie jak powyżej buduje projekt na zasadzie source to image więc potrzebuje do zbudowania także JDK. W metodzie z pluginem springa JDK na poziomie Buildpacków nie było pobierane, gdyż to maven na hoście, a nie w kontenerze buildera budował aplikacje, której jar przekazywany był dalej do buildera buildpacka.

Pack oprócz budowania z kodu źródłowego może także zacząć od gotowego jara, lecz wymaga to wcześniej zbudowania tego jara np. poprzez mvn clean package aby jar się stworzył. Gdy mamy już jara możemy wskazać go w argumencie --path przy wywołaniu narzędzia pack. Buidlack Java wykryje, że jest to jar, a nie kod źródłowy, pominie maven buildpack i odpowiednio pokieruję dalszym budowaniem.

pack build sample_app --path ./target/demo-0.0.1-SNAPSHOT.jar --volume $(pwd)/bindings/:/platform/bindings --pull-policy if-not-present --builder paketobuildpacks/builder:0.1.136-base

Rozwiązanie poprawne (Spring Boot 2.5+)

Kiedy zaczynałem pisać tego posta (luty 2021) spring nie obsługiwał bindowania za pomocą pluginu budującego aplikację, zmieniło się to wraz z wypuszczeniem wersji Spring Boot 2.5. W tej wersji w konfiguracji pluginu dodana została opcja <bindings> pozwalająca wskazać folder hosta, który powinien być zamontowany przy budowie obrazu a następnie jego zawartość wykorzystana do podmiany zależności. Mechanizm ten działa podobnie jak opisywany wyżej pack CLI, gdzie także wskazywaliśmy folder, który ma być zamontowany i dostępny podczas budowania. Różnica jest jednak w tym, że jar budowany jest na hoście i dopiero później przekazywany do buildpacków dzięki czemu korzystamy z lokalnego repo zależności .m2. Wszystkie opcje konfiguracji pluginu można podejrzeć tutaj.

My skupimy się na bindowaniu, przyjmuje ono 2 formaty:

  • Pierwszy sposób to Podanie ścieżkI do pliku np. folder trzymany wraz z plikami aplikacji lub gdzieś na filesystemie hosta

	
		/source/to/host/file:/destination/in/builder:opcje(np. rw/ro) np.
    

U mnie w projekcie testowym ścieżka by wyglądała tak:


    
		${project.basedir}/bindings:/platform/bindings
    

  • Drugi sposób to Podanie nazwy wolumenu np. stworzyć nazwany volumen dokerowy na serwerze dodać do niego dane, które chcemy wykorzystać przy budowaniu i podać nazwę tego wolumenu w konfiguracji pluginu zamiast ścieżki do katalogu/pliku.

    
		docker-volumeName:/platform/bindings
    

Ja skorzystałem z pierwszej opcji i wyciągnąłem ścieżkę do zmiennej środowiskowej, cała konfiguracja pluginu wygląda u mnie tak:


    org.springframework.boot
    spring-boot-maven-plugin
    
        
            ${project.artifactId}:${project.version}
            paketobuildpacks/builder:0.1.136-base
            
                    ${env.BINDINGS_PATH}:/platform/bindings
            
        
    

Aby jej użyć poprawnie musiałem dodać zmienną środowiskową export BINDINGS_PATH=scieżka/do/katalogu/z/mapowaniami u mnie jest to

export BINDINGS_PATH=$(pwd)/bindings/

Taka konfiguracja pozwoli nam wykorzystać wcześniej stworzone bindowania bez tworzenia swojego buildera czy wykorzystywania zewnętrznych narzędzi typu PackCLI. Dodatkowo ścieżkę do mapowań mamy wyciągniętą do zmiennej środowiskowej więc wystarczy, że mapowania będą dostępne na serwerze budującym (np. skrypt sciągający je, gdy nie istnieją przed budowaniem aplikacji) nie musimy ich trzymać we wszystkich aplikacjach.

Więcej o konfiguracji pluginu do budowania możecie poczytać tutaj Spring Boot Maven Plugin Documentation

⚡Ważne⚡

Ważne, aby w każdym z 3 sposobów podać konkretną wersję buildera np. paketobuildpacks/builder:0.1.136-base inaczej za każdym razem, gdy wyjdzie nowa wersja buildera, a my korzystamy z domyślnego tagu latest, projekt nam się nie zbuduje przez brak mapowan dla nowych zaleznosci. Aby to naprawic będziemy musieli zaktualizować nasze bindowania o nowe zależności.

Brak certyfikatu

Może się zdarzyć tak, że mimo mapowania zależności nadal mamy problem z ich pobraniem. Jeśli widzicie komunikat, z którego wynika, że jest jakiś problem z certyfikatem to znaczy, że wasza firma wymaga go przy pobieraniu zależności (np. z nexusa) a builder, który buduje aplikacje go nie posiada.

Rozwiązaniem tego problem również jest mechanizm bindowania i dodanie certyfikatu do buildera. paketo buildpacks wykorzystują pojedynczy buildpack ca-certs, który pozwala na dodanie certyfikatów przy pomocy mechanizmu bindowania
Do naszych wcześniejszych bindowań wystarczy dodać folder (obok java-bindings) np. o nazwie certs a w nim plik type z wpisem ca-certificates i pliki z certyfikatami, które chcemy mieć dołączone do aplikacji podczas jej budowania oraz podczas działania. Certyfikaty powinny mieć rozszerzenie .pem. Poniżej przykład jak to zrobić

mkdir $(pwd)/bindings/certs
echo ca-certificates >> $(pwd)/bindings/certs/type
cp $(pwd)/twój/certyfikat.pem $(pwd)/bindings/certs/

Brak pakietów na poziomie systemu operacyjnego w Run Image?

Może się zdarzyć, że będzie nam czegoś brakowało w obrazie startującym aplikację, czegoś co jest kluczowe do jej działania. Wyobraźmy sobie sytuację, że nasza aplikacja generuje plik PDF z niestandardową czcionką. Aplikacja się buduje idzie na serwer przy pierwszym zapytaniu i próbie wygenerowania PDFa, system się wywala i dostajemy błąd związany np. z Jasper Reports fonts, ponieważ w systemie nie ma zainstalowanych czcionek i pakietu fontconfig. Na chwilę obecną znam tylko 2 sposoby na wyjście z tej sytuacji.

🛠️Ciekawostka🛠️

Project CNB pracuje nad udostępnieniem czegoś na wzór buildpacków (roboczo stackpack), które będą mogły zainstalować zależności na poziomie systemu operacyjnego, ale na razie rozwiązanie to nie jest dostępne i nie wiadomo kiedy będzie

Użycie obrazu full zamiast base

Jest to szybki sposób i wymaga najmniej konfiguracji,ale niesie za sobą spore konsekwencje. Polega on na zmienieniu rodzaju buildera z base na full, który posiada więcej pakietów systemowych w build/run image i waży około 1,41GB zamiast ~684MB, ale spokojnie to nie jest waga końcowego obrazu z aplikacją. Obraz, który budowałem w tym artykule z wykorzystaniem buildera base waży ~271MB zaś z wykorzystaniem buildera full ~ ~866MB to już sporo więcej i raczej nie chcemy marnować tyle miejsca.

Dodatkowo większy obraz bazowy to więcej pakietów systemowych w obrazie, a to z kolei przekłada się na większe prawdopodobieństwo podatności obrazu. Ogólnie można powiedzieć, że im mniejszy obraz tym jest on bezpieczniejszy, co widać na poniższym wykresie (2019 rok), który przedstawia ilość wykrytych podatności przez snyk.io względem wielkości obrazu bazowego node.js

Take actions to improve security in your Docker images | Snyk

Własny run image

Innym sposobem jest zbudowanie własnego obrazu uruchomieniowego (run image) zawierającego wymagane przez nas zależności na poziomie systemu operacyjnego. Można to zrobić np. za pomocą poniższego Dockerfile:

FROM paketobuildpacks/run:1.1.19-base-cnb

USER root

RUN apt-get update \
  && apt-get install -y --no-install-recommends \
    libfreetype6 \
    fontconfig \
  && rm -rf /var/lib/apt/lists/*

USER cnb

Następnie trzeba go zbudować i wysłać na repo (u mnie lokalne repo):

docker build . -t localhost:5000/java-run-image:1
docker push localhost:5000/java-run-image:1

Oraz dodać do konfiguracji pluginu springa jak poniżej:

...
        
            ${project.artifactId}:${project.version}
            paketobuildpacks/builder:0.1.136-base
            localhost:5000/java-run-image:1
            
                    ${env.BINDINGS_PATH}:/platform/bindings
            
        
...

Drugi sposób opierając się na builder:base zwiększa obraz wynikowy tylko o ~2MB (w naszym przypadku, jeśli instalujecie coś innego może to być więcej) czyli teraz waży 273MB przy pierwszym sposobie było to 866MB więc jest różnica.

Ponowne wykorzystanie zależności maven pomiędzy różnymi aplikacjami

Jak już mówiłem wcześniej buidlpacki przechowują zależności w warstwach podręcznych (cache) i jeśli zachodzi taka potrzeba przy następnym budowaniu wykorzystują tą warstwę w całości lub częściowo ją przebudowują dociągając nowe zależności, które pojawiły się w nowej wersji aplikacji.

Buildpack Maven dzięki temu, że ma dostęp do tego cache, po zmianie pom.xml dociąga do warstwy tylko nową zależność, zamiast budować ją w całości od nowa tak jak to robi Dockerfile (opisane jest to tutaj Czy to koniec Dockerfile? Cloud Native Buildpacks - Obszerne wyjaśnienie 🏗️ (cupofcodes.pl)). Należy jednak mieć świadomość ze cache ten jest ograniczony do zakresu, który definiuje nazwa obrazu. Oznacza to, że jak mamy inną aplikację, która posiada częściowe lub w całości, dokładnie te same zależności co aplikacja pierwsza, to i tak do warstwy podręcznej tego obrazu przy pierwszym budowaniu pobrane zostaną wszystkie zależności. Dzieje się tak dlatego, że jeden obraz nie ma dostępu do cache innego obrazu. Kolejne budowania oczywiście będą już wykorzystywać ten cache o ile nazwa obrazu będzie się zgadzać.

Jednym z rozwiązań tej niedogodności, jest zmapowanie w packCLI poprzez --volume lokalnego katalogu .m2 wyglądałoby to tak

pack build sample_app --path ./target/demo-0.0.1-SNAPSHOT.jar --volume lokalne/.m2:/home/cnb/.m2 --pull-policy if-not-present --builder paketobuildpacks/builder:0.1.136-base

Działa to na pewno z buildpackami dostarczonymi przez Paketo, jednak ma to jeden skutek uboczny, jeśli użyliśmy mechanizmu bindowania do zmapowania pliku settings.xml w maven, to mapowanie to zostanie zignorowane i zostanie użyty plik settings.xml ze ścieżki, którą zmapowaliśmy poprzez --volume. W naszym przypadku jest to "lokalne/.m2". Nie gwarantuje, że inni dostawcy buildpacków także obsługują ten sposób.

Oczywiście ta niedogodność z zależnościami maven występuje tylko wtedy, kiedy budujemy obraz aplikacji z kodu źródłowego z wykorzystaniem buildpacków. Jeśli robimy to pluginem Springa zależności pobierane/dodawane są do lokalnego repo (odbywa się to jeszcze przed uruchomieniem budowania za pomocą buildpacków) i są dostępne także dla innych aplikacji. Tak samo, sprawa się ma, jak budujemy obraz za pomocą PackCLI jednak zamiast kodu źródłowego dostarczam jar wtedy naturalnie buildpack maven nie jest uruchamiany.

Ponowne użycie innych zależności buildpacków

Zależności maven to nie jedyne zależności, które mogą być pobierane podczas budowania. Aplikacja w Javie potrzebuje jeszcze np. JVM do jej uruchomienia, ściągnięciem tej zależności zajmuje się buildpack javowy, który analizuje kontekst budowania i w zależności czy potrzebujemy całego JDK, czy wystarczy nam tylko JRE ściąga potrzebną paczkę do obrazu. Po ściągnięciu zależność dodawana jest do pamięci podręcznej oraz dodawana do obrazu wynikowego podczas budowania. Przy ponownym budowaniu tej aplikacji jest ona pobierana z pamięci podręcznej przez co kolejne budowania trwają zdecydowanie krócej.

Jednak tutaj również występuje pewna niedogodność, którą jest zakres dostępu do pamięci podręcznej (cache) konkretnego obrazu. Jeśli budujemy obraz, którego nie budowaliśmy nigdy wcześniej i korzysta on z zależności np. JRE to musimy je pobrać ponownie. Nawet jeśli mamy tą zależność już w pamięci podręcznej innego obrazu, który wcześniej budowaliśmy to nie możemy jej użyć, ponieważ nasz obraz budujący nie widzi innych cache.

Jest na to obejście możemy wykorzystać wcześniej opisane mapowanie zależności, gdzie zamiast http/https do miejsca w sieci (github,nexus itp.) wskażemy katalog w kontenerze zmapowany poprzez volume do miejsca na dysku hosta, w którym taka zależność się znajduje. Wsparcie dla mapowań wykorzystujących protokołu file:// zostało dodane do buildpacków w sierpniu 2021.

Jest to jakieś wyjście z sytuacji, ale raczej dość upierdliwe. Na szczęście twórcy tej technologii pracują nad rozwiązaniem tego problemu i miejmy nadzieje, że wymyślą ciekawsze rozwiązanie nie wymagające takiej konfiguracji.

Zmiana Run Image bez Przebudowy aplikacji

Kolejną zaletą podczas wykorzystywania obrazów zbudowanych za pomocą Cloud Native Buildpakcs, jest możliwość podmienienia obrazu bazowego aplikacji bez potrzeby jej całkowitego przebudowywania.

Mechanizm działania jest prosty, każdy obraz zbudowany za pomocą CNB posiada metadane przetrzymujące konfigurację jego warstw. Gdy wywołamy polecenie pack rebase <nazwa-obrazu>, narzędzie zobaczy jaka warstwa systemu jest aktualnie wykorzystana w obrazie aplikacji i poszuka dla niego nowszej wersji systemu lokalnie oraz w repozytorium. Jeśli znajdzie nową wersję to zmieni metadane obrazu aplikacji tak aby wskazywały na nową wersję warstwy systemu.

Co ważne, dzięki wykorzystaniu rebase, obraz nie jest w całości przebudowywany tak jak to ma miejsce, kiedy robimy podobną operację wykorzystując Dockerfile. Cała proces jest prosty i szybki.

https://buildpacks.io/docs/concepts/operations/rebase/

Zmiana obrazu, na razie będzie widoczna tylko lokalnie, aby zmiana była widoczna dla wszystkich, zmienioną konfigurację trzeba "opublikować". Można to zrobić na 2 sposoby albo dodając do polecenia rebase opcje --publish, albo samemu zrobić push zmienionego obrazu za pomocą dockera.

Funkcjonalność ta może nie wydaje się jakoś bardzo przydatna dla organizacji, która ma mało kontenerów. Jednak biorąc pod uwagę efekt skali, gdzie organizacje mają po 100, 200 czy nawet 1000 obrazów jest to już bardzo duże ułatwienie, w momencie, gdy trzeba załatać lukę bezpieczeństwa na poziomie systemu operacyjnego. W takiej sytuacji wystarczy skrypt wykonujący pack inspect na wszystkich obrazach, po czym dla tych obrazów, które są dotknięte luką wykonanie pack rebase. Bez konieczności przebudowywania całych obrazów cała operacja powinna zająć nieporównywalnie mniej czasu niż opcja z przebudowywaniem wszystkiego.

Oczywiście kontenery korzystające z tych obrazów powinny być zrestartowane, aby pobrały sobie nową warstwę obrazu bazowego

Rebase jest możliwe dzięki ABI Compatibility (Application Binary Interface) dostawcy gwarantują kompatybilność wsteczną załatanych wersji obrazu bazowego z oryginalnym obrazem bazowym w ramach głównej wersji. Wyjaśnię to na przykładzie Ubuntu jeśli używamy ubuntu 20.04 to, ubuntu 20.04.1 powinien być kompatybilny wstecznie z wersją 20.04 i wszystko powinno działać poprawnie na nowej wersji obrazu.

Podsumowanie

TL; DR wersja krótka
Gdy mamy już w firmie wszystko dobrze pospinane na dockerfile lub Jib( o ile to tylko Java i nie mamy potrzeby konteneryzowania innych jezykow), mamy od tego speców i jesteśmy z tego procesu zadowoleni, to nie ma większego sensu przepinać się na CNB tylko dlatego że to coś swieżego.

Jeśli jednak nie mamy w projekcie/firmie żadnego podejścia do konteneryzacji aplikacji i dopiero zaczynamy w to wchodzić a chcemy budować dobre i bezpieczne obrazy aplikacji, wykorzystując różne języki programowania, wtedy moim zdaniem warto zainteresować się Buildpackami. Zaczynając od Czy to koniec Dockerfile? Cloud Native Buildpacks - Obszerne wyjaśnienie 🏗️

Wersja dłuższa ;)
Z jednej strony bindowanie jest uciążliwe i gdy nie mamy dostępu do GitHuba tracimy benefit związany z ciągłą aktualizacją bezpieczeństwa. Więc albo co jakiś czas ręcznie będziemy podbijali mapowania albo utkniemy na konkretnej wersji.

Z drugiej zaś konieczność bindowania może być zaletą, ponieważ niektóre firmy nie mogą pozwolić sobie na ciągnięcie wszystkiego co popadnie z Internetu. Zamiast tego np. ze względów bezpieczeństwa wolą sami decydować, kiedy się podbijają i co wykorzystują.

W artykule tym starałem się opisać wszystkie problemy jakie napotkałem podczas wykorzystywania buildpacków w codziennej pracy, oraz to jak te problemy rozwiązać.

Niewątpliwą przewagą Buildpack nad Docker file jest to, że do zbudowania obrazu aplikacji, który jest "common case" korzystamy z wiedzy i doświadczenia osób zajmujących się tym zawodowo, przez co mamy "prawie pewność", że zbudowany przez nas obraz będzie wykorzystywał najlepsze praktyki, a także będzie bezpieczniejszy niż obraz zbudowany za pomocą Dockerfile przez developera, który nie zna tych wszystkich dobrych praktyk, a umówmy się niewielu developerów specjalizuje się w pisaniu dobrych Dockerfile. Co więcej z doświadczenia wiem, że sporo developerów nie za bardzo wie jak działa docker. Nie twierdzę, że to źle czy dobrze, nie każdy przecież musi znać dokera od podszewki, dlatego w takim przypadku moim zdaniem lepiej użyć buildpacków.

A więc pora odpowiedzieć sobie na pytanie zawarte w tytule tego artykułu, czy to koniec Dockerfile? Moim zdaniem to jeszcze nie ten moment. Technologia CNB Jest bardzo ciekawa i ma duży potencjał, bo zdejmuje z nasz ciężar dbania i inwestowania czasu, oraz zasobów w naukę i śledzenie trendów dotyczących budowania małych bezpiecznych obrazów za pomocą Dockerfile. Z drugiej strony cierpi na problemy wieku dziecięcego, chociażby związane z dostarczaniem zależności, gdy nie mamy dostępu do GitHuba na serwerze CI i wtedy zaczynają się problemy, które jakoś trzeba rozwiązać na szczęście macie ten post :). Co prawda Builder mogłyby zawierać w sobie wszystkie potrzebne zależności JDK itp. do zbudowania obrazu aplikacji, jednak wtedy obraz takiego builder byłby ogromny, a wykorzystywana była by niewielka jego zawartość.

Moim zdaniem CNB skupia się głównie na zdjęciu z nas ciężaru budowania, dobrych, bezpiecznych standardowych obrazów aplikacji w różnych popularnych językach programowania jedną spójną specyfikacją. Dlatego według mnie tutaj ma sporą przewagę nad Dockerfile i różnymi narzędziami dla różnych języków Z drugiej strony we wszystkich niestandardowych przypadkach Dockerfile nadal błyszczy, ponieważ za jego pomocą jesteśmy w stanie zrobić znacznie więcej niż za pomocą buildpacków. Jak to mawiał wujek Ben

"Great power comes with great responsibility" - (Uncle Ben, Spiderman)

Dlatego moim zdaniem, jeśli już decydujemy się na wykorzystanie Dockerfile do zbudowania czegokolwiek, powinniśmy poświecić trochę czasu na zdobycie specjalistycznej wiedzy dotyczącej tego jak pod spodem działa Docker i Dockerfile trochę o tym można przeczytać w innym moim artykule Docker, Ogry, Cebule i Warstwy cz1 - Wirtualne Maszyny, Warstwy i Kontenery