Desplegar una aplicación en .NET Core en una máquina virtual en Azure con Nginx

En ciertos escenarios todavía es necesario usar máquinas virtuales para hospedar nuestros sitios web. Eso no significa que los mismos no puedan automatizarse a base de scripting, tanto la infra como la configuración si así lo necesitas. En este artículo te cuento cómo he desplegado una API en .NET Core en una máquina virtual con Nginx.

Variables de entorno

Para este ejemplo he utilizado las siguientes variables de entorno:

# General variables
RESOURCE_GROUP="tour-of-heroes-on-vms"
LOCATION="westeurope"
VM_SIZE="Standard_B2s"
# VNET variables
VNET_NAME="tour-of-heroes-vnet"
VNET_ADDRESS_PREFIX=192.168.0.0/16
API_SUBNET_NAME="api-subnet"
API_SUBNET_ADDRESS_PREFIX=192.168.2.0/24

# API VM on Azure
API_VM_NAME="api-vm"
API_VM_IMAGE="UbuntuLTS"
API_VM_ADMIN_USERNAME="apiadmin"
API_VM_ADMIN_PASSWORD="Api@dmin1232!"
API_VM_NSG_NAME="api-vm-nsg"
echo -e "Variables loaded from 00-variables.sh"

Con estas podré crear el grupo de recursos donde vivirá la red virtual a la que se conectará la máquina virtual y la misma en sí.

Nota: el ejemplo que yo voy a desplegar, una API, hace uso de una base de datos SQL Server que ya desplegué en un artículo anterior. Sin embargo, en esa ocasión he configurado la máquina virtual sin ninguna IP pública:

echo -e "Create a database vm named $DB_VM_NAME with image $DB_VM_IMAGE"
az vm create \
--resource-group $RESOURCE_GROUP \
--name $DB_VM_NAME \
--image $DB_VM_IMAGE \
--admin-username $DB_VM_ADMIN_USERNAME \
--admin-password $DB_VM_ADMIN_PASSWORD \
--public-ip-address "" \
--vnet-name $VNET_NAME \
--subnet $DB_SUBNET_NAME \
--size $VM_SIZE \
--nsg $DB_VM_NSG_NAME

El agente de SQL Server para que solo permita conectividad de tipo privada:

echo -e "Add SQL Server extension to the database vm"
az sql vm create \
--name $DB_VM_NAME \
--license-type payg \
--resource-group $RESOURCE_GROUP \
--location $LOCATION \
--connectivity-type PRIVATE \
--port 1433 \
--sql-auth-update-username $DB_VM_ADMIN_USERNAME \
--sql-auth-update-pwd $DB_VM_ADMIN_PASSWORD \
--backup-schedule-type manual \
--full-backup-frequency Weekly \
--full-backup-start-hour 2 \
--full-backup-duration 2 \
--storage-account "https://$STORAGE_ACCOUNT_NAME.blob.core.windows.net/" \
--sa-key $STORAGE_KEY \
--retention-period 30 \
--log-backup-frequency 60

Y luego por otro lado configuro una regla en el network security group que solo permita acceso a través del puerto 1433, protocolo TCP y para subnet donde está la API que voy a desplegar:

echo -e "Create a network security group rule for SQL Server port 1433 from the api subnet"
az network nsg rule create \
--resource-group $RESOURCE_GROUP \
--nsg-name $DB_VM_NSG_NAME \
--name AllowSQLServer \
--priority 1001 \
--destination-port-ranges 1433 \
--protocol Tcp \
--source-address-prefixes $API_SUBNET_ADDRESS_PREFIX \
--direction Inbound

Pero esto es adicional a lo que nos ocupa hoy 😙

Crear el grupo de recursos y la red virtual

El primer paso sería crear el grupo de recursos y la red virtual a la que se va a conectar la máquina con Nginx:

echo -e "Creating resource group $RESOURCE_GROUP in $LOCATION"
az group create \
--name $RESOURCE_GROUP \
--location $LOCATION
echo -e "Creating virtual network $VNET_NAME with address prefix $VNET_ADDRESS_PREFIX
az network vnet create \
--resource-group $RESOURCE_GROUP \
--name $VNET_NAME \
--address-prefixes $VNET_ADDRESS_PREFIX \
--subnet-name $API_SUBNET_NAME \
--subnet-prefixes $API_SUBNET_ADDRESS_PREFIX

Crear máquina virtual con Ubuntu

Ahora ya podemos crear la máquina virtual, en este caso con Ubuntu como sistema operativo, que será donde instalarás Nginx y desplegarás tu aplicación.

echo -e "Create an api vm named $API_VM_NAME with image $API_VM_IMAGE"
FQDN_API_VM=$(az vm create \
--resource-group $RESOURCE_GROUP \
--name $API_VM_NAME \
--image $API_VM_IMAGE \
--admin-username $API_VM_ADMIN_USERNAME \
--admin-password $API_VM_ADMIN_PASSWORD \
--vnet-name $VNET_NAME \
--subnet $API_SUBNET_NAME \
--public-ip-address-dns-name tour-of-heroes-api-vm \
--nsg $API_VM_NSG_NAME \
--size $VM_SIZE --query "fqdns" -o tsv)
echo -e "Api VM created"
echo -e "You can connect using $FQDN_API_VM"
echo -e "Create a network security group rule for port 80"
az network nsg rule create \
--resource-group $RESOURCE_GROUP \
--nsg-name $API_VM_NSG_NAME \
--name AllowHttp \
--priority 1002 \
--destination-port-ranges 80 \
--direction Inbound
# https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/linux-nginx?view=aspnetcore-7.0&tabs=linux-ubuntu
echo -e "Execute script to install nginx, .NET Core, deploy the app and create the service"
az vm run-command invoke \
--resource-group $RESOURCE_GROUP \
--name $API_VM_NAME \
--command-id RunShellScript \
--scripts @scripts/install-tour-of-heroes-api.sh \
--parameters https://github.com/0GiS0/tour-of-heroes-dotnet-api/releases/download/1.0.5/drop.zip $FQDN_API_VM $DB_VM_ADMIN_USERNAME $DB_VM_ADMIN_PASSWORD

Como ves, además de crear la máquina, asocio a la misma un network security group y creo una regla para habilitar el puerto 80 y así poder acceder a la API en este caso. Por último, utilizo el comando az vm run-command invoke para ejecutar el script que instalará todo lo que necesito dentro de este Ubuntu:

Instalar Nginx y desplegar aplicación en .NET

El último paso es desplegar, de forma automatizada, la aplicación .NET en este Ubuntu y servirla a través de Nginx. Para ello, he creado este script:

echo -e "Install nginx server"
sudo apt update && sudo apt install -y nginx unzip
echo -e "Install .NET Core"
wget https://packages.microsoft.com/config/ubuntu/18.04/packages-microsoft-prod.deb -O packages-microsoft-prod.deb && sudo dpkg -i packages-microsoft-prod.deb && rm packages-microsoft-prod.deb && sudo apt-get update && sudo apt-get install -y aspnetcore-runtime-7.0
systemctl status nginx
sudo mkdir -p /var/www/tour-of-heroes-api
sudo chown -R $USER:$USER /var/www/tour-of-heroes-api
sudo chmod -R 755 /var/www/tour-of-heroes-api
echo -e "Download the last release of the api app from github"
wget $1 -O drop.zip
echo -e "Unzip the api app"
unzip drop.zip -d /var/www/tour-of-heroes-api
sudo sed -i 's/# server_names_hash_bucket_size 64;/server_names_hash_bucket_size 128;/g' /etc/nginx/nginx.conf
sudo SERVER_NAME=$2 bash -c 'cat > /etc/nginx/sites-available/tour-of-heroes-api.conf <<EOF
server {
     listen        80;
     server_name   $SERVER_NAME;
     location / {
         proxy_pass         http://localhost:5000;
         proxy_http_version 1.1;
         proxy_set_header   Upgrade \$http_upgrade;
         proxy_set_header   Connection keep-alive;
         proxy_set_header   Host \$host;
         proxy_cache_bypass \$http_upgrade;
         proxy_set_header   X-Forwarded-For \$proxy_add_x_forwarded_for;
         proxy_set_header   X-Forwarded-Proto \$scheme;
     }
 }
EOF'
sudo ln -s /etc/nginx/sites-available/tour-of-heroes-api.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
sudo bash -c "cat <<EOF > /etc/systemd/system/tour-of-heroes-api.service
[Unit]
Description=Tour of heroes .NET Web API App running on Ubuntu
[Service]
WorkingDirectory=/var/www/tour-of-heroes-api
ExecStart=/usr/bin/dotnet /var/www/tour-of-heroes-api/tour-of-heroes-api.dll
RestartSec=10
KillSignal=SIGINT
SyslogIdentifier=dotnet-tour-of-heroes-api
User=www-data
Environment=ASPNETCORE_ENVIRONMENT=Development
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
Environment=ConnectionStrings__DefaultConnection='Server=192.168.1.4,1433;Initial Catalog=heroes;Persist Security Info=False;User ID=$3;Password=$4;TrustServerCertificate=True'
[Install]
WantedBy=multi-user.target
EOF"
sudo systemctl enable tour-of-heroes-api.service
sudo systemctl start tour-of-heroes-api.service
# sudo systemctl disable tour-of-heroes-api.service
sudo systemctl status tour-of-heroes-api.service
# journalctl -u tour-of-heroes-api.service

Lo primero que hago es instalar Nginx, seguido del runtime de .NET, y compruebo que el servidor web se está ejecutando correctamente. Después necesito acondicionar el directorio donde va a vivir mi aplicación, en este caso /var/www/tour-of-heroes-api, a la que, además de crearlo, le doy los permisos necesarios. Descargo la última release que GitHub Actions ha generado por mi de forma automatizada y la descomprimo en dicho directorio. Con la aplicación en su sitio ya puedo configurar Nginx para decirle a través de qué puerto y hostname debe servir esta aplicación, la cual estará internamente escuchando a través del puerto 5000. Esto lo que significa es que Nginx en realidad está haciendo de proxy inverso de este proceso hacia el exterior, por lo que necesito que este se esté ejecutándose en la máquina. La forma más elegante de hacerlo es a través de un servicio, que es lo que configuro al final del script.

¡Saludos!