Terraform で Python Lambda を作成してクラウド料金を節約する

Jan 02 2023
クラウドを上手に使うには、クラウドの言語を話す必要があります。そしてクラウドの言語はコードです。

クラウドを上手に使うには、クラウドの言語を話す必要があります。そしてクラウドの言語はコードです。

リソースの管理を大規模に自動化したい場合は、Terraform などのコードを使用して、リソースの作成と管理を簡素化する必要があります。コストを低く抑えるためには、Lambda などのサーバーレスの使用を優先する必要があります。また、実行中のリソースを動的に変更するには、Python などのプログラミング言語を使用できる必要があります。

このプロジェクトでは、Terraform、Lambdas、Python を使用して、クラウド料金の不必要な料金から保護する反復可能なタスクを作成する方法を見ていきます。Terraform を使用して、特定のタグを持つすべてのインスタンスを停止する Python スクリプトを実行する Lambda を作成し、その Lambda が毎日実行されるようにスケジュールします。

何よりも、これは完全にコード内で行われます。それでは早速始めましょう。

前提条件

  • Terraform の中級理解— テンプレートについて説明しますが、このウォークスルーを最大限に活用するには、リソースとモジュールがどのように機能するかを知っておく必要があります。
  • Python の初心者の理解— Lambda スクリプトで条件文、For ループ、リストを使用します。
  • Boto3 に関するある程度の知識— Lambda スクリプト全体で AWS SDK for Python ( Boto3) を使用します。

セットアップは、いくつかのファイルとフォルダーで構成されます。

  • providers.tf: このファイルは、Terraform が AWS リソースを作成および管理できるようにするために必要な AWS プロバイダーを定義します。
  • main.tf: このファイルには、IAM 権限を管理するモジュールや Lambda 関数を作成する別のモジュールなど、プロジェクトのリソースが示されています。
  • iam: このモジュールは、EC2 インスタンスを開始および停止する権限を持つ IAM ロールやロールにアタッチできる IAM ポリシーなど、Lambda 関数に必要な IAM リソースを定義します。
  • lambda: このモジュールは、名前、コード、モジュールで定義されている IAM ロールとポリシーアタッチメントを含む Lambda 関数を定義します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
}

このモジュールでは、EC2 の使用を停止および開始するために必要なアクセス許可を Lambda に与えます。これを行うには、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:*"
        }
    ]
}

これらの値は、ルート モジュールのmain.tf内の変数を介して Lambda モジュールに渡されます。

#-- 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 モジュールに渡される変数を宣言します。

# -- lambda_name/variables.tf --

variable "filename" {
  type = string
}

variable "lambda_name" {
  type = string
}

variable "lambda_role_arn" {
  type = string
}

variable "role_policy_attachment" {}

filename覚えていると思いますが、ルート モジュールには、後でLambda モジュールの値として使用する zip ファイルを作成するための以下のコードがありました。

#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 にアクセスするには、AWS コンソールの Lambda メニューに移動します。次の名前の関数が表示されるはずです。

Lambda をクリックして、「Test」をクリックします。

すべてのデフォルトを保持し、イベント名には任意のものを使用します。

もう一度「テスト」をクリックすると、実行結果が表示されます。[関数ログ]セクションには、次のように表示されるはずです。

実行中のインスタンスがないため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 に戻り、テストを再度実行します。

今回の実行結果では、停止リストに次のタグが付けられた 3 つのインスタンス ID が含まれていることがわかりますDev Environment

EC2 コンソールを見ると、インスタンスが停止しています。

まとめ

このデモで見てきたように、Lambda、Python、Terraform を組み合わせて使用​​すると、イベントに応答する自動プロセスを作成し、Python で記述されたコードを実行し、信頼性が高くコスト効率の高い方法でインフラストラクチャ リソースを管理できます。私たちは、毎日のスケジュールに従ってインスタンスを停止するという比較的単純なタスクを実行しました。それでも、これら 3 つのツールをデータ処理、バックアップ、監視などのタスクにどのように組み合わせて使用​​できるかは想像できるでしょう。

コースで使用したコードを確認したい場合は、リポジトリがここにあります。https://github.com/nickcmiller/tf-stop-instances