🌑

Welcome

Cloudless Blob: portable, cloud-agnostic blob storage



The problem

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:

  • Every caller has to change. Moving from S3 to Azure Blob means a different SDK, a different API surface, and a code change in every consumer — not just a config edit.
  • You need a downtime window. There is no moment where the old and new locations are both authoritative, so the usual approach is: stop writes, copy, re-point everything, restart. For a bucket measured in terabytes, that window is long enough to need planning and sign-off.

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.



Solution

Put an indirection layer between clients and cloud storage, and make migration a first-class operation on that layer rather than a project.

1
2
3
4
5
6
7
8
9
10
11
Clients (S3 SDK, curl, internal services)
│ S3 API

Bucket-Gateway ────── Auth │ Routing Table │ Cloud Manager

┌──────────┼──────────┐
▼ ▼ ▼
AWS S3 Azure Blob GCP GCS

Bucket-Operator (control plane) — provisions buckets, orchestrates migrations
Bucket-Migrator (data plane) — parallel copy workers, checkpointing

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
2
3
4
5
6
7
kind: Bucket
metadata:
name: my-aws-bucket
spec:
logicalName: my-app-data
provider: aws
region: us-east-1
1
2
3
4
5
6
7
8
9
kind: Migration
spec:
bucketRef: {name: my-analytics-bucket}
source: {provider: aws, region: us-east-1, physicalName: atoms-prod-analytics-data}
destination: {provider: azure, region: eastus, physicalName: atoms-prod-analytics-data-az}
strategy:
copyWorkers: 50
syncBeforeCutover: true
deleteSourceAfter: false

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
2
3
4
5
6
7
8
CREATE TABLE bucket_routes (
logical_name VARCHAR(255) PRIMARY KEY,
provider VARCHAR(50) NOT NULL, -- aws, azure, gcp
physical_name VARCHAR(255) NOT NULL,
region VARCHAR(100) NOT NULL,
read_only BOOLEAN DEFAULT FALSE,
...
);


Solving the Problem

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:

  1. COPYING — bulk transfer of every object. Takes hours to days. The source stays fully read-write; nothing is cut over yet.
  2. 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.
  3. CUTOVER — update the provider/physical_name columns for that logical name. Seconds.
  4. 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.



Result

  • Business logic never touchs cloud provider implementation details.
  • Minimal downtime during migration: only some write downtime, zero read downtime.
  • Adding new cloud provider is fast: just need to define new Bucket CRD.
  • Creating new migration path is fast: just need to define new Migration CRD.


Implementation

Source code

— Sep 5, 2026