Production Infrastructure BlueprintBlueprint Rev. 2026-08-09

Method #

Two kinds of number appear in this repository and are kept apart. Footprints — image size, memory, start-up time, series, log bytes, backup sizes, failed requests — were measured on this system, and the method is given so each can be reproduced or disputed. Prices are published list prices with sources, in the cost analysis. Kubernetes requests and limits cite the measurement they were derived from, never a round number.

Container image #

ApproachBase imageSizevs. naive
Fat JAR on a JDK baseeclipse-temurin:21-jdk-noble523 MB
Fat JAR on a JRE baseeclipse-temurin:21-jre-alpine279 MB−47%
Layered JAR, jlink runtime — shippeddistroless/java-base-debian12:nonroot170 MB−67%

Where the shipped image's 170 MB goes

MB per layer, API image · the application layer is too small to see, which is the point

  • dependencies, /app/lib 69 MB
  • jlink runtime 65 MB
  • distroless base 36 MB
  • application, /app/app.jar 33 KB

A typical deploy changes application code and nothing else, so it pushes and pulls 33 KB, not 170 MB. The worker image, from the same Dockerfile, is 164 MB; the API grew to 200 MB once the Redis, Kafka and JPA dependencies arrived. The jlink runtime is 63 MB against the 345 MB JDK it was cut from, with a module list curated by hand and certified by the smoke test, because jdeps cannot see reflection or driver discovery.

Startup and CDS #

Spring context refreshRun 1Run 2Run 3Mean
Without CDS4.78 s4.93 s4.99 s4.90 s
With a CDS archive3.55 s3.69 s3.73 s3.66 s

−25% start-up for +88 MB of image. CDS is a separate target, make image-cds, rather than the default: for pods that live for days, a second per deploy does not pay for a 52% larger image. It flips when start-up is on the critical path — aggressive autoscaling, or scale-to-zero.

Memory against limits #

Application containers: resident memory against the limit

podman stats after the smoke-test workload · the bar is utilisation of the limit

  • api 433 MB / 640 MiB · 68%
  • worker 311 MB / 448 MiB · 66%
  • kafka 326 MB / 1400 MiB · 22%
  • postgres 29 MB / 512 MiB · 5%
  • gateway 29 MB / 128 MiB · 22%
  • redis 7 MB / 128 MiB · 5%
  • The API limit went from 512 to 640 MiB because adding the Redis and Kafka clients put the old limit at 81% — no headroom for a GC pause or a burst. The number said so.
  • Kafka's limit is generous against its 22%, deliberately: its real consumer is the page cache holding recent segments, which never shows in RSS.
  • No -Xmx anywhere. Heap is MaxRAMPercentage=70 of the cgroup limit, so the limit is the one place memory is tuned; the rest is metaspace, thread stacks, code cache and direct buffers.

Observability stack: resident memory against the limit

about 590 MB for all six — roughly one API replica

  • grafana 178 MB / 256 MiB · 66%
  • alloy 115 MB / 192 MiB · 57%
  • tempo 110 MB / 256 MiB · 41%
  • loki 86 MB / 256 MiB · 32%
  • prometheus 77 MB / 384 MiB · 19%
  • alertmanager 21 MB / 64 MiB · 31%

What fits on the node #

Memory requested on the CAX21

GiB of requests against 8 GB of RAM · k3s and Flux's controllers are the unshaded remainder's first tenants

  • application 2.7 Gi
  • observability 0.7 Gi
  • Traefik, cert-manager, policy-controller 0.27 Gi
  • not requested 3.8 Gi

3.7 Gi requested, 4.7 Gi with the API at its HPA ceiling of four replicas. Limits add up to 6.1 Gi — more than is comfortable, deliberately: they are ceilings a container is killed at, not reservations, and the thing that would make them peak together is a load spike, which the HPA and the objectives exist to catch. The full table, and the Postgres connection budget, are in Operations.

What observing it costs #

Scrape targetSamples per scrapeBudget, sample_limit
api412 (was 1,025)1,500
worker5431,200
gateway3441,000
postgres exporter, in the cluster224 (default 651)400
grafana, after dropping its own series8 of ~3,700
Logs, per request, 9,051 requests of steady loadBytesShare
All services — 1.48 lines1,101
gateway access log80976%
api15715%
worker949%

Loki's chunks compressed 4.4 to 1: thirty days at a million requests a day is about 7.5 GB, inside the 1 TB the object storage price already includes. Before the span filter, 176 of 198 stored traces were scheduled tasks and probes.

Load, rollouts and restores #

RunWhereRequestsFailedNotes
make load, two minutesCompose7,2510p99 37 ms through the gateway
Steady k6 profile, 50 req/s, three minutesCompose9,0510p99 38 ms; the log measurement above
Rollouts, preStop sleepkind11,9724all POSTs, keep-alive closes crossing requests
Rollouts, drain before SIGTERMkind11,9700identical load: 40 req/s, half writes, three rollouts
make drill, weeklykind, CI6,0010every API pod replaced under load
Kafka stopped for 150 s under loadCompose15 writes0no attempts spent; drained 12 s after restart
Point-in-time restore30,000 rows, 27 MB300,000 rows, 205 MB
Base backup, taken and uploaded2.0 s4.0 s
Base backup in object storage5.3 MB15.2 MB
WAL archived by the drill11 MB80 MB
Fetch and verify the base backup1.6 s2.6 s
Until promoted and writable5.6 s10.6 s
A timed-out WAL segment, stored16 KB of 16 MB16 KB of 16 MB

Restore time grows with the WAL to replay far more than with the base backup — which is the argument for a daily base backup rather than a weekly one. The runbook itself, run command by command against the production manifests in kind, took 12 seconds from stop to promoted for a 27 MB database (Restore the database).

Still to be measured #

  • An invoice. Every price is a list price; the first month's bill replaces them.
  • Loki and the WAL archive against Hetzner's own object storage endpoint, including request latency from the node.
  • Kafka's page-cache working set under sustained produce load — whether 1.4 GB is generous or merely adequate.
  • Registry storage and egress under the real deploy cadence, where the 33 KB layer should show.
  • Backup size and restore time on data that compresses like real data rather than repeated strings.
esc
↑ ↓ to move↵ to open/ or ⌘K to search