はじめに

Lambdalith構成(複数のAPIを1つのLambdaで処理する構成)を採用すると、CloudWatchの組み込みメトリクスが関数単位でしか記録されず、「どのAPIが遅いのか分からない」という運用面の課題が生じます。

そこで今回は、CloudWatch Embedded Metric Format(EMF)を活用し、API別のレイテンシを可視化してみました。

1. Lambdalithとは

1つのLambda関数で複数のAPIを処理する構成です。
API Gateway側は/{proxy+}で全リクエストをまとめて受け、振り分けは関数の中のルーターが行います。

API Gateway ──► POST /{proxy+} ──► Lambda(1 関数)
                                     ├── /fast
                                     ├── /slow
                                     └── ...

対になるのが「1 API = 1 Lambda」の構成です。

Lambdalith 1 API = 1 Lambda
関数の数 1 APIの本数だけ
コールドスタート 全APIで実行環境を共有するので起きにくい 低頻度のAPIは毎回起きやすい
Provisioned Concurrency 1関数に設定すれば全APIに効く 関数ごとに設定・課金
IAM権限 実行ロールが1つなので全APIの権限の和集合になる APIごとに最小権限
メトリクスの粒度 関数単位(全APIが混ざる) 関数単位 = API単位

表にあるようにメリット・デメリットがありますが、メトリクスの粒度が今回の主題です。

2. 何が問題なのか

組み込みメトリクスがエンドポイント別に割れません。

メトリクス 何が起きるか 原因
Lambda Duration 全エンドポイントが1系列に混ざる ディメンションは関数名・エイリアス・バージョンだけで、パスの情報が無い
API Gateway Latency 同上 詳細メトリクスのResourceは設定上のリソースパスに集約される

MetricsEnabled: true(API Gatewayの詳細メトリクス)を有効にしても解決しません。
Resourceディメンションに入るのは実パスではなく、テンプレートに書いたリソースパス(/{proxy+})だからです。

API Gatewayのメトリクス一覧。Resource は /{proxy+} しか存在しない

3. EMFとは

決められた形のJSONを標準出力に書くと、CloudWatch Logs側がそれをメトリクスに変換してくれる仕組みです。
アプリ側がやるのは実質printだけです。

Lambda が標準出力に JSON を 1 行 print
   │
   ▼
CloudWatch Logs(ログとしてそのまま保存される)
   │
   │  CloudWatch が _aws ブロックを見つけて自動で抽出
   ▼
CloudWatch メトリクス(グラフ・アラームに使える系列になる)

1行書くだけでログとメトリクスの両方に出るのがポイントです。
ログとして全文が残るのでLogs Insightsで検索でき、同時にグラフ化・アラーム設定ができる系列にもなります。

パス別にレイテンシを取る方法は、EMF以外にもあります。

方法 メリット 注意点
EMF 標準出力に書くだけで、ログとメトリクスの両方になる ディメンション設計を誤るとメトリクスが増えて課金が膨らむ
PutMetricData メトリクスだけを直接送れる 同期的なAPI呼び出しが発生しレイテンシに影響あり
追加のIAM権限とAPIリクエスト課金も必要
メトリクスフィルタ 既に構造化ログを出していれば、アプリのコードを変えずに済む フィルタ作成が必要
ログ形式が変わるとパターン不一致でサイレントに計測が止まる

今回はEMFを選びました。レイテンシに影響がないこと、独自の指標を直感的にコードの追加だけで増やしていけることが理由です。

EMFにおいて自分で決められるのは次の3つです。

役割 課金
メトリクス名 何を測るか(ApiLatencyなど) —
ディメンション その値が誰のものか(ApiPath=/fastなど) 組み合わせの数だけ課金
メタデータ 付帯情報(request_idなど) なし

今回はディメンションにAPIのパスを持たせることで、パスごとのメトリクスを取得します。
ここに何を置くかは自由なので、他にも業務上の指標など、標準メトリクスには無い独自メトリクスの記録が可能です。

4. 実装

※ PythonでLambdalithを採用する場合、ベストプラクティスとして「FastAPI + Web Adapter」が挙げられますが、今回は簡単のために「Powertools for AWS Lambda + 自作デコレータ」で実装しています。

ルーター

全エンドポイントがこの1つのリゾルバに登録されます。

from aws_lambda_powertools.event_handler import APIGatewayRestResolver

router = APIGatewayRestResolver()

計装デコレータ

Lambdalithでは、横断的な処理を1つの共通デコレータに集約して全ルートがそれを通る形にしました。
計装もそこに置けば、エンドポイントを何本足しても計装のロジックは1箇所のままです。

import time
from functools import wraps

from aws_lambda_powertools import Metrics
from aws_lambda_powertools.metrics import MetricUnit

# 名前空間と service は環境変数から取る
#   POWERTOOLS_METRICS_NAMESPACE / POWERTOOLS_SERVICE_NAME
metrics = Metrics()


def instrumented(func):

    @wraps(func)
    def wrapper(*args, **kwargs):

        # マッチしたルート。`.path` はルート定義(`/items/<item_id>` など)であって、
        # リクエストの実パスではない。
        route = router.context['_route']

        metrics.add_dimension(name='ApiPath', value=route.path)
        metrics.add_dimension(name='Method', value=route.method)

        started = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed_ms = (time.perf_counter() - started) * 1000

        metrics.add_metric(name='ApiLatency', unit=MetricUnit.Milliseconds, value=elapsed_ms)
        metrics.add_metric(name='ApiRequestCount', unit=MetricUnit.Count, value=1)

        # ディメンションではないので、値が何種類あってもメトリクスは増えない。
        # ApiPath がルート定義なのに対し、こちらはリクエストの実パス。
        metrics.add_metadata(key='request_id', value=router.lambda_context.aws_request_id)
        metrics.add_metadata(key='path', value=router.current_event.path)

        return result

    return wrapper

エンドポイント

@router.post('/fast')
@instrumented          # ← これだけ。計測のコードは書かない
def handler():

    time.sleep(0.03)   # 検証用に 30ms 待つ

    return {'value': 1}

エントリポイント

@metrics.log_metrics()   # ← これだけ
def handler(event, context):

    return router.resolve(event, context)

@metrics.log_metricsは(event, context)を受け取る関数にしか付けられないため、この1行だけは共通デコレータ側に寄せられません。

5. 出力を確認する

標準出力に出たJSON

実際にLambdaが吐いた1行です(抜粋・整形しています)。

{
  "_aws": {
    "Timestamp": 1789120742766,
    "CloudWatchMetrics": [
      {
        "Namespace": "EmfPoc/Api",
        "Dimensions": [["ApiPath", "Method", "service"]],
        "Metrics": [
          {"Name": "ApiLatency", "Unit": "Milliseconds"},
          {"Name": "ApiRequestCount", "Unit": "Count"}
        ]
      }
    ]
  },
  "ApiPath": "/slow",
  "Method": "POST",
  "service": "lambdalith",
  "request_id": "5c309943-...",
  "path": "/slow",
  "ApiLatency": [1500.11],
  "ApiRequestCount": [1.0]
}

_awsだけがCloudWatchへの指示書で、残りはただのJSONフィールドです。
Dimensionsに名前が挙がっているものだけがディメンションになります。
request_idは同じ階層にいますが列挙されていないので、メトリクスにはならず課金も増えません。
pathも同じです。3章の表がそのまま形になっています。

API別に分かれたメトリクス

aws cloudwatch list-metricsで、作られたメトリクスがそのまま見えます。

ApiLatency        ApiPath=/fast
ApiLatency        ApiPath=/slow
ApiRequestCount   ApiPath=/fast
ApiRequestCount   ApiPath=/slow

値も設定どおりでした。

/fast   =   30.11 ms
/slow   = 1500.11 ms

2章で見た「1系列に混ざる」状態から、ApiPathごとの2系列に分かれました。

EMFのApiLatency。/fast と /slow が別々の系列として表示されている

Logs Insightsでも検索できる

EMFはJSONなので、メトリクス化されると同時に構造化ログとしても検索できます。
追加の実装は要りません。

fields ApiPath, ApiLatency.0 as latency_ms, request_id, path
| filter ispresent(ApiLatency.0)
| sort latency_ms desc
| limit 5

実行結果です。

ApiPath=/slow  latency_ms=1500.11  request_id=5c309943-...  path=/slow
ApiPath=/fast  latency_ms=30.11    request_id=21945fe0-...  path=/fast

6. コストとディメンション設計

課金の単位

CloudWatchのカスタムメトリクスは「名前空間 × メトリクス名 × ディメンションの組み合わせ」ごとに1メトリクスとして課金されます。

メトリクス数 1本あたりの月額
最初の10,000 $0.30
10,001〜250,000 $0.10
250,001〜1,000,000 $0.05
1,000,001〜 $0.02

※ 2026年9月時点、東京リージョン(ap-northeast-1)の価格です。この記事の金額はすべてこの単価で計算しています。

課金は1時間ごとに按分され、その1時間にメトリクスを送信した場合にのみ料金が発生します。

何本作られるか

今回の構成では4メトリクスでした。

ディメンションの組み合わせ メトリクス名 本数
service + Method=POST + ApiPath=/fast ApiLatency, ApiRequestCount 2
service + Method=POST + ApiPath=/slow ApiLatency, ApiRequestCount 2
合計 4

ディメンションはフィルタではなく、メトリクスのidentityの一部です。
値が1つ違えば完全に別のメトリクスになります。
事前登録は不要で、データが最初に届いた瞬間にメトリクスが生まれます。

ApiPathの値の種類 作られるメトリクス 月額
2種類(/fast, /slow) 4 $1.2
10,000種類 20,000 $4,000

そしてカスタムメトリクスは手動で削除できません(15か月データが無ければ自動的に消えます)。
送信を止めれば課金も止まるので、損害は気づくまでの時間で決まります。

実パスをそのまま入れない

/items/のようなパスパラメータ付きルートで実パスを使うと、idの数だけメトリクスが生成されます。

ApiPathにはルート定義を渡します。Powertoolsはマッチしたルートをrouter.context['_route']に保持しているので、そこから取れます。

POST /items/12345   → ApiPath = /items/<item_id>
POST /items/99999   → ApiPath = /items/<item_id>   ← 増えない

リクエスト固有の値が必要ならadd_metadata()を使います。
ディメンションにならないのでメトリクスが増えず、Logs Insightsからは検索できます。

7. EMFで取れないもの

EMFはハンドラの中でメトリクスを積んでから書き出す仕組みなので、ハンドラが完走しなかった呼び出しは記録されません。

事象 Lambda Invocations EMF ApiRequestCount
正常に終わった呼び出し +1 +1
ハンドラ内の例外 +1 記録なし
タイムアウト +1 記録なし
メモリ超過(OOM) +1 記録なし
init中のエラー +1 記録なし

このうち例外だけはEMFの制約ではなく、計装デコレータの作りによるものです。
add_metricをfinally側に置けば例外時も記録できます。

そもそもパス別に取れないものもあります。

Lambda標準 パス別 理由
Throttles ✕ 関数が起動する前に弾かれるのでEMFが1行も出ない
ConcurrentExecutions ✕ 実行環境全体の値でリクエスト単位ではない

EMFで埋まるのはハンドラ内で起きたことだけです。
タイムアウト・OOM・スロットリングはLambdaの標準メトリクス(関数全体値)で見るしかなく、両方を併用することになります。

8. まとめ

Lambdalith構成でも、EMFを使えばエンドポイント別のレイテンシを取得できました。

EMFが効くのはLambdalithに限りません。ディメンションに何を置くかは自由なので、業務上の指標など、標準メトリクスには無い独自メトリクスの記録が可能です。

ただしEMFはLambda標準メトリクスの置き換えではありません。
タイムアウトやOOMのようにプロセスが殺される種類の失敗は記録されないので、標準メトリクスとの併用が前提になります。