APC 技術ブログ

株式会社エーピーコミュニケーションズの技術ブログです。

株式会社 エーピーコミュニケーションズの技術ブログです。

【AWS】Lambda上でTerraformを動かす関数を作ってみた

はじめに

こんにちは、株式会社エーピーコミュニケーションズ クラウド事業部の鈴木歩です。
Terraform で IaC を行いたいと思って実機を操作して勉強してましたが
IAM 認証に阻まれて本質と異なるところで時間を食ってしまいました・・・。
そんな課題を解決する為に"Lambda"上で Terraform を動かす関数を作ってみたので紹介します。
(ついでに Python の勉強も兼ねてます。Python 初心者なので変な部分あっても暖かく見守ってください。)

説明すること

  • Lambda 関数上で Terraform を動かす方法

※ 一般的な Lambda、S3、IAM、Terraform の操作は割愛します

事前準備

  • インターネットに繋がっているパソコン
  • 任意の Web ブラウザ
  • 以下の認可がある IAM ユーザー
    • Lambda を操作できる
    • S3 を操作できる
    • IAM ロールを作れる

Step1:S3 バケットの準備

以下のファイルを準備します。

  • Terraform バイナリファイル
    • 公式ページから入手します
    • この後作る Lambda 関数の OS と CPU アーキテクチャに合わせてください。
      • ここでは Linux の ARM64 とします(理由は後述)
  • 適用したい Terraform ソースコード
    • tfvars ファイルは必ず用意してください。

任意の S3 バケットを作成して、用意したファイルを以下のようなディレクトリ構造で格納します。

/
|
|-- binary/
|    |
|    └-- tf-arm/        ←任意のディレクトリ名(バイナリディレクトリと呼びます)
|        |
|        └-- terraform  ←Terraformバイナリファイル
|
└-- ec2-natins/         ←任意のディレクトリ名(ソースディレクトリと呼びます)
    |
    |-- main.tf         ←適用したいTerraformソースコード
    |-- test.tfvars
    └-- variables.tf

※S3 側アクセス権も正しく設定する

Step2:IAM ロールの作成

以下の信頼ポリシーを与えた IAM ロールを作ります。

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

IAM ロールに以下の許可ポリシーを与えます。

  • Terraform で操作したいリソースの作成、削除などの操作許可
    • 状況に応じて変わる為、ここでは割愛。ここでは EC2 を操作する許可を与えます。
  • Lambda 関数そのものを動かす為の以下の操作許可
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "s3objectrw",
      "Effect": "Allow",
      "Action": "s3:*Object",
      "Resource": [
        "arn:aws:s3:::【Step1で作ったS3バケット名】",
        "arn:aws:s3:::【Step1で作ったS3バケット名】/*"
      ]
    },
    {
      "Sid": "s3readonly",
      "Effect": "Allow",
      "Action": [
        "s3:Get*",
        "s3:List*",
        "s3:Describe*",
        "s3-object-lambda:Get*",
        "s3-object-lambda:List*"
      ],
      "Resource": "*"
    },
    {
      "Sid": "lambdabasic",
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "*"
    }
  ]
}

Step3:Lambda 関数を作成する

任意の名前で関数を作成します。ランタイムは Python としてください。(動確済み:Python3.13)

アーキテクチャはどちらでも構いませんが、Step1 でダウンロードしたバイナリファイルと合わせてください。
個人的にはコスト効率のよい ARM がおすすめです。

実行ロールは Step2 で作った IAM ロールを指定してください。

以下の設定を変更します。

  • メモリを既定から増やす。
    • 128MB だと実行時間がかなり伸びます。
    • 答えは無いですが、私は 512MB にして特に不満はありません
  • タイムアウトを既定から増やす。
    • 3 分では収まるか収まらないかの瀬戸際です。
    • 連続で複数回実行する関数では無いので最大値の 15 分で問題ないと思います。

2つのファイルを作成します。ZIP のアップロードでも画面内エディタでも構いません。

lambda_function.py(クリックすると展開されます)

import boto3
import subprocess
import os
import json
import arguments

def lambda_handler(event, context):

    # **Step1:引数のシリアライズ
    args = arguments.serialize(event)
    if not isinstance(args, dict):
        args = json.loads(args)

    # **Step2:変数チェック**
    missing_env_vars = []

    # Terraformのモード
    mode = args["message"].get("mode")
    if (mode is None) or ((mode != "create") and (mode != "remove")):
        missing_env_vars.append("mode")

    # ループカウンター
    loop_cnt = args["message"].get("loop_cnt")
    if (loop_cnt is None) or (int(loop_cnt) < 0):
        missing_env_vars.append("loop_cnt")

    # S3バケット名
    bucket_name = args["message"]["payload"].get("bucket_name")
    if bucket_name is None:
        missing_env_vars.append("bucket_name")

    # バイナリのフォルダ
    bin_prefix = args["message"]["payload"].get("bin_prefix")
    if (bin_prefix is None) or (not bin_prefix.endswith("/")):
        missing_env_vars.append("bin_prefix")

    # ソースのフォルダ
    src_prefix = args["message"]["payload"].get("src_prefix")
    if (src_prefix is None) or (not src_prefix.endswith("/")):
        missing_env_vars.append("src_prefix")

    # tfvarsファイル名
    tfvars_file = args["message"]["payload"].get("tfvars_file")
    if (tfvars_file is None) or (not ".tfvars" in tfvars_file):
        missing_env_vars.append("tfvars_file")

    # 変数チェックの結果
    if missing_env_vars:
        error_msg = {
            "statusCode": 400,
            "body": {
                "error": "Missing required variables",
                "details": ", ".join(missing_env_vars),
                "args": args["message"],
            },
        }
        print(json.dumps(error_msg))
        raise error_msg

    # **Step3:共通の変数宣言**
    download_dir = "/tmp/working"  # ダウンロード先のディレクトリ
    bin_file = "terraform"
    bin_path = os.path.join(download_dir, bin_file)
    tfvars_path = os.path.join(download_dir, tfvars_file)
    tfstate_file = f"{tfvars_file.replace('.tfvars', '')}.tfstate"
    tfstate_path = os.path.join(download_dir, tfstate_file)
    s3 = boto3.client("s3")

    # **step4:S3からファイル一覧を取得**
    try:
        src_objects = s3.list_objects_v2(Bucket=bucket_name, Prefix=src_prefix)
        bin_objects = s3.list_objects_v2(Bucket=bucket_name, Prefix=bin_prefix)
        files = src_objects.get("Contents", []) + bin_objects.get("Contents", [])
        if not files:
            error_msg = {
                "statusCode": 400,
                "body": {"error": "No files found in S3 bucket"},
            }
            print(json.dumps(error_msg))
            raise error_msg
    except Exception as e:
        error_msg = {
            "statusCode": 500,
            "body": {"error": "Error fetching file list from S3", "details": str(e)},
        }
        print(json.dumps(error_msg))
        raise error_msg

    # **step5:必要ファイルのチェック**
    bin_exist = False
    tfvars_exist = False
    tfstate_exist = False
    keys = []
    for file in files:
        if file["Key"].endswith("/"):
            continue
        if bin_file in file["Key"]:
            bin_exist = True
        if tfvars_file in file["Key"]:
            tfvars_exist = True
        if tfstate_file in file["Key"]:
            tfstate_exist = True
        keys.append(file["Key"])
    if not bin_exist:
        error_msg = {
            "statusCode": 400,
            "body": {"error": "bin file does not exist", "details": bin_file},
        }
        print(json.dumps(error_msg))
        raise error_msg
    if not tfvars_exist:
        error_msg = {
            "statusCode": 400,
            "body": {"error": "tfvars file does not exist", "details": tfvars_file},
        }
        print(json.dumps(error_msg))
        raise error_msg
    if (tfstate_exist) and (mode == "create"):
        error_msg = {
            "statusCode": 400,
            "body": {"error": "tfstate file already exists", "details": tfstate_file},
        }
        print(json.dumps(error_msg))
        raise error_msg
    if (not tfstate_exist) and (mode == "remove"):
        error_msg = {
            "statusCode": 400,
            "body": {"error": "tfstate file does not exist", "details": tfstate_file},
        }
        print(json.dumps(error_msg))
        raise error_msg

    # **step6:ファイルをダウンロード**
    os.makedirs(download_dir, exist_ok=True)
    for key in keys:
        local_path = os.path.join(download_dir, os.path.basename(key))
        try:
            s3.download_file(bucket_name, key, local_path)
            os.chmod(local_path, 0o755)
        except Exception as e:
            error_msg = {
                "statusCode": 500,
                "body": {
                    "error": "Error downloading file",
                    "details": f"{key}: {str(e)}",
                },
            }

    # **step7:Terraformコードの適用**
    try:
        subprocess.run([bin_path, "init"], cwd=download_dir, check=True)
        if mode == "create":
            subprocess.run(
                [
                    bin_path,
                    "plan",
                    f"-var-file={tfvars_path}",
                    "-compact-warnings",
                ],
                cwd=download_dir,
                check=True,
            )
            subprocess.run(
                [
                    bin_path,
                    "apply",
                    f"-var-file={tfvars_path}",
                    f"-state-out={tfstate_path}",
                    "-auto-approve",
                    "-compact-warnings",
                ],
                cwd=download_dir,
                check=True,
            )
        elif mode == "remove":
            subprocess.run(
                [
                    bin_path,
                    "plan",
                    f"-var-file={tfvars_path}",
                    f"-state={tfstate_path}",
                    "-destroy",
                    "-compact-warnings",
                ],
                cwd=download_dir,
                check=True,
            )
            subprocess.run(
                [
                    bin_path,
                    "destroy",
                    f"-var-file={tfvars_path}",
                    f"-state={tfstate_path}",
                    "-auto-approve",
                    "-compact-warnings",
                ],
                cwd=download_dir,
                check=True,
            )
    except subprocess.CalledProcessError as e:
        error_msg = {
            "statusCode": 500,
            "body": {"error": "Error executing Terraform", "details": str(e)},
        }
        print(json.dumps(error_msg))
        raise error_msg

    # **step8:tfstateファイルのS3操作**
    try:
        if mode == "create":
            s3.upload_file(tfstate_path, bucket_name, f"{src_prefix}{tfstate_file}")
        elif mode == "remove":
            s3.delete_object(Bucket=bucket_name, Key=f"{src_prefix}{tfstate_file}")
    except Exception as e:
        error_msg = {
            "statusCode": 500,
            "body": {"error": "Error tfstate file", "details": str(e)},
        }
        print(json.dumps(error_msg))
        raise error_msg

    # **step9:出力の成形*
    resource_map = {}
    subject = None
    if mode == "create":
        subject = f"Terraform on Lambda Success!! {mode}"
        try:
            with open(tfstate_path, "r") as f:
                tfstate_data = json.load(f)
            resources = tfstate_data.get("resources", [])
            for resource in resources:
                resource_type = resource.get("type", "0")
                resource_instances = resource.get("instances", [])
                for instance in resource_instances:
                    attributes = instance.get("attributes", {})
                    resource_id = attributes.get("id", "0")
                    arn_id = attributes.get("arn", "0")
                    name_tag = attributes.get("tags", {}).get("Name", "0")
                    if resource_type not in resource_map:
                        resource_map[resource_type] = []
                    resource_map[resource_type].append(
                        {"id": resource_id, "arn": arn_id, "Name": name_tag}
                    )
        except json.JSONDecodeError as e:
            error_msg = {
                "statusCode": 500,
                "body": {"error": "Error decoding tfstate file", "details": str(e)},
            }
            print(json.dumps(error_msg))
            raise error_msg
        except Exception as e:
            error_msg = {
                "statusCode": 500,
                "body": {"error": "Error processing tfstate file", "details": str(e)},
            }
            print(json.dumps(error_msg))
            raise error_msg
    elif mode == "remove":
        subject = f"Terraform on Lambda Success!! {mode}"
        loop_cnt = int(loop_cnt) - 1

    # **step10. 成功レスポンス**
    success_msg = {
        "statusCode": 200,
        "body": {
            "subject": subject,
            "loop_cnt": loop_cnt,
            "payload": {
                "bucket_name": bucket_name,
                "src_prefix": src_prefix,
                "bin_prefix": bin_prefix,
                "tfvars_file": tfvars_file,
                "resources": resource_map,
            },
        },
    }
    print(json.dumps(success_msg))
    return success_msg

arguments.py(クリックすると展開されます)

import json
import os

def serialize(event):

    args = {
        "source": None,
        "subject": None,
        "message": None,
    }
    tmp_sub = None
    tmp_msg = None

    #print("===== debug: event to json=====")
    #print(json.dumps(event))

    try:

        # SNS、SQS、StepFunctions、directの場合
        if "Records" in event:
            args["source"] = (
                event["Records"][0].get("EventSource")
                or event["Records"][0].get("eventSource")
            )
            match args["source"]:
                case "aws:sns":
                    tmp_sub = event["Records"][0]["Sns"].get("Subject")
                    tmp_msg = event["Records"][0]["Sns"].get("Message")
                case "aws:sqs":
                    tmp_msg = event["Records"][0].get("body")
                case "aws:stepfunctions":
                    tmp_msg = event["Records"][0].get("body")
                case "direct":
                    tmp_msg = event["Records"][0].get("body")
            if not isinstance(tmp_msg, dict): tmp_msg = json.loads(tmp_msg)

            if ("requestPayload" in tmp_msg) and ("responsePayload" in tmp_msg):
                tmp_msg = tmp_msg["responsePayload"].get("body")
                if not isinstance(tmp_msg, dict): tmp_msg = json.loads(tmp_msg)

            if "Subject" in tmp_msg:
                tmp_sub = tmp_msg.pop("Subject")
            elif "subject" in tmp_msg:
                tmp_sub = tmp_msg.pop("subject")

            if not tmp_sub is None:
                args["subject"] = tmp_sub
            if not tmp_msg is None:
                args["message"] = tmp_msg

        # 環境変数の場合
        elif os.environ.get("ARG_KIND") == "env":
            args["source"] = os.environ.get("ARG_KIND")
            args["subject"] = os.environ.get("SUBJECT")
            args["message"] = {
                "mode": os.environ.get("MODE"),
                "loop_cnt": os.environ.get("LOOP_CNT"),
                "payload": {
                    "bucket_name": os.environ.get("BUCKET_NAME"),
                    "bin_prefix": os.environ.get("BIN_PREFIX"),
                    "src_prefix": os.environ.get("SRC_PREFIX"),
                    "tfvars_file": os.environ.get("TFVARS_FILE"),
                    "resources": os.environ.get("RESOURCES"),
                },
            }

        else:
            error_msg = {
                "statusCode": 400,
                "body": {
                    "error": "Unable to determine arg.",
                    "details": event,
                },
            }
            print(json.dumps(error_msg))
            raise error_msg

    except json.JSONDecodeError as e:
        error_msg = {
            "statusCode": 500,
            "body": {
                "error": "Invalid JSON format",
                "details": str(e),
            }
        }
        print(json.dumps(error_msg))
        raise error_msg

    except Exception as e:
        error_msg = {
            "statusCode": 500,
            "body": {
                "error": "Error without JSON parse",
                "details": str(e)
            },
        }
        print(json.dumps(error_msg))
        raise error_msg

    print("===== args =====")
    print(json.dumps(args))
    return args

Step4:Lambda 関数の実行

この関数では、引数や環境変数から必要なパラメータを取得します。 以下の5つの方法でパラメータを読み込みます。
環境変数以外は JSON 形式での読み込むことを想定しています。
ループできるように返値をそのまま引数に渡しても動作するように作ってあります。 また、SNS や SQS を基準にしているため、Record や eventSource といった Key も必要になります。
(後述の例を参考にしてください。)

  • SNS によるトリガー
  • SQS によるトリガー
  • StepFunctions によるトリガー(デバッグ甘いので要注意)
  • Lambda 関数のテストイベントの JSON
  • Lambda 関数の環境変数

動作させる為に必須の各 Key と Value 例は以下です。

  • "mode"
    • リソースを作成(terraform apply)するか、削除(terraform destroy)するか
    • Value は前者"create"、後者"remove"としてください。
  • "loop_cnt"
    • ループの回数を指定。
      • Value は数値としてください。
  • "payload"
    • ネストして以下の Key と Value を入れてください。
    • 環境変数の場合はネストせずにそのまま Key と Value を入れてください。
    • "bucket_name"
      • Step1 で作ったバケット名
      • Value はバケット名のみ(arn ではない)としてください。
    • "src_prefix"
      • ソースディレクトリを指定してください。
      • Value は最後をスラッシュ/にしてください。
    • "bin_prefix"
      • バイナリディレクトリを指定してください。
      • Value は最後をスラッシュ/にしてください。
    • "tfvars_file"
      • ソースディレクトリに格納した tfvars ファイルを指定してください。
      • Value は.tfvars で終わるようにしてください。

また、返値としては以下を出力します。

  • "statusCode"
    • 結果の判定
    • Value は数値で返して、200 が成功、400 と 500 が失敗です。(設計は適当)
  • "body"
    • ネストして以下の Key と Value が入ります。
    • "subject"
      • 結果のメッセージが入ります。
    • "loop_cnt"  - mode の Value が remove ときに-1 します。
    • "payload"
      • "bucket_name"
      • "src_prefix"
      • "bin_prefix"
      • "tfvars_file"
        • "resources"
          • 作成したリソース情報を出力。
          • mode が create の時のみ出力されます。remove は空欄になります。(改善したい)
          • Value はネストして以下が入ります。
            • arn
            • id(リソース固有の ID があれば無ければ arn)
            • Name(Name タグ)

SNSトリガーの例(クリックすると展開されます)

{
  "Records": [
    {
      "EventSource": "aws:sns",
      "Sns": {
        "Subject": "TitleSNS_out",
        "Message": {
          "mode": "create or remove",
          "loop_cnt": 1,
          "subject": "TitleSQS_in",
          "payload": {
            "bucket_name": "s3-hogehoge-tfcode",
            "src_prefix": "ec2-natins/",
            "bin_prefix": "binary/tf-arm/",
            "tfvars_file": "hogehoge.tfvars",
            "resources": {
              "aws_instance": [
                {
                  "id": 0,
                  "arn": 0,
                  "Name": 0
                }
              ],
              "aws_network_interface": [
                {
                  "id": 0,
                  "arn": 0,
                  "Name": 0
                }
              ]
            }
          }
        },
        "Type": "",
        "MessageId": "",
        "TopicArn": "",
        "Timestamp": "",
        "SignatureVersion": "",
        "Signature": "",
        "SigningCertUrl": "",
        "UnsubscribeUrl": "",
        "MessageAttributes": {
          "Test": {
            "Type": "",
            "Value": ""
          },
          "TestBinary": {
            "Type": "",
            "Value": ""
          }
        }
      },
      "EventVersion": "",
      "EventSubscriptionArn": ""
    }
  ]
}

SQSトリガーの例(クリックすると展開されます)

{
  "Records": [
    {
      "eventSource": "aws:sqs",
      "body": {
        "mode": "create or remove",
        "loop_cnt": 1,
        "subject": "TitleSQS",
        "payload": {
          "bucket_name": "s3-hogehoge-tfcode",
          "src_prefix": "ec2-natins/",
          "bin_prefix": "binary/tf-arm/",
          "tfvars_file": "hogehoge.tfvars",
          "resources": {
            "aws_instance": [
              {
                "id": 0,
                "arn": 0,
                "Name": 0
              }
            ],
            "aws_network_interface": [
              {
                "id": 0,
                "arn": 0,
                "Name": 0
              }
            ]
          }
        }
      },
      "messageId": "",
      "receiptHandle": "",
      "messageAttributes": {},
      "md5OfBody": "",
      "eventSourceARN": "",
      "awsRegion": "",
      "attributes": {
        "ApproximateReceiveCount": "",
        "SentTimestamp": "",
        "SenderId": "",
        "ApproximateFirstReceiveTimestamp": ""
      }
    }
  ]
}

StepFunctionsトリガーの例(クリックすると展開されます)

{
  "Records": [
    {
      "eventSource": "aws:stepfunctions",
      "body": {
        "mode": "create or remove",
        "loop_cnt": 1,
        "subject": "TitleStepFunctions",
        "payload": {
          "bucket_name": "s3-hogehoge-tfcode",
          "src_prefix": "ec2-natins/",
          "bin_prefix": "binary/tf-arm/",
          "tfvars_file": "hogehoge.tfvars",
          "resources": {
            "aws_instance": [
              {
                "id": 0,
                "arn": 0,
                "Name": 0
              }
            ],
            "aws_network_interface": [
              {
                "id": 0,
                "arn": 0,
                "Name": 0
              }
            ]
          }
        }
      }
    }
  ]
}

テストイベント(JSON)の例(クリックすると展開されます)

{
  "Records": [
    {
      "eventSource": "direct",
      "body": {
        "mode": "create or remove",
        "loop_cnt": 1,
        "subject": "TitleDirect",
        "payload": {
          "bucket_name": "s3-hogehoge-tfcode",
          "src_prefix": "ec2-natins/",
          "bin_prefix": "binary/tf-arm/",
          "tfvars_file": "hogehoge.tfvars",
          "resources": {
            "aws_instance": [
              {
                "id": 0,
                "arn": 0,
                "Name": 0
              }
            ],
            "aws_network_interface": [
              {
                "id": 0,
                "arn": 0,
                "Name": 0
              }
            ]
          }
        }
      }
    }
  ]
}

まとめ

  • 一般的な Terraform 操作は CLI から行うが、EC2 や WSL といった環境が無くても Terraform ができた。
    • インストールやログインといった IaC の本質以外のことを気にしなくて良くなる!
    • 権限も IAM ロールで管理できる為、IAM ユーザーの認可設定をいじらなくてもよくなる!
  • Python の勉強になった。
  • apply や destroy 以外の操作を実装していないので難しいことは出来ない・・・
  • 今後の改善ポイント
    • destroy した時に何を消したのか返値として出力したい
    • StepFunctions のデバッグをちゃんとやりたい

終わりに

APC は AWS Advanced Tier Services(アドバンストティアサービスパートナー)認定を受けております。

その中で私達クラウド事業部は AWS などのクラウド技術を活用した SI/SES のご支援をしております。
www.ap-com.co.jp

https://www.ap-com.co.jp/service/utilize-aws/

また、一緒に働いていただける仲間も募集中です!
今年もまだまだ組織規模拡大中なので、ご興味持っていただけましたらぜひお声がけください。

www.ap-com.co.jp