logo

Despliegue de aplicaciones web en Amazon AWS a bajo coste con GitHub Actions


Una de las ventajas que tenemos cuando desarrollamos aplicaciones web estáticas, desde sitios estáticos con enlaces entre páginas hasta aplicaciones «enriquecidas» con frameworks de desarrollo como Angular, es que no necesitamos un servidor que haga un renderizado de las páginas, si no que es el usuario con su navegador el que se encarga de esa tarea.

Para ello, las diferentes nubes nos ofrecen la posibilidad de ofrecer estos sitios a través de la generación de un CDN que sirvan estos documentos a partir del contenido de un contenedor de objetos, como puede ser un S3 dentro de Amazon o blobs dentro de Azure.

Este enfoque tiene ventajas de escalabilidad y resiliencia que serían difíciles de replicar a nivel de servicios si no fuese con un incremento significativo de costes.

Haciendo números

Antes de nada, en la cabecera hablábamos de una ventaja significativa a nivel de costes y escalabilidad utilizando un CDN y un sitio estático alojado en un S3 frente a una infraestructura «tradicional» en la que tenemos que aprovisionar servidores web que sirvan nuestro sitio. Pero vamos a echar unos pequeños cálculos:

1. Escalabilidad

En términos de escalabilidad y durabilidad, los servicios como Amazon S3 están preparados para escalar hasta Exabytes de espacio y tener una durabilidad de hasta 11 nueves (con copias desde un mínimo de tres servidores dentro de la región), mientras que CloudFront realiza una copia en los endpoints más cercanos a los usuarios que trabajan con la aplicación, y mantiene la copia hasta la caducidad del contenido. Es decir, para alcanzar una disponibilidad parecida a la de S3, como mínimo tendríamos que disponer de tres servidores web con el contenido sincronizado, situados en diferentes ubicaciones.

2. Coste

Aquí vamos a analizar los costes que tendrá la infraestructura que acabamos de definir.

CloudFrontEl primer TB de salida a internet es gratuito, así como los primeros 10 millones de solicitudes al mes.
Amazon S3Los primeros 50 TB cotizan a 0,023 USD/Gb en la región de España

Con los datos que acabamos de ver, el alojamiento y todo el tráfico que se produjese en nuestro sitio estático o SPA tendría un coste cercano a cero o muy pocos céntimos al mes, independientemente del tráfico que tenga la aplicación.

Vamos a configurar nuestra infraestructura

Es momento de empezar a configurar nuestra infraestructura. Antes de nada, vamos a desglosar las partes que necesitaremos para que todo funcione correctamente.

1. Un bucket de S3.
Este bucket deberá de tener una configuración orientada a que sea legible desde CloudFront, con las políticas de acceso y ACL correspondientes a un sitio web estático.

2. CloudFront configurado para leer de S3
Un perfil de distribución de CloudFront listo para leer de S3, junto con un certificado configurado dentro de Certificate Manager listo para ser utilizado en el S3.

3. Zona dentro de Route53
Una zona con un dominio asociado en Route53. Esto no es obligatorio, pero en este artículo hemos decidido utilizar la infraestructura de Amazon AWS para todo el sistema, así que vamos a definiremos las entradas DNS en un dominio adquirido en la propia AWS.

4. Un sitio preparado para ser desplegado
Obviamente, necesitaremos previamente un sitio estático listo para ser desplegado. Como comentamos, podremos utilizar un sitio totalmente estático (archivos html), o lo más práctico podría ser una aplicación SPA de renderizado en cliente.

Nuestros ficheros Terraform.

Ahora que conocemos las partes con las que tenemos que trabajar, vamos a estructurar nuestro proyecto y definir nuestros ficheros de Terraform.

Por qué Terraform. Terraform es un sistema definido por Hashicorp que nos permite definir en un modelo IaC (Infrastructure-as-Code), la infraestructura necesaria para el despliegue de nuestras aplicaciones. La principal ventaja frente a otros sistemas es que es compatible prácticamente con todos los proveedores de nube y también con sistemas in-house.

Tradicionalmente, mi forma de estructurar los proyectos se basa en los directorios que muestro a continuación:

A continuación os muestro los ficheros de despliegue que irían dentro de la carpeta iac y luego desgranamos el resultado.

Punto de entrada a la aplicación

# /variables.tf
# ---------------------------
# La variable de DOMAIN_NAME nos servirá para definir el dominio que se configurará dentro de la infraestructura
variable "DOMAIN_NAME" {
  type = string
}
# /main.tf
# ---------------------------
# Entrada a Terraform, cargando el proveedor de AWS, que ahora va por la v6.x
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~>6.0"
    }
  }
}

data "aws_caller_identity" "current" {}

Certificado SSL

Una de las partes que necesitaremos antes de seguir configurando nuestro proyecto, será definir un certificado que asegure nuestro sitio web. Esto lo podemos hacer de manera gratuita con Certificate Manager de AWS, con la particularidad de que deberemos de introducir varias entradas DNS en el gestor del dominio -en nuestro caso Route53-, para validar que se tiene la autorización sobre el dominio.

Como todo este proceso es un poco estándar, independientemente de que estemos montando un CDN, un balanceador de carga o cualquier punto de entrada de AWS -como una virtual en EC2-, vamos a definir un módulo que, tomando como entrada los datos de la zona hospedada en Route53, genere el certificado y nos dé como salida el identificador del recurso del certificado en AWS y su ARN para poder referenciarlo en nuestro destino.

Este módulo lo vamos a ubicar dentro de la carpeta modules/acm, y tendremos los siguientes ficheros dentro del mismo:

# /modules/acm/variables.tf
# --------------------------------
variable "zone_id" {
  type = string
}

variable "domain_name" {
  type = string
}

variable "subject_alternative_names" {
  type = list(string)
}

variable "region" {
  type = string
}

variable "validation_record_ttl" {
  default = "300"
}

variable "tags" {
  type = map(string)
}
# /modules/acm/outputs.tf
# -----------------------------
output "certificate_id" {
  value = aws_acm_certificate.certificate.id
}

output "certificate_arn" {
  value = aws_acm_certificate_validation.certificate.certificate_arn
}
# /modules/acm/main.tf
# -----------------------------
terraform {
  required_providers {
    aws = {}
  }
}

provider aws {
  region = var.region
}

# Definición del recurso del certificado con el nombre del dominio
# junto con el método de validación y los nombres que pueden tomar
resource "aws_acm_certificate" "certificate" {
    domain_name               = var.domain_name
  validation_method         = "DNS"
  subject_alternative_names = var.subject_alternative_names
  
  tags = var.tags

  lifecycle {
    create_before_destroy = true
  }
}

# Recurso donde se recorre las entradas que se deben introducir para
# validar el certificado, y se añaden al dominio en Route53
resource "aws_route53_record" "validation" {
  for_each = {
    for dvo in aws_acm_certificate.certificate.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    } if dvo.domain_name == var.domain_name || contains(var.subject_alternative_names, dvo.domain_name)
  }

  name    = each.value.name
  type    = each.value.type
  zone_id = var.zone_id
  records = [each.value.record]
  ttl     = var.validation_record_ttl
}

# Recurso sin entidad asociada en AWS que notifica de la validación
# exitosa del certificado
resource "aws_acm_certificate_validation" "certificate" {
  certificate_arn         = aws_acm_certificate.certificate.arn
  validation_record_fqdns = [for record in aws_route53_record.validation : record.fqdn]
}

Bucket de S3

Ahora es momento de crear los recursos asociados al sitio en sí mismo. Empezaremos con el bucket de S3 con la que definiremos nuestro sitio para que sea un site público y tenga los permisos necesarios para que CloudFront pueda adquirir los ficheros y servirlos. Tradicionalmente, el nombre del bucket en el caso de sitios públicos, se pone con el mismo nombre que el dominio desde el que se va a servir. En nuestro caso simplemente es una nomenclatura sin impacto real, pero que puede ser positiva para localizar de manera rápida los sitios que tenemos dentro de una región.

# /_s3_site.tf
# ---------------------------------------------
# Declaración del recurso del bucket en sí mismo
resource "aws_s3_bucket" "static_website" {
  tags = {
    Name = "Static website"
  }

  force_destroy = true
  bucket        = var.DOMAIN_NAME
}

# Definición de acceso público al bucket, permitiendo acceso de lectura
resource "aws_s3_bucket_public_access_block" "static_website" {
  bucket = aws_s3_bucket.static_website.id

  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}

resource "aws_s3_bucket_ownership_controls" "static_website" {
  bucket = aws_s3_bucket.static_website.id
  rule {
    object_ownership = "ObjectWriter"
  }
}

resource "aws_s3_bucket_acl" "static_website" {
  bucket = aws_s3_bucket.static_website.id
  acl    = "private"
}

# Esta configuración la habilitaremos en el caso de que sea una aplicación de Angular
# resource "aws_s3_bucket_website_configuration" "static_website" {
#   bucket = aws_s3_bucket.static_website.id

#   index_document {
#     suffix = "index.html"
#   }

#   error_document {
#     key = "index.html"
#   }
# }

# Política que permite a cloudfront capturar los ficheros desde el bucket
data "aws_iam_policy_document" "static_website_access" {
  statement {
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }

    actions = [
      "s3:GetObject"
    ]

    resources = [
      "${aws_s3_bucket.static_website.arn}/*"
    ]

    condition {
      test     = "StringEquals"
      variable = "AWS:SourceArn"

      values = [
        aws_cloudfront_distribution.static_website.arn
      ]
    }
  }
}

resource "aws_s3_bucket_policy" "static_website" {
  bucket     = aws_s3_bucket.static_website.id
  policy     = data.aws_iam_policy_document.static_website_access.json
  depends_on = [aws_s3_bucket_acl.static_website]
}

Aclaraciones sobre el bloque anterior:
1. Desde este bloque hacemos referencia al recurso de CloudFront, que todavía no tenemos definido. Aunque pueda parecer que hay una referencia circular, ésta no se llega a producir nunca, puesto que la creación del recurso base no dependen entre sí, si no que son en la configuración de opciones cuando se crean estas vinculaciones.

2. Hay un área comentada que hace referencia a aplicaciones de Angular. Es importante aclarar que, cuando en nuestras apps de Angular no hemos activado SSR, y son aplicaciones web estáticas, podemos servirlas desde un S3 «aprovechándonos» de todo este potencial que tiene esta estructuración – o utilizar Amazon AWS Amplify -.
Esta configuración que vemos comentada sirve para que, teniendo en cuenta que las aplicaciones de Angular hacen un enrutado virtual – la página de destino no existe en sí misma -, cuando CloudFront o el sitio de S3 no encuentre el recurso solicitado, lo redirija automáticamente a utilizar el index.html. De esta manera conseguiremos, entre otras cosas, que si alguien accede a un área de nuestra aplicación SPA sin pasar por la raíz y haciendo todo el flujo de navegación, ésta se sirva como procede.

CDN con CloudFront

Ahora es momento de configurar nuestro CloudFront para que podamos servir nuestro sitio en cualquier punto del planeta desde los endpoints disponibles.

# /_cloudfront.tf
# -------------------------------------
# Política para acceder desde CloudFront a S3 que luego adjuntaremos a nuestro recurso
resource "aws_cloudfront_origin_access_control" "default" {
  name                              = "example"
  description                       = "Example Policy"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

# Variable local con el nombre del recurso que hemos definido de S3
locals {
  s3_origin_id = "static_website_s3"
}

# Generación del certificado SSL utilizando el módulo de ACM
module "static_website_cm" {
  source = "./modules/acm"

  zone_id                   = data.aws_route53_zone.current.zone_id
  domain_name               = var.DOMAIN_NAME
  subject_alternative_names = ["www.${var.DOMAIN_NAME}"]
  region                    = "us-east-1" # Siempre en esta región

  tags = {
    Name = "Static website"
  }
}

# Recurso en sí mismo de CloudFront
resource "aws_cloudfront_distribution" "static_website" {
  origin {
    domain_name              = aws_s3_bucket.static_website.bucket_regional_domain_name
    origin_access_control_id = aws_cloudfront_origin_access_control.default.id
    origin_id                = local.s3_origin_id
  }

  enabled             = true
  is_ipv6_enabled     = true
  default_root_object = "index.html"

  aliases = ["www.${var.DOMAIN_NAME}", "${var.DOMAIN_NAME}"]

  # Comportamiento de caché por defecto
  default_cache_behavior {
    allowed_methods  = ["HEAD", "GET"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = local.s3_origin_id

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 60
    max_ttl                = 300

    # Esta función la comentaremos en anotaciones
    function_association {
      event_type = "viewer-request"
      function_arn = aws_cloudfront_function.www_to_non_www_redirect.arn
    }
  }

  # Asignación de certificado
  viewer_certificate {
    acm_certificate_arn = module.static_website_cm.certificate_arn
    ssl_support_method  = "sni-only"
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }
}

# Recurso de función
resource "aws_cloudfront_function" "www_to_non_www_redirect" {
    name = "www_to_non_www_redirect"
    runtime = "cloudfront-js-1.0"
    publish = true
    code = file("${path.module}/cloudfront_functions/redirect_www_to_non_www.js")
}
# cloudfront_functions/redirect_www_to_non_www.js
# ---------------------------------------------------
function handler(event){
    var host =
    (event.request.headers && event.request.headers.host &&
      event.request.headers.host.value) ||
    '';

    var redirect = false;

    if (host.indexOf("www.") == 0){
        redirect = true;
    }

    if (event.request.uri != event.request.uri.toLowerCase()){
        redirect = true;
    }

    if (redirect){
        var response = {
            statusCode: 301,
            statusDescription: "Found",
            headers: {
                "location": { "value": `https://${host.replace("www.", "")}${event.request.uri.toLowerCase()}` }
            }
        }

        return response;
    }

    return event.request;
}

Desgranando la función. Como vemos, hemos construido una función sobre CloudFront que nos permite hacer una redirección de HTTP a HTTPS, así como de www.* a no www. Esta función mejora el comportamiento del sitio respecto a buscadores y genera enlaces permanentes que informan al navegador que, en un caso futuro, directamente puede viajar directamente al destino.

Route53. La pieza final

Ahora que ya tenemos los recursos preparados para servir nuestro sitio web estático, es momento de apuntar las direcciones de dominio dentro de Route53 a nuestro CloudFront y que éste empiece a procesar llamadas.

# /_route53.tf
# --------------------------------
data "aws_route53_zone" "current" {
  name         = "${var.DOMAIN_NAME}."
  private_zone = false
}

resource "aws_route53_record" "api" {
  zone_id = data.aws_route53_zone.current.zone_id
  name    = var.DOMAIN_NAME
  type    = "A"
  alias {
    name                   = aws_cloudfront_distribution.static_website.domain_name
    zone_id                = aws_cloudfront_distribution.static_website.hosted_zone_id
    evaluate_target_health = true
  }
}

resource "aws_route53_record" "static_website_www" {
  zone_id = data.aws_route53_zone.current.zone_id
  name    = "www.${var.DOMAIN_NAME}"
  type    = "CNAME"
  ttl     = 300
  records = ["${var.DOMAIN_NAME}"]
}

Este fichero tiene poco más que agregar. Por una parte apuntamos al recurso de la zona de DNS de nuestro dominio en Route53, y después hacemos un enlace A a nuestro CloudFront (en el ejemplo solo IPv4, pero podríamos crear el enlace correspondiente a AAAA para IPv6).

Por último agregamos una entrada de redirección canónica desde www al no www. Aunque tengamos la función, con esta entrada nos quitaremos bastante peso en las llamadas que tengamos a CloudFront.

Momento de desplegar el sitio

Ha llegado el momento final. Contando que tengamos ya la infraestructura implementada, vamos a realizar una acción de GitHub que nos permita desplegar los ficheros que tengamos dentro de nuestra carpeta src.

# .github/workflows/deploy.yml
# -----------------------------------------
name: "Deploy static website"

on: 
    push: 
        branches: ["develop","main"]
    workflow_dispatch: 

jobs:
    deploy:
        runs-on: ubuntu-latest
        name: "Deploy on S3"
        needs: [build]
        steps:
          - id: install-aws-cli
            uses: unfor19/install-aws-cli-action@v2
          - uses: actions/download-artifact@v3
            id: download
            with:
                path: artifacts
          - name: Upload to S3
            run: |
                aws s3 sync --region eu-west-1 --include "*.*" --acl public-read ./artifacts/data s3://bucket-name
            env:
                AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
                AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}   
          - name: Invalidate CloudFront
            uses: chetan/invalidate-cloudfront-action@v2
            env:
                DISTRIBUTION: ${{ secrets.DISTRIBUTION }}
                PATHS: "/*"
                AWS_REGION: "us-east-1"
                AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
                AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}     
            

Este fichero es un parcial que estaría orientado al despliegue. Tenemos que tener en cuenta que, antes de hacer una subida de los ficheros, tendremos que hacer compilaciones intermedias si las hay (Angular, SaSS, etc.).

Los pasos que describe el fichero son muy sencillos:
– Primero hace la instalación de la AWS CLI necesaria para hacer la subida de los ficheros.
– A continuación descarga los «artifacts» creados en pasos anteriores, con la compilación del sitio que se va a desplegar
– En un tercer paso hace una subida al bucket de los ficheros del artefacto
– Por último, invalida la caché de CloudFront para que la nueva versión quede disponible desde el primer momento.

Final thoughts

En este artículo hemos tratado cómo desplegar un sitio estático en una infraestructura de alta resiliencia y escalabilidad por muy poco dinero, siempre utilizando herramientas de uso gratuito tanto profesional como personal.

No obstante, dentro de Monkey Dev tenemos el compromiso y la férrea determinación de que, como europeos, deberíamos de reducir nuestra dependencia a servicios de empresas que no sean europeas. Por ello, en el próximo artículo orientado a web estáticas, trabajaremos contra proveedores totalmente europeos.