Devfile: Standardisiere deine Entwicklungsumgebung

24.07.2024Stefan Welsch
DevOps Developer Experience Development Integration DevOps Cloud Computing How-to Web Development

Banner

Devfile.io ist eine Open-Source-Initiative, die einen offenen Standard für containerisierte Entwicklungsumgebungen mithilfe von YAML-Dateien definiert. Das Tool wurde 2019 in Go geschrieben und das Hauptziel ist die Einrichtung und Verwaltung von Entwicklungsumgebungen zu vereinfachen und zu automatisieren. Dies ist besonders nützlich in der Cloud-nativen Entwicklung, bei der die Umgebungen über verschiedene Entwicklungs-, Test- und Bereitstellungsphasen hinweg konsistent sein müssen. Devfile ist ausserdem ein CNCF Sandbox Projekt. Hier ein Auszug aus der CNCF Landscape

image-20240613081819606

Entstehung und Zweck

Devfile.io wurde entwickelt, um die Herausforderungen der Aufrechterhaltung konsistenter Entwicklungsumgebungen zu bewältigen. Dies ist gerade in Zeiten von BYOD (bring your own device) wichtig, da die lokale Entwicklungsumgebung meist stark von dem jeweiligen Betriebssystem beeinflusst wird.

Die Idee ist daher, eine standardisierte Methode zur Definition der Konfiguration dieser Umgebungen bereitzustellen, wodurch sie portabel und reproduzierbar werden.

Ein Devfile spezifiziert dabei die Werkzeuge, Abhängigkeiten und Einstellungen, die für eine Entwicklungsumgebung erforderlich sind, sodass Entwickler sofort das machen können, was ihnen auch wirklich Spass macht, nämlich direkt mit dem Coding beginnen zu können, ohne ihre Umgebung jedes Mal aufwendig und nervenaufreibend manuell einrichten zu müssen.

Hauptmerkmale und Vorteile

Standardisierung: Devfiles verwenden YAML und definieren eine klare API mittels einer Schema Version. Reproduzierbarkeit: Durch die Definition der Umgebung in einem Devfile können Entwickler sicherstellen, dass die Umgebung auf verschiedenen Maschinen und über die gesamte Entwicklungslaufzeit eines Projekts konsistent ist. Automatisierung: Devfiles automatisieren die Einrichtung von Entwicklungsumgebungen und reduzieren den Zeit- und Arbeitsaufwand für die manuelle Konfiguration dieser Umgebungen. Integration: Devfiles integrieren sich in verschiedene Entwicklungswerkzeuge und -plattformen wie Eclipse Che, odo und JetBrains Space 1️⃣, Red Hat Developer Sandbox, um nahtlose Entwicklungserfahrungen zu bieten.

1️⃣ Leider sieht es allerdings so aus, als ob Jetbrains Space nicht mehr weiter angeboten wird und durch Github Codespaces ersetzt wurde bzw. gerade in der Transition ist.

Innerloop vs Outerloop

In einer Devfile-Spezifikation gibt es zwei Bereiche für die Bereitstellung: Innerloop und Outerloop. Diese Bereiche sind für eine umfassende Entwicklungserfahrung sowie für die ordnungsgemäße Integration des gesamten Spektrums an Entwicklungstools für Kubernetes- und OpenShift-Projekte unerlässlich.

Innerloop

Innerloop sind alle Aktionen, die ein Entwickler in seiner Entwicklungsumgebung durchführt, z. B. das Ausführen von Tests, Debugging und lokale Implementierungen, bevor er seinen Code in das VCS (Version Control System) eincheckt.

Outerloop

Outerloop deckt somit logischerweise dann alles ab, was nach der Entwicklungsphase kommt. Sobald der Quellcode in das VCS eingecheckt wurde, werden beispielsweise Integrationstests, vollständige Builds oder Deployments durchgeführt.

Aufbau eines Devfile’s

Wollen wir uns erstmal anschauen, wie so ein devfile aussieht. Wir sehen hier ein valides Devfile, welches die Minimalanforderungen erfüllt.

1
2
3
4
5
6
7
schemaVersion: 2.3.0

components: 
  - name: golang
    container: 
      image: golang:1.22-bookworm
      command: "tail -f /dev/null"

In Zeile 1 definieren wir die Schema Version. Diese definiert einfach, welche Elemente in unserem Yaml File erlaubt sind und welche nicht, bzw. definiert die API Version, die wir nutzen.

In Zeile 3 definieren wir dann die Komponenten. Komponenten sind nichts anderes als Development Tools, Runtimes oder auch Services. Hier geben wir konkret das Container Image an, welches für die Entwicklung verwendet werden soll.

Es gibt noch viele weitere Dinge, die wir definieren können. Eine vollständige Liste findet man in der Beschreibung der jeweiligen Schema Version.

Aber wollen wir uns doch mal ein kleines Real World Beispiel ansehen. Da ich in meinem nächsten Techup odo anschauen möchte, werde ich euch hier ein Beispiel anhand der Red Hat OpenShift Dev Spaces zeigen. Ich erstelle mir erstmal ein kleines Go Programm mit dem ich dann später “spielen” kann,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

import (
  "fmt"
  "net/http"
)

func main() {
  http.HandleFunc("/", HelloServer)
  http.ListenAndServe("0.0.0.0:8080", nil)
}

func HelloServer(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:])
}

Sollte man noch keinen Red Hat Account haben, muss man sich diesen zuerst erstellen. Ich werde diese Schritte hier nicht einzeln zeigen, da diese recht intuitiv sind. Da ich meistens in IntelliJ entwickle, installiere ich mir als nächstes das OpenShift Toolkit by Red Hat. Nachdem diese beiden Schritte gemacht sind, sollte im IntelliJ ein neues Icon auf der linken Seite erscheinen.

image-20240610094510963

Bei euch steht dort jetzt wahrscheinlich noch eine lokale Url zum Cluster. Mit einem Rechtsklick auf den Server könnt ihr euch dann bei eurem Remote Cluster anmelden. Auch hier sind wieder ein paar Schritte in einem internen IntelliJ Browser erforderlich, auf die ich hier nicht weiter eingehe. Wenn wir das erledigt haben, kümmern wir uns um unser eigentliches Devfile. Lokal habe ich jetzt ein ganz einfaches Go Projekt, mit dem oben gezeigten Code.

image-20240610095003552

Wenn ich wieder zurück in die OpenShift Ansicht gehe, sehe ich mein lokales Projekt und kann dort “Start dev on Cluster” auswählen.

image-20240610095203796

Es öffnet sich ein interaktives Terminal in dem ich nun noch ein paar Angaben machen muss. Ich wähle hier jeweils die Defaults. Wenn alles fertig ist, sollte in der Ausgabe irgendwann die folgenden Zeilen stehen.

[!TIP]

Ich hatte anfangs ein paar Probleme, da die Verzeichnisse nicht korrekt angegeben wurden oder die Berechtigungen auf die Ordner falsch waren. Mit ein paar Anpassungen an den Pfaden hat es aber dann doch geklappt.

1
2
${PROJECT_SOURCE}/.go --> /opt/app-root/src/.go
${PROJECT_SOURCE}/.cache --> /tmp/.cache

image-20240610103216190

Nun wir können jetzt sehen, dass Port Forwardings erzeugt wurden, welche wir jetzt lokal aufrufen können. Wenn ich im Browser http://127.0.0.1:20001/b-nova erscheint die folgende Seite

image-20240610103657817

Wir sehen nun also durch das Port Forwarding im lokalen Browser die Ausgabe der Go Applikation, welche auf dem OpenShift Cluster läuft. Sehr cool!

Gehen wir wieder in unseren lokalen Ordner. Wir sehen, dass das devfile.yaml für uns angelegt wurde.

image-20240610103904505

Wollen wir uns die Datei doch mal genauer anschauen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
schemaVersion: 2.2.0

metadata:
  description: Go (version 1.19.x) is an open source programming language that makes
    it easy to build simple, reliable, and efficient software.
  displayName: Go Runtime
  icon: https://raw.githubusercontent.com/devfile-samples/devfile-stack-icons/main/golang.svg
  language: Go
  name: devfiles-demo
  projectType: Go
  provider: Red Hat
  tags:
    - Go
  version: 1.2.1

components:
  - container:
      args:
        - tail
        - -f
        - /dev/null
      endpoints:
        - name: port-8080-tcp
          protocol: tcp
          targetPort: 8080
        - exposure: none
          name: debug
          targetPort: 5858
      env:
        - name: DEBUG_PORT
          value: "5858"
      image: registry.access.redhat.com/ubi9/go-toolset:1.19.13-4.1697647145
      memoryLimit: 1024Mi
      mountSources: true
    name: runtime
    
commands:
  - exec:
      commandLine: go build main.go
      component: runtime
      env:
        - name: GOPATH
          value: /opt/app-root/src/.go
        - name: GOCACHE
          value: /tmp/.cache
      group:
        isDefault: true
        kind: build
      workingDir: ${PROJECT_SOURCE}
    id: build
  - exec:
      commandLine: ./main
      component: runtime
      group:
        isDefault: true
        kind: run
      workingDir: ${PROJECT_SOURCE}
    id: run
  - exec:
      commandLine: |
        dlv \
          --listen=127.0.0.1:${DEBUG_PORT} \
          --only-same-user=false \
          --headless=true \
          --api-version=2 \
          --accept-multiclient \
          debug --continue main.go        
      component: runtime
      env:
        - name: GOPATH
          value: /opt/app-root/src/.go
        - name: GOCACHE
          value: /tmp/.cache
      group:
        isDefault: true
        kind: debug
      workingDir: ${PROJECT_SOURCE}
    id: debug

Das devfile sieht schon etwas komplizierter aus, als unser Minimalbeispiel. Wollen wir mal Zeile für Zeile durchgehen und es aufbröseln. schemaVersion haben wir uns weiter oben bereits angeschaut. Springen wir also sofort zu den Metadaten.

Metadata

Wie der Name bereits sagt, können wir für unser devfile Metadaten definieren, welche dem Entwickler zusätzliche Informationen liefern. Alle Metadaten sind optional, weswegen ich diese hier nicht weiter verfolge.

Kommen wir zum “Herzstück” unseres devfile’s. Die Component

Component

Die Component definiert unsere Laufzeitumgebung, bzw. Umgebungen. Es gibt 5 verschiedene Typen von Components: kubernetes, container, openshift, image, volume.

Wir schauen uns container, image und volume mal genauer an.

container

Mit container können wir benutzerdefinierte Tools in den Arbeitsbereich einbinden. Diese werden mittels eines Container Image imagedefiniert. Wir können dem Container dabei args , also Argumente übergeben, oder auch per env Umgebungsvariablen zur Verfügung stellen. Mit endpoints geben wir an, auf welchen Ports der Container angesprochen werden kann. memoryLimit definiert noch den maximalen Speicher, der dem Container zur Verfügung steht und mountSources erlaubt dem Container den Zugriff auf die Projektsourcen (/projects Pfad) .

image

Im Gegensatz zu container können wir mit image direkt ein Image basierend auf einem Dockerfile bauen. Hier ein Beispiel:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
components:
  - name: outerloop-build
    image:
      imageName: python-image:latest
      autoBuild: true
      dockerfile:
        uri: docker/Dockerfile
        args:
          - 'MY_ENV=/home/path'
        buildContext: .
        rootRequired: false

Ich denke der Aufbau ist selbsterklärend.

volume

Als letztes schauen wir uns noch volume an. Wir können diese dazu nutzen, Daten zwischen den Containern auszutauschauen oder auch um Daten während der Entwicklung mit anderen Teams auszutauschen. Schauen wir uns das an einem kleinen Beispiel an:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
schemaVersion: 2.2.0
metadata:
  name: mydevfile
components:
  - name: mydevfile
    container:
      image: golang
      memoryLimit: 512Mi
      mountSources: true
      command: ['sleep', 'infinity']
      volumeMounts:
        - name: cache
          path: /.cache
  - name: cache
    volume:
      size: 2Gi

Hier sehen wir, dass es ein Volume cache gibt, welches dann per Volume Mount dem Container hinzugefügt wird.

Zeile 37-78 definiert uns 3 Commands. Schauen wir uns an, was Commands sind und wofür wir diese brauchen.

Commands

Commands in einem Devfile sind spezifische Anweisungen oder Aktionen, die definiert werden, um verschiedene Entwicklungsaufgaben innerhalb einer Entwicklungsumgebung zu automatisieren und zu erleichtern. Diese Commands sind wesentliche Bestandteile eines Devfiles und dienen verschiedenen Zwecken, darunter das Bauen, Testen, Ausführen und Debuggen von Anwendungen.

Wofür werden Commands gebraucht?

  1. Automatisierung von Aufgaben:
    • Build-Commands: Automatisieren den Bauprozess der Anwendung, indem sie die erforderlichen Werkzeuge und Schritte zum Kompilieren des Codes und Erstellen von Artefakten ausführen.
    • Run-Commands: Starten die Anwendung in einer bestimmten Umgebung, sei es lokal oder in einer Cloud-Umgebung.
    • Test-Commands: Führen Testsuiten aus, um die Anwendung zu überprüfen und sicherzustellen, dass sie wie erwartet funktioniert.
  2. Standardisierung und Konsistenz:
    • Durch die Definition von Commands im Devfile können alle Entwickler eines Teams dieselben Befehle verwenden, was zu einer konsistenteren und vorhersehbareren Entwicklungsumgebung führt.
  3. Erleichterung der Entwicklung:
    • Debug-Commands: Erleichtern das Debuggen der Anwendung durch Vorkonfiguration von Debugging-Tools und -Einstellungen.
    • Init-Commands: Führen Initialisierungsaufgaben durch, wie z.B. das Einrichten von Datenbanken oder das Konfigurieren von Umgebungsvariablen.
  4. Wiederholbarkeit und Skalierbarkeit:
    • Commands ermöglichen es, wiederholbare und skalierbare Entwicklungsprozesse zu schaffen, die leicht von einem Entwickler auf den anderen übertragen werden können.

Struktur eines Commands

Ein Command in einem Devfile ist typischerweise als YAML- oder JSON-Eintrag definiert und besteht aus mehreren Komponenten, darunter:

  • ID: Die ID des Commands.
  • attributes: Map in der man Implementierungsabhängige yaml Attribute definieren kann.
  • Type: Der Typ des Commands (z.B. exec für das Ausführen eines Shell-Befehls, apply für das Anwenden einer K8s-Ressource, composite für die Ausführung mehrerer Sub-Commands ).
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
commands:
  - exec:
      commandLine: go build main.go
      component: runtime
      env:
        - name: GOPATH
          value: /opt/app-root/src/.go
        - name: GOCACHE
          value: /tmp/.cache
      group:
        isDefault: true
        kind: build
      workingDir: ${PROJECT_SOURCE}
    id: build
  - exec:
      commandLine: ./main
      component: runtime
      group:
        isDefault: true
        kind: run
      workingDir: ${PROJECT_SOURCE}
    id: run
  - exec:
      commandLine: |
        dlv \
          --listen=127.0.0.1:${DEBUG_PORT} \
          --only-same-user=false \
          --headless=true \
          --api-version=2 \
          --accept-multiclient \
          debug --continue main.go        
      component: runtime
      env:
        - name: GOPATH
          value: /opt/app-root/src/.go
        - name: GOCACHE
          value: /tmp/.cache
      group:
        isDefault: true
        kind: debug
      workingDir: ${PROJECT_SOURCE}
    id: debug

Wir haben in unserem Beispiel nur exec als Type und wollen uns diesen nun genauer anschauen. Mit dem exec Type können wir CLI Commands in unserem Container ausführen.

Das Attribut commandLine definiert dabei den Befehl.

Mit component können wir angeben, auf welche Komponente sich das Command bezieht. Da wir nur eine Komponente runtime haben, wird auch nur diese angegeben.

Wir können mit env jedem Command Umgebungsvariablen zur Verfügung stellen.

Ein weiteres Feld ist group. Mögliche Werte sind hier build, run, test, debug oder deploy. So können wir also für die verschiedenen Phasen in unserer Applikation, ein entsprechendes Command ausführen. isDefault definiert dann das Standard Command innerhalb einer Gruppe. Es darf nur ein Default Command geben.

Damit können wir also für den entsprechenden Lifecycle genau definieren, was ausgeführt werden soll.

Schauen wir uns das ganze doch mal in der Praxis, am Beispiel unseres Go Programms an. Was passiert jetzt genau, wenn wir lokal entwickeln und sich was am Code ändert.

Wir ändern in unserem Programm die Begrüssung von “Hello” zu “Hello and welcome” ab und beobachten, was genau in der Konsole passiert.

1
2
3
func HelloServer(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Hello and welcome, %s!", r.URL.Path[1:])
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
File /Users/swelsch/Development/b-nova/github.com/b-nova-techhub/devfiles-demo/main.go changed
 •  Waiting for Kubernetes resources  ...
 ✓  Syncing files into the container [3ms]
 ✓  Waiting for the application to be ready [2s]

↪ Dev mode
 Status:
 Watching for changes in the current directory /Users/swelsch/Development/b-nova/github.com/b-nova-techhub/devfiles-demo

Web console accessible at http://localhost:20000/

Keyboard Commands:
[Ctrl+c] - Exit and delete resources from the cluster
     [p] - Manually apply local changes to the application on the cluster
Pushing files...

Und wenn wir nun im Browser wieder unsere Applikation aufrufen?

image-20240610121327945

Das ist ziemlich cool. Wir können also lokal auf unserem Rechner entwickeln und der ganze Build und Deployment-Prozess wird anhand des devfile’s für uns erledigt. Im Hintergrund wird ein Deployment auf RedHat OpenShift erstellt und die Dateien bei einer Änderung synchronisiert und neu gebaut.

Hier sehen wir das OpenShift Deployment in der Konsole

image-20240610122043634

Schauen wir uns das ganze noch einmal im Pod selbst an. Ich verbinde mich mit dem Terminal zum Pod und gehe in das Source-Verzeichnis:

image-20240610122243313

Wir sehen die letzte Änderung der Binary Datei (also main) war um 10:17. Ich ändere jetzt lokal wieder den Text in der main.go Datei und wie wir sehen können ändern sich die Timestamps der Quelldatei und auch der des Binary.

image-20240610122452035

Das war eine kurze Einführung in devfiles.io was das Setup eines Entwicklungsprojekts wirklich sehr einfach und unkompliziert macht. Dem aufmerksamen Leser ist wahrscheinlich schon aufgefallen, dass im Hintergrund odo.dev verwendet wird, welches ich oben bereits erwähnt habe. Ich werde euch dies im nächsten Techup genauer zeigen.

Devfile Registry

Schauen wir uns als letztes noch die Devfile Registry an. Diese dient dazu, Devfile-Stacks für Kubernetes-Entwicklerwerkzeuge wie odo, Eclipse Che und die OpenShift Developer Console zu speichern und bereitzustellen. Damit können wir also über die Devfile-Registry direkt auf Devfiles zugreifen und diese nutzen.

Dabei entspricht jeder Devfile-Stack einer bestimmten Laufzeit oder einem bestimmten Framework, z. B. Node.js, Quarkus oder Go. Ein Devfile-Stack enthält ausserdem auch noch die devfile.yaml-Datei, ein Logo und auch Outer-Loop-Ressourcen. Diese sorgen dafür das Codeüberprüfungen und Integrationstests ausgeführt werden, die in der Regel durch CI/CD-Pipelines (Continuous Integration/Continuous Delivery) automatisiert werden. Kurz gesagt bieten die Devfile-Stacks Entwicklern eine Vorlagen für den Einstieg in die Entwicklung cloud-nativer Anwendungen.

image-20240613080718566

Fazit

Devfiles ist meiner Meinung nach ein Schritt in die richtige Richtung. Da ich dies bis jetzt aber nur in Testprojekten verwendet habe und auch nicht wirklich im Team damit gearbeitet habe, kann ich es abschliessend noch nicht bewerten. Für meine Tests ist es allerdings ein sehr nützliches Tool, welches die Setup Zeit einer Entwicklungsumgebung wesentlich optimieren kann.

Stefan Welsch

Stefan Welsch – Manitu, 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.