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.
Usando Recursos Solo Cuando Los Necesitas
La mayoría de equipos tecnológicos hoy en día son lo suficientemente sabios para evitar probar características y arreglar bugs en producción pero solo si hay posibilidad de que la gerencia los cache. En su lugar, dividimos nuestros despliegues en entornos de producción, staging y desarrollo, usando un flujo de trabajo riguroso para asegurar que solo código maduro llegue a nuestros servidores de producción.
Esto, por supuesto, puede volverse costoso a medida que el tamaño del sistema y el número de instancias/recursos para cada entorno sigue creciendo. Usualmente empleamos algunas técnicas para limitar los costos, como reservar el uso de instancias poderosas solo para entornos de producción, y ejecutar cargas de staging/desarrollo en sus primos más baratos y menos poderosos.
Estas instancias ejecutándose aún cuestan dinero, así que queremos asegurar que están ejecutándose solo cuando la gente las usará, lo que significa (ojalá) de lunes a viernes durante horas laborales. Vamos a diseñar una pequeña solución para resolver este problema y ahorrar un montón de dinero y, mientras estamos en eso, tomar nota para sacar a relucir estos números en la próxima conversación de negociación salarial.
Diseñando un Mecanismo Automatizado para Iniciar y Detener Instancias
Las instancias de Staging y Desarrollo, en muchos lugares, se usan casi exclusivamente durante horas laborales, de 9:00 a 17:00.
Podemos asegurar que las máquinas estén ejecutándose solo cuando se necesiten con solo 10 transiciones de estado por semana:
- Iniciar las instancias el lunes a las 9:00
- Detener las instancias el lunes a las 17:00
- Iniciar las instancias el martes a las 9:00
- Detener las instancias el martes a las 17:00
- Iniciar las instancias el miércoles a las 9:00
- Detener las instancias el miércoles a las 17:00
- Iniciar las instancias el jueves a las 9:00
- Detener las instancias el jueves a las 17:00
- Iniciar las instancias el viernes a las 9:00
- Detener las instancias el viernes a las 17:00
Porque las acciones son periódicas, podemos lograr esto usando eventos EventBridge Scheduler, funciones Lambda, y el AWS SDK. La solución más simple es usar dos reglas de scheduler (una para iniciar, una para detener) y asociar cada una con una Función Lambda (una para iniciar, una para detener).
Vamos a hacer algo ligeramente más interesante:
- Mantendremos las dos reglas de scheduler, pero agregaremos un pequeño pedazo de datos a los eventos que emiten para controlar el comportamiento de la función Lambda. Configuraremos una dead letter queue para habilitar reintentos de eventos en caso de que fallen.
- La idea principal es aprender cómo usar datos de eventos proporcionados por cada evento para disparar diferentes comportamientos en la misma función Lambda.
- Nuestra función Lambda recibirá los eventos, descubrirá qué tipo de acción debe tomar, y luego iniciará o detendrá cada instancia EC2 de staging y desarrollo en nuestro sistema.
Si logramos esto, los desarrolladores encontrarán sus instancias de staging y desarrollo listas y esperando cuando lleguen cada mañana, mientras ahorramos ~76% en costos de ejecución apagando todo fuera de horas laborales.
Oh, una cosa rápida antes de continuar.
¿Cómo Sabrá la Función Lambda Cuáles Instancias son de Staging y Desarrollo, y Cuáles son de Producción?
Equivocarse en esto puede ser realmente, realmente costoso; no queremos detener instancias de producción y dejar a los clientes sin acceso al sistema.
Entonces, ¿cómo lo sabe la Lambda? Fácil, somos increíblemente minuciosos y previsores (¿es esta una palabra real?), y hemos hecho un gran trabajo etiquetando cada recurso en el sistema para que sepamos a qué entorno pertenecen.
Cada instancia EC2 tiene una etiqueta Environment
que puede tener el valor de development
, staging
, o production
. La función Lambda usará esta información para decidir qué instancias debe atacar.
Dos Notas del Mundo Real
- En un entorno profesional, es poco probable que tengas instancias de producción, staging y desarrollo ejecutándose una junto a la otra. Una configuración apropiada involucra usar AWS Organizations y crear cuentas dedicadas a recursos para cada entorno, para que puedas aislarlos y hacer mejor seguimiento de su uso y costo.
- AWS ya tiene una solución bien documentada para este problema. Puedes encontrar el enfoque oficial para programación de instancias aquí. Es probablemente más complejo de lo que usualmente necesitas, pero vale la pena estudiar cómo logran esto (y bastante más) con componentes simples.
Creando nuestro Proyecto
El mismo procedimiento estándar de siempre: Primero, solo necesitamos crear una carpeta vacía (nombré la mía AutoEC2InstanceStartStop
) y ejecutar cdk init app --language typescript
dentro de ella.
Este siguiente cambio es totalmente 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 así:
{
"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.
Creando la Función Lambda
La función Lambda es la pieza central de la solución, así que empecemos con ella. Crea una carpeta llamada lambdas
en el nivel raíz del proyecto (junto a bin
y lib
), y dentro de ella crea instance_start_stop.rb
:
require 'aws-sdk-ec2'
EC2 = Aws::EC2::Resource.new
def handler(event:, context:)
action = event['action']
unless ['start', 'stop'].include?(action)
raise "Invalid action: #{action}"
end
tag_filter = { name: 'tag:Environment', values: ["development", "staging"] }
case action
when 'start'
EC2.instances(filters: [tag_filter, { name: 'instance-state-name', values: ['stopped'] }])
.each(&:start)
when 'stop'
EC2.instances(filters: [tag_filter, { name: 'instance-state-name', values: ['running'] }])
.each(&:stop)
end
end
Vamos paso a paso:
- La función importa su única dependencia,
aws-sdk-ec2
, e instancia un cliente EC2 tipo recurso. No todos los servicios soportan recursos, pero hacen que interactuar con la API de AWS sea muy fácil, así que úsalos cuando tengas la oportunidad. - El handler de nuestra función recupera el valor
action
del evento que recibe, luego verifica si es uno de los dos valores soportados:start
ostop
. Si por alguna razón la acción del evento no es ninguno de esos dos valores, levantará una excepción. - Creamos un filtro que usaremos más tarde en el proceso de recuperación—apunta a la etiqueta
Environment
, e intenta recuperar todas las instancias donde el valor de esta etiqueta es ya seadevelopment
ostaging
. - Después, basándose en el valor de action (start o stop), realizaremos una llamada a la función
instances
con el filtro que escribimos antes, y un nuevo filtro que apunta al estado de la instancia. Cuando queremos iniciar instancias, solo nos importan aquellas que están en estadostopped
, y cuando queremos detener las instancias, solo nos importan las que están en estadorunning
. No queremos desperdiciar llamadas API tratando de apagar una máquina que ya está offline, o iniciar una máquina que ya está en estado ejecutándose. - Después de recuperar la lista de máquinas, iteramos llamando ya sea el método
start
o el métodostop
—esto resulta en una llamada al API que dispara la transición de estado correcta en cada máquina.
Es una función muy simple, pero hace el trabajo lo suficientemente bien. Ahora es momento de construir nuestro stack.
Construyendo el 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_events as events} from 'aws-cdk-lib';
import {aws_events_targets as event_targets} from 'aws-cdk-lib';
import {aws_lambda as lambda} from 'aws-cdk-lib';
import {aws_sqs as sqs} from 'aws-cdk-lib';
import {aws_iam as iam} from 'aws-cdk-lib';
Los primeros recursos que necesitamos crear son la cola que usaremos como DLQ, y nuestra función Lambda:
const dlq = new sqs.Queue(this, 'deadLetterQueue');
const startStopFunction = new lambda.Function(this, 'startStopFunction', {
runtime: lambda.Runtime.RUBY_3_4,
code: lambda.Code.fromAsset('lambdas'),
handler: 'instance_start_stop.handler',
description: 'Starts and stops an EC2 instance',
});
Ahora, solo necesitamos definir los dos horarios y habremos casi terminado. Primero creémoslos y luego discutamos cómo logran nuestro objetivo:
const startSchedule = new events.Rule(this, 'startSchedule', {
schedule: events.Schedule.cron({weekDay: 'MON-FRI', hour: '7', minute: '0'}),
targets: [new event_targets.LambdaFunction(startStopFunction, {
deadLetterQueue: dlq,
maxEventAge: cdk.Duration.minutes(5),
retryAttempts: 2,
event: events.RuleTargetInput.fromObject({action: 'start'}),
})]
});
const stopSchedule = new events.Rule(this, 'stopSchedule', {
schedule: events.Schedule.cron({weekDay: 'MON-FRI', hour: '15', minute: '0'}),
targets: [new event_targets.LambdaFunction(startStopFunction, {
deadLetterQueue: dlq,
maxEventAge: cdk.Duration.minutes(5),
retryAttempts: 2,
event: events.RuleTargetInput.fromObject({action: 'stop'}),
})]
});
- Cada evento recibe un prop
schedule
para especificar cuándo deben ejecutarse. En un laboratorio anterior usamos una regla para emitir un evento cada 30 minutos, así que pasamos una reglarate
como esta:schedule: events.Schedule.rate(cdk.Duration.minutes(30))
. Esta vez necesitamos ejecutar acciones a una hora determinada cada día, así que en su lugar usamos una reglacron
que establece que las ejecutaremos de lunes a viernes a una hora particular. - Ambas funciones apuntan a la misma Lambda, y usan la misma configuración de reintento—nada interesante en esta parte.
- ¡Excepto por la propiedad
event
! Esta parte es muy importante, porque es lo que define la acción que la función Lambda va a tomar. Puedes configurar los contenidos del evento emitido por la regla de varias maneras. En nuestro caso solo usamos el métodofromObject
pasando un diccionario con los valores que necesitamos para cada caso. Después de esto,startSchedule
emitirá eventos con los contenidos{action: 'start'}
ystopSchedule
emitirá uno con los contenidos{action: 'stop'}
. - Nota que las horas están en UTC. Porque vivo en una zona con hora CEST, necesité restar 2 horas de la hora donde quería que los eventos fueran emitidos, así 9:00 se convierte en 7:00 (UTC) y 17:00 se convierte en 15:00 (UTC).
Agregando Permisos y Creando el Stack
Nuestra Lambda necesita algunos permisos para poder operar apropiadamente, ya que algunos de los métodos que ejecutamos llaman a la API de AWS por debajo:
- startStopFunction debe poder llamar ec2:DescribeInstances
- startStopFunction debe poder llamar ec2:StartInstances
- startStopFunction debe poder llamar ec2:StopInstances
En CDK, estos permisos pueden definirse como:
const ec2ManagementPolicy = new iam.PolicyStatement({
actions: ['ec2:DescribeInstances', 'ec2:StartInstances', 'ec2:StopInstances'],
resources: ['*'],
});
startStopFunction.addToRolePolicy(ec2ManagementPolicy);
Y hemos terminado. Ahora podemos ir a main.ts y crear nuestro stack:
new AutoEc2InstanceStartStopStack(app, 'AutoEc2InstanceStartStopStack', {});
Cuando te sientas listo, ve adelante y despliega la solución ejecutando cdk deploy
.
Probando la Solución
El siguiente paso es crear manualmente algunas instancias EC2 y agregar etiquetas Environment para development, staging, y production.
Si te sientes extra perezoso, no te preocupes—te tengo cubierto. Fui adelante y creé otro stack para agregar instancias etiquetadas, solo crea otro archivo en lib
, nombré el mío ec2_test_instances-stack.ts
:
import * as cdk from 'aws-cdk-lib';
import {Construct} from 'constructs';
import {aws_ec2 as ec2} from 'aws-cdk-lib';
interface Ec2TestInstancesStackProps extends cdk.StackProps {
devInstanceNumber: number;
stagingInstanceNumber: number;
prodInstanceNumber: number;
}
export class Ec2TestInstancesStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: Ec2TestInstancesStackProps) {
super(scope, id, props);
const defaultVpc = ec2.Vpc.fromLookup(this, 'DefaultVpc', {isDefault: true});
this.createTestInstances(props.devInstanceNumber, 'development', defaultVpc);
this.createTestInstances(props.stagingInstanceNumber, 'staging', defaultVpc);
this.createTestInstances(props.prodInstanceNumber, 'production', defaultVpc);
}
createTestInstances(numberOfInstances: number, environment: string, vpc: ec2.IVpc) {
for (let index = 0; index < numberOfInstances; index++) {
const instance = new ec2.Instance(this, `${environment}-${index + 1}`, {
instanceType: ec2.InstanceType.of(ec2.InstanceClass.T2, ec2.InstanceSize.MICRO),
machineImage: ec2.MachineImage.latestAmazonLinux2023(),
vpc: vpc,
});
cdk.Tags.of(instance).add('Environment', environment);
}
}
}
Luego, crea este stack con 2 instancias de cada tipo:
new Ec2TestInstancesStack(app, 'testInstances', {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
devInstanceNumber: 2,
stagingInstanceNumber: 2,
prodInstanceNumber: 2,
});
Y ahora, bueno, ¡solo esperamos a verlo en acción!
Si no quieres esperar mucho tiempo para probar la solución, puedes editar tu stack y programar los eventos a una hora más conveniente, con una ventana pequeña (10 minutos) entre la emisión de ambos eventos.
En el panel de administración puedes verificar los tiempos de emisión para los próximos 10 eventos para cada una de nuestras reglas:
¡Eso es todo! Una vez que hemos verificado que la solución funciona como se esperaba, podemos darnos palmaditas en la espalda y sentir la satisfacción de haber completado otro experimento en la nube. Solo recuerda escribir la cantidad de dinero que estás ahorrando cada mes y sacarlo a relucir en la próxima conversación salarial con tu manager—ojalá y te ayude a negociar un mejor salario.
¡IMPORTANTE! Siempre recuerda eliminar tu stack ejecutando cdk destroy
o eliminándolo manualmente en la consola.
Mejoras y Experimentos
- El AWS SDK soporta operaciones batch para iniciar y detener múltiples instancias en una sola llamada API. En lugar de hacer una llamada API para hacer transición del estado en cada máquina, modifica la función Lambda para usar una de estas llamadas batch.
- Supongamos que configuraste health checks para algunas de tus instancias y configuraste auto-scaling. ¿Qué pasará cuando esta solución empiece a apagar máquinas? ¿Cuál sería la manera más fácil de arreglar este problema?
- Nos enfocamos en instancias EC2, pero puedes tener servicios desplegados en ECS. ¿Cómo implementarías un comportamiento similar dirigido a contenedores ejecutándose en ECS? ¿Importaría si son despliegues EC2 o despliegues Fargate?
- Nuestra función Lambda funciona bien ahora, pero podríamos querer desarrollar los comportamientos Start y Stop independientemente en el futuro. Re-arquitectura la solución para usar dos funciones Lambda en lugar de una, y actualiza los eventos emitidos por el Scheduler para tomar en cuenta este cambio.
- La función Lambda no toma varios estados en consideración, solo
stopped
yrunning
. ¿Puede esto causar un problema en escenarios cuando las instancias están pasando por una transición de estado al mismo tiempo que nuestra Lambda está siendo ejecutada? ¿Qué tan malo puede ser el problema?
Esta solución demuestra cómo unos pocos componentes simples de AWS pueden juntarse para resolver un problema real de negocio que afecta a la mayoría de equipos de desarrollo.
La belleza de este enfoque es su simplicidad—solo estamos usando una pequeña función con dos reglas de scheduler para lograr nuestros objetivos. EventBridge maneja la programación, Lambda hace el trabajo pesado, y el etiquetado apropiado asegura que no apaguemos accidentalmente nada importante.
Es el tipo de solución que te hace ver como un héroe frente a tu equipo de finanzas con mínimo esfuerzo, lo cual no está mal para solo unas pocas líneas de código.
¡Espero que esto te sea útil!