Architecture Overview
emgr is an imgproxy-compatible image resizing/transcoding service written in Rust. A client requests a resized image via a signed URL; on a cache miss emgr fetches the source, processes it, writes the result to object storage, and redirects the client there. On a cache hit it skips straight to the redirect. This redirect-based delivery model is the single most consequential design decision in the system (see "Redirect-based delivery" below) and shapes almost everything else documented here and in docs/architecture/components.md.
This file describes the system as it exists on main today. For component-by-component detail (which module owns what, which files back which behaviour), see components.md.
Request flow
sequenceDiagram
participant Client
participant emgr as emgr (resize_handler)
participant Cache as Storage (cache check)
participant Guard as SSRF guard
participant Origin as Source origin
participant Storage as Storage backend
Client->>emgr: GET /{signature}/{options}/{source}.{ext}
Note over emgr: src/modules/router/router.rs:48<br/>route "/{signature}/{*rest}"
emgr->>emgr: url::split + verify_signature<br/>(src/modules/api/resize.rs:45-47,<br/>src/modules/signing/verify.rs:31)
emgr->>emgr: SignedRequest::parse_with_config<br/>(src/modules/url/mod.rs)
emgr->>emgr: CacheService::generate_key<br/>(src/services/cache/handler.rs:173)
emgr->>Cache: check_cache(key)<br/>(src/services/resize/handler.rs:183)
alt cache hit
Cache-->>emgr: true
emgr-->>Client: 301 Location: storage/key<br/>(src/modules/api/resize.rs:139)
else cache miss
Cache-->>emgr: false
emgr->>emgr: become single-flight leader<br/>(src/services/resize/handler.rs:205-233)
emgr->>Guard: fetch_validated(source url)<br/>(src/services/image/handler.rs:204)
Guard->>Guard: scheme + allowlist + resolve_validated_addr<br/>(src/services/image/source_guard.rs:147,180,356)
Guard->>Origin: GET (pinned to validated addr)
Origin-->>Guard: image bytes (streamed, size-capped)
Guard-->>emgr: Bytes
emgr->>emgr: spawn_blocking: decode/resize/encode<br/>(src/services/image/handler.rs:438, 620-621)
emgr->>Storage: upload_image_with_ttl(key, bytes)<br/>(src/services/resize/handler.rs:281)
Storage-->>emgr: ok
emgr-->>Client: 301 Location: storage/key<br/>(src/modules/api/resize.rs:139)
end
Client->>Storage: GET storage/key (second round trip)
Storage-->>Client: image bytesCache hit and cache miss both end in the same 301 Moved Permanently redirect (src/modules/api/resize.rs:139) to a URL under StorageConfig::cdn_base_url — never a redirect back to the caller-supplied source (deliberately: that used to be an open redirect from a trusted domain, and 301s are cached permanently by browsers, so a transient origin failure would have permanently steered clients away — see the comment at src/modules/api/resize.rs:77). The difference between hit and miss is entirely in what happens before that redirect is built.
Redirect-based delivery: the central trade-off
emgr never streams processed image bytes back to the client on the same connection. Both a cache hit and a freshly-processed cache miss end the request with a 301 pointing at the object in storage (local filesystem, S3, or in-memory-for-tests — see "Storage backends" below); the client then makes a second request to fetch the actual bytes. imgproxy, by contrast, streams the processed image directly on the original connection and has no server-side result cache at all — every request is reprocessed from scratch.
This is not an incidental implementation detail; it is the architectural choice that produces both emgr's strongest and weakest measured numbers. Across the three-way bench-imgproxy/ runs recorded in .bench-baseline/BASELINE.md (several 2-VU, medians-of-3 sweeps across the local_fs/s3 backends and both the pre- and post-AVIF FORMATS sweeps — the exact ratio moves with backend and fixture mix, roughly 3.3x-3.7x cold and 2.6x-2.9x throughput across those runs), the current headline position is:
| Path | emgr | imgproxy | Ratio |
|---|---|---|---|
| Cold (cache miss), p50 | — | — | emgr ~3.48x slower |
| Cold (cache miss), throughput | — | — | emgr ~2.86x lower |
| Warm (cache hit), p50 | 0.39 ms | ~21 ms | not a processing comparison — see below |
- Cold path cost. A cache miss pays for two HTTP round trips per delivered image (
req/image = 2.00in the baseline tables) instead of imgproxy's one: the client requests the resize, gets a 301, and re-fetches from storage. Part of the cold-path time is genuine decode/resize/encode work, but part of it is this two-hop delivery shape itself — the same shape that makes the warm path so cheap. - Warm path payoff. A cache hit costs a single
check_cachelookup (src/services/resize/ handler.rs:183) and a redirect — no decode, no resize, no encode.emgrnever touches the image pipeline for a repeat request; imgproxy has no equivalent and reprocesses every single request, identical bytes or not. The warm figure above is not a processing-speed comparison and must not be read as one: imgproxy has no server-side result cache at all, so "0.39 ms vs ~21 ms" measures an architectural difference (skip the whole pipeline vs. reprocess from scratch), not which engine encodes faster. - The trade is inherent, not a bug to fix. The cold penalty cannot be removed without giving up the warm win — they are the same architecture viewed from two angles. Which one dominates in practice depends on production cache-hit rate, a number neither benchmark measures (
.bench-baseline/BASELINE.md, "Where the remaining cold gap lives"). - In production, imgproxy is normally deployed behind an external CDN that would absorb repeat requests the way
emgr's built-in cache does natively — so the honest framing is "emgr's built-in result cache vs. imgproxy's reliance on an external one," not "emgrprocesses images faster" (it measurably does not — cold: ~3.48x slower p50, ~2.86x lower throughput).
Storage backends
Three backends implement a common StorageBackend trait (src/services/storage/core.rs), selected by Cargo feature and, when more than one is compiled in, by the STORAGE_TYPE environment variable (src/services/storage/handler.rs:42-141):
local_fs(src/services/storage/local_fs_handler.rs) — writes to a local directory.s3(src/services/storage/s3_handler.rs) — S3-compatible object storage (MinIO-tested).in_memory(src/services/storage/in_memory_handler.rs) — an unboundedHashMap, gated#[cfg(all(test, feature = "in_memory"))]. It does not exist in a release build regardless of whether thein_memoryCargo feature is enabled — selectingSTORAGE_TYPE=IN_MEMORYin production fails fast at startup instead of running an unbounded, uncapped cache. It exists purely to exerciseStorageBackendin this crate's own tests.
The trait also carries an optional per-entry TTL (upload_image_with_ttl); None means "never expires," the only behaviour available before TTL support existed, and still the default — ResizeService has no config knob feeding a real duration into it today (src/services/resize/ handler.rs:126-135).
Concurrency model
tokioasync I/O throughout, with the runtime's worker-thread count configurable (TOKIO_WORKER_THREADS, cgroup-aware default —src/main.rs:33-51).- CPU-bound work on
spawn_blocking. Decode/resize/encode runs on tokio's managed blocking thread pool, not a hand-rolledrayonpool —rayon's value proposition is intra-job work- stealing parallelism, and nothing in this pipeline fans a single image's work out across threads (src/services/image/handler.rs:358-373).rayonis not a dependency of this crate. - Two independent semaphores bound in-flight work:
download_semaphorecaps concurrent source fetches,processing_semaphorecaps concurrent CPU-bound decode/resize/encode jobs (src/services/image/handler.rs:117-127). Both shed load with an error rather than queue when exhausted. - Single-flight coalescing. Once a cache miss is confirmed, concurrent requests for the same cache key share one leader's work instead of each downloading/processing/uploading independently (
src/services/resize/handler.rs:29-127, issue #37). AnRAIIguard broadcasts the leader's result (or a synthetic failure, if the leader panics or is cancelled) to every follower via atokio::sync::broadcastchannel, so followers can never hang indefinitely. - Router-level saturation shedding and rate limiting (
src/modules/router/middlewares.rs): atokio::sync::Semaphore-based concurrency cap plus request timeout (saturation_and_timeout_middleware,503on exhaustion), and per-IP token-bucket rate limiting viatower_governor(429 Too Many Requestson excess).
Security boundary
- HMAC-SHA256 signed URLs (
src/modules/signing/), imgproxy-compatible (HMAC-SHA256(key, salt || signed_path), constant-time comparison). Signing configuration fails closed at process startup (SigningConfig::from_env) rather than silently accepting unsigned traffic if misconfigured — an explicitALLOW_UNSIGNED_REQUESTS=trueopt-in is required to use the/unsigned/...escape hatch for local development. - SSRF guard (
src/services/image/source_guard.rs) applied to every source fetch (and every watermark fetch, which reuses the same guarded path):- Scheme allowlist: only
http/https(validate_scheme). - Optional
ALLOWED_SOURCESprefix allowlist, matched structurally (parsed scheme/host/port/path segments, never raw-textstarts_with) to close userinfo-spoofing and subdomain-boundary bypasses (is_allowed_source,matches_allowed_prefix). - Private/loopback/link-local/CGNAT/IPv6-ULA range blocking, including decimal/octal/hex IPv4 literal decoding (
is_blocked_ip_with_policy,parse_ip_literal), independently toggleable viaALLOW_LOOPBACK_SOURCE_ADDRESSES/ALLOW_LINK_LOCAL_SOURCE_ADDRESSES. - DNS-rebinding pinning: the address is resolved and validated once, then the HTTP client is pinned to that exact
SocketAddrfor the connection (build_pinned_client) — a second, attacker-controlled DNS answer at connect time can never be observed. - Per-hop redirect revalidation: redirects are followed manually (
reqwest's own redirect handling is disabled), and every check above re-runs for each hop's new location (fetch_validated,src/services/image/handler.rs:204-278) — anALLOWED_SOURCESmatch is recomputed per hop, so a bypass on one allowlisted host never carries over to a redirect target that doesn't independently match.
- Scheme allowlist: only
- Streaming download size cap.
Content-Lengthis checked as a cheap early rejection, but the real enforcement streams the body and aborts once the running total exceeds the cap — closing the gap a dishonest origin or chunked transfer encoding (noContent-Length) would otherwise leave open (download_image,src/services/image/handler.rs:291-347). - Decompression-bomb guard. Image dimensions are read from the header only (
peek_dimensions/ImageReader::into_dimensions) and checked againstMAX_SRC_RESOLUTION_MPbefore any full decode is attempted, so a small-on-disk/huge-decoded source is rejected without ever allocating the decoded buffer (process_image_blocking_with_limits's call site,src/services/image/handler.rs:617-618;check_source_resolution,:3007-3024). - Strict storage-key grammar (
src/services/storage/key_validation.rs) rejects any key that doesn't match exactly whatCacheService::generate_keycan produce, closing IDOR/traversal against both the download route and every storage backend. /metricsbearer-token auth (src/modules/metrics_auth/), gated behind theotelfeature, fails closed at startup the same way signing does./healthis deliberately left unauthenticated — it is the target of Kubernetes liveness/readiness/startup probes, which have no practical way to carry a bearer token, and it only ever returns the literal string"OK".
Codec choices
emgr has no C or C++ dependencies (#134) — every codec below is pure Rust, built by cargo and linked directly into the binary. A no-native-deps CI job fails the build if any dependency reintroduces native code, and the Docker build no longer installs nasm, cmake, meson, or ninja-build. This did not remove every third-party notice obligation: jpeg-encoder is itself licensed (MIT OR Apache-2.0) AND IJG, so the Independent JPEG Group notice in NOTICE stays required — see License.
- JPEG decode and encode go through
jpeg-decoder/jpeg-encoder, pure-Rust crates, replacingmozjpeg/libjpeg-turbo (#134) — DCT-scaled decode (jpeg-decoder'sDecoder::scale()) when a resize makes a smaller decode safe, full-size decode otherwise, with a fallback to theimage-crate (zune-jpeg) decoder on anyjpeg-decoderfailure.jpeg-decoderwas picked specifically forscale()— the fasterzune-jpeg(whatimagedecodes JPEG through) has no scaled-decode API at all, so decoding through it here would silently drop that optimization.jpeg-encodercarries the progressive-mode and raw APP-marker APIs the mozjpeg path relied on, but does not implement trellis quantisation. Seeadr/0001-image-engine.mdfor the original engine decision;.bench-baseline/BASELINE.md's "Post-#67 baseline" numbers describe the since-replaced mozjpeg implementation and have not been re-measured against the new one: TODO(re-measure). - Resampling uses the
fast_image_resizecrate rather thanimage's ownDynamicImage::resizekernel — several times faster at equivalent measured quality (DSSIM); see.bench-baseline/ BASELINE.md's "Current baseline" section.fast_image_resizewas already pure Rust and is unaffected by #134. - WebP encode and decode go through
vaam-image-webp, this org's fork ofimage-rs/image-webptracking its unreleased lossy VP8 encoder, replacing thewebpcrate (real libwebp via FFI, used for encode) and the dedicatedImageService::decode_webp_libwebp/libwebp_decodeFFI path (used for decode) (#134). No published crate encodes lossy WebP in pure Rust as of this writing —image-webp's released version is lossless-only, and lossless WebP runs roughly 5-12x larger than lossy on photographic content, which would make the format useless for this service's main job; the fork is pinned byrev, notbranch, and is temporary by construction, meant to be dropped once upstream cuts a release with lossy encoding. Seeadr/0001-image-engine.md(original rationale for thewebp-crate cutover this replaces) andadr/0003-webp-measurement.md(its now-superseded byte-size measurement). The libwebp-vs-image-webpnumbers this section used to carry (~29% slower on the synthetic fixture, ~2.24x faster on 24 real Kodak photographs, pixel-identical DSSIM 0.0) described that older comparison and are unmeasured againstvaam-image-webp: TODO(re-measure). The synthetic-vs-real inversion those numbers showed is still why the criterion suite benches both fixture kinds — see Testing. - AVIF encode and decode go through pure-Rust crates instead of
libavif(AOM + dav1d, vendored C, previouslysrc/services/image/avif_codec.rs) (#134):ravif(wrappingrav1e) for encode,avif-decode(wrappingrav1d, the Rust port ofdav1d) for decode. On the encode side this is a return toravif/rav1eafteradr/0004-avif-measurement.mdandadr/0005-avif-measurement-libavif-mozjpeg.mdrecord the earlier evaluation that moved production tolibavif/AOM — those ADRs are the historical record of that call and are not revised here. #134's motivation is dependency composition, not a re-run of that perceptual-quality comparison, so whether AOM's measured edge overrav1estill holds against therav1erelease in use today is unmeasured: TODO(re-measure). See theravif/avif-decodedependency comments inCargo.tomlfor the current codec/dependency choice.adr/0004measured the encoders this replaced before #67/#68 and its figures were already void; #134 does not revive them.
Cache key design
CacheService::generate_key (src/services/cache/handler.rs) hashes a version byte followed by every resize-affecting parameter, each length-prefixed rather than delimiter-separated: hasher.update(len_be_bytes); hasher.update(field_bytes) per field. This matters because url is fully attacker-controlled — a fixed delimiter byte (e.g. | or \0) could appear inside the URL itself and be used to forge a byte stream that collides with a different, legitimate parameter combination. A 4-byte big-endian length prefix makes every field boundary unambiguous regardless of the field's own content, so the mapping from (field_1, .., field_n) to the hashed stream is injective. The leading version byte (currently 11, CACHE_KEY_VERSION, src/services/cache/ handler.rs:147) is bumped whenever the hashed layout or the encoder/decoder producing the cached bytes changes in a way that would otherwise let old and new entries collide or serve stale output — see the version history documented directly in that file's CACHE_KEY_VERSION doc comment, including a case where a bump was deliberately not taken (issue #67's WebP decoder swap to libwebp) because the output was measured perceptually identical (DSSIM 0.0 across 24 real photos) and a bump would have forced a full reprocessing storm for zero visible benefit. The two most recent bumps: v10 for #5's metadata-strip-by-default cutover, v11 for the AVIF encoder moving from ravif/rav1e to libavif/AOM.
Request lifecycle, including rejections
stateDiagram-v2
[*] --> PathReceived: GET /{signature}/{*rest}<br/>src/modules/router/router.rs:48
PathReceived --> RateLimited: over per-IP burst<br/>tower_governor, src/modules/router/middlewares.rs:292
RateLimited --> [*]: 429 Too Many Requests
PathReceived --> Saturated: concurrency cap exceeded<br/>src/modules/router/middlewares.rs:119-135
Saturated --> [*]: 503 service at capacity
PathReceived --> SplitPath: url::split<br/>src/modules/url/mod.rs:76
SplitPath --> BadGrammar: malformed path
BadGrammar --> [*]: 400 Bad Request<br/>src/modules/api/resize.rs:129 (url_parse_error)
SplitPath --> VerifySignature: verify_or_reject<br/>src/modules/api/resize.rs:90
VerifySignature --> BadSignature: missing/wrong signature,<br/>or unsigned not allowed
BadSignature --> [*]: 403 Forbidden<br/>src/modules/api/resize.rs:95,105,118
VerifySignature --> ParseOptions: SignedRequest::parse_with_config<br/>src/modules/url/mod.rs
ParseOptions --> BadOptions: unknown/disallowed option,<br/>bad value, unknown preset
BadOptions --> [*]: 400 Bad Request
ParseOptions --> CacheKeyed: CacheService::generate_key
CacheKeyed --> CacheHit: check_cache = true<br/>src/services/resize/handler.rs:183
CacheHit --> [*]: 301 redirect to storage
CacheKeyed --> SingleFlight: check_cache = false
SingleFlight --> Fetching: this caller is leader<br/>src/services/resize/handler.rs:205-233
Fetching --> SsrfBlocked: scheme/allowlist/IP-range<br/>rejected, src/services/image/source_guard.rs
SsrfBlocked --> [*]: 400 Bad Request<br/>(SourceRejected downcast,<br/>src/modules/utils/err.rs:109-115)
Fetching --> OversizedSource: streamed size exceeds<br/>MAX_IMAGE_SIZE_MB, src/services/image/handler.rs:291-347
OversizedSource --> [*]: 400 Bad Request<br/>("too large", src/modules/utils/err.rs:120-124)
Fetching --> Decoding: source fetched
Decoding --> OverResolutionSource: header-only dimension check<br/>fails MAX_SRC_RESOLUTION_MP,<br/>src/services/image/handler.rs:617-618,3007-3024
OverResolutionSource --> [*]: 400 Bad Request<br/>("too large", src/modules/utils/err.rs:120-124)
Decoding --> Processing: spawn_blocking decode/resize/encode<br/>src/services/image/handler.rs:438,620-621
Processing --> Uploading: upload_image_with_ttl<br/>src/services/resize/handler.rs:281
Uploading --> Delivered: 301 redirect to storage
Delivered --> [*]
SingleFlight --> Following: another caller is leader
Following --> Delivered: leader's broadcast result
Following --> LeaderFailed: leader panicked/cancelled
LeaderFailed --> [*]: error propagated from leader's failureEvery rejection state above maps to a non-cacheable response (Cache-Control: no-store, src/modules/utils/err.rs) — a cached error is worse than an uncached one, since a CDN or client would otherwise treat a transient failure as permanent.
Technology stack
- Language: Rust (2024 edition).
- Web framework:
axum, withtower_governorfor rate limiting andtower-httpfor compression/CORS. - Image processing:
image(container formats, PNG/GIF paths),jpeg-decoder/jpeg-encoder(JPEG decode + encode),vaam-image-webp(WebP),ravif/avif-decode(AVIF),fast_image_resize(resampling) — every codec is pure Rust (#134); no C or C++ dependency remains in the image-processing path. - Storage:
aws-sdk-s3(S3/MinIO), local filesystem, in-memory (test-only). - Observability: OpenTelemetry tracing/metrics behind the
otelCargo feature (src/modules/tracer/), Prometheus-format/metricsbehind bearer-token auth. - Allocator: the platform
Systemallocator (src/main.rs), replacingmimalloc(#134).
There is no generated OpenAPI server in this codebase today. The service used to be built around a gen-server/packages//openapi.yaml generated router; that was removed as part of a hand-written- router rewrite (issue #53) and no longer exists anywhere in the tree or in Cargo.toml.