Abril de 2019

Volumen 34, número 4

[Puntos de datos]

EF Core en una aplicación en contenedores Docker

Por Julie Lerman

Julie LermanHe dedicado mucho tiempo a EF Core y Entity Framework, y también he pasado mucho tiempo trabajando con Docker. Mis columnas anteriores dan fe de ello. Sin embargo, hasta ahora, no había reunido estas tecnologías. Dado que las herramientas de Docker para Visual Studio 2017 existen desde hace tiempo, me pareció que sería un salto fácil. Pero no lo fue. Puede que sea porque yo prefiero saber qué está pasando en segundo plano y también quiero conocer y comprender mis opciones. En cualquier caso, acabé revisando información de entradas de blog, artículos, números de GitHub y documentación de Microsoft antes de poder lograr mi objetivo original. Lo que espero es que, para los lectores de esta columna, resulte más fácil encontrar el camino (y evitar algunas dificultades que tuve) al consolidarlo todo en un mismo sitio.

Voy a centrarme en Visual Studio 2017 y eso significa Windows, por lo que necesitará comprobar que tiene el escritorio de Docker para Windows instalado (dockr.ly/2tEQgR4) y configurado para usar contenedores Linux (valor predeterminado). Esto también requiere que Hyper-V esté habilitado en la máquina, pero, si es necesario, el instalador le avisará. Si está trabajando en Visual Studio Code (independientemente del sistema operativo), existen bastantes extensiones para trabajar con Docker directamente desde el IDE.

Creación del proyecto

Empecé mi recorrido con una sencilla API de ASP.NET Core. Los pasos para configurar un nuevo proyecto para que coincida con el mío son: Nuevo proyecto | .NET Core | Aplicación web ASP.NET Core. En la página donde se elige el tipo de aplicación, elija API. Asegúrese de que esté marcada la opción Enable Docker Support (Habilitar la compatibilidad con Docker) (figura 1). Deje la configuración del sistema operativo en Linux. Los contenedores Windows son más grandes y complicados, y sus opciones de hospedaje aún están bastante limitadas. Lo he aprendido por las malas.

Configurar el nuevo proyecto de API de ASP.NET Core
Figura 1 Configurar el nuevo proyecto de API de ASP.NET Core

Dado que ha habilitado la compatibilidad con Docker, verá un archivo Dockerfile en el nuevo proyecto. Este proporciona instrucciones al motor de Docker para crear imágenes y ejecutar un contenedor basado en la imagen final. Ejecutar un contenedor es parecido a crear una instancia de un objeto a partir de una clase. En la figura 2 se muestra el archivo Dockerfile que ha creado la plantilla. (Tenga en cuenta que estoy usando Visual Studio 2017 versión 15.9.7 con .NET Core 2.2 instalado en mi equipo. A medida que evolucionan las herramientas de Docker, es posible que también lo haga el archivo Dockerfile).

Figura 2 Archivo Dockerfile predeterminado creado por la plantilla de proyecto

FROM microsoft/dotnet:2.2-aspnetcore-runtime AS base
WORKDIR /app
EXPOSE 80
FROM microsoft/dotnet:2.2-sdk AS build
WORKDIR /src
COPY ["DataApi/DataApi.csproj", "DataApi/"]
RUN dotnet restore "DataApi/DataApi.csproj"
COPY . .
WORKDIR "/src/DataApi"
RUN dotnet build "DataApi.csproj" -c Release -o /app
FROM build AS publish
RUN dotnet publish "DataApi.csproj" -c Release -o /app
FROM base AS final
WORKDIR /app
COPY --from=publish /app .
ENTRYPOINT ["dotnet", "DataApi.dll"]

La primera instrucción identifica la imagen base usada para crear las imágenes posteriores, y el contenedor de la aplicación se especifica como se indica a continuación:

microsoft/dotnet:2.2-aspnetcore-runtime

A continuación, se creará una imagen de compilación a partir de la imagen base. La imagen de compilación es únicamente para compilar la aplicación, por lo que también necesita el SDK. Se ejecutan varios comandos en la imagen de compilación para incorporar el código de proyecto a la imagen, y para restaurar los paquetes necesarios antes de compilar la imagen.

La siguiente imagen que se cree se usará para la publicación. Se basa en la imagen de compilación. Para esta imagen, Docker ejecutará dotnet publish para crear una carpeta con los recursos mínimos necesarios para ejecutar la aplicación.

La imagen final no necesita el SDK y se crea a partir de la imagen base. Todos los recursos de la publicación se copian en esta imagen y se identifica un punto de entrada; es decir, qué debe ocurrir cuando se ejecuta esta imagen.

De forma predeterminada, las herramientas de Visual Studio realizan solo la primera fase de la configuración de depuración y se omiten las imágenes de publicación y finales. Sin embargo, para la configuración de lanzamiento, se usa todo el archivo Dockerfile.

Los distintos conjuntos de compilaciones se conocen como compilaciones de varias fases, y cada paso se centra en una tarea diferente. Curiosamente, cada comando que se ejecuta en una imagen, como los seis comandos de la imagen de compilación, hace que Docker cree una nueva capa de la imagen. La documentación de Microsoft sobre arquitectura para aplicaciones .NET en contenedores de bit.ly/2TeCbIu hace un trabajo maravilloso, ya que explica el archivo Dockerfile línea por línea y describe cómo se ha hecho más eficaz mediante compilaciones en varias etapas.

Por ahora, dejaré el archivo Dockerfile con su valor predeterminado.

Depuración del controlador predeterminado en Docker

Antes de pasar a la depuración con Docker, verificaré la aplicación mediante una depuración en el perfil autohospedado de ASP.NET Core (que usa Kestrel, un servidor web multiplataforma para ASP.NET Core). Asegúrese de que el botón Iniciar depuración (flecha verde en la barra de herramientas) está configurado para ejecutarse con el perfil que coincide con el nombre del proyecto. En este caso, es DataAPI.

A continuación, ejecute la aplicación. El explorador debería abrirse en la URL http://localhost: 5000/api/values y mostrar los resultados del método de controlador predeterminados (“value1”, “value2”). Ahora que ya sabe que la aplicación funciona, es hora de probarla en Docker. Detenga la aplicación y cambie el perfil de depuración a Docker. Si se está ejecutando Docker para Windows (con la configuración adecuada indicada al principio de este artículo), Docker ejecutará el archivo Dockerfile. Si nunca ha extraído las imágenes de referencia, para empezar, el motor de Docker extraerá las imágenes del centro de Docker. El proceso para extraer las imágenes puede llevar algunos minutos. Puede ver su progreso en la ventana de salida de la compilación. A continuación, Docker compilará las imágenes correspondientes siguiendo los pasos del archivo Dockerfile, aunque no volverá a generar ninguna imagen que no haya cambiado desde la última ejecución. El último paso se realiza mediante Visual Studio Tools para Docker, que llamará a la compilación de Docker y, después, a la ejecución de Docker para iniciar el contenedor. Como resultado, se abrirá una nueva ventana (o pestaña) del explorador con el mismo resultado que antes, pero la dirección URL será diferente porque procede de dentro de la imagen de Docker que está exponiendo ese puerto. En mi caso, es http://172.26.137.194/api/values. Una configuración alternativa provocaría que el explorador se iniciara en http://localhost:hostPort.

Si Visual Studio no puede ejecutar Docker

Detecté dos problemas que, inicialmente, impedían que Docker compilara las imágenes. La primera vez que intenté depurar desde Visual Studio orientado a Docker, recibí el mensaje “Error al intentar ejecutar el contenedor Docker”. El error hace referencia a la línea container.targets 256. Esto no era informativo ni útil hasta que, más tarde, me di cuenta de que podría haber visto los detalles del error en el resultado de la compilación. Después de probar varias cosas (como muchas lecturas en Internet, pero sin comprobar la ventana del resultado de la compilación), acabé intentando extraer una imagen de la CLI de Docker. Al hacerlo, se me indicó que debía iniciar sesión en Docker, aunque ya había iniciado sesión en la aplicación de escritorio de Docker. Al hacerlo, pude depurar desde Visual Studio 2017. Posteriormente, cerrar sesión en la CLI de Docker no afectó a esto y pude seguir con la depuración. No estoy segura de la relación entre las dos acciones. Sin embargo, al desinstalar y volver a instalar el escritorio de Docker para Windows por completo, me vi forzada, de nuevo, a iniciar sesión en la CLI de Docker para poder ejecutar mi aplicación. Según el problema descrito en GitHub en bit.ly/2Vxhsx4, parece que es porque inicié sesión en Docker para Windows con mi dirección de correo electrónico, no mi nombre de inicio de sesión.

Además, recibí el mismo error al deshabilitar Hyper-V. Al volver a habilitar Hyper-V y reiniciar la máquina se resolvió el problema. Para los curiosos: tuve que ejecutar una máquina virtual VirtualBox para una tarea que no tenía ninguna relación y VirtualBox requiere que Hyper-V esté deshabilitado.

¿Qué administra el motor de Docker?

Como resultado de ejecutar esta aplicación por primera vez, el motor de Docker extrajo las dos imágenes anotadas de Docker Hub (hub.docker.com) e hizo un seguimiento de ellas. Pero el archivo Dockerfile también creó otras imágenes que había almacenado en caché. Al ejecutar imágenes de Docker en la línea de comandos, se descubrió la imagen docker4w que usa Docker para Windows, una imagen de aspnetcore-runtime extraída de Docker Hub, y la imagen dataapi:dev que se creó al compilar el archivo Dockerfile; es decir, la imagen desde la que se ejecuta la aplicación. Si ejecuta imágenes de Docker -a para mostrar imágenes ocultas, verá dos imágenes más (sin etiquetas), que son las imágenes de compilación y publicación intermedias que crea el archivo Dockerfile, como se muestra en la figura 3. No verá nada acerca de la imagen del SDK, según Glenn Condron, de Microsoft, "debido a una peculiaridad del funcionamiento de las compilaciones en varias fases de Docker".

Imágenes de Docker expuestas y ocultas después de ejecutar la API
Figura 3 Imágenes de Docker expuestas y ocultas después de ejecutar la API

Para obtener aún más detalles sobre una imagen, use el comando:

docker image inspect [imageid]

¿Qué sucede con los contenedores? El comando docker ps revela el contenedor que han creado las herramientas de Docker para Visual Studio 2017 llamando a docker run (con parámetros) en la imagen de desarrollo. He apilado el resultado en la figura 4 para que pueda ver todas las columnas. No hay ningún contenedor oculto.

Contenedor Docker creado al ejecutar la aplicación desde la depuración de Visual Studio 2017
Figura 4 Contenedor Docker creado al ejecutar la aplicación desde la depuración de Visual Studio 2017

Configuración de la API de datos

Ahora vamos a convertirlo en una API de datos con EF Core como mecanismo de persistencia de datos. El modelo es simplista para que pueda centrarse en la inclusión en contenedores y su impacto en el origen de datos de EF Core.

Para empezar, agregue una clase denominada Magazine.cs:

public class Magazine
{
  public int MagazineId { get; set; }
  public string Name { get; set; }
}

A continuación, deberá instalar tres paquetes NuGet diferentes. Dado que voy a mostrarle la diferencia entre usar una base de datos SQLite independiente y una base de datos SQL Server, debe agregar los dos paquetes, Microsoft.EntityFrameworkCore.Sqlite y Microsoft.EntityFrameworkCore.SqlServer, al proyecto. También se ejecutarán las migraciones de EF Core, por lo que el tercer paquete que debe instalar es Microsoft.EntityFrameworkCore.Design.

Ahora dejaré que las herramientas creen un controlador y DbContext para la API. En caso de que esto sea nuevo para usted, estos son los pasos:

  • Haga clic con el botón derecho en la carpeta Controladores del Explorador de soluciones.
  • Elija Agregar | Controlador | Controlador de API con acciones que usan Entity Framework.
  • Seleccione la clase Magazine como clase de modelo.
  • Haga clic en el signo más situado junto a la clase de contexto de datos y cambie la parte resaltada del nombre a Mag para que se convierta en [SuAplicación].Models.MagContext y, a continuación, haga clic en Agregar.
  • Deje el nombre del controlador predeterminado como MagazinesController.
  • Haga clic en Agregar.

Al terminar, tendrá una nueva carpeta de datos con la clase MagContext y la carpeta Controladores tendrá un nuevo archivo MagazineController.cs.

Ahora, haré que EF Core inicialice la base de datos con tres revistas mediante la propagación basada en DbContext sobre la que escribí en mi columna de agosto de 2018 (msdn.com/magazine/mt829703). Agregue este método a MagContext.cs:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
  modelBuilder.Entity<Magazine>().HasData(
    new Magazine { MagazineId = 1, Name = "MSDN Magazine" },
    new Magazine { MagazineId = 2, Name = "Docker Magazine" },
    new Magazine { MagazineId = 3, Name = "EFCore Magazine" }
  );
 }

Configuración de la base de datos

Para crear la base de datos, tengo que especificar el proveedor y la cadena de conexión y, a continuación, crear y ejecutar una migración. Para empezar, quiero seguir un recorrido familiar, así que empezaré por orientarme a SQL Server LocalDB y especificar la cadena de conexión en el archivo appsettings.json.

Al abrir appsettings.json, descubrirá que ya contiene una cadena de conexión que se creó mediante las herramientas de controlador cuando les permití definir el archivo MagContext. Aunque se instalaron tanto los proveedores SQL Server como SQLite, parece que el valor predeterminado es el proveedor SQL Server. Esto resultó ser cierto en las pruebas subsiguientes. Prefiero usar mi propio nombre de cadena de conexión y mi propio nombre de base de datos, por lo que reemplacé la cadena de conexión MagContext con MagsConnectionMssql y agregué mi nombre de base de datos preferida: DP0419Mags:

"ConnectionStrings": {
    "MagsConnectionMssql":
      "Server=(localdb)\\mssqllocaldb;Database=DP0419Mags;Trusted_Connection=True;"
  }

En el archivo startup.cs de la aplicación, que incluye un método ConfigureServices, las herramientas también insertaron código para configurar DbContext. Cambie el nombre de la cadena de conexión de MagContext para que coincida con el nuevo nombre:

services.AddDbContext<MagContext>(options =>
  options.UseSqlServer(Configuration.GetConnectionString(  "MagsConnectionMssql")));

Ahora puedo usar migraciones de EF Core para crear la primera migración y, dado que estoy en Visual Studio, puedo hacerlo mediante comandos de PowerShell en la consola del administrador de paquetes:

add-migration initMssql

Migración de la base de datos

El comando creó un archivo de migración, pero no voy a crear mi propia base de datos con los comandos de migración: cuando implemente mi aplicación, no quiero tener que ejecutar comandos de migración para crear o actualizar la base de datos. En su lugar, usaré el método de EF Core Database.Migrate. Donde situar este método lógico en la aplicación es una decisión importante. Necesita ejecutarlo cuando se inicie la aplicación. Mucha gente interpreta que esto significa al ejecutar el archivo startup.cs, pero el equipo de ASP.NET Core recomienda colocar el código de inicio de la aplicación en el archivo program.cs, que es el verdadero punto inicial de una aplicación ASP.NET Core. Pero, al igual que con cualquier decisión, puede haber factores que afectan a esta guía.

El método Main predeterminado del programa llama al método ASP.NET Core, CreateWebHostBuilder, que realiza una gran cantidad de tareas en su nombre y, a continuación, llama a dos métodos más: Build y Run:

public static void Main(string[] args)
{
  CreateWebHostBuilder(args).Build().Run();
}

Necesito migrar la base de datos después de Build, pero antes de Run. Para hacerlo, he creado un método de extensión para leer la información del proveedor del servicio definida en startup.cs, que detectará la configuración de DbContext. A continuación, el método llama a Database.Migrate en el contexto. Adapté el código (y la guía del miembro del equipo de EF Core, Brice Lambson) a partir del problema de GitHub de bit.ly/2T19cbY para crear el método de extensión para el objeto IWebHost que se muestra en la figura 5. El método está diseñado para tomar un tipo genérico de DbContext.

Figura 5 Método de extensión para IWebHost

public static IWebHost MigrateDatabase<T>(this IWebHost webHost) where T : DbContext
{
  using (var scope = webHost.Services.CreateScope())
  {
    var services = scope.ServiceProvider;
    try
    {
      var db = services.GetRequiredService<T>();
      db.Database.Migrate();
    }
    catch (Exception ex)
    {
      var logger = services.GetRequiredService<ILogger<Program>>();
      logger.LogError(ex, "An error occurred while migrating the database.");
    }
  }
  return webHost;
}

A continuación, modifiqué el método Main para llamar a MigrateDatabase para MagContext entre Build y Run:

CreateWebHostBuilder(args).Build().MigrateDatabase<MagContext>().Run();

A medida que agrega todo este código nuevo, Visual Studio debería indicarle que realice adiciones mediante instrucciones para Microsoft.EntityFrameworkCore, Microsoft.Extensions.DependencyInjection y el espacio de nombres para la clase MagContext.

Ahora, la base de datos se migrará (o incluso se creará) según sea necesario en tiempo de ejecución.

Un último paso antes de depurar es indicar a ASP.NET Core que apunte al controlador de Magazines al iniciar y no al controlador de valores. Puede hacerlo en el archivo launchsettings.json cambiando las instancias de launchUrl de api/values a api/Magazines.

Ejecución de la API de datos en Kestrel y, a continuación, en Docker

Como hice con el controlador de valores, para comenzar, voy a probar esto en el servidor autohospedado mediante el perfil del proyecto (por ejemplo, DataAPI), no el perfil de Docker. Dado que la base de datos aún no existe, Migrate la creará, lo que significa que habrá un breve retraso, ya que SQL Server, incluso LocalDB, tiene mucho trabajo por hacer. Pero la base de datos se crea y alimenta y, a continuación, el método de controlador predeterminado lee y muestra las tres revistas en el explorador en localhost:5000/api/Magazines.

Ahora vamos a probarlo con Docker. Cambie el perfil de depuración a Docker y vuelva a ejecutarlo. ¡Oh no!  Cuando se abre el explorador, muestra una excepción SQLException, con detalles que explican que TryGetConnection generó un error.

¿Qué es lo que ocurre aquí? La aplicación busca el servidor SQL Server (definido como "(localdb) \\mssqllocaldb" en la cadena de conexión) dentro del contenedor Docker en ejecución. Pero LocalDB está instalado en mi equipo y no dentro del contenedor. Aunque es una opción común para prepararse para una base de datos SQL Server si está en un entorno de desarrollo, no funciona tan fácilmente cuando tiene como destino contenedores Docker.

Esto significa que tengo que hacer más trabajo, y posiblemente, tiene más preguntas. Yo las tenía.

Un desvío fácil

Hay algunas opciones geniales, como el uso de SQL Server para Linux en otro contenedor Docker o el uso de una base de datos Azure SQL como destino. En mi próximo par de artículos, profundizaré en esas soluciones, pero antes quiero que vea una solución rápida, en la que el servidor de base de datos existirá dentro del contenedor y la API se ejecutará correctamente. Puede conseguirlo fácilmente con SQLite, que es una base de datos independiente.

Ya debería tener instalado el paquete Microsoft.EntityFramework.SQLite. Las dependencias de este paquete NuGet forzarán la instalación de los componentes del entorno de ejecución de SQLite en la imagen donde se compila la aplicación.

Agregue una nueva cadena de conexión denominada MagsConnectionSqlite al archivo appsettings.json. He especificado como nombre de archivo DP0419Mags.db:

"ConnectionStrings": {
    "MagsConnectionMssql":
      "Server=(localdb)\\mssqllocaldb;Database=DP0419Mags;Trusted_Connection=True;",
    "MagsConnectionSqlite": "Filename=DP0419Mags.db;"
  }

En Inicio, cambie el proveedor de DbContext a SQLite por el nuevo nombre de cadena de conexión:

services.AddDbContext<MagContext>(options =>
  options.UseSqlite(Configuration.GetConnectionString(  "MagsConnectionSqlite")));

El archivo de migración que creó es específico del proveedor SQL Server, por lo que necesitará reemplazarlo. Elimine la carpeta Migrations y, a continuación, ejecute Add-Migration initSqlite en la consola del administrador de paquetes para volver a crear la carpeta junto con los archivos de migración e instantáneas.

Puede ejecutarlo en el servidor integrado si quiere ver el archivo que se crea o, simplemente, puede iniciar la depuración de esto en Docker. La nueva base de datos SQLite se crea muy rápidamente cuando se llama al comando Migrate y, a continuación, el explorador vuelve a mostrar las tres revistas. Tenga en cuenta que la dirección IP de la URL coincidirá con la que vio antes al ejecutar el controlador de valores en Docker. En mi caso, es http://172.26.137.194/api/Magazines. Así que, ahora, la API y SQLite se ejecutan dentro del contenedor Docker.

Próximamente, una solución más orientada a la producción

Aunque, ciertamente, usar la base de datos SQLite simplifica la tarea de dejar que EF Core cree una base de datos dentro del mismo contenedor que está ejecutando la aplicación, es probable que no sea la manera en que quiera implementar la API en producción. Uno de los atractivos de los contenedores es que puede expresar la separación de intereses empleando y coordinando varios contenedores.

En el caso de esta pequeña solución, quizás SQLite funcionaría. Pero, para sus soluciones del mundo real, debería usar otras opciones. Centrándose en SQL Server, una de esas opciones sería orientarse a una instancia de Azure SQL Database. Con esta opción, independientemente de dónde se ejecute la aplicación (en la máquina de desarrollo, en IIS, en un contenedor Docker, en un contenedor Docker en la nube), puede estar seguro de que siempre va a apuntar a una base de datos o servidor de bases de datos coherente, en función de sus requisitos. Otra posibilidad es utilizar servidores de bases de datos en contenedores, como SQL Server para Linux, tal como expliqué en una columna anterior (msdn.com/magazine/mt784660). Los microservicios presentan un nuevo nivel de soluciones posibles, ya que la recomendación es tener una base de datos por microservicio. También puede administrarlos fácilmente en contenedores. Existe un libro excelente (y gratuito) de Microsoft sobre la arquitectura de aplicaciones .NET para microservicios en contenedores disponible en bit.ly/2NsfYBt.

En las siguientes columnas, exploraré algunas de estas soluciones a medida que muestro cómo orientarse a Azure SQL o SQL Server en contenedores, administrar cadenas de conexión y proteger las credenciales mediante variables de entorno de Docker, y habilitar EF Core para detectar cadenas de conexión durante el diseño mediante comandos de migración y en tiempo de ejecución desde el contenedor Docker. Incluso con mi experiencia previa con Docker y EF Core, experimenté distintas curvas de aprendizaje para comprender los detalles de estas soluciones y estoy deseando compartirlos todos con ustedes.


Julie Lerman es directora regional de Microsoft, MVP de Microsoft, instructora y consultora del equipo de software. Vive en las colinas de Vermont. Puede encontrarla haciendo presentaciones sobre el acceso a datos y otros temas en grupos de usuarios y en conferencias en todo el mundo. Su blog es thedatafarm.com/blog y es la autora de "Programming Entity Framework", así como de una edición de Code First y una edición de DbContext, de O’Reilly Media. Sígala en Twitter en @julielerman y vea sus cursos de Pluralsight en bit.ly/PS-Julie.

Gracias a los siguientes expertos técnicos de Microsoft por revisar este artículo: Glenn Condron, Steven Green, Mike Morton