De Azure a GCP y de ASP.NET MVC a ASP.NET Core 3.1

31 marzo
Andrey Zhukov, .NET Team Leader, DataArt
De Azure a GCP y de ASP.NET MVC a ASP.NET Core 3.1
En este artículo, describí mi propia experiencia al migrar un proyecto real de una plataforma en la nube a otra. Por supuesto, esta no es la única forma posible de hacerlo. Pero creo se podrán encontrar consejos que le facilitarán la vida a todos los que están a punto de hacer esa transición.

El objetivo: De Azure a GCP

El cliente decidió pasar de una nube (Azure) a otra (Google Cloud Platform). Inicialmente se planeó transferir la parte del servidor a Node.js y desarrollar el sistema con la ayuda de un equipo de desarrolladores full-stack typescript. Cuando me uní al proyecto, había un par de aplicaciones ASP.NET MVC, cuya vida debía prolongarse. Entonces, tuve que transferir esas aplicaciones a GCP.

Estado inicial y factores que obstaculizaron una transferencia inmediata

En principio, había dos aplicaciones ASP.NET MVC que interactuaban con una base de datos común de MS SQL. Se habían implementado en Azure App Services.

La primera aplicación, llamémosla Portal Web, tenía una interfaz de usuario construida sobre Razor, TypeScript, JavaScript, Knockout y Bootstrap. No se previeron problemas con estas tecnologías del cliente. Pero el backend de la aplicación usaba varios servicios específicos de Azure: Azure Service Bus, Azure Blobs, Azure Tables Storage, Azure Queue Storage. Había que hacer algo, ya que ninguno de ellos era compatible con GCP. Además, la aplicación utilizó Azure Cache para Redis. El servicio Azure WebJob se utilizaba para procesar solicitudes largas y sus tareas se pasaban a través del Azure Service Bus. Las tareas en segundo plano podían durar hasta media hora, según el programador de soporte.


Inicialmente la arquitectura del Portal Web de nuestro proyecto se veía así

Azure WebJobs también tenía que ser reemplazado. Una arquitectura de job-queuing para la informática de larga duración no es la única solución posible: se pueden utilizar bibliotecas especializadas para tareas en segundo plano, como Hangfire, o recurrir al IHostedService de Microsoft.

La segunda aplicación, llamémosla Web API, fue ASP.NET WEB API. Utilizaba solo bases de datos MS SQL. Para ser precisos, el archivo de configuración contenía enlaces a varias bases de datos, pero en realidad, la aplicación solo accedía a una de ellas. Pero yo todavía tenía por delante descubrir esto.

Ambas aplicaciones estaban operativas, pero también estaban en mal estado: no había arquitectura como tal, había mucho código antiguo sin uso, no cumplían con los principios de construcción de aplicaciones ASP.NET MVC, etc. El propio cliente admitió la baja calidad del código y la persona que originalmente había escrito las aplicaciones, había dejado la empresa hacía varios años. Se dio luz verde entonces a todos los cambios y nuevas soluciones.

Era necesario transferir las aplicaciones ASP.NET MVC a ASP.NET Core 3.1 y transferir WebJob de .NET Framework a .NET Core para que pudieran implementarse en Linux. Es posible usar Windows en GCP, pero no es recomendable. Era necesario deshacerse de los servicios específicos de Azure, reemplazar Azure WebJob con otra cosa y decidir cómo íbamos a implementar aplicaciones en GCP, es decir, elegir una alternativa a Azure App Services. Era necesario agregar compatibilidad con Docker. Al mismo tiempo, era bueno introducir al menos algún tipo de arquitectura y mejorar la calidad del código.

Principios generales y consideraciones

Al refactorizar, nos adherimos al principio de cambios paso a paso: todo el trabajo se dividió en etapas, que a su vez consistían en tareas separadas.

Al final de cada etapa, la aplicación tenía que quedar en un estado estable, es decir, pasar por lo menos las Smoke test. Al final de cada paso, la aplicación (o la parte de ella que ha sufrido cambios) también debía estar cerca de un estado estable. Es decir, debían poder ejecutarse o al menos estar en un estado compatible, si este paso podía considerarse intermedio.

Los pasos y las etapas debían ser lo más cortos posible: el trabajo debía dividirse en tareas del tamaño de un bocado. A veces, todavía teníamos que agregar pasos cuando la aplicación no se compilaba durante uno o dos días. En algunos casos, al final de un paso determinado, solo se podía compilar el proyecto de la solución que se había cambiado recientemente. Si una etapa o paso se podía dividir en proyectos, era necesario comenzar a trabajar con aquel que no dependía de otros, y luego pasar a aquellos que dependían solo de él, etc. Más abajo, podrán encontrar el plan que creamos.

Al reemplazar los servicios de Azure, es posible elegir un servicio de GCP alternativo u optar por una solución independiente de la nube. Revisaremos y justificaremos la elección de servicios para este proyecto para cada caso por separado.

Plan de trabajo

El plan de alto nivel en su conjunto fue dictado por el cliente, aunque en algún lugar agregué pasos que el cliente no sabía que eran necesarios o a los que no prestó mucha atención. Además, se fue modificando ligeramente a medida que avanzábamos. En algunas etapas, se agregó la refactorización de la arquitectura y el código que no estaba directamente relacionada con la transición a otra plataforma. La versión final se puede ver a continuación. Cada elemento de este plan es una etapa, y la aplicación se encontró en un estado estable al completar cada una de ellas.

  1. 1.Portal Web de ASP.NET MVC a ASP.NET Core
    • Analizar el código y las dependencias del Portal Web sobre los servicios de Azure y bibliotecas de terceros, estimando el tiempo requerido.
    • Mover Portal Web a .NET Core.
    • Refactorizar para abordar los principales problemas.
    • Fusionar los cambios del Portal Web de la rama del repositorio principal realizados por varios desarrolladores al mismo tiempo.
    • Dockerización del Portal Web.
    • Testing del Portal Web, corregir errores e implementar la nueva versión en Azure.
  2. 2. Web API de ASP.NET MVC a ASP.NET Core
    • Escribir test automáticos E2E para Web API.
    • Analizar el Código y las dependencias de Web API en servicios Azure y librerías de terceros, estimando el tiempo requerido.
    • Eliminar el código fuente no utilizado de Web API.
    • Mover Web API a .NET Core.
    • Refactorizar para abordar los principales problemas.
    • Fusionar los cambios de Web API de la rama del repositorio principal realizados por varios desarrolladores al mismo tiempo.
    • Dockerización de Web API.
    • Testing de Web API, corregir errores e implementar la nueva versión en Azure.
  3. 3. Eliminar dependencias Azure
    • Eliminar las dependencias de Portal Web en Azure.
  4. 4. Deployment en GCP
    • Implementar Portal Web en un entorno de prueba en GCP.
    • Testing del Portal Web y solución de posibles errores.
    • Migración de base de datos para el entorno de prueba.
    • Implementar la Web API en un entorno de prueba en GCP.
    • Testing de Web API y solución de posibles errores.
    • Migración de base de datos para entorno de producción.
    • Portal WebWeb API para producir GCP.

El plan completo se presenta solo con fines informativos, más adelante en el artículo intentaré revelar en detalle solo las preguntas más interesantes desde mi punto de vista.

.Net Framework à .Net Core

Antes de comenzar la migración de código, encontré un artículo sobre la migración de .Net Framework a .Net Core de Microsoft y luego un enlace sobre la migración de ASP.NET a ASP.NET Core.

La migración de proyectos no-Web fue relativamente simple:

  • convertir el formato de almacenamiento de los paquetes NuGet usando Visual Studio 2019;
  • adaptar la lista de estos paquetes y sus versiones;
  • cambiar de App.config en XML a settings.json y reemplazar todas las llamadas existentes a los valores de configuración con la nueva sintaxis.

Algunas versiones de los paquetes de Azure SDK NuGet se habían modificado, lo que provocaba incompatibilidad. En la mayoría de los casos, no siempre fue posible encontrar la versión más nueva que sea admitida por el código .NET Core, que no requerirían cambios en la lógica del código del programa anterior. Los paquetes para trabajar con Azure Service Bus y WebJobs SDK fueron las excepciones. Tuvimos que cambiar a la serialización binaria desde Azure Service Bus y migrar WebJob a una nueva versión de SDK incompatible con versiones anteriores.

Migrar ASP.NET MVC a ASP.NET Core fue mucho más complicado. Todos los pasos anteriores debían realizarse también para los proyectos web, pero tuvimos que comenzar con un nuevo proyecto ASP.NET Core, donde transferimos el código del proyecto anterior. La estructura del proyecto ASP.NET Core era muy diferente de su predecesor, muchas de las clases estándar ASP.NET MVC habían sufrido cambios.

A continuación, se muestra una lista de lo que cambiamos. La mayor parte será relevante para cualquier transición de ASP.NET MVC a ASP.NET Core.

1. Creación de un nuevo proyecto ASP.NET Core y traspaso del código central del antiguo proyecto ASP.NET MVC.

2. Corrección de las dependencias del proyecto en bibliotecas externas (en nuestro caso, estos eran solo paquetes NuGet).

3. Reemplazo de Web.config con appsettings.json y todos los cambios de código relacionados con esto.

4. Implementación del mecanismo estándar de inyección de .NET Core Dependency en lugar de cualquiera de sus alternativas utilizadas en el proyecto Asp.NET MVC.

5. Utilización de middleware StaticFiles para todas las carpetas raíz de archivos estáticos: imágenes, fuentes, scripts JavaScript, estilos CSS, etc.

app.UseStaticFiles(); // wwwroot
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(
Path.Combine(Directory.GetCurrentDirectory(), «Scripts»)),
RequestPath = «/Scripts»
});

All static files can be moved to wwwroot.

6. Cambiar el uso de bundleconfig.json para todos los paquetes de JavaScript y CSS en lugar de los mecanismos antiguos. Cambiar la sintaxis de la conexión de JavaScript y CSS:

<link rel="stylesheet" href="~/bundles/Content.css" asp-append-version="true" />
<script src="~/bundles/modernizr.js" asp-append-version="true"></script>

Para que la directiva asp-append-version="true" funcione correctamente, los paquetes deben estar ubicados en la raíz, es decir, en la carpeta wwwroot (ver aquí).

Yo utilicé una versión adaptada de este ayudante para depurar paquetes.

7. Cambiar el mecanismo para manejar UnhandledExceptions: ASP.NET Core tiene soporte para ello, tuvimos que descubrirlo y utilizarlo en lugar de lo que se usaba anteriormente en el proyecto.

8. Registro: adapté los antiguos mecanismos de registro para usar los estándar en ASP.NET Core, e implementé Serilog. Este último es opcional, pero, en mi opinión, vale la pena hacerlo para obtener un registro estructurado flexible con una gran cantidad de opciones para almacenar registros.

9. Sesión: si se utilizó una sesión en el proyecto anterior, entonces el código para acceder a ella deberá adaptarse ligeramente y escribir un ayudante para guardar cualquier objeto, ya que inicialmente solo se admitía una cadena.

10. Enrutamiento: el antiguo proyecto usaba un mecanismo basado en plantillas, necesitaba un pequeño ajuste.

11. Serialización JSON: ASP.NET Core usa la biblioteca System.Text.Json de forma predeterminada en lugar de Newtonsoft.Json. Microsoft afirma que funciona más rápido que su predecesor, sin embargo, a diferencia de este último, no es compatible con gran parte de lo que Newtonsoft.Json pudo hacer de forma inmediata sin la participación del programador. Es bueno que haya una opción para volver a Newtonsoft.Json. Esto es exactamente lo que hice cuando descubrí que la mayor parte de la serialización en la Web API estaba rota y que volver a ponerla en funcionamiento con la nueva biblioteca era demasiado difícil, si no imposible. Se puede leer más información sobre el uso de Newtonsoft.Json aquí.

12. El antiguo proyecto usaba TypeScript 2.3. Tuve que jugar con su conexión, instalar Node.js, seleccionar la versión correcta del paquete Microsoft.TypeScript.MSBuild, agregar y configurar tsconfig.json, corregir el archivo de definiciones para la biblioteca Knockout y agregar las directivas //@ts-ignore aquí y allá. ..

13. El código para forzar la compatibilidad con HTTPS se incluye automáticamente cuando esta opción está habilitada en el asistente de proyectos. Se eliminó el código antiguo que usaba el atributo personalizado HttpsOnly.

14. Todas las acciones de bajo nivel, como obtener parámetros del cuerpo, URL, encabezados HTTP y HttpContext, requirieron cambios, ya que la API para acceder a ellos había cambiado en comparación con ASP.NET MVC. Habríamos tenido notablemente menos trabajo si los mecanismos de enlace estándar a través de los parámetros de Actions y Controllers se hubiesen usado con más frecuencia en el proyecto anterior.

15. Se agregó Swagger usando la biblioteca Swashbuckle.AspNetCore.Swagger.

16. El mecanismo de autenticación no-estándar requirió una refactorización para llevarlo a la forma estándar.

La cantidad de cambios fue muy grande, por eso a menudo teníamos que dejar solo un controlador y hacerlo funcionar. Luego se agregaron otros de forma gradual, siguiendo el principio de cambios incrementales.

¿Qué hacer con los servicios específicos de Azure?

Después de cambiar a ASP.NET Core, necesitábamos deshacernos de los servicios de Azure. Una opción era encontrar soluciones que fueran independientes de la plataforma en la nube, o bien algo adecuado en la lista de GCP. Afortunadamente, muchos servicios tienen alternativas directas de otros proveedores de la nube.

Decidimos reemplazar Azure Service Bus con Redis Pub/Sub siguiendo la fuerte recomendación del cliente. Esta es una herramienta bastante simple, no tan poderosa y flexible como, por ejemplo, RabbitMQ, pero era suficiente para nuestro escenario simple. El hecho de que Redis ya se utilizara en el proyecto también favoreció esta elección. Afortunadamente, con el tiempo confirmamos que esta decisión fue la correcta. La lógica para trabajar con la cola fue abstraída y separada en dos clases, una para implementar el envío de un objeto arbitrario y la otra para recibir mensajes y pasarlos para su procesamiento. Solo tomó unas pocas horas seleccionar estos objetos, y si el Redis Pub/Sub en sí necesitara ser reemplazado repentinamente, entonces también sería muy simple.

Azure Blobs se reemplazó con GCP Blobs. La solución es obvia, pero, aun así, hubo una diferencia en la funcionalidad de los servicios: GCP Blobs no admite agregar datos al final de un blob existente y, en nuestro proyecto, se usó un blob de este tipo para crear algo similar a los registros en formato CSV. En la plataforma de Google, decidimos registrar esta información en el paquete de operaciones de Google Cloud, anteriormente conocido como Stackdriver.

Utilizamos Azure Table Storage para escribir registros de aplicaciones y acceder a ellos desde el Portal Web. Había un registrador self-written para esto. Y decidimos adaptar este proceso a las prácticas de Microsoft, es decir, utilizar su interfaz ILogger. Además, se introdujo la biblioteca de registro estructurado Serilog. En GCP, el registro se configuró en Stackdriver.

Durante algún tiempo, el proyecto tuvo que funcionar en paralelo tanto en GCP como en Azure. Por lo tanto, toda la funcionalidad específica de la plataforma se ha separaba en diferentes clases que implementaban interfaces comunes: IBlobService, IRequestLogger, ILogReader. La abstracción del registro se logró automáticamente mediante el uso de la biblioteca Serilog. Pero para mostrar los registros en el Portal Web, como se hacía en la aplicación anterior, era necesario adaptar el orden de los registros en Azure Table Storage implementando nuestro propio Serilog.Sinks.AzureTableStorage.KeyGenerator.IKeyGenerator. En GCP, los receptores de enrutadores de registros se crearon para leer los de las operaciones de Google Cloud y transmitir datos a BigQuery, desde donde los recibía la aplicación.

¿Qué hacer con Azure WebJobs?

El servicio Azure WebJobs solo está disponible para Azure App Services en Windows. Es esencialmente una aplicación de consola que usa el SDK de Azure WebJobs dedicado. Nosotros eliminamos la dependencia de este SDK. La aplicación permaneció ejecutándose permanentemente en la consola y siguió una lógica similar:

static async Task Main(string[] args)
{
....
var builder = new HostBuilder();
...
var host = builder.Build();
using (host)
{
await host.RunAsync();
}
...
}

La clase registrada con Dependency Injection fue responsable de todo el trabajo.

public class RedisPubSubMessageProcessor : Microsoft.Extensions.Hosting.IHostedService
{
...
public async Task StartAsync(CancellationToken cancellationToken)
...
public async Task StopAsync(CancellationToken cancellationToken)
...
}

Este es el mecanismo estándar para .NET Core. Aunque no depende del SDK de Azure WebJob, esta aplicación de consola funcionó correctamente como Azure WebJob. También funcionó a la perfección en un contenedor Docker de Linux que ejecuta Kubernetes, que se analizará más adelante en este artículo.

Refactorizando a lo largo del camino

La arquitectura y el código de la aplicación estaban lejos de ser los ideales. A lo largo de muchos pasos, fuimos realizando pequeños cambios en el código afectado. También hubo etapas de refactorización especialmente planificadas, acordadas y evaluadas junto con el cliente. Allí, eliminamos los problemas de autenticación y autorización, y los trasladamos a las prácticas de Microsoft. Hubo una etapa separada para introducir una determinada arquitectura, resaltar capas y eliminar dependencias innecesarias. Trabajar con la Web API comenzó con el paso de eliminar el código no utilizado. En la primera etapa, mientras se reemplazaban muchos servicios de Azure, se realizó la definición de interfaces y la separación de estas dependencias en clases separadas.

En mi opinión, todo esto fue necesario y tuvo un efecto positivo en el resultado final.

Docker

Con el soporte de Docker todo fue bastante fluido. El Dockerfile podía emplearse fácilmente usando Visual Studio, por eso los agregamos para todos los proyectos correspondientes a aplicaciones, para el Portal Web, Web API y WebJob (que luego se convirtió en una aplicación de consola). Estos Dockerfiles estándar de Microsoft no sufrieron cambios significativos y funcionaron desde el primer momento con una única excepción: tuve que agregar comandos al Dockerfile para el Portal Web, para instalar Node.js. Esto es requerido por el contenedor de compilación para trabajar con TypeScript.

RUN apt-get update && \
apt-get -y install curl gnupg && \
curl -sL https://deb.nodesource.com/setup_12.x | bash — && \
apt-get -y install nodejs

Azure App Services -> GKE

No existe una única solución correcta para implementar aplicaciones .NET Core en GCP. Siempre se puede elegir entre varias opciones:

  • App Engine Flex.
  • Kubernetes Engine.
  • Compute Engine.

En nuestro caso, nos decidimos por Google Kubernetes Engine (GKE). En ese momento, ya teníamos aplicaciones en contenedores (Linux), por lo que GKE resultó ser quizás la más flexible de las tres soluciones presentadas anteriormente, ya que permite compartir recursos del clúster entre múltiples aplicaciones. En general, para seleccionar una de las tres opciones, puede utilizarse el diagrama de flujo en el enlace a continuación.

https://cloud.google.com/solutions/deploy-dotnet-applications#choosing_a_deployment_option

Todas las soluciones para los servicios de GCP utilizados se describen anteriormente, excepto MS SQL Server, que reemplazamos con Cloud SQL de Google.

La arquitectura de nuestro sistema después de migrarlo a GCP

Testing

El Portal Web se testeó manualmente. Después de cada etapa, yo mismo hice un simple Smoke test debido a la presencia de una interfaz de usuario. Si, al final de la siguiente etapa, se lanzaba un nuevo código en Prod, otros usuarios (en particular el Product Owner) se unían al proceso de prueba. Desafortunadamente, no hubo especialistas en QA dedicados al proyecto. Por supuesto, todos los errores identificados se corrigieron antes del inicio de la siguiente etapa y luego se agregó un Puppeteer test, que ejecutaba un script para cargar uno de los dos tipos de reportes con algunos parámetros y comparaba el resultante con el de referencia. La prueba se había integrado en el CICD y sumar algunos testeos unitarios fue problemático debido a la falta de arquitectura.

Por el contrario, el primer paso para migrar la Web API fue escribir tests. Se había utilizado Postman para hacer esto anteriormente, luego estas pruebas se llamaron en CICD usando Newman. Incluso antes, la integración Swagger se agregó al código anterior, lo que ayudó a crear una lista inicial de direcciones de métodos y probar muchas de ellas. Uno de los siguientes pasos fue definir una lista actualizada de transacciones. Para eso, se utilizaron los logs de IIS (Internet Information Services), que estuvieron disponibles por un período de mes y medio. Para muchos de los métodos actuales de la lista, se crearon varias pruebas con diferentes parámetros. Los testeos que cambiaban los datos en la base de datos se apartaron en una colección de Postman separada y no se ejecutaron en tiempos de compartidos. Por supuesto, todo esto fue parametrizado para que pudiera ejecutarse en Staging, Prod y Dev.

Los testeos nos permitieron asegurarnos de que el producto permaneciera estable después de la migración. Por supuesto, hubiera sido ideal cubrir toda la funcionalidad con pruebas automatizadas. Por lo tanto, en el caso de Web API, a pesar del gran esfuerzo invertido al principio, la migración, búsqueda y corrección de errores posteriores fue mucho más fácil.

Azure MS SQL à GCP Managed MS SQL

Migrar MS SQL de Managed Azure a GCP Cloud SQL resultó no ser tan sencillo como parecía en un principio. Fueron varias las razones principales:

  • Base de datos muy grande (El portal Azure mostraba: Almacenamiento de datos de base de datos / Espacio utilizado 181GB).
  • Dependencias de tablas externas.
  • Falta de un formato común para exportar desde Azure e importar a GCP Cloud SQL.

Al migrar la base de datos, me basé principalmente en este artículo, que resultó ser el más útil de todos los que pude encontrar. Antes de comenzar, se debían eliminar todas las referencias a tablas y bases de datos externas; de lo contrario, la migración fallaría. Azure SQL solo admite la exportación al formato bacpac, que es más compacto que el formato de copia de seguridad estándar. En nuestro caso, fueron 6 GB en bacpac versus 154 GB en respaldo.

Pero GCP Cloud solo permitiría importar la copia de seguridad, por lo que necesitábamos una conversión, que logramos hacer solo restaurándola a MS SQL local desde bacpac y creando una copia de seguridad a partir de ella. Estas operaciones requirieron la instalación de la última versión de Microsoft SQL Server Management Studio, siendo la versión local de MS SQL Server más baja. Muchas de estas operaciones tomaron varias horas, algunas incluso duraron días. Mi recomendación es aumentar la cuota de Azure SQL antes de importar y hacer una copia de la base de datos prod para hacer la importación desde ella. En algunos casos, necesitábamos transferir un archivo de una nube a otra para acelerar la descarga a la máquina local. También agregamos un SSD de 1TB específicamente para archivos de base de datos.

Tareas a futuro

Al pasar de Azure App Services a GCP Kubernetes, perdimos CICD, los deployments de Feature Branch y de Blue/Green. En Kubernetes, todo esto es más complicado y requiere una implementación diferente, pero probablemente se haga a través de las mismas acciones de Github. En la nueva nube, estamos siguiendo el concepto IaC (Infrastructure-as-Code) junto con Pulumi.

De la aplicación anterior heredamos un rendimiento deficiente y tiempos de consulta demasiado largos de base de datos. Eliminar estos problemas, sin dudas, será una de nuestras prioridades para el futuro cercano.