
Streaming
Leaving Kinesis: Shards Are Not Kafka Partitions
Kinesis vs Kafka is not a transport swap. Kinesis maps a key into a hash range you split; Kafka into a modulus that moves when you add partitions.
Search this comparison and you get feature tables: ops burden, rough throughput, a retention line, sometimes a winner. Several still cap Kinesis retention at seven days. None name what actually breaks the migration, which is not a feature at all — it is how each system turns a key into a place.
The Migration That Looks Like a Transport Swap
The shapes line up almost perfectly, which is the problem. Both hold a stream as a set of ordered sublogs, route a record to one sublog by its key, and let many consumers read in parallel — one reader per sublog, each tracking a resumable position. Side by side, the work looks like swapping a client library, rewriting IAM into ACLs, and standing up brokers.
It is not, for one structural reason. A Kinesis shard owns a *range* of hash values; a Kafka partition is selected by a *modulus*. Splitting a range divides it at a boundary you nominate. Adding partitions changes the divisor for every key in the topic at once, and Kafka's operations documentation is blunt that it will not move the data already there. So the capacity operation that is safe on one side is the dangerous one on the other — and underneath both sit bounds that stopped being quotas and became the application's shape. Same trap as leaving SQS, and the exit is cheapest to price while it is still small.
Shards Split a Range; Partitions Change a Modulus
Before anything else, establish which of three routing cases the stream is in. DescribeStreamSummary carries RecordDistributionStrategy, but only on an on-demand stream: the field *is only present for streams that use the on-demand capacity mode*. On a provisioned stream it is absent — and absence is an answer, not a failed call, because provisioned behaviour is USER_PARTITION_KEY.
Case one — USER_PARTITION_KEY. The default, and the only strategy a provisioned stream supports: setting AUTO on one results in an InvalidArgumentException. Partition keys are Unicode strings capped at 256 characters, and an MD5 hash function maps them to 128-bit integer values and maps records to shards using the shards' hash key ranges. A shard's hash key values are ordered contiguous non-negative integers; when you SplitShard, you name a value inside that range, and that value and all higher ones go to one child, all lower ones to the other. Resharding is always pairwise.
The records written before the split do not move. They remain in the parent shard, which transitions OPEN to CLOSED to EXPIRED — closed still serves reads for a limited time, expired means retention has passed. So per-key order across a split is the *consumer's* responsibility: read a child before the parent is drained and you can read data for a particular hash key out of the order given by the sequence numbers, and the parent is exhausted only when getRecordsResult.getNextShardIterator returns null. A raw GetRecords consumer implements that loop itself; a KCL consumer gets it from the lease row's parentShardId, whose job is to make sure the parent shard is fully processed before processing starts on the child shards.
Case two — AUTO. On-demand streams only. Kinesis distributes records evenly across shards using service-managed algorithms and ignores any partition key and ExplicitHashKey a producer provides; AWS's comparison of the two strategies records the consequence in one cell: *no ordering guarantees*. No hash range, no per-key assignment. Shard control is a *provisioned* feature either way: SplitShard, MergeShards and UpdateShardCount are marked *only applicable for provisioned*, so on-demand lacks them whatever the strategy.
Case three — Kafka. The producer decides, not the broker. `partitioner.class` determines which partition a record goes to, and its default strategy is: if no partition is specified but a key is present, choose a partition based on a hash of the key. Kafka's operations guide spells out the cost: where data is partitioned by hash(key) % number_of_partitions, the mapping logic changes when the partition count increases, messages with the same key may be routed to different partitions, and *Kafka will not attempt to automatically redistribute existing data*. There is no undo — Kafka does not currently support reducing the number of partitions — and consumers on auto.offset.reset=latest may skip messages produced to new partitions before discovering them.
The Limits That Became Your Application's Design
A shard is a fixed unit of capacity: up to 5 read transactions per second and 2 MB/s total read, up to 1,000 records per second and 1 MB/s total write, partition keys included. Record size leaked into code first: the default maximum is 1 MiB; records up to 10 MiB are supported, but as an explicit per-stream opt-in — UpdateMaxRecordSize, or MaxRecordSizeInKiB at CreateStream — and AWS scopes it narrowly: recommended for intermittent payloads, served by temporarily bursting beyond the stream's sustained write throughput, with the per-shard limits of 1 MB/s write and 2 MB/s read *remaining unchanged*, and availability confined to the Regions that page lists. It is not designed for sustained large-record ingestion, so size the Kafka cluster against open shard count times 1 MB/s of sustained write, and treat 10 MiB as an intermittent per-record ceiling, not a rate.
Retention is the second leak, and it is a time window only: 24 hours by default, raised to at most 8,760 hours — 365 days. On a provisioned stream the paid extension is two tiers, not one: an extra per-shard-hour rate for extended retention, then beyond seven days a GB-month storage rate plus a per-GB retrieval charge for GetRecords. AWS's US-East worked example, read on 2026-10-11, prices one shard at $0.015 per shard hour and adds $0.020 per shard hour for extended retention, plus $0.023 per GB-month and $0.021 per GB retrieved — per-shard rates on live regional pricing, not a price for the stream. An architecture keeping 30 days in the stream is paying a storage bill inside a streaming product.
Kafka answers differently, and one answer has no Kinesis equivalent. retention.ms is the direct translation of the window. retention.bytes is enforced per partition, so the topic's byte ceiling is that value times the partition count — a partition increase multiplies your disk. But cleanup.policy=compact retains the latest value for each key, a retention shape a time window cannot express. Tiered storage moves the long tail to object storage; the switch is remote.storage.enable, false by default, on top of remote.log.storage.system.enable on the broker. The local.retention.* keys only bound the local copy once that switch is on; unset, *the value in retention.ms and retention.bytes will be used* — so setting them alone moves nothing. Kafka ships no RemoteStorageManager either; you configure somebody's. The limitations list includes *no support for compacted topics* — compaction and tiering are a choice, not a stack.
---
# v1, not v1beta2. Strimzi 1.0.0's CHANGELOG: "Remove the v1beta2 API (and
# v1alpha1 and v1beta2 for KafkaTopic and KafkaUser) from the CRDs and fully
# move to the v1 API" -- and "Use the v1 API as the stored API version".
# https://raw.githubusercontent.com/strimzi/strimzi-kafka-operator/1.0.0/CHANGELOG.md
apiVersion: kafka.strimzi.io/v1
kind: KafkaTopic
metadata:
name: payments-events
labels:
strimzi.io/cluster: payments-kafka
spec:
# NOT 12. A shard count is purchased throughput; a partition count is both
# consumer parallelism AND the modulus every key is hashed against. Choose it
# from key cardinality and target consumer parallelism, then freeze it for the
# whole cutover: raising it later re-maps existing keys and moves no data.
partitions: 24
replicas: 3
config:
# The one direct translation of a Kinesis retention window: 7 days -- and
# the steady-state value, NOT the cutover value. Kafka deletes a rolled
# segment once that segment's NEWEST record timestamp is older than
# retention.ms, and the backfill copier below deliberately stamps records
# from ApproximateArrivalTimestamp, up to the full Kinesis retention window
# old. Leave this at 7 days during a 7-day backfill and the oldest copied
# segments are deletable the moment they roll, while the copier is still
# running. So: set retention.ms: -1 ("If set to -1, no time limit is
# applied") -- or a value well beyond retention span + cutover duration --
# BEFORE the backfill starts, and restore the value below only after the
# reader flip AND after dual-write stops.
retention.ms: 604800000
# Enforced per partition, so the topic ceiling is this times 24 = 1.5 TiB.
retention.bytes: 68719476736
# delete, not compact: tiered storage has no support for compacted topics,
# so choosing compaction here would close that door. Tiering is NOT enabled
# by this manifest -- that needs remote.storage.enable plus local.retention.*
# here, remote.log.storage.system.enable on the broker, and a
# RemoteStorageManager implementation, which Kafka does not ship.
cleanup.policy: delete
min.insync.replicas: 2
# Availability is never silently preferred over durability on this topic.
unclean.leader.election.enable: false
---
apiVersion: kafka.strimzi.io/v1
kind: KafkaUser
metadata:
name: payments-reconciler
labels:
strimzi.io/cluster: payments-kafka
spec:
authentication:
type: tls
authorization:
type: simple
acls:
- resource:
type: topic
name: payments-events
operations: [Describe, Read]
host: "*"
- resource:
type: group
name: payments-reconciler
operations: [Read]
host: "*"# Batching is now a latency decision, not a billing one. Nothing here is
# chunked at 25 KB, so size batch.size against the broker and the payload.
bootstrap.servers={{ KAFKA_BOOTSTRAP }}
security.protocol=SASL_SSL
sasl.mechanism=SCRAM-SHA-512
sasl.jaas.config=org.apache.kafka.common.security.scram.ScramLoginModule required \
username="{{ KAFKA_USERNAME }}" password="{{ KAFKA_PASSWORD }}";
# linger.ms default changed from 0 to 5 in Apache Kafka 4.0: the producer waits
# 5 ms or until the batch reaches batch.size, whichever comes first. Raise it
# only against a measured latency budget, not out of habit from PUT Payload Units.
linger.ms=5
batch.size=65536
# Compression is applied to full batches, so batching efficacy and compression
# ratio move together. zstd for a throughput-shaped stream.
compression.type=zstd
# Durability half of the min.insync.replicas=2 contract on the topic.
acks=all
# Enabled by default when no conflicting configuration is set; stated here so a
# future edit cannot silently disable it. Requires acks=all, retries > 0 and
# max.in.flight.requests.per.connection <= 5.
enable.idempotence=true
max.in.flight.requests.per.connection=5
# Not only a producer setting: with an authorizer on, InitProducerId has to be
# authorized too. On a current broker that is satisfied by IdempotentWrite on
# the CLUSTER resource OR by Write on any topic (KIP-679), so a principal that
# already holds the topic Write above will not fail for lack of the cluster
# grant. Granting IdempotentWrite on CLUSTER explicitly -- kafka-acls has an
# --idempotent convenience flag for it -- is still worth doing: it states the
# intent where an auditor reads it instead of leaving it implied by a topic ACL
# that a later narrowing could remove.The third leak is scaling. UpdateShardCount carries three default refusals: no more than ten calls per rolling 24-hour period per stream, no target more than double the current shard count, and no target below half of it — so a 4x jump fails on an untouched quota, and so does a shrink to a quarter. AWS recommends targets at multiples of 25% because other values scale more slowly, and reaching any target means splits and merges that create short-lived shards counting against the account's regional limit. On-demand hides the shards but keeps Region-dependent ceilings of its own — a different planning problem, not an absence of one.
The Consumer Side: Lease Tables Versus Committed Offsets
A Kinesis consumer carries a second managed dependency that rarely appears in a migration plan, because it is not Kinesis. The Kinesis Client Library keeps consumption metadata outside the stream, in DynamoDB tables it creates per consuming application — and the version matters. KCL 3.x creates three tables by default — lease, worker metrics and coordinator state — and from KCL 3.5 the single-table format can consolidate all metadata into the lease table alone. Any plan sentence about those tables needs a KCL version attached.
Only one of those holds the thing you are migrating. Checkpointing persistently stores the position of the last successfully processed record in a shard, and *checkpoints are stored in the DynamoDB lease table as part of the metadata of the lease* — which is what lets a replacement worker resume. Worker metrics and coordinator state are load-balancing machinery: a bill and an operational surface, not a position.
So the translation is narrower than it looks: one attribute on one row per shard becomes a committed offset per partition, with two consequences. The position has been living in a database with its own capacity mode, throttling and failure modes; retire those from the runbook deliberately. And that lease row has been enforcing parent-before-child ordering through parentShardId, so if any consumer is a raw GetRecords loop, confirm it carries that rule before assuming per-key order was intact.
Cutting Over by Replay, Not by Drain
A queue is drained. A log is replayed — which is the whole reason this cutover needs no maintenance window. Kinesis reads from an arbitrary point in time: GetShardIterator accepts AT_TIMESTAMP alongside TRIM_HORIZON, LATEST, AT_SEQUENCE_NUMBER and AFTER_SEQUENCE_NUMBER. That one iterator type turns the boundary between history and live traffic into a value you choose, not a state you wait for.
Pin one UTC instant, T, exactly once: it is both the backfill's stop point and the live tail's start, and one that re-derives it has a moving edge. Two documented behaviours make it delicate. If no record carries the exact timestamp, the iterator returned is for the next (later) record: harmless on a first call, a silent gap on a second. And if the timestamp is older than the current trim horizon, the iterator returned is for the oldest untrimmed data record — TRIM_HORIZON by another name: not an error, indistinguishable from an iterator at T, and it quietly replays the whole retained shard. Check T against RetentionPeriodHours first.
#!/usr/bin/env bash
set -euo pipefail
STREAM="${KINESIS_STREAM:?set the stream name}"
STATE_DIR="${STATE_DIR:-./cutover-state}"
mkdir -p "$STATE_DIR"
# T is pinned ONCE, here, and never re-derived. Everything downstream reads it
# from this file: the backfill's stop point and the live tail's start point are
# the same instant by construction.
if [ ! -f "$STATE_DIR/boundary-T" ]; then
date -u +%s > "$STATE_DIR/boundary-T"
fi
T=$(cat "$STATE_DIR/boundary-T")
summary=$(aws kinesis describe-stream-summary --stream-name "$STREAM" \
--query 'StreamDescriptionSummary.[RetentionPeriodHours,RecordDistributionStrategy,OpenShardCount]' \
--output text)
read -r retention_hours strategy open_shards <<< "$summary"
# RecordDistributionStrategy is only present for on-demand streams, so the CLI
# renders it as None on a provisioned one. That absence is not a third strategy
# and not a failed call: a provisioned stream routes by partition key and
# rejects AUTO outright, so resolve it to USER_PARTITION_KEY and say so.
if [ -z "$strategy" ] || [ "$strategy" = "None" ]; then
strategy="USER_PARTITION_KEY (provisioned; field absent)"
fi
# A T older than the trim horizon does not fail: GetShardIterator returns a
# TRIM_HORIZON iterator instead, which replays the whole retained shard and
# looks exactly like success. Refuse to start rather than discover that later.
now=$(date -u +%s)
trim_horizon=$(( now - retention_hours * 3600 ))
if [ "$T" -le "$trim_horizon" ]; then
echo "ABORT: boundary T has aged past the trim horizon. Re-pin it." >&2
exit 1
fi
# What the backfill will actually copy: TRIM_HORIZON through T -- the entire
# retained span, not a recent slice of it. Recorded so the reconciliation gate
# can check its own window against it instead of taking one on trust. Pinned
# once, like T: a second run of this script would otherwise walk the recorded
# start forward while the window's start stays put, which can only make the
# coverage check more permissive.
if [ ! -f "$STATE_DIR/backfill-start" ]; then
echo "$trim_horizon" > "$STATE_DIR/backfill-start"
fi
# The reconciliation window is [WINDOW_START, T). Pinned once, here, for the
# same reason T is: a window whose start moves cannot be reconciled twice. It
# DEFAULTS TO THE RETENTION SPAN because that is what the backfill copies -- a
# narrower window is a spot check, and the gate downstream will refuse to clear
# the reader flip on one.
WINDOW_HOURS="${WINDOW_HOURS:-$retention_hours}"
if [ ! -f "$STATE_DIR/window-start" ]; then
echo $(( T - WINDOW_HOURS * 3600 )) > "$STATE_DIR/window-start"
fi
echo "stream=$STREAM strategy=$strategy shards=$open_shards T=$T"
echo "backfill span starts $(cat "$STATE_DIR/backfill-start")"
echo "reconciliation window starts $(cat "$STATE_DIR/window-start")"
# TWO SHARD LISTS, and conflating them is the quietest way to lose history.
#
# The BACKFILL list is the UNFILTERED one. FROM_TRIM_HORIZON is the ListShards
# default and "includes all the shards within the retention period of the data
# stream (trim to tip)" -- closed parents left behind by past splits and merges
# included. Those closed parents are where every pre-split record lives, so a
# backfill enumerated any other way silently copies no pre-split history at all
# on a stream that has resharded inside the retention window. Read each parent
# to exhaustion before its children, or per-key order breaks at the split.
aws kinesis list-shards --stream-name "$STREAM" \
--shard-filter "Type=FROM_TRIM_HORIZON" \
--query 'Shards[].[ShardId,ParentShardId]' --output text \
> "$STATE_DIR/backfill-shards"
echo "backfill reads $(wc -l < "$STATE_DIR/backfill-shards") shards, closed parents included"
# The TAIL list is the FILTERED one, and the filter is not optional hygiene.
# AT_TIMESTAMP "includes all shards whose start timestamp is less than or equal
# to the given timestamp and end timestamp is greater than or equal to the
# given timestamp or still open" -- so at T it drops every parent closed before
# T. That is exactly right here and exactly wrong above. Without it a copier
# would tail shards that can never receive another record, because
# GetShardIterator on a closed shard returns a valid iterator for its last
# sequence number rather than an error.
aws kinesis list-shards --stream-name "$STREAM" \
--shard-filter "Type=AT_TIMESTAMP,Timestamp=$T" \
--query 'Shards[].ShardId' --output text | tr '\t' '\n' | while read -r shard; do
# One AT_TIMESTAMP call per shard against the single pinned value. Every read
# after this one follows the NextShardIterator that GetRecords returns, and
# must be used inside its five-minute validity. These files are a record of
# what was pinned, not a resumable handle: an iterator expires five minutes
# after it is returned, so a backfill that restarts later calls
# GetShardIterator again at AFTER_SEQUENCE_NUMBER of the last record copied.
aws kinesis get-shard-iterator \
--stream-name "$STREAM" \
--shard-id "$shard" \
--shard-iterator-type AT_TIMESTAMP \
--timestamp "$T" \
--query 'ShardIterator' --output text > "$STATE_DIR/tail-iterator.$shard"
echo "pinned tail iterator for $shard"
doneThe loop itself is bounded. A shard iterator expires five minutes after it is returned, and the pattern is two-part: GetShardIterator supplies the first, every subsequent read uses the NextShardIterator that GetRecords returns. A restart does *not* re-pin T — it derives a fresh iterator at AFTER_SEQUENCE_NUMBER of the last record copied, because a second AT_TIMESTAMP of T skips history still unfilled. Do the rate arithmetic first: GetRecords returns at most 10,000 records or 10 MB per call at 5 transactions per second per shard, and a call returning 10 MB throttles the next five seconds with ProvisionedThroughputExceededException.
"""Dual-write shim for a Kinesis-to-Kafka cutover.
Kinesis stays authoritative until the reader flip; Kafka runs as a shadow. The
Kinesis partition key is reused verbatim as the Kafka record key ONLY once the
stream's record distribution strategy is confirmed as USER_PARTITION_KEY. On
AUTO, Kinesis ignored the partition key and published no ordering guarantee, so
copying it would carry an assignment the service never made.
DescribeStreamSummary returns RecordDistributionStrategy only for streams in
on-demand capacity mode, where it is AUTO or USER_PARTITION_KEY. A provisioned
stream omits the field entirely; provisioned behaviour is USER_PARTITION_KEY,
which is why that is the default below.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
class CutoverRefused(RuntimeError):
"""A precondition of the cutover does not hold. Do not start."""
@dataclass(frozen=True)
class Cutover:
stream_name: str
topic: str
frozen_partitions: int
ack_timeout_s: float
def assert_user_partition_key(kinesis, stream_name: str) -> str:
"""Refuse to reuse the partition key on a stream that ignored it."""
summary = kinesis.describe_stream_summary(StreamName=stream_name)
# An absent field means PROVISIONED, not AUTO: the key was honoured. The
# default below is therefore the correct fail direction, not a guess.
strategy = summary["StreamDescriptionSummary"].get(
"RecordDistributionStrategy", "USER_PARTITION_KEY"
)
if strategy != "USER_PARTITION_KEY":
raise CutoverRefused(
f"{stream_name} is on {strategy}: Kinesis ignored the partition key and "
"ExplicitHashKey and published no ordering guarantee. There is no per-key "
"assignment to carry. Choose the Kafka key from the payload deliberately."
)
return strategy
def assert_partition_count(producer, cutover: Cutover) -> None:
"""Key reuse preserves assignment only while the modulus holds."""
live = len(producer.partitions_for(cutover.topic) or ())
if live != cutover.frozen_partitions:
raise CutoverRefused(
f"{cutover.topic} now has {live} partitions, frozen at "
f"{cutover.frozen_partitions}. hash(key) % partitions has moved, so "
"existing keys may resolve to a different partition than their history."
)
def dual_write(kinesis, producer, cutover: Cutover, key: str, payload: dict) -> None:
body = json.dumps(payload, separators=(",", ":")).encode()
# Kinesis first and synchronously: it is the system of record until the flip,
# so a Kafka-only write is a divergence with no rollback target.
kinesis.put_record(StreamName=cutover.stream_name, PartitionKey=key, Data=body)
# send() only buffers the record and hands back a FutureRecordMetadata.
# Discarding that future is how a shadow gap hides behind a successful
# PutRecord: flush() blocks until the future is done -- including done with
# an error -- and raises only its own KafkaTimeoutError, so the caller sees
# a clean return on a produce that failed. Wait on the future instead, and
# let the broker's error reach the caller that just wrote to Kinesis.
future = producer.send(cutover.topic, key=key.encode(), value=body)
future.get(timeout=cutover.ack_timeout_s)
def build_cutover() -> Cutover:
return Cutover(
stream_name=os.environ["KINESIS_STREAM"],
topic=os.environ["KAFKA_TOPIC"],
frozen_partitions=int(os.environ["KAFKA_FROZEN_PARTITIONS"]),
ack_timeout_s=float(os.environ.get("KAFKA_ACK_TIMEOUT_S", "10")),
)
def main() -> None:
import boto3
from kafka import KafkaProducer
cutover = build_cutover()
kinesis = boto3.client("kinesis")
producer = KafkaProducer(
bootstrap_servers=os.environ["KAFKA_BOOTSTRAP"].split(","),
acks="all",
enable_idempotence=True,
# max_in_flight_requests_per_connection is deliberately NOT set here.
# Do not carry the "<= 5" from the properties file above into this
# client: that bound is the Java producer's. kafka-python 2.2.x raises
# KafkaConfigurationError on any user-provided value other than 1 under
# idempotence, and overrides its own default to 1 when you leave it
# alone; 3.0.x aligned with the Java bound. Unset is correct on both.
)
# Both checks run at startup, before a single record is written.
assert_user_partition_key(kinesis, cutover.stream_name)
assert_partition_count(producer, cutover)
dual_write(kinesis, producer, cutover, "acct-7781", {"event": "ping"})
# Drain on the way out. NOT the acknowledgement check -- dual_write already
# waited on its own future, because flush() completing is not proof that
# any individual produce succeeded.
producer.flush()
if __name__ == "__main__":
main()Reconciliation separates honest cutovers from hopeful ones, and it rests on a clock you can name. The Kinesis-side clock is ApproximateArrivalTimestamp, set when the stream receives and stores a record — and AWS says there are no guarantees about the timestamp accuracy, or that the timestamp is always increasing. So: a declared tolerance, never an equality. Count records inside the pinned window on both sides: that field on the Kinesis side, and on the Kafka side each record's own timestamp, whose clock message.timestamp.type decides — default CreateTime, the producer's, or LogAppendTime, the broker's. Keep the default: the copier must stamp each record from its own ApproximateArrivalTimestamp, in milliseconds — Kinesis documents that instant seconds-first with a millisecond fraction (1459799926.480), Kafka wants an integer of milliseconds. LogAppendTime would overwrite it with broker append time, which happens after T, so a correct backfill lands outside the window and fails its own gate. Never an end-offset diff — an offset is not a record count, so it passes cleanly on a wrong backfill. And the window has to be the span the backfill copied, TRIM_HORIZON through T; count a recent hour instead and you have certified an hour while authorizing a year.
#!/usr/bin/env bash
set -euo pipefail
STREAM="${KINESIS_STREAM:?set the stream name}"
TOPIC="${KAFKA_TOPIC:?set the topic name}"
STATE_DIR="${STATE_DIR:-./cutover-state}"
# Both written once by the pin script. Reading them rather than re-deriving
# them is the whole point: a window whose edges move cannot be reconciled.
# UNITS: these are Unix SECONDS -- date -u +%s wrote them.
WINDOW_START=$(cat "$STATE_DIR/window-start")
WINDOW_END=$(cat "$STATE_DIR/boundary-T")
# A Kafka record timestamp is an integer of MILLISECONDS since epoch, so the
# Kafka-side counter is handed the millisecond pair and the Kinesis-side
# counter the seconds. Hand seconds to a counter comparing milliseconds and
# nothing falls in range: kafka_count is 0, delta equals kinesis_count, and the
# gate aborts a correct migration. It fails closed, so no data is lost -- but a
# gate that fires on good runs is how teams learn to ignore the gate.
WINDOW_START_MS=$(( WINDOW_START * 1000 ))
WINDOW_END_MS=$(( WINDOW_END * 1000 ))
# The span the backfill copied: TRIM_HORIZON through T. Counting a narrower
# window and then clearing the flip is the trap this file exists to close.
BACKFILL_START=$(cat "$STATE_DIR/backfill-start")
# PREREQUISITE, checked before this script is worth running: the target topic's
# retention.ms was set to -1 (or far beyond retention span + cutover duration)
# BEFORE the backfill started, and is still there. Kafka deletes a rolled
# segment once that segment's newest record timestamp is older than
# retention.ms, and the copier writes backdated timestamps -- so on a
# retention-span retention.ms the oldest copied segments are deletable as soon
# as they roll. This gate would then be counting history that is already being
# trimmed. Restore the intended retention.ms after the reader flip and after
# dual-write stops, never before.
#
# NOT SUPPLIED HERE. Each counter must print a single integer: the number of
# records whose own-side timestamp falls in the pinned window, in that side's
# own units -- [WINDOW_START, WINDOW_END) seconds for Kinesis,
# [WINDOW_START_MS, WINDOW_END_MS) milliseconds for Kafka.
# Kinesis side: count on ApproximateArrivalTimestamp as GetRecords returns it
# -- the server-side arrival clock. Never a timestamp inside the payload:
# that one is the producer's and has no relation to when Kinesis stored it.
# Kafka side: count on each record's timestamp over the same window. Whose
# clock that is depends on message.timestamp.type -- leave it on the
# default CreateTime, which keeps the value the producer supplied, and
# have the backfill copier set that value from each record's own
# ApproximateArrivalTimestamp. A Kafka record timestamp is an integer of
# milliseconds since epoch; Kinesis documents the same instant as a Unix
# epoch value with millisecond precision, written seconds-first
# (1459799926.480), so the copier multiplies by 1000.
# Do NOT set message.timestamp.type=LogAppendTime on this topic: the
# broker then overwrites the producer's timestamp with its own local time
# at append, and every backfill append happens AFTER T -- so a complete,
# correct copy of [TRIM_HORIZON, T) lands outside the window this gate
# counts and the gate fails on a good migration.
# Deliberately NOT a sum of end offsets: an offset is not a record count, a
# partition is not a shard, and that diff passes on a wrong backfill.
COUNT_KINESIS="${COUNT_KINESIS_CMD:?path to your Kinesis window counter}"
COUNT_KAFKA="${COUNT_KAFKA_CMD:?path to your Kafka window counter}"
# AWS guarantees neither accuracy nor monotonicity on ApproximateArrivalTimestamp,
# so an exact equality here would be a check that fails on correct backfills and
# teaches the team to ignore it. Declare the tolerance instead. 200 ppm is an
# EXAMPLE operator input, not a measured figure: set it from the duplicate and
# loss budget you are willing to defend, and write down why.
TOLERANCE_PPM="${TOLERANCE_PPM:-200}"
# Absolute floor, because a ppm of a small window truncates to zero and turns
# the gate back into the exact equality this exists to avoid.
TOLERANCE_FLOOR="${TOLERANCE_FLOOR:-5}"
kinesis_count=$("$COUNT_KINESIS" --stream "$STREAM" \
--from "$WINDOW_START" --to "$WINDOW_END")
kafka_count=$("$COUNT_KAFKA" --topic "$TOPIC" \
--from "$WINDOW_START_MS" --to "$WINDOW_END_MS")
delta=$(( kafka_count - kinesis_count ))
[ "$delta" -lt 0 ] && delta=$(( -delta ))
# Round UP: integer division of count * ppm / 1000000 is 0 below 5,000 records.
scaled=$(( (kinesis_count * TOLERANCE_PPM + 999999) / 1000000 ))
allowed=$(( scaled > TOLERANCE_FLOOR ? scaled : TOLERANCE_FLOOR ))
printf 'kinesis=%d kafka=%d delta=%d allowed=%d\n' \
"$kinesis_count" "$kafka_count" "$delta" "$allowed"
if [ "$delta" -gt "$allowed" ]; then
echo "ABORT: reconciliation outside tolerance. Do not flip readers." >&2
exit 1
fi
# Inside tolerance is only half the question. The other half is WHAT was
# counted. A window narrower than the backfill certifies a slice and says
# nothing about the history behind it, so it does not clear the flip -- it is a
# spot check, and it exits non-zero so a pipeline cannot read it as a pass.
printf 'within tolerance over [%d, %d)\n' "$WINDOW_START" "$WINDOW_END"
if [ "$WINDOW_START" -gt "$BACKFILL_START" ]; then
printf 'PARTIAL: backfill copied from %d; this window starts %ds later.\n' \
"$BACKFILL_START" "$(( WINDOW_START - BACKFILL_START ))" >&2
echo "A spot check does NOT clear the reader flip. Re-run with" >&2
echo "WINDOW_HOURS set to the retention span, on a fresh state dir." >&2
exit 2
fi
echo "window covers the full TRIM_HORIZON-to-T backfill: safe to flip readers"One sequencing rule: stop dual-writing only after the Kinesis retention window has elapsed past the reader flip. Until then Kinesis is the rollback target and that window is the whole budget — so buy extended retention before the cutover, not during the incident.
Failure Modes You Now Own
This is where sovereignty stops being rhetorical: four things can now page an on-call engineer that could not before, each with a setting behind it.
- Under-replication becomes a write failure. `min.insync.replicas` defaults to
1, so an unconfigured cluster has no quorum floor on acknowledged writes. Set it, and under-replication stops being a dashboard colour: withacks=all, fewer thanmin.insync.replicasmembers in the ISR makes the producer raiseNotEnoughReplicasorNotEnoughReplicasAfterAppend. Kafka's documented typical scenario is replication factor 3,min.insync.replicas2,acks=all— andacks=1loses the record if the leader fails before followers replicate it. - Disk-full is now a thing that happens.
log.retention.bytesis the maximum size of the log before deleting it, and defaults to-1— no size bound until someone sets it, under any of the directories named inlog.dirs. - The availability-versus-durability call is now explicitly yours.
unclean.leader.election.enableallows replicas not in the ISR set to be elected leader as a last resort, even though doing so may result in data loss. A managed service made that call for you. Make it on purpose, per topic, and write down why. - Rebalance storms. Two independent detectors fire them.
max.poll.interval.msdefaults to 300000 ms: ifpoll()is not called before it expires the consumer is considered failed and the group rebalances — so slow processing, not a crash, is the usual cause.session.timeout.msis the heartbeat detector, defaults to 45000 ms, and is not supported whengroup.protocolisconsumer, where the broker configgroup.consumer.session.timeout.mscontrols it.group.instance.idmakes a consumer a static member, which with a larger session timeout avoids rebalances from transient unavailability.
None of these are exotic — they are the ordinary cost of holding the thing yourself, knowable in advance — the only honest argument for doing it, and the same calculus as running autoscaling on bare metal.
What an Auditor Is Shown, and Who Can Read the Stream
The assumption is that the managed side arrives with evidence and the self-hosted side builds it. Half true, and the wrong half is expensive. CloudTrail is enabled on an AWS account when the account is created, and the Kinesis logging page lists GetRecords, GetShardIterator, PutRecord and PutRecords among the actions it supports logging. *Supported* is not *recorded*: trails and event data stores do not log data events by default, and additional charges apply. Unconfigured, a trail holds management events — CloudTrail's name for control plane operations, the category CreateStream, SplitShard and UpdateShardCount fall into. Read-and-write evidence appears only once a data-event selector is configured on AWS::Kinesis::Stream. If a control narrative claims to show *who read this stream*, check that selector exists before an assessor does.
On Kafka the equivalent is a build rather than a bill. Authorization is pluggable, configured with authorizer.class.name, and for KRaft clusters the documentation is imperative: use `StandardAuthorizer` on all nodes, brokers and controllers alike. Nothing enables it for you. Once set, ACLs live in the cluster metadata log and the default is usefully restrictive — if a resource has no ACL matching it, only super users are allowed to access it — so a topic nobody has labelled is closed, not open. The foot-gun is the documented escape hatch, allow.everyone.if.no.acl.found=true, which inverts exactly that. It reads as a convenience and behaves as a policy change, so it belongs in a standing configuration review and a concentration-risk and exit-evidence file, not in someone's head.
Portability Is a Protocol, Not a Migration Plan
The optionality argument here is narrower and more durable than the cost argument. Kafka publishes a protocol guide covering the available requests, their binary format, and the proper way to make use of them to implement a client. Alternative brokers implement that published client protocol, so there is more than one to choose between — a specification, not a promise about a vendor's longevity.
That is the whole claim, and it should not be inflated. The practical question is which design choices keep the next move cheap. Freeze the partition count deliberately and record the reasoning: it is the modulus, and changing it is the one near-irreversible operation here. Keep the key derivation explicit in code rather than inherited from whatever the Kinesis producer sent, so the ordering contract sits where a reader can check it. And keep broker-specific features off the critical path: each one narrows what you can move to next.
The exit you can afford is the one you designed before you needed it. Everything else is a negotiation conducted from inside someone else's product.
On a decade horizon the stream outlives the broker, the orchestrator and probably the team. What makes that survivable is not picking the right system today — it is that the addressing model, the ordering contract and the retention policy are written down as decisions with reasons, so whoever inherits them can tell a constraint from a habit. Shards are not partitions; knowing why is the part that ports.
§FAQ/Common questions
Frequently asked
Is a Kinesis shard the same as a Kafka partition?
Structurally similar, addressed completely differently. Both are ordered sublogs that a key routes into. But on a Kinesis stream using the default USER_PARTITION_KEY strategy, an MD5 hash function maps partition keys to 128-bit integer values and maps records to shards using the shards' hash key ranges — each shard owning a set of ordered contiguous non-negative integers. A SplitShard divides one of those ranges at a value you choose, and every key outside that range is unaffected. Kafka's default partitioner chooses a partition based on a hash of the key, and the operations documentation describes the mapping as hash(key) % number_of_partitions — so when the partition count increases, the mapping logic changes for the whole topic, messages with the same key may be routed to different partitions, and Kafka will not attempt to automatically redistribute existing data. Capacity on one side is a bounded, local change; on the other it is global.
Can I reuse my Kinesis partition key as the Kafka message key?
Only after you have checked the stream's record distribution strategy, and only while the partition count is frozen. DescribeStreamSummary carries RecordDistributionStrategy, but that field is only present for streams that use the on-demand capacity mode, where its value is AUTO or USER_PARTITION_KEY. A provisioned stream omits it — which is an answer rather than a failed call, because provisioned behaviour is USER_PARTITION_KEY and a provisioned stream rejects AUTO with an InvalidArgumentException. On USER_PARTITION_KEY, records that share a partition key are sent to the same shard, so reusing the key carries a real assignment across. On AUTO — on-demand only — Kinesis distributes records evenly using service-managed algorithms and ignores any partition key and ExplicitHashKey a producer provides, with no ordering guarantees, so reusing the key there manufactures an ordering contract the service never offered. Even on USER_PARTITION_KEY the guarantee holds only while hash(key) % number_of_partitions is stable, so freeze the topic's partition count for the duration of the cutover and assert it at producer startup.
How do I migrate from Kinesis to Kafka without losing ordering?
Replay rather than drain. Pin one UTC boundary T, and call GetShardIterator with AT_TIMESTAMP once per shard against that single value — T is both the backfill's stop point and the live tail's start. Guard it first: if the timestamp is older than the current trim horizon the iterator returned is for the oldest untrimmed record, which is not an error and will silently replay the whole retained shard. Dual-write with Kinesis authoritative, backfill TRIM_HORIZON to T, and advance on the NextShardIterator that each GetRecords returns, using every iterator within its five-minute validity; a restart resumes at AFTER_SEQUENCE_NUMBER of the last record copied rather than re-pinning T. Reconcile counts inside the pinned window on a named clock per side with a declared tolerance, not an end-offset diff. Then flip readers, and stop dual-writing only after the Kinesis retention window has elapsed — that window is your entire rollback budget.
Does Kinesis have an equivalent of Kafka log compaction?
Kinesis retention is specified as a time window: a stream's retention period defaults to 24 hours and can be increased up to 8,760 hours (365 days), with additional charges beyond the first 24. Kafka's cleanup.policy adds a second shape — the compact policy retains the latest value for each key, and the design documentation states the guarantee as always retaining at least the last known value for each message key within the log of data for a single topic partition. That is a per-key retention model rather than a time-or-size one, and it is what makes a Kafka topic usable as a changelog of current state rather than only as a window of recent events. One caveat if you were planning to pair it with object storage: Kafka 4.3's tiered-storage limitations list includes no support for compacted topics, so compaction and remote storage are a choice between two designs, not two features to switch on together.
What actually changes on the bill when you leave Kinesis?
The billing unit, which is the part that shaped your code. On a provisioned stream a PUT Payload Unit is counted in 25 KB chunks — a 5 KB record contains one, a 45 KB record two, a 1 MB record forty — so small events were effectively free to batch and large ones were priced in steps. On On-demand Standard the basis changes entirely: Data-In is billed per GB, calculated as the number of records multiplied by the size of each record rounded up to the nearest 1 KB, with AWS's example counting a 4.5 KB record as 5 KB. Retention is priced separately again, and on a provisioned stream in two tiers: an extra per-shard-hour rate for extended retention, then a GB-month storage rate plus a per-GB retrieval rate beyond seven days. Do not carry those shard-hour figures across to On-demand Standard — there, extended retention is itself a GB-month rate rather than a per-shard-hour add-on, and the mode carries a per-stream hourly charge on top, so the arithmetic has a different shape as well as different numbers. Self-hosting replaces all of that with disk bytes multiplied by replication factor plus the operational floor underneath it — a different shape of number, not automatically a smaller one.
Further reading
- RabbitMQ Message Broker: Topology, Backpressure, Failure
- Leaving SQS: Kafka Is Not a Drop-In Queue Replacement
- NATS vs Kafka: Strimzi and Redpanda Off Confluent Cloud
- Vendor Lock-In in the Cloud: Pricing Your Exit as a Number
- Third-Party Risk When One Vendor Runs Your Whole Platform
- KEDA and Descheduler: Two-Tier Autoscaling on Bare Metal
- Repatriation Cost: A Five-Year TCO Model You Can Defend
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.