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

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](https://github.com/mvisonneau/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

```yaml
.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

```yaml
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

```yaml
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:

```yaml
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`

```yaml
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

```yaml
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:

```yaml
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?

```yaml
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:

```yaml
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

```yaml
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

```yaml
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:

```dockerfile
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:

```yaml
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

```yaml
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

```yaml
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

```yaml
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:

```yaml
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`

```yaml
.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:

1.  **Cache de dependencias** con claves por lockfile (#5)
    
2.  **DAG con** `needs` (#12)
    
3.  `workflow:rules` para matar pipelines duplicadas (#19)
    
4.  `interruptible: true` (#20)
    
5.  **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.* 🔥
