Mike (@mikerb95)CodeByMike

Docker en este proyecto: entorno y pruebas, no despliegue

Esta página no es una guía de Docker ni la documentación de la configuración -esa vive en los propios archivos y en docs/plan-docker.md. Es el material parapoder explicarlo en voz alta: qué conceptos hay que dominar, qué hace cada pieza, por qué se decidió así y qué preguntas conviene tener respondidas de antemano.

8
Conceptos
el vocabulario mínimo
7
Piezas
archivos que intervienen
3
Hallazgos
bugs que enseñaron algo
2/5
Fases
implementadas

La tesis

Docker aquí no despliega la aplicación: reproduce el entorno y da fidelidad a las pruebas

Este proyecto se despliega en Vercel, no en contenedores. Meter la aplicación Astro en un contenedor para producción sería un retroceso: se perderían el edge, los despliegues de previsualización por cada Pull Request y el rollback automático del pipeline, a cambio de nada. El uso profesional de Docker en un stack como este no es empaquetar la aplicación, sino controlar tres cosas que la plataforma no da: el entorno de desarrollo, la infraestructura de pruebas y la cadena de suministro.

Saber dónde NO aplicar una herramienta es parte de la competencia técnica. Un contenedor para producción aquí sería una respuesta memorizada; esto es una decisión de arquitectura.

Conceptos

Cada concepto con su definición, dónde aparece en este proyecto y el malentendido típico que conviene poder corregir si sale en la exposición.

Imagen vs. contenedor

La imagen es una plantilla inmutable de solo lectura: un sistema de archivos congelado más metadatos de cómo arrancarlo. El contenedor es una instancia en ejecución de esa imagen, con una capa de escritura propia encima.

Aquí La imagen del devcontainer se construye una vez desde .devcontainer/Dockerfile; cada vez que se abre el proyecto se crea un contenedor a partir de ella.

Ojo La analogía correcta no es «imagen = ISO, contenedor = máquina virtual». Un contenedor no virtualiza hardware ni arranca un kernel: comparte el kernel del anfitrión y solo aísla su vista del sistema.

Namespaces y cgroups

Los dos mecanismos del kernel de Linux que hacen posible un contenedor. Los namespaces aíslan lo que el proceso VE (su tabla de procesos, su red, sus puntos de montaje); los cgroups limitan lo que CONSUME (CPU, memoria).

Aquí Es la razón de que los dos servidores libSQL puedan escuchar ambos en el puerto 8080 dentro de su propio namespace de red, y se publiquen fuera como 8080 y 8081.

Ojo Un contenedor no es una máquina virtual ligera: no hay hipervisor ni kernel invitado. Por eso arranca en milisegundos, y también por eso el aislamiento es más débil que el de una VM.

Capas y caché de construcción

Cada instrucción del Dockerfile produce una capa apilada sobre la anterior. Si una instrucción y todo lo previo no cambian, Docker reutiliza la capa en vez de reconstruirla.

Aquí Por eso la instalación de Chromium va en su propia instrucción y no mezclada con otras: rehacer la imagen por un cambio menor no vuelve a descargar 114 MB de navegador.

Ojo Borrar un archivo en una capa posterior no lo elimina de la imagen: sigue presente en la capa donde se creó. Por eso un secreto que entra al contexto de construcción puede quedar en la imagen aunque el Dockerfile parezca no copiarlo.

Tag vs. digest

Un tag (:latest, :22.12) es una etiqueta móvil: quien publica la imagen puede reapuntarla a otro contenido cuando quiera. El digest (sha256:…) es el hash del contenido: identifica una imagen exacta e irrepetible.

Aquí Las tres imágenes del proyecto están fijadas por digest. El comentario deja el número de versión legible al lado, pero quien manda es el hash.

Ojo Fijar :22.12 en vez de :latest parece suficiente y no lo es: ese mismo tag se reconstruye con parches distintos. Una reproducibilidad que depende de que nadie mueva un tag no es reproducibilidad.

Volumen y bind mount

La capa de escritura de un contenedor muere con él. Un volumen es almacenamiento gestionado por Docker que sobrevive; un bind mount expone directamente una carpeta del anfitrión dentro del contenedor.

Aquí El código fuente entra por bind mount (se edita en el anfitrión y se ve dentro al instante); los datos de las bases y node_modules van en volúmenes con nombre.

Ojo node_modules va deliberadamente en volumen y no en el bind mount: montarlo desde el anfitrión mezclaría binarios compilados para dos sistemas distintos, y esos fallos no se parecen en nada a su causa.

Red de Compose y resolución por nombre

Compose crea una red privada donde cada servicio es alcanzable por su nombre. La publicación de puertos (8080:8080) es un puente hacia afuera, no cómo se hablan los servicios entre sí.

Aquí Desde dentro del devcontainer la base es http://libsql-main:8080; desde el anfitrión es http://127.0.0.1:8080. Es la misma base vista desde dos lados.

Ojo Dentro de un contenedor, «localhost» es ese contenedor, no la máquina. Apuntar a localhost para hablar con otro servicio es el error más común al empezar con Compose.

Capacidades (capabilities)

Linux parte los privilegios de root en unidades independientes. En vez de «root o no root», se puede conceder exactamente la facultad que hace falta: cambiar dueños de archivo, saltarse permisos, bajar de usuario.

Aquí Los contenedores de base de datos arrancan con cap_drop: ALL y solo cuatro capacidades reañadidas, medidas una por una.

Ojo Ante un «permission denied» la salida fácil es --privileged. Eso devuelve todos los privilegios de golpe y convierte la herramienta en una superficie de ataque nueva.

Compose y devcontainer

Compose declara en un archivo un conjunto de servicios, sus redes y volúmenes, y los levanta juntos. Un devcontainer es un estándar abierto que le dice al editor: «abre este proyecto DENTRO de este contenedor».

Aquí compose.yaml declara las dos bases; .devcontainer/compose.yaml lo reutiliza con include y añade el servicio de desarrollo. Una sola definición de la infraestructura, no dos que acaban divergiendo.

Anatomía

Qué archivo hace qué, y (lo que de verdad preguntan) por qué está hecho así.

compose.yamlDeclara dos servidores libSQL (sqld): el principal y el de la demo

Cada uno con su volumen, su puerto publicado solo en 127.0.0.1 y su configuración de seguridad. Un ancla YAML (&libsql) evita repetir la configuración común en los dos servicios.

Por qué Son dos instancias separadas porque en producción también lo son: el aislamiento de la demo es por construcción, no por filtrar consultas. Si aquí fueran una sola, las pruebas que afirman que la demo nunca filtra datos reales pasarían por accidente.

.devcontainer/DockerfileConstruye el entorno de desarrollo

Parte de Node 22.12 fijado por digest, instala las dependencias de sistema de Chromium como root, y luego los navegadores de Playwright como usuario sin privilegios.

Por qué La versión de Playwright se pasa como argumento y debe coincidir con la del package.json: Playwright se niega a usar navegadores instalados por otra versión, y descubrirlo en el pipeline es tarde. Se instala solo Chromium porque es el único navegador declarado en la configuración de pruebas.

.devcontainer/compose.yamlUne el entorno de desarrollo con las bases

Incluye el compose raíz y añade el servicio de desarrollo, con el código montado desde el anfitrión y las variables de entorno apuntando a las bases por nombre de servicio.

Por qué Usa include en vez de copiar la definición de las bases. Dos definiciones de la misma infraestructura acaban divergiendo, y la que se rompe siempre es la que nadie mira.

.devcontainer/devcontainer.jsonLe dice al editor cómo abrir el proyecto dentro del contenedor

Servicio a usar, usuario, carpeta de trabajo, extensiones, puertos reenviados y el npm ci posterior a la creación.

Por qué El npm ci es obligatorio: el volumen de node_modules nace vacío y sin él el primer arranque deja el proyecto sin dependencias, fallando con un error que no las menciona.

.dockerignoreExcluye archivos del contexto de construcción

Variables de entorno, .git, node_modules, artefactos de compilación y documentos binarios.

Por qué No es solo peso. Es higiene de cadena de suministro: un secreto que llega al contexto puede quedar en una capa de la imagen aunque el Dockerfile no lo copie nunca.

scripts/wait-libsql.mjsEspera a que las bases acepten conexiones

Sondea el endpoint de salud de cada servidor hasta que responde o se agota el plazo.

Por qué Compose devuelve el control cuando el contenedor arrancó, no cuando el proceso de dentro está listo. Sembrar en ese hueco falla de forma intermitente, que es el peor tipo de fallo en una suite de pruebas. Se hace desde el anfitrión con Node y no con un healthcheck de Compose para no depender de qué binarios trae la imagen de sqld.

playwright.config.tsElige el modo de base de datos de las pruebas end-to-end

Con E2E_DB_MODE=server apunta a los contenedores; sin esa variable, a bases en archivo. La suite de pruebas es exactamente la misma.

Por qué El modo por archivo sigue siendo el predeterminado a propósito. Obligar a levantar contenedores para correr las pruebas sería cambiar una prueba que funciona por una que además hay que administrar. El pipeline no cambia.

Decisiones

Las cinco preguntas incómodas y su respuesta preparada.

¿Por qué no se despliega el proyecto en un contenedor?

Porque la plataforma de despliegue ya resuelve mejor ese problema. Contenerizar la aplicación costaría el edge, los despliegues de previsualización por Pull Request y el rollback automático del pipeline, sin ganar portabilidad real: la base de datos es un servicio gestionado y el resto del sistema es código. El contenedor entra donde sí hay un problema abierto - el entorno y las pruebas.

¿Qué gana una prueba corriendo contra un servidor en contenedor en vez de un archivo?

Fidelidad de protocolo. La base de producción se habla por HTTP, no por sistema de archivos. Un archivo local no ejerce la misma ruta de código del cliente, ni el mismo manejo de conexiones, ni la misma semántica de transacciones concurrentes. Las pruebas donde eso importa son justamente las de pagos y aislamiento del portal, que son las que más caro salen si dan un falso positivo.

¿Por qué esas cuatro capacidades y no simplemente --privileged?

Porque se midieron. Con todas las capacidades retiradas, el servidor moría en bucle; se fueron reañadiendo de una en una hasta encontrar el mínimo que funciona. El resultado explica la secuencia de arranque del programa: crea su directorio de datos en un volumen ajeno, se adueña de él y baja de privilegios antes de ejecutarse. Una quinta capacidad candidata resultó innecesaria y no se incluyó.

¿Por qué el sembrador solo acepta destinos locales?

Porque borra el esquema del destino antes de sembrarlo. Mientras solo aceptaba rutas de archivo, un error de configuración estropeaba una prueba; al admitir direcciones HTTP, el mismo error podría borrar una base real. La restricción enumera lo permitido y no lo prohibido: una lista de permitidos falla cerrada, una de prohibidos falla abierta en cuanto aparece un caso que nadie previó.

¿Por qué el modo con contenedores no es el predeterminado?

Porque tendría un coste permanente para todos y un beneficio concentrado en unas pocas pruebas. El modo por archivo no necesita Docker, arranca antes y es lo que ya corre en el pipeline. El modo servidor está disponible con una variable de entorno para cuando la pregunta que se investiga es de concurrencia o de transacciones.

Hallazgos

Tres fallos reales de la implementación. Valen más que la configuración final: son lo que demuestra que el trabajo se hizo, no se copió.

01

El mínimo de privilegios se mide, no se supone

Síntoma
Con todas las capacidades retiradas, los dos contenedores de base de datos entraban en bucle de reinicio y no llegaban a escuchar.
Causa
El arranque del servidor crea su directorio de datos dentro de un volumen que no le pertenece, se adueña de él y luego baja de privilegios: tres operaciones que necesitan capacidades distintas.
Lección
La reacción natural ante un fallo de permisos es devolver todos los privilegios. Ese reflejo es exactamente cómo los contenedores acaban corriendo abiertos en producción. Buscar el mínimo cuesta unos minutos y deja la configuración explicada.
02

Una prueba mal escrita afirma menos de lo que parece

Síntoma
La primera medición de capacidades dio por bueno un conjunto que en realidad no funcionaba, y el fallo reapareció al levantar el entorno de verdad.
Causa
La sonda buscaba el texto «Permission denied» en los registros, pero el fallo real decía «Operation not permitted». Un mensaje distinto para el mismo problema bastó para que la prueba mintiera.
Lección
Es un caso de estudio de testing perfecto: un aserto sobre un mensaje de error es un aserto sobre una cadena de texto, no sobre el comportamiento. El criterio correcto era el que se usó después - comprobar que el servidor respondiera.
03

Un fallo que sale con código de éxito

Síntoma
Con un servidor de desarrollo abierto en otra terminal, las pruebas end-to-end morían con «el proceso del servidor web terminó antes de tiempo», sin más explicación.
Causa
El framework mantiene un bloqueo global de servidor de desarrollo. El segundo arranque no fallaba por puerto ocupado: imprimía «ya hay un servidor corriendo» y terminaba con código de éxito, así que el corredor de pruebas solo veía un proceso que se fue.
Lección
Un proceso que falla pero devuelve código 0 es invisible para quien lo orquesta. El arreglo destraba las pruebas locales con Docker y sin él, y no tiene nada que ver con contenedores: apareció porque montar la infraestructura nueva obligó a ejercer un camino que nadie ejercía.

Comandos

Los ocho que hay que poder teclear sin dudar durante una demostración en vivo.

ComandoQué hace
npm run db:upLevanta los dos servidores libSQL y espera a que ambos respondan antes de devolver el control.
npm run db:seedAplica las migraciones y siembra datos ficticios en ambas bases.
npm run db:resetElimina los volúmenes y vuelve a levantar desde cero. El comando para cuando el estado local es sospechoso.
npm run db:downDetiene los contenedores conservando los datos.
npm run test:e2eSuite end-to-end en el modo predeterminado, con bases en archivo y sin Docker.
npm run test:e2e:serverLa misma suite contra los servidores en contenedor.
docker compose psEstado de los servicios: cuáles están arriba y desde cuándo.
docker compose logs libsql-mainRegistros del servidor principal. El primer sitio donde mirar cuando un contenedor reinicia en bucle.

Qué estudiar

Ordenado por lo que cuesta perder puntos si falta, no por dificultad.

Imagen, contenedor, capas y caché de construcción

Imprescindible

Es el vocabulario mínimo. Sin esto no se puede explicar por qué una reconstrucción tarda 2 segundos o 3 minutos.

Diferencia real entre contenedor y máquina virtual (kernel compartido)

Imprescindible

Es la pregunta de examen más frecuente y donde más se nota si el concepto está memorizado o entendido.

Volúmenes, bind mounts y persistencia

Imprescindible

Explica por qué los datos sobreviven a un reinicio pero no a un db:reset, y por qué node_modules no se monta desde el anfitrión.

Redes de Compose y resolución por nombre de servicio

Imprescindible

Permite responder por qué la misma base es libsql-main:8080 por dentro y 127.0.0.1:8080 por fuera.

Capacidades de Linux y principio de mínimo privilegio

Para destacar

Es el punto donde esta implementación se separa de un uso escolar de Docker. Hay un hallazgo propio que contar.

Fijación por digest y reproducibilidad

Para destacar

Distingue «sé usar Docker» de «entiendo por qué mi construcción es o no reproducible».

Namespaces y cgroups por encima

Recomendado

No hace falta dominarlos, pero nombrarlos correctamente sostiene la respuesta sobre contenedor vs. máquina virtual.

Cadena de suministro: SBOM, escaneo de vulnerabilidades, firma de imágenes

Para destacar

Es el estado del arte actual y las fases pendientes del plan. Mencionarlo con criterio marca el techo de la exposición.

Preguntas probables

Cada una desplegable: conviene intentar responderla antes de abrirla.

¿Un contenedor es una máquina virtual ligera?

No. Una máquina virtual emula hardware y arranca su propio kernel; un contenedor comparte el kernel del anfitrión y solo aísla su vista del sistema mediante namespaces, limitando recursos con cgroups. De ahí que arranque en milisegundos, y también que su aislamiento sea más débil: una vulnerabilidad del kernel afecta a todos los contenedores de la máquina.

Si no despliegan en Docker, ¿para qué lo usan?

Para tres cosas que la plataforma de despliegue no da: un entorno de desarrollo idéntico en cualquier máquina, una infraestructura de pruebas con el mismo protocolo de base de datos que producción, y herramientas de análisis aisladas y con versión fija. El despliegue lo resuelve mejor la plataforma; el entorno y las pruebas no los resolvía nadie.

¿Qué pasa si borro un contenedor? ¿Pierdo los datos?

Depende de dónde estén. La capa de escritura del contenedor muere con él, pero los datos de las bases viven en volúmenes con nombre gestionados por Docker, así que sobreviven. Solo se pierden al eliminar los volúmenes explícitamente, que es lo que hace el comando de reinicio limpio.

¿Por qué fijan las imágenes por digest y no por versión?

Porque un tag es una etiqueta móvil: quien publica la imagen puede reapuntarla a otro contenido, y el mismo tag de versión se reconstruye con parches distintos. El digest es el hash del contenido, así que identifica una imagen exacta. Si la reproducibilidad depende de que nadie mueva un tag, no es reproducibilidad.

¿Cómo sabes que las pruebas realmente corren contra el contenedor?

Porque las migraciones del ORM y el sembrador completo se aplican sobre él por HTTP, sin ningún cambio de código: 51 tablas migradas y miles de registros sembrados. Si estuviera hablando con un archivo local, el protocolo sería otro y el servidor no registraría esas operaciones.

Ese contenedor corre como root. ¿No es un riesgo?

El proceso del servidor baja de privilegios durante su arranque; lo que se controló es qué puede hacer antes de bajarlos. Se retiraron todas las capacidades y se reañadieron solo las cuatro que se midieron como imprescindibles, se prohíbe la escalada de privilegios y los puertos se publican únicamente en la interfaz local, no en la red.

¿Qué falta por hacer?

Tres fases planificadas: llevar las herramientas de análisis del laboratorio a contenedores con versión fija, inyectar fallos de red reales entre la aplicación y la base para validar la política de degradación elegante, y cerrar la cadena de suministro con inventario de dependencias, escaneo de vulnerabilidades y firma de imágenes.

Fases

Plan completo en docs/plan-docker.md. Poder nombrar lo que falta, y por qué está planificado así, vale tanto como lo entregado.

F1

Entorno de desarrollo reproducible

Implementada

Devcontainer con Node y navegador de pruebas fijados por digest. Elimina la clase de fallo en que la versión del entorno rompe la compilación.

F2

Base de datos real en las pruebas

Implementada

Dos servidores libSQL en contenedor, con la suite end-to-end capaz de correr contra ellos mediante una variable de entorno.

F3

Herramientas del laboratorio en contenedor

Pendiente

Análisis dinámico de seguridad, pruebas de carga y escaneo de dependencias como imágenes con versión fija, idénticas en local y en el pipeline.

F4

Inyección de fallos de red

Pendiente

Un intermediario entre la aplicación y la base que introduce latencia y cortes que el código no sabe que están ocurriendo. Es lo que valida de verdad la política de degradación elegante.

F5

Cadena de suministro verificable

Pendiente

Inventario de dependencias por imagen, escaneo de vulnerabilidades integrado al panel del laboratorio, y firma con procedencia verificable.