Lección 7.2.1 · 70 min
Docker y docker-compose: imágenes reproducibles con uv
Un bot que funciona en tu portátil y falla en el servidor casi siempre tiene la misma causa: no es el mismo Python, ni las mismas librerías, ni el mismo sistema. Esta lección escribe el Dockerfile de un bot con uv siguiendo la guía oficial de Astral, un docker-compose con base de datos y monitorización, y un revisor en Python que comprueba las reglas que importan: digests fijados, lockfile, varias etapas, usuario sin privilegios, comprobaciones de salud y ningún secreto en las capas.
- Módulo
- 7.2 · Infraestructura y despliegue
- Duración
- 70 min
- Requisitos
- 0.2.2 · Entornos virtuales y dependencias: uv init, uv add, pyproject.toml y lockfiles, 0.2.5 · Git y GitHub: commits, ramas, pull requests y .gitignore, 4.4.5 · Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos, 4.5.1 · Arquitectura de un sistema en vivo: de los datos a la reconciliación
Lecciones del nivel 7
7.1 · Ingeniería de software para trading
7.2 · Infraestructura y despliegue
7.3 · MLOps para modelos de trading
- 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
- 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
- 7.3.3 Monitorización del modelo en vivo y desactivación automática
- 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
7.4 · Regulación, ética y fiscalidad
- 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
- 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
- 7.4.3 PRIIPs, KID y ETFs estadounidenses
- 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
- 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
- 7.4.6 Fiscalidad del trading en España
- 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
7.5 · Carrera profesional quant
Qué fija una imagen y qué no
La lección 0.2.2 fijó las librerías con uv.lock: el mismo código con las mismas versiones da el mismo backtest. En
producción eso no basta. El bot corre en otro sistema operativo, con otro Python, otras bibliotecas del sistema (la
de C, OpenSSL, los certificados) y otra configuración. Una imagen de Docker empaqueta todo eso en capas de solo
lectura: un sistema base, un Python, el entorno virtual y el código. Un contenedor es esa imagen en marcha, con una
capa de escritura encima que se pierde al borrarlo.
La reproducibilidad es una cadena, y se rompe por el eslabón más flojo:
Fuente Mermaid
flowchart TB A[uv.lock] --> B[docker build] C[Base con digest] --> B D[Código en git] --> B B --> E[Imagen con digest] E --> F[Registro] F --> G[Servidor]
uv.lock fija las librerías de Python con sus hashes; el digest de la imagen base fija el sistema y el intérprete; el
commit fija el código; y el digest de la imagen resultante es lo que se despliega, no una etiqueta. Una imagen no fija
lo que viene de fuera en tiempo de ejecución: la hora, los datos, las respuestas del broker ni los secretos, que se
montan al arrancar y nunca se meten dentro.
El proyecto y un Dockerfile ingenuo
El bot de juguete es un proyecto de uv como el del nivel 0: pyproject.toml, uv.lock, el paquete en src/bot/,
pruebas, un README, unos datos de ejemplo y, como en muchos repositorios reales, un .env con una clave de prueba y
una carpeta secretos/ con lo que leerá el compose. La celda crea el árbol en una carpeta temporal:
from pathlib import Path
RAIZ = Path("proyecto")
ARCHIVOS = {
"pyproject.toml": '[project]\nname = "bot"\nversion = "1.4.0"\n'
'requires-python = ">=3.14,<3.15"\n',
"uv.lock": "version = 1\n# pandas 3.0.6 ...\n",
"src/bot/__init__.py": "",
"src/bot/__main__.py": "from bot.bucle import main\nmain()\n",
"src/bot/bucle.py": "def main():\n ...\n" + "#\n" * 3000,
"src/bot/estrategia.py": "def senal(p):\n ...\n" + "#\n" * 4500,
"src/bot/salud.py": "# ver la celda del latido\n" + "#\n" * 900,
"tests/test_estrategia.py": "def test_senal():\n ...\n",
"README.md": "# Bot\n",
".env": "EXCHANGE_API_KEY=clave-de-juguete-123\n",
"secretos/db_password.txt": "juguete-db\n",
"secretos/exchange_api_key.txt": "clave-de-juguete-123\n",
"secretos/grafana_admin.txt": "juguete-grafana\n",
"prometheus.yml": "scrape_configs: []\n",
"data/ticks.parquet": "x" * 2_000_000,
".git/objects/pack/pack.pack": "x" * 3_000_000,
}
for ruta, texto in ARCHIVOS.items():
p = RAIZ / ruta
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(texto)
total = sum(len(t.encode()) for t in ARCHIVOS.values())
print(len(ARCHIVOS), "ficheros,", f"{total / 1e6:.2f} MB")16 ficheros, 5.02 MBEl Dockerfile que sale de copiar el primer ejemplo que aparece en un buscador es este. Funciona: construye una imagen y el bot arranca. Tiene, sin embargo, casi todos los defectos que importan en producción:
DF_INGENUO = """\
FROM python:3.14
WORKDIR /app
COPY . .
RUN pip install uv && uv sync
ENV EXCHANGE_API_KEY=clave-de-juguete-123
CMD ["uv", "run", "python", "-m", "bot"]
"""La etiqueta 3.14 apunta a la última 3.14 y a la última imagen de Debian de ese momento. COPY . . mete en la imagen
todo el directorio, también .git, los datos y el .env. pip install uv instala la versión de uv del día. uv sync
sin opciones puede actualizar el lockfile si no cuadra con pyproject.toml. La clave queda en la configuración de la
imagen, a la vista de cualquiera que la descargue (docker image inspect). El proceso corre como root. Y nada le dice
a Docker si el bot sigue vivo o está colgado.
Para revisar un Dockerfile hay que leerlo como lo lee Docker: una instrucción por línea, con las líneas terminadas en
\ unidas a la siguiente, comentarios fuera y las opciones (--mount=…, --from=…) separadas de los argumentos.
Cada FROM abre una etapa. El lector siguiente cubre lo que usa la lección; no entiende los heredocs (RUN <<EOF) ni
cambios del carácter de escape, que el revisor marcaría como instrucciones raras:
import re
def leer_dockerfile(texto):
"""Instrucciones con su línea, etapa, opciones y argumentos."""
instr, buf, n0, etapa = [], "", None, -1
for n, linea in enumerate(texto.splitlines(), 1):
s = linea.strip()
if not s or s.startswith("#"):
continue # vacías y comentarios
n0 = n0 or n
if s.endswith("\\"):
buf += s[:-1].strip() + " "
continue
buf += s
kw, _, args = buf.partition(" ")
opciones = {}
args = args.strip()
while args.startswith("--"):
tok, _, args = args.partition(" ")
k, _, v = tok[2:].partition("=")
opciones.setdefault(k, []).append(v)
args = args.strip()
kw = kw.upper()
etapa += kw == "FROM"
instr.append({"linea": n0, "etapa": etapa, "kw": kw,
"op": opciones, "args": args})
buf, n0 = "", None
return instr
for i in leer_dockerfile(DF_INGENUO):
print(i["linea"], i["etapa"], i["kw"], i["args"])1 0 FROM python:3.14
2 0 WORKDIR /app
3 0 COPY . .
4 0 RUN pip install uv && uv sync
5 0 ENV EXCHANGE_API_KEY=clave-de-juguete-123
6 0 CMD ["uv", "run", "python", "-m", "bot"]El Dockerfile con uv
La guía de Astral para usar uv en Docker propone, punto por punto, lo que falta al ingenuo1Nota 1 · Astral, «Using uv in Docker» (https://docs.astral.sh/uv/guides/integration/docker/), consultada el 23 de
octubre de 2026: imágenes de uv, fijar versión y digest, UV_COMPILE_BYTECODE, caché con --mount=type=cache y
UV_LINK_MODE=copy, capas intermedias con --no-install-project, --no-editable en varias etapas y
UV_PYTHON_DOWNLOADS=0. Las opciones --locked y --frozen, en la referencia de la CLI de uv.: copiar el binario de
uv desde su imagen oficial con una versión fijada (y, si se quiere reproducibilidad completa, con su digest, porque las
etiquetas se pueden mover), instalar las dependencias en una capa propia antes de copiar el código
(--no-install-project), montar la caché de uv con --mount=type=cache y UV_LINK_MODE=copy, compilar el bytecode
con UV_COMPILE_BYTECODE=1 para arrancar antes, no instalar las dependencias de desarrollo (UV_NO_DEV=1), usar el
Python de la imagen (UV_PYTHON_DOWNLOADS=0) y separar en dos etapas la construcción del resultado.
Fuente Mermaid
flowchart TB L[Lock y pyproject] --> B[Etapa builder] U[uv y su caché] --> B B --> V[.venv] V --> F[Imagen final] S[src/] --> F
Los digests no caben en una línea de 72 caracteres, así que la celda los guarda en constantes y los inserta en el
texto con format. Son los que devolvían los registros el 23 de octubre de 2026 para python:3.14.9-slim-trixie en
Docker Hub y astral-sh/uv:0.13.5 en GitHub (la última versión publicada de uv ese día):
IMG = {
"PY": "python:3.14.9-slim-trixie@sha256:"
"b347e2dcbc7f0b6f9fb7182e9da6a187"
"fb10ae715e0692518c1aed0eec94a700",
"UV": "ghcr.io/astral-sh/uv:0.13.5@sha256:"
"32a7bb39b496babbd997b0a39a4d66c1"
"26bdaf5661b302bf6f06abda8deca6ff",
}
DF_BUENO = """\
# syntax=docker/dockerfile:1
# --- etapa 1: dependencias (uv no llega a la imagen final)
FROM {PY} AS builder
COPY --from={UV} /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy \\
UV_PYTHON_DOWNLOADS=0 UV_NO_DEV=1
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \\
--mount=type=bind,source=uv.lock,target=uv.lock \\
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \\
uv sync --locked --no-install-project --no-editable
# --- etapa 2: Python, el entorno y el código
FROM {PY}
RUN groupadd --system --gid 10001 bot \\
&& useradd --system --uid 10001 --gid bot --no-create-home bot
WORKDIR /app
COPY --from=builder --chown=bot:bot /app/.venv /app/.venv
COPY --chown=bot:bot src/ /app/src/
ENV PATH="/app/.venv/bin:$PATH" PYTHONPATH=/app/src \\
PYTHONUNBUFFERED=1
USER bot
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s \\
--retries=3 CMD ["python", "-m", "bot.salud"]
CMD ["python", "-m", "bot"]
""".format(**IMG)
(RAIZ / "Dockerfile").write_text(DF_BUENO)
print(len(leer_dockerfile(DF_BUENO)), "instrucciones en",
1 + max(i["etapa"] for i in leer_dockerfile(DF_BUENO)),
"etapas")14 instrucciones en 2 etapasUna decisión merece explicación. La guía de Astral instala el proyecto dentro del entorno con una segunda
uv sync --locked --no-editable y copia a la etapa final solo .venv. Así la imagen final no lleva el código fuente,
pero el entorno entero cambia con cada commit, y con él la capa que el servidor tiene que descargar. Aquí el entorno
solo lleva las dependencias, el código va en una capa aparte (src/) y Python lo encuentra con PYTHONPATH. Para una
aplicación que no se publica como paquete es la opción más barata en despliegues; para una librería, la de la guía.
Un revisor de Dockerfiles
Las reglas se pueden comprobar sin Docker. Cada una tiene un código, una línea y un motivo; la tabla resume las nueve:
| Código | Regla | Por qué |
|---|---|---|
| DF01 | toda imagen externa con digest | las etiquetas son mutables |
| DF02 | la etapa final no corre como root | una fuga del proceso no es una fuga del sistema |
| DF03 | uv sync con --locked o --frozen; nada de pip install suelto | el lockfile manda |
| DF04 | las dependencias se instalan en otra etapa | instalador y caché fuera de la imagen |
| DF05 | HEALTHCHECK en la etapa final | proceso vivo no es bot sano |
| DF06 | ningún secreto en ENV ni ARG | quedan en la imagen |
| DF07 | COPY . después de instalar dependencias | la caché de capas |
| DF08 | uv sync con la caché de uv montada | no volver a descargar ruedas |
| DF09 | .env y secretos/ fuera del contexto | COPY . los metería dentro |
DIGEST = re.compile(r"@sha256:[0-9a-f]{64}$")
SECRETO = re.compile(r"KEY|SECRET|TOKEN|PASS", re.I)
INSTALA = re.compile(r"uv sync|pip install")
def revisar_dockerfile(texto):
ins = leer_dockerfile(texto)
etapas = {i["args"].split()[-1].lower() for i in ins
if i["kw"] == "FROM" and " as " in i["args"].lower()}
ultima = max(i["etapa"] for i in ins)
out = []
def mal(i, cod, msg):
out.append((i["linea"], cod, msg))
instalo = set() # etapas que ya instalaron
for i in ins:
kw, a, op = i["kw"], i["args"], i["op"]
imgs = [a.split()[0]] if kw == "FROM" else op.get("from", [])
for img in imgs:
if img.lower() not in etapas and not DIGEST.search(img):
mal(i, "DF01", f"imagen sin digest: {img}")
if kw in ("ENV", "ARG"):
for k in re.findall(r"(\w+)=", a) or a.split()[:1]:
if SECRETO.search(k):
mal(i, "DF06", f"secreto en {kw}: {k}")
if kw == "COPY" and a.split()[0] == "." \
and i["etapa"] not in instalo:
mal(i, "DF07", "COPY . antes de instalar dependencias")
if kw == "RUN" and INSTALA.search(a):
instalo.add(i["etapa"])
if "pip install" in a:
mal(i, "DF03", "pip install sin lockfile")
if "uv sync" in a and not re.search(
r"--(locked|frozen)\b", a):
mal(i, "DF03", "uv sync sin --locked ni --frozen")
if "uv sync" in a and not any(
"type=cache" in m and ".cache/uv" in m
for m in op.get("mount", [])):
mal(i, "DF08", "uv sync sin caché montada")
if i["etapa"] == ultima:
mal(i, "DF04", "dependencias instaladas en la "
"imagen final")
final = [i for i in ins if i["etapa"] == ultima]
users = [i["args"].split(":")[0] for i in final
if i["kw"] == "USER"]
if not users or users[-1] in ("root", "0"):
mal(final[0], "DF02", "la imagen final corre como root")
if not any(i["kw"] == "HEALTHCHECK" for i in final):
mal(final[0], "DF05", "sin HEALTHCHECK")
return sorted(out)
for nombre, df in (("ingenuo", DF_INGENUO), ("bueno", DF_BUENO)):
h = revisar_dockerfile(df)
print(f"{nombre}: {len(h)} hallazgos")
for linea, cod, msg in h:
print(f" línea {linea:>2} {cod} {msg}")ingenuo: 9 hallazgos
línea 1 DF01 imagen sin digest: python:3.14
línea 1 DF02 la imagen final corre como root
línea 1 DF05 sin HEALTHCHECK
línea 3 DF07 COPY . antes de instalar dependencias
línea 4 DF03 pip install sin lockfile
línea 4 DF03 uv sync sin --locked ni --frozen
línea 4 DF04 dependencias instaladas en la imagen final
línea 4 DF08 uv sync sin caché montada
línea 5 DF06 secreto en ENV: EXCHANGE_API_KEY
bueno: 0 hallazgosNueve hallazgos en el ingenuo, ninguno en el bueno. Dos comentarios. La regla DF06 mira solo los nombres: no sabe si
DB_HOST=db es un secreto (no lo es) ni encontraría una clave escrita en un RUN echo. Y DF03 marca cualquier
pip install sin distinguir --require-hashes: en un proyecto con uv no hay motivo para usarlo. Un revisor así no
sustituye a la lectura; convierte en automático lo que una persona olvida a las once de la noche.
La regla DF09 mira el contexto, no el Dockerfile: lo que docker build envía al constructor es el directorio entero
salvo lo que excluye .dockerignore. La documentación de Docker describe la sintaxis2Nota 2 · Docker, «Build context», sección «.dockerignore files» (https://docs.docker.com/build/concepts/context/).: un patrón por línea, **
para cualquier número de directorios y ! para volver a incluir; la última regla que coincide manda.
def patron_a_regex(p):
p = p.strip("/")
r = re.escape(p).replace(r"\*\*/", "(?:.*/)?")
r = r.replace(r"\*\*", ".*").replace(r"\*", "[^/]*")
return re.compile(r.replace(r"\?", "[^/]") + "(?:/.*)?$")
def contexto(raiz, ignorar=""):
reglas = [(l.startswith("!"), patron_a_regex(l.lstrip("!")))
for l in ignorar.splitlines()
if l.strip() and not l.startswith("#")]
dentro = {}
for f in sorted(p for p in raiz.rglob("*") if p.is_file()):
rel = f.relative_to(raiz).as_posix()
fuera = False
for negar, rx in reglas:
if rx.match(rel):
fuera = not negar
if not fuera:
dentro[rel] = f.stat().st_size
return dentro
def revisar_contexto(dentro):
return [("DF09", f) for f in dentro
if f == ".env" or f.startswith("secretos/")]
IGNORAR = """\
.git
.venv
**/__pycache__
.env
secretos/
data/
tests/
*.md
Dockerfile
"""
(RAIZ / ".dockerignore").write_text(IGNORAR)
for nombre, ign in (("sin .dockerignore", ""), ("con él", IGNORAR)):
c = contexto(RAIZ, ign)
print(f"{nombre}: {len(c)} ficheros, "
f"{sum(c.values()) / 1e6:.2f} MB,",
revisar_contexto(c))
print(sorted(contexto(RAIZ, IGNORAR)))sin .dockerignore: 18 ficheros, 5.02 MB, [('DF09', '.env'), ('DF09', 'secretos/db_password.txt'), ('DF09', 'secretos/exchange_api_key.txt'), ('DF09', 'secretos/grafana_admin.txt')]
con él: 9 ficheros, 0.02 MB, []
['.dockerignore', 'prometheus.yml', 'pyproject.toml', 'src/bot/__init__.py', 'src/bot/__main__.py', 'src/bot/bucle.py', 'src/bot/estrategia.py', 'src/bot/salud.py', 'uv.lock']Sin .dockerignore, COPY . . llevaría a la imagen 5 MB de historial de git y datos, la clave del .env y las tres
contraseñas de secretos/. Con él, el contexto se queda en lo que la imagen necesita. La guía de Astral recomienda en
particular excluir .venv: el entorno local depende de la plataforma y se crea de cero dentro de la imagen.
La caché de capas, medida
La documentación de Docker resume la caché en una frase: cuando una capa cambia, todas las posteriores se reconstruyen, aunque no fueran a producir nada distinto3Nota 3 · Docker, «Docker build cache» (https://docs.docker.com/build/cache/): «Once a layer changes, then all downstream layers need to be rebuilt as well».. Una capa nueva no solo cuesta tiempo de construcción: hay que subirla al registro y el servidor tiene que descargarla en el despliegue siguiente. Para ponerle cifra hace falta el tamaño de la capa de dependencias. La celda lo mide en el entorno del curso para lo que importaría un bot de cripto típico (numpy, pandas, pyarrow, ccxt, pydantic, pyyaml y requests) y todo lo que arrastran:
from importlib import metadata as md
from packaging.requirements import Requirement
def cierre(raices):
"""Paquetes instalados que arrastran `raices` (sin extras)."""
vistos, pila = set(), list(raices)
while pila:
n = pila.pop().lower().replace("_", "-")
if n in vistos:
continue
try:
d = md.distribution(n)
except md.PackageNotFoundError:
continue
vistos.add(n)
for r in d.requires or []:
req = Requirement(r)
if req.marker is None or req.marker.evaluate({"extra": ""}):
pila.append(req.name)
return vistos
def bytes_instalados(n):
return sum(f.size or 0 for f in md.distribution(n).files or [])
DEPS = cierre(["numpy", "pandas", "pyarrow", "ccxt", "pydantic",
"pyyaml", "requests"])
tam = {n: bytes_instalados(n) for n in DEPS}
DEPS_MB = round(sum(tam.values()) / 1e6, 1)
print(len(DEPS), "paquetes,", DEPS_MB, "MB instalados")
for n, b in sorted(tam.items(), key=lambda x: -x[1])[:4]:
print(f" {n:<10} {b / 1e6:6.1f} MB")32 paquetes, 357.6 MB instalados
pyarrow 168.2 MB
numpy 56.3 MB
pandas 39.1 MB
ccxt 39.1 MBAhora la simulación. Cada instrucción recibe una clave de caché: el hash de la clave anterior, del texto de la
instrucción y del contenido de lo que lee (los ficheros que copia o monta, o la última capa de la etapa de la que copia).
Si la clave no cambia, la capa se reutiliza. Diez commits realistas: seis tocan el código, uno las pruebas, uno el
README, uno los datos de ejemplo y uno actualiza pandas en uv.lock. Para cada commit se suman los MB de las capas
nuevas de la imagen final:
import hashlib
def h(*partes):
return hashlib.sha256("|".join(partes).encode()).hexdigest()
def claves(df, repo, ignorar):
"""Clave y peso (MB) de cada capa de la imagen final."""
ctx = {f: t for f, t in repo.items()
if contexto_virtual(f, ignorar)}
ins = leer_dockerfile(df)
ultima = max(i["etapa"] for i in ins)
nombre, fin, previa, capas = {}, {}, {}, []
for i in ins:
kw, a, op, e = i["kw"], i["args"], i["op"], i["etapa"]
lee = []
if kw == "FROM":
if " as " in a.lower():
nombre[e] = a.split()[-1]
clave = h(a)
else:
if kw == "COPY" and "from" in op:
lee = [fin.get(op["from"][0], op["from"][0])]
elif kw == "COPY":
src = a.split()[-2].rstrip("/")
lee = [h(f, t) for f, t in sorted(ctx.items())
if src == "." or f.startswith(src + "/")]
for m in op.get("mount", []):
if "type=bind" in m:
s = re.search(r"source=([^,]+)", m).group(1)
lee.append(h(s, ctx.get(s, "")))
clave = h(previa[e], kw, a, str(op), *lee)
previa[e] = clave
if e < ultima: # etapa intermedia
fin[nombre.get(e, str(e))] = clave
continue
peso = 0.0 # MB de la capa
if kw == "RUN" and INSTALA.search(a):
peso = DEPS_MB
elif kw == "COPY" and "/app/.venv" in a:
peso = DEPS_MB
elif kw == "COPY":
peso = sum(len(t) for f, t in ctx.items()
if src == "." or
f.startswith(src + "/")) / 1e6
capas.append((clave, peso))
return capas
def contexto_virtual(f, ignorar):
reglas = [(l.startswith("!"), patron_a_regex(l.lstrip("!")))
for l in ignorar.splitlines() if l.strip()]
fuera = False
for negar, rx in reglas:
if rx.match(f):
fuera = not negar
return not fuera
COMMITS = [
("estrategia: filtro de volumen", "src/bot/estrategia.py"),
("tests del filtro", "tests/test_estrategia.py"),
("estrategia: umbral", "src/bot/estrategia.py"),
("README", "README.md"),
("bucle: reconexión", "src/bot/bucle.py"),
("datos de ejemplo", "data/ticks.parquet"),
("pandas 3.0.6 a 3.0.7", "uv.lock"),
("estrategia: corrige signo", "src/bot/estrategia.py"),
("salud: latido a 90 s", "src/bot/salud.py"),
("estrategia: stop", "src/bot/estrategia.py"),
]
repo = dict(ARCHIVOS)
antes = {"ingenuo": claves(DF_INGENUO, repo, ""),
"bueno": claves(DF_BUENO, repo, IGNORAR)}
FILAS = []
for n, (msg, f) in enumerate(COMMITS, 1):
repo[f] = repo[f] + f"# cambio {n}\n"
fila = [n, msg]
for nombre, df, ign in (("ingenuo", DF_INGENUO, ""),
("bueno", DF_BUENO, IGNORAR)):
ahora = claves(df, repo, ign)
viejas = {c for c, _ in antes[nombre]}
fila.append(round(sum(p for c, p in ahora
if c not in viejas), 3))
antes[nombre] = ahora
FILAS.append(fila)
print(f"{n:>2} {msg:<29} {fila[2]:>7.1f} {fila[3]:>7.3f}")
tot_i = sum(f[2] for f in FILAS)
tot_b = sum(f[3] for f in FILAS)
print(f"total MB nuevos: ingenuo {tot_i:.1f}, bueno {tot_b:.1f}") 1 estrategia: filtro de volumen 362.6 0.017
2 tests del filtro 362.6 0.000
3 estrategia: umbral 362.6 0.017
4 README 362.6 0.000
5 bucle: reconexión 362.6 0.017
6 datos de ejemplo 362.6 0.000
7 pandas 3.0.6 a 3.0.7 362.6 357.617
8 estrategia: corrige signo 362.6 0.017
9 salud: latido a 90 s 362.6 0.017
10 estrategia: stop 362.6 0.017
total MB nuevos: ingenuo 3626.2, bueno 357.7Con el Dockerfile ingenuo, cada commit, sea cual sea, invalida COPY . . y con él la capa de dependencias: 357,6 MB
de entorno más 5 MB de contexto, diez veces. Con el bueno, los cambios de código generan 17 KB, los de pruebas,
README y datos no generan nada (están fuera del contexto) y solo el commit que cambia uv.lock reconstruye el entorno.
La figura de la cabecera muestra los diez commits. La diferencia no es solo de tiempo: en un VPS con un disco pequeño,
diez versiones del bot ingenuo dejan 3,6 GB de capas que hay que limpiar con docker image prune.
El latido: un bot vivo no es un bot sano
HEALTHCHECK le dice a Docker cómo saber si el contenedor funciona. Un proceso puede estar vivo y no hacer nada: un
await que no vuelve, una conexión medio cerrada, un bucle esperando un dato que no llega. La referencia del Dockerfile
lo explica con un servidor web atascado en un bucle infinito4Nota 4 · Docker, «Dockerfile reference», HEALTHCHECK (https://docs.docker.com/reference/dockerfile/): valores por
defecto --interval=30s, --timeout=30s, --start-period=0s, --retries=3.. Para un bot, la señal útil es un latido: el bucle
principal escribe la hora en un fichero en cada vuelta, y bot.salud comprueba que no sea demasiado viejo. Con el reloj
inyectado, como en la lección 4.5.1, se prueba sin esperar:
import os
LATIDO = Path("tmp/latido")
MAX_EDAD = 90 # segundos
def comprobar(ruta, ahora, max_edad=MAX_EDAD):
"""0 = sano, 1 = enfermo (el código que espera HEALTHCHECK)."""
try:
edad = ahora - ruta.stat().st_mtime
except FileNotFoundError:
return 1
return 0 if edad <= max_edad else 1
LATIDO.parent.mkdir(exist_ok=True)
LATIDO.touch()
T0 = 1_760_000_000 # reloj inyectado (epoch)
os.utime(LATIDO, (T0, T0))
for t in (T0 + 30, T0 + 90, T0 + 91):
print(f"latido hace {t - T0:>3} s -> {comprobar(LATIDO, t)}")
print("sin fichero ->", comprobar(Path("tmp/no-existe"), T0))
def peor_deteccion(max_edad, intervalo, reintentos, timeout):
"""Del último latido a `unhealthy`, en el peor caso."""
return max_edad + intervalo * reintentos + timeout
print("peor caso:", peor_deteccion(MAX_EDAD, 30, 3, 5), "s")latido hace 30 s -> 0
latido hace 90 s -> 0
latido hace 91 s -> 1
sin fichero -> 1
peor caso: 185 sCon 30 segundos de intervalo y tres reintentos (los valores por defecto de Docker son 30 s, 30 s de timeout y 3
reintentos), un bot colgado tarda en el peor caso unos tres minutos en marcarse unhealthy. Es un compromiso: más
rápido significa más falsas alarmas durante un garbage collection largo o un mercado sin datos. Dos detalles
prácticos. La imagen slim no trae curl, así que la comprobación es un módulo de Python. Y la documentación de las
políticas de reinicio describe el reinicio cuando el contenedor termina5Nota 5 · Docker, «Start containers automatically» (https://docs.docker.com/engine/containers/start-containers-automatically/).; un contenedor unhealthy no termina
solo. Hace falta que alguien lo vea (las alertas de la lección 7.2.3) o que el propio bot tenga un vigilante que lo
haga salir con error cuando su bucle se para, para que restart: unless-stopped lo levante.
docker-compose: bot, base de datos y monitorización
Un bot en producción no va solo: necesita dónde guardar órdenes y ejecuciones (PostgreSQL), algo que recoja sus métricas (Prometheus) y algo donde mirarlas (Grafana). Compose describe los cuatro servicios, sus redes, volúmenes y secretos en un fichero, y los levanta en orden. El diseño:
Fuente Mermaid
flowchart TB G[grafana] --> P[prometheus] P --> B[bot] B --> D[(postgres)] B --> X[Exchange]
La base de datos y Prometheus viven en una red interna sin salida a Internet; solo el bot tiene, además, una red con
salida hacia el exchange. Grafana se publica en 127.0.0.1 y se entra por un túnel SSH. Las contraseñas y la clave del
exchange van en secrets:: Compose monta cada una como fichero en /run/secrets/<nombre> solo en los servicios que la
piden, y la documentación de Docker explica por qué no en variables de entorno: las ve cualquier proceso y acaban en
los logs sin querer6Nota 6 · Docker, «Manage secrets securely in Docker Compose» (https://docs.docker.com/compose/how-tos/use-secrets/). Las
variables _FILE de PostgreSQL, en la documentación de la imagen oficial; __FILE de Grafana, en «Configure a
Grafana Docker image».. La imagen de PostgreSQL lee la contraseña de un fichero con POSTGRES_PASSWORD_FILE y Grafana
con GF_SECURITY_ADMIN_PASSWORD__FILE. Primero, el compose ingenuo y el bueno:
import yaml
IMG |= {
"PG": "postgres:18.6-trixie@sha256:"
"edb0c08dc33befd0262ab3b5cf1c4f3f"
"8cfd8b09e88c05b45adee29d6761ddb6",
"PROM": "prom/prometheus:v3.15.1@sha256:"
"fa3302279710be2f21a8301ec17a8b4f"
"2301fd829bd113839b3b6b02b6f191a2",
"GRAF": "grafana/grafana:13.2.4@sha256:"
"dbbc38ea21103cefb50396340cd88afe"
"c779c7e070b0a7bf5a75b4ce46651d3f",
}
COMPOSE_INGENUO = """\
services:
db:
image: postgres:latest
environment:
POSTGRES_PASSWORD: juguete-db
ports: ["5432:5432"]
bot:
build: .
environment:
EXCHANGE_API_KEY: clave-de-juguete-123
depends_on: [db]
grafana:
image: grafana/grafana
ports: ["3000:3000"]
"""
COMPOSE_BUENO = """\
name: bot-trading
services:
db:
image: {PG}
environment:
POSTGRES_USER: bot
POSTGRES_DB: trading
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
volumes: ["pgdata:/var/lib/postgresql"]
networks: [interna]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bot -d trading"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
bot:
build: .
image: bot-trading:1.4.0
environment:
BOT_MODO: paper
DB_HOST: db
secrets: [db_password, exchange_api_key]
depends_on:
db: {{condition: service_healthy}}
networks: [interna, salida]
read_only: true
tmpfs: [/tmp]
stop_grace_period: 30s
restart: unless-stopped
prometheus:
image: {PROM}
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- promdata:/prometheus
networks: [interna]
restart: unless-stopped
grafana:
image: {GRAF}
environment:
GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana_admin
secrets: [grafana_admin]
ports: ["127.0.0.1:3000:3000"]
networks: [interna]
depends_on:
prometheus: {{condition: service_started}}
restart: unless-stopped
networks:
interna: {{internal: true}}
salida: {{}}
volumes:
pgdata: {{}}
promdata: {{}}
secrets:
db_password: {{file: ./secretos/db_password.txt}}
exchange_api_key: {{file: ./secretos/exchange_api_key.txt}}
grafana_admin: {{file: ./secretos/grafana_admin.txt}}
""".format(**IMG)
(RAIZ / "compose.yaml").write_text(COMPOSE_BUENO)
c = yaml.safe_load(COMPOSE_BUENO)
print(list(c["services"]), list(c["secrets"]))['db', 'bot', 'prometheus', 'grafana'] ['db_password', 'exchange_api_key', 'grafana_admin']El revisor de compose sigue la misma idea que el de Dockerfiles. Las reglas salen de la documentación: depends_on en su forma corta solo
espera a que el contenedor arranque, mientras que condition: service_healthy espera a que la comprobación de salud de
la dependencia pase7Nota 7 · Docker, «Compose file reference: services», depends_on (https://docs.docker.com/reference/compose-file/services/).; y Docker desvía el tráfico de los puertos publicados antes de que llegue a las cadenas que usa
ufw, así que un "5432:5432" en un servidor con cortafuegos queda abierto a Internet aunque ufw diga lo contrario8Nota 8 · Docker, «Packet filtering and firewalls», sección «Docker and ufw»
(https://docs.docker.com/engine/network/packet-filtering-firewalls/)..
def revisar_compose(texto, raiz):
c = yaml.safe_load(texto)
svc, out = c["services"], []
declarados = set(c.get("secrets", {}))
for n, s in svc.items():
img = s.get("image", "")
if "build" not in s and not DIGEST.search(img):
out.append((n, "DC01", f"imagen sin digest: {img}"))
env = s.get("environment", {})
if isinstance(env, list):
env = dict(e.split("=", 1) for e in env)
for k in env:
if SECRETO.search(k) and not k.endswith("FILE"):
out.append((n, "DC02", f"secreto en environment: {k}"))
for p in s.get("ports", []):
if not str(p).startswith("127.0.0.1:"):
out.append((n, "DC03", f"puerto en todas las "
f"interfaces: {p}"))
dep = s.get("depends_on", {})
if isinstance(dep, list):
dep = {d: {"condition": "service_started"} for d in dep}
for d, cond in dep.items():
sano = "healthcheck" in svc.get(d, {})
if sano and cond.get("condition") != "service_healthy":
out.append((n, "DC04", f"no espera a que {d} "
f"esté sano"))
if not sano and d == "db":
out.append((d, "DC05", "sin healthcheck y otro "
"servicio depende de él"))
if "restart" not in s:
out.append((n, "DC06", "sin política de reinicio"))
for sec in s.get("secrets", []):
f = c.get("secrets", {}).get(sec, {}).get("file")
if sec not in declarados or not (raiz / f).is_file():
out.append((n, "DC07", f"secreto sin fichero: {sec}"))
return sorted(set(out))
for nombre, txt in (("ingenuo", COMPOSE_INGENUO),
("bueno", COMPOSE_BUENO)):
h = revisar_compose(txt, RAIZ)
print(f"{nombre}: {len(h)} hallazgos")
for servicio, cod, msg in h:
print(f" {servicio:<8} {cod} {msg}")ingenuo: 10 hallazgos
bot DC02 secreto en environment: EXCHANGE_API_KEY
bot DC06 sin política de reinicio
db DC01 imagen sin digest: postgres:latest
db DC02 secreto en environment: POSTGRES_PASSWORD
db DC03 puerto en todas las interfaces: 5432:5432
db DC05 sin healthcheck y otro servicio depende de él
db DC06 sin política de reinicio
grafana DC01 imagen sin digest: grafana/grafana
grafana DC03 puerto en todas las interfaces: 3000:3000
grafana DC06 sin política de reinicio
bueno: 0 hallazgosDiez hallazgos en el ingenuo y ninguno en el bueno. El de más consecuencias es el más discreto: depends_on: [db]
arranca el bot en cuanto existe el contenedor de PostgreSQL, que tarda unos segundos en aceptar conexiones; el bot
falla al conectar en el arranque y, sin política de reinicio, se queda parado. La regla DC05 está escrita solo para
db: generalizarla a cualquier servicio del que otro dependa es uno de los ejercicios.
Compose calcula el orden de arranque a partir de depends_on, y lo deshace al revés al parar. Es una ordenación
topológica, la misma que hace graphlib de la biblioteca estándar:
from graphlib import TopologicalSorter
svc = yaml.safe_load(COMPOSE_BUENO)["services"]
grafo = {n: set(s.get("depends_on", {})) for n, s in svc.items()}
ts = TopologicalSorter(grafo)
ts.prepare()
tandas = []
while ts.is_active():
listos = sorted(ts.get_ready())
tandas.append(listos)
ts.done(*listos)
print("arranque:", " -> ".join("+".join(t) for t in tandas))
print("parada: ", " -> ".join("+".join(t) for t in tandas[::-1]))arranque: db+prometheus -> bot+grafana
parada: bot+grafana -> db+prometheusLa parada importa tanto como el arranque. stop_grace_period: 30s le da al bot 30 segundos desde la señal de parada
para cancelar órdenes abiertas, guardar estado y cerrar conexiones antes de que Docker lo mate; el bot tiene que
capturar la señal y hacerlo (lección 4.5.5). Y como la base de datos se para después del bot, sus últimas escrituras
llegan.
Construir, arrancar y comprobar
En una máquina con Docker, los pasos reales son estos. docker compose config --quiet valida el fichero con el
propio Compose, y conviene que el revisor de la lección y el de Docker digan lo mismo; --wait hace que up espere a
que los servicios con comprobación de salud estén sanos:
docker buildx build -t bot-trading:1.4.0 proyecto/
docker compose -f proyecto/compose.yaml config --quiet
docker compose -f proyecto/compose.yaml up -d --wait
docker compose -f proyecto/compose.yaml ps
docker image inspect bot-trading:1.4.0 --format '{{.Id}}'Desde Python, para un script de despliegue que pare si algo falla:
import subprocess
def docker(*args):
r = subprocess.run(["docker", *args], cwd=RAIZ,
capture_output=True, text=True)
if r.returncode:
raise RuntimeError(r.stderr.strip())
return r.stdout.strip()
docker("buildx", "build", "-t", "bot-trading:1.4.0", ".")
docker("compose", "config", "--quiet")
docker("compose", "up", "-d", "--wait")
print(docker("compose", "ps", "--format", "json"))El digest de la imagen construida es lo que se apunta en el registro de despliegues: «el 23 de octubre se desplegó
sha256:…, construido desde el commit …». Con eso, volver atrás es desplegar el digest anterior, no reconstruir y
esperar que salga lo mismo. Cómo se construye y se publica la imagen en cada commit, y cómo llega al servidor, es la
lección 7.2.2.
Ejercicios
- Generaliza la regla DC05 para que marque cualquier servicio sin
healthcheckdel que otro dependa concondition: service_healthy, y añade una regla DC08 que marque los servicios conbuildsinimage(sin etiqueta, el registro de despliegues no puede decir qué se desplegó).
Solución
def revisar_compose_2(texto, raiz):
c = yaml.safe_load(texto)
svc = c["services"]
out = [h for h in revisar_compose(texto, raiz)
if h[1] != "DC05"]
for n, s in svc.items():
dep = s.get("depends_on", {})
if isinstance(dep, list):
continue
for d, cond in dep.items():
if (cond.get("condition") == "service_healthy"
and "healthcheck" not in svc.get(d, {})
and "build" not in svc.get(d, {})):
out.append((d, "DC05", f"{n} espera que esté sano "
f"y no tiene healthcheck"))
if "build" in s and "image" not in s:
out.append((n, "DC08", "build sin image"))
return sorted(set(out))
malo = COMPOSE_BUENO.replace(" image: bot-trading:1.4.0\n", "")
malo = malo.replace("prometheus: {condition: service_started}",
"prometheus: {condition: service_healthy}")
for h in revisar_compose_2(malo, RAIZ):
print(h)('bot', 'DC08', 'build sin image')
('prometheus', 'DC05', 'grafana espera que esté sano y no tiene healthcheck')Prometheus no tiene healthcheck en el compose bueno porque nadie esperaba a que estuviera sano; en cuanto Grafana lo
exige, hace falta uno. Los servicios con build se excluyen porque su comprobación está en el Dockerfile
(HEALTHCHECK), que el revisor de compose no lee: un revisor más completo leería los dos.
- Añade al revisor de Dockerfiles una regla DF10 que marque
ADDcon una URL sin--checksum, y pruébala conADD https://astral.sh/uv/install.sh /uv-installer.sh(la línea de la guía de Astral para instalar uv con su instalador) y con la misma línea con--checksum=sha256:seguido de 64 ceros.
Solución
def revisar_dockerfile_2(texto):
out = revisar_dockerfile(texto)
for i in leer_dockerfile(texto):
if (i["kw"] == "ADD" and re.match(r"https?://", i["args"])
and "checksum" not in i["op"]):
out.append((i["linea"], "DF10", "ADD remoto sin "
"--checksum"))
return sorted(out)
base = DF_BUENO.replace(
"WORKDIR /app\nRUN --mount", "WORKDIR /app\n{ADD}\nRUN --mount", 1)
sin = "ADD https://astral.sh/uv/install.sh /uv-installer.sh"
con = "ADD --checksum=sha256:" + "0" * 64 + sin[3:]
for linea in (sin, con):
h = revisar_dockerfile_2(base.replace("{ADD}", linea))
print([x[1:] for x in h])[('DF10', 'ADD remoto sin --checksum')]
[]La referencia del Dockerfile admite --checksum en ADD con fuentes HTTP: si el fichero descargado no tiene ese
hash, la construcción falla. Sin él, el instalador puede cambiar entre dos construcciones y nadie lo nota. Copiar uv
desde su imagen con digest, como hace la lección, evita el problema de raíz.
- Un compañero dice que fijar digests es exagerado para un bot personal: «con
python:3.14-slimme basta, y así tengo siempre los parches». ¿Qué le responderías?
Solución
Que tiene razón en el riesgo que señala y no en la solución. Con una etiqueta, dos construcciones del mismo commit
pueden dar imágenes distintas sin que nadie lo decida: Docker Hub muestra la etiqueta python:3.14.8-slim-trixie
actualizada el 21 de octubre de 2026, y la 3.14.9 apareció al día siguiente; la documentación de Docker recuerda que
las etiquetas son mutables. Si el bot falla
después de un despliegue, no sabrá si fue su código o la imagen. Con un digest, cada cambio de base es un commit
(revisable, con su CI, reversible), y los parches siguen llegando si una herramienta como Dependabot propone el digest
nuevo cada semana. Para un bot personal, la diferencia es poder contestar a «¿qué cambió?» con una línea del historial.
Footnotes
-
Astral, «Using uv in Docker» (https://docs.astral.sh/uv/guides/integration/docker/), consultada el 23 de octubre de 2026: imágenes de uv, fijar versión y digest,
UV_COMPILE_BYTECODE, caché con--mount=type=cacheyUV_LINK_MODE=copy, capas intermedias con--no-install-project,--no-editableen varias etapas yUV_PYTHON_DOWNLOADS=0. Las opciones--lockedy--frozen, en la referencia de la CLI de uv. ↩ -
Docker, «Build context», sección «.dockerignore files» (https://docs.docker.com/build/concepts/context/). ↩
-
Docker, «Docker build cache» (https://docs.docker.com/build/cache/): «Once a layer changes, then all downstream layers need to be rebuilt as well». ↩
-
Docker, «Dockerfile reference»,
HEALTHCHECK(https://docs.docker.com/reference/dockerfile/): valores por defecto--interval=30s,--timeout=30s,--start-period=0s,--retries=3. ↩ -
Docker, «Start containers automatically» (https://docs.docker.com/engine/containers/start-containers-automatically/). ↩
-
Docker, «Manage secrets securely in Docker Compose» (https://docs.docker.com/compose/how-tos/use-secrets/). Las variables
_FILEde PostgreSQL, en la documentación de la imagen oficial;__FILEde Grafana, en «Configure a Grafana Docker image». ↩ -
Docker, «Compose file reference: services»,
depends_on(https://docs.docker.com/reference/compose-file/services/). ↩ -
Docker, «Packet filtering and firewalls», sección «Docker and ufw» (https://docs.docker.com/engine/network/packet-filtering-firewalls/). ↩
Respuesta plegada en cada pregunta
Autoevaluación
El Dockerfile empieza con
FROM python:3.14.9-slim-trixie. Lo construyes hoy y dentro de un mes, sin cambiar nada. ¿Obtienes la misma imagen base?- ASí: la etiqueta incluye la versión exacta de Python
- BNo necesariamente: las etiquetas son mutables y el editor las vuelve a publicar (Docker Hub muestra la del 3.14.8 actualizada el 21 de octubre de 2026); solo un digest
@sha256:…fija el contenido - CSí, porque Docker guarda la imagen en caché para siempre
- DNo, porque
slimcambia de distribución cada mes
Ver respuesta
Respuesta B. No necesariamente: las etiquetas son mutables y el editor las vuelve a publicar (Docker Hub muestra la del 3.14.8 actualizada el 21 de octubre de 2026); solo un digest
@sha256:…fija el contenidoLa documentación de Docker lo dice así: las etiquetas son mutables y fijar el digest garantiza la misma imagen aunque el editor sustituya la etiqueta. El precio es que las correcciones de seguridad ya no llegan solas: hay que actualizar el digest a propósito (Dependabot con el ecosistema
dockerabre esas PR).¿Qué diferencia hay entre
uv sync --lockedyuv sync --frozen?- ANinguna: son sinónimos
- B
--frozendescarga más rápido porque no comprueba hashes - CLas dos instalan lo que dice
uv.locksin cambiarlo;--lockedademás falla si el lockfile no está al día conpyproject.toml, y--frozenno lo comprueba - D
--lockedbloquea el entorno para que nadie lo modifique
Ver respuesta
Respuesta C. Las dos instalan lo que dice
uv.locksin cambiarlo;--lockedademás falla si el lockfile no está al día conpyproject.toml, y--frozenno lo compruebaSegún la referencia de uv,
--lockedexige que el lockfile esté al día y sale con error si habría que actualizarlo;--frozenusa el lockfile como fuente de verdad sin comprobarlo. La guía de Astral usa--frozenen la primera sincronización, cuando solo está montadouv.lock; en la lección se montan los dos ficheros y se usa--locked.En el Dockerfile ingenuo,
COPY . .va antes deuv sync. ¿Qué pasa al cambiar una línea deestrategia.py?- ASolo se reconstruye la capa del código
- BNada: Docker detecta que las dependencias no han cambiado
- CFalla la construcción
- DCambia la clave de caché de
COPY . .y todas las capas posteriores se reconstruyen, también la de las dependencias: 362,6 MB nuevos por un cambio de una línea
Ver respuesta
Respuesta D. Cambia la clave de caché de
COPY . .y todas las capas posteriores se reconstruyen, también la de las dependencias: 362,6 MB nuevos por un cambio de una líneaDocker reconstruye una capa cuando cambia su entrada y, después, todas las que la siguen. Por eso las dependencias van en una capa que solo depende de
uv.lockypyproject.toml, y el código se copia después. En la simulación de la lección, diez commits suman 3.626,2 MB nuevos con el Dockerfile ingenuo y 357,7 MB con el bueno.La contraseña de la base de datos va en
environment: POSTGRES_PASSWORD: …del compose. ¿Qué propone la documentación de Docker?- ADeclararla en
secrets:y pasar a la imagenPOSTGRES_PASSWORD_FILE=/run/secrets/db_password: Compose monta el fichero solo en los servicios que lo piden y no queda en el entorno del proceso - BCifrarla con base64 en el propio compose
- CPonerla en un
ARGdel Dockerfile - DEscribirla en
.envy hacerCOPY . .
Ver respuesta
Respuesta A. Declararla en
secrets:y pasar a la imagenPOSTGRES_PASSWORD_FILE=/run/secrets/db_password: Compose monta el fichero solo en los servicios que lo piden y no queda en el entorno del procesoLas variables de entorno las ven todos los procesos del contenedor y acaban en logs sin querer; los argumentos de construcción quedan en la imagen. Compose monta cada secreto como fichero en
/run/secrets/<nombre>, y las imágenes de PostgreSQL y Grafana leen la contraseña de un fichero con_FILEy__FILE.- ADeclararla en
El compose publica Grafana como
"3000:3000"en un servidor con ufw que solo deja pasar el puerto 22. ¿Está Grafana a salvo de Internet?- ASí: ufw bloquea todo lo que no sea el 22
- BSí, si Grafana tiene contraseña
- CNo: Docker desvía el tráfico de los puertos publicados antes de que llegue a las cadenas que usa ufw; hay que publicar en
127.0.0.1:3000:3000y entrar por un túnel SSH - DNo, porque Grafana abre el puerto por su cuenta
Ver respuesta
Respuesta C. No: Docker desvía el tráfico de los puertos publicados antes de que llegue a las cadenas que usa ufw; hay que publicar en
127.0.0.1:3000:3000y entrar por un túnel SSHLa documentación de Docker lo advierte: el tráfico de los puertos publicados se encamina en la tabla nat antes de llegar a las cadenas INPUT y OUTPUT que usa ufw, así que sus reglas no se aplican. Publicar solo en la interfaz local cierra ese hueco.
Ideas clave
- Una imagen fija el sistema, Python y las librerías; una etiqueta no fija la imagen. Base, uv e imágenes de servicios van con digest
@sha256:…, y su actualización es un cambio revisado. - Con uv:
uv.locken el repositorio,uv sync --locked(o--frozensi aún no estápyproject.toml), caché montada con--mount=type=cacheyUV_LINK_MODE=copy, como en la guía de Astral. - El orden de las instrucciones es el orden de la caché: dependencias antes que el código. En diez commits simulados, el Dockerfile ingenuo genera 3.626,2 MB de capas nuevas y el bueno 357,7 MB, casi todo en el único commit que cambia
uv.lock. - Varias etapas: uv y la caché se quedan en la de construcción; la final lleva Python, el entorno, el código, un usuario sin privilegios y una comprobación de salud.
- Ningún secreto en
ENV,ARGni en el contexto de construcción:.dockerignoredeja fuera.envysecretos/, y Compose monta los secretos como ficheros en/run/secrets/. - En compose: digests,
depends_onconcondition: service_healthy, red interna sin salida para la base de datos y puertos solo en127.0.0.1. Un revisor en Python comprueba todo eso antes de construir.
Recursos
Research
Documentación
- uv: Using uv in Docker (guía oficial de Astral)
- Docker: Building best practices (fijar la imagen base por digest, USER, varias etapas)
- Docker: Build secrets
- Docker Compose: referencia de services (depends_on, healthcheck)
- Docker Compose: Manage secrets in Compose
- Docker: Packet filtering and firewalls (Docker y ufw)