Esta es la historia de muchas horas de trabajo en un hoyo continuo tratando de elegir optimizaciones entre package managers, caches paralelismo, optimizaciones de PostgreSQL, advisory locks, y el golpe de realidad de darme cuenta que el cuello de botella no era nada de lo que había estado optimizando, si no algo que no había analizado.
El punto de partida
El codebase: un backend FastAPI (~50 routers, ~45 modelos SQLAlchemy)
con 1826 tests, desplegado vía GitHub Actions a AWS ECS Fargate en
arm64. El CI corre en un self-hosted spot runner (4× concurrency,
Graviton).
En un inicio comenzamos con un paralelismo tradicional, nada del otro mundo, solo dos shards vía pytest-split.
# .github/workflows/deploy-backend.yml — inicial
- run: cd backend && pip install --cache-dir "$RUNNER_TEMP/pip-cache" -r requirements-dev.txt
- name: Tests (shard ${{ matrix.shard }}/2)
run: |
cd backend
pytest tests/ \
--splits 2 --group ${{ matrix.shard }} \
--cov=app --cov-report= \
-v
docker build estándar. Y usando pip para todo.
# backend/Dockerfile — inicial
FROM python:3.11-slim AS base
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
Intento no. 1: instalar dependencias más rápido con uv
uv es el reemplazo más sencillo que existe de pip creado por Astral, normalmente de 10x hasta 100× más rápido para resolver e instalar paquetes. La migración es bastante sencilla ya que son dos
líneas en el Dockerfile y listo:
# Pin vía la imagen multi-stage oficial para que el binario sea reproducible
COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /uvx /usr/local/bin/
RUN uv pip install --system --no-cache-dir -r requirements.txt
requirements.txt y requirements-dev.txt quedan exactamente iguales, uv pip los lee nativo. No hay que migrar forzosamente a pyproject.toml.
Y en CI, le implementamos la action correspondiente:
- uses: astral-sh/setup-uv@v6
with:
version: "0.5.11"
enable-cache: true
cache-suffix: "shard-${{ matrix.shard }}"
- run: cd backend && uv pip install --system -r requirements-dev.txt
En este caso el cache-suffix por shard replica el workaround del --cache-dir por shard que teníamos con pip — sin él, los dos shards que caen en el mismo runner self-hosted se pelean por el mismo tarball y uno de los dos se muere con tar exit code 2.
La comparativa, en números:
$ time uv pip install -r requirements.txt
...
uv pip install -r requirements.txt 1.23s user 1.59s system 51% cpu 5.509 total
5.5s contra ~60s con pip y casi 80s en el runner.
Intento no. 2: BuildKit cache mounts en el Dockerfile
El layer cache de Docker solo ayuda cuando el COPY no invalida las
layers de abajo. Cualquier cambio en requirements.txt re-construye
todo lo que sigue. Los BuildKit cache mounts ayudan a persistir el contenido entre builds sin importar la invalidación de layers:
# backend/Dockerfile — con cache mounts
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends \
libpq-dev gcc curl
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --system -r requirements.txt
Algo importante a mencionar:
-
Quitar el
rm -rf /var/lib/apt/lists. Ya que servía para reducir el tamaño de la imagen, pero con cache mounts BuildKit es dueño de esos paths y los limpia entre builds. -
sharing=lockedserializa lecturas concurrentes. Sin eso, dos builds en paralelo en el mismo runner pueden corromper el caché.
El runner que en este caso es self-hosted trae el builder legacy de Docker por default, y el primer push después del commit de cache mounts se murió con:
the --mount option requires BuildKit. Refer to
https://docs.docker.com/go/buildkit/ to learn how to build images with
BuildKit enabled
Lo arreglamos, de lo más fácil: instalar buildx y alias de docker build a docker buildx build:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
install: true
El install: true es la opción crítica. Sin eso, docker build sigue usando el builder legacy.
Buildx no carga al daemon local por default, así que el step de
docker push que ya teníamos también dejó de funcionar. Cambio a
--push directo:
- run: |
cd backend
docker build \
--cache-from $IMAGE:latest \
--cache-to type=inline \
-t $IMAGE:sha-$TAG \
-t $IMAGE:latest \
--push \
.
El --cache-to type=inline mete la metadata del layer cache dentro
de la imagen, cojn estoel --cache-from del siguiente build lo jala de regreso el ECR.
El primer build todavía paga el costo de instalación de las dependencias y paquetes; los builds siguientes con los mismos requirements se lo brincan. Pasando de ~40s a ~10s en cache hits.
Intento no. 3: la trampa de pytest-xdist
El instinto, pensar que entre mas shards, podemos ahorrarnos mas tiempo, es decir 2 shards × 4 workers = 8 procesos en paralelo.
# backend/requirements-dev.txt
pytest-xdist==3.6.1
# .github/workflows/deploy-backend.yml
pytest tests/ \
--splits 2 --group ${{ matrix.shard }} \
-n 4 --dist worksteal \
--cov=app --cov-report= \
-v
Aquí es donde empieza el dilema.
Trampa 1: DROP SCHEMA entre workers
El conftest remueve y recrea el schema al inicio de cada session:
@pytest_asyncio.fixture(scope="session", autouse=True)
async def setup_db():
async with test_engine.begin() as conn:
await conn.execute(sa.text("DROP SCHEMA public CASCADE"))
await conn.execute(sa.text("CREATE SCHEMA public"))
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
scope="session" significa una vez por session de pytest. Con
pytest-xdist, cada worker es su propia session.
Los cuatro workers apuntando a la misma DB myapp_test se la pasan removiendo el schema de los demás a media corrida.
Fix: una DB por worker, con sufijo de PYTEST_XDIST_WORKER:
def _suffix_dburl(url: str, worker: str) -> str:
if "?" in url:
url_part, _, query = url.partition("?")
query = f"?{query}"
else:
url_part, query = url, ""
if "/" not in url_part.split("@", 1)[-1]:
return url
prefix, dbname = url_part.rsplit("/", 1)
return f"{prefix}/{dbname}_{worker}{query}"
_XDIST_WORKER = os.environ.get("PYTEST_XDIST_WORKER")
if _XDIST_WORKER:
for key in ("DATABASE_URL", "TEST_DATABASE_URL"):
if val := os.environ.get(key):
os.environ[key] = _suffix_dburl(val, _XDIST_WORKER)
El worker gw0 agarra myapp_test_gw0, gw1 agarra _gw1, y así de manera secuencial.
Trampa 2: max_locks_per_transaction
Primera corrida con -n auto (10 workers en el runner self-hosted):
================== 31 passed, 11 warnings, 8 errors in 14.73s ==================
DROP SCHEMA CASCADE sobre ~50 tablas toma un relation-level lock por cada una.
10 workers × 50 = 500 locks.
El default de PostgreSQL para max_locks_per_transaction es 64
Como lo arreglamos, bajamos el limite/cap a 4 workers por shard en vez de auto. 4 × 2 shards = 8 procesos paralelos.
Trampa 3: la divergencia del DATABASE_URL
Después de limitar a los workers, un test empezó a fallar en CI:
test_comp_expiry_worker_skips_stripe_managed
sqlalchemy.exc.ProgrammingError: relation "subscriptions" does not
exist
¿Por qué? El test invoca un background worker:
async def test_comp_expiry_worker_skips_stripe_managed(client, db, admin):
member = await _seed_user(db, tier="TEST")
sub = await _seed_sub(db, member, tier="TEST", ...)
await db.commit()
from app.workers.comp_expiry_worker import expire_comp_subscriptions
await expire_comp_subscriptions() # ← abre su propio AsyncSessionLocal
Fix: alinear DATABASE_URL con TEST_DATABASE_URL antes de que
app.main importe lo que sea:
# backend/tests/conftest.py — top del archivo, antes de importar app.main
_test_db = os.environ.get("TEST_DATABASE_URL")
if _test_db:
os.environ["DATABASE_URL"] = _test_db
_XDIST_WORKER = os.environ.get("PYTEST_XDIST_WORKER")
if _XDIST_WORKER:
for key in ("DATABASE_URL", "TEST_DATABASE_URL"):
if val := os.environ.get(key):
os.environ[key] = _suffix_dburl(val, _XDIST_WORKER)
from app.main import app # ← engine construido con la URL correcta desde el inicio
Intento no. 4: template database de PostgreSQL
Cada worker todavía usa un DROP SCHEMA + CREATE TABLE × 50 al
inicio de la session.
En el runner ARM eso son ~5 segundos por worker.
PostgreSQL tiene un truco: CREATE DATABASE ... TEMPLATE
clona una DB vía copia a nivel de archivo en ~100ms en lugar de
ejecutar todo el SQL.
Construyes el schema una vez en una DB template dedicada, y luego cada worker la clona:
# backend/tests/conftest.py
TEMPLATE_DB_NAME = "myapp_test_template"
_TEMPLATE_LOCK_ID = 7321456789012345 # int arbitrario
async def _ensure_template_db(admin_url: str, template_url: str) -> None:
admin_engine = create_async_engine(
admin_url, isolation_level="AUTOCOMMIT", poolclass=NullPool
)
try:
async with admin_engine.connect() as conn:
exists = (await conn.execute(
sa.text("SELECT 1 FROM pg_database WHERE datname = :n"),
{"n": TEMPLATE_DB_NAME},
)).scalar_one_or_none()
if not exists:
await conn.execute(
sa.text(f'CREATE DATABASE "{TEMPLATE_DB_NAME}"')
)
finally:
await admin_engine.dispose()
tmpl_engine = create_async_engine(template_url, poolclass=NullPool)
try:
async with tmpl_engine.connect() as conn:
has_schema = (await conn.execute(sa.text(
"SELECT 1 FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_name = 'users' "
"LIMIT 1"
))).scalar_one_or_none()
if not has_schema:
async with tmpl_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
finally:
await tmpl_engine.dispose()
async def _clone_template_to(admin_url: str, dbname: str) -> None:
admin_engine = create_async_engine(
admin_url, isolation_level="AUTOCOMMIT", poolclass=NullPool
)
try:
async with admin_engine.connect() as conn:
# Matar conexiones stale para que el DROP no se bloquee
await conn.execute(sa.text(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
"WHERE datname = :n AND pid <> pg_backend_pid()"
), {"n": dbname})
await conn.execute(sa.text(f'DROP DATABASE IF EXISTS "{dbname}"'))
await conn.execute(sa.text(
f'CREATE DATABASE "{dbname}" TEMPLATE "{TEMPLATE_DB_NAME}"'
))
finally:
await admin_engine.dispose()
Trampa 4: filelock se cuelga
Los workers se pelean por crear el template. El primer intento usaba
filelock:
# No hagas esto
import filelock
lock = filelock.FileLock("/tmp/myapp_test_template.lock", timeout=120)
await asyncio.to_thread(lock.acquire)
Los workers consistentemente llegan a el timeout de 120s.
Con un cambio a un advisory lock de PostgreSQL, se soluciona. Es el mismo recurso compartido que ya necesitamos, se auto libera al cerrar la conexión (así que un worker que truene no puede dejar el lock huérfano como pasa con file locks):
async def _setup_db_via_template() -> None:
split = _split_db_url(TEST_DATABASE_URL)
if split is None:
return
prefix, dbname = split
admin_url = f"{prefix}/postgres"
template_url = f"{prefix}/{TEMPLATE_DB_NAME}"
admin_engine = create_async_engine(
admin_url, isolation_level="AUTOCOMMIT", poolclass=NullPool
)
try:
async with admin_engine.connect() as conn:
# Bloquea hasta conseguirlo; se auto-libera al cerrar la conexión
await conn.execute(
sa.text("SELECT pg_advisory_lock(:id)"),
{"id": _TEMPLATE_LOCK_ID},
)
try:
await _ensure_template_db(admin_url, template_url)
finally:
await conn.execute(
sa.text("SELECT pg_advisory_unlock(:id)"),
{"id": _TEMPLATE_LOCK_ID},
)
finally:
await admin_engine.dispose()
await _clone_template_to(admin_url, dbname)
El primer worker que agarra el lock construye el template (~5s); los demás esperan y luego clonan en ~100ms cada uno.
Después de todo esto — DBs por worker, alineación de DATABASE_URL,
template clones, advisory locks — corriendo un smoke local de 7
archivos:
88 passed, 5 warnings in 12.28s
Bajó de ~2 minutos con el setup roto inicial de xdist.
Intento no. 5: la medición que rompió el supuesto
El wall time por shard seguía como en 7 minutos.
El CI total en 11m una mejora modesta sobre el baseline, no la bajada dramática que sugería el smoke local.
Hora de medir en serio. Agregué un step diagnóstico que corre una vez
y reporta tiempos:
- name: Diagnose pytest startup cost
if: matrix.shard == 1
run: |
cd backend
echo "::group::A — solo collection de pytest, sin coverage"
time pytest tests/ --co -q --no-header --no-cov
echo "::endgroup::"
echo "::group::B — solo collection de pytest CON coverage"
time pytest tests/ --co -q --no-header --cov=app --cov-report=
echo "::endgroup::"
echo "::group::C — corrida chiquita serial, sin coverage, sin xdist"
time pytest tests/test_critical.py -q --no-header --no-cov
echo "::endgroup::"
Los resultados en el runner ARM self-hosted fueron:
A — solo collection, sin coverage real 0m18.909s
B — solo collection CON coverage real 0m23.810s
C — corrida chica serial, sin nada real 0m21.481s
La observación clave, hacer collection de 1826 tests y
correr un solo archivo chico sin coverage tardan lo mismo.
O sea, el costo no es la collection. No es coverage (solo 5s de
diferencia).
No es la ejecución de los tests. Es el startup de pytest + el import del conftest.
Específicamente el from app.main import app en el conftest que jala ~45 modelos, ~50 routers, middleware, settings — todo de un jalón. Veinte segundos de cold-import en este runner.
Cada vez.
Con xdist, cada uno de los 4 workers paga este costo de 20s
independiente. Spawn en paralelo, pero el runner solo tiene ciertos
cores; los cold imports de modelos pydantic y mappers de SQLAlchemy se pelean por CPU.
Ahí estaban los ~80s perdidos.
Round 6: soltar xdist, irse a 4 shards
Si cada proceso de pytest usa 20s fijos de startup, la optimización
más barata es usarlo menos veces.
2 shards × 4 workers de xdist = 10 startups de pytest (2 controllers + 8 workers).
4 shards × 1 proceso serial = 4 startups de pytest.
test:
runs-on: ${{ inputs.runner || 'self-hosted' }}
needs: lint
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
services:
postgres:
image: postgres:15
env: { POSTGRES_DB: myapp_test, POSTGRES_USER: appuser, POSTGRES_PASSWORD: testpass }
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
ports: ["5432"]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with: { python-version: "3.11" }
- uses: astral-sh/setup-uv@v6
with:
version: "0.5.11"
enable-cache: true
cache-suffix: "shard-${{ matrix.shard }}"
- run: cd backend && uv pip install --system -r requirements-dev.txt
- name: Tests (shard ${{ matrix.shard }}/4)
env:
DATABASE_URL: postgresql://someappuser:sometestpass@localhost:${{ job.services.postgres.ports[5432] }}/myapp_test
TEST_DATABASE_URL: postgresql+asyncpg://someappuser:sometestpass@localhost:${{ job.services.postgres.ports[5432] }}/myapp_test
SECRET_KEY: ci-test-secret-key-32bytes-minimum-length
ENV: test
COVERAGE_FILE: .coverage.${{ matrix.shard }}
run: |
cd backend
pytest tests/ \
--splits 4 --group ${{ matrix.shard }} \
--cov=app --cov-report= \
-q --no-header
Cada shard corre ~450 tests en serial con un solo proceso depytest.
Sin fan-out de xdist, sin re-import de conftest por worker, sin contención de CPU en los cold imports.
El runner self-hosted anuncia 4x de concurrencia disponible, así que los cuatro shards corren en paralelo.
El coverage se extiende a cuatro archivos:
coverage:
needs: test
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with: { python-version: "3.11" }
- uses: astral-sh/setup-uv@v6
with: { version: "0.5.11" }
- run: uv pip install --system coverage==7.6.1
- uses: actions/download-artifact@v5
with:
pattern: coverage-*
path: backend/
merge-multiple: true
- run: |
cd backend
coverage combine .coverage.1 .coverage.2 .coverage.3 .coverage.4
coverage report --fail-under=60
También quité el -v y lo cambié por -q --no-header. Con xdist, el -v bufferea el output por worker hasta que termina un test, -q tiene output instantáneo y muestra la salida de inmediato.
El resultado
Corrida real del CI después del push:
✓ lint in 1m6s
✓ test (1) in 6m46s
✓ test (2) in 6m44s
✓ test (3) in 6m57s
✓ test (4) in 6m57s
✓ coverage in 10s
✓ build-and-deploy in 1m53s
Timeline del test step del shard 1:
16:31:44 inicio del step
16:32:57 [pytest-split] Running group 1/4 ← 1m13s adentro
16:33:25 ........... [ 15%] ← primer punto a 1m41s
16:34:18 ........... [ 45%]
16:37:04 ........... [ 91%]
16:37:47 ........... [100%] 472 passed in 5m21s
Tiempo al primer output: 1m13s vs 2m37s antes.
Más o menos a la mitad.
CI total: 10m16s vs ~20m del baseline antes de cualquier optimización.
Otras cosas que probe y que definitivamente no ayudaron
-
Precompile de pyc (
python -m compileall). Medición local: 13.0s en frío vs 12.6s en caliente. -
pytest-xdist --dist workstealestá bueno cuando cada worker tiene un costo de setup parecido. Cuando el setup es ~20s y los tests son mayormente rápidos, el impuesto de startup por worker se come la ganancia de paralelismo. -
filelockpara serializar entre procesos. No me serializaba bien los workers en mi setup. Me cambié a advisory locks de PG. -
El flag
-v. Causaba 2 minutos de buffering de output bajo xdist sin beneficio en performance.
Qué sí ayudaría a futuro
-
Mejorar el conftest. El
from app.main import appes el costo más grande en mi caso. La app importa cada router, cada modelo, cada service al arranque. Partirla (lazy router registration, o romper el import monolítico) bajaría a 20s de startup. -
Una segunda pasada en los shards.
pytest-splitbalancea por duración. Si un shard consistentemente va 30s atrás, re-balancea:
pytest --splits 4 --group 1 --store-durations
comitea un .test_durations nuevo contra el que los futuros runs
se balancean.
- Sacar coverage del hot path. Coverage solo agregó ~5s en ARM en nuestro benchmark, pero a ~5s × 4 shards = 20s ahorrados. Trade-off: o lo aceptas en el job de tests o agregas un job de coverage no-paralelo aparte. Nosotros lo dejamos en el path.
Lecciones
- Mide antes de optimizar. Varias horas de trabajo en xdist, template DBs y BuildKit fueron útiles, pero el verdadero unlock vino de un step diagnóstico de 30 segundos que me dijo que el cuello de botella era el startup de pytest, no nada de lo que llevaba atacando.
- El pytest más rápido es uno que no inicias dos veces. Cada invocación de pytest paga un costo de startup fijo. Con un conftest pesado, ese costo domina todo lo demás. Más paralelismo = más startups = más costo. Menos shards más grandes le ganan a más shards chiquitos pasado un umbral.
-
pytest-xdistno es gratis. Funciona bien cuando el costo por test >> el costo de startup. Cuando el startup es 20s y los tests son de 500ms, la ecuación se invierte. -
Recursos por worker necesitan aislamiento por worker. El bug sutil fue que los background workers abrían su propio
AsyncSessionLocalapuntando a la DB equivocada. El fix no estaba en la aplicación — estaba en el conftest de tests, alineando las env vars antes de que la app importara nada. -
PostgreSQL tiene las primitivas. Advisory locks para sincronizar entre procesos,
CREATE DATABASE ... TEMPLATEpara bootstrap de schema. Las dos me salvaron de inventar mecanismos más débiles encima. -
Los BuildKit cache mounts siguen sub-utilizados. Dos líneas en un Dockerfile (
--mount=type=cachepara los cachés de apt y uv/pip) bajaron los docker builds repetidos de ~40s a ~10s, pero solo después de cambiarse del builder legacy de Docker víasetup-buildx-action.
Top comments (0)