Migrando Mi Blog a AWS: Un Viaje de Ghost a Jekyll

He estado posponiendo esta migración por un tiempo, ¡pero finalmente me senté y moví mi blog a AWS—y fue mucho más fácil de lo que esperaba!

Durante años, he ejecutado y alojado BrainsToBytes usando Ghost, que a pesar de ser un excelente software (lo recomiendo), para mi se ha vuelto más difícil de justificar con el tiempo. Esto es principalmente por dos razones:

  • Los costos de alojamiento son elevados para lo que obtengo del blog. Este proyecto no genera ningún ingreso, y principalmente lo mantengo porque a pesar de no ser muy buen escritor, encuentro el proceso divertido y entretenido cuando tengo tiempo. La suscripción más barata de Ghost Pro con todas las características básicas cuesta $25/mes, y simplemente no puedo justificar gastar $300 al año en este proyecto. Eventualmente encontré una buena solución ejecutando el blog en Pikapods (es genial—échale un vistazo si tienes tiempo), pero luego me pregunté si podía crear una solución más sencilla y barata por mi cuenta.

  • Simplemente no me importan o no uso todas las características que Ghost proporciona, y preferiría tener más control sobre el software que uso para mi blog. Esto es solo una manía—como dije, Ghost es bastante bueno y me gusta—pero aún quería ver si había una manera más fácil de ejecutar un blog estático simple.

Eso inició una búsqueda que terminó con la versión actual del blog. Como con la mayoría de las cosas, tuvo una parte aburrida y una parte divertida.

La Parte Aburrida: Encontrando un Generador de Sitios Estáticos

Quería algo simple y personalizable que me permitiera ejecutar el blog con suficiente control para personalizar adecuadamente todo lo que quería, pero con suficiente simplicidad para no estorbar y simplemente dejarme escribir. Descubrí los generadores de sitios estáticos, leí un poco sobre ellos, y decidí que eran la herramienta adecuada para el trabajo.

Después de algo de consideración, decidí probar dos proyectos diferentes:

Terminé eligiendo Jekyll porque amo Ruby y no puedo pensar críticamente las ventajas de Hugo (es más rápido, es nuevo y genial) no son tan importantes como las ventajas que obtuve de Jekyll:

  • Configuración muy fácil
  • Muchos temas y personalizaciones
  • Conozco Ruby mejor de lo que conozco Go
  • Fácil de entender y usar
  • Lo suficientemente rápido—quiero decir, solo está generando un sitio estático y solo toma unos pocos segundos

Portar el sitio fue fácil ya que había escrito cada publicación del blog usando Markdown. Solo fue cuestión de poner archivos en la carpeta correcta, añadir front matter (YAML específico de Jekyll que tienes que añadir a cada artículo), y organizar las imágenes en sus carpetas correctas con referencias.

Me tomó unas pocas horas de trabajo tedioso, pero lo logré. Fue realmente mucho, mucho más rápido y fácil de lo que esperaba.

Si hubiera sabido de antemano que sería tan indoloro, probablemente lo habría hecho hace mucho tiempo.

Esta fue la parte aburrida de la migración, pero terminé con un sitio completamente estático. Sin necesidad de bases de datos o servidores—solo archivos estáticos simples. Ahora solo necesitaba encontrar una manera de servir el sitio, ojalá de una manera fácil, rápida y barata.

La Parte Divertida: Creando la Infraestructura

He estado aprendiendo sobre computación en la nube durante los últimos meses (casi un año ahora, tal vez), y esto parecía una buena oportunidad para poner ese conocimiento en uso. He aprendido cómo usar la mayoría de los servicios en AWS, y recientemente, me he involucrado más en IaC (Infraestructura como Código) usando CDK. (Nota: Si estás empezando con IaC en Amazon Web Services, hazte un favor y aprende el mínimo de escritura de plantillas de CloudFormation, luego salta directamente a CDK—tendrás una experiencia mucho mejor ahí.)

Construir una manera de servir un sitio estático en AWS por muy poco dinero y realmente buen rendimiento resulta ser bastante fácil. La configuración es más o menos así:

Diagram Jekyll Site

  • Una Hosted Zone (¿Zona Alojada?) de AWS Route53 te permite definir cómo enrutar el acceso para un dominio dado. Creé una zona alojada y la configuré para que el tráfico en el apex (brainstobytes.com) y el subdominio www (www.brainstobytes.com) sea dirigido a mi distribución de CloudFront.
  • Quiero que las personas puedan acceder al sitio web de manera segura usando HTTPS, así que configuré y validé un certificado usando AWS Certificate Manager y lo asocié con mi distribución de CloudFront.
  • La distribución de CloudFront está a cargo de servir y cachear mi servicio en varias ubicaciones alrededor del planeta. Esto asegura que la experiencia sea ágil y el sitio web se comporte de manera tolerante a fallos.
  • Los archivos estáticos que la distribución de CloudFront servirá están almacenados en un bucket de S3. El bucket de S3 mismo no es accesible desde ningún otro lugar, y estamos usando la característica Origin Access Control (OAC) para habilitar comunicación solo entre la distribución y el bucket.
  • La manera en que Jekyll genera archivos y URLs es un poco problemática para esta configuración, pero logré arreglar este problema escribiendo una pequeña función de JavaScript (una Función de CloudFront que se ejecuta en la ubicación de borde donde el sitio cacheado está almacenado) para limpiar la URL. El código mismo se ve así:
async function handler(event) {
    var request = event.request;
    var uri = request.uri;

    // Check whether the URI is missing a file name.
    if (uri.endsWith('/')) {
        request.uri += 'index.html';
    }

    // Check whether the URI is missing a file extension.
    else if (!uri.includes('.')) {
        request.uri += '.html';
    }

    return request;
}

Luego solo asocio la función con la distribución de cloudfront y me aseguro de que se ejecute en el hook de viewer request. La función fue adaptada de esta otra función encontrada en la documentación de AWS.

Ahora solo tenía que escribir una plantilla para automatizar la creación de esta infraestructura. Tomé algunas consideraciones al escribirla que me llevaron a concluir que debería crear tres recursos a mano y automatizar todo lo demás:

  • La zona alojada y el certificado fueron creados a mano de antemano y validados. Esto hace el despliegue más fácil porque la plantilla no se preocupa por qué zona alojada o certificados proporciono, y me permite asociar rápidamente diferentes distribuciones con una variedad de dominios. Fue útil mientras prototipaba soluciones para otros proyectos. La función fue escrita a mano y añadida a mi cuenta de AWS usando la consola, de esta manera puedo reutilizar la misma función para todos mis despliegues de Jekyll.

  • La plantilla solo recibe referencias a estos recursos como props para la clase Stack, luego realiza toda la creación de recursos requerida, todo el cableado, e incluso la adición de registros a la Zona Alojada. La plantilla implementa los contenidos del rectángulo azul punteado en el siguiente diagrama:

Diagram Jekyll Site

La plantilla se ve así:

import * as cdk from "aws-cdk-lib";
import { Tags } from "aws-cdk-lib";
import { Construct } from "constructs";
import { aws_s3 as s3 } from "aws-cdk-lib";
import { aws_cloudfront as cloudfront } from "aws-cdk-lib";
import { aws_certificatemanager as acm } from "aws-cdk-lib";
import { aws_route53 as route53 } from "aws-cdk-lib";
import { aws_cloudfront_origins as origins } from "aws-cdk-lib";
import { aws_route53_targets as targets } from "aws-cdk-lib";
import { aws_s3_deployment as s3deploy } from "aws-cdk-lib";

interface JekyllCloudStackProps extends cdk.StackProps {
  domainName: string;
  subDomain: string;
  certificateArn: string;
  pathToProject: string;
  cloudfrontFunctionName: string;
  cloudfrontFunctionArn: string;
}

export class JekyllCloudStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: JekyllCloudStackProps) {
    super(scope, id, props);

    // Because we are dealing with Jekyll, we know the names of these two files in advance
    const indexFilename = "index.html";
    const errorFilename = "404.html";

    const {
      domainName,
      subDomain,
      certificateArn,
      pathToProject,
      cloudfrontFunctionName,
      cloudfrontFunctionArn,
    } = props;

    const fullDomain = `${subDomain}.${domainName}`;

    const hostedZone = route53.HostedZone.fromLookup(this, "siteHZ", {
      domainName: domainName,
    });

    const certificate = acm.Certificate.fromCertificateArn(
      this,
      "siteCertificate",
      certificateArn
    );

    const siteBucket = new s3.Bucket(this, "siteBucket", {
      bucketName: domainName,
      websiteIndexDocument: indexFilename,
      websiteErrorDocument: errorFilename,
      publicReadAccess: false,
      removalPolicy: cdk.RemovalPolicy.DESTROY, // If we delete our stack, we delete this bucket
      autoDeleteObjects: true,
    });

    const distributionDomainNames = subDomain
      ? [domainName, fullDomain]
      : [domainName];

    const distribution = new cloudfront.Distribution(this, "siteDistribution", {
      defaultBehavior: {
        origin: origins.S3BucketOrigin.withOriginAccessControl(siteBucket),
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        compress: true,
        functionAssociations: [
          {
            eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
            function: cloudfront.Function.fromFunctionAttributes(
              this,
              "jekyllResolveFunction",
              {
                functionName: cloudfrontFunctionName,
                functionArn: cloudfrontFunctionArn,
              }
            ),
          },
        ],
      },
      defaultRootObject: indexFilename,
      certificate: certificate,
      domainNames: distributionDomainNames,
      priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
    });

    const apexARecord = new route53.ARecord(
      this,
      "cloudfrontApexDistroRecord",
      {
        recordName: domainName,
        target: route53.RecordTarget.fromAlias(
          new targets.CloudFrontTarget(distribution)
        ),
        zone: hostedZone,
      }
    );

    if (subDomain) {
      new route53.ARecord(this, "cloudfrontSubdomainDistroRecord", {
        recordName: fullDomain,
        target: route53.RecordTarget.fromAlias(
          new targets.Route53RecordTarget(apexARecord)
        ),
        zone: hostedZone,
      });
    }

    new s3deploy.BucketDeployment(this, "DeployWithInvalidation", {
      sources: [s3deploy.Source.asset(pathToProject)],
      destinationBucket: siteBucket,
      distribution,
      distributionPaths: ["/*"],
    });

    Tags.of(siteBucket).add("project", domainName);
    Tags.of(distribution).add("project", domainName);
  }
}

Nota que para la clase de precio, estoy usando cloudfront.PriceClass.PRICE_CLASS_100, que cachea mi contenido solo en Europa y América del Norte. Si quieres servir tu contenido globalmente, deberías usar una de las clases de precio más grandes. Esto te permitirá cachear tu sitio en más ubicaciones, pero es más caro.

¡Y listo! Después de solo ejecutar cdk deploy, ¡ahora puedo poner cualquier sitio de Jekyll en AWS, ejecutándose en una plataforma altamente disponible y veloz por mucho menos de $1 por mes! Puedes, por supuesto, hacer lo mismo y simplemente reutilizar mi plantilla o adaptarla a tus necesidades. Creo que hay algo que ganar estudiando la plantilla y tratando de entender cómo CDK define y conecta diferentes componentes—eso me ha ayudado a entender cosas rápidamente en el pasado también.

Lo Que Aprendí

Lo que aprendí de este proyecto es que, uno, tal vez no debería procrastinar en proyectos de tamaño mediano porque probablemente esté sobreestimando el esfuerzo involucrado en completarlos, y dos, trabajar con infraestructura como código sirve como un amplificador para tus habilidades de desarrollo de software. ¡También puede ser muy divertido y bastante rentable! Así que sí, esa es la historia de cómo finalmente logré migrar mi blog.


Nota Adicional

Hay una posibilidad de que la función proporcionada arriba pueda causarte problemas dependiendo de cómo configuraste tu proyecto Jekyll. Puede devolver una página de acceso Prohibido al acceder a algunas URLs que terminan con una barra diagonal final. Para arreglar eso, puedes usar esta versión de la función de CloudFront:

async function handler(event) {
    var request = event.request;
    var uri = request.uri;

    // We remove the trailing / from all URLS except for the root and the pagination pages
    if(uri !== '/' && !uri.includes('/page') && uri.endsWith('/') ) {
        uri = uri.slice(0, -1);
    }

    // Check whether the URI is missing a file name.
    if (uri.endsWith('/')) {
        uri += 'index.html';
    }

    // Check whether the URI is missing a file extension.
    else if (!uri.includes('.')) {
        uri += '.html';
    }

    request.uri = uri;
    return request;
}

Juan Luis Orozco Villalobos

¡Hola! Soy Juan, ingeniero de software y consultor en Budapest. Me especializo en computación en la nube e IA, y me encanta ayudar a otros a aprender sobre tecnología e ingeniería