社内で運用している 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つのグラフ画像を統計サマリーと一緒に送信することで、数値データと視覚パターンの両面から分析できるようになりました。

全体の流れを振り返る

graph TD
    A[SDK移行完了] --> B[罠1: max_tokens 非対応]
    B --> C[罠2: temperature 非対応]
    C --> D[罠3: メトリクスJSON空]
    D --> D1[仮説1: IAM権限不足]
    D1 --> D2[権限追加 → 変化なし]
    D2 --> D3[仮説2: 旧Jenkinsfileとの差分]
    D3 --> D4[原因: テスト用イベントフック]
    D4 --> E[罠4: LLMレスポンス空]
    E --> E1[原因: プロンプトが18,000トークン]
    E1 --> F[罠5: 画像分析の追加]
    F --> G[完成]

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

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

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

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

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

おわりに

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

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

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

参考書籍

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

[📦 商品リンク: moshimo-book-aws-operations]

[📦 商品リンク: moshimo-book-monitoring-intro]

[📦 商品リンク: moshimo-book-sre-google]

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