Experimentos en la Nube: Construyendo una Aplicación ECS con Balanceador de Carga en Fargate

El código fuente para este artículo se puede encontrar aquí.

¡Bienvenido a otro experimento en la nube! La idea detrás de estos tutoriales prácticos es proporcionar experiencia práctica construyendo soluciones cloud-native de diferentes tamaños usando servicios de AWS y CDK. Nos enfocaremos en desarrollar experiencia en Infrastructure as Code, servicios de AWS y arquitectura en la nube mientras entendemos tanto el “cómo” como el “por qué” detrás de nuestras decisiones.

Nota: Este laboratorio debe ser estudiado junto con un laboratorio complementario que muestra una manera alternativa de lograr el mismo objetivo. La sección introductoria es la misma, pero vale la pena contrastar la manera en que ambos stacks son construidos.

Hospedando Contenedores en un Entorno Serverless

Docker (y otras tecnologías de contenedores como Podman) son increíbles. Hacen que el empaquetado y despliegue de software sea mucho más fácil, así que no es sorpresa que los contenedores se hayan convertido en un formato común y ampliamente soportado para compartir y desplegar aplicaciones. Hay muchas alternativas diferentes para ejecutar contenedores, desde despliegues completos de Kubernetes hasta solo ejecutar algunos contenedores en una máquina virtual, como una pequeña instancia EC2.

AWS te permite hacer ambas cosas, pero también ofrece una manera simplificada de ejecutar contenedores—tanto en máquinas virtuales como en un entorno serverless—a través del Elastic Container Service (ECS). En este laboratorio, aprenderemos cómo crear un servicio ECS simple con balanceador de carga ejecutándose en la plataforma de contenedores serverless de AWS, Fargate.

La arquitectura de nuestra solución se verá así:

Diagrama ECS Fargate

Antes de proceder, es importante revisar algunos conceptos relevantes, al menos a un nivel superficial:

  • Load Balancer: Un componente que distribuye el tráfico entrante a través de múltiples objetivos con el fin de esparcir la carga uniformemente entre ellos. Usaremos un Application Load Balancer para este laboratorio, pero AWS también proporciona un Network Load Balancer y el load balancer clásico (principalmente para usos legacy).

  • Target Group: Una colección de recursos que reciben tráfico reenviado por el load balancer. Estos usualmente son contenedores, máquinas virtuales, funciones Lambda, o básicamente casi cualquier cosa a la que puedas adjuntar una IP. Los load balancers pueden tener múltiples target groups.

  • ECS Cluster: Una colección de recursos de computación que servirán como la fundación sobre la que tus contenedores se ejecutarán. Puedes hacer que sea un cluster respaldado por EC2 o usar el entorno serverless Fargate.

  • ECS Fargate Service: Un tipo de servicio que puedes agregar a tu cluster que se ejecuta en un entorno serverless. Este servicio es responsable de asegurar que un número dado de tareas de un tipo dado estén ejecutándose en un estado saludable dentro de tu cluster.

  • ECS Task: Las tareas son básicamente contenedores. Bueno, no necesariamente, porque una sola tarea puede tener definiciones para más de un contenedor (como una aplicación contenedor más un contenedor de logging/métricas ejecutándose al lado), pero puedes pensar en ellas como contenedores o colecciones de contenedores.

  • ECS Task Definition: Esta es un plano que contiene instrucciones para construir tus tareas. Si has hecho programación orientada a objetos, puedes pensar en las task definitions como clases y las tareas como instancias.

Nuestra app ejecutará dos tareas (cada una con un solo contenedor) y distribuirá solicitudes entre ellas para mejorar la resistencia y rendimiento—un patrón común en despliegues en la nube. El diagrama de arriba omite los grupos de seguridad, que bloquean acceso directo a contenedores mientras aún permiten que el load balancer reenvíe tráfico. Ten en cuenta que nuestro stack creará estos recursos automáticamente.

¡Genial! Con una mejor idea de dónde encaja todo, ¡estamos listos para empezar a construir nuestra solución!

Construyendo la Aplicación de Prueba

Necesitamos una aplicación que podamos contenerizar para probar nuestro stack—ojalá algo simple. Crearemos una pequeña aplicación Sinatra con una sola ruta (la raíz) y una sola vista que imprima algunos datos básicos únicos para cada contenedor.

Crea una carpeta llamada app, y dentro de ella crea un Gemfile con estos contenidos:

source 'https://rubygems.org'
gem 'sinatra'
gem 'rackup'
gem 'puma'

Ahora podemos crear el archivo principal de la app. Junto al Gemfile, crea app.rb:

# frozen_string_literal: true

require 'sinatra'

set :port, 4567
set :bind, '0.0.0.0'

get '/' do
  @container_hostname = ENV['HOSTNAME']
  erb :index
end

Este archivo usa una vista (index), así que el siguiente paso es crear una carpeta llamada views (dentro de la carpeta app), y dentro de ella crear un archivo llamado index.erb:

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Test Sinatra App</title>
</head>
<body style="font-family: 'Times New Roman';">
  <div style="text-align: center; line-height: 100px;">
    <h2>Test Sinatra App</h2>
    <% if @container_hostname %>
      <p>Serving content from: <%= @container_hostname %></p>
    <% end %>
  </div>
</body>
</html>

Ahora, el último paso es escribir un Dockerfile para construir la imagen docker de nuestra aplicación:

# Dockerfile
FROM ruby:3.3
ENV APP_ENV=production
WORKDIR /app
COPY . /app
RUN bundle install
EXPOSE 4567
CMD ["ruby", "app.rb"]

¡Y hemos terminado! La estructura de carpetas debería verse así:

Estructura de Carpetas de Ejemplo de App

Puedes escribir la app tú mismo o solo copiarla del repositorio del laboratorio. No necesitas probarla localmente, pero si quieres darle una pequeña prueba, solo ejecuta el comando docker build --tag 'sample-sinatra' . para crear la imagen del contenedor, y luego ejecútala con docker run -p 8055:4567 sample-sinatra. Esto servirá la app en tu localhost en el puerto 8055, así:

Test Sinatra App

Hemos terminado con la app—ahora podemos enfocarnos en la infraestructura.

Construyendo nuestro Stack

Creación del Proyecto

Primero, necesitamos la configuración regular del proyecto a la que nos hemos acostumbrado.

Crea una carpeta vacía (nombré la mía LoadBalancedECSFargateFromScratch) y ejecuta cdk init app --language typescript dentro de ella.

Este siguiente cambio es opcional, pero lo primero que hago después de crear un nuevo proyecto CDK es dirigirme a la carpeta bin y renombrar el archivo app a main.ts. Luego abro el archivo cdk.json y edito la configuración app:

{
  "app": "npx ts-node --prefer-ts-exts bin/main.ts",
  "watch": {
    ...
  }
}

Ahora tu proyecto reconocerá main.ts como el archivo de aplicación principal. No tienes que hacer esto—solo me gusta tener un archivo llamado main sirviendo como archivo principal de la app.

Importaciones del Stack

Al mirar el diagrama, sabemos que necesitaremos las siguientes importaciones en la parte superior del stack:

import * as cdk from 'aws-cdk-lib';
import {Construct} from 'constructs';
import {aws_ec2 as ec2} from 'aws-cdk-lib';
import {aws_ecs as ecs} from 'aws-cdk-lib';
import {aws_elasticloadbalancingv2 as elbv2} from 'aws-cdk-lib';

Crear la Red y el Cluster

El primer recurso que crearemos es la VPC. Haremos una VPC pequeña con dos subnets (pública y privada) en cada una de dos zonas de disponibilidad. Mantendremos cada subnet pequeña (/28), así que una red /26 será suficiente para hospedar las cuatro subnets. El plan es hospedar los contenedores en subnets privadas y mantener el load balancer activo en ambas subnets públicas.

const vpc = new ec2.Vpc(this, "VPC", {
  ipAddresses: ec2.IpAddresses.cidr("10.0.0.0/26"),
  natGateways: 0,
  maxAzs: 2,
  subnetConfiguration: [
    {cidrMask: 28, name: "private-subnet", subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS},
    {cidrMask: 28, name: "public-subnet", subnetType: ec2.SubnetType.PUBLIC},
  ],
});

Nota: En lugar de tener redes privadas PRIVATE_ISOLATED, usamos PRIVATE_WITH_EGRESS. Nuestros contenedores aún necesitarán acceso saliente para obtener las imágenes de contenedor de ECR

El siguiente paso es crear el cluster al que agregaremos nuestros servicios y tareas. El único parámetro que necesitamos pasar es la VPC que acabamos de crear:

const cluster = new ecs.Cluster(this, "cluster", {
  vpc: vpc
});

Crear la Task Definition

Para ahora, la carpeta app debería estar junto a tus carpetas bin y lib.

Lo primero que necesitamos asegurar es que nuestra tarea tendrá suficientes recursos para ejecutarse apropiadamente. Crearemos una task definition simple que asigne 1GB de RAM a la tarea y medio CPU virtual (512 equivale a 0.5 vCPU). La app es súper simple y esto es probablemente excesivo, pero es un buen punto de partida. Fargate se asegurará de que nuestros contenedores tengan acceso a los recursos que necesitan, así que no tenemos que preocuparnos sobre la capa de computación subyacente y podemos simplemente descargar la responsabilidad a AWS.

const taskDefinition = new ecs.FargateTaskDefinition(this, 'taskDefinition', {
  memoryLimitMiB: 1024,
  cpu: 512,
})

Ahora, agregamos un contenedor a la tarea. La imagen vendrá de nuestros assets locales, así que usamos ecs.ContainerImage.fromAsset, y luego, porque nuestra app está sirviendo contenido en el puerto 4567, necesitamos exponer ese puerto.

const appContainerTD = taskDefinition.addContainer("appContainer", {
  image: ecs.ContainerImage.fromAsset("app"),
  logging: ecs.LogDrivers.awsLogs({ streamPrefix: "App" }),
});

appContainerTD.addPortMappings({
  containerPort: 4567
});

Nota: Puedes haber notado que addContainer puede recibir una propiedad portMappings directamente, pero cuando trabajas con Fargate necesitarás pasar los port mappings con una llamada adicional a addPortMappings, porque pasarlos directamente en el constructor no funciona.

Esta es información suficiente para asegurar que nuestro contenedor sea apropiadamente construido y subido a ECR cuando despleguemos nuestro stack. Ahora cambiemos nuestra atención al servicio Fargate.

Crear el Servicio Fargate

Agregamos el cluster y la task definition a nuestro servicio Fargate durante la creación, luego establecemos el conteo de tareas deseado. En este caso, 2 tareas deberían estar ejecutándose en un estado saludable para considerar el despliegue saludable, así que estableceremos el porcentaje mínimo saludable a 100%.

También queremos desplegar nuestros contenedores en subnets privadas, así que necesitamos especificar esa información a nivel de servicio. Con todo esto en mente, podemos proceder a crear nuestro servicio así:

const fargateService = new ecs.FargateService(this, 'fargateService', {
  cluster,
  taskDefinition,
  desiredCount: 2,
  minHealthyPercent: 100,
  vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
});

Crear y Configurar el Load Balancer

El paso final es construir un application load balancer y asegurar que apunte a nuestros contenedores. Lo primero es crear el application load balancer. Queremos que tenga una dirección enrutable por internet, así que pasaremos las siguientes propiedades:

const appLB = new elbv2.ApplicationLoadBalancer(this, 'LB', {
  vpc,
  internetFacing: true
});

El paso final es agregar un listener y crear el target group, asegurando que las solicitudes sean enviadas a nuestros contenedores en el puerto correcto. Esta será responsabilidad del servicio, así que necesitamos pasar las siguientes propiedades a las funciones addListener y addTargets:

const listener = appLB.addListener('serviceListener', {
  port: 80,
});

listener.addTargets("ECS", {
  port: 80,
  targets: [
    fargateService.loadBalancerTarget({
      containerName: appContainerTD.containerName,
      containerPort: 4567,
    }),
  ],
  healthCheck: {
    path: "/",
  },
});

Lo bueno de esta configuración es que mantendrá tu computación ejecutándose en redes privadas y solo colocará el load balancer en subnets públicas. Los grupos de seguridad también serán creados automáticamente para permitir acceso solo en los puertos correctos y desde las entidades correctas:

Grupos de Seguridad

¡Ya casi terminamos! El último construct opcional que podemos agregar es una salida CloudFormation para ver la URL del load balancer después del despliegue, haciendo nuestras vidas un poquito más fáciles:

new cdk.CfnOutput(this, "Load Balancer URL", { value: appLB.loadBalancerDnsName });

Probando la Solución

Después de ejecutar cdk deploy, puedes visitar la URL devuelta al final del despliegue del stack. Obtendrás una vista que se ve más o menos así:

Ejemplo en Vivo

Porque el load balancer dividirá las solicitudes entre ambos contenedores, recargar la página actualizará el valor después de la cadena Serving content from. Así que, puedes obtener una vista con el valor 10-0-0-60.eu-west-1.compute.internal y luego otra que dice 10-0-0-20.eu-west-1.compute.internal.

Otra cosa que puedes hacer es inspeccionar el panel ECS y la página del target group, donde puedes encontrar que tu servicio ya está ejecutándose. Ahí, puedes inspeccionar y verificar que ambas tareas estén ejecutándose y en un estado saludable:

Ejemplo en Vivo


Ejemplo en Vivo

¡IMPORTANTE! Siempre recuerda eliminar tu stack ejecutando cdk destroy o eliminándolo manualmente en la consola.

Mejoras y Experimentos

  • En lugar de usar Fargate, trata de ejecutar la misma carga de contenedores en un servicio respaldado por instancias EC2.
  • ¿Hay una manera de mantener todas las subnets privadas aisladas, mientras aún otorgas acceso a ECR desde dentro de esas subnets?
  • Mejora el health check para nuestros contenedores, actualmente no está funcionando muy bien.
  • El mejor y (probablemente) más útil de los experimentos que puedes intentar es usar este laboratorio como fundación para contenerizar y desplegar tu propia aplicación. Una aplicación con un solo tipo de contenedor es suficientemente buena, pero algo que involucre varios contenedores diferentes trabajando juntos para lograr una tarea puede ser un experimento aún más útil (como desplegar tu propio stack ELK).

ECS es muy útil y fácil de usar, y con Fargate como una opción que abstrae mucho de la sobrecarga operacional permitiéndote ejecutar tus contenedores en un entorno serverless, puedes enfocar aún más de tu tiempo en construir proyectos más divertidos.

Si no lo has hecho, echa un vistazo al laboratorio complementario, aprenderás una manera mucho más fácil de construir la solución que acabamos de hacer.

¡Espero que esto te sea útil!

Juan Luis Orozco Villalobos

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