Blog de Amazon Web Services (AWS)

Despliegue de Moodle en AWS con contenedores de Amazon ECS

Por Ulises Jiménez, Arquitecto de Soluciones AWS México

 

Amazon ECS

Moodle

Education

Moodle es un sistema de gestión de aprendizaje de código abierto, se utiliza ampliamente alrededor del mundo. Hay diferentes alternativas para comenzar con Moodle en AWS, desde hacerlo de manera rápida o usando una arquitectura de referencia con un conjunto de plantillas de AWS CloudFormation para generar un ambiente elástico y de alta disponibilidad. Uno de los principales retos de los sistemas de gestión de aprendizaje es que pueden llegar a tener una demanda de uso poco predecible y que puede crecer muy rápidamente por lo que se buscan arquitecturas elásticas, de costo optimizado y con pocas tareas de mantenimiento.

En este blog mostraremos un ejemplo de como desplegar Moodle con contenedores usando Amazon ECS y Fargate lo cual elimina la necesidad de aprovisionar y administrar servidores, permite especificar y pagar recursos por aplicación y mejora la seguridad mediante el aislamiento de aplicaciones por diseño en contenedores.

Los contenedores ofrecen un modo estándar de empaquetar el código, las configuraciones y las dependencias de la aplicación en un único objeto. Los contenedores comparten un sistema operativo instalado en el servidor, y se ejecutan como procesos aislados de los recursos, lo que garantiza implementaciones rápidas, fiables y consistentes sea cual sea el entorno en el que se realizan.

La implementación la realizaremos usando AWS Cloud Development Kit (CDK). AWS CDK es un marco de desarrollo de software de código abierto que sirve para modelar y aprovisionar recursos destinados a aplicaciones en la nube mediante lenguajes de programación

como Python. Luego CDK se encargará de transformar estas definiciones en plantillas de CloudFormation y desplegarlas. Esto permite acelerar y facilitar la gestión de recursos al tener definida la infraestructura en código.

Finalmente vamos a mostrar en una prueba de estrés sencilla como escala el número de contenedores de forma automática por alguna métrica del clúster. En ese momento ejecutaremos comandos para simular la carga directamente en los contenedores.

 

Figura 1. Diagrama de arquitectura

 

Prerrequisitos

 

Construcción

Creación del proyecto

Usaremos AWS Cloud Development Kit (CDK) para simplificar el proceso de desarrollo tanto como sea posible, inicialmente seguiremos los pasos iniciales comunes a las aplicaciones de CDK con Python.

 

  1. Validar la instalación de CDK
cdk --version

 

  1. Configurar la línea de comandos de AWS con un usuario con acceso programático y política de acceso administrador
aws configure

 

  1. Generar el proyecto de CDK
$ mkdir ecs-cdk-moodle
$ cd ecs-cdk-moodle
$ cdk init --language python

 

  1. Especificar las dependencias de la aplicación (requirements.txt)
-e .
aws_cdk.core
aws_cdk.aws_ec2
aws_cdk.aws_ecs
aws_cdk.aws_rds
aws_cdk.aws_efs
aws_cdk.aws_ecr_assets
aws_cdk.aws_elasticloadbalancingv2
 
  1. Instalar los requerimientos
$ source .venv/bin/activate$ pip install -r requirements.txt

 

  1. Vamos a usar la siguiente estructura de proyecto
├── app.py
├── ecs_cdk_moodle
│   ├── VPCStack.py
│   ├── LoadBalancerStack.py
│   ├── FileSystemStack.py
│   ├── DatabaseStack.py
│   ├── ApplicationStack.py
├── requirements.txt
└── src
    └── Docker.moodle

 

  1. En el stack de VPC (VPCStack.py) vamos a definir la VPC, la VPC usará 2 zonas de disponibilidad con 2 subredes, una pública y una privada, la subred privada será para desplegar las tareas del clúster de Amazon ECS así como para la base de datos, la subred pública será para desplegar el balanceador de carga.

import os
from aws_cdk import (
    aws_ec2 as _ec2,
    aws_elasticloadbalancingv2 as _elbv2,
    core as cdk
)
 
class MoodleVPCStack(cdk.Stack):
 
    def __init__(self, scope: cdk.Construct, construct_id: str,
                 **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)
 
        # VPC in 2 AZs with separate Private and Public subnets and 2 NAT Gateways
        self.vpc = _ec2.Vpc(
            self, "MoodleVPC",
            max_azs=2,
            subnet_configuration=[
                _ec2.SubnetConfiguration(
                    subnet_type=_ec2.SubnetType.PUBLIC,
                    name="Public",
                    cidr_mask=24
                ),
                _ec2.SubnetConfiguration(
                    subnet_type=_ec2.SubnetType.PRIVATE,
                    name="Private",
                    cidr_mask=24,
                )
            ],
            nat_gateway_provider=_ec2.NatProvider.gateway(),
            nat_gateways=2,
        )
       
        cdk.CfnOutput(self, "MoodleVPCID",
                       value=self.vpc.vpc_id)
 

  1. En el stack del balanceador de carga (LoadBalancerStack.py) vamos a definir el balanceador que es el punto de acceso a la aplicación.

import os
from aws_cdk import (
    aws_ec2 as _ec2,
    aws_ecs as _ecs,
    aws_rds as _rds,
    aws_efs as _efs,
    aws_ecr_assets as _ecr_assets,
    aws_elasticloadbalancingv2 as _elbv2,
    aws_logs as _logs,
    core as cdk
)
 
class MoodleLoadBalancerStackProperties:
 
    def __init__(
            self,
            vpc: _ec2.Vpc,
    ) -> None:
 
        self.vpc = vpc
 
class MoodleLoadBalancerStack(cdk.Stack):
 
    def __init__(self, scope: cdk.Construct, construct_id: str,
                 properties: MoodleLoadBalancerStackProperties, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)
 
        # define loadbalancer security group with custom outbound rule
        lbsg = _ec2.SecurityGroup(self, id="MoodleLoadBalancer-SG"
                               , vpc=properties.vpc, allow_all_outbound=False
        )
 
        # set egress rule to port 80/443
        lbsg.add_egress_rule(_ec2.Peer.any_ipv4(),
            connection=_ec2.Port.tcp(80),
            description="Allow outbound 80/443"
        )
 
        # load balancer       
        self.loadbalancer = _elbv2.ApplicationLoadBalancer(
            self, "MoodleLoadBalancer",
            vpc=properties.vpc,
            internet_facing=True,
            security_group=lbsg,
            vpc_subnets=_ec2.SubnetSelection(subnet_type=_ec2.SubnetType.PUBLIC)           
        )

 

  1. En el stack del sistema de archivos (FileSystemStack.py) vamos a definir un sistema de archivos de Amazon EFS

import os
from aws_cdk import (
    aws_ec2 as _ec2,
    aws_ecs as _ecs,
    aws_rds as _rds,
    aws_efs as _efs,
    aws_ecr_assets as _ecr_assets,
    aws_elasticloadbalancingv2 as _elbv2,
    aws_logs as _logs,
    core as cdk
)
 
class MoodleFileSystemStackProperties:
    def __init__(
            self,
            vpc: _ec2.Vpc,
    ) -> None:
 
        self.vpc = vpc
 
class MoodleFileSystemStack(cdk.Stack):
 
    def __init__(self, scope: cdk.Construct, construct_id: str,
                 properties: MoodleFileSystemStackProperties, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)
 
        # define shared file system
        self.file_system = _efs.FileSystem(
            self, "MoodleFileSystem",
            vpc=properties.vpc,
            performance_mode=_efs.PerformanceMode.GENERAL_PURPOSE,
            throughput_mode=_efs.ThroughputMode.BURSTING
        )

 

  1. En el stack de base de datos (DatabaseStack.py) vamos a definir el recurso de base de datos de MySQL

 


import os
from aws_cdk import (
    aws_ec2 as _ec2,
    aws_ecs as _ecs,
    aws_rds as _rds,
    aws_efs as _efs,
    aws_ecr_assets as _ecr_assets,
    aws_elasticloadbalancingv2 as _elbv2,
    aws_logs as _logs,
    core as cdk
)
 
class MoodleDatabaseStackProperties:
 
    def __init__(
            self,
            vpc: _ec2.Vpc,
    ) -> None:
 
        self.vpc = vpc
 
class MoodleDatabaseStack(cdk.Stack):
 
    def __init__(self, scope: cdk.Construct, construct_id: str,
                 properties: MoodleDatabaseStackProperties, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)
 
        # define mysql rds database
        self.database = _rds.DatabaseInstance(
            self, "MoodleDatabase",
            engine=_rds.DatabaseInstanceEngine.mysql(
                version=_rds.MysqlEngineVersion.VER_8_0_21
            ),
            database_name="MoodleDatabase",
            vpc=properties.vpc,
            port=3306,
            instance_type=_ec2.InstanceType.of(
                _ec2.InstanceClass.MEMORY6_GRAVITON,
                _ec2.InstanceSize.LARGE,
            ),
            deletion_protection=False,
            publicly_accessible=False,
            storage_encrypted=True,
            backup_retention=cdk.Duration.days(7),
            removal_policy=cdk.RemovalPolicy.DESTROY,
        )

 

  1. Ahora vamos a generar el archivo Dockerfile con la referencia a la imagen de Moodle que vayamos a utilizar e instalar la utilería de stress

[Dockerfile]

FROM bitnami/moodle:3.11.2

RUN apt-get update -y && apt-get install -y stress

 

  1. Los siguientes pasos van a ser sobre el stack de aplicación (ApplicationStack.py), aquí presentamos la definición del encabezado y se ira complementando a medida que lo vamos a explicar

import os
from aws_cdk import (
    aws_ec2 as _ec2,
    aws_ecs as _ecs,
    aws_rds as _rds,
    aws_efs as _efs,
    aws_ecr_assets as _ecr_assets,
    aws_elasticloadbalancingv2 as _elbv2,
    aws_logs as _logs,
    aws_iam as _iam,
    core as cdk
)
 
class MoodleApplicationStackProperties:
 
    def __init__(
            self,
            vpc: _ec2.Vpc,
            loadbalancer: _elbv2.ApplicationLoadBalancer,
            database: _rds.DatabaseInstance,
            filesystem: _efs.FileSystem            
    ) -> None:
 
        self.vpc = vpc
        self.loadbalancer = loadbalancer
        self.database = database
        self.filesystem = filesystem
 
class MoodleApplicationStack(cdk.Stack):
 
    def __init__(self, scope: cdk.Construct, construct_id: str,
                 properties: MoodleApplicationStackProperties, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)
 

 

  1. Vamos a definir el clúster de ECS con CloudWatch Container Insights Este servicio va a permitir recolectar, agregar y resumir métricas y logs del clúster de Amazon ECS. Lo vamos a usar para validar el mecanismo de auto escalado de las tareas del clúster.

 

# set container image
        docker_build_path = os.path.dirname(__file__) + "../../src"
        moodle_image =_ecr_assets.DockerImageAsset(
            self, "MoodleImage",
            directory=docker_build_path,
            file="Docker.moodle",
        )
 
        # define ecs cluster with container insights
        moodle_cluster = _ecs.Cluster(
            self, 'MoodleCluster',
            vpc=properties.vpc,
            container_insights=True,           
        )
 

 

  1. Definir un volumen de ECS desde el sistema de archivos de EFS

 

# mount efs in ecs cluster
        moodle_volume = _ecs.Volume(
            name="MoodleVolume",
            efs_volume_configuration=_ecs.EfsVolumeConfiguration(
               file_system_id=properties.filesystem.file_system_id
            )
        )
 

 

  1. Definir la tarea de Fargate especificando el volumen anterior así como su tamaño de 1 vCPU y 2 GB RAM.

 

# define task execution role and attach ECSTaskExecutionRole managed policy
        taskRole = _iam.Role(self, "MoodleTaskExecutionRole", assumed_by=_iam.ServicePrincipal("ecs-tasks.amazonaws.com"))
        taskRole.add_managed_policy(_iam.ManagedPolicy.from_aws_managed_policy_name("service-role/AmazonECSTaskExecutionRolePolicy"))
 
        # set task definition with 1 vCPU and 2GB RAM    
        task = _ecs.FargateTaskDefinition(
            self, "MoodleTaskDefinition",
            volumes=[moodle_volume],cpu=1024,memory_limit_mib=2048,
            task_role=taskRole,
        )
    

 

  1. Definir el contenedor de Moodle con las variables de ambiente y secretos como el usuario y clave, los secretos serán leídos del servicio de AWS Secrets Manager y fueron agregados durante la creación de la base de datos.

 

# define container with logging enabled
        container = task.add_container(
            "MoodleImage",
            environment={
                'MOODLE_USERNAME': 'admin',
                'MOODLE_PASSWORD': "initialch@ngeit",
                'MOODLE_EMAIL': 'admin@localhost.localdomain',
                'MOODLE_DATABASE_TYPE': 'mysqli',
                'ALLOW_EMPTY_PASSWORD':'no',
                'BITNAMI_DEBUG':'false',
                'MOODLE_DATABASE_HOST':properties.database.db_instance_endpoint_address,
                'MOODLE_DATABASE_PORT_NUMBER':properties.database.db_instance_endpoint_port,
                'PHP_ENABLE_OPCACHE':"yes"
            },
            secrets={
                'MOODLE_DATABASE_NAME':
                    _ecs.Secret.from_secrets_manager(properties.database.secret, field="dbname"),
                'MOODLE_DATABASE_USER':
                    _ecs.Secret.from_secrets_manager(properties.database.secret, field="username"),
                'MOODLE_DATABASE_PASSWORD':
                    _ecs.Secret.from_secrets_manager(properties.database.secret, field="password")                  
            },
            image=_ecs.ContainerImage.from_docker_image_asset(moodle_image),
            logging=_ecs.LogDrivers.aws_logs(stream_prefix="moodle-ecs-fargate", log_retention=_logs.RetentionDays.FIVE_DAYS)
        )
 
  1. Agregar a la definición del contenedor el punto de montaje y la relación de puertos a exponer en el contenedor.

 

# define mount point to /bitnami
        moodle_mount_point = _ecs.MountPoint(
            read_only=False,
            container_path="/bitnami",
            source_volume=moodle_volume.name
        )
       
        # add mount point to container
        container.add_mount_points(moodle_mount_point)

        # set mapping port in container
        container.add_port_mappings(
            _ecs.PortMapping(container_port=8080)
        )
 

 

  1. Para concluir el stack de aplicación (ApplicationStack.py) vamos a definir el servicio de Fargate y asignarlo al clúster previamente creado, establecer el valor del tiempo de espera para la prueba de salud lo suficientemente grande para permitir al contenedor inicializarse completamente, también activamos la ejecución de comandos dentro de los contenedores (que usaremos para ejecutar una prueba de estrés sencilla desde los contenedores) y los valores del umbral para permitir el auto escalamiento. Como estamos creando la base de datos junto con el clúster, el primer contenedor será responsable de realizar la instalación de los objetos en la base de datos y en el sistema de archivos.

 


   # create ecs service in Fargate mode with execute command enabled 
        service = _ecs.FargateService(
            self, "MoodleFargateService",
            task_definition=task,
            platform_version=_ecs.FargatePlatformVersion.VERSION1_4,
            cluster=moodle_cluster,
            desired_count=1,
            health_check_grace_period=cdk.Duration.seconds(900),
            enable_execute_command=True,
        )
 
        # set ecs auto scale mix and max capacity
        autoscale = service.auto_scale_task_count(
            min_capacity=1,
            max_capacity=4
        )
 
        # define ecs auto scale based on cpu utilization of 75%
        autoscale.scale_on_cpu_utilization(
            "MoodleAutoscale",
            target_utilization_percent=75,
            scale_in_cooldown=cdk.Duration.seconds(300),
            scale_out_cooldown=cdk.Duration.seconds(300),
        )
 
        #configure security groups
        service.connections.allow_to(other=properties.database, port_range=_ec2.Port.tcp(3306))
        service.connections.allow_to(other=properties.filesystem, port_range=_ec2.Port.tcp(2049))
        service.connections.allow_from(other=properties.loadbalancer, port_range=_ec2.Port.tcp(80))     
 
        # refer to existing certificate by arn
        #cert = _cm.Certificate.from_certificate_arn(self, "cert","arn:aws:acm:*:*:certificate/*")
 
        # set load balancer listener on port 80/443
        http_listener = properties.loadbalancer.add_listener(
            "MoodleHttpListener",
            port=80,
            #certificates=[cert]
        )
 
        # add load balancer target and health checks with threshold values
        http_listener.add_targets(
            "MoodleHttpServiceTarget",
            protocol=_elbv2.ApplicationProtocol.HTTP,
            targets=[service],
            health_check=_elbv2.HealthCheck(healthy_http_codes="200-299,301,302",
            healthy_threshold_count=3,
            unhealthy_threshold_count=2,           
            interval=cdk.Duration.seconds(10))
        )
 
        # output load balancer dns
        cdk.CfnOutput(
            self, "MoodleLoadBalancerDNSName",
            value=properties.loadbalancer.load_balancer_dns_name
        )
 

  1. Ahora vamos a definir la aplicación (app.py) haciendo uso de los stacks definidos previamente

 

#!/usr/bin/env python3
import os
from aws_cdk import core as cdk
from ecs_cdk_moodle.VPCStack import MoodleVPCStack
from ecs_cdk_moodle.FileSystemStack import (
    MoodleFileSystemStackProperties,
    MoodleFileSystemStack
)
from ecs_cdk_moodle.LoadBalancerStack import (
    MoodleLoadBalancerStackProperties,
    MoodleLoadBalancerStack
)
from ecs_cdk_moodle.DatabaseStack import (
    MoodleDatabaseStackProperties,
    MoodleDatabaseStack
)
from ecs_cdk_moodle.ApplicationStack import (
    MoodleApplicationStackProperties,
    MoodleApplicationStack
)
 
# specify environment details
environment_name = "DEV"
 
app = cdk.App()
 
# specify tag details
tags = [
    ['Application', 'Moodle'],
    ['Environment', environment_name]
]
 
# create vpc stack
moodle_vpc_stack = MoodleVPCStack(
    app, f"MoodleVPC{environment_name}")
 
# define load balancer properties, passing vpc as parameter
moodle_loadbalancer_properties = MoodleLoadBalancerStackProperties(
    vpc=moodle_vpc_stack.vpc,
)
 
#create load balancer stack
moodle_loadbalancer_stack = MoodleLoadBalancerStack(
    app, f"MoodleLoadBalancer{environment_name}",
    properties=moodle_loadbalancer_properties
)
 
#create database properties, passing vpc as parameter
moodle_database_properties = MoodleDatabaseStackProperties(
    vpc=moodle_vpc_stack.vpc
)
 
# create database stack
moodle_database_stack = MoodleDatabaseStack(
    app, f"MoodleDatabase{environment_name}",
    properties=moodle_database_properties
)
 
# create file system properties, passing vpc as parameter
moodle_filesystem_properties = MoodleFileSystemStackProperties(
    vpc=moodle_vpc_stack.vpc
)
 
# create file system stack
moodle_filesystem_stack = MoodleFileSystemStack(
    app, f"MoodleFileSystem{environment_name}",
    properties=moodle_filesystem_properties
)
 
# create application properties, passing previous resources as parameters
moodle_application_properties = MoodleApplicationStackProperties(
    vpc=moodle_vpc_stack.vpc,
    loadbalancer=moodle_loadbalancer_stack.loadbalancer,
    database=moodle_database_stack.database,
    filesystem=moodle_filesystem_stack.file_system,
)
 
# create application stack
moodle_application_stack = MoodleApplicationStack(
   app, f"MoodleApplication{environment_name}",
    properties=moodle_application_properties
)
 
# assign tags to resources created within stacks
for stack in [moodle_vpc_stack, moodle_loadbalancer_stack, moodle_filesystem_stack, moodle_database_stack, moodle_application_stack]:
    for tag in tags:
        cdk.Tags.of(stack).add(tag[0], tag[1])
 
app.synth()
 
  1. Hacer el despliegue, validar la creación de los recursos y cambiar la clave de administrador de Moodle. En el momento que desplegamos el primer stack vamos a poder ver el valor del campo URL del balanceador (LoadBalancerDNSName) para entrar a Moodle (El balanceador tomará algunos minutos en inicializarse completamente, así como el contenedor en concluir la instalación de Moodle).

$ cdk ls
$ cdk deploy MoodleVPCDEV MoodleLoadBalancerDEV MoodleFileSystemDEV
MoodleDatabaseDEV MoodleApplicationDEV
 


Figura 2. Moodle ejecutándose en contenedores

 

  1. Comprobar el mapa del clúster de ECS en Cloudwatch Container Insights, donde además podemos revisar métricas y logs del clúster y sus tareas.

Figura 3. Estado del cluster y CloudWatch Container Insights

 

  1. Ejecutar el comando de stress en los contenedores con el propósito de generar un uso de CPU mayor en las tareas que forman el clúster.
$ aws ecs execute-command \
--region Región \
--cluster Nombre de clúster \
--task ID de Task \
--container moodle \
--command "/bin/sh" \
stress --cpu 4 -v --timeout 900s

 

  1. Validar el escalamiento de las tareas del clúster. De acuerdo a la definición se intenta mantener el uso del CPU en el clúster a 75%, cuando se supera este valor se realiza una acción de escalamiento y se agrega una tarea al clúster, cuando el uso de CPU disminuya también lo hará el número de tareas al mínimo definido que en este caso es 2. Con eso logramos una arquitectura eficiente en términos de costo y operación, dos de los pilares del marco de buena arquitectura. Cabe mencionar que si la demanda de la carga de trabajo es más predecible de acuerdo al momento del día podemos definir el escalamiento haciendo uso de la característica de un horario programado o bien combinar ambos enfoques.

Figura 4. Métricas de CloudWatch

 

Siguientes pasos

Como base de datos hemos usado Amazon RDS MySQL pero podríamos cambiar a Amazon Aurora a partir de Moodle 3.10 y ganar en escalabilidad, con respecto al canal de acceso a la aplicación la recomendación es realizarlo mediante HTTPS con un certificado de seguridad, algo también importante a considerar en una arquitectura escalable son las pruebas de carga. Moodle tiene la opción de generar planes de prueba de JMeter que podemos cargar en la solución de pruebas de carga distribuida de AWS.

Una opción para reducir el costo de despliegue es utilizando precios de Spot y Planes de Ahorro con AWS Fargate al igual que con las instancias de Amazon EC2. En comparación con los precios bajo demanda Fargate Spot ofrece hasta un 70 % de descuento en aplicaciones con tolerancia a interrupciones.

 

Limpieza de recursos

  1. Eliminar los recursos creados por CDK con el comando cdk destroy
$ cdk ls
$ cdk destroy MoodleVPCDEV MoodleLoadBalancerDEV MoodleFileSystemDEV
MoodleDatabaseDEV MoodleApplicationDEV

Conclusión

En este blog hemos visto como definir una arquitectura de Moodle basada en contenedores, un mecanismo escalable y efectivo en costo que reduce las tareas administrativas de gestión de los contenedores y acelera el servicio a nuestros clientes. También definimos la Infraestructura en Código en un esquema que se puede replicar con facilidad entre ambientes.

 

 


Sobre el autor

Ulises Jiménez es Arquitecto de Soluciones en AWS México, con experiencia previa en diferentes industrias actualmente apoya a clientes del Sector Público en acelerar su entrega de soluciones.

 

 

 

 

Revisor Técnico

Christian Castro es Arquitecto de Soluciones Senior para Gobierno Federal de México en Sector Público. Christian es responsable de establecer y mantener arquitecturas de soluciones en AWS para clientes del Gobierno Federal Mexicano, además forma parte de la comunidad técnica de contenedores (TFC) dentro de AWS.