Terraform, Vue.js e Aws CloudFront

di Andrea Zani, in terraform,

Dopo il primo post dedicato a Kubernetes e il secondo a EKS, ecco il terzo post dedicato a CloudFront. CloudFront è un servizio CDN in AWS che consente la distribuzione di contenuto web statico e dinamico attraverso i suoi 310 Points of Presence a livello mondiale. Tecnicamente >300 punti di accesso  (edge locations) eseguono il redirect della richiesta a 13 regioni (Regioanal edge caches) che eseguono la reale richiesta alla risorsa originale e la salvano nella loro cache. Questo gran numero di punti accesso distribuiti a livello geografico permettono una richiesta molto veloce da pressocché qualsiasi paese. Prendendo lo schema dalla documentazione ufficile di AWS e CloudFront, ecco il funzionamento di base:

In CloudFront è sufficiente creare una Distribution, la quale accetta come input un Bucket S3, un LoadBalancer e altre risorse interne di AWS, oppure un HTTP Endpoint. In questo post mi dedicherò a S3.

Vue.js

Ora creerò una fin troppo semplice web appliction client side in Vue.js solo per scopo didattico (ma è possibile usare anche Angular, React, o semplice pagine HTML con o senza vanilla Javascript). Per comodità aggiungo anche Bootstrap e avrò questa semplice pagina di output:

Una volta inseriti dei valori numerici nelle due Textbox e cliccato Sum locally, sarà visualizzato il risultato:

Per la creazione della web application in Vue.js ho eseguito i classici passi come da documentazione. Avendo già installato sulla macchine Nodejs e NPM, ho esguito questi comandi:

npm install -g @vue/cli

Quindi ho creato una nuova applicazione con il comando:

vue create name-application

Quindi ho selezionato la versione tre di Vue.js e alla fine nell'esempio creato ho cancellato e rimosso i riferimenti al componente di default creato in src/componentes, HelloWord.vue. Quindi ho creato un mio componente Calculator.vue. Utilizzando Bootstrap per l'output ho aggiunto con npm le dipendenze dedicate a Bootstrap.

Questo post non vuole essere un trattato su Vue.js - e poi io non sarei in grado di farlo. L'unico dettaglio che sarà utile in avanti è il pulsante in rosso Sum Remotely. Questo pulsante permette la chiamata di una API esterna in caso sia definito nel file url.json inserito in public/extra. In questo post inserisco una stringa vuota perché lo utilizzerò nel prossimo:

{"url": ""}

All'avvio della mia Vue.js application ho inserito l'evento beforeMount con questo codice:

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);
      });
}

Che richiede il contenuto di quel file JSON e, se presente, salva il valore nella variabile ExternalUrl che sarà controllato dalla funzione chiamata dal button Sum Remotely.

Controllato che tutto funzioni (almeno localmente) con il comando npm run serve è il momento di farne il deploy locale:

npm run build

Che creerà il file necessari per il deploy che mi serviranno nei prossimi passi.

Terraform

Ovviamente per pubblicare il codice sopra prodotto userò Terraform. Scopo finale è rendere disponibile la web application creata in AWS CloudFront. Per ottenere questo innanzitutto devo inviare tutti i file su S3, quindi dovrò creare una Distribution in CloudFront in modo da renderla pubblica a livello mondiale.

Strumenti necessari:

  • terraform (tool) almeno la versione 1.0
  • aws cli (dalla verisone 2)
  • Account di AWS attivo.

Questa volta inserirò tutto in un unico file per mia pigrizia. Innanzitutto ecco la definizione dei provider necessari:

terraform {
  required_version = "~> 1.00"

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

provider "aws" {
  region = "eu-south-1"
}

Oltre le versioni dei provider ho impostato, come nei post precedenti, la zona dove pubblicare il tutto - eu-south-1, Milano. E' ora il momento di creare il Bucket in S3 dove inserirò tutti i file della mia webapplication in Vue.js:

variable "s3bucketname" {
    description = "Bucket name"
    type = string
    default = "az-site2"  
}

resource "aws_s3_bucket" "site" {
  bucket = var.s3bucketname
  force_destroy = true
}

S3, Terraform e Content-type

L'invio dei file su S3 è semplice con Terraform. C'è una Resource apposita che con poche righe di codice mi permette questa operazione in modo veloce, grazie alla funzione for_each:

resource "aws_s3_bucket_object" "myapplication" {
  for_each = fileset("../output/dist/", "**/*.*")
  bucket = aws_s3_bucket.site.id
  key = each.value
  source = "../output/dist/${each.value}"
  etag = filemd5("../output/dist/${each.value}")
}

Ma una volta eseguito questo script con il comando Terraform e reso pubblico (mostrerò in seguito questa operazione), una volta richiesti i file dal browser questo non li mostrerà ma avvierà il download degli stessi. Questo problema è dovuto dal Content Type mancante. Se i file vengono inviarti dalla console web di AWS questo problema non sussiste perché il tool di upload di AWS è abbastanza intelligente da capire il tipo di file, ma da Terraform questo non è possibile e la soluzione, l'unica che ho trovato, è inviare gruppi di file per tipo con il corretto Content Type. Nel mio file Terraform quindi complico tutto inserendo per ogni tipo una resource per l'upload dei file:

resource "aws_s3_bucket_object" "html" {
  for_each = fileset("../output/dist/", "**/*.html")
  bucket = aws_s3_bucket.site.id
  key = each.value
  source = "../output/dist/${each.value}"
  etag = filemd5("../output/dist/${each.value}")
  content_type = "text/html"
}

resource "aws_s3_bucket_object" "svg" {
  for_each = fileset("../output/dist/", "**/*.svg")
  bucket = aws_s3_bucket.site.id
  key = each.value
  source = "../output/dist/${each.value}"
  etag = filemd5("../output/dist/${each.value}")
  content_type = "image/svg+xml"
}
...

Inizio inviando i file di tipo HTML con il corretto Content Type. Di seguito faccio lo stesso passaggio con i file SVG, quindi Javascript, etc... Questo risolve il problema. Ora che i file sono caricati in S3 si deve configurare il Bucket perché questa sia disponibile online:

resource "aws_s3_bucket_website_configuration" "site" {
  bucket = aws_s3_bucket.site.id

  index_document {
    suffix = "index.html"
  }

  error_document {
    key = "error.html"
  }
}

resource "aws_s3_bucket_acl" "site" {
  bucket = aws_s3_bucket.site.id
  acl = "public-read"
}

resource "aws_s3_bucket_policy" "site" {
  bucket = aws_s3_bucket.site.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "PublicReadGetObject"
        Effect    = "Allow"
        Principal = "*"
        Action    = "s3:GetObject"
        Resource = [
          aws_s3_bucket.site.arn,
          "${aws_s3_bucket.site.arn}/*",
        ]
      },
    ]
  })
}

In queste sezioni definisco il nome della pagina principale e quella di errore, quindi creo una Role di AWS per permettere l'accesso al Bucket. Se eseguissi questo script con Terraform avrei già risolto il problema perché l'url in S3 mostrerebbe la mia webapplication via browser.

CloudFront

E' arrivato il momento di mostrare al mondo interno la mia web application in Vue.js con CloudFront ottenendo il massimo delle prestazioni. Sempre con Terraform posso configurare il tutto. Inizio con:

locals {
  s3_origin_id = "mysite"
}

resource "aws_cloudfront_origin_access_identity" "origin_access_identity" {
  comment = "mysite"
}

Con cui credo un identity per CloudFront - posso inserire qualsiasi nome. Quindi posso creare la Resource per CloudFront:

resource "aws_cloudfront_distribution" "s3_distribution" {

In cui dovrò inserire alcune sezioni. Ecco la prima dove definisco l'origin che CloudFront utilizzerà come source:

origin {
  domain_name = aws_s3_bucket.site.bucket_regional_domain_name
  origin_id   = local.s3_origin_id

  s3_origin_config {
    origin_access_identity = aws_cloudfront_origin_access_identity.origin_access_identity.cloudfront_access_identity_path
  }
}

enabled             = true
is_ipv6_enabled     = true
comment             = "my-cloudfront"
default_root_object = "index.html"

Importante il domain_name e l'origin_id che faranno riferimento al Bucket dove ho inserito la mia web application in Vue.js. Oltre ad abilitare l'Ipv6 definisco anche il file che sarà utilizzato utilizzato di default in caso nel path richiesto dal browser non fosse inserito.

default_cache_behavior {
    allowed_methods  = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = local.s3_origin_id

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    viewer_protocol_policy = "allow-all"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400
  }

Nella sezione precedente inserisco le informazioni per la cache in CloudFront. Innanzitutto inserisco i Methods che dovrà gestire per le richieste e quali saranno messo in cache (GET e HEAD). In forward_values posso impostare se CloudFront dovrà trattare nella cache anche i cookie e i parametri nella querystring (di questo spiegherò più in dettaglio nel prossimo post). Nelle impostazioni TTL potrò inserire quanto la cache in CloudFront dovrà durare (valore minimo, valore di default, e valore massimo, sempre in secondi) prima che CloudFront le cancelli dalla cache e rifaccia la richiesta a S3.

Se volessi inserire diverse sezioni di cache per diverse sezioni del sito web, potrei inserire più sezioni come la seguente:

  ordered_cache_behavior {
    path_pattern     = "/content/*"
    allowed_methods  = ["GET", "HEAD", "OPTIONS"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = local.s3_origin_id

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    min_ttl                = 0
    default_ttl            = 86400
    max_ttl                = 31536000
    compress               = true
    viewer_protocol_policy = "redirect-to-https"
  }

Dove specifico una durata di cache maggiore per le richieste a /content/. Aggiungo infine dei TAG e siccome non ho un dominio e un certificato da collegare a CloudFront dirò di gestire il tutto a lui:

tags = {
  Environment = "development"
  Name        = "my-tag"
}

viewer_certificate {
  cloudfront_default_certificate = true
}

CloudFront mi permette anche di bloccare o abilitare l'accesso solo da alcuni paesi:

restrictions {
  geo_restriction {
    restriction_type = "whitelist"
    locations        = ["US", "IT"]
  }
}

In questo caso solo dagli Stati Uniti e dall'Italia sarà possibile accedere al contenuto di CloudFront. Fatto da un paese non presente nella lista il browser visualizzerà questo errore:

Inoltre è possibile definire la price_class che vogliamo sia utilizzata in CloudFront, per esempio:

price_class = "PriceClass_200"

Sono accettati tre parametri:

  • PriceClass_All: nessuna restrizione, il contenuto sarà distribuito a livello planetario.
  • PriceClass_200: il contenuto non utilizzerà i servizi in Sud America e Australia/Nuova Zelanda
  • PriceClass_100: restrizione massima, oltre ai paesi esclusi nella classe 200 saranno escluse anche il Sud Africa, Medio Oriente, Giappone e altri paesi Asiatici.

Avendo una restrizione di tipo 100 ovviamente non significa che la risorsa non sarà disponibile se richiesta dall'Australia: essa sarà raggiungibile e utilizzerà il paese più vicino (in linea teorica gli Stati Uniti) con una maggiore latenza dovuta alla maggiore distanza. A questa pagina maggiori informazioni.

Alla fine dello script in Terraform visualizzo il dominio che utilizzerò per richiamare la mia web application in Vue.js:

output "cloudfront" {
  description = "DNS output from cloudfront"
  value = "https://${aws_cloudfront_distribution.s3_distribution.domain_name}"
}

Da tutto il mondo

Provo a richiamare la mia web application dal browser e verifico che tutto funzioni. Non mi basta. Controllo gli IP di risposta. Dalla mia connessione di casa:

nslookup ay7z457uy1.execute-api.eu-south-1.amazonaws.com
...

Non-authoritative answer:
Name:    ay7z457uy1.execute-api.eu-south-1.amazonaws.com
Addresses:  13.226.171.10
          13.226.171.108
          13.226.171.61
          13.226.171.68

E verifico gli IP nei siti che visualizzano i paesi dove sono stati assegnati. Per esempio, utilizzando iplocation ottengo per il primo IP della lista:

La stessa richiesta fatta da una macchina virtuale in Australia:

nslookup ay7z457uy1.execute-api.eu-south-1.amazonaws.com
...

Non-authoritative answer:
Name:   ay7z457uy1.execute-api.eu-south-1.amazonaws.com
Address: 18.67.93.42
Name:   ay7z457uy1.execute-api.eu-south-1.amazonaws.com
Address: 18.67.93.83
Name:   ay7z457uy1.execute-api.eu-south-1.amazonaws.com
Address: 18.67.93.111
Name:   ay7z457uy1.execute-api.eu-south-1.amazonaws.com
Address: 18.67.93.68

Troppa cache

CloudFront per ottimizzare le prestazioni usa ovviamente una cache la cui scadenza viene definitiva nelle impostazioni viste prima (di default è ventiquattro ore). Per pagine in continuo aggiornamento questo comporta ovviamente dei problemi visto che, con un grande periodo di cache, si avrà disponibile l'aggiornamento dopo troppo tempo, ma con una cache troppo breve si perderebbero i vantaggi nel suo utilizzo.

La soluzione è invalidare la cache. La cosa è possibile direttamente dalla console web di AWS oppure con il comando da terminale:

aws cloudfront create-invalidation --distribution-id {distribution_id} --paths /*

E da Terraform? Se si aggiornasse il contenuto della web application d'esempio potrei vederne le modifiche solo dopo il lungo periodo della cache. Per fortuna esiste un trucco per invalidare la cache direttamente da Terraform. Il trucco è in realtà semplice, il primo passo è creare un archivio zip del contenuto:

data "archive_file" "website" {
  output_path = "../output/zip/website.zip"
  source_dir  = "../output/dist"
  type        = "zip"
}

Terraform in questo caso crea in output/zip un archivio ZIP con tutto il contenuto della web application in Vue.js. Esiste una resource - null_resource - che permette di collegare un trigger il cui la modifica del contenuto permette la creazione ex novo della resource. Inoltre questa resource permette l'esecuzione di comandi, quindi ecco la soluzione finale:

resource "null_resource" "website" {
  provisioner "local-exec" {
    command = "aws cloudfront create-invalidation --distribution-id ${aws_cloudfront_distribution.s3_distribution.id} --paths /*"
  }

  triggers = {
    index = filebase64sha256(data.archive_file.website.output_path)
  }
}

Nel trigger viene inserito l'Hash256 del contenuto del file. Qualsiasi modifica del contenuto dello zip modificherebbe questo valore che avvierebbe il command in local-exec, nel mio caso l'invalidazione del contenuto di CloudFront.

Problema risolto - nel mio esempio io invalido tutto il contenuto della cache, ma con quel comando in AWS è possibile selezionare il Path che subirà la cancellazione del contenuto della cache. Questo permette la modifica della cache mirata per le risorse realmente modificate.

Route 53 e Dominio

Sempre con Terraform è possibile gestire i domini con il servizio Route 53 di AWS. Il risultato ottimale sarebbe collegare un dominio con certificato SSL alla distribuzione CloudFront creata in questo post. Ma al momento non mi è stato possibile per assenza di un dominio su cui poter fare questa demo. In futuro?

Conclusioni

A questo link il codice sorgente della semplice web application in Vue.js e lo script in Terraform per creare il tutto in AWS. La web application qui presentata è limitata dall'assenza di una API remota per la somma. Questa API la porterò nel prossimo post dove pubblicherò una Lambda in Net6 in AWS che sarà distribuita anch'essa a livello plenetario da CloudFront. In questo caso si deve gestire con più dettagli come dev'essere gestita la cache da parte di CloudFront. Quando avrò tempo.

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