CloudWatch の請求書を最近じっくり眺めましたか? 「カスタムメトリクス」 の行が EC2-Instance-Hours より大きい、 という account はそれなりにあると思います。 そこを削ろうとすると面白いことに気付くので、 そのメモと、 削るためのツールを書いた話を残します。
料金は「ユニークな時系列の数」 でしか決まらない
us-east-1 list price (2026-06、 AWS の最新は要確認):
| tier (metrics/月) | $/metric·月 |
|---|---|
| 最初の 10,000 | $0.30 |
| 10,001 – 250,000 | $0.10 |
| 250,001 – 1,000,000 | $0.05 |
| 1,000,000 超 | $0.02 |
バイト数でも request 数でもない、 unique series (= namespace + metric name + dimension-value の組み合わせ) の個数だけが課金軸です。 加えて PutMetricData リクエスト数 ($0.01 / 1,000 req) が乗りますが、 これは通常副次的。
unique series が爆発するパターン
これも単純で、 高 cardinality な dimension を 1 個入れると掛け算で series が増えます:
# AWS SDK の典型コード
cloudwatch.put_metric_data(
Namespace='MyApp/Web',
MetricData=[{
'MetricName': 'http_requests',
'Value': 1,
'Dimensions': [
{'Name': 'service', 'Value': 'checkout'}, # ~10 unique
{'Name': 'method', 'Value': 'POST'}, # ~5 unique
{'Name': 'user_id', 'Value': '7f3a...'}, # ~12,000 unique ← !!
{'Name': 'request_id', 'Value': 'req-...'}, # per-event unique ← !!!
],
}]
)
この 4 dimension の組み合わせは 理論最大 10 × 5 × 12,000 × N (request 数) の time series を作ります。 N は毎リクエスト違う request_id なのでほぼ実 request 数。 たとえば月 100 万 request あったら 数百万 series が一気に出る。
「request_id をメトリクスに入れない」 は誰でも知っているはずなのに、 実際の production アカウントを aws cloudwatch list-metrics で覗くと やってる。 OpenTelemetry の resource.attribute をデフォルトで dimension に転写するライブラリ、 自前の ad-hoc PutMetricData コード、 過去の Datadog から CloudWatch に移行したときに残った設定 — 出所はいろいろです。
なぜ「flat $0.30 × N」 計算は嘘なのか
これが本稿の中核です。 50,000 unique series あるとき、 月いくら? 50,000 × $0.30 = $15,000 ではありません。 tier が下がるので:
first 10,000 × $0.30 = $3,000
+ next 40,000 × $0.10 = $4,000
= $7,000/月
$15,000 と $7,000 で桁が変わるくらい違う。 「flat レート × series 数」 で savings を語ると常に二重に間違います:
- savings を過大評価: 現状コストを inflate
- 削除後コストを過大評価: 削った series は 安い高 tier から消えるので、 deletion の dollar 価値は均一じゃない
これがどれくらい load-bearing かというと、 S4 Metrics の cost 計算関数 monthly_cost_usd は monthly_cost_usd(50_000) == $7,000.00 という assertion がテストスイートに入っています。 dry-run で出る数字は AWS の本当の tier 計算と bit 一致するように作ってあって、 セールススライド向けの「平均レート」 計算じゃない。
tier が decreasing なので、 削減の dollar 効果は 10k〜250k series の中規模アカウントで最大化されます。 1M 超アカウントは margin が $0.02 まで落ちているので、 同じ %削減でも絶対額は伸びるけど比率としては小さくなる。
解決ツールを書きました — S4 Metrics
ここまでの議論を踏まえて、 cardinality を govern する collector daemon を Rust で書いて Apache-2.0 で公開しました。
- リポジトリ: https://github.com/abyo-software/s4-metrics
- バージョン: v0.1.0 (2026-06-16)
- 言語: Rust (Edition 2024)
- 兄弟プロダクト: S4 (S3) / S4 Logs (CloudWatch Logs)
アプリと CloudWatch の間に collector daemon metricsd を置いて、 declarative YAML rule で dimension を変形してから emit します。
[App: StatsD / OTLP / EMF / PutMetricData — endpoint override only]
│ ingest
▼
┌──────────────────────────────────────────────┐
│ metricsd │
│ ingest → govern → aggregate → emit │
│ (cardinality engine + DDSketch, 60s window) │
└──────┬──────────────────────────┬──────────────┘
│ governed + aggregated │ full-resolution tee
▼ ▼
[CloudWatch PutMetricData] [S3 / Prometheus remote-write]
migration は他の S4 ファミリと同じ「endpoint 差し替えるだけ」 :
# 元: aws cloudwatch put-metric-data ...
# 後:
aws cloudwatch put-metric-data \
--endpoint-url http://metricsd.internal:4318 ...
StatsD / DogStatsD / OTLP metrics / EMF / PutMetricData の 5 種の intake が同居している collector で、 受けて出す形式は CloudWatch ですが、 dropped detail は S3 に full-resolution tee されます (これは S4 Logs と同じ思想。 「消すんじゃなくて、 per-series 課金じゃない場所に移す」)。
Govern の 5 つの動詞
cardinality-engine クレートが提供する変換は 5 つで、 keep → drop → rollup → bucket → sample の順に評価されます:
rules:
# 1. keep — allowlist で残すものだけ宣言
- name: keep-alert-dims
namespace: "MyApp/Critical"
metric: "*"
keep_dimensions: [service, region]
# 2. drop — 高 cardinality 軸を消す (the main gun)
- name: drop-unbounded-ids
namespace: "MyApp/*"
metric: "*"
drop_dimensions: [user_id, request_id]
# 3. rollup — 具体値を template に折りたたむ
- name: rollup-paths
namespace: "MyApp/Web"
metric: "http_requests"
rollup:
dimension: path
replace_with: "/users/:id" # 3,000 paths → 1 series
# 4. bucket — 連続値を境界で離散化
- name: bucket-latency
namespace: "MyApp/Web"
metric: "request_latency_ms"
bucket:
dimension: latency_bucket_raw
boundaries: [10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0]
# 5. sample — datapoint 数の lever (cardinality は減らない!)
- name: sample-noisy
namespace: "MyApp/*"
metric: "debug_*"
sample:
rate: 0.1
sample は cardinality を減らさない点が重要なので明示しておきます。 sampled でも series は存在するので CloudWatch の billable series count は同じ。 sample が下げるのは datapoint 数 + PutMetricData request 数 (これも秒額に乗るが副次的)。 cardinality を削りたいなら drop / rollup / bucket / keep のどれか。
rule の compile は deterministic で「同じ入力 + 同じ rule なら同じ出力 (sampling 含む)」 が保証されているので、 dry-run の予測が本番 emit と bit 一致します。 これがないと「試算」 が信用できません。
dry-run で「値段だけ」 計算する
metricsd dry-run で PutMetricData を 1 回も叩かず、 tier-aware の before/after を出します。 custom metric 側の課金は増えません (CloudWatch API request 自体は --from-cloudwatch 経由なら ListMetrics 呼び出し分が乗ります、 通常は free tier の範囲内)。 入力は 2 通り:
# (a) 既存メトリクス population を JSON-lines で渡す
metricsd dry-run --rules rules.yaml --from-file population.jsonl
# (b) account に直接問い合わせ (read-only ListMetrics)
metricsd dry-run --rules rules.yaml --from-cloudwatch --region us-east-1
出力:
S4 Metrics dry-run estimate (CloudWatch tiered pricing)
─────────────────────────────────────────────────────────────────
billable series monthly $
current 50000 $7000.00
after governance 8000 $2400.00
─────────────────────────────────────────────────────────────────
cardinality reduction 84.0%
monthly savings $4600.00
annual savings $55200.00
これが何より素直で、 「うちは tier どこ?」 「rule 入れたらいくら削れる?」 を 作業 0、 追加 custom metric 課金なしで見れるのが OSS としての価値だと思っています。
計測値
合成 high-cardinality workload (http_requests に user_id (12,000 distinct) + path (3,000 distinct) + instance_id (400) + per-event-unique request_id + request_latency_ms に numeric dimension 2,500 distinct) を 22,500 distinct series まで膨らませて、 代表的な govern rule (drop-unbounded-ids + rollup-paths + bucket-latency 等) を applied:
| 削減前 | 削減後 | |
|---|---|---|
| billable series | 22,500 | 8,900 |
| 月額 (tier 計算) | $4,250.00 | $2,670.00 |
| 月次節約 | $1,580.00 | |
| 年換算 | $18,960.00 |
series count では 60.44% 削減、 $ ベースでは 37.2% 削減 — ギャップは tier が decreasing なせいで、 削った series の多くが marginal $0.10 帯 (10,001〜250,000) から外れたものだから (一番 marginal が高い $0.30 帯の 10k 枠はほぼ残る)。 ここを混同して「60% series 削減 = 60% 節約」 と書くのが S4 系で禁止している anti-pattern です。
数字は crates/bench/ の測定ハーネスが実物のパイプラインを回して出力したもので、 ハンドエディットしていません (docs/savings-calc.md 上部に reproduction コマンドあり)。
実 AWS でも動かしました (mock じゃなくて)
2026-06-15、 ap-northeast-1 で:
-
metricsd run --cloudwatch true(default) + S3 tee + flush 3 秒 - HTTP intake に untimestamped な
PutMetricData(AWS Query 形式)、 daemon は 202 を返す - StatsD UDP で
smoke.timer:120|ms、smoke.timer:240|msを投げる
CloudWatch 側:
$ aws cloudwatch list-metrics --namespace S4Metrics/SmokeTest
smoke.latency service=checkout
smoke.timer service=checkout
smoke.requests service=checkout
get-metric-statistics で smoke.latency の Sum/SampleCount/Max が期待どおり、 S3 tee 側にも zstd 圧縮 jsonl で着地。 LocalStack だけだと CloudWatch の untimestamped PutMetricData の挙動を再現できない (実 AWS は当該分を当該分単位窓に丸める) ので、 ここは実 region で確認する必要がありました。
ロックインしません — S3 tee の format は OSS 復号可
dropped detail は account=…/namespace=…/dt=…/…jsonl.zst 形式で S3 に書かれます。 本体は 標準 RFC 8878 zstd フレームの JSONL (S4F2 コンテナではなく単一の zstd フレーム)、 隣に s4-codec の S4IX sidecar を置く構成です。 S4 Metrics が消えても aws s3 cp ... - | zstd -dc | jq ... で読めます。 S4IX サイドカーで range-read 索引できますが、 無視しても本体は普通の zstd JSONL として処理可能。
これも S4 Logs と同じ規律: データ format が contract、 ツールは交換可能。
DDSketch で「分布」 を 1 series に畳む
per-request の request_latency_ms を全部別 series にしたら series 数で死ぬので、 distributions は 60 秒窓で DDSketch に畳んで、 quantiles を CloudWatch の values/counts 形式で送ります。 DDSketch は相対誤差 1% (default) を保証する quantile sketch で、 p50/p99 のような quantile queries は CloudWatch 上でそのまま使えます。
正直注記: CloudWatch の values/counts percentile 自体も近似計算なので、 quantile の絶対誤差は (DDSketch 1% × CW percentile 近似) の合成になります。 「exact percentile」 とは謳いません。 また
PutMetricDataは 1 リクエスト 150 values 上限があるので、 flush ごとに carrying できる DDSketch 解像度はそれで bound されます。
ビルドして起動
v0.1.0 は release binary をまだ配っていないので、 source build から:
git clone https://github.com/abyo-software/s4-metrics
cd s4-metrics
cargo build --release -p metricsd
./target/release/metricsd run \
--statsd-listen 0.0.0.0:8125 \
--http-listen 0.0.0.0:4318 \
--rules rules.yaml \
--flush-interval 60 \
--region us-east-1 \
--s3-bucket my-metrics-archive --s3-prefix s4metrics \
--default-namespace MyApp/Web
deploy 補助として deploy/systemd/ に unit ファイル、 deploy/cloudformation/ に AWS 上で動かす雛形があります (この CFN テンプレ自体は OSS 同梱、 商用 AMI を subscribe しなくても自分で AMI を焼けば使えます)。
制限 (デプロイ前に読んでください)
-
SigV4 検証はしません。 S4 Logs gateway と違って
--auth-mode sigv4は無く、 intake は届いたものを受け付けます。 private subnet + SG + service mesh の内側に置く前提です。 transport は--tls-cert/--tls-keyで TLS 終端可能 -
OTLP の
ExponentialHistogramとSummaryは silently skip。Gauge/Sum/Histogram(explicit-bounds) は正常にマップされますが、 expo histogram は base-scale 展開がsketchクレートに必要で v0.1.0 では未対応、Summaryは pre-computed 値で再集約不能なので drop。 batch 自体は失敗しません - sample は cardinality を減らさない (再掲)。 series は依然存在するので CloudWatch 課金単位は変わらず、 datapoint 数だけが減ります。 cardinality 削減目的では drop/rollup/bucket/keep を使ってください
-
EMF は「節約機構」 ではない。 EMF から抽出された metrics は同じ tier 料金で billable な custom metric、 さらに EMF log 自体が CloudWatch Logs ingest
$0.50/GBで 二重課金になります。 EMF 経路の節約は S4 Logs (兄弟製品) との bundle で初めて閉じる - single account / deployment 想定。 multi-account baseline、 ListMetrics 自動ベースライン、 savings dashboard、 S4 Logs と組んだ combined collector は v0.1.0 OSS core にはありません (roadmap で別出し)
ライセンスと連絡先
- ライセンス: Apache-2.0
- issue / PR: https://github.com/abyo-software/s4-metrics
「うちは tier どこにいて、 rule 入れたらいくら削れそうか」 を試したいだけなら metricsd dry-run --from-cloudwatch を ListMetrics 権限だけ与えて回すのが一番早いです (PutMetricData は呼ばないので custom metric 課金は増えません、 ListMetrics の API request 分のみ)。
質問・要望は GitHub issue にどうぞ。