So werden Cloud-fähige Images mit Cloud Native Buildpacks gebaut.

19.05.2021 Stefan Welsch
Cloud DevOps Cloud native Kubernetes DevOps Framework How-to

Buildpacks transformieren Applikations-Quellcode zu Images, welche man direkt in der Cloud laufen lassen kann. Dabei wird der Code untersucht um festzustellen, welche Abhängigkeiten gebraucht werden.

Buildpacks wurde erstmal 2011 von Heroku konzipiert. Seitdem wurde das Projekt von der Cloud Foundry und anderen Platform as a Service (PaaS) Providern adaptiert (Google App Engine, Gitlab, …).

Das Cloud Native Buildpacks Projekt wurde von Pyvotal und Heroku im Januar 2018 initiiert. Nur 9 Monate später, also im Oktoboer 2018 ist das Projekt der CNCF beigetreten. Ziel des Projekt ist es, das buildpack Ecosystem zu vereinheitlichen. Dazu gibt es den Platform-To-Build Vertrag, der genau definiert wurde und Erkenntnisse aus jahrelanger Erfahrung von Pyvotal und Heroku enthält.

Cloud Native Buildpacks unterstützen moderne Container Standards wie OCI (Open Container Initiative) und nutzen dabei immer die neuesten Fähigkeiten.

Komponenten

Wollen wir uns die wichtigsten Komponenten von Buildpacks anschauen.

Builder

Ein Builder ist ein Image, welche alle Komponenten beinhaltet um einen Build auszuführen. Ein Builder Image besteht aus einem Build Image, Lifecycle, buildpacks und sonstigen Files, welche zur Konfiguration notwendig sind.

Buildpack

Ein Buildpack ist eine Einheit, welche den Applikationscode betrachtet und daraus einen Plan formuliert, wie die Applikation ausgeführt wird. Dabei wird anhand vom Code der richtig Buildpack ermittelt und danach der Build mit allen notwendigen Installationen ausgeführt.

Lifecycle

Der Lifecycle ochestriert buildpack Ausführungen und führt dann die Artefakte zu dem App Image zusammen

Platform

Die Platform ist beispielsweise die Pack CLI oder im CICD Prozess ein Plugin, welche aus dem Lifecycle, Buildpack und dem Applikationscode das OCI Image erzeugt.

Bauen wir unsere erste App

Damit wir loslegen können, brauchen wir erstmal eine Platform, mit der wir unser OCI Image erzeugen können. Lokal können wir dazu einfach die Pack CLI nutzen. Die Installation der Pack CLI ist in verschiedenen Formen möglich und wird sehr gut auf der Pack CLI Seite beschrieben. Es gibt zum Beispiel ein Binary für Windows, Mac und die verschiedenen Linux-Distributionen, welche einfach über die entsprechenden Package-Manager installiert werden können.

Da wir Betriebssystem-unabhängig bleiben wollen, will ich hier das offizielle Docker Image nutzen.

Schauen wir uns im ersten Schritt an, welche Builder uns zur Verfügung stehen. Dies können wir mit dem folgenden Befehl erreichen.

docker run -ti buildpacksio/pack builder suggest                                                                                                                                                                                                                                                              09:54:32
Suggested builders:
	Google:                gcr.io/buildpacks/builder:v1      Ubuntu 18 base image with buildpacks for .NET, Go, Java, Node.js, and Python
	Heroku:                heroku/buildpacks:18              Base builder for Heroku-18 stack, based on ubuntu:18.04 base image
	Heroku:                heroku/buildpacks:20              Base builder for Heroku-20 stack, based on ubuntu:20.04 base image
	Paketo Buildpacks:     paketobuildpacks/builder:base     Ubuntu bionic base image with buildpacks for Java, .NET Core, NodeJS, Go, Ruby, NGINX and Procfile
	Paketo Buildpacks:     paketobuildpacks/builder:full     Ubuntu bionic base image with buildpacks for Java, .NET Core, NodeJS, Go, PHP, Ruby, Apache HTTPD, NGINX and Procfile
	Paketo Buildpacks:     paketobuildpacks/builder:tiny     Tiny base image (bionic build image, distroless-like run image) with buildpacks for Java Native Image and Go

Diese Builder können wir nun nutzen um unser OCI Image direkt aus dem Sourcecode unserer Applikation zu bauen. Ich erstelle also erstmal eine Go File mit dem folgenden Inhalt:

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
	port := "8080"

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		_, err := fmt.Fprint(w, "hello from b-nova")
		if err != nil {
			log.Fatalf("error printing message to response writer %s", err)
			return
		}
	})

	log.Printf("Now listening on port %s.", port)
	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil))
}

Wer sich mit Go nicht auskennt:
Wir starten hier einen einfachen HTTP Server auf Port 8080, der eine statische Ausgabe macht, sobald wir die URL aufrufen.

Nun können wir schon unser erstes Image aus dem Sourcecode erstellen. In der Konsole geben wir also folgendes ein:

docker run \
    -v /var/run/docker.sock.raw:/var/run/docker.sock \
    -u501 \
    -v $PWD:/workspace -w /workspace \
    buildpacksio/pack build go-sample --builder=gcr.io/buildpacks/builder:v1

Wir erhalten nun eine recht lange Ausgabe, welche wir uns im Detail mal anschauen wollen

Unable to find image 'buildpacksio/pack:latest' locally
latest: Pulling from buildpacksio/pack
...
Status: Downloaded newer image for buildpacksio/pack:latest
v1: Pulling from buildpacks/builder
...
Status: Downloaded newer image for gcr.io/buildpacks/builder:v1
v1: Pulling from buildpacks/gcp/run
...
Status: Downloaded newer image for gcr.io/buildpacks/gcp/run:v1

Wir sehen hier, dass verschiedene Images heruntergeladen werden.

buildpacksio/pack → Das ist unsere Platform mit der wir zusammen mit dem Lifecycle, dem Buildpack und dem Applikationscode das OCI Image erzeugen können.

buildpacks/builder → Hier ist unser Build Image. Dieses Image wird genutzt um die Buildumgebung zu erstellen. In der Buildumgebung wird dann der Lifecycle und die Buildpacks ausgeführt.

buildpacks/gcp/run → Das Run Image ist das Base Image für unser Applikations Image.

Build-Image und Run-Image nennt man auch Stack. Man braucht diese immer gemeinsam um ein Image zu erstellen

Als nächstes folgt der Lifecycle. Wir sehen in unserer Ausgabe folgendes:

===> DETECTING
4 of 6 buildpacks participating
google.go.runtime  0.9.1
google.go.gopath   0.9.0
google.go.build    0.9.0
google.utils.label 0.0.1
===> ANALYZING
Previous image with name "go-sample" not found
Restoring metadata for "google.go.runtime:go" from cache
===> RESTORING
Restoring data for "google.go.runtime:go" from cache
===> BUILDING
=== Go - Runtime (google.go.runtime@0.9.1) ===
...
Using latest runtime version: 1.16.3
=== Go - Gopath (google.go.gopath@0.9.0) ===
--------------------------------------------------------------------------------
Running "go get -d (GOPATH=/layers/google.go.gopath/gopath GO111MODULE=off)"
Done "go get -d (GOPATH=/layers/google.go.gopath/gopath GO111MODUL..." (118.624084ms)
=== Go - Build (google.go.build@0.9.0) ===
--------------------------------------------------------------------------------
Running "go list -f {{if eq .Name \"main\"}}{{.Dir}}{{end}} ./..."
/workspace
Done "go list -f {{if eq .Name \"main\"}}{{.Dir}}{{end}} ./..." (61.671871ms)
--------------------------------------------------------------------------------
Running "go build -o /layers/google.go.build/bin/main ./. (GOCACHE=/layers/google.go.build/gocache)"
Done "go build -o /layers/google.go.build/bin/main ./. (GOCACHE=/l..." (529.000116ms)
=== Utils - Label Image (google.utils.label@0.0.1) ===
===> EXPORTING
Adding layer 'google.go.build:bin'
Adding 1/1 app layer(s)
Adding layer 'launcher'
Adding layer 'config'
Adding layer 'process-types'
Adding label 'io.buildpacks.lifecycle.metadata'
Adding label 'io.buildpacks.build.metadata'
Adding label 'io.buildpacks.project.metadata'
Setting default process type 'web'
Saving go-sample...
*** Images (b5c6cd0a9637):
      go-sample
Reusing cache layer 'google.go.runtime:go'
Successfully built image 'go-sample'

Der Lifecycle besteht wie wir sehen aus:

DETECT(1): Hier werden passende Buildpacks gefunden, welche während der Buildphase genutzt werden

ANALYZE(7): Hier werden Files wiederhergestellt, welche die Build- und Export-Phase optimieren können

RESTORE(10): Hier werden Layer vom Cache wiederhergestellt

BUILD(12): Hier wird der Applikationscode in ein lauffähiges Artefakt transformiert, welches in einen Container eingepackt werden kann.

EXPORT(29): Hier wird das OCI Image erstellt

Nachdem unser Image gebaut wurde, können wir dies einfach mit dem docker run Befehl starten

docker run -p8080:8080 go-sample
2021/05/04 05:46:20 Now listening on port 8080.

Unser eigenes buildpack

Nun haben wir gesehen, wie man mit einem bereits bestehenden buildpack seine Applikation zu einem Image transformieren kann. Wollen wir uns nun anschauen, wie man ein eigenes buildpack schreiben kann. Dies ist beispielsweise nützlich, wenn man den Buildprozess modifizieren will. Dazu installieren wir uns erstmal die Pack CLI Binary.

brew install buildpacks/tap/pack

Danach erstellen wir uns die erforderlichen Files für ein eigenes Buildpack. Das Projekt hat danach die folgende Struktur:

In die Dateien fügen wir nun folgendes ein:

# buildpack.toml
 
# Buildpack API version
api = "0.5"

# Buildpack ID and metadata
[buildpack]
id = "com.bnova/go-sample"
version = "0.0.1"
name = "Go Sample"

# Stacks that the buildpack will work with
[[stacks]]
id = "io.buildpacks.samples.stacks.bionic"
# detect

#!/usr/bin/env bash
set -eo pipefail

exit 1
# build

#!/usr/bin/env bash
set -eo pipefail

echo "---> Go Buildpack"
exit 1

Anschliessend müssen wir die beiden Files im bin Ordner noch ausführbar machen.

chmod +x go-buildpack/bin/detect go-buildpack/bin/build

Um unser Buildpack zu testen, müssen wir den buildpack gegen unsere go Applikation laufen lassen. Dazu führen wir in der CLI folgendes aus.

# Set the default builder
pack config default-builder cnbs/sample-builder:bionic

Nun builden wir unsere Applikation mit unserem Buildpack.

pack build go-sample --path . --buildpack ./go-buildpack

Output: 
===> DETECTING
[detector] err:  com.bnova/go-sample@0.0.1 (1)
[detector] ERROR: No buildpack groups passed detection.
[detector] ERROR: failed to detect: buildpack(s) failed with err
ERROR: failed to build: executing lifecycle. This may be the result of using an untrusted builder: failed with status code: 101

Wie wir sehen können schlägt der Build erstmal fehl, da wir in unserem detect Skript erstmal nur einen Fehler zurückgeben. Wollen wir dies nun so anpassen, dass wir bei einem Go File keinen Fehler mehr erhalten.

# detect

#!/usr/bin/env bash
set -eo pipefail

if [[ -f *.go ]]; then
   exit 100
fi

Wenn wir unsere Applikation nun wieder builden erhalten wir die folgende Ausgabe:

pack build go-sample --path . --buildpack ./go-buildpack

Output: 
===> DETECTING
[detector] com.bnova/go-sample 0.0.1
===> ANALYZING
===> RESTORING
===> BUILDING
[builder] ---> Go Buildpack
[builder] 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

Nun müssen wir uns noch unser Build-Skript schreiben um die Applikation zu bauen. Das fertige Skript sieht so aus.

#!/usr/bin/env bash
set -eo pipefail

echo "---> Go Buildpack"

# 1. GET ARGS
layersdir=$1

# 2. CREATE THE LAYER DIRECTORY
golayer="$layersdir"/go
mkdir -p "$golayer"

# 3. DOWNLOAD GO
echo "---> Downloading and extracting Go"
go_url=https://golang.org/dl/go1.16.3.linux-amd64.tar.gz
wget -q -O - "$go_url" | tar -xzf - -C "$golayer"

# 4. MAKE GO AVAILABLE DURING LAUNCH
echo -e 'launch = true' > "$layersdir/go.toml"

# 5. MAKE GO AVAILABLE TO THIS SCRIPT
export PATH="$golayer"/go/bin:$PATH

# 6. BUILD THE APP
go build

# ========== ADDED ===========
# 7. SET DEFAULT START COMMAND
cat > "$layersdir/launch.toml" <<EOL
[[processes]]
type = "web"
command = "./go-sample"
EOL

Wollen wir uns das Skript mal genauer unter die Lupe nehmen.

Schritte 1-5:

Wir erstellen uns den Layer für die go Installation und machen es für den Build verfügbar.

Schritt 6:

Wir bauen unsere Applikation und erzeugen das fertige Binary

Schritt 7:

Wir müssen unserer Applikation ein default Start Kommando übergeben. Wir können hier mehrere Prozesse angeben, wenn wir verschiedene Entrypoints haben (Beispielsweise könnte hier noch ein asynchroner Task laufen). Der “web” Prozess ist der aktuell der Default Prozess.

Nun können wir unseren Build wieder ausführen und sollten keine Fehler mehr erhalten.

pack build go-sample --path . --buildpack ./go-buildpack

Output:
bionic: Pulling from cnbs/sample-builder
Digest: sha256:a674cd6b556924e0b36000c00f0cda8ee42c20aa9be45e4ddfc65ea43c5423e7
Status: Image is up to date for cnbs/sample-builder:bionic
bionic: Pulling from cnbs/sample-stack-run
Digest: sha256:0e6d2966062c26f0a0660c89c5bd1dba7e1fa019e6d68ef5c3694eafde1ab805
Status: Image is up to date for cnbs/sample-stack-run:bionic
0.10.2: Pulling from buildpacksio/lifecycle
Digest: sha256:c3a070ed0eaf8776b66f9f7c285469edccf5299b3283c453dd45699d58d78003
Status: Image is up to date for buildpacksio/lifecycle:0.10.2
===> DETECTING
[detector] b-nova.com/go-sample 0.0.1
===> ANALYZING
===> RESTORING
===> BUILDING
[builder] ---> Go Buildpack
[builder] ---> Downloading and extracting Go
===> EXPORTING
[exporter] Adding layer 'b-nova.com/go-sample:go'
[exporter] Adding 1/1 app layer(s)
[exporter] Reusing layer 'launcher'
[exporter] Adding layer 'config'
[exporter] Reusing layer 'process-types'
[exporter] Adding label 'io.buildpacks.lifecycle.metadata'
[exporter] Adding label 'io.buildpacks.build.metadata'
[exporter] Adding label 'io.buildpacks.project.metadata'
[exporter] Setting default process type 'web'
[exporter] *** Images (10b297b97276):
[exporter]       go-sample
Successfully built image go-sample

Umgebungsvariablen

Nun da wir unser eigenes Buildpack haben, können wir beispielsweise Umgebungsvariablen an unseren Buildprozess übergeben. Schauen wir uns das an einem kleinen Beispiel an. Nehmen wir an, wir müssten den Port der Applikation im Buildprozes konfigurieren können.

Wir ändern also in unserer Applikation die Initialisierung des Ports folgendermassen ab:

alt: 
func main() {
  port := "8080"

neu:
var Port string
func main() {
  port := Port
  if port == "" {
    port = "8080"
  }

Wir lesen also den Port aus der Umgebungsvariable PORT aus, welche an unsere Go-Applikation übergeben wird.

Danach müssen wir unser build File modifizieren, damit wir auf die Umgebungsvariablen Zugriff haben.
Wir fügen folgendes unter Schritt 1 “GET ARGS” ein:

# 1. GET ARGS
layersdir=$1

# ENV VARS
platform_dir=$2
env_dir=${platform_dir}/env
echo "     env_dir: ${env_dir}"
echo "     env vars:"
if compgen -G "${env_dir}/*" > /dev/null; then
  for var in ${env_dir}/*; do
    declare "$(basename ${var})=$(<${var})"
  done
fi
export | sed 's/^/       /'

und ändern den Build Befehl folgendermassen ab

# 6. BUILD THE APP
go build -ldflags "-X main.Port=$PORT"

Nun können wir beim Bauen des Image den Port als Umgebungsvariable mitgeben. Wir führen dazu den build folgendermassen aus:

pack build go-sample --path . --env="PORT:8081" --buildpack ./go-buildpack

Der Http-Server sollte jetzt auf Port 8081 statt Port 8080 laufen.

Den gesamten Quellcode findet in unserem Github: https://github.com/b-nova/buildpacks-go-sample

Ausblick

Cloud Native Buildpacks sind ein sehr mächtiges und einfaches Mittel, wie man seinen Quellcode schnell in ein Image transformieren kann. Wir haben heute einen kleinen Ausblick gesehen, wie man sein eigenes buildpack erstellen kann um den Build anzupassen. Ich werde mir in den nächsten Tagen noch Tekton in Verbindung mit Cloud Native Buildpacks anschauen. Ob es aber einen weiteren Blogbeitrag dazu gibt ist noch nicht sicher.

Was aber sicher ist, ist die Tatsache, dass wir bei b-nova uns weiterhin mit interessanten Themen rund um die Themen Cloud, GitOps und DevOps auseinandersetzen werden. Stay tuned.

Stefan Welsch – Pionier, Stuntman, Mentor. Als Gründer von b-nova ist Stefan immer auf der Suche nach neuen und vielversprechenden Entwicklungsfeldern. Er ist durch und durch Pragmatiker und schreibt daher auch am liebsten Beiträge die sich möglichst nahe an 'real-world' Szenarien anlehnen.