The previous lesson deliberately left the product photos out of HDFS: thousands of small files that the website has to serve over HTTP, with metadata, versions and a durability that does not depend on a NameNode. Object storage was born for exactly that. It gives up directories, partial writes and rename, and in return offers a flat model of buckets and keys, an HTTP API that Amazon S3 turned into a de facto standard, "eleven nines" of durability through replication and erasure coding, and a cost per gigabyte that no network drive can match. Today it is the default store for photos, videos, backups, documents and, increasingly, the data lake itself. In this lesson we will understand its model and its API, see how durability is achieved (with a numerical Reed-Solomon example), learn to recognise when it is not the right tool, and put it to work at Kilometre Zero with MinIO in docker-compose.yml and a boto3 module with which Montblanc Dairy will upload the photo of its aged-cheese and the website will display it using presigned URLs.
Contents
- Object, file and block
- The object model: buckets, keys, metadata and versions
- The S3 API as a de facto standard
- Durability: replication and erasure coding (Reed-Solomon)
- Consistency in object stores
- When to use objects and when not to
- Cost: storage, operations and egress
- Objects at Kilometre Zero: photos, invoices and backups
- Hands-on: MinIO,
mcandservices/catalog/photos.pywithboto3 - Common Mistakes and Tips
- Exercises
- Conclusion
- Object, file and block
There are three ways of presenting storage to a program, and it is worth telling them apart before anything else:
| Block | File | Object | |
|---|---|---|---|
| Unit | Numbered fixed-size blocks (512 B–4 KB) | Files in a directory tree | Objects identified by a key in a flat bucket |
| Interface | Block reads/writes (SCSI, NVMe, iSCSI, Ceph RBD) | POSIX: open, read, write, seek, rename |
HTTP: PUT, GET, DELETE, LIST |
| Modification | Any block, at any time | Any byte, append, truncation |
None: the object is replaced in full |
| Metadata | None (the file system on top supplies it) | Fixed: permissions, dates, size | Fixed + user-defined (x-amz-meta-*) |
| Who uses it | An operating system (a VM's disk, a database) | Applications that need file semantics | Web applications, backups, data lakes, static content |
| Typical scale | One logical disk per consumer | Terabytes to petabytes (04-02) | Petabytes to exabytes, trillions of objects |
| Latency | Microseconds to milliseconds | Milliseconds | Tens of milliseconds per operation |
| Examples | EBS, Ceph RBD, iSCSI | NFS, HDFS, CephFS | S3, MinIO, Ceph RGW, Azure Blob, GCS |
The "modification" row is the one that changes everything: an object is immutable. To change one byte you upload a new object under the same key. That immutability simplifies replication (two copies of an object are either identical or one of them belongs to a different version; there are no intermediate states) and it is the reason object stores scale so far and cost so little. It is also the reason for almost all of their limitations (section 6).
- The object model: buckets, keys, metadata and versions
- Bucket: the top-level container, with a unique name (in S3, globally unique; in MinIO, unique per deployment). A bucket has a region, an access policy, a versioning configuration and lifecycle rules. Kilometre Zero will use
km0-photos,km0-invoicesandkm0-backups. - Key: the object's name within the bucket, a string of up to 1,024 bytes. There are no directories:
photos/aged-cheese/original.jpgis a single string, and the slash means nothing to the system. Consoles and tools display "folders" by grouping on a prefix, but it is an illusion: listing "the folderphotos/aged-cheese/" is aLISToperation withprefix=photos/aged-cheese/. - Data: from 0 bytes to 5 TB (S3). Opaque: the system does not interpret the contents.
- Metadata: system metadata (
Content-Type,Content-Length,Last-Modified,ETag) and user metadata (x-amz-meta-producer: montblanc-dairy, up to 2 KB in total). Metadata travels with the object and is retrieved withHEADwithout downloading the data. It cannot be modified without rewriting the object (this is done by copying the object onto itself). - Versioning: if the bucket enables it, each
PUTto an existing key does not overwrite it but creates a new version with its ownVersionId, andDELETEdoes not delete but places a delete marker on top. Earlier versions are still there and can be recovered: it is the simplest protection against accidental deletion and against a process that uploads corrupt files. - Lifecycle: declarative rules by prefix or tag: "objects tagged
kind=thumbnailthat are older than 90 days move to a cold storage class", "non-current versions are deleted after 30 days", "incomplete multipart uploads are aborted after 7 days". The system applies them on its own, with no code. - Storage classes: the same object can live in tiers with different costs and access times: standard (milliseconds), infrequent access (cheaper per GB, more expensive per read), archive (S3 Glacier: hours to retrieve, cents per TB). MinIO implements tiering to another, remote store.
- Tags: key-value pairs for classifying objects (
campaign=artisan-cheese-week) and driving lifecycle rules and access policies.
flowchart LR
subgraph B["bucket km0-photos (versioning enabled)"]
K1["photos/aged-cheese/original.jpg<br/>v3 (current) · v2 · v1<br/>meta: producer=montblanc-dairy"]
K2["photos/aged-cheese/thumb-300.jpg<br/>v1"]
K3["photos/pink-tomato/original.jpg<br/>v1<br/>meta: producer=la-vega-farm"]
K4["photos/crianza-wine/original.jpg<br/>delete marker · v2 · v1"]
end
LC["Lifecycle rule:<br/>thumbnails, tag kind=thumbnail, > 90 days → cold class<br/>non-current versions > 30 days → delete"] -.-> B
- The S3 API as a de facto standard
Amazon launched S3 in 2006 with a simple REST API, and today practically every object store implements it: MinIO, Ceph RGW, Google Cloud Storage (interoperability mode), Backblaze B2, Cloudflare R2, Wasabi… Learn S3 and you have learnt them all. The basic operations:
| Operation | HTTP | What it does |
|---|---|---|
PutObject |
PUT /bucket/key |
Uploads a complete object (up to 5 GB in a single call); accepts metadata in headers |
GetObject |
GET /bucket/key |
Downloads; supports Range for reading a chunk, and conditions (If-None-Match) |
HeadObject |
HEAD /bucket/key |
Metadata and ETag only: whether it exists, how big it is, what type it is |
DeleteObject(s) |
DELETE |
Deletes (or places a marker if versioning is on); up to 1,000 keys per call |
ListObjectsV2 |
GET /bucket?list-type=2&prefix=… |
Lists keys by prefix, paginated 1,000 at a time, with delimiter=/ to simulate folders |
CopyObject |
PUT with x-amz-copy-source |
Server-side copy with no download; this is how you "rename" and how you change metadata |
| Multipart upload | CreateMultipartUpload, UploadPart, CompleteMultipartUpload |
Upload in parts (5 MB–5 GB each, up to 10,000), in parallel and resumable; mandatory above 5 GB |
| Presigned URL | None: it is a signature | A URL carrying the server's signature and an expiry, which lets a third party perform one specific operation without credentials |
Two concepts that deserve more detail:
ETag. Every object has an entity tag that changes if the content changes. For simple uploads it is the MD5 of the content ("9e107d9d372bb6826bd81d3542a419d6"), which lets you verify the integrity of an upload by comparing the local MD5 with the ETag returned. For multipart uploads it is the MD5 of the parts' MD5s followed by -N (the number of parts), so it is no use as a checksum of the whole file. It is also used in conditional reads: the website issues a GET with If-None-Match: "<etag>" and receives 304 Not Modified if the photo has not changed.
Presigned URLs. The object store should not be publicly accessible, and Kilometre Zero's website does not want to proxy every photo (it would pay for the bandwidth twice and add latency). The solution is for the catalog service, which does have credentials, to sign a download URL with an expiry (say 15 minutes) and hand it to the browser; the browser downloads straight from the store, which verifies the signature and the date. The same mechanism works for uploads: Montblanc Dairy receives a presigned PUT URL and uploads its photo directly to the bucket without going through catalog. The signature (Signature V4) is an HMAC-SHA256 of the canonical request with the secret key; the server recomputes it and compares. It is a perfect example of a capability: a portable, limited permission, with no accounts or sessions, a topic that will come up again in 06-01.
- Durability: replication and erasure coding (Reed-Solomon)
S3 advertises an annual durability of 99.999999999% ("eleven nines"): if you store 10 million objects, you can expect to lose one every 10,000 years. This is not an availability figure (that one is 99.9%–99.99%); it is the probability that the bytes still exist. It is achieved with two techniques.
Replication. N complete copies in N different failure domains (disks, servers, racks, data centres). With 3 copies you can tolerate the loss of 2, and the overhead is 200% (3 bytes stored for every useful byte). It is what HDFS does (04-02), and it is simple and fast to read (any copy will do).
Erasure coding. Instead of copying, the object is split into k data fragments and m parity fragments are computed, such that any k of the k+m fragments are enough to rebuild the object. It tolerates m losses with an overhead of only m/k. The usual algorithm is Reed-Solomon, the same family of codes used by CDs, QR codes and space communications.
The intuition, without finite-field algebra: think of k = 2 data values, d1 and d2, and m = 2 parities:
We store [d1, d2, p1, p2] on four disks. If we lose d1 and d2 (both data fragments), we have two equations and two unknowns: d2 = p2 − p1, d1 = p1 − d2. If we lose d1 and p2: d1 = p1 − d2. Any pair of survivors is enough. Reed-Solomon generalises this to any k and m, with coefficients chosen so that every subset of k fragments is solvable, working in a finite field (GF(2⁸)) so that the additions and multiplications operate on bytes without overflowing. A numerical example with integers to see that it works:
# "Toy" Reed-Solomon with k=2, m=2 and integer arithmetic (in real life: GF(2^8))
d1, d2 = 7, 12
p1 = d1 + d2 # 19
p2 = d1 + 2 * d2 # 31
# d1 and d2 are lost; p1 and p2 survive:
d2_rec = p2 - p1 # 12
d1_rec = p1 - d2_rec # 7
assert (d1_rec, d2_rec) == (d1, d2)Let us compare real configurations for 1 TB of useful data:
| Scheme | Fragments | Losses tolerated | Bytes stored | Overhead | Who uses it |
|---|---|---|---|---|---|
| 3 replicas | 3 | 2 | 3 TB | 200% | HDFS by default, Cassandra |
| RS(4,2) | 4+2 | 2 | 1.5 TB | 50% | MinIO with 6 disks (by default half parity: RS(3,3) on 6 disks) |
| RS(8,4) | 8+4 | 4 | 1.5 TB | 50% | MinIO with 12 disks, Ceph |
| RS(10,4) | 10+4 | 4 | 1.4 TB | 40% | Facebook f4, HDFS 3 (RS-10-4) |
| RS(12,4) | 12+4 | 4 | 1.33 TB | 33% | Backblaze (17+3), Azure LRC variants |
The price of erasure coding is reconstruction: to read an object you have to gather k fragments from k nodes (more latency than reading one copy), and to repair a lost disk you have to read k fragments for every affected object (a lot of network traffic). That is why systems use it for large, cold data and keep replication (or caches, 04-05) for what is small and hot; many do both: replication for small objects and erasure coding above a certain size.
The eleven nines are calculated by combining a disk's annual failure probability (1–3%), the rebuild time after a failure (hours) and the number of simultaneous failures needed to lose data (m+1 in the same group within that window). With RS(8,4) and repairs taking hours, that coincidence is astronomically unlikely. On top of that come per-fragment checksums and scrubbing: a background process periodically rereads everything to detect silent corruption (bit rot) and repair it before failures accumulate.
- Consistency in object stores
For 14 years, S3 was eventually consistent for overwrites and deletes: after a PUT to an existing key, a GET could return the previous version for a few seconds; after a DELETE, a LIST could still show the key. In December 2020 Amazon announced strong read-after-write consistency for all operations: any GET, HEAD or LIST issued after an acknowledged PUT or DELETE sees the new state. In the vocabulary of 03-01, each object is linearizable, and in that of 03-02, S3 is CP (a write is not acknowledged until the quorum of replicas has it). MinIO offers the same guarantee within a deployment, and so does Ceph RGW.
Not every provider does: some S3-compatible stores, and cross-region replication setups, are eventual (the copy in the other region takes seconds or minutes). And there is an important caveat even with strong consistency: there are no transactions across objects. Uploading original.jpg and then thumb-300.jpg are two independent operations; a reader may see the first and not the second. If the application needs them to appear together, upload the thumbnails first and the "index" object that references them last, or store the reference in the database only once all the uploads have finished.
Finally, caches in front of the store (a CDN, the subject of 08-04, or a browser honouring Cache-Control) bring eventual consistency back in on their own account: replacing original.jpg does not purge the CDN. That is why common practice is not to overwrite content keys: you upload original-v3.jpg (or use a hash of the content as part of the key) and update the reference. The immutability of the object is thus extended to the key.
- When to use objects and when not to
An object store is a good fit when the data is:
- Immutable, or replaced wholesale: photos, videos, PDFs, backups, closed event files, build artefacts, trained models.
- Large in total volume, and accessed by a known key or by prefix.
- Served over HTTP to browsers, apps or other services, often with a CDN in front.
- Tolerant of tens of milliseconds per operation.
And it is not a good fit when you need:
- File semantics:
seekand partial writes,append, locks, real directories. Systems that mount S3 as a disk (s3fs, Mountpoint) work for reads, but every write rewrites the whole object. - Atomic rename. On a file system,
mv photos/tmp/x.jpg photos/aged-cheese/original.jpgis an atomic metadata operation; in S3 it isCopyObject+DeleteObject, two operations, with no atomicity and a cost proportional to the size. Moving a "directory" of a million objects means a million copies. Many processing engines (Module 5) ran into trouble with this when moving from HDFS to S3, which is why table formats that avoid renaming (Iceberg, Delta) exist. - Low latency and lots of small operations: reading 10,000 objects of 1 KB means 10,000 HTTP requests; a database (04-04) or a cache (04-05) does it a thousand times faster.
- Frequent, large listings:
LISTis paginated at 1,000 and is the slowest operation; the application should record in its database which objects exist, not discover them by listing. - Data that changes often: every change is a new version of the complete object.
- Cost: storage, operations and egress
Cloud object stores charge for three things, and the third catches many teams out:
| Item | Order of magnitude (S3 standard, 2026) | Comment |
|---|---|---|
| Storage | €0.02/GB·month | 1 TB of photos ≈ €20/month; cold classes go down to €0.004/GB·month |
| Operations | €5 per million PUT/LIST; €0.4 per million GET |
A million photos uploaded ≈ €5; LIST calls are expensive and slow |
| Data transfer out (egress) | €0.05–0.09/GB to the internet | Serving 1 TB of photos a month from the bucket ≈ €50–90, more than storing it |
Egress is the main reason for putting a CDN in front (08-04): the CDN caches the photos at its edges, and the bucket pays only for the first delivery to each edge. It is also the reason why moving data between clouds is expensive and why the data lake (04-02) and the compute (Module 5) should be in the same region. Some providers (R2, B2) do not charge for egress, and running your own MinIO changes the model: there is no cost per GB served, but there are disks, servers and staff to operate them.
- Objects at Kilometre Zero: photos, invoices and backups
Three kinds of platform data go to the object store:
| Data | Bucket | Key | Who writes | Who reads | Versioning | Lifecycle |
|---|---|---|---|---|---|---|
| Product photos (original + 300 and 800 px thumbnails) | km0-photos |
photos/<slug>/original.jpg, photos/<slug>/thumb-300.jpg |
Producers (via a presigned PUT URL), a thumbnail process |
The website (via a presigned GET URL, later a CDN) |
Yes | Non-current versions > 30 days → delete; old thumbnails → cold class |
| PDF invoices | km0-invoices |
invoices/2026/09/P-2026-000123.pdf |
orders, on confirmation |
The customer from "my orders" (presigned URL) | No (immutable by law) | Legal retention: object lock for 5 years |
| PostgreSQL backups | km0-backups |
postgres/km0_inventory/2026-09-14T02:00.dump |
Nightly job (07-03 will show how) | Restores | No | > 30 days → cold class; > 1 year → delete |
Photos are the main case. The complete flow, from Montblanc Dairy choosing a photo in its dashboard to Anna seeing it on the website:
sequenceDiagram
participant Q as Montblanc Dairy<br/>dashboard
participant C as catalog
participant M as MinIO<br/>(km0-photos)
participant T as Thumbnail<br/>generator
participant W as Website (Anna's browser)
Q->>C: I want to upload a photo of aged-cheese (jpeg, 1.8 MB)
C->>C: validates product and producer; generates key photos/aged-cheese/original.jpg
C-->>Q: presigned PUT URL (10 min)
Q->>M: direct PUT with the photo and metadata
M-->>Q: 200 + ETag + VersionId
Q->>C: upload complete (ETag)
C->>C: stores the key and ETag in km0_catalog; publishes photo.uploaded
T->>M: GET original.jpg
T->>M: PUT thumb-300.jpg, thumb-800.jpg
W->>C: GET /product/aged-cheese
C->>C: signs a GET URL for thumb-800.jpg (15 min)
C-->>W: HTML with the presigned URL
W->>M: GET thumb-800.jpg (valid signature)
M-->>W: 200, image (Cache-Control)
catalog never touches the bytes of the photo: it signs, validates and stores references. This is the usual pattern, and the one that lets the store, not the service, absorb the bandwidth.
- Hands-on: MinIO,
mc and services/catalog/photos.py with boto3
mc and services/catalog/photos.py with boto3MinIO is an open-source, S3-compatible object store written in Go that deploys as a single binary. In production it runs in distributed mode (several nodes, several disks per node, automatic erasure coding); for km0/ one node with one volume is enough. We add this to docker-compose.yml:
# km0/docker-compose.yml (excerpt)
services:
minio:
image: minio/minio:RELEASE.2026-06-01T00-00-00Z
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: km0admin
MINIO_ROOT_PASSWORD: km0secret123 # in 06-04 we will move it to a secrets manager
ports:
- "9000:9000" # S3 API
- "9001:9001" # web console
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
minio-init: # creates the buckets and rules just once
image: minio/mc:latest
depends_on:
minio: { condition: service_healthy }
entrypoint: >
/bin/sh -c "
mc alias set km0 http://minio:9000 km0admin km0secret123 &&
mc mb --ignore-existing km0/km0-photos km0/km0-invoices km0/km0-backups &&
mc version enable km0/km0-photos &&
mc ilm rule add km0/km0-photos --noncurrent-expire-days 30 &&
mc anonymous set none km0/km0-photos &&
echo ready"
volumes:
minio-data:mc (MinIO Client) is the CLI, and it works with any S3. The useful commands from your own machine:
mc alias set km0 http://localhost:9000 km0admin km0secret123
mc ls km0 # buckets
mc cp aged-cheese.jpg km0/km0-photos/photos/aged-cheese/original.jpg \
--attr "producer=montblanc-dairy;product=aged-cheese"
mc ls --versions km0/km0-photos/photos/aged-cheese/ # versions of each key
mc stat km0/km0-photos/photos/aged-cheese/original.jpg # metadata, ETag, VersionId
mc ilm rule ls km0/km0-photos # lifecycle rules
mc share download --expire 15m km0/km0-photos/photos/aged-cheese/original.jpg # presigned URL
mc admin info km0 # disks, usage, cluster statusboto3 is the official AWS SDK for Python and, thanks to MinIO's compatibility, it works just by changing endpoint_url. The module services/catalog/photos.py gathers everything catalog needs:
# km0/services/catalog/photos.py
"""Product photo management in the object store (MinIO / S3)."""
import hashlib
import os
import boto3
from botocore.config import Config
BUCKET = "km0-photos"
s3 = boto3.client(
"s3",
endpoint_url=os.getenv("S3_ENDPOINT", "http://localhost:9000"),
aws_access_key_id=os.getenv("S3_ACCESS_KEY", "km0admin"),
aws_secret_access_key=os.getenv("S3_SECRET_KEY", "km0secret123"),
region_name="eu-west-1", # MinIO ignores it; boto3 requires it for signing
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
def photo_key(slug: str, variant: str = "original", ext: str = "jpg") -> str:
return f"photos/{slug}/{variant}.{ext}"
def upload_photo(slug: str, local_path: str, producer: str, variant: str = "original") -> dict:
"""Uploads a photo with metadata and verifies integrity by comparing the ETag with the MD5."""
with open(local_path, "rb") as f:
data = f.read()
md5 = hashlib.md5(data).hexdigest()
key = photo_key(slug, variant)
resp = s3.put_object(
Bucket=BUCKET, Key=key, Body=data,
ContentType="image/jpeg",
CacheControl="public, max-age=31536000, immutable", # the CDN and the browser may cache it for a year
Metadata={"producer": producer, "product": slug, "variant": variant},
Tagging="kind=original" if variant == "original" else "kind=thumbnail", # the lifecycle rule filters on this tag
)
etag = resp["ETag"].strip('"')
if etag != md5: # simple upload: ETag == MD5 of the content
raise RuntimeError(f"integrity: etag {etag} != md5 {md5}")
return {"key": key, "etag": etag, "version_id": resp.get("VersionId")}
def download_url(slug: str, variant: str = "thumb-800", minutes: int = 15) -> str:
"""Presigned GET URL that the website embeds in the HTML; expires after `minutes`."""
return s3.generate_presigned_url(
"get_object",
Params={"Bucket": BUCKET, "Key": photo_key(slug, variant)},
ExpiresIn=minutes * 60,
)
def upload_url(slug: str, producer: str, minutes: int = 10) -> str:
"""Presigned PUT URL so the producer can upload directly, without going through catalog."""
return s3.generate_presigned_url(
"put_object",
Params={"Bucket": BUCKET, "Key": photo_key(slug), "ContentType": "image/jpeg",
"Metadata": {"producer": producer, "product": slug}},
ExpiresIn=minutes * 60,
)
def list_photos(slug: str) -> list[dict]:
"""Lists a product's variants (LIST by prefix, paginated)."""
paginator = s3.get_paginator("list_objects_v2")
result = []
for page in paginator.paginate(Bucket=BUCKET, Prefix=f"photos/{slug}/"):
for obj in page.get("Contents", []):
result.append({"key": obj["Key"], "bytes": obj["Size"], "etag": obj["ETag"].strip('"')})
return result
def versions(slug: str, variant: str = "original") -> list[dict]:
resp = s3.list_object_versions(Bucket=BUCKET, Prefix=photo_key(slug, variant))
return [{"version_id": v["VersionId"], "current": v["IsLatest"], "date": v["LastModified"], "bytes": v["Size"]}
for v in resp.get("Versions", [])]
def restore_version(slug: str, version_id: str, variant: str = "original") -> str:
"""Recovers an earlier version by copying it onto itself: it becomes the current one."""
key = photo_key(slug, variant)
resp = s3.copy_object(Bucket=BUCKET, Key=key,
CopySource={"Bucket": BUCKET, "Key": key, "VersionId": version_id},
MetadataDirective="COPY")
return resp["VersionId"]
def configure_lifecycle() -> None:
"""Thumbnails older than 90 days to the cold class; non-current versions deleted after 30 days;
abandoned multipart uploads aborted after 7 days."""
s3.put_bucket_lifecycle_configuration(
Bucket=BUCKET,
LifecycleConfiguration={"Rules": [
{"ID": "cold-thumbnails", "Status": "Enabled",
"Filter": {"Tag": {"Key": "kind", "Value": "thumbnail"}}, # thumbnails only: originals are never moved
"Transitions": [{"Days": 90, "StorageClass": "COLD"}]},
{"ID": "old-versions", "Status": "Enabled", "Filter": {"Prefix": ""},
"NoncurrentVersionExpiration": {"NoncurrentDays": 30}},
{"ID": "abandoned-multipart", "Status": "Enabled", "Filter": {"Prefix": ""},
"AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 7}},
]},
)
if __name__ == "__main__":
r = upload_photo("aged-cheese", "samples/aged-cheese.jpg", producer="montblanc-dairy")
print("uploaded:", r)
print("download (15 min):", download_url("aged-cheese", "original"))
print("photos of aged-cheese:", list_photos("aged-cheese"))
r2 = upload_photo("aged-cheese", "samples/aged-cheese-v2.jpg", producer="montblanc-dairy")
for v in versions("aged-cheese"):
print(" version", v)
print("restored as:", restore_version("aged-cheese", r["version_id"]))Notes on the less obvious parts:
Config(signature_version="s3v4", s3={"addressing_style": "path"}): a local MinIO has no wildcard DNS, so the bucket goes in the path (/km0-photos/…) rather than in the host (km0-photos.localhost). Signature V4 is the one modern presigned URLs use.upload_photochecks theETagagainst the local MD5: if the network corrupted anything, it is detected straight away. Withput_object(a simple upload) this equality holds; with multipart it does not, as we saw in section 3. For photos over 100 MB, uses3.upload_file, which does multipart automatically with per-part retries.CacheControl: immutable: since we promised not to overwrite content that the CDN is going to cache, we can allow it to be cached for a year. When the dairy changes the photo, the new version will have a differentVersionIdand, in Kilometre Zero's practice, the website will referenceoriginal.jpg?v=<etag>so that the URL changes.download_urldoes not talk to the server:generate_presigned_urlcomputes the signature locally with the secret key. It is instantaneous and does not use up a bucket operation. Note thatExpiresIncannot exceed 7 days with Signature V4.upload_urlincludesContentTypeandMetadatain the signed parameters: the producer must send exactly those headers, or the signature will not match. It is the way to force the upload to carry the metadata thatcatalogdecided on.restore_versionshows the trick for "modifying" an immutable object: copying it onto itself from the desired version, which creates a new version with the old content.configure_lifecycle: in MinIO,StorageClassrefers to a tiering level defined withmc ilm tier add(for example, another MinIO with slow disks, or S3 Glacier); in S3 you would useSTANDARD_IAorGLACIER. The abandoned-multipart rule stops you paying for orphaned parts that nobody sees in listings.
A quick two-line test:
pip install boto3
python -m services.catalog.photos # uploads, lists, versions, restores
curl -sI "$(python -c 'from services.catalog.photos import download_url; print(download_url("aged-cheese","original"))')" | head -5The curl -I returns 200 OK with Content-Type: image/jpeg, ETag, x-amz-meta-producer: montblanc-dairy and Cache-Control. Wait 15 minutes and try again: 403 Forbidden with Request has expired. That is the presigned URL doing its job.
Common Mistakes and Tips
- Thinking in folders. They do not exist.
LISTby prefix is slow and paginated; record in the database which keys each product has instead of listing the bucket on every page view. - Overwriting keys that a CDN or a browser caches. The bucket will be consistent; the caches will not. Change the key (a hash or version in the name) and update the reference.
- Exposing credentials to the browser or to the producer. Never. Sign URLs from the service, with a short expiry and with the parameters you want to enforce included in the signature.
- Public buckets "to make it work". A public bucket with
LISTenabled is the most common data leak in the cloud.mc anonymous set none, and presigned URLs or a CDN with signing. - Using
renameor moving large prefixes. There is no rename: it is a copy + delete per object. Design your keys so that you never have to move them. - Trusting the ETag as the MD5 of multipart uploads. It is not. If you need a hash of the complete file, compute it and store it as metadata (
x-amz-meta-sha256) or use full-object checksums (ChecksumAlgorithm). - Leaving egress and operations out of the budget. Serving photos straight from the bucket to thousands of users costs more than storing them; the CDN (08-04) is an economic decision before it is a latency one.
- No versioning and no lifecycle. Versioning is cheap insurance against accidental deletion; lifecycle rules stop versions and abandoned multipart uploads growing without limit. Turn them on from day one.
Exercises
Exercise 1. Kilometre Zero has 4,200 products with 3 photo variants each (original 1.8 MB, thumb-800 180 KB, thumb-300 35 KB) and 2,000 producers. During Artisan Cheese Week the website serves 6 million product views a day, each with one thumb-800. (a) Work out the total storage in the bucket and its monthly cost at €0.02/GB. (b) Work out the daily and monthly egress at €0.08/GB if it is served straight from the bucket. (c) What percentage of the traffic would have to be served from a CDN for the bucket's egress to drop to 5% of the previous figure, and what change to the keys makes that possible without the risk of serving stale photos?
Exercise 2. Implement generate_thumbnails(slug) in photos.py: download original.jpg into memory with get_object, generate the 800 and 300 px variants with Pillow and upload them with upload_photo, reusing the original's metadata (read it with head_object). Then describe what the website may see if a user loads the aged-cheese page just between the upload of the original and that of the thumbnails, and how you would avoid showing a broken image.
Exercise 3. With RS(k=4, m=2) over 6 disks, a 24 MB object is split into 4 data fragments of 6 MB and 2 parity fragments. (a) How many bytes does it take up in total, and what is the overhead compared with 3 replicas? (b) If two disks fail, how many MB have to be read to rebuild the object for a read, and how many to repair a single lost fragment of that object? (c) Explain why MinIO, with 6 disks, defaults to RS(3,3) (parity = half) rather than RS(4,2), and what is gained and lost.
Solutions
Solution 1:
(a) Per product: 1.8 MB + 0.18 MB + 0.035 MB ≈ 2.0 MB. Total: 4,200 × 2.0 MB ≈ 8.5 GB (plus non-current versions for 30 days; let us assume an extra 20%: ~10 GB). Cost: 10 GB × €0.02 ≈ €0.20/month. Storage is irrelevant.
(b) Daily egress: 6,000,000 × 180 KB ≈ 1.08 TB/day; monthly ≈ 32 TB. At €0.08/GB: ≈ €86/day, €2,600/month. Thirteen thousand times the cost of storing it. The GET operations (6 M/day × €0.4/M ≈ €2.4/day) also exceed storage.
(c) For the bucket to serve only 5%, the CDN must answer 95% of requests from cache (hit ratio ≥ 95%), which is realistic with 4,200 small objects and Cache-Control: max-age=31536000, immutable. The condition for that one-year max-age to be safe is that the key (or the URL) changes whenever the content changes: photos/aged-cheese/thumb-800.jpg?v=<etag> or photos/aged-cheese/<etag>/thumb-800.jpg. With immutable keys you never have to invalidate the CDN; 08-04 will go into the details.
Solution 2:
from io import BytesIO
from PIL import Image
def generate_thumbnails(slug: str, widths=(800, 300)) -> list[dict]:
key = photo_key(slug, "original")
head = s3.head_object(Bucket=BUCKET, Key=key)
meta = head["Metadata"] # producer, product, variant
original = s3.get_object(Bucket=BUCKET, Key=key)["Body"].read()
image = Image.open(BytesIO(original)).convert("RGB")
results = []
for width in widths:
copy = image.copy()
copy.thumbnail((width, width * 10)) # keeps the aspect ratio, limits the width
buf = BytesIO(); copy.save(buf, "JPEG", quality=85, optimize=True)
tmp_path = f"/tmp/{slug}-{width}.jpg"
with open(tmp_path, "wb") as f: f.write(buf.getvalue())
results.append(upload_photo(slug, tmp_path, producer=meta["producer"], variant=f"thumb-{width}"))
return results(We reuse upload_photo, which already sets variant in the metadata.) Between the upload of the original and that of the thumbnails, the bucket contains original.jpg but not thumb-800.jpg: the website, which asks for a presigned URL for the thumbnail, would get a valid URL, but the GET would return 404 NoSuchKey and the browser would show a broken image. The store is consistent per object, but there is no transaction across objects (section 5). Solution: catalog does not mark the photo as "available" in km0_catalog (nor publish photo.uploaded for the website) until the generator confirms the thumbnails; in the meantime the website shows the previous photo or a placeholder image. A more robust alternative: the generator uploads the thumbnails before the reference is updated, and the reference (in the database) is the only visible "commit".
Solution 3:
(a) 4 × 6 MB + 2 × 6 MB = 36 MB for 24 useful MB: a 50% overhead. With 3 replicas it would be 72 MB (200%): erasure coding saves half the disk while tolerating the same 2 losses.
(b) To read the object you need any 4 fragments: 24 MB read from 4 disks (with the two failures, exactly the 4 survivors). To repair a single lost 6 MB fragment you have to read 4 fragments (24 MB) and recompute: the repair cost is k times the fragment size, which is the characteristic penalty of erasure coding compared with replication (where repairing 6 MB costs reading 6 MB).
(c) With RS(3,3), each object tolerates the loss of 3 disks out of 6 (half), with a 100% overhead; with RS(4,2) it tolerates 2, with a 50% overhead. MinIO defaults to the safer configuration (and with 6 disks, two simultaneous failures are not that rare during a long repair window), and lets the operator lower the parity (MINIO_STORAGE_CLASS_STANDARD=EC:2) when capacity is preferred. You gain fault tolerance and reads that need only 3 fragments; you lose the additional 50% of disk.
Conclusion
Object storage changes the storage contract: flat buckets and keys instead of directories, immutable objects that are replaced in full, metadata that travels with the data, and versioning and lifecycle rules that the system applies on its own. The S3 API (PUT/GET/HEAD/LIST, multipart, ETag, presigned URLs) is the standard that MinIO, Ceph and the rest implement. Eleven nines of durability are achieved by combining replication with erasure coding, where Reed-Solomon rebuilds an object from any k of its k+m fragments with an overhead of 33–50% instead of the 200% of three copies, in exchange for costlier reads and repairs. S3 and MinIO are nowadays strongly consistent per object, but there are no cross-object transactions and no atomic rename, and the caches in front of the bucket bring eventual consistency back, so good practice is not to overwrite keys. Egress, not storage, dominates the bill, and that is the economic case for the CDN we will see in 08-04. Kilometre Zero has assigned its photos to objects (km0-photos, with photos.py, direct upload by Montblanc Dairy via a presigned URL, and delivery to the website with 15-minute URLs), along with its invoices and its backups, and has set it all up with MinIO and mc in docker-compose.yml.
We now have somewhere to keep massive files (HDFS) and files that get served (objects). What is missing is the storage that drives the platform every second: small records, queried and updated with low latency, with transactions and with the consistency guarantees we chose in 03-02. That is the territory of distributed databases, where orders will migrate to Cassandra, inventory will stay on PostgreSQL, and the consistent hashing ring of 04-01 will reappear with replicas and per-operation consistency levels.
Distributed Architectures Course
Module 1: Introduction to Distributed Systems
- Basic Concepts of Distributed Systems
- Distributed System Models
- Advantages and Challenges of Distributed Systems
- The Fallacies of Distributed Computing
- Time, Clocks and Event Ordering
- From Monolith to Distributed Platform: the Kilometre Zero Case
Module 2: Communication in Distributed Systems
- Communication Protocols
- RPC and RMI
- gRPC and Data Serialization
- Messaging and Message Queues
- Asynchronous Communication Patterns
Module 3: Consistency and Replication
- Consistency Models
- The CAP Theorem and PACELC
- Consensus Algorithms
- Data Replication
- Distributed Transactions and Sagas
Module 4: Distributed Storage
- Data Partitioning and Consistent Hashing
- Distributed File Systems
- Object Storage
- Distributed Databases
- Distributed Caches
Module 5: Distributed Computing
- Distributed Computing Models
- MapReduce and Hadoop
- Spark and In-Memory Computing
- Stream Processing
- Job Scheduling and Data Pipelines
Module 6: Security in Distributed Systems
- Authentication and Authorization
- Encryption and Data Protection
- Identity Management
- Service-to-Service Security: mTLS and Secrets Management
- API Gateways, Rate Limiting and Auditing
Module 7: Monitoring and Maintenance
- Monitoring Distributed Systems
- Centralized Logs and Distributed Tracing
- Failure Management and Recovery
- Resilience Patterns: Timeouts, Retries and Circuit Breakers
- Automation and Orchestration
- Testing Distributed Systems and Chaos Engineering
