Hacía años que un error no me atascaba de verdad, de esos en los que uno cierra la laptop, la vuelve a abrir una hora después y el problema sigue ahí, sonriéndole a uno con descaro.
Este duró diez horas. Además, era mi primer proyecto en Java/Kotlin, y eso es parte de la historia. Tengo que admitirlo: disfruté el rompecabezas. Esa sensación de rodear algo que debería ser imposible y luego acorralarlo poco a poco es buena parte de la razón por la que me dediqué a esto.
El error: las subidas de imágenes a Cloudflare R2 fallaban una y otra vez con un 500. Todos los archivos de configuración eran correctos. Todas las variables de entorno estaban definidas. Aun así, la aplicación apuntaba al servidor equivocado. Esto es lo que me enseñó la búsqueda: lecciones que valen para cualquier backend, con un toque de Spring Boot.
El escenario
Un servicio de Spring Boot sube fotos de propiedades a Cloudflare R2 (almacenamiento compatible con S3). Las subidas devolvían HTTP 500. El stack trace apuntaba a una biblioteca de redimensionamiento de imágenes que lanzaba UnsupportedFormatException. El culpable parecía obvio: quitar el redimensionamiento, volver a desplegar y listo.
No estaba listo. Esa fue la lección cero: el error más ruidoso no siempre es el verdadero.
Lección 1: verifique lo que realmente se está ejecutando
El código de redimensionamiento ya se había eliminado del código fuente. Aun así, el fallo seguía apareciendo con marcas de tiempo recientes. La razón era vergonzosamente simple: el .jar en ejecución era un build antiguo. Cada corrección de las horas anteriores vivía en archivos fuente que nunca se habían compilado en el artefacto desplegado.
Un archivo de configuración en disco, una línea corregida en application.yml, una corrección en un archivo .kt: nada de eso importa hasta que se compila y el proceso se reinicia desde el nuevo build. Cuando el comportamiento contradice su código fuente, su primera pregunta debería ser: ¿el proceso siquiera está ejecutando este código?
Cómo comprobarlo, en concreto:
- Confirme que la marca de tiempo del artefacto de build es más reciente que su último cambio.
- Lea la configuración dentro del artefacto en ejecución. Para un jar de Spring Boot:
unzip -p app.jar BOOT-INF/classes/application.yml. - Busque el síntoma con grep en los logs en vivo, con un filtro de tiempo reciente.
Lección 2: lo que cuenta es el entorno del proceso
Con el jar ya actualizado, apareció un segundo error. La aplicación registraba como endpoint de almacenamiento localhost:9000 en lugar de la URL de R2, aunque la variable de entorno estaba claramente definida en el archivo .env que cargaba el servicio.
Así que revisé lo que el proceso tenía en realidad:
Bash
cat /proc/<pid>/environ | tr '\0' '\n' | grep -i storage La variable estaba ahí, en el entorno del proceso. Y aun así Spring resolvía el valor predeterminado de localhost. Es el tipo de resultado que parece imposible, y “imposible” es una señal de que está probando la capa equivocada.
Lección 3: fuerce el valor con la máxima precedencia para localizar la falla
Cuando la resolución de la configuración se comporta mal y usted no ve por qué, deje de leer configuración y haga un experimento que no se pueda sobrescribir. En Spring, las propiedades de sistema de la JVM (-D) están en lo más alto del orden de precedencia, por encima de las variables de entorno y de los valores predeterminados del YAML:
Bash
java -Dapp.storage.endpoint=https://...r2.cloudflarestorage.com -jar app.jar Registró el endpoint de R2 de inmediato. Esa sola prueba dividió el problema limpiamente en dos: el jar y el código de la aplicación estaban bien; la falla estaba por completo en cómo el proceso gestionado por el servicio vinculaba una variable de entorno a una propiedad. Yo había pasado horas sospechando del código. El experimento lo exoneró en quince segundos.
Lección 4: una solución que funciona y una solución perfecta son cosas distintas
La solución fue inyectar los valores de almacenamiento como propiedades de sistema -D directamente en la unidad de systemd, esquivando lo que fuera que impedía que el archivo de entorno se vinculara. Funciona. Tampoco es elegante: los secretos en ExecStart son visibles en ps, y la pregunta de fondo, por qué no se vincula la variable de entorno, sigue abierta. Aun así, entregar una solución que funciona y registrar la limpieza como una tarea aparte es mejor que quedarse bloqueado persiguiendo la elegancia.
La lección de fondo
El código nunca estuvo roto. Diez horas se esfumaron porque cada corrección acertada se aplicó a algo que en realidad no se estaba ejecutando: primero un binario obsoleto, luego un mecanismo de entorno que, sin dar aviso, no vinculaba.
Conviene ser honesta en algo: trabajé esto con un asistente de IA, y tampoco encontró la causa al instante. Propuso algunas teorías equivocadas (una particularidad de los placeholders anidados, un archivo de propiedades fantasma, una sobrescritura de perfil) antes de que llegáramos a la verdad. Lo que de verdad nos hizo avanzar fue hacer experimentos capaces de descartar cosas: revisar el entorno del proceso en vivo, leer la configuración incorporada en el jar en ejecución, forzar el valor con un flag -D. De ahí sale una lección propia: ante un error de varias capas, cuente con llegar a la causa prueba a prueba, con una herramienta o por su cuenta.
Cuando la configuración parece correcta pero el comportamiento dice otra cosa, interrogue directamente al proceso en ejecución: ¿qué artefacto ejecuta, qué entorno tiene en realidad y qué hace cuando usted fuerza el valor más allá de cada capa que podría estar engañándolo?