Deployment
What a container image and a Kubernetes manifest need before they run a grelmicro application in production.
Declare the environment
Set GREL_ENVIRONMENT=production in the manifest of every deployed
environment:
GREL_ENVIRONMENT=production
Four values name a tier: development, test, staging, and production.
They are the well-known values of the OpenTelemetry
deployment.environment.name
attribute, and grelmicro writes the declared value into that attribute on the
tracer resource, so the same variable names your environment in every trace.
Declaring production or staging turns on the backend check below. This is
the one place to set it per environment, so unlike GREL_ENV_LOAD it belongs
in the manifest rather than the image.
Any other value names no tier grelmicro can act on, so it neither gates the check nor reaches the trace attribute. It reads as undeclared, and says so:
GrelmicroConfigWarning: GREL_ENVIRONMENT='preprod' is not one of development,
test, staging, production, so the backend check runs as if it were
undeclared.
A fleet that calls its tiers qa and preprod keeps booting, and
prodution is loud instead of silent.
The value names a tier, not the name your organisation gave the environment.
Map yours to the closest of the four: a deployed environment that runs more
than one replica is staging, a throwaway CI run is test. You know which
one an environment called integration is, and grelmicro does not.
The backend check
A Lock on a memory backend is not a lock. It excludes nothing once a second
replica runs, and without a check it says nothing about it. So in production
and staging, a pattern that promises a guarantee across replicas refuses to
start on a backend that cannot deliver it:
BackendScopeError: Coordination('default') is bound to MemoryLockAdapter,
which provides scope 'process', but requires scope 'cluster' in environment
'production'. Use a Redis, Valkey, Postgres, or Kubernetes backend, or pass
requires= to say what reach you want.
The check runs before the first connection opens, so a misconfigured pod fails at boot instead of on the request that needed the lock.
A backend provides a scope, and a component requires one. The message names both sides, so the fix is in the sentence that reports the problem.
| Backend scope | Backends | State is shared by |
|---|---|---|
process |
Memory | One process |
host |
SQLite | The processes on one host |
cluster |
Redis, Valkey, Postgres, Kubernetes | Every process that connects |
cluster means every process connected to that backend, wherever those
processes run. One Redis serving two Kubernetes clusters still holds one lock.
Coordination and Outbox require cluster, because a lock, a leader, a
distributed cron and an outbox all promise something across replicas. Cache,
RateLimiterComponent and CircuitBreakerComponent require process: a
per-replica cache and a per-replica circuit breaker are the standard shape,
not a mistake.
Say what you want with requires= and the default no longer applies:
from grelmicro import Grelmicro
from grelmicro.coordination import Coordination
from grelmicro.providers.memory import MemoryProvider
from grelmicro.providers.redis import RedisProvider
from grelmicro.resilience import RateLimiterComponent
memory = MemoryProvider()
redis = RedisProvider("redis://localhost:6379/0")
micro = Grelmicro(
uses=[
# One replica owns this schedule, and a restart may repeat a run.
Coordination(memory, name="cron", requires="process"),
# A budget shared by every replica, or the app does not start.
RateLimiterComponent(redis, requires="cluster"),
]
)
It reads in both directions. Lower the bar to accept the scope you have, or
raise it to make the wiring you meant a startup condition: a
RateLimiterComponent(redis, requires="cluster") fails at boot the day
someone points it at memory. A Cache that an
Idempotency reads from wants requires="cluster" too,
because a replay has to find the stored response wherever the retry lands.
Only a bound backend is checked. A component you never registered is the
documented way to say you want
local behavior: a @cron with no Coordination fires on every replica on
purpose. Wiring a memory schedule backend instead says you expected the fleet
to agree, so that one is reported.
A Bulkhead(uses=[...]) holds components the app
never registers, so those are checked on first entry to the scope instead of
at startup.
When the environment is not declared
An undeclared environment still reports a backend that cannot keep its
promise. The report is a GrelmicroConfigWarning and a WARNING on the
grelmicro logger, once at startup, on both channels like the
ignored-variable report:
GrelmicroConfigWarning: Coordination('default') is bound to MemoryLockAdapter,
which provides scope 'process', but requires scope 'cluster'. Set
GREL_ENVIRONMENT to declare where this runs, or pass requires='process' to
say that is the reach you want.
So the three states differ in severity, not in what they find:
GREL_ENVIRONMENT |
A backend that cannot keep the promise |
|---|---|
production, staging |
BackendScopeError at startup |
| unset | Warning on both channels, once |
development, test |
Nothing |
Declare test in your test suite and the memory backends every test wires up
go quiet. Testing shows where.
Check it before it deploys
micro.check_backends() asks the question the deployed app will ask, from a
process that declares something else, so a test catches the wiring before a
pod does. It raises the same BackendScopeError, naming every binding that
does not hold:
def test_backends_hold_across_replicas() -> None:
micro.check_backends()
It checks against production by default. Pass environment= to ask about
another one.
Turn on environment configuration
Set GREL_ENV_LOAD=1 in the image:
ENV GREL_ENV_LOAD=1
Every GREL_* variable that fills a component field is read only when this
flag is truthy (1, true, yes, on). Without it, a pod that sets
GREL_LOG_LEVEL=DEBUG logs at INFO, and a pod that sets
GREL_LOCK_CART_LEASE_DURATION=120 keeps the 60 second default.
GREL_ENVIRONMENT is the exception, along with the flag itself. Neither
fills a component field, and a safety check behind an opt-in flag would be
off in the pods that need it most.
Provider variables are not gated. REDIS_URL, VALKEY_URL, POSTGRES_URL
and SQLITE_PATH are read out of the box, because those names belong to your
environment rather than to grelmicro, and a missing one fails at startup
naming the variable it wanted. So a pod without the flag still connects to its
backend and still ignores every GREL_* knob, which is what makes the flag
easy to forget.
Set it in the image, not in the manifest. A manifest gets copied from one environment to the next, and one copy will leave it out.
When the flag is missing
grelmicro names the ignored variable on two channels. It raises a
GrelmicroConfigWarning, which fails a test suite running -W error. It
also logs a WARNING on the grelmicro logger, so on the default backend
the report is a normal record in the log stream:
{"variable":"GREL_LOG_LEVEL","time":"2026-08-06T13:44:04.156560+00:00","level":"WARNING","msg":"GREL_LOG_LEVEL is set but was not applied: environment-driven configuration is opt-in. Set GREL_ENV_LOAD=1 to enable it, or pass the value directly.","logger":"grelmicro"}
The variable name is in the variable field, so an alert can match on the
field instead of the message text. Each name is reported once per process,
at startup, and only for variables a component actually declares.
Passing env_load=False to a component is a decision, so it stays quiet.
Logs
A container needs no log configuration. The default AUTO format writes
TEXT to a terminal and JSON everywhere else, so the same image gives a
readable stream in development and a parseable one in a pod. Uvicorn's own
lines take the same format.
Set the level per environment, once the flag above is on:
GREL_LOG_LEVEL=INFO
Kubernetes polls the probe endpoints every few seconds for the life of the
pod, and the access log reports each one. Attach ProbeFilter to drop them:
import logging
from fastapi import FastAPI
from grelmicro import Grelmicro
from grelmicro.health import HealthChecks
from grelmicro.integrations.fastapi import health_router
from grelmicro.log import ProbeFilter, configure
health = HealthChecks()
micro = Grelmicro(uses=[health])
app = FastAPI()
micro.install(app)
app.include_router(health_router())
configure()
logging.getLogger("uvicorn.access").addFilter(ProbeFilter())
Logging covers the formats, the backends and the other filters.
Health probes
health_router() serves /livez, /readyz and /healthz. Point the
liveness probe at /livez, which stays 200 while the process is alive, and
the readiness probe at /readyz, which turns 503 as soon as a critical
check fails and takes the pod out of the Service.
A pod that serves no HTTP, a consumer or a scheduler, gets the same three
endpoints on a port of its own from OpsServer, and points
its probes at that port.
Keep the readiness period short and the liveness period long. Readiness
reacts to a lost backend, liveness only to a process that is gone. Use a
startupProbe on /livez instead of a long initialDelaySeconds, so a slow
first connection never counts as a liveness failure.
Health checks covers the component and the endpoint behavior.
Shutdown
Kubernetes sends SIGTERM and waits terminationGracePeriodSeconds before
SIGKILL. Keep Tasks(shutdown_timeout=...) at or below that window. Both
default to 30 seconds, so the defaults already line up.
Removing the pod from the Service and sending SIGTERM happen in parallel, so
a request in flight can arrive after the server has started to stop. A short
preStop sleep holds the signal back until the endpoint change has spread.
Draining matters for the locks. A task that finishes its iteration releases
its lock through async with, and LeaderElection releases the leadership
lock so a standby takes over at once. A task force-cancelled at the end of
the window leaves its lease to expire on the backend instead.
Graceful shutdown has the full contract.
Replicas
Lock, TaskLock and LeaderElection coordinate through the backend, so
every replica has to point at the same one. Give them a provider and let the
environment carry the connection:
REDIS_URL=redis+sentinel://sentinel-0:26379,sentinel-1:26379/mymaster/0
REDIS_PASSWORD=...
REDIS_SENTINEL_PASSWORD=...
The composition root then holds no connection code at all:
from grelmicro import Grelmicro
from grelmicro.providers.redis import RedisProvider
redis = RedisProvider()
micro = Grelmicro(uses=[redis])
Pods sharing a namespace with another grelmicro application need a prefix
so the two do not collide on key or lease names. Providers
covers the URL forms and the backend options.
Reconfigure without a restart
Mount a ConfigMap or a Secret and grelmicro re-resolves the live components when the file changes, with no rollout. See Live reconfiguration.
A complete manifest
apiVersion: apps/v1
kind: Deployment
metadata:
name: cart
spec:
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: cart
template:
metadata:
labels:
app.kubernetes.io/name: cart
spec:
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: cart
image: registry.example.com/cart:1.4.0
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
ports:
- containerPort: 8000
env:
- name: GREL_ENVIRONMENT
value: production
- name: GREL_LOG_LEVEL
value: INFO
- name: GREL_LOCK_CART_LEASE_DURATION
value: "120"
- name: REDIS_URL
value: redis://redis:6379/0
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: cart-redis
key: password
startupProbe:
httpGet:
path: /livez
port: 8000
periodSeconds: 2
failureThreshold: 30
livenessProbe:
httpGet:
path: /livez
port: 8000
periodSeconds: 20
readinessProbe:
httpGet:
path: /readyz
port: 8000
periodSeconds: 5
lifecycle:
preStop:
sleep:
seconds: 5
GREL_ENV_LOAD=1 is missing here on purpose: the image sets it, so no
manifest can forget it. Put it in the env block only when you cannot
change the image, and then put it in every copy of the manifest.
Checklist
- [ ]
GREL_ENVIRONMENTis set in every deployed environment. - [ ]
micro.check_backends()is asserted in a test. - [ ]
GREL_ENV_LOAD=1is in the image. - [ ] The startup logs carry no
variablefield. - [ ]
GREL_LOG_LEVELis set per environment, and the format is left toAUTO. - [ ]
ProbeFilteris attached touvicorn.access. - [ ]
/livezand/readyzare wired, with readiness the faster of the two. - [ ]
terminationGracePeriodSecondsis at or aboveTasks(shutdown_timeout=...). - [ ] Every replica points at the same backend, through a provider.