Für das Ausführen von Anwendungen hat sich, vor allem im Cloud-Umfeld, die Verwendung von Containern etabliert. Mit dem Aufkommen von Docker wurde es einfach, Software in einer definierten Umgebung auszuführen, welche sich einfach reproduzieren lässt. Sämtliche Runtime-Abhängigkeiten werden in ein Container-Image verpackt und können einfach auf einem anderen System ausgeführt werden. Die Diskrepanz zwischen unterschiedlichen Stages im Entwicklungsprozess (Development, Test, Produktion) wird reduziert, works on my machine zählt nicht mehr als Ausrede.

Um nun nicht nur die Laufzeit- sondern auch die Entwicklungs-Umgebung reproduzierbar und portierbar zu gestalten, bieten sich Development Containers an.

Development Container

Um eine Entwicklungsumgebung herzustellen, genügt heutzutage zumeist nicht mehr nur ein Texteditor oder eine IDE. Es werden Runtimes, Compiler, CLIs und verschiedenste Tools benötigt, um Software zu entwickeln. Arbeitet man in mehreren Programmiersprachen oder Projekten, kann das Setup schnell einige Stunden in Anspruch nehmen. Ein weiteres Problem tritt auf, wenn man unterschiedliche Versionen einer Software benötigt. Node.js ist hier ein Beispiel, für das es mit dem Node Version Switcher mehr einen Workaround als eine gute Lösung gibt.

Zur Lösung dieser Probleme werden schon seit langer Zeit vorkonfigurierte virtuelle Maschinen eingesetzt, die entweder lokal ausgeführt werden oder zu denen man sich über ein Remote-Protokoll wie SSH oder RDP verbinden kann. Die Developer-Experience ist in beiden Fällen aber zumeist nicht optimal.

Nutzt man für die Entwicklung Visual Studio Code, bietet sich mit der Nutzung von Devcontainern hier eine leichtgewichtige Alternative an. Neben VS Code unterstützt bislang nur Visual Studio den Einsatz von Devcontainern, und auch nur für C++ Projekte. Lässt sich der eigene Workflow aber mit diesen Einschränkungen umsetzen, sind Devcontainer eine gute Option. Netter Nebeneffekt: Die Devcontainer verhalten sich auf unterschiedlichen Computern und Betriebssystemen gleich und sind somit für die Zusammenarbeit im Team gut geeignet. Die Konfiguration lässt sich neben dem Sourcecode in einem Git-Repository ablegen und versionieren.

Devcontainer in VS Code

Voraussetzung für die Nutzung von Devcontainern in VS Code ist eine installierte Version von Docker und die Extension Dev Containers, welche direkt von Microsoft kommt. Sie stellt über die Command Palette eine Reihe von Anweisungen bereit, um Devcontainer zu erstellen und zu verwalten.

VS Code Command Palette mit Befehlen für die Nutzung von Devcontainers

Devcontainer erstellen

Zum Erstellen eines Devcontainers genügt eine JSON-Datei devcontainer.json im Unterordner .devcontainer. Die Definition eines Containers besteht im Grunde aus drei Bereichen: Image, Features und Konfiguration. Wählt man in VS-Code den Command Dev Containers: Add Dev Container Configuration Files..., wird man durch genau diese drei Bereiche geführt.

  • Image: Beschreibt das Docker-Image, welches als Ausgangspunkt für den Devcontainer dient. Hier kann ein vorkonfiguriertes Devcontainer-Image oder jedes beliebige andere Docker-Image nutzen werden. Alternativ kann man ein Dockerfile oder ein Docker Compose-File angeben, um ein eigenes Image zu erstellen.

  • Features: Pakete und Optionen, um Software zum Ausgangsimage hinzuzufügen und zu konfigurieren. So können Tools wie git, verschiedene CLIs oder Docker aber auch ganze Entwicklungs-Stacks für einzelne Programmiersprachen wie Go, .NET oder PHP als Feature einfach hinzugefügt werden. Es existieren einige vordefinierte Features, alternativ baut man sich eigene Features aus Shell-Scripten zusammen.

  • Konfiguration: Unter Konfiguration fallen z.B. Port-Forwardings, Lifecycle-Scripte oder Anpassungen für VS Code.

Die folgende Konfiguration nutzt das Baseimage für Note.js 20 mit TypeScript, installiert zusätzlich die AWS- sowie die GitHub-CLI und führt nach dem Erstellen des Containers den Befehl yarn install aus.

{
  "$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.schema.json",
  "name": "Node.js & TypeScript",
  "image": "mcr.microsoft.com/devcontainers/typescript-node:0-20",
  "features": {
    "ghcr.io/devcontainers/features/aws-cli:1": {
      "version": "latest"
    },
    "ghcr.io/devcontainers/features/github-cli:1": {
      "installDirectlyFromGitHubRelease": true,
      "version": "latest"
    }
  },
  // Use 'postCreateCommand' to run commands after the container is created.
  "postCreateCommand": "yarn install"
}

Ruft man über die VS-Code Command Palette den Befehl Dev Containers: Reopen in Container auf, wird der Devcontainer erstellt und VS Code verbindet sich mit dem Container. Das Erstellen kann einige Minuten dauern, da das Baseimage heruntergeladen und die Features installiert werden müssen. Der Root-Ordner des Projektes wird in den Container gemountet.

Nach dem Start zeigt uns VS Code den gemounteten Ordner im Explorer an und wir können mit der Entwicklung beginnen. Über das +-Icon im Terminal-Fenster kommen wir an eine Shell, die im Container läuft.

VS Code mit geöffnetem Devcontainer

Ein eigenes Devcontainer-Image erstellen

Genügt ein vorkonfiguriertes Image in Kombination mit den zur Verfügung stehenden Features nicht, kann man sich mit Hilfe eines Dockerfiles ein maßgeschneidertes Devcontainer-Image bauen.

Für ein einfaches Ruby-Setup sieht das Dockerfile wie folgt aus:

FROM ruby:3.0-bullseye

RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
  && apt-get -y install --no-install-recommends nano libvips libvips-dev libvips-tools

RUN gem install jekyll bundler

Innerhalb der devcontainer.json-Datei gibt man statt des Keywords image unterhalb von build das Dockerfile sowie den Build-Context an. Über den Features-Block werden ZSH als Default-Shell und git installiert. Zusätzlich wird VS Code angewiesen, die Extensions für Ruby sowie Github Copilot zu installiert. Über den postCreateCommand-Eintrag stellt man sicher, dass die benötigten Gems installiert werden.

{
  "$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.schema.json",
  "name": "Ruby DevContainer",
  "build": {
    "dockerfile": "Dockerfile",
    "context": "."
  },
  "features": {
    "ghcr.io/devcontainers/features/common-utils:2": {
      "installZsh": true,
      "configureZshAsDefaultShell": true,
      "installOhMyZsh": true,
      "username": "vscode",
      "userUid": "1000",
      "userGid": "1000",
      "upgradePackages": true,
      "nonFreePackages": true
    },
    "ghcr.io/devcontainers/features/git:1": {
      "version": "latest",
      "ppa": false
    }
  },
  "customizations": {
    "vscode": {
      "extensions": [
        "rebornix.Ruby",
        "GitHub.copilot"
      ]
    }
  },
  "forwardPorts": [],
  "postCreateCommand": "bundler install --gemfile=./Gemfile && ruby --version",
  "remoteUser": "vscode"
}

Möchte man zusätzliche Software installieren oder hat Änderungen an dem Container-Image vorgenommen, erstellt man über den Befehl Dev Containers: Rebuild and Reopen in Container einfach ein neues Image.