はじめに
サーフィンの波情報を通知する Wave アプリ(WavePilot)に、CloudWatch のログ管理と pino による構造化ログを導入した記録です。
Terraform の書き方やハマったエラーも含めて残しておきます。
WavePilot とは
サーファー向けの波情報通知アプリです。
- お気に入りのサーフスポットを登録すると、毎日指定時刻に LINE で波情報を通知
- Stormglass API から波高・風速・風向きを取得
- フロントエンドは React、バックエンドは Node.js (Express) を Lambda で動作
アプリの全体像や作った経緯は、こちらの記事にまとめています。
https://qiita.com/mugicha0141/items/84b9a45daf68d5bdaa27
技術スタック
| 項目 | 内容 |
|---|---|
| フロントエンド | React → S3 + CloudFront |
| バックエンド | Node.js (Express) → Lambda |
| DB | DynamoDB |
| 通知 | LINE Messaging API + EventBridge Scheduler |
| IaC | Terraform |
なぜ CloudWatch 管理を導入したか
Lambda は実行するだけで自動的に CloudWatch Logs にログを送ってくれます。
ただし、何も設定しないと以下の問題があります。
- ロググループの保持期間が無期限になる
→ ログが溜まり続けてコストが増える - ロググループが Terraform の管理外で作られる
→terraform destroyしてもログが残り続ける -
console.logの文字列羅列でログが検索しにくい
console.logとは
JavaScript でログにメッセージを出力する命令です。
Lambda ではconsole.logの出力内容がそのまま CloudWatch Logs に送られます。
実行時の状況を後から追うための、ほぼ唯一の手がかりになります。
今回は「ロググループの保持期間設定と Terraform 管理化」に加えて、console.log を JSON 形式に変えて検索しやすくする構造化ログ(pino の導入)に取り組みました。
追加した Terraform コード
1. CloudWatch Log Groups の定義
terraform/main.tf に追記しました。
# ── CloudWatch Log Groups ─────────────────────────────────────────
resource "aws_cloudwatch_log_group" "wave_app_backend" {
count = var.environment == "prod" ? 1 : 0
name = "/aws/lambda/wave-app-backend"
retention_in_days = 14
tags = {
Environment = var.environment
}
}
resource "aws_cloudwatch_log_group" "wave_notify" {
count = var.environment == "prod" ? 1 : 0
name = "/aws/lambda/wave-notify"
retention_in_days = 14
tags = {
Environment = var.environment
}
}
ポイント解説
| 項目 | 内容 |
|---|---|
count = var.environment == "prod" ? 1 : 0 |
本番のみ作成。ローカル(LocalStack)では不要なので作らない |
name = "/aws/lambda/wave-app-backend" |
Lambda のロググループ名は /aws/lambda/関数名 が AWS のデフォルトルール |
retention_in_days = 14 |
14日より古いログは自動削除。コスト削減のため必ず設定する |
2. Lambda に logging_config を追加
各 Lambda 関数のリソースブロック内に追記しました。
resource "aws_lambda_function" "wave_app_backend" {
# ... 既存の設定 ...
dynamic "logging_config" {
for_each = var.environment == "prod" ? [1] : []
content {
log_format = "Text"
log_group = aws_cloudwatch_log_group.wave_app_backend[0].name
}
}
depends_on = [aws_cloudwatch_log_group.wave_app_backend]
}
wave_notify Lambda も同様に追記しました。
dynamic ブロックとは
Terraform でブロックを条件付きで生成する構文です。
通常の if 文のような書き方はブロックに対してできないため、dynamic を使います。
dynamic "logging_config" {
for_each = var.environment == "prod" ? [1] : []
# for_each が [1](要素1つ)→ ブロックが1回生成される
# for_each が [](空) → ブロックが生成されない
content {
# 実際に生成されるブロックの中身
}
}
log_format = "Text" にした理由
今回は従来どおりの "Text" のままにしました。
今回導入した pino は標準出力に JSON を直接書き出すため、"Text" のままでも CloudWatch Logs Insights が JSON のフィールドを自動認識してくれます。
depends_on を付けた理由
logging_config 内でロググループを参照しているため、Lambda より先にロググループを作る必要があります。
depends_on で作成順序を明示的に指定しています。
ハマったエラー
terraform apply を実行したところ、以下のエラーが出ました。
Error: creating CloudWatch Logs Log Group (/aws/lambda/wave-app-backend):
ResourceAlreadyExistsException: The specified log group already exists
原因
Lambda は初回実行時に自動でロググループを作ります。
このプロジェクトはすでに本番稼働していたため、最初に Lambda が動いた時点で AWS が自動作成していました。
Lambda 初回実行
↓
AWS が自動で作成
/aws/lambda/wave-app-backend (保持期間:無期限)
/aws/lambda/wave-notify (保持期間:無期限)
これらは Terraform の管理外で作られたため、terraform destroy を実行しても削除されずに残り続けていました。
対処法
古いログは不要だったので、AWS CLI で直接削除してから再度 apply しました。
aws logs delete-log-group \
--log-group-name "/aws/lambda/wave-app-backend" \
--region ap-northeast-1
aws logs delete-log-group \
--log-group-name "/aws/lambda/wave-notify" \
--region ap-northeast-1
削除後に再度デプロイしました。
./deploy.sh --env prod --infra
今度は「新規作成」として処理され、保持期間14日でロググループが作られました。

注意
削除から apply までの間に Lambda が実行されると、AWS がロググループを再作成して同じエラーになります。
本番で試す場合は、実行が少ない時間帯に素早く行うか、下の import を使ってください。
補足:古いログを残したい場合
削除せず、既存のロググループを Terraform の管理下に取り込むimportという方法もあります。terraform import \ 'aws_cloudwatch_log_group.wave_app_backend[0]' \ '/aws/lambda/wave-app-backend'
確認方法
AWS コンソール → CloudWatch → ロググループ で、以下が確認できれば成功です。
-
/aws/lambda/wave-app-backendが保持期間「14日」で存在する -
/aws/lambda/wave-notifyが保持期間「14日」で存在する
pino の導入
console.log の文字列ログのままでは CloudWatch Logs Insights で検索しにくいため、
Node.js の代表的なロガーライブラリ pino を導入して JSON 形式(構造化ログ)に変えました。
インストール
npm install pino
使い方
const pino = require("pino");
const logger = pino();
// info ログ
logger.info({ method: req.method, path: req.path }, "Request received");
// エラーログ
logger.error({ error: err.message }, "Stormglass API error");
出力される JSON:
{"level":30,"time":1234567890,"pid":1,"msg":"Request received","method":"GET","path":"/api/wave-data"}
ログレベル
pino のレベルは数値で管理されています。
| レベル | 数値 | 用途 |
|---|---|---|
| trace | 10 | 詳細なデバッグ情報 |
| debug | 20 | デバッグ情報 |
| info | 30 | 通常の処理(リクエスト受信、処理完了など) |
| warn | 40 | 警告(処理はできたが注意が必要な状態) |
| error | 50 | エラー(処理に失敗した) |
| fatal | 60 | 致命的なエラー |
追加したログ一覧
| 場所 | レベル | 内容 |
|---|---|---|
| 全エンドポイント共通 | info | リクエスト受信(ミドルウェアで一括管理) |
| LINE webhook | info | 署名チェック・検証結果・イベント数 |
| LINE アカウント連携 | info | 連携完了 |
| ログイン | info/error | 試行・成功・エラー |
| 波情報取得 | info/error | リクエスト受信・Stormglass エラー |
| お気に入り | error | 登録・取得・更新・削除エラー |
| EventBridge スケジュール | info | 作成・更新・削除 |
| サーバー起動 | info | 起動完了(ローカル開発時) |
CloudWatch でログを確認
デプロイ後にアプリを操作(ログイン → 地図から波情報取得 → お気に入り登録)したところ、
CloudWatch Logs にログが出力されていることを確認しました。
確認場所:AWS コンソール → CloudWatch → ロググループ → /aws/lambda/wave-app-backend
ログの種類
ロググループを開くとログストリームが並んでいます。中には2種類のログが混在しています。
Lambda が自動で出力するログ(Text 形式では消せません):
START RequestId: abc123 Version: $LATEST
END RequestId: abc123
REPORT RequestId: abc123 Duration: 123ms Billed Duration: 200ms Memory Size: 128MB
pino が出力する構造化ログ:
{"level":30,"time":1234567890,"msg":"Request received","method":"POST","path":"/login"}
{"level":30,"time":1234567890,"msg":"Login successful","userId":"1"}
{"level":30,"time":1234567890,"msg":"Request received","method":"GET","path":"/api/wave-data"}
{"level":30,"time":1234567890,"msg":"Wave data request","lat":"35.6","lng":"139.7"}
ログストリームが2つ表示される理由
/aws/lambda/wave-app-backend の中に、2つのログストリームが作られていました。
ログストリームは Lambda のインスタンスごとに1つ作られます。
アプリを開いた際にブラウザが複数のリクエストを同時に送ると(波情報取得・お気に入り一覧取得など)、Lambda が複数インスタンスを起動して並列処理します。これがログストリームが2つになった理由です。
リクエスト① → Lambda インスタンス A が起動 → ログストリーム1
リクエスト② → Lambda インスタンス B が起動 → ログストリーム2
使い続けるとストリームはどんどん増えますが、正常な動作です。
Lambda は常時起動していない
Lambda はリクエストが来たときに実行環境が用意され、使われない時間が続くと環境は破棄されます。
使った分だけ課金される仕組みなので、軽量アプリには非常に向いています。
Logs Insights でまとめて分析
ロググループを直接見ると「どのストリームを見ればいいかわからない」問題が起きます。
Logs Insights を使えば全ストリームをまとめて検索・分析できます。
確認場所:CloudWatch → ログ分析
ロググループで /aws/lambda/wave-app-backend を選択してクエリを実行します。
クエリの基本構文
| コマンド | 役割 |
|---|---|
fields |
表示するフィールドを指定 |
filter |
条件で絞り込む |
stats |
集計する |
sort |
並び替え |
limit |
表示件数を制限 |
よく使うクエリ例
pino のログだけを、新しい順に50件表示する
fields @timestamp, msg, method, path, error
| filter ispresent(msg)
| sort @timestamp desc
| limit 50
filter ispresent(msg) で、Lambda が自動で出力する START・END・REPORT の行を除外しています。
pino のログだけが新しい順に表示され、リクエストの内容を確認できました。

エラーだけ絞り込む
fields @timestamp, msg, error
| filter level = 50
| sort @timestamp desc
pino のレベルは数値
Logs Insights でfilter level = "ERROR"と書いても動きません。
pino は info = 30、warn = 40、error = 50 の数値で管理しているため、filter level = 50と書きます。
エンドポイント別のアクセス数を集計する
fields path
| filter msg = "Request received"
| stats count() by path
まとめ
| やったこと | 効果 |
|---|---|
| CloudWatch Log Groups を Terraform で定義 |
terraform destroy でログも一緒に消えるようになった |
retention_in_days = 14 を設定 |
古いログが自動削除されてコスト増を防げる |
logging_config で Lambda と紐付け |
Lambda が使うロググループを Terraform 管理のものに固定できた |
| pino で構造化ログを導入 | CloudWatch Logs Insights でクエリ分析できるようになった |
Lambda を使っている場合はロググループの保持期間設定と構造化ログを最初から入れておくことを強くおすすめします。
次回の課題
現在はログを確認・分析できる状態になりましたが、エラーが起きても気づけない状態です。
次回は CloudWatch Alarms を設定して、エラーが一定数を超えたときに通知が来るようにする予定です。通知先は、普段使っている LINE を予定しています。
