Streaming RDS CloudWatch Metrics to Kafka with aiokafka
This page builds an async poller that pulls RDS CloudWatch metrics with aioboto3, turns each data point into a cost-signal record, and publishes it to a Kafka topic through an aiokafka producer with per-instance keying, durable acknowledgements, and clean backpressure handling.
Back to: Real-Time Metric Streaming Setup
CloudWatch is the authoritative source for RDS resource consumption — CPUUtilization, ReadIOPS/WriteIOPS, and FreeableMemory are the four signals that most directly track compute and I/O spend — but the CloudWatch API is a request/response surface, not a stream. To feed a live cost pipeline you need something in between: a poller that calls GetMetricData on a tight cadence and fans the results into a durable log where aggregators can consume them independently. Kafka is that log, and aiokafka lets the producer share the same event loop as the aioboto3 client so a single process can poll many instances without threads. The records this poller emits are the same shape consumed downstream by schema validation for billing data and by the concurrency-controlled async usage parsing workflows that pull provider cost APIs alongside this telemetry.
The polling window is a rolling one: every tick asks CloudWatch for the last few minutes of one-minute-period statistics, so the effective sampling resolution is bounded by the metric period, not the poll interval:
Polling faster than the metric period only re-reads the same data points, so the interval below is aligned to the 60-second period.
Prerequisites
Before running the poller, confirm the following are in place.
AWS IAM: the identity running the job needs read-only CloudWatch access. Attach a least-privilege policy scoped to
cloudwatch:GetMetricData(andcloudwatch:ListMetricsfor discovery); this is the same least-privilege posture described in scoping IAM least privilege for Cost Explorer.{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["cloudwatch:GetMetricData", "cloudwatch:ListMetrics"], "Resource": "*" } ] }GetMetricDatadoes not support resource-level ARNs, soResource: "*"is expected; constrain it with acloudwatch:namespacecondition key if your governance requires it.Python: 3.10 or newer (the code uses
asyncio.TaskGroup-style structured shutdown and modern typing).Kafka: a reachable broker (0.10+) and a pre-created topic. Do not rely on auto-topic-creation in production — create it with an explicit partition count so keyed ordering is stable.
Libraries: install the async AWS and Kafka clients.
pip install "aioboto3>=13.0" "aiokafka>=0.11" "pydantic>=2.7"Credentials: resolved from the standard AWS chain (
os.environ, instance profile, or IRSA on EKS). Never hard-code keys; the producer reads onlyKAFKA_BOOTSTRAPfrom the environment.
Step-by-Step Implementation
The poller acquires an aioboto3 CloudWatch client and an aiokafka producer, then loops: fetch a batch of metric data points, shape each into a cost-signal record, and publish keyed by DB instance. Backpressure is handled by awaiting the send future when the producer buffer fills, and shutdown drains in-flight sends before exiting.
Step 1 — Define the cost-signal record
The record is a small, frozen Pydantic model so the serialized payload is stable and self-describing on the wire. Keying on db_instance guarantees all metrics for one instance land on the same partition, preserving per-instance ordering for stateful aggregators.
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
class MetricSignal(BaseModel):
"""One CloudWatch data point shaped as a cost signal."""
model_config = ConfigDict(frozen=True)
db_instance: str # partition key, e.g. "prod-orders-1"
metric: str # CPUUtilization | ReadIOPS | WriteIOPS | FreeableMemory
value: float
unit: str # Percent | Count/Second | Bytes
timestamp: datetime # UTC, from CloudWatch
account: str
def to_bytes(self) -> bytes:
# model_dump_json emits ISO-8601 timestamps; encode for Kafka.
return self.model_dump_json().encode("utf-8")
# Expected:
# MetricSignal(db_instance="prod-orders-1", metric="CPUUtilization",
# value=63.4, unit="Percent", timestamp=..., account="1234").to_bytes()
# -> b'{"db_instance":"prod-orders-1","metric":"CPUUtilization",...}'
Step 2 — Poll CloudWatch with get_metric_data
GetMetricData takes a batch of up to 500 queries in one call, so a single request covers every metric across every instance. Each query needs a unique Id (lowercase, starts with a letter); build a lookup so returned results map back to the instance and metric they came from.
import os
from datetime import datetime, timedelta, timezone
import aioboto3
METRICS = [
("CPUUtilization", "Percent"),
("ReadIOPS", "Count/Second"),
("WriteIOPS", "Count/Second"),
("FreeableMemory", "Bytes"),
]
def build_queries(instances: list[str]) -> tuple[list[dict], dict[str, tuple[str, str]]]:
"""Return (MetricDataQueries, id -> (db_instance, metric)) lookup."""
queries, lookup = [], {}
for i, inst in enumerate(instances):
for j, (metric, _unit) in enumerate(METRICS):
qid = f"q{i}_{j}"
lookup[qid] = (inst, metric)
queries.append({
"Id": qid,
"MetricStat": {
"Metric": {
"Namespace": "AWS/RDS",
"MetricName": metric,
"Dimensions": [
{"Name": "DBInstanceIdentifier", "Value": inst}
],
},
"Period": 60, # 1-minute resolution
"Stat": "Average",
},
"ReturnData": True,
})
return queries, lookup
async def poll_once(cw, instances: list[str], account: str) -> list[MetricSignal]:
"""One GetMetricData call over a rolling 5-minute window."""
queries, lookup = build_queries(instances)
end = datetime.now(timezone.utc)
start = end - timedelta(minutes=5)
unit_by_metric = dict(METRICS)
signals: list[MetricSignal] = []
paginator_token = None
while True:
kwargs = dict(
MetricDataQueries=queries,
StartTime=start,
EndTime=end,
ScanBy="TimestampDescending",
)
if paginator_token:
kwargs["NextToken"] = paginator_token
resp = await cw.get_metric_data(**kwargs)
for result in resp["MetricDataResults"]:
inst, metric = lookup[result["Id"]]
# Take only the newest point per query this tick.
if result["Timestamps"]:
signals.append(MetricSignal(
db_instance=inst,
metric=metric,
value=result["Values"][0],
unit=unit_by_metric[metric],
timestamp=result["Timestamps"][0],
account=account,
))
paginator_token = resp.get("NextToken")
if not paginator_token:
break
return signals
Expected shape of the returned list on one tick for two instances:
[MetricSignal(db_instance='prod-orders-1', metric='CPUUtilization', value=63.4, ...),
MetricSignal(db_instance='prod-orders-1', metric='ReadIOPS', value=812.0, ...),
MetricSignal(db_instance='prod-orders-1', metric='WriteIOPS', value=140.0, ...),
MetricSignal(db_instance='prod-orders-1', metric='FreeableMemory', value=2.1e9, ...),
MetricSignal(db_instance='prod-analytics-2', metric='CPUUtilization', value=12.0, ...)]
Step 3 — Publish to Kafka with keyed, durable sends
The producer is configured with acks="all" so a send is only acknowledged once every in-sync replica has the record — the correct durability for cost data you will bill against. enable_idempotence=True prevents duplicates on retry. The key=db_instance argument drives partition assignment so ordering is preserved per instance.
The sequence below shows one tick: the poller reads a batch from CloudWatch, the producer publishes each keyed record, and Kafka acknowledges only after replication.
import asyncio
import logging
from aiokafka import AIOKafkaProducer
from aiokafka.errors import KafkaError
TOPIC = "rds-cost-signals"
async def run_stream(instances: list[str], account: str, interval: float = 60.0) -> None:
"""Poll RDS metrics and stream cost signals to Kafka until cancelled."""
session = aioboto3.Session()
producer = AIOKafkaProducer(
bootstrap_servers=os.environ["KAFKA_BOOTSTRAP"],
acks="all", # wait for all in-sync replicas
enable_idempotence=True, # no duplicates on retry
linger_ms=50, # small batching window
max_request_size=1_048_576,
compression_type="lz4",
)
await producer.start()
try:
async with session.client("cloudwatch") as cw:
while True:
tick = asyncio.get_running_loop().time()
signals = await poll_once(cw, instances, account)
for sig in signals:
try:
# await applies backpressure: if the buffer is full,
# this blocks until space frees, throttling the poller.
await producer.send_and_wait(
TOPIC,
key=sig.db_instance.encode("utf-8"),
value=sig.to_bytes(),
)
except KafkaError:
logging.exception("publish failed for %s/%s",
sig.db_instance, sig.metric)
logging.info("published %d signals", len(signals))
# Sleep the remainder of the interval, accounting for poll time.
elapsed = asyncio.get_running_loop().time() - tick
await asyncio.sleep(max(0.0, interval - elapsed))
finally:
# Drain in-flight sends before the socket closes.
await producer.stop()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
asyncio.run(run_stream(["prod-orders-1", "prod-analytics-2"], account="123456789012"))
Expected log output over two ticks:
INFO:root:published 8 signals
INFO:root:published 8 signals
Verification
Confirm records are landing with the right key distribution before wiring an aggregator to the topic.
Consume from the tail with the console consumer, printing keys:
kafka-console-consumer.sh --bootstrap-server "$KAFKA_BOOTSTRAP" \ --topic rds-cost-signals --property print.key=true --max-messages 4Expected record shape on the wire (one value line):
{"db_instance":"prod-orders-1","metric":"CPUUtilization","value":63.4,"unit":"Percent","timestamp":"2026-07-18T14:03:00Z","account":"123456789012"}Check partition assignment. All four metrics for
prod-orders-1must share a partition — verify withkafka-console-consumer.sh --partition Nthat a single instance’s keys never split across partitions, which would break per-instance ordering for stateful consumers.
Gotchas & Edge Cases
- Metric period floors your resolution. RDS standard monitoring publishes at 60-second granularity; a
Periodbelow 60 returns empty arrays for many data points. For sub-minute signals you must enable Enhanced Monitoring (which publishes to CloudWatch Logs, notGetMetricData) — do not shrink the poll interval expecting finer data. GetMetricDatacaps at 500 queries per call. Four metrics per instance means 125 instances per request. Beyond that, page withNextTokenor shard instances across pollers; the code above already followsNextToken.- CloudWatch data lags 1–3 minutes. The newest data point is often incomplete or absent. The rolling 5-minute window and
TimestampDescendingscan take the freshest settled point rather than the current minute — treat CloudWatch as near-real-time, not instantaneous, and align consumers accordingly. acks="all"needsmin.insync.replicasset on the broker. Without it, a single-replica partition acknowledges immediately and the durability guarantee is illusory. Confirm the topic’smin.insync.replicasis at least 2 for cost data you bill against.- Backpressure vs data loss.
send_and_waitblocks the poll loop when the buffer fills, which is correct — it slows ingestion rather than dropping records. If you switch to fire-and-forgetsend()for throughput, you must stillawaitthe returned futures or handleKafkaErrorin a callback, or a broker hiccup silently loses cost signals. The retry discipline here mirrors implementing retry logic for failed metric pulls. - Throttling on the CloudWatch side. High-cardinality estates can hit
ThrottlingExceptiononGetMetricData; batch aggressively (500 queries per call) and back off on throttle rather than polling per instance — the same rate-limit strategy as handling rate limits when pulling database metrics.
Frequently Asked Questions
Why key on db_instance instead of letting Kafka round-robin?
Keying pins every record for one instance to a single partition, which guarantees ordering for that instance. Stateful aggregators — a rolling CPU average or an IOPS-to-cost integrator — depend on seeing an instance’s data points in timestamp order. Round-robin spreads one instance across partitions, so a consumer group can process its points out of order and corrupt the running total.
Is send_and_wait too slow for a high-cardinality estate?
For a per-tick batch it is fine because linger_ms still batches records under the hood. If you need maximum throughput, collect the futures from producer.send() (non-blocking) and await asyncio.gather(*futures) once per tick — you keep the acks="all" durability while overlapping network round-trips, but you must handle partial failures in the gathered results.
Should I run one poller per instance or one poller for many?
One poller for many. GetMetricData batches up to 500 queries per call, so a single process covers over 100 instances in one request and one Kafka producer connection. Per-instance pollers multiply both CloudWatch API calls (inviting throttling) and producer connections for no benefit.
How do I avoid double-counting when the poller restarts?
Enable idempotent production (enable_idempotence=True, already set) so retries within a session do not duplicate, and make downstream consumers idempotent on (db_instance, metric, timestamp). Because CloudWatch data points are settled and timestamped, a restart that re-reads the same window republishes identical keys that the consumer deduplicates rather than double-counts.
Can I send Decimal values to preserve precision?
CloudWatch returns metrics as floats, so precision loss is already bounded at the source. Serialize as JSON numbers for these gauge-style signals. Reserve Decimal for monetary fields downstream — the schema validation layer coerces cost into Decimal when it joins these usage signals to per-unit rates.
Related
- Capturing Live Cost Signals from PostgreSQL Logical Replication — an engine-side source of live cost signals that complements CloudWatch polling.
- Buffering Cost Metrics with Redis Streams — a lighter-weight durable buffer when a full Kafka cluster is more than the workload needs.
- Real-Time Metric Streaming Setup — the parent topic covering the sub-minute streaming tier end to end.
Back to: Real-Time Metric Streaming Setup