1. Contrato de archivos del servicio
Ahora el directorio de exportación ya viene de fuera, la ruta al archivo se construye a través de Path, el nombre del archivo ya no depende de caracteres aleatorios y el directorio se verifica de antemano para escritura. Queda lo más práctico: ver cómo se comporta este contrato cuando el contenedor no solo se restartean, sino que realmente se elimina y se crea de nuevo.
Cuando por primera vez hacemos que un servicio Spring Boot escriba CSV en disco, surge la sensación de «bueno, la exportación funciona». Es exactamente el momento en que el ingeniero dentro de nosotros debe preguntar cortésmente (sin gritar): ¿y qué garantiza exactamente el servicio sobre estos archivos? El contrato de archivos es un conjunto de reglas que hace predecible la exportación: dónde escribimos, cómo nombramos, si sobrescribimos, qué devolvemos al cliente y qué sucede en ejecuciones repetidas del contenedor.
En términos simples, el contrato de archivos es un «acuerdo» entre su servicio y el mundo exterior. El mundo exterior en nuestro curso es usted como desarrollador, que quiere ver el resultado en la máquina host, y Docker, que adora recrear contenedores (y no siente el más mínimo remordimiento). Si el contrato no está formulado, la exportación puede convertirse en el «gato de Schrödinger»: parece que el archivo existe, pero solo mientras no se elimine el contenedor. Y el contenedor, como sabemos, puede desaparecer más rápido de lo que se enfría su café.
Vamos a fijar en qué consiste un contrato de archivos mínimo predecible para nuestro Container-Ready Catalog Service:
| Parte del contrato | Qué fijamos | Por qué es importante en el contenedor |
|---|---|---|
| Un único punto de verdad para el directorio | Dentro de la aplicación — app.export.dir, fuera — APP_EXPORT_DIR; dentro del contenedor es el target path | Sin esto, la ruta se dispersa por el código y empieza a depender de «cómo está en el portátil del autor» |
| Preparación del directorio | Se crea el directorio, se verifica que es un directorio y que se puede escribir en él | Es mejor detectar errores de permisos y montaje con antelación, no «en medio de la escritura del archivo» |
| Nombre del archivo | Generamos un nombre seguro, adecuado para el sistema de archivos | De lo contrario, : y otros caracteres rompen repentinamente la exportación o convierten el nombre en una ruta |
| Política de sobrescritura | Por defecto, no sobrescribimos el archivo existente «en silencio» | Con solicitudes repetidas y recreación del contenedor, es fácil destruir el resultado anterior sin darse cuenta |
| Resultado de la operación | Devolvemos como mínimo fileName; la ruta completa se puede mantener dentro de la aplicación | La exportación debe ser verificable: «¿qué archivo se creó?» no es una pregunta filosófica |
| Comportamiento en reinicio | Los archivos importantes viven fuera del writable layer mediante mount | De lo contrario, docker rm destruirá el resultado junto con el contenedor |
Estas decisiones no hacen que el servicio sea «ideal» ni «enterprise-ready». Pero lo hacen explicable, y eso en un proyecto educativo es casi un superpoder.
2. Dónde se verifica realmente el contrato: restart y rm
Mientras pruebas la exportación dentro de un solo contenedor, todo parece fiable incluso donde en realidad no hay nada fiable. docker restart engaña fácilmente: el archivo permanece porque el contenedor es el mismo y el writable layer es el mismo. La verificación real comienza con docker rm y un nuevo docker run.
Aquí hay una matriz práctica que rápidamente vuelve a la realidad:
| Acción con el contenedor | Qué sucede con el writable layer | Archivos en writable layer | Archivos en el directorio de exportación montado |
|---|---|---|---|
| docker restart | La capa se conserva | Normalmente permanecen | Permanecen |
| docker stop → docker start | La capa se conserva | Normalmente permanecen | Permanecen |
| docker rm | La capa se elimina | Desaparecen | Permanecen, porque viven fuera del contenedor |
| Nuevo docker run desde la misma imagen | Será una nueva capa | Los archivos antiguos no aparecerán | Los archivos antiguos están en el host/volume, el nuevo contenedor los verá |
Aquí es donde el bind mount o el named volume cambian la naturaleza del resultado. Si el directorio de exportación está montado fuera, el archivo deja de depender del destino de un contenedor específico.
Para la exportación CSV en este día, el bind mount es más natural que el named volume: necesitas el archivo en la máquina host como un artefacto normal. El named volume también sobrevivirá a la recreación del contenedor, pero entonces el CSV vivirá «dentro de Docker», no directamente en la carpeta del proyecto.
3. Escenario: exportación y recreación del contenedor
Tomemos el mismo servicio y conectemos el directorio de exportación hacia afuera. Supongamos que el directorio data/exports junto al proyecto ya existe:
docker run --name catalog-service --rm \
-p 8080:8080 \
-e APP_EXPORT_DIR=/app/exports \
--mount type=bind,source="$(pwd)/data/exports",target=/app/exports \
docker-java-catalog-service
Aquí ocurren dos cosas clave.
Primero, APP_EXPORT_DIR=/app/exports establece el target path dentro del contenedor — el lugar donde el servicio realmente escribe el archivo.
Segundo, el bind mount conecta a esa ruta el directorio real de la máquina host. Es decir, /app/exports dentro del contenedor y data/exports/ en tu proyecto se convierten en dos entradas al mismo directorio.
Ahora llamas a POST /api/catalog/exports, y el servicio crea un archivo, por ejemplo catalog-20260321-101500.csv. Aparece inmediatamente:
- en la máquina host en data/exports/;
- dentro del contenedor en la ruta /app/exports.
Luego eliminas el contenedor:
docker rm -f catalog-service
Y ejecutas un nuevo contenedor con el mismo comando. El archivo antiguo todavía está en data/exports/, porque vivía no en el writable layer, sino en el directorio montado. El nuevo contenedor lo verá en la misma ruta target.
Puedes verificarlo de la manera más directa:
docker exec catalog-service ls -l /app/exports
Si todo está configurado correctamente, dentro del nuevo contenedor verás el mismo CSV que sobrevivió a la eliminación del contenedor anterior.
Es en este momento cuando el contrato de archivos deja de ser teoría. Sin el mount, la misma prueba terminaría con un directorio vacío o su ausencia: junto con el contenedor antiguo desaparecería también el writable layer.
4. Nombre de archivo y política de sobrescritura
Tan pronto como el directorio de exportación está montado fuera, un nombre inofensivo como catalog.csv se convierte rápidamente en una trampa. El nuevo contenedor ve el archivo antiguo. Una nueva solicitud de exportación ve el archivo antiguo. Si por defecto siempre escribes en el mismo nombre, o sobrescribes el resultado anterior o empiezas a vivir en el caos de «¿cuál de estos catalog.csv estoy viendo ahora?».
Por lo tanto, la línea base segura aquí es simple: nombre único + prohibición de sobrescritura silenciosa. La unicidad proporciona un historial legible de exportaciones, y la prohibición de sobrescritura hace que el conflicto sea visible de inmediato, no después de media hora de investigación.
Si exportDir ya está preparado y normalizado, la escritura puede verse así:
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
Path file = exportDir.resolve(fileName).normalize();
if (!file.startsWith(exportDir)) {
throw new IllegalArgumentException("Bad file name: " + fileName);
}
Files.writeString(file, csv, StandardOpenOption.CREATE_NEW);
CREATE_NEW es muy útil aquí. Si el nombre coincide con un archivo existente, obtienes un error explícito en lugar de perder silenciosamente el resultado anterior. Para la exportación, esto es casi siempre más honesto.
Y sí, por eso el nombre de archivo seguro del paso anterior no es cosmética. Mientras el archivo vivía solo dentro de un contenedor, un mal nombre aún podía no tener consecuencias. Tan pronto como el archivo comenzó a sobrevivir a la recreación del contenedor, el naming y la política de sobrescritura se convirtieron en parte del contrato, no en «detalles de implementación».
5. El resultado de la exportación debe ser verificable
Cuando la exportación termina simplemente con un 200 OK y luego hay que ir al contenedor y adivinar qué se creó exactamente, el contrato es débil. En la API externa suele ser suficiente devolver fileName y itemsCount. La ruta completa es útil dentro de la aplicación — para registros, ExportJob y diagnóstico.
El resultado interno puede verse así:
import java.nio.file.Path;
// Modelo interno del resultado: qué creamos y dónde se encuentra dentro del proceso
public record ExportResult(String fileName, Path file) {
}
Esto no significa que sea necesario devolver la ruta absoluta del contenedor hacia afuera. Al contrario, al cliente le suele bastar con el nombre del archivo. Pero dentro de la aplicación, Path es útil: facilita registrar la escritura, llenar ExportJob, diagnosticar problemas y vincular la respuesta de la API con el CSV real en el disco.
En ese momento, la API, el sistema de archivos y el entorno del contenedor dejan de vivir vidas diferentes. Tienes un resultado claro que se puede encontrar tanto inmediatamente después de la solicitud como después de la recreación del contenedor.
6. Errores típicos al exportar archivos
Error n.º 1: “El archivo se creó — significa que está guardado”.
A menudo, la primera exportación se verifica así: «entré al contenedor a través de docker exec, vi el archivo — todo perfecto». Pero esto es solo una verificación del hecho de escritura en el writable layer. En cuanto se elimine el contenedor, el archivo desaparecerá. Si el resultado debe sobrevivir a la sustitución del contenedor, sin mount esto no es almacenamiento del resultado, sino una ilusión temporal.
Error n.º 2: Ruta hardcodeada en el código en lugar de app.export.dir / APP_EXPORT_DIR.
Una ruta como Path.of("/tmp/exports") puede funcionar localmente e incluso en el contenedor, pero esto rompe el principio principal del curso: la misma imagen debe ejecutarse en diferentes entornos sin recompilación ni modificación del código. La ruta del directorio debe vivir en la configuración, no en el método de negocio.
Error n.º 3: Un solo archivo catalog.csv y sobrescritura silenciosa.
Cuando la exportación siempre escribe en el mismo nombre, las solicitudes repetidas o nuevos contenedores comienzan a sobrescribir los resultados. Esto es especialmente molesto con bind mount: piensas que estás guardando el historial de exportaciones, pero en realidad solo tienes la última. Los nombres únicos y CREATE_NEW hacen que el conflicto sea visible de inmediato.
Error n.º 4: Faltan verificaciones del directorio y el error aparece «en algún lugar dentro de la escritura».
Si el directorio no existe o no es escribible, sin preparación previa obtendrás una excepción desde las profundidades de la API de archivos, y se verá como un «IOException al escribir». A un principiante le cuesta entender que el problema no está en el CSV ni en el código de exportación, sino en el entorno de ejecución. La preparación del directorio y la validación temprana hacen que el error sea corto y honesto: «no se puede escribir en el directorio».
Error n.º 5: Confundir «reinicio del contenedor» y «recreación del contenedor».
docker restart reinicia el mismo contenedor, su writable layer generalmente permanece. Por lo tanto, se crea la ilusión de que «los archivos se guardan». Pero tan pronto como eliminas el contenedor y creas uno nuevo, el writable layer desaparece. Si no distingues estas operaciones, vives fácilmente con la falsa seguridad de que la exportación ya es fiable, hasta que un día ejecutas docker rm y pierdes todo.
GO TO FULL VERSION