Setting S3 Lifecycle Rules for Survey Archives
A marine data archive grows without bound and is read very unevenly. Lifecycle rules are the mechanism for making that cheap, and they are also silent, delayed and irreversible: a rule that matches the wrong prefix transitions objects to a class you cannot read quickly, and you discover it during the reprocess that needed them. This page sets them deliberately, within the publishing workflow.
Root cause: an archive is three populations, not one
Applying one rule to a bucket treats three different things identically.
Raw soundings and raw AIS. Large, immutable, read rarely — but read entirely when they are read, because a reprocess consumes the whole survey. The right target for cold storage, and the population where retrieval cost matters most.
Derived products. COGs, Zarr stores, density grids. Read constantly by consumers, small relative to the raw data, and regenerable from it. Keeping them in standard storage costs little and saves a reprocess every time someone opens a map.
Staging and temporaries. Rechunker intermediates, preempted tile uploads, failed multipart parts. Should not exist for more than a day and, without a rule, exist forever while being invisible in the console’s object listing.
import boto3
def bucket_composition(bucket: str) -> dict[str, float]:
"""Bytes by top-level prefix, so the populations can be seen."""
s3 = boto3.client("s3")
sizes: dict[str, float] = {}
paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=bucket):
for obj in page.get("Contents", []):
top = obj["Key"].split("/", 1)[0]
sizes[top] = sizes.get(top, 0.0) + obj["Size"]
return {k: v / 2**40 for k, v in sorted(sizes.items(), key=lambda kv: -kv[1])}
The listing above will not show the fourth population at all. Incomplete multipart uploads are charged for and are not objects, so they appear in no listing and in no size total — only in the bill.
Step-by-step
1. The rule that costs nothing
ABORT_INCOMPLETE = {
"ID": "abort-incomplete-multipart",
"Filter": {"Prefix": ""}, # the whole bucket
"Status": "Enabled",
"AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 3},
}
Three days rather than one, so a legitimately slow upload of a large mosaic is not killed mid-flight. There is no scenario in which a five-day-old incomplete upload is wanted.
2. Tier the raw archive on measured access, not on a round number
def transition_age_from_access(access_log_days: list[int], *, keep_warm_fraction: float = 0.99) -> int:
"""Age beyond which `keep_warm_fraction` of reads no longer occur."""
if not access_log_days:
raise ValueError("no access records — do not guess a transition age")
ordered = sorted(access_log_days)
idx = int(len(ordered) * keep_warm_fraction)
age = ordered[min(idx, len(ordered) - 1)]
logger.info("%.0f%% of reads occur within %d days of an object's creation",
keep_warm_fraction * 100, age)
return age
Marine archives have a characteristic access shape: heavy for the weeks around delivery, near zero for a year, then an occasional complete re-read when a model improves or a dispute arises. That means the transition age can be aggressive — ninety days is often defensible — while the retrieval budget has to assume a whole-archive read.
3. Budget the retrieval before choosing the class
STORAGE_USD_PER_TIB_MONTH = {"STANDARD": 23.6, "STANDARD_IA": 12.9,
"GLACIER_IR": 4.1, "DEEP_ARCHIVE": 1.0}
RETRIEVAL_USD_PER_TIB = {"STANDARD": 0.0, "STANDARD_IA": 10.2,
"GLACIER_IR": 30.7, "DEEP_ARCHIVE": 20.5}
def class_cost(tib: float, storage_class: str, *, months: int = 36,
full_reads: int = 2) -> dict[str, float]:
"""Total cost of holding and re-reading an archive over its useful life."""
if storage_class not in STORAGE_USD_PER_TIB_MONTH:
raise ValueError(f"unknown class {storage_class}")
store = tib * STORAGE_USD_PER_TIB_MONTH[storage_class] * months
retrieve = tib * RETRIEVAL_USD_PER_TIB[storage_class] * full_reads
return {"storage": store, "retrieval": retrieve, "total": store + retrieve}
For 84 TiB over three years with two full reprocesses, deep archive saves a great deal on storage and costs thousands on retrieval — and, more importantly, imposes a restore latency of hours before the reprocess can start. Instant-retrieval glacier classes usually win for a live archive precisely because the reprocess is a scheduled, whole-archive read rather than a random one.
4. Apply per prefix, and verify the filter
def put_lifecycle(bucket: str, transition_days: int) -> None:
"""Three rules, one per population. Applied together so they cannot drift."""
rules = [
ABORT_INCOMPLETE,
{"ID": "raw-to-cold", "Status": "Enabled",
"Filter": {"Prefix": "raw/"},
"Transitions": [{"Days": transition_days, "StorageClass": "GLACIER_IR"}]},
{"ID": "staging-expiry", "Status": "Enabled",
"Filter": {"Prefix": "_staging/"},
"Expiration": {"Days": 7}},
]
boto3.client("s3").put_bucket_lifecycle_configuration(
Bucket=bucket, LifecycleConfiguration={"Rules": rules})
logger.info("applied %d lifecycle rule(s) to %s", len(rules), bucket)
def preview_matches(bucket: str, prefix: str, limit: int = 20) -> list[str]:
"""What a rule's filter would actually match. Run this before applying."""
s3 = boto3.client("s3")
page = s3.list_objects_v2(Bucket=bucket, Prefix=prefix, MaxKeys=limit)
keys = [o["Key"] for o in page.get("Contents", [])]
logger.info("prefix %r matches %d object(s) (showing %d)",
prefix, page.get("KeyCount", 0), len(keys))
return keys
Note there is no rule for derived products. Leaving them in standard storage is the deliberate choice: they are a fifth of the volume, read constantly, and moving them saves little while adding retrieval latency to every consumer request.
Verification and acceptance test
import pytest
def test_lifecycle_rules_match_the_intended_prefixes(bucket):
cfg = boto3.client("s3").get_bucket_lifecycle_configuration(Bucket=bucket)
ids = {r["ID"]: r for r in cfg["Rules"]}
assert "abort-incomplete-multipart" in ids, "the free rule is missing"
assert ids["raw-to-cold"]["Filter"]["Prefix"] == "raw/"
assert "Transitions" not in ids.get("staging-expiry", {}), (
"staging should expire, not transition — a cold temporary file is the worst case")
def test_no_derived_product_would_be_transitioned(bucket):
cfg = boto3.client("s3").get_bucket_lifecycle_configuration(Bucket=bucket)
for rule in cfg["Rules"]:
prefix = rule.get("Filter", {}).get("Prefix", "")
if rule.get("Transitions") and preview_matches(bucket, prefix or "derived/"):
assert not prefix.startswith("derived"), (
f"rule {rule['ID']} would move derived products to cold storage")
The second test is the one that catches an empty-prefix rule, which matches everything including the products consumers read every minute — the single most damaging lifecycle mistake, and the easiest to make.
Retention is a policy question wearing a technical costume
Lifecycle rules delete things. That makes the expiry half of this configuration a records-management decision rather than an engineering one, and the two most common failures are both organisational.
Deleting data a regulator expects to exist. Hydrographic surveys supporting charting, dredging contracts and environmental permits often carry statutory retention periods measured in years or decades. An expiry rule written to control cost can quietly breach one, and nothing in the object store knows the difference between a temporary file and a legally-required record.
Keeping everything because nobody will decide. The opposite failure costs money indefinitely and, worse, makes the archive harder to use: a bucket holding every intermediate from every run for eight years is one in which nobody can find the authoritative version of anything.
The workable middle is to make retention explicit per population, in the same file as the lifecycle configuration, with an owner named:
RETENTION = {
"raw/": {"years": None, "owner": "hydrographic-lead", "basis": "statutory, indefinite"},
"derived/": {"years": 7, "owner": "data-products", "basis": "regenerable from raw"},
"_staging/": {"years": 0, "owner": "platform", "basis": "no value after the run"},
"manifests/": {"years": None, "owner": "hydrographic-lead", "basis": "provenance for raw"},
}
def rules_from_retention(retention: dict) -> list[dict]:
"""Generate lifecycle rules from the declared policy, so the two cannot drift."""
rules = [ABORT_INCOMPLETE]
for prefix, policy in retention.items():
if policy["years"] == 0:
rules.append({"ID": f"expire-{prefix.strip('/')}", "Status": "Enabled",
"Filter": {"Prefix": prefix}, "Expiration": {"Days": 7}})
elif policy["years"] is not None:
rules.append({"ID": f"expire-{prefix.strip('/')}", "Status": "Enabled",
"Filter": {"Prefix": prefix},
"Expiration": {"Days": int(policy["years"] * 365.25)}})
return rules
Generating the rules from the policy rather than maintaining both means the question “why does this expire” always has an answer, and the answer names a person. Note that manifests/ never expires even though it is tiny: a run manifest outliving its data is harmless, and data outliving its manifest is an archive nobody can interpret.
Edge cases and gotchas
- Transitions have a minimum object size that makes them counter-productive. Objects under 128 KB are charged as 128 KB in some classes, so transitioning a Zarr store of millions of small chunks can increase cost. Tier the raw archive, which is large objects, and leave chunked stores alone.
- Rules apply to versions too, if versioning is on. A bucket with versioning and no
NoncurrentVersionExpirationaccumulates every overwritten object forever. On a pipeline that republishes a product nightly, that is a copy per night, indefinitely. - Lifecycle actions are asynchronous and irreversible. Transitions happen within a day or so of the rule matching, and moving back costs a retrieval plus a re-upload. Use
preview_matchesbefore applying, on the real bucket, every time — the check takes seconds and the mistake takes days to undo.
Related
- Publishing COG & Zarr to Object Storage — the write path whose staging prefix these rules clean up
- Rechunking a Zarr Store Without a Full Rewrite — the temporary store that most needs an expiry rule
- Using Spot Instances for Bathymetric Jobs — the source of the abandoned uploads the first rule removes