Azure KeyVault + Docker

En este post quiero explicaros como podemos desplegar nuestro entorno de desarrollo de una forma fácil y segura. Para ello vamos a usar docker para poder crear un contenedor con nuestra aplicación “API netcore 3.1” que conecta contra nuestra base de datos Azure SQL de forma segura sin tener que exponer nuestras credenciales.

Docker es una plataforma para desarrolladores y sysadmins (utlizando la filosofía DevOps) que nos permite desarrollar, desplegar y ejecutar aplicaciones en contenedores de una forma fácil y sencilla.

Para ello vamos a empezar creando nuestra API en .NET Core 3.1, pare ello usaremos el siguiente comando:

1dotnet new API -n Azuretraining

Esto nos creara la estructura de nuestro proyecto con el nombre que le hemos asignado «Azuretraining» tal y como podemos observar en la siguiente imagen:

Estructura del proyecto tras el lanzamiento del comando anteriormente mencionado.

Ahora vamos a agregar el nuget para poder trabajar con SQL en nuestro proyecto:

1dotnet add package Microsoft.EntityFrameworkCore.SqlServer 
2dotnet add package Microsoft.EntityFrameworkCore.InMemory

Incorporaremos una nueva clase al modelo, para este ejemplo definiremos un modelo de ejemplo llamado «Courses» dentro de nuestra carpeta Models:

 1namespace Azuretraining.Models
 2{
 3    public class Course
 4    {
 5        public long Id { get; set; }
 6        public string Name { get; set; }
 7        public string Description { get; set; }
 8        public DateTime StartDate { get; set; }
 9        public DateTime EndDate { get; set; }
10        public int Capacity {get; set;}
11        public double Qualification {get; set;} 
12        public string Modality {get; set;}
13        public string Category {get; set;}
14        public bool IsComplete { get; set; }
15    }
16}

Una vez tenemos nuestro modelo incorporaremos el contexto de base de datos, será la clase principal que coordina la funcionalidad de Entity Framework para un modelo de datos. Para ello agregaremos a nuestra carpeta Models un nuevo fichero llamado «CourseContext.cs» con el siguiente formato:

 1using Microsoft.EntityFrameworkCore;
 2
 3namespace Azuretraining.Models
 4{
 5    public class CourseContext : DbContext
 6    {
 7        public CourseContext(DbContextOptions<CourseContext> options)
 8            : base(options)
 9        {
10        }
11
12        public DbSet<Course> Courses { get; set; }
13    }
14}

Ahora deberemos de modificar nuestro fichero «Startup.cs» agregando las referencias necesarias para usar los servicios:

 1using Microsoft.EntityFrameworkCore;
 2using Azuretraining.Models;
 3Y modificaremos nuestra función «ConfigureServices» para que tenga el siguiente aspecto:
 4
 5public void ConfigureServices(IServiceCollection services)
 6{
 7    services.AddDbContext<CourseContext>(opt =>
 8        opt.UseInMemoryDatabase("CourseList"));
 9    services.AddControllers();
10}

Esto nos proporcionará poder usar una BD en memoria para realizar unas primeras pruebas antes de atacar a nuestra BD en la nube. Una vez lo tenemos todo preparado vamos a agregar los Nugets necesarios para poder hacer el scaffolding y generar de forma automática nuestro Controller con las siguientes instrucciones:

1dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design
2dotnet add package Microsoft.EntityFrameworkCore.Design
3dotnet tool install --global dotnet-aspnet-codegenerator
4dotnet aspnet-codegenerator controller -name CoursesController -async -api -m Course -dc CourseContext -outDir 

Controllers Veremos que en la carpeta Controller nos ha generado el archivo «CoursesController.cs» donde tendremos definida todas las acciones de nuestra API. Ahora ejecutaremos nuestra aplicación y en un explorador introducimos la siguiente URL: https://localhost:5001/api/courses.

Ahora vamos a modificar nuestra aplicación para que conecte directamente con nuestra BD de Azure SQL. Para ello previamente deberemos haber creado nuestra instancia, podemos usar Azure CLI para poder hacerlo como se muestra en el siguiente ejemplo:

1az sql server create --subscription "NOMBRE DE LA SUSCRIPCIÓN" --name trainginappDB --resource-group TrainingApp --location "West Europe" --admin-user "NOMBRE DE USUARIO" --admin-password "PASSWORD"
2 
3az sql server firewall-rule create --subscription "NOMBRE DE LA SUSCRIPCIÓN"  --resource-group TrainingApp --server trainginappdb --name AllowAllIps --start-ip-address 0.0.0.0 --end-ip-address 0.0.0.0
4
5az sql db create --subscription "NOMBRE DE LA SUSCRIPCIÓN" --resource-group TrainingApp --server trainginappdb --name TrainingApp --service-objective S0

Una vez tenemos creada nuestra instancia de BD en Azure SQL, vamos a preparar nuestra solución para «dockerizar», para ello generaremos un fichero .Dockerfile con el siguiente contenido:

 1# https://hub.docker.com/_/microsoft-dotnet-core
 2 FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
 3  WORKDIR /app
 4
 5# copy csproj and restore as distinct layers
 6  COPY *.csproj ./
 7  RUN dotnet restore
 8
 9# copy everything else and build app
10  COPY . ./
11  RUN dotnet publish -c release -o out --no-restore
12
13# final stage/image
14  FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
15  WORKDIR /app
16  COPY --from=build /app/out .
17  ENTRYPOINT ["dotnet", "azuretraining.dll"] 
18Y un fichero «.dockerignore» en nuestra solución con el siguiente contenido:
19
20# directories
21**/bin/
22**/obj/
23**/out/
24
25# files
26Dockerfile*
27**/*.md

Para nuestra cadena de conexión usaremos Azure KeyVault para poder proteger nuestro «secretos», para ello iremos al portal de Azure y crearemos un nuevo Azure KeyVault. Una vez creado vamos a Secrets -> Generate/Import como se puede apreciar en la siguiente captura:

En la siguiente pantalla deberemos de indicar que es una entrada manual, le damos un nombre a nuestro secreto, en este caso «ConnectionStrings–TrainingConnection» esto se debe a que en nuestro fichero «appsettings.json» tenemos la definición de nuestro ConnectionStrings de la siguiente forma y para que el KeyVault pueda insertar el valor en tiempo de ejecución debemos de separarlos con «–» el nombre concatenando la relación padre-hijo:

Ahora añadimos la cadena de conexión hacia nuestro Azure SQL que nos facilita cuando creamos el servicio, como se puede apreciar en la siguiente captura:

Una vez que ya tenemos nuestro KeyVault para poder proteger nuestros «secretos» vamos a modificar nuestro proyecto para poder usarlo, para ello necesitaremos añadir los siguiente Nugets:

1dotnet add package Microsoft.Azure.KeyVault
2dotnet add package Microsoft.Azure.Services.AppAuthentication
3dotnet add package Microsoft.Extensions.Configuration.AzureKeyVault
4Modificaremos nuestro archivo «Program.cs» añadiremos los imports necesarios:
1using Microsoft.Azure.KeyVault;
2using Microsoft.Azure.Services.AppAuthentication;
3using Microsoft.Extensions.Configuration;
4using Microsoft.Extensions.Configuration.AzureKeyVault;

Sustituiremos el método IHostBuilder para poder obtener la información de nuestro KeyVault y asignarlo el siguiente formato:

 1public static IHostBuilder CreateHostBuilder(string[] args) =>
 2    Host.CreateDefaultBuilder(args)
 3        .ConfigureAppConfiguration((ctx, builder) =>
 4        {
 5            var keyVaultEndpoint = GetKeyVaultEndpoint();
 6            if (!string.IsNullOrEmpty(keyVaultEndpoint))
 7            {
 8                var azureServiceTokenProvider = new AzureServiceTokenProvider();
 9                var keyVaultClient = new KeyVaultClient(
10                    new KeyVaultClient.AuthenticationCallback(
11                        azureServiceTokenProvider.KeyVaultTokenCallback));
12                builder.AddAzureKeyVault(
13                    keyVaultEndpoint, keyVaultClient, new DefaultKeyVaultSecretManager());
14            }
15        })
16        .ConfigureWebHostDefaults(webBuilder =>
17        {
18            webBuilder.UseStartup<Startup>();
19        });
20static string GetKeyVaultEndpoint() => Environment.GetEnvironmentVariable("KEYVAULT_ENDPOINT");

Ahora agregaremos en el environment (todo esto lo hacemos para que la acción se realice en tiempo de ejecución), para ello nos fijaremos que en la última linea de nuestro «Program.cs» indicábamos obtener de la variable «KEYVAULT_ENDPOINT» en ella declararemos la URL de nuestro Azure KeyVaul, esta información la deberemos de añadirla a nuestro fichero «launch.json» con el siguiente formato:

 1{
 2   // Use IntelliSense to find out which attributes exist for C# debugging
 3   // Use hover for the description of the existing attributes
 4   // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
 5   "version": "0.2.0",
 6   "configurations": [
 7        {
 8            "name": ".NET Core Launch (web)",
 9            "type": "coreclr",
10            "request": "launch",
11            "preLaunchTask": "build",
12            // If you have changed target frameworks, make sure to update the program path.
13            "program": "${workspaceFolder}/bin/Debug/netcoreapp3.1/trainingapp.courses.dll",
14            "args": [],
15            "cwd": "${workspaceFolder}",
16            "stopAtEntry": false,
17            // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
18            "serverReadyAction": {
19                "action": "openExternally",
20                "pattern": "^\\s*Now listening on:\\s+(https?://\\S+)"                
21            },
22            "env": {
23                "ASPNETCORE_ENVIRONMENT": "Development",
24                "KEYVAULT_ENDPOINT": "https://NOMBREDENUESTROKEYVAULT.vault.azure.net/"
25            },
26            "sourceFileMap": {
27                "/Views": "${workspaceFolder}/Views"
28            }
29        },
30        {
31            "name": ".NET Core Attach",
32            "type": "coreclr",
33            "request": "attach",
34            "processId": "${command:pickProcess}"
35        }
36    ]
37}

Por ultimo sustituiremos en nuestro fichero «Startup.cs» la conexión de la BD en memoria por la conexión hacia nuestro Azure SQL con la configuración que hemos preparado en los pasos anteriores, y quedará de la siguiente forma:

Esta primer servicio es la conexión que realizamos para conectar con nuestra «Cadena de conexión» securizada:

1services.AddDbContext(options =>
2                 options.UseSqlServer(Configuration.GetConnectionString("TrainingConnection")));

Este segundo servicio nos permitirá crear las tabla y estructura iniciales en caso de que no lo tengamos:

1services.BuildServiceProvider().GetService().Database.Migrate();

Ahora lanzamos nuestra aplicación y vemos que nos ha funcionado correctamente:

ATENCIÓN: Como hemos podido ver hasta aquí lo único que hemos echo es indicar la url de nuestro Azure KeyVault para poder recuperar la información de la cadena de conexión, pero el «truco» es que sino estamos logados en nuestro azure CLI en local no podremos usarlo y nos devolverá el siguiente error:

 1Startup.cs(34,13): warning ASP0000: Calling 'BuildServiceProvider' from application code results in an additional copy of singleton services being created. Consider alternatives such as dependency injecting services as parameters to 'Configure'. [/Users/msanchez/Projects/Azuretraining/Azuretraining.csproj]
 2Unhandled exception. System.ArgumentNullException: Value cannot be null. (Parameter 'connectionString')
 3at Microsoft.EntityFrameworkCore.Utilities.Check.NotEmpty(String value, String parameterName)
 4at Microsoft.EntityFrameworkCore.SqlServerDbContextOptionsExtensions.UseSqlServer(DbContextOptionsBuilder optionsBuilder, String connectionString, Action1 sqlServerOptionsAction)    at Azuretraining.Startup.<ConfigureServices>b__4_0(DbContextOptionsBuilder options) in /Users/msanchez/Projects/Azuretraining/Startup.cs:line 32    at Microsoft.Extensions.DependencyInjection.EntityFrameworkServiceCollectionExtensions.<>c__DisplayClass1_02.b__0(IServiceProvider p, DbContextOptionsBuilder b)
 5at Microsoft.Extensions.DependencyInjection.EntityFrameworkServiceCollectionExtensions.CreateDbContextOptions[TContext](IServiceProvider applicationServiceProvider, Action2 optionsAction)    at Microsoft.Extensions.DependencyInjection.EntityFrameworkServiceCollectionExtensions.<>c__DisplayClass10_01.b__0(IServiceProvider p)
 6at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitFactory(FactoryCallSite factoryCallSite, RuntimeResolverContext context)
 7at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)    at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)    at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite singletonCallSite, RuntimeResolverContext context)    at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
 8at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)
 9at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)    at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)    at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitScopeCache(ServiceCallSite singletonCallSite, RuntimeResolverContext context)    at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
10at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.Resolve(ServiceCallSite callSite, ServiceProviderEngineScope scope)
11at Microsoft.Extensions.DependencyInjection.ServiceLookup.DynamicServiceProviderEngine.<>c__DisplayClass1_0.b__0(ServiceProviderEngineScope scope)
12at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngine.GetService(Type serviceType, ServiceProviderEngineScope serviceProviderEngineScope)
13at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngine.GetService(Type serviceType)
14at Microsoft.Extensions.DependencyInjection.ServiceProvider.GetService(Type serviceType)
15at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetService[T](IServiceProvider provider)
16at Azuretraining.Startup.ConfigureServices(IServiceCollection services) in /Users/msanchez/Projects/Azuretraining/Startup.cs:line 34
17at System.RuntimeMethodHandle.InvokeMethod(Object target, Object[] arguments, Signature sig, Boolean constructor, Boolean wrapExceptions)
18at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
19at Microsoft.AspNetCore.Hosting.ConfigureServicesBuilder.InvokeCore(Object instance, IServiceCollection services)
20at Microsoft.AspNetCore.Hosting.ConfigureServicesBuilder.<>c__DisplayClass9_0.g__Startup|0(IServiceCollection serviceCollection)
21at Microsoft.AspNetCore.Hosting.ConfigureServicesBuilder.Invoke(Object instance, IServiceCollection services)
22at Microsoft.AspNetCore.Hosting.ConfigureServicesBuilder.<>c__DisplayClass8_0.b__0(IServiceCollection services)
23at Microsoft.AspNetCore.Hosting.GenericWebHostBuilder.UseStartup(Type startupType, HostBuilderContext context, IServiceCollection services)
24at Microsoft.AspNetCore.Hosting.GenericWebHostBuilder.<>c__DisplayClass12_0.b__0(HostBuilderContext context, IServiceCollection services)
25at Microsoft.Extensions.Hosting.HostBuilder.CreateServiceProvider()
26at Microsoft.Extensions.Hosting.HostBuilder.Build()
27at Azuretraining.Program.Main(String[] args) in /Users/msanchez/Projects/Azuretraining/Program.cs:line 19

Este error nos dará si intentamos ejecutar nuestro contenedor de docker, para solventarlo deberemos de aplicar un «work around» que nos permita poder trabajar sin problemas y a la vez que subimos el código a cualquier repositorio de código no tengamos que mostrar nuestra cadenas de conexión o información sensible. Para ello lo que vamos a hacer es añadir un nuevo fichero llamado docker-compose.yml con la siguiente composición:

 1version: "3.7"
 2
 3networks:
 4    azuretraining.services.network:
 5        driver: bridge
 6
 7services:
 8    azuretraining.services.courses:
 9        container_name: Azuretraining.Services
10        build:
11          context: ../
12          dockerfile: ./Azuretraining.Dockerfile   
13        ports:
14            - "8001:80"
15        networks:
16            - azuretraining.services.network
17        volumes:
18            - ~/.azure:/root/.azure   
19        environment: 
20            - KEYVAULT_ENDPOINT=https://NOMBREDENUESTROKEYVAULT.vault.azure.net/ 

En nuestro docker-compose hemos definido la estructura de ejecución de nuestros servicio, en este caso solo tenemos un contenedor, donde le indicamos el network, puerto, nombre del contenedor, etc…

En este caso lo más importante son las propiedades volumes y environment. En el environment agregaremos nuestra url del Azure KeyVault, y en volumes lo que vamos a hacer es crear un volumen compartido donde copiaremos nuestra carpeta local de Azure para que podamos hacer sin ningún problema login con Azure CLI. Lo más importante es que aunque esta carpeta se suba no compromete nuestra seguridad pues no tiene nada vinculante.

Ahora modificaremos nuestro fichero .dockerfile para incluirle el Azure CLI y que podamos consumir la conexión hacia nuestro Azure KeyVault desde nuestro contenedor:

 1# https://hub.docker.com/_/microsoft-dotnet-core
 2 FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
 3  WORKDIR /app
 4
 5# copy csproj and restore as distinct layers
 6  COPY *.csproj ./
 7  RUN dotnet restore
 8
 9# copy everything else and build app
10  COPY . ./
11  RUN dotnet publish -c release -o out --no-restore
12
13# final stage/image
14  FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
15
16# install azure cli
17ENV DEBIAN_FRONTEND noninteractive
18
19RUN apt-get update \
20    && apt-get -y install --no-install-recommends apt-utils dialog 2>&1 \
21    #
22    # Verify git, process tools, lsb-release (common in install instructions for CLIs) installed
23    && apt-get -y install git openssh-client iproute2 procps apt-transport-https gnupg2 curl lsb-release \
24    && echo "deb [arch=amd64] https://packages.microsoft.com/repos/azure-cli/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/azure-cli.list \
25    && curl -sL https://packages.microsoft.com/keys/microsoft.asc | apt-key add - 2>/dev/null \
26    && apt-get update \
27    && apt-get install -y azure-cli; 
28
29
30  WORKDIR /app
31  COPY --from=build /app/out .
32  ENTRYPOINT ["dotnet", "azuretraining.dll"]

Por último solo nos queda lanzar el siguiente comando para ejecutar nuestras aplicación en local «dockerizada» y «securizada»:

1docker-compose up

De esta forma tendremos nuestro proyecto completamente securizado pudiendo trabajar de forma fácil y sencilla, sin preocuparnos de que subamos información sensible a nuestro repositorio de código.

Dar las gracias a mi compañero @cmendibl3 por colaborar.

Saludos!