Skip to main content
RunBook Academy

← All break/fix scenarios in Observability

advancedstorage-backend~30 min

Break/Fix: Object Storage Unavailable

Reported symptoms

  • ●Loki queries covering the last hour return normally; the same query over yesterday returns an error
  • ●Tempo resolves trace IDs captured a few minutes ago and returns not found for trace IDs captured yesterday
  • ●Finance flags the object-storage line: the bucket has grown for three consecutive days and nothing has been deleted from it
  • ●A log-based alert failed to fire overnight; the Loki ruler came back from a routine pod restart with no rule groups loaded
  • ●Every Prometheus panel, every Prometheus-based alert and every scrape target is completely normal
  • ●Ingestion dashboards are green: push rate, distributor accept rate and ingester flush rate are all at their usual values
  • ●An engineer with console access opens the bucket, lists the prefix and downloads a chunk without any trouble
  • ●No deploy of Loki, Tempo, Grafana or the collectors in three weeks, and the ingest volume has not changed

Evidence

  • · loki_objstore_request_duration_seconds_count broken out by operation and status_code shows PUT and LIST returning 200 while GET and DELETE return 403
  • · The Loki querier logs carry AccessDenied with status code 403 on chunk fetches
  • · loki_compactor_oldest_processed_age_seconds has been climbing steadily since 02:10 on Tuesday instead of resetting each sweep
  • · tempo_querier_query_errors_total is rising while tempo_ingester_failed_flushes_total is flat at zero
  • · aws s3api head-bucket against the bucket succeeds from an operator workstation using the operator credentials
  • · aws iam simulate-principal-policy for the platform role returns allowed for s3:PutObject and s3:ListBucket and implicitDeny for s3:GetObject and s3:DeleteObject
  • · The change record shows an IAM least-privilege consolidation applied to the shared observability role at 02:05 on Tuesday
  • · No Loki or Tempo pod has restarted since Monday, so nothing is running on a stale credential
Diagnosis and resolutionclick to reveal

Root cause

The object store was never unavailable. The bucket was up, the objects were intact, and the network path was clean throughout. What changed is the IAM path to it, which is the seam where most Loki and Tempo storage incidents actually begin. An IAM consolidation replaced the inline policy on the shared observability role with a least-privilege policy generated from a write-path template, so the new policy grants s3:PutObject and s3:ListBucket and omits s3:GetObject and s3:DeleteObject. Because reads and writes are separate actions, the platform failed asymmetrically and therefore quietly. Ingesters kept flushing, so every ingestion dashboard stayed green. Queriers lost the cold path, so Loki answers only from the ingester window and Tempo returns not found for any block that has already left it. The compactor lost both the read of its marker files and the delete of expired chunks, so the retention sweep stalled and the bucket began to grow. The ruler lost the read of its rule groups, which cost nothing until the ruler restarted and found it could not load them. Prometheus was untouched because its local TSDB is not in the bucket at all, which is exactly why half the platform looked perfect and made the storage hypothesis hard to reach. No restart was needed for the deny to take effect, because IAM is evaluated per request rather than at credential issue, and that same fact is what rules out the stale-credential explanation everyone reaches for first.

Remediation

The fix is a policy change on the role, not a change to Loki or Tempo, and nothing in the observability stack should be restarted to achieve it. Restore s3:GetObject and s3:DeleteObject to the role scoped to the bucket ARN, then prove the grant with aws iam simulate-principal-policy before looking at the data plane, so that a still-failing query tells you about the data plane rather than about the policy. Watch the 403 rate on loki_objstore_request_duration_seconds_count fall to zero without touching a single pod; that is both the confirmation of the fix and the proof that the cause was the policy rather than a cached credential. Then handle the arrears, which are the part that gets forgotten. The compactor has three days of un-swept retention to work through and will issue a burst of LIST and DELETE against one prefix when it resumes, which is precisely the shape that earns a 503 SlowDown from the bucket, so watch the sweep rather than assuming it. Confirm the ruler reloads its rule groups, which are still in the bucket because the role lost the ability to read them and never had the ability to remove them. Resist two attractive wrong moves: restarting the Loki pods, which removes the ingester window that is currently the only working read path, and failing over to the replica bucket, which is reached through the same denied role and would replace a read outage with a read outage against staler data. If the IAM change window is genuinely closed, hold deliberately rather than improvising: name an owner and an end time, tell the incident channel that Prometheus is intact and trustworthy while Loki and Tempo are read-degraded beyond roughly the last two hours, and say plainly that an empty Loki result for yesterday is not evidence that nothing was logged.

Verification

Verify the permission and the behaviour separately, because they fail for different reasons. On the permission, aws iam simulate-principal-policy must return allowed for all four of s3:GetObject, s3:PutObject, s3:DeleteObject and s3:ListBucket against both the bucket ARN and an object ARN under it. On the behaviour, the rate of loki_objstore_request_duration_seconds_count with a 4xx status_code must be zero for every operation, not merely lower, since a 403 against the object store is never a normal steady state. Then test the paths the incident actually broke, each against data that is certainly outside the ingester window: run a Loki query over a range from two days ago and confirm it returns lines, and resolve a Tempo trace ID captured before the incident began and confirm the trace renders. Confirm retention is moving again by watching loki_compactor_oldest_processed_age_seconds fall rather than climb, and confirm the bucket object count starts dropping once retention_delete_delay has elapsed. Confirm the ruler has its rule groups loaded and is evaluating them. Finally, prove the new guard can fail: remove s3:GetObject from the staging role and confirm the alert fires inside its for window. An alert that has only ever been green has never been tested.

Prevention

Alert on the storage response codes, not on storage availability. A 403 on any object-store operation is never normal, so a rule on the rate of loki_objstore_request_duration_seconds_count with a 4xx status_code, split by operation, catches this class within a scrape interval and names the missing action in the alert itself. Alert separately on the retention sweep, because a compactor that cannot delete is invisible on every other panel until the bill arrives; loki_compactor_oldest_processed_age_seconds climbing past a known ceiling is the cheapest signal there is. Run aws iam simulate-principal-policy for the four actions on a schedule and page on a denial, since it is the only check that catches a bad policy before a reader does and it costs nothing. Split the read role from the write role so a single policy edit cannot remove both halves of the storage path at once, and put the per-component action table in the policy document itself so the next person to write a least-privilege template has the requirement in front of them. Add the observability principals to the blast-radius review for any IAM consolidation, because the observability platform is a consumer of storage that nobody thinks to consult. And write down the asymmetry that made this quiet: reads and writes fail independently, so a green ingestion dashboard is not evidence that the storage path is healthy.

Reported symptoms

Four tickets are open against the observability platform. On the face of it they belong to four different teams, and three of them have already been closed once.

Logs work, history does not. The checkout team reports that Loki is broken. The platform team looks, runs a query over the last thirty minutes, gets lines back, and closes the ticket. The checkout team reopens it with a screenshot: the same selector over yesterday afternoon returns an error, not an empty result. Every query that stays inside roughly the last two hours succeeds. Every query that reaches past it fails.

Traces have the same shape and nobody connects them. A separate ticket says Tempo has lost old traces. A trace ID copied from a log line five minutes ago resolves and renders. A trace ID from a ticket filed yesterday returns not found. The reporter assumes retention was shortened.

The bucket is growing and nothing is being deleted. Finance flags the object-storage line item. It has risen three days running. The storage team confirms the growth is real and points out something stranger: the daily delete count against the bucket is zero, and has been since Tuesday.

An alert did not fire. Overnight, a log-based alert that should have paged did not. The Loki ruler pod was restarted routinely at 06:10 as part of node maintenance and came back with no rule groups loaded. That ticket is filed against node maintenance.

And the thing that is not a symptom, which is what kept all four tickets separate: Prometheus is perfect. Every scrape target is up, every panel renders, every Prometheus-based alert has fired and resolved normally all week. Ingestion is green too. The push rate into Loki, the distributor accept rate and the ingester flush rate are all sitting at their usual values.

Nobody has deployed Loki, Tempo, Grafana or a collector in three weeks.

Evidence provided

The cheapest question to ask first is not “is the bucket up” but “what is the bucket saying to us”. Loki emits a histogram for every backend operation, labelled by operation and by status code, so one query separates a reachability problem from an authorisation problem without touching the cloud console at all.

Read-only / Safetwo operations succeed, two are refused
$ curl -sG http://prometheus.internal.example.com:9090/api/v1/query \
--data-urlencode 'query=sum by (operation, status_code) (rate(loki_objstore_request_duration_seconds_count[10m]))' \
| jq -r '.data.result[] | .metric.operation + " " + .metric.status_code + " " + .value[1]'
PUT    200  38.412
LIST   200  0.851
HEAD   200  2.960
GET    403  14.203
DELETE 403  0.612

Illustrative output

That single result is the whole incident, and it is worth sitting with before reading further. A bucket that is down does not return 200 to some operations and 403 to others. A network partition does not either.

Read-only / Safethe querier names the failure precisely
$ kubectl -n loki logs deploy/loki-querier --since=15m | grep -i -m 2 denied
level=error caller=fetcher.go msg="error fetching chunks" err="AccessDenied: Access Denied, status code: 403"
level=error caller=fetcher.go msg="error fetching chunks" err="AccessDenied: Access Denied, status code: 403"

Illustrative output

The compactor tells the same story in a different vocabulary. Its progress metric reports the age of the oldest window it has finished processing, so a healthy compactor keeps it small and a stalled one lets it climb.

Read-only / Safejust over three days of un-swept retention
$ curl -s http://loki-compactor.internal.example.com:3100/metrics | grep -E '^loki_compactor_oldest_processed_age_seconds'
loki_compactor_oldest_processed_age_seconds 268140

Illustrative output

Tempo, queried the same way, splits neatly along the same seam: its read path is failing and its write path is not.

Read-only / Safereads failing, flushes clean
$ curl -sG http://prometheus.internal.example.com:9090/api/v1/query \
--data-urlencode 'query=sum(rate(tempo_querier_query_errors_total[10m])) or vector(0)' \
--data-urlencode 'time=now' \
| jq -r '.data.result[0].value[1]'
curl -sG http://prometheus.internal.example.com:9090/api/v1/query \
--data-urlencode 'query=sum(rate(tempo_ingester_failed_flushes_total[10m])) or vector(0)' \
| jq -r '.data.result[0].value[1]'
3.417
0

Illustrative output

Two checks falsify the obvious hypothesis. The bucket answers a head-bucket from an operator workstation, using the operator’s own credentials rather than the platform’s, which separates “the bucket is reachable” from “the platform is allowed to use it”.

Read-only / Safethe bucket is present and reachable
$ aws s3api head-bucket --bucket prod-observability --region eu-west-1; echo exit=$?
exit=0

Illustrative output

And the policy simulator answers the question directly, for the identity that actually matters.

Read-only / Safetwo of the four required actions are gone
$ aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/observability-storage \
--action-names s3:GetObject s3:PutObject s3:DeleteObject s3:ListBucket \
--resource-arns arn:aws:s3:::prod-observability \
                arn:aws:s3:::prod-observability/tenant-checkout/probe \
--query 'EvaluationResults[*].[EvalActionName,EvalDecision]' --output text
s3:GetObject     implicitDeny
s3:PutObject     allowed
s3:DeleteObject  implicitDeny
s3:ListBucket    allowed

Illustrative output

The change record shows an IAM least-privilege consolidation applied to the shared observability role at 02:05 on Tuesday. No Loki or Tempo pod has restarted since Monday.

Work the evidence before reading on

Four tickets, four teams, one bucket that is demonstrably up.

  1. A bucket outage and a network partition both fail everything. This failure returns 200 to PUT and LIST and 403 to GET and DELETE. What kind of fault can distinguish between operations on the same bucket from the same client?
  2. Loki answers for the last two hours and fails beyond that. Which component serves each of those two ranges, and which of them talks to the bucket?
  3. Prometheus is entirely unaffected while Loki and Tempo are both degraded in the same way. What does Prometheus not have in that bucket?
  4. The ruler was fine for four days and broke at 06:10 when it restarted. What does a ruler do at startup that it does not do while it is running?

Before continuing: no pod has restarted since Monday and the platform’s credentials have not been reissued, yet the behaviour changed at 02:05 on Tuesday. What does that tell you about where the decision to refuse these requests is being made, and what does it rule out?

Root cause

1. The store was never unavailable; the path to it was

The bucket was up for the whole incident. Objects were intact, the region was healthy, the network path was clean, and an operator could list and download at will. What changed was the identity’s right to use it.

At 02:05 on Tuesday an IAM consolidation replaced the inline policy on the shared observability-storage role. The replacement was generated from a write-path template, so it grants s3:PutObject and s3:ListBucket and omits s3:GetObject and s3:DeleteObject. Those four actions are the set the platform needs, and losing half of them does not take the platform down. It takes half of the platform down, which is much harder to see.

2. Reads and writes are different actions, so the failure is asymmetric

This is the property that turned one policy edit into four unrelated-looking tickets.

The ingesters only need to write. They kept flushing chunks and blocks on schedule, so push rate, accept rate and flush rate never moved and every ingestion dashboard stayed green. That greenness is what let the first Loki ticket be closed as unreproducible.

The queriers only need to read. In Loki, a query over a recent range is answered from the ingesters, which hold the window between “in memory” and “in object storage” — typically about two hours. A query that reaches beyond that window has to fetch chunks from the bucket, and every one of those fetches now returns 403. That is the two-hour boundary the checkout team measured without knowing what it was. Tempo behaves identically: spans still land and still flush, but a trace whose block has already left the ingester has to be downloaded, and the download is refused.

The compactor needs to read its marker files and delete expired chunks. It lost both. Retention stopped on Tuesday morning, which is why the bucket has grown for three days and why the delete count is exactly zero rather than merely low. Nothing alerted, because no rule watched the retention sweep.

The ruler needs to read its rule groups from the bucket at startup. It had them in memory, so it kept evaluating happily for four days. The 06:10 restart was the first moment it needed the read, and the read was refused. The alert that did not fire had nothing to do with node maintenance.

3. Prometheus was fine because it is not in the bucket

Prometheus 2.55 writes samples to a local TSDB. Its data is not in the object store, so no policy on that bucket can affect it. Every Prometheus panel and every Prometheus-based alert therefore stayed correct throughout, and that correctness is the single biggest reason the storage hypothesis was slow to arrive: half the platform was healthy, and it was the half people look at first.

Resolution

  1. Announce that no pods are to be restarted and no bucket failover is to be attempted. Both are already being suggested; say why they are wrong once, in the channel, so it does not have to be relitigated.
  2. State the working picture explicitly: Prometheus is intact and trustworthy, Loki and Tempo are readable only inside the ingester window, and an empty Loki result for yesterday means "cannot read" rather than "nothing was logged". This matters because other people are drawing conclusions from those queries right now.
  3. Restore s3:GetObject and s3:DeleteObject to the observability-storage role, scoped to the bucket ARN and the object ARNs beneath it, alongside the s3:PutObject and s3:ListBucket the consolidation kept.
  4. Prove the grant before you look at the platform. Re-run aws iam simulate-principal-policy for all four actions against both the bucket ARN and an object ARN. If a query still fails after this returns allowed, the problem is in the data plane and not in the policy — that separation is the point of checking in this order.
  5. Watch the 403 rate on loki_objstore_request_duration_seconds_count fall to zero without touching a single pod. This is both the confirmation of the fix and the proof that the cause was the policy rather than a cached credential.
  6. Watch the compactor resume. It has three days of arrears and will issue a burst of LIST and DELETE against one prefix, which is the exact shape that earns a 503 SlowDown from the bucket. Watch the sweep rather than assuming it, and be ready to let it run over several cycles rather than forcing it.
  7. Confirm the ruler reloads its rule groups. They are still in the bucket: the role lost the ability to read them and never had the ability to remove them, so nothing was deleted.
  8. Only then fix the policy process. The technical fix takes one change; the reason a write-path template was applied to a read-and-write consumer takes a conversation, and it belongs after service is restored, not during.

Verification

  1. aws iam simulate-principal-policy returns allowed for all four of s3:GetObject, s3:PutObject, s3:DeleteObject and s3:ListBucket, against both the bucket ARN and an object ARN beneath it. Both resources matter: ListBucket is granted on the bucket, the other three on the objects.
  2. The rate of loki_objstore_request_duration_seconds_count with a 4xx status code is zero for every operation. Not lower — zero. A 403 against the object store has no normal steady state.
  3. A Loki query over a range from two days ago returns lines. Pick the range deliberately so it cannot be answered from the ingester window; a query over the last hour proves nothing here and will pass even if the fix failed.
  4. A Tempo trace ID captured before the incident began resolves and renders. Same reasoning: the trace has to be old enough that its block is in the bucket.
  5. loki_compactor_oldest_processed_age_seconds is falling rather than climbing. This is the check that tells you retention is genuinely moving again rather than merely permitted.
  6. The bucket object count begins to drop once retention_delete_delay has elapsed. The delay is deliberate — deletes are marked before they are executed — so do not read the gap as a failed sweep.
  7. The ruler reports its rule groups loaded and is evaluating them. Confirm from the ruler, not from the alert firing, because an alert that is correctly not firing looks identical to one that cannot fire.
  8. The new guard can fail. Remove s3:GetObject from the staging role and confirm the alert fires inside its for window. An alert that has only ever been green has never been tested.

Prevention

  • Alert on the object store’s response codes rather than on its availability. A 403 on any operation is never normal, so a rule on the 4xx rate split by operation catches this entire class within a scrape interval and names the missing action in the alert text. Availability checks would have stayed green for the whole incident, because the bucket was available.

  • Alert on the retention sweep separately. A compactor that cannot delete is invisible on every other panel until the bill arrives, and the bill is a three-day feedback loop. loki_compactor_oldest_processed_age_seconds climbing past a known ceiling is the cheapest signal available.

  • Run the policy simulator on a schedule and page on a denial. It is the only check that catches a bad policy before a reader does, it needs no access to the data plane, and it costs nothing:

# Substitute your own values before running:
ROLE_ARN=arn:aws:iam::123456789012:role/observability-storage
BUCKET=prod-observability

aws iam simulate-principal-policy \
  --policy-source-arn "$ROLE_ARN" \
  --action-names s3:GetObject s3:PutObject s3:DeleteObject s3:ListBucket \
  --resource-arns "arn:aws:s3:::$BUCKET" "arn:aws:s3:::$BUCKET/probe" \
  --query 'EvaluationResults[?EvalDecision!=`allowed`].EvalActionName' \
  --output text
  • Keep the per-component action table next to the policy, so the next person writing a least-privilege template has the requirement in front of them instead of inferring it from a sample of traffic:
{
  "Sid": "ObservabilityObjects",
  "Effect": "Allow",
  "Comment": "ingester PUT; querier and index-gateway GET; compactor GET+DELETE; ruler GET+PUT",
  "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
  "Resource": "arn:aws:s3:::prod-observability/*"
}
  • Split the read role from the write role. Separation of read and write makes rotation simpler and limits blast radius if a credential leaks, and it also means a single policy edit cannot remove both halves of the storage path at once.

  • Put the observability principals into the blast-radius review for IAM consolidations. The observability platform is a consumer of storage that nobody thinks to consult, and a permission boundary that cannot be reduced below the platform’s requirement is a stronger control than a review anyway.

  • Write down the asymmetry, because it is the part that made this quiet: reads and writes are separate actions and fail independently. A green ingestion dashboard is not evidence that the storage path is healthy, and this incident is the proof.