Softwareentwicklung: Von Rabbit Holes, Verzweiflung, Euphorie - und viel Kaffee!
Es ist Montag, 10 Uhr. Ich sitze seit 6 Uhr am Rechner und fixe einen Bug. Viel suchen, ein wenig googeln, ChatGPT um Hilfe fragen, verzweifeln, Lösung finden, Kaffee trinken und glücklich sein. Danke.
Ich bereite den Pull Request vor. Wir arbeiten mit Azure Devops, unser Code ist in C#.Net Core 6.0 und unsere Codebasis gerade einmal 30.000 Zeilen groß. Immerhin, für nur ein Jahr Arbeit mit wenig Ressourcen haben wir viel geschafft. Der Pull Request ist gestellt und mein Kaffee schon wieder leer, also hole ich mir noch einen.
Die Kaffeemaschine braucht Bohnen, danach Wasser und dann ist auch noch die Auffangschale voll. Ich sitze bestimmt sieben bis acht Minuten dort und reinige alles, komme wieder und wundere mich: Meine automatisierte Pipeline ist immer noch nicht durchgelaufen. Ein Blick in das Azure-Devops-Web-Interface verrät mir, dass die vergangenen Läufe immer zwischen 25 und 28 Minuten brauchten. Ich staune. Bei nur 30.000 Zeilen Code? 28 Minuten?
Challenge accepted!
Azure Devops vs. Entwickler: 1:0
Ich schaue auf die Pipelines in der Historie und stelle fest, dass in den vergangenen Monaten die Abarbeitungszeit enorm zugenommen hat. Erst waren es 5 Minuten, dann 10, inzwischen 25. Also schauen wir einmal tiefer – was macht so eine Pipeline eigentlich?
Unsere Pipeline hat nichts Besonderes inne:
1) Code restoren
2) Code testen & Tests veröffentlichen
3) Code Coverage erzeugen & veröffentlichen (an Sonarqube)
4) Code builden
Ich zweifele an mir selbst, da das, was da steht, nicht mit den Zeiten übereinstimmen kann, die ich lese. Ich mache ein paar Testläufe und habe Sorge, dass ich nun bei jedem Testlauf zwei Tassen Kaffee trinke, da ich bei jedem Testlauf 25 Minuten warten muss. Während des zweiten Testlaufs warte ich geschlagene 55 Minuten statt nur 25 und stelle fest, dass sich das Problem potenziert, wenn andere Kollegen auch noch arbeiten.
Es sind die ersten zwei Stunden vergangen, in denen ich außer Staunen nicht viel gemacht habe, als ich realisiere, dass das Problem nicht nur mich, sondern das gesamte Team betrifft. Ich frage im Gruppen-Teams-Channel nach und erfahre, dass alle sieben anderen Entwickler das Problem kennen und meist mit "Kaffee holen" beantworten, statt sich ihm zu widmen. Ich bekomme Memes wie das eines lachenden Kindes, als ich sage: "Ich tue mal was dagegen."
Man wünscht mir Glück und viel Geduld. Letztere werde ich brauchen.
Wenn das Log zu dir spricht!
Zuerst werfe ich die Detailanalyse an. Die Auswahl in Azure Devops für Pipelines ermöglicht die Systemdiagnose. Dadurch werden erweiterte Logs dargstellt in den Ausführungsschritten.
Die erste Analyse zeigt mir, dass die Tests schuld seien:
Der von uns Dotnet-Test genannte Schritt läuft zwischen 10 und 20 Minuten, sehr schwankend. Also versuche ich, das Problem einzugrenzen: Ich rufe meine lokale IDE auf und lasse die Tests dort laufen: vier Minuten. Wir haben nur 400 Tests, eigentlich sollte das viel schneller laufen – und gleichzeitig, woher kommt der Multiplikator von 2.5 bis 5 für die Pipeline?
So sieht derzeit unser Pipeline-Task aus:
- task: DotNetCoreCLI@2
displayName: Dotnet Test
inputs:
command: test
projects: |
**/*Test*/*.csprojWas sind nochmal dieses Docker und diese Container?
Ich schaue mir unsere Unit Tests genauer an und stelle fest, dass wir oftmals auch Integration Tests zwischen den Unit Tests mit ausführen. Dazu benutzen wir Testcontainers(öffnet im neuen Fenster) . Das ist ein immer populärer werdendes Nuget-Paket, also eine externe Bibliothek, die wir verwenden, um unsere Tests auszuführen. Diese Erweiterung ermöglicht es, über den Code Container zum Testen hochzufahren und so zum Beispiel Datenbanken isoliert zu testen.
Wer also das Einspielen von Daten in die Datenbank testen soll, die anschließend gesucht und gelöscht werden, hat immer das Problem: Woher die Datenbank nehmen? Wie in den Zustand zurücksetzen oder sichergehen, dass nicht gleichzeitig jemand anders testet? Wie die Verbindungseinstellungen dazu pflegen? Testcontainers löst genau das. Im Code sagen wir: "Jetzt hochfahren und vorbereiten" , dann testen wir und dann löschen wir – das alles schön performant mit Docker.
Denkste!
Die Idee ist brillant, aber die Ausführung nicht. Unsere Codebasis setzt bei mehr als 30 Prozent aller Tests auf dieses Tool, damit wird also nicht nur eine Datenbank hochgefahren, sondern es sind gleichzeitig viele – das braucht Speicher, CPU und Zeit. Vor allem Zeit.
Ich überprüfe meinen Verdacht und klammere alle Tests mit Testcontainers aus: Siehe da, lokal bin ich von vier Minuten auf eine Minute runter – ein großer Unterschied. Unser Problem ist also hausgemacht, wer hätte das gedacht. Was nun tun?
Viele Dr.-House-Momente und die Pipeline, die verrückt macht!
Der saubere Weg wäre sicherlich, die Integration Tests durch Unit Tests zu ersetzen. Aber so würde aus meinem Vorhaben, mal kurz die Pipeline zu fixen, ein sicher tage- oder sogar wochenlanges Abenteuer werden. Und mein Scrum Master würde mich einen Kopf kürzer machen. Also gehe ich den Weg des geringsten Widerstandes und denke mir etwas aus. Ziel ist es, dass die Tests bleiben, da sie uns Sicherheit geben. Aber sie dauern zu lange, wenn sie bei jedem Check-in laufen. Also lagere ich sie aus.
Ich erstelle eine zweite Pipeline, die ich nicht mehr bei jedem Check-in triggere, sondern nur noch nach Schedule (jede Nacht um 20 Uhr). Beim Dotnet-Test filtere ich hier auf Integration Tests, also alle langsamen mit Testcontainern.
Neue Pipeline:
- task: DotNetCoreCLI@2
displayName: Dotnet Test
inputs:
command: test
projects: |
**/*Test*/*IntegrationTest.csprojAlte Pipeline überarbeitet:
- task: DotNetCoreCLI@2
displayName: Dotnet Test
inputs:
command: test
projects: |
**/*Test*/*UnitTest.csprojViele Wege führen nach Rom – viele aber auch nicht
Super! Wir sind einen großen Schritt weiter – denke ich mir, lasse die Pipeline laufen und hole mir mal wieder einen Kaffee. Inzwischen ist Tag zwei nicht nur angebrochen, sondern deutlich fortgeschritten, als ich herausfinde, dass die Ersparnis von drei Minuten (von vier auf eine) auf meinem lokalen Testing leider auch nur drei Minuten in der Pipeline sind und diese immer noch 20 Minuten läuft.
Nun gut, probieren wir rum. Fangen wir erneut an mit dem Zyklus der magischen Softwareentwicklung: Logs lesen, ChatGPT fragen, Stack Overflow lesen, Google fragen – zack! Dr.-House-Moment: Was war nochmal dieses Sonarqube? Stimmt!
Sonarqube ist ein Tool zur statischen Codeanalyse. Diese hat zwei große Schritte: einen Prepare-Schritt vor dem Test und einen Publsh-Schritt nach dem Test. Warte – was wäre, wenn die Analyse, die fast der einzige Faktor ist, der hier in der Pipeline abweicht, dazu führt, dass es langsam ist?
Pipeline editieren. Sonarqube ausbauen. Zittern. Erfolg! Die Pipeline ist in weniger als vier Minuten durchgelaufen. Wow! Aber es gibt ein Problem: Ausbauen ist nicht. Ich kenne also nun das Problem, aber die Lösung ist keine Lösung. Wir brauchen die Analyse, wir brauchen das Code Coverage, wir brauchen die Unterstützung.
Also weiter mit dem Ideenbrainstorming: Warum verursacht ein Task wie Sonarqube einen Overhead von rund 15 bis 20 Minuten in der Pipeline?
Ich installiere lokal mein Sonarqube und teste es dort. Trotz Prepare und Code Coverage komme ich auf drei Minuten. Ich verzweifle erneut. Irgendwas stimmt doch da nicht. Logs erneut lesen: Dr.-House-Moment Nummer zwei. Der Task DotNetCoreCLI meiner Pipeline lässt alle Tests nacheinander laufen, nicht parallel. Ein Tipp, den ich bereits von ChatGPT erhalten habe: "Parallelisieren Sie doch Ihre Unit Tests." Schlaumeier, wer denkst du eigentlich, wer du bist? Und dann hast du auch noch Recht.
Also RTFM: Read the freaking manual(öffnet im neuen Fenster) . Da steht nichts von Parallelisierung. Also Alternativen suchen. Wie heißt du nochmal? ChatGPT, welche Alternativen zu DotNetCoreCLI gibt es? Die Antwort: VSTest. Es gibt sogar einen kompletten Learnartikel(öffnet im neuen Fenster) darüber. Weiter geht's!
Tag 4: Das Rabbit Hole
"Jeder Entwickler kennt es. Jeder Entwickler war schon mal an diesem Punkt." Solche Sätze sage ich mir vor, während ich weiter versuche, meine Pipeline zu fixen. Ich bin frustriert, da der Umbau von DotNetCoreCLI zu VSTest nun schon einige Stunden dauert. Ich stoße auf Probleme. Googele die Lösung. Passe an. Neue Probleme. Wieder googeln. Nachdenken. Anpassen. Nun kommt ein Problem bei der Lösung A, das ich googele, woraufhin ich Lösung B finde, die aber zu Umbau C führt, wozu ich wieder beim Problem von vor zwei Stunden bin.
Das erinnert mich an ... Passierschein A38(öffnet im neuen Fenster) ! Die Pipeline, die Verrückte macht. Selbst Asterix kannte die Cloud!
Nach dem Mittagskaffee beginne ich, an mir zu zweifeln, und erwische mich dabei, statt Lösungen zu VSTest-Problemen "Jobs, bei denen man nicht nachdenken braucht" zu googeln. Ich freue mich über mein Homeoffice, denn so hört (außer meiner Frau) die Frustschreie niemand, als die Pipeline nach knapp 15 Iterationen wieder bei Problem A hängen bleibt.
Der technische Grund ist schnell erläutert: Der Task VSTest beruht in seiner Gänze auf dem gleichnamigen Tool VSTest, das mit DotNet installiert wird – aber eben nicht standardmäßig unter Linux. Alle unsere Buildagents sind aber Linux, weswegen ich es dort installiert habe – nach einer Google-Anleitung natürlich – was mir das .Net SDK auf dem Agenten zerschossen hat, was dazu geführt hat, dass andere Tasks nicht mehr funktionierten – und beim Wiederherstellen war dann zwar das SDK wieder frisch, aber mit einer alten VSTest-Version.
Am Ende von Tag 4 beschließe ich, aus dem Loch der VSTest-Probleme herauszukommen, und stelle meine Pipeline mit DotNetCoreCLI und Sonarqube wieder her. Ich schreibe meinem Projektleiter und frage ihn, ob er damit einverstanden wäre, dass wir Sonarqube nicht in der Pipeline ausführen und auf Codequalität verzichten. Wer braucht schon Qualität.
Ich schicke die Teams-Nachricht nicht ab und gehe ins Bett. Meine Frau fragt mich, warum ich weinend in der Fötusstellung einschlafe, aber ich murmele nur "Pipeline" vor mich hin. So muss es Javascript-Entwicklern jeden Tag gehen.
Ende gut. Pipeline gut
Ich wache nachts auf und habe einen Geistesblitz. Plötzlich weiß ich, was ich falsch gemacht habe: Ich weiß eigentlich gar nicht, was ich tue. Der DotNetCoreCLI-Task ist eine Unbekannte. Wenn ich sie verstehen würde, könnte ich viel besser die Lösung finden. Siehe da: Alle Pipeline-Tasks sind Open Source(öffnet im neuen Fenster) .
Also lerne ich, wie Azure Devops Pipeline Tasks funktionieren. Zu jedem Task gibt es Parameter, Beschreibungen und immer dieselben Dateien – und Javascript-Dateien, die ausgeführt werden. Gefunden(öffnet im neuen Fenster) . So sieht mein Task aus. Früher, vor vielen grauen Haaren, habe ich ein paar Monde lang Javascript, Typescript und React geschrieben. Das hilft.
Es ist kurz nach Mittag, als ich einen unendlichen Moment der Freude habe: In Zeile 99 steht schon mein ganzes Problem: eine For-Loop über alle Projekte, die alles erklärt.
Aber langsam – wer bis hier noch mitgekommen ist, dem schulde ich nun eine Erklärung: Wenn ich lokal meiner IDE sage "Bau mal die Projekte" und "Führe die Unit Tests aus" , so läuft im Hintergrund ein sehr schlauer Algorithmus, der beides – je nach CPU und Kernen – so schlau und parallel macht, wie es nur geht. Abhängigkeiten müssen natürlich in Projekten und Tests immer beachtet werden, aber wir haben hier schon eine hohe Zeitersparnis.
Wenn ich nun aber das Ganze mit einer simplen For-Schleife über alle Projekte durchgehe, verliere ich jegliche Ersparnis. Eins nach dem anderen, im Gänseschritt marsch.
Gleichzeitig erklärt es, warum die Pipeline über die Monate von Mal zu Mal langsamer wurde: Aus 1 wurden 10 und aus 10 irgendwann 35 Projekte. Jedes Mal schlägt die For-Schleife zu und macht alles langsamer. Und dann kommt auch noch Sonarqube!
Sonarqube führt eine Build-Analyse aus. Ein Build dauert also immer länger mit Sonarqube als ohne. Die Projekte werden im DotNetCoreCLI-Task zudem noch built UND restored. Unsortiert. Während also lokal erst Projekt A, dann Projekt B (abhängig von A) erstellt wird, so wird in der Pipeline erst Projekt B erstellt (was A erstellt, weil abhängig) und dann nochmal A. Also builden wir vieles umsonst. Doppelt. Je nach Reihenfolge, die scheinbar dem Zufall unterliegt – was auch die wechselnden Pipelinezeiten erklärt.
Ich überlege kurz, ob ich einen eigenen Pipeline-Task schreibe, der es besser macht, aber erinnere mich dann wieder an das Thema Rabbit Holes und entscheide mich für eine kleinere Lösung: Ich weiß, dass im DotNet Test Command sowohl der Parameter --no-build als auch --no-restore existieren, die dazu führen, dass ein Test eben nur ein Test ist. Wenn ich diese nun hinzufüge und gleichzeitig den Build und Restore manuell VORHER mache, sorge ich so dafür, dass meine doofe For-Schleife nur noch das Testing macht und hebele 95 Prozent der Probleme aus.
Gesagt getan. Hier mein fertiges Script:
- task: DotNetCoreCLI@1
displayName: Dotnet Restore
inputs:
command: 'restore'
projects: |
**/Backend.sln
- task: Bash@3
displayName: 'Build'
inputs:
targetType: inline
script: dotnet build Backend.sln --configuration debug
- task: DotNetCoreCLI@2
displayName: Dotnet Test
inputs:
command: test
projects: |
**/*Test*/One*/**/*UnitTests.csproj
arguments: '--configuration ${{ parameters.buildConfiguration }} /p:CollectCoverage=true /p:CoverletOutputFormat="opencover" --no-restore --no-build'
publishTestResults: trueZack. Commit. Pipeline. Start. Warten. Zwei Minuten. Drei Minuten. Vier Minuten. Fertig. Ich atme aus, lösche die nicht abgeschickte Teams-Nachricht an meinen Projektleiter von gestern und schreibe ihm: "Ich glaube, ich habe soeben 20 Minuten für alle Team-Entwickler bei jedem Pipeline-Durchlauf erspart, was 80 Prozent der Zeit sind! Das sind im Schnitt zehn Durchläufe, also 200 Minuten Zeit pro Tag! Kriege ich eine Gehaltserhöhung?" Ich lösche den letzten Satz mit der Gehaltshöhung und schicke ab.
Fünf Tage voller Frust, die nun in endloser Erleichterung verblassen. Ich buche meine Arbeitszeiten für die Woche, klappe meinen Laptop zu und gehe Schlaf nachholen. Den brauche ich jetzt.
Rene Koch(öffnet im neuen Fenster) ist Senior-Softwareentwickler. Er arbeitet vor allem mit dem Dotnet-Framework von Microsoft, aber auch mit Azure Cloud und Devops, React, Gitlab und vielen weiteren Anwendungen.
- Anzeige Hier geht es zu Docker: Das Praxisbuch für Entwickler und DevOps-Teams bei Amazon Wenn Sie auf diesen Link klicken und darüber einkaufen, erhält Golem eine kleine Provision. Dies ändert nichts am Preis der Artikel.



