はじめに
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+})だからです。

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系列に分かれました。

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のようにプロセスが殺される種類の失敗は記録されないので、標準メトリクスとの併用が前提になります。