Optimización en GitLab: más de 30 consejos para pipelines más rápidos

Soy Roxs 👩💻| Software Developer | DevOps | DevSecOps | en @295DevOps 🖼 Content Creator. No se puede crecer si no estas dispuesto a saltar a la zona de peligro 🔥
Cada minuto que un dev espera un pipeline es un minuto de foco perdido. Multiplicalo por cada commit, cada MR, cada persona del equipo... y de repente ese pipeline de 25 minutos es el impuesto más caro que paga tu organización.
Llevo años viviendo dentro de GitLab CI, manteniendo un framework de pipelines que sirve a miles de repositorios. Y si algo aprendí es esto: los pipelines lentos casi nunca son culpa de GitLab. Son culpa de cómo los escribimos.
Acá van más de 30 consejos, ordenados de lo que más impacto tiene a lo más fino. No hace falta aplicar todos: con los primeros 10 ya podés recortar la mitad del tiempo.
📊 Parte 1: Medí antes de tocar nada
1. No optimices a ciegas
Antes de cambiar una línea de YAML, andá a CI/CD > Analytics de tu proyecto y mirá la duración de pipelines (p50, p90) y los jobs que más tardan. Optimizar sin datos es adivinar con confianza.
2. Identificá el camino crítico
Tu pipeline dura lo que dura su cadena más larga de dependencias. Abrí la vista de grafo del pipeline: si tenés 20 jobs pero 3 forman una cadena secuencial de 15 minutos, esos 3 son tu problema. El resto es ruido.
3. Monitoreá tendencias, no fotos
Un pipeline no se degrada de golpe, se degrada de a 10 segundos por semana. Usá el GitLab CI Pipelines Exporter con Prometheus + Grafana para ver la película completa. Métricas clave: duración, tiempo en cola, tasa de fallos, cache hit rate.
4. Medí el "time to first feedback"
No solo importa cuánto tarda el pipeline completo: importa cuánto tarda el dev en saber si rompió algo. Poné lint y unit tests lo más temprano y rápido posible.
💾 Parte 2: Caché (el 80% de la ganancia está acá)
5. Cacheá dependencias con claves basadas en lockfiles
.cache_template: &cache
key:
files:
- package-lock.json
paths:
- .npm/
policy: pull
install:
cache:
<<: *cache
policy: pull-push
script:
- npm ci --cache .npm --prefer-offline
La clave se regenera solo cuando cambia el lockfile. Cambia una dependencia → cache nuevo. No cambia nada → hit instantáneo.
6. Usá políticas de caché correctas
El default es pull-push: baja el cache al inicio y lo sube al final, en cada job. Subir un node_modules de 500MB en cada job es tirar minutos a la basura. Solo el job que instala dependencias necesita pull-push; el resto, policy: pull.
7. Cache ≠ Artifacts (grabátelo a fuego)
Regla de oro: si el pipeline se rompe sin ese dato → artifact. Si el pipeline se hace lento sin ese dato → cache. El cache puede desaparecer en cualquier momento (eviction, runner distinto); nunca dependas de él para que algo funcione.
8. Cache distribuido para runners efímeros
Si tus runners son autoscalados (Kubernetes, spot instances), el cache local no sirve: cada job cae en una máquina distinta. Configurá cache remoto en S3/GCS/Azure Blob en el config.toml del runner. Sin esto, tu cache: del YAML es decorativo.
9. Definí claves de fallback
cache:
key: "$CI_COMMIT_REF_SLUG-deps"
fallback_keys:
- "main-deps"
Una branch nueva sin cache propio puede arrancar desde el cache de main en vez de instalar todo desde cero.
10. Cuidado con los caches obesos
Un cache de 2GB puede tardar más en descargar/descomprimir que lo que ahorra. Si tu cache engordó, revisá qué estás guardando: casi nunca necesitás cachear builds intermedios, solo dependencias.
11. Activá FastZip
variables:
FF_USE_FASTZIP: "true"
CACHE_COMPRESSION_LEVEL: "fast"
Compresión paralela para caches y artifacts. Ganancia gratis en jobs con caches grandes.
🕸️ Parte 3: Arquitectura del pipeline (DAG o muerte)
12. Usá needs y abandoná las stages secuenciales
Por defecto, cada stage espera a que termine TODA la stage anterior. Con needs armás un grafo dirigido (DAG) donde cada job arranca apenas sus dependencias reales terminan:
test-frontend:
stage: test
needs: ["build-frontend"] # no espera a build-backend
test-backend:
stage: test
needs: ["build-backend"] # no espera a build-frontend
Este solo cambio suele recortar 30-40% del tiempo total.
13. needs: [] para los jobs independientes
Lint, análisis estático, validación de YAML... nada de eso necesita el build. Con needs: [] arrancan en el segundo cero del pipeline.
14. Paralelizá tests con parallel
unit-tests:
parallel: 4
script:
- pytest --splits $CI_NODE_TOTAL --group $CI_NODE_INDEX
GitLab levanta 4 jobs y cada uno corre un pedazo de la suite. Con herramientas de split por duración (pytest-split, knapsack), los pedazos quedan balanceados y no tenés un runner terminando en 30 segundos mientras otro muele 4 minutos.
15. parallel:matrix para combinaciones
build:
parallel:
matrix:
- ARCH: [amd64, arm64]
ENV: [staging, production]
Cuatro builds simultáneos con una sola definición de job. Ideal para imágenes multi-arquitectura (tema que me toca de cerca 😅).
16. No paralelices más allá de tu capacidad
Partir los tests en 20 jobs suena hermoso hasta que te acordás de que tu fleet tiene 5 executors concurrentes. Los otros 15 hacen cola y el pipeline termina siendo MÁS lento por overhead de scheduling. Regla práctica: paralelizá hasta ~80% de tu capacidad de runners y monitoreá el queue depth.
17. Parent-child pipelines para monorepos
En un monorepo, no tiene sentido correr todo por cada cambio. Un pipeline padre detecta qué cambió y dispara solo los pipelines hijos relevantes:
trigger-frontend:
trigger:
include: frontend/.gitlab-ci.yml
rules:
- changes:
- frontend/**/*
🎯 Parte 4: Corré solo lo que hace falta
18. rules:changes es tu mejor amigo
¿Para qué correr los tests de backend si el MR solo tocó un README?
test-backend:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- backend/**/*
- .gitlab-ci.yml
19. workflow:rules para matar pipelines duplicadas
Si tenés MR pipelines y branch pipelines activas a la vez, cada push a una branch con MR abierto dispara DOS pipelines. Duplicás costo y tiempo de cola:
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
20. interruptible: true en (casi) todos los jobs
Cuando alguien pushea 3 commits seguidos, ¿de qué sirve terminar el pipeline del commit 1? Con interruptible: true + auto-cancelación de pipelines redundantes, los pipelines viejos se cancelan solos. Excepción: jobs de deploy, esos NUNCA interruptibles.
21. Mové lo pesado fuera del camino del dev
Scans completos de seguridad, tests E2E exhaustivos, benchmarks... no necesitan correr en cada MR. Movelos a pipelines programadas (nightly) o a when: manual, y dejá en el MR una versión rápida que dé señal temprana.
22. Timeouts agresivos
Un job colgado con timeout default de 1 hora es un runner secuestrado por 1 hora. Poné timeouts realistas por job (timeout: 10m). Si tu build tarda 5 minutos, un timeout de 10 ya te avisa que algo anda mal.
23. retry quirúrgico para fallos transitorios
integration-tests:
retry:
max: 1
when:
- runner_system_failure
- stuck_or_timeout_failure
Ojo: retry para fallos de infraestructura, no para tests flaky. Si un test es flaky, el fix es arreglar el test, no reintentarlo hasta que pase. Retry indiscriminado esconde problemas y duplica tiempos.
🐳 Parte 5: Docker sin dolor
24. Imágenes base chicas, siempre
node:22 pesa ~1GB. node:22-alpine, ~130MB. Ese pull ocurre en CADA job que usa la imagen. Multiplicá la diferencia por miles de jobs al mes y llorá (o festejá, si ya migraste).
25. Armate una imagen de CI con el tooling preinstalado
Si cada job arranca con apt-get install curl jq zip..., estás instalando lo mismo cientos de veces por día. Construí una imagen custom con todo adentro, versionala, y usala como image: default. Es de las optimizaciones con mejor retorno en plataformas grandes.
26. BuildKit con cache de registry
build:
variables:
DOCKER_BUILDKIT: "1"
script:
- docker build
--cache-from $CI_REGISTRY_IMAGE:cache
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE:cache,mode=max
-t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
Las capas estables (base, dependencias) se reutilizan entre pipelines aunque el runner sea efímero. Reducciones del 60-80% en builds con base estable son totalmente normales.
27. Ordená el Dockerfile para maximizar cache de capas
Lo que menos cambia arriba, lo que más cambia abajo:
COPY package*.json ./
RUN npm ci
COPY . . # el código va al final
Si copiás todo el código antes de instalar dependencias, cada commit invalida TODAS las capas.
28. Considerá Kaniko o Buildah si DinD te pesa
Docker-in-Docker implica levantar un daemon aparte en cada job: son 20-30 segundos de puro overhead antes de construir nada, más los dolores de privileged: true. Kaniko y Buildah construyen imágenes sin daemon.
29. Usá el Dependency Proxy de GitLab
Cada docker pull node:22-alpine desde Docker Hub cuenta contra rate limits y viaja por internet. El Dependency Proxy cachea las imágenes upstream dentro de tu GitLab:
image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/node:22-alpine
Pulls más rápidos, cero rate limit, menos dependencia de servicios externos.
📦 Parte 6: Git, artifacts y transferencia de datos
30. Shallow clone
variables:
GIT_DEPTH: "10"
Un repo con años de historia puede pesar gigas. Para CI casi siempre alcanza con los últimos commits. En repos grandes, esto ahorra tiempo en cada job.
31. GIT_STRATEGY: none cuando no necesitás el código
Jobs de notificación, deploys que solo usan artifacts, triggers... ¿para qué clonar el repo? GIT_STRATEGY: none se saltea el clone completo.
32. expire_in en TODOS los artifacts
artifacts:
paths:
- dist/
expire_in: 1 hour
Para artifacts intermedios del pipeline, 1 hora sobra. Para releases, 30 días. Sin expire_in, el storage crece hasta que alguien de infra te escribe con cariño.
33. Descargá solo los artifacts que necesitás
deploy:
needs:
- job: build
artifacts: true
- job: test
artifacts: false # necesito la dependencia, no sus reportes
Por defecto, un job descarga los artifacts de TODAS las stages anteriores. En pipelines grandes eso son cientos de MB inútiles por job.
🏃 Parte 7: Runners (donde se gana o se pierde todo)
34. Dimensioná los runners con datos, no con fe
Runners con poca memoria = OOMKills random y jobs que fallan "misteriosamente" (si corrés runners en Kubernetes, revisá los límites de memoria de los pods ANTES de culpar al código). Runners sobredimensionados = plata quemada. Monitoreá CPU/memoria real de los jobs y ajustá requests/limits en consecuencia.
35. Ruteá jobs con tags según sus necesidades
No todos los jobs necesitan la misma máquina. Un lint corre en cualquier lado; un build multi-arch quiere hierro específico:
build-heavy:
tags: [large-runner]
lint:
tags: [small-runner]
Runners especializados = menos cola, menos desperdicio.
36. Warm pool para autoscaling
Si escalás desde cero, cada pico de demanda paga el costo de arranque de las máquinas (1-3 minutos de cola). Mantené un mínimo de runners calientes en horario laboral y escalá a cero de noche. El balance costo/latencia lo definen tus métricas de cola.
37. Concurrencia del runner: el número que nadie revisa
El concurrent del config.toml limita cuántos jobs corre un runner a la vez. Muy bajo = cola innecesaria con la máquina ociosa. Muy alto = jobs peleándose por CPU y todos lentos. Revisalo junto con las métricas de saturación del host.
🧰 Bonus: mantenibilidad que también es velocidad
38. DRY con extends y !reference
.base-test:
stage: test
cache: !reference [.cache_template, cache]
before_script:
- source .venv/bin/activate
test-unit:
extends: .base-test
script: pytest tests/unit
Un pipeline mantenible se optimiza más rápido. Un YAML de 800 líneas copy-pasteadas no lo toca nadie por miedo.
39. Centralizá con include y CI/CD components
Si tenés muchos repos, no repitas la lógica en cada uno: publicá templates versionados e incluílos. Una mejora en el template = mejora automática en todos los proyectos. (Así funciona cualquier framework de CI interno que se precie 😉).
40. Agendá una revisión trimestral del pipeline
Los pipelines se degradan solos: se agregan jobs, crecen las suites, se acumulan pasos que ya nadie recuerda para qué eran. Una revisión cada 3 meses con las métricas en la mano mantiene todo a raya.
Mi take 🔥
La optimización de CI no es un truco de YAML: es un problema de arquitectura y de cultura. Los equipos con pipelines rápidos no son los que conocen más flags de GitLab; son los que miden, cuestionan cada job ("¿esto necesita correr acá? ¿ahora? ¿siempre?") y tratan el pipeline como un producto más.
Si tuviera que elegir solo cinco cosas para arrancar hoy:
Cache de dependencias con claves por lockfile (#5)
DAG con
needs(#12)workflow:rulespara matar pipelines duplicadas (#19)interruptible: true(#20)Imágenes base chicas (#24)
Con eso solo, la mayoría de los equipos recorta entre 40 y 60% del tiempo de pipeline. El resto es refinamiento.
¿Cuál de estos ya aplicaste? ¿Tenés algún truco que no esté en la lista? Te leo en los comentarios 👇
Tengo fuego. Tengo superpower. 🔥





