CodeGym /Cursos /Docker for Spring /Imagen inmutable y configuración en tiempo de ejecución

Imagen inmutable y configuración en tiempo de ejecución

Docker for Spring
Nivel 11, Lección 0
Disponible

1. La tentación de reconstruir la imagen según el modo

Hay una razón muy humana por la que los principiantes empiezan a generar diferentes imágenes para diferentes modos. Cuando algo no funciona, uno quiere «fijar» el estado funcional para siempre: «Aquí está la imagen para standalone, aquí la imagen para postgres, aquí la imagen para… ¡uy, ya la tercera». Parece lógico: si el modo es diferente, entonces la imagen también debe ser diferente. Pero es como tratar un resfriado comprando un termómetro nuevo: el síntoma cambia, pero la causa sigue ahí.

El problema es que la imagen Docker es un artefacto que debe vivir mucho tiempo y ejecutarse muchas veces. Si empiezas a crear imágenes separadas para cada pequeño ajuste, casi instantáneamente caes en un «zoológico de etiquetas»: catalog-standalone, catalog-postgres, catalog-postgres-new-try-final2. En ese momento pierdes la principal ventaja de la contenerización: la reproducibilidad. Porque reproducibilidad no es «a mí me funciona», sino «todo el equipo ejecuta lo mismo y entiende en qué se diferencia solo por los parámetros de ejecución».

Veamos un momento un escenario «incorrecto» típico. Quieres cambiar el puerto — y en lugar de pasar otro valor al iniciar el contenedor, modificas el Dockerfile, reconstruyes la imagen y la llamas «8081». Luego quieres activar otro modo de almacenamiento — otra reconstrucción. Luego quieres otro nivel de registro — otra reconstrucción. Al final, el Dockerfile deja de ser una instrucción de construcción y se convierte en una colección de «conjuros chamánicos», donde cada conjuro parece funcionar, pero el precio es el caos.

2. Imagen inmutable: un artefacto, muchas ejecuciones

La palabra immutable suena como si nos fuéramos a meter en filosofía, pero en realidad es una idea muy práctica: la imagen construida no cambia «según el humor». Dentro de ella está el código de la aplicación (nuestro jar de Spring Boot) y valores predeterminados razonables. Todo. Puedes ejecutar esta imagen 1 vez, 10 veces o 100 veces, y cada vez sigue siendo la misma — como un mismo USB con un programa que simplemente conectas en diferentes ordenadores.

Para que sea más fácil mantenerlo en la mente, es útil separar dos eventos: build y run. Build es cuando creas el artefacto. Run es cuando ejecutas ese artefacto en condiciones concretas. Si cada vez que cambian las condiciones reconstruyes el artefacto — estás mezclando estos eventos, y con ellos mezclas la responsabilidad. Luego te preguntas por qué «a mí me funciona, pero a mi colega no» — porque ya no tienes un solo artefacto, sino varios similares, y se diferencian más de lo que crees.

Aquí tienes un esquema simple de lo que queremos tener en la cabeza:

flowchart LR
  A["Dockerfile + fuentes/artefacto"] -->|docker build| B["Imagen (inmutable)"]
  B -->|docker run + config en ejecución| C["Contenedor"]
  C --> D["Spring Boot inicia y reúne los valores finales de las propiedades"]

Observa la idea «sutil»: Dockerfile es una forma de crear una imagen, no una «lista de diferentes ejecuciones». Las ejecuciones deben diferenciarse por qué valores pasamos al inicio, no por cuántas veces reescribimos el Dockerfile intentando «convencer» a la aplicación de que se comporte de otra manera.

3. Configuración en tiempo de ejecución: cambiamos los valores de entrada

Cuando decimos runtime configuration, nos referimos a los valores que llegan en el momento de iniciar la aplicación. Pueden ser el puerto, el perfil activo, la dirección de la base de datos, banderas de comportamiento (por ejemplo, «read-only»), la ruta al directorio de exportación — todo lo que depende del entorno, no de las fuentes. Y en el mundo de los contenedores esto es especialmente importante porque el contenedor está creado precisamente para la idea: «una imagen, muchas ejecuciones».

Para verlo sin ruido innecesario, a continuación utilizaremos una clave simple app.mode. Es una bandera didáctica: con ella es cómodo analizar la configuración externalizada sin mezclar todo con un modelo de configuración más rico de la aplicación.

Es muy importante aceptar una fórmula práctica que repetiremos como un mantra (y sí, en TI también hay mantras): si cambia el código, se necesita reconstrucción; si cambia la configuración, no se necesita reconstrucción. Esto no es religión ni «best practice por best practice». Es simplemente ahorro de tiempo y nervios, y, lo que es más importante, ahorro de confianza en el sistema. Si cada cambio de configuración produce una nueva imagen, nadie en el equipo está seguro de qué código está funcionando exactamente.

Spring Boot aquí es nuestro aliado por defecto. Está creado desde el principio para que la configuración pueda venir del exterior: de archivos, de parámetros de inicio, del entorno. Lo que nos importa aquí es una cosa: a la aplicación le da igual de dónde vino el valor; lo que le importa es qué valor resultó al final.

4. Qué va dentro de la imagen, qué va fuera

Cuando escuchas por primera vez «imagen inmutable», la siguiente pregunta natural es: «¿Y qué se puede “hornear” dentro de la imagen?». La respuesta es simple: todo lo que se refiere al código y a los valores predeterminados seguros. Todo lo que se refiere al entorno y al modo de ejecución debe venir del exterior. De lo contrario, obtienes una «imagen que sabe demasiado», y eso casi siempre es una mala idea.

A continuación, una tabla útil que puedes tener como chuleta cuando la mano se estire otra vez para modificar el Dockerfile por un nuevo puerto.

¿A dónde pertenece? Ejemplos Por qué así
Dentro de la imagen (parte inmutable) jar de la aplicación, dependencias, estructura básica de archivos, configuraciones predeterminadas que son seguras para todos Es parte del «producto». Si esto cambia, cambia el artefacto, por lo tanto se necesita una nueva imagen
En la configuración en tiempo de ejecución (parte mutable) puerto, perfil activo, URL de la base de datos, usuario/contraseña, banderas de comportamiento (read-only), nivel de registro, «activar/desactivar» integración Es parte del «entorno». Debe cambiar sin reconstrucción: de lo contrario, conviertes la construcción en un ritual diario

Otra pequeña tabla (muy útil) sobre cuándo la reconstrucción está realmente justificada. Ayuda a no confundirse y a no discutir con un colega a nivel de «me parece».

¿Qué cambió? ¿Se necesita reconstruir la imagen? Criterio simple
Cambiaste código Java (controlador, servicio, repositorio) Sí Cambia el jar → cambia el contenido de la imagen
Cambiaste dependencias (Gradle) Sí Cambia el classpath de ejecución → se necesita un nuevo artefacto
Cambiaste puerto, perfil, URL de base de datos, bandera read-only No Son parámetros de entrada para la ejecución, no una «nueva versión de la aplicación»
Cambiaste Dockerfile porque «quiero otro modo» Casi siempre no Si solo cambia el modo, el problema no está en el Dockerfile, sino en la configuración de la ejecución

5. Endpoints en tiempo de ejecución para una instantánea de la configuración

Para que este principio no sea una abstracción, necesitamos un probe diagnóstico temporal — un manejador corto que muestre los valores finales después del inicio. No lleves todos los fragmentos siguientes al proyecto como controladores de producción independientes: son miniilustraciones independientes. Por lo general, basta con un endpoint simple en el que vayas añadiendo la propiedad que necesites.

Para ello es conveniente usar @Value — una anotación que asigna al campo el valor final de la propiedad. La idea clave aquí es: el código del controlador no sabe de qué fuente vino el valor. Simplemente recibe el resultado de la configuración con el que la aplicación se inició. Esto es ideal para demostrar la configuración externalizada: el mismo código, pero valores diferentes al iniciar.

Añadimos propiedades básicas en application.yml

Para que los ejemplos sean reproducibles, definamos valores predeterminados razonables. En un proyecto real, parte de los valores predeterminados ya existen, pero hoy nos interesa ver el mecanismo con claves simples.

server:
  port: 8080 # puerto predeterminado si no se pasa nada al iniciar

app:
  mode: standalone # modo predeterminado, seguro para inicio local

catalog:
  read-only: false # predeterminado: trabajamos en modo normal, no en "solo lectura"

Aquí no hay nada «secreto» ni específico de una máquina concreta. Son exactamente valores predeterminados: si no pasamos nada al iniciar, el servicio arranca en modo «standalone» comprensible y no «sabe internamente» de repente que debe conectarse a PostgreSQL.

Fragmento para comprobar app.mode

// Este fragmento se puede añadir a cualquier controlador diagnóstico temporal
@Value("${app.mode}")
String mode;

@GetMapping("/api/runtime/mode")
String mode() {
    return mode;
}

Si iniciaste el servicio con los valores predeterminados, la petición a GET /api/runtime/mode devolverá standalone. Y, de nuevo: al código le da igual si ese valor estaba en el archivo, llegó como parámetro de inicio o de cualquier otra forma. Simplemente muestra el resultado.

Fragmento para la bandera catalog.read-only con valor predeterminado

// Si no se pasó la clave, se toma el valor predeterminado seguro
@Value("${catalog.read-only:false}")
boolean readOnly;

@GetMapping("/api/runtime/read-only")
boolean readOnly() {
    return readOnly;
}

Este ejemplo es útil porque muestra: una bandera de comportamiento es un candidato ideal para la configuración en tiempo de ejecución. En una ejecución quieres modo read-only (por ejemplo, para diagnóstico o visualización segura), en otra el modo completo. Cambiar el Dockerfile y reconstruir la imagen por esto es una forma demasiado cara de decir «aquí true, aquí false».

Lo mismo para el perfil

Del mismo modo se puede consultar el perfil: leer dentro de la aplicación spring.profiles.active y devolver el valor final a través del mismo probe temporal. Si el perfil no se ha especificado explícitamente, es conveniente devolver default para que la respuesta sea predecible.

Aquí necesitamos un hecho práctico: el perfil, como cualquier otra clave en tiempo de ejecución, llega al inicio y no convierte el modo en una imagen separada. En los ejemplos anteriores no lo convertimos en el principal conmutador de modo: ahora lo importante es la mecánica misma de la configuración externalizada.

6. Valores predeterminados y ${key:default}

La notación ${key:default} tiene un efecto sorprendentemente agradable: hace que el comportamiento del sistema sea predecible incluso cuando olvidaste pasar la configuración. Para los principiantes esto es especialmente importante, porque en el primer mes de estudio de Docker (y Spring Boot también) olvidarás «pasar una banderita». Y es mejor que el servicio arranque con un valor predeterminado seguro a que falle dejándote a solas con un stack trace rojo de medio pantallón.

Pero aquí es importante no exagerar. Un valor predeterminado es como una rueda de repuesto: útil mientras está «por si acaso», y muy extraño si conduces constantemente con ella porque te da pereza arreglar las ruedas principales. Algunos valores deben ser obligatorios, de lo contrario la aplicación no tiene derecho a fingir que «todo está bien». Por ejemplo, si el modo realmente requiere conexión a la base de datos, tener una «contraseña de base de datos predeterminada» en el código es una mala idea.

Lo muestro con dos minirreglas que suenan aburridas, pero ahorran mucho tiempo:

Tipo de configuración ¿Valor predeterminado adecuado? Ejemplo
Bandera de comportamiento cómoda Sí catalog.read-only:false
Valor del que depende la seguridad/corrección Generalmente no contraseña de BD, URL de un servicio externo

Y otro pequeño detalle que a menudo sorprende a los principiantes: si usas @Value("${app.mode}") y la propiedad no está definida en ninguna parte, la aplicación puede no iniciar porque Spring no pudo sustituir el valor. Esto no es un «capricho de Spring»; es tu protección contra la situación de «la aplicación arrancó, pero funciona mal, y ni siquiera sabes por qué».

7. Build-time y run-time: ARG vs. inicio

En días anteriores ya vimos que en el Dockerfile hay diferentes herramientas: ARG, ENV, ENTRYPOINT, CMD. Hoy nos importa relacionar esto con el principio de imagen inmutable. El error más común aquí es intentar usar ARG como una «configuración normal de la aplicación» y luego sorprenderse de que para cambiar el modo se necesita un docker build de nuevo. Spoiler: porque ARG vive en la etapa de construcción, y eso no es un bug, sino su propósito.

Aquí hay un ejemplo de un enfoque «casi funcional», pero metodológicamente peligroso. Es tentador: parece que hay configuración, pero es build-time.

# ARG solo existe en la etapa de construcción de la imagen (build-time)
ARG APP_MODE=standalone

# Trasladamos el valor de ARG a ENV, pero ENV al final se "hornea" en la imagen
# (por lo tanto, cambiar el modo lleva otra vez hacia la reconstrucción)
ENV APP_MODE=${APP_MODE}

Con este Dockerfile, cambiar el modo se convierte en una reconstrucción:

# Cambiamos el modo mediante build-arg -> cada vez se necesita docker build
docker build --build-arg APP_MODE=postgres -t catalog:postgres .

Técnicamente se puede hacer. Pero es exactamente la trampa de la que hoy estamos huyendo: el modo de trabajo pasó a ser parte de la construcción, aunque debería ser parte de la ejecución.

La imagen correcta debe ser diferente. Dejamos en la imagen el valor predeterminado (por ejemplo, standalone), y el «conmutador» del modo se pasa al inicio. Aquí nos basta ver el principio: la clave se puede pasar al inicio sin cambiar la imagen. El canal concreto de entrega del valor es ya cuestión de conveniencia y prioridad de las fuentes.

Por ejemplo, la misma imagen se puede iniciar en diferentes puertos sin reconstrucción, simplemente cambiando los argumentos de inicio:

# La misma imagen, diferentes puertos y diferentes valores de server.port
docker run --rm -p 8080:8080 docker-java-catalog-service --server.port=8080
docker run --rm -p 8081:8081 docker-java-catalog-service --server.port=8081

La imagen es la misma. El código es el mismo. Solo se diferencian los valores de entrada al inicio — y eso es exactamente lo que queremos.

Aquí a menudo surge un «debate interno»: «Pero ENV también se puede cambiar, ¿no?». Sí, se pueden asignar valores de entorno al iniciar el contenedor. Pero el punto clave sigue siendo el mismo: hay que cambiar en el inicio, no en la construcción. ENV dentro del Dockerfile sirve para valores predeterminados seguros, no para «diferentes entornos». De lo contrario, simplemente trasladas el caos del README al Dockerfile, y luego lo llamas orgullosamente «contenerización».

8. Errores típicos en la configuración en tiempo de ejecución

Error n.º 1: generar imágenes para cada modo — catalog-standalone, catalog-postgres, catalog-postgres-final-final.
Este enfoque al principio parece cómodo porque «cada imagen se inicia sin parámetros». Pero muy pronto dejas de entender qué hay dentro de cada imagen. En una hay un jar diferente, en otra una configuración diferente, en otra han entrado archivos extra por casualidad. Lo correcto es mantener una sola imagen y cambiar solo los valores en tiempo de ejecución al iniciar.

Error n.º 2: hornear modos y entornos en el Dockerfile mediante ENV, convirtiendo el Dockerfile en un archivo de configuración.
ENV en el Dockerfile es parte de la imagen. Si guardas allí la dirección de la base de datos, inicios de sesión, contraseñas o el «modo postgres», haces que la imagen «sepa demasiado». Luego alguien sube esa imagen a un registro, y de repente la configuración empieza a vivir separada de la realidad. El Dockerfile debe describir el empaquetado de la aplicación, no sustituir la configuración externa.

Error n.º 3: usar ARG como mecanismo principal para cambiar el comportamiento de la aplicación.
ARG es build-time. Es útil cuando necesitas gestionar la construcción (por ejemplo, elegir la versión de algo en la etapa de build), pero para cambiar a diario el puerto, el modo, el perfil y cosas similares, provoca reconstrucciones que no son necesarias. Si te sorprendes cambiando --build-arg más a menudo que las fuentes, casi siempre es una señal de que has confundido «build» con «run».

Error n.º 4: esperar que la aplicación «de alguna manera lo entienda» si no se ha definido una propiedad.
Cuando una propiedad es realmente crítica, es mejor que el servicio falle al inicio con un error comprensible a que se inicie en un estado indefinido. Y cuando la propiedad no es crítica (como la bandera read-only), es mejor definir explícitamente un valor predeterminado mediante ${key:default}. El término medio entre estos enfoques suele ser «a veces funciona, a veces no», y ese es el modo de desarrollo más caro.

Error n.º 5: discutir sobre la fuente del valor, en lugar de mirar el valor final.
Los principiantes a menudo se estancan en la pregunta «¿vino del archivo o del inicio?». En la práctica, es más importante aprender a hacerse otra pregunta: «¿qué valor resultó después del inicio?». Precisamente por eso añadimos endpoints simples en tiempo de ejecución: permiten ver el resultado de la configuración en dos segundos y dejar de adivinar.

1
Tarea
Docker for Spring, nivel 11, lección 0
Bloqueada
`app.mode` como parámetro de ejecución
`app.mode` como parámetro de ejecución
1
Tarea
Docker for Spring, nivel 11, lección 0
Bloqueada
Flag `catalog.read-only` sin nueva compilación de imagen
Flag `catalog.read-only` sin nueva compilación de imagen
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION