Running flare in production. This page is the short, opinionated
counterpart to security.md and
benchmark.md: what to wire up, what to monitor,
and what to do when something looks wrong.
The goal of this page is not a tour of every knob. It is the minimum set of things you must own before flare is in front of users.
| Mode | When to pick it | Knob |
|---|---|---|
| Single worker | < 5 k req/s, or you genuinely care about ordering across requests. | HttpServer.serve(handler) |
Multi worker, SO_REUSEPORT |
Default for > 5 k req/s. Each worker has its own listener + reactor; kernel hashes accepts across them. Best p99 / p99.99 in heterogeneous workloads. | HttpServer.serve(handler, num_workers=N) |
Multi worker, shared listener (EPOLLEXCLUSIVE) |
When you want a single accept queue and willing to tolerate a small p99.99 bump under skewed keep-alive. | HttpServer.serve_shared_listener(...) |
| Multi listener | Same handler exposed on multiple addresses (IPv4 + IPv6, public + UDS, etc). | HttpServer.bind([SocketAddr.localhost(80), ...]) |
Sizing rule of thumb: start with num_workers = runtime.scheduler.physical_core_count() and do not oversubscribe.
flare is thread-per-core; SMT siblings hurt tail latency more than
they help median.
- ALPN advertised on wss:// is
["http/1.1"]for a plainWsClient;WsAutoClientwithprefer_h2=Trueis the opt-in route to["h2", "http/1.1"](seefeatures.md). HttpClientoverhttps://advertises both and dispatches on the server's selection (RFC 7301).- The TLS layer uses the host's OpenSSL. Pin the openssl pixi package version with the same caution you'd apply to nginx.
- Certificate reload without dropping connections:
TlsAcceptor.reload(...)swaps the certificate atomically (a failed reload raises and keeps the old certificate). It also rotates the session-ticket key: tickets issued before the reload no longer resume. Worked example:examples/advanced/cert_reload.mojo. - Backend rationale + planned rustls-for-QUIC direction:
tls-strategy.md.
flare does not ship a built-in /healthz. The intentional
shape:
def healthz(req: Request) raises -> Response:
return Response(200, "ok\n")
var app = App[Unit]()
app.get("/healthz", healthz)Two reasons for the explicit choice:
- The probe URL is part of your contract with your load
balancer / k8s probe / Datadog agent. flare cannot guess
whether you want
/healthz,/health,/_health, or/__/health. - The probe semantics (is the worker pool ready? is a downstream dependency up?) belong to the application, not the framework. The 200-OK echo above is the contract; deepen it as your service requires.
flare does not install a signal handler -- Mojo does not yet allow
the process-global mutable state an async-signal-safe handler needs, so
the caller wires signal(2) and calls drain. See
examples/intermediate/drain.mojo.
On HttpServer.drain(timeout_ms):
- The listener is closed (no new accepts).
- The call blocks for the whole
timeout_mswhile the reactor keeps serving the connections already open, so a response that is still being written finishes.drain(0)skips the wait and is equivalent toclose(). The single-worker path publishes no live-connection count, so it cannot return early when the last connection ends: pass the longest wait you can tolerate. Call it from a thread other than the one runningserve(). - When the window ends the reactor flips
Cancel.SHUTDOWNon every live connection before closing it, so cancel-aware handlers and streaming bodies observe the shutdown at their next poll instead of losing the socket mid-chunk. ServerConfig.shutdown_timeout_ms(default 5 s) is the value to pass.
A serve_http3 loop drains the same call (RFC 9114 sec 5.2): every
QUIC connection is sent GOAWAY naming the next request stream it may
not open, new connections are refused, and each connection is closed
with H3_NO_ERROR as soon as its in-flight requests are answered, or
when timeout_ms passes. The loop returns once none is left. The h3
client reads the GOAWAY, finishes what is in flight, and no longer
reuses that connection from its pool. A program driving a
QuicListener directly calls begin_drain(timeout_ms) and then
drain_step() each turn until it returns True.
Multi-worker Scheduler.drain(timeout_ms) returns one
ShutdownReport per worker. in_flight_at_deadline and timed_out
are the real per-worker live-connection counts (each worker publishes
its count to a shared atomic cell every reactor iteration, read back
after the worker joins), not a fabricated 0/1. Scheduler.crashed_worker_count()
reports how many workers exited on a reactor poll failure rather than
a clean stop -- is_running() only tracks join state and cannot make
that distinction.
Every worker re-reads the stop flag at least every 100 ms, whichever
reactor it runs on. The io_uring buffer-ring workers (FLARE_BUFRING_HANDLER=1)
have no wakeup channel, so they bound their io_uring_enter wait with a
100 ms timeout (Linux 5.11+); an idle server therefore joins in about that
time on shutdown() and drain, without waiting for a client to connect.
For zero-downtime deploys behind a load balancer:
- Stop sending traffic at the LB.
- Send
SIGTERMto the old flare process. - Wait for the process to exit (its grace window must be longer than the longest p99.9 handler).
- Start the new flare process.
If you do not have a load balancer in front, flare-via-pixi
processes are not a hot-restart story. Run two ports and an
iptables flip, or run behind nginx.
flare ships three layers, all opt-in. Mix them as needed.
| Layer | What it gives you | Source |
|---|---|---|
RequestId[Inner] |
A per-request ID injected into every log line and echoed in X-Request-Id on the response. |
flare.http.middleware |
StructuredLogger[Inner] |
One JSON object per response on stdout (method, path, status, duration_us, request_id). Wire to your log shipper directly. | flare.http.structured_logger |
Metrics[Inner] |
Prometheus-format counters + histograms on /metrics. Cardinality is bounded by the router's route count, not by the URL path. |
flare.http.metrics |
Recommended minimum: wire RequestId[StructuredLogger[YourApp]]
on every server. A request-id-bearing log line is the difference
between "looks like 3 % of requests fail" and "here is the exact
500, with the upstream call that returned the bad response".
The HTTP server reads four limits from ServerConfig. Defaults
are tuned for sidecar / API-gateway shapes; raise only after
auditing the buffering chain.
| Limit | Default | Raise when |
|---|---|---|
max_header_list_size |
8 KiB | You really do have a downstream that sends 32 KiB cookies. |
max_body_bytes |
10 MiB | You're accepting file uploads. Use multipart streaming, not in-memory. |
max_uri_bytes |
8 KiB | Almost never. URLs longer than 8 KiB are usually a bug. |
keep_alive_idle_ms |
60 s | Keep-alive-heavy clients (browsers) -- consider 120 s; CDN -- 300 s; never above 600 s. |
max_connections |
0 (unlimited) | Per-worker accept-path cap. Set it to bound the file-descriptor / connection-flood surface; at the cap the worker takes its listener out of the reactor, so it sleeps instead of spinning, and the surplus waits in the listen backlog until a slot frees and the listener is put back. With per-worker SO_REUSEPORT listeners the kernel assigns each connection to a worker, so one worker can be at its cap while another has room. |
The corresponding HTTP/2 limits live on Http2ServerConfig:
| Limit | Default | Notes |
|---|---|---|
SETTINGS_MAX_HEADER_LIST_SIZE |
8 KiB | Advertised in the preface SETTINGS. |
SETTINGS_MAX_CONCURRENT_STREAMS |
100 | Per RFC 9113 §6.5.2. |
SETTINGS_ENABLE_PUSH |
0 (client) | We do not accept server push. |
- Run flare as an unprivileged user (uid >= 1000). flare does not need root for ports >= 1024; bind to 8443 + map via iptables or run behind a reverse proxy.
- Capabilities: nothing beyond
CAP_NET_BIND_SERVICEif you must bind to 80/443 directly. Prefer the proxy route. - Resource limits:
RLIMIT_NOFILEshould be >= 65 536 for serious traffic. The pixi tasks set this automatically; in systemd units, setLimitNOFILE=65536. - Memory: a flare worker steady-states around 30-80 MiB
resident for HTTP/1.1 only; +30-50 MiB if
permessage-deflatecontexts are negotiated heavily (eachPermessageDeflateContextcarries ~256 KiB of zlib state per connection).
| Symptom | Likely cause | First action |
|---|---|---|
| p99 latency creeps up over hours, no traffic-shape change. | Per-connection state leak (typically PermessageDeflateContext not closing on hung WS clients). |
Look at /metrics for flare_active_connections{transport="ws"}. If it monotonically grows, set WsServer.with_idle_timeout(60_000). |
| 5xx spike after deploy, no traceback. | Sanitised-error policy is hiding the root cause. | Tail logs for request_id=<X> to recover the full error message; the response body is intentionally generic (see security.md). |
Worker panic exits but the rest keep running. |
CatchPanic[Inner] middleware caught a Mojo abort; one worker is restarting. |
Check /metrics for flare_worker_restarts_total. The pool self-heals; investigate the corresponding log line. |
| TLS handshake errors in the log, intermittent. | OpenSSL session ticket key rotation drift (you reloaded the cert but the old keys are still in use somewhere). | TlsAcceptor.reload replaces the SSL_CTX and with it the ticket key, so a ticket issued before the reload falls back to a full handshake; reload every host together so a client moving between hosts does not see mixed behaviour. |
| Memory grows unboundedly under WS load. | Each WsConnection allocates 64 KiB of recv buffer; many idle WS clients * 64 KiB. |
Set WsServer.with_idle_timeout. Audit permessage-deflate if context-takeover is on. |
flare ships three soak workloads under the bench pixi
environment:
| Task | What it stresses | Duration |
|---|---|---|
pixi run --environment bench bench-soak-slow-clients |
Lingering reads, partial header dribbles, idle keep-alive. | 60 s default; SOAK_DURATION_SECS=86400 for 24 h. |
pixi run --environment bench bench-soak-churn |
Rapid connection open/close, accept storms. | Same. |
pixi run --environment bench bench-soak-mixed |
Mix of the above plus uniform request load. | Same. |
pixi run --environment bench bench-soak-smoke |
5-minute CI gate across all three workloads. | Fixed. |
pixi run --environment bench bench-soak-extended |
300 s per workload; for pre-release validation without the 24 h wait. | Fixed. |
The runners write reports under
benchmark/results/<version>/soak_*/<host>/<utc-timestamp>.json
with median + p99 + p99.99 windows in 1-min, 1-hr, and full-
duration buckets. The gate definitions (RSS within 2x of cold-
start, no monotonic-growing connection count) live in
benchmark.md.
Soak runs every minor release on the EPYC 7R32 reference host; Apple Silicon (M2 Pro, 12 cores) runs the same harness for cross-arch corroboration. A second-host bench publication (adding an Intel Sapphire Rapids SKU) is in flight. The methodology is identical; the cross-host numbers are in the same JSON layout so downstream comparison tools work without code changes.
flare runs cleanly in a static Docker image. The dependency shape is:
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y libssl3 libbrotli1 libdeflate0 zlib1g \
&& rm -rf /var/lib/apt/lists/*
COPY ./target/server /usr/local/bin/server
COPY ./certs /etc/flare/certs
EXPOSE 8443
USER 1000:1000
ENTRYPOINT ["/usr/local/bin/server", "--addr", "0.0.0.0:8443"]Three knobs to remember:
USER 1000:1000-- never root in the image.LimitNOFILE=65536-- set this in your orchestrator (k8scontainers.resourcesor systemd unit).- Mount certs read-only; flare reloads atomically via
TlsAcceptor.reload.
security.md-- per-layer security posture + sanitised-error policy.threat-model.md-- attack surface + mitigations.benchmark.md-- methodology + reference numbers.concurrency.md-- the closure-binding + cross-thread primitive rules.