A bucket name is a physical address pretending to be a logical one.
When an application calls s3.get_object(Bucket='atoms-prod-analytics-data', ...), that string encodes three separate facts: which cloud provider holds the data, which region it sits in, and what the bucket happens to be called there. All three are baked into application code, config files, IAM policies, and every service that touches the data.
That coupling makes moving data between clouds disproportionately expensive:
The problem isn’t that migration is hard. It’s that implementation details about bucket physical location leaked into business logic, so migration became everyone’s problem instead of the storage layer’s.
Put an indirection layer between clients and cloud storage, and make migration a first-class operation on that layer rather than a project.
1 | Clients (S3 SDK, curl, internal services) |
Three components, split by what they’re responsible for:
Bucket-Gateway is a stateless, horizontally scalable service that speaks the S3 API. It authenticates the caller, resolves a logical bucket name to a physical location, and proxies the operation to whichever cloud currently holds the data. Authentication is dual-mode: SPIFFE/mTLS identity for in-cluster services (zero configuration — Istio injects the identity) and AWS Signature V4 for external clients, which is what makes existing S3 SDKs work unmodified. Authorization is ABAC, expressed as OPA/Rego policies.
Bucket-Operator is a Kubernetes operator with two CRDs, Bucket and Migration. Declaring a migration is a YAML apply, not a runbook.
1 | kind: Bucket |
1 | kind: Migration |
deleteSourceAfter: false is worth noticing: the source bucket survives the cutover, so rollback is another single-row update rather than a second migration.
Bucket-Migrator is the data plane: parallel copy workers with server-side copy optimization where the provider supports it, progress tracking, and checkpointing so a failed transfer resumes rather than restarts.
The routing table is the whole trick, and it’s a single Postgres table:
1 | CREATE TABLE bucket_routes ( |
Callers stop changing, because they never knew the physical name. Clients address analytics-data, a logical name that is stable for the lifetime of the data. The provider, region, and physical bucket name live in one row of bucket_routes and are resolved per request.
The downtime window collapses into a routing update. A migration runs as a four-phase state machine:
COPYING — bulk transfer of every object. Takes hours to days. The source stays fully read-write; nothing is cut over yet.SYNCING — delta sync of everything that changed during the copy. The read_only flag on the route freezes writes while reads continue served from the source. Minutes.CUTOVER — update the provider/physical_name columns for that logical name. Seconds.COMPLETED.The cutover is atomic because it’s a single row update, and it takes effect immediately because the gateway is stateless and resolves routes per request. There’s no deploy, no DNS propagation, no client restart, no coordination with consuming teams. The long part of the migration (the copy) happens entirely while the system is live, and the part that requires a consistency boundary (the sync) only blocks writes, not reads.
— Sep 5, 2026
Made with ❤ and at Earth.