ObservabilityLXX · Loki at ScaleLokiScale
Querier Scaling
What you'll learn
- Explain why the querier is the cheapest Loki component to scale and the most expensive to operate under an unbounded query
- Read the split-by-interval, parallelism, and max-concurrent knobs and predict their effect on latency and load
- Configure querier replicas and query-frontend connection settings for a production deployment
- Diagnose the most common querier failure shapes: pool saturation, ingester round-trip failure, and split-too-fine
Prerequisites
Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13
A platform team deploys Loki in microservices mode with two querier pods and no query-frontend. A Grafana dashboard polls a LogQL query with a 7-day range and a heavy regex every 30 seconds. The two queriers saturate. Grafana panels time out. The on-call engineer adds four more queriers. Saturation continues. The team adds the query-frontend with a results cache; the saturation clears without adding a single querier.
The querier is the Loki component that is cheapest to scale and most expensive to operate without a query-frontend. Knowing the shape of the workload — query size, query parallelism, cache hit rate — is the difference between adding one pod and adding six.
What it is
The querier is the Loki component that answers LogQL queries. It is stateless, it is the easiest Loki component to scale, and it is the component that pays the cost of every long-running query the moment the query-frontend cache misses.
A query reaches the querier through one of two paths:
- Direct. Grafana or
logcliconnects to a querier pod on port 3100 and sends the LogQL query. The querier resolves the stream selectors, fetches matching chunks from the bucket, fetches matching head chunks from the ingesters, executes the query, and returns the result. This path is the default when no query-frontend is in front. - Through the query-frontend. The query-frontend splits the query into time-bounded sub-queries, dispatches them to queriers in parallel, applies results cache, and returns the merged result. This is the path that makes querier scaling tractable at scale.
The querier has no local state beyond per-request buffers. A restart loses in-flight queries (the client retries) and nothing else. This is why scaling is cheap: add a pod, the load balancer picks it up, and the bucket does the rest.
Why a sysadmin cares
Querier scaling is the operational discipline that determines whether the read path keeps up with Grafana. Three operational pains are specific to the querier:
- Pool saturation. A single wide LogQL query can hold a
querier worker for tens of seconds. Without
max_concurrentandquery_timeout, one unbounded query from a Grafana dashboard can starve every other request. - Ingester round-trip cost. A query that asks for the last five minutes hits every ingester for head chunks. With many ingesters and a wide stream selector, the round-trip dominates query latency.
- Bucket bandwidth. Every historical query fetches chunks from the bucket. A single 24-hour query with many labels fetches gigabytes. The querier pool’s bandwidth scales with query count, not with replica count.
How it works
The querier answers two request shapes from two paths:
Grafana / logcli query-frontend
| |
v v
+---------------+ +-------------+
| querier | | split into |
| (direct) | | sub-queries |
| resolve | | by interval |
| streams, | +------+------+
| fetch chunks, | |
| run LogQL | v
+-------+-------+ +-------------+
| | dispatch to |
| | queriers |
v | in parallel |
+---------------+ +-------------+ +------+------+
| ingester | <-----> | querier | |
| head chunks | | pool | <-----------+
+---------------+ +-------------+
| |
v v
+---------------+ +-----------------+
| object store | | object store |
| historical | | historical |
| chunks | | chunks |
+---------------+ +-----------------+
Two production details to call out:
- The split-by-interval. The query-frontend splits a wide query (e.g., 7 days) into many narrow queries (e.g., 24 sub-queries each covering 7 hours). Each sub-query runs on a separate querier worker. The narrower the interval, the more workers used in parallel, the higher the total CPU cost on the pool. The wider the interval, the fewer workers but each one scans more chunks.
- The head chunk round-trip. A query that asks for the last
five minutes must contact every ingester. The querier fans out
to all
JOINEDingesters, asks for matching head chunks, and merges the responses with the bucket result. An ingester fleet with many replicas makes this round-trip expensive.
How to configure it
A production querier config pins the four knobs that decide behaviour under load:
# loki-querier.yaml
querier:
# Where to find the query-frontend. When set, the querier is
# an internal worker; user-facing queries go to the query-
# frontend first. When unset, the querier answers directly.
frontend_address: loki-query-frontend:9095
# Per-query worker concurrency. Each worker scans one chunk
# at a time. The total number of in-flight chunks per query
# is the pool size times this factor.
worker_parallelism_factor: 4
# How long a single query may run before the querier cancels
# it. This is the second line of defence after the query-
# frontend's parallelisation; both should be set.
query_timeout: 60s
# How many queries a single querier pod accepts in parallel.
# The right value depends on the pod's CPU and the average
# query cost. A 4-vCPU pod with mostly narrow queries can
# take 50; a pod serving many wide queries should take 10.
max_concurrent: 20
query_range:
# Results cache lives on the query-frontend, not the querier.
# This block is here as a reminder; see the caching lesson
# for the full configuration.
split_queries_by_interval: 24h
parallelise_shardable_queries: true
results_cache:
cache:
memcached:
endpoint: memcached.internal:11211
ttl: 24h
frontend:
# Per-tenant concurrency cap on the query-frontend. Without
# this, one tenant can saturate the querier pool.
max_outstanding_per_tenant: 2048
Four production details to call out:
frontend_addressmust point at the query-frontend if one is in the deployment. A querier withfrontend_addressset is an internal worker, not a user-facing endpoint. The user-facing endpoint is the query-frontend.worker_parallelism_factorcontrols how many chunks one query fetches in parallel. Raising it speeds up wide queries and increases the bucket bandwidth spike from a single query.query_timeoutis the second line of defence. The query- frontend should split and parallelise; the querier should cancel anything that survives past the expected runtime.max_concurrentis the pool size per pod. The total pool size ismax_concurrent × replicas. Plan capacity againstqueries_per_second × average_query_duration.
How to validate it
Six checks confirm the querier pool is healthy and that the read path is correctly wired:
# READ-ONLY: confirm the querier is registered and ready.
curl -s http://loki-querier:3100/ready | jq .
# expected: { "querier": "ready" }
curl -s http://loki-querier:3100/services | jq -r '.services[]'
# expected: querier. (Nothing else — the querier pod does not
# also start the query-frontend or the distributor.)
# READ-ONLY: confirm the querier reaches the query-frontend.
# The /frontend_address should resolve and the connection
# should not be in CLOSE_WAIT or FIN_WAIT_2.
ss -tan state established '( dport = :9095 or sport = :9095 )' \
| grep loki-querier | wc -l
# expected: at least one established connection per querier pod.
# READ-ONLY: confirm a query returns from the querier directly
# (no query-frontend in front). This proves the querier can
# serve traffic even if the query-frontend is down.
curl -sG http://loki-querier:3100/loki/api/v1/query \
--data-urlencode 'query={job="varlog"}' \
--data-urlencode 'limit=10' | jq '.status, (.data.result | length)'
# expected: status=success, result is a non-empty array.
# READ-ONLY: confirm the ingester search path is alive.
# A query that asks for the last one minute should return head
# chunks from the ingesters.
NOW_NS=$(date +%s)000000000
S_NS=$((NOW_NS - 60000000000))
curl -sG http://loki-querier:3100/loki/api/v1/query_range \
--data-urlencode 'query={job="varlog"}' \
--data-urlencode "start=${S_NS}" \
--data-urlencode "end=${NOW_NS}" \
--data-urlencode 'limit=10' | jq '.status'
# expected: status=success.
# READ-ONLY: confirm the querier pool is not saturated. The
# histogram loki_querier_concurrent_queries should sit well
# below max_concurrent under steady-state load.
curl -s http://loki-querier:3100/metrics \
| grep '^loki_querier_concurrent_queries ' | head -3
# expected: count of in-flight queries on each pod is well
# below max_concurrent. A reading at the limit means the pool
# is saturated and new queries will queue.
# READ-ONLY: confirm the split-by-interval setting on the
# query-frontend is appropriate for the workload. Look at
# loki_query_frontend_split_queries_total: a high rate of
# splits for narrow intervals means the setting is too small.
curl -s http://loki-query-frontend:3100/metrics \
| grep '^loki_query_frontend_split_queries_total'
# expected: a healthy mix; an unbounded skew toward small
# intervals means too much parallelism.
How it can fail
Five shapes appear repeatedly:
- Querier pool saturated.
max_concurrentis too low for the query rate, or a few wide queries are holding workers. Symptom:loki_querier_concurrent_queriesat the limit, Grafana panels returning 504,loki_request_duration_secondsp99 climbing into the tens of seconds. - Ingester round-trip failure. The querier cannot reach
one or more ingesters, or the ingester returns an error. A
query for the last 5 minutes returns partial results.
Symptom:
loki_querier_ingester_query_errors_totalrising, logs from the querier showingrpc erroragainst ingester gRPC. - Split-too-fine. The query-frontend splits every query
into many tiny sub-queries, each one a separate round trip
to the querier. The pool is busy but each worker does little
work. Symptom:
loki_query_frontend_split_queries_totalspiking with small intervals, query latency high despite plenty of CPU headroom. - Bucket bandwidth exceeded. The querier fetches chunks
faster than the bucket or the network can sustain. Symptom:
loki_objstore_request_duration_seconds_bucketpushed into the multi-second buckets, the querier’s gRPC pool to the bucket exhausted. - Query-frontend unreachable from querier. The querier has
frontend_addressset but the query-frontend is down or blocked by a NetworkPolicy. Symptom: every query that should go through the query-frontend returns 500 withfrontend unreachablefrom the querier logs.
How to troubleshoot it
The diagnostic order:
- Is the querier ready?
curl /ready. A not-ready querier is not in the load balancer. A/readythat returns{ "querier": "not ready" }is the cheapest signal to find. - Is the query-frontend in front?
curl /serviceson the querier shows onlyquerier. Confirm the deployment’s ingress routes user traffic to the query-frontend, not directly to the querier. A querier that is being used as the user-facing endpoint in production has no results cache and no per-tenant concurrency cap. - Is the pool saturated?
loki_querier_concurrent_querieson each pod. A pool at the limit is a pool that is rejecting work. - Are queries timing out?
loki_querier_query_timeout_totaland the p99 ofloki_request_duration_seconds. A spike in timeouts meansquery_timeoutis too tight or the underlying fetch is too slow. - Are ingester round-trips succeeding? Look at the per-pod
log for
rpc errorfrom the ingester gRPC client. A sustained rate of failures means the ingester fleet is in a bad state, not the querier. - Is the bucket slow?
loki_objstore_request_duration_secondsp99. A spike here affects every querier that fetches historical chunks.
Security implications
The querier reads from the bucket and serves queries to Grafana:
- Authentication. Loki itself does not implement authentication. Front the querier (or, preferably, the query-frontend) with a reverse proxy that enforces JWT or mTLS. A querier that is directly reachable from the application fleet is a multi-tenant data leak waiting to happen.
- Tenant isolation. The querier never trusts a tenant ID
from the request body. Every request goes through the
distributor’s tenant resolution, which stamps
X-Scope-OrgIDfrom the auth proxy’s JWT. The querier reads only the chunks that match that header. - Bucket credentials. The querier reads from the bucket but does not write. A read-only IAM role is sufficient. A leaked read-write credential is also a write attack surface.
Performance implications
The querier is network- and disk-bound on bucket fetches:
- Network bandwidth. Each query fetches every matching chunk. A 24-hour query with broad stream selectors fetches gigabytes from the bucket. The querier pool’s bandwidth scales with the number of in-flight queries, not the number of pods.
- Bucket requests. Every query that touches the bucket
issues at least one
ListObjectsV2plus one fetch per chunk. A 24-hour query with 1,000 matching chunks issues roughly 1,001 bucket requests per second for the duration of the query. - Memory. The querier holds chunks in memory only for the duration of the filter pipeline. Peak memory is roughly the size of the largest chunk in the result set, plus per-query scratch space.
- CPU. LogQL execution is cheap per chunk but expensive in aggregate. A query that scans 10,000 chunks consumes roughly ten times the CPU of one that scans 1,000.
Production guidance
- Run at least two querier pods. One is a single point of failure.
- Add the query-frontend before raising the querier pool above three replicas. The cache collapses the cost of dashboard polling that would otherwise require four or five times as many queriers.
- Set
max_concurrentbased on the querier pod’s CPU and the average query cost. A 4-vCPU pod handling 4-vCPU-wide queries should run withmax_concurrent: 5; the same pod handling narrow queries can run withmax_concurrent: 50. - Set
query_timeoutto the longest expected legitimate query, not to a guess. 60 seconds is the standard starting point for production; lower it on read-replica deployments serving only dashboards, raise it for known analytics workloads. - Monitor
loki_querier_concurrent_queriesandloki_request_duration_secondsp99. The first tells you whether the pool is full; the second tells you whether the full pool is the cause of the latency.
Verification
You should now be able to answer:
- Why is the querier the cheapest Loki component to scale and the most expensive to operate without a query-frontend?
- What does
split_queries_by_intervaldo, and what is the trade-off between a small and a large interval? - How does
max_concurrentinteract withworker_parallelism_factorto determine the total bucket fetch rate? - What is the failure mode of an over-saturated querier pool, and why does adding more queriers not always fix it?
- How does a wide LogQL query reach the bucket, and what controls the bandwidth it consumes?
Quiz
Knowledge check · 8 questions
Q1. Why is the querier the easiest Loki component to scale?
Q2. What does split_queries_by_interval on the query-frontend do?
Q3. Adding more querier pods always reduces user-visible query latency.
Q4. Which of these are appropriate first moves when query latency rises? (select all that apply)
Q5. A querier pod shows loki_querier_concurrent_queries at the max_concurrent limit. What is the right first move?
Q6. Name the metric that shows the number of in-flight queries on a querier pod.
Q7. A query that asks for the last 5 minutes returns partial results. The first suspect is:
Q8. The querier should run with a results cache configured locally.
Passing score: 75%. Answers are checked in this browser.