Como instalar un servicio Net Core en Linux

Banner de la entrada "Como instalar un servicio NetCore en Linux"

Hace un par de semanas hablamos de como poder crear un servicio Net Core que fuese multiplataforma, y probamos a instalarlo en Windows. Hoy vamos a continuar con el proceso y vamos a instalarlo en Linux para comprobar que realmente es multiplataforma (y de paso ver lo realmente fácil que es).

Para poder hacerlo, solo necesitamos 2 cosas:

  • Una máquina Linux con .Net Core.
  • Una aplicación .Net Core programada como un servicio.

Para la primera de las dos, te dejo un enlace a mi entrada hablando sobre «Como instalar .Net Core en Linux» , para la segunda, vamos a reutilizar el código que vimos en la última entrada. Para ello, desde nuestra terminal (asumo que no todo el mundo usa git, por lo que voy a utilizar la descarga http), vamos a escribir los comandos:

wget https://codeload.github.com/FixedBuffer/ServicioNetCore/zip/master
unzip -a master

Una vez descomprimido, vamos a navegar hasta el .csproj con:

cd ServicioNetCore-master
cd PostServicioNetCore

Y una vez dentro, simplemente vamos a publicarlo:

dotnet publish --configuration Release

Con estos pasos, ya tenemos un binario listo para registrar como servicio. ¡Vamos a ello!

Instalando nuestro servicio Net Core en Linux

En primer lugar, la ruta donde hemos generado los binarios es poco amigable (además de ser poco práctica), así que vamos a mover la carpeta a una ruta más apropiada:

sudo mv /home/jorturfer/ServicioNetCore-master/PostServicioNetCore/bin/Release//netcoreapp2.2/publish /usr/local/NetCoreService

*Estamos moviendo la carpeta publish entera a la ruta «/usr/local/», que es una ruta mucho más apropiada para nuestro servicio, aunque no es obligatorio.

Una vez que tenemos todo puesto en su sitio vamos a instalar un servicio NetCore en Linux, para ello, solo hay que crear su fichero de definición en «/etc/systemd/system/» y registrarlo. Para crear la definición, vamos a escribir:

sudo nano /etc/systemd/system/netcoreservice.service

Y dentro del fichero, vamos a rellenar el template:

[Unit]
Description=DESCRIPCION DEL SERVICIO

[Service]
WorkingDirectory= RUTA DONDE ESTAN LOS BINARIOS
ExecStart=/usr/bin/dotnet RUTA HASTA LA DLL
Restart=always
RestartSec=10
SyslogIdentifier=IDENTIFICADOR DEL SISTEMA
User=USUARIO QUE LO VA A EJECUTAR
Environment=VARIABLES DE ENTORNO QUE VA A RECIBIR

[Install]
WantedBy=multi-user.target

Después de rellenar los datos, nos quedará algo como esto:

[Unit]
Description=Servicio para la entrada de "Como instalar un servicio NetCore en Linux"
[Service]
WorkingDirectory= /usr/local/NetCoreService
ExecStart=/usr/bin/dotnet /usr/local/NetCoreService/PostServicioNetCore.dll
Restart=always
RestartSec=10
SyslogIdentifier=dotnet-postservice
User=jorturfer
Environment=ASPNETCORE_ENVIRONMENT=Production

[Install]
WantedBy=multi-user.target

Por último, vamos a registrarlo con el comando:

sudo systemctl enable netcoreservice.service

Después de esto, ya podemos manejar nuestro servicio Net Core en Linux como cualquier otro servicio con los comandos:

sudo service nombreservicio start/stop/status/restart

Vamos a comprobar que funciona bien, ejecutando:

sudo service netcoreservice start

Después de eso, deberíamos encontrar un fichero llamado «PostServicioCore.txt» junto al binario con un registro por cada minuto que ha estado arrancado. Además, gracias a SSH, vamos a poder asociarnos al proceso y depurarlo como si estuviese en nuestra maquina. (Incluso con Visual Studio Code gracias al último anuncio de Microsoft sobre depuración remota en la MSBuild2019)

Como hemos podido comprobar, el servicio que hicimos multiplataforma, realmente lo es, e instalar un servicio Net Core en Linux no tiene ningún misterio.

Cómo crear un servicio Net Core multiplataforma

Imagen para el post de crear servicios para .Net Core

Después de varias semanas hablando sobre las maravillas de Terraform o de Integración y Despliegue continuos (CI/CD), hoy vengo a hablaros de un problema con el que me he encontrado con un proyecto que tengo entre manos.

Por necesidades del proyecto, quiero hacer un servicio .Net Core, y poder instalarlo en cualquier plataforma. Esto no es ningún problema en Linux por ejemplo, pero para poder correr un servicio en Windows hay que cumplir con ciertos requisitos. Para .Net Framework existen múltiples herramientas que nos lo permiten, como puede ser un proyecto de instalador de servicio o utilizar Topself, pero para .Net Core ya es otra cosa…

Para que un servicio se pueda instalar y funcione en Windows, tiene que heredar de «ServiceBase«, lo que difiere un poco de cómo funciona en otras plataformas, y hace que crear un servicio multiplataforma no sea algo directo. Esto se puede solucionar fácilmente con un poco de código.

Creando nuestro servicio Net Core

Para empezar, vamos a crear una aplicación de consola .Net Core:

La imagen muestra la creación de un proyecto de consola net core

Para poder hospedar un servicio cualquiera, vamos a necesitar añadir el paquete NuGet «Microsoft.Extensions.Hosting«, y además, para poder correrlo cuando funcione en Windows, vamos a necesitar también el paquete NuGet «System.ServiceProcess.ServiceController«. Una vez que los hemos añadido, vamos a crear nuestro IHost:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;

namespace PostServicioNetCore
{
    class Program
    {
        static async Task Main(string[] args)
        {
            var host = new HostBuilder()
                 .ConfigureHostConfiguration(configHost =>
                 {
                     //Configuración del host
                 })
                 .ConfigureAppConfiguration((hostContext, configApp) =>
                 {
                     //Configuración de la aplicacion

                 })
                .ConfigureServices((hostContext, services) =>
                {
                    //Configuración de los servicios
                    services.AddHostedService<LifetimeHostedService>();
                });
            if (!Debugger.IsAttached) //En caso de no haber debugger, lanzamos como servicio
            {
                await host.RunServiceAsync();
            }
            else
            {
                await host.RunConsoleAsync();
            }
        }
    }
}

Al igual que haríamos en un proyecto ASP NET Core en el Startup.cs y en Program.cs, podemos configurar las diferentes partes de nuestro servicio Net Core mediante los métodos:

  • «ConfigureHostConfiguration» (en Program.cs)
  • «ConfigureAppConfiguration» («Configure» en Startup.cs)
  • «ConfigureServices» (en Startup.cs)

En este caso, estamos levantando un IHost genérico al cual le vamos a inyectar la dependencia del servicio/servicios que queremos ejecutar registrándolos mediante «AddHostedService».

Para poder probar, el servicio que hemos registrado es este (tiene que implementar IHostedService):

using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

namespace PostServicioNetCore
{
    public class LifetimeHostedService : IHostedService, IDisposable
    {
        ILogger<LifetimeHostedService> logger;
        private Timer _timer;
        private string _path;
        public LifetimeHostedService(ILogger<LifetimeHostedService> logger, IHostingEnvironment hostingEnvironment)
        {
            this.logger = logger;
            _path = $"{hostingEnvironment.ContentRootPath}PostServicioCore.txt";
        }

        public Task StartAsync(CancellationToken cancellationToken)
        {
            try
            {
                WriteToFile("Inicia el servicio");
                _timer = new Timer((e) => WriteTimeToFile(), null, TimeSpan.Zero, TimeSpan.FromMinutes(1));
            }
            catch (Exception ex)
            {
                logger.LogError($"{ex.Message},{ex.StackTrace}");
            }
            return Task.CompletedTask;
        }

        public Task StopAsync(CancellationToken cancellationToken)
        {
            _timer?.Change(Timeout.Infinite, 0);
            WriteToFile("Finaliza el servicio");
            return Task.CompletedTask;
        }

        private void WriteTimeToFile()
        {
            WriteToFile(DateTime.Now.ToString());
        }

        private void WriteToFile(string message)
        {
            if (!File.Exists(_path))
            {
                using (var sw = File.CreateText(_path))
                {
                    sw.WriteLine(message);
                }
            }
            else
            {
                using (var sw = File.AppendText(_path))
                {
                    sw.WriteLine(message);
                }
            }
        }

        public void Dispose()
        {
            _timer?.Dispose();
        }
    }
}

En él, simplemente se añaden mensajes a un fichero que se encuentra junto al binario para indicar cuando empieza, cuando finaliza y cada minuto mientras esté activo, pero aquí sería donde vamos a poner que es lo que hace nuestro servicio (consultar una cola de mensajes, lecturas a una Db, etc).

Hasta aquí, este es el mismo concepto que utilizamos en ASP NET Core para crear servicios que corran en segundo plano en una web. Creamos el servicio heredando de «IHostedService» y lo registramos desde «ConfigureServices». El problema como decía al principio, es que los servicios en Windows no funcionan así, tienen que heredar de «ServiceBase».

Preparando nuestro servicio para Windows

En primer lugar, vamos a necesitar crear nuestra clase heredada:

using Microsoft.Extensions.Hosting;
using System;
using System.ServiceProcess;
using System.Threading;
using System.Threading.Tasks;

namespace PostServicioNetCore
{
    public class ServiceBaseLifetime : ServiceBase, IHostLifetime
    {
        private readonly TaskCompletionSource<object> _delayStart = new TaskCompletionSource<object>();

        public ServiceBaseLifetime(IApplicationLifetime applicationLifetime)
        {
            ApplicationLifetime = applicationLifetime ?? throw new ArgumentNullException(nameof(applicationLifetime));
        }

        private IApplicationLifetime ApplicationLifetime { get; }

        public Task WaitForStartAsync(CancellationToken cancellationToken)
        {
            cancellationToken.Register(() => _delayStart.TrySetCanceled());
            ApplicationLifetime.ApplicationStopping.Register(Stop);

            new Thread(Run).Start(); //Ejecutamos la tarea en un hilo para bloquear y prevenir que IHost.StartAsync termine.
            return _delayStart.Task;
        }

        private void Run()
        {
            try
            {
                Run(this); // Bloqueamos la ejecución hasta que el servicio termine.
                _delayStart.TrySetException(new InvalidOperationException("Stopped without starting"));
            }
            catch (Exception ex)
            {
                _delayStart.TrySetException(ex);
            }
        }

        public Task StopAsync(CancellationToken cancellationToken)
        {
            Stop();
            return Task.CompletedTask;
        }

        // Se llama desde base.Run() cuando el servicio esta listo para funcionar.
        protected override void OnStart(string[] args)
        {
            _delayStart.TrySetResult(null);
            base.OnStart(args);
        }

        protected override void OnStop()
        {
            ApplicationLifetime.StopApplication();
            base.OnStop();
        }
    }
}

Sin ánimo de entrar muy en profundidad, ya que esta clase es una copia del ejemplo que ofrece Microsoft navegando entre los repositorios en GitHub, en ella básicamente se implementan los métodos propios de un servicio Windows y de la interfaz «IHostLifetime«. De este modo, la aplicación puede correr como un servicio en Windows y ser usada desde «IHost».

Esta clase tiene que estar en el contenedor de inyección de dependencias siempre que el servicio corra sobre Windows, así que vamos a añadir unos métodos de extensión que nos facilite incluirla si es necesario:

using System.Runtime.InteropServices;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Threading;
using System.Threading.Tasks;

namespace PostServicioNetCore
{
    public static class ServiceBaseLifetimeHostExtensions
    {
        public static IHostBuilder UseServiceBaseLifetime(this IHostBuilder hostBuilder)
        {
            return hostBuilder.ConfigureServices((hostContext, services) => services.AddSingleton<IHostLifetime, ServiceBaseLifetime>());
        }

        public static Task RunServiceAsync(this IHostBuilder hostBuilder, CancellationToken cancellationToken = default)
        {
            //Si es Windows, añadimos el ServiceBaseLifetime a la inyeccion de dependencias
            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                return hostBuilder.UseServiceBaseLifetime().Build().RunAsync(cancellationToken);
            }
            else //Sino, ejecutamos normalmente
            {
                return hostBuilder.Build().RunAsync(cancellationToken);
            }
        }
    }
}

Gracias a la condición que pusimos al arrancar el IHost para detectar si estábamos en sin el debugger, y con la condición que añadimos dentro de la extensión RunAsServiceAsync, vamos a poder ejecutar nuestro programa como un servicio para Windows, para Linux o como un programa de consola (que es ideal para depurarlo).

Por último, si queremos instalar el servicio en Windows, primero vamos a publicarlo de manera que generemos un .exe indicándole al publicar que queremos que el «Tiempo de ejecución de destino» sea una versión de Windows»

La imagen muestra como indicarle el destino de la publicación

Una vez que hemos publicado nuestro .exe, vamos a tirar de «Service Controller Commands» para instalarlo. Desde la terminal:

sc create "Servicio Core" binpath= "C:\Users\jorge\source\repos\PostServicioNetCore\PostServicioNetCore\bin\Release\netcoreapp2.2\publish\PostServicioNetCore.exe"

Por el contrario, si queremos que el servicio sea para Linux, basta con que no generemos un .exe al publicar y lo registremos normalmente (como veremos en la próxima entrada aprovechando este código).

Con esto, ya tenemos nuestro servicio Net Core multiplataforma listo para ser instalado y ejecutado tanto en Windows como en Linux, y al que solo tenemos que ir añadiéndole la funcionalidad que queramos mediante nuevas implementaciones de «IHostedService».

Como siempre, dejo el código fuente en GitHub para que puedas descargártelo y probar.

«Terraformando» nuestra infraestructura desde Azure Pipelines

La imagen muestra los logos de terraform, azure pipelines y azure cloud

En la última entrada, hablamos sobre Terraform como herramienta para para gestionar nuestra infraestructura mediante código. También hemos hablado hace poco sobre el despliegue continuo desde Azure Pipeline con ARM. Como pudimos comprobar, trabajar con Terraform nos facilita la vida respecto a lo que es el trabajo con ARM directamente. Hoy vamos a unir las dos cosas utilizando Terraform desde Azure Pipelines.

En su momento comentábamos que una de las grandes ventajas de Terraform es que mantiene el estado de los recursos. Esto es algo muy útil y que con trabajo local no supone ningún problema, pero eso se nos queda corto si trabajamos desde el agente de Azure Pipelines. Esto es porque cuando el agente termine va a borrar los recursos, y esto incluye el fichero de estado del despliegue. Para solucionar este «inconveniente» y persistir el estado entre despliegues, vamos a utilizar un Azure Storage donde guardar el fichero.

Una vez aclarado esto, ¡vamos a crear una WebApp en Linux y conectarla a una base de datos Sql Server!

Codificando la infraestructura

Para poder generar la infraestructura y conectarla entre si sin revisión manual, vamos a utilizar la potencia que nos ofrece Terraform para relacionar recursos y así añadir datos como la cadena de conexión. En GitHub está el proyecto completo, dividido en secciones para facilitar su mantenimiento, y utilizando la posibilidad de modular partes para crear la Web App y el Sql Server.

Utilizando estas ventajas, vamos a generar la cadena de conexión de la base de datos utilizando los datos de salida y se lo vamos a pasar a la configuración de la webapp. Para eso, vamos a crear una salida en el módulo de sql server donde vamos a generar la cadena de conexión:

output "conection_string_value" {
  description = "Sql Server ConnectionString"
  value = "Server=${azurerm_sql_server.sqlServer.fully_qualified_domain_name};Initial Catalog=${azurerm_sql_database.sqlServerDb.name};User Id=${var.SQL_ADMIN_ID};Password=${var.SQL_PASSWORD};"
}

Con esto, vamos a poder utilizarla más adelante:

module "linuxwebapp" {
  //...
  conection_string_value = "${module.sqlserver.conection_string_value}"
}

El código de la infraestructura es fácil de seguir, pero aun así recomiendo echarle un ojo en profundidad para entender el concepto anterior.

Creando la integración continua

Para poder desplegar Terraform desde Azure Pipelines, el primer paso es crear el pipeline de integración. De esto hablamos hace algún tiempo en una entrada sobre CI, pero esta vez vamos a utilizar la interfaz gráfica en vez de yml. Como se pueden ver, los pasos son los mismos que cuando utilizábamos yml:

La imagen muestra el pipeline de integración

Aquí solo hay un pequeño cambio respecto a lo que vimos anteriormente, y es que tenemos que meter los ficheros de Terraform al artefacto para poder utilizarlos más adelante:

La imagen señala los campos que hay que rellenar en la tarea de copiar archivos

Para configurar esta nueva tarea, basta con decirle el nombre de la carpeta donde está el código Terraform que queremos desplegar, ponerle el filtro de selección (** para seleccionarlo todo), y por último crear una carpeta en el directorio del artefacto y pegar los ficheros Terraform en él.

El hecho de utilizar la interfaz gráfica para la integración es solo por cambiar, pero para el despliegue solo se puede utilizar la interfaz gráfica de momento.

Ejecutar Terraform desde Azure Pipelines

Una vez que tenemos la integración lista, vamos a crear una Release para que despliegue la infraestructura y el proyecto. Para ahorrarnos trabajo, vamos a utilizar el Task «Terraform Build & Release Tasks«, así que vamos a instalarlo en el pipeline:

La imagen muestra el botón para instalar las Task de Terraform en Azure Pipelines

Una vez que lo tenemos instalado, vamos a crear el pipeline donde instalaremos Terraform, lo inicializaremos y aplicaremos para desplegar los recursos, y por último publicaremos la web:

La imagen muestra el pipeline de release

Al igual que hacíamos con ARM, lo ideal sería tener diferentes ranuras donde tengamos las diferentes etapas de dev, pre, pro, etc.

Además, vamos a necesitar diferentes variables, que vamos a registrar también en el pipeline, para eso, vamos a la pestaña «Variables»:

La imagen señala la pestaña "Variables"

Y vamos a registrar las variables que necesitamos para nuestro Terraform:

La imagen muestra las variables del pipeline

Por convención, el pipeline le pasa directamente las variables que coincidan y empiecen por TF_VAR a Terraform siempre que no sean secretos. Esto es algo a tener en cuenta para evitarnos un comando apply larguísimo donde le pasemos muchísimas variables.

Volviendo al pipeline, lo primero que tenemos que hacer es instalar Terraform, para eso, vamos a utilizar la Task «Terraform Installer» y le vamos a indicar la versión de Terraform que queremos utilizar:

La imagen muestra donde indicar la versión

El siguiente paso, es configurar una Task de tipo «Terraform CLI» para ejecutar el comando init:

La imagen muestra la interfaz de la Task Terraform CLI

Dentro de esta Task, vamos a seleccionar el comando «init», y vamos a indicar la ruta donde está el código Terraform, por último, vamos a seleccionar el tipo de backend, que como dijimos al principio, será en Azure Storage, por lo tanto, seleccionamos «azurerm». Esto nos permite ampliar la configuración pulsando sobre «AzureRM Backend Configuration», y así indicarle los datos de configuración:

La imagen muestra la configuración del backend en el Task

Con esto listo, el último paso con Terraform es crear una tercera Task de tipo «Terraform CLI». Esta vez vamos a elegir el tipo de comando «apply», le vamos a indicar la ruta a donde están los ficheros de código Terrafom, y por último le vamos a indicar las opciones. Entre las opciones, vamos a indicarle «-auto-approve» para que no pida confirmación antes de desplegar los cambios, y le vamos a pasar todas las variables de tipo «secrets» mediante «-var VARIABLE=VARIABLE_PIPELINE» (recordemos que las demás variables se le pasan por convención):

Un ejemplo de las opciones utilizadas es:

-auto-approve -var AZURE_SUBSCRIPTION_ID=$(TF_VAR_AZURE_SUBSCRIPTION_ID) -var AZURE_CLIENT_ID=$(TF_VAR_AZURE_CLIENT_ID) -var AZURE_CLIENT_SECRET=$(TF_VAR_AZURE_CLIENT_SECRET) -var AZURE_TENANT_ID=$(TF_VAR_AZURE_TENANT_ID) -var SQL_PASSWORD=$(TF_VAR_SQL_PASSWORD)

Con esto, y si todo ha ido bien, ya vamos a conseguir desplegar los recursos en Azure. Para desplegar la Web, solo nos queda añadir un Task de tipo «Azure App Service Deploy» tal cual hicimos en el caso de ARM para desplegar la web.

Tras lanzar una release, dentro de nuestro portal en azure podremos encontrar algo como esto:

La imagen muestra los recursos de Azure deplegados mediante Terraform y Azure Pipelines

Para este ejemplo, hemos utilizado el template de ASP NET Core y le hemos añadido que ejecute las migraciones de la base de datos al iniciar, para que la web este lista para funcionar directamente.

Como siempre, he dejado el código fuente completo (tanto Terraform como la Web) en GitHub.

Conclusión

Como hemos podido comprobar, Terraform es una herramienta muy potente que nos facilita mucho la vida manejando infraestructura como código (IaC), además de permitirnos trabajar con múltiples proveedores. Podemos integrar perfectamente Terraform y Azure Pipelines, o cualquier otro servicio CI/CD utilizando los comandos directamente, por lo que es algo que vale la pena revisar y conocer.