oximg fits Kubernetes without adapters: configuration is entirely
environment variables, /health serves as both probe endpoints, the
process is stateless (no writes, no local cache), and SIGTERM starts a
graceful drain — which is exactly the rolling-update lifecycle.
A starting point, not a prescription — adjust resources and replica count to your traffic:
apiVersion: apps/v1
kind: Deployment
metadata:
name: oximg
spec:
replicas: 2
selector:
matchLabels: { app: oximg }
template:
metadata:
labels: { app: oximg }
spec:
# SIGTERM -> drain in-flight requests -> exit 0. Give slow
# encodes room; the kubelet's SIGKILL backstops a hung client.
terminationGracePeriodSeconds: 30
containers:
- name: oximg
# latest rebuilds on every main push; pin a version tag or digest.
image: ghcr.io/oximg/oximg:0.8.2
ports:
- containerPort: 8081
env:
- name: OXIMG_SOURCE_BASE_URL
value: "https://static.example.com/originals"
# In memory-tight pods, the decoded-size cap is the lever:
- name: OXIMG_MAX_SRC_PIXELS
value: "30000000"
resources:
requests:
cpu: "1"
memory: 512Mi
limits:
# Worker count follows the cgroup CPU quota (Rust's
# available_parallelism reads it), so an integer limit
# gives you an exact concurrency budget.
cpu: "2"
memory: 1Gi
readinessProbe:
httpGet: { path: /health, port: 8081 }
periodSeconds: 5
livenessProbe:
httpGet: { path: /health, port: 8081 }
periodSeconds: 10
securityContext:
# The image already runs as uid 10001; these make the pod
# spec assert it.
runAsNonRoot: true
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
---
apiVersion: v1
kind: Service
metadata:
name: oximg
spec:
selector: { app: oximg }
ports:
- port: 80
targetPort: 8081For a local-files deployment instead of a remote origin, mount the
images as a read-only volume at /images (the default IMAGES_DIR)
— a ro PVC, an NFS mount, or an init container that syncs from
object storage.
-
CPU: oximg saturates whatever it is given — an internal semaphore pins concurrent pixel work to the observed core count, and throughput scales close to linearly with cores (see BENCH.md). What "observed" means here is specific and worth setting deliberately:
you set oximg permits limits.cpu: 11 limits.cpu: 1500m1 limits.cpu: 1900m1 limits.cpu: 22 limits.cpu: 2500m2 limits.cpu: 500m1 (the floor) only requests.cpu, no limitthe node's core count (Measured on cgroup v2 with the released image; confirm any deployment with the
oximg_cpu_workersgauge underOXIMG_METRICS=1.)Three things follow. It reads
limits.cpu, notrequests.cpu— requests becomescpu.weight, a scheduling share with no count in it, so there is nothing there to observe; a limit set purely as a blast-radius guard silently becomes a concurrency decision. Fractional limits round down, so1500mbuys the same single permit as1000mwhile costing 50% more — the second permit arrives at2, not at1001m. And with no limit at all, a pod sizes itself to the node, which on a large node is far more concurrency than its share of CPU can serve. CPU-managerstaticpolicy (exclusive cores via cpuset) is also respected, and when both a cpuset and a quota apply the smaller wins.If
oximg_request_duration_seconds{phase="queue"}is where your latency lives, permits are what to raise — and on Kubernetes that means raisinglimits.cputo the next whole number.With a remote source, permits are not held across the origin fetch (since issue #22 — downloads are buffered off-permit, bounded by
OXIMG_FETCH_CONCURRENCY), sophase="fetch"no longer names throughput that extra permits would recover. On 0.8.x and earlier, where the fetch did hold a permit, raisingOXIMG_WORKERSabove the CPU count recovered roughly thefetch/processshare (measured: 43% fetch share -> +34% throughput; a productiongs://deployment measured 47-51%; see bench/permit-lab) — if you run those versions with remote sources, that guidance still applies, together with its saturation arithmetic:permits x (1 - fetch/process)is the CPU needed at saturation, so two permits at a 49% fetch share want ~1.02 CPU andlimits.cpu: 1would throttle them.The memory ceiling on permits is version-independent:
(memory limit - idle RSS) / decoded-bytes p99bounds permits, and on a small pod with a heavy decode tail it is the binding one. A deployment with a 1320 MiB p99 decode estimate, 814 MiB idle, and a 4 GiB limit cannot run three permits whatever the CPU says. Buffered remote sources addOXIMG_FETCH_CONCURRENCY x OXIMG_MAX_SOURCE_BYTESas the worst-case fetch-buffer term, and the decoded-bytes estimate counts each request's buffer while it decodes.Read fetch numbers from warm traffic: a freshly started pod pays connection and TLS setup and reads several points high over its first requests.
-
Memory: bounded by concurrency × per-request buffers, which
OXIMG_MAX_SRC_PIXELScaps. The 64 MP default admits large sources; 30 MP is a sensible cap when your originals are phone photos and your pods are small. Measure with your own corpus — peak RSS under load is reported in BENCH.md. -
Horizontal scaling: the process is stateless, so an HPA on CPU works out of the box. Request coalescing is per-pod: behind a plain round-robin Service its benefit falls toward zero as replicas multiply, and a CDN or caching layer in front (honoring the 1-year
Cache-Control) matters far more than pod-local dedup at scale. If you do want coalescing across a scaled Deployment, consistent-hash the URL at the ingress so identical requests reach the same pod — ingress-nginx:nginx.ingress.kubernetes.io/upstream-hash-by: "$request_uri".
The graceful-shutdown path is what makes surge/unavailable rollouts
clean: the old pod stops accepting, finishes what it has (AVIF
encodes are the slow tail), and exits 0. Keep
terminationGracePeriodSeconds comfortably above your slowest
expected request; the kubelet's SIGKILL after the grace period is the
backstop for a client that never finishes reading.
No Prometheus metrics endpoint and no structured (JSON) logs — stderr
lines only (OXIMG_LOG=request to include successes). If you need
request metrics today, derive them at the ingress/mesh layer. This is
a deliberate PoC-stage boundary; see the README's roadmap.