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.

Fig. — Capas nuevas de la imagen final en diez commits simulados, con el Dockerfile ingenuo y con el de uv en dos etapas: 3.626,2 MB frente a 357,7 MB, casi todo en el único commit que cambia uv.lock
Lecciones del nivel 7
  1. 7.1 · Ingeniería de software para trading

    1. 7.1.1 Estructura de proyecto, configuración y calidad: del notebook al repositorio
    2. 7.1.2 Rendimiento: medir, perfilar y compilar solo lo que hace falta
    3. 7.1.3 Patrones: eventos, colas de mensajes y arquitectura hexagonal
  2. 7.2 · Infraestructura y despliegue

    1. 7.2.1 Docker y docker-compose: imágenes reproducibles con uv
    2. 7.2.2 VPS, nube y latencia; CI/CD con GitHub Actions
    3. 7.2.3 Orquestación y monitorización: grafos de tareas, logs estructurados, métricas, alertas y latidos
    4. 7.2.4 Seguridad y continuidad: 2FA, IP, copias y procedimientos ante fallos
  3. 7.3 · MLOps para modelos de trading

    1. 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
    2. 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
    3. 7.3.3 Monitorización del modelo en vivo y desactivación automática
    4. 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
  4. 7.4 · Regulación, ética y fiscalidad

    1. 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
    2. 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
    3. 7.4.3 PRIIPs, KID y ETFs estadounidenses
    4. 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
    5. 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
    6. 7.4.6 Fiscalidad del trading en España
    7. 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
  5. 7.5 · Carrera profesional quant

    1. 7.5.1 Roles del quant: seis oficios alrededor de un mismo sistema
    2. 7.5.2 Dónde trabajar: qué hace cada empresa y qué dicen los registros
    3. 7.5.3 Portfolio público y entrevistas: enseñar criterio, no rentabilidades
    4. 7.5.4 Certificaciones, formación y prop firms: qué certifica cada cosa y qué mide un examen

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:

uv.lockdocker buildBase con digestCódigo en gitImagen con digestRegistroServidor
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:

IN [01]python
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")
OUT [01]El repositorio del bot
16 ficheros, 5.02 MB

El 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:

IN [02]python
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:

IN [03]python
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"])
OUT [03]Un lector de Dockerfiles
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.

Lock y pyprojectEtapa builderuv y su caché.venvImagen finalsrc/
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):

IN [04]python
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")
OUT [04]El Dockerfile de producción
14 instrucciones en 2 etapas

Una 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ódigoReglaPor qué
DF01toda imagen externa con digestlas etiquetas son mutables
DF02la etapa final no corre como rootuna fuga del proceso no es una fuga del sistema
DF03uv sync con --locked o --frozen; nada de pip install sueltoel lockfile manda
DF04las dependencias se instalan en otra etapainstalador y caché fuera de la imagen
DF05HEALTHCHECK en la etapa finalproceso vivo no es bot sano
DF06ningún secreto en ENV ni ARGquedan en la imagen
DF07COPY . después de instalar dependenciasla caché de capas
DF08uv sync con la caché de uv montadano volver a descargar ruedas
DF09.env y secretos/ fuera del contextoCOPY . los metería dentro
IN [05]python
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}")
OUT [05]Las reglas DF01 a DF08
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 hallazgos

Nueve 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.

IN [06]python
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)))
OUT [06]El contexto de construcción y .dockerignore
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:

IN [07]python
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")
OUT [07]Lo que pesan las dependencias del bot
32 paquetes, 357.6 MB instalados
  pyarrow     168.2 MB
  numpy        56.3 MB
  pandas       39.1 MB
  ccxt         39.1 MB

Ahora 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:

IN [08]python
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}")
OUT [08]Diez commits: capas nuevas con cada Dockerfile
 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.7

Con 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:

IN [09]python
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")
OUT [09]bot.salud: el latido y cuánto tarda en detectarse un cuelgue
latido hace  30 s -> 0
latido hace  90 s -> 0
latido hace  91 s -> 1
sin fichero -> 1
peor caso: 185 s

Con 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:

grafanaprometheusbotpostgresExchange
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:

IN [10]python
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"]))
OUT [10]Dos ficheros de compose
['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/)..

IN [11]python
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}")
OUT [11]Las reglas DC01 a DC07
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 hallazgos

Diez 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:

IN [12]python
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]))
OUT [12]Orden de arranque y de parada
arranque: db+prometheus -> bot+grafana
parada:   bot+grafana -> db+prometheus

La 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:

IN [13]python
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

  1. Generaliza la regla DC05 para que marque cualquier servicio sin healthcheck del que otro dependa con condition: service_healthy, y añade una regla DC08 que marque los servicios con build sin image (sin etiqueta, el registro de despliegues no puede decir qué se desplegó).
Solución
IN [14]python
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)
OUT [14]DC05 general y DC08
('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.

  1. Añade al revisor de Dockerfiles una regla DF10 que marque ADD con una URL sin --checksum, y pruébala con ADD 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
IN [15]python
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])
OUT [15]DF10: descargas sin checksum
[('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.

  1. Un compañero dice que fijar digests es exagerado para un bot personal: «con python:3.14-slim me 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

  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. ↩

  2. Docker, «Build context», sección «.dockerignore files» (https://docs.docker.com/build/concepts/context/). ↩

  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». ↩

  4. Docker, «Dockerfile reference», HEALTHCHECK (https://docs.docker.com/reference/dockerfile/): valores por defecto --interval=30s, --timeout=30s, --start-period=0s, --retries=3. ↩

  5. Docker, «Start containers automatically» (https://docs.docker.com/engine/containers/start-containers-automatically/). ↩

  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». ↩

  7. Docker, «Compose file reference: services», depends_on (https://docs.docker.com/reference/compose-file/services/). ↩

  8. 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

  1. 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 slim cambia 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 contenido

    La 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 docker abre esas PR).

  2. ¿Qué diferencia hay entre uv sync --locked y uv sync --frozen?

    • ANinguna: son sinónimos
    • B--frozen descarga más rápido porque no comprueba hashes
    • CLas dos instalan lo que dice uv.lock sin cambiarlo; --locked además falla si el lockfile no está al día con pyproject.toml, y --frozen no lo comprueba
    • D--locked bloquea el entorno para que nadie lo modifique
    Ver respuesta

    Respuesta C. Las dos instalan lo que dice uv.lock sin cambiarlo; --locked además falla si el lockfile no está al día con pyproject.toml, y --frozen no lo comprueba

    Según la referencia de uv, --locked exige que el lockfile esté al día y sale con error si habría que actualizarlo; --frozen usa el lockfile como fuente de verdad sin comprobarlo. La guía de Astral usa --frozen en la primera sincronización, cuando solo está montado uv.lock; en la lección se montan los dos ficheros y se usa --locked.

  3. En el Dockerfile ingenuo, COPY . . va antes de uv sync. ¿Qué pasa al cambiar una línea de estrategia.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ínea

    Docker 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.lock y pyproject.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.

  4. 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 imagen POSTGRES_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 ARG del Dockerfile
    • DEscribirla en .env y hacer COPY . .
    Ver respuesta

    Respuesta A. Declararla en secrets: y pasar a la imagen POSTGRES_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

    Las 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 _FILE y __FILE.

  5. 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:3000 y 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:3000 y entrar por un túnel SSH

    La 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.lock en el repositorio, uv sync --locked (o --frozen si aún no está pyproject.toml), caché montada con --mount=type=cache y UV_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, ARG ni en el contexto de construcción: .dockerignore deja fuera .env y secretos/, y Compose monta los secretos como ficheros en /run/secrets/.
  • En compose: digests, depends_on con condition: service_healthy, red interna sin salida para la base de datos y puertos solo en 127.0.0.1. Un revisor en Python comprueba todo eso antes de construir.

Recursos