Ceph Storage Cluster — From Concepts to a Working Application

A complete, hands-on reference covering Ceph architecture, a 3-node cluster deployment with cephadm, object (RGW/S3) and block (RBD) storage, and a containerized application that consumes both.
Environment used in this guide
3 nodes:
ceph1(192.168.122.222),ceph2(192.168.122.121),ceph3(192.168.122.89)OS: Ubuntu 24.04 LTS
Container runtime: Docker
Ceph release: Squid (19.2.3), deployed via
cephadmEach node has one raw disk (
/dev/vdb, 15 GiB) dedicated to an OSD
Table of Contents
1. Core Concepts
Ceph is a distributed, decentralized storage system that solves the three inherent limitations of a single disk:
Capacity — a single disk has a ceiling; Ceph distributes data across many disks.
Durability — disks fail; Ceph keeps multiple copies on independent disks.
Throughput — a single disk serializes I/O; Ceph parallelizes across disks.
Unlike traditional SAN/NAS systems, Ceph has no central controller. There is no single point of failure and no central bottleneck. This is achieved through RADOS and the CRUSH algorithm (below).
RADOS
At its lowest layer, Ceph stores everything as objects (a flat namespace of uniquely-named binary blobs with metadata). This layer is called RADOS (Reliable Autonomic Distributed Object Store). Every higher-level interface — block, file, and object gateway — is built on top of RADOS and ultimately translates its operations into RADOS object reads/writes.
2. Cluster Architecture
A Ceph cluster is composed of daemons, each with a distinct role:
| Daemon | Process | Role | Recommended count |
|---|---|---|---|
| Monitor | ceph-mon |
Holds cluster maps (source of truth), authentication. Requires quorum. | 3 (odd) |
| Manager | ceph-mgr |
Metrics, dashboard, orchestration. Active/standby. | 2 |
| OSD | ceph-osd |
Stores object data; handles replication, recovery, rebalancing. | 3+ (one per disk) |
| RGW | ceph-radosgw |
S3/Swift-compatible object gateway. Optional. | as needed |
| MDS | ceph-mds |
CephFS metadata. Optional. | as needed |
Quorum (why MONs are odd-numbered)
Monitors must reach a majority (quorum) to agree on cluster state. With 3 MONs, majority is 2, tolerating 1 failure. An even count (e.g. 4) provides no extra fault tolerance over 3 and increases split-brain risk, so an odd number is always used.
MON vs MGR redundancy
MONs run all-active because they must vote to agree (quorum).
MGRs run active/standby because the role is single-instance; the standby takes over on failure. Redundancy here is for failover, not consensus.
Neither MON nor MGR sits in the data path — clients fetch maps once, then talk directly to OSDs. This is why they never become a bottleneck.
3. The Data Path: Pool, PG, CRUSH
Ceph does not maintain a central lookup table of object locations. Instead it computes locations. This makes the cluster infinitely scalable (no central bottleneck) and self-adjusting.
The write path has two computed stages:
object name --(hash mod pg_num)--> PG --(CRUSH + cluster map)--> [OSD, OSD, OSD]
Object → PG: the object name is hashed and taken modulo
pg_numto select a Placement Group. PGs are a middle layer that groups millions of objects into a manageable number of buckets — recovery, replication, and rebalancing all operate at PG granularity.PG → OSDs: CRUSH takes the PG id plus the cluster's CRUSH map (physical topology) and returns the list of OSDs that should hold the PG. CRUSH operates on PGs, not individual objects.
CRUSH and failure domains
Because the CRUSH map encodes physical topology (which OSD is in which host, rack, row), CRUSH can place replicas across distinct failure domains. Example: with host as the failure domain, a 3-replica object is placed on OSDs in three different hosts, so losing an entire host still leaves two valid copies.
Primary OSD
The first OSD in the CRUSH output is the Primary. Clients write to and read from the Primary; the Primary coordinates replication to the Secondaries and only acknowledges the write once all copies are persisted. CRUSH distributes the Primary role across OSDs to spread coordination load evenly.
4. Data Protection
Protection is configured per pool, not cluster-wide. Two methods:
| Method | Mechanism | Space efficiency | Recovery cost | Use case |
|---|---|---|---|---|
| Replication | N full copies (size, default 3) |
Low (~33% at size 3) | Low (plain copy) | Hot data, latency-sensitive |
| Erasure Coding | k data + m parity chunks | High (e.g. ~67% at k=4,m=2) | High (math rebuild) | Cold/archival, large volumes |
Key replication parameters:
size— number of replicas (default 3).min_size— minimum replicas required to continue serving writes (default 2). Below this, the pool blocks writes to protect data integrity.
5. Cluster Deployment
The general deployment pattern is: bootstrap one node → add remaining nodes → add OSDs. This holds for a 3-node lab or a 100-node cluster.
5.1 Install cephadm (on ceph1)
apt update
apt install -y cephadm
5.2 Bootstrap the cluster (on ceph1)
This creates the first MON and MGR. Note the --image override pointing to a mirror — the default quay.io registry may be unreachable in some networks.
cephadm --docker --image quay.m.daocloud.io/ceph/ceph:v19.2.3 bootstrap \
--mon-ip 192.168.122.222 \
--cluster-network 192.168.122.0/24 \
--allow-fqdn-hostname
Bootstrap output includes the dashboard URL, admin user, and a one-time password. Save these. All daemons run as Docker containers.
Set the mirror as the default base image so newly added nodes pull from it too:
ceph config set mgr mgr/cephadm/container_image_base quay.m.daocloud.io/ceph/ceph
5.3 Install the ceph CLI on the host (on ceph1)
cephadm add-repo --release squid
cephadm install ceph-common
ceph -s # cluster status; expect HEALTH_WARN until OSDs exist
5.4 Add the other nodes
cephadm manages nodes over SSH using its own key (/etc/ceph/ceph.pub).
Distribute the key (enter each node's root password when prompted):
ssh-copy-id -f -i /etc/ceph/ceph.pub root@192.168.122.121
ssh-copy-id -f -i /etc/ceph/ceph.pub root@192.168.122.89
Ubuntu 24.04 note: the cluster SSH key is RSA (SHA-1), which recent OpenSSH rejects by default. On each new node, allow RSA-SHA2:
echo "PubkeyAcceptedAlgorithms +ssh-rsa,rsa-sha2-256,rsa-sha2-512" \ > /etc/ssh/sshd_config.d/99-ceph-rsa.conf systemctl restart ssh
Add the hosts:
ceph orch host add ceph2 192.168.122.121
ceph orch host add ceph3 192.168.122.89
ceph orch host ls
cephadm automatically distributes MONs to reach a 3-node quorum and adds a standby MGR.
5.5 (Optional) Remove the monitoring stack
For a lab, the Prometheus/Grafana/Alertmanager/node-exporter stack is optional and its images may be blocked. Removing it keeps HEALTH clean and the nodes light:
ceph orch rm grafana
ceph orch rm prometheus
ceph orch rm alertmanager
ceph orch rm node-exporter
5.6 Create OSDs
Convert every available raw disk into an OSD:
ceph orch apply osd --all-available-devices
This also sets a standing policy: any future raw disk is auto-consumed as an OSD. After the OSDs come up, the cluster reaches HEALTH_OK:
ceph -s
ceph osd tree # shows root -> host -> osd topology (the CRUSH map)
Expected state: 3 osds: 3 up, 3 in, mon: 3 daemons (quorum), mgr: active + standby.
6. Object Storage (RGW / S3)
6.1 Deploy the RGW daemon (on ceph1)
RGW is a Ceph daemon; it runs on the cluster nodes, not on the application host. Applications are remote S3 clients.
ceph orch apply rgw myrgw --placement="ceph1" --port=8000
ceph orch ps --daemon-type rgw # wait for STATUS = running
RGW auto-creates several internal pools (.rgw.root, default.rgw.meta, default.rgw.log, default.rgw.buckets.data, etc.). This is expected.
6.2 Create an S3 user (on ceph1)
The user is a machine identity (access key + secret key), analogous to AWS IAM credentials. Create one per application; use meaningful UIDs.
radosgw-admin user create --uid=myapp --display-name="My App"
Record the access_key and secret_key from the JSON output. The user's op_mask of read, write, delete already permits normal bucket/object CRUD.
6.3 Client model
User/bucket ownership: keys belong to the user, not a bucket. A user can own many buckets.
Who does what: admin creates the user (server side,
radosgw-admin); the application creates buckets and reads/writes objects (client side, S3 protocol).
7. Block Storage (RBD)
RBD exposes a virtual block device. Unlike RGW, RBD needs no gateway daemon — the client (kernel driver) talks directly to the OSDs after computing placement via CRUSH.
7.1 Create pool and image (on ceph1)
ceph osd pool create rbd-pool 32
rbd pool init rbd-pool
rbd create rbd-pool/myimage --size 1024 # 1 GiB
rbd info rbd-pool/myimage
rbd info shows the image is split into 256 × 4 MiB objects. These objects are thin-provisioned — only written when data actually lands.
7.2 Map, format, mount
rbd map rbd-pool/myimage # -> /dev/rbd0
mkfs.ext4 /dev/rbd0
mkdir -p /mnt/myrbd
mount /dev/rbd0 /mnt/myrbd
The OS now treats /dev/rbd0 as an ordinary disk. Any write is transparently split into RADOS objects and replicated across the three nodes.
Note:
map/mountare not persistent across reboots by default. For persistence, configure/etc/ceph/rbdmapand/etc/fstab.
7.3 Verify replication
Every RBD data object is replicated exactly like a rados-written object:
rados -p rbd-pool ls | head
ceph osd map rbd-pool <object-name>
# -> up ([1,2,0], p1) = three OSDs on three hosts, Primary = osd.1
8. The Demo Application
A Flask app (in Docker) that stores uploads via both paths, side by side, to make the difference concrete.
Two paths compared
| S3 / RGW path | Disk / RBD path | |
|---|---|---|
| How the app connects | in code (boto3) | via a mounted folder |
| App aware of Ceph? | yes (endpoint + keys) | no (just a folder) |
| Gateway daemon | RGW (radosgw) |
none |
| Best for | files, objects, backups, sharing | databases, app disks, volumes |
Key insight: in the RBD path the app has no Ceph-specific code — it writes to /data like any folder. The Docker volume mount -/mnt/myrbd:/data is what places that folder on Ceph. RBD attaches at the system level; S3 attaches at the code level.
Configuration (environment variables)
Credentials are injected at runtime, never hardcoded:
environment:
S3_ENDPOINT: "http://192.168.122.222:8000"
S3_ACCESS_KEY: "<access_key>"
S3_SECRET_KEY: "<secret_key>"
S3_BUCKET: "myapp-bucket"
DISK_PATH: "/data"
volumes:
- /mnt/myrbd:/data
Run
cd ceph-storage-app
docker compose up --build -d
docker compose ps
# open http://192.168.122.222:5000
Verify (from the cluster)
# S3 objects landed in RADOS:
rados -p default.rgw.buckets.data ls
# RBD files landed on the mounted device:
ls -lh /mnt/myrbd
9. Storage Write Flow
S3 path (upload → persisted)
Browser — user submits the upload form (HTTP to app on :5000).
Flask app — receives the file; boto3 wraps it in the S3 protocol with the access key and sends it to RGW.
RGW (:8000) — authenticates the key, converts the file into RADOS object(s).
Placement computed — object name is hashed to a PG; CRUSH maps the PG to three OSDs. No central lookup.
Primary OSD — writes replica 1.
Replication — the Primary copies to the two Secondary OSDs (because the pool has
size=3), on three distinct hosts.Ack — the Primary waits until all three replicas are persisted, then acknowledges.
Success — the ack propagates back through RGW → app → browser.
RBD path (difference)
The RBD path skips steps 1–3 (no browser round-trip semantics, no RGW, no boto3). The app writes to /data; the RBD kernel driver computes placement (step 4) directly and writes to the OSDs. From step 4 onward, both paths are identical — same hash, same CRUSH, same 3-replica placement. This is the practical proof that "everything is RADOS underneath."
| Stage | S3 path | RBD path |
|---|---|---|
| App write mechanism | boto3 code | ordinary file write |
| Gateway daemon | RGW | none |
| Hash → PG → CRUSH → 3 replicas | identical | identical |
10. Application Source Code
Complete, runnable source of the demo app. Directory layout:
ceph-storage-app/
├── app.py # Flask app, both storage backends
├── templates/
│ └── index.html # upload UI
├── requirements.txt # flask + boto3
├── Dockerfile # container image
└── docker-compose.yml # env vars (S3 creds) + RBD volume mount
10.1 app.py
import os
import io
from datetime import datetime
from flask import Flask, request, render_template, redirect, url_for, send_file, flash
import boto3
from botocore.client import Config
app = Flask(__name__)
app.secret_key = "ceph-demo-secret"
# --- Path 1: S3 / RGW ---
S3_ENDPOINT = os.environ.get("S3_ENDPOINT", "http://192.168.122.222:8000")
S3_ACCESS_KEY = os.environ.get("S3_ACCESS_KEY", "")
S3_SECRET_KEY = os.environ.get("S3_SECRET_KEY", "")
S3_BUCKET = os.environ.get("S3_BUCKET", "myapp-bucket")
# --- Path 2: local disk (backed by RBD) ---
DISK_PATH = os.environ.get("DISK_PATH", "/data")
def get_s3_client():
"""Build an S3 client pointed at RGW (endpoint_url), not AWS."""
return boto3.client(
"s3",
endpoint_url=S3_ENDPOINT,
aws_access_key_id=S3_ACCESS_KEY,
aws_secret_access_key=S3_SECRET_KEY,
config=Config(signature_version="s3v4"),
region_name="us-east-1",
)
def ensure_bucket():
"""Create the bucket if it does not exist (the app does this itself)."""
s3 = get_s3_client()
try:
existing = [b["Name"] for b in s3.list_buckets().get("Buckets", [])]
if S3_BUCKET not in existing:
s3.create_bucket(Bucket=S3_BUCKET)
except Exception as e:
print(f"[warn] could not ensure bucket: {e}")
@app.route("/")
def index():
"""Home page: list files from both backends."""
s3_files, s3_error = [], None
try:
s3 = get_s3_client()
resp = s3.list_objects_v2(Bucket=S3_BUCKET)
for obj in resp.get("Contents", []):
s3_files.append({
"name": obj["Key"],
"size": obj["Size"],
"modified": obj["LastModified"].strftime("%Y-%m-%d %H:%M"),
})
except Exception as e:
s3_error = str(e)
disk_files, disk_error = [], None
try:
os.makedirs(DISK_PATH, exist_ok=True)
for name in sorted(os.listdir(DISK_PATH)):
full = os.path.join(DISK_PATH, name)
if os.path.isfile(full):
st = os.stat(full)
disk_files.append({
"name": name,
"size": st.st_size,
"modified": datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M"),
})
except Exception as e:
disk_error = str(e)
return render_template(
"index.html",
s3_files=s3_files, s3_error=s3_error, s3_bucket=S3_BUCKET, s3_endpoint=S3_ENDPOINT,
disk_files=disk_files, disk_error=disk_error, disk_path=DISK_PATH,
)
@app.route("/upload", methods=["POST"])
def upload():
"""Store the uploaded file in S3 or on disk, based on user choice."""
backend = request.form.get("backend")
f = request.files.get("file")
if not f or f.filename == "":
flash("No file selected.")
return redirect(url_for("index"))
if backend == "s3":
try:
ensure_bucket()
s3 = get_s3_client()
s3.upload_fileobj(f, S3_BUCKET, f.filename)
flash(f"File '{f.filename}' stored in S3 (RGW).")
except Exception as e:
flash(f"S3 upload error: {e}")
elif backend == "disk":
try:
os.makedirs(DISK_PATH, exist_ok=True)
f.save(os.path.join(DISK_PATH, f.filename))
flash(f"File '{f.filename}' stored on disk (RBD).")
except Exception as e:
flash(f"Disk write error: {e}")
return redirect(url_for("index"))
@app.route("/download/<backend>/<path:name>")
def download(backend, name):
"""Download a file from the selected backend."""
if backend == "s3":
s3 = get_s3_client()
buf = io.BytesIO()
s3.download_fileobj(S3_BUCKET, name, buf)
buf.seek(0)
return send_file(buf, as_attachment=True, download_name=name)
elif backend == "disk":
return send_file(os.path.join(DISK_PATH, name), as_attachment=True, download_name=name)
return redirect(url_for("index"))
@app.route("/health")
def health():
return {"status": "ok"}, 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
10.2 templates/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ceph Storage App</title>
<style>
body { font-family: sans-serif; max-width: 900px; margin: 30px auto; padding: 0 16px; }
.cols { display: flex; gap: 20px; flex-wrap: wrap; }
.col { flex: 1; min-width: 320px; border: 1px solid #ddd; border-radius: 10px; padding: 16px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 6px; border-bottom: 1px solid #eee; }
.upload-box { border: 1px solid #ddd; border-radius: 10px; padding: 16px; margin-bottom: 20px; }
button { background: #185FA5; color: #fff; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; }
.flash { background: #FAEEDA; border: 1px solid #EF9F27; padding: 10px; border-radius: 6px; margin-bottom: 16px; }
</style>
</head>
<body>
<h1>Ceph Storage App</h1>
{% with messages = get_flashed_messages() %}
{% if messages %}{% for m in messages %}<div class="flash">{{ m }}</div>{% endfor %}{% endif %}
{% endwith %}
<div class="upload-box">
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file" required>
<div>
<label><input type="radio" name="backend" value="s3" checked> Store in S3 (RGW)</label>
<label><input type="radio" name="backend" value="disk"> Store on disk (RBD)</label>
</div>
<button type="submit">Upload</button>
</form>
</div>
<div class="cols">
<div class="col">
<h2>S3 / RGW</h2>
<div>bucket: {{ s3_bucket }} — endpoint: {{ s3_endpoint }}</div>
{% if s3_error %}<div>error: {{ s3_error }}</div>{% endif %}
<table>
<tr><th>name</th><th>size</th><th>modified</th><th></th></tr>
{% for f in s3_files %}
<tr><td>{{ f.name }}</td><td>{{ f.size }} B</td><td>{{ f.modified }}</td>
<td><a href="/download/s3/{{ f.name }}">download</a></td></tr>
{% endfor %}
</table>
</div>
<div class="col">
<h2>Disk / RBD</h2>
<div>mount: {{ disk_path }}</div>
{% if disk_error %}<div>error: {{ disk_error }}</div>{% endif %}
<table>
<tr><th>name</th><th>size</th><th>modified</th><th></th></tr>
{% for f in disk_files %}
<tr><td>{{ f.name }}</td><td>{{ f.size }} B</td><td>{{ f.modified }}</td>
<td><a href="/download/disk/{{ f.name }}">download</a></td></tr>
{% endfor %}
</table>
</div>
</div>
</body>
</html>
10.3 requirements.txt
flask==3.0.3
boto3==1.34.144
10.4 Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
COPY templates/ templates/
EXPOSE 5000
CMD ["python", "app.py"]
10.5 docker-compose.yml
services:
storage-app:
build: .
container_name: ceph-storage-app
ports:
- "5000:5000"
environment:
# --- S3 / RGW path ---
S3_ENDPOINT: "http://192.168.122.222:8000"
S3_ACCESS_KEY: "<access_key>"
S3_SECRET_KEY: "<secret_key>"
S3_BUCKET: "myapp-bucket"
# --- disk / RBD path ---
DISK_PATH: "/data"
volumes:
# /data inside the container maps to the RBD mount on the host.
- /mnt/myrbd:/data
restart: unless-stopped
11. Code Path Trace (request → persisted)
This section follows the exact code executed for each storage path, from the HTTP request to the bytes landing on OSDs.
11.1 S3 path — line by line
Trigger: browser POSTs the form to /upload with backend=s3.
upload()handler runs (app.py).backend = request.form.get("backend")→"s3".f = request.files.get("file")→ the uploaded file object (in memory).Empty-file guard: if no file, flash a message and redirect.
ensure_bucket()is called.Builds a client via
get_s3_client().s3.list_buckets()→ RGW returns existing buckets.If
myapp-bucketis missing,s3.create_bucket(...)→ RGW creates it (this itself becomes RADOS metadata objects in the RGW pools).
get_s3_client()constructs the boto3 client.endpoint_url=S3_ENDPOINT→ points boto3 at RGW (http://…:8000), not AWS.aws_access_key_id/aws_secret_access_key→ the RGW user's keys.signature_version="s3v4"→ request signing scheme RGW expects.
s3.upload_fileobj(f, S3_BUCKET, f.filename)— the actual write.boto3 streams the file body to RGW as an S3
PUT(multipart if large), signing the request with the secret key.Leaves the app process here. Everything below is server-side Ceph.
RGW (
radosgwon :8000) receives the PUT.Verifies the signature/credentials (authentication).
Splits the object into RADOS objects and issues writes to the
default.rgw.buckets.datapool.
RADOS placement (per object).
Object name → hash mod
pg_num→ PG.CRUSH(PG, cluster map) → three OSDs on three distinct hosts.
Replication.
The write goes to the Primary OSD; the Primary replicates to two Secondaries.
Primary acks only after all three copies are persisted (
size=3).
Return trip.
RGW returns
200 OKto boto3 →upload_fileobjreturns → the handler runsflash("File '…' stored in S3 (RGW).")and redirects to/.index()then callss3.list_objects_v2(...), and the file appears in the S3 column.
Where to observe it on the cluster:
rados -p default.rgw.buckets.data ls
# object name is: <bucket-id>.<shard>_<original-filename>
ceph osd map default.rgw.buckets.data "<object-name>" # -> up ([x,y,z])
11.2 RBD path — line by line
Trigger: browser POSTs the form to /upload with backend=disk.
upload()handler runs.backend→"disk".os.makedirs(DISK_PATH, exist_ok=True)ensures/dataexists.
f.save(os.path.join(DISK_PATH, f.filename))— the actual write.This is an ordinary POSIX file write to
/data/<filename>.No boto3, no RGW, no Ceph-specific code in the app.
Docker volume mapping.
/datain the container is bind-mounted to/mnt/myrbdon the host (docker-compose.yml:- /mnt/myrbd:/data).So the write actually lands on the host's
/mnt/myrbd.
ext4 → RBD block device.
/mnt/myrbdis an ext4 filesystem on/dev/rbd0.The ext4 layer turns the file write into block writes on
/dev/rbd0.
RBD kernel driver → RADOS.
The
krbddriver maps block offsets to RADOS objects (rbd_data.<id>.<seq>) inrbd-pool.For each such object: hash → PG → CRUSH → three OSDs (identical to step 6–7 of the S3 path).
Persisted.
Once the OSDs ack, the block write completes; ext4 and the app see success.
index()lists/datawithos.listdir, and the file appears in the disk column.
Where to observe it on the cluster:
ls -lh /mnt/myrbd
rados -p rbd-pool ls | grep rbd_data | head
ceph osd map rbd-pool "<object-name>" # -> up ([x,y,z])
11.3 The convergence point
Both paths differ only in the entry gateway into RADOS:
S3 path enters through RGW (application-level, via boto3 code).
RBD path enters through the krbd driver (system-level, via a file write).
From the moment an object reaches RADOS (hash → PG → CRUSH → 3 replicas), the two paths are byte-for-byte identical in how data is placed, replicated, and persisted. This is the concrete demonstration that every Ceph interface is a facade over the same RADOS object store.
12. Command Reference
Status
ceph -s # cluster summary (run this constantly)
ceph health detail # explain any WARN/ERR
ceph df # capacity by pool
ceph osd tree # CRUSH topology: root -> host -> osd
ceph osd df # usage + PG count per OSD
Orchestration
ceph orch host ls
ceph orch host add <name> <ip>
ceph orch ps [--daemon-type osd|mon|mgr|rgw]
ceph orch ls
ceph orch device ls [--refresh]
ceph orch apply osd --all-available-devices
Pools
ceph osd pool create <name> <pg_num>
ceph osd pool ls
ceph osd pool get <name> size
ceph osd pool get <name> min_size
ceph osd pool application enable <name> <rbd|rgw|cephfs|rados>
Objects (rados / placement)
rados -p <pool> put <obj> <file>
rados -p <pool> ls
ceph osd map <pool> <obj> # shows PG and OSD placement for an object
RBD
rbd pool init <pool>
rbd create <pool>/<image> --size <MB>
rbd info <pool>/<image>
rbd map <pool>/<image>
rbd showmapped
rbd du <pool>/<image>
RGW / S3 admin
ceph orch apply rgw <name> --placement="<host>" --port=<port>
radosgw-admin user create --uid=<uid> --display-name="<name>"
radosgw-admin user info --uid=<uid>
radosgw-admin key create --uid=<uid> --key-type=s3 --gen-access-key --gen-secret
13. Troubleshooting Notes
403 Forbidden pulling quay.io/ceph/ceph Registry unreachable. Use a mirror via --image quay.m.daocloud.io/ceph/ceph:<tag> at bootstrap and set mgr/cephadm/container_image_base to the mirror.
ssh-copy-id / cephadm cannot SSH to nodes (Ubuntu 24.04) The RSA (SHA-1) cluster key is rejected. Add PubkeyAcceptedAlgorithms +ssh-rsa,... under /etc/ssh/sshd_config.d/ on the target nodes and restart ssh.
HEALTH_WARN: OSD count 0 < osd_pool_default_size 3 No OSDs yet. Expected before ceph orch apply osd.
HEALTH_WARN: pool(s) do not have an application enabled Run ceph osd pool application enable <pool> <rados|rbd|rgw|cephfs>.
MON_CLOCK_SKEW: clock skew detected Time drift between MONs. Re-sync (systemctl restart systemd-timesyncd) and, if needed, restart the MON (ceph orch daemon restart mon.<host>). Clears after the next health check.
RBD not present after reboot map/mount are not persistent by default. Re-run rbd map + mount, or configure /etc/ceph/rbdmap and /etc/fstab.
Dashboard/RGW not reachable from a laptop The 192.168.122.x addresses are private to the cluster network. Access from a host on that network, or use an SSH tunnel.
Summary
Starting from three bare Ubuntu nodes, this guide built a fault-tolerant Ceph cluster (3-MON quorum, active/standby MGR, 3 OSDs), exposed both object (RGW/S3) and block (RBD) storage, and connected a containerized application that writes through both. Regardless of the interface used, all data converges to the same RADOS layer, is placed by CRUSH across three failure domains, and is stored as three replicas — delivering scalability, durability, and no single point of failure.
