Terraform, Vue.js, Lambda in Net6 e Aws CloudFront

di Andrea Zani, in terraform,

Nel post precedente ho mostrato come gestire una web application client side scritta in Vue.js da CloudFront. Grazie a questa CDN di AWS è possibile distribuire a livello mondiale quell'applicazione client side sicuri delle ottime prestazioni grazie alla distribuzione capillare dei nodi dell'infrastruttura di AWS - ovviamente qualsiasi contenuto web statico ne trae vantaggio. Con il post attuale, invece, farò gestire a CloudFront una Lambda scritta in C# e Net6 in modo di aggiungere un po' di server side alla semplice applicazione mostrata. Ovviamente la configurazione e la creazione di tutte le risorse in AWS sarà fatta con Terraform.

Lambda in C# e Net6

Innanzitutto si devono installare due tool nella macchina dove si vuole creare applicazioni Lambda per AWS. Il primo tool è dedicato a Visual Studio: Aws Toolkit for Visual Studio. Questo tool creerà nuovi tipi di applicazioni da creare in Visual Studio:

Due sono i tipi di progetti utili:

  • Aws Lambda Project
  • Aws ServerLess Application

Le differenze a livello di applicazioni create in Visual Studio è che la prima creerà un unico entry point ad una function handler (come avviene anche per Lambda scritte in NodeJs e altri linguaggi) ed è più consigliato per la creazione di Lambda di utilizzo interno ad AWS (comunicazione tra servizi e altro, e non solo web). Esempio di codice creato in automatico da questo tipo di progetto:

using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;

// The function handler that will be called for each Lambda event
var handler = (string input, ILambdaContext context) =>
{
    return input.ToUpper();
};

// Build the Lambda runtime client passing in the handler to call for each
// event and the JSON serializer to use for translating Lambda JSON documents
// to .NET types.
await LambdaBootstrapBuilder.Create(handler, new DefaultLambdaJsonSerializer())
        .Build()
        .RunAsync();

L'applicazione di tipo Aws Serverless Application è più indicata per il Web e permette di creare web application più complesse con Controller e contenuto statico:

Nel mio esempio userò proprio quest'ultimo tipo selezionando come Blueprint l'ASP.NET Core Minimal API che creerà lo scheletro della web application in cui inserirò la web api di cui ho bisogno.

Nel progetto si esempio creato faccio una piccola modifica al file principale - program.cs - per aggiungere il CORS al mio controller:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllers();

// Add AWS Lambda support. When application is run in Lambda Kestrel is swapped out as the web server with Amazon.Lambda.AspNetCoreServer. This
// package will act as the webserver translating request and responses between the Lambda event source and ASP.NET Core.
builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi);


builder.Services.AddCors(options =>
{
    options.AddPolicy("corsapp", builder =>
    {
        builder.WithOrigins("*").AllowAnyMethod().AllowAnyHeader();
    });
});

var app = builder.Build();

app.UseCors();

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.MapGet("/", () => "Welcome to running ASP.NET Core Minimal API on AWS Lambda");

app.Run();

Quindi cancello il controller di base creato - CaculatorController - e ne creo un mio più semplice:

using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;

namespace AWSServerless_test1.Controllers
{
    [ApiController]
    [EnableCors("corsapp")]
    [Route("api/[controller]")]
    public class CalcController : ControllerBase
    {
        private readonly ILogger<CalcController> _logger;

        public CalcController(ILogger<CalcController> logger) => _logger = logger;

        [HttpGet("add")]
        public IActionResult Add(int x, int y)
        {
            _logger.LogInformation($"{x} plus {y} is {x + y}");
            var json = new
            {
                time = DateTimeOffset.UtcNow,
                result = x + y
            };

            return Ok(new { results = json });
        }
    }
}

In cui viene abilitato il CORS e viene creato un unico method GET che accetta due numeri in Querystring e ne restituisce la somma. E la Lambda in C# e Net6 è completa. Per potere farne il Deploy su AWS ora ho la possibilità di poterlo vare con Visual Studio cliccando con il tasto destro del mouse sul nome del progetto e selezionando: Publish to AWS Lambda che aprirà una nuova schermata in cui selezionare il Bucket S3 dove sarà inserito lo Stack di CloudFormation che sarà utilizzato per la creazione delle risorse in AWS.

Non è quello che serve a me visto che voglio usare Terraform. Io ho la necessità di un secondo tool da aggiungere: Amazon-Lambda-Tool. Installato, come spiegato nella pagina linkata, da terminale ora posso compilare e impacchettare il progetto pronto per AWS con un solo comando:

dotnet lambda packet

Lambda in Terraform

Ho tutto quello che mi serve per creare il tutto con Terraform. Innanzitutto i Provider (verions.tf):

terraform {
  required_version = "~> 1.00"

  required_providers {
    aws = {
      source = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

La configurazione dei provider (providers.tf):

terraform {
  required_version = "~> 1.00"

  required_providers {
    aws = {
      source = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

Per il Deploy della web application in S3 e CloudFront ho speso fin troppe parole nel post precedente. E' arrivato il momento della pubblicazione della Lambda. In questo caso ho creato il file 1-lambda.tf. Per la pubblicazione della Lambda uso questo codice:

resource "aws_iam_role" "iam_for_lambda" {
  name = "iam_for_lambda"

  assume_role_policy = jsonencode({
    "Version": "2012-10-17",
    "Statement": [
        {
        "Action": "sts:AssumeRole",
        "Principal": {
            "Service": "lambda.amazonaws.com"
        },
        "Effect": "Allow",
        "Sid": ""
        }
    ]
    })
}

resource "aws_lambda_function" "test_lambda" {
  filename      = "../AWSServerless_test1/AWSServerless_test1/bin/Release/net6.0/AWSServerless_test1.zip"
  function_name = var.lambda_function_name
  role          = aws_iam_role.iam_for_lambda.arn
  handler       = "AWSServerless_test1"

  source_code_hash = filebase64sha256("../AWSServerless_test1/AWSServerless_test1/bin/Release/net6.0/AWSServerless_test1.zip")

  runtime = "dotnet6"
  memory_size = 512

  environment {
    variables = {
      name = "LambdaNet6"
    }
  }

  depends_on = [
    aws_iam_role_policy_attachment.lambda_logs,
    aws_cloudwatch_log_group.example,
  ]
}

Dove creo una Role per l'esecuzione della Lambda e una Resource di tipo aws_lambda_function dove inserisco il file zippato creato dal comando visto prima - dotnet lambda packet. Per poter gestire il Log in CloudWatch aggiungo queste Resource presenti anche tra le dipendenze della Lambda:

resource "aws_cloudwatch_log_group" "example" {
  name              = "/aws/lambda/${var.lambda_function_name}"
  retention_in_days = 14
}

resource "aws_iam_policy" "lambda_logging" {
  name        = "lambda_logging"
  path        = "/"
  description = "IAM policy for logging from a lambda"

  policy = jsonencode({
    "Version": "2012-10-17",
    "Statement": [
        {
        "Action": [
            "logs:CreateLogGroup",
            "logs:CreateLogStream",
            "logs:PutLogEvents"
        ],
        "Resource": "arn:aws:logs:*:*:*",
        "Effect": "Allow"
        }
    ]
    })
}

resource "aws_iam_role_policy_attachment" "lambda_logs" {
  role       = aws_iam_role.iam_for_lambda.name
  policy_arn = aws_iam_policy.lambda_logging.arn
}

Tutto qua: eseguendo il codice con il comando terraform saranno create tutte le risorse necessarie per la Lambda in AWS. Rimane il problema come renderla disponibile via HTTP. Per ottenere questo è necessario creare una API Gateway, ovviamente con Terraform. Qui il codice diventa un po' più complesso perché, come dalla Console Web di AWS, è necessario creare la Resource per la API con i vari collegamenti dei Method alla Lambda. Ecco il codice per futura memoria:

resource "aws_api_gateway_rest_api" "example" {
  name        = "ServerlessExample"
  description = "Terraform Serverless Application Example"
}

resource "aws_api_gateway_resource" "proxy" {
  rest_api_id = aws_api_gateway_rest_api.example.id
  parent_id   = aws_api_gateway_rest_api.example.root_resource_id
  path_part   = "{proxy+}"
}

resource "aws_api_gateway_method" "proxy" {
  rest_api_id   = aws_api_gateway_rest_api.example.id
  resource_id   = aws_api_gateway_resource.proxy.id
  http_method   = "ANY"
  authorization = "NONE"
}

resource "aws_api_gateway_integration" "lambda" {
  rest_api_id = aws_api_gateway_rest_api.example.id
  resource_id = aws_api_gateway_method.proxy.resource_id
  http_method = aws_api_gateway_method.proxy.http_method

  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = aws_lambda_function.test_lambda.invoke_arn
}

############
resource "aws_api_gateway_method" "proxy_root" {
  rest_api_id   = aws_api_gateway_rest_api.example.id
  resource_id   = aws_api_gateway_rest_api.example.root_resource_id
  http_method   = "ANY"
  authorization = "NONE"
}

resource "aws_api_gateway_integration" "lambda_root" {
  rest_api_id = aws_api_gateway_rest_api.example.id
  resource_id = aws_api_gateway_method.proxy_root.resource_id
  http_method = aws_api_gateway_method.proxy_root.http_method

  integration_http_method = "GET"
  type                    = "AWS_PROXY"
  uri                     = aws_lambda_function.test_lambda.invoke_arn
}
###############
resource "aws_api_gateway_deployment" "example" {
  depends_on = [
    aws_api_gateway_integration.lambda,
    aws_api_gateway_integration.lambda_root,
  ]

  rest_api_id = aws_api_gateway_rest_api.example.id
  stage_name  = "prod"
}

resource "aws_lambda_permission" "apigw" {
  statement_id  = "AllowAPIGatewayInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.test_lambda.function_name
  principal     = "apigateway.amazonaws.com"

  # The /*/* portion grants access from any method on any resource
  # within the API Gateway "REST API".
  source_arn = "${aws_api_gateway_rest_api.example.execution_arn}/*/*"
}

E' il momento di configurare CloudFront perché possa avere come source la Lambda appena creata. Siccome necessito del Domain e del Path per due parametri differenti e l'Api Gateway le ritorna come unico Url, uso una Regex per estrarre i due valori:

locals {
  regobject1 = regex("^(?:(?P<scheme>[^:\\/?#]+):)?(?:\\/\\/(?P<domain>[^\\/?#]*))?\\/(?P<path>[\\w]+)$",aws_api_gateway_deployment.example.invoke_url)
}

Quindi come da documentazione creo la Resource con i parametri della Regex appena vista compresa l'origin:

resource "aws_cloudfront_distribution" "distribution" {
  origin {
    domain_name = local.regobject1.domain
    origin_path = "/${local.regobject1.path}"
    origin_id   = "apigw_root"
    custom_header {
      name  = "origin_id"
      value = "apigw_root"
    }

    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }
  }

Definita di seguito la sezione per la cache globale con i valori di default, definisco la cache per la mia API:

ordered_cache_behavior {
    path_pattern     = "/api/*"
    allowed_methods  = ["GET", "HEAD", "OPTIONS"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "apigw_with_path"

    default_ttl = 60
    min_ttl     = 10
    max_ttl     = 3600

    forwarded_values {
      query_string = true
      query_string_cache_keys = [ "x", "y" ]
      cookies {
        forward = "all"
      }
    }

    viewer_protocol_policy = "redirect-to-https"
  }

A parte i valori di TTL qui ho definito anche che la cache dovrà essere utilizzata solo per i parametri Querystring, nel dettaglio solo per i parametri x e y. Solo la modifica di questi parametri farà eseguire a CloudFront una richiesta reale alla mia Lambda con il relativo salvataggio nella cache: l'aggiunta di altri parametri sarà completamente ignorato se il contenuto di x e y sono già presenti in cache.

Inoltre nei metodi accettati ho lasciato solo il GET (e l'HEAD) perché altre tipi di chiamate come il POST (PUT/DELETE...) comportano normalmente una modifica del contenuto, quindi l'uso della cache sarebbe errato.

Passare l'URL della Lambda alla web application

Nella web application mostrata nel post precedente avevo inserito l'evento beforeMount per richiedere il file JSON url.json presente nella directory public.

beforeMount() {
    console.log('Requested url to remote service');
    fetch("/extra/url.json")
      .then(response => response.json())
      .then(data => {
        this.ExternalUrl = data.url;
        console.log("Externl url = " + this.ExternalUrl);
      });
}

In questo file avevo inserito la key url con un valore vuoto che mi permetteva di controllare la presenza di un URL per poter richiedere una API Rest remota per il calcolo. Ora ho la Lambda remota accessibile da CloudFront e quindi posso inserire qui l'url corretto. Con Terraform la cosa è banale, nel codice di Terraform della web application ho aggiunto questo codice:

resource "aws_s3_bucket_object" "url_content" {
  bucket = aws_s3_bucket.site.id  
  key = "extra/url.json"
  content = "{\"url\": \"https://${aws_cloudfront_distribution.distribution.domain_name}\"}"
  content_type = "application/json"
}

Nel bucket S3 dove è presente il contenuto client side ora è l'url della Lambda senza altre modifiche.

Conclusioni

Una persona mi ha chiesto perché non mettere tutta una web application scritta in Net Core all'interno di una AWS Lambda invece di usare il doppio approccio static site S3 + Lambda. In effetti nulla mi proibirebbe di mettere nella Lambda di questo post dei controller che rispondano con pagine HTML e altro contenuto statico. Ma le Lambda sono fatte per altro, soprattutto con le loro limitazioni (cold start e impossibilità di installare componenti esterni direttamente sul server, etc...).

L'utilizzo di CloudFront nelle Lambda ne ottimizza l'uso soprattutto in caso di un grandissimo numero di richieste. La distribuzione in cache a livello planetario risolve l'altro problema della possibile latenza tra il paese da dove arriva la richiesta e il paese dove è installata la Lambda. Inoltre smorza il problema tipico delle Lambda - il cold start - in caso di scaling per un numero elevato di richieste in parallelo.

Fine di questo post. Qui il codice sorgente da installare con questi passaggi:

cd vuejs
npm run build
cd ..
cd AWSServerless_test1
cd AWSServerless_test1
dotnet lambda package
cd ..
cd ..

cd vuejs
npm install
npm run build

cd ..
cd terraform
terraform init
terraform plan
terrafrom apply -auto-approve

Se tutto funziona si avrà questo output:

Apply complete! Resources: 30 added, 0 changed, 0 destroyed.
lambda_base_url = "https://rggdqbob9d.execute-api.eu-south-1.amazonaws.com/prod"
lambda_cloudfront_url = "https://d16dtqjtodir87.cloudfront.net/api/calc/add?x=5&y=3"
lambda_domain = {
  "domain" = "rggdqbob9d.execute-api.eu-south-1.amazonaws.com"
  "path" = "prod"
  "scheme" = "https"
}
site-url = "https://d3jd73vcrm3c9l.cloudfront.net"

E cliccando sull'ultimo URL si aprirà la pagina web dell'esempio. Infine, per eliminare tutto:

terraform destroy -auto-approve

In questa serie di post dedicati a Terraform non ho mai approfondito il deployment CD/CI etc... - forse in futuro? - e come questo diventa più semplice anche con infrastruttute complesse. Terraform dunque promosso? Sì.

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Nella stessa categoria
I più letti del mese