GitLab CI/CD na Kubernetes: twarde limity zasobów per projekt
Jak przenieśliśmy CI/CD z Docker runnerów na Kubernetes executor z ResourceQuota i shell-operatorem. Efekt: 40-60% szybsze pipeline'y.
200 vCPU. 320 GB RAM. Pięć hostów z Docker runnerami. I ciągle za mało.
Jeden źle zaprojektowany pipeline potrafił zjeść cały node. Reszta jobów? Czekała w kolejce albo padała z OOM. Brzmi znajomo?
📋 TL;DR
- Problem: Docker runner nie izoluje zasobów — jeden pipeline może wysycić cały host
- Rozwiązanie: Kubernetes executor + automatyczne ResourceQuota per projekt
- Bonus: Shell-operator do ustawiania limitów per service (PostgreSQL vs Redis)
- Efekt: 40-60% szybsze pipeline’y, zero awarii runnerów, pełna przewidywalność
⚠️ Problem: Docker runner nie chroni przed „noisy neighbors”
Klient miał klasyczną infrastrukturę CI/CD:
- 5 hostów z GitLab Docker runnerami
- 200 vCPU + 320 GB RAM łącznie
- Dziesiątki projektów walczących o zasoby
Skutki braku izolacji:
| Symptom | Częstotliwość |
|---|---|
| Pipeline wysyca 100% CPU na hoście | 8-9x / tydzień |
| Awaria runnera wymagająca ręcznej interwencji | 3x / tydzień |
| Wydłużenie jobów przez brak zasobów | codziennie |
Dockerowe limity oparte o cgroups są „miękkie”. Przy dużym obciążeniu nie dają takiej izolacji jak Kubernetes z ResourceQuota i LimitRange.
🎯 Cel: twarde limity per projekt
Wymagania klienta były jasne:
- Twarde limity CPU/RAM na poziomie projektu lub grupy projektów
- Różne pule zasobów dla różnych zespołów:
- Frontend: 10 vCPU / 10 GiB RAM
- Backend: 20 vCPU / 20 GiB RAM
- Elastyczność — zespoły decydują, jak rozdzielić budżet między kontenery w jobie
- Zero ręcznej roboty — namespace’y i quota tworzą się automatycznie
🔧 Krok 1: Kubernetes executor z dynamicznymi namespace’ami
Pierwszy krok to uruchomienie klastra RKE2 i instalacja GitLab Runnera z Kubernetes executorem.
Kluczowy fragment konfiguracji runnera:
[[runners]]
[runners.kubernetes]
namespace_overwrite_allowed = "ci-.*"
Dzięki temu można per job nadpisać namespace zmienną KUBERNETES_NAMESPACE_OVERWRITE.
W szablonie grupy/projektu:
variables:
KUBERNETES_NAMESPACE_OVERWRITE: "ci-${CI_PROJECT_NAMESPACE}-${CI_PROJECT_NAME}"
Efekt mapowania:
| Projekt GitLab | Namespace K8s |
|---|---|
backend-projects/app1 | ci-backend-projects-app1 |
frontend-projects/app2 | ci-frontend-projects-app2 |
Każdy projekt = osobny namespace = możliwość własnej ResourceQuota.
🔧 Krok 2: Automatyczne ResourceQuota per grupa
Do automatycznego tworzenia quota wykorzystaliśmy nsinjector (dziś sensowniej użyć Kyverno, ale zasada ta sama).
Konfiguracja dla grup frontend i backend:
- name: ci-quota-injector-front
namespaces:
- ci-frontend-projects-*
excludedNamespaces:
- ci-backend-projects-*
resources:
- |
apiVersion: v1
kind: ResourceQuota
metadata:
name: ci-resourcequota
spec:
hard:
requests.cpu: "10"
requests.memory: 10Gi
limits.cpu: "10"
limits.memory: 10Gi
- name: ci-quota-injector-back
namespaces:
- ci-backend-projects-*
excludedNamespaces:
- ci-frontend-projects-*
resources:
- |
apiVersion: v1
kind: ResourceQuota
metadata:
name: ci-resourcequota
spec:
hard:
requests.cpu: "20"
requests.memory: 20Gi
limits.cpu: "20"
limits.memory: 20Gi
W praktyce:
- Projekt
backend-projects/app1→ limit 20 CPU / 20 GiB - Projekt
frontend-projects/app2→ limit 10 CPU / 10 GiB
Kubernetes pilnuje sumy requests/limits w namespace i nie pozwala jej przekroczyć.
💡 Pro tip: Potrzebujesz indywidualnych limitów dla konkretnego projektu? Dodaj osobny wzorzec namespace + dedykowaną ResourceQuota.
⚠️ Problem nr 2: limity per service w jobie
GitLab runner pozwala ustawić limity dla services, ale wspólne dla wszystkich:
[runners.kubernetes]
service_cpu_limit = "500m"
service_cpu_limit_overwrite_max_allowed = "10000m"
service_cpu_request = "128m"
service_cpu_request_overwrite_max_allowed = "10000m"
service_memory_limit = "512Mi"
service_memory_limit_overwrite_max_allowed = "10Gi"
service_memory_request = "128Mi"
service_memory_request_overwrite_max_allowed = "10Gi"
Można je nadpisać per job w .gitlab-ci.yml, ale wciąż są wspólne dla wszystkich services.
To za mało, jeśli PostgreSQL potrzebuje 2 GiB RAM, a Redis tylko 256 MiB.
🔧 Rozwiązanie: Shell-operator do patchowania per service
Użyliśmy shell-operatora od Flant — narzędzia uruchamiającego hooki w reakcji na eventy K8s.
Jak to działa:
- Runner nadaje Podom etykietę
shell-operator: "true" - Shell-operator nasłuchuje Podów z tą etykietą
- Po utworzeniu Poda hook:
- Odczytuje spec z
BINDING_CONTEXT_PATH - Identyfikuje kontenery services
- Generuje patch na podstawie env z
.gitlab-ci.ymllub domyślnych wartości - Wykonuje
kubectl patch pod
- Odczytuje spec z
Pełny hook:
#!/usr/bin/env bash
if [[ $1 == "--config" ]] ; then
cat <<EOF
configVersion: v1
kubernetes:
- apiVersion: v1
kind: Pod
executeHookOnEvent:
- Added
labelSelector:
matchLabels:
shell-operator: "true"
allowFailure: true
EOF
else
type=$(jq -r '.[0].type' $BINDING_CONTEXT_PATH)
if [[ $type == "Event" ]] ; then
echo $BINDING_CONTEXT_PATH
# Domyślne limity per typ serwisu
source ./limits.conf
# Nazwa i namespace Poda
podName=$(jq -r '.[0].object.metadata.name' $BINDING_CONTEXT_PATH)
podNamespace=$(jq -r '.[0].object.metadata.namespace' $BINDING_CONTEXT_PATH)
# Wszystkie kontenery w Podzie
podContainers=$(jq -r '.[0].object.spec.containers[].name' $BINDING_CONTEXT_PATH)
# Lista kontenerów będących services (nazwy zawierające "svc")
svc_containers=$(echo $podContainers | awk -F" " '{for(i=1;i<=NF;i++){if ($i ~ /svc/){print $i}}}')
if [[ ! -z $svc_containers ]] ; then
for i in $svc_containers; do
# Obraz kontenera service
SVC_IMAGE_NAME=$(jq --arg svc_container_name "$i" -r '.[0].object.spec.containers[] | select(.name==$svc_container_name).image' $BINDING_CONTEXT_PATH)
# Próba odczytu wartości z env ustawionych w .gitlab-ci
CPU_LIMIT=$(jq --arg svc_container_name "$i" -r '.[0].object.spec.containers[] | select(.name==$svc_container_name).env | .[] | select(.name=="KUBERNETES_SVC_CPU_LIMIT").value' $BINDING_CONTEXT_PATH)
# Jeśli brak env w jobie – domyślne wartości z limits.conf
if [[ -z $CPU_LIMIT ]] ; then
case "$SVC_IMAGE_NAME" in
*postgresql*)
CPU_LIMIT=$KUBERNETES_PSQL_CPU_LIMIT
CPU_REQUEST=$KUBERNETES_PSQL_CPU_REQUEST
MEM_LIMIT=$KUBERNETES_PSQL_MEMORY_LIMIT
MEM_REQUEST=$KUBERNETES_PSQL_MEMORY_REQUEST
;;
# Tutaj można dodać redis, elasticsearch, mysql itd.
esac
elif [[ ! -z $CPU_LIMIT ]] ; then
CPU_REQUEST=$(jq --arg svc_container_name "$i" -r '.[0].object.spec.containers[] | select(.name==$svc_container_name).env | .[] | select(.name=="KUBERNETES_SVC_CPU_REQUEST").value' $BINDING_CONTEXT_PATH)
MEM_LIMIT=$(jq --arg svc_container_name "$i" -r '.[0].object.spec.containers[] | select(.name==$svc_container_name).env | .[] | select(.name=="KUBERNETES_SVC_MEM_LIMIT").value' $BINDING_CONTEXT_PATH)
MEM_REQUEST=$(jq --arg svc_container_name "$i" -r '.[0].object.spec.containers[] | select(.name==$svc_container_name).env | .[] | select(.name=="KUBERNETES_SVC_MEM_REQUEST").value' $BINDING_CONTEXT_PATH)
else
echo "Nothing to do! Exiting.."
exit 0;
fi
# Podstawienie wartości do szablonu patcha
sed -e "s/SVC_CONTAINER_NAME/$i/" \
-e "s/CPU_LIMIT/$CPU_LIMIT/" \
-e "s/CPU_REQUEST/$CPU_REQUEST/" \
-e "s/MEM_LIMIT/$MEM_LIMIT/" \
-e "s/MEM_REQUEST/$MEM_REQUEST/" svc-env.yaml > $i.yaml
# Patch zasobów kontenera w Podzie
kubectl patch pod $podName -n $podNamespace --patch-file $i.yaml
done
else
echo "Nothing to do! Exiting.."
exit 0;
fi
fi
fi
Szablon svc-env.yaml:
spec:
containers:
- name: "SVC_CONTAINER_NAME"
resources:
limits:
cpu: "CPU_LIMIT"
memory: "MEM_LIMIT"
requests:
cpu: "CPU_REQUEST"
memory: "MEM_REQUEST"
Użycie w .gitlab-ci.yml:
services:
- name: postgres:15
alias: psql-svc
variables:
KUBERNETES_SVC_CPU_LIMIT: "1000m"
KUBERNETES_SVC_CPU_REQUEST: "500m"
KUBERNETES_SVC_MEM_LIMIT: "2Gi"
KUBERNETES_SVC_MEM_REQUEST: "1Gi"
Bez zmiennych? Wchodzą domyślne wartości z limits.conf.
📊 Efekty wdrożenia
| Metryka | Przed | Po |
|---|---|---|
| Średni czas pipeline’a | 100% (baseline) | 40-60% szybciej |
| Awarie runnerów / miesiąc | ~12 | 0 |
| Ręczne interwencje / tydzień | 2-3 | 0 |
| Przewidywalność czasów | ❌ losowa | ✅ stabilna |
Co się zmieniło:
- ✅ Gwarantowana pula zasobów — każdy projekt ma swoje, nie konkuruje z innymi
- ✅ Koniec z „noisy neighbors” — jeden pipeline nie może zepsuć dnia całemu zespołowi
- ✅ Świadome zarządzanie — zespoły wiedzą, ile mają i jak to rozdzielić
- ✅ Zero awarii — Kubernetes throttluje zamiast crashować
⚠️ Uczciwy trade-off: Niektóre pipeline’y (te, które wcześniej zjadały cały node) teraz trwają dłużej. Ale są przewidywalne i nie psują pracy innym.
🎯 Kiedy to ma sens?
To rozwiązanie sprawdzi się, gdy:
- Masz wielu zespołów / projektów współdzielących infrastrukturę CI/CD
- Doświadczasz „noisy neighbor” problem — jeden projekt psuje drugim
- Potrzebujesz audytowalności i limitów per zespół
- Twoje pipeline’y używają ciężkich services (bazy, search, cache)
Nie potrzebujesz tego, jeśli:
- Masz mało projektów i dużo zasobów
- Wszystkie pipeline’y są podobne i lekkie
- Nie masz kompetencji Kubernetes w zespole
Podsumowanie
Migracja z Docker runnerów na Kubernetes executor to nie tylko zmiana technologii. To zmiana paradygmatu zarządzania zasobami CI/CD.
Zamiast „kto pierwszy, ten lepszy” masz twarde gwarancje.
Kubernetes z ResourceQuota + shell-operator do fine-grained control = pełna kontrola nad tym, kto ile może zużyć. I przewidywalność, której Docker runner po prostu nie da.
Masz podobny problem z CI/CD? Chętnie porozmawiamy o Twoim case’ie.
Autor: Wojciech Sokola @ OPSWRO Team