Skip to content

Ops Server

A consumer reads from a queue. A scheduler fires jobs. Neither serves HTTP, and Kubernetes still restarts the pod that stops answering /livez, and Prometheus still needs a /metrics to scrape.

OpsServer gives that process a port of its own:

from faststream import FastStream
from faststream.redis import RedisBroker

from grelmicro import Grelmicro
from grelmicro.health import HealthChecks
from grelmicro.http import OpsServer
from grelmicro.metrics import Metrics, MetricsExporterType
from grelmicro.providers.redis import RedisProvider

redis = RedisProvider(url="redis://localhost:6379/0")
health = HealthChecks(auto_health=True)

micro = Grelmicro(
    uses=[
        redis,
        health,
        Metrics(exporter=MetricsExporterType.PROMETHEUS),
        OpsServer(port=8080),
    ]
)

app = FastStream(RedisBroker("redis://localhost:6379/0"))
micro.install(app)
# GET /livez, /readyz, /healthz and /metrics on :8080

That is the whole setup. No web framework, no ASGI server, no new dependency. It always serves /livez, because liveness is about the process rather than about a component, and it serves the rest of what the app registers: readiness and the report when a HealthChecks is registered, and /metrics when a Metrics is.

Endpoint Served when Answers
GET /livez always 200, empty body
GET /readyz a HealthChecks is registered 200 or 503, empty body
GET /healthz a HealthChecks is registered the JSON report
GET /metrics a Metrics with the prometheus exporter is registered the Prometheus exposition

They answer exactly what the FastAPI router answers, because both render through the same code. Read Health Checks for the report and ?exclude=, and Metrics for the exposition.

It reads the default instance of each, the way micro.health does, so an app that registers neither says so at startup rather than listening on a port that answers little:

OpsServerError: OpsServer has nothing to serve. Register a HealthChecks, a
Metrics, or both, under the default name: Grelmicro(uses=[HealthChecks(),
OpsServer()]).

An instance registered under a name of its own is served by mounting health_asgi(component) in an ASGI app instead.

While the app is starting

Components open in registration order, so a server registered first is listening while the rest of the app is still connecting. Until the app has finished opening, /livez answers 200 and /readyz, /healthz and /metrics answer 503, which is what a process that is alive and not yet ready owes an orchestrator. It reports Ready only once every component is open, and the check table it reads by then is the finished one.

A path it does not serve and a method it does not answer are refused during that window exactly as they are after it, so a probe pointed at the wrong one never looks healthy for the first second and broken after it.

That is also when it settles what it serves, because a Metrics still opening has no exporter to read yet. An exporter other than prometheus renders no exposition, so /metrics is left unserved and the reason is logged once, instead of every scrape getting a 500.

The Kubernetes side

The port is the one your probes point at:

livenessProbe:
  httpGet: { path: /livez, port: 8080 }
readinessProbe:
  httpGet: { path: /readyz, port: 8080 }

The default binds every interface, IPv4 and IPv6, because the kubelet reaches a pod on its pod IP and a dual-stack cluster may use either. Set host="127.0.0.1" to keep the port on loopback, for a sidecar that scrapes from inside the pod.

Configuration

Parameter Default What it decides
port 8080 The port it listens on.
host every interface The address it binds.
show_details False Whether /healthz carries each check's details.
request_timeout 10.0 Seconds one request may take, first byte to last. Keep it above the HealthChecks timeout, or a slow check answers 408 where it would have answered 503.
shutdown_timeout 5.0 Seconds in-flight requests get to finish on shutdown.
max_connections 32 Connections served at once.

Each one reads from the environment under GREL_OPS_, so a deployment moves the port without touching the code:

GREL_OPS_PORT=9100

Two servers on one app take a name each, and a named one reads GREL_OPS_{NAME}_:

micro = Grelmicro(uses=[health, OpsServer(), OpsServer(name="admin", port=8081)])

Where to register it

Register it first in uses=[...]. Components close in reverse order, so the one registered first closes last, and the probes keep answering while the rest of the app drains. Starting first costs nothing, because it answers 503 until the app is open.

On shutdown it stops accepting immediately, lets in-flight requests finish within shutdown_timeout, and cancels what is still running.

What it is, and what it is not

It is a small HTTP/1.1 server on the standard library. It reads a request line and its headers, answers, and closes. It uses no request body, keeps no connection alive, and speaks no TLS. A small body is read and dropped so the connection closes cleanly, and a large one is refused.

That is enough for a kubelet, a load balancer, and a Prometheus scrape, and it is deliberately not enough for anything else. Give it the pod network, not an ingress. A request it cannot read gets the status that says why:

Answer When
400 The request line or a header is malformed, or two Content-Length headers disagree.
404 The path is not one it serves.
405 The method is not GET or HEAD.
408 The request stopped mid-way and request_timeout elapsed.
414 The request line is longer than 8 KiB.
413 The request carries a body larger than 8 KiB.
431 A header line, or the number of headers, is over the limit.
501 The request uses chunked framing.
503 max_connections are already in flight.

Mounting the endpoints instead

A process that already runs an ASGI framework does not need a second port. Mount the endpoints in the app it already serves:

from starlette.applications import Starlette
from starlette.responses import PlainTextResponse
from starlette.routing import Mount, Route

from grelmicro import Grelmicro
from grelmicro.health import HealthChecks, HealthDetails, health_asgi

health = HealthChecks()
micro = Grelmicro(uses=[health])


@health.check("database")
async def check_database() -> HealthDetails | None:
    return None


async def home(request: object) -> PlainTextResponse:
    return PlainTextResponse("hello")


app = Starlette(
    routes=[
        Route("/", home),
        # Last: mounted at "" it matches every path, so a route after it
        # would never be reached.
        Mount("", app=health_asgi()),
    ]
)
micro.install(app)
# Endpoints: GET /livez, GET /readyz, GET /healthz

health_asgi() and metrics_asgi() are pure-ASGI apps, so they mount in Starlette, Litestar, or anything else that speaks ASGI, and they answer exactly what OpsServer answers. Mounted at "" the app matches every path, so it goes last, after the routes of your own. Mount it under a prefix instead and it matches that prefix alone, wherever you put it. On FastAPI, prefer health_router(): it serves the same endpoints and adds the OpenAPI schema and the Depends gates.

Read Frameworks for what runs where.