Kurzfassung Self-hosted TwinCAT-CI ist nicht dasselbe wie ein einzelner Jenkins-Agent. Ab einer Handvoll Entwickler braucht es einen Pool echter Windows-Maschinen, und dieser Pool muss bereitgestellt, gepatcht und so gepflegt werden, dass CI immer weiß, welche Maschinen gerade verfügbar sind. In diesem Maßstab können zwei Builds auf derselben physischen Maschine landen und sich gegenseitig die TwinCAT-Runtime zerlegen, mit Fehlern, die beim erneuten Anstoßen einfach verschwinden. Unter Jenkins löst man das in drei Schritten: eine bestimmte Maschine sperren, den Build daran pinnen und die Lock-Registry automatisch mit dem echten Node-Pool synchron halten. Zeugwerk CI/CD betreibt die Maschinen schon und übernimmt das alles, damit man es nicht selbst tun muss.
Warum eine Build-Maschine nicht mehr reicht
Dieser Beitrag handelt davon, was passiert, sobald CI auf eigener Infrastruktur läuft und über eine Maschine hinauswachsen muss.
Das erste TwinCAT-CI-Setup ist meist simpel: ein Windows-PC mit TwinCAT, ein Jenkins-Agent oder GitLab-Runner registriert, Pipelines auf main gerichtet. In einem kleinen Team mit wenigen gleichzeitigen Builds kann das durchaus lange reichen.
Kapazität ist die offensichtliche Grenze. Zehn Entwickler, die in dieselbe Pipeline auf derselben Maschine pushen, bedeuten Wartezeit. Ein Release-Branch und main, die gleichzeitig bauen, bedeuten Wartezeit. Ein Hotfix auf release/1.x, während Feature-Arbeit auf main läuft, bedeutet Wartezeit. CI, das Entwickler ignorieren, weil es “schon noch durchläuft”, ist CI, das seinen Job nicht erfüllt.
TwinCAT bringt außerdem Einschränkungen mit, über die sich die meisten Web-Teams erst zu spät Gedanken machen:
- Mehrere Runtime-Versionen. Maschinen im Feld laufen vielleicht auf TC3.1.4024, neue Entwicklung auf 4026. Das braucht getrennte Build-Pools, nicht nur mehr Slots auf einer Maschine.
- Parallele Release-Linien. Wer Release Flow einführt, braucht eigene CI-Läufe für
release/1.xundrelease/2.x, gleichzeitig und absichtlich. - Echte Hardware im Spiel. Ein TwinCAT-Build ist nicht nur Kompilierung. Die Runtime muss tatsächlich laufen, damit kompilierter Code deployed und Unit-Tests ausgeführt werden können. Das bindet jeden Build für seine gesamte Dauer an eine bestimmte Maschine.
Ein Team, das CI ernst nimmt, hört auf zu fragen “brauchen wir CI?” und fängt an zu fragen “wie viele Build-Maschinen brauchen wir?” Für einen Maschinenbauer mit mehreren Projekten, mehreren TwinCAT-Versionen und mehr als ein paar Ingenieuren lautet die ehrliche Antwort: mehrere. Oft deutlich mehr.
Das ist kein Jenkins-Detail. Das ist der Einstiegspreis für self-hosted TwinCAT-CI.
Was ein Build-Pool in der Praxis bedeutet
Container-CI skaliert, indem man mehr Pods startet. TwinCAT-CI skaliert, indem man mehr Windows-Maschinen aufstellt. Jede davon ist ab dann ein dauerhaftes Asset, das man besitzt:
| Was man bereitstellt | Was man betreibt |
|---|---|
| Windows Server oder Windows 10/11 als Build-Host | OS-Updates, Speicherplatz, Neustarts |
| TwinCAT XAE + Ziel-Runtime-Version | Versions-Upgrades, Side-by-Side-Installationen für mehrere TC-Builds |
| Beckhoff- und Visual-Studio-Toolchain | Lizenzaktivierung pro Maschine |
| Jenkins-Agent / GitLab-Runner / GitHub-Actions-Runner | Runner-Dienst, Konnektivität, Credentials |
| CI-Tooling (Build-CLIs, Paketmanager) | Versionierte Verteilung auf jeden Node (wir nutzen dafür Scoop) |
| Netzwerkzugriff auf Git, Artefakt-Speicher, Package-Feeds | Firewall-Regeln, Tokens, Credential-Rotation |
Eine Maschine hinzuzufügen heißt nicht “Agent installieren und vergessen”. Ein neuer Node muss das TwinCAT-Versions-Label tragen, das die Pipelines erwarten, die richtigen Tools in der richtigen Version haben und online bleiben. Geht eine Maschine zum Patchen offline, sinkt die Kapazität. Wird ein Node von Hand aufgesetzt und ein Schritt übersprungen, gibt es Builds, die auf einem Agent durchlaufen und auf einem anderen rätselhaft scheitern.
Die meisten Teams kalkulieren Zeit fürs Schreiben der Pipeline ein. Kaum jemand kalkuliert Zeit für den dauerhaften Betrieb einer kleinen Flotte Windows-Build-Server. Wird die Farm nicht gesund gehalten, hört CI auf, wie ein Sicherheitsnetz zu wirken, und fühlt sich an wie ein Glücksspielautomat: sporadische Fehler, lange Warteschlangen, Entwickler, die Builds so lange neu starten, bis etwas Grünes dabei herauskommt, und am Ende niemand, der dem Ergebnis wirklich vertraut. Genau deshalb gibt es gemanagtes TwinCAT-CI: dieselbe Build-Kapazität, aber alles aus der Tabelle oben ist das Problem des Anbieters, nicht das eigene.
Wer die Farm selbst betreibt, hat mit der Bereitstellung nur die Hälfte erledigt. Die andere Hälfte ist ein konkretes Problem: den Pool so zu nutzen, dass sich TwinCAT-Builds nicht gegenseitig in die Quere kommen. Sobald mehr als eine Maschine existiert, dem Namen nach schon eine Flotte, dem Anspruch nach noch nicht, ist der naheliegende Schritt, sie unter einem gemeinsamen Label zu registrieren und Jenkins, GitLab oder GitHub Actions jeden Build auf den Host zu schicken, der gerade einen freien Slot hat. Das klingt nach der Lösung, und genau da beginnen die Probleme. Hier ist warum, und der Ansatz, der tatsächlich greift.
Warum ein gemeinsames Pool-Label nicht reicht
Übliche CI setzt voraus, dass Build-Agents zustandslos sind. Container starten, Job laufen lassen, wegwerfen. Zwei Jobs teilen sich keine Umgebung, weil es keine gemeinsame Umgebung gibt. Pooling funktioniert, weil jeder Slot so gut ist wie jeder andere.
TwinCAT bricht diese Annahme. Ein Build braucht eine echte Windows-Maschine mit der richtigen TwinCAT-Version, eine tatsächlich laufende Runtime und auf diesem Host aktiviertes Tooling. Eine wegwerfbare TwinCAT-Runtime in einem Container lässt sich nicht hochfahren. Die Maschine trägt Zustand zwischen Builds: Runtime-Konfiguration, installierte Bibliotheken, Lizenzaktivierung. Zwei Builds gleichzeitig auf derselben Maschine sind voneinander nicht isoliert, egal was die Pipeline-Definition sagt.
Der naheliegende Jenkins-Ansatz ist, alle Agents mit einem Pool-Label zu versehen und den Scheduler frei wählen zu lassen:
agent { label 'TC3.1.4024' }
Fünf Maschinen tragen dieses Label. Jenkins plant frei über alle mit freiem Executor-Slot. Erlauben Nodes mehrere Executors, können zwei Builds legal gleichzeitig auf derselben physischen Maschine landen.
So sieht das in der Praxis aus: Zwei Pipelines starten im Abstand weniger Sekunden. Beide landen auf build-agent-03, auf verschiedenen Executor-Slots. Build A startet eine TwinCAT-Kompilierung. Build B, direkt dahinter, startet die Runtime neu für eine saubere Testumgebung. Build A war mitten in der Kompilierung, als die Runtime zurückgesetzt wurde. Seine Projektdatei ist weg. Build A scheitert mit einem kryptischen Fehler, der wie ein Code-Problem aussieht. Der Entwickler führt ihn unverändert erneut aus, und er läuft durch. Niemand verbindet den Fehler je mit zwei Builds, die sich eine Maschine teilen, die sie sich nicht teilen sollten.
Diese Art von Fehler wirft keine saubere Fehlermeldung. Sie erzeugt sporadische Ergebnisse, die beim Wiederholen verschwinden, und genau deshalb braucht man Tage statt Minuten, bis man die echte Ursache findet.
Executor-Counts lösen das nicht
Die naheliegende erste Reaktion ist, die Executor-Slots pro Node auf eins zu setzen. Das verhindert die Kollision, eröffnet aber ein anderes Problem.
Build-Nodes sind selten ausschließlich für TwinCAT reserviert. Dieselbe Maschine könnte auch Integrationstests oder Packaging-Jobs laufen lassen, die keinen exklusiven Zugriff brauchen und gerne parallel liefen. Jeden Node auf einen Executor zu zwingen, nur um TwinCAT-Builds zu schützen, serialisiert das alles ohne Grund.
Es gibt noch einen zweiten Fehlermodus, selbst mit einem Executor pro Node. TwinCAT-Pipelines haben oft eine verschachtelte Stage, die versucht, einen weiteren Executor aus demselben Pool zu holen, während die äußere Stage ihren Slot noch hält. Diese innere Stage wartet auf einen Slot, der nie frei wird, weil einzig die Pipeline selbst ihn belegt. Ein paar gleichzeitige Builds, die das tun, und die ganze Queue wirkt eingefroren, ohne eine erkennbare Fehlermeldung.
So oder so versucht man, ein Scheduling-Problem durch das Anpassen einer Zahl zu lösen. Das eigentliche Problem ist, dass Scheduling (irgendeinen freien Slot finden) und Exklusivität (gerade nur ein TwinCAT-Build berührt diese Maschine) nicht dasselbe sind, und Jenkins verbindet die beiden nicht automatisch.
Die Lösung: Maschine sperren, dann den Build daran pinnen
Jenkins hat zwei Mechanismen, die leicht verwechselt werden.
Ein Executor ist ein Build-Slot auf einem Node. agent { label '...' } fragt den Scheduler nach einem beliebigen Node mit passendem Label, der einen freien Slot hat. Gesucht wird ein verfügbarer Slot, nicht eine bestimmte Maschine.
Eine Lockable Resource ist ein benannter Mutex, verwaltet vom Lockable-Resources-Plugin. Eine Pipeline, die lock(...) aufruft, blockiert, bis eine bestimmte Ressource, oder eine aus einem gelabelten Pool, exklusiv verfügbar ist. Solange der Lock gehalten wird, kann niemand sonst ihn erwerben.
Beide zu kombinieren ergibt exklusiven Maschinenzugriff, aber die Reihenfolge zählt. Deklariert eine Pipeline zuerst ein agent auf Pool-Ebene und ruft dann lock innerhalb einer Stage auf, wird der Executor zugewiesen, bevor der Lock geprüft wird. Zwei Builds können auf derselben Maschine auf verschiedenen Slots landen und dann um denselben Lock konkurrieren. Wer verliert, sitzt schon auf der Maschine, hält einen Executor und ist angekommen, bevor irgendeine Exklusivitätsprüfung stattfand. Kleines Fenster, gleiches Problem wie vorher.
Die Reihenfolge muss sein: zuerst den Lock erwerben, dann den Agent zuweisen. Der Agent auf Pipeline-Ebene ist none, es wird also noch nichts eingeplant. Der Lock läuft in options, vor jeder Stage, und schreibt die zugeteilte Maschine in eine Variable. Eine einzelne äußere Stage pinnt auf genau diese Maschine. Alles andere läuft verschachtelt darunter, auf demselben gesperrten Host:
pipeline {
// Am Pipeline-Start wird kein Executor verbraucht.
// Der Lock muss erworben werden, bevor ein Agent zugewiesen wird.
agent none
options {
// Eine Maschine aus dem Pool reservieren, bevor irgendeine Stage läuft.
// 'resource_name' erhält den genauen Node-Namen, der zugeteilt wurde,
// z.B. "build-agent-03".
lock(label: 'TC3.1.4024', quantity: 1, variable: 'resource_name')
}
stages {
stage('Make') {
// Auf die spezifische Maschine pinnen, die der Lock aufgelöst hat, nicht das Pool-Label.
// Jenkins kann das nicht auf einem anderen Node einplanen.
agent { label env.resource_name }
stages {
stage('Prepare') { /* checkout, Abhängigkeiten wiederherstellen */ }
stage('Build') { /* kompilieren, Tests ausführen */ }
stage('Artifacts') { /* Ausgaben archivieren */ }
}
// Der Lock wird für die gesamte 'Make'-Stage gehalten und beim Verlassen freigegeben.
}
}
}
Der lock-Schritt löst eine Ressource aus dem TC3.1.4024-Pool auf, sagen wir build-agent-03, und schreibt diesen Namen in env.resource_name. Die äußere stage('Make') pinnt auf genau diesen Namen, Jenkins bleibt also nichts anderes übrig, als alles auf build-agent-03 einzuplanen. Der Lock hält, bis die gesamte Make-Stage fertig ist oder die Pipeline abgebrochen wird.
Der Unterschied ist klein im Code und groß im Verhalten: agent { label 'TC3.1.4024' } allein fragt nach einer Maschine aus dem Pool. Lock-dann-Pin garantiert eine bestimmte Maschine, exklusiv gehalten, egal wie viele Executor-Slots diese Maschine hat.
Der Teil, den man leicht vergisst: den Pool synchron halten
Lock und Pin funktionieren nur, wenn jeder Jenkins-Node eine passende Lockable Resource mit den richtigen Labels hat. Fügt man eine Maschine hinzu und vergisst, ihre Ressource anzulegen, tritt sie stillschweigend nie dem Pool bei. Geht ein Node offline, ohne dass seine Ressource aktualisiert wird, warten Pipelines fröhlich auf eine Maschine, die nie zurückkommt.
Das von Hand zu pflegen übersteht keine echte Flotte. Wir nutzen ein kleines Groovy-Skript, syncLockableResources.groovy, abgelegt in $JENKINS_HOME/init.groovy.d/. Jenkins führt beim Start alles in diesem Ordner aus, und dieses Skript registriert zusätzlich einen Listener, der bei jeder Node-Zustandsänderung feuert, damit die Registry nie driftet:
// $JENKINS_HOME/init.groovy.d/syncLockableResources.groovy
import hudson.slaves.ComputerListener
import hudson.slaves.OfflineCause
import hudson.model.Computer
import hudson.model.Hudson
import hudson.model.Node
import hudson.model.TaskListener
import jenkins.model.Jenkins
import org.jenkins.plugins.lockableresources.LockableResourcesManager
def logger = java.util.logging.Logger.getLogger("lockable-resource-sync")
def computeLabels = { Computer c, Node node ->
if (!c.isOnline()) return "${node.name} OFFLINE"
return node.labelString + " " + node.name
}
def syncResource = { Computer c ->
if (c instanceof Hudson.MasterComputer) return
Node node = c.getNode()
if (node == null) return
def manager = LockableResourcesManager.get()
def resource = manager.fromName(node.name)
if (resource == null) {
manager.createResource(node.name)
resource = manager.fromName(node.name)
resource.setEphemeral(false)
}
resource.setLabels(computeLabels(c, node))
manager.save()
logger.info("Synced '${node.name}' → '${resource.labels}'")
}
Jenkins.get().getComputers().each { c -> syncResource(c) }
def sync = syncResource
ComputerListener.all().add(new ComputerListener() {
@Override void onOnline(Computer c, TaskListener listener) { sync(c) }
@Override void onOffline(Computer c, OfflineCause cause) { sync(c) }
@Override void onTemporarilyOnline(Computer c) { sync(c) }
@Override void onTemporarilyOffline(Computer c, OfflineCause cause) { sync(c) }
})
logger.info("lockable-resource-sync listener registered")
Drei Dinge lohnt es sich zu verstehen:
Es legt die Ressource an, falls sie fehlt. Beim ersten Onlinekommen eines Nodes gibt manager.fromName(node.name) null zurück. Das Skript erstellt eine Lockable Resource, benannt nach dem Node, und ab da findet der Lock-Schritt sie.
Es spiegelt die Labels des Nodes auf die Ressource. Genau deshalb funktioniert agent { label env.resource_name }: Die Ressource heißt build-agent-03, der Node heißt build-agent-03, und der Label-String enthält diesen Namen plus die Pool-Labels (TC3.1.4024 und andere) direkt aus der Jenkins-Node-Konfiguration. Eine Maschine zwischen Pools zu verschieben ist damit nur eine Label-Änderung in Jenkins, nichts, was separat gepflegt werden muss.
Es markiert Offline-Nodes, damit sie niemand sperren kann. Ein Node, der offline geht, wird zu node-name OFFLINE, was zu keinem Pool-Label passt, also fällt er still aus der Rotation, bis er zurückkommt.
Das Skript läuft einmal beim Start, um alles zu erfassen, was sich während eines Jenkins-Neustarts geändert hat, und bleibt danach als Listener aktiv, solange Jenkins läuft.
Wie andere Plattformen das lösen
Jenkins braucht ein Plugin, ein Pipeline-Muster und ein Init-Skript, um hierhin zu kommen. Andere Plattformen bauen es direkter ein.
GitLab CI hat resource_group: einen projektweiten Mutex. Dieselbe resource_group-Zeichenkette mehreren Jobs geben, und GitLab serialisiert sie automatisch; der Rest wartet in der Queue. Maschinen kennt es nicht, also kombiniert man es mit einem Runner-tag, der den Job auf einen bestimmten Self-Hosted-Runner pinnt. Tag wählt die Maschine, Resource Group serialisiert den Zugriff.
GitHub Actions hat concurrency auf Workflow- oder Job-Ebene: einen repository-weiten Mutex mit der Option, den wartenden Run zu canceln statt zu warten. Kombiniert mit einem runs-on-Label für einen bestimmten Self-Hosted-Runner ergibt sich dieselbe Kombination: Label wählt die Maschine, Concurrency Group serialisiert sie.
Beide sind einfacher als das, was Jenkins braucht. Das ist kein Argument für einen CI-Systemwechsel. Unter Jenkins funktioniert das genauso gut, es braucht nur mehr Teile, um dort anzukommen.
Drei Teile, keine Abkürzung, und nur ein Punkt auf der Liste
Lock beansprucht eine bestimmte Maschine, bevor irgendeine Arbeit beginnt. Pin zwingt den Build auf genau diese Maschine, nicht auf irgendeinen freien Slot. Sync hält die Lock-Registry ehrlich, während sich die Flotte ändert, sodass niemand sie von Hand nachpflegen muss. Fehlt einer der drei Teile, ist man zurück bei sporadischen Kollisionen, die beim erneuten Anstoßen verschwinden und eine Woche später auf einer anderen Maschine wieder auftauchen.
Nichts davon ist exotisch, und nichts davon ist der ganze Job. Locking sorgt für exklusiven Maschinenzugriff. Es sagt nichts darüber, wie die richtigen Tools auf jeden Node kommen, wie Pipelines über Release-Branches hinweg korrekt anspringen oder wie Library-Abhängigkeiten über release/1.x und release/2.x hinweg aufgelöst werden. Jedes davon ist ein eigenes Problem, mit eigenem Setup und eigenem laufenden Aufwand.
| Schicht | Was es braucht |
|---|---|
| Build-Farm | N Windows-Maschinen pro TwinCAT-Version bereitstellen; patchen, überwachen, ersetzen |
| Maschinen-Exklusivität | Locking-Plugin, Pipeline-Muster, Sync-Skript (Jenkins) oder Runner-Tagging + Mutex-Konfiguration (GitLab/GitHub) |
| Tool-Verteilung | Authentifizierte, versionierte CLIs auf jedem Node |
| Branching + Trigger | Pipelines, die auf release/** feuern, Artefakte pro Release-Linie versioniert |
| Dependency Resolution | Ein Paketmanager, der verzweigte Release-Historie versteht |
| Verantwortung | Jemand, der all das noch versteht, wenn die Person, die es aufgebaut hat, weiterzieht |
Teams, die bei dieser ganzen Liste landen, haben das selten geplant. Sie haben “CI/CD einrichten” als Projekt mit Enddatum geplant. Die Farm ist der Teil, der danach einfach weiterläuft.
Was Zeugwerk CI/CD stattdessen übernimmt
Zeugwerk CI/CD, die gemanagte Option aus dem Landscape-Beitrag, existiert vor allem, um diese ganze Liste vom eigenen Tisch zu nehmen. Die Build-Nodes sind TwinCAT-fähig, dem eigenen Team dediziert (bei Bedarf gleich eine ganze Flotte davon) und werden nach jedem Build restlos zurückgesetzt. Exklusivität ist damit einfach der Normalzustand, nicht etwas, das man sich erst erarbeiten müsste. Dependency Resolution, Artefakt-Erzeugung und Test-Reporting sind direkt dabei, und der Code muss GitHub, GitLab oder Bitbucket nie verlassen; ein leichtgewichtiger Proxy übernimmt den Rest. Setup ist eher eine Sache von einem Nachmittag als von Farm-Engineering.
Zeugwerk leistet einen wertvollen und einzigartigen Beitrag zur Modernisierung der SPS-Softwareentwicklung. Der Build-Service ist gut in GitHub integriert und zuverlässig, er funktioniert einfach. Das Team hat uns beim Onboarding gut unterstützt und ist auch danach schnell und hilfsbereit.
— Brianna Laugher · Principal Software Engineer · Celleo
Das macht self-hosted nicht für alle zur falschen Wahl. Strikte Air-Gap-Richtlinien, eine bestehende Jenkins-Investition oder regulatorische Anforderungen, die externe Infrastruktur ausschließen, sind echte Gründe, die eigenen Maschinen weiterzubetreiben. Wo keiner davon greift, läuft es meist auf die Frage hinaus, ob der Betrieb von Windows-Build-Servern etwas ist, worin das eigene Team gut werden will. Und wer self-hosted bleibt, steht damit nicht ganz allein da: Unsere DevTools-CLIs, genau die, für die dieses ganze Locking überhaupt nötig ist, kommen auf die eigenen Nodes so, wie im Scoop-Beitrag beschrieben. Zumindest dieser Teil aktualisiert sich also von selbst und wird nicht zu einer weiteren Sache, die man von Hand pflegen muss.
Wann self-hosted trotzdem Sinn ergibt
Eine eigene Farm lohnt sich, wenn jedes Byte im eigenen Netz bleiben muss, wenn ein reifes DevOps-Team ohnehin Windows-Infrastruktur betreibt, oder wenn CI On-Prem-Systeme erreichen muss, an die ein Cloud-Build schlicht nicht herankommt.
Sonst zählt man besser die Maschinen, bevor man Pipeline-Zeilen zählt. “Wir fangen mit einer an und erweitern später” ist ein guter Plan, solange “erweitern” ehrlich einkalkuliert wird: nicht nur Hardware, sondern Locking, Tooling, Sync-Skripte und jemanden, der den ganzen Pool gesund hält.
Self-hosted TwinCAT-Build-Farm im Betrieb, oder unsicher ob gemanagtes CI passt? Meld dich.
