既存のCloudWatch監視を止めずにOTelメトリクスへ移行してみた

既存のCloudWatch監視を止めずにOTelメトリクスへ移行してみた

CloudWatch の Classic メトリクスから OpenTelemetry メトリクスへ、既存監視を止めずに移行する dual-write を小規模環境で実機検証しました。Collector の fan-out 設定でアプリ改修ゼロに実現でき、その過程で踏んだ落とし穴(cumulative 問題、OTelLib ディメンション、IAM の暗黙 Deny など)を具体的にお伝えします。
2026.07.07

はじめに

こんにちは!ひろたこです。
今回は 2026年6月に GA した CloudWatch の OpenTelemetry(OTel)メトリクスネイティブサポートについて、「既存の Classic メトリクス監視を止めずに移行する」という観点で検証してみました!

OTel メトリクスは GB 単位の取り込み課金でカーディナリティ(重複具合や種類の多さ)に強く、魅力的なのは間違いないのですが、いま動いている PutMetricData・EMF・アラームの上に成り立っている監視を「えいや」で切り替えるわけにはいきません。

OTel メトリクスの基礎はすでに良記事があります。

そこで本記事は 「移行」だけに絞ります。具体的には、公式移行ガイドの Phase 1 にあたる dual-write(二重書き込み)を、アプリ改修なしで Collector 1つの fan-out 設定として実装し、以下を実測しました。

  • 1つの Collector config で Classic と OTel の両系統にメトリクスを届ける
  • 両系統で名前・次元/ラベルがどうズレるか(対応表)
  • Classic アラームを残したまま PromQL アラームを並走させると何が起きるか
  • 二重課金がどう見えるか
  • 実際に踏んだ落とし穴と、移行判断チェックリスト

移行の全体像と「3つの dual」

公式のカスタムメトリクス移行ガイドは、移行を4フェーズで定義しています。

  1. Dual-write: 両系統に同じメトリクスを書き込む
  2. Validate: 両系統の値が一致することを検証する
  3. Recreate consumers: ダッシュボード・アラームを OTel 側で作り直す
  4. Cut over: Classic 側への書き込みを止める

「flag day(一斉切り替え日)はない」と明言されており、段階移行が公式の推奨です。本記事の検証はこの Phase 1〜3 を小さく一周するものです。

ここで紛らわしいのが、公式ドキュメントに 「dual」が付く用語が3つあることです。最初に整理しておきます。

用語 出典 意味
dual publishing EKS Container Insights 移行ガイド amazon-cloudwatch-observability アドオンの設定で Classic / OTel 両ストリームを同時発行するアドオン組み込みの機能。両ストリーム分課金されるため公式は「期間は短く」と明記
dual-write カスタムメトリクス移行ガイド 移行 Phase 1 の公式用語。本記事の検証対象。カスタムメトリクスには組み込み機能がないので自分で実装する
dual ingest IAM 条件キーのドキュメント Classic と OTLP の両方を受け入れる期間の IAM 設計の文脈で使われる用語(後述の暗黙 Deny 問題に関係)

EKS Container Insights を使っている方はアドオンの dual publishing に乗るのが素直です(なお Classic の Enhanced Container Insights は公式に maintenance mode 宣言済みです)。本記事はそれ以外の、PutMetricData や EMF でカスタムメトリクスを送っている層向けに、dual-write を Collector の fan-out で実装します。

環境・前提条件

ツール バージョン
AWS CLI 2.35.16
Terraform v1.15.7 (darwin_arm64)
Terraform AWS Provider v6.53.0
OTel Collector otel/opentelemetry-collector-contrib:0.155.0
Docker (EC2/AL2023) 25.0.14
Docker Compose / Buildx v5.3.0 / v0.35.0
awscurl brew 最新(PromQL API / OTLP への SigV4 リクエスト用)
  • リージョン: ap-northeast-1(東京)。2026年7月7日時点で OTLP 取り込み・PromQL クエリ・Query Studio すべて利用可能なことを公式の対応リージョン表と実機疎通で確認済みです
  • 検証環境: EC2 t3.micro(AL2023)1台に Docker で Collector とダミーメトリクス generator(Python + OTel SDK 1.43.0)を同居
  • 稼働時間: 2026年7月7日 16:22〜18:33 JST(約2時間11分)で terraform destroy 済み。費用は概算 $1 未満(内訳は後述)

やったこと

1. Terraform で検証環境を構築する

EC2 t3.micro、最小権限のインスタンスロール(cloudwatch:PutMetricData + awsemf 用の logs 権限)、アラーム並走ペア2本、IAM 検証用ロールを Terraform で建てます。

$ terraform apply tfplan
aws_instance.lab: Creation complete after 13s [id=i-05690732daa5e9cf7]

Apply complete! Resources: 10 added, 0 changed, 0 destroyed.

Outputs:
instance_id = "i-05690732daa5e9cf7"
otlp_namespace_test_role_arn = "arn:aws:iam::123456789012:role/dualwrite-lab-ns-condition-test"

apply 自体は約25秒・10リソースで完了です(user_data の完了までは別途数分)。

ここで嬉しい発見がありました。Terraform AWS Provider v6.53.0 は PromQL アラームに対応済みです!aws_cloudwatch_metric_alarmevaluation_criteria { promql_criteria {} }evaluation_interval が生えており、CLI での補完なしで apply が通りました。実際に通った定義がこちらです。

resource "aws_cloudwatch_metric_alarm" "promql_orders_latency" {
  alarm_name        = "dualwrite-lab-promql-orders-latency"
  alarm_description = "PromQL: app.request.duration (endpoint=/api/orders) histogram_avg > 0.5s"

  # PromQL アラームは閾値・比較演算子をクエリ自体に埋め込む(> 0.5 の部分)。
  # クエリは単一系列を返す必要があるため avg() で集約している
  evaluation_interval = 60 # クエリを評価する間隔(秒)
  evaluation_criteria {
    promql_criteria {
      query           = "avg(histogram_avg({\"app.request.duration\", \"@resource.service.name\"=\"dual-write-demo\", \"endpoint\"=\"/api/orders\"})) > 0.5"
      pending_period  = 60  # この秒数ブリーチし続けたら ALARM
      recovery_period = 120 # この秒数ブリーチしなかったら OK
    }
  }
}

Classic アラームで必須だった comparison_operator / threshold / evaluation_periods は、PromQL アラームでは指定不要でした。閾値をクエリ末尾の > 0.5 として埋め込むスタイルです。既存の Terraform 資産と同じ resource type のまま移行できるのは地味にありがたいです。

2. 1つの Collector config で dual-write する

本記事の主役、fan-out する Collector config の全文です。アプリは Collector に OTLP で送るだけなので、アプリ側の改修はゼロで dual-write が成立します。

# =============================================================================
# OTel Collector で CloudWatch Classic と OTel メトリクスに dual-write する設定
#
# ポイント: アプリは Collector (OTLP) に送るだけ。Collector が
#   (A) otlphttp + sigv4auth → CloudWatch OTel メトリクス(OTLP エンドポイント)
#   (B) awsemf               → CloudWatch Classic(EMF ログ経由のカスタムメトリクス)
# の2系統へ fan-out する。アプリ改修なしで dual-write(移行ガイド Phase 1)が成立する。
#
# 動作確認: opentelemetry-collector-contrib v0.155.0 / ap-northeast-1
# =============================================================================

# ------------------------------------------------------------------
# extensions: SigV4 署名。CloudWatch OTLP エンドポイントの認証に必須
# ------------------------------------------------------------------
extensions:
  sigv4auth:
    # OTLP エンドポイントの SigV4 サービス名は "monitoring"(固定)
    service: "monitoring"
    region: "${env:AWS_REGION}"
    # 認証情報は EC2 インスタンスロール(IMDS)から自動取得される

receivers:
  # アプリからのメトリクスを OTLP で受ける(gRPC/HTTP 両対応にしておく)
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  # バッチ化は公式推奨。OTLP エンドポイントの制限
  # (1MB/リクエスト、1,000 datapoint/リクエスト)にも収まりやすくなる
  batch:
    send_batch_size: 200
    timeout: 10s

  # Classic(awsemf) 行きにだけ挟む temporality 変換。
  # OTel SDK デフォルトの cumulative のまま EMF に書くと、Classic 側の
  # SampleCount/Sum/Max が「プロセス開始以来の累積」になってしまい、
  # Average が直近の値を反映しない(=アラームが発火しない)。実測で確認済み
  cumulativetodelta:

exporters:
  # ---------------------------------------------------------------
  # (A) OTel メトリクスへ: CloudWatch OTLP エンドポイント直送
  #     - HTTP/1.1 のみ対応(gRPC 非対応)なので otlphttp exporter を使う
  #     - 認証は sigv4auth extension に委譲
  # ---------------------------------------------------------------
  otlphttp:
    metrics_endpoint: "https://monitoring.${env:AWS_REGION}.amazonaws.com/v1/metrics"
    auth:
      authenticator: sigv4auth

  # ---------------------------------------------------------------
  # (B) Classic へ: awsemf exporter
  #     - CloudWatch Logs に EMF(Embedded Metric Format)で書き込み、
  #       CloudWatch がログからカスタムメトリクスを生成する
  #     - つまり Classic 側は logs 系の IAM 権限が別途必要
  # ---------------------------------------------------------------
  awsemf:
    region: "${env:AWS_REGION}"
    # Classic 側のカスタム名前空間(OTel 側には名前空間の概念がない → ズレ検証項目)
    namespace: "DualWriteLab"
    log_group_name: "/metrics/dual-write-lab"
    log_stream_name: "collector"
    # デフォルトは ZeroAndSingleDimensionRollup(次元なし+各次元単独のロールアップ
    # メトリクスも追加生成される)。Classic のメトリクス数課金が膨らむ要因になるので
    # 移行検証では等価比較しやすい NoDimensionRollup を明示する
    dimension_rollup_option: "NoDimensionRollup"
    # リソース属性(service.name 等)をディメンションに昇格させない。
    # 昇格させると Classic 側のディメンション数が膨らみ、
    # 既存 PutMetricData 運用との比較が崩れるため
    resource_to_telemetry_conversion:
      enabled: false

service:
  extensions: [sigv4auth]
  # ★dual-write の本体: 同じ otlp receiver を2つの pipeline に接続して fan-out する。
  #   exporters: [otlphttp, awsemf] と1本の pipeline に並べるだけでも fan-out には
  #   なるが、Classic 行きにだけ cumulativetodelta を挟みたいので pipeline を分ける
  pipelines:
    # (A) OTel メトリクスへ: cumulative のまま送る(PromQL の rate() 等が正しく効く)
    metrics/otel:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp]
    # (B) Classic へ: delta に変換してから EMF に書く
    metrics/classic:
      receivers: [otlp]
      processors: [cumulativetodelta, batch]
      exporters: [awsemf]
  telemetry:
    logs:
      level: info

設計のキモは pipeline を2本に分けているところです。exporters: [otlphttp, awsemf] と1本の pipeline に並べるだけでも fan-out にはなるのですが、それだと後述する cumulative 問題を回避できません。cumulativetodelta processor を Classic 行きにだけ挟みたいので、同じ receiver を2つの pipeline に接続しています。OTel 行きは cumulative のまま送らないと PromQL の rate() 等が正しく効かなくなるので、両方に挟むのも NG です。

なお Collector v0.155.0 の起動ログには "otlphttp" alias is deprecated; use "otlp_http" instead という警告が出ます。公式ドキュメントのサンプルは 2026年7月7日時点で otlphttp のままなので、警告が出ても慌てなくて大丈夫です(動作はします)。

3. 両系統にメトリクスが届くことを確認する

apply から約10分後、まず Classic 側です。

$ aws cloudwatch list-metrics --namespace DualWriteLab --output json

7メトリクス系列(app.requests ×4、app.request.duration ×2、app.queue.depth ×1)が出現しました。代表例がこちらです。

{
    "Namespace": "DualWriteLab",
    "MetricName": "app.request.duration",
    "Dimensions": [
        { "Name": "endpoint", "Value": "/api/orders" },
        { "Name": "OTelLib", "Value": "dual-write-generator" }
    ]
}

アプリが付けた覚えのない OTelLib というディメンションが付いています。これは awsemf exporter が OTel の instrumentation scope 名を自動追加したもので、後でしっかりハマりますww(ハマったポイント参照)

classic-metrics-console

次に OTel 側。PromQL API を awscurl で叩きます。

$ awscurl --service monitoring --region ap-northeast-1 -X POST \
    -H 'Content-Type: application/x-www-form-urlencoded' \
    -d 'query={"app.requests", "@resource.service.name"="dual-write-demo"}' \
    'https://monitoring.ap-northeast-1.amazonaws.com/api/v1/query'
{
    "metric": {
        "@aws.account": "123456789012",
        "@aws.region": "ap-northeast-1",
        "@instrumentation.@name": "dual-write-generator",
        "@resource.deployment.environment.name": "lab",
        "@resource.service.instance.id": "56a36398-937a-4a45-a44e-ca906ef2f5ec",
        "@resource.service.name": "dual-write-demo",
        "@resource.service.namespace": "devio-lab",
        "@resource.telemetry.sdk.language": "python",
        "@resource.telemetry.sdk.name": "opentelemetry",
        "@resource.telemetry.sdk.version": "1.43.0",
        "__monotonicity__": "true",
        "__name__": "app.requests",
        "__temporality__": "cumulative",
        "__type__": "Sum",
        "__unit__": "{request}",
        "endpoint": "/api/orders",
        "status_code": "200"
    },
    "value": [1783409587.083, "968"]
}

OTel 側はリソース属性が @resource.*、AWS メタデータが @aws.* として全部ラベル化され、__type__ / __temporality__ / __unit__ といったメタラベルまで付いています。情報量の差が一目瞭然ですね。

肝心の数値の一致も確認しました。同一メトリクス app.request.duration(endpoint=/api/orders)の平均値を両系統で取ると、

  • OTel 側(histogram_avg(...)): 0.17739648746006
  • Classic 側(get-metric-statistics --statistics Average の直近 datapoint): 0.17896521488163447

histogram の集約タイミング差程度でほぼ一致です。dual-write の等価性が確認できました!

query-studio-histogram-avg

なおこのスクショ、凡例が @resource.service.instance.id ごとに 7系列に分かれている点に注目してください。これも後述の「系列分離」の伏線です。

4. 名前・次元/ラベルのズレを対応表にする

Validate フェーズで一番効いてくるのがこの対応表だと思います。同一の OTel メトリクス app.request.duration(histogram, unit="s")が両系統でどう見えるかの実測結果です。

観点 Classic 側(awsemf 経由) OTel 側(OTLP エンドポイント)
メトリクス名 app.request.duration(ドット維持) __name__="app.request.duration"
名前空間 DualWriteLab(awsemf の config で指定。OTel 側に対応概念なし なし(@resource.service.namespace 等で代替)
datapoint 属性 endpoint ディメンション endpoint ラベル endpoint(bare)または @datapoint.endpoint
instrumentation scope ディメンション OTelLib が自動追加される ラベル @instrumentation.@name
リソース属性(service.name 等) 消えるresource_to_telemetry_conversion: false の場合) @resource.service.name 等すべてラベルとして保持
単位 Unit: Seconds(EMF が "s" → "Seconds" に変換) __unit__ ラベルに OTel の unit 文字列を保持(実測: __unit__="{request}"
型情報 なし(統計値セットに落ちる) __type__ / __temporality__ / __monotonicity__ ラベル
histogram Min/Max/Sum/Count の統計値セット ネイティブ histogram(histogram_avg 等で操作)
AWS メタデータ なし @aws.account / @aws.region が自動付与

Classic 側が実際どう作られているかは、awsemf が書いた EMF の生ログを見るとよく分かります。

{
    "OTelLib": "dual-write-generator",
    "Version": "1",
    "_aws": {
        "CloudWatchMetrics": [
            {
                "Namespace": "DualWriteLab",
                "Dimensions": [["endpoint", "OTelLib"]],
                "Metrics": [
                    { "Name": "app.request.duration", "Unit": "Seconds", "StorageResolution": 60 }
                ]
            }
        ],
        "Timestamp": 1783409855505
    },
    "app.request.duration": { "Max": 0, "Min": 0, "Count": 37, "Sum": 6.018531870272575 },
    "endpoint": "/api/orders"
}

Max: 0, Min: 0 になっているのは cumulativetodelta の副作用です(ハマったポイント参照)。

5. Classic アラームを残したまま PromQL アラームを並走させる

同じ「/api/orders の平均レイテンシ > 0.5s」を、Classic アラーム(awsemf 経由のメトリクス)と PromQL アラーム(OTLP 経由のメトリクス)の2本で監視し、generator を spike モード(duration を 0.05〜0.30s → 0.8〜1.5s に)へ切り替えて発火させました。

alarm-list

結果の時系列がこちらです(2026年7月7日実測)。

時刻 (JST) イベント
16:40:23 generator を spike モードで再起動
16:41:31 Classic アラーム OK → ALARM(spike から +68秒)
16:43:01 PromQL アラーム OK → ALARM(spike から +158秒)
16:46:10 generator を normal モードに復帰
16:48:31 Classic アラーム ALARM → OK(復帰から +141秒)
16:52:01 PromQL アラーム ALARM → OK(復帰から +351秒)
$ aws cloudwatch describe-alarm-history --alarm-name dualwrite-lab-classic-orders-latency \
    --history-item-type StateUpdate --max-records 5 \
    --query 'AlarmHistoryItems[].[Timestamp,HistorySummary]' --output text
2026-07-07T16:41:31.653000+09:00        Alarm updated from OK to ALARM
2026-07-07T16:37:31.652000+09:00        Alarm updated from INSUFFICIENT_DATA to OK

同一の異常で両アラームがきちんと発火しました! PromQL 側が +90秒ほど遅いのは設定通りで、evaluation_interval=60 + pending_period=60 の合算です。復帰も recovery_period=120 の分だけ遅くなります。Classic の period × evaluation_periods とはパラメータ体系が違うので、移行時は「何秒で発火してほしいか」から逆算して設定し直す必要があります。

alarm-history-classic

alarm-history-promql

並走してみて気づいた挙動の違いも2つ共有します。

  • PromQL アラームは作成直後から OK 状態で始まります(Classic は INSUFFICIENT_DATA から)。並走監視ダッシュボードで初期状態の見え方が違うので、「PromQL 側が OK = データが来ている」とは限らない点に注意です
  • PromQL アラームの詳細画面には Classic にない**「寄稿者(Contributors)」セクション**があります。ちょっとした UI の発見でした

なお、Classic アラーム詳細のスクショに「データ不足」の期間が写っていますが、これは後述する OTelLib ディメンション不一致で INSUFFICIENT_DATA のまま放置されていた時代の傷跡です。

6. 二重課金がどう見えるか

まず前提の料金体系(2026年7月7日時点、公式料金ページ)を整理します。

項目 Classic OTel
取り込み PutMetricData $0.01/100万リクエスト $0.50/GB(非圧縮 OTLP ペイロード換算、15か月保存込み)
保存 $0.30/メトリクス/月(第1ティア) 取り込みに込み(メトリクス数課金なし)
クエリ GetMetricData 等の API 課金 PromQL API $0.01/100万サンプルスキャン。コンソール(Query Studio・ダッシュボード)は無料。PromQL アラームは評価ごとにスキャン課金

公式試算では 5,000系列・60秒間隔・500B/datapoint で 108GB/月 = **$54(OTel)vs $1,500(Classic 第1ティア)**という例が示されており、系列数(カーディナリティ)が多いほど OTel が圧倒的に有利です。dual-write 期間中はこの両方がかかるので、期間は短く保つのが基本ですね。

今回の検証での実際の見え方はこうでした。

  • 公式ドキュメントには「OTel の取り込み量は Usage Metrics で確認できる」とあるのですが、検証当日の AWS/Usage 名前空間には該当メトリクスが未出現でした(出ていたのは API CallCount と Resource カウントのみ)。反映遅延のようです
  • Cost Explorer の usage type 実名と実額は、請求データ反映(最大24時間)を待って確認します

約2時間11分の検証全体の概算費用は以下の通りです。

項目 概算
EC2 t3.micro オンデマンド 約2.2h(ap-northeast-1 $0.0136/h) 約 $0.03
EBS gp3 8GB 約2.2h 約 $0.002
Classic カスタムメトリクス(app系 8個 ×2h + cardinality.test 600個 × 断続約25分の時間按分) 約 $0.3〜0.5
OTel 取り込み(app系 約2.5MB + cardinality 約20MB、$0.50/GB) 約 $0.01
EMF ログ取り込み(数十MB) 約 $0.02
アラーム2本 ×2h + PromQL 評価スキャン ほぼ $0
合計 $1 未満の見込み

同じ2時間でも、コストの主因が Classic のメトリクス数課金(特にカーディナリティ検証で作った600系列)だったのが示唆的でした。

ハマったポイント

12個踏んだのですが、特に移行時に効きそうなものから順に紹介します。同じことを試す方の参考になれば!

1. cumulative のまま awsemf に流すと Classic 統計が「プロセス開始以来の累積」になる

これが一番の落とし穴でした。OTel SDK のデフォルト(cumulative temporality)のまま awsemf に流すと、histogram の sum/count/max が「プロセス開始からの累積」として毎回 export され、Classic 側の SampleCount が単調増加していきます。実測ログがこちらです(16:36:54 に cumulativetodelta 入りの config で Collector を再起動)。

2026-07-07T16:32:00+09:00 SampleCount: 5613.0 Avg: 0.1776 Max: 0.2999   ← cumulative(累積し続ける)
2026-07-07T16:33:00+09:00 SampleCount: 6741.0 Avg: 0.177  Max: 0.2999
2026-07-07T16:34:00+09:00 SampleCount: 7910.0 Avg: 0.1762 Max: 0.2999
2026-07-07T16:35:00+09:00 SampleCount: 8950.0 Avg: 0.1758 Max: 0.2999
2026-07-07T16:37:00+09:00 SampleCount: 186.0  Avg: 0.1748 Max: 0.0     ← delta(分あたり件数に正常化)

cumulative のままだと Average は「プロセス開始以来の平均」になるので、スパイクが起きても Average がなかなか動きません。つまり既存の感覚で作った Classic アラームが発火しない/大幅に遅れるという、監視として最悪の壊れ方をします。回避策が config の cumulativetodelta です。

ただしトレードオフがあります。上のログの通り、delta 変換後は histogram の Min/Max が 0 になります(cumulative な min/max は delta に変換できないため落とされる)。Average・SampleCount・Sum は正しいのですが、Maximum 統計を使う Classic アラームがある場合はこの構成では維持できません。移行チェックリストの必須確認項目です。

2. OTelLib ディメンション不一致で Classic アラームが INSUFFICIENT_DATA のまま

アプリが付けた datapoint 属性だけを見て dimensions = { endpoint = "/api/orders" } で Classic アラームを作ったところ、10分以上経っても INSUFFICIENT_DATA(StateReason: "Unchecked: Initial alarm creation")のままでした。

原因は前述の通り、awsemf が OTelLib ディメンションを全メトリクスに自動追加するためです。NoDimensionRollup 設定だと [endpoint] だけの系列は存在しないので、アラームがどの系列にも一致しません。OTelLib をディメンションに加えて更新したら数分で OK に遷移しました。

$ aws cloudwatch describe-alarms --alarm-names dualwrite-lab-classic-orders-latency \
    --query 'MetricAlarms[0].StateValue' --output text
OK

エラーにならず静かにデータ不足が続くだけなので、気づきにくいです。dual-write で「Classic 側の見た目を既存と完全に揃えたい」場合は、この自動追加ディメンションの存在を織り込んでください。

3. IAM の cloudwatch:namespace 条件で OTLP が暗黙 Deny される

Classic 運用でよくある「名前空間を絞った PutMetricData 許可」がそのまま罠になります。この条件付きポリシーで検証しました。

{
  "Effect": "Allow",
  "Action": ["cloudwatch:PutMetricData"],
  "Resource": "*",
  "Condition": {
    "StringEquals": { "cloudwatch:namespace": "DualWriteLab" }
  }
}

このロールで Classic の put-metric-data は成功(exit=0)するのに、OTLP エンドポイントは拒否されます。

$ awscurl --service monitoring --region ap-northeast-1 -X POST \
    -H 'Content-Type: application/json' -d '{}' \
    'https://monitoring.ap-northeast-1.amazonaws.com/v1/metrics'
{
  "code": 7,
  "message": "User: arn:aws:sts::123456789012:assumed-role/dualwrite-lab-ns-condition-test/ns-test is not authorized to perform: cloudwatch:PutMetricData on resource: arn:aws:cloudwatch:ap-northeast-1:123456789012:dataset/default because no identity-based policy allows the cloudwatch:PutMetricData action"
}

OTLP リクエストには namespace という概念がないため、StringEquals の条件キーが評価できず暗黙 Deny になるのが原因です(公式ドキュメントの記述どおり)。エラーメッセージから、OTLP の書き込み先が arn:aws:cloudwatch:{region}:{account}:dataset/default という Classic には無い「dataset」リソースであることも分かりました。権限トラブルの切り分けに使えそうです。

回避策は条件演算子を StringEqualsIfExists に変えるだけです。ポリシー更新後に同じリクエストが通ることも実証できました(「namespace 条件キーがあるリクエストは DualWriteLab のみ許可、無いリクエストは条件スキップで許可」の意味になります)。dual ingest 期間の IAM はこれが公式推奨です。

4. 「系列が分かれる」の実態は再起動と quote 忘れ

PromQL アラームには「クエリが単一系列を返すこと」という要件があります。ここで2つ踏みました。

(a) プロセス再起動ごとに系列が分かれる。 generator(OTel SDK アプリ)を再起動するたびにリソース属性 service.instance.id が変わり、同じメトリクス・同じ属性でも別系列になります。

=== histogram_avg({"app.request.duration","endpoint"="/api/orders"}) ===
series: 2    ← 同一メトリクスが2系列に分離
 instance.id= 0971539a-364c-4169-8e86-8f4d4e8eb928 value= 0.174414
 instance.id= 8872bcf3-ee95-4c1c-aa5b-8591018a82bc value= 0.172114

素の selector でアラームを作ると、デプロイ(再起動)のたびに複数系列になってアラームが壊れます。本検証のアラームクエリを avg(histogram_avg(...)) にしていたのはこのためで、再起動を挟んでも正常に評価され続けました。集約関数で明示的に潰しておくのが安全です。

(b) by 句のドット付きラベルは quote 必須。 OTel のラベルはドットを含むので、Prometheus の感覚で quote なしに書くとパースエラーです。

=== avg by (@resource.service.name) (...) — quote なし ===
{"error":"Invalid PromQL query: Failed to parse query: unexpected character after '.': 's'","errorType":"bad_data","status":"error"}

=== avg by ("@resource.service.name") (...) — quote あり ===
{"status":"success", ... "metric": {"@resource.service.name": "dual-write-demo"}, "value": [..., "0.17135745198395344"]}

正しい構文で集約すればきちんと1系列に潰れるので、「avg by しても分かれる」という噂は(少なくとも私の環境では)再現しませんでした。

5. 500系列超えはサイレントに切り捨てられる(Query Studio は警告なし)

PromQL クエリには 500系列/クエリの上限があります。series_id ラベル600種のメトリクスを作って試したところ、

$ awscurl ... -d 'query={"cardinality.test"}' .../api/v1/query
status: success
warnings: ['results truncated due to limit (500)']
series returned: 500      ← 600系列中500系列で切り捨て

HTTP 200 の success のまま、warnings 付きで切り捨てられます。エラーにならないので、warnings を見ないクライアントは「全系列取れた」と誤認します。さらに怖いのが Query Studio で、切り捨てが起きても画面のどこにも警告が出ません(2026年7月7日時点)。グラフと凡例は500系列分だけ描画され、超過分の存在に気づく手段が UI 上にないのです。

query-studio-truncation

移行後のダッシュボードでは「500系列を超えうる selector を書かない」「必ず集約を挟む」が必須の運用ルールになりそうです。

ちなみにこの600系列、dual-write なので Classic 側にも600カスタムメトリクスとして実際に出現しました。Classic のメトリクス数課金がカーディナリティに比例して膨らむことの実物でもあります(確認後すぐ止めました)。

6. クエリレンジ上限はドキュメントと3点ズレている

7日超レンジの拒否を確認しようとしたら、ドキュメントとの差異を3つ見つけました(2026年7月7日時点の実測)。

=== (1) 8日レンジの query_range → 通る(拒否されない!)===
{"status":"success","data":{"resultType":"matrix","result":[]}}

=== (2) 30日レンジの query_range → 拒否 ===
{"error":"Query time range 2592000000ms exceeds 691200000ms limit","errorType":"bad_data","status":"error"}

=== (3) range selector で 8日 → 別の上限で拒否 ===
{"error":"Range selector 691200000ms exceeds 86400000ms limit","errorType":"bad_data","status":"error"}
  1. 実装上のレンジ上限は 691200000ms = 8日(ドキュメントは「7日(lookback 込み)」)。7日 + 1日の lookback バッファではないかと推測しています
  2. range selector([...])には独立した 24時間上限があります(86400000ms)。avg_over_time(x[8d]) のような長い window はこちらに先に当たります。ドキュメントの制限表には未記載でした
  3. エラー時の HTTP ステータスは 400 BadRequestException(ドキュメントの制限表では 422)

このあたりは今後ドキュメントか実装のどちらかが追従すると思うので、「現時点ではこう動いた」という記録としてお読みください。

その他の細かいハマり

  • brew install terraform は homebrew-core から formula 削除済み(BUSL ライセンス変更のため)で入らない → brew tap hashicorp/tap && brew install hashicorp/tap/terraform
  • AL2023 の dnf install docker には compose / buildx が含まれず、docker compose build が "compose build requires buildx 0.17.0 or later" で失敗 → GitHub releases から buildx v0.35.0 を追加
  • Query Studio の Builder モードは histogram_avg() 等の Function を自動付与するので、counter/gauge ではそのまま実行すると "No data available" になる → Function を外す
  • awsemf が自動作成するロググループ /metrics/dual-write-lab は Terraform 管理外なので、terraform destroy 後に手動削除が必要だった

結果: 移行判断チェックリスト

今回の実測を踏まえて、「dual-write を始める前に確認すべきこと」をチェックリストにまとめました。

確認項目 なぜ確認するか 本検証での根拠
メトリクスのカーディナリティ(系列数)はどれくらいか 系列数が多いほど OTel のコストメリットが大きい。公式試算では5,000系列で $54 vs $1,500(2026年7月7日時点) dual-write した600系列が Classic 側で600メトリクス課金として実体化した
Maximum / Minimum 統計のアラーム・ダッシュボードがあるか cumulativetodelta 経由の Classic 側では histogram の Min/Max が 0 になり維持できない EMF 生ログで Max: 0, Min: 0 を実測
Classic アラームの本数と、その定義がディメンション完全一致か awsemf の OTelLib 自動追加により、既存の感覚のディメンション指定では INSUFFICIENT_DATA になる ディメンション不一致で10分以上 INSUFFICIENT_DATA を実測
PromQL アラームのクエリは集約関数で単一系列に潰してあるか 再起動ごとの service.instance.id 変化で系列が分かれ、単一系列要件に抵触する 再起動直後に2系列へ分離するのを実測
500系列を超えうる selector をダッシュボードに書いていないか API は HTTP 200 + warnings、Query Studio は警告なしでサイレントに切り捨てる 600系列 → 500系列切り捨てを実測
IAM に cloudwatch:namespaceStringEquals 条件がないか OTLP リクエストが暗黙 Deny される。StringEqualsIfExists へ変更が必要 AccessDenied と IfExists での解消を実測
アラームの発火・復帰までの所要時間の要件 PromQL アラームは evaluation_interval + pending_period / recovery_period の体系で、Classic と同じ感覚では組めない 同一スパイクで Classic +68秒 / PromQL +158秒を実測
IaC の対応状況 Terraform AWS Provider は v6.53.0 で promql_criteria 対応済み。CDK にも PromQLAlarm L2 コンストラクトあり v6.53.0 で apply 成功

まとめ

今回は CloudWatch の Classic メトリクスから OTel メトリクスへ、既存監視を止めずに移行するための dual-write を実機検証しました!

わかったこと:

  • Collector 1つの fan-out 設定で、アプリ改修ゼロの dual-write が成立する。同一メトリクスの平均値は両系統でほぼ一致(約0.1774 vs 約0.1790)し、同一スパイクで両アラームが発火した
  • pipeline は2本に分けるのが正解。Classic 行きにだけ cumulativetodelta を挟まないとアラームが機能せず、OTel 行きに挟むと PromQL が壊れる
  • 「同じメトリクス」でも両系統の見え方は結構違うOTelLib 自動追加・リソース属性の消失・unit 変換・メタラベルなど、Validate フェーズでは対応表を片手に突き合わせる必要がある
  • Terraform AWS Provider v6.53.0 は PromQL アラーム対応済み。既存の aws_cloudwatch_metric_alarm のまま移行できる
  • 落とし穴は「エラーにならない」タイプが多い。INSUFFICIENT_DATA のまま沈黙するアラーム、警告なしの500系列切り捨て、暗黙 Deny。移行検証は「動いた」ではなく「壊れ方を知る」つもりでやるのが良さそうです

正直、検証前は「dual-write なんて exporter を2つ並べるだけでしょ」と思っていたのですが、実際にやってみると temporality・ディメンション・IAM と、系統ごとの世界観の違いに起因する罠が想像以上にありました。机上の設計だけで移行計画を立てなくて本当に良かったです。約2時間・$1 未満でこれだけの落とし穴を事前に踏めるなら、本番移行の前に小さく dual-write 検証を回すのは圧倒的にコスパが良いと感じました!

一方で、今回の検証は n=1・小規模構成です。大規模環境での Collector のスループットや、コンテナ環境(EKS の dual publishing)はカバーできていないので、そこは公式ガイドと今後の検証に委ねます。

この記事が、これから移行を計画する方の助けになれば幸いです。
最後まで読んでいただき、誠にありがとうございました。

参考記事

公式ドキュメント・アナウンス

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事