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:
-
Primera vez:
pip installdescarga packages (60s)- GitHub guarda el cache de pip
- Key del cache: hash de
pyproject.toml
-
Veces siguientes:
- GitHub detecta
pyproject.tomlno cambió - Restaura cache (5s)
pip installreusa packages del cache (3s)
- GitHub detecta
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:
-
Primera build:
- Todos los layers se buildean (3 min)
- GHA guarda cada layer en cache
-
Siguiente build (sin cambios en
pyproject.toml):- Layers
FROMyapt-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.
- Layers
-
Siguiente build (con
pyproject.tomlmodificado):- 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).
- Layers anteriores a
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.md→docs-changed: true,python-changed: false testyqualityse skipeanci-successvalida 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:
- ✅ Pip cache (setup-python)
- ✅ Mypy cache (actions/cache)
- ✅ pytest paralelo (-n auto)
- ✅ 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 enconftest.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
-
Baseline: mide el tiempo actual de tu pipeline. Anota el número.
-
Iteración 1 — pip cache:
- Agrega
cache: 'pip'a tusetup-python - Push, mide. ¿Cuánto bajó?
- Agrega
-
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.
-
Iteración 3 — paralelismo de jobs:
- Quita
needs:innecesarios - Verifica que jobs independientes corren paralelos
- Push, mide.
- Quita
-
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
-
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:
- El comando del layer cambió
- 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-pythonconcache: '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
- Caching dependencies to speed up workflows — referencia oficial.
- docker/build-push-action GHA cache — Docker oficial.
- pytest-xdist documentation — testing paralelo.
- paths-filter action — para conditional jobs.
- GitHub Actions billing — entender costos de runners.
Cápsula 04 de 08 — Módulo 5 — CI/CD for Python Backend Guide