Lambdaのメモリ監視を安価に実現(CDKで構築)

# 経緯

バッチ処理をAWSのLambdaで組んで運用していたところ、ある日突然失敗のアラートメールが。

調べたところ、処理しているファイルのデータ量が日に日に増加しており、ついにはそのLambdaに割り当てていたメモリ上限を超過してしまったとのことでした。

失敗してからじゃなくて、そろそろヤバいよ！ってタイミングで警告のメールが欲しいよねという話になりました。

ECSやEC2で動かしてるシステムは標準でそのメトリクスが見られるので、簡単にできるでしょと思ったわけです。

## 意外と簡単な方法ないじゃん！

> ECSやEC2で動かしてるシステムは標準でそのメトリクスが見られるので、簡単にできるでしょと思ったわけです。

**考えが甘い。**
そう、なんと標準メトリクスにはLambdaのメモリ消費に関するものがありませんでした。

調査を進めると Lambda Insights の機能を有効にするとメモリ消費などのメトリクスも取得されるとのこと。
これで良いかもなーと思ったんですが、問題は料金。

CloudWatchのカスタムメトリクスは**1つあたり月額0.30ドル**。
そして Lambda Insights は6つのカスタムメトリクスを作成するので、**月額1.80ドル**もかかってしまいます。
これがさらにLambda関数分だけかかるとなると、結構な金額になってしまいます。

## ログをばらして拾える…？

Lambdaの内部の処理自体でメモリを取得して、CloudWatchに送信するか～？いやいや、監視の都合でアプリケーションのソースコードいじるなんてあり得ない…
と、他の方法ないかなーとログを眺めていたところ、Lambdaの実行ごとのログには以下のようなものが表示されているのが目につきました。

> REPORT RequestId: 181307c0-21ae-4646-97ac-f65f105f1137	Duration: 828.67 ms	Billed Duration: 829 ms	Memory Size: 256 MB	Max Memory Used: 230 MB

メモリの最大と消費が出てるし、これをうまいことバラして拾えるのでは…？

## やってる人いた

**いるもんですね。**

https://www.tdi.co.jp/miso/aws-lambda-cloudwatch

仕組みとしてはロググループに対してメトリクスフィルタを設定。
そこでまさにうまいことバラす方法があるようで、メモリの「最大」と「消費」の数値をそれぞれでカスタムメトリクスに出力しています。

消費率の算出は CloudWatch Alarm 側で行い、90％を超えたらアラーム状態に。

**本記事はこちらのリンク先の内容をCDKで実装したよ！というのが本題なので、その設定の詳細な内容についてはリンク先をご参照ください。**

## 実際に試す

記事の通りにメトリクスフィルタとアラームを設定してみた結果が以下の通り。（アラームとなる閾値は80％にしました。）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/385410/bd29c14f-63df-473c-85ae-032f72973adc.png)

良い感じですね。

ちなみに、検証のために任意のメモリ負荷をかけられるNode.jsのプログラムを用意しました。（完全AI製）

<details><summary>サンプルコード</summary>

```javascript
exports.handler = async (event, context) => {
  // Check if there's a request to consume memory
  if (event.memory) {
    const targetMemoryMB = parseInt(event.memory, 10);
    if (!isNaN(targetMemoryMB) && targetMemoryMB > 0) {
      console.log(`Attempting to consume ${targetMemoryMB}MB of memory`);

      try {
        // Calculate how much additional memory we need to allocate
        const currentUsageMB = process.memoryUsage().rss / (1024 * 1024);
        const additionalMemoryNeededMB = Math.max(0, targetMemoryMB - currentUsageMB);

        if (additionalMemoryNeededMB > 0) {
          // Allocate memory in chunks to reach target
          const memoryChunks = [];
          const chunkSizeMB = 10; // Allocate in 10MB chunks
          const numChunks = Math.ceil(additionalMemoryNeededMB / chunkSizeMB);

          console.log(`Allocating ${numChunks} chunks of ${chunkSizeMB}MB to reach ${targetMemoryMB}MB`);

          for (let i = 0; i < numChunks; i++) {
            // Each element in the array is 1MB (1024 * 1024 bytes)
            const bytesPerMB = 1024 * 1024;
            const chunk = Buffer.alloc(chunkSizeMB * bytesPerMB);

            // Write some data to ensure the memory is actually allocated
            for (let j = 0; j < chunk.length; j += 4096) {
              chunk[j] = 1;
            }

            memoryChunks.push(chunk);

            // Log progress every few chunks
            if (i % 10 === 0 || i === numChunks - 1) {
              const currentMemory = Math.round(process.memoryUsage().rss / (1024 * 1024));
              console.log(`Allocated chunk ${i + 1}/${numChunks}, current memory usage: ${currentMemory}MB`);
            }
          }
        }

        // Final memory usage after allocation
        const finalUsedMemory = Math.round(process.memoryUsage().rss / 1024 / 1024);
        const finalMemoryPercentUsed = Math.round((finalUsedMemory / maxMemory) * 100);
        console.log(`Final Memory Usage: ${finalUsedMemory}MB of ${maxMemory}MB (${finalMemoryPercentUsed}%)`);

        return {
          statusCode: 200,
          body: JSON.stringify({
            message: "Memory consumption completed",
            requestedMemory: targetMemoryMB,
            actualMemoryUsed: finalUsedMemory,
            maxMemory: maxMemory,
            percentUsed: finalMemoryPercentUsed,
          }),
        };
      } catch (error) {
        console.error("Error allocating memory:", error);
        return {
          statusCode: 500,
          body: JSON.stringify({
            message: "Error allocating memory",
            error: error.message,
          }),
        };
      }
    } else {
      return {
        statusCode: 400,
        body: JSON.stringify({
          message: "Invalid memory consumption request",
          detail: "memory must be a positive number",
        }),
      };
    }
  }

  return {
    statusCode: 200,
    body: JSON.stringify({
      message: "Memory usage reported",
      usedMemory: usedMemory,
      maxMemory: maxMemory,
      percentUsed: memoryPercentUsed,
    }),
  };
};

```

上記プログラムをhandlerとして設定し、以下のjsonの入力でテスト実行できます。
正確じゃないですが、だいたい目当ての負荷をかけられる感じ。

```json
{
  "memory": 256
}
```
</details>

## でも手作業でやってらんないぜ

Lambda関数が1個しかないなら設定は手順用意してやればいいですが、もう2個以上になると手でやりたくありません。
IaCの出番です。

CDKでやっていきます。

# CDKのコード

この記事の本題。

libの内容だけ記載します。（binやそのほかで特別なことはやってません。cdk initのテンプレのまま。）

```typescript
import * as cdk from "aws-cdk-lib";
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as logs from "aws-cdk-lib/aws-logs";
import { Construct } from "constructs";
import * as path from "path";

export class CdkLambdaMemoryAlertStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const functionName = "sample-function";
    const memorySize = 256;
    const f = new lambda.Function(this, `LambdaFunc-${functionName}`, {
      functionName: functionName,
      runtime: lambda.Runtime.NODEJS_22_X,
      handler: "handler.handler",
      code: lambda.AssetCode.fromAsset(path.join(__dirname, "../src")),
      timeout: cdk.Duration.seconds(60),
      memorySize: memorySize,
      logGroup: new logs.LogGroup(this, `LogGroup-${functionName}`, {
        logGroupName: `/aws/lambda/${functionName}`,
        retention: logs.RetentionDays.ONE_WEEK,
        removalPolicy: cdk.RemovalPolicy.DESTROY,
      }),
    });

    new logs.MetricFilter(this, `MetricFilterMemoryAllocated-${functionName}`, {
      logGroup: f.logGroup,
      filterName: `MemoryAllocated`,
      filterPattern: logs.FilterPattern.literal("[f1=REPORT, ...]"),
      metricNamespace: "LambdaCustomMetrics",
      metricName: `${functionName}-MemoryAllocated`,
      metricValue: "$13",
    });

    new logs.MetricFilter(this, `MetricFilterMemoryUsed-${functionName}`, {
      logGroup: f.logGroup,
      filterName: `MemoryUsed`,
      filterPattern: logs.FilterPattern.literal("[f1=REPORT, ...]"),
      metricNamespace: "LambdaCustomMetrics",
      metricName: `${functionName}-MemoryUsed`,
      metricValue: "$18",
    });

    new cloudwatch.Alarm(this, `AlarmMemoryConsumption-${functionName}`, {
      alarmName: `LambdaMemoryConsumption-${functionName}`,
      evaluationPeriods: 1,
      threshold: 80,
      comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
      metric: new cloudwatch.MathExpression({
        expression: "m1/m2*100",
        usingMetrics: {
          m1: new cloudwatch.Metric({
            namespace: "LambdaCustomMetrics",
            metricName: `${functionName}-MemoryUsed`,
            statistic: "Maximum",
          }),
          m2: new cloudwatch.Metric({
            namespace: "LambdaCustomMetrics",
            metricName: `${functionName}-MemoryAllocated`,
            statistic: "Maximum",
          }),
        },
      }),
    });
  }
}
```

重要なのは MetricFilter 2個と Alarm の部分です。
これをデプロイすれば、前述の記事と同様のものができるはず。
（名称は私の好みで変えていますが、それぞれの Logical ID やリソース名はどうぞ任意に）

また、このコードではAlarmの後ろのアクションを定義していません。

# 補足: 料金

Lambda関数1個当たり、以下の料金になります。

- CloudWatch Custom Metrics: 2つで0.60ドル (1つあたり0.30ドル)
- CloudWatch Alarm 標準解像度アラーム: 1つあたり0.10ドル

**トータルで月額0.70ドルです。**
ロググループのメトリクスフィルタ自体には料金はかかりません。

Lambda Insights 案だと、カスタムメトリクス6つ + CloudWatch Alarm 1つなので1.90ドルです。
これを考えると、メモリ消費のみを見たい場合は結構安く済みますよね。

# おわりに

:::note info
記事中で使用しているCDKのソースコード全体は[こちらのGitHubリポジト](https://github.com/easy-easy/cdk-lambda-memory-alert)リにて公開しております。
バージョンなどの情報が知りたい場合はリポジトリのpackage.jsonをご参照ください。
:::

本当はメトリクスのDimensionとして関数名を出したい気持ちもあったんですが、現状のメトリクスフィルタの機能では固定の値をDimensionとして設定することはできず断念しました。

Dimensionはログからフィルターパターンで取ってきた項目を動的に設定することしかできないようです。（2025/3時点）

また、文中で紹介した参考記事のほうは Lambda Insights のリリースより前に書かれたもののようですね。
確かに Lambda Insights は有効にするだけなので楽々なんですが、ここで紹介した方法もプログラムの作りこみとかがあるわけではないので、コストを優先する場合はこの方法も良いかなと思っています。