Philipp Lehmann @PhilippTheServer

CRUSH Hybrid Rules: Pinning the Read Primary to SSD Without Rebalancing

Placing one replica on flash and the rest on spinning disk with a two-step CRUSH rule.

The problem

A CephFS metadata pool, or an RGW bucket index pool, is small but latency-sensitive: every metadata read and write goes through it, and in a replicated pool every one of those ops goes through the primary OSD first. If the primary happens to sit on a spinning disk, its seek latency becomes the floor for every metadata operation in the cluster, no matter how fast the replicas are.

The obvious fix — move the pool’s CRUSH rule to select only SSD-class OSDs — works, but it changes every replica for every PG in the pool. Ceph treats a CRUSH rule change like any other change to placement: it recomputes the up set for every PG against the new rule, and any PG whose replicas moved has to backfill. For a pool with real data, that is a cluster-wide data migration, and it means buying enough SSD capacity to hold the entire pool three times over (for the default replication factor), not just enough to hold one replica’s worth.

osd primary-affinity looks like it should help — it lets you tell Ceph “prefer not to make this OSD primary” — but affinity only reorders already-placed replicas. If none of a PG’s three replicas is on SSD, adjusting affinity has nothing to work with. The disk that matters was never a candidate.

What is actually needed is asymmetric placement: exactly one replica constrained to SSD, the rest constrained to HDD, decided at CRUSH time rather than after the fact. CRUSH’s default chooseleaf step does not do this — it selects N replicas from a single device class in one pass. Getting an asymmetric result means writing a rule with two separate selection passes.

Working through it

How the up set decides who is primary

For a PG, CRUSH produces an ordered list of OSDs — the up set — and, barring a primary- affinity override, the first entry in that list is the primary. The order comes directly from the sequence of take/chooseleaf/emit steps in the rule: whatever a rule selects first becomes eligible to be first in the list. This means the primary’s device class is a property of the rule’s structure, not something tuned separately from placement.

Writing a two-step rule

CRUSH rules are a sequence of steps executed in order, and a rule can take a different root or device class more than once, accumulating selections across separate emit statements:

rule mixed_replicated_rule {
        id 11
        type replicated
        step take default class ssd
        step chooseleaf firstn 1 type host
        step emit
        step take default class hdd
        step chooseleaf firstn 0 type host
        step emit
}

The first block takes the SSD-class subtree of the default root and chooses exactly one leaf (firstn 1) at the host failure domain — this becomes the first, and therefore primary, OSD. The second block takes the HDD-class subtree and chooses firstn 0, which in CRUSH’s rule language means “as many as the pool’s size requires, minus what has already been chosen” — so for a 3x replicated pool this picks the remaining two replicas from HDD hosts.

The type host failure domain applies independently to each block, so the SSD pick and the two HDD picks are not forced onto separate hosts from each other by this rule alone — each block enforces host-level separation only among its own selections. In practice this is normally fine because the SSD and HDD device classes are already on physically different hosts in a hybrid cluster, but it is worth checking ceph osd tree for the actual host layout rather than assuming it.

Testing a rule before it touches a live map

CRUSH maps can be edited and tested entirely offline with crushtool, which compiles, decompiles and simulates placement without a running cluster:

# Pull the live map, decompile it to text, or start from a synthetic one.
ceph osd getcrushmap -o live.bin
crushtool -d live.bin -o live.txt

# Edit live.txt: add the mixed_replicated_rule block above.

# Compile the edited text back to binary.
crushtool -c live.txt -o edited.bin

# Simulate placement against the new rule without touching the cluster.
crushtool -i edited.bin --test --rule 11 --num-rep 3 \
  --show-mappings --min-x 0 --max-x 20

--show-mappings prints, for each simulated input (x), the OSD list CRUSH would produce. The first OSD in each line is the one that would become primary; confirming it is always drawn from the SSD device class, and the remaining two are always HDD, is the whole test. This catches a rule that accidentally allows the HDD block to pick an SSD OSD (or vice versa) before it can affect a single PG.

Accepting the rebalance this does cause

This is not free. Changing a pool’s crush_rule still recomputes every PG’s up set, and the primary — one out of three replicas — moves for every PG in the pool. That is real data movement. The point of the hybrid rule is not to avoid rebalancing altogether; it is to shrink the migration from “the whole pool times its replication factor” to “one replica per PG”, which is the difference between an operation that fits in spare SSD capacity and one that does not.

The solution

A minimal, fully offline demonstration using a synthetic CRUSH map — no live cluster required to prove the rule behaves correctly:

# hybrid.txt
tunable choose_local_tries 0
tunable choose_local_fallback_tries 0
tunable choose_total_tries 50
tunable chooseleaf_descend_once 1
tunable straw_calc_version 1

device 0 osd.0 class ssd
device 1 osd.1 class hdd
device 2 osd.2 class hdd
device 3 osd.3 class ssd
device 4 osd.4 class hdd
device 5 osd.5 class hdd

host node-a {
        id -2
        alg straw2
        hash 0
        item osd.0 weight 1.00
        item osd.1 weight 1.00
}
host node-b {
        id -3
        alg straw2
        hash 0
        item osd.2 weight 1.00
        item osd.3 weight 1.00
}
host node-c {
        id -4
        alg straw2
        hash 0
        item osd.4 weight 1.00
        item osd.5 weight 1.00
}

root default {
        id -1
        alg straw2
        hash 0
        item node-a weight 2.00
        item node-b weight 2.00
        item node-c weight 2.00
}

rule mixed_replicated_rule {
        id 11
        type replicated
        step take default class ssd
        step chooseleaf firstn 1 type host
        step emit
        step take default class hdd
        step chooseleaf firstn 0 type host
        step emit
}
crushtool -c hybrid.txt -o hybrid.bin
crushtool -i hybrid.bin --test --rule 11 --num-rep 3 \
  --show-mappings --min-x 0 --max-x 10

Each output line lists three OSDs per simulated PG; the first OSD is always one of the ssd-class devices (0 or 3), and the remaining two are drawn from the hdd-class devices, confirming the rule’s intent before it is applied anywhere real.

Applying it to a live cluster is the standard CRUSH map update sequence, followed by pointing the pool at the new rule:

ceph osd getcrushmap -o live.bin
crushtool -d live.bin -o live.txt
# add the mixed_replicated_rule block to live.txt
crushtool -c live.txt -o edited.bin
ceph osd setcrushmap -i edited.bin

ceph osd pool set cephfs_metadata crush_rule mixed_replicated_rule

ceph osd pool set ... crush_rule triggers backfill for the affected pool only; watch it with ceph -s and ceph osd pool stats.

Conclusion

osd primary-affinity and CRUSH placement solve different problems: affinity chooses among replicas that already exist, and cannot compensate for a device class that was never selected in the first place. When the goal is a specific device class for a specific replica, that has to be expressed in the rule itself.

CRUSH rules are not restricted to uniform selection — a rule is a sequence of independent take/choose/emit blocks, and each block can constrain a different device class, which is what makes asymmetric replica placement possible without inventing a new feature.

crushtool turns “I think this rule does what I want” into something verified before it touches a running map: it compiles, decompiles and simulates placement standalone, so a mistake in the rule shows up as an obviously wrong --show-mappings output rather than as a live migration to the wrong device class.