一覧に戻る
    監視SDKの移行で踏んだ5つの罠──LLMモデル更新とCloudWatchメトリクス取得の連鎖デバッグ記録
    開発ラボ
    PRこの記事には広告が含まれています

    監視SDKの移行で踏んだ5つの罠──LLMモデル更新とCloudWatchメトリクス取得の連鎖デバッグ記録

    20 分で読める

    社内で運用している CloudWatch 監視の仕組みを、シェルスクリプトベースの Jenkinsfile から Python SDK ベースの新しい Jenkinsfile に移行しました。

    「SDK化すれば保守しやすくなるし、テストも書けるようになる」。そう思って進めた移行だったのですが、正直ここからが長かった。1つ直すと次の問題が出てくる、典型的な"モグラ叩き"デバッグに突入しました。

    この記事では、移行中に踏んだ5つの罠と、それぞれの原因特定プロセスを時系列で記録します。

    前提:何をやっていたか

    CloudWatch の CPU メトリクスを定期取得して、LLM(Azure OpenAI)に分析させ、Markdownレポートを自動生成する監視パイプラインです。

    CloudWatch → メトリクス取得 → 閾値チェック → グラフ画像生成 → LLM分析 → レポート出力

    もともとは Jenkinsfile の中で AWS CLI を直接叩いてメトリクスを取得し、jq で加工して LLM に渡すという構成でした。これを Python SDK に集約して、テスタブルな形にリファクタリングしたのが今回の移行です。

    罠1:max_tokens が使えない

    移行後に最初に出たエラーがこれです。

    Error code: 400 - {'error': {'message': "Unsupported value: 'max_tokens' is not
    supported with this model. Use 'max_completion_tokens' instead.", ...}}

    LLM のモデルを新しいバージョンに更新したタイミングで、max_tokens パラメータが非推奨になり、max_completion_tokens を使う必要がありました。

    修正自体はシンプルです。

    # Before
    response = client.chat.completions.create(
        model=config.deployment_name,
        messages=messages,
        max_tokens=config.max_tokens,        # ← 非推奨
    )
    
    # After
    response = client.chat.completions.create(
        model=config.deployment_name,
        messages=messages,
        max_completion_tokens=config.max_completion_tokens,  # ← 新パラメータ
    )

    データクラスの定義と設定ファイル(YAML)も合わせて変更。ここは素直に直せました。

    学び: モデルのバージョンアップ時は API パラメータの互換性を必ずチェックする。リリースノートに書いてあるのに見落としていました。

    罠2:temperature もサポート外

    1つ目を直して再実行。するとまた 400 エラー。

    Unsupported value: 'temperature' does not support 0.7 with this model.
    Only the default (1) value is supported.

    新しいモデルでは temperature パラメータ自体がサポートされておらず、デフォルト値(1)のみ受け付ける仕様でした。

    API 呼び出しから temperature パラメータを削除して対応。

    response = client.chat.completions.create(
        model=config.deployment_name,
        messages=messages,
        max_completion_tokens=config.max_completion_tokens,
        # temperature は指定しない(モデルのデフォルトを使用)
    )

    学び: パラメータを1つ直したら、他のパラメータも同じモデルで動くか確認する。「直った」と思って次に進むと、同じ種類の問題を見落とす。

    罠3:メトリクスのJSONが空──意外な犯人

    ここからが本番でした。

    LLM のエラーは解消したものの、今度は CloudWatch から取得した daily_metrics.jsonweekly_metrics.json の中身が空。データポイントが0件です。

    {
      "datapoints": [],
      "statistics": {
        "max_cpu": 0.0,
        "avg_cpu": 0.0,
        "points": 0
      }
    }

    グラフ画像(GetMetricWidgetImage API)は正常に生成されているのに、統計データ(GetMetricStatistics API)だけが空。同じ認証情報、同じディメンション指定なのに、なぜ片方だけ動くのか。

    調査1:メトリクスの存在確認

    まず、CloudWatch にデータ自体が存在するか確認しました。

    aws cloudwatch get-metric-statistics \
      --namespace AWS/EC2 \
      --metric-name CPUUtilization \
      --dimensions Name=AutoScalingGroupName,Value=<ASG名> \
      --start-time 2026-02-08T00:00:00Z \
      --end-time 2026-02-09T12:00:00Z \
      --period 3600 \
      --statistics Maximum Average \
      --region us-west-2

    結果:34個のデータポイントが取得できた。データは存在しています。

    調査2:IAM権限の疑い

    管理者権限では取得できて、AssumeRole 経由だと取得できない。となると IAM 権限が怪しい。

    Auto Scaling Group のディメンションを使って CloudWatch メトリクスを取得する場合、裏側で ASG の存在確認が走る可能性があります。IAM ロールに以下の権限を追加しました。

    # Auto Scaling の読み取り権限
    - autoscaling:DescribeAutoScalingGroups
    - autoscaling:DescribeAutoScalingInstances
    
    # EC2 の読み取り権限
    - ec2:DescribeInstances
    - ec2:DescribeInstanceStatus
    - ec2:DescribeTags

    CloudFormation スタックを更新して、複数アカウントのクロスアカウントロールに権限を反映。

    …しかし、まだ空のまま。

    調査3:旧Jenkinsfileとの差分比較

    ここで方針を変えました。「旧 Jenkinsfile では動いていたのに、新しい SDK では動かない」。であれば、差分を見るべきです。

    旧 Jenkinsfile は AWS CLI を直接呼んでいました。新しい SDK は boto3 の CloudWatch クライアントを使っています。boto3 のクライアント生成部分を読んでいくと、こんなコードが見つかりました。

    client = boto3.client("cloudwatch", **client_kwargs)
    
    # テスト用フレームワーク(moto)との互換性のために追加されたイベントフック
    client.meta.events.register(
        "before-parameter-build.cloudwatch.GetMetricStatistics",
        self._normalize_cloudwatch_params
    )
    client.meta.events.register(
        "before-call.cloudwatch.GetMetricStatistics",
        self._normalize_cloudwatch_request_body
    )

    意外とここが肝です。

    このイベントフックは、テスト用のモックフレームワーク(moto)との互換性のために追加されたものでした。boto3 の API リクエストが発行される前にパラメータを書き換えるフックです。

    テスト環境では正しく動作しますが、本番環境では CloudWatch API のリクエストパラメータを予期せず改変してしまい、結果として空のレスポンスが返ってきていました。

    # 修正:イベントフックを削除
    client = boto3.client("cloudwatch", **client_kwargs)
    # フック登録なし。boto3 の標準的な API 呼び出しに任せる

    GetMetricWidgetImage が動いていた理由も、これで説明がつきます。イベントフックは GetMetricStatistics にだけ登録されていたので、GetMetricWidgetImage は影響を受けなかったのです。

    学び: テスト用のコードが本番に影響するパターンは見落としやすい。「テスト環境では動く」が「本番環境でも動く」とは限らない。boto3 のイベントフックのような低レベルの介入は、特に注意が必要です。

    罠4:LLMに送るプロンプトが大きすぎる

    メトリクスが取得できるようになり、データポイントも正常に JSON に入るようになりました。が、今度は LLM の分析で空のレスポンスが返ってくる。

    OpenAI API呼び出しに失敗しました(3回試行):
    OpenAI APIから空のレスポンスが返されました。

    エラーコードなし。API 呼び出し自体は成功しているのに、レスポンスの content が空。

    データサイズを計算してみました。

    Weekly datapoints: 168個 → 26,590文字
    Daily datapoints:  288個 → 45,247文字
    合計: 71,837文字 ≒ 約18,000トークン

    全データポイントを JSON でそのまま送信していました。プロンプトテンプレートと合わせると、モデルの入力制限に引っかかっていたと考えられます。

    旧 Jenkinsfile では統計サマリー(max, avg, points)だけを送信していました。新しい SDK では全データポイントを送る実装になっていたのが原因です。

    # Before: 全データポイントを送信(約18,000トークン)
    "weekly_metrics": json.dumps(weekly_metrics, ensure_ascii=False, indent=2)
    
    # After: 統計サマリーのみ送信(数百トークン)
    weekly_summary = {
        "statistics": weekly_metrics.get("statistics", {}),
        "period": weekly_metrics.get("period", {}),
        "status": weekly_metrics.get("status", ""),
        "warnings": weekly_metrics.get("warnings", []),
        "datapoints_count": len(weekly_metrics.get("datapoints", []))
    }
    "weekly_metrics": json.dumps(weekly_summary, ensure_ascii=False, indent=2)

    学び: LLM に渡すデータは「分析に必要な粒度」を考えて設計する。生データを丸ごと渡すのは、トークン効率が悪いだけでなく、モデルの入力制限に引っかかるリスクがある。

    罠5(おまけ):せっかくだから画像も分析させたい

    ここまでで監視パイプラインは動くようになりました。ただ、ふと気づいたことがあります。

    「グラフ画像は生成できているのに、LLM には統計値しか送っていない。画像も一緒に分析させたほうが良いのでは?」

    統計値だけでは見えないトレンドやスパイクのパターンを、Vision 機能で拾えるはずです。

    # ユーザーメッセージを構築
    user_content = [{"type": "text", "text": prompt}]
    
    # グラフ画像を追加
    for image_path in image_paths:
        with open(image_path, "rb") as f:
            image_data = base64.b64encode(f.read()).decode("utf-8")
        user_content.append({
            "type": "image_url",
            "image_url": {"url": f"data:image/png;base64,{image_data}"}
        })
    
    messages = [
        {"role": "system", "content": "You are a CloudWatch metrics analysis expert "
         "with the ability to analyze both numerical data and visual graphs."},
        {"role": "user", "content": user_content},
    ]

    週次・日次の2つのグラフ画像を統計サマリーと一緒に送信することで、数値データと視覚パターンの両面から分析できるようになりました。

    全体の流れを振り返る

    1つ直すと次が見える。5つの問題を順番に解いていった結果、最初の状態よりも良い監視パイプラインになりました。

    今回の判断ポイントまとめ

    問題最初の仮説実際の原因学び
    max_tokens エラーパラメータ名の変更✅ その通りモデル更新時はAPIパラメータの互換性確認
    temperature エラー同上✅ その通り1つ直したら他も確認
    メトリクスJSON空IAM権限不足❌ テスト用イベントフックテスト用コードが本番に影響するパターン
    LLMレスポンス空モデルの不具合?❌ プロンプトサイズ超過LLMへの入力は必要な粒度で設計

    3つ目の「メトリクスJSON空」は、最初の仮説(IAM権限不足)が外れたケースです。管理者権限では動くのに AssumeRole 経由だと動かない、という状況から IAM を疑うのは自然な推論でしたが、実際の原因はもっと低レイヤーにありました。

    「旧バージョンでは動いていた」という情報から差分比較に切り替えたのが、結果的に正解でした。

    おわりに

    移行作業は「動くものを別の形にする」だけなので、一見シンプルに見えます。でも、実際にはこうした細かい互換性の問題が積み重なります。

    特に今回は「テスト用コードが本番を壊す」というパターンが印象的でした。テストの moto 互換性のために追加したイベントフックが、本番の CloudWatch API を改変してしまう。テストを書くこと自体は良い習慣ですが、テスト用のコードが本番コードパスに侵入していないか、注意が必要です。

    もう行くしかないなと思いながら1つずつ潰していった数時間でしたが、結果的には画像分析の追加まで辿り着けたので、移行前より良い状態になりました。遠回りも悪くない、と思える作業でした。

    参考書籍

    AWS運用や監視設計について体系的に学びたい方には、以下の書籍が参考になります。

    実際の運用経験と照らし合わせながら読むことで、今回のような問題に対する引き出しが増えていきます。

    この記事は役に立ちましたか?

    Coffee cup

    この記事が、何かの整理につながったら

    コーヒー1杯分の応援をもらえると嬉しいです。

    ※ これは応援とは別の話ですが、

    同じようなテーマを自分の文脈で整理したい場合は、 (文章だけだと詰まりやすい人向けに) 思考整理の壁打ちという形で対話の時間も取っています。

    対話の時間について