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プロバイダーを自作しました。
- データソースではなくリソースなので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にはPythonやJavaScriptが使われることもありますが、私が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/