Zum Inhalt springen

Tipps zum Einsatz von Jenkins CI

Jenkins CI wird noch immer in vielen größeren Organisationen für alle mögliche Dinge eingesetzt und obwohl das immer wieder sehr oft in Tränen endet, werden Erfahrungen und Ratschläge anderer ignoriert. Damit nicht noch mehr Tränen vergossen werden, schreibe ich das jetzt hier mal für alle auf: Lernt gratis aus den Schmerzen anderer und vermeidet diese Probleme mit der einfachen Lektüre dieses Blogposts! 

Muss es überhaupt Jenkins sein?

Jenkins war das erste Mainstream CI-Tool und ist bis heute mit großem Abstand das populärste Tool im In-House-Einsatz, also on-premise oder der selbst verwalteten Cloud. Es stammt aus der Hochzeit der JEE-Webanwendungen und ist dementsprechend komplex und voller Legacy-Code. Besonders erwähnenswerte No-Gos für den Einsatz in 2019 sind meiner Meinung nach:

  • Jenkins lässt sich nicht über Konfigurationsdateien dynamisch konfigurieren, sondern lädt diese beim Start einmalig und hält sie dann in einer internen, proprietären Datenbank. Änderungen sind dann nur über eine funktional unvollständige und fehleranfällige API möglich, über das Web-Interface, oder durch Groovy-Scripting/Java-Plugins. Das hat auch zur Folge, dass man die Konfiguration nicht mit üblichen Tools versionieren oder per DevOps-Tooling der Wahl duplizieren/wiederherstellen kann, die eingebaute „Versionierung“ einzelner Optionen (Jobs, Nodes) ist unvollständig und verfügt über keine remote API oder Exportfunktion. Würde Jenkins eine von aussen ansprechbare (no-)SQL-Datenbank nutzen, wären viele Probleme gelöst und eine Replikation/Failover möglich.
  • Das Jenkins Master/Slave-Modell ist antiquiert und extrem fehleranfällig. Agents werden zB auf den Slaves per SSH-Connection vom Master aus gestartet oder verbinden sich per JNLP mit dem Master. Bricht aber diese connection ab oder startet der Master neu, sind alle noch laufende Jobs und deren Ergebnisse verloren.
  • Jenkins verliert bei einem Neustart regelmäßig die Jobliste der noch auszuführenden Jobs. Das kann man z.B. beobachten, wenn man Jenkins Master als Docker-Container betreibt und dann zeitnah auch Dockerd neustartet. Jenkins löscht die Job-Persistierung noch bevor das System hochgefahren ist und ist auch nicht in der Lage, das passende Signal seitens Docker zum Herunterfahren wieder zur Persistierung zu nutzen. (Wie verwenden hier das offizielle Jenkins LTS Image).
  • Logging ist die Hölle. Granulare Loglevel sind nicht impementiert, eine Erhöhung des Loglevels auf „fine“ sorgt für eine unfassbare Flut an belanglosen Meldungen, aber auch nicht zur mehr Informationen über das Verhalten von Jobs oder Slaves.
  • Jenkins, auch die kommerzielle Lösung mit Support, hat keine HA-Lösung am Start. Was als HA verkauft wird, ist eine lahme hot-standby-Spiegelung auf DRBD-Level oder Network-Storage (z.B. NFS oder irgend einer kubernetes Storage-Lösung). Bei jedem „Schwenk“ (Failover) zwischen den Instanzen gehen alle laufenden Jobs verloren (siehe oben). Der „offizielle Workaround“ ist der Einsatz eines Plugins, dass fehlgeschlagene Jobs einfach neu startet. Was bei hunderten Jobs natürlich idiotisch ist, bei denen es laufend betrieblich zu kaputten Builds kommt. Diese dann immer und immer wieder erneut auszuführen, erhöht nur die Systemlast und Latenz für „neue“ Jobs.
  • Plugins. Plugins sind die reinste Hölle. Jeder, der ein Jenkins Plugin installiert, muss sich dessen bewusst sein und dies nur unter Erleidung körperlich größter Schmerzen vollziehen oder besser vermeiden. Viele Plugins sind seit Jahren unmaintained, fast immer voller bugs und suggerieren ein größeres, aktiveres Ökosystem, als vorhanden. Dokumentation ist oft nicht vorhanden, die enorme Abstraktion sorgt für enorme Aufwände beim Reverse-Engineering/Verstehen des Codes. Tests sind lausig, wenn vorhanden.
  • Groovy und Pipeline-Plugin werden als Alternative des traditionellen Jobs angepriesen, sorgen aber nur für noch mehr Matsch, weil jetzt auch noch massiv eigener Code an die brüchige Basis angedockt wird.
  • Selbst populäre Plugins, wie das Github oder Github-Pull-Request-Builder-Plugin sind broken by design, wenn man eine gewisse Anzahl an Jobs oder Pull-Requests überschreitet. Das passiert besonders gerne bei Mono-Repos, wo ein Commit zwar auch nur einen Github-Webhook auslöst, doch dann innerhalb von Jenkins unter Umstände 20 Jobs anstösst. Das Github-Plugin führt nun aber wiederum 20 API-Requests (viel davon identisch) und startet dann diese Jobs. Je nach Teamgröße und Setup überschreitet man spielend leicht die tägliche Quote der GitHub public API, was das ganze Setup zum Erliegen bringt. Caching der Requests ist nicht sinnvoll in Jenkins möglich bzw wurde erst vor einigen Monaten durch eine Änderung wieder sabotiert (kein HTTP proxy mehr nutzbar), trotzdem gibt es interessante Ansätze ausserhalb von Jenkins wie z.B. Leeroy von Docker. Hier erfolgt die Interaktion mit GitHub ausserhalb von Jenkins in einer überschaubaren Anwendung, die dann nur doch Tests anstösst und Ergebnisse auswertet.
  • Da Jenkins, wie oben beschrieben, keine zuverlässig persistente Queue hat, werden auch eingehende Webhooks öfter mal nach Annahmebestätigung (also HTTP 2xx/3xx-Response) verloren, insbesondere in der Phase eines Restarts. Hier muss dann vom Developer manuell erneut zB gepusht werden oder manuell in der Jenkins WebUI die Tests angestossen werden.

Also, wenn ihr es könnt, vermeidet grundsätzlich den Einsatz von Jenkins. Bitte macht keine Deployment-Automatisierung oder scripting von Cloud-Ressourcen (Terraform etc) innerhalb von Jenkins.

Ihr macht nur Web-Kram oder baut einfach nur binaries für eine Plattform/OS? Sucht euch etwas anderes, nutzt Travis CI, Circle, GitLab, Drone, Go-CI – oder baut euch eine eigene kleine Test-Pipeline. Geniesst eure niedrigen Anforderungen, die Welt steht euch offen, lasst sie euch von Jenkins nicht verderben!

2. Operationelle GOs und NO-GOs

Monorepos

Das miserable Verhalten von Jenkins bei Monorepos muss ich hier nochmal ansprechen. Ja, Monorepos machen bei bestimmten Projekten durchaus Sinn, wenn man eine große Core-Library hat und diese dann auf diversen Plattformen mit Wrapper-Code veröffentlicht. Da muss man eben laufend die Gesamtheit des Systems testen und auf einem Stand halten. Dependency-Management und Versionierung über dutzende einzelne Repos hinweg ist ein PITA, deshalb ist ein Monorepo hier sinnvoll.

Andererseits verhindern Monorepos auch die Aufteilung auf mehrere Jenkins-Master und je nach Projektumfang- und Entwicklerzahl ist somit eine 24/7-Auslastung des Masters-Nodes vorhanden, die selbst kurze Systemupdates unmöglich macht. Dank der nicht vorhandenen HA-Lösung gibt es also immer eine Downtime. Entweder stoppt man einfach den Node und verliert alle laufenden Jobs, oder greift schon ggf. Stunden vor der geplanten Downtime ein und deaktiviert neue Job-Ausführungen auf den Agents. Auch hier trägt wieder o.g. Bug bezüglich Job-Queue-Persistierung zu viel Ärger bei.

VPN

Weiterhin ist es absolut nicht mehr zeitgemäß, eine Jenkins-Instanz offen „im Internet“ zu betreiben, selbst über HTTPS landet jeder Request doch am shitty web-framework von Jenkins inkl. laufend bekannt werdender Bugs mit Cookies, CSRF oder den diversen Auth-Möglichkeiten. Bitte nutzt WireGuard. Bitte nutzt WireGuard! Ein Reverseproxy davor (zB nginx) kann auch weiterhin whitelisted Requests durchlassen, z.B. webhooks oder bestimmte API calls. Alle anderen bleiben ohne VPN draussen. WireGuard ist jeder andere VPN-Lösung in allen Belangen überlegen, solange ihr nicht Layer 2 oder Meshing benötigt, was hier nicht der Fall ist.

Vergesst bloated OpenVPN oder IPSec, die Batterien leerfressen oder Ärger mit Zertifikaten oder kaputten nativen Clients verursachen. Für das Jenkins-Szenario braucht ihr auch keine default-route, ihr baut einfach einen Tunnel auf und könnt dann auf zB ein System mittels RFC1918-Adressen zugreifen (DNS „jenkins.internal.company.com“ kann z.B. auch darauf zeigen), ohne euer lokales Routing weiter zu beeinflussen. Das wars.

Ihr bestimmt das lokal, der Server kann euch keine default route pushen, wie es bei anderen VPN-Lösungen der Fall ist. Ihr könnt/müsst Traffic für jedes Subnetz lokal freischalten (`AllowedIPs` in der WireGuard Config). Auch die Agents hinter einem NAT (z.B. in eurem Büro) können darüber eine zuverlässige, bidirektionale VPN-Verbindung aufbauen und halten, so wie es auch mein 12€ Raspberry Pi Zero seit Wochen schafft – trotz DSL-Disconnects und belastetem 2.4 GHz-Wifi-Band.

Lockdown

Entwickler sollten grundsätzlich keine Rechte zur Bearbeitung von Jenkins Jobs besitzen und es sollte eine Konvention sein, dass Testläufe und Auswertungen/Deployment-Hooks innerhalb des zu testenden Repositories gespeichert und damit versioniert sind. Somit gibt es auch ein Merge-Conflict-Handling und die üblichen Wege, sich mit seinen Kollegen über Änderungen abzustimmen (Issues, PR, Slack).

Jenkins-Jobs sollten ausnahmslos nur ein script aufrufen, z.B. `bin/ci` – innerhalb dieses Scripts im jeweiligen Repo erfolgt dann alles weitere, also die Trennung in „setup“, „test“, „report“, „deploy“, „teardown“ (oder das Container-Scripting, je nach Workflow).

Dieses/s Script/s müssen auch ausserhalb von Jenkins laufen können (deshalb z.B. eine Container-Lösung in Betracht ziehen), lokal auf Entwicklermaschinen oder einem anderen CI, auf das man eventuell in der Zukunft wechseln kann. Konfigurationen und Credentials sind deshalb ausnahmslos über Environment-Variablen zu übergeben. Auch hier hält man sich in Zukunft alle Wege offen.

Own the Test-Stack

Komplexere Plugins wie z.B. Android-Emulator sind problematisch: Hier werden Dinge in eine Web-Gui abstrahiert, die unter Umständen den Entwicklern selbst nicht bekannt oder geläufig sind. Als Non-Android-Developer habe ich keine Ahnung, was da genau passiert und so manche Frage an Android-Entwickler brachte auch keine Klärung. Das darf aber nicht sein. Auch hier gilt: Emulator-Setup, -Run und -Teardown gehören in Shellscripts innerhalb des zu testenden Repositories. Gerne auch zur Ausführung innerhalb eines Docker-Containers.

Jenkins selbst räumt trotz Plugin und Konfiguration nicht zuverlässig z.B. die Emulator-Dienste nach Test-Beendingung auf. Hierbei können undefinierte Zustände entstehen und CI-Läufe beeinträchtigen. Entweder räumt man mit Cronjobs und einer Frickellösung alter Prozesse ab, oder startet die Agents alle paar Tage neu, beides verletzt folgende Regel:

Agents = Stateless

Jenkins Agents (Slaves) sind zustandslos und müssen auch zustandslos gehalten werden: Keine testrelevante Konfiguration, keine Installation von Drittsoftware darf über die Test-Scripts installiert oder modifziert werden, keine Prozesse dürfen nach Abschluss eines Jobs „übrig bleiben“. Es ergeben sich sonst nicht mehr nachvollziehbare Versions-Zustände und Race-Conditions, die mit DevOps nicht mehr beherrschbar oder geschweige denn im Notfall wiederherstellbar sind.

Obacht, das gilt auch für Container. „Wie ist denn das Builder-Image vor 4 Monaten auf den Agent gekommen“ wollt ihr euch nicht fragen. Hier bietet sich zB der Einsatz eines Chaos-Monkeys ein. Zwischen Geschwindigkeit und Konsistenz muss sorgfältig abgewogen werden: Einmal im Monat alte Docker-Images löschen ist zwar besser als nie, es besteht aber ausreichend Zeit für Entwickler und Admins, dazwischen versehentlich Mist zu bauen. Dann läuft der Clean-up-Task und es gibt „keinen Zusammenhang“ zu der vor Wochen versehentlich gebauten Dependency. Weiterhin sorgt Docker’s Base-Image-Inheritance dafür, dass man auch nicht nach jedem Job „seine eigenen“ Images aufräumen kann, weil diese auch von anderen Projekten verwendet werden. Wenn man den Aufwand nicht scheut, kann man eigene dockerd-Instanzen pro Runner oder Projekt betreiben und so eine gegenseitige Einflussnahme verhindern. Multiple Dockerd-Instanzen pro Host sind jedenfalls kein Hexenwerk.

Kapseln mit Containern

Nutzt trotzdem, wo immer es geht, Docker um die Systemumgebung für Tests zu schaffen. Also das besagte `test/ci` script sollte ein ebenso im Repository hinterlegtes Dockerfile (eines, oder mehrere, oder docker-compose oder was auch immer) bauen, dann die Tests in diesem Container ausführen. So sind auch alle Build-Abhängigkeiten versioniert, als Teil des Dockerfiles.

Keine 20 Ruby-, Node oder Java-Versionen mehr auf Agent-Nodes!

Uptime rules

Wenn ihr ein verteiltes Team aus Entwicklern habt, die wirklich in jeder Zeitzone sitzen, dann könnt ihr auch nicht nachts um 4 den Jenkins-Master neustarten, ohne vom Kollegen, der gerade in Thailand arbeitet, via Slack einen Rüffel zu bekommen. Das macht Updates zu einer großen Herausforderung. Wenn dazu ein Mono-Repo kommt, kann man auch nicht einfach mehrere Jenkins-Setup parallel betreiben um die Auswirkungen auf einen Teil zu beschränken. Bleibt also in Ermangelung einer HA-Lösung nur das Festlegen eines regelmäßigen Wartungsfensters. Das ist nur möglich, wenn man zumindest die kritischsten Probleme noch ein paar Tage aussitzen kann, also im Jenkins-Fall die Security-Updates – auch hier wieder: Nutzt WireGuard um das Risiko und den Update-Druck zu reduzieren.

Responsible Hardware Use

Flaky Tests, also sich nicht konsistent verhaltende Testergebnisse, sind immer ein nerviges Problem. Im Zuge der Nachforschung und Bekämpfung solcher Anomalien könnte man auf die Ideen kommen, neben den Event-gesteuerten Testläufen (vi GitHub Hook) auch zeitgesteuert eine Ausführung durchzuführen („viel hilft viel“). Das ist per se gut, wenn man es 1-2x am Tag macht um schleichende Änderungen (z.B. durch Upstream/3rd party dependencies) zu testen. Geht man jedoch hin und sorgt durch eine aggressive Job-Crontab dafür, dass z.B. zwischen 20 und 6 Uhr alle Agents wiederholt, non-stopp, mit immer den gleichen Tests ausgelastet werden, nervt man einerseits den Admin gewaltig, denn der sucht händeringend eine Phase, in der man 10 Minuten downtime zum Upgrade einplanen kann.

Andererseits verschleisst die Hardware:

Zwar sind SSD/NVMe-Server mittlerweile z.B .bei Hetzner spottbillig, sodass man durchaus 10… 20… 50 Stück mieten kann, allerdings gilt das nur für die Standardausführung mit 2x500GB SSD/NVMe pro System. Zusätzliche oder größere SSD/NVMe SSD kosten mitunter mehr, als der ganze Server in der Standardausführung. Fährt man nun ein RAID-1 (Mirroring) hat man noch ca 400GB nutzbaren Speicherplatz, der von großen Monorepos schnell gefressen wird. Also fängt man notgedrungen an, die Systeme mit RAID-0 (Striping) aufzusetzen, denn schliesslich haben die Nodes ja keinen State…

Solche nächtlichen Build-Exzesse zur Bekämpfung von flaky tests sorgen dann aber dafür, dass SSD Wear Out innerhalb von wenigen Monaten die SSD zerstören, zumindest aber laut SMART bald ein Datenverlust auftreten wird. Mangels Mirroring (um ca. 900GB usable disk space zu erhalten) muss man also hier auch sofort das ganze System (automatisiert) neu aufsetzen. Und die zweite SSD fällt dann einige Wochen später aus und das Spiel wiederholt sich von vorne.

Ein Richtwert für die Lebensdauer bei Flash-Speichern: Maximale Lebensdauer in Schreibvorgängen (TBW) = mindestens 100x die Größe des Speichers. Moderne und hochwertige Flash-Speicher (bspw. Samsung EVO plus) schaffen auch 600 full writes – oder zumindest deckt die Gewährleistung diese Nutzung ab.

Man sollte also auch die Hardware sinnvoll einsetzen: SSDs reduzieren die Testdauer, was inbesondere den Entwicklern hilft, ein schnelles Feedback zu erhalten. Allerdings sterben sie „relativ“ schnell. Festplatten sind langsam, halten aber deutlich länger bzw. sind günstiger (d.h. 2-6TB pro Platte sind ausreichend und man kann problemlos ein Mirroring fahren). Nutzt das passende Werkzeug und verletzt euch nicht absichtlich selbst.

Fazit

Viele angesprochenen Probleme äussern sich bei Monorepos, bei einem zeitlich-geographisch verteilten Entwicklerteam, bei der Hook/PullRequest-Nutzung von Github.com (kein private Enterprise GitHub, GitLab etc) und durch komplexeren Build/Test-Umgebungen (insbesondere native Apps für diverse Plattformen) – auch wenn es ausreichend Gründe gibt, die genau diese Nutzung so rechtfertigen.

Ohne diese Anforderungen gäbe es schon lange keinen Grund mehr, überhaupt noch Jenkins zu verwenden: GitLab CI, Circle CI, Drone.io und die CI/CD-Pipelines der Cloud-Anbieter verfügen durchweg über eine bessere, aktuelle Architektur oder sind managed und lassen es dann auch überhaupt nicht zu, sich selbst in den Fuß zu schiessen.

TL;DR

Lasst die Finger von Jenkins oder verwendet es wie eine scharfe Chemikalie: Nur mit Schutzausrüstung. Haltet die CI-Konfiguration zu dünn wie möglich, dafür ausführlich Code und Beschreibungen in den jeweiligen Repos halten. Kein offenes Feuer. Nur mit Schutzbrille.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.

* Die DSGVO-Checkbox ist ein Pflichtfeld

*

Ich stimme zu