Preparar aplicativos para una migración de región: código legacy y parametrización

Migrar aplicativos a una nueva región puede romper más cosas de las esperadas.

En nuestro blog anterior, “Cómo redujimos el costo en 70%”, describimos cómo migramos la infraestructura de Fork desde São Paulo hasta Ohio. Este artículo cubre la capa técnica de ese proceso: el código.

Una migración de región presenta varias capas de complejidad: conectividad interna, integración con terceros, sincronización de datos y funcionamiento de los aplicativos. Los primeros tres son resueltos con una configuración correcta, y los LLMs ayudan a detectar la mayoría de estos problemas.

Sin embargo, el funcionamiento de los aplicativos suele ser lo más difícil y este caso no fue distinto. Los aplicativos de Fork estaban escritos en una versión de Java que Elastic Beanstalk ya no permite desplegar, lo que hizo que la migración del código fuera un requisito, no una opción.

Cómo migrar código legacy sin morir en el intento

homero.jpg

Un buen comienzo es intentar compilar el proyecto. En este caso, debido a que la aplicación está escrita en Java, algunas aplicaciones se compilan con Maven y otras con Gradle. Cualquiera fuera el caso, los cambios de versiones son un cambio simple de código pero con implicancias que afectan todo el árbol de dependencias del framework:

Maven — pom.xml

<!-- Antes -->
<properties>
    <java.version>X.X</java.version>
</properties>

<!-- Después -->
<properties>
    <java.version>21</java.version>
</properties>

Gradle — build.gradle (Groovy) / build.gradle.kts (Kotlin)

// Antes
java {
    sourceCompatibility = JavaVersion.VERSION_X_X
    targetCompatibility = JavaVersion.VERSION_X_X
}

// Después
java {
    sourceCompatibility = JavaVersion.VERSION_21
    targetCompatibility = JavaVersion.VERSION_21
}

Además de eso, es importante considerar el framework en el cual está escrita la app. Si el framework está en una versión incompatible con la versión del runtime, también debe ser actualizado. Esto implica que el framework, por debajo del capó, actualiza todas sus librerías internas lo que rompe la aplicación y su funcionamiento.

<!-- Antes -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>X.X.X.RELEASE</version>
</parent>

<!-- Después -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.5</version>
</parent>

Con tan solo estas pocas líneas de código hemos actualizado el compilador + framework y hemos roto todas las aplicaciones originales. Lo importante es entender de qué formas distintas estos quiebres se reflejan en errores (o ausencia de) y los principales métodos para solucionar estos.

Namespace de paquetes distintos

Comencemos la limpieza de los aplicativos entendiendo el siguiente tipo de error:

error: package javax.servlet does not exist
error: package javax.persistence does not exist
error: cannot find symbol: class HttpServletRequest

Un error del estilo "package does not exist" es una pista clara de que ocurrió un cambio de más alto nivel que requiere reestructuración y ajuste a los endpoints existentes.

Independiente del motivo, a veces los namespaces de los paquetes cambian requiriendo un ajuste del import como también rediseños mayores cuando los métodos cambian en su funcionalidad. Para los aplicativos de Fork por ejemplo, el paquete "javax" tuvo que ser migrado a "jakarta" lo que, a pesar de introducir una capa de cambios en las aplicaciones, cuenta con documentación clara sobre cómo realizarlos:

En estricto rigor, en una vista de alto nivel, los cambios de este tipo de problemas se abordan inicialmente con la actualización en los imports, seguido de la limpieza de los errores que puedan ir surgiendo en base a ello:

// Antes
import javax.servlet.http.HttpServletRequest;
import javax.persistence.Entity;

// Después
import jakarta.servlet.http.HttpServletRequest;
import jakarta.persistence.Entity;

Compatibilidad de versiones

Cuando una librería desaparece en una nueva versión, casi siempre existe un reemplazo. Los frameworks documentan estos cambios en sus changelogs porque les conviene que el usuario migre en lugar de buscar otra alternativa. Ubicar ese changelog es el primer paso para entender qué aspectos claves se rompen debido a las nuevas versiones y qué alternativas existen:

A veces no basta con actualizar referencias e importaciones, sino que hay que cambiar de raíz la librería utilizada para solucionar un problema. Sin embargo, el argumento previo de buscar soluciones dentro del mismo changelog o documentación del framework se sostiene igual. Por ejemplo, la librería Springfox perdió soporte en 2020 y es incompatible con las versiones actuales post-migración. Su propósito es documentar la API, por lo que buscar una alternativa es un trabajo directo de elegir otra solución con un diseño similar de tal forma que solo realizemos cambios mínimos. En este caso bastó con acudir a la documentación oficial para encontrar la solución recomendada:

En la práctica, el cambio es tan directo como esto:

// Antes — Springfox
@Configuration
@EnableSwagger2
public class SwaggerConfig {
    @Bean
    public Docket apiDocket() {
        return new Docket(DocumentationType.SWAGGER_2)
            .select().apis(RequestHandlerSelectors.basePackage("com.example")).build();
    }
}

// Después — SpringDoc
@Configuration
public class SwaggerConfig {
    @Bean
    public OpenAPI apiInfo() {
        return new OpenAPI()
            .info(new Info().title("Service Name").version("1.0.0"));
    }
}

En donde la principal consecuencia es un cambio en la URL de la documentación:

Antes Después
/swagger-ui.html /swagger-ui/index.html
/v2/api-docs /v3/api-docs

Que a su vez implica actualizar la configuración de seguridad para permitir que las nuevas URLs tengan el mismo acceso que las antiguas.

Cambios de comportamientos default

Ya abordamos los errores grandes y claros que reclama el compilador, como "package does not exist" o endpoints que responden 500. El caso más difícil es el opuesto: el compilador acepta el código sin queja, pero en tiempo de ejecución el endpoint lanza 403 (u otro error) sin explicación aparente.

Actualizar un aplicativo puede llegar a romper varios supuestos. Parámetros implícitos que debían comportarse de cierta manera ahora pueden exigir configuración explícita para lograr lo mismo. El compilador no ayuda en estos casos y lo único que puede garantizar comportamientos homogéneos a través de la migración son baterías de tests.

Este mismo fenómeno no solo vive en código de aplicativo sino también en sintaxis de queries escritas bajo el framework. Por ejemplo, en el cambio de Hibernate 5 (el ORM de Spring Boot) hacia la versión 6 podemos notar el siguiente problema:

// Antes — Hibernate 5 toleraba omitir SELECT
@Query("FROM unaTabla t WHERE t.unID = ?123")

// Después — Hibernate 6 requiere SELECT explícito
@Query("SELECT t FROM unaTabla t WHERE t.unID = ?123")

Esto no es deuda técnica. El código compilaba y funcionó correctamente durante años. Lo que cambia es el contrato implícito del framework al actualizar versión.

El compilador no reclama esto porque para él ambas queries son texto plano. Solo en runtime, cuando el endpoint relevante recibe un HTTP Request, se lanza la excepción. En el mejor caso, un test captura esto rápidamente. En un mal día, el endpoint puede ser uno relacionado a reportería mensual, y en dichos casos podría ser un error silencioso durante mucho tiempo antes de alertar a alguien.

Cuando el problema no está en tu código sino en quien lo compila

Existe otra categoría de fallo: los "annotation processors", herramientas que generan código en base a metadata y que pueden romperse en silencio al actualizar el compilador. Lombok es el caso más común, genera automáticamente getters y setters a partir de anotaciones. El error que aparece es:

error: cannot find symbol
  symbol: method getName()

Mientras que el código se ve así:

// Esto es TODO lo que existe en tu archivo .java
@Data
public class User {
    private String name;
    private String email;
}

El método no existe en el archivo. Se crea en tiempo de compilación. La pista principal en estos casos es entender quién llama al método, ya que se llega a definiciones de clase sin métodos declarados.

Finalmente, la metodología de resolución es similar a los casos anteriores. En el caso de Lombok, podemos encontrar un changelog que referencia los problemas de versiones que encontramos:

La causa raíz es que versiones de Lombok anteriores a 1.18.34 son incompatibles con Java 21. El sistema de módulos de Java (JPMS), introducido en Java 9, restringe el acceso a código interno del compilador que Lombok necesita para generar código. La solución tiene dos pasos.

Primero, actualizar la versión en pom.xml:

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.34</version>
    <scope>provided</scope>
</dependency>

Segundo, crear el archivo .mvn/jvm.config en la raíz del proyecto con los flags que abren los módulos necesarios:

--add-opens jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED

Sin este archivo, Lombok lanza ExceptionInInitializerError al intentar acceder a las estructuras internas del compilador, lo que resulta en los errores cannot find symbol sobre todos los getters y setters generados.

Métodos de parametrización

spiderman.png
Habiendo resuelto el funcionamiento de los aplicativos, nos queda la segunda capa del problema. Parametrizar para lograr la migración sin perder la interconexión de sistemas.

Una startup en etapa temprana suele priorizar velocidad, lo que introduce valores hardcodeados a través de toda la aplicación: URLs, tokens, IPs. Al migrar de región, todas estas referencias se rompen en cascada. Parametrizar el 100% de estos valores es un requisito para que la migración sea viable.

Identificación de parámetros hardcodeados

Antes de mover cualquier valor a un sistema de parametrización, hay que saber qué existe. Un script dirigido sobre el código fuente es suficiente para construir un inventario completo.

Los servicios internos de AWS que más frecuentemente aparecen hardcodeados son aquellos cuya URL incluye la región. Por lo tanto, cualquier migración de región los rompe automáticamente:

Servicio Patrón de URL Por qué cambia al migrar
API Gateway <id>.execute-api.<region>.amazonaws.com El ID y la región están en la URL
RDS <nombre>.<id>.<region>.rds.amazonaws.com Endpoint único por instancia y región
Elastic Beanstalk <env>.eba-<id>.<region>.elasticbeanstalk.com URL generada por EB, atada a la región
S3 <bucket>.s3.<region>.amazonaws.com Bucket es regional
SQS sqs.<region>.amazonaws.com/<account>/<queue> Región explícita en la URL
ElastiCache <cluster>.<id>.<region>.cache.amazonaws.com Endpoint por cluster y región
ALB / ELB <nombre>.<region>.elb.amazonaws.com Load balancer regional
CloudFront <id>.cloudfront.net Global pero el ID cambia si se recrea la distribución
Cognito cognito-idp.<region>.amazonaws.com User pool atado a la región
ECR <account>.dkr.ecr.<region>.amazonaws.com Registry regional

Para encontrarlos todos en una lectura con el siguiente comando se realiza una revisión quirúrgica sobre palabras clave:

#!/bin/bash
echo "=== AUDITORÍA DE HARDCODED VALUES ==="

echo "--- POSIBLES SECRETS ---"
grep -rn --include="*.yml" --include="*.yaml" --include="*.properties" \
  -E "(password|token|apikey|api[-_]key|secret|credential)\s*[:=]\s*\S+" \
  src/main/resources/ | grep -v "\${" | grep -v "#"

echo ""
echo "--- URLs INTERNAS AWS ---"
grep -rn --include="*.yml" --include="*.yaml" --include="*.properties" \
  -E "(execute-api|\.rds\.|elasticbeanstalk\.com|\.sqs\.|\.cache\.|\.elb\.|cloudfront\.net|cognito-idp|\.ecr\.|\.s3\.)[a-z0-9.-]*amazonaws\.com" \
  src/main/resources/ | grep -v "\${"

echo ""
echo "--- AWS ACCESS KEYS ---"
grep -rn -E "AKIA[A-Z0-9]{16}" src/

El grep -v "\${" filtra todo lo que ya está parametrizado con un placeholder de Spring. El output son solo valores reales que quedan por migrar.

Con los resultados, clasificar cada hallazgo:

Tipo Ejemplo Destino
Secret / credencial token, password, API key SSM SecureString
URL interna propia API Gateway, EB endpoint, dominio interno SSM String
URL de tercero estable Google, Transbank, proveedor externo Dejar hardcodeado
Config que varía por entorno nombre de bucket, región, nombre de DB SSM String o EB env var

El criterio de corte: si el valor cambiaría al migrar de región o de cuenta AWS, va a SSM. Si es una URL pública de un tercero que no depende de tu infraestructura, puede quedarse hardcodeada a pesar de que sea una buena práctica parametrizar.

Parameter Store (AWS SSM)

Parameter Store es un servicio nativo de AWS para centralizar configuración y secretos con control de acceso por IAM. Los parámetros son por región y por cuenta, por lo que en organizaciones con cuentas separadas por ambiente se requiere replicación en cada una.

Por qué SSM

1. Seguridad

Un secreto en application.yml commiteado en el repositorio es visible para cualquier developer con acceso al repositorio y si este es público, para cualquier persona en internet. Las EB env vars resuelven el problema del repositorio, pero siguen siendo visibles en texto plano en la consola de AWS para cualquier usuario con acceso al environment.

SSM con tipo SecureString encripta el valor en reposo con KMS. Solo roles IAM con permiso explícito pueden leerlo, un dev puede tener acceso al código y al ambiente de EB sin tener acceso a los secretos.

2. Rotación sin cambiar el código

Cambiar un secreto hardcodeado requiere editar el archivo → commit → push → pipeline → redespliegue. Con SSM, el cambio es un solo comando:

aws ssm put-parameter \
  --name "/mi-servicio/mi-ambiente/mi-variable" \
  --value "nuevo-valor" \
  --type SecureString \
  --overwrite

La app lee el nuevo valor en el próximo despliegue, lo que requiere un reinicio del servicio, pero sin tocar código ni pipeline.

3. Separación de entornos sin duplicar código

Sin SSM, separar dev/staging/prod implica mantener múltiples archivos de config con valores distintos (application-dev.yml, application-staging.yml, application-prod.yml) que alguien debe mantener sincronizados manualmente.

Con SSM, un solo application.yml sirve para todos los entornos. En nuestro caso, el perfil activo de Spring determina qué path de SSM se carga:

# application.yml — común a todos los entornos
spring:
  config:
    import: "aws-parameterstore:/mi-servicio/"

# application-prod.yml — solo agrega el path de prod
spring:
  config:
    import: "aws-parameterstore:/mi-servicio_prod/"

4. Auditoría

SSM registra cada lectura de parámetro en CloudTrail, quién leyó qué secreto, desde qué rol, y cuándo. Un token hardcodeado no deja rastro de uso.

5. Estructura de paths y tipos

SSM organiza los parámetros por path jerárquico. La convención que usamos:

/mi-servicio/          ← compartido entre todos los entornos
/mi-servicio_staging/  ← solo staging
/mi-servicio_prod/     ← solo producción

El tipo del parámetro depende de su sensibilidad:

Tipo SSM Cuándo usarlo
String URLs internas, nombres de recursos, configuración no sensible
SecureString Passwords, tokens, API keys, cualquier credencial

URLs de terceros estables (Google, Transbank, proveedores externos) no deben necesariamente estar en SSM, pueden estar hardcodeadas. En SSM queremos solo los valores que son propios de la infraestructura y que cambian entre entornos o regiones.

Integración al código

Dependiendo del framework, esta sección puede variar, pero de manera macro el principio es el mismo. Se configura un lugar centralizado de lectura de parámetros y a través de la aplicación se referencian estos parámetros por nombre.

En Java con Spring Boot, la conexión a SSM se declara tal como se vio en la sección anterior. El código consume el valor como cualquier otra propiedad:

// Antes — hardcodeado
public static final String miUrl = "...";

// Después — resuelto desde SSM en runtime
@Value("${mi-servicio.miUrl}")
private String miUrl;

En Python el patrón es equivalente:

# Variable de entorno (EB la inyecta desde SSM)
mi_url = os.getenv("miUrl")

# O directo desde SSM
ssm = boto3.client("ssm")
mi_url = ssm.get_parameter(Name="/mi-servicio/miUrl", 
                            WithDecryption=True)["Parameter"]["Value"]

1. bootstrap.yml ya no funciona

Spring Cloud AWS 3.x usa spring.config.import exclusivamente. Los archivos bootstrap.yml son ignorados en silencio: la app arranca sin error pero SSM no carga.

2. Errores silenciosos: IAM

Si el instance profile no tiene la policy ssm:GetParametersByPath, SSM tampoco carga, sin excepción ni warning. Las propiedades simplemente quedan vacías y el primer endpoint que las usa falla en runtime. La policy mínima requerida:

{
  "Effect": "Allow",
  "Action": ["ssm:GetParametersByPath", "ssm:GetParameter"],
  "Resource": "arn:aws:ssm:<REGION>:<ACCOUNT_ID>:parameter/mi-servicio/*"
}

Otras alternativas de parametrización

SSM no es la única herramienta disponible. Dependiendo del tipo de valor y de dónde se usa, existen otras dos opciones válidas:

Alternativa Para qué sirve Cuándo usarla
SSM Parameter Store Secrets y config de runtime de la app Cualquier valor que la app lee al arrancar
CI/CD variables (GitLab/GitHub) Credenciales del pipeline de deploy ARNs de roles, tokens de herramientas de CI
EB env vars Config simple no sensible Flags de feature, nombres de entorno, timeouts

Las CI/CD variables cubren el pipeline, no el runtime: en nuestro caso migramos de keys IAM estáticas a OIDC, donde el pipeline recibe credenciales temporales por JWT sin secrets de larga duración.

Resultados

La migración técnica cubrió 14 aplicativos en Elastic Beanstalk, todos escritos en Java 8 con Spring Boot 2.x, migrados a Corretto 21 y Spring Boot 3.3.5. En cada uno se ejecutaron los cuatro ejes descritos en este artículo: actualización de runtime y framework, migración de namespaces, reconfiguración de seguridad, y parametrización completa en SSM.

Los resultados en términos de costes están cubiertos en el artículo anterior, "Cómo redujimos el costo en 70%".

Aprendizajes y limitaciones

Uno de los principales aprendizajes que dejó esta experiencia es el hecho de que, a pesar de que gran parte del desarrollo de software hoy en día es AI-driven, como desarrolladores no debemos olvidar los fundamentales. Entender qué documentación es relevante para el problema en mano, ya que a pesar de contar con asistencia de LLMs, esta tiene claras limitaciones si no es guiada de manera apropiada.

En particular, se logró realizar la actualización de aplicaciones exitosamente y se desarrolló una metodología en base a un problema abstraído a tal grado que la solución puede replicarse en cualquier tipo de aplicativo.

Los desafíos, soluciones y consideraciones indicadas en este artículo fueron el resultado de un trabajo de ingeniería mayor que, a pesar del uso de LLM para descubrir y explorar casos borde, fue desafiante.

Una distinción que este proceso dejó en evidencia es la diferencia entre errores de compilación y errores de runtime, y cómo el LLM se comportó de manera muy distinta frente a cada uno.

Los errores de compilación fueron resueltos de forma confiable y directa: reemplazos de namespace, APIs removidas, versiones incompatibles. Son mecánicos, verificables estáticamente, y el modelo los identificó bien en una sola pasada leyendo outputs claros en consola.

Por otro lado, los errores de runtime fueron la fuente de ruido real. Varios aplicativos compilaban sin errores y fallaban al arrancar, bajo carga, o solo al ejecutar un endpoint específico. Algunos de estos fallos no tenían señal en compilación: dependencias que el framework dejó de tolerar por defecto, configuraciones que se ignoraban en silencio sin lanzar excepción, o comportamientos que solo se manifestaban cuando llegaba tráfico real.

La regla que surgió de esto: el LLM es un aliado para los cambios mecánicos, pero no puede anticipar comportamientos que solo existen en ejecución. Una batería de tests funcionales corriendo localmente, antes de cada push, es el único mecanismo que cierra esa brecha.

Una migración de región comprime en semanas decisiones de arquitectura que normalmente se toman de forma gradual. Fuerza parametrizar, actualizar y desacoplar todo al mismo tiempo. El resultado, cuando se hace bien, es un sistema más robusto del que entró al proceso.

Si este tipo de desafíos en la nube son de tu interés, visítanos en blog.frust.co donde encontrarás artículos sobre migraciones, infraestructura en AWS y optimización de costos o directamente en frust.co para entender y ahorrar en tus costes en la nube.

Comienza ahora con Frust

¿Aún tienes dudas? Revísalas en las preguntas frecuentes.

frust
un@frust.co🇨🇱 Callao 2911, of 4144, Santiago, RM, 7550285🇺🇸 1111B S Governors Ave STE 29963, Dover, DE 19904