Backend Latam Radio #9
Publicado el 03/06/2026
Hardening III: sync robusto, Docker y observabilidad
Las fases finales del hardening atacan tres frentes distintos pero relacionados: que la sincronización sobreviva a datos sucios, que el contenedor sea defensible en producción, y que pueda saber qué está pasando dentro sin tener que adivinar.
Sync robusto: transaction.atomic + tolerancia por estación
El comando update_radios original tenía dos pecados graves:
- Sin transacciones. Si una estación reventaba a mitad del
update_or_create+set(tags), la base quedaba en estado inconsistente. - Sin manejo de errores. Una sola estación con
country_code=None(que rompe el.upper()) abortaba todo el bucle.
La versión refactorizada del repositorio:
# streams/repositories/stations/stations_repository.py
@transaction.atomic
def create_or_update_station(self, data):
uuid = _safe_str(getattr(data, "uuid", ""))
country_code = _safe_str(getattr(data, "country_code", "")).upper()
if not uuid or len(country_code) != 2:
raise ValueError(
f"station with invalid identity (uuid={uuid!r}, country_code={country_code!r})"
)
# ... station_data con _safe_str / _safe_url en cada campo
station, _ = Station.objects.update_or_create(uuid=uuid, defaults=station_data)
# ... seteo de M2M
return station
Y en el comando:
def _persist_stations(self, repo, stations, country_code):
ok, err = 0, 0
for station in stations:
try:
repo.create_or_update_station(station)
ok += 1
except Exception as exc:
err += 1
logger.warning(
"[%s] saltando estación inválida (uuid=%s): %s",
country_code, getattr(station, "uuid", "?"), exc,
)
return ok, err
Tres mejoras combinadas:
@transaction.atomicenvuelve el upsert + setting de M2M. Si algo falla, rollback completo._safe_str/_safe_urlson helpers defensivos. Nada dedata.country_code.upper()directo: primero verifica que es string, después.upper().- Try/except por estación dentro del bucle del país. Una fila mala se loggea y se sigue.
El comando multi-país, multi-fichero
El antes recibía un único country_code como argumento posicional. Sincronizar LATAM era 20 invocaciones a mano. El después:
def add_arguments(self, parser):
parser.add_argument("country_codes", nargs="*",
help="Códigos ISO-2 (ej.: AR BR CL).")
parser.add_argument("--from-file", type=str, default=None,
help="Fichero con un código por línea.")
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
parser.add_argument("--hide-broken", action="store_true", default=True)
Si no paso códigos, lee un fichero por defecto que vive en streams/management/commands/data/latam_iso_codes.txt:
AR
BO
BR
CL
CO
...
Lo más simple posible. El cron diario ejecuta:
docker compose exec backend python manage.py update_radios
Sin argumentos. Sincroniza los 20 países LATAM.
Docker: slim, no-root, ordenado
El Dockerfile antes:
FROM python:3.12
# ...
RUN pip install -r requirements.txt
RUN apt-get update -y && apt-get upgrade -y && apt-get install -y --no-install-recommends \
binutils libproj-dev gdal-bin libgdal-dev python3-gdal libsqlite3-mod-spatialite
COPY . .
Tres problemas:
python:3.12trae1GB. La variante150MB.slimespip installantes deapt install. Si cambiarequirements.txt, se reinstala todo. Si invierto el orden, la capa de apt se cachea.- Corre como root dentro del contenedor. Cualquier RCE = root del contenedor.
El después:
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /usr/src/app
# Dependencias del sistema para GeoDjango (GDAL/PROJ) y SpatiaLite en local.
# `curl` lo dejamos para los healthchecks del contenedor.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
binutils curl gdal-bin libgdal-dev libproj-dev \
libsqlite3-mod-spatialite python3-gdal \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt requirements-dev.txt ./
ARG INSTALL_DEV=0
RUN pip install --upgrade pip \
&& if [ "$INSTALL_DEV" = "1" ]; then \
pip install -r requirements-dev.txt; \
else \
pip install -r requirements.txt; \
fi
RUN groupadd --system django && useradd --system --gid django --create-home django
COPY --chown=django:django . .
# `COPY --chown` cambia el owner de los archivos copiados pero NO del WORKDIR,
# que sigue siendo de root. El entrypoint hace `collectstatic` y crea
# `staticfiles/` en runtime → "permission denied". Le damos ownership del
# WORKDIR a `django` y pre-creamos `staticfiles/` para no depender del backend.
RUN chmod +x entrypoint.sh \
&& chown -R django:django /usr/src/app \
&& mkdir -p /usr/src/app/staticfiles \
&& chown django:django /usr/src/app/staticfiles
USER django
EXPOSE 8000
ENTRYPOINT ["./entrypoint.sh"]
CMD ["gunicorn", "latamradio.wsgi:application", \
"--bind", "0.0.0.0:8000", \
"--workers", "3", \
"--access-logfile", "-", \
"--error-logfile", "-"]
Cambios:
python:3.12-slim+rm -rf /var/lib/apt/lists/*para no arrastrar caché de apt.aptprimero,pipdespués: layer caching óptimo.ARG INSTALL_DEV: la misma imagen sirve para dev (instala pytest) y para prod (no lo instala). El compose lo activa con build args.- Usuario no-root
django. El proceso corre como ese usuario. chown -R django:django /usr/src/appdespués delCOPY: sin esto,collectstaticen el entrypoint reventaba con "permission denied" sobrestaticfiles/. Es uno de esos detalles que en local no aparecen (corres como root, todo funciona) y solo se ven en el primer despliegue real.- CMD con gunicorn y logs a stdout/stderr para que Dokploy/Docker los recoja. Adiós a
runserveren producción.
El compose: red privada para que Redis no esté en internet
El docker-compose.yml declara una red app-internal con driver: bridge y mete backend y redis dentro. La sutileza importante: en cuanto a backend le añades networks: [app-internal] para enlazarlo con Redis, tienes que listar también la red que usa Traefik (en Dokploy es dokploy-network, lo añade el orquestador) o tu reverse proxy pierde el contenedor. Y al revés: Redis solo está en app-internal, sin ports: publicados, por lo que no es alcanzable desde internet aunque el host abra puertos. La única manera de hablar con Redis es ser otro servicio del compose en la misma red.
Es la regla operativa que evita meter la pata: cualquier componente que no sirva al exterior nunca debe tener ports:, solo expose: o nada.
CSRF y el reverse proxy
Otro detalle de despliegue tras Traefik: el admin de Django devolvía 403 Forbidden — CSRF verification failed al hacer login. Django 4+ requiere declarar los orígenes confiables explícitamente con scheme HTTPS:
DJANGO_CSRF_TRUSTED_ORIGINS=https://latamradio.rexvalentia.es
Sin eso, cualquier formulario (admin incluido) detrás de un reverse proxy con TLS termina rechazado. El error es claro en cuanto sabes dónde mirar; sin saberlo, te quema una tarde.
Logging: configurado, no por defecto
Django por defecto loggea poco y mal. Configuración explícita:
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {name} {process:d} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"level": env("DJANGO_LOG_LEVEL", default="INFO"),
"class": "logging.StreamHandler",
"formatter": "verbose",
},
},
"root": {
"handlers": ["console"],
"level": env("DJANGO_LOG_LEVEL", default="INFO"),
},
"loggers": {
"django.security": {
"handlers": ["console"],
"level": "WARNING",
"propagate": False,
},
"streams": {
"handlers": ["console"],
"level": env("DJANGO_APP_LOG_LEVEL", default="INFO"),
"propagate": False,
},
"api": {
"handlers": ["console"],
"level": env("DJANGO_APP_LOG_LEVEL", default="INFO"),
"propagate": False,
},
},
}
Tres loggers nombrados (streams, api, django.security) que el management command y los viewsets usan. Cuando una estación se descarta, sale como WARNING con el uuid. Si en el futuro hay intentos de password reset masivos, django.security los verá.
Swagger detrás de auth en producción
Pequeño detalle de operación: /docs/ con drf-yasg expone toda la estructura de la API. En desarrollo lo quiero abierto; en producción lo escondo tras IsAdminUser:
# api/schema.py
_permission = permissions.AllowAny if settings.DEBUG else permissions.IsAdminUser
schema_view = get_schema_view(
api_info,
public=True,
permission_classes=(_permission,),
)
Una línea. Suficiente para que el swagger no sea reconnaissance gratis.
Resultado
- Sincronización tolerante a basura, transaccional, multi-país.
- Imagen Docker más pequeña, más rápida de construir y más segura.
- Logging estructurado por niveles y por aplicación.
- Docs cerradas en producción.
Última fase: la red de seguridad. Tests, limpieza y retrospectiva.