CloudWatch Logs のエラーを自動で GitHub Issues に課題化する

ECS で稼働するWebアプリケーションのエラーログを自動的に GitHub Issues に報告する仕組みを構築しました。手動でログを監視する必要がなくなり、エラー発生時に即座にチームが認識・対応できるようになります。

背景

マルチテナントの業務システムを ECS Fargate 上で運用しています。アプリケーションは2つあり、それぞれ異なるフレームワークで構築されています。

アプリフレームワーク用途
webLaravel (PHP)業務管理システム
apiDjango (Python)API サーバー

これまで CloudWatch Logs にログは収集していたものの、エラーの検知は手動確認に頼っていました。500エラーや例外発生を見逃すリスクがあり、自動検知の仕組みが必要でした。

アーキテクチャ

Subscription Filter + Lambda + GitHub Issues API の構成を採用しました。

CloudWatch Logs (/ecs/{prefix}-ecs-{app})
  └── Subscription Filter (エラーパターンマッチ)
        └── Lambda Function (Docker/arm64, Python 3.12)
              ├── エラー解析 (HTTP 5xx, 例外, スタックトレース)
              ├── ±5秒のログコンテキスト取得
              ├── 既存 Open Issue 検索
              └── 新規 Issue 作成 or 既存 Issue にコメント追加

この構成を選んだ理由

方式リアルタイム性柔軟性コスト
Subscription Filter + Lambda (採用)
Metric Filter + Alarm + SNS中 (1分以上遅延)
CloudWatch Logs Insights (定期実行)

Subscription Filter はログ出力時にほぼリアルタイムで Lambda を起動するため、エラー発生から数秒で Issue が作成されます。

実装のポイント

1. アプリごとにリポジトリを分離

エラーの報告先を Secrets Manager で管理し、アプリ名に応じてリポジトリを切り替えます。

1
2
3
4
5
6
7
8
9
{
  "github": {
    "token": "ghp_xxx",
    "repos": {
      "web": "myorg/web-app",
      "api": "myorg/api-server"
    }
  }
}

Lambda ハンドラーでは、ログストリーム名からアプリ名を抽出してリポジトリを決定します。

1
2
3
4
5
6
7
8
def extract_app_name(log_group):
    """/ecs/myapp-stage-ecs-api -> api"""
    parts = log_group.rsplit("-", 1)
    return parts[-1] if parts else log_group

# Secrets Manager から repos マッピングを取得
repos = github_conf.get("repos", {})
github_repo = repos.get(app_name, github_conf.get("repo", ""))

2. PHP と Python の両方のエラーパターンに対応

Laravel (PHP) と Django (Python) でエラーの出力形式が異なるため、両方のパターンを検出します。

Subscription Filter のフィルタパターン (Laravel):

?"HTTP/1.1\" 5" ?"ERROR" ?"Traceback" ?"CRITICAL" ?"PHP Fatal" ?"production.ERROR"

Python コードでのパターンマッチ:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# HTTP 5xx
re.compile(r'HTTP/\d\.\d"\s+(5\d{2})')

# Python/Django
re.compile(r"\b(ERROR|CRITICAL|FATAL)\b")
re.compile(r"Traceback \(most recent call last\)")

# PHP/Laravel
re.compile(r"PHP Fatal error:", re.IGNORECASE)
re.compile(r"\]\s+\w+\.ERROR:")  # Laravel ログ形式

3. スタックトレースのコンテキスト取得

Subscription Filter はフィルタに一致した行のみを Lambda に送信します。しかし、Python の Traceback や PHP のスタックトレースは複数行にまたがるため、エラーの全容がわかりません。

そこで、CloudWatch Logs API で エラー発生時刻の前後±5秒のログを取得し、連続するエラー行を1つのブロックにグルーピングします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
CONTEXT_BUFFER_MS = 5000  # 前後5秒

def fetch_log_context(log_group, log_stream, error_timestamps):
    min_ts = min(error_timestamps) - CONTEXT_BUFFER_MS
    max_ts = max(error_timestamps) + CONTEXT_BUFFER_MS
    response = client.get_log_events(
        logGroupName=log_group, logStreamName=log_stream,
        startTime=min_ts, endTime=max_ts, limit=200,
    )
    return response.get("events", [])

4. 連続投稿の抑制 (クールダウン)

エラーが大量発生すると Issue へのコメントが洪水のように投稿されてしまいます。これを防ぐため、10分間のクールダウンを実装しています。

1
2
3
4
5
6
7
COOLDOWN_MINUTES = 10

def _is_within_cooldown(self, issue_number):
    # 直近コメントの created_at を確認
    last_time = datetime.fromisoformat(last_created.replace("Z", "+00:00"))
    now = datetime.now(timezone.utc)
    return (now - last_time) < timedelta(minutes=COOLDOWN_MINUTES)

5. Docker イメージベースの Lambda

エラー解析ロジックが複雑化することを見越して、Docker イメージベースの Lambda を採用しました。

  • ローカルで pytest によるユニットテスト実行が可能
  • 依存パッケージの管理が容易
  • ビルドスクリプトがテスト → ビルド → プッシュを自動化
1
2
3
4
5
6
FROM public.ecr.aws/lambda/python:3.12

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ${LAMBDA_TASK_ROOT}/app/
CMD ["app.handler.lambda_handler"]

Terraform モジュールの構成

modules/log_monitor/ として再利用可能なモジュールにまとめています。

modules/log_monitor/
├── main.tf          # Subscription Filter
├── lambda.tf        # Lambda 関数 (Docker Image/arm64)
├── iam.tf           # IAM ロール・ポリシー
├── secrets.tf       # Secrets Manager
├── repository.tf    # ECR リポジトリ
├── logs.tf          # Lambda 用 Log Group
├── locals.tf        # フィルタ定義
├── variables.tf / outputs.tf
├── bin/build.bash   # ビルドスクリプト
└── docker/          # Lambda ソースコード + テスト

環境ごとの設定は JSON ファイルで外出しします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "apps": {
    "web": {
      "filter_pattern": "?\"HTTP/1.1\\\" 5\" ?\"ERROR\" ?\"PHP Fatal\" ?\"production.ERROR\"",
      "labels": ["web"]
    },
    "api": {
      "filter_pattern": "?\"HTTP/1.1\\\" 5\" ?\"ERROR\" ?\"Traceback\" ?\"CRITICAL\"",
      "labels": ["api"]
    }
  }
}

GitHub Issue の出力例

Lambda が自動作成する Issue は以下のような形式です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
## Error Report - 2026-02-24 07:38:51 UTC

- **Environment**: stage
- **Log Group**: `/ecs/myapp-stage-ecs-api`
- **Log Stream**: `ecs/api/abc123`
- **Error Blocks**: 3

### [1] 2026-02-24 07:01:27 UTC - `application`

\```
2026-02-24 16:01:27.289 | ERROR | myapp.reports.utils - External API client error
\```

### [2] 2026-02-24 07:01:27 UTC - `5xx`

\```
10.0.12.81:1208 - "POST /api/v1/reports/generate/ HTTP/1.1" 500
\```

ラベル CloudWatchLog + アプリ名が付与され、同一ラベルの Open Issue が既にあればコメントとして追記されます。

デプロイ手順

3ステップでデプロイできます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 1. ECR リポジトリ作成 (初回のみ)
terraform apply -target=module.log_monitor.aws_ecr_repository.this

# 2. Docker イメージビルド・プッシュ (テスト実行含む)
modules/log_monitor/bin/build.bash stage --profile my-profile

# 3. Lambda・Subscription Filter 等の作成
terraform apply -target=module.log_monitor

# 4. Secrets Manager に認証情報設定 (初回のみ)
aws secretsmanager put-secret-value \
  --secret-id myapp-stage-log-monitor \
  --secret-string '{"github":{"token":"ghp_xxx","repos":{...}}}'

Sentry との比較

エラー監視の SaaS として広く使われている Sentry と、本構成(CloudWatch + Lambda + GitHub Issues)を比較します。

本構成の強み

強み詳細
GitHub Issues との直接統合エラーがそのまま開発チームのワークフロー(Issue → PR → Close)に乗る
AWS に閉じている外部 SaaS への依存がなく、エラー情報が AWS 外に出ない
コスト透明性Lambda 実行回数ベースで、低〜中トラフィック環境ならほぼ無料
カスタマイズ自由フィルタパターン、クールダウン時間、コンテキスト取得範囲を自由に調整可能

Sentry が優れている領域

一方で、Sentry には本構成で再現が難しい、または自作すると大きなコストがかかる機能があります。

1. エラーのグルーピングとデデュプリケーション

本構成では「同一ラベルの Open Issue があればコメント追記 + 10分クールダウン」で重複を抑制していますが、異なる原因のエラーが1つの Issue に混在する可能性があります。Sentry はスタックトレースの構造を解析し、同じ原因のエラーを自動的に1つの Issue にまとめます。発生回数・影響ユーザー数のカウントも自動です。

2. ソースコードレベルのコンテキスト

本構成では ±5秒のログからエラーブロックを構成しますが、Sentry はスタックトレースの各フレームに対して該当するソースコードの前後数行を表示します。リリースバージョンと紐づけて「どのデプロイで混入したか」も追跡可能です。

3. パフォーマンスモニタリング(トレーシング)

本構成はエラー(5xx、例外)のみを検知しますが、Sentry Performance はリクエスト全体のトレースを記録し、遅いクエリ、遅い外部 API 呼び出し、N+1 クエリを自動検出します。エラーにならないが遅い処理(P95 レイテンシの劣化等)も可視化できます。これを自前で実装するには OpenTelemetry + Jaeger/Tempo 等の分散トレーシング基盤が必要になり、構築コストが大きくなります。

4. リリースとの紐づけ

「このエラーはリリース v1.2.3 で初めて発生した」「このリリースで regression が起きている」をダッシュボードで確認でき、コミットとの紐づけにより「誰のどのコミットが原因か」まで追跡可能です。本構成ではログの timestamp から推測するしかありません。

5. ユーザーコンテキスト

Sentry SDK はエラー発生時に「どのユーザーが」「どのブラウザ/OS で」「どのページで」操作していたかを自動記録します。マルチテナント環境では「どのテナントに影響しているか」の把握が重要ですが、ログのパースだけでは限界があります。

6. アラートの柔軟性

「過去1時間で同じエラーが50回以上発生したら Slack に通知」「新規エラーのみ通知」「特定のエラーは無視」といったルールを GUI で設定可能です。本構成では通知の粒度を変えるには Lambda コードの修正が必要です。

Sentry のデメリット

観点詳細
コストTeam プランは月 $26〜。エラー量が増えるとイベント数課金が嵩む
データの外部送出エラー情報(スタックトレース、リクエストデータ)が Sentry のサーバーに送信される。セキュリティポリシーによっては不可(セルフホスト版もあるが運用負荷が大きい)
監視ツールの分散CloudWatch と Sentry の両方を見る必要があり、チームが確認する場所が増える

使い分けの指針

両者を置き換える関係ではなく、役割を分けるのが現実的です。

役割ツール
インフラ・ログレベルの異常検知、Issue 化本構成(CloudWatch + Lambda + GitHub Issues)
アプリケーションレベルのエラー分析・パフォーマンスSentry(導入する場合)

Sentry の導入が特に効果的になるのは以下のような状況です。

  • エラーの種類が多すぎて GitHub Issues では追いきれなくなった
  • 「遅いがエラーにはならない」パフォーマンス問題を追いたい
  • リリース頻度が高く、どのデプロイで regression が起きたかを素早く特定したい
  • 影響ユーザー数をテナント単位で把握したい

MVP フェーズやチーム規模が小さいうちは、本構成だけで十分にカバーできます。

まとめ

  • Subscription Filter + Lambda でセミリアルタイムのエラー検知を実現
  • アプリごとにリポジトリを分離し、開発チームが自然に Issue を確認できる
  • PHP/Laravel と Python/Django の両方のエラーパターンに対応
  • スタックトレースのコンテキスト取得でエラーの全容を報告
  • 10分クールダウンで Issue の洪水を防止
  • Docker Lambda + pytest でテスト容易性を確保
  • Terraform モジュール化で複数環境への展開が容易