Design S3-Like Object Storage
This page is one interview loop in three rounds. All three rounds design the same system. Each round opens with the interviewer raising the scope, and the design from the round before has to evolve to meet it.
| Round 1: Mid-level | Round 2: Senior | Round 3: Architect | |
|---|---|---|---|
| Story | File storage for one company's apps: user uploads and documents | The company-wide storage platform: media, backups, logs | A public cloud storage service: millions of tenants |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| Data | ~50M objects, ~100 TB | 20 PB active, 1.8B live objects | ~1.12 EB in 4 regions, ~100B objects, most of it cold |
| Traffic | ~450 requests/s peak | 25K writes/s and 250K reads/s peak; 400 Gbps out, 40 Gbps in | ~1M requests/s on average worldwide |
| Survives | Losing a disk or a node | Losing an AZ | Losing a region, for replicated buckets |
| Targets | 99.9%; no data loss on a disk or node failure | 99.99%; 11 nines of modeled durability; small GET P99 < 15 ms | 99.99% per region; a durability model we can defend; cost per GB-month as a goal |
| Reading time | ~35 min | ~40 min | ~45 min |
You can start at any round. Rounds 2 and 3 open with a "Where we left off" summary that catches you up.
Loop Opener: What Is Object Storage?
You Already Use One: a Coat Check
You hand your coat to the attendant and get a ticket. Later you hand back the ticket and get exactly the same coat. You never learn which rack it hung on. You can't alter the coat while it's checked; if you want a different coat stored, you check a new one under a new ticket.
Object storage works the same way for files. You upload a file (an object) under a name made of a bucket (a named container, like acme-uploads) and a key (the name inside the bucket, like invoices/2026/09/inv-4411.pdf). Later you ask for that bucket and key and get exactly the same bytes back. Amazon S3, Google Cloud Storage and Azure Blob Storage all work this way.
| What people keep in it | Bucket | Key | Typical size |
|---|---|---|---|
| Profile photos | acme-uploads | avatars/u_1001.jpg | 100 KB |
| Invoices and documents | acme-docs | invoices/2026/09/inv-4411.pdf | 2 MB |
| Database backups | acme-backups | orders-db/2026-09-26.dump | 10 GB |
| Application logs | acme-logs | api/2026-09-26/10/part-0007.gz | 20 MB |
Three things make it different from a file system on your laptop:
- No editing in place. An object is written whole and never modified. To change it, you upload a new one under the same key, and it replaces the old one.
- No real folders.
invoices/2026/09/is just the start of some keys. "Folders" are an illusion made by listing keys that share a prefix. - Access over HTTP. Every operation is an HTTP request:
PUTto store,GETto read,DELETEto remove.
What Makes It Hard
- Numbers. Billions of objects, sized from a few bytes to terabytes, in the same system.
- Disks die every day. At a few thousand drives, several fail every month. The promise is still that a stored file is never lost.
- Two very different workloads. Finding an object by name is a small, hot, database-like lookup. Moving its bytes is a huge, streaming, disk-and-network job. One system does both badly.
- Cost. Storage is sold by the gigabyte-month. Every extra copy we keep is money.
The Question the Whole Loop Answers
How do we store any amount of bytes so they are never lost, always findable, and cheap?
The answer gets sharper every round:
- Round 1: split the names (metadata) from the bytes (data), and keep three copies of the bytes.
- Round 2: replace most of those copies with erasure coding, and pay for it in repair traffic, tail latency and small-object tricks.
- Round 3: add geography, law (data that must not be deleted) and money (archive tiers and metering), and put a number on "never lost".
Round 1 · Mid-level · "File Storage for One Company's Apps"
~35 min · SDE II (L5) · 1 region, 3 AZs · ~50M objects, ~100 TB · ~450 requests/s peak · 99.9%
R1.1 Establish Design Scope
The interviewer says: "Our apps let users upload photos and documents. Design the service that stores them." Before we draw anything, we ask questions and say what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How big are the files? | From a few KB (avatars) to a few GB (exports). Most are under 5 MB. | One upload path won't fit all sizes. Large files need to be uploaded in pieces (step 1.3). |
| Can a stored file be edited? | No. A new upload replaces it. | Objects are immutable (never changed after they're written). We never have to update bytes in place, which makes copies easy to keep identical. |
| Do users need folders? | Only "show me everything under photos/2026/". | We need listing by prefix, which means keys must be kept in sorted order somewhere (step 1.5). |
| Who uploads? | Browsers and phones, directly. Our API servers shouldn't carry the bytes. | Clients need a way to talk to storage without holding our credentials (step 1.4). |
| How durable? | Losing a disk or a server must never lose a file. | Every byte lives on more than one machine (step 1.2). |
| After an upload, can a reader see an older version for a while? | No. If the upload succeeded, the next read must return it. | Strong read-after-write: the place that says "this object exists" must be a single source of truth (step 1.6). |
| Versioning, cheaper tiers for old files? | Not yet. | We keep one version per key. |
Out of scope for this round:
- Versions. Keeping old copies of overwritten files needs version IDs and delete markers. Nobody asked yet.
- Storage tiers. Moving old files to cheaper storage needs lifecycle rules.
- Other regions. One AWS region, with its three Availability Zones (AZs: separate data centers a few milliseconds apart).
In a multi-round loop, the interviewer brings parts of the out-of-scope list back later. Write it where you can see it.
R1.2 Functional Requirements, Derived Step by Step
We read the problem one phrase at a time and turn each phrase into an operation:
| Phrase from the problem | Operation |
|---|---|
| "Store a file" | PUT /{bucket}/{key}: store the bytes; replace any object with that key |
| "Read it back" | GET /{bucket}/{key}, optionally a byte range |
| "Check it exists, and its size" | HEAD /{bucket}/{key}: headers only, no bytes |
| "Remove it" | DELETE /{bucket}/{key} |
"Show me everything under photos/2026/" | GET /{bucket}?list-type=2&prefix=photos/2026/ with paging |
| "Upload a 10 GB export" | Multipart upload: start, upload parts, complete |
| "Phones upload directly" | Pre-signed URLs: a time-limited permission to do one operation |
Not yet: versions, storage classes, lifecycle rules, replication to another region.
R1.3 Non-Functional Requirements: the Questions
We state each quality in words first, in the order we'd defend it. The numbers come in R1.7.
- Durability first: an acknowledged upload is never lost. A user who saw "uploaded" must never see "file not found" because a disk died. Losing data is worse than being down.
- Then availability. Uploads and downloads keep working through a node failure and a database failover.
- Latency for small objects. An avatar or a thumbnail sits on a page load. The time to the first byte matters.
- Throughput for big objects. A 10 GB export doesn't care about the first byte; it cares about megabytes per second.
- Read-after-write. After a successful
PUTorDELETE, every followingGET,HEADand list sees it. No "wait a few seconds".
Why durability and availability are different. Availability is "can I read it right now?". Durability is "does it still exist?". A service can be down for an hour (bad availability) and lose nothing (perfect durability). The reverse is the disaster: it answers every request quickly and one file in a million is quietly gone. We design for durability first.
R1.4 The API
We copy S3's API shape, because every SDK and tool already speaks it.
Upload a small file, with a checksum
httpPUT /acme-uploads/avatars/u_1001.jpg HTTP/1.1 Host: storage.internal.acme.com Authorization: AWS4-HMAC-SHA256 Credential=AKIDAPP01/20260926/us-east-1/objstore/aws4_request, SignedHeaders=host;x-amz-checksum-sha256;x-amz-content-sha256;x-amz-date, Signature=5f2c... x-amz-date: 20260926T101500Z x-amz-content-sha256: UNSIGNED-PAYLOAD x-amz-checksum-sha256: <base64 SHA-256 of the 102,400-byte body> Content-Type: image/jpeg Content-Length: 102400 <102,400 bytes>
httpHTTP/1.1 200 OK ETag: "9b2cf535f27731c974343645a3985328" x-amz-checksum-sha256: <base64 SHA-256 of the 102,400-byte body>
The client computes a SHA-256 of the body and sends it. The service computes its own as the bytes stream in, and rejects the upload with 400 BadDigest if they differ. That catches corruption between the phone and our disks, which TCP's own 16-bit checksum can miss.
Read part of a large file
httpGET /acme-backups/orders-db/2026-09-26.dump HTTP/1.1 Range: bytes=0-8388607
httpHTTP/1.1 206 Partial Content Content-Range: bytes 0-8388607/10737418240 Content-Length: 8388608
Ranged reads let a client download a big file in parallel pieces, and resume after a dropped connection.
List by prefix, one level at a time
httpGET /acme-uploads?list-type=2&prefix=photos/2026/&delimiter=/&max-keys=1000 HTTP/1.1
xml<ListBucketResult> <Prefix>photos/2026/</Prefix> <KeyCount>3</KeyCount> <IsTruncated>true</IsTruncated> <CommonPrefixes><Prefix>photos/2026/08/</Prefix></CommonPrefixes> <CommonPrefixes><Prefix>photos/2026/09/</Prefix></CommonPrefixes> <Contents><Key>photos/2026/cover.jpg</Key><Size>184320</Size></Contents> <NextContinuationToken>cGhvdG9zLzIwMjYvY292ZXIuanBn</NextContinuationToken> </ListBucketResult>
delimiter=/ folds every key that has another / after the prefix into one common prefix, which a UI shows as a folder. A page holds at most 1,000 entries; the client passes continuation-token to get the next page. The token is just the last key returned, encoded, so the next page starts right after it.
Multipart upload: three steps
httpPOST /acme-backups/orders-db/2026-09-26.dump?uploads HTTP/1.1
xml<InitiateMultipartUploadResult><UploadId>mpu_7a8b9c0d</UploadId></InitiateMultipartUploadResult>
httpPUT /acme-backups/orders-db/2026-09-26.dump?partNumber=1&uploadId=mpu_7a8b9c0d HTTP/1.1 Content-Length: 104857600 <100 MiB of bytes>
httpHTTP/1.1 200 OK ETag: "b10a8db164e0754105b7a99be72e3fe5"
httpPOST /acme-backups/orders-db/2026-09-26.dump?uploadId=mpu_7a8b9c0d HTTP/1.1 <CompleteMultipartUpload> <Part><PartNumber>1</PartNumber><ETag>"b10a8db164e0754105b7a99be72e3fe5"</ETag></Part> <Part><PartNumber>2</PartNumber><ETag>"1b2cf535f27731c974343645a3985328"</ETag></Part> </CompleteMultipartUpload>
We adopt S3's limits: parts are 5 MiB to 5 GiB (the last part can be smaller), at most 10,000 parts, so the largest object is 10,000 × 5 GiB = 48.8 TiB, about 53.7 TB. S3 itself announced this 50 TB limit in December 2025, up from 5 TB. A single PUT carries at most 5 GB.
A pre-signed URL
Our API server signs a URL and hands it to the phone, which uploads straight to storage:
httpPUT /acme-uploads/avatars/u_1001.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIDAPP01%2F20260926%2Fus-east-1%2Fobjstore%2Faws4_request&X-Amz-Date=20260926T101500Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Signature=a41f... HTTP/1.1 Host: storage.internal.acme.com Content-Length: 102400 <102,400 bytes>
Status codes
| Code | Meaning | What the client does |
|---|---|---|
200 OK / 206 Partial Content | Done | Nothing |
400 BadDigest | The checksum didn't match the bytes | Re-upload |
403 AccessDenied | Bad or expired signature, or no permission | Get a fresh URL or credentials |
404 NoSuchKey | No object with that key | Treat as absent |
503 SlowDown | We're shedding load | Back off with jitter, then retry |
Recap
- Seven operations:
PUT,GET(with ranges),HEAD,DELETE, list by prefix, multipart upload, pre-signed URLs. - About 50M objects and 100 TB, from KB to GB each; one region with 3 AZs.
- A successful upload is never lost, and is visible to the very next read.
Let's build it, starting with the simplest version that works.
R1.5 Design Evolution: From One Disk to Replicated Chunks
Every step follows the same pattern: a problem, your turn to think, the answer, and what the answer costs us. The cost is always the next problem.
Step 1.0: The Baseline
One server. Files on its disk. The path on disk is the bucket and key: /data/acme-uploads/avatars/u_1001.jpg. A small web server maps PUT to "write file", GET to "read file".
Synthesizing vector architecture diagram...
What's good about it: it takes an afternoon to build, and listing by prefix is a directory listing. For a prototype, this is right.
Everything that follows exists because this disk will die, and this one server will fill up.
Step 1.1: Where Does a File Live When We Have 100 Servers?
The problem: one disk is full, so we buy 100 storage servers. A GET for avatars/u_1001.jpg arrives.
What would you do? How do we know which server holds the file?
This split is the idea the whole loop is built on:
| Metadata plane | Data plane | |
|---|---|---|
| What it holds | Names, sizes, checksums, chunk locations | The bytes |
| Size per object | ~600 bytes | KB to TB |
| Access pattern | Many small, random reads and writes; sorted scans for listing | Large sequential writes and reads |
| What it needs | Strong consistency, sorted keys | Cheap capacity, bandwidth, durability |
Synthesizing vector architecture diagram...
A read asks the metadata store first, then fetches the bytes from a storage node. The two are separate systems that scale separately.
Step 1.2: A Disk Died and Took Files With It
The problem: a storage node's disk fails. Every chunk on it is gone, and so are the files they belonged to. What would you do? How do we make sure one dead disk, or one dead server, loses nothing?
Why one copy per AZ? A failure that takes out a whole rack, or a whole AZ, then costs at most one copy of any chunk. It also means every AZ has a full copy of everything, so a front-end can always read from a node in its own AZ, which is faster and costs nothing in cross-AZ transfer.
Primitive: Distributed Consensus (Raft & Paxos) (why data copies don't need consensus here: chunks are immutable, and the metadata store decides which copies count)
Step 1.3: Uploading a 20 GB File Fails at 95%
The problem: a user uploads a 20 GB video from a laptop on hotel Wi-Fi. At 95% the connection drops. The upload starts again from zero, and fails again. What would you do?
Synthesizing vector architecture diagram...
An upload is invisible to readers until the single transaction in COMPLETING. From INITIATED or UPLOADING, an explicit abort or the 7-day deadline frees its parts.
The object's ETag for a multipart upload is not an MD5 of the whole file. S3-compatible stores compute it from the parts' MD5s and add -N for N parts, so clients should treat an ETag as an opaque version marker, and use the full-object checksum when they need to verify content.
Step 1.4: Uploads Pass Through Our API Servers and Saturate Them
The problem: the photo app's API servers receive each upload from the phone and forward it to storage. During an evening peak, the API servers spend their CPU and network copying bytes, and ordinary API calls slow down. What would you do? The phone must not hold our storage credentials.
Primitive: OAuth2, OIDC & Distributed Token Authentication (a pre-signed URL is a short-lived, narrowly scoped token)
Step 1.5: List Everything Under photos/2026/
The problem: the gallery page asks for everything under photos/2026/, one "folder" level at a time, 1,000 at a time. The bucket has 20M keys.
What would you do?
Primitive: Database Sharding & Partition Keys (range vs hash partitioning)
Step 1.6: Metadata Says the File Exists, but the Bytes Are Missing
The problem: an upload writes the metadata record first, then starts writing chunks. The front-end crashes halfway. A reader now finds the record, asks for the chunks, and gets "not found". What would you do? In what order do we write, and what cleans up after crashes?
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | One server, files on disk | Everything below |
| 1.1 | Where does a file live? | Metadata store + storage nodes holding chunks; front-ends | Two systems must agree |
| 1.2 | A disk died | 3 copies, one per AZ; ack after all 3; repair worker | 3× disk |
| 1.3 | Big uploads fail | Multipart upload; one-transaction complete | Abandoned uploads need cleanup |
| 1.4 | Bytes through our API servers | Pre-signed URLs | Key management |
| 1.5 | List by prefix | Sorted index; seek past folders; token = last key | Metadata store must be ordered |
| 1.6 | Metadata without bytes | Bytes first, then commit; GC with a 24 h grace | Orphans until GC runs |
R1.6 Architecture v1
Now the concepts get AWS names.
Synthesizing vector architecture diagram...
Clients talk to front-ends through an NLB, using URLs our apps signed. Front-ends look up and commit object records in Aurora, and move bytes to and from storage nodes, one copy of each chunk per AZ. The repair worker and the garbage collector run in the background and always check Aurora before acting.
Why these pieces:
- D3en instances carry large, cheap hard drives as instance store (disks physically attached to the host). A
d3en.2xlargehas 8 vCPUs, 32 GiB of memory and 4 × 14 TB HDDs (56 TB). Hard drives are the right medium: our data is mostly large and read sequentially. Instance-store data is lost if the instance stops or its host fails, which is fine because every chunk has two other copies. - Aurora PostgreSQL holds the metadata: a sorted B-tree index for listing, real transactions for multipart complete, and storage that Aurora itself keeps as six copies across three AZs. Front-ends read from the writer, not the reader, so reads always see the latest commit. The reader exists to take over in a failover.
- Fargate front-ends are stateless, so any front-end can serve any request and we can replace them freely.
The metadata schema
objects: one row per object, primary key (bucket_id, object_key)
| Column | Type | Notes |
|---|---|---|
bucket_id | BIGINT | From the buckets table |
object_key | TEXT (up to 1,024 bytes) | The key; the B-tree keeps it sorted |
object_id | UUID | New for every upload, so an overwrite is a new object |
size_bytes | BIGINT | |
etag | TEXT | |
checksum_sha256 | BYTEA | Full-object checksum |
content_type | TEXT | |
created_at | TIMESTAMPTZ |
object_chunks: primary key (object_id, seq)
| Column | Type | Notes |
|---|---|---|
object_id | UUID | |
seq | INT | Chunk order within the object |
chunk_id | UUID | |
offset_bytes / size_bytes | BIGINT | Where this chunk sits in the object |
crc32c | INT | Checked by the storage node on every read |
chunk_replicas: primary key (chunk_id, node_id), index on node_id
| Column | Type | Notes |
|---|---|---|
chunk_id | UUID | |
node_id | TEXT | e.g. b2 |
az | TEXT | Enforces "one per AZ" at placement |
The index on node_id answers the repair worker's question, "which chunks were on node b2?", with one range read.
multipart_uploads (upload_id, bucket_id, object_key, state, initiated_at) and multipart_parts (upload_id, part_number, size_bytes, etag, chunk_ids) hold uploads in progress. The garbage collector treats a part's chunks as referenced until the upload completes or is aborted.
Tracing one small PUT
- The phone sends
PUT /acme-uploads/avatars/u_1001.jpgwith a pre-signed URL. The NLB passes the connection to a front-end. - The front-end checks the signature and expiry, and that the app's key may write
acme-uploads. - It picks three nodes, one per AZ, and streams the 100 KB to all three as chunk
c_91f2, computing SHA-256 as the bytes pass. - Each node writes the chunk,
fsyncs, verifies the CRC32C, and replies. The front-end compares its SHA-256 with the client's header. - One Aurora transaction replaces the
objectsrow and inserts the chunk rows. Commit. 200 OKwith the ETag. The old avatar's chunks are now orphans; the collector deletes them after 24 hours.
Tracing one GET
- The front-end reads the object's record and chunk list from the Aurora writer (about 1–2 ms).
- For each chunk it picks the copy in its own AZ, reads it (the node verifies CRC32C before sending), and streams it. It asks for chunk N+1 while sending chunk N, so a large file streams without gaps.
- If the local copy is missing or fails its checksum, it reads another AZ's copy and reports the bad one to the repair worker.
Tracing a multipart upload
POST ?uploads inserts a multipart_uploads row. Each PUT ?partNumber writes chunks (three copies, as above) and upserts one multipart_parts row. POST ?uploadId checks the parts and, in one transaction, writes the objects row and the object_chunks rows (the parts' chunks in order) and marks the upload complete.
R1.7 Numbers
Targets
| Quality | Target | Why this number |
|---|---|---|
| Availability | 99.9% | At most 0.1% × 8,760 h ≈ 8.8 hours down a year. Fine for one company's uploads. |
| Durability | No loss on any single disk or node failure | 3 copies in 3 AZs; repair within a day |
| Small-object latency | Time to first byte P99 < 100 ms for objects under 1 MB | A photo on a page load |
| Large-object throughput | ≥ 50 MB/s per download stream | A 10 GB export in under 4 minutes; clients can open parallel ranged reads for more |
| Consistency | Read-after-write for PUT, DELETE and list | From R1.1 |
What we store (the interviewer's "most are under 5 MB", made concrete; this mix is our assumption)
| Class | Share of objects | Average size | Objects | Bytes |
|---|---|---|---|---|
| Small: avatars, thumbnails | 50% | 100 KB | 25M | 2.5 TB |
| Medium: photos, PDFs | 45% | 2 MB | 22.5M | 45 TB |
| Large: videos, exports (about a thousand ~10 GB exports included) | 5% | 20 MB | 2.5M | 50 TB |
| Total | 1.95 MB (weighted) | 50M | 97.5 TB |
The weighted average is 0.5 × 0.1 + 0.45 × 2 + 0.05 × 20 = 0.05 + 0.9 + 1.0 = 1.95 MB per stored object.
Traffic, reconciled with what we store. The store holds about two years of uploads, and files are rarely deleted. So writes must add up to the stored bytes over two years:
| Item | Math | Result |
|---|---|---|
| New objects per day | 50M ÷ 730 days | ≈ 68,500; we plan for 70,000 |
| New bytes per day | 70,000 × 1.95 MB | 136.5 GB |
| Check: two years of writes | 136.5 GB × 730 | ≈ 99.6 TB, matching ~100 TB stored |
PUTs per second | 70,000 ÷ 86,400 s | 0.8 average; ~8 at peak (uploads bunch up in the day; we assume 10×) |
| Reads per day | 5M GET + 2M HEAD + 0.5M list | 7.5M, about 87/s average; ~435/s at peak (5×) |
Bytes read per GET | 80% small (100 KB), 18% medium (2 MB), 2% ranged reads of large files (3 MB) | 0.08 + 0.36 + 0.06 = 0.5 MB on average |
| Egress | 5M × 0.5 MB | 2.5 TB/day: 231 Mbps average, ~1.2 Gbps at peak |
| Ingress | 136.5 GB/day | 13 Mbps average; 8 PUT/s × 1.95 MB ≈ 16 MB/s ≈ 125 Mbps at peak |
About 440 requests a second at peak, carrying a gigabit or so. This is a small system by request count. The bytes, not the requests, drive the design.
Storage fleet
| Item | Math | Result |
|---|---|---|
| Replicated bytes | 97.5 TB × 3 | 292.5 TB (97.5 TB per AZ) |
| Node | d3en.2xlarge: 4 × 14 TB | 56 TB raw |
| Nodes | 3 per AZ, 9 total: 168 TB per AZ | Each AZ is 97.5 ÷ 168 ≈ 58% full |
| Growth | 136.5 GB/day × 365 ≈ 50 TB/year; per AZ that's +30 points | Add three nodes (one per AZ) before an AZ passes 75%, in about 7 months |
Repairing a dead node. A node at 58% holds 56 TB × 0.58 ≈ 32.5 TB of chunks. Their other two copies are spread over the six nodes in the other two AZs. We launch a replacement node in the same AZ and copy the chunks into it. Its four drives can write about 1,000 MiB/s together (AWS's figure for d3en.2xlarge, sequential); we allow repair half of that, about 500 MB/s, so client traffic keeps its share: 32.5 × 10^12 ÷ 500 × 10^6 ≈ 65,000 s ≈ 18 hours. For those 18 hours, those chunks have two copies. Each source node sends only 500 ÷ 6 ≈ 83 MB/s.
Metadata
| Item | Math | Result |
|---|---|---|
| Record size | ~360 bytes of fields (key, IDs, size, checksums, chunk list) + index overhead | ≈ 600 bytes per object |
| Total | 50M × 600 B | 30 GB |
| Instance | db.r7g.xlarge (4 vCPU, 32 GiB): the hot part of the index stays in memory | ~440 queries/s at peak is light work |
Front-ends: 6 Fargate tasks (2 vCPU, 4 GB, Graviton), two per AZ. At peak each carries about 75 requests a second and 200 Mbps.
Rough monthly cost (us-east-1 on-demand list prices; check the AWS Pricing Calculator before quoting)
| Line | Math | ≈ Monthly |
|---|---|---|
| Storage nodes | 9 × d3en.2xlarge × 730 h × $1.051 | $6,900 |
| Internet egress | 2.5 TB/day ≈ 75 TB/month: first 10 TB × $0.09/GB = $900; next 40 TB × $0.085 = $3,400; last 25 TB × $0.07 = $1,750 | $6,050 |
| Aurora | 2 × db.r7g.xlarge (≈ $400 each) + 30 GB storage + I/O | $850 |
| Network Load Balancer | $16 base + processed bytes: 2.64 TB/day ≈ 110 GB/hour; one NLB capacity unit covers 1 GB/hour at $0.006 → 110 × $0.006 × 730 h | $500 |
| Front-ends | 6 Fargate Graviton tasks × ≈ $58 | $350 |
| Cross-AZ replication | Two of three copies cross an AZ: 136.5 GB/day × 2 × 30 ≈ 8.2 TB × $0.02/GB. Reads use the local copy: $0 | $160 |
| CloudWatch, KMS, misc. | $150 | |
| Total | ≈ $15,000 |
Two lessons hide in this table. The biggest line after the disks is bytes to the internet, which any storage service pays, S3 included. And a 3-year commitment on the nodes (a d3en.2xlarge is about $0.454/hour on a 3-year reservation) takes the node line from $6,900 to about $3,000.
R1.8 Trade-Offs
Three copies vs RAID
| 3 copies in 3 AZs (chosen) | RAID 6 in each server | |
|---|---|---|
| Survives | Any disk, any node, a whole AZ (two copies left) | Two disks in one server |
| Doesn't survive | Three failures in three AZs before repair | The server, its rack or its AZ |
| Space | 3× | About 1.25× (8 data + 2 parity disks) |
| Rebuild | Copy chunks from many nodes in parallel | One server rebuilds its array for many hours, slowly and alone |
RAID is cheaper, but it protects the disk, not the data. The failures we care about most take out whole servers.
Metadata in a relational store vs a key-value store
| Aurora PostgreSQL (chosen) | A key-value store (e.g. DynamoDB) | |
|---|---|---|
| Sorted listing | B-tree on (bucket_id, object_key): native | Sorted only within one partition key value, so a whole bucket under one key; one partition serves a limited request rate |
| Multipart complete | One ACID transaction | Transactions exist but are limited in size (a 191-part upload is many items) |
| Scale ceiling | One writer; fine for 30 GB and hundreds of writes a second | Scales out with no ceiling we'd reach |
At 50M objects, one relational writer is the simplest correct answer. Round 2 has 36 times more objects, and we'll revisit this.
Streaming through front-ends vs pre-signed direct upload
Pre-signed URLs keep the bytes off our apps' servers. The bytes still pass through our storage front-ends, which we want: that's where signatures, checksums and placement happen. What we gave up: an app that needs to inspect or transform a file (virus scan, resize) must now do it after the upload, by reading the stored object.
Build vs S3
For honesty's sake: S3 would cost less here. Storage: 97,500 GB at $0.023 for the first 50 TB and $0.022 after ≈ $2,200; requests ≈ $170; the same $6,050 of internet egress: about $8,400 a month, and nobody on call. We build it because the interviewer asked, and because the design is what we'll grow. In a real company at this size, the first answer is "use S3."
R1.9 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| A storage node dies | Heartbeats stop; reads that picked its copy fail over to another AZ's copy in milliseconds. | After 10 minutes of silence it's declared dead. The repair worker lists its chunks through the node_id index and copies each into a replacement node in the same AZ: about 18 hours at 500 MB/s. Writes skip the dead node at once. |
| A disk returns bad bytes | A chunk fails its CRC32C on read. | The node refuses to send it; the front-end reads another copy, and the repair worker replaces the bad copy. |
| The Aurora writer fails | Metadata writes and reads fail for a short time; uploads and downloads get 503. | Aurora promotes the reader in another AZ, typically within tens of seconds. Front-ends reconnect and clients retry with backoff. No committed record is lost: Aurora's storage keeps six copies across three AZs. |
| A client abandons a multipart upload | Parts sit on disk with no object. | The 7-day deadline aborts it; the garbage collector frees its chunks. An alarm counts uploads older than 7 days, in case the cleanup job itself stops. |
| A front-end crashes mid-upload | The client's connection drops. | Chunks already written become orphans (collected after 24 h); no record was committed, so readers never see a partial object. The client retries the PUT, or just the failed part. |
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | Three copies in three AZs; ack after all three are on disk; a repair worker; Aurora failover across AZs; Aurora's automated backups for the metadata. REL 9 · REL 10 · REL 11 |
| Performance Efficiency | Metadata and data scale separately; reads from the local AZ's copy; sorted index with seek-past-folder listing; parallel ranged reads for big files. PERF 3 |
| Security | Pre-signed URLs scoped to one method, one key and minutes; per-app keys with bucket permissions; TLS from clients to front-ends; chunks encrypted at rest (below). SEC 3 · SEC 8 · SEC 9 |
| Cost Optimization | ≈ $15K/month derived; egress is the second-biggest line; AZ-local reads avoid cross-AZ charges; S3 compared honestly. COST 5 · COST 8 |
| Operational Excellence | Light this round: alarms on first-byte latency, 5xx rate, disk fullness per AZ, and chunks with fewer than three copies. OPS 8 |
| Sustainability | Light this round: the 7-day abort deadline and the collector free disk that would otherwise fill with abandoned bytes. SUS 4 |
Encryption at rest in detail. Each object gets its own random data key, and the storage nodes encrypt its chunks with it (AES-256). The data key is stored in the object record, encrypted ("wrapped") under a per-bucket key. The per-bucket key is itself generated and protected by AWS KMS, and front-ends cache the unwrapped bucket key for a few minutes. So KMS is called about once per bucket every few minutes, not once per object, and a stolen disk holds only ciphertext.
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Asks about sizes, mutability, listing and durability first, and says what each answer changes.
- Separates metadata from data, and explains why a formula like
hash % Nis the wrong way to place files. - Replicates across AZs, and knows when a write counts as done.
- Uses multipart upload and pre-signed URLs, and says what each one costs.
- Writes bytes before the metadata commit, and cleans up orphans safely.
- Reconciles request rates with stored bytes, and sizes the fleet from the math.
Follow-up questions
-
"Why not acknowledge after two of the three copies, to make uploads faster?" Answer: we could, and Round 2 does something like it under a deadline. But then some acknowledged objects sit on two copies until the third is written, and a second failure in that window leaves one. At eight uploads a second, waiting for the third copy costs a few milliseconds on each upload; we'd rather pay that than explain a lost file.
-
"A user overwrites a photo and immediately reloads the page. Can they see the old photo?" Answer: not from us: the
GETreads the committed record from the Aurora writer, which already points at the new chunks. They can see it from a cache in front of us (a browser cache or a CDN), which is why URLs for changing content carry a version or a content hash. -
"How would you find every object that lost a copy when node b2 died?" Answer: the
chunk_replicastable has an index onnode_id, so it's one range read ofnode_id = 'b2'. Without that index, we'd scan every chunk. That's why placement is recorded, not computed.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
"hash(key) % N picks the server" | Changing N moves almost everything, and it gives no sorted order for listing. |
| "RAID is our redundancy" | It protects disks, not servers, racks or AZs. |
| "Write the metadata first to reserve the name" | Readers can see an object whose bytes don't exist. |
| "Proxy uploads through the app servers" | Doubles the bytes moved and couples app load to upload volume. |
| "Scan and filter for listing" | Reads the whole bucket to return 1,000 keys. |
Round 2 · Senior · "20 PB, Billions of Objects, and Cheap Durability"
~40 min · Senior SDE (L6) · 1 region, 3 AZs · 20 PB active, 1.8B objects · 25K writes/s and 250K reads/s peak · 99.99% · small GET P99 < 15 ms
R2.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 2. If you're starting here, it's everything you need from Round 1.
Round 1 in 60 seconds. "We built file storage for one company's apps: about 50M objects and 100 TB, one region with three AZs, 99.9%, and a few hundred requests a second. We split metadata from data. Aurora PostgreSQL holds one record per object, sorted by bucket and key, so listing by prefix is a range read, and a multipart upload completes in one transaction. The bytes live as chunks of up to 64 MB on nine hard-drive storage nodes, three copies per chunk, one in each AZ, and we acknowledge only after all three are on disk. We write the bytes first and then commit the record, so the commit is the moment an object exists and every read after it sees it; a garbage collector deletes unreferenced chunks after 24 hours. Phones upload straight to our front-ends with pre-signed URLs. About $15K a month, where internet egress is almost as big as the disks. Open costs: three copies of everything, one database writer, and nothing special for tiny objects, slow nodes or silent corruption."
Architecture v1, compact
Synthesizing vector architecture diagram...
Round 1 in one picture: records in Aurora, bytes as three copies of each chunk, and two background jobs that check Aurora before they touch any chunk.
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | Where does a file live? | Metadata store + chunk storage nodes | Two systems must agree |
| 1.2 | A disk died | 3 copies, one per AZ; repair worker | 3× disk |
| 1.3 | Big uploads fail | Multipart upload | Abandoned uploads |
| 1.4 | Bytes through our API servers | Pre-signed URLs | Key management |
| 1.5 | List by prefix | Sorted index, seek past folders | Needs an ordered store |
| 1.6 | Metadata without bytes | Bytes first, then commit; GC | Orphans until GC runs |
Open costs: 3× storage; one Aurora writer; tiny objects stored as their own chunk files; no protection against a slow node or silent corruption.
R2.1 The Scope Raise
Interviewer: "The service is now the company's storage platform: product media, database backups, analytics logs. We hold 20 petabytes in the active tier and 1.8 billion live objects, and we'll get to 100 billion objects. Peak traffic is 25,000 writes and 250,000 reads a second, about 400 gigabits a second out and 40 in. Finance says three copies of 20 PB is too expensive. Every read must see the latest write, including listings. Teams want old versions kept and old data made cheaper. Hundreds of millions of our objects are tiny, many under 4 KB. We've seen disks return wrong bytes without an error. It must survive losing an AZ at 99.99%. And small reads need to be fast: under 15 ms at P99."
A scope raise is not the end of scoping. We ask back, and say what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How are the 1.8B objects split by size? | 20% are small (about 50 KB), 60% medium (about 2 MB), 20% large (about 50 MB). | The average stored object is 11.21 MB, and large objects hold 89% of the bytes. Small objects are few bytes but many requests: they get their own path (step 2.5). |
| What are the 25K writes a second? | Mostly small: tiny state files, deletes, overwrites, parts of big uploads. Only about 40 Gbps of bytes arrives at peak. | 40 Gbps ÷ 25K = 200 KB per write request on average. The requests and the stored bytes must reconcile (R2.6). |
| How long does data stay in the active tier? | Logs expire at 90 days; media and backups move to a cheaper class after a month or two. | The active tier holds about four months of arrivals, and lifecycle rules drain it as fast as it fills (step 2.7). |
| Which data gets read? | Mostly new data. Our estimate: at least 80% of the bytes read belong to objects less than a week old. | Recent data can live differently from old data. It changes how we lay out bytes (step 2.1) and what the design costs (R2.6). |
| "Survive losing an AZ": do we keep serving, or just not lose data? | Both: keep serving every read, and lose nothing. | Every object must be readable from two AZs. That fixes how many pieces of each object go in each AZ (step 2.1). |
| Is 15 ms P99 measured at our front-end? | Yes, time to first byte for objects under 64 KB, inside our service. | Small objects can't sit on hard drives behind a disk seek under load. They need flash and a single read (step 2.5). |
| Do listings need read-after-write too? | Yes. A job writes 1,000 files, then lists them, and must see all 1,000. | No stale caches in front of the metadata. Reads go to a single up-to-date owner of each key range (step 2.6). |
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Data | ~100 TB | 20 PB active (100B objects ≈ 1.12 EB later) |
| Objects | 50M | 1.8B live, 11.21 MB average |
| Requests | ~450/s peak | 25K writes/s and 250K reads/s peak (10K and 100K average) |
| Bandwidth | ~1.2 Gbps out | 400 Gbps out, 40 Gbps in, at peak |
| Survives | A node | An AZ, still serving |
| Availability | 99.9% (8.8 h/year) | 99.99% (52.6 min/year) |
| Durability | No loss on one failure | 11 nines, modeled (R3.4 builds the model) |
| Latency | P99 < 100 ms first byte | Small GET P99 < 15 ms |
| Features | One version per key | + versioning, lifecycle, storage classes, full-object checksums |
| Cost | Build vs S3 | Cheaper than 3 copies: Finance's ask |
R2.2 What Breaks in the Round 1 Design
| Round 1 choice | What breaks at the new scope |
|---|---|
| 3 copies of everything | 20 PB × 3 = 60 PB of disk. Finance wants that cut. |
| One Aurora writer, ~600 B per object | 1.8B records ≈ 1.1 TB today, 60 TB at 100B objects; 25K writes and 250K strongly consistent reads a second at peak. Aurora's readers lag the writer, so read-after-write would force every read onto the one writer. |
| Keys sorted in one index | A job writing logs/2026-09-26/… sends every write to the same end of the same index. |
| A tiny object is its own chunk file | Hundreds of millions of files of a few KB: each costs a metadata record, a file-system inode and a 4 KB disk block, and each read is a disk seek. |
| Trusting the disk | Silent corruption returns wrong bytes with no error; a copy we never read can rot for years unnoticed. |
| Waiting for a slow node | One node stalling makes every read that touches it slow; at 250K reads a second, P99 is set by the slowest node. |
| One copy per AZ, read locally | With fewer copies, some reads must cross AZs, and on AWS each GB crossing costs $0.02. |
We fix them in this order: cost of copies (2.1), the repair traffic that follows (2.2), silent corruption (2.3), slow nodes (2.4), tiny objects (2.5), hot prefixes (2.6), versions and tiers (2.7).
R2.3 New Requirements and API Additions
Versioning. A bucket owner turns it on:
httpPUT /acme-media?versioning HTTP/1.1 <VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>
Every PUT now creates a new version and returns its ID:
httpHTTP/1.1 200 OK ETag: "c8b417e8ef28258b538053c9e99a8123" x-amz-version-id: 3HL4kqtJlcpXroDTDmJ.rmSpXd3dIbrHY
A DELETE without a version ID removes nothing. It adds a delete marker: a special version that says "the latest thing here is a deletion".
httpDELETE /acme-media/covers/launch.png HTTP/1.1
httpHTTP/1.1 204 No Content x-amz-delete-marker: true x-amz-version-id: 7Zl1b0yJ4Uq2.dmKsp0mZt9FfZ3aQ9Xc
Now GET /acme-media/covers/launch.png returns 404 with x-amz-delete-marker: true, but the older versions are still there: GET ?versionId=3HL4kqtJ… reads one, and DELETE ?versionId=… permanently removes one version. GET /acme-media?versions&prefix=covers/ lists all versions and delete markers of the keys under a prefix, newest first per key.
Storage classes. A PUT can name one; the default is STANDARD:
httpPUT /acme-backups/orders-db/2026-09-26.dump HTTP/1.1 x-amz-storage-class: STANDARD_IA
| Class | For | Stored | Priced |
|---|---|---|---|
STANDARD | Anything read often | Fastest paths: flash for small objects, recent data kept as full copies (step 2.1) | Higher per GB-month; no retrieval fee |
STANDARD_IA | Data read rarely, but needed at once | Erasure coded on hard drives from day one, on fuller nodes | Lower per GB-month; a per-GB retrieval fee and a 30-day minimum, so it isn't used for hot data |
Lifecycle rules. The bucket owner sets rules; a background engine applies them. (S3 takes this as XML; our API accepts the same fields as JSON.)
json{ "Rules": [ { "ID": "logs-expire", "Filter": { "Prefix": "logs/" }, "Status": "Enabled", "Expiration": { "Days": 90 } }, { "ID": "backups-to-ia", "Filter": { "Prefix": "orders-db/" }, "Status": "Enabled", "Transitions": [ { "Days": 30, "StorageClass": "STANDARD_IA" } ], "NoncurrentVersionExpiration": { "NoncurrentDays": 30 }, "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 } } ] }
Full-object checksums. The client may send a CRC of the whole object (S3 supports ten algorithms, including CRC-64/NVME, its default, CRC-32C, CRC-32, SHA-1 and SHA-256). For streaming uploads, where the client doesn't know the checksum until it has sent the last byte, it sends the checksum as an HTTP trailer, after the body:
httpPUT /acme-logs/api/2026-09-26/10/part-0007.gz HTTP/1.1 Content-Encoding: aws-chunked x-amz-decoded-content-length: 20971520 x-amz-trailer: x-amz-checksum-crc64nvme <body in chunks> ... 0 x-amz-checksum-crc64nvme: dPQ4Zk3Hq0I=
We store it in the object record, and recompute it whenever we move the object's bytes (step 2.3). A CRC-64 of the full object has a useful property: it can be combined from the CRCs of the parts, so multipart uploads can get a true full-object checksum without re-reading the data.
R2.4 Design Evolution: Erasure Coding and Paying for It
Step 2.1: Three Copies Are Too Expensive at 20 PB
The problem: 20 PB × 3 copies = 60 PB of disk. Finance wants the same protection for much less space. What would you do? And state exactly which failures your answer survives.
Synthesizing vector architecture diagram...
One 8+4 stripe after encoding: 12 chunks on 12 nodes, 4 per AZ, each on a different rack. A normal read takes D1 to D8 with no decoding. If AZ-b is down, the other 8 chunks are exactly enough to decode D4 to D6, with nothing to spare. The layout rotates per stripe so parity isn't always in the same AZ.
Go deeper: why no layout over 3 AZs does better than 1.5×. To survive losing an AZ, the chunks in the other two AZs must be at least k. With the same number of chunks per AZ (n ÷ 3), that means 2n ÷ 3 ≥ k, so n ÷ k ≥ 1.5. A wider code over 3 AZs (16+8, say) has the same 1.5× cost; what it buys is tolerance of more scattered drive failures (8 instead of 4), at the price of reading 16 chunks per repair. Going below 1.5× while surviving an AZ needs more AZs: 12+4 over 4 AZs (4 per AZ) is 1.33×. Round 3 uses that for cold data.
Primitive: Distributed Consensus (Raft & Paxos) (the metadata record, not the chunks, decides which bytes are the object)
Step 2.2: A Drive Died, and the Repair Floods the Network
The problem: a 16 TB drive fails. The repair service rebuilds every chunk that was on it, as fast as it can. Cross-AZ links fill up and client reads slow down across the cluster. What would you do?
Step 2.3: Bits Flipped on Disk, and Nobody Noticed
The problem: a drive's firmware bug returns wrong bytes with no error. Worse, a chunk of a backup nobody has read in months is corrupt, and we'll only find out when we need it. What would you do?
Step 2.4: One Slow Node Makes Reads Slow
The problem: a large GET reads 8 data chunks from 8 nodes in parallel. One of them has a drive retrying a bad sector, and takes 2 seconds. The whole read waits for it.
What would you do?
If a chunk's recorded location points at a node that has since been replaced, the node answers "unknown chunk". The front-end then asks the placement service where the chunk lives now, and meanwhile decodes the range from other chunks. A stale location is a slow read, never a failed one.
Step 2.5: Billions of 2 KB Objects Waste Disks and Metadata
The problem: a team writes hundreds of millions of 2 KB JSON files. Stored as their own coded stripes, each is split 12 ways. What would you do?
Why compaction can stall writes, and why we don't update in place. Compaction is the only thing that turns dead bytes back into free space. If deletes create dead bytes faster than compaction reclaims them, free space shrinks until new writes have nowhere to go, and the node must throttle or refuse them: a write stall caused by background work falling behind. LSM-tree databases hit the same wall in another form: when compaction lags, the number of unmerged files grows until the engine slows or stops writes to protect reads. We prevent it the same way: compaction has its own reserved I/O budget, dead space is an alarm long before disks fill, and placement sends new containers to nodes with free space. The alternative, an update-in-place layout like a B-tree, would reuse holes directly. But it would give up what makes containers cheap: every write would be a random write instead of an append, free space would fragment, and changing bytes inside an erasure-coded stripe means re-reading the stripe and recomputing its parity on every delete. Immutable, append-only containers with occasional compaction cost less overall.
Primitive: Write-Ahead Log & LSM-Trees · Drill: WAL/LSM compaction storm
Step 2.6: All Writes Hit One Prefix
The problem: the metadata no longer fits one database, so we split it into partitions. Then an analytics job writes 30,000 small log objects a second, all named logs/2026-09-26/<sequence>.json. Every one lands in the same partition, which starts rejecting requests.
What would you do? Listing by prefix must keep working.
Primitive: Database Sharding & Partition Keys · Loop: Design a Distributed Key-Value Store (partitioning and replication, the eventually consistent version)
Step 2.7: Old Data Should Cost Less, and People Need Old Versions
The problem: a deploy script overwrote 40,000 product images with blank files, and the team wants yesterday's versions back. Meanwhile, backups from last quarter sit on the same storage as today's media. What would you do?
Recovering the 40,000 images is then a script: list versions under products/, and for each key whose newest version is from the bad deploy, copy the previous version on top. No bytes move.
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | 3 copies too expensive | 8+4 erasure coding, 4 chunks per AZ; 3 copies for the first 7 days, encoded locally per AZ | 8 reads per repaired chunk; zero margin after an AZ loss |
| 2.2 | Repair floods the network | Repair math; priority by margin left; spread sources | Longer windows for low-priority stripes |
| 2.3 | Silent corruption | Block CRCs, end-to-end object checksum, scrubber (13-day pass) | ~4% of drive bandwidth |
| 2.4 | One slow node | Hedged reads (k + 1 after P95), deadlines | ~5% more reads |
| 2.5 | Tiny objects | Containers: flash (3 copies) under 128 KB, HDD containers to 8 MB; tombstones; compaction at 30% dead | Compaction I/O; two-stage deletes |
| 2.6 | Hot prefix | Range-partitioned Raft metadata; split by load; hash-spread tails with merged listing | Brief 503 SlowDown; we run the store |
| 2.7 | Versions and cheaper data | Newest-first version records; reference sets; lifecycle scans | More metadata; background I/O |
R2.5 Architecture v2
Synthesizing vector architecture diagram...
Clients find front-ends through DNS rather than a load balancer (R2.6 explains why). Every request asks the metadata partition that owns its key; small objects come from flash, recent data from the replicated window, older data from 8+4 stripes. Background services on the storage nodes repair, scrub, compact and collect garbage, and always check the metadata before deleting anything.
The object record in v2 (one per version; about 600 bytes with index overhead)
| Field | Example | Notes |
|---|---|---|
| Sort key | bucket_id / object_key / version stamp (newest first) | Owned by one partition |
version_id | 128-bit opaque | Built from the commit's HLC timestamp |
kind | OBJECT or DELETE_MARKER | |
size, etag, checksum_crc64nvme | End-to-end check | |
storage_class | STANDARD | |
layout | PACKED_FLASH · PACKED_HDD · STRIPED | How to read it |
location | container ID + offset + length, or a list of stripes (12 chunk IDs each) | Chunk IDs, not node names: a placement map says where each chunk lives, so repair doesn't rewrite object records |
encryption | wrapped data key, bucket key ID | As in Round 1 |
Tracing a PUT of a 50 MB object: when is it acknowledged?
- The front-end checks the signature, then asks the partition owning the key to check the bucket exists and the caller may write.
- It picks one hard-drive node per AZ and streams the body to all three as it arrives, in 8 MB pieces, never holding more than a few pieces in memory. It computes the CRC-64 as bytes pass.
- Each node appends to an open extent,
fsyncs, verifies block CRCs, and acknowledges. - When all three have acknowledged (or two, after the 50 ms deadline, with the third queued as urgent repair), the front-end compares its CRC-64 with the client's trailer.
- It commits the version record at the partition leader (a Raft majority, 2 of 3 AZs). That commit is the acknowledgment point.
200 OKwith the version ID. - On day 7 the encoder in each AZ reads its local copy, computes the 12 chunks, writes its 4 to four racks, verifies the result against the object checksum, and updates the placement map. Then each AZ deletes its full copy.
Tracing a degraded GET (AZ-b is down; the object is older than 7 days)
- The front-end (AZ-a) reads the record from the partition leader. If the old leader was in AZ-b, a new leader was elected in seconds.
- The stripe's layout puts D4–D6 and P2 in AZ-b. The front-end asks for D1–D3 and P1 (local), D7, D8, P3 and P4 (AZ-c): 8 chunks, no spare.
- It decodes D4–D6 from those 8 and streams bytes. Reads that fall only in D1–D3 or D7–D8 need no decoding.
Tracing repair after a drive loss
- Node
a19reports drive 7 failed. The placement map lists 11 TB of chunks on it. - Each affected stripe goes into the repair queue at its margin (normally 3 left: normal priority).
- Rebuilders in AZ-a read 8 surviving chunks per stripe (3 local, 5 from other AZs), rebuild the lost chunk, write it to a node on another rack in AZ-a, verify the CRCs, and update the placement map. Object records don't change.
R2.6 Numbers and Cost
Reconciling traffic with storage. This is where the numbers we were handed must agree with each other.
| Item | Math | Result |
|---|---|---|
| Average stored object | 0.2 × 0.05 MB + 0.6 × 2 MB + 0.2 × 50 MB = 0.01 + 1.2 + 10 | 11.21 MB (averaged over objects, not requests) |
| Check against the active tier | 1.8B × 11.21 MB | ≈ 20.2 PB ✓ |
| Share of bytes | large 10 ÷ 11.21 · medium 1.2 ÷ 11.21 · small 0.01 ÷ 11.21 | 89.2% · 10.7% · 0.09% (≈ 18 TB) |
| Ingress | 40 Gbps peak ÷ 2.5 peak-to-average | 16 Gbps = 2 GB/s average ≈ 172.8 TB/day |
| Bytes per write request | 5 GB/s ÷ 25,000 at peak | 200 KB, not 11.21 MB |
| If every write were a new 11.21 MB object | 25,000 × 11.21 MB | 280 GB/s ≈ 2.2 Tbps, and 864M new objects a day would fill 1.8B in about two days. The given numbers can't mean that. |
| How long data stays active | 20 PB ÷ 172.8 TB/day | ≈ 116 days of arrivals; lifecycle drains the rest |
| New objects that stay | 172.8 TB/day ÷ 11.21 MB | ≈ 15.4M a day ≈ 180 a second carry almost all the bytes |
| The other ~9,800 writes a second | deletes, small overwrites, short-lived temp objects, tags, multipart bookkeeping | Tiny bytes: about 4 KB each ≈ 40 MB/s |
So of 10,000 write requests a second on average, about 180 create the objects that make up the 20 PB. The rest are small, which is why request rate and bytes must be sized separately.
Reads (our assumed mix at the average of 100K/s)
| Request | Share | Per second | Bytes each | Bytes/s |
|---|---|---|---|---|
HEAD | 30% | 30,000 | 0 | 0 |
| List | 5% | 5,000 | ~20 KB | 0.1 GB |
GET | 65% | 65,000 | ~300 KB (small objects and 1–8 MB ranges of large ones) | 19.5 GB |
| Total | 100,000 | ≈ 20 GB/s = 160 Gbps average; 400 Gbps at the 250K peak |
Half of GETs are for small objects (our assumption): 81K a second at peak, served from flash.
Disk
| Item | Math | Result |
|---|---|---|
| Replicated window | 7 days × 172.8 TB = 1.21 PB, × 3 | 3.63 PB |
| Erasure-coded | (20 − 1.21) PB × 1.5 | 28.19 PB |
| Physical total | 31.8 PB (1.59× the data), instead of 60 PB with three copies, or 30 PB with pure 8+4 | |
| Fleet | 31.8 PB ÷ 80% fill target ÷ 336 TB per d3en.12xlarge (24 × 14 TB) | 118.4 → 120 nodes, 40 per AZ; 40.3 PB raw, 79% full |
| Flash for small objects | 18 TB × 3 copies = 54 TB on i4i.4xlarge (3.75 TB NVMe each) at 60% fill | 24 nodes, 8 per AZ |
| Random reads on HDD | the other ~81K (non-small) GET/s at peak × ~1.5 disk reads each ≈ 122K; capacity 120 × 24 drives × ~150 random reads/s (our assumption for a 7,200 rpm drive) ≈ 432K | ≈ 28% busy at peak |
Why 80% full? Repair needs free space to write rebuilt chunks, compaction needs room to write new containers before deleting old ones, and growth needs a few months of lead time.
Repair
| Case | Math | Result |
|---|---|---|
| The whiteboard number: a 16 TB drive, 8+4 | 16 TB × 8 = 128 TB read; ÷ 14,400 s | ≈ 71 Gbps for 4 hours |
| Our drive: 14 TB at 79% | 11.1 TB × 8 = 88 TB; ÷ 14,400 s | ≈ 49 Gbps for 4 hours |
| A whole node: 24 drives | 265 TB of chunks; 89% coded: 235 TB × 8 ≈ 1.88 PB of reads (+ 30 TB copied from replicas) | At a 200 Gbps (25 GB/s) cap: 1.88 × 10^15 ÷ 2.5 × 10^10 ≈ 75,000 s ≈ 21 h |
| Cross-AZ cost of that node | 5 of every 8 source chunks come from other AZs: 1.18 PB × $0.02/GB | ≈ $24K per lost node |
Instance-store data doesn't survive a host failure, so a failed d3en host always means rebuilding its 265 TB. Such events are why the repair cap exists, and why critical stripes can jump the queue.
Metadata
| Item | Math | Result |
|---|---|---|
| Records | 1.8B live + ~30% versions and markers ≈ 2.3B × 600 B | ≈ 1.4 TB; × 3 replicas = 4.2 TB |
| At 100B objects (Round 3) | 10^11 × 600 B | ≈ 60 TB |
| Load | every request touches metadata: 275K/s at peak | 18 nodes (6 per AZ); after an AZ loss, 12 nodes at ≈ 23K requests/s each, which we load-test |
Front-ends: 440 Gbps of client traffic at peak, in and out through the front-ends. A c7gn.8xlarge has 100 Gbps of network. 30 of them (10 per AZ) carry about 15 Gbps each at peak, and 22 Gbps each after losing an AZ.
Small-GET latency budget (P99, steps happen one after another, so they add)
| Step | ms |
|---|---|
| Front-end: parse, verify signature (signing key cached) | 1 |
| Metadata: leader read with read-index confirmation | 5 |
| Flash read from the local AZ's copy, over the network | 2 |
| Send the first byte | 1 |
| Total | 9, leaving 6 ms of margin under 15 |
The hedge isn't free in the worst case: if the local flash copy hasn't answered after 5 ms, we ask a second copy, so a hedged read's flash step is about 5 + 2 = 7 ms, and the total is 1 + 5 + 7 + 1 = 14 ms: still under 15, with 1 ms to spare. Most reads never hedge.
The line that surprised us: cross-AZ transfer. On AWS, bytes that cross between AZs cost $0.01 per GB in each direction, $0.02 per GB crossed. At our volume:
| Item | Math | ≈ Monthly |
|---|---|---|
| Bytes read per month | 20 GB/s × 2.628M s (30.4 days) | 52.6 PB |
| Bytes written per month | 172.8 TB/day × 30.4 | 5.26 PB |
| Reads, if all data were 8+4 | 2/3 of read bytes cross AZs: 35 PB × $0.02/GB | $701K |
| Reads, with the 7-day replicated window | only reads of older data cross: 20% × 2/3 × 52.6 PB = 7 PB × $0.02 | $140K |
| Writes: 3 copies | 2 of 3 copies cross: 10.5 PB × $0.02 | $210K |
| Writes: straight to 8+4 | 8 of 12 chunks cross, 1.0× the bytes: 5.26 PB × $0.02 | $105K |
Three designs, compared (disk nodes at 3-year reserved prices, $2.73/hour per d3en.12xlarge; transfer at list price)
| 3 copies, forever | 8+4 from the start | 3 copies for 7 days, then 8+4 (chosen) | |
|---|---|---|---|
| Physical | 60 PB → 225 nodes | 30 PB → 114 nodes | 31.8 PB → 120 nodes |
| Disk nodes | $448K | $227K | $239K |
| Cross-AZ transfer | $210K | $806K | $350K |
| Total | $659K | $1,033K | $589K |
The finance premise, "erasure coding is cheaper", is true for disks and false for transfer. The hybrid wins only because reads concentrate on new data. Break-even with three copies forever: the hybrid's reads cost (1 − f) × \$701K, where f is the share of read bytes for data under 7 days old, so it wins while \$239K + \$210K + (1 − f) × \$701K < \$659K, that is while f > 70%. We measure f every week. If it drops, we lengthen the window.
Monthly cost of the whole platform (us-east-1 list prices)
| Line | 3-year reserved | On-demand |
|---|---|---|
120 × d3en.12xlarge (hard drives) | $239K | $553K |
24 × i4i.4xlarge (small objects) | $11K | $24K |
18 × i4i.4xlarge (metadata) | $8K | $18K |
30 × c7gn.8xlarge (front-ends) | $16K | $44K |
| Cross-AZ transfer | $350K | $350K |
| Route 53, CloudWatch, KMS, misc. | $3K | $3K |
| Total | ≈ $628K | ≈ $992K |
That's about $0.031 per GB-month on 3-year pricing. Three copies forever, with the same flash, metadata and front-end lines, would be about $696K, or $0.035 per GB-month, so the hybrid saves about 10% of the total, not the 47% the disk math alone promised (31.8 PB instead of 60). Transfer is 56% of the bill.
Why no load balancer? A Network Load Balancer charges for processed bytes: one capacity unit is 1 GB per hour at $0.006, so $0.006 per GB. Our 57.8 PB a month through it would add about $347K a month. Front-ends instead publish their IP addresses in DNS: Route 53 multivalue answers return up to 8 healthy IPs per query, with health checks removing dead front-ends. S3's own endpoints also resolve to many IP addresses. Clients use the zonal name for their AZ, so traffic from a client to a front-end doesn't cross AZs either.
Compared with S3. The same 20 PB in S3 Standard: storage at $0.023, $0.022 and $0.021 per GB for the first 50 TB, the next 450 TB and the rest ≈ $421K; write-class and list requests (about 6K writes a second, since the other ~4K of the 10K are DELETEs, which S3 doesn't charge for, plus 5K lists: 11K × 2.628M s × $0.005 per 1,000) ≈ $145K; GET and HEAD (~95K a second) ≈ $100K; transfer between S3 and EC2 in the same region is free. About $665K a month. We're slightly cheaper only with a 3-year commitment, and S3 includes the people who run it.
Which numbers changed the design? The 11.21 MB average against 200 KB per write forced us to size bytes and requests separately. The 0.09% of bytes in small objects made three copies on flash affordable. And the cross-AZ price, not the disk price, decided how long data stays replicated.
R2.7 Trade-Offs
Erasure-code widths over 3 AZs
| Code | Chunks per AZ | Cost | After losing an AZ | Chunks read per repair |
|---|---|---|---|---|
| 6+3 | 3 | 1.5× | 6 left = k: zero margin | 6 |
| 8+4 (chosen) | 4 | 1.5× | 8 left = k: zero margin | 8 |
| 10+4 | 5, 5, 4 | 1.4× | Lose a 5-chunk AZ: 9 left < 10: unreadable | 10 |
| 7+5 | 4 | 1.71× | 8 left, k = 7: one spare | 7 |
| 9+6 | 5 | 1.67× | 10 left, k = 9: one spare | 9 |
8+4 and 6+3 cost the same; 8+4 tolerates 4 scattered failures instead of 3, for reading 8 chunks per repair instead of 6. 10+4 looks cheaper and doesn't survive an AZ at all. Buying a spare chunk after an AZ loss costs about 11–14% more disk (R2.8 says when that's worth it).
Go deeper: local reconstruction codes. Azure Storage uses codes with extra local parities: for example 12 data chunks in two groups of 6, each group with its own parity, plus 2 global parities. A single lost chunk is rebuilt from its group of 6 instead of 12, which halves repair reads for the most common failure, at slightly more storage and a more complex decoder. For us, a variant with a local parity group per AZ would let most single-chunk repairs read only inside one AZ, cutting the cross-AZ repair bill.
EC vs three copies for small objects. Coding the flash containers 8+4 would need 12 flash nodes instead of 24, saving about $5.6K a month. But small GETs (about 32K a second on average, 50 KB each) would read from other AZs two thirds of the time: 32.5K × 50 KB × 2.628M s ≈ 4.3 PB a month, × 2/3 × $0.02 ≈ $57K. Three copies win by ten times, and every small read stays at one local flash read.
When to acknowledge a write
| Rule | Protection at the moment of "OK" | Latency |
|---|---|---|
| 3 copies, all on disk (chosen) | Survives 2 more failures | The slowest of 3 AZs |
| 2 of 3 copies, third queued (our fallback after 50 ms) | Survives 1 more failure until repaired | The second of 3 |
| 8+4 straight away, all 12 chunks | Survives 4 more | The slowest of 12 nodes |
| 8+4, 10 of 12 chunks, 2 queued | Survives 2 more until repaired | The 10th of 12 |
| 8+4, 8 of 12 | Survives nothing: one more loss destroys it | Never do this |
If we wrote large write-once data (backups nobody reads) straight to 8+4, we'd ack after all 12, or after 10 with the missing 2 queued as urgent repair and counted as reduced protection.
Choosing the metadata store
| Option | Ordered listing | Read-after-write | Scale | Verdict |
|---|---|---|---|---|
| Our range-partitioned Raft store (chosen) | Yes, and splits keep order | Leader reads with read index | Splits by size and load | We run it |
| Aurora PostgreSQL | Yes | Only on the one writer; readers lag | One writer for every write and every consistent read | Round 1's answer; not this scale |
| Aurora PostgreSQL Limitless Database | Sharded by hash of a shard key: shard by bucket and one bucket is one shard; shard by key and every listing asks every shard | Yes | Scales writes across shards | Wrong partitioning model for sorted, hot buckets |
| DynamoDB | Only within one partition key value | Strongly consistent reads, on request | Scales out | A bucket's sorted keys would have to share one partition key, whose throughput is limited |
| A distributed SQL or ordered KV product (e.g. CockroachDB, TiKV, FoundationDB) | Yes | Yes | Yes | Viable; we'd still own its operation |
(Aurora's old multi-master option was a feature of Aurora MySQL 5.6-compatible version 1, which reached end of life in February 2023. It never existed for PostgreSQL.)
Our consistency stance. Every metadata read and write goes to the partition leader, confirmed by a majority, so reads are linearizable: CP for metadata. The data plane has no consistency question at all, because chunks are immutable and only become visible through a committed record. During a network split between AZs, the side with a partition's majority keeps serving it; the other side's front-ends get errors for that partition and retry through another AZ.
R2.8 Failure Modes
| Trigger | What you'd see | How the design responds | Drill |
|---|---|---|---|
| Losing an AZ | 40 disk nodes, 8 flash nodes, 6 metadata nodes and 10 front-ends vanish. Coded reads decode from 8 chunks; recent data has 2 copies. REL 10 | Raft partitions elect leaders in the other AZs in seconds. DNS health checks drop the dead front-ends. Every coded stripe is at zero margin, so any further drive loss makes its stripes unreadable until the AZ returns. We don't rebuild into the other AZs for an outage we expect to end: rebuilding a third of 28 PB takes days. If the AZ is declared lost for good, rebuilding its ~9.4 PB of chunks reads about 75 PB: about 3.5 days even at 2 Tbps. Across 1,920 surviving drives at an assumed 1% annual failure rate, we'd expect 1,920 × 0.01 × 3.5 ÷ 365 ≈ 0.18 drive failures in that window: roughly a 1-in-6 chance of losing the stripes on one drive. That's the price of zero margin; 9+6 would close it. | – |
| Correlated drive failures | One batch of drives starts failing at 3× the normal rate. | Track failure rates per drive model and batch; place a stripe's 12 chunks on drives from different batches where we can; when a batch's rate rises, drain it proactively (copy off before it fails) and raise its stripes' repair priority. | – |
| Compaction falls behind | Dead space climbs on nodes with delete-heavy buckets; free space drops toward the 80% line. REL 6 | Compaction has its own I/O budget, raised automatically when dead space passes 25% of a node; placement stops sending new containers to nodes above 85% full; alarm long before writes would stall. | WAL/LSM compaction storm |
| Metadata and data disagree | A record points at a chunk that doesn't exist, or chunks exist with no record. | Bytes-first-then-commit makes the second case normal (orphans) and the first a bug. The scrubber's cross-check finds both; missing chunks go to repair from the stripe's others, orphans to the garbage collector after the grace period. | – |
| A partition splits under load | 503 SlowDown for one prefix for seconds to a minute. | SDKs retry with full-jitter backoff; the split finishes; the hash-spread mode takes over for tail-heavy ranges. An alarm fires only if throttling lasts longer than a few minutes. | – |
| The day-7 encoder falls behind | The replicated window grows past 7 days; disk fills faster. | It's an alarm, not an outage: data is safe as copies. We add encoder throughput or pause the window's growth by encoding the largest, least-read objects first. | – |
R2.9 Production Gotchas
1. Tiny objects stored straight into erasure coding
- Symptom: disks fill far faster than the stored bytes suggest; small reads are slow.
- Cause: a 2 KB object split 12 ways occupies 12 disk blocks (48 KB) and needs 8 reads.
- Fix: containers (step 2.5), and a check in the write path that routes anything under 128 KB to them.
2. Buffering whole uploads in front-end memory
- Symptom: front-ends run out of memory during a burst of large uploads.
- Cause: a front-end that holds each 5 GB part in memory before writing it needs 5 GB per upload; a few dozen at once exhaust a 64 GiB machine.
- Fix: stream in 8 MB pieces with backpressure (stop reading from the client while storage nodes are behind), so memory per upload is bounded to a few pieces.
3. No end-to-end checksum
- Symptom: a customer's file has one wrong byte in the middle, and every internal check passed.
- Cause: corruption happened in a front-end's memory or on a network card after the TCP checksum and before the chunk CRC was computed.
- Fix: a full-object checksum from the client (or computed at the edge), verified at commit and every time the bytes are rewritten (step 2.3).
4. Orphaned multipart uploads
- Symptom: "used space" is petabytes more than the sum of object sizes.
- Cause: clients start multipart uploads and never complete or abort them.
- Fix: a default 7-day abort rule on every bucket, a metric for bytes in incomplete uploads, and an alarm if the cleanup job stops.
5. Counting with counters instead of sets
- Symptom: containers are compacted while mostly live, or chunks are freed while a version still points at them.
- Cause: "dead bytes" or "reference count" was kept as a number that at-least-once processing incremented or decremented twice.
- Fix: keep sets (dead object IDs, referencing version IDs) and derive the numbers from them (steps 2.5 and 2.7).
R2.10 Pillar Check
| Pillar | What Round 2 adds |
|---|---|
| Reliability | 8+4 with 4 chunks per AZ survives an AZ (zero margin, stated); repair by margin; scrubbing; Raft metadata across AZs; published per-prefix limits with 503 SlowDown. REL 1 · REL 5 · REL 10 · REL 11 |
| Performance Efficiency | Flash and one read for small objects; hedged k + 1 reads; zonal endpoints and DNS spreading; a 9 ms small-GET budget. PERF 3 · PERF 4 |
| Security | Bucket policies checked at the metadata partition; SigV4 everywhere; KMS-protected bucket keys; TLS 1.3 from clients and mutual TLS between front-ends, storage and metadata nodes (below). SEC 3 · SEC 8 · SEC 9 |
| Cost Optimization | Three designs priced; transfer found to be 56% of the bill; no load balancer on the byte path; 3-year reservations on the steady fleet; S3 compared. COST 6 · COST 7 · COST 8 |
| Operational Excellence | Alarms tied to first actions; deploys one AZ at a time (below). OPS 6 · OPS 8 |
| Sustainability | Light this round: lifecycle expiry and noncurrent-version cleanup keep us from storing what nobody needs; 1.59× instead of 3× means about half the drives spinning. SUS 4 · SUS 5 |
Security in detail. Front-ends terminate TLS 1.3 and verify SigV4 signatures with signing keys cached per access key and day. Permissions are bucket policies stored in the metadata plane with the bucket; the partition that owns the bucket's range evaluates them, so a policy change and the next request see the same state. Front-ends cache bucket settings (policies, versioning state, lifecycle) with the setting's version number; a change pushes an invalidation, and a front-end refilling its cache only stores what it fetched if no newer version arrived meanwhile, so a late refill can't overwrite an invalidation. Inside the cluster, front-ends, metadata nodes and storage nodes authenticate each other with mutual TLS. Storage nodes sit in private subnets that accept traffic only from front-ends, rebuilders and each other.
Operations in detail.
| Alarm | Threshold | Severity | First action |
|---|---|---|---|
| Stripes at zero margin (8 of 12) or single-copy objects | > 0 for 10 min | P1 | Check the repair queue is draining; raise the critical budget |
| Repair queue age, normal priority | > 24 h | P2 | Raise the repair cap; look for a failing batch |
Small-GET P99 | > 15 ms for 5 min | P2 | Find the partition or flash node; check hedging rates |
503 SlowDown rate | > 1% of a bucket's requests for 10 min | P3 | Confirm a split is running; check for a tail-heavy range |
| Scrub errors per drive | > 3 in a day | P2 | Drain the drive |
| Disk fullness per AZ | > 80% | P2 | Order nodes; check the day-7 encoder and compaction |
| Share of read bytes on data under 7 days | < 75% for a week | P3 | The cost model is drifting (R2.6); review the window |
Deploys go one AZ at a time, never touching two replicas of a partition or more than one AZ's chunks at once, starting with a canary node that bakes for an hour.
R2.11 Round 2 Rubric and Follow-Ups
What a strong senior (L6) answer adds over L5
- Reconciles request rates with stored bytes and notices when given numbers can't both be true.
- Explains erasure coding precisely: the 1.5× cost, the 4-per-AZ layout, and exactly which failures it survives, including "zero margin after an AZ".
- Does the repair math and prioritizes by remaining margin.
- Handles silent corruption end to end, and slow nodes with hedging.
- Treats small objects differently, with numbers.
- Keeps listings ordered while splitting hot partitions, and knows why prepending a hash breaks listing.
- Prices the design on AWS and finds the line (cross-AZ transfer) that changes it.
Follow-up questions
-
"How is a
GETright after aPUTguaranteed to see it?" Answer: thePUTis acknowledged only after its version record commits at the partition leader, with a majority of replicas. TheGETreads the record from that leader, which confirms it's still the leader before answering. So the read sees every committed write. Chunks are immutable and only reachable through a record, so there's no stale data to find. Nothing in front of the leader caches object records. -
"Why not 10+4 over three AZs? It's cheaper." Answer: 14 chunks can't split evenly over 3 AZs; one AZ holds 5. Losing that AZ leaves 9 chunks, and we need 10. It's 1.4× and doesn't survive an AZ. Over 3 AZs, anything that survives an AZ costs at least 1.5×.
-
"Cut the bill by 30%." Answer: 30% of ≈ $628K is about $188K. Transfer is the lever: $350K. A local-parity code per AZ would keep most repairs inside an AZ; routing each
GETof coded data to a front-end in the AZ holding most of its data chunks helps a little. At this spend we'd also negotiate private pricing with AWS. Storage has less room: nodes are already 79% full on 3-year prices. Honestly, the biggest saving is not building it: S3 at ≈ $665K list, with a volume discount, is about the same price and needs no team.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "8+4 survives any 4 AZs" | There are 3 AZs. It survives one AZ, with no margin left. |
| "Parity overhead is 33%" | Parity is 50% on top of the data (1.5×); one third of the stored bytes. |
| "Acknowledge after 8 of 12 chunks" | The object then survives no further failure. |
| "Prepend a hash to spread prefixes" | Listing by prefix breaks unless the sub-ranges are merged in order. |
| "Erasure coding is always cheaper" | On AWS, reads of coded data cross AZs and are billed per GB. |
| "Aurora PostgreSQL multi-master" | Multi-master was an Aurora MySQL 5.6 feature, now retired. |
Round 3 · Architect · "Exabytes, Public Cloud, and Compliance"
~45 min · Principal (L7) · 4 regions, 4 AZs each · ~1.12 EB, ~100B objects · ~1M requests/s average · 99.99% per region
R3.0 Where We Left Off
What the candidate says in the first 60 seconds of Round 3, and everything you need if you start here.
Round 2 in 60 seconds. "We run the company's storage platform in one region across three AZs: 20 PB active, 1.8 billion objects, 25K writes and 250K reads a second at peak, 99.99%. Metadata lives in a range-partitioned store of Raft groups, one replica per AZ; reads go to the leader, so every read and listing sees the latest write, and hot partitions split by load, with hash-spread tails whose listings merge sorted sub-ranges. New data is written as three copies, one per AZ, and after 7 days each AZ encodes its own copy into 8+4 Reed–Solomon chunks, 4 per AZ: 1.59× the data instead of 3×. That survives an AZ, with zero margin. Objects under 128 KB live packed in containers on flash with three copies; medium ones in hard-drive containers; tombstones and compaction at 30% dead. Repairs are prioritized by margin left, a scrubber checks every byte every 13 days, and reads hedge to a ninth chunk after the P95. It costs about $628K a month on AWS, and 56% of that is cross-AZ transfer. Open costs: one region, nothing that can't be deleted, cold data on the same disks as hot, and no billing."
Architecture v2, compact
Synthesizing vector architecture diagram...
Round 2 in one picture: every request asks the metadata partition that owns its key; small objects come from flash, everything else from hard drives, where data spends its first week as copies and the rest of its life as 8+4 stripes.
Rounds 1–2 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | Where does a file live? | Metadata/data split | Two systems must agree |
| 1.2 | A disk died | 3 copies, one per AZ | 3× disk |
| 1.3 | Big uploads fail | Multipart upload | Abandoned uploads |
| 1.4 | Bytes through app servers | Pre-signed URLs | Key management |
| 1.5 | List by prefix | Sorted index | Needs an ordered store |
| 1.6 | Metadata without bytes | Bytes first, then commit; GC | Orphans |
| 2.1 | 3 copies too expensive | 8+4, 4 per AZ; 7-day replicated window | Repair reads 8×; zero margin after an AZ |
| 2.2 | Repair floods the network | Priority by margin; caps | Longer low-priority windows |
| 2.3 | Silent corruption | Block CRCs, object checksum, scrubber | Scrub I/O |
| 2.4 | Slow nodes | Hedged k + 1 reads | ~5% more reads |
| 2.5 | Tiny objects | Flash and HDD containers; compaction | Compaction I/O |
| 2.6 | Hot prefix | Raft range partitions; split by load | Brief throttling |
| 2.7 | Versions, cheaper data | Version records; reference sets; lifecycle | More metadata |
Open costs: a single region is a single blast radius; nothing stops deletion; cold data sits on disks priced for hot data; and nobody gets a bill.
R3.1 The Scope Raise
Interviewer: "We're turning the platform into a public cloud storage service. Millions of customer accounts, a bit over an exabyte, and we'll run it in several regions. Some customers need a copy of certain buckets in a second region. A bank tells us regulators require some records to be impossible to delete for seven years. Most of the data is cold and should cost almost nothing to keep, but it must come back when asked. Every byte stored and every request is billed. And a whole region can fail."
We ask back, and say what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Do we run on AWS or on our own hardware? | We're the provider now. We build our own regions. | Round 2 showed a public service can't pay per-GB fees to cross AZs: they were 56% of the bill. With our own network, crossing an AZ costs link capacity, not a per-GB fee. Cost math moves to hardware (R3.6). Each region gets 4 AZs. |
| How much of the data is cold? | About 60% isn't read for months, 25% is read rarely, 15% is active. | Three storage classes with different media and codes (step 3.3). |
| Which buckets need a second region, and how stale may the copy be? | About 10% of non-archive data. Minutes are fine; some want a written guarantee, like 15 minutes. | Asynchronous replication with per-object status and a lag target (step 3.1). |
| "Impossible to delete" by whom? | By anyone: the customer's own administrators, their root account, and us. Some also need to freeze data for lawsuits, with no end date. | Retention enforced inside the metadata plane on every delete path, with no override (step 3.2). |
| How quickly must archived data come back? | Hours are fine; customers who can wait two days should pay less. | Asynchronous restore with tiers (step 3.3). |
| How is usage billed? | Per GB-month by class, per request, per GB out; usage visible by the hour; invoices monthly. | A metering pipeline that never double-counts (step 3.5). |
| If a region fails, what must still work? | Replicated buckets keep serving reads from their other region. Other buckets may be unavailable but must lose nothing. | Durability is per region; availability across regions is per bucket (step 3.1, R3.8). |
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Customers | Internal teams | Millions of accounts |
| Data | 20 PB active | ≈ 1.12 EB: 15% Standard, 25% Infrequent Access, 60% Archive |
| Objects | 1.8B | ~100B |
| Requests | 110K/s average | ~1M/s average worldwide (≈ 2.6 trillion a month) |
| Footprint | 1 region, 3 AZs, on AWS | 4 regions of our own, 4 AZs each |
| Survives | An AZ | A region, for replicated buckets (reads from the other region) |
| Availability | 99.99% | 99.99% per region; higher for reads of replicated buckets |
| New | – | Cross-region replication; object lock; archive with restore; metering and billing |
| Cost goal | Cheaper than 3 copies | Cost per GB-month per class, below the price we sell it at |
R3.2 What Breaks in the Round 2 Design
| Round 2 property | How it fails at the new scope |
|---|---|
| One region | A region-wide event (power, network, a bad control-plane change) makes every bucket unavailable at once. |
| Deletes always succeed for authorized users | An attacker with stolen admin credentials, or a careless script, can delete seven years of records. "Please don't" isn't a control. |
| Cold data on active disks | 673 PB of archive on powered hard drives at 1.33–1.5× would cost several times what an archive tier can be sold for (step 3.3). |
| No metering | We can't bill 2.6 trillion requests a month, or tell a customer why their bill doubled. |
| Every tenant shares every partition and node | One tenant's burst of a million requests a second throttles everyone who shares its partitions and nodes. |
| 8+4 over 3 AZs | Zero margin after an AZ loss. With 4 AZs per region we can do better at the same cost (step 3.4). |
R3.3 New Requirements and API Additions
Replication rules (per bucket; versioning must be on in both buckets, so every version keeps its identity):
json{ "Role": "arn:ourcloud:iam::111122223333:role/replication", "Rules": [ { "ID": "invoices-to-eu-west", "Status": "Enabled", "Filter": { "Prefix": "invoices/" }, "Destination": { "Bucket": "arn:ourcloud:storage:::acme-docs-replica-euw", "StorageClass": "STANDARD_IA", "ReplicationTime": { "Status": "Enabled", "Minutes": 15 } }, "DeleteMarkerReplication": { "Status": "Enabled" } } ] }
HEAD on a source object then shows x-amz-replication-status: PENDING, COMPLETED or FAILED; on the copy it shows REPLICA.
Object lock. Turned on per bucket (it requires versioning), with an optional default retention:
json{ "ObjectLockEnabled": "Enabled", "Rule": { "DefaultRetention": { "Mode": "COMPLIANCE", "Years": 7 } } }
Or per object version, at upload:
httpPUT /bank-records/trades/2026/09/26/batch-0412.parquet HTTP/1.1 x-amz-object-lock-mode: COMPLIANCE x-amz-object-lock-retain-until-date: 2033-09-27T00:00:00Z
A legal hold has no date; it's on until someone with permission turns it off:
httpPUT /bank-records/trades/2026/09/26/batch-0412.parquet?legal-hold&versionId=Qv1x9c... HTTP/1.1 <LegalHold><Status>ON</Status></LegalHold>
| Governance mode | Compliance mode | Legal hold | |
|---|---|---|---|
| Who can delete the version or shorten retention early? | Only users granted a special bypass permission, who must also send x-amz-bypass-governance-retention: true | Nobody, including the account's root user and our own operators | Nobody while it's on |
| Can retention be extended? | Yes | Yes | – (no date) |
| For | Protection against mistakes; testing a policy | Regulated records | Litigation |
An archive class with asynchronous restore. Objects in ARCHIVE can't be read directly: a GET returns 403 InvalidObjectState. A restore request makes a temporary readable copy:
httpPOST /acme-backups/orders-db/2025-01-31.dump?restore HTTP/1.1 <RestoreRequest> <Days>7</Days> <GlacierJobParameters><Tier>Bulk</Tier></GlacierJobParameters> </RestoreRequest>
httpHTTP/1.1 202 Accepted
While it runs, HEAD shows x-amz-restore: ongoing-request="true"; when done, x-amz-restore: ongoing-request="false", expiry-date="Sun, 04 Oct 2026 00:00:00 GMT", and GET works until then. We offer Standard (within 12 hours) and Bulk (within 48 hours, cheaper), the same targets S3 Glacier Deep Archive publishes.
Usage reports
httpGET /v1/usage?account=111122223333&from=2026-09-01T00:00Z&to=2026-09-26T00:00Z&granularity=HOUR&group-by=bucket,storage_class HTTP/1.1
json{ "rows": [ { "hour": "2026-09-25T13:00Z", "bucket": "acme-media", "storage_class": "STANDARD", "byte_hours": 8.47e14, "put_requests": 1182233, "get_requests": 40114920, "bytes_out": 9.1e12 } ], "complete_through": "2026-09-25T21:00Z" }
complete_through tells the customer which hours are final; later hours can still grow as late records arrive (step 3.5).
R3.4 Design Evolution: Regions, Law and Money
Step 3.1: Keep a Copy in Another Region
The problem: a customer's invoices bucket lives in us-east. They want a copy in eu-west, so a regional disaster can't take both, and they want to know how far behind the copy is. What would you do?
Synthesizing vector architecture diagram...
The client's PUT returns before any cross-region work starts. The replicator follows the partition's log in order; if it crashes anywhere, it resumes from its last recorded log position and repeats steps whose effects are idempotent.
Why not publish an event to a queue after each commit? That's a dual write: the commit can succeed and the publish fail (a timeout, a crash in between), and the replica silently never gets that version. Reading the committed log, or writing an "outbox" row in the same transaction as the record, makes the change and its notification one atomic fact. We read the log rather than poll an outbox table because polling adds queries to the busiest partition leaders, delivers only as fast as the poll interval, and must track what it has already seen; the log is already ordered, durable and positioned.
Why the Frankfurt booking goes wrong. The drill below asks about a seat booked in Frankfurt that still looks free in Virginia two seconds later. That's our two-way replication case: each region reads its own copy, the copy lags by seconds, and a conditional write checks only the local copy, so both regions can "win" the last seat. The fix is not faster replication but one owner per key: route writes that need a single answer to one region. And making every write synchronous to three regions to avoid it would put the slowest inter-region round trip on every write and stop all writes whenever any region is down.
Primitives: Cloud Disaster Recovery & Multi-Region Active-Active · Change Data Capture & Outbox Pattern · Drills: Replication multi-region consistency · CDC outbox dual-write drift
Step 3.2: Data Must Be Undeletable for 7 Years
The problem: the bank must keep trade records for seven years in a form nobody can delete or change. An attacker with the bank's root credentials, a rogue admin, or our own support staff must all fail. What would you do?
Synthesizing vector architecture diagram...
A legal hold always wins. Compliance mode waits for the date plus a safety margin; governance mode lets a specially permitted caller through.
Step 3.3: Cold Data Costs Too Much on Active Disks
The problem: about 673 PB of our 1.12 EB is archive data: backups, old logs, records kept for law. It sits on the same powered hard drives as active data. The market price for this kind of storage is about $1 per TB-month. What would you do? Show the cost per TB.
The same logic gave us the IA class: it stays on hard drives (it must be readable in milliseconds), but on a wider, cheaper code spread over 4 AZs, at a higher fill, with no flash or replicated window, and a retrieval fee that keeps hot data out.
Step 3.4: Durability Must Be a Number We Can Defend
The problem: the sales page says "11 nines". A customer's auditor asks how we know. What would you do?
Step 3.5: Metering Trillions of Requests for Billing
The problem: about 1 million requests a second, 2.6 trillion a month, each billed by type, plus every byte stored per hour and every byte sent out. A customer disputes a bill and wants the hourly breakdown. What would you do?
Primitive: Message Queues vs Event Streams · Loop: Design a Distributed Message Queue
Step 3.6: One Tenant's Traffic Hurts Others
The problem: a customer launches a data-processing job that sends 800,000 GETs a second to one bucket. Partitions and storage nodes shared with thousands of other customers slow down, and they open support tickets.
What would you do?
The biggest tenant is about to grow tenfold. Their buckets' partitions split by load automatically, but the cells holding them would run out of headroom. So we plan it like a migration: raise their limits deliberately, move their largest buckets to a dedicated (or nearly empty) cell before the growth lands, and, if one bucket outgrows a cell, split it by key range across cells. A tenant that big is carved out on purpose, not discovered by an outage.
A platform-wide report across all tenants ("top 100 accounts by growth this month") never queries the live metadata partitions, which are sharded by bucket and key across thousands of partitions in about two dozen cells. It runs on the metering data and on daily inventory snapshots copied into an analytics store, where scanning everything is cheap and hurts nobody.
Primitive: Circuit Breaker, Bulkhead & Fault Tolerance · Drill: Sharding tenant hotspot · Loop: Design a Distributed Rate Limiter
Round 3 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 3.1 | A copy in another region | Async replication from the Raft log; version IDs preserved; status and lag per object | RPO = lag; backbone transfer; a second copy |
| 3.2 | Undeletable for 7 years | Object lock in the version record; checked on every delete path; one-day clock margin | Storage we can't reclaim |
| 3.3 | Cold data too expensive | IA on 12+4 over 4 AZs; archive bundles on offline media; batched restores | Restore latency; a scheduler |
| 3.4 | Defend "11 nines" | Model with AFR and repair time; 8+4 at 3 per AZ; correlated-risk controls | Modeling and monitoring |
| 3.5 | Metering | Per-minute batches, replaceable rows, hourly rollups; exact byte totals in metadata | Billing lags; reconciliation |
| 3.6 | Noisy tenants | Per-tenant limits, partition admission, shed before TLS; cells | Placement and rebalancing |
R3.5 Global Architecture
Synthesizing vector architecture diagram...
Each region is a set of independent cells behind a cell directory, plus an archive tier, replicators and a metering pipeline. Replication crosses regions only through the destination's own front-ends, so the destination applies the same checks as for any write. Only billing is global, and it's off the request path.
Tracing a replicated PUT
- us-east front-end: limits check, signature, cell directory → cell 1.
- Bytes to three flash or hard-drive nodes in three AZs; version record committed at the partition leader with
replication_status = PENDING.200 OK. - The replicator for that partition reads the new log entry, streams the bytes to eu-west, and eu-west commits the same version ID as
REPLICA. - The replicator marks the source
COMPLETED. The lag metric for that rule drops back.
Tracing a delete of a locked version
DELETE ?versionId=Qv1x9c…reaches the partition leader for the key.- The record says
COMPLIANCE, retain until 2033-09-27. Leader time is earlier than retain-until plus the one-day margin. 403 AccessDenied. An audit entry records who tried. Nothing else happens. The same check stops lifecycle expiration and garbage collection from touching it.
Tracing an archive restore
POST ?restore(Bulk, 7 days) → the partition records a restore request on the version and returns202.- The restore scheduler collects requests for the same cartridge and mounts it when the batch is big enough or the 48-hour target approaches.
- It reads the object's range from its data chunk, checks the object checksum, writes a temporary Standard copy (three copies), and updates the record: restored until a date.
HEADshowsongoing-request="false". - After 7 days, the temporary copy's reference is dropped and the garbage collector frees it. The archive copy never moved.
R3.6 Numbers and Cost
Data by class (1.121 EB = 100B objects × 11.21 MB)
| Class | Share | Logical | Physical factor | Physical |
|---|---|---|---|---|
| Standard | 15% | 168.2 PB | 1.59 (7-day window, then 8+4) | 267.5 PB |
| Infrequent Access | 25% | 280.3 PB | 1.33 (12+4) | 373.7 PB |
| Archive | 60% | 672.6 PB | 1.33 (12+4, offline media) | 896.8 PB |
| Replicated copies | 10% of Standard + IA = 44.8 PB | 44.8 PB | 16.8 PB × 1.59 + 28.0 PB × 1.33 | 64.1 PB |
| Total | 1.166 EB stored | ≈ 1.37 overall | ≈ 1.60 EB |
Traffic. We scale Round 2's rates per PB: Standard ≈ 5,000 reads/s and 500 writes/s per PB (100K and 10K for 20 PB), IA about a twentieth of that for reads.
| Item | Math | Result |
|---|---|---|
| Standard reads | 168 PB × 5,000 | 840K/s |
| IA reads | 280 PB × 250 | 70K/s |
| Writes | 168 PB × 500 | 84K/s |
| Total | ≈ 1M requests/s average | |
| Requests a month | 1M × 86,400 × 30.4 | ≈ 2.6 trillion |
| New bytes a day | Round 2's 172.8 TB/day × (168 ÷ 20) | ≈ 1.45 PB/day |
| Check: Standard's residence time | 168 PB ÷ 1.45 PB/day | 116 days, as in Round 2; the rest flows to IA and archive, which hold roughly the last 2 years |
Drives and repair (our own nodes, with 24 TB drives, an assumption for current high-capacity drives)
| Item | Math | Result |
|---|---|---|
| Physical on hard drives | Standard + IA + replicated copies | 705.3 PB |
| Drives | 705.3 PB ÷ (24 TB × 80% full) | ≈ 36,700 |
| Cells | 705.3 PB ÷ 4 regions ≈ 176 PB stored per region; ÷ 80% ≈ 220 PB of raw disk; ÷ ~40 PB per cell (Round 2's 120 nodes) | about 6 cells per region, two dozen in all |
| Failures a year at 1% AFR | 36,700 × 0.01 | ≈ 367, about one a day |
| Reads to repair one drive | 19.2 TB of chunks × 10.3 (average chunks read: 8 for Standard, 12 for IA, weighted by bytes) | ≈ 198 TB |
| Within 4 hours | 198 × 10^12 ÷ 14,400 s | 13.8 GB/s ≈ 110 Gbps while it runs |
| Average repair traffic | 198 TB/day | ≈ 2.3 GB/s ≈ 18 Gbps worldwide, around the clock |
Bigger drives mean more data per failure and longer repairs: a 24 TB drive holds about 1.7× (24 ÷ 14) the data of a 14 TB drive at the same fill, and T enters the durability model to the fourth power. The repair read per failure grows more than that, from about 88 TB in Round 2 to about 198 TB here, because IA's 12+4 code reads 12 chunks per lost chunk instead of 8. That's a reason to keep repair bandwidth growing with drive size.
Cross-region replication
| Item | Math | Result |
|---|---|---|
| Replicated new bytes | 10% × 1.45 PB/day | 145 TB/day ≈ 1.68 GB/s ≈ 13.4 Gbps average, ~34 Gbps at peak |
| Billed to customers | 145,000 GB/day × $0.02 (S3's inter-region transfer price, used as our reference) × 30.4 | ≈ $88K/month of revenue; our cost is backbone capacity we own |
Metering volume
| Item | Math | Result |
|---|---|---|
| Raw per-request records, if we logged them | 2.6 trillion × ~200 B | ≈ 520 TB a month |
| Per-minute batches | ~300 front-ends (Round 2's 30 per 440 Gbps, scaled) × ~5,000 active keys per minute (our assumption) ÷ 60 | ≈ 25K rows/s, ~150 B each ≈ 3.75 MB/s |
Cost per GB-month per class (our cost with the d3en stand-in of $5.93 per raw TB-month; serving adds Round 2's flash, metadata and front-end cost per TB for Standard, about $1.8, and an assumed $0.3 for IA; archive is an assumed target, not a derived number)
| Class | Our cost per TB-month | Per GB-month | Reference list price per GB-month |
|---|---|---|---|
| Standard | 1.99 × $5.93 + $1.8 ≈ $13.6 | ≈ $0.014 | $0.021–0.023 |
| Infrequent Access | 1.57 × $5.93 + $0.3 ≈ $9.6 | ≈ $0.0096 | $0.0125 |
| Archive | target ≤ $0.5 all-in (media, libraries, drives, space) | ≤ $0.0005 | $0.00099 |
| Monthly | Math | ≈ Cost |
|---|---|---|
| Standard | 168,200 TB × $13.6 | $2.29M |
| IA | 280,300 TB × $9.6 | $2.69M |
| Archive (target) | 672,600 TB × $0.5 | $0.34M |
| Replicated copies | 16,800 TB × $13.6 + 28,000 TB × $9.6 | $0.50M |
| Total | ≈ $5.8M, about $0.0052 per GB-month averaged over 1.12 EB |
Storage revenue at the reference prices would be about $8.4M a month (Standard $3.53M, IA $3.50M, archive $0.67M, replicated copies $0.70M), before requests and transfer. These costs leave out people, buildings and the backbone, so the margin is thinner than it looks; the point is that each class is priced above what it costs, which Round 2's AWS-hosted design could not do for Standard.
R3.7 Trade-Offs
Replication between regions vs erasure coding across regions
| Two regions, each with its own coded copy (chosen) | One code spread over 3 regions (e.g. 6+3, 3 chunks per region) | |
|---|---|---|
| Storage for "survive a region" | 2 × 1.5 = 3.0× | 1.5× |
| Normal reads | Local to one region | Two thirds of the chunks come from other regions: tens of milliseconds, every read |
| Repairs | Inside a region | Every repair crosses regions |
| Region outage | The other region serves reads by itself | Every read decodes across the two remaining regions; zero margin |
| Fits | Anything read often; any bucket that must keep working | Archive data that's rarely read and huge, if its customers accept slower restores |
Geo-distributed coding halves the cost of surviving a region, and pays for it in latency and inter-region traffic on every operation. We offer replication per bucket, and would consider cross-region coding only for the archive.
Archive restore time vs cost. Restores are slow because batching makes them cheap: one mount serves many requests. A 1–5 minute restore would need cartridges mounted and waiting, or data on powered disks, which is exactly the cost we left behind. So we sell speed as a price: Standard within 12 hours, Bulk within 48 hours for less.
Cell size
| Small cells (a few PB) | Large cells (100+ PB) | |
|---|---|---|
| Blast radius | Small | Large |
| Stranded free space | High: every cell keeps its own headroom | Low |
| Big buckets | Must span cells often | Rarely span |
| Chosen | About 40 PB of hard-drive capacity per cell, like Round 2's platform: a size we already know how to run and repair |
Closing the loop. The question was: how do we store any amount of bytes so they are never lost, always findable, and cheap?
- Never lost: a stripe survives any 4 losses; repairs are prioritized by margin; a scrubber reads every byte twice a month; locked data can't be deleted; replicated buckets survive a region; and the durability model says which input to watch.
- Always findable: one ordered, strongly consistent metadata plane decides what exists; bytes become visible only through a committed record.
- Cheap: three copies only while data is hot, 8+4 or 12+4 afterwards, offline media for the archive, and per-class prices set above per-class costs.
R3.8 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| A region outage | All cells in us-east unreachable. | Replicated buckets: customers read from the replica bucket in eu-west (and may write there, with two-way replication resolving overlaps by last writer wins). Objects written in the last few seconds or minutes before the outage may be missing there: that's the RPO. Non-replicated buckets: unavailable until the region returns, but nothing is lost: durability is inside each region. |
| A replication backlog | Lag climbs from seconds to hours for one rule; PENDING counts grow. | Find the cause: a destination throttling, a slow link, a replicator stuck on one partition. Replicators scale per partition; rules with the 15-minute option get priority for bandwidth. Customers see per-object status, so nothing is silently "done". |
| A bug deletes metadata | A deploy in one cell removes version records it shouldn't. | Contained to one cell by staged deploys. Chunks aren't freed until 7 days after their last reference goes (Round 3 lengthens the 24-hour grace for this), and each partition's Raft snapshots and logs are copied to another cell every few minutes. We restore the partitions to just before the bad deploy; the chunks they point to still exist. Versioning and object lock protect against deletes through the API, not against our own bugs; delayed GC and metadata backups do. |
| Correlated media failures | A batch of drives, or a batch of tape cartridges, starts failing. | Stripes span batches where possible; AFR is tracked per batch; the batch is drained proactively, and its stripes are pushed up the repair queue (step 3.4). For archive media, a periodic read-verify of a sample of cartridges finds a bad batch before customers do. |
| A restore flood | A customer asks to restore 10 PB at once. | The restore queue has per-account quotas and fair scheduling; bulk restores run as capacity allows within their 48-hour target, and very large requests are priced and scheduled as projects. |
Drills: Replication multi-region consistency · CDC outbox dual-write drift
R3.9 Runbook and Incident Response
Golden signals, per cell and per region OPS 8 · REL 6
| Signal | Alarm | Severity | First action |
|---|---|---|---|
| Durable-write failures (a write that couldn't get its copies or its commit) | > 0.1% for 5 min | P1 | Which AZ or partition? Is placement short of healthy nodes? |
| Stripes at zero margin | > 0 for 10 min | P1 | Confirm critical repairs are running and draining |
| Repair queue age, by priority | critical > 1 h; normal > 24 h | P1 / P2 | Raise repair caps; look for a failing batch |
| Scrub error rate per drive and per batch | > 3 errors/day per drive; a batch above its baseline | P2 | Drain the drive; open a batch investigation |
503 SlowDown from partition throttling | > 1% of a bucket's requests for 10 min | P3 | Is a split running? A tail-heavy range? A tenant over its limit? |
| Replication lag, per rule | > 15 min for rules with the time option | P2 | Check replicator health and the destination |
| Garbage-collection backlog (unreferenced bytes past grace) | growing for 3 days | P3 | GC is stuck or blocked; free space will follow |
| Restore queue vs targets | any Standard request older than 10 h | P2 | Add mount capacity or reprioritize |
Procedure: a drive fails
- The node reports the drive; its chunks are marked lost; stripes enter the repair queue at their margin.
- Check that the queue drains at the expected rate (about 198 TB of reads for a 24 TB drive; around 4 hours at full priority).
- The drive is replaced in the next scheduled visit. Nothing is urgent unless its stripes were already below normal margin.
Procedure: a rack fails (power or top-of-rack switch)
- All nodes in it go silent. Placement never puts two chunks of a stripe in one rack, so each stripe lost at most one chunk.
- Wait 15 minutes: most rack events are power or network and come back. Hedged reads cover reads meanwhile.
- If it stays down, its nodes are declared lost and their chunks repaired at normal priority. Watch the zero-margin count: it should stay at zero.
Incident flow OPS 10
Synthesizing vector architecture diagram...
The first question is always the blast radius: a tenant, a cell, an AZ or a region. Each has a prepared response.
Go deeper: operator commands. Our own regions are run through an internal admin API, not a cloud provider's CLI. Two examples:
Raise the repair budget in a cell while a failing batch is drained:
httpPUT /admin/cells/use1-cell-03/repair-budget HTTP/1.1 Authorization: Bearer <operator token, two-person approved> Content-Type: application/json { "normal_gbps_per_node": 15, "critical_share": 0.5, "expires_at": "2026-09-27T06:00:00Z" }
List stripes at zero margin, oldest first:
httpGET /admin/cells/use1-cell-03/stripes?margin=0&order=oldest&limit=100 HTTP/1.1 Authorization: Bearer <operator token>
Every change carries an expiry, so a temporary override can't become permanent by accident.
Game days. Monthly: power off a rack, then a whole AZ in one cell, during business hours, and watch repair and zero-margin counts. Quarterly: restore a cell's metadata from backups into a scratch cell and compare it with the live one; restore a random sample of archive objects end to end. REL 12 · OPS 11
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | Per-bucket cross-region replication with measured RPO; cells; 8+4 at 3 per AZ for one spare after an AZ loss; a durability model with monitored inputs; delayed GC and metadata backups against bugs; game days. REL 9 · REL 10 · REL 12 · REL 13 |
| Performance Efficiency | Each class on the medium that fits its access pattern: flash for small hot objects, disks for IA, offline media for archive; per-partition admission control. PERF 3 |
| Security | Object lock enforced in the metadata plane on every delete path, with no override in compliance mode; bypass permission for governance mode; an audit log of lock changes; retention deletes refused while the leader's clock is in doubt. SEC 3 · SEC 4 · SEC 8 |
| Cost Optimization | Cost per GB-month derived per class and compared with its price; price-backward reasoning for the archive; lifecycle moving data down the classes; metering. COST 1 · COST 3 · COST 4 |
| Operational Excellence | Compliance requirements turned into a mechanism; alarms per cell with first actions; incident flow by blast radius; COEs. OPS 1 · OPS 8 · OPS 10 · OPS 11 |
| Sustainability | 60% of the data on media that draws no power while idle; 1.33× codes for cold classes; lifecycle expiry. SUS 2 · SUS 4 · SUS 5 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Turns "survive a region" into a per-bucket choice with an RPO, a price and stated conflict behavior.
- Makes "cannot be deleted" a mechanism with no bypass, including for the provider, and names the business question it creates.
- Prices each class backward from what it can be sold for, and changes the medium when the math says so.
- Defends a durability number with a model, knows which input is the lever, and knows the model's blind spots (correlation, bugs).
- Builds metering that is idempotent end to end.
- Thinks in blast radius: tenants, partitions, cells, regions.
Follow-up questions
-
"A customer deleted a bucket's objects by mistake an hour ago. Versioning was on. What can they do? And if it was off?" Answer: with versioning, the deletes only added delete markers; removing the markers (or copying the previous versions on top) brings everything back, with no bytes moved. Without versioning, the records are gone from the API's point of view. The chunks still exist for the 7-day grace period, and we have metadata backups, but restoring a single customer's deletes is an operator action we'd offer only as support, not a promise. That's why versioning, and object lock for regulated data, are the recommendation.
-
"Why not make the archive a 1–5 minute restore, like an expedited tier?" Answer: a minutes-level restore means the data must be on media that's already mounted or powered, which is the cost the archive exists to avoid. We could offer a small expedited capacity (a few mounted drives reserved for urgent requests) at a much higher per-GB price, as long as its volume is capped so it can't turn the archive back into a disk tier.
-
"Your 11 nines: what would make you lower it?" Answer: a sustained rise in real repair time (the queue ages instead of draining), a drive batch whose AFR we can't drain fast enough, or a design change that removes margin, such as a code with zero spare after an AZ loss for a class that promises the same durability. The model tells us how much each moves the number; the monitoring tells us when it's happening.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Write to both regions synchronously" | Every write pays the inter-region round trip, and either region's outage stops writes in both. |
| "Replicate for protection against deletes" | Deletes replicate too; only versioning and object lock protect against them. |
| "A deny-delete policy is WORM" | Administrators can change policies. Compliance mode must be enforced below the permission system. |
| "A wider code makes cold data cheap" | Over the same AZs, codes only move between about 1.5× and 1.33×; the medium is what costs. |
| "11 nines because we have 4 parity chunks" | Independent-failure math gives far more; correlated events set the real number. |
| "Increment a billing counter per request" | A hot central write on every request, and double counts on retries. |
Loop Closer: Interview Strategy for All Three Rounds
How to Run Each 60-Minute Round
| Time | Round 1 | Round 2 | Round 3 |
|---|---|---|---|
| 0–5 min | Scoping questions | Restate the Round 1 design in 60 seconds | Restate the Round 2 design in 60 seconds |
| 5–15 min | Requirements + API | Scope raise → what breaks | Scope raise → what breaks |
| 15–40 min | Design steps 1.0–1.6 | Design steps 2.1–2.7 | Design steps 3.1–3.6 |
| 40–50 min | Numbers + trade-offs | Numbers, cost, trade-offs | Numbers, cost per class, trade-offs |
| 50–60 min | Failures + pillar check | Failures + pillar check | Failures, runbook, pillar check |
For how to spend a single 45-minute round, see the 45-minute interview blueprint.
The Two Sentences That Matter Most
- Opening a round: "Before I design, let me ask a few scoping questions."
- When the scope is raised: "Here's what breaks in the current design, and here's the order I'll fix it in."
Well-Architected Review Sheet
Interviewers rarely ask "which pillar is this?". They ask the pillar's question in plain words. Rehearse one sentence per row.
| Pillar | Question you'll hear | One-sentence answer | Round | Backed by |
|---|---|---|---|---|
| Reliability | "What happens when a disk or node dies?" (REL 11) | Every chunk has copies or parity in other AZs; repair re-creates what was lost, most-at-risk stripes first. | 1–2 | Steps 1.2, 2.2 |
| "What happens when an AZ goes down?" (REL 10) | Reads decode from the other AZs' chunks and Raft elects new leaders; with 3 AZs and 8+4 that leaves zero margin, which Round 3 fixes with 4 AZs. | 2–3 | R2.8, step 3.4 | |
| "What are your limits?" (REL 1) | Per-prefix request rates that grow as partitions split, 5 GB per PUT, 10,000 parts, about 50 TB per object, all published. | 1–2 | R1.4, step 2.6 | |
| "What's your RPO for another region?" (REL 13) | Per bucket: replication lag, normally seconds, measured per rule, with a 15-minute option. | 3 | Step 3.1 | |
| "How do you protect against your own bugs?" (REL 9) | Delayed chunk deletion, metadata backups per partition, and cells with staged deploys. | 3 | R3.8 | |
| "How do you stop one slow node from hurting reads?" (REL 5) | Hedged reads: a ninth chunk request after the P95 time, decode from the first 8. | 2 | Step 2.4 | |
| Performance | "How are small reads fast?" (PERF 3) | Packed on flash with a copy in every AZ: one metadata read and one flash read, 9 ms at P99. | 2 | Step 2.5, R2.6 |
| "How do clients reach you at 400 Gbps?" (PERF 4) | DNS returns many healthy front-end IPs per AZ; no per-GB load balancer on the byte path. | 2 | R2.6 | |
| Cost | "Why not three copies?" (COST 6) | 8+4 is 1.5× instead of 3×; we keep three copies only for a week, while reads concentrate there. | 2 | Step 2.1 |
| "Where does transfer bite?" (COST 8) | Internet egress in Round 1, cross-AZ reads of coded data on AWS in Round 2 (56% of the bill). | 1–2 | R1.7, R2.6 | |
| "Should we just use S3?" (COST 5) | At 100 TB, yes; at 20 PB, the prices are close and S3 includes the team. | 1–2 | R1.8, R2.6 | |
| "What does a GB-month cost you?" (COST 1) | About 1.4 cents for Standard, 1 cent for IA, a target under a tenth of a cent for archive. | 3 | R3.6 | |
| Operations | "How do you know data is safe right now?" (OPS 8) | Alarms on stripes at zero margin, repair-queue age and scrub errors, each with a first action. | 2–3 | R2.10, R3.9 |
| "How do you meet a regulator's requirement?" (OPS 1) | Turn it into a mechanism: compliance-mode lock checked on every delete path, with an audit log. | 3 | Step 3.2 | |
| Security | "Who can upload?" (SEC 3) | Holders of a pre-signed URL for one method and key, valid for minutes; apps hold keys with bucket permissions. | 1 | Step 1.4 |
| "How is data protected?" (SEC 8, SEC 9) | Per-object data keys wrapped by bucket keys that a key-management service protects (AWS KMS in Rounds 1–2, our own in Round 3); TLS 1.3 outside, mutual TLS inside; object lock for records. | 1–3 | R1.10, R2.10, step 3.2 | |
| Sustainability | "How do you reduce its footprint?" (SUS 4, SUS 2) | Lifecycle expiry and tiering, 1.33–1.5× codes instead of 3×, and cold data on media that draws no power when idle. | 2–3 | Step 2.7, step 3.3 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| Metadata | Splits metadata from data; sorted index for listing; commit point after the bytes. | Range-partitioned Raft store, split by load, hash-spread tails with merged listings; linearizable reads. | Metadata as the enforcement point for locks, the source of replication and of exact billing totals. |
| Durability | Three copies across AZs; repair worker. | Erasure coding with exact failure tolerance; repair math and priority; scrubbing; end-to-end checksums. | A durability model with the lever named; correlated risks handled; 4 AZs for margin. |
| Performance | Ranged reads; AZ-local copies. | Small objects on flash in containers; hedged reads; latency budget. | Each class on the medium that fits it; admission control per partition. |
| Cost | Rough monthly cost; notices egress; compares with S3. | Prices three layouts and finds cross-AZ transfer; break-even on read locality. | Cost per GB-month per class against its price; price-backward archive design. |
| Failure handling | Node loss, database failover, abandoned uploads. | AZ loss at zero margin, correlated drives, compaction backlog, splits under load. | Region outage per bucket, metadata bugs, restore floods, runbooks by blast radius. |
| Evolving under new scope | Builds from one server, one problem at a time. | Opens with "what breaks" and reconciles the numbers before designing. | Changes the operating model (own regions, cells, law, billing), not just the components. |