Crea un Lambda Python per risparmiare sulla bolletta del cloud con Terraform

Jan 02 2023
Per usare bene il cloud, devi parlare il linguaggio del cloud. E il linguaggio del cloud è il codice.

Per usare bene il cloud, devi parlare il linguaggio del cloud. E il linguaggio del cloud è il codice.

Se vogliamo automatizzare la gestione delle nostre risorse su larga scala, dobbiamo utilizzare codice, come Terraform, per semplificare la creazione e la gestione delle risorse. Dobbiamo dare la priorità all'utilizzo del serverless, come Lambda, per mantenere bassi i costi. E dobbiamo essere in grado di usare linguaggi di programmazione, come Python, per alterare dinamicamente le risorse in esecuzione.

In questo progetto vedremo come utilizzare Terraform, Lambdas e Python per creare un'attività ripetibile che ci protegga da addebiti non necessari sulle bollette del cloud. Utilizzeremo Terraform per creare un Lambda che esegue uno script Python che arresterà tutte le istanze con un particolare tag e programmeremo l'esecuzione giornaliera di tale Lambda.

Soprattutto, questo sarà fatto interamente in codice. Quindi arriviamo a questo.

Prerequisiti

  • Comprensione intermedia di Terraform : spiegherò i miei modelli, ma dovresti sapere come funzionano le risorse e i moduli per ottenere il massimo da questa procedura dettagliata.
  • Comprensione per principianti di Python : utilizzeremo condizionali, cicli For ed elenchi nello script Lambda.
  • Una certa conoscenza di Boto3 : utilizzerò l'SDK AWS per Python (Boto3) in tutto lo script Lambda.

L'installazione è composta da diversi file e cartelle:

  • providers.tf: questo file definisce il provider AWS, necessario affinché Terraform sia in grado di creare e gestire le risorse AWS.
  • main.tf: Questo file illustra le risorse per il progetto, incluso un modulo per la gestione delle autorizzazioni IAM e un altro modulo per la creazione della funzione Lambda.
  • iam: questo modulo definisce le risorse IAM richieste per la funzione Lambda, incluso un ruolo IAM con autorizzazioni per avviare e arrestare le istanze EC2 e una policy IAM che può essere collegata al ruolo.
  • lambda: questo modulo definisce la funzione Lambda, inclusi il nome, il codice, il ruolo IAM e l'allegato della policy definiti nel iammodulo.
  • python: un file che contiene lo script che verrà utilizzato dal lambdaModulo.

Fornitori.tf

Usando us-east-1 per questo progetto, ma sentiti libero di cambiare quella regione a seconda di dove ti trovi.

# -- root/variables.tf -- 

#Declare the AWS provider
terraform {
  required_providers {
    aws = {
      source = "hashicorp/aws"
    }
  }
}
# Configure the AWS Provider
provider "aws" {
  region = "us-east-1"
}

# -- root/main.tf --

#Defines variable I wll use to name the Lambda I'm creating
locals {
  lambda_name = "stop-dev-instances"
}

#Module containing IAM permissions used by Lambda
module "iam" {
  source = "./iam"
}

#Defines a data resource of type "archive_file" named "zip_the_python_code". 
data "archive_file" "zip_the_python_code" {
  type = "zip"
  # Creates a zip archive file by combining the contents of the "python" directory and saving it to the specified output path
  source_dir = "${path.module}/python/"
  # The output path for the zip file is being set to a file 
  # Named after the value of the "lambda_name" local variable in the "python" directory
  output_path = "${path.module}/python/${local.lambda_name}.zip"
}


#Module creating the actual Lambda
module "lambda" {
  source      = "./lambda"
  lambda_name = local.lambda_name
  #The filename of the zip file containing the code for the lambda function
  filename = "${path.module}/python/${local.lambda_name}.zip"
  #The IAM role that the lambda function should assume
  lambda_role_arn        = module.iam.lambda_role_arn
  #The IAM policy attached to that role
  role_policy_attachment = module.iam.role_policy_attachment
}

In questo modulo, forniremo a Lambda le autorizzazioni di cui ha bisogno per arrestare e avviare gli usi di EC2. Per fare ciò, dobbiamo abilitare Lambda ad assumere un ruolo IAM con le autorizzazioni appropriate.

iam/main.tf

# -- iam/main.tf -- 

#Policy for Lambda to assume IAM role
resource "aws_iam_role" "lambda_role" {
  name = "assume-lambda-role"
  #References a file from the iam directory that has the Lambda's assume role IAM policy
  assume_role_policy = file("${path.module}/iam_role.txt")
}

#Policy for the IAM role to use
resource "aws_iam_policy" "iam_policy_for_lambda_role" {
  name        = "aws-policy-for-assume_role"
  description = "AWS IAM Policy for managing assume-lambda-role"
  #References a file from the iam directory that has the IAM policy for the role assumed
  policy = file("${path.module}/iam_policy.txt")
}

#Attach the IAM policy to the role
resource "aws_iam_role_policy_attachment" "role_policy_attachment" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = aws_iam_policy.iam_policy_for_lambda_role.arn
}

Policy che consente a Lambda di assumere il ruolo IAM:

{
 "Version": "2012-10-17",
 "Statement": [
   {
     "Action": "sts:AssumeRole",
     "Principal": {
       "Service": "lambda.amazonaws.com"
     },
     "Effect": "Allow",
     "Sid": ""
   }
 ]
}

Policy che consente al ruolo IAM di trovare e arrestare le istanze EC2:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeInstances",
                "ec2:StopInstances"
            ],
            "Resource": "*"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": "logs:CreateLogGroup",
            "Resource": "arn:aws:logs:us-east-1:235447109042:*"
        },
        {
            "Sid": "VisualEditor2",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:us-east-1:235447109042:log-group:/aws/lambda/createEnvInstances:*"
        }
    ]
}

Questi valori verranno passati nel modulo Lambda tramite variabili nel main.tf nel modulo root.

#-- iam/outputs.tf --

output "lambda_role_arn" {
  value = aws_iam_role.lambda_role.arn
}

output "role_policy_attachment" {
  value = aws_iam_role_policy_attachment.role_policy_attachment
}

Questo modulo acquisisce le policy IAM create in precedenza e uno script Python, che esamineremo successivamente, per arrestare tutte le istanze EC2 etichettate Dev. Utilizzeremo anche CloudWatch per programmare l'esecuzione giornaliera di Lambda.

principale.tf

# -- lambda/main.tf --

#Create Lambda function that implements Python code
#This function will stop the instances labeled Dev
resource "aws_lambda_function" "lambda_function" {
  #Uses the Python file that is zipped in main.tf
  filename      = var.filename
  function_name = "stop-Dev-instances"
  #Attach IAM role to Lambda
  role          = var.lambda_role_arn
  handler       = "lambda_function.lambda_handler"
  runtime       = "python3.8"
  timeout       = 60
  #Wait until IAM Policy is attached to IAM role before creating
  depends_on    = [var.role_policy_attachment]
}

# Create the daily stop schedule
resource "aws_cloudwatch_event_rule" "every_day" {
  name                = "daily"
  schedule_expression = "rate(1 day)"
}

# Allow CloudWatch to invoke stop_dev_lambda Function
resource "aws_lambda_permission" "allow_cloudwatch_to_invoke" {
  function_name = aws_lambda_function.lambda_function.function_name
  statement_id  = "CloudWatchInvoke"
  action        = "lambda:InvokeFunction"
  #Uses the daily CloudWatch stop schedule we just created
  source_arn = aws_cloudwatch_event_rule.every_day.arn
  principal  = "events.amazonaws.com"
}

# Set the stop_dev_lambda to perform when the every_day is triggered
resource "aws_cloudwatch_event_target" "invoke_lambda" {
  rule       = aws_cloudwatch_event_rule.every_day.name
  arn        = aws_lambda_function.lambda_function.arn
  depends_on = [aws_cloudwatch_event_rule.every_day, aws_lambda_function.lambda_function]
}

Dichiara le variabili che verranno passate dal modulo radice al modulo Lambda:

# -- lambda_name/variables.tf --

variable "filename" {
  type = string
}

variable "lambda_name" {
  type = string
}

variable "lambda_role_arn" {
  type = string
}

variable "role_policy_attachment" {}

Se ricordi, nel modulo radice, avevamo il codice seguente per creare un file zip che in seguito abbiamo utilizzato come filenamevalore nel nostro modulo Lambda:

#Defines a data resource of type "archive_file" named "zip_the_python_code". 
data "archive_file" "zip_the_python_code" {
  type = "zip"
  # Creates a zip archive file by combining the contents of the "python" directory and saving it to the specified output path
  source_dir = "${path.module}/python/"
  # The output path for the zip file is being set to a file 
  # Named after the value of the "lambda_name" local variable in the "python" directory
  output_path = "${path.module}/python/${local.lambda_name}.zip"
}

import json

def lambda_handler(event, context):
    import logging
    import boto3 
    
    
    #make logging executable in Lambda and locally
    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.
        # Reference: https://stackoverflow.com/questions/37703609/using-python-logging-with-aws-lambda
        logging.getLogger().setLevel(logging.INFO)
    else:
        logging.basicConfig(level=logging.INFO)
    
    #Set the boto3 client to modify us-east-1 resources
    #change this if your region isn't us-east-1
    ec2_client=boto3.client("ec2", region_name='us-east-1')
    
    #capture all the instance reservations in us-east-1
    list_instances=ec2_client.describe_instances()
    reservations=list_instances["Reservations"]
    
    #declare list that will collect ids of instances to stop
    stop_list=[]
    
    #iterate through instance reservations
    for r in reservations:
        instances=r["Instances"]
        #iterate through instances within the reservation
        for i in instances:
            instance_id = i['InstanceId']
            #determine whether instance is running AND has a Environment tagged as Dev
            #add matching instance_ids to stop_list
            if i['State']['Name'] == 'running':
                tags=i['Tags']
                to_stop=False
                for t in tags:
                    if t['Key']=='Environment' and t['Value']=='Dev':
                        to_stop=True
                if to_stop:
                    logging.info(f"{instance_id} is being stopped", )
                    stop_list.append(instance_id)
                else:
                    logging.info(f"{instance_id} is not tagged as Environment:Dev and will not be stopped")
            else:
                logging.info(f"{instance_id} is not running and will not be stopped")
    logging.info(f"Stop List: {stop_list}")
    
    #stop all instance_ids on the stop_list
    if(len(stop_list)>0):
        result=ec2_client.stop_instances(InstanceIds=stop_list)
        #log the outcome of running ec2_clinet.stop_instances
        logging.info(f"Result: {result}")
    else:
        #log that the list is empty if there are no Dev instances to stop
        logging.info("Stop List is empty. Nothing to stop")

È ora di candidarsi

Verifica di aver creato tutte le cartelle e i file seguenti nella directory:

Dopo aver completato la directory, è il momento di applicare le modifiche al terminale. Dovresti conoscere questi comandi se hai già utilizzato Terraform, ma in caso contrario, eccoli qui:


#make sure the directory has been initialized
terraform init

#validate that the configuration files are syntatically valid and internally consistent
terraform validate

#see what changes Terraform will make when you run your template
terraform plan

#deploy your infrastructure
terraform apply

A questo Lambda, vai al menu Lambda nella tua console AWS. Dovresti vedere una funzione con il suo nome:

Fare clic su Lambda e fare clic su Test :

Mantieni tutte le impostazioni predefinite e usa quello che vuoi per il nome dell'evento come vuoi:

Quando premi di nuovo Test , vedrai i risultati dell'esecuzione . Nella sezione Registri funzioni , dovresti vedere quanto segue:

Poiché non abbiamo Devistanze in esecuzione, questo è il risultato previsto.

Per testare Devle istanze in esecuzione, puoi avviare manualmente un'istanza con Environment Devi tag o utilizzare lo script Terraform di seguito per creare rapidamente alcune istanze di test:

terraform {
  required_providers {
    aws = {
      source = "hashicorp/aws"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

resource "aws_instance" "test" {
  count = 3
  ami           = "ami-0093a6022697a73aa"
  instance_type = "t2.micro"
  tags = {
      Name = "Dev-${count.index}"
      Environment = "Dev"
  }
}

Ora torna a Lambda ed esegui di nuovo Test .

Questa volta nei risultati dell'esecuzione, vedremo che l' Elenco Stop contiene i tre ID istanza contrassegnati con il Dev Environmenttag:

E se guardiamo alla console EC2, le istanze sono state arrestate:

Avvolgendo

Come hai visto in questa demo, utilizzando insieme Lambda, Python e Terraform, puoi creare processi automatizzati che rispondono agli eventi, eseguire codice scritto in Python e gestire le risorse dell'infrastruttura in modo affidabile ed economico. Abbiamo eseguito un'attività relativamente semplice di arresto delle istanze su base giornaliera. Tuttavia, puoi immaginare come questi tre strumenti potrebbero essere utilizzati insieme per attività come l'elaborazione dei dati, i backup e il monitoraggio.

Se vuoi vedere il codice che ho usato nel corso, il repository può essere trovato qui:https://github.com/nickcmiller/tf-stop-instances