Создайте Python Lambda, чтобы сэкономить на счетах за облако с помощью Terraform

Jan 02 2023
Чтобы правильно использовать облако, вы должны говорить на языке облака. А язык облака — это код.

Чтобы правильно использовать облако, вы должны говорить на языке облака. А язык облака — это код.

Если мы хотим автоматизировать управление нашими ресурсами в масштабе, нам нужно использовать код, такой как Terraform, чтобы упростить создание ресурсов и управление ими. Мы должны отдавать приоритет использованию бессерверных систем, таких как Lambdas, чтобы снизить затраты. И нам нужно иметь возможность использовать языки программирования, такие как Python, для динамического изменения запущенных ресурсов.

В этом проекте мы увидим, как мы можем использовать Terraform, Lambdas и Python для создания повторяемой задачи, которая защитит нас от ненужных расходов на наши облачные счета. Мы будем использовать Terraform для создания Lambda, запускающего скрипт Python, который будет останавливать все экземпляры с определенным тегом, и запланируем ежедневное выполнение этой Lambda.

Лучше всего то, что это будет сделано полностью в коде. Итак, давайте приступим к делу.

Предпосылки

  • Промежуточное понимание Terraform — я объясню свои шаблоны, но вы должны знать, как работают ресурсы и модули, чтобы получить максимальную отдачу от этого пошагового руководства.
  • Начальное понимание Python — мы будем использовать условные операторы, циклы for и списки в лямбда-скрипте.
  • Некоторое знание Boto3 — я буду использовать AWS SDK для Python (Boto3) во всем лямбда-скрипте.

Установка состоит из нескольких файлов и папок:

  • providers.tf: этот файл определяет поставщика AWS, который требуется для того, чтобы Terraform мог создавать ресурсы AWS и управлять ими.
  • main.tf: этот файл иллюстрирует ресурсы для проекта, включая модуль для управления разрешениями IAM и еще один модуль для создания функции Lambda.
  • iam: этот модуль определяет ресурсы IAM, необходимые для функции Lambda, включая роль IAM с разрешениями на запуск и остановку экземпляров EC2 и политику IAM, которую можно прикрепить к роли.
  • lambda: этот модуль определяет функцию Lambda, включая ее имя, код, а также роль IAM и вложение политики, определенные в модуле iam.
  • python: файл, содержащий сценарий, который будет использоваться модулем lambda.

Провайдеры.tf

Используйте us-east-1 для этого проекта, но не стесняйтесь менять этот регион в зависимости от того, где вы находитесь.

# -- 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
}

В этом модуле мы предоставим Lambda разрешения, необходимые для остановки и запуска использования EC2. Для этого мы должны позволить Lambda взять на себя роль IAM с соответствующими разрешениями.

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
}

Политика, позволяющая Lambda взять на себя роль IAM:

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

Политика, позволяющая роли IAM находить и останавливать инстансы 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:*"
        }
    ]
}

Эти значения будут переданы в модуль Lambda через переменные в main.tf в корневом модуле.

#-- 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
}

Этот модуль использует ранее созданные политики IAM и сценарий Python, который мы рассмотрим после этого, чтобы остановить все экземпляры EC2 с пометкой Dev. Мы также будем использовать CloudWatch для планирования ежедневного запуска Lambda.

main.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]
}

Объявите переменные, которые будут переданы из корневого модуля в лямбда-модуль:

# -- lambda_name/variables.tf --

variable "filename" {
  type = string
}

variable "lambda_name" {
  type = string
}

variable "lambda_role_arn" {
  type = string
}

variable "role_policy_attachment" {}

Если вы помните, в корневом модуле у нас был приведенный ниже код для создания zip-файла, который мы позже использовали в качестве значения filenameв нашем лямбда-модуле:

#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")

Время подать заявку

Убедитесь, что вы создали все указанные ниже папки и файлы в каталоге:

После заполнения каталога пришло время применить ваши изменения на терминале. Вы должны знать эти команды, если раньше использовали Terraform, а если нет, то вот они:


#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

Для этого Lambda перейдите в меню Lambda в консоли AWS. Вы должны увидеть функцию с ее именем:

Нажмите на Lambda и нажмите Test :

Сохраните все значения по умолчанию и используйте любое имя события , какое хотите:

Когда вы снова нажмете Test , вы увидите результаты Execution . В разделе «Журналы функций» вы должны увидеть следующее:

Поскольку у нас нет Devзапущенных экземпляров, это ожидаемый результат.

Чтобы протестировать работающие Devэкземпляры, вы можете либо вручную запустить экземпляр с Environment Devтегами, либо использовать приведенный ниже скрипт Terraform для быстрого создания нескольких тестовых экземпляров:

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"
  }
}

Теперь вернитесь в Lambda и снова запустите Test .

На этот раз в результатах выполнения мы увидим, что стоп-лист содержит три идентификатора экземпляра, помеченные тегом Dev Environment:

И если мы посмотрим на консоль EC2, экземпляры будут остановлены:

Подведение итогов

Как вы видели в этой демонстрации, используя Lambda, Python и Terraform вместе, вы можете создавать автоматизированные процессы, которые реагируют на события, выполняют код, написанный на Python, и управляют ресурсами инфраструктуры надежным и экономичным способом. Мы выполнили относительно простую задачу остановки экземпляров по ежедневному расписанию. Тем не менее, вы можете себе представить, как эти три инструмента можно использовать вместе для таких задач, как обработка данных, резервное копирование и мониторинг.

Если вы хотите увидеть код, который я использовал в курсе, репозиторий можно найти здесь:https://github.com/nickcmiller/tf-stop-instances