Módulo 5: Proyecto — CI/CD Pipeline Completo

4. Caching y optimización

Qué cubre esta cápsula

Tu pipeline está modular y mantenible. Pero tarda 5 minutos por run. Multiplica: 5 min × 50 PRs/mes × 12 meses = 50+ horas/año que el equipo espera mirando spinners. Y eso solo en CI — el CD agrega otros 3 min de build de Docker.

Esta cápsula optimiza el pipeline con técnicas que reducen el tiempo a la mitad o más: actions/cache para dependencias de pip, Docker layer caching con GHA backend, paralelismo correcto, y conditional jobs que skipean lo innecesario. Mismo comportamiento, 2-3× más rápido.

Al terminar, podrás:

  • Configurar caching de pip dependencies para reducir install time de 60s a 5s
  • Implementar Docker layer caching con cache-from/cache-to: type=gha
  • Identificar oportunidades de paralelismo entre jobs
  • Crear conditional jobs que skipean cuando archivos relevantes no cambian
  • Medir la mejora con métricas concretas (antes/después)
  • Evitar las trampas comunes que rompen el cache silenciosamente

El problema: pipelines lentos cuestan productividad

Pipeline lento genera tres problemas compuestos:

1. Tiempo perdido del developer

Push branch → esperar CI 5 min → fix typo en lint → push otra vez → esperar 5 min → ...

Cada iteración cuesta 5 min de wait. Una sesión de trabajo con 4 iteraciones = 20 min perdidos. Multiplicado por todo el equipo, todos los días, son horas.

2. Context switching

Mientras espera CI, el developer hace otras cosas (Slack, otro PR, café). Después tiene que volver al contexto del PR original. Cada switch cuesta 10-15 min de re-engagement según research de productivity.

3. Menos PRs por día

CI rápido → más PRs/día → más feedback → mejor código. CI lento → menos PRs → más bugs llegan a code review tardío.

Objetivo: CI bajo 2 min para que el feedback loop sea rápido.


El modelo mental: cada step tiene un costo cacheable

Piensa cada step de tu pipeline:

Step                              Sin cache    Con cache    Reduction
─────────────────────────────────────────────────────────────────────
actions/checkout                    5s           5s           0%
actions/setup-python                15s          5s           67%
pip install -e ".[dev]"             60s          3s           95%
ruff check .                        5s           5s           0%
black --check .                     3s           3s           0%
mypy app/                           20s          5s (cached)  75%
pytest                              30s          30s          0%
docker build                        180s         15s          92%
docker push                         60s          60s          0%
─────────────────────────────────────────────────────────────────────
TOTAL                               378s         131s         65%

Los steps cacheables son los que descargan/computan algo costoso una vez y reutilizan después.

  • pip install: descarga packages → cacheable
  • ✅ Docker build: compila layers → cacheable
  • ✅ mypy: cache de tipo info → cacheable
  • ❌ Tests: deben correr cada vez → no cacheable

Identifica los caros y cacheables. Cada uno es una optimización.


Step 1: cache de pip dependencies

actions/setup-python tiene caching built-in:

- uses: actions/setup-python@v5
  with:
    python-version: '3.12'
    cache: 'pip'                            # ← activa caching
    cache-dependency-path: 'pyproject.toml' # ← key del cache

Cómo funciona:

  1. Primera vez:

    • pip install descarga packages (60s)
    • GitHub guarda el cache de pip
    • Key del cache: hash de pyproject.toml
  2. Veces siguientes:

    • GitHub detecta pyproject.toml no cambió
    • Restaura cache (5s)
    • pip install reusa packages del cache (3s)

Ahorro: 60s → 8s.

Cuándo invalida cache: cuando pyproject.toml cambia (e.g., agregas una dep). Es exactamente lo que quieres: si la lista de deps cambia, no reusas el cache stale.

Para repos con requirements.txt

cache-dependency-path: 'requirements*.txt'

Glob requirements*.txt matchea requirements.txt, requirements-dev.txt, etc.

Para repos con poetry.lock

- uses: actions/setup-python@v5
  with:
    python-version: '3.12'
    cache: 'poetry'   # poetry-specific cache
- run: poetry install

Cache manual con actions/cache (más control)

Si necesitas caching más complejo:

- name: Cache pip
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: pip-${{ runner.os }}-${{ hashFiles('pyproject.toml') }}
    restore-keys: |
      pip-${{ runner.os }}-

- run: pip install -e ".[dev]"

Key: hash del archivo lockfile. Si cambia, cache miss.

Restore-keys: fallback que permite "partial cache hit" — si exact match falla, usa la versión más reciente con prefix similar.


Step 2: Docker layer caching con GHA backend

Sin cache, docker build tarda 3 min cada vez:

Step 1/8: FROM python:3.12-slim    [downloaded: 30s]
Step 2/8: RUN apt-get install...    [60s]
Step 3/8: COPY pyproject.toml ./    [1s]
Step 4/8: RUN pip install -e .[dev] [90s]
Step 5/8: COPY app/ ./app/          [2s]
Step 6/8: CMD [...]                 [1s]

Cada step rebuildea desde cero. 3 min cada PR.

Con GHA cache, builds incrementales son segundos:

- uses: docker/setup-buildx-action@v3

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ghcr.io/repo:latest
    cache-from: type=gha          # ← lee cache de GHA
    cache-to: type=gha,mode=max   # ← guarda cache a GHA

Cómo funciona:

  1. Primera build:

    • Todos los layers se buildean (3 min)
    • GHA guarda cada layer en cache
  2. Siguiente build (sin cambios en pyproject.toml):

    • Layers FROM y apt-get install: cache hit (5s total)
    • Layer pip install: cache hit (no cambió pyproject.toml)
    • Layer COPY app/: rebuild (cambió código)
    • Layer CMD: cache hit
    • Total: 30s.
  3. Siguiente build (con pyproject.toml modificado):

    • Layers anteriores a COPY pyproject.toml: cache hit
    • Layer pip install: rebuild (deps cambiaron)
    • Layers siguientes: rebuild
    • Total: 90s (mejor que 3 min, peor que 30s).

mode=max es crítico

cache-to: type=gha,mode=max

mode=max guarda todos los layers, incluyendo intermedios.

mode=min (default) guarda solo el layer final. Mucho menos útil.

Siempre mode=max para builds frecuentes.

Order matters: deps antes que código

# ❌ BAD: código antes que deps
COPY . .
RUN pip install -e ".[dev]"

# ✅ GOOD: deps antes que código
COPY pyproject.toml ./
RUN pip install -e ".[dev]"
COPY app/ ./app/

Con order correct, modificar código no invalida el cache de pip install. Solo el último layer (COPY app/) se rebuildea.


Step 3: paralelismo correcto

Jobs que pueden correr en paralelo deben hacerlo. Jobs con dependencia real corren secuencial.

Antipattern (secuencial innecesario):

jobs:
  test:
    runs-on: ubuntu-latest
    steps: [...]

  quality:
    needs: test   # ❌ ¿por qué quality espera a test?
    runs-on: ubuntu-latest
    steps: [...]

Si quality no depende de test (lint y test son independientes), no usar needs:. Corren paralelos por default.

Patrón correcto:

jobs:
  test:
    runs-on: ubuntu-latest
    steps: [...]

  quality:
    runs-on: ubuntu-latest   # corre en paralelo a test
    steps: [...]

  ci-success:
    needs: [test, quality]    # ahora SÍ depende de ambos
    runs-on: ubuntu-latest
    steps: [...]

Ahorro: si test tarda 2 min y quality 1 min:

  • Sequential: 2 + 1 = 3 min
  • Parallel: max(2, 1) = 2 min

33% menos tiempo. En pipelines con más jobs, el ahorro crece.

Matrix paralelo

Matrix builds corren en paralelo por default:

strategy:
  matrix:
    python-version: ['3.10', '3.11', '3.12']

3 jobs corren simultáneamente, cada uno con una versión.

Excepción: fail-fast: true (default) cancela los otros si uno falla. Para CI normal, quieres fail-fast: false para ver todos los failures:

strategy:
  fail-fast: false   # ← muestra todos los failures
  matrix:
    python-version: ['3.10', '3.11', '3.12']

Step 4: conditional jobs (skip si no relevante)

Si tu PR solo modifica documentación, ¿necesitas correr tests? No.

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      python-changed: ${{ steps.filter.outputs.python }}
      docs-changed: ${{ steps.filter.outputs.docs }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            python:
              - 'app/**'
              - 'tests/**'
              - 'pyproject.toml'
              - 'alembic/**'
            docs:
              - 'docs/**'
              - 'README.md'
              - '**.md'

  test:
    needs: detect-changes
    if: needs.detect-changes.outputs.python-changed == 'true'
    runs-on: ubuntu-latest
    steps: [...]

  quality:
    needs: detect-changes
    if: needs.detect-changes.outputs.python-changed == 'true'
    runs-on: ubuntu-latest
    steps: [...]

Cómo funciona:

  • PR cambia README.mddocs-changed: true, python-changed: false
  • test y quality se skipean
  • ci-success valida que ambos pasaron... pero skipped no es "success"

Gotcha: if: skipped o if: success() || skipped():

ci-success:
  needs: [detect-changes, test, quality]
  if: always()
  runs-on: ubuntu-latest
  steps:
    - run: |
        # Solo fallar si test/quality CORRIÓ y falló
        # Si skipped (porque no había cambios de Python), está OK
        if [[ "${{ needs.test.result }}" == "failure" || "${{ needs.quality.result }}" == "failure" ]]; then
          exit 1
        fi
        echo "CI OK or skipped"

Trade-off: complejidad del workflow vs ahorro de tiempo. Para repos donde docs y código cambian con frecuencia similar, el ahorro vale.


Step 5: parallel testing con pytest-xdist

Si tu suite tarda 30 segundos secuencial, puedes bajarla a 10s con paralelismo de tests:

- run: pip install pytest-xdist

- run: pytest -n auto   # usa todos los cores disponibles

-n auto distribuye tests entre N processes según CPUs del runner (típicamente 2-4 en GHA gratuito).

Trade-off:

  • ✅ Reduce tiempo de tests en 50-70%
  • ❌ Tests que comparten state (DB, files) pueden tener race conditions
  • ❌ Output de tests fallidos puede ser confuso

Cómo manejar race conditions:

# tests/conftest.py
import pytest

@pytest.fixture
def isolated_db(tmp_path, worker_id):
    """Cada worker tiene su propia DB."""
    db_path = tmp_path / f"test_{worker_id}.db"
    # ... setup
    return db_path

worker_id viene de pytest-xdist y identifica qué worker está corriendo el test.


Step 6: combinar todo

_reusable-tests.yml optimizado:

name: Reusable tests

on:
  workflow_call:
    inputs:
      python-versions:
        required: false
        default: '["3.10", "3.11", "3.12"]'
        type: string

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python-version: ${{ fromJSON(inputs.python-versions) }}

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'                            # ← pip cache
          cache-dependency-path: 'pyproject.toml'

      - run: |
          python -m pip install --upgrade pip
          pip install -e ".[dev]"

      - name: Cache mypy
        uses: actions/cache@v4
        with:
          path: .mypy_cache
          key: mypy-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('app/**/*.py') }}
          restore-keys: |
            mypy-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }}-
            mypy-${{ matrix.python-version }}-

      - run: pytest -n auto --cov=app --cov-fail-under=80

Optimizaciones combinadas:

  1. ✅ Pip cache (setup-python)
  2. ✅ Mypy cache (actions/cache)
  3. ✅ pytest paralelo (-n auto)
  4. ✅ Matrix corre en paralelo (3 versions Python)

Tiempo total:

  • Sin optimizaciones: 5 min
  • Con todas las optimizaciones: 2 min
  • 2.5× más rápido.

Medir la mejora

Antes/después con datos concretos:

- name: Measure CI time
  if: always()
  run: |
    START_TIME="${{ github.event.workflow_run.created_at }}"
    END_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    DURATION=$(($(date -d "$END_TIME" +%s) - $(date -d "$START_TIME" +%s)))
    echo "CI duration: ${DURATION}s"

O usar GitHub Actions API:

gh api "/repos/USER/REPO/actions/runs?per_page=20" | jq -r '
  .workflow_runs[] |
  {
    run_id: .id,
    name: .name,
    duration_seconds: (
      ((.updated_at | sub("Z"; "+00:00") | fromdateiso8601) -
       (.run_started_at | sub("Z"; "+00:00") | fromdateiso8601))
    ),
    conclusion: .conclusion
  }
'

Track over time:

  • Semana 1 (sin cache): media 5 min
  • Semana 2 (con pip cache): media 3 min
  • Semana 3 (con Docker cache): media 2.5 min
  • Semana 4 (con paralelismo): media 1.5 min

Visualiza en un gráfico. Vas a ver el impacto compuesto de cada optimización.


Trampas comunes

1. Cache key incluye archivos que cambian seguido

key: pip-${{ hashFiles('**/*.py') }}   # ❌

Cualquier cambio en código Python invalida el cache, aunque las deps no hayan cambiado.

Cómo manejar: el key debe incluir solo archivos que invalidan las dependencies:

key: pip-${{ hashFiles('pyproject.toml', 'poetry.lock', 'requirements*.txt') }}

2. cache-to: type=gha sin mode=max

cache-to: type=gha   # ❌ default es mode=min

Solo el final layer se cachea. Los intermedios (los caros) no.

Cómo manejar:

cache-to: type=gha,mode=max

3. Order incorrecto del Dockerfile

# ❌
COPY . .
RUN pip install -e ".[dev]"

Cada cambio en cualquier archivo invalida el RUN pip install.

Cómo manejar:

# ✅
COPY pyproject.toml ./
RUN pip install -e ".[dev]"
COPY app/ ./app/

Cambios en código no invalidan el layer de deps.

4. Cache size limit hit

GitHub Actions tiene 10 GB de cache por repo. Si excedes, GitHub evicta los caches viejos automáticamente.

Síntomas:

  • Cache hits inconsistentes
  • "Cache size exceeds limit" warnings en logs

Cómo manejar:

  • Borrar caches viejos: Settings → Actions → Caches
  • Reducir cache size: cachear solo lo realmente caro (no todo el venv)
  • Considerar self-hosted runners con cache propio

5. Paralelismo que rompe tests

# tests/test_db.py
def test_create_user():
    user = User.create("alice")
    assert User.count() == 1   # asume DB vacía

def test_create_another():
    user = User.create("bob")
    assert User.count() == 1   # asume DB vacía OTRA VEZ

Sin paralelismo, tests corren en orden y cada uno limpia la DB. Con pytest -n auto, corren en paralelo en distintos workers — la DB no está vacía.

Cómo manejar:

  • Tests deben ser independientes (no asumir state previo)
  • Fixtures con cleanup (yield + cleanup en conftest.py)
  • Separar tests con DB de tests unitarios y solo paralelizar los unitarios

6. restore-keys sin fallback

key: pip-v2-${{ hashFiles('pyproject.toml') }}
# falta restore-keys

Si pyproject.toml cambió (hash diferente), no hay fallback parcial. Cache miss completo.

Cómo manejar:

key: pip-v2-${{ hashFiles('pyproject.toml') }}
restore-keys: |
  pip-v2-
  pip-

Si exact match falla, busca prefix match. Cache "mostly hit" mejor que cache miss completo.


Caso desarrollado: optimización compuesta

Pipeline antes:

test (3 versions):   5 min
quality:             3 min
build-and-push:      5 min
deploy:              3 min
─────────────────────────
Total:               16 min (sequencial)
                     11 min (test + quality en paralelo)

Aplicando optimizaciones:

Iteración 1: pip cache

  • Setup-python con cache: pip install pasa de 60s a 5s
  • Test: 5 min → 3 min
  • Quality: 3 min → 2 min

Total: 11 min → 8 min.

Iteración 2: Docker GHA cache

  • Build con cache-from/cache-to=gha,mode=max
  • Build pasa de 5 min a 1 min en runs subsecuentes

Total: 8 min → 4 min.

Iteración 3: pytest paralelo

  • pytest -n auto en runners con 4 cores
  • Test job: 3 min → 1.5 min

Total: 4 min → 3 min.

Iteración 4: mypy cache

  • actions/cache para .mypy_cache
  • mypy: 20s → 5s

Total: 3 min → 2:45 min.

Conclusión: de 16 min sequencial a 2:45 con optimizaciones compuestas. 5.8× más rápido.


Ejercicio: optimiza tu pipeline

  1. Baseline: mide el tiempo actual de tu pipeline. Anota el número.

  2. Iteración 1 — pip cache:

    • Agrega cache: 'pip' a tu setup-python
    • Push, mide. ¿Cuánto bajó?
  3. Iteración 2 — Docker cache:

    • Verifica que tu Dockerfile tiene order correct (deps antes que código)
    • Agrega cache-from: type=gha, cache-to: type=gha,mode=max
    • Push, mide.
  4. Iteración 3 — paralelismo de jobs:

    • Quita needs: innecesarios
    • Verifica que jobs independientes corren paralelos
    • Push, mide.
  5. Iteración 4 — pytest paralelo (opcional):

    • Instala pytest-xdist
    • pytest -n auto
    • Asegúrate que no tienes tests con shared state que rompa con paralelismo
  6. Resultado final: total time vs baseline. Goal: 50%+ reduction.


Auto-verificación

1. ¿Por qué Docker cache requiere order correcto del Dockerfile?

Docker cachea layer por layer. Cada RUN, COPY, FROM es un layer.

El cache de un layer se invalida si:

  1. El comando del layer cambió
  2. Cualquier layer previo cambió

Implicación: si modificas algo "arriba" en el Dockerfile, todo "abajo" se rebuildea, aunque no haya cambiado.

Ejemplo malo:

FROM python:3.12-slim
WORKDIR /app

COPY . .                          # ← cambia con CADA commit
RUN pip install -e ".[dev]"       # ← se invalida con cada commit
CMD ["uvicorn", "app.main:app"]

Cualquier cambio en app/main.py invalida pip install. Build full cada vez.

Ejemplo bueno:

FROM python:3.12-slim
WORKDIR /app

COPY pyproject.toml ./            # ← cambia con cada nueva dep
RUN pip install -e ".[dev]"       # ← solo se invalida si pyproject.toml cambia
COPY app/ ./app/                  # ← cambia con cada commit, pero ya no afecta pip
CMD ["uvicorn", "app.main:app"]

Cambio en app/main.py solo invalida COPY app/ y CMD. Build solo cambia 2 layers, no todos.

Regla mnemónica: "ordenar layers de menos a más cambiante". Lo que cambia raramente (deps system, deps Python) arriba. Lo que cambia con cada PR (código) abajo.

2. Tu pipeline tarda 5 min pero NO ves mejora con caching. ¿Cómo diagnosticas?

Posibles causas:

1. Cache hit/miss en logs:

GitHub Actions imprime mensajes:

Cache hit: <key>     ← bien
Cache not found     ← cache miss, primera vez o key cambió

Diagnóstico: revisar logs del step de cache. ¿Dice "hit" o "miss"?

Si dice "miss" siempre:

  • Cache key es inestable (incluye algo que cambia siempre)
  • O nunca se cacheó (primera run o limit hit)

2. Cache key incluye demasiado:

key: pip-${{ hashFiles('**/*.py') }}   # ❌

**/*.py incluye TODO el código. Cualquier cambio invalida cache.

Diagnóstico: verificar que el key incluye SOLO archivos que afectan las dependencies (pyproject.toml, poetry.lock).

3. Cache size limit:

GitHub: 10 GB por repo. Si superas, los caches viejos se evictan.

Diagnóstico: Settings → Actions → Caches. Ver qué hay. Borrar viejos.

4. Restore-keys mal configurados:

key: pip-v1-${{ hashFiles('pyproject.toml') }}

Si pyproject.toml cambia, cache miss completo (sin fallback).

Diagnóstico: agregar restore-keys con prefijos progresivos para "partial hits".

5. La operación que esperabas cachear no usa el cache:

- uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: pip-${{ hashFiles('pyproject.toml') }}

- run: pip install --no-cache-dir -e ".[dev]"   # ❌ desactiva pip cache

--no-cache-dir le dice a pip que NO use cache. Tu actions/cache es inútil.

Diagnóstico: revisar los comandos de install. Asegurarse que usan el cache que cacheaste.

6. El step de cache corre DESPUÉS del install:

- run: pip install -e ".[dev]"   # install corre primero
- uses: actions/cache@v4         # cache se guarda DESPUÉS
  with:
    path: ~/.cache/pip
    key: pip-...

El cache se guarda pero no se restaura en el run actual (porque ya pasó el install).

Diagnóstico: usar actions/setup-python con cache: pip (que maneja el order automáticamente) o actions/cache ANTES del install.

Estrategia general: agrega set -x en bash steps para verbose, revisa los logs paso por paso, y comparar runs con cache hit vs cache miss.


Resumen y siguiente paso

  • Caching de pip y Docker reduce CI de 5 min a 2 min sin cambiar funcionalidad
  • actions/setup-python con cache: 'pip' es el quick win más fácil
  • Docker GHA cache requiere order correct del Dockerfile (deps antes que código)
  • Paralelismo correcto entre jobs independientes ahorra 30%+ tiempo
  • pytest-xdist paraleliza tests dentro del job (con cuidado por race conditions)
  • Conditional jobs skipean trabajo cuando archivos relevantes no cambiaron
  • Cache hits/misses son visibles en logs — diagnostica con esa info

Puente al próximo paso: Tu pipeline es rápido. Pero usa versiones específicas de actions (@v4, @v5) y deps de Python con versiones pinneadas. Eventualmente quedan obsoletas o tienen vulnerabilidades. En la cápsula 05 vas a configurar Dependabot para que actualice todo automáticamente con PRs semanales — sin trabajo manual.


Recursos

  1. Caching dependencies to speed up workflows — referencia oficial.
  2. docker/build-push-action GHA cache — Docker oficial.
  3. pytest-xdist documentation — testing paralelo.
  4. paths-filter action — para conditional jobs.
  5. GitHub Actions billing — entender costos de runners.

Cápsula 04 de 08 — Módulo 5 — CI/CD for Python Backend Guide