Apuntes DAM
Volver al inicio

Validador de docker-compose: puertos, volúmenes, secretos y orden de arranque

Ejercicio de PythonMuy difícilUnos 90 minutos

Revisa un fichero docker-compose antes de desplegarlo: servicios sin imagen, puertos del anfitrión que chocan, volúmenes sin declarar, imágenes sin versión, contraseñas en claro y dependencias que no existen o forman un ciclo; y calcula el orden de arranque con una ordenación topológica.

  • docker-compose
  • Puertos del anfitrión y del contenedor
  • Volúmenes con nombre y carpetas
  • depends_on
  • Ordenación topológica (Kahn)
  • Secretos fuera del código

Enunciado

Un fichero docker-compose.yml describe una aplicación de varios contenedores: cada servicio con su imagen (o la carpeta con el Dockerfile para construirla), los puertos que publica en el anfitrión, los volúmenes donde guarda sus datos, sus variables de entorno y de qué otros servicios depende.

Muchos errores solo aparecen al ejecutar docker compose up: dos servicios que publican el mismo puerto del anfitrión, un volumen con nombre que no está declarado, una dependencia mal escrita… y otros ni siquiera dan error pero son malas prácticas: imágenes sin versión fija (latest cambia sin avisar) o contraseñas escritas en el fichero, que acaba en Git.

El código de partida ya trae leer_yaml, que convierte el fichero en diccionarios y listas: tú solo tienes que revisarlo.

Qué tiene que hacer el programa

  1. Si no hay sección services (o está vacía): Error: no hay sección services y nada más.
  2. Servicios: a, b, c (por orden alfabético) y, por cada servicio en el orden del fichero, nombre: y sus partes separadas por · : imagen X, build X, puertos 8080→80, aleatorio→3000, 53→53/udp, volumen datos → /ruta o carpeta ./html → /ruta (si el origen empieza por ., / o ~) por cada volumen, y depende de a, b. Un servicio sin nada: (vacío).
  3. Puertos: [IP:]ANFITRIÓN:CONTENEDOR[/tcp|udp] o solo CONTENEDOR (Docker elige el del anfitrión). Si no encaja: error nombre: el puerto «p» no es válido. Si el mismo puerto y protocolo del anfitrión se publica más de una vez: el puerto 8080/tcp del anfitrión lo publican web y api (cada servicio una vez, en el orden del fichero, y app (2 veces) si un mismo servicio lo publica varias).
  4. Errores por servicio: nombre: no tiene image ni build, nombre: el volumen «v» no está declarado en volumes, nombre: depende de sí mismo, nombre: depende de «x», que no existe. Si las dependencias forman un ciclo: dependencia circular entre a, b (los servicios que quedan sin poder arrancar).
  5. Avisos: nombre: la imagen X no fija la versión (se usará latest) (sin etiqueta o con latest) y nombre: CLAVE lleva el secreto en claro; usa una variable (${...}) con un fichero .env o secrets para variables cuyo nombre contiene PASSWORD, SECRET o TOKEN con un valor que no empieza por ${. environment puede ser una lista CLAVE=valor o un mapa.
  6. Después: Errores: ninguno o Errores: y cada uno como - … (primero los de los servicios, en el orden del fichero, después los de puertos y por último el del ciclo); Avisos: y los avisos (solo si hay); y, si no hay errores, Orden de arranque: a → b → c: cada servicio después de los que dependen de él, y entre los que están listos a la vez, el primero por orden alfabético.

Entrada

Un fichero docker-compose.yml (el subconjunto de YAML que lee leer_yaml).

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Una tienda con cuatro servicios

Entrada

services:
  web:
    image: nginx:1.27
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro
    depends_on:
      - api
  api:
    build: ./api
    ports:
      - "8081:3000"
    environment:
      - DB_HOST=db
      - DB_PASSWORD=${DB_PASSWORD}
    depends_on: [db, cache]
  db:
    image: postgres:16
    volumes:
      - datos:/var/lib/postgresql/data
  cache:
    image: redis
volumes:
  datos:

Salida por consola

Servicios: api, cache, db, web
  web: imagen nginx:1.27 · puertos 8080→80 · carpeta ./html → /usr/share/nginx/html · depende de api
  api: build ./api · puertos 8081→3000 · depende de db, cache
  db: imagen postgres:16 · volumen datos → /var/lib/postgresql/data
  cache: imagen redis
Errores: ninguno
Avisos:
  - cache: la imagen redis no fija la versión (se usará latest)
Orden de arranque: cache → db → api → web

Un fichero con muchos problemas

Entrada

services:
  app:
    image: miapp:latest
    ports:
      - 8080:8080
      - "8080:9090"
      - "53:53/udp"
      - "puerto"
    environment:
      MYSQL_ROOT_PASSWORD: secreto123
      API_TOKEN: ""
    volumes:
      - logs:/var/log
    depends_on:
      - bd
      - app
  admin:
    ports:
      - "127.0.0.1:8080:80"

Salida por consola

Servicios: admin, app
  app: imagen miapp:latest · puertos 8080→8080, 8080→9090, 53→53/udp · volumen logs → /var/log · depende de bd, app
  admin: puertos 8080→80
Errores:
  - app: el puerto «puerto» no es válido
  - app: el volumen «logs» no está declarado en volumes
  - app: depende de «bd», que no existe
  - app: depende de sí mismo
  - admin: no tiene image ni build
  - el puerto 8080/tcp del anfitrión lo publican app (2 veces) y admin
Avisos:
  - app: la imagen miapp:latest no fija la versión (se usará latest)
  - app: MYSQL_ROOT_PASSWORD lleva el secreto en claro; usa una variable (${...}) con un fichero .env o secrets

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. Recorre los servicios

datos["services"] es un diccionario nombre → configuración. Para cada uno, monta una lista de partes y únelas con · . Una función lista(v) que convierta None en [] y un valor suelto en [v] simplifica mucho.

2. Los puertos con una expresión regular

Captura la IP opcional, el puerto del anfitrión opcional, el del contenedor y el protocolo. Guarda en un diccionario (puerto, protocolo) → servicios para detectar los que se repiten.

python
re.fullmatch(r"(?:(\d+\.\d+\.\d+\.\d+):)?(?:(\d+):)?(\d+)(?:/(tcp|udp))?", p)
3. El orden de arranque

Algoritmo de Kahn: para cada servicio, el conjunto de dependencias pendientes. Mientras quede alguno, elige el primero (alfabéticamente) sin pendientes, añádelo al orden y quítalo de las dependencias de los demás. Si en algún momento ninguno está listo, hay un ciclo.

4. Errores y avisos

Acumúlalos en dos listas mientras recorres el fichero y escríbelos al final.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 3 casos ocultos que buscan los errores típicos.

🐍PythonValidador de docker-compose: puertos, volúmenes, secretos y orden de arranqueMuy difícil

Ejemplo

Entrada (lo que se escribe por teclado)
services:
  web:
    image: nginx:1.27
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro
    depends_on:
      - api
  api:
    build: ./api
    ports:
      - "8081:3000"
    environment:
      - DB_HOST=db
      - DB_PASSWORD=${DB_PASSWORD}
    depends_on: [db, cache]
  db:
    image: postgres:16
    volumes:
      - datos:/var/lib/postgresql/data
  cache:
    image: redis
volumes:
  datos:
Salida esperada
Servicios: api, cache, db, web
  web: imagen nginx:1.27 · puertos 8080→80 · carpeta ./html → /usr/share/nginx/html · depende de api
  api: build ./api · puertos 8081→3000 · depende de db, cache
  db: imagen postgres:16 · volumen datos → /var/lib/postgresql/data
  cache: imagen redis
Errores: ninguno
Avisos:
  - cache: la imagen redis no fija la versión (se usará latest)
Orden de arranque: cache → db → api → web
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
0/5 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
python
1import re
2import sys
3
4
5def leer_yaml(texto):
6    """Lee el subconjunto de YAML de los ficheros compose: mapas por sangría, listas de valores con «- »,
7    listas en línea [a, b], comillas y comentarios. Devuelve diccionarios, listas y cadenas."""
8    filas = []
9    for linea in texto.split("\n"):
10        if linea.lstrip().startswith("#"):
11            continue
12        sin = linea.split(" #")[0].rstrip()
13        if sin.strip():
14            filas.append((len(sin) - len(sin.lstrip()), sin.strip()))
15
16    def escalar(t):
17        if t.startswith("[") and t.endswith("]"):
18            return [escalar(x.strip()) for x in t[1:-1].split(",") if x.strip()]
19        if len(t) >= 2 and t[0] == t[-1] and t[0] in "\"'":
20            return t[1:-1]
21        return t
22
23    def bloque(i, sangria):
24        if filas[i][1].startswith("- "):
25            lista = []
26            while i < len(filas) and filas[i][0] == sangria and filas[i][1].startswith("- "):
27                lista.append(escalar(filas[i][1][2:].strip()))
28                i += 1
29            return lista, i
30        mapa = {}
31        while i < len(filas) and filas[i][0] == sangria:
32            clave, _, resto = filas[i][1].partition(":")
33            resto = resto.strip()
34            i += 1
35            if resto:
36                mapa[clave.strip()] = escalar(resto)
37            elif i < len(filas) and filas[i][0] > sangria:
38                mapa[clave.strip()], i = bloque(i, filas[i][0])
39            else:
40                mapa[clave.strip()] = None
41        return mapa, i
42
43    return bloque(0, filas[0][0])[0] if filas else {}
44
45
46def lista(v):
47    if v is None:
48        return []
49    return v if isinstance(v, list) else [v]
50
51
52def main():
53    datos = leer_yaml(sys.stdin.read())
54    servicios = datos.get("services") if isinstance(datos, dict) else None
55    if not isinstance(servicios, dict) or not servicios:
56        print("Error: no hay sección services")
57        return
58    volumenes = datos.get("volumes") or {}
59    errores, avisos = [], []
60    publicados = {}                         # (puerto, protocolo) → servicios
61    print(f"Servicios: {', '.join(sorted(servicios))}")
62    for nombre, s in servicios.items():
63        s = s or {}
64        partes = []
65        if "image" in s:
66            partes.append(f"imagen {s['image']}")
67            imagen = s["image"]
68            etiqueta = imagen.rsplit("/", 1)[-1].partition(":")[2]
69            if not etiqueta or etiqueta == "latest":
70                avisos.append(f"{nombre}: la imagen {imagen} no fija la versión (se usará latest)")
71        if "build" in s:
72            partes.append(f"build {s['build']}")
73        if "image" not in s and "build" not in s:
74            errores.append(f"{nombre}: no tiene image ni build")
75        textos_puertos = []
76        for p in lista(s.get("ports")):
77            m = re.fullmatch(r"(?:(\d+\.\d+\.\d+\.\d+):)?(?:(\d+):)?(\d+)(?:/(tcp|udp))?", str(p))
78            if not m:
79                errores.append(f"{nombre}: el puerto «{p}» no es válido")
80                continue
81            ip, anfitrion, contenedor, proto = m.groups()
82            proto = proto or "tcp"
83            if anfitrion is None:
84                textos_puertos.append(f"aleatorio→{contenedor}")
85                continue
86            publicados.setdefault((int(anfitrion), proto), []).append(nombre)
87            textos_puertos.append(f"{anfitrion}→{contenedor}{'/udp' if proto == 'udp' else ''}")
88        if textos_puertos:
89            partes.append(f"puertos {', '.join(textos_puertos)}")
90        for v in lista(s.get("volumes")):
91            origen, _, destino = str(v).partition(":")
92            destino = destino.split(":")[0]
93            if origen.startswith((".", "/", "~")):
94                partes.append(f"carpeta {origen} → {destino}")
95            else:
96                partes.append(f"volumen {origen} → {destino}")
97                if origen not in volumenes:
98                    errores.append(f"{nombre}: el volumen «{origen}» no está declarado en volumes")
99        entorno = s.get("environment")
100        variables = [str(e).partition("=") for e in entorno] if isinstance(entorno, list) else [(k, "=", str(v or "")) for k, v in (entorno or {}).items()]
101        for clave, _, valor in variables:
102            if re.search(r"PASSWORD|SECRET|TOKEN", clave.upper()) and valor and not valor.startswith("${"):
103                avisos.append(f"{nombre}: {clave} lleva el secreto en claro; usa una variable (${{...}}) con un fichero .env o secrets")
104        depende = lista(s.get("depends_on"))
105        if depende:
106            partes.append(f"depende de {', '.join(depende)}")
107        for d in depende:
108            if d == nombre:
109                errores.append(f"{nombre}: depende de sí mismo")
110            elif d not in servicios:
111                errores.append(f"{nombre}: depende de «{d}», que no existe")
112        print(f"  {nombre}: {' · '.join(partes) if partes else '(vacío)'}")
113    for (puerto, proto), quien in publicados.items():
114        if len(quien) > 1:
115            # cada servicio una vez, con las veces que lo publica si son varias
116            nombres = [f"{q} ({quien.count(q)} veces)" if quien.count(q) > 1 else q for q in dict.fromkeys(quien)]
117            errores.append(f"el puerto {puerto}/{proto} del anfitrión lo publican {' y '.join(nombres)}")
118    # orden de arranque: cada servicio después de sus dependencias (Kahn, por orden alfabético en los empates)
119    pendientes = {n: {d for d in lista((s or {}).get("depends_on")) if d in servicios and d != n} for n, s in servicios.items()}
120    orden = []
121    while pendientes:
122        listos = sorted(n for n, deps in pendientes.items() if not deps)
123        if not listos:
124            errores.append(f"dependencia circular entre {', '.join(sorted(pendientes))}")
125            break
126        n = listos[0]
127        orden.append(n)
128        del pendientes[n]
129        for deps in pendientes.values():
130            deps.discard(n)
131    print("Errores:" if errores else "Errores: ninguno")
132    for e in errores:
133        print(f"  - {e}")
134    if avisos:
135        print("Avisos:")
136        for a in avisos:
137            print(f"  - {a}")
138    if not errores:
139        print(f"Orden de arranque: {' → '.join(orden)}")
140
141
142main()

Un puerto del anfitrión solo lo puede escuchar un proceso: si dos contenedores publican el 8080, el segundo no arranca. Los puertos internos de los contenedores, en cambio, se pueden repetir, porque cada contenedor tiene su propia red.

depends_on solo ordena el arranque: no espera a que la base de datos esté lista para aceptar conexiones (para eso está condition: service_healthy con un healthcheck). El orden se calcula con una ordenación topológica, que es imposible si hay un ciclo.

Fijar la versión de la imagen (postgres:16) hace los despliegues reproducibles; latest puede traer una versión mayor nueva cualquier día.

Las contraseñas no deben estar en el fichero, que se sube a Git: se ponen en un .env que no se versiona (${POSTGRES_PASSWORD}) o en secrets.

Para ir más allá

  • Admite la sintaxis larga de depends_on con condition y avisa si una base de datos no tiene healthcheck.
  • Lee también las redes (networks) y comprueba que existen.
  • Genera el .env.example con las variables que el fichero espera.

Dónde se explica