---
title: "7.2.1 Docker y docker-compose con uv"
description: "Imágenes reproducibles con uv según la guía de Astral: digests, lockfile, caché, multi-stage y usuario sin privilegios, más un compose validado en Python."
url: "https://pythonparatrading.com/curso-python-trading/nivel-7/docker-y-docker-compose-con-uv/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-7/docker-y-docker-compose-con-uv.png"
---

# 7.2.1 · 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.

- Curso: [Nivel 7: Producción, MLOps, regulación, carrera y capstone](https://pythonparatrading.com/curso-python-trading/nivel-7/)
- 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](https://pythonparatrading.com/curso-python-trading/nivel-0/entornos-virtuales-y-dependencias-con-uv/), [0.2.5 · Git y GitHub: commits, ramas, pull requests y .gitignore](https://pythonparatrading.com/curso-python-trading/nivel-0/git-y-github-para-trading/), [4.4.5 · Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos](https://pythonparatrading.com/curso-python-trading/nivel-4/credenciales-seguras-y-permisos-minimos/), [4.5.1 · Arquitectura de un sistema en vivo: de los datos a la reconciliación](https://pythonparatrading.com/curso-python-trading/nivel-4/arquitectura-de-un-sistema-en-vivo/)

## Al terminar esta lección

- Explicar qué fija una imagen de Docker y qué no, y por qué una etiqueta como `python:3.14` no es reproducible y un digest sí.
- Escribir un Dockerfile de varias etapas con uv: lockfile con `--locked`, caché de uv montada, dependencias en su propia capa y usuario sin privilegios.
- Ordenar las instrucciones para que un cambio de código no vuelva a construir ni a descargar las dependencias, y medir la diferencia.
- Escribir un docker-compose para un bot, una base de datos y la monitorización, con secretos en ficheros, comprobaciones de salud y orden de arranque.
- Validar Dockerfiles y ficheros de compose con un revisor escrito en Python antes de construir nada.

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

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

<aside class="callout" data-kind="nota">

El entorno donde se ejecutan las celdas del curso no tiene Docker. Los Dockerfiles y los ficheros de compose de esta
lección se escriben como texto, se analizan y se validan con Python en celdas que se ejecutan de verdad, y los
comandos reales (`docker build`, `docker compose up`) aparecen al final en una celda que no se ejecuta. Los digests de
las imágenes se consultaron en los registros el 23 de octubre de 2026.

</aside>

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

```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")
```
```output
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:

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

```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"])
```
```output
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 ingenuo[^1]: 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.

```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):

```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")
```
```output
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ó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 |

```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}")
```
```output
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 sintaxis[^2]: un patrón por línea, `**`
para cualquier número de directorios y `!` para volver a incluir; la última regla que coincide manda.

```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)))
```
```output
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 distinto[^3]. 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:

```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")
```
```output
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:

```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}")
```
```output
 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 infinito[^4]. 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:

```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")
```
```output
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 **termina**[^5]; 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:

```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 querer[^6]. 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:

```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"]))
```
```output
['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 pase[^7]; 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 contrario[^8].

```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}")
```
```output
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:

```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]))
```
```output
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:

```bash
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:

```python skip
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.

<aside class="callout" data-kind="riesgo">

Fijar digests tiene un coste: las correcciones de seguridad de la imagen base dejan de llegar solas. La documentación
de Docker propone mantenerlos con Dependabot (`package-ecosystem: "docker"`), que abre una PR cuando hay un digest
nuevo. Un digest fijado hace dos años no es reproducibilidad: es una imagen sin parches.

</aside>

## 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.**

```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)
```
```output
('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.

2. 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.**

```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])
```
```output
[('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.

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

[^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/).

## 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?

   - A. Sí: la etiqueta incluye la versión exacta de Python
   - 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
   - C. Sí, porque Docker guarda la imagen en caché para siempre
   - D. No, porque `slim` cambia de distribución cada mes

   *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`?

   - A. Ninguna: son sinónimos
   - B. `--frozen` descarga más rápido porque no comprueba hashes
   - 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
   - D. `--locked` bloquea el entorno para que nadie lo modifique

   *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`?

   - A. Solo se reconstruye la capa del código
   - B. Nada: Docker detecta que las dependencias no han cambiado
   - C. Falla la construcción
   - 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

   *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?

   - 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
   - B. Cifrarla con base64 en el propio compose
   - C. Ponerla en un `ARG` del Dockerfile
   - D. Escribirla en `.env` y hacer `COPY . .`

   *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?

   - A. Sí: ufw bloquea todo lo que no sea el 22
   - B. Sí, si Grafana tiene contraseña
   - 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
   - D. No, porque Grafana abre el puerto por su cuenta

   *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

### Research

- [Docker para trading](https://pythonparatrading.com/docker-para-trading/) · Research-0013 · 4 min

### Documentación

- [uv: Using uv in Docker (guía oficial de Astral)](https://docs.astral.sh/uv/guides/integration/docker/) · docs.astral.sh
- [Docker: Building best practices (fijar la imagen base por digest, USER, varias etapas)](https://docs.docker.com/build/building/best-practices/) · docs.docker.com
- [Docker: Build secrets](https://docs.docker.com/build/building/secrets/) · docs.docker.com
- [Docker Compose: referencia de services (depends_on, healthcheck)](https://docs.docker.com/reference/compose-file/services/) · docs.docker.com
- [Docker Compose: Manage secrets in Compose](https://docs.docker.com/compose/how-tos/use-secrets/) · docs.docker.com
- [Docker: Packet filtering and firewalls (Docker y ufw)](https://docs.docker.com/engine/network/packet-filtering-firewalls/) · docs.docker.com

Lección anterior: [7.1.3 · Patrones: eventos, colas de mensajes y arquitectura hexagonal](https://pythonparatrading.com/curso-python-trading/nivel-7/patrones-event-driven-colas-y-arquitectura-hexagonal/)

Lección siguiente: [7.2.2 · VPS, nube y latencia; CI/CD con GitHub Actions](https://pythonparatrading.com/curso-python-trading/nivel-7/vps-nube-latencia-y-ci-cd/)
