Ceph Code Architecture and the Request Path from the Inside
TL;DR this chapter in a few lines
- The ceph/ceph repository is a large C++ monorepo with Python parts and submodules; build it with
do_cmake.sh, CMake and Ninja and test with vstart. - The OSD talks to storage only through
ObjectStore::Transaction; BlueStore is one implementation. - No blocking work may run in a messenger thread or while holding a PG lock; structures are versioned with encode/decode for compatibility.
- The chapter follows a RADOS write and an RGW PUT function by function and times the write path with OpTracker.
22-1 Repository map#
The github.com/ceph/ceph repository is a large C++ monorepo with Python parts. On the main branch, the src/ directory contains, in addition to Ceph's own code, several submodules and external libraries (rocksdb, seastar, spdk, isa-l, fmt, googletest, s3select, arrow, zstd and so on). Knowing this boundary is the first step in reading the code: whatever is inside these submodules is not Ceph code.

| Directory | Where to start reading | Why |
|---|---|---|
src/osd/ | OSD.cc (dispatch and shards), PrimaryLogPG.cc (op execution), PeeringState.cc | the data path and PG convergence |
src/os/bluestore/ | BlueStore.cc (queue_transactions, _do_write), bluestore_types.h | the on-disk format |
src/osdc/ | Objecter.cc (op_submit, _calc_target) | the client path and retry |
src/mon/ | Monitor.cc, Paxos.cc, PaxosService.cc, OSDMonitor.cc | maps and consensus |
src/msg/async/ | AsyncMessenger.cc, AsyncConnection.cc, ProtocolV2.cc, Event.h | networking and msgr2 |
src/common/options/ | global.yaml.in, osd.yaml.in, rgw.yaml.in, mds.yaml.in, rbd.yaml.in | the authoritative source of defaults |
src/crimson/ | osd/osd.cc | the rewrite of the OSD based on Seastar (shared-nothing, one reactor per core) |
To find the real default of any option, instead of searching blogs go straight to the src/common/options/*.yaml.in file at that version's tag (for example v20.2.0). All of this book's default tables were extracted from these files.
22-2 Build and development environment#
The build is done with CMake and Ninja. The do_cmake.sh script at the repository root creates the build directory (its name can be changed with the BUILD_DIR variable) and runs CMake with -GNinja. Extra flags can be passed with the ARGS variable. The target named vstart in src/CMakeLists.txt builds only what is needed for a local development cluster.
git clone https://github.com/ceph/ceph.git && cd ceph
git submodule update --init --recursive
./install-deps.sh
ARGS="-DCMAKE_BUILD_TYPE=Debug -DWITH_MGR_DASHBOARD_FRONTEND=OFF" ./do_cmake.sh
cd build && ninja vstart # only the components needed for vstart
# local cluster: 1 MON, 3 OSDs, no MDS
MON=1 OSD=3 MDS=0 MGR=1 ../src/vstart.sh -n -d -x --bluestore
./bin/ceph -s
../src/stop.shThe vstart.sh options, from the script's own help text: -n (new cluster), -d (debug), -x/-X (enable/disable cephx), -l (localhost) and -b (BlueStore, which is the default). The number of daemons is set with the environment variables MON, OSD, MDS, MGR, RGW and so on.
A full Ceph build needs tens of gigabytes of space and a lot of memory. To read the code, it is enough to clone the repository with --filter=blob:none and sparse-checkout only the directories you need. The author of this guide also used this method to check names.
22-3 Main classes and relationships#

22-3-1 The ObjectStore interface#
src/os/ObjectStore.h is the boundary between PG logic and storage. The OSD never writes directly to disk. The OSD builds an ObjectStore::Transaction (defined in src/os/Transaction.h) containing a list of operations such as write, zero, truncate, remove, setattrs, omap_setkeys and omap_rmkeys on collections, and hands it to the backend with queue_transactions. The backend guarantees that the whole transaction is atomic. This property is why the data object and the pg log entry become durable at the same time. The existing implementations are BlueStore (the default), MemStore (testing) and, in crimson, SeaStore and CyanStore.
22-4 Thread model#

| Class | File | Role |
|---|---|---|
ShardedThreadPool, OSD::ShardedOpWQ | common/WorkQueue.h, osd/OSD.h | the sharded op queue; op order within each PG is preserved |
OSDShard | osd/OSD.h | holds the scheduler, the shard's PGs and its lock |
Finisher | common/Finisher.h | a callback queue with a dedicated thread |
SafeTimer (= CommonSafeTimer<ceph::mutex>) | common/Timer.h | event scheduling |
Worker, NetworkStack, EventCenter | msg/async/Stack.h, msg/async/Event.h | the network event loop |
The golden rule in OSD code is that no blocking work (synchronous I/O, a long lock) may be done in a network thread or while holding a PG lock. Many strange "slow requests" in the past came from violating this rule. A heartbeat_map warning with the message had timed out after in the OSD log is a sign of a thread stuck in ShardedOpWQ.
22-5 Encoding and version compatibility#
Every structure that goes over the network or to disk is serialized with the include/encoding.h infrastructure or its faster version include/denc.h. The main pattern is versioning each struct with two numbers: struct_v (the current version) and compat (the lowest version whose decoder can read this data). The block length is also stored so that an old decoder can skip new trailing fields. The macros are ENCODE_START(v, compat, bl), ENCODE_FINISH(bl), DECODE_START(v, bl), DECODE_FINISH(bl) and WRITE_CLASS_ENCODER(cl). In denc, the equivalents are DENC_START(v, compat, p) and WRITE_CLASS_DENC(T).
// this guide's own example (not from Ceph code): adding a field while keeping compatibility
struct demo_info_t {
uint64_t size = 0;
std::string owner; // added in version 2
void encode(ceph::buffer::list& bl) const {
ENCODE_START(2, 1, bl); // v=2, a version 1 decoder can also read it
encode(size, bl);
encode(owner, bl);
ENCODE_FINISH(bl);
}
void decode(ceph::buffer::list::const_iterator& p) {
DECODE_START(2, p);
decode(size, p);
if (struct_v >= 2) decode(owner, p);
DECODE_FINISH(p); // unknown fields from later versions are skipped
}
};
WRITE_CLASS_ENCODER(demo_info_t)This mechanism is the basis of rolling upgrades. A new-version OSD encodes OSDMap or MOSDOp with more fields, and an old OSD reads it by skipping the end of the block. If a change is truly incompatible, compat goes up, and the upgrade is then tied to flags such as require_osd_release. The ceph-dencoder tool in src/tools/ceph-dencoder/ exposes these same encoders for compatibility testing and inspecting real data.
22-6 Reading and debugging the code in practice#
| Tool | Example | Use |
|---|---|---|
| debug level | ceph config set osd.3 debug_osd 20/20 | the first number is the written log level and the second the in-memory level kept for dumping on a crash |
| admin socket | ceph daemon osd.3 help, config show, dump_ops_in_flight | direct commands to the daemon on the same host (common/admin_socket.cc) |
| OpTracker | ceph daemon osd.3 dump_historic_ops | timestamped events of each op; osd_op_history_size=20 and osd_op_history_duration=600 |
| ceph-dencoder | ceph-dencoder type OSDMap import om decode dump_json | decoding and displaying binary structures |
| ceph-objectstore-tool | --op list, --op log, dump | offline inspection of BlueStore and PGs |
| gdb | gdb -p $(pidof ceph-osd) then thread apply all bt | analysing hangs; -dbg packages or debuginfod are needed |
| tracing | jaeger_tracing_enable (default false), osd_blkin_trace_all | distributed request tracing; requires a build with Jaeger/OpenTelemetry support |
# reading an op's path in the log: follow the tid and reqid
ceph config set osd.3 debug_osd 20 ; ceph config set osd.3 debug_bluestore 20
rados -p test put obj1 /etc/hosts
grep -E 'do_op|execute_ctx|issue_repop|_txc_state_proc' /var/log/ceph/ceph-osd.3.log | tail
ceph config rm osd.3 debug_osd ; ceph config rm osd.3 debug_bluestore
ceph-dencoder list_types | grep -i -E 'osdmap|pg_log'22-7 The full path of a RADOS write, function by function#

| Step | Function (file) | What it does |
|---|---|---|
| 1 | Objecter::op_submit → _op_submit (osdc/Objecter.cc) | inflight budget (objecter_inflight_ops), tid, choosing the session |
| 2 | Objecter::_calc_target | name hash, pg, CRUSH and upmap, acting and primary; detecting the need to resend after a map change |
| 3 | Objecter::_send_op | builds MOSDOp with the map epoch; sends through ProtocolV2::send_message |
| 4 | OSD::ms_fast_dispatch (osd/OSD.cc) | converts to an OpRequest; rejects ops with an older epoch; enqueue_op |
| 5 | OSD::ShardedOpWQ::_process → OSD::dequeue_op | takes it from the shard's scheduler; PG lock |
| 6 | PrimaryLogPG::do_request → do_op → do_op_impl | checks peering/active, caps, object existence, blocklist, degraded state |
| 7 | PrimaryLogPG::execute_ctx → prepare_transaction → do_osd_ops | executes each OSDOp (write, omap, cls class call) and builds the Transaction and the pg log entry |
| 8 | PrimaryLogPG::issue_repop → ReplicatedBackend::submit_transaction → issue_op | sends MOSDRepOp to the replicas and the local queue_transactions |
| 9 | ReplicatedBackend::do_repop (on the replica) | applies the same transaction and replies commit |
| 10 | PrimaryLogPG::eval_repop | when all have committed: the MOSDOpReply |
For EC, step 8 becomes ECBackend::submit_transaction and the read-modify-write pipeline in ECCommon::RMWPipeline::start_rmw. On this path, for a partial write the existing shards are read first, parity is computed, and then all shards are written.
pseudocode: do_op on the primary (this book's summary of PrimaryLogPG.cc)
do_op(op):
if pg not active or op.epoch > osdmap: wait / requeue
if client blocklisted or cap denies: reply -EPERM/-EBLOCKLISTED
obc = get_object_context(oid) # may wait for recovery of this object
if object degraded/missing: wait_for_degraded_object
ctx = OpContext(op, obc) ; execute_ctx(ctx)
execute_ctx(ctx):
r = prepare_transaction(ctx) # do_osd_ops: each OSDOp → Transaction operations
if read-only: reply now
ctx.log += pg_log_entry_t(version = ++last_update, reqid, prior_version)
repop = new RepGather(ctx) ; issue_repop(repop, ctx)22-8 Inside BlueStore: a TransContext#

| State (BlueStore_components.h) | Meaning |
|---|---|
| STATE_PREPARE | building and allocation |
| STATE_AIO_WAIT / STATE_IO_DONE | waiting for the direct data aio to finish |
| STATE_KV_QUEUED / STATE_KV_SUBMITTED / STATE_KV_DONE | queued, submitted and durable in RocksDB |
| STATE_DEFERRED_QUEUED / STATE_DEFERRED_CLEANUP / STATE_DEFERRED_DONE | executing deferred writes |
| STATE_FINISHING / STATE_DONE | release |
Why deferred helps on HDD: a random 4K write on an HDD needs a full seek. With deferred, the data is written to the RocksDB WAL (which is usually on NVMe) and acked immediately, and the actual write is done later in batches. On SSD, this double write only adds write amplification, which is why the SSD default is zero.
22-9 The RBD write path#

22-10 The PUT path in RGW#

The rgw::sal layer (Storage Abstraction Layer, in rgw_sal.h) has separated RGW from RADOS in recent versions, so that other drivers such as posix, dbstore or d4n can sit under the same frontend and ops.
22-11 The open and write path in CephFS#

| Layer | Function (file) |
|---|---|
| Client | Client::_open, Client::_write, Client::get_caps, Client::handle_caps (client/Client.cc) |
| MDS | Server::handle_client_open, Server::handle_client_openc (mds/Server.cc) |
| Locks and caps | Locker::acquire_locks, Locker::handle_client_caps (mds/Locker.cc) |
22-12 The write path with real OpTracker timings#
The function-by-function path of the previous section can be timed with the OSD's own tool. Each OpRequest records timestamped points in OpTracker. Some are fixed flags: mark_queued_for_pg, mark_reached_pg, mark_started and mark_commit_sent in src/osd/OpRequest.h. Some are text events: "op_commit", "sub_op_commit_rec" and "waiting for subops from …" in src/osd/ReplicatedBackend.cc.
On a three-OSD BlueStore test cluster with Ceph 18.2.7, we set osd_op_history_size to 1000. Then we wrote 300 4 KiB objects one after another into pool p22 with size = 2 and read ceph daemon osd.N dump_historic_ops from all three OSDs.
Source in code: TrackedOp::mark_event and OpRequest::mark_flag_point (src/common/TrackedOp.cc, src/osd/OpRequest.h); ReplicatedBackend::submit_transaction and do_repop_reply
Complexity: O(number of ops × number of events)
pseudocode
for each op in ops (all three OSDs):
if description starts with "osd_op(" and pool = 4 ← primary ; if "osd_repop(" ← replica
ev = [(event, time)] in recorded order
for each consecutive pair (a, b): stage["a → b"].append(t_b − t_a)
median and 90th percentile of each stage ; count the order of op_commit and sub_op_commit_recResult (median / 90th percentile, microseconds). The whole write on the primary took about 1637 microseconds. Most of this time was spent waiting for commits. In 268 of the 300 ops, the local commit (op_commit) arrived before the replica's confirmation. The interval from waiting for subops to op_commit had a median of 1006 and a 90th percentile of 1407 microseconds. After that, it took another 356 microseconds until sub_op_commit_rec. In the remaining 32 ops the order was reversed.
The path before the PG is cheap. Its largest share was the sharded op queue: queued_for_pg to reached_pg with a median of 54 and a 90th percentile of 233. After that comes reached_pg to started at 86 microseconds, which is the time for the PG lock and preparing the op context.
On the replica, the started to commit_sent part (the BlueStore transaction and kv sync), with a median of 809 microseconds, is the main part. When the two paths are put on one axis, the replica finishes its work about 1170 microseconds after the start. But the primary sees the confirmation at about 1600 microseconds. This gap of several hundred microseconds is the cost of the return messenger path and the reply being queued, and it grows on a real network.
Practical lesson: when analysing a slow op (chapter 17), first see which two events have the largest gap. A large gap before reached_pg means the queue or the PG lock. A large gap between waiting for subops and sub_op_commit_rec means the replica or the network. A large gap up to op_commit means the local device or BlueStore.
22-13 Supplementary and up to date#
- crimson is still maturing in Tentacle and Umbrella. The classic OSD (
src/osd/) and crimson (src/crimson/osd/) have separate op path code but share encoding, OSDMap and CRUSH. Before operational use, check the release notes of that version. - The cls class method registration API on main has changed to a typed form (
ClassRegistrar) (chapter 15). Build out-of-tree class code against the target version's tag. neorados(insrc/neorados/) is a modern, asynchronous asio-based C++ interface, and RGW and librbd are gradually adopting it.
22-14 Hands-on exercise#
- 1. Bring up a vstart cluster with
MON=1 OSD=3 MDS=0, enabledebug_osd=20on one of the OSDs and run arados put. Find the ten functions of the "full path of a write" table in the log and note their order. - 2. Decode the map with
ceph osd getmap -o omandceph-dencoder type OSDMap import om decode dump_json | jq '.epoch, .pools[0]'. - 3. Compile the
demo_info_tstructure in a small unit test. Encode data with version 2 and decode it with a decoder that only knows the size field. - 4. On a stopped OSD, find the last pg log entry with
ceph-objectstore-tool --op logand compare theversion,prior_versionandreqidfields with the table in chapter 1. - 5. During a
rados benchtest, runthread apply all bton an OSD withgdb -pand identify thetp_osd_tp,msgr-workerandbstore_kv_syncthreads.
22-15 Review questions#
- 1. Why does the OSD not write directly to disk, and why does it use ObjectStore::Transaction?
- 2. What inputs does
_calc_targetin Objecter have, and when is an op resent? - 3. Why does each PG's op always go to one OSDShard?
- 4. What is the role of
ms_fast_dispatch, and what must not be done in it? - 5. How does
ENCODE_START(v, compat, bl)make rolling upgrades possible? - 6. What is the difference between the direct and deferred paths in BlueStore, and why is the SSD default zero?
- 7. When does the primary reply to the client in replication?
- 8. In RGW, which function moves an index entry from pending to complete?
- 9. In CephFS, why is the MDS not on the data path, and what is the effect of not having the Fb cap?
- 10. Where do you look to find the real default of an option in a specific version?
Answers to these questions are in Appendix E: Answers to Review Questions. Answers to this chapter's questions →
22-16 Sources for this chapter#
- The Ceph repository
https://github.com/ceph/ceph - Developer guide
https://docs.ceph.com/en/latest/dev/developer_guide/ - Running vstart
https://docs.ceph.com/en/latest/dev/dev_cluster_deployment/ - OSD internals documentation
https://docs.ceph.com/en/latest/dev/osd_internals/ - encoding
https://docs.ceph.com/en/latest/dev/encoding/ - BlueStore
https://docs.ceph.com/en/latest/rados/configuration/bluestore-config-ref/ - crimson
https://docs.ceph.com/en/latest/dev/crimson/ - Weil et al., "Ceph: A Scalable, High-Performance Distributed File System", OSDI 2006
https://www.usenix.org/legacy/event/osdi06/tech/full_papers/weil/weil.pdf