Amazon CloudWatch が OpenTelemetry メトリクスをネイティブサポート(GA)— OTLP 直接送信と PromQL を試してみた
はじめに
2026年6月16日、Amazon CloudWatchがOpenTelemetryメトリクスのネイティブサポートを一般提供(GA)しました。
これに伴い、公式ドキュメントではCloudWatchのメトリクスモデルが2つに整理されました。従来のPutMetricData / EMFで送信しNamespace・MetricName・Dimensionsで管理するモデルは CloudWatch Metrics (Classic) と呼ばれるようになっています。今回追加されたOTLPで送信しPromQLで参照するモデルは OpenTelemetry Metrics (Recommended) として位置付けられています。
これまでもOTel SDKやCollectorでCloudWatchにメトリクスを送ることはできましたが、その場合はEMFやPutMetricData経由でClassicモデルとして格納されるのが一般的でした。今回のアップデートではOTLPを直接受け付ける新しいメトリクスストアが追加され、PromQLでネイティブにクエリできます。
何が変わったか
| 項目 | CloudWatch Metrics (Classic) | OpenTelemetry Metrics |
|---|---|---|
| 課金モデル | ユニークメトリクス数 × 月 + API リクエスト数 | 取り込み・保存が GB ベース |
| クエリ言語 | Statistics / Metric Math / Metrics Insights | PromQL (query / query_range) |
| 型意味論 | なし(数値 datapoint) | Counter (Sum) / Gauge を区別 |
| 送信プロトコル | PutMetricData API | OTLP over HTTP (protobuf) |
このアップデートが大きい理由
- 課金モデルの変更: 高カーディナリティなワークロード(ラベルの組み合わせが多い)ではコスト構造が大きく変わります
- PromQL エコシステムとの接続: Prometheus HTTP API互換のquery/query_rangeで参照でき、既存クエリ資産を流用しやすくなります
- 型意味論の導入: CounterとGaugeを区別でき、単調増加のSumに対して
rate()やincrease()を意図に沿って使いやすくなります
コスト試算例
公式の料金ページに掲載されている例をもとに、同じワークロードでの月額を比較します。
シナリオ: 100インスタンス × 50メトリクス = 5,000ユニークメトリクス、1分間隔で送信、1 datapointあたり約500バイト
| 項目 | Classic | OTel Metrics |
|---|---|---|
| メトリクス数課金 | 5,000 × $0.30 = $1,500/月 | なし |
| API課金 (PutMetricData) | $0.01/1,000リクエスト(別途) | なし(取り込みに含む) |
| 取り込み課金 | — | 108 GB × $0.50 = $54/月 |
| 保存 | 15か月(課金に含む) | 15か月(課金に含む) |
| 合計 | 約 $1,500+/月 | 約 $54/月 |
※ OTelの取り込み量: 5,000 dp/min × 500 bytes × 60 × 24 × 30 ≈ 108 GB/月
※ 前提や単価は 公式料金ページ のExample 22より引用。リージョンにより異なります
※ これは筆者による単純化した比較であり、実際のコストは圧縮率・attributes数・クエリ頻度・無料枠などで変動します
高カーディナリティ(ユニークメトリクス数が多い)なワークロードほど、OTelのGBベース課金が有利になります。逆に、メトリクス数が少なくClassicの無料枠(10メトリクス)で収まる規模では差が出にくい場合もあります。
検証の概要
メトリクス設計
| メトリクス名 | OTLP 型 | 意味 | 確認するクエリ |
|---|---|---|---|
cpu_usage_percent |
Gauge | CPU使用率(0〜100) | avg_over_time, max_over_time, quantile_over_time, 閾値超過 |
network_bytes_sent_total |
Sum (monotonic, cumulative) | 累積送信バイト数 | rate(), increase() |
GaugeとCounterでPromQLの関数選択がどう変わるかを確認します。
データパターン
- 1分間隔 × 24時間 = 各1,440ポイント
- 過去24時間分のタイムスタンプを付けて一括送信(バックフィル)
cpu_usage_percent: 日中40-80%、夜間10-30% のサイン波 + 昼スパイク(90%超)+ ノイズnetwork_bytes_sent_total: 単調増加。日中レート高、夜間レート低。昼に急増区間
検証環境
- リージョン: ap-northeast-1
- EC2: Amazon Linux 2023, t3.micro
- Python 3.11 + Docker
- 主要ライブラリ:
opentelemetry-proto==1.31.0,boto3==1.38.0,requests==2.32.3
IAM 権限
| 用途 | 必要な Action |
|---|---|
| OTLP 送信 | cloudwatch:PutMetricData |
| PromQL クエリ | cloudwatch:GetMetricData, cloudwatch:ListMetrics |
本検証では上記権限でOTLP送信・PromQLクエリとも200応答を確認しました。公式ドキュメントでもOTLP送信に必要なActionとして cloudwatch:PutMetricData が記載されています。
データ生成
1分間隔 × 24時間のテストデータを生成します。
generate_data.py(クリックで展開)
"""Generate dummy metrics data: cpu_usage_percent (Gauge) and network_bytes_sent_total (Counter)."""
import json
import math
import random
import time
from datetime import datetime, timezone
INTERVAL_SEC = 60
POINTS = 1440 # 24h * 60min
SEED = 42
def generate_cpu_usage(now_ts: float) -> list[dict]:
"""Generate cpu_usage_percent: sine wave with day/night pattern + noon spike + noise."""
random.seed(SEED)
start_ts = now_ts - (POINTS * INTERVAL_SEC)
data = []
for i in range(POINTS):
ts = start_ts + i * INTERVAL_SEC
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
hour = dt.hour + dt.minute / 60.0
# Base: sine wave peaking at 13:30
base = math.sin((hour - 7) / 24 * 2 * math.pi)
if 9 <= hour <= 18:
value = 40 + 40 * max(0, base) # 40-80%
else:
value = 10 + 20 * max(0, base + 0.5) # 10-30%
# Noon spike
if 11.5 <= hour <= 12.5:
value = 90 + random.uniform(0, 10)
# Random noise
value += random.uniform(-3, 3)
value = max(0, min(100, value))
data.append({"timestamp_ns": int(ts * 1e9), "value": round(value, 2)})
return data
def generate_network_bytes(now_ts: float) -> list[dict]:
"""Generate network_bytes_sent_total: monotonically increasing counter."""
random.seed(SEED + 1)
start_ts = now_ts - (POINTS * INTERVAL_SEC)
cumulative = 1_000_000
data = []
for i in range(POINTS):
ts = start_ts + i * INTERVAL_SEC
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
hour = dt.hour + dt.minute / 60.0
if 9 <= hour <= 18:
rate = random.uniform(5000, 15000)
else:
rate = random.uniform(500, 2000)
if 11.5 <= hour <= 12.5:
rate = random.uniform(30000, 50000)
cumulative += rate
data.append({"timestamp_ns": int(ts * 1e9), "value": round(cumulative, 2)})
return data
def main():
now_ts = time.time()
cpu_data = generate_cpu_usage(now_ts)
net_data = generate_network_bytes(now_ts)
output = {
"generated_at": datetime.fromtimestamp(now_ts, tz=timezone.utc).isoformat(),
"interval_sec": INTERVAL_SEC,
"points": POINTS,
"metrics": {
"cpu_usage_percent": {
"type": "gauge",
"unit": "percent",
"data": cpu_data,
},
"network_bytes_sent_total": {
"type": "sum",
"unit": "bytes",
"is_monotonic": True,
"aggregation_temporality": "CUMULATIVE",
"data": net_data,
},
},
}
with open("results/generated_data.json", "w") as f:
json.dump(output, f)
print(f"Generated {POINTS} points for 2 metrics")
if __name__ == "__main__":
main()
OTLP でメトリクスを送信する
エンドポイント情報
| 項目 | 値 |
|---|---|
| URL | https://monitoring.{Region}.amazonaws.com/v1/metrics |
| Content-Type | application/x-protobuf |
| SigV4 service name | monitoring |
| 成功レスポンス | HTTP 200、ボディ空 |
送信スクリプト
send_otel.py(クリックで展開)
"""Send metrics to CloudWatch OTLP endpoint with SigV4 signing."""
import json
import time
from datetime import datetime, timezone
import requests
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
from botocore.session import Session
from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2 import (
ExportMetricsServiceRequest,
)
from opentelemetry.proto.common.v1.common_pb2 import AnyValue, KeyValue
from opentelemetry.proto.metrics.v1.metrics_pb2 import (
AggregationTemporality,
Gauge,
Metric,
NumberDataPoint,
ResourceMetrics,
ScopeMetrics,
Sum,
)
from opentelemetry.proto.resource.v1.resource_pb2 import Resource
REGION = "ap-northeast-1"
ENDPOINT = f"https://monitoring.{REGION}.amazonaws.com/v1/metrics"
SERVICE_NAME = "monitoring"
BATCH_SIZE = 1000
def get_credentials():
session = Session()
return session.get_credentials().get_frozen_credentials()
def build_resource() -> Resource:
return Resource(
attributes=[
KeyValue(key="service.name", value=AnyValue(string_value="otel-metrics-demo")),
KeyValue(key="service.version", value=AnyValue(string_value="1.0.0")),
KeyValue(key="deployment.environment", value=AnyValue(string_value="test")),
]
)
def build_gauge_request(data_points: list[dict]) -> ExportMetricsServiceRequest:
"""Build OTLP request for cpu_usage_percent (Gauge)."""
start_time_ns = data_points[0]["timestamp_ns"]
ndps = [
NumberDataPoint(
time_unix_nano=dp["timestamp_ns"],
start_time_unix_nano=start_time_ns,
as_double=dp["value"],
)
for dp in data_points
]
metric = Metric(
name="cpu_usage_percent",
unit="percent",
gauge=Gauge(data_points=ndps),
)
return _wrap_metric(metric)
def build_sum_request(data_points: list[dict]) -> ExportMetricsServiceRequest:
"""Build OTLP request for network_bytes_sent_total (Sum/Counter)."""
start_time_ns = data_points[0]["timestamp_ns"]
ndps = [
NumberDataPoint(
time_unix_nano=dp["timestamp_ns"],
start_time_unix_nano=start_time_ns,
as_double=dp["value"],
)
for dp in data_points
]
metric = Metric(
name="network_bytes_sent_total",
unit="bytes",
sum=Sum(
data_points=ndps,
aggregation_temporality=AggregationTemporality.AGGREGATION_TEMPORALITY_CUMULATIVE,
is_monotonic=True,
),
)
return _wrap_metric(metric)
def _wrap_metric(metric: Metric) -> ExportMetricsServiceRequest:
return ExportMetricsServiceRequest(
resource_metrics=[
ResourceMetrics(
resource=build_resource(),
scope_metrics=[ScopeMetrics(metrics=[metric])],
)
]
)
def send_request(payload: bytes, credentials) -> dict:
"""Send protobuf payload with SigV4 signature."""
headers = {"Content-Type": "application/x-protobuf"}
aws_request = AWSRequest(method="POST", url=ENDPOINT, data=payload, headers=headers)
SigV4Auth(credentials, SERVICE_NAME, REGION).add_auth(aws_request)
response = requests.post(
ENDPOINT,
data=payload,
headers=dict(aws_request.headers),
timeout=30,
)
return {"status_code": response.status_code, "body": response.text}
def send_metric_batched(data_points: list[dict], build_fn, credentials, metric_name: str):
"""Send metric data in batches of 1000 datapoints."""
for i in range(0, len(data_points), BATCH_SIZE):
batch = data_points[i : i + BATCH_SIZE]
request = build_fn(batch)
payload = request.SerializeToString()
result = send_request(payload, credentials)
print(f" {metric_name} batch {i // BATCH_SIZE + 1}: {len(batch)} points, "
f"{len(payload)} bytes -> {result['status_code']}")
time.sleep(0.5)
def main():
with open("results/generated_data.json") as f:
data = json.load(f)
credentials = get_credentials()
print("=== Sending cpu_usage_percent (Gauge) ===")
send_metric_batched(
data["metrics"]["cpu_usage_percent"]["data"],
build_gauge_request, credentials, "cpu_usage_percent",
)
print("\n=== Sending network_bytes_sent_total (Sum/Counter) ===")
send_metric_batched(
data["metrics"]["network_bytes_sent_total"]["data"],
build_sum_request, credentials, "network_bytes_sent_total",
)
if __name__ == "__main__":
main()
送信結果
=== Sending cpu_usage_percent (Gauge) ===
cpu_usage_percent batch 1: 1000 points, 29145 bytes -> 200
cpu_usage_percent batch 2: 440 points, 12901 bytes -> 200
=== Sending network_bytes_sent_total (Sum/Counter) ===
network_bytes_sent_total batch 1: 1000 points, 29154 bytes -> 200
network_bytes_sent_total batch 2: 440 points, 12910 bytes -> 200
ポイント
- Sum/Counter では
start_time_unix_nanoの設定が必須です。 Gaugeでは任意です(本検証では揃えて設定しましたが、未設定でも問題なく取り込まれます) is_monotonic=Trueとaggregation_temporality=CUMULATIVEを指定すると、Counter(Sum)として扱われます。本検証ではrate()/increase()が意図通りに機能しましたunitフィールド(percent,bytes)はmetric nameには影響しません。PromQL上でそのままcpu_usage_percentで参照できます
PromQL でクエリする
API 情報
| 項目 | 値 |
|---|---|
| Instant query | /api/v1/query |
| Range query | /api/v1/query_range |
| HTTP method | GET / POST 両対応 |
| Content-Type | application/x-www-form-urlencoded(POST 時) |
ベースURLはOTLPと同じ https://monitoring.{Region}.amazonaws.com で、SigV4 service nameも monitoring です。
クエリスクリプト
query_promql.py(クリックで展開)
"""Query CloudWatch PromQL API and record results."""
import json
import time
from datetime import datetime, timezone
import requests
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
from botocore.session import Session
REGION = "ap-northeast-1"
BASE_URL = f"https://monitoring.{REGION}.amazonaws.com"
SERVICE_NAME = "monitoring"
def get_credentials():
session = Session()
return session.get_credentials().get_frozen_credentials()
def promql_query(query: str, credentials, time_param: str = None) -> dict:
"""Run PromQL instant query (/api/v1/query)."""
url = f"{BASE_URL}/api/v1/query"
params = {"query": query}
if time_param:
params["time"] = time_param
aws_req = AWSRequest(
method="POST", url=url, data=params,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
SigV4Auth(credentials, SERVICE_NAME, REGION).add_auth(aws_req)
resp = requests.post(url, data=params, headers=dict(aws_req.headers), timeout=30)
return resp.json()
def promql_query_range(query: str, start: str, end: str, step: str, credentials) -> dict:
"""Run PromQL range query (/api/v1/query_range)."""
url = f"{BASE_URL}/api/v1/query_range"
params = {"query": query, "start": start, "end": end, "step": step}
aws_req = AWSRequest(
method="POST", url=url, data=params,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
SigV4Auth(credentials, SERVICE_NAME, REGION).add_auth(aws_req)
resp = requests.post(url, data=params, headers=dict(aws_req.headers), timeout=30)
return resp.json()
def main():
credentials = get_credentials()
now = time.time()
# Gauge queries
print("avg_over_time(cpu_usage_percent[1h]):")
r = promql_query("avg_over_time(cpu_usage_percent[1h])", credentials)
print(f" -> {r['data']['result'][0]['value'][1]}")
print("max_over_time(cpu_usage_percent[24h]):")
r = promql_query("max_over_time(cpu_usage_percent[24h])", credentials)
print(f" -> {r['data']['result'][0]['value'][1]}")
print("quantile_over_time(0.95, cpu_usage_percent[24h]):")
r = promql_query("quantile_over_time(0.95, cpu_usage_percent[24h])", credentials)
print(f" -> {r['data']['result'][0]['value'][1]}")
# Counter queries
print("rate(network_bytes_sent_total[15m]):")
r = promql_query("rate(network_bytes_sent_total[15m])", credentials)
print(f" -> {r['data']['result'][0]['value'][1]}")
print("increase(network_bytes_sent_total[1h]):")
r = promql_query("increase(network_bytes_sent_total[1h])", credentials)
print(f" -> {r['data']['result'][0]['value'][1]}")
# Range query: threshold
start_24h = str(int(now - 86400))
end = str(int(now))
print("cpu_usage_percent > 70 (range query, step=60s):")
r = promql_query_range("cpu_usage_percent > 70", start_24h, end, "60", credentials)
points = len(r["data"]["result"][0]["values"])
print(f" -> {points} evaluation steps above threshold")
if __name__ == "__main__":
main()
クエリ結果
cpu_usage_percent (Gauge)
| クエリ | 結果 |
|---|---|
avg_over_time(cpu_usage_percent[1h]) |
62.17 |
max_over_time(cpu_usage_percent[24h]) |
100.00 |
quantile_over_time(0.95, cpu_usage_percent[24h]) |
81.69 |
cpu_usage_percent > 70(query_range, step=60s) |
322 評価ステップが閾値超過 |
network_bytes_sent_total (Counter)
| クエリ | 結果 |
|---|---|
rate(network_bytes_sent_total[15m]) |
61.66 bytes/sec |
increase(network_bytes_sent_total[1h]) |
535,767 bytes |
評価時刻のlookback内にデータが必要なため、rate() には [15m] を指定しています。
レスポンスに含まれるラベル
PromQLのレスポンスには、送信時に設定したresource attributesと、CloudWatchが自動付与するラベルが含まれます。
{
"metric": {
"@aws.account": "111122223333",
"@aws.region": "ap-northeast-1",
"@resource.deployment.environment": "test",
"@resource.service.name": "otel-metrics-demo",
"@resource.service.version": "1.0.0"
},
"value": [1781630360.281, "62.16982142857143"]
}
Counter(Sum)の場合は、さらにSum固有の属性ラベルが付与されます。
{
"__monotonicity__": "true",
"__temporality__": "cumulative",
"__type__": "Sum",
"__unit__": "bytes"
}
__type__ と __unit__ はGaugeにも付与されます(例: "Gauge", "percent")。__monotonicity__ と __temporality__ はSum固有です。
自動付与ラベル一覧
| 分類 | ラベル |
|---|---|
| CloudWatch 自動 | @aws.account, @aws.region |
| resource attributes 由来 | @resource.service.name, @resource.service.version, @resource.deployment.environment |
| OTel メトリクスメタ(全型共通) | __type__, __unit__ |
| Sum 固有属性 | __monotonicity__, __temporality__ |
PromQLのセレクタで @ を含むラベルを使う場合は引用符で囲みます。
cpu_usage_percent{"@resource.service.name"="otel-metrics-demo"}
Classic Metric Math との比較
同じデータをClassic (PutMetricData) にも投入し、GetMetricData + Metric Mathで同じ分析意図のクエリを実行しました。
結果比較
cpu_usage_percent (Gauge)
| やりたいこと | PromQL | Classic | PromQL 結果 | Classic 結果 |
|---|---|---|---|---|
| 1時間の平均 | avg_over_time(cpu_usage_percent[1h]) |
Period=3600, Stat=Average | 62.17 | 62.17 |
| 24時間の最大値 | max_over_time(cpu_usage_percent[24h]) |
Period=60, Stat=Maximum | 100.00 | 100.00 |
| p95 | quantile_over_time(0.95, cpu_usage_percent[24h]) |
Stat='p95', Period=3600 | 81.69 | 99.10(Period ごとの最大) |
| 閾値超過 | cpu_usage_percent > 70(query_range, step=60s) |
IF(m1 > 70, m1), Period=60 |
322 評価ステップ | 322 評価ステップ |
最大値と閾値超過は両者一致しました。p95のみ差があるのは計算方法の違いによるものです。PromQLの quantile_over_time(0.95, [24h]) は24h window内の全サンプルからグローバルにp95を算出します。一方Classicの Stat='p95' は各1時間Periodごとにp95を算出し、Period別の値を返します。
network_bytes_sent_total (Counter)
| やりたいこと | PromQL | Classic | PromQL 結果 | Classic 結果 |
|---|---|---|---|---|
| 秒あたり転送量 | rate(network_bytes_sent_total[15m]) |
RATE(m1), Period=300 |
61.66 | 103〜199(Period ごと) |
| 1時間の転送量 | increase(network_bytes_sent_total[1h]) |
DIFF(m1), Period=3600 |
535,767 | 564,519〜597,785(Period ごと) |
数値差の理由
最大値・閾値超過は両者一致し、Averageも一致しました。差が出たのはp95のみです。両モデルの計算上の違いとして、PromQLが送信サンプルの時系列に対して範囲関数を直接評価する一方、ClassicのGetMetricDataは指定Periodごとの統計値を返す点があります。
| 観点 | PromQL | Classic |
|---|---|---|
| データアクセス | 送信サンプルに対して直接評価 | Period 集約後の統計値 |
| p95 | 評価時刻から見た 24h window 内サンプルの p95 | 各 Period 内のローカル p95 |
| rate | lookback window 内のカウンター増分をリセット補正・外挿して秒あたりに換算 | 隣接 Period の差分 ÷ 秒数 |
| increase / DIFF | rate と同じ外挿補正を含む増分(rate × range) |
隣接 Period の値の差分 |
| 閾値比較 | raw 値に対して比較 | Period の Average に対して比較 |
計算方法が異なるだけで、どちらかが「正しい」わけではありません。
記述性の比較
同じ「秒あたり転送レートを取得する」という意図を、それぞれの方法で書くと以下のようになります。
PromQL:
rate(network_bytes_sent_total[15m])
Classic (GetMetricData + Metric Math):
{
"MetricDataQueries": [
{
"Id": "m1",
"MetricStat": {
"Metric": {
"Namespace": "OTelComparison/Classic",
"MetricName": "network_bytes_sent_total"
},
"Period": 300,
"Stat": "Average"
},
"ReturnData": false
},
{
"Id": "rate_m1",
"Expression": "RATE(m1)",
"ReturnData": true
}
]
}
| 観点 | PromQL | Classic Metric Math |
|---|---|---|
| 記述量 | 1行 | JSON 構造 10行以上 |
| ウィンドウ幅変更 | [5m] → [1h] に書き換えるだけ |
Period を変更して別クエリ |
| 型認識 | Counter/Gauge を区別 | 型情報なし |
スクリプト
send_classic.py(クリックで展開)
"""Send same metrics to CloudWatch Classic via PutMetricData."""
import json
import time
from datetime import datetime, timezone
import boto3
REGION = "ap-northeast-1"
NAMESPACE = "OTelComparison/Classic"
BATCH_SIZE = 1000
def send_metric_data(client, metric_name: str, unit: str, data_points: list[dict]):
"""Send metric data in batches of 1000."""
for i in range(0, len(data_points), BATCH_SIZE):
batch = data_points[i : i + BATCH_SIZE]
metric_data = [
{
"MetricName": metric_name,
"Timestamp": datetime.fromtimestamp(dp["timestamp_ns"] / 1e9, tz=timezone.utc),
"Value": dp["value"],
"Unit": unit,
}
for dp in batch
]
response = client.put_metric_data(Namespace=NAMESPACE, MetricData=metric_data)
status = response["ResponseMetadata"]["HTTPStatusCode"]
print(f" {metric_name} batch {i // BATCH_SIZE + 1}: {len(batch)} points -> {status}")
time.sleep(0.5)
def main():
with open("results/generated_data.json") as f:
data = json.load(f)
client = boto3.client("cloudwatch", region_name=REGION)
print("=== Sending cpu_usage_percent (Classic) ===")
send_metric_data(client, "cpu_usage_percent", "Percent",
data["metrics"]["cpu_usage_percent"]["data"])
print("\n=== Sending network_bytes_sent_total (Classic) ===")
send_metric_data(client, "network_bytes_sent_total", "Bytes",
data["metrics"]["network_bytes_sent_total"]["data"])
if __name__ == "__main__":
main()
query_classic.py(クリックで展開)
"""Query CloudWatch Classic (GetMetricData) and record results."""
import json
from datetime import datetime, timezone, timedelta
import boto3
REGION = "ap-northeast-1"
NAMESPACE = "OTelComparison/Classic"
def get_metric_data(client, queries: list[dict], start: datetime, end: datetime) -> dict:
"""Run GetMetricData."""
response = client.get_metric_data(
MetricDataQueries=queries, StartTime=start, EndTime=end,
)
return response["MetricDataResults"]
def main():
client = boto3.client("cloudwatch", region_name=REGION)
now = datetime.now(timezone.utc)
start_24h = now - timedelta(hours=24)
start_1h = now - timedelta(hours=1)
# 1h Average
results = get_metric_data(client, [{
"Id": "m1",
"MetricStat": {
"Metric": {"Namespace": NAMESPACE, "MetricName": "cpu_usage_percent"},
"Period": 3600, "Stat": "Average",
},
}], start_1h, now)
print(f"1h Average: {results[0]['Values']}")
# RATE
results = get_metric_data(client, [
{
"Id": "m1",
"MetricStat": {
"Metric": {"Namespace": NAMESPACE, "MetricName": "network_bytes_sent_total"},
"Period": 300, "Stat": "Average",
},
"ReturnData": False,
},
{"Id": "rate_m1", "Expression": "RATE(m1)", "ReturnData": True},
], start_24h, now)
print(f"RATE: {results[0]['Values'][:5]}...")
# IF threshold
results = get_metric_data(client, [
{
"Id": "m1",
"MetricStat": {
"Metric": {"Namespace": NAMESPACE, "MetricName": "cpu_usage_percent"},
"Period": 60, "Stat": "Average",
},
"ReturnData": False,
},
{"Id": "threshold", "Expression": "IF(m1 > 70, m1)", "ReturnData": True},
], start_24h, now)
print(f"Threshold > 70: {len(results[0]['Values'])} points")
if __name__ == "__main__":
main()
Ingestion レイテンシ
メトリクスを送信してからクエリで参照可能になるまでの時間を計測しました。
| パス | レイテンシ |
|---|---|
| OTel OTLP → PromQL | 約10秒 |
| Classic PutMetricData → GetMetricData | 約30秒 |
今回の検証ではOTelの方が約3倍速い結果でした(n=1のため統計的に有意ではありません)。今回の計測では公式ドキュメントの目安「1〜2分」より速い結果でしたが、負荷や時間帯による変動が想定されます。
本検証では、バックフィルデータ(過去24時間分を一括送信)も送信後約10秒で全範囲がPromQLから参照可能になりました。Classic側は直近データが約30秒で参照可能になりましたが、バックフィル全体が揃うまでにはさらに時間を要しました。
注意事項
- Classic との共存: OTel MetricsとClassic Metricsは別のストアです。同じデータを両方に送る場合、OTel側(GBベース)とClassic側(メトリクス数 + API)の両方で別々に課金されます
- PromQL の制限: 7日間のレンジ上限、500シリーズ上限があります(公式ドキュメント参照)
まとめ
CloudWatchのOpenTelemetryメトリクスネイティブサポートにより、OTLPで送信してPromQLで参照するワークフローが使えるようになりました。Pythonからprotobufを生成しSigV4署名付きでPOSTすることで送信でき、本検証では送信後約10秒でPromQLから参照できました。CounterとGaugeの型意味論を持つため、rate() や increase() といった関数が意図に沿って使いやすく、Classic Metric Mathと比べて記述が簡潔です。課金がGBベースに変わったことで、高カーディナリティなメトリクスを扱うワークロードではコスト面でも有利になる可能性があります。
新規にメトリクス基盤を構築する場合、OTel MetricsはPromQLを活かせる有力な選択肢になります。一方で、前述のPromQLの制約もあるため、要件に照らした見極めは必要です。既存のClassicで運用が回っているものを無理に移行する必要はありませんが、高カーディナリティでコスト増に悩んでいる場合やPromQLで高度な分析をしたい場合は移行を検討する価値があります。移行にあたっては、公式の移行ガイドもあわせて参照してください。
参考









