Philipp Lehmann @PhilippTheServer

Tuning Ceph Recovery: The Trade Between Degraded Time and Client Latency

Choosing an mClock profile deliberately instead of inheriting whatever ships by default.

The problem

An OSD or a whole host fails. Ceph starts recovering redundancy for every PG that lost a replica, which means reading surviving copies and writing new ones — on the same disks and the same network links that client I/O uses. This happens at the worst possible time: capacity and I/O headroom are already reduced by the failure, and now a second workload is competing for what is left.

Throttle recovery too aggressively and applications see it as an outage — write latency climbs, timeouts start, and a routine disk replacement looks to on-call like a cluster-wide incident. Throttle it too gently and the cluster spends longer in a degraded state, during which a second failure — on a different disk holding one of the same PGs — can mean an unrecoverable object rather than an inconvenience. There is no setting that avoids this trade-off; there is only a decision about which side to lean on, made either deliberately or by accident.

It is easy to make this decision by accident, because it looks like a solved problem. Since the Quincy release, Ceph’s default OSD operation scheduler is mClock, which promises built-in QoS between client and recovery I/O without hand-tuned throttles. That is true as far as it goes — but mClock ships with a default profile (balanced), and “balanced” is itself a choice about the client/recovery trade-off, not a resolution of it. A cluster running the default without anyone having chosen it is one operational-incident-shaped surprise away from someone tuning osd_max_backfills by hand, discovering under mClock it does nothing, and being confused about why.

Working through it

Two schedulers, two different sets of knobs

Ceph has carried two OSD operation schedulers. The older one (wpq, weighted priority queue) is throttled with manual, relative knobs: osd_max_backfills (how many backfill operations an OSD runs concurrently), osd_recovery_max_active (concurrent recovery operations), osd_recovery_op_priority, and osd_recovery_sleep (a fixed delay inserted between recovery operations, still useful as a blunt brake even under mClock).

The current default, mclock_scheduler, works differently: it allocates I/O capacity between client ops, recovery/backfill, and scrub according to a cost model and a chosen profile, rather than a set of independent relative priorities. Under mClock, most of the old manual knobs are overridden to values the profile has already decided on — osd_max_backfills and osd_recovery_max_active among them — and changing them has no effect unless osd_mclock_override_recovery_settings is explicitly set, which the documentation itself recommends against doing casually.

Choosing a profile instead of a value

The built-in profiles are:

  • balanced — the default; a middle ground between client and recovery I/O.
  • high_client_ops — prioritises client latency, recovery proceeds more slowly.
  • high_recovery_ops — prioritises finishing recovery, at a real cost to client latency during the window it runs.

The profile is a cluster-wide (or per-OSD) config value, changeable at runtime:

ceph config set osd osd_mclock_profile high_recovery_ops

The choice depends on what actually failed and what is actually at risk. A single disk replacement in an otherwise healthy, low-utilisation cluster is a reasonable case for leaving the default alone — the redundancy loss is small and recovery is naturally cheap. A whole host failure that drops a pool’s replication factor by a third, in a cluster that is already busy, is a reasonable case for high_recovery_ops — the risk being managed is a second failure during an unusually long exposure window, and it is worth paying client latency to shorten that window. Be honest with whoever owns the affected applications that this is the trade being made; high_recovery_ops is not a free performance setting, it takes client latency to give recovery bandwidth.

Proving the effect rather than assuming it

The only way to know what a profile actually costs a given cluster’s hardware is to measure it, because the right answer depends on disk speed, network headroom and how busy the cluster already is. This is reproducible on a single disposable host with cephadm, using only public container images:

# Bootstrap a single-node cluster (adjust the IP to the host running this).
cephadm bootstrap --mon-ip 10.0.0.10 --allow-fqdn-hostname

# Add OSDs from spare/loopback devices as needed, e.g.:
ceph orch daemon add osd node-a:/dev/sdb
ceph orch daemon add osd node-a:/dev/sdc
ceph orch daemon add osd node-a:/dev/sdd

ceph osd pool create bench-pool 32 32

The solution

A script that forces a backfill by taking an OSD out, measures client-facing write latency under each mClock profile while that backfill runs, and prints a comparison:

#!/usr/bin/env bash
# compare_mclock_profiles.sh
# Forces recovery by marking one OSD out, then measures rados bench write
# latency under each mClock profile while that recovery runs.
# Usage: ./compare_mclock_profiles.sh <osd-id-to-take-out> <pool>
set -euo pipefail

OSD_ID="$1"
POOL="$2"
PROFILES=(balanced high_client_ops high_recovery_ops)

for profile in "${PROFILES[@]}"; do
  echo "=== profile: ${profile} ==="
  ceph config set osd osd_mclock_profile "${profile}"

  # Re-trigger backfill so this profile is measured against a comparable
  # amount of recovery work each time.
  ceph osd in "osd.${OSD_ID}"
  sleep 5
  ceph osd out "osd.${OSD_ID}"

  echo "waiting for backfill to start..."
  until ceph -s | grep -q "backfilling"; do sleep 2; done

  echo "client latency while backfill is active:"
  rados bench -p "${POOL}" 30 write --no-cleanup -t 16 | grep -E "Average Latency|Stddev Latency"

  ceph osd in "osd.${OSD_ID}"
  echo "waiting for recovery to settle before the next profile..."
  until ceph -s | grep -q "HEALTH_OK"; do sleep 5; done
done

Run it against the same disposable cluster for each profile in turn; the rados bench output for high_recovery_ops should show materially worse average and tail write latency than high_client_ops, with balanced in between — the actual gap is specific to the underlying disks and network, which is exactly why it needs to be measured on the hardware in question rather than assumed from the profile names.

Confirm which profile is actually active on a given OSD, since a config set at the osd section applies cluster-wide but per-OSD overrides can exist:

ceph config get osd.0 osd_mclock_profile

Conclusion

Since mClock became the default, the meaningful decision moved from tuning a handful of independent throttles to choosing one of three profiles — know which scheduler a cluster is running before reaching for osd_max_backfills, because under mClock it is very likely inert.

There is no profile that avoids the trade-off, only ones that lean further toward client latency or further toward shortening the degraded window; choose per incident based on what is actually at risk, not once, globally, and forget about it.

osd_recovery_sleep remains a legitimate blunt instrument even under mClock, for the case where a profile alone still leaves recovery too aggressive for a particular set of slow disks — it is not overridden the way the backfill/recovery concurrency settings are.