Die Anforderungen an Softwareentwicklungsprojekte werden immer komplexer und damit steigt auch der Bedarf einer konsistenten reproduzierbaren Entwicklungsumgebung. Das Aufsetzen dieser Umgebungen kann oft zur zeitraubenden Mammutaufgabe werden.
Insbesondere bei älteren Workflows, die auf den Einsatz virtueller Maschinen setzen, ist das der Fall. Jeder Entwickler benötigt dabei eine Vielzahl von Tools, Libraries und Diensten, die konfiguriert werden müssen, um eine bestehende Code-Basis zum Laufen zu bringen.
Diese Problematik wird noch stärker ersichtlich, wenn verschiedene Betriebssysteme ins Spiel kommen. An der Stelle kommt Containerisierung ins Spiel, um den Konfigurationsaufwand solcher Prozesse auf ein Minimum zu beschränken.
Was versteht man unter Containerisierung?
Mit Tools wie Docker können Entwickler die gesamte Anwendungsarchitektur, inklusive aller Abhängigkeiten, in einem Container verpacken und diesen anschließend ausführen. Dadurch kann gewährleistet werden, dass die Software in jeder Umgebung auf die gleiche Weise ausgeführt wird. Das klassische „es funktioniert doch bei mir“- Problem kann dementsprechend vollständig vermieden werden.
Diese Container werden dabei über sogenannte Dockerfiles konfiguriert. Sie enthalten eine Reihe von Anweisungen, die den Aufbau des Containers schrittweise beschreiben. Auf Basis dieser Dateien erstellt Docker daraufhin Docker Images, welche für die eigentliche Ausführung der Container in beliebigen Zielumgebungen verwendet werden.
Jetzt stellt sich die Frage, wie man diesen Containerisierungsansatz praktisch für Anwendungen umsetzen kann. Zur Veranschaulichung wird im Folgenden die Containerisierung einer existierenden nopCommerce
-Anwendung durchgeführt.
Containerisierung einer nopCommerce-Anwendung
Glücklicherweise bietet Visual Studio in Kombination mit Docker Desktop
ein ausgezeichnetes Toolset, welches den Prozess der Containerisierung durch die automatisierte Erstellung von Dockerfiles und sogar Docker Compose Files zur Orchestrierung mehrerer Container enorm vereinfacht.
Definition des Dockerfiles
Für die Verwendung einer Standard-nopCommerce-Anwendung ist die daraus resultierende Schablone bereits ausreichend, um wenigstens die Anwendung ausführen zu können. Für die Ausführung einer existierenden nopCommerce-Anwendung hingegen müssen zusätzliche Anpassungen am Dockerfile vorgenommen werden.
4 Komponenten sind diesbezüglich von Bedeutung:
datasettings.json
– enthält den Connection String für die verwendete Datenbank.
appsettings.json
– enthält Einstellungen für den integrierten Kestrel-Webserver.
plugins.json
– hält den aktuellen Zustand aller installierter oder zu installierender Plugins fest.
- Theme Folder - Ordner des verwendeten Custom Themes (falls vorhanden)
Das vollständige Dockerfile könnte dann wie folgt aussehen:
FROM mcr.microsoft.com/dotnet/core/aspnet:2.2 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/core/sdk: 2.2 AS build
WORKDIR /src
COPY ["Libraries/Nop.Core/Nop.Core.csproj",
"Libraries/Nop.Core/"]
COPY ["Libraries/Nop.Data/Nop.Data.csproj", "Libraries/Nop.Data/"]
COPY ["Libraries/Nop.Services/Nop.Services.csproj", "Libraries/Nop.Services/"]
COPY ["Presentation/Nop.Web.Framework/Nop.Web.Framework.csproj", "Presentation/Nop.Web.Framework/"]
COPY ["Presentation/Nop.Web/Nop.Web.csproj", "Presentation/Nop.Web/"]
RUN dotnet restore "Presentation/Nop.Web/Nop.Web.csproj"
COPY..
WORKDIR */src/Presentation/Nop.Web"
RUN dotnet build "Nop.Web.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "Nop.Web.csproj" -c Release -o /app/publish/p:UseAppHost-false
COPY [./nop_config/dataSettings.json",
COPY [./nop_config/appsettings.json",
COPY [./nop_config/plugins.json",
"/app/publish/App_Data/"]
COPY [./Presentation/Nop. Web/Plugins/meineAnwendung/Theme", "/app/publish/Themes/meineAnwendung"]
"/app/publish/App_Data/"]
"/app/publish/App_Data/"]
FROM base AS final
WORKDIR /app
COPY --from-publish /app/publish
ENTRYPOINT ["dotnet", "Nop.Web.dll"]
Idealerweise sollte der Plugin-Code ebenfalls im Rahmen dieses Dockerfiles für den Build-Prozess konfiguriert werden, da ansonsten die durch Visual Studio kompilierten Plugin-Daten mitversioniert werden müssen.
Einrichtung lokaler Datenbank-Container
Nach der Definition des Dockerfiles für die nopCommerce-Anwendung erfolgt die Einrichtung eines lokalen Datenbank-Containers, mit dem die Anwendung auch kommunizieren kann. Zu diesem Zweck wird Docker Compose verwendet, mit dessen Hilfe wir das offizielle Image für Microsoft SQL Server herunterladen und für die Ausführung als Container definieren.
Das Compose-File sähe dann folgendermaßen aus:
version: "3.8"
services:
nop.web:
image: ${DOCKER_REGISTRY-}nopweb
build:
context: .
dockerfile: Presentation/Nop.Web/Dockerfile
depends_on:
- nop.db
ports:
- "80:80"
- "44360:443"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ASPNETCORE_Kestrel__Certificates__Default__Password=test123
- ASPNETCORE_Kestrel__Certificates__Default__Path=/https/nop-https.pfx
- ASPNETCORE_URLS=https://+:443;http://+:80
volumes:
- ./Plugins/Nop.Plugin.MeineAnwendung/Theme:/app/Themes/MeineAnwendungstheme
networks:
- nop_network
nop.db:
image: "mcr.microsoft.com/mssql/server:2022-latest"
container_name: nop_db
user: root
environment:
- SA_PASSWORD=meinPasswort
- ACCEPT_EULA=Y
- MSSQL_PID=Express
ports:
- "1433:1433"
restart: always
volumes:
- sql-data:/var/opt/mssql
networks:
- nop_network
volumes:
sql-data:
networks:
nop_network:
Das vorliegende Docker Compose File betreibt die nopCommerce-Anwendung auf dem Port 44360 und verwendet ein selbstsigniertes SSL-Zertifikat für dessen Betrieb.
Um den Entwicklungsprozess zu beschleunigen, kann zusätzlich ein Mount-Bind definiert werden. Dadurch ist es uns möglich, Dateien während der lokalen Entwicklung vom Host-Rechner direkt in den Container einzubinden. Auf diese Weise können beispielsweise lokale Anpassungen am Frontend-Code unmittelbar vom Docker-Container reflektiert werden, sodass der Effekt einer emulierten Hot-Reload-Funktion entsteht. Insbesondere für ältere Versionen von .NET ist dieser Ansatz sehr hilfreich.
Über das Docker Compose File konnten wir nun festlegen, dass die beiden Komponenten die Möglichkeit haben, miteinander zu kommunizieren.
Um diese Kommunikation jedoch in die Wege zu leiten, erfordert nopCommerce zusätzlich eine dataSettings.json
-Datei, die während des Anwendungsstarts geladen wird. Sie ist dafür zuständig, den Connection String für die Datenbank festzuhalten und an die Anwendung zu übermitteln. Innerhalb des Docker-Containers befindet sich diese im „App_Data“-Ordner.
Die Datei könnte wie folgt aussehen:
"DataProvider": "sqlserver",
"DataConnectionString": "Data Source=nop.db,1433; Initial Catalog=meineDB; Integrated Security=False; Persist Security Info-False; User ID-sa;Password=meinPasswort;", "RawDataSettings": {}
Als Datenquelle wird hierbei der Name des definierten Microsoft-SQL-Server-Containers aus dem Docker Compose File verwendet. Mit der Konfiguration dieser Datei und Einbindung in den Container wird die Ausführung einer bestehenden nopCommerce-Anwendung möglich. Dazu navigiert man in den Ordner des Docker Compose Files, öffnet die Befehlszeile und gibt den Befehl „docker compose up –build“ ein, woraufhin die entsprechenden Container neu gebaut und anschließend ausgeführt werden.
Die Einrichtung einer lokalen Entwicklungsumgebung ist damit abgeschlossen, aber lässt sich dieses Container-basierte Setup auch in ein Azure-basiertes Deployment überführen?
Erforderliche Dienste für ein Container-basiertes Setup in Azure
Für den Betrieb einer nopCommerce-Anwendung als Container in Azure werden mindestens folgende Dienste benötigt:
- Azure Container Registry
- Azure SQL Datenbank
- Azure App Service oder alternativ eine Azure Container Instance
Nach der Einrichtung dieser Dienste wird es möglich, die Anwendung über eine Pipeline zu deployen.
Deployment über Azure
Für den Bau und das Deployment erstellter Docker Images verwenden wir 3 Pipeline-Schritte:
- Einloggen in ein bestehendes Azure Container Registry, welches in der Lage ist, das erstellte Container Images zu speichern.
- Bauen der im Docker Compose File definierten nopCommerce-Anwendung.
- Pushen des generierten Images in das festgelegte Azure Container Registry.
Das Ganze sieht dann folgendermaßen aus:
trigger:
- master
pool:
vmImage: "ubuntu-latest"
steps:
- script: echo $(ACR_PASSWORD) | docker login $(containerRegistry) -u $(ACR_USERNAME) --password-stdin
displayName: "Docker Login"
env:
ACR_USERNAME: $(ACR_USERNAME)
ACR_PASSWORD: $(ACR_PASSWORD)
- task: DockerCompose@0
displayName: "Build services"
inputs:
containerRegistryType: "Azure Container Registry"
dockerRegistryServiceConnection: $(containerRegistry)
dockerComposeFile: "**/docker-compose.integration-deployment.yml"
action: "Build services"
- task: DockerCompose@0
displayName: "Push Images"
inputs:
containerRegistryType: "Azure Container Registry"
dockerRegistryServiceConnection: $(containerRegistry)
dockerComposeFile: "**/docker-compose.integration-deployment.yml"
action: "Push services"
Der Azure App Service kann anschließend für die Anwendungsausführung in Azure auf Basis des generierten Images konfiguriert werden.
Nachdem das Hosting und die Ausführung der Anwendung nun geklärt sind, ergibt sich jedoch eine weitere Herausforderung: Als Content-Management-System steht nopCommerce im intensiven Austausch mit der angekoppelten Datenbank. Bei der Entwicklung neuer Features für die Plattform kommt es dabei regelmäßig zu Konflikten bei der Zusammenführung von Datenbankanpassungen zwischen Entwicklern.
Ein möglicher Ansatz, um diese Konflikte zu vermeiden, ist die Versionierung dieser Anpassungen. Mögliche Tools diesbezüglich wären beispielsweise Flyway, EF Migrations, und Liquibase. Im Folgenden wird Liquibase näher durchleuchtet.
Was ist Liquibase?
Liquibase ist ein in Java geschriebenes Tool zur Datenbankversionierung, welches den kollaborativen Prozess durch klare Versionierung von Datenbankänderungen optimiert.
Nicht nur lassen sich so Änderungen rückwirkend besser nachvollziehen, sondern bei Bedarf sogar automatisiert zurücksetzen. Das ist gerade in CI-/CD-Workflows ein echtes Must-Have. Alle diese Änderungen werden dabei übersichtlich in Changelogs und Changesets festgehalten.
Changelogs und Changesets in Liquibase
Changelogs werden in einem der von Liquibase unterstützten Dateiformate verfasst (XML, YAML, JSON und SQL). Falls man sich für die Verwendung von SQL entscheidet, werden hierfür leider keine automatisierten Rollbacks angeboten. Jeder Changelog besteht dabei aus einer beliebigen Anzahl aus Changesets. Diese entsprechen den einzelnen vorgenommenen Anpassungen an der Datenbank, also beispielsweise der Erstellung einer neuen Tabelle wie in folgender Abbildung zu sehen:
databaseChangeLog:
- preconditions:
- dbms:
type: mssql
- username: sa
- runningAs:
- changeSet:
id: 1
author: Christian Michna
changes:
- createTable:
tableName: testTable
columns:
- column:
name: id
type: int
constraints:
primaryKey: true
- column:
name: name
type: varchar(255)
rollback:
- dropTable:
tableName: testTable
Preconditions und Rollbacks in Liquibase
Wie man in der Abbildung oben erkennen kann, werden vor der Festlegung des ersten Changesets zwei Preconditions definiert.
Preconditions dienen dazu, sicherzustellen, dass die Datenbankanpassungen nur unter spezifischen Voraussetzungen durchgeführt werden. In dem Beispiel oben wird die Anpassung ausschließlich ausgeführt, wenn es sich um eine Microsoft-SQL-Server-Datenbank handelt und wenn der Liquibase-Nutzer zusätzlich als Systemadministrator eingeloggt ist. In Kombination mit Changesets bieten Preconditions damit eine Ebene der Granularität und Sicherheit im Datenbankänderungsprozess, die besonders in komplexen oder kritischen Umgebungen von großem Vorteil sein kann.
Falls darüber hinaus die Notwendigkeit besteht, die angewendeten Änderungen rückgängig zu machen, bietet Liquibase die Möglichkeit eines automatisierten Rollbacks.
Hierfür gibt es zwei Möglichkeiten: Mit der Funktion “rollback-to-date” kann die Datenbank auf den Zustand eines festgelegten Zeitpunkts zurückgesetzt werden. Wenn stattdessen nur eine bestimmte Anzahl zuletzt durchgeführter Changesets rückgängig gemacht werden sollen, kann die Funktion “rollback-count X” genutzt werden, wobei X der Anzahl rückgängig zu machender Changesets entspricht.
Master-Changelog in Liquibase
Der Master-Changelog dient als zentrale Konfigurationsdatei, die alle anderen Changelogs und Changesets referenziert. Statt eine lange Liste von Änderungen in einer einzigen Datei zu verwalten, ermöglicht das eine modulare und organisierte Herangehensweise.
Dies kann wie folgt aussehen:
databaseChangeLog:
- include:
file: changelog_1.yaml
Alternativ kann die Angabe eines Ordners erfolgen, woraufhin alle darin enthaltenen Changelogs in alphabetischer Reihenfolge angewendet werden.
databaseChangeLog:
- includeAll:
path: com/example/changelogs/
Den Status aller durchgeführten Changesets trackt Liquibase über eine bei der ersten Ausführung angelegten Tabelle. Vor der Ausführung eines Changesets prüft Liquibase diese Tabelle, um festzustellen, ob das Changeset bereits angewendet wurde. Auf diese Weise kann das Risiko von Datenbankkonflikten verringert werden.
Liquibase-Konfigurationsdatei
Für die Festlegung aller für Liquibase relevanten Parameter gilt es als Best Practice, eine liquibase.properties-Datei anzulegen. Diese Datei dient als zentrale Anlaufstelle für alle Liquibase-Konfigurationen. Unter anderem wird hier der Connection String für die Datenbank, der Datenbank-User mit Passwort sowie der Pfad zum Master-Changelog festgelegt. Das macht nicht nur die Arbeit einfacher, sondern sorgt auch dafür, dass alle im Team mit den gleichen Einstellungen arbeiten. Bei Ausführung von Liquibase-Operationen wird diese Datei automatisch verwendet, um die jeweiligen Parameter auszulesen.
classpath: /liquibase/changelog
url: jdbc:sqlserver://nop.db:1433; databasename=meineDB; encrypt=false
changeLogFile: master-changelog.yaml
username: sa
password: meinDBPasswort
Ausführung als Container in der Pipeline
Eines der Highlights von Liquibase ist die Option, das Tool als Container ausführen zu können. Dadurch lässt es sich besonders einfach in bestehende CI-/CD-Prozesse integrieren.
Die Erweiterung einer Pipeline für die Anwendung von Liquibase-Updates könnte dabei wie folgt aussehen:
- script: |
docker run --rm \
-v "$(Build.SourcesDirectory)/Liquibase/changelogs:/liquibase/changelog" \
liquibase/liquibase\
--url="$(dbUrl)" \
--username=$(dbUsername) A
--password=$(dbPassword)
--changeLogFile=$(changeLogFile) \
update
displayName: 'Liquibase Docker Container Update'
Daraufhin führt der Pipeline-Agent den Container aus und aktualisiert die angegebene Datenbank auf Grundlage des definierten Changelogs.
Fazit
Für Teams, die sich mit der komplexen Herausforderung der Datenbankversionierung auseinandersetzen müssen, erweist sich Liquibase als wahrer Game-Changer.
Es stellt nicht nur die Ordnung im Versionschaos wieder her, sondern passt sich auch nahtlos in vorhandene Entwicklungsprozesse ein. Die Kombination aus Containerisierung und Datenbankversionierung schafft damit ein leistungsstarkes Ökosystem, das nicht nur die Entwicklungszeit verkürzt, sondern auch die Qualität der Releases steigert.
Haben Sie Fragen zu diesem Beitrag? Wir freuen uns auf Ihre Kontaktaufnahme!