terrraformを使ったGoのLambdaの管理

SREの菅原です。

カンムのサービスはWebサービスバッチ処理なども含めて基本的にはECS上で動かしているのですが、簡単なバッチ処理はLambda+EventBridge Schedulerの組み合わせで動かすこともあります。

LambdaはECSに比べてDockerイメージのビルドやECRの準備が不要で作成の手間が少ないのですが、terraformでデプロイまで含めて管理しようとすると少し問題がありました。

terraformでのLambdaのデプロイの問題点

例えば以下のような構成のNode.jsのLambdaをデプロイする場合

/
├── lambda.tf
└── lambda
    ├── app.js
    ├── package-lock.json
    └── package.json
// app.js
const util = require("util");
const gis = util.promisify(require("g-i-s"));

exports.handler = async (event) => {
  const rs = await gis("nyan");
  console.log(JSON.stringify(rs, null, 2));
};

null_resource(またはterraform-data)とarchive_fileを使って、terraformでLambdaの作成とデプロイを行えます。

resource "null_resource" "npm_install" {
  triggers = {
    package_json      = filebase64sha256("lambda/package.json")
    package_lock_json = filebase64sha256("lambda/package-lock.json")
  }

  provisioner "local-exec" {
    working_dir = "lambda"
    command     = "npm install"
  }
}

data "archive_file" "nyan" {
  type        = "zip"
  output_path = "app.zip"
  source_dir  = "lambda"
  depends_on  = [null_resource.npm_install]
}

resource "aws_lambda_function" "nyan" {
  function_name    = "nyan"
  runtime          = "nodejs20.x"
  role             = "..."
  handler          = "app.handler"
  filename         = data.archive_file.nyan.output_path
  source_code_hash = data.archive_file.nyan.output_base64sha256
}

しかしこの方法だと

  • archive_fileがデータソースであるため、terraformを実行するたびにzipファイルが作成される *1
  • 特にCIやAtlantis*2でterraformを実行する場合、意図しないタイミングでLambdaの更新が実行される
  • npm installやpip installなどzipファイル作成前の処理の定義が複雑になる

という問題があります。

terraform-provider-lambdazip

そこで、これらの問題を解決しterraformだけでLambdaの管理を行えるようにするため、terraformプロバイダーを自作しました。

github.com

  • データソースではなくリソースなのでtriggersの変更がなければzipファイル作成処理が走らない
  • before_createでzipファイル作成前の処理を指定できる

lambdazipプロバイダーを使って先ほどのlambda.tfを書き直すと次のようになります。

data "lambdazip_files_sha256" "triggers" {
  files = [
    "lambda/app.js",
    "lambda/package.json",
    "lambda/package-lock.json",
  ]
}

resource "lambdazip_file" "nyan" {
  base_dir      = "lambda"
  sources       = ["**"]
  output        = "lambda.zip"
  before_create = "npm i"
  triggers      = data.lambdazip_files_sha256.triggers.map
}

resource "aws_lambda_function" "nyan" {
  function_name    = "nyan"
  runtime          = "nodejs20.x"
  role             = "..."
  handler          = "app.handler"
  filename         = lambdazip_file.nyan.output
  source_code_hash = lambdazip_file.nyan.base64sha256
}

Go Lambda

社内のLambdaにはPythonJavaScriptが使われることもありますが、私がLambdaを作成する場合は慣れているGoで実装することが多いです。

  • npmやpipなどライブラリの同梱について考える必要がない
  • 手元の環境でもCI/Atlantis環境でもデプロイ用のバイナリのクロスコンパイルができる
  • Go 1.21以降で再現可能なビルドができるようになったのでソースコードの変更だけをトリガにデプロイできる
  • 共有ライブラリへの依存を避けやすい

などがGo Lambdaの良い点です。

terraformでの定義は

/
├── lambda.tf
└── lambda
    ├── main.go
    ├── go.mod
    └── go.sum
data "lambdazip_files_sha256" "triggers" {
  files = ["lambda/*.go", "lambda/go.mod", "lambda/go.sum"]
}

resource "lambdazip_file" "app" {
  base_dir      = "lambda"
  sources       = ["bootstrap"]
  output        = "lambda.zip"
  before_create = "GOOS=linux GOARCH=amd64 go build -o bootstrap main.go"
  triggers      = data.lambdazip_files_sha256.triggers.map
}

resource "aws_lambda_function" "app" {
  filename         = lambdazip_file.app.output
  function_name    = "my_func"
  role             = aws_iam_role.lambda_app_role.arn
  handler          = "my-handler"
  source_code_hash = lambdazip_file.app.base64sha256
  runtime          = "provided.al2023"
}

のようになります。

以下、業務で使用しているGo Lambdaの一例です。

例: リザーブインスタンスの期限のメトリクス化

インフラコスト削減ためAWS RDSやOpenSearchリザーブドインスタンスを利用しているのですが、AWS Cost Explorerが提供している期限切れアラートはEメールへの通知のみで、また7日前・30日前・60日前と決められたタイミングにしか通知を送ることができません。

カンムのインフラのアラートはほとんどがDatadogで管理されておりリザーブインスタンスの期限切れアラートもなるべくDatadogに集約したい、また通知のタイミング以外にも複数のアカウントのリザーブインスタンスの期限がどの程度迫っているのか簡単に把握したい、といったモチベーションがありGo Lambdaを使ってリザーブインスタンスの期限をDatadogのメトリクスにしてみました。

main.go

Goの実装はGetReservationUtilization APIを呼び出して、Datadogにメトリクスを送るだけの単純なものです。 AWS Organizationsの親アカウントでGetReservationUtilizationを呼び出すと、子アカウントのRIの情報を取得することができます。

package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "time"

    "github.com/DataDog/datadog-api-client-go/v2/api/datadog"
    "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/costexplorer"
    "github.com/aws/aws-sdk-go-v2/service/costexplorer/types"
    "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
)

var (
    TARGET_SERVICES = []string{
        "Amazon Relational Database Service",
        "Amazon OpenSearch Service",
    }
    DD_API_KEY_FROM = os.Getenv("DD_API_KEY_FROM")
    DD_APP_KEY_FROM = os.Getenv("DD_APP_KEY_FROM")
)

const (
    METRIC_NAME = "costexplor.reservation.days_to_expiry"
)

func main() {
    lambda.Start(HandleRequest)
}

func HandleRequest(ctx context.Context, event any) error {
    now := time.Now()
    output, err := getReservationUtilization(ctx, now)

    if err != nil {
        return fmt.Errorf("failed to getReservationUtilization: %w", err)
    }

    if len(output.UtilizationsByTime) == 0 {
        log.Println("No data")
        return nil
    }

    utilizations := output.UtilizationsByTime[0]

    for _, g := range utilizations.Groups {
        endDateTime, err := time.Parse("2006-01-02T15:04:05.000Z", g.Attributes["endDateTime"])

        if err != nil {
            return fmt.Errorf("failed to parse endDateTime: %w", err)
        }

        daysToExpiry := endDateTime.Sub(now).Hours() / 24

        if daysToExpiry < -10 {
            // 10日以上経った過去のReservationは無視
            continue
        }

        tags := []string{
            "account_name:" + g.Attributes["accountName"],
            "service:" + g.Attributes["service"],
            "lease_id:" + g.Attributes["leaseId"],
        }

        submitMetrics(ctx, now.Unix(), daysToExpiry, tags)
    }

    return nil
}

func getReservationUtilization(ctx context.Context, now time.Time) (*costexplorer.GetReservationUtilizationOutput, error) {
    cfg, err := config.LoadDefaultConfig(ctx)

    if err != nil {
        return nil, err
    }

    client := costexplorer.NewFromConfig(cfg)

    input := &costexplorer.GetReservationUtilizationInput{
        TimePeriod: &types.DateInterval{
            Start: aws.String(now.AddDate(0, 0, -90).Format("2006-01-02")),
            End:   aws.String(now.Format("2006-01-02")),
        },
        Filter: &types.Expression{
            Dimensions: &types.DimensionValues{
                Key:    "SERVICE",
                Values: TARGET_SERVICES,
            },
        },
        GroupBy: []types.GroupDefinition{
            {
                Type: "DIMENSION",
                Key:  aws.String("SUBSCRIPTION_ID"),
            },
        },
    }

    return client.GetReservationUtilization(ctx, input)
}

func submitMetrics(ctx context.Context, ts int64, daysToExpiry float64, tags []string) error {
    ddApiKey, err := getSecretValue(ctx, DD_API_KEY_FROM)

    if err != nil {
        return err
    }

    ddAppKey, err := getSecretValue(ctx, DD_APP_KEY_FROM)

    if err != nil {
        return err
    }

    body := datadogV2.MetricPayload{
        Series: []datadogV2.MetricSeries{
            {
                Metric: METRIC_NAME,
                Type:   datadogV2.METRICINTAKETYPE_GAUGE.Ptr(),
                Unit:   datadog.PtrString("day"),
                Points: []datadogV2.MetricPoint{
                    {
                        Timestamp: datadog.PtrInt64(ts),
                        Value:     datadog.PtrFloat64(daysToExpiry),
                    },
                },
                Tags: tags,
            },
        },
    }

    configuration := datadog.NewConfiguration()
    apiClient := datadog.NewAPIClient(configuration)
    api := datadogV2.NewMetricsApi(apiClient)

    ctx = context.WithValue(ctx, datadog.ContextAPIKeys, map[string]datadog.APIKey{
        "apiKeyAuth": {Key: ddApiKey},
        "appKeyAuth": {Key: ddAppKey},
    })

    _, _, err = api.SubmitMetrics(ctx, body, *datadogV2.NewSubmitMetricsOptionalParameters())

    if err != nil {
        return fmt.Errorf("Error when calling `MetricsApi.SubmitMetrics`: %w\n", err)
    }

    log.Printf("Put metric value=%.2f tags=%v ", daysToExpiry, tags)

    return nil
}

func getSecretValue(ctx context.Context, secretId string) (string, error) {
    cfg, err := config.LoadDefaultConfig(ctx)

    if err != nil {
        return "", err
    }

    input := &secretsmanager.GetSecretValueInput{
        SecretId: aws.String(secretId),
    }

    client := secretsmanager.NewFromConfig(cfg)
    output, err := client.GetSecretValue(ctx, input)

    if err != nil {
        return "", err
    }

    return aws.ToString(output.SecretString), nil
}

tfファイル

前述の通りterraformでLambdaを定義し、EventBridge Schedulerで一時間ごとにメトリクスを送信します。

data "lambdazip_files_sha256" "dd_ce_reservation_days_to_expiry" {
  files = [
    "./lambda/dd-ce-reservation-days-to-expiry/main.go",
    "./lambda/dd-ce-reservation-days-to-expiry/go.mod",
    "./lambda/dd-ce-reservation-days-to-expiry/go.sum",
  ]
}

resource "lambdazip_file" "dd_ce_reservation_days_to_expiry" {
  base_dir      = "./lambda/dd-ce-reservation-days-to-expiry"
  sources       = ["bootstrap"]
  output        = "lambda.zip"
  before_create = "GOOS=linux GOARCH=amd64 go build -o bootstrap main.go"
  triggers      = data.lambdazip_files_sha256.dd_ce_reservation_days_to_expiry.map
}

resource "aws_lambda_function" "dd_ce_reservation_days_to_expiry" {
  function_name    = "dd-ce-reservation-days-to-expiry"
  runtime          = "provided.al2023"
  role             = aws_iam_role.lambda_dd_ce_reservation_days_to_expiry.arn
  handler          = "bootstrap"
  filename         = lambdazip_file.dd_ce_reservation_days_to_expiry.output
  source_code_hash = lambdazip_file.dd_ce_reservation_days_to_expiry.base64sha256
  timeout          = 300

  environment {
    variables = {
      DD_API_KEY_FROM = aws_secretsmanager_secret.datadog_DD_API_KEY.name
      DD_APP_KEY_FROM = aws_secretsmanager_secret.datadog_DD_APP_KEY.name
    }
  }

  depends_on = [
    aws_cloudwatch_log_group.lambda_dd_ce_reservation_days_to_expiry,
  ]
}

# ()

resource "aws_scheduler_schedule" "dd_ce_reservation_days_to_expiry" {
  name                         = "dd-ce-reservation-days-to-expiry"
  schedule_expression          = "rate(1 hour)"
  schedule_expression_timezone = "Asia/Tokyo"
  state                        = "ENABLED"

  flexible_time_window {
    mode = "OFF"
  }

  target {
    arn      = aws_lambda_function.dd_ce_reservation_days_to_expiry.arn
    role_arn = aws_iam_role.dd_ce_reservation_days_to_expiry_schedule.arn
  }
}

表示例

Datadogでメトリクスを表示すると、どのアカウントのどのRIがどの程度残っているのかが一目でわかります。

まとめ

terraformでGo Lambdaをデプロイできると、ちょっとした処理をバッチ化するのがとても楽になり、インフラ環境の改善が進みます。 さらにAtlantisとの組み合わせで、PR上でLambdaのデプロイが可能になり、開発体験も非常に良いです。

今後も引き続き環境の改善に務めていきたいところです。

*1:リソースのarchive_fileもあるのですがdeprecatedです

*2:RP上でterraformを実行できるツールです https://www.runatlantis.io/