CI/CD
.yml
GitLab · Runner · Concurrency · Magento
Runner-Kapazität und Concurrency
in großen Teams gezielt steuern

Wer in einem größeren Team mit GitLab CI/CD arbeitet, kennt das Phänomen: Jobs landen in der Queue, obwohl die Pipeline schon längst abgeschickt wurde. Die Ursache liegt fast immer in zu niedrig konfigurierten concurrent-Limits, fehlenden Runner-Tags oder unausgewogener Lastverteilung zwischen Build-, Test- und Deploy-Jobs.

15 Min. Lesezeit concurrent · config.toml · Tags · job_policy · Monitoring GitLab 16+ · Magento 2.4 · PHP 8.4

1. Das Queue-Problem in wachsenden Teams

Wenn ein Team wächst und mehr Entwickler gleichzeitig Merge Requests öffnen, treffen Pipeline-Starts aufeinander. GitLab verteilt Jobs an verfügbare Runner — sind alle Runner ausgelastet, landen neue Jobs in der Queue. Die Queue-Zeit ist die Zeitspanne zwischen dem Trigger eines Jobs und seinem tatsächlichen Start. In kleinen Teams mit wenigen gleichzeitigen Pipelines bleibt diese Zeit gering. In Teams mit zehn oder mehr aktiven Entwicklern kann die Queue-Zeit die eigentliche Ausführungszeit eines Jobs überschreiten, was Feedback-Zyklen empfindlich verlängert.

Das Problem ist in Magento-Projekten besonders ausgeprägt: Build-Jobs mit composer install, setup:di:compile und Tailwind-Build laufen häufig vier bis acht Minuten. Wenn drei Teams gleichzeitig auf denselben Runner angewiesen sind, warten die letzten Jobs nicht Sekunden, sondern Minuten, bevor sie überhaupt starten. Das Ergebnis ist ein künstlich verlängerter Deployment-Zyklus, obwohl die Server-Hardware theoretisch ausreichen würde. Die Lösung liegt nicht immer in mehr Hardware, sondern zunächst in der richtigen Kapazitätskonfiguration des bestehenden Runners.

Die zwei wichtigsten Stellschrauben sind concurrent auf Runner-Ebene und limit auf Executor-Ebene in der config.toml. Daneben spielen Tags, fair scheduling und die Struktur der Pipelines selbst eine Rolle. Dieser Artikel zeigt, wie man diese Stellschrauben gezielt einsetzt, um Queue-Zeiten für ein wachsendes Magento-Team messbar zu reduzieren.

2. concurrent und limit: das Herzstück der Kapazitätssteuerung

Die globale concurrent-Einstellung in /etc/gitlab-runner/config.toml gibt an, wie viele Jobs der Runner-Prozess gleichzeitig ausführen darf — über alle registrierten Runner-Instanzen hinweg. Der Standardwert nach der Installation ist meistens 1, was bedeutet, dass der Runner jeden Job sequenziell abarbeitet. Für einen Server mit acht CPU-Kernen und 32 GB RAM ist dieser Wert drastisch zu niedrig. Ein realistischer Ausgangspunkt für einen dedizierten Build-Server ist concurrent = 4, also vier parallel laufende Jobs, da Magento-Builds durch composer install und setup:di:compile speicherintensiv sind.

Der pro-Runner-Wert limit im [[runners]]-Block begrenzt, wie viele Jobs eine einzelne Runner-Instanz gleichzeitig übernehmen darf. Wenn concurrent = 4 und zwei Runner mit limit = 2 registriert sind, können in der Summe vier Jobs laufen, je zwei pro Runner-Instanz. Dieses Modell erlaubt es, verschiedene Runner-Instanzen für verschiedene Aufgaben zu konfigurieren — etwa einen Runner mit hohem Limit für schnelle Test-Jobs und einen Runner mit niedrigem Limit für ressourcenintensive Build-Jobs — ohne den globalen concurrent-Wert übermäßig zu erhöhen.

# /etc/gitlab-runner/config.toml
# Global concurrent: maximum simultaneous jobs across all runners on this host
concurrent = 6
check_interval = 3
shutdown_timeout = 30

[[runners]]
  name = "magento-build-runner"
  url = "https://gitlab.example.com"
  token = "RUNNER_TOKEN_BUILD"
  executor = "docker"
  # Limit for this runner instance only
  limit = 3
  [runners.docker]
    image = "php:8.4-cli"
    volumes = ["/cache:/cache", "/composer-cache:/root/.composer"]
    memory = "4g"
    cpus = "2"

[[runners]]
  name = "magento-deploy-runner"
  url = "https://gitlab.example.com"
  token = "RUNNER_TOKEN_DEPLOY"
  executor = "shell"
  # Deploy runner: only one job at a time to avoid concurrent symlink switches
  limit = 1
  [runners.custom]
    run_exec = ""

Ein häufiger Fehler ist es, concurrent zu erhöhen, ohne die verfügbaren Systemressourcen zu berücksichtigen. Wenn fünf parallele composer install-Prozesse gleichzeitig laufen und jeder davon 2 GB RAM benötigt, gerät ein Server mit 8 GB RAM in Swap-Bereiche, was alle Jobs verlangsamt statt sie zu beschleunigen. Die richtige Formel: concurrent = verfügbarer RAM / durchschnittlicher RAM-Bedarf pro Job, abzüglich eines Puffers für das Betriebssystem.

3. Runner-Tags als Routing-Mechanismus

Runner-Tags sind in GitLab CI/CD das primäre Mittel, um Jobs gezielt an bestimmte Runner zu routen. Ohne Tags übernimmt jeder ungetaggte Runner jeden ungetaggten Job — was in einer gemischten Umgebung aus Build-, Test- und Deploy-Runnern zu einer unerwünschten Durchmischung führt. Ein Deploy-Runner, der versehentlich einen ressourcenintensiven Build-Job übernimmt, bindet seine limit-Kapazität und verzögert Deploy-Jobs, die auf denselben Runner angewiesen sind.

Das empfohlene Muster für große Teams ist eine klare Tag-Hierarchie: build für Compiler und Asset-Build-Jobs, test für PHPUnit und PHPStan, deploy für SSH-basierte Deployments, docker für Jobs, die einen Docker-Executor benötigen, und shell für Jobs mit direktem Serverzugriff. Jeder Runner wird mit einem oder mehreren dieser Tags registriert, und jeder Job in der .gitlab-ci.yml deklariert seinen tags-Block. So landet jeder Job garantiert beim richtigen Runner-Typ, und die Queue-Zeiten für kritische Deploy-Jobs sind von der Last der Build-Jobs entkoppelt.

4. job_policy und fair scheduling zwischen Projekten

In Umgebungen, in denen mehrere Magento-Projekte denselben GitLab-Runner teilen, entsteht das Problem der Job-Fairness: Ein Projekt, das viele parallele Pipelines auslöst, kann den Runner für andere Projekte dauerhaft blockieren. GitLab bietet mit dem fair scheduling-Algorithmus auf Instanz-Ebene eine automatische Ausbalancierung: Der GitLab-Server verteilt Jobs nicht in strikter FIFO-Reihenfolge, sondern bevorzugt Projekte mit weniger aktuell laufenden Jobs. Das reduziert Starvation, bei der ein Projekt lange wartet, weil ein anderes Projekt die Runner-Kapazität dominiert.

Auf Runner-Ebene kann man zusätzlich mit dem maximum_timeout-Wert im [[runners]]-Block dafür sorgen, dass hängende Jobs automatisch abgebrochen werden und die Kapazität freigeben. Ein typischer Wert für Magento-Build-Jobs beträgt 1800 Sekunden (30 Minuten). Jobs, die länger laufen, sind fast immer ein Symptom eines Problems — etwa ein blockierter composer install oder ein fehlgeschlagener Frontend-Build, der auf Benutzereingabe wartet und nie abbricht. Mit einem definierten Timeout gibt der Runner die Ressource kontrolliert frei, statt sie dauerhaft zu belegen.

5. Horizontale Skalierung: wann ein zweiter Runner sinnvoll ist

Horizontale Skalierung — also das Hinzufügen weiterer Runner-Hosts — ist die richtige Maßnahme, wenn die vertikale Kapazität des bestehenden Servers ausgeschöpft ist. Der Indikator dafür ist nicht allein die hohe CPU- oder RAM-Auslastung, sondern eine stabil hohe Queue-Zeit trotz eines optimierten concurrent-Werts. Wenn Jobs im Schnitt zwei Minuten in der Queue warten, bevor sie starten, und alle verfügbaren Ressourcen des Runners ausgelastet sind, ist ein zweiter Runner-Host sinnvoller als weiteres Tuning.

GitLab unterstützt dabei mehrere Muster: Mehrere physische oder virtuelle Hosts, auf denen jeweils ein gitlab-runner-Prozess läuft und mit denselben Tags registriert ist. GitLab verteilt Jobs automatisch zwischen diesen Runnern. Alternativ bieten Cloud-Autoscaler (AWS EC2 Autoscaler, Google Cloud Autoscaler) die Möglichkeit, Runner-Instanzen dynamisch nach Bedarf zu starten und zu beenden. Für Magento-Teams mit vorhersehbarer Arbeitslast — zum Beispiel mit definierten Sprint-Zyklen — ist eine statische zweite Runner-Maschine oft die einfachere und kostengünstigere Lösung.

6. Vollständige config.toml für Magento-Build-Runner

Die folgende Konfiguration zeigt eine produktionsnahe config.toml für einen dedizierten Magento-Build-Runner mit Docker-Executor. Sie berücksichtigt Concurrency, Ressourcenlimits, Cache-Volumes, Netzwerkkonfiguration und einen separaten Deploy-Runner ohne Concurrency-Problem. Die Kommentare erklären die Entscheidungen hinter jedem Parameter.

# /etc/gitlab-runner/config.toml
# Global limit: maximum jobs across all [[runners]] on this host
concurrent = 5
check_interval = 5
shutdown_timeout = 60
log_level = "info"
log_format = "json"

[[runners]]
  name = "magento-build-php84"
  url = "https://gitlab.example.com"
  token = "${GITLAB_RUNNER_TOKEN_BUILD}"
  executor = "docker"
  # Max 3 concurrent build jobs on this runner instance
  limit = 3
  # Job timeout: abort after 25 minutes
  maximum_timeout = 1500
  # Route only jobs tagged with 'build' and 'php84'
  tag_list = ["build", "php84", "docker"]

  [runners.cache]
    Type = "local"
    Path = "/runner-cache"
    Shared = false

  [runners.docker]
    tls_verify = false
    image = "php:8.4-cli"
    privileged = false
    # Bind-mount Composer cache to speed up repeated builds
    volumes = [
      "/srv/composer-cache:/root/.composer:rw",
      "/srv/npm-cache:/root/.npm:rw",
      "/runner-cache:/cache"
    ]
    shm_size = 0
    memory = "4096m"
    memory_swap = "4096m"
    cpus = "2.0"
    network_mode = "bridge"

[[runners]]
  name = "magento-deploy-ssh"
  url = "https://gitlab.example.com"
  token = "${GITLAB_RUNNER_TOKEN_DEPLOY}"
  executor = "shell"
  # Deploy: only one job at a time to prevent concurrent symlink switches
  limit = 1
  maximum_timeout = 900
  tag_list = ["deploy", "production", "shell"]
  # Shell executor uses the gitlab-runner user's environment
  shell = "bash"

Wichtig bei dieser Konfiguration: Der Build-Runner verwendet Docker-Executor mit Ressourcenlimits pro Container, um sicherzustellen, dass einzelne Jobs nicht die gesamte Host-Kapazität in Beschlag nehmen. Der Deploy-Runner läuft mit Shell-Executor und strikt limit = 1, weil parallele Deployments zu Race Conditions beim Symlink-Wechsel führen würden. Das log_format = "json" auf globaler Ebene erlaubt das strukturierte Parsing der Runner-Logs in Monitoring-Systemen wie Grafana Loki oder Elasticsearch.

7. Queue-Zeiten beobachten und auswerten

GitLab selbst bietet in der Instanz-Administration unter Admin Area → Monitoring → GitLab Runner eine Übersicht über aktiv laufende Jobs und wartende Jobs. Diese Ansicht reicht für Troubleshooting aus, ist aber für kontinuierliches Monitoring nicht geeignet, weil sie keine historischen Daten speichert. Für dauerhaftes Kapazitäts-Monitoring gibt es zwei praxiserprobte Ansätze: den GitLab Prometheus-Endpunkt und die GitLab API.

GitLab exportiert Runner-Metriken unter /metrics, darunter gitlab_runner_jobs_total, gitlab_runner_jobs{state="running"} und gitlab_runner_jobs{state="waiting"}. Diese Metriken lassen sich in Prometheus scrapen und in Grafana visualisieren. Ein einfaches Alerting-Regel könnte so aussehen: Wenn gitlab_runner_jobs{state="waiting"} > 5 für mehr als fünf Minuten, sendet Alertmanager eine Benachrichtigung. Das ist das Frühwarnsignal dafür, dass die Kapazität nicht mit der Last Schritt hält und eine Anpassung des concurrent-Werts oder ein weiterer Runner-Host erforderlich ist.

8. Vergleich der Strategien zur Queue-Reduzierung

Es gibt mehrere Stellschrauben für kürzere Queue-Zeiten, und die richtige Wahl hängt vom konkreten Engpass ab. Eine pauschale Erhöhung von concurrent hilft nur, wenn Systemressourcen noch verfügbar sind. Tag-Routing hilft nur, wenn Jobs falsch verteilt werden. Horizontale Skalierung hilft nur, wenn der Engpass wirklich in der Kapazität liegt und nicht in der Pipeline-Struktur selbst.

Strategie Wann sinnvoll Risiko Aufwand
concurrent erhöhen Ressourcen noch frei, Standardwert zu niedrig OOM bei zu aggressiver Erhöhung Gering
Runner-Tags einführen Deploy-Runner übernimmt Build-Jobs Fehlkonfiguration blockiert Jobs komplett Mittel
maximum_timeout setzen Hängende Jobs blockieren Kapazität Legitime lange Jobs werden abgebrochen Gering
Zweiter Runner-Host Erster Host voll ausgelastet Infrastrukturkosten, Wartungsaufwand Hoch
Pipeline-Struktur optimieren Jobs laufen länger als nötig Erfordert tiefes Pipeline-Verständnis Mittel–Hoch

In der Praxis ist der erste Schritt immer die Messung: Wie lang ist die durchschnittliche Queue-Zeit pro Job-Typ? Wo liegt der Engpass — beim Build-Runner, beim Deploy-Runner oder beim Test-Runner? Erst wenn diese Fragen datenbasiert beantwortet sind, lohnt es sich, eine der obigen Strategien gezielt anzuwenden. Blindes Erhöhen von concurrent ohne Monitoring führt regelmäßig zu OOM-Situationen, die alle laufenden Jobs abbrechen und die Queue-Zeit für den gesamten Abend verlängern.

9. Typische Fehler beim Kapazitätsmanagement

Der häufigste Fehler ist ein zu hoher concurrent-Wert ohne angepasste Docker-Ressourcenlimits. Wenn zehn parallele Jobs ohne Memory-Limit laufen und einer davon einen Speicherleck hat, wächst der Speicherbedarf unkontrolliert, bis der OOM-Killer des Kernels Jobs beendet. Die Lösung: immer memory und memory_swap im [runners.docker]-Block setzen, sodass ein einzelner Job nur das ihm zugewiesene RAM nutzen kann.

Ein zweiter Fehler ist das Vergessen von Tags beim Registrieren eines neuen Runners. Ein Runner ohne Tags übernimmt alle ungetaggten Jobs — auch Jobs aus Projekten, für die dieser Runner eigentlich nicht gedacht war. In einer Umgebung mit mehreren Projekten führt das dazu, dass ein Deploy-Runner für Projekt A versehentlich Build-Jobs von Projekt B übernimmt. Durch konsequente Tag-Strategie und die Runner-Einstellung run_untagged = false im [[runners]]-Block wird dieses Problem strukturell ausgeschlossen. Ein dritter Fehler ist das Ignorieren des maximum_timeout: Ohne Timeout kann ein hängender Job die gesamte limit-Kapazität eines Runners dauerhaft blockieren, bis jemand manuell eingreift.

10. Zusammenfassung

Queue-Zeiten in großen Teams sind kein unvermeidliches Schicksal, sondern ein Konfigurationsproblem mit bekannten Lösungen. Der erste Schritt ist immer das Messen: GitLab-Metriken und Prometheus zeigen, welcher Runner-Typ der Engpass ist. Der zweite Schritt ist die Anpassung von concurrent und limit auf der Basis realer Ressourcenverfügbarkeit — nicht auf der Basis von Wunschdenken. Tag-Routing entkoppelt Build-, Test- und Deploy-Runner voneinander, sodass ein voller Build-Queue nicht den Deploy-Runner blockiert. maximum_timeout verhindert, dass einzelne hängende Jobs die Kapazität dauerhaft belegen.

Für Magento-Projekte ist die Empfehlung klar: ein dedizierter Build-Runner mit Docker-Executor und limit = 2–4, ein separater Deploy-Runner mit Shell-Executor und limit = 1, und Prometheus-basiertes Monitoring für Queue-Zeiten und Job-Durchsatz. Erst wenn diese Grundkonfiguration ausgeschöpft ist, lohnt sich der Aufwand eines zweiten Runner-Hosts oder eines Cloud-Autoscalers.

Runner-Kapazität und Queue-Zeiten — Das Wichtigste auf einen Blick

concurrent & limit

Global-Wert und pro-Runner-Limit müssen auf reale RAM-Verfügbarkeit abgestimmt sein. OOM durch zu hohe Werte verlangsamt alle Jobs.

Tag-Routing

Build-, Test- und Deploy-Runner mit dedizierten Tags entkoppeln. run_untagged = false schützt vor ungewollter Job-Übernahme.

maximum_timeout

Hängende Jobs ohne Timeout blockieren Kapazität dauerhaft. Für Magento-Builds: 1500–1800 Sekunden als obere Grenze setzen.

Monitoring

Prometheus-Metriken von GitLab scrapen, Queue-Zeit und waiting-Jobs in Grafana visualisieren — erst messen, dann skalieren.

11. FAQ: Runner-Kapazität und Queue-Zeiten in großen Teams

1Unterschied concurrent vs. limit?
concurrent ist der globale Host-Maximalwert. limit ist der Maximalwert pro [[runners]]-Instanz. concurrent = 6 mit zwei Runnern à limit = 3 ergibt maximal sechs parallele Jobs.
2Jobs in Queue obwohl RAM frei ist?
Der concurrent-Wert ist ausgeschöpft — nicht der RAM. GitLab sendet keine weiteren Jobs, bis ein laufender fertig ist. concurrent explizit erhöhen.
3Deploy-Jobs durch Build-Jobs verzögert?
Dedizierte Tags einführen: 'build' für Build-Runner, 'deploy' für Deploy-Runner. run_untagged = false verhindert ungewollte Job-Übernahme.
4Was passiert bei zu hohem concurrent?
OOM-Killer beendet Prozesse. Jobs schlagen fehl, obwohl der Code korrekt ist. Immer memory-Limit pro Container setzen und concurrent daran ausrichten.
5run_untagged = false — Zweck?
Dieser Runner übernimmt nur Jobs mit einem seiner Tags. Allgemeine Jobs anderer Projekte werden ignoriert. Verhindert ungewollte Kapazitätsbindung.
6Wie Queue-Zeit messen?
Job-Detailseite in GitLab zeigt 'Queued'. Prometheus-Metriken unter /metrics mit state='waiting' scrapen und in Grafana historisch auswerten.
7Wann zweiten Runner-Host hinzufügen?
Wenn erster Host voll ausgelastet ist und Queue-Zeiten trotz optimalem concurrent hoch bleiben. Vorher Pipeline-Optimierung (Caching, needs) prüfen.
8Empfohlenes concurrent für Magento-Builds?
RAM in GB geteilt durch 3. Bei 16 GB: 4–5. Bei Docker-Executor memory-Limit auf 3–4 GB setzen und concurrent entsprechend anpassen.
9Was macht maximum_timeout?
Bricht Jobs nach der definierten Sekunden-Anzahl ab. Für Magento-Builds 1500 Sekunden. Hängende Jobs geben so automatisch Kapazität frei.
10Kann GitLab Runner automatisch skalieren?
Ja, über Docker Machine Executor oder Cloud-Autoscaler (AWS EC2, Google Cloud). Für vorhersehbare Last ist ein fester zweiter Host oft einfacher zu betreiben.