Wenn man als Software-Entwickler an einem Projekt arbeitet, das über “Hallo Welt” und ein paar Dutzend Zeilen Code hinaus geht, sammelt sich schnell ein ganzer Zoo an Werkzeugen an: Ein Paketmanager zur Verwaltung der Abhängigkeiten, ein Linter und/oder Formatter für lesbaren und standardkonformen Code, ein Test Runner, ein Build Tool und so weiter. Wäre es da nicht schön, wenn man beispielsweise einem neuen Teammitglied einfch ein vorgefertigtes Bündel mit allen Tools übergeben könnte? Das geht tatsächlich, mit einem sogenannten Development Container.
Bisher läuft es meistens so ab: Man bearbeitet den Code auf seinem lokalen Entwicklungsrechner, auf dem auch alle benötigten Werkzeuge laufen. Die Ergebnisse der Arbeit landen später in irgendeiner Form zum Beispiel in einem Docker-Container oder einem Kubernetes-Pod. Mit einem Development Container oder kurz Devcontainer sind die Werkzeuge in einem Container installiert und werden dort ausgeführt. Der Code Editor spaltet sich gewissermaßen in zwei Teile auf: Ein Teil läuft weiterhin auf dem lokalen Rechner, und ein zweiter Teil läuft im Container, um die verschiedenen Werkzeuge einzubinden.
Die Definition eines solchen Devcontainers umfasst je nach konkretem Setup einige wenige, im einfachsten Fall sogar nur eine einzige Datei. Ein neues Teammitglied kann sich diese Datei oder Dateien herunterladen und unkompliziert eine komplette Entwicklungsumgebung auf dem eigenen Rechner starten.
Die Development Container Specification stammt ursprünglich aus dem Team hinter Microsofts Visual Studio Code. Dort stand man vor einem ähnlichen Problem wie oben beschrieben: Wie stellt man auf unkomplizierte Weise sicher, dass jedes Teammitglied mit dem gleichen Toolstack arbeitet? Entsprechend ist die Unterstützung für Devcontainer in VSCode am umfangreichsten. Support in unterschiedlichem Umfang gibt es aber auch in GitHub Codespaces, in IntelliJ IDEA, als Kommandozeilen-Programm und anderen Tools.
Im einfachsten Fall genügen ein paar Zeilen in einer JSON-Datei im Projektverzeichnis: Eine .devcontainer/devcontainer.json kann zum Beispiel so aussehen:
{
"name": "An example project",
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bookworm",
"forwardPorts": [
3000
],
"postCreateCommand": "npm install",
"postStartCommand": "npm run dev"
}
Mit dieser Beschreibungsdatei kann man einen Devcontainer für ein Typescript-Projekt auf Node.js starten. Der Container öffnet Port 3000, nach dem Erzeugen des Devcontainers wird erstmal alles installiert, und nach jedem Start des Containers wird der dev server gestartet. Mehr braucht man dazu nicht.
In diesem Blogbeitrag soll es aber um einen etwas komplexeren Fall gehen. Zum einen möchten wir als Editor nicht VSCode verwenden, sondern unseren persönlichen Favoriten Zed, dessen Unterstützung für Devcontainer noch recht rudimentär ist. Zum anderen soll als Basis nicht ein vorgefertigtes Image aus einer Registry dienen, sondern ein eigenes Docker-Image. Stattfinden soll das Ganze im PHP-Ökosystem.
Die .devcontainer/devcontainer.json ist gar nicht mal so viel komplexer als im oben gezeigten Beispiel:
{
"name": "A little more complex example",
"dockerComposeFile": "../docker-compose.yml",
"service": "dev",
"runServices": ["dev", "php", "web", "db"],
"workspaceFolder": "/app"
}
Das name-Feld ist genau wie oben. Anstelle eines image verweist die Beschreibung jetzt auf ein compose file, das wir gleich noch näher betrachten werden. In diesem compose file ist ein service namens dev definiert, dessen Container wir als Devcontainer verwenden wollen. Insgesamt sollen beim Starten die Services dev, php, web und db hochgefahren werden. Und der Editor soll das Verzeichnis /app im Container als Workspace nehmen.
Also weiter zur docker-compose.yml. Interessant ist hier vor allem der Service dev:
services:
(...)
dev:
build: .
container_name: devcontainer
volumes:
- ./app:/app
(...)
Auch hier wird kein vorgefertigtes Image verwendet, sondern der . bezieht sich auf ein Dockerfile im selben Verzeichnis, das wir noch näher anschauen werden. Auch warum der Container, den dieser Service startet, einen bestimmten Namen bekommt, wird noch eine Rolle spielen. Der Quellcode unserer App wird als Volume integriert.
Als nächstes also zum Dockerfile:
FROM php:8.4-cli
# install composer
RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
&& php composer-setup.php \
&& php -r "unlink('composer-setup.php');" \
&& mv composer.phar /usr/local/bin/composer
# install phpactor
RUN curl -Lo phpactor.phar https://github.com/phpactor/phpactor/releases/latest/download/phpactor.phar \
&& chmod a+x phpactor.phar \
&& mv phpactor.phar /usr/local/bin/phpactor
CMD [ "tail", "-f", "/dev/null" ]
Die Basis ist das offizielle php Image. Anschließend installieren wir noch Composer und Phpactor. Letzterer ist der Language Server, den Zed standardmäßig für PHP verwendet. Weitere Tools können hier hinzugefügt, oder auch über Composer als Abhängigkeit definiert werden. Wichtig ist, dass der im Container gestartete Prozess nicht sofort wieder beendet wird - schließlich soll der Container noch vorhanden sein, wenn sich Zed zu ihm verbinden will. Also nehmen wir etwas, dass im Zweifel bis in alle Ewigkeit läuft, hier tail -f /dev/null.
Jetzt brauchen wir noch ein paar Tasks für Zed, um den Devcontainer zu starten, ein Terminal im Container zu öffnen usw. Die dazugehörige .zed/tasks.json sieht so aus:
[
{
"label": "DevContainer: Up",
"command": "devcontainer up --workspace-folder ${ZED_WORKTREE_ROOT}",
"use_new_terminal": true,
"allow_concurrent_use": false,
"reveal": "always"
},
{
"label": "DevContainer: Open Shell",
"command": "devcontainer exec --workspace-folder ${ZED_WORKTREE_ROOT} bash",
"use_new_terminal": true,
"allow_concurrent_use": false,
"reveal": "always"
}
]
Das definiert die Tasks DevContainer: Up und DevContainer: Open Shell. Der eine startet den Devcontainer, der andere öffnet ein Terminal in Zed, in dem eine bash Shell im Container läuft. Dort kann man dann beispielsweise neue Abhängigkeiten mit Composer hinzufügen, die Testsuite mit PHPUnit oder Pest laufen lassen und so weiter - eben alles, was man in einer Shell auf dem lokalen Rechner tun würde, wenn die Tools dort installiert wären.
Hier wird auch wichtig, dass der compose service dev weiter oben den richtigen container_name bekommen hat. Denn aus einem noch nicht ganz geklärten Grund findet devcontainer exec --workspace-folder ${ZED_WORKTREE_ROOT} ... den Container nicht. Alternativ kann mit devcontainer exec --container-id some_container_id direkt auf den Container verweisen. Es hat sich aber gezeigt, dass es direkt funktioniert, wenn der Container den offenbar erwarteten Namen, eben devcontainer, hat.
Bleibt noch die Frage, woher das Kommando devcontainer kommt, das diese Tasks aufrufen. Das ist die Devcontainer CLI, die wir noch kurz installieren müssen:
npm install -g @devcontainers/cli
Das Vorhandensein von npm und Node.js setzen wir an dieser Stelle einfach voraus, damit dieser Blogbeitrag nicht ausufert.
Und das war es im Wesentlichen schon. Natürlich könnte man dieses Setup noch deutlich ausbauen, beispielsweise composer install als postCreateCommand in der devcontainer.json einbauen, oder die Tasks um solche zum Bauen oder zum Herunterfahren des Containers erweitern. Aber die Grundlagen in diesem Blogbeitrag sollten hoffentlich jeden in die Lage versetzen, sich einen Devcontainer ganz nach den eigenen Bedürfnissen zu bauen, und wenn gewünscht als Basis für die gemeinsame Arbeit im Team zu verwenden.