CDK + AutoScale + Lifecycle Hooks + Lambda

CDK

AWS CDK + AutoScale + Lifecycle Hooks + Lambda + SSM =💪

CDK — Kubernetes a veces es demasiado pesado o demasiado caro para resolver todos los problemas o una solución de “lift and shift” demasiado grande para los equipos de desarrollo.

Pero a medida que las soluciones se trasladan a la nube y, a menudo, enfrentan mayores cargas de trabajo, las soluciones aún deben poder escalar dinámicamente debido a razones de costo o utilización. No todas las aplicaciones o soluciones se pueden escalar horizontalmente de forma automática o sencilla.

Hay casos en los que es necesario tomar medidas cuando un componente de la solución aumenta o disminuye; por ejemplo, si el componente de trabajo aumenta o disminuye, es posible que deba hacer que un nodo de control sea consciente de esos cambios a través de actualizaciones de configuración.

Veamos un enfoque en Amazon Web Services donde un Autoscale Group (ASG) puede usar Lifecycle Hooks para activar operaciones durante el lanzamiento o la finalización de una instancia.

En este desglose, utilizaremos AWS Simple Notification Service (SNS) que entregará los mensajes relacionados con el ciclo de vida de ASG, AWS Lambda en el que una función sin servidor escuchará el tema del ciclo de vida de SNS para reaccionar a la changes, AWS Systems Manager, que se utilizará para invocar comandos de shell en el punto de enlace de destino, a fin de mostrar cómo estos ganchos pueden dar forma a los cambios en varios sistemas.

Una nota importante es que Systems Manager Agent (SMS) solo está instalado en imágenes de Amazon Linux de manera predeterminada; es posible que deba actualizar su imagen de máquina de Amazon para incluir la instalación y configuración de este elemento.


El repositorio de ejemplo que veremos a medida que analicemos estos conceptos utiliza Amazon Cloud Development Toolkit (CDK). El CDK le permite usar uno de los muchos lenguajes de código populares para definir las plantillas de formación de nubes resultantes que se pueden usar para implementaciones repetibles.

Este ejemplo básico configura una VPC y un grupo de escalado automático.

Paso a paso

Veamos el proyecto de ejemplo basado en CDK que:

1. Crear una red privada virtual (VPC)

2. Crear un grupo de escalado automático

3. Crea un tema SNS

4. Registre la función Lambda y suscríbase al tema SNS

5. Crea la función Lambda

Estos cinco elementos diferentes se capturan en el siguiente repositorio de GitHub de ejemplo:

cdk-autoscale-lambda-ssm-action

Creación de una VPC

Hay muchos ejemplos de la configuración básica de una VPC. Centraremos nuestra atención en el archivo net.py. A continuación puede ver la creación de una red privada virtual simple.

class ExampleNetworkStack(Stack):
    def __init__(self, scope: Construct, id: str, props, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)

        # Subnet configurations for a public and private tier
        subnet1 = SubnetConfiguration(name="PublicNet", subnet_type=SubnetType.PUBLIC, cidr_mask=24)
        subnet2 = SubnetConfiguration(
            name="PrivateNet",
            subnet_type=SubnetType.PRIVATE_WITH_NAT,
            cidr_mask=24,
        )

        vpc = Vpc(
            self,
            "ExampleVPC",
            cidr="10.0.0.0/16",
            enable_dns_hostnames=True,
            enable_dns_support=True,
            max_azs=2,
            nat_gateway_provider=NatProvider.gateway(),
            nat_gateways=1,
            subnet_configuration=[subnet1, subnet2],
        )

        # This will export the VPC's ID in CloudFormation under the key
        # 'vpcid'
        CfnOutput(self, "vpcid", value=vpc.vpc_id)

        # Prepares output attributes to be passed into other stacks
        # In this case, it is our VPC, subnets and public_subnet_id.
        self.output_props = props.copy()
        self.output_props["vpc"] = vpc
        self.output_props["subnets"] = vpc.public_subnets
        self.output_props["public_subnet_id"] = vpc.public_subnets[0].subnet_id

Crear un grupo de escalado automático

La siguiente sección de código, systems.py, proporciona un ejemplo de grupo de escalado automático que usaremos para realizar un seguimiento de los cambios del ciclo de vida con fines de automatización.

from aws_cdk.aws_ec2 import SubnetType
from aws_cdk import (
    BundlingOptions,
    Duration,
    aws_ec2 as ec2,
    aws_autoscaling as autoscaling,
    aws_elasticloadbalancingv2 as elbv2,
    aws_iam as iam,
    aws_sns as sns,
    aws_lambda as _lambda,
    aws_autoscaling_hooktargets as hooktargets,
    aws_sns_subscriptions as subscriptions,
    Stack,
)
from constructs import Construct
from constructs import Construct
from . import AWS_AMI, AWS_REGION, AWS_KEYPAIR


class ExampleSystemStack(Stack):
    def __init__(self, scope: Construct, id: str, props, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)

        # Autoscale group
        autoscale_group = autoscaling.AutoScalingGroup(
            self,
            "example-autoscale-group",
            vpc=props["vpc"],
            instance_type=ec2.InstanceType.of(ec2.InstanceClass.MEMORY5, ec2.InstanceSize.XLARGE),
            machine_image=ec2.MachineImage.generic_linux({AWS_REGION: AWS_AMI}),
            key_name="{keypair}".format(keypair=AWS_KEYPAIR),
            vpc_subnets=ec2.SubnetSelection(subnet_type=SubnetType.PRIVATE_WITH_NAT),
        )

        asg_dict = {
            "asg_name": autoscale_group.auto_scaling_group_name,
        }
        props.update(asg_dict)

        # Creates a security group for the autoscale group
        sg_example_asg = ec2.SecurityGroup(self, id="sg_example_asg", vpc=props["vpc"], security_group_name="sg_example_asg")

        # Creates a security group for the the autoscale group application load balancer
        sg_alb = ec2.SecurityGroup(self, id="sg_alb", vpc=props["vpc"], security_group_name="sg_example_asg_alb")

        # Allow all egress from ALB to ASG instance
        sg_alb.connections.allow_to(sg_example_asg, ec2.Port.all_tcp(), "To ASG")

        # Allows connections from ALB to ASG instances access port 80
        # where application UI listens
        sg_example_asg.connections.allow_from(sg_alb, ec2.Port.tcp(80), "ALB Ingress")

        # Allow SSH connection between manager and auto-scale group
        sg_example_asg.connections.allow_from(sg_example_asg, ec2.Port.tcp(22), "SSH Ingress")

        sg_example_asg.add_ingress_rule(
            peer=ec2.Peer.any_ipv4(),
            connection=ec2.Port.tcp(22),
            description="ssh",
        )

        # Adds the security group 'sg_example_asg' to the autoscaling group
        autoscale_group.add_security_group(sg_example_asg)

        # Creates an application load balance
        example_alb = elbv2.ApplicationLoadBalancer(
            self,
            "ExampleALB",
            vpc=props["vpc"],
            security_group=sg_alb,
            internet_facing=True,
        )

        # Adds the autoscaling group's instance to be registered as targets on port 80
        example_listener = example_alb.add_listener("Listener", port=80)
        example_listener.add_targets("Target", port=80, protocol=elbv2.ApplicationProtocol.HTTP, targets=[autoscale_group])

        # This creates a "0.0.0.0/0" rule to allow every one to access the ALB
        example_listener.connections.allow_default_port_from_any_ipv4("Open to the world")

Crear un tema de SNS

Ahora que tenemos el grupo de escalado automático, podemos crear el tema de SNS y hacer que escuche los eventos del ciclo de vida del grupo de escalado automático.

        topic_id = "system-autoscale-topic"
        topic = sns.Topic(self, topic_id, display_name="System autoscale topic", topic_name=topic_id)

        # Create role
        asg_topic_pub_role_name = "system-autoscale-topic-publisher-role-"
        asg_topic_pub_role = iam.Role(
            scope=self,
            id=asg_topic_pub_role_name,
            assumed_by=iam.ServicePrincipal("autoscaling.amazonaws.com"),
            role_name=asg_topic_pub_role_name,
            inline_policies=[
                iam.PolicyDocument(
                    statements=[
                        iam.PolicyStatement(resources=[topic.topic_arn], actions=["sns:Publish"]),
                    ]
                )
            ],
        )

        topic_hook = hooktargets.TopicHook(topic)
        asg_lifecyclehook_name = "system-autoscale-lifecycle-hook"
        autoscale_group.add_lifecycle_hook(
            id=asg_lifecyclehook_name,
            lifecycle_transition=autoscaling.LifecycleTransition.INSTANCE_LAUNCHING,
            default_result=autoscaling.DefaultResult.ABANDON,
            heartbeat_timeout=Duration.minutes(15),
            lifecycle_hook_name=asg_lifecyclehook_name,
            notification_target=topic_hook,
            role=asg_topic_pub_role,
        )

Registre una función Lambda y suscríbase al tema SNS

Con el tema SNS en su lugar, podemos definir una función Lambda que escuchará las notificaciones emitidas por los eventos del ciclo de vida del grupo de escalado automático.

        # Create role
        lambda_role_name = "system-autoscale-topic-lamda-role"
        lambda_role = iam.Role(
            scope=self,
            id=lambda_role_name,
            assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"),
            role_name=lambda_role_name,
            inline_policies=[
                iam.PolicyDocument(
                    statements=[
                        iam.PolicyStatement(resources=["arn:aws:logs:*:*:*"], actions=["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]),
                        iam.PolicyStatement(resources=["*"], actions=["autoscaling:CompleteLifecycleAction"]),
                    ]
                )
            ],
            managed_policies=[
                iam.ManagedPolicy.from_aws_managed_policy_name("AWSLambdaExecute"),
                iam.ManagedPolicy.from_aws_managed_policy_name("service-role/AWSLambdaVPCAccessExecutionRole"),
                iam.ManagedPolicy.from_aws_managed_policy_name("service-role/AmazonSSMAutomationRole"),
            ],
        )

        lambda_sg_name = "system-autoscale-launching-lambda-sg"
        lambda_sg = ec2.SecurityGroup(self, lambda_sg_name, security_group_name=lambda_sg_name, vpc=props["vpc"], allow_all_outbound=True)

        # Defines an AWS Lambda resource
        lambda_name = "system-autoscale-launch-lambda"
        asg_launch = _lambda.Function(
            self,
            lambda_name,
            runtime=_lambda.Runtime.PYTHON_3_9,
            function_name=lambda_name,
            description="Lambda function deployed to handle launching Controller instances.",
            code=_lambda.Code.from_asset(
                "./scale_manager/lambda",
                bundling=BundlingOptions(
                    image=_lambda.Runtime.PYTHON_3_9.bundling_image,
                    command=["bash", "-c", "pip install -r requirements.txt -t /asset-output && cp -dR . /asset-output"],
                ),
            ),
            handler="asg_launching.handler",
            role=lambda_role,
            security_groups=[lambda_sg, sg_example_asg],
            timeout=Duration.minutes(10),
            vpc=props["vpc"],
        )

        topic.add_subscription(subscriptions.LambdaSubscription(asg_launch))

Código detrás de la función Lambda

También echemos un vistazo a cómo se ve el código de la función Lambda. En este caso, solo vamos a hacer una llamada simple usando la API de Systems Manager para escribir un archivo en el sistema de archivos, pero dependiendo de su caso de uso específico, puede hacer muchos cambios más complejos.

import boto3
import logging
import json
import time

if len(logging.getLogger().handlers) > 0:
    # The Lambda environment pre-configures a handler logging to stderr. If a handler is already configured,
    # `.basicConfig` does not execute. Thus we set the level directly.
    logging.getLogger().setLevel(logging.INFO)
else:
    logging.basicConfig(level=logging.INFO)

logger = logging.getLogger()


def get_notification_sns_msg(notification):
    message_dict = {}
    records = notification.get("Records", [])
    for record in records:
        if record.get("EventSource") != "aws:sns":
            continue
        sns_data = record.get("Sns", {})
        message = sns_data.get("Message", "{}")
        try:
            message_dict = json.loads(message)
        except:
            logger.error(f"Cannot parse Sns message. Details:{message}")
        if message_dict:
            break
    return message_dict


def handler(notification, context):
    logger.info(f"Request receieved. Details:{notification}")
    client = boto3.client("ssm")
    message_dict = get_notification_sns_msg(notification)
    instance_id = message_dict.get("EC2InstanceId")
    if not instance_id:
        return "Error processing notification."

    command_call = time.time_ns()
    temp_file = f"/tmp/run-command-{command_call}.txt"
    response = client.send_command(
        InstanceIds=[instance_id],
        DocumentName="AWS-RunShellScript",
        Parameters={
            "commands": [
                f"echo 'This file is from a run command via SSM Agent for instance {instance_id}.' > {temp_file}",
                f"if [ -e {temp_file} ]; then echo -n True; else echo -n False; fi",
            ]
        },
    )
    command_id = response["Command"]["CommandId"]
    tries = 0
    output = "False"
    while tries < 20:
        tries = tries + 1
        try:
            time.sleep(2)
            result = client.get_command_invocation(
                CommandId=command_id,
                InstanceId=instance_id,
            )
            if result["Status"] == "InProgress":
                continue
            output = result["StandardOutputContent"]
            break
        except client.exceptions.InvocationDoesNotExist:
            continue

    as_client = boto3.client("autoscaling")
    response = as_client.complete_lifecycle_action(
        LifecycleHookName=message_dict.get("LifecycleHookName"),
        LifecycleActionToken=message_dict.get("LifecycleActionToken"),
        AutoScalingGroupName=message_dict.get("AutoScalingGroupName"),
        LifecycleActionResult="CONTINUE",
        InstanceId=instance_id,
    )

    return output == "True"

Sinopsis

Con los enfoques descritos anteriormente, tiene un poderoso conjunto de herramientas que le permiten crear una implementación dinámica y reactiva.

Puede aprovechar los beneficios de más capacidades nativas de la nube con un peso más ligero y menos reingeniería para las soluciones existentes.

Hemos capturado todos estos pasos con infraestructura como código, aprovechando el poder del CDK y simplemente superponiendo algunos servicios de AWS adicionales que abren una variedad de capacidades.


Si le interesa, puede echar un vistazo a algunos de los otros artículos que he escrito recientemente sobre Laravel:

Recent Post