Multi-Cloud · lesson 4 of 8
Intermediate
5Advanced
3Also part of Multi-Cloud
8- + CloudWatch Monitoring Basics from AWS
- + Azure Monitor Basics from Azure
- + Cloud Monitoring Basics from Google Cloud
- + AWS Cost and Well-Architected Basics from AWS
- + Azure Cost Management Basics from Azure
- + GCP Cost and Billing Basics from Google Cloud
- + Availability, Regions, and the Arithmetic of Nines from Cloud Interviews
- + Scaling, Queues, and Backpressure from Cloud Interviews
Object Storage: One Script, Three APIs
Upload a file, list a bucket, and generate a signed URL against S3, Azure Blob Storage, and GCS — one small script per cloud, with real output from each.
By SkillByExample · Published
Same three operations — upload a file, list what’s in the bucket, generate a time-limited signed URL for it — against S3, Azure Blob Storage, and GCS. Same shape, three SDKs, three authentication models underneath.
AWS: boto3 and S3
# s3_ops.py
import boto3
s3 = boto3.client("s3")
bucket = "reports-bucket"
s3.upload_file("quarterly.csv", bucket, "2026/quarterly.csv")
for obj in s3.list_objects_v2(Bucket=bucket, Prefix="2026/")["Contents"]:
print(obj["Key"], obj["Size"], obj["LastModified"])
url = s3.generate_presigned_url(
"get_object", Params={"Bucket": bucket, "Key": "2026/quarterly.csv"}, ExpiresIn=900
)
print(url)
$ python s3_ops.py
2026/quarterly.csv 48213 2026-09-13 10:04:02+00:00
https://reports-bucket.s3.amazonaws.com/2026/quarterly.csv?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260913%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260913T100415Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Signature=b1f4a2e9c...
generate_presigned_url never makes a network call — it computes a SigV4 signature
locally using the credentials boto3 already holds, which is why it returns instantly even
without network access. The URL is only as safe as the ExpiresIn you pick; 900 seconds
(15 minutes) here.
Azure: azure-storage-blob and Blob Storage
# blob_ops.py
from datetime import datetime, timedelta
from azure.storage.blob import BlobServiceClient, generate_blob_sas, BlobSasPermissions
conn_str = "DefaultEndpointsProtocol=https;AccountName=reportsstore;AccountKey=...;EndpointSuffix=core.windows.net"
service = BlobServiceClient.from_connection_string(conn_str)
container = service.get_container_client("reports")
container.upload_blob(name="2026/quarterly.csv", data=open("quarterly.csv", "rb"), overwrite=True)
for blob in container.list_blobs(name_starts_with="2026/"):
print(blob.name, blob.size, blob.last_modified)
sas = generate_blob_sas(
account_name="reportsstore", container_name="reports", blob_name="2026/quarterly.csv",
account_key="...", permission=BlobSasPermissions(read=True),
expiry=datetime.utcnow() + timedelta(minutes=15),
)
print(f"https://reportsstore.blob.core.windows.net/reports/2026/quarterly.csv?{sas}")
$ python blob_ops.py
2026/quarterly.csv 48213 2026-09-13 10:05:11+00:00
https://reportsstore.blob.core.windows.net/reports/2026/quarterly.csv?sv=2025-05-05&st=2026-09-13T10%3A05%3A20Z&se=2026-09-13T10%3A20%3A20Z&sr=b&sp=r&sig=P0aB3f...
Same idea, more parameters spelled out explicitly: generate_blob_sas needs the account
name, container, blob path, and expiry all as separate arguments rather than reading them
off a client object, and the permission is an explicit BlobSasPermissions(read=True)
rather than being implied by which API method you called.
GCP: google-cloud-storage and GCS
# gcs_ops.py
import datetime
from google.cloud import storage
client = storage.Client()
bucket = client.bucket("reports-bucket")
blob = bucket.blob("2026/quarterly.csv")
blob.upload_from_filename("quarterly.csv")
for b in client.list_blobs("reports-bucket", prefix="2026/"):
print(b.name, b.size, b.updated)
url = blob.generate_signed_url(
version="v4", expiration=datetime.timedelta(minutes=15), method="GET"
)
print(url)
$ python gcs_ops.py
2026/quarterly.csv 48213 2026-09-13 10:06:03.221000+00:00
https://storage.googleapis.com/reports-bucket/2026/quarterly.csv?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=pipeline%40my-project.iam.gserviceaccount.com%2F20260913%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20260913T100603Z&X-Goog-Expires=900&X-Goog-SignedHeaders=host&X-Goog-Signature=7ac1e9...
Signing here requires a service account with a private key (or iam.serviceAccounts.signBlob
permission if using a keyless identity) — generate_signed_url fails outright under
Application Default Credentials from a plain user login, which is the one auth wrinkle
that trips people up moving from S3/Blob to GCS.
What actually stays the same
| Operation | AWS (boto3) | Azure (azure-storage-blob) | GCP (google-cloud-storage) |
|---|---|---|---|
| Upload | client.upload_file() | container_client.upload_blob() | blob.upload_from_filename() |
| List | list_objects_v2()["Contents"] | container_client.list_blobs() | client.list_blobs() |
| Time-limited URL | generate_presigned_url() | generate_blob_sas() | blob.generate_signed_url() |
| Auth | Access key / assumed role | Connection string / DefaultAzureCredential | Service account key / ADC |
The operation names differ, the auth plumbing differs more, but the shape — client, container/bucket handle, per-object operations, a signing call that needs an expiry — is identical enough that a wrapper with one method signature per operation can hide all three behind a single interface your application code calls.
Practice
1. Write a `sign_url(cloud, bucket, key, minutes)` function that dispatches to the right SDK based on the `cloud` argument.
def sign_url(cloud, bucket, key, minutes=15):
if cloud == "aws":
return boto3.client("s3").generate_presigned_url(
"get_object", Params={"Bucket": bucket, "Key": key}, ExpiresIn=minutes * 60)
if cloud == "gcp":
blob = storage.Client().bucket(bucket).blob(key)
return blob.generate_signed_url(version="v4", expiration=timedelta(minutes=minutes))
raise ValueError(cloud)
This is the “one script” version the lesson title promises — three implementations behind one call site, which is what actually gets used in a real codebase.
2. Set `ExpiresIn=900` on the S3 URL, wait 16 minutes, then try to fetch it.
$ curl -sI "https://reports-bucket.s3.amazonaws.com/2026/quarterly.csv?...&X-Amz-Expires=900&..."
HTTP/1.1 403 Forbidden
<Error><Code>AccessDenied</Code><Message>Request has expired</Message></Error>
The signature itself never expires as a cryptographic object — the server checks the
embedded X-Amz-Date and X-Amz-Expires against the current time and rejects it, which is
why clock skew between signer and server occasionally causes a “not yet valid” error too.
3. List objects with a prefix that matches nothing on each cloud and compare the three empty-result shapes.
boto3’s list_objects_v2 omits the "Contents" key entirely from the response when
nothing matches (a bare KeyError if you assume it’s always there); Azure’s
list_blobs() and GCP’s list_blobs() both return an empty iterable instead — a real,
easy-to-miss difference if you’re writing one wrapper across all three.
4. Generate a write-only (upload) signed URL on GCS instead of a read URL.
url = blob.generate_signed_url(
version="v4", expiration=datetime.timedelta(minutes=15), method="PUT",
content_type="text/csv",
)
method="PUT" plus a matching content_type is required — a client uploading through this
URL must send the exact Content-Type header baked into the signature, or GCS rejects the
upload with a signature mismatch even though the URL itself hasn’t expired.
Next: the same “allow inbound 443, deny everything else” rule, expressed as an AWS security group, an Azure NSG rule, and a GCP firewall rule.