Validador de docker-compose: puertos, volúmenes, secretos y orden de arranque
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
- Si no hay sección
services(o está vacía):Error: no hay sección servicesy nada más. 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 → /rutaocarpeta ./html → /ruta(si el origen empieza por.,/o~) por cada volumen, ydepende de a, b. Un servicio sin nada:(vacío).- Puertos:
[IP:]ANFITRIÓN:CONTENEDOR[/tcp|udp]o soloCONTENEDOR(Docker elige el del anfitrión). Si no encaja: errornombre: 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, yapp (2 veces)si un mismo servicio lo publica varias). - 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). - Avisos:
nombre: la imagen X no fija la versión (se usará latest)(sin etiqueta o conlatest) ynombre: CLAVE lleva el secreto en claro; usa una variable (${...}) con un fichero .env o secretspara variables cuyo nombre contiene PASSWORD, SECRET o TOKEN con un valor que no empieza por${.environmentpuede ser una listaCLAVE=valoro un mapa. - Después:
Errores: ningunooErrores: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 secretsGuí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.
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.
Ejemplo
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:
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
Solución explicada
Ver la solución completa
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
conditiony avisa si una base de datos no tiene healthcheck. - Lee también las redes (
networks) y comprueba que existen. - Genera el
.env.examplecon las variables que el fichero espera.