Lambda S3 Cache
An AWS Lambda setup that caches files from public URLs in S3, supporting both HTTP file caching (RPMs, repository metadata) and OCI Distribution API v2 (container image manifests and blobs). When a URL is requested, the service returns a 302 redirect to either the cached S3 copy (via presigned URL) or the original source. Cache misses trigger asynchronous downloads to S3, ensuring future requests are served from the cache.
Caching uses the original URL’s host and path as the S3 key for HTTP content,
and a flat content-addressed layout (cache/oci/<digest>) for OCI artifacts.
Frequently accessed content automatically extends its cache lifetime on each
access.
Note: Cached content behavior depends on file type:
- Immutable content (matching immutable extension filter, e.g.,
.rpm): Changes at origin won’t be reflected until cache expires - Mutable content (non-matching extension, e.g.,
repomd.xml): Cache is revalidated on each request via HEAD check; stale behavior is configurable
Cache Revalidation: Mutable content uses HTTP ETags for efficient validation.
The uploader stores the origin’s ETag in S3 metadata when caching content. On
subsequent requests, the handler sends a HEAD request with If-None-Match header
containing the stored ETag. If the origin responds with 304 Not Modified, the
cache is fresh and served directly. If the ETag differs, the cache is stale and
an async refresh is triggered; response behavior depends on StaleCacheBehavior.
Note: If the origin doesn’t provide ETags, the cache cannot validate freshness. In this case, mutable content always redirects to origin without triggering cache updates (caching would be ineffective since every request would redirect anyway).
Features
- Streaming Upload: Handles files efficiently by streaming directly from source to S3 without loading into memory
- Presigned S3 URLs: Returns short-lived signed URLs for cached content
- Automatic Expiration: Cached content expires after a certain time, with lifetime extended on each access
- URL Prefix Allowlist: Only caches content matching explicitly allowed host+path prefixes
- Immutable Extension Filter: Identifies immutable content that doesn’t require revalidation
- Cache Revalidation: Mutable content (non-matching extensions) is validated on each request; stale cache behavior is configurable (origin or cache)
- OCI Image Caching: Caches OCI container image manifests and blobs via the OCI Distribution API v2, protecting against upstream digest garbage collection
- Multi-Registry Support: OCI caching supports multiple upstream registries by encoding the registry hostname in the request path
- Custom Domain: Optional custom domain with automatic TLS certificate management via ACM and Route53
Architecture
Three Lambda functions handle the caching workflow:
- Handler - API Gateway endpoint that checks cache, returns 302 redirects, and triggers async operations. Implements touch cooldown to prevent S3 throttling by only touching objects after a configurable time period has elapsed since last modification.
- Uploader - Downloads from origin and streams to S3 on cache misses (invoked asynchronously)
- Touch - Updates S3 object timestamps to extend cache lifetime on cache hits (invoked asynchronously only when cooldown period has elapsed)
Request Flow
flowchart TD
Start([Request]) --> CheckURL{URL matches<br/>AllowedPrefixes?}
CheckURL -->|No| RedirectOrigin
CheckURL -->|Yes| HeadS3[HEAD S3]:::network
HeadS3 --> CheckCache{Cache exists?}
CheckCache -->|No: Cache Miss| InvokeUploaderMiss[Invoke Uploader async]:::async
CheckCache -->|Yes: Cache Hit| CheckImmutable{Immutable extension?}
CheckImmutable -->|No: Mutable| HeadOrigin[HEAD Origin<br/>If-None-Match: stored ETag]:::network
HeadOrigin --> CheckChanged{Origin changed?}
CheckChanged -->|No ETag from origin| RedirectOrigin
CheckChanged -->|ETag differs| InvokeUploaderStale[Invoke Uploader async]:::async
CheckChanged -->|ETag matches| CheckCooldown
CheckChanged -->|304 Not Modified| CheckCooldown
CheckChanged -->|"Error/timeout"| CheckCooldown
CheckImmutable -->|Yes| CheckCooldown
CheckCooldown{Touch cooldown<br/>elapsed?} -->|Yes| InvokeTouch[Invoke Touch async]:::async
InvokeUploaderMiss --> RedirectOrigin
InvokeUploaderStale -->|"STALE_CACHE_BEHAVIOR=origin"| RedirectOrigin
InvokeUploaderStale -->|"STALE_CACHE_BEHAVIOR=cache"| RedirectCache
CheckCooldown -->|No| RedirectCache
InvokeTouch --> RedirectCache
subgraph redirectGroup [ ]
RedirectOrigin[302 to Origin URL]:::redirect
RedirectCache[302 to S3 Presigned URL]:::redirect
end
style redirectGroup fill:none,stroke:none
classDef network fill:#10b981,color:#000
classDef async fill:#ff9900,color:#000
classDef redirect fill:#3b82f6,color:#fff
Legend: Green = network call, Orange = async Lambda invocation, Blue = 302 redirect response
Prerequisites
- AWS CLI configured with appropriate credentials (IAM permissions for Lambda, API Gateway, S3, CloudFormation, CloudWatch Logs, and optionally Route53/ACM for custom domain)
- Podman or Docker (for containerized SAM build/deploy)
- Python 3.11 or later (for development)
Deployment
Build and deploy using containerized AWS SAM CLI:
make lambda-s3-cache/build # Build Lambda package
make lambda-s3-cache/deploy # First deployment (interactive/guided)
make lambda-s3-cache/redeploy # Subsequent deployments (non-interactive)
Deployment output: ApiEndpoint - the API Gateway URL to use for requests
Custom Domain
Optional custom domain with automatic TLS certificate management (ACM +
Route53). Requires a Route53 hosted zone. Deploy with CustomDomainName and
HostedZoneId parameters - CloudFormation handles certificate creation, DNS
validation, and configuration. Certificate validation takes 5-30 minutes; allow
up to 1 hour for DNS propagation.
Parameters
| Parameter | Description | Default |
|---|---|---|
ResourcePrefix |
Prefix for all resource names | myapp-prod |
PresignedUrlExpiration |
Presigned URL expiration (seconds) | 3600 |
AllowedPrefixes |
Whitespace-separated list of allowed URL prefixes | example.com/path/ |
ImmutableExtensions |
File extensions for immutable content (no revalidation) | .rpm |
CacheExpirationDays |
Days to keep cached content (minimum: 1) | 14 |
TouchCooldownMinutes |
Minimum minutes between touch operations | 60 |
StaleCacheBehavior |
When cache is stale: origin or cache |
origin |
OciAllowedPrefixes |
Whitespace-separated OCI image prefixes (registry/name) |
`` |
OciOriginHeadTimeout |
Timeout (seconds) for HEAD requests to OCI registries | 5 |
CustomDomainName |
Optional custom domain name | `` |
HostedZoneId |
Route53 hosted zone ID (required if custom domain) | `` |
SentryDsn |
Optional Sentry DSN for error tracking | `` |
Resource naming: All AWS resources follow {ResourcePrefix}-{type}-{name}
pattern (e.g., myapp-prod-bucket, myapp-prod-lambda-handler).
Usage
Make GET requests to the API endpoint (or custom domain if configured). The URL
to cache is encoded in the path (without the https:// prefix):
curl -L "https://<api-endpoint>/example.com/path/to/file.rpm"
Behavior:
- First request (cache miss): Redirects to original URL while triggering async S3 upload
- Subsequent requests (cache hit):
- Immutable content (matching immutable extension filter): Redirects to presigned S3 URL and resets cache expiration
- Mutable content (non-matching extension): HEAD request validates cache
freshness (10s timeout). If stale, behavior depends on
StaleCacheBehavior; if fresh, serves from cache
- Disallowed prefixes: URLs not matching allowed prefixes are transparently redirected to the original URL (302 pass-through)
Immutable Extension Filter Behavior
The ImmutableExtensions parameter determines caching behavior:
-
Matching extension (e.g.,
.rpm): Treated as immutable content. Cached without revalidation - changes at origin won’t be reflected until cache expires. Served directly from cache on all requests. -
Non-matching extension (e.g.,
.xml,.gz): Treated as mutable content. Cached with revalidation - HEAD request on each cache hit validates freshness using ETags. If stale, behavior depends onStaleCacheBehavior:origin(default): Redirects to origin for fresh content, async refreshcache: Serves stale cache immediately (faster), async refresh in background
Important: All files matching AllowedPrefixes are cached, regardless of
extension. The extension filter only determines whether revalidation is performed.
Usage as Repository Proxy
The cache can be used as a DNF/yum baseurl for RPM repositories. Configure
ImmutableExtensions to include .rpm:
.rpmfiles (matching extension): Cached as immutable - fast, no revalidation- Metadata files (non-matching extension, e.g.,
repomd.xml,primary.xml.gz): Cached with revalidation - ensures fresh metadata while benefiting from cache
[myrepo]
name=My Repository
baseurl=https://koji-s3-cache.example.com/download.example.org/pub/repo/$basearch/
enabled=1
This provides caching benefits for RPM downloads while ensuring repository metadata stays fresh.
OCI Image Caching
The cache also supports the OCI Distribution API v2 for caching container image manifests and blobs. This prevents deployment failures caused by upstream registries garbage-collecting digest-pinned images when tags move.
Requests with a v2/ path prefix are handled as OCI requests. The upstream
registry is extracted from the first path segment after v2/, enabling
multi-registry support through a single cache endpoint.
OCI request format:
GET /v2/<registry>/<image-name>/manifests/<digest>
GET /v2/<registry>/<image-name>/blobs/<digest>
Example:
# Health check
curl https://cache.example.com/v2/
# Manifest request (quay.io/openshift/origin-oauth-proxy)
curl -L https://cache.example.com/v2/quay.io/openshift/origin-oauth-proxy/manifests/sha256:504c036d...
OCI cache strategy (origin-first):
The OCI cache uses an origin-first strategy, matching the proven CKI dependency-archive pattern. This minimizes S3 egress — the cache is only consulted when the upstream registry is unavailable:
- HEAD upstream registry — check if the digest is still available
- Origin available + not cached → redirect to upstream, trigger async S3 upload
- Origin available + cached → redirect to upstream (touch if cooldown elapsed)
- Origin unavailable + cached manifest → serve inline from S3 (200 with
body,
Content-Typefrom metadata,Docker-Content-Digestheader) - Origin unavailable + cached blob → 302 to presigned S3 URL
- Origin unavailable + not cached → redirect to upstream as last resort
- Any error → redirect to upstream (fail-open)
S3 key layout: cache/oci/sha256:<digest> — flat, content-addressed. The
same blob shared by different images is stored only once.
OCI request flow:
flowchart TD
Start([OCI Request]) --> CheckPath{Path starts<br/>with v2/?}
CheckPath -->|No| RPMHandler[RPM cache handler]
CheckPath -->|Yes| V2Health{v2 or v2/?}
V2Health -->|Yes| Return200[200 OK<br/>registry/2.0]:::redirect
V2Health -->|No| ParsePath[Parse path:<br/>registry/name/kind/digest]
ParsePath --> ValidDigest{Valid digest?<br/>manifests or blobs?}
ValidDigest -->|No| ForwardUpstream[302 to upstream]:::redirect
ValidDigest -->|Yes| CheckAllow{In OCI<br/>AllowedPrefixes?}
CheckAllow -->|No| ForwardUpstream
CheckAllow -->|Yes| HeadOrigin[HEAD upstream registry]:::network
HeadOrigin --> OriginUp{Origin available?}
OriginUp -->|Yes| CheckOciCache{Cached in S3?}
CheckOciCache -->|No| InvokeUploader[Invoke Uploader async]:::async
CheckOciCache -->|Yes| TouchCheck{Touch cooldown?}
TouchCheck -->|Elapsed| InvokeTouchOci[Invoke Touch async]:::async
TouchCheck -->|Not elapsed| RedirectUpstream
InvokeTouchOci --> RedirectUpstream[302 to upstream]:::redirect
InvokeUploader --> RedirectUpstream
OriginUp -->|No| CheckFallback{Cached in S3?}
CheckFallback -->|Yes, manifest| ServeInline[200 inline<br/>from S3]:::redirect
CheckFallback -->|Yes, blob| PresignedRedirect[302 to<br/>presigned S3]:::redirect
CheckFallback -->|No| ForwardUpstream
classDef network fill:#10b981,color:#000
classDef async fill:#ff9900,color:#000
classDef redirect fill:#3b82f6,color:#fff
Note: Tag-based pulls (/manifests/latest), tag listings
(/tags/list), and images not in OciAllowedPrefixes are transparently
forwarded to the upstream registry without caching.
Development
See the main README for development workflows.
make lambda-s3-cache/setup # Install dependencies
make check # Lint code
make fmt # Format code
make test # Run unit tests
make coverage # Run tests with coverage
Configuration
Lambda functions receive configuration via environment variables (automatically set by CloudFormation):
| Variable | Handler | Uploader | Touch | Description |
|---|---|---|---|---|
S3_BUCKET_NAME |
✅ | ✅ | ✅ | S3 bucket name |
PRESIGNED_URL_EXPIRATION |
✅ | Presigned URL expiration (sec) | ||
UPLOADER_LAMBDA_ARN |
✅ | Uploader Lambda ARN | ||
TOUCH_LAMBDA_ARN |
✅ | Touch Lambda ARN | ||
TOUCH_COOLDOWN_MINUTES |
✅ | Min minutes between touch ops | ||
ALLOWED_PREFIXES |
✅ | Allowed URL prefixes | ||
IMMUTABLE_EXTENSIONS |
✅ | Extensions for immutable content | ||
STALE_CACHE_BEHAVIOR |
✅ | Stale behavior: origin or cache | ||
OCI_ALLOWED_PREFIXES |
✅ | OCI image prefixes to cache | ||
ORIGIN_HEAD_TIMEOUT |
✅ | OCI origin HEAD timeout (sec) | ||
SENTRY_DSN |
✅ | ✅ | ✅ | Optional Sentry DSN |
Security & Limitations
Security:
- S3 bucket has public access blocked; all objects encrypted at rest (AES256)
- Presigned URLs expire after a certain time
- IAM policies follow least privilege principle
- URL prefix allowlist prevents caching arbitrary URLs
- OCI image allowlist (
OCI_ALLOWED_PREFIXES) controls which images are cached - Immutable extension filter distinguishes immutable vs mutable content for revalidation
- OCI caching only handles public images (no auth forwarding)
Limitations:
- Lambda timeout: 15 min (uploader), 30 sec (handler, touch)
- Lambda memory: 1024 MB (uploader), 256 MB (handler, touch)
- S3 object size: Up to 5 TB (AWS limit)
- API Gateway 6 MB response limit for inline manifest responses (manifests are typically under 100 KB)
- OCI caching only supports digest-based pulls (
sha256:...); tag-based pulls are forwarded to the upstream registry
Design Decisions Record
DDR-1: S3 Lambda OCI cache vs ECR pull-through cache
Decision: Build the OCI cache as a new code path in the existing lambda-s3-cache Lambda, rather than using AWS ECR pull-through cache.
Context: Third-party container images pinned by digest vanish when
upstream registries garbage-collect old manifests after tag re-pushes
(e.g., quay.io/openshift/origin-oauth-proxy:5.1 digests disappear
within hours). This causes ImagePullBackOff failures on pod restarts.
Alternatives: (a) ECR pull-through cache — managed service, no custom
code, retains cached digests when upstream is unavailable. (b)
images.paas.redhat.com — Red Hat internal registry with existing
pull-through proxy orgs for some registries. (c) Artifactory — supports
any upstream registry as a remote repository.
Rationale: ECR pull-through cache requires private ECR registries,
which need IAM authentication — tokens expire every 12 hours, requiring a
CronJob for credential rotation on OCP pods. The S3 Lambda approach
serves content via a public HTTPS URL with zero authentication overhead.
images.paas.redhat.com was ruled out due to dependency on Red Hat IT
(rug-pulling risk if they decommission the proxy orgs). Artifactory was
not in use and would add a new service dependency. The S3 Lambda approach
reuses existing infrastructure (same Lambda, API Gateway, S3 bucket, custom
domain) and adds ~100 lines of OCI handler code ported from the proven CKI
dependency-archive.
DDR-2: Origin-first (HEAD-first) strategy vs cache-first
Decision: Use an origin-first strategy: always check the upstream registry first via HEAD request, redirect to upstream when available, and serve from S3 only when upstream returns 404 or errors.
Context: The existing RPM cache uses a cache-first strategy (check S3 first, revalidate with origin). For OCI artifacts, the design choice affects S3 egress costs and cache freshness.
Alternatives: (a) Cache-first — check S3 first, serve from cache if present. (b) Origin-first — check upstream first, fall back to S3.
Rationale: OCI digests are content-addressed and immutable — there is no staleness concern (unlike RPM repo metadata). The origin-first approach minimizes S3 egress costs: when upstream is healthy (the common case), every pull redirects directly to the upstream registry with zero S3 data transfer. S3 is only read when upstream has garbage-collected the digest. This matches the CKI dependency-archive pattern, which is proven in production. The cache populates asynchronously on first access and serves as a safety net, not a performance layer.
DDR-3: Registry hostname in URL path vs per-registry subdomains
Decision: Encode the upstream registry hostname as the first path
segment after v2/ (e.g.,
koji-s3-cache.example.com/quay.io/openshift/origin-oauth-proxy@sha256:...).
Context: The CKI dependency-archive assumes a single upstream registry
(OCI_REGISTRY env var). Hummingbird pulls from 4+ registries (quay.io,
docker.io, ghcr.io, registry.gitlab.com).
Alternatives: (a) Per-registry subdomains
(quay-io.cache.example.com/...) — standard OCI URL format but requires
wildcard TLS certificates and DNS records, potentially separate Lambda
deployments. (b) Registry hostname in the URL path — single endpoint,
handler extracts the registry from the first path segment.
Rationale: Encoding the registry in the path allows a single Lambda,
single API Gateway, single custom domain, and single TLS certificate to
serve all registries. The /{path+} catch-all route already captures
everything. The v2/ prefix naturally separates OCI traffic from RPM
cache traffic. The kubelet constructs the correct path automatically from
the image reference. This approach requires zero new infrastructure beyond
the existing lambda-s3-cache stack.
DDR-4: Jinja macro rewrite vs registries.conf
Decision: Rewrite image URLs at Jinja template render time using a
cached_image() macro, rather than configuring registries.conf on OCP
nodes.
Context: OCP pods need to pull from the cache instead of directly from
upstream registries. The standard approach is registries.conf mirror
configuration on the cluster nodes.
Alternatives: (a) Node-level registries.conf via MachineConfig. (b)
Jinja macro rewriting image refs at deploy time.
Rationale: Hummingbird has namespace-admin access only on OCP
clusters — no MachineConfig, no node-level changes. The Jinja macro
approach works entirely at the deployment manifest level:
vars.d/*.yml files continue to contain upstream refs (Renovate keeps
working unchanged), and the macro rewrites them to cache URLs at render
time. This is transparent to Renovate, requires no cluster-admin
privileges, and can be disabled with a single variable change.
DDR-5: Scope limited to third-party images
Decision: Only cache third-party images. Hummingbird-owned images
(quay.io/hummingbird-ci/* and registry.access.redhat.com/hi/*) are
excluded.
Context: The cache exists to protect against upstream registries garbage-collecting digest-pinned images.
Alternatives: Cache all images uniformly.
Rationale: Hummingbird-owned images have additional sha-based tags
(commit SHA, build ID) that persist independently of the floating
:latest tag. Old digests are not garbage-collected because these
immutable tags keep them reachable. Caching them would add S3 storage
and Lambda invocations with no benefit. The cached_image() Jinja macro
skips rewriting for quay.io/hummingbird-ci/ prefixes.
DDR-6: Separate OCI handler function vs extending RPM cache logic
Decision: Implement _handle_oci_cacheable() as a separate function
from the existing RPM cache logic (lambda_handler → _handle_rpm).
Context: Both OCI and RPM caching share the same S3 bucket, uploader Lambda, and touch mechanism. The code could be unified.
Alternatives: Extend the existing RPM handler to handle OCI requests by adding OCI-specific branches.
Rationale: The RPM cache has ETag-based revalidation and configurable stale-cache behavior (origin vs cache) that do not apply to OCI — digests are immutable by definition. Keeping the handlers separate avoids accidentally changing RPM behavior and keeps each code path focused. The OCI handler was ported from the CKI dependency-archive, which is proven in production; staying close to the CKI code structure reduces the risk of introducing untested edge cases.
License
This project is licensed under the GNU General Public License v3.0 or later - see the LICENSE file for details.