← WSZYSTKIE WPISY

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:

SymptomCzęstotliwość
Pipeline wysyca 100% CPU na hoście8-9x / tydzień
Awaria runnera wymagająca ręcznej interwencji3x / tydzień
Wydłużenie jobów przez brak zasobówcodziennie

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:

  1. Twarde limity CPU/RAM na poziomie projektu lub grupy projektów
  2. Różne pule zasobów dla różnych zespołów:
    • Frontend: 10 vCPU / 10 GiB RAM
    • Backend: 20 vCPU / 20 GiB RAM
  3. Elastyczność — zespoły decydują, jak rozdzielić budżet między kontenery w jobie
  4. 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 GitLabNamespace K8s
backend-projects/app1ci-backend-projects-app1
frontend-projects/app2ci-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:

  1. Runner nadaje Podom etykietę shell-operator: "true"
  2. Shell-operator nasłuchuje Podów z tą etykietą
  3. Po utworzeniu Poda hook:
    • Odczytuje spec z BINDING_CONTEXT_PATH
    • Identyfikuje kontenery services
    • Generuje patch na podstawie env z .gitlab-ci.yml lub domyślnych wartości
    • Wykonuje kubectl patch pod

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

MetrykaPrzedPo
Średni czas pipeline’a100% (baseline)40-60% szybciej
Awarie runnerów / miesiąc~120
Ręczne interwencje / tydzień2-30
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