社内で運用している 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.json と weekly_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]
実際の運用経験と照らし合わせながら読むことで、今回のような問題に対する引き出しが増えていきます。