Crear imágenes Docker de proyectos netcore en varias versiones del framework

Imagina que estás probando alguna versión release de netcore (pongamos la 2.2-preview3) y quieres generar imágenes Docker de tu proyecto para esa imagen. Pero a la vez quieres también crear las imágenes usando la última versión estable (pongamos la 2.1).

Asumiendo que el código fuente es compatible, ¿como puedes gestionar eso sin morir en el intento?

Pues la verdad es que la combinación de Directory.Build.props, docker-compose y argumentos de build de Docker lo hacen ridículamente sencillo. ¡Vamos a verlo!

Directory.Build.props

El principal problema para mantener más de una versión a la vez es que las versiones nuget de los paquetes son distintas. Así cuando compile la versión de 2.1 voy a querer usar los paquetes de 2.1 (p. ej. Microsoft.AspNetCore.App en 2.1.6 o bien Microsoft.EntityFrameworkCore en 2.1.4) pero si estoy compilando para 2.2-preview3 voy a querer ambos paquetes en la 2.2.0-preview3-35497.

Tener dos csprojs no es una opción pero por suerte Directory.Build.props acude a nuestro rescate.

Este fichero es una maravilla de la compilación de msbuild y te permite definir variables y acciones de msbuild que automáticamente se integran en el proceso de construcción. La mera existencia de este fichero basta para que sea usado y afecta a todos los proyectos situados en el mismo directorio o cualquier directorio hijo. Esto te permite controlar n csprojs con un solo Directory.Build.props. Si quieres ver un ejemplo de uso real, mira como lo usamos en Beatpulse.

Os pongo un ejemplo real: quiero compilar el  mismo proyecto tanto para 2.1, como para 2.2-preview3 y también para 2.2 GA (usando las imágenes nightly). Lo primero que hago es crearme un fichero Directory.build.props tal y como sigue:

<Project>
  <PropertyGroup>
    <NetCoreToTarget></NetCoreToTarget>
  </PropertyGroup>
  <Choose>
    <When Condition=" '$(NetCoreVersion)' == 'netcore22' ">
      <PropertyGroup>
        <NetCoreToTarget>net22</NetCoreToTarget>
      </PropertyGroup>
    </When>
    <When Condition=" '$(NetCoreVersion)' == 'netcore22-preview3' ">
      <PropertyGroup>
        <NetCoreToTarget>net22-preview3</NetCoreToTarget>
      </PropertyGroup>
    </When>
    <When Condition=" '$(NetCoreVersion)' == 'netcore21' Or '$(NetCoreVersion)' == '' ">
      <PropertyGroup>
        <NetCoreToTarget>net21</NetCoreToTarget>
      </PropertyGroup>
     </When>
  </Choose>
  <Import Project="build/dependencies.props" />
</Project>

Se trata de un fichero muy sencillo que hace lo siguiente:

  1. En base al valor de un argumento msbuild llamado NetCoreVersion crea una variable msbuild llamada NetCoreTarget y lo mapea de forma que:
    1. Si NetCoreVersion vale netcore22, entonces NetCoreTarget vale net22
    2. Si NetCoreVersion vale netcore22-preview3 entonces NetCoreTarget vale net22-preview3
    3. Si NetCoreVersion vale netcore21 o no tiene valor, entonces NetCoreTarget vale net21
  2. Finalmente importa el fichero «build/dependencies.props»

El fichero importado es el que me define la versiones de los paquetes a usar, en base al valor de NetCoreTarget que hemos establecido antes:

<Project>
  <PropertyGroup Label="SDK Versions">
    <NetStandardTargetVersion>netstandard2.0</NetStandardTargetVersion>
  </PropertyGroup>
  <PropertyGroup Label="Global csproj settings">
    <LangVersion>latest</LangVersion>
    <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
    <AspNetCoreHostingModel>inprocess</AspNetCoreHostingModel>
  </PropertyGroup>

  <Choose>
    <When Condition=" '$(NetCoreToTarget)' == 'net22-preview3' ">
      <PropertyGroup Label="Netcore Versions">
          <NetCoreTargetVersion>netcoreapp2.2</NetCoreTargetVersion>
          <MicrosoftEntityFrameworkCore>2.2.0-preview3-35497</MicrosoftEntityFrameworkCore>
          <MicrosoftEntityFrameworkCoreSqlServer>2.2.0-preview3-35497</MicrosoftEntityFrameworkCoreSqlServer>
      </PropertyGroup>
    </When>
    <When Condition=" '$(NetCoreToTarget)' == 'net22' ">
      <PropertyGroup Label="Netcore Versions">
          <NetCoreTargetVersion>netcoreapp2.2</NetCoreTargetVersion>
          <MicrosoftEntityFrameworkCore>2.2.0</MicrosoftEntityFrameworkCore>
          <MicrosoftEntityFrameworkCoreSqlServer>2.2.0</MicrosoftEntityFrameworkCoreSqlServer>
      </PropertyGroup>
    </When>
    <When Condition=" '$(NetCoreToTarget)' == 'net21' ">
      <PropertyGroup Label="Netcore Versions">
          <NetCoreTargetVersion>netcoreapp2.1</NetCoreTargetVersion>
          <MicrosoftEntityFrameworkCore>2.1.2</MicrosoftEntityFrameworkCore>
          <MicrosoftEntityFrameworkCoreSqlServer>2.1.2</MicrosoftEntityFrameworkCoreSqlServer>
      </PropertyGroup>
     </When>
  </Choose>
</Project>

Con eso defino variables de msbuild addicionales, llamadas:

  • NetCoreTargetVersion
  • MicrosoftEntityFrameworkCore
  • MicrosoftEntityFrameworkCoreSqlServer

La idea es que definiría una variable por cada paquete nuget que deseo usar y dicha variable tiene la versión del paquete u otro setting necesario.

Ahora con esto no nos basta claro. Si usamos esa técnica no podemos tener las versiones de los paquetes nuget en los csprojs. Pero ese es un cambio trivial. Abre el csproj, busca los <PackageReferences> y modifica la versión para que use la variable msbuild del paquete correspondiente:

<ItemGroup>
  <PackageReference Include="Microsoft.AspNetCore.App" />
  <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="$(MicrosoftAspNetCoreMvcVersioning)" />
  <PackageReference Include="Microsoft.EntityFrameworkCore" Version="$(MicrosoftEntityFrameworkCore)" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="$(MicrosoftEntityFrameworkCoreSqlServer)" />
</ItemGroup>

Vale. Observa que de golpe y porrazo has centralizado todas las versiones de paquetes nuget en un solo lugar: el fichero dependencies.props. Si quieres actualizar paquetes no necesitas modificar todos los csprojs: te basta con tocar en un solo lugar. También puedes ver que Microsoft.AspNetCore.App no tiene versión. De este modo se usa la versión exacta del SDK que se esté usando en cada momento.

No tienes porque preocuparte por Visual Studio: VS entiende Directory.Build.props y vas a ver tus paquetes nuget resueltos a la versión correcta. Lo jodido es que VS no pasa parámetros de msbuild, así que verás «la versión por defecto» (en este caso netcore21).

Nota: Si te estás preguntando porque mapeo el parámetro NetCoreVersion a NetCoreToTarget y no uso NetCoreVersion directamente en dependencies.props, la respuesta es que en este caso concreto lo que hago no es necesario. Es decir podría limitarme a importar el fichero build/dependencies.props y cuando compile pasar el parámetro msbuild NetCoreToTarget y todo funcionaría igual. Pero yo siempre prefiero hacer ese mapeo porque me da flexibilidad: si en un futuro quiero hacer más cosas cuando haya un cambio de versión puedo agregarlas en el Directory.Build.props y me queda todo más limpio. Pero vamos, es pura preferencia personal.

Debes editar también algunas líneas más en la sección de PropertyGroup, el valor de <TargetFramework>:

<PropertyGroup>
  <TargetFramework>$(NetCoreTargetVersion)</TargetFramework>
  <DockerDefaultTargetOS>$(DockerDefaultTargetOS)</DockerDefaultTargetOS>
</PropertyGroup>

¡Bien! Ya tenemos un conjunto de csprojs que se compilan a netcore21 o netcore2.2-preview3 o netcore2.2 según un parámetro de msbuild.

Docker compose y argumentos de build

Bien, ahora toca la segunda parte que es generar imágenes de docker distintas para cada entorno. Aquí nos ayudará tanto docker-compose como usar argumentos de build de Docker.

En el Dockerfile le vamos a pasar cuatro argumentos de build:

  1. La imagen
  2. El tag de runtime
  3. El tag del sdk
  4. El valor del parámetro msbuild NetCoreVersion

Aquí tienes una posible versión del Dockerfile (por supuesto deberás adaptarla):

ARG sdkTag=2.1-sdk
ARG runtimeTag=2.1-aspnetcore-runtime
ARG coreversion=netcore21
ARG image=microsoft/dotnet
FROM ${image}:${runtimeTag} AS base
WORKDIR /app

FROM ${image}:${sdkTag} AS build
ARG coreversion
WORKDIR /src
COPY ./Directory.Build.props .
COPY ./build ./build
WORKDIR /src/project
COPY ./MyProjectFolder .
RUN dotnet build "MyProject.csproj"  -p:NetCoreVersion=${coreversion} -c Release -o /app

FROM build AS publish
ARG coreversion
RUN dotnet publish "MyProject.csproj"  -p:NetCoreVersion=${coreversion} -c Release -o /app

FROM base AS final
WORKDIR /app
COPY --from=publish /app .
ENTRYPOINT ["dotnet", "MyProject.dll"]

Nota: Observa que copio, obviamente, el Directory.build.props en la imagen de compilación. Eso me obliga a que el contexto de build sea, como mínimo, el que contenga dicho fichero.

Definimos los 4 argumentos de build (sdkTag, runtimeTag, coreversion y image) y los usamos donde los necesitamos: tanto en los FROM (para poder usar distintas imágenes base y tags) y también cuando llamamos a dotnet para pasarle el parámetro msbuild.

Porque eso es lo que nos faltaba: ver como pasar parámetros msbuild usando dotnet.exe. Pues es muy fácil basta con añadir «-p:parametro=valor». De este modo pasamos el valor contenido en el argumento de build coreversion al parámetro msbuild NetCoreVersion.

Ya solo nos queda una cosa más. Tener un fichero compose por cada versión (uno para 2.1, otro para 2.2-preview3 y otro para 2.2) que establezca esos valores de build (bueno, el de 2.1 te lo puedes ahorrar ya que los argumentos de build tienen los valores por defecto de 2.1 en este ejemplo).

Por ejemplo, el de 2.2 podría ser un docker-compose.net22.yml tal como ese:

version: '3.4'

services:
  myproject:
    build:
      args:
        image: microsoft/dotnet-nightly
        sdkTag: 2.2-sdk
        runtimeTag: 2.2-aspnetcore-runtime
        coreversion: netcore22
 

Si ahora quieres generar las imágenes docker para 2.2 te basta con un «docker-compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.net22.yml  build«. Y solo modificando la parte en negrita (pasando otro fichero compose) generarías las imágenes para las otras versiones.

Por supuesto, lo habitual es que tu quieras generar tags distintos para cada versión, así que en tu fichero compose principal tendrías algo como:

myproject:
  image: myimage:${TAG:-latest}

Sencillo, eficaz y establecer el valor de la variable de entorno TAG al valor que quieras antes de ejecutar el docker-compose build.

Espero que te haya sido útil!

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *