<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Amir Kolahi]]></title><description><![CDATA[Amir Kolahi]]></description><link>https://amirkolahi.ir</link><generator>RSS for Node</generator><lastBuildDate>Sun, 06 Sep 2026 09:25:04 GMT</lastBuildDate><atom:link href="https://amirkolahi.ir/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Ceph Storage Cluster — From Concepts to a Working Application]]></title><description><![CDATA[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.
Enviro]]></description><link>https://amirkolahi.ir/ceph-storage-cluster-from-concepts-to-a-working-application</link><guid isPermaLink="true">https://amirkolahi.ir/ceph-storage-cluster-from-concepts-to-a-working-application</guid><category><![CDATA[ceph]]></category><category><![CDATA[cluster]]></category><category><![CDATA[storage]]></category><dc:creator><![CDATA[Amir Kolahi]]></dc:creator><pubDate>Thu, 02 Jul 2026 12:38:44 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/68e5fa90376eba109742f803/1b396e9a-b333-4021-8050-056bb22687c1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A complete, hands-on reference covering Ceph architecture, a 3-node cluster deployment with <code>cephadm</code>, object (RGW/S3) and block (RBD) storage, and a containerized application that consumes both.</p>
<p><strong>Environment used in this guide</strong></p>
<ul>
<li><p>3 nodes: <code>ceph1</code> (192.168.122.222), <code>ceph2</code> (192.168.122.121), <code>ceph3</code> (192.168.122.89)</p>
</li>
<li><p>OS: Ubuntu 24.04 LTS</p>
</li>
<li><p>Container runtime: Docker</p>
</li>
<li><p>Ceph release: Squid (19.2.3), deployed via <code>cephadm</code></p>
</li>
<li><p>Each node has one raw disk (<code>/dev/vdb</code>, 15 GiB) dedicated to an OSD</p>
</li>
</ul>
<hr />
<h2>Table of Contents</h2>
<ol>
<li><p><a href="#1-core-concepts">Core Concepts</a></p>
</li>
<li><p><a href="#2-cluster-architecture">Cluster Architecture</a></p>
</li>
<li><p><a href="#3-the-data-path-pool-pg-crush">The Data Path: Pool, PG, CRUSH</a></p>
</li>
<li><p><a href="#4-data-protection">Data Protection</a></p>
</li>
<li><p><a href="#5-cluster-deployment">Cluster Deployment</a></p>
</li>
<li><p><a href="#6-object-storage-rgw--s3">Object Storage (RGW / S3)</a></p>
</li>
<li><p><a href="#7-block-storage-rbd">Block Storage (RBD)</a></p>
</li>
<li><p><a href="#8-the-demo-application">The Demo Application</a></p>
</li>
<li><p><a href="#9-storage-write-flow">Storage Write Flow</a></p>
</li>
<li><p><a href="#10-application-source-code">Application Source Code</a></p>
</li>
<li><p><a href="#11-code-path-trace-request--persisted">Code Path Trace (request → persisted)</a></p>
</li>
<li><p><a href="#12-command-reference">Command Reference</a></p>
</li>
<li><p><a href="#13-troubleshooting-notes">Troubleshooting Notes</a></p>
</li>
</ol>
<hr />
<h2>1. Core Concepts</h2>
<p>Ceph is a distributed, decentralized storage system that solves the three inherent limitations of a single disk:</p>
<ul>
<li><p><strong>Capacity</strong> — a single disk has a ceiling; Ceph distributes data across many disks.</p>
</li>
<li><p><strong>Durability</strong> — disks fail; Ceph keeps multiple copies on independent disks.</p>
</li>
<li><p><strong>Throughput</strong> — a single disk serializes I/O; Ceph parallelizes across disks.</p>
</li>
</ul>
<p>Unlike traditional SAN/NAS systems, Ceph has <strong>no central controller</strong>. There is no single point of failure and no central bottleneck. This is achieved through RADOS and the CRUSH algorithm (below).</p>
<h3>RADOS</h3>
<p>At its lowest layer, Ceph stores everything as <strong>objects</strong> (a flat namespace of uniquely-named binary blobs with metadata). This layer is called <strong>RADOS</strong> (<em>Reliable Autonomic Distributed Object Store</em>). 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.</p>
<hr />
<h2>2. Cluster Architecture</h2>
<p>A Ceph cluster is composed of daemons, each with a distinct role:</p>
<table>
<thead>
<tr>
<th>Daemon</th>
<th>Process</th>
<th>Role</th>
<th>Recommended count</th>
</tr>
</thead>
<tbody><tr>
<td>Monitor</td>
<td><code>ceph-mon</code></td>
<td>Holds cluster maps (source of truth), authentication. Requires quorum.</td>
<td>3 (odd)</td>
</tr>
<tr>
<td>Manager</td>
<td><code>ceph-mgr</code></td>
<td>Metrics, dashboard, orchestration. Active/standby.</td>
<td>2</td>
</tr>
<tr>
<td>OSD</td>
<td><code>ceph-osd</code></td>
<td>Stores object data; handles replication, recovery, rebalancing.</td>
<td>3+ (one per disk)</td>
</tr>
<tr>
<td>RGW</td>
<td><code>ceph-radosgw</code></td>
<td>S3/Swift-compatible object gateway. Optional.</td>
<td>as needed</td>
</tr>
<tr>
<td>MDS</td>
<td><code>ceph-mds</code></td>
<td>CephFS metadata. Optional.</td>
<td>as needed</td>
</tr>
</tbody></table>
<h3>Quorum (why MONs are odd-numbered)</h3>
<p>Monitors must reach a <strong>majority (quorum)</strong> 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 <strong>odd number</strong> is always used.</p>
<h3>MON vs MGR redundancy</h3>
<ul>
<li><p><strong>MONs</strong> run all-active because they must <em>vote</em> to agree (quorum).</p>
</li>
<li><p><strong>MGRs</strong> run active/standby because the role is single-instance; the standby takes over on failure. Redundancy here is for <em>failover</em>, not <em>consensus</em>.</p>
</li>
</ul>
<p>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.</p>
<hr />
<h2>3. The Data Path: Pool, PG, CRUSH</h2>
<p>Ceph does <strong>not</strong> maintain a central lookup table of object locations. Instead it <strong>computes</strong> locations. This makes the cluster infinitely scalable (no central bottleneck) and self-adjusting.</p>
<p>The write path has two computed stages:</p>
<pre><code class="language-plaintext">object name --(hash mod pg_num)--&gt; PG --(CRUSH + cluster map)--&gt; [OSD, OSD, OSD]
</code></pre>
<ol>
<li><p><strong>Object → PG:</strong> the object name is hashed and taken modulo <code>pg_num</code> to 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.</p>
</li>
<li><p><strong>PG → OSDs:</strong> 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 <strong>PGs, not individual objects</strong>.</p>
</li>
</ol>
<h3>CRUSH and failure domains</h3>
<p>Because the CRUSH map encodes physical topology (which OSD is in which host, rack, row), CRUSH can place replicas across <strong>distinct failure domains</strong>. Example: with <code>host</code> 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.</p>
<h3>Primary OSD</h3>
<p>The first OSD in the CRUSH output is the <strong>Primary</strong>. 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.</p>
<hr />
<h2>4. Data Protection</h2>
<p>Protection is configured <strong>per pool</strong>, not cluster-wide. Two methods:</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Mechanism</th>
<th>Space efficiency</th>
<th>Recovery cost</th>
<th>Use case</th>
</tr>
</thead>
<tbody><tr>
<td>Replication</td>
<td>N full copies (<code>size</code>, default 3)</td>
<td>Low (~33% at size 3)</td>
<td>Low (plain copy)</td>
<td>Hot data, latency-sensitive</td>
</tr>
<tr>
<td>Erasure Coding</td>
<td>k data + m parity chunks</td>
<td>High (e.g. ~67% at k=4,m=2)</td>
<td>High (math rebuild)</td>
<td>Cold/archival, large volumes</td>
</tr>
</tbody></table>
<p>Key replication parameters:</p>
<ul>
<li><p><code>size</code> — number of replicas (default 3).</p>
</li>
<li><p><code>min_size</code> — minimum replicas required to continue serving writes (default 2). Below this, the pool blocks writes to protect data integrity.</p>
</li>
</ul>
<hr />
<h2>5. Cluster Deployment</h2>
<p>The general deployment pattern is: <strong>bootstrap one node → add remaining nodes → add OSDs</strong>. This holds for a 3-node lab or a 100-node cluster.</p>
<h3>5.1 Install cephadm (on ceph1)</h3>
<pre><code class="language-bash">apt update
apt install -y cephadm
</code></pre>
<h3>5.2 Bootstrap the cluster (on ceph1)</h3>
<p>This creates the first MON and MGR. Note the <code>--image</code> override pointing to a mirror — the default <code>quay.io</code> registry may be unreachable in some networks.</p>
<pre><code class="language-bash">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
</code></pre>
<p>Bootstrap output includes the dashboard URL, admin user, and a one-time password. Save these. All daemons run as Docker containers.</p>
<p>Set the mirror as the default base image so newly added nodes pull from it too:</p>
<pre><code class="language-bash">ceph config set mgr mgr/cephadm/container_image_base quay.m.daocloud.io/ceph/ceph
</code></pre>
<h3>5.3 Install the ceph CLI on the host (on ceph1)</h3>
<pre><code class="language-bash">cephadm add-repo --release squid
cephadm install ceph-common
ceph -s   # cluster status; expect HEALTH_WARN until OSDs exist
</code></pre>
<h3>5.4 Add the other nodes</h3>
<p>cephadm manages nodes over SSH using its own key (<code>/etc/ceph/ceph.pub</code>).</p>
<p>Distribute the key (enter each node's root password when prompted):</p>
<pre><code class="language-bash">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
</code></pre>
<blockquote>
<p><strong>Ubuntu 24.04 note:</strong> the cluster SSH key is RSA (SHA-1), which recent OpenSSH rejects by default. On each new node, allow RSA-SHA2:</p>
<pre><code class="language-bash">echo "PubkeyAcceptedAlgorithms +ssh-rsa,rsa-sha2-256,rsa-sha2-512" \
  &gt; /etc/ssh/sshd_config.d/99-ceph-rsa.conf
systemctl restart ssh
</code></pre>
</blockquote>
<p>Add the hosts:</p>
<pre><code class="language-bash">ceph orch host add ceph2 192.168.122.121
ceph orch host add ceph3 192.168.122.89
ceph orch host ls
</code></pre>
<p>cephadm automatically distributes MONs to reach a 3-node quorum and adds a standby MGR.</p>
<h3>5.5 (Optional) Remove the monitoring stack</h3>
<p>For a lab, the Prometheus/Grafana/Alertmanager/node-exporter stack is optional and its images may be blocked. Removing it keeps <code>HEALTH</code> clean and the nodes light:</p>
<pre><code class="language-bash">ceph orch rm grafana
ceph orch rm prometheus
ceph orch rm alertmanager
ceph orch rm node-exporter
</code></pre>
<h3>5.6 Create OSDs</h3>
<p>Convert every available raw disk into an OSD:</p>
<pre><code class="language-bash">ceph orch apply osd --all-available-devices
</code></pre>
<p>This also sets a standing policy: any future raw disk is auto-consumed as an OSD. After the OSDs come up, the cluster reaches <code>HEALTH_OK</code>:</p>
<pre><code class="language-bash">ceph -s
ceph osd tree     # shows root -&gt; host -&gt; osd topology (the CRUSH map)
</code></pre>
<p>Expected state: <code>3 osds: 3 up, 3 in</code>, <code>mon: 3 daemons (quorum)</code>, <code>mgr: active + standby</code>.</p>
<hr />
<h2>6. Object Storage (RGW / S3)</h2>
<h3>6.1 Deploy the RGW daemon (on ceph1)</h3>
<p>RGW is a Ceph daemon; it runs <strong>on the cluster nodes</strong>, not on the application host. Applications are remote S3 clients.</p>
<pre><code class="language-bash">ceph orch apply rgw myrgw --placement="ceph1" --port=8000
ceph orch ps --daemon-type rgw   # wait for STATUS = running
</code></pre>
<p>RGW auto-creates several internal pools (<code>.rgw.root</code>, <code>default.rgw.meta</code>, <code>default.rgw.log</code>, <code>default.rgw.buckets.data</code>, etc.). This is expected.</p>
<h3>6.2 Create an S3 user (on ceph1)</h3>
<p>The user is a <strong>machine identity</strong> (access key + secret key), analogous to AWS IAM credentials. Create one per application; use meaningful UIDs.</p>
<pre><code class="language-bash">radosgw-admin user create --uid=myapp --display-name="My App"
</code></pre>
<p>Record the <code>access_key</code> and <code>secret_key</code> from the JSON output. The user's <code>op_mask</code> of <code>read, write, delete</code> already permits normal bucket/object CRUD.</p>
<h3>6.3 Client model</h3>
<ul>
<li><p><strong>User/bucket ownership:</strong> keys belong to the <em>user</em>, not a bucket. A user can own many buckets.</p>
</li>
<li><p><strong>Who does what:</strong> admin creates the user (server side, <code>radosgw-admin</code>); the application creates buckets and reads/writes objects (client side, S3 protocol).</p>
</li>
</ul>
<hr />
<h2>7. Block Storage (RBD)</h2>
<p>RBD exposes a virtual block device. Unlike RGW, RBD needs <strong>no gateway daemon</strong> — the client (kernel driver) talks directly to the OSDs after computing placement via CRUSH.</p>
<h3>7.1 Create pool and image (on ceph1)</h3>
<pre><code class="language-bash">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
</code></pre>
<p><code>rbd info</code> shows the image is split into 256 × 4 MiB objects. These objects are thin-provisioned — only written when data actually lands.</p>
<h3>7.2 Map, format, mount</h3>
<pre><code class="language-bash">rbd map rbd-pool/myimage      # -&gt; /dev/rbd0
mkfs.ext4 /dev/rbd0
mkdir -p /mnt/myrbd
mount /dev/rbd0 /mnt/myrbd
</code></pre>
<p>The OS now treats <code>/dev/rbd0</code> as an ordinary disk. Any write is transparently split into RADOS objects and replicated across the three nodes.</p>
<blockquote>
<p><strong>Note:</strong> <code>map</code>/<code>mount</code> are not persistent across reboots by default. For persistence, configure <code>/etc/ceph/rbdmap</code> and <code>/etc/fstab</code>.</p>
</blockquote>
<h3>7.3 Verify replication</h3>
<p>Every RBD data object is replicated exactly like a <code>rados</code>-written object:</p>
<pre><code class="language-bash">rados -p rbd-pool ls | head
ceph osd map rbd-pool &lt;object-name&gt;
# -&gt; up ([1,2,0], p1)  = three OSDs on three hosts, Primary = osd.1
</code></pre>
<hr />
<h2>8. The Demo Application</h2>
<p>A Flask app (in Docker) that stores uploads via <strong>both</strong> paths, side by side, to make the difference concrete.</p>
<h3>Two paths compared</h3>
<table>
<thead>
<tr>
<th></th>
<th>S3 / RGW path</th>
<th>Disk / RBD path</th>
</tr>
</thead>
<tbody><tr>
<td>How the app connects</td>
<td>in <strong>code</strong> (boto3)</td>
<td>via a <strong>mounted</strong> folder</td>
</tr>
<tr>
<td>App aware of Ceph?</td>
<td>yes (endpoint + keys)</td>
<td>no (just a folder)</td>
</tr>
<tr>
<td>Gateway daemon</td>
<td>RGW (<code>radosgw</code>)</td>
<td>none</td>
</tr>
<tr>
<td>Best for</td>
<td>files, objects, backups, sharing</td>
<td>databases, app disks, volumes</td>
</tr>
</tbody></table>
<p>Key insight: in the RBD path the app has <strong>no Ceph-specific code</strong> — it writes to <code>/data</code> like any folder. The Docker volume mount <code>-/mnt/myrbd:/data</code> is what places that folder on Ceph. RBD attaches at the <em>system</em> level; S3 attaches at the <em>code</em> level.</p>
<h3>Configuration (environment variables)</h3>
<p>Credentials are injected at runtime, never hardcoded:</p>
<pre><code class="language-yaml">environment:
  S3_ENDPOINT: "http://192.168.122.222:8000"
  S3_ACCESS_KEY: "&lt;access_key&gt;"
  S3_SECRET_KEY: "&lt;secret_key&gt;"
  S3_BUCKET: "myapp-bucket"
  DISK_PATH: "/data"
volumes:
  - /mnt/myrbd:/data
</code></pre>
<h3>Run</h3>
<pre><code class="language-bash">cd ceph-storage-app
docker compose up --build -d
docker compose ps
# open http://192.168.122.222:5000
</code></pre>
<h3>Verify (from the cluster)</h3>
<pre><code class="language-bash"># S3 objects landed in RADOS:
rados -p default.rgw.buckets.data ls
# RBD files landed on the mounted device:
ls -lh /mnt/myrbd
</code></pre>
<hr />
<h2>9. Storage Write Flow</h2>
<h3>S3 path (upload → persisted)</h3>
<ol>
<li><p><strong>Browser</strong> — user submits the upload form (HTTP to app on :5000).</p>
</li>
<li><p><strong>Flask app</strong> — receives the file; boto3 wraps it in the S3 protocol with the access key and sends it to RGW.</p>
</li>
<li><p><strong>RGW (:8000)</strong> — authenticates the key, converts the file into RADOS object(s).</p>
</li>
<li><p><strong>Placement computed</strong> — object name is hashed to a PG; CRUSH maps the PG to three OSDs. No central lookup.</p>
</li>
<li><p><strong>Primary OSD</strong> — writes replica 1.</p>
</li>
<li><p><strong>Replication</strong> — the Primary copies to the two Secondary OSDs (because the pool has <code>size=3</code>), on three distinct hosts.</p>
</li>
<li><p><strong>Ack</strong> — the Primary waits until all three replicas are persisted, then acknowledges.</p>
</li>
<li><p><strong>Success</strong> — the ack propagates back through RGW → app → browser.</p>
</li>
</ol>
<h3>RBD path (difference)</h3>
<p>The RBD path <strong>skips steps 1–3</strong> (no browser round-trip semantics, no RGW, no boto3). The app writes to <code>/data</code>; the RBD kernel driver computes placement (step 4) directly and writes to the OSDs. <strong>From step 4 onward, both paths are identical</strong> — same hash, same CRUSH, same 3-replica placement. This is the practical proof that "everything is RADOS underneath."</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>S3 path</th>
<th>RBD path</th>
</tr>
</thead>
<tbody><tr>
<td>App write mechanism</td>
<td>boto3 code</td>
<td>ordinary file write</td>
</tr>
<tr>
<td>Gateway daemon</td>
<td>RGW</td>
<td>none</td>
</tr>
<tr>
<td>Hash → PG → CRUSH → 3 replicas</td>
<td>identical</td>
<td>identical</td>
</tr>
</tbody></table>
<hr />
<h2>10. Application Source Code</h2>
<p>Complete, runnable source of the demo app. Directory layout:</p>
<pre><code class="language-plaintext">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
</code></pre>
<h3>10.1 <code>app.py</code></h3>
<pre><code class="language-python">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/&lt;backend&gt;/&lt;path:name&gt;")
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)
</code></pre>
<h3>10.2 <code>templates/index.html</code></h3>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
  &lt;meta charset="utf-8"&gt;
  &lt;meta name="viewport" content="width=device-width, initial-scale=1"&gt;
  &lt;title&gt;Ceph Storage App&lt;/title&gt;
  &lt;style&gt;
    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; }
  &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
  &lt;h1&gt;Ceph Storage App&lt;/h1&gt;
  {% with messages = get_flashed_messages() %}
    {% if messages %}{% for m in messages %}&lt;div class="flash"&gt;{{ m }}&lt;/div&gt;{% endfor %}{% endif %}
  {% endwith %}

  &lt;div class="upload-box"&gt;
    &lt;form action="/upload" method="post" enctype="multipart/form-data"&gt;
      &lt;input type="file" name="file" required&gt;
      &lt;div&gt;
        &lt;label&gt;&lt;input type="radio" name="backend" value="s3" checked&gt; Store in S3 (RGW)&lt;/label&gt;
        &lt;label&gt;&lt;input type="radio" name="backend" value="disk"&gt; Store on disk (RBD)&lt;/label&gt;
      &lt;/div&gt;
      &lt;button type="submit"&gt;Upload&lt;/button&gt;
    &lt;/form&gt;
  &lt;/div&gt;

  &lt;div class="cols"&gt;
    &lt;div class="col"&gt;
      &lt;h2&gt;S3 / RGW&lt;/h2&gt;
      &lt;div&gt;bucket: {{ s3_bucket }} — endpoint: {{ s3_endpoint }}&lt;/div&gt;
      {% if s3_error %}&lt;div&gt;error: {{ s3_error }}&lt;/div&gt;{% endif %}
      &lt;table&gt;
        &lt;tr&gt;&lt;th&gt;name&lt;/th&gt;&lt;th&gt;size&lt;/th&gt;&lt;th&gt;modified&lt;/th&gt;&lt;th&gt;&lt;/th&gt;&lt;/tr&gt;
        {% for f in s3_files %}
        &lt;tr&gt;&lt;td&gt;{{ f.name }}&lt;/td&gt;&lt;td&gt;{{ f.size }} B&lt;/td&gt;&lt;td&gt;{{ f.modified }}&lt;/td&gt;
        &lt;td&gt;&lt;a href="/download/s3/{{ f.name }}"&gt;download&lt;/a&gt;&lt;/td&gt;&lt;/tr&gt;
        {% endfor %}
      &lt;/table&gt;
    &lt;/div&gt;
    &lt;div class="col"&gt;
      &lt;h2&gt;Disk / RBD&lt;/h2&gt;
      &lt;div&gt;mount: {{ disk_path }}&lt;/div&gt;
      {% if disk_error %}&lt;div&gt;error: {{ disk_error }}&lt;/div&gt;{% endif %}
      &lt;table&gt;
        &lt;tr&gt;&lt;th&gt;name&lt;/th&gt;&lt;th&gt;size&lt;/th&gt;&lt;th&gt;modified&lt;/th&gt;&lt;th&gt;&lt;/th&gt;&lt;/tr&gt;
        {% for f in disk_files %}
        &lt;tr&gt;&lt;td&gt;{{ f.name }}&lt;/td&gt;&lt;td&gt;{{ f.size }} B&lt;/td&gt;&lt;td&gt;{{ f.modified }}&lt;/td&gt;
        &lt;td&gt;&lt;a href="/download/disk/{{ f.name }}"&gt;download&lt;/a&gt;&lt;/td&gt;&lt;/tr&gt;
        {% endfor %}
      &lt;/table&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<h3>10.3 <code>requirements.txt</code></h3>
<pre><code class="language-plaintext">flask==3.0.3
boto3==1.34.144
</code></pre>
<h3>10.4 <code>Dockerfile</code></h3>
<pre><code class="language-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"]
</code></pre>
<h3>10.5 <code>docker-compose.yml</code></h3>
<pre><code class="language-yaml">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: "&lt;access_key&gt;"
      S3_SECRET_KEY: "&lt;secret_key&gt;"
      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
</code></pre>
<hr />
<h2>11. Code Path Trace (request → persisted)</h2>
<p>This section follows the <strong>exact code</strong> executed for each storage path, from the HTTP request to the bytes landing on OSDs.</p>
<h3>11.1 S3 path — line by line</h3>
<p><strong>Trigger:</strong> browser POSTs the form to <code>/upload</code> with <code>backend=s3</code>.</p>
<ol>
<li><p><code>upload()</code> <strong>handler runs</strong> (<code>app.py</code>).</p>
<ul>
<li><p><code>backend = request.form.get("backend")</code> → <code>"s3"</code>.</p>
</li>
<li><p><code>f = request.files.get("file")</code> → the uploaded file object (in memory).</p>
</li>
<li><p>Empty-file guard: if no file, flash a message and redirect.</p>
</li>
</ul>
</li>
<li><p><code>ensure_bucket()</code> is called.</p>
<ul>
<li><p>Builds a client via <code>get_s3_client()</code>.</p>
</li>
<li><p><code>s3.list_buckets()</code> → RGW returns existing buckets.</p>
</li>
<li><p>If <code>myapp-bucket</code> is missing, <code>s3.create_bucket(...)</code> → RGW creates it (this itself becomes RADOS metadata objects in the RGW pools).</p>
</li>
</ul>
</li>
<li><p><code>get_s3_client()</code> constructs the boto3 client.</p>
<ul>
<li><p><code>endpoint_url=S3_ENDPOINT</code> → points boto3 at RGW (<code>http://…:8000</code>), not AWS.</p>
</li>
<li><p><code>aws_access_key_id</code> / <code>aws_secret_access_key</code> → the RGW user's keys.</p>
</li>
<li><p><code>signature_version="s3v4"</code> → request signing scheme RGW expects.</p>
</li>
</ul>
</li>
<li><p><code>s3.upload_fileobj(f, S3_BUCKET, f.filename)</code> — the actual write.</p>
<ul>
<li><p>boto3 streams the file body to RGW as an S3 <code>PUT</code> (multipart if large), signing the request with the secret key.</p>
</li>
<li><p><strong>Leaves the app process here.</strong> Everything below is server-side Ceph.</p>
</li>
</ul>
</li>
<li><p><strong>RGW (</strong><code>radosgw</code> <strong>on :8000)</strong> receives the PUT.</p>
<ul>
<li><p>Verifies the signature/credentials (authentication).</p>
</li>
<li><p>Splits the object into RADOS objects and issues writes to the <code>default.rgw.buckets.data</code> pool.</p>
</li>
</ul>
</li>
<li><p><strong>RADOS placement</strong> (per object).</p>
<ul>
<li><p>Object name → hash mod <code>pg_num</code> → PG.</p>
</li>
<li><p>CRUSH(PG, cluster map) → three OSDs on three distinct hosts.</p>
</li>
</ul>
</li>
<li><p><strong>Replication.</strong></p>
<ul>
<li><p>The write goes to the Primary OSD; the Primary replicates to two Secondaries.</p>
</li>
<li><p>Primary acks only after all three copies are persisted (<code>size=3</code>).</p>
</li>
</ul>
</li>
<li><p><strong>Return trip.</strong></p>
<ul>
<li><p>RGW returns <code>200 OK</code> to boto3 → <code>upload_fileobj</code> returns → the handler runs <code>flash("File '…' stored in S3 (RGW).")</code> and redirects to <code>/</code>.</p>
</li>
<li><p><code>index()</code> then calls <code>s3.list_objects_v2(...)</code>, and the file appears in the S3 column.</p>
</li>
</ul>
</li>
</ol>
<p><strong>Where to observe it on the cluster:</strong></p>
<pre><code class="language-bash">rados -p default.rgw.buckets.data ls
# object name is: &lt;bucket-id&gt;.&lt;shard&gt;_&lt;original-filename&gt;
ceph osd map default.rgw.buckets.data "&lt;object-name&gt;"   # -&gt; up ([x,y,z])
</code></pre>
<h3>11.2 RBD path — line by line</h3>
<p><strong>Trigger:</strong> browser POSTs the form to <code>/upload</code> with <code>backend=disk</code>.</p>
<ol>
<li><p><code>upload()</code> <strong>handler runs.</strong></p>
<ul>
<li><p><code>backend</code> → <code>"disk"</code>.</p>
</li>
<li><p><code>os.makedirs(DISK_PATH, exist_ok=True)</code> ensures <code>/data</code> exists.</p>
</li>
</ul>
</li>
<li><p><code>f.save(os.path.join(DISK_PATH, f.filename))</code> — the actual write.</p>
<ul>
<li><p>This is an ordinary POSIX file write to <code>/data/&lt;filename&gt;</code>.</p>
</li>
<li><p><strong>No boto3, no RGW, no Ceph-specific code in the app.</strong></p>
</li>
</ul>
</li>
<li><p><strong>Docker volume mapping.</strong></p>
<ul>
<li><p><code>/data</code> in the container is bind-mounted to <code>/mnt/myrbd</code> on the host (<code>docker-compose.yml</code>: <code>- /mnt/myrbd:/data</code>).</p>
</li>
<li><p>So the write actually lands on the host's <code>/mnt/myrbd</code>.</p>
</li>
</ul>
</li>
<li><p><strong>ext4 → RBD block device.</strong></p>
<ul>
<li><p><code>/mnt/myrbd</code> is an ext4 filesystem on <code>/dev/rbd0</code>.</p>
</li>
<li><p>The ext4 layer turns the file write into block writes on <code>/dev/rbd0</code>.</p>
</li>
</ul>
</li>
<li><p><strong>RBD kernel driver → RADOS.</strong></p>
<ul>
<li><p>The <code>krbd</code> driver maps block offsets to RADOS objects (<code>rbd_data.&lt;id&gt;.&lt;seq&gt;</code>) in <code>rbd-pool</code>.</p>
</li>
<li><p>For each such object: hash → PG → CRUSH → three OSDs (identical to step 6–7 of the S3 path).</p>
</li>
</ul>
</li>
<li><p><strong>Persisted.</strong></p>
<ul>
<li><p>Once the OSDs ack, the block write completes; ext4 and the app see success.</p>
</li>
<li><p><code>index()</code> lists <code>/data</code> with <code>os.listdir</code>, and the file appears in the disk column.</p>
</li>
</ul>
</li>
</ol>
<p><strong>Where to observe it on the cluster:</strong></p>
<pre><code class="language-bash">ls -lh /mnt/myrbd
rados -p rbd-pool ls | grep rbd_data | head
ceph osd map rbd-pool "&lt;object-name&gt;"   # -&gt; up ([x,y,z])
</code></pre>
<h3>11.3 The convergence point</h3>
<p>Both paths differ only in the <strong>entry gateway</strong> into RADOS:</p>
<ul>
<li><p>S3 path enters through <strong>RGW</strong> (application-level, via boto3 code).</p>
</li>
<li><p>RBD path enters through the <strong>krbd driver</strong> (system-level, via a file write).</p>
</li>
</ul>
<p>From the moment an object reaches RADOS (hash → PG → CRUSH → 3 replicas), the two paths are <strong>byte-for-byte identical</strong> 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.</p>
<hr />
<h2>12. Command Reference</h2>
<h3>Status</h3>
<pre><code class="language-bash">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 -&gt; host -&gt; osd
ceph osd df                  # usage + PG count per OSD
</code></pre>
<h3>Orchestration</h3>
<pre><code class="language-bash">ceph orch host ls
ceph orch host add &lt;name&gt; &lt;ip&gt;
ceph orch ps [--daemon-type osd|mon|mgr|rgw]
ceph orch ls
ceph orch device ls [--refresh]
ceph orch apply osd --all-available-devices
</code></pre>
<h3>Pools</h3>
<pre><code class="language-bash">ceph osd pool create &lt;name&gt; &lt;pg_num&gt;
ceph osd pool ls
ceph osd pool get &lt;name&gt; size
ceph osd pool get &lt;name&gt; min_size
ceph osd pool application enable &lt;name&gt; &lt;rbd|rgw|cephfs|rados&gt;
</code></pre>
<h3>Objects (rados / placement)</h3>
<pre><code class="language-bash">rados -p &lt;pool&gt; put &lt;obj&gt; &lt;file&gt;
rados -p &lt;pool&gt; ls
ceph osd map &lt;pool&gt; &lt;obj&gt;    # shows PG and OSD placement for an object
</code></pre>
<h3>RBD</h3>
<pre><code class="language-bash">rbd pool init &lt;pool&gt;
rbd create &lt;pool&gt;/&lt;image&gt; --size &lt;MB&gt;
rbd info &lt;pool&gt;/&lt;image&gt;
rbd map &lt;pool&gt;/&lt;image&gt;
rbd showmapped
rbd du &lt;pool&gt;/&lt;image&gt;
</code></pre>
<h3>RGW / S3 admin</h3>
<pre><code class="language-bash">ceph orch apply rgw &lt;name&gt; --placement="&lt;host&gt;" --port=&lt;port&gt;
radosgw-admin user create --uid=&lt;uid&gt; --display-name="&lt;name&gt;"
radosgw-admin user info --uid=&lt;uid&gt;
radosgw-admin key create --uid=&lt;uid&gt; --key-type=s3 --gen-access-key --gen-secret
</code></pre>
<hr />
<h2>13. Troubleshooting Notes</h2>
<p><code>403 Forbidden</code> <strong>pulling</strong> <code>quay.io/ceph/ceph</code> Registry unreachable. Use a mirror via <code>--image quay.m.daocloud.io/ceph/ceph:&lt;tag&gt;</code> at bootstrap and set <code>mgr/cephadm/container_image_base</code> to the mirror.</p>
<p><code>ssh-copy-id</code> <strong>/ cephadm cannot SSH to nodes (Ubuntu 24.04)</strong> The RSA (SHA-1) cluster key is rejected. Add <code>PubkeyAcceptedAlgorithms +ssh-rsa,...</code> under <code>/etc/ssh/sshd_config.d/</code> on the target nodes and restart ssh.</p>
<p><code>HEALTH_WARN: OSD count 0 &lt; osd_pool_default_size 3</code> No OSDs yet. Expected before <code>ceph orch apply osd</code>.</p>
<p><code>HEALTH_WARN: pool(s) do not have an application enabled</code> Run <code>ceph osd pool application enable &lt;pool&gt; &lt;rados|rbd|rgw|cephfs&gt;</code>.</p>
<p><code>MON_CLOCK_SKEW: clock skew detected</code> Time drift between MONs. Re-sync (<code>systemctl restart systemd-timesyncd</code>) and, if needed, restart the MON (<code>ceph orch daemon restart mon.&lt;host&gt;</code>). Clears after the next health check.</p>
<p><strong>RBD not present after reboot</strong> <code>map</code>/<code>mount</code> are not persistent by default. Re-run <code>rbd map</code> + <code>mount</code>, or configure <code>/etc/ceph/rbdmap</code> and <code>/etc/fstab</code>.</p>
<p><strong>Dashboard/RGW not reachable from a laptop</strong> The <code>192.168.122.x</code> addresses are private to the cluster network. Access from a host on that network, or use an SSH tunnel.</p>
<hr />
<h2>Summary</h2>
<p>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.</p>
]]></content:encoded></item><item><title><![CDATA[HashiCorp Vault — Learning Guide]]></title><description><![CDATA[A hands-on walkthrough of HashiCorp Vault covering secrets management, dynamic database credentials, and Kubernetes authentication.

Table of Contents

What is Vault?

Phase 1 — Running Vault with Doc]]></description><link>https://amirkolahi.ir/hashicorp-vault-learning-guide</link><guid isPermaLink="true">https://amirkolahi.ir/hashicorp-vault-learning-guide</guid><category><![CDATA[Vault]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[Docker]]></category><category><![CDATA[hashicorp]]></category><dc:creator><![CDATA[Amir Kolahi]]></dc:creator><pubDate>Tue, 30 Jun 2026 10:01:28 GMT</pubDate><content:encoded><![CDATA[<p>A hands-on walkthrough of HashiCorp Vault covering secrets management, dynamic database credentials, and Kubernetes authentication.</p>
<hr />
<h2>Table of Contents</h2>
<ol>
<li><p><a href="#1-what-is-vault">What is Vault?</a></p>
</li>
<li><p><a href="#2-phase-1--running-vault-with-docker">Phase 1 — Running Vault with Docker</a></p>
</li>
<li><p><a href="#3-phase-2--kv-secrets-engine">Phase 2 — KV Secrets Engine</a></p>
</li>
<li><p><a href="#4-phase-3--dynamic-database-secrets">Phase 3 — Dynamic Database Secrets</a></p>
</li>
<li><p><a href="#5-phase-4--kubernetes-authentication">Phase 4 — Kubernetes Authentication</a></p>
</li>
</ol>
<hr />
<h2>1. What is Vault?</h2>
<p>HashiCorp Vault is a secrets management tool that provides a centralized, secure place to store and access sensitive information such as:</p>
<ul>
<li><p>Database passwords</p>
</li>
<li><p>API keys</p>
</li>
<li><p>TLS certificates</p>
</li>
<li><p>SSH private keys</p>
</li>
<li><p>Tokens</p>
</li>
</ul>
<h3>The Problem Vault Solves</h3>
<p>Without Vault, teams typically hardcode secrets in <code>.env</code> files or source code:</p>
<pre><code class="language-bash"># ❌ The wrong way — secret is exposed in code/git
DB_PASSWORD="super_secret_123"
</code></pre>
<p>This leads to:</p>
<ul>
<li><p>Secrets leaking into Git history</p>
</li>
<li><p>No audit trail of who accessed what</p>
</li>
<li><p>Rotating secrets requires updating every service manually</p>
</li>
<li><p>If one secret leaks, there is no way to know who used it or from where</p>
</li>
</ul>
<h3>How Vault Fixes This</h3>
<p>Vault acts as a central secure vault where:</p>
<ul>
<li><p>All secrets are <strong>encrypted at rest</strong></p>
</li>
<li><p>Every access is <strong>audited and logged</strong></p>
</li>
<li><p>Secrets can <strong>expire automatically</strong> (TTL)</p>
</li>
<li><p>Applications get <strong>dynamic, short-lived credentials</strong> instead of static passwords</p>
</li>
<li><p>Access is controlled via <strong>policies</strong></p>
</li>
</ul>
<hr />
<h2>2. Phase 1 — Running Vault with Docker</h2>
<h3>Why Docker?</h3>
<p>We use Docker to run Vault locally for learning purposes. Vault runs in <strong>dev mode</strong>, which means:</p>
<ul>
<li><p>Everything is stored in memory (no persistence)</p>
</li>
<li><p>Auto-unsealed on startup</p>
</li>
<li><p>NOT suitable for production</p>
</li>
</ul>
<h3>Create a Docker Network</h3>
<p>Before starting any containers, we need a shared network so Vault and the database can communicate with each other by name instead of IP address.</p>
<pre><code class="language-bash">docker network create vault-net
</code></pre>
<p>Think of this like connecting two computers to the same switch — once on the same network, containers can reach each other by their container name (e.g., <code>vault-dev</code>, <code>postgres-dev</code>).</p>
<h3>Start the Vault Container</h3>
<pre><code class="language-bash">docker run --rm -d \
  --name vault-dev \
  --network vault-net \
  -p 8200:8200 \
  -e VAULT_DEV_ROOT_TOKEN_ID=myroot \
  hashicorp/vault:latest \
  vault server -dev -dev-listen-address="0.0.0.0:8200"
</code></pre>
<p><strong>What each flag does:</strong></p>
<table>
<thead>
<tr>
<th>Flag</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>--rm</code></td>
<td>Remove container when stopped</td>
</tr>
<tr>
<td><code>-d</code></td>
<td>Run in background (detached)</td>
</tr>
<tr>
<td><code>--name vault-dev</code></td>
<td>Give the container a name</td>
</tr>
<tr>
<td><code>--network vault-net</code></td>
<td>Attach to our shared network</td>
</tr>
<tr>
<td><code>-p 8200:8200</code></td>
<td>Expose port 8200 to the host</td>
</tr>
<tr>
<td><code>VAULT_DEV_ROOT_TOKEN_ID=myroot</code></td>
<td>Set the root token to <code>myroot</code></td>
</tr>
<tr>
<td><code>-dev-listen-address="0.0.0.0:8200"</code></td>
<td>Listen on all interfaces (required inside Docker)</td>
</tr>
</tbody></table>
<h3>Connect to Vault</h3>
<p>Enter the container and set environment variables:</p>
<pre><code class="language-bash">docker exec -it vault-dev sh

export VAULT_ADDR='http://127.0.0.1:8200'
export VAULT_TOKEN='myroot'
</code></pre>
<p>Verify Vault is running:</p>
<pre><code class="language-bash">vault status
</code></pre>
<p>Expected output:</p>
<pre><code class="language-plaintext">Seal Type       shamir
Initialized     true
Sealed          false      ← This means Vault is open and ready
Storage Type    inmem
Version         2.0.2
</code></pre>
<h3>Understanding Seal / Unseal</h3>
<p>When Vault starts, it is <strong>Sealed</strong> by default — no one can access secrets until it is unlocked.</p>
<p>Vault uses <strong>Shamir's Secret Sharing</strong> to split the master key into multiple pieces:</p>
<ul>
<li><p>Example: split into 5 pieces, need any 3 to unseal</p>
</li>
<li><p>Each key holder has one piece — no single person can unlock Vault alone</p>
</li>
</ul>
<p>In dev mode, Vault <strong>auto-unseals</strong> itself. In production, this is done manually or via auto-unseal (AWS KMS, etc.).</p>
<hr />
<h2>3. Phase 2 — KV Secrets Engine</h2>
<h3>What is KV?</h3>
<p>KV (Key-Value) is the simplest secrets engine in Vault. It works like a secure dictionary:</p>
<pre><code class="language-plaintext">Key   → Value
path  → secret data
</code></pre>
<p>Vault uses <strong>KV Version 2</strong> by default, which adds versioning — every update to a secret creates a new version, and old versions are preserved.</p>
<h3>Write a Secret</h3>
<pre><code class="language-bash">vault kv put secret/myapp db_password="super_secret_123" api_key="abc-xyz-789"
</code></pre>
<p>Output:</p>
<pre><code class="language-plaintext">== Secret Path ==
secret/data/myapp     ← Note the /data/ prefix — this is KV v2

======= Metadata =======
version    1           ← First version
</code></pre>
<h3>Read a Secret</h3>
<pre><code class="language-bash">vault kv get secret/myapp
</code></pre>
<h3>Understanding the <code>/data/</code> Path</h3>
<p>In KV v2, Vault automatically adds a <code>/data/</code> layer to every path:</p>
<pre><code class="language-plaintext">What you write:   secret/myapp
Where it lives:   secret/data/myapp       ← actual data
                  secret/metadata/myapp   ← version history
</code></pre>
<p>This is important when using the API directly:</p>
<pre><code class="language-bash"># API path always includes /data/
curl \
  --header "X-Vault-Token: myroot" \
  http://127.0.0.1:8200/v1/secret/data/myapp
</code></pre>
<h3>Versioning</h3>
<p>Every time you update a secret, a new version is created:</p>
<pre><code class="language-bash"># Update the secret — creates version 2
vault kv put secret/myapp db_password="new_password_456" api_key="abc-xyz-789"

# Read current version (v2)
vault kv get secret/myapp

# Read a specific old version
vault kv get -version=1 secret/myapp
</code></pre>
<h3>Delete vs Destroy vs Rollback</h3>
<table>
<thead>
<tr>
<th>Command</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>vault kv delete -versions=2 secret/myapp</code></td>
<td><strong>Soft delete</strong> — marks version as deleted, data still exists</td>
</tr>
<tr>
<td><code>vault kv destroy -versions=2 secret/myapp</code></td>
<td><strong>Hard delete</strong> — permanently removes the data</td>
</tr>
<tr>
<td><code>vault kv rollback -version=1 secret/myapp</code></td>
<td>Creates a new version with old data (e.g., version 3 = copy of version 1)</td>
</tr>
</tbody></table>
<blockquote>
<p>⚠️ Vault never modifies history. Rollback always creates a <strong>new</strong> version.</p>
</blockquote>
<h3>View All Version Metadata</h3>
<pre><code class="language-bash">vault kv metadata get secret/myapp
</code></pre>
<hr />
<h2>4. Phase 3 — Dynamic Database Secrets</h2>
<h3>The Problem with Static Credentials</h3>
<p>Traditional approach:</p>
<pre><code class="language-plaintext">DB_PASSWORD=super_secret_123  ← same password, forever, for everyone
</code></pre>
<p>Problems:</p>
<ul>
<li><p>If it leaks, it works forever</p>
</li>
<li><p>Can't tell which service used the password</p>
</li>
<li><p>Rotating means updating every service</p>
</li>
</ul>
<h3>How Dynamic Secrets Work</h3>
<p>Vault generates a <strong>unique, temporary credential</strong> every time an application asks for one:</p>
<pre><code class="language-plaintext">App A asks Vault → Vault creates user v-token-abc / pass xyz123 → expires in 1h
App B asks Vault → Vault creates user v-token-def / pass abc456 → expires in 1h
</code></pre>
<p>If App B is compromised, you revoke only its credential. App A is unaffected.</p>
<h3>Start PostgreSQL</h3>
<pre><code class="language-bash">docker run -d \
  --name postgres-dev \
  --network vault-net \
  -e POSTGRES_USER=root \
  -e POSTGRES_PASSWORD=rootpassword \
  -e POSTGRES_DB=mydb \
  postgres:15
</code></pre>
<p>Both <code>vault-dev</code> and <code>postgres-dev</code> are on <code>vault-net</code>, so Vault can reach Postgres at <code>postgres-dev:5432</code>.</p>
<h3>Enable the Database Secrets Engine</h3>
<pre><code class="language-bash">vault secrets enable database
</code></pre>
<p>This activates a Vault plugin that knows how to talk to databases and manage credentials.</p>
<h3>Configure the Database Connection</h3>
<pre><code class="language-bash">vault write database/config/mypostgres \
  plugin_name=postgresql-database-plugin \
  allowed_roles="my-role" \
  connection_url="postgresql://{{username}}:{{password}}@postgres-dev:5432/mydb?sslmode=disable" \
  username="root" \
  password="rootpassword"
</code></pre>
<p><strong>What each field means:</strong></p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>plugin_name</code></td>
<td>Which database plugin to use</td>
</tr>
<tr>
<td><code>allowed_roles</code></td>
<td>Which roles are permitted to use this connection</td>
</tr>
<tr>
<td><code>connection_url</code></td>
<td>How to connect — <code>{{username}}</code> and <code>{{password}}</code> are filled in by Vault</td>
</tr>
<tr>
<td><code>username</code> / <code>password</code></td>
<td>The admin credentials Vault uses to create/revoke users</td>
</tr>
</tbody></table>
<h3>Create a Role</h3>
<p>A role defines <strong>what kind of user</strong> Vault should create when asked for credentials:</p>
<pre><code class="language-bash">vault write database/roles/my-role \
  db_name=mypostgres \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
  default_ttl="1h" \
  max_ttl="24h"
</code></pre>
<p><strong>What each field means:</strong></p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>db_name</code></td>
<td>Which database connection to use</td>
</tr>
<tr>
<td><code>creation_statements</code></td>
<td>The SQL Vault runs to create the user — <code>{{name}}</code>, <code>{{password}}</code>, <code>{{expiration}}</code> are filled in automatically</td>
</tr>
<tr>
<td><code>default_ttl</code></td>
<td>Credential expires after 1 hour</td>
</tr>
<tr>
<td><code>max_ttl</code></td>
<td>Even with renewal, credential expires after 24 hours</td>
</tr>
</tbody></table>
<h3>Generate a Dynamic Credential</h3>
<pre><code class="language-bash">vault read database/creds/my-role
</code></pre>
<p>Output:</p>
<pre><code class="language-plaintext">lease_id           database/creds/my-role/spfqnE8B...
lease_duration     1h
password           uSf6vcWg4zJyD-JRMpKp
username           v-token-my-role-dgrQiyYxSs4iJNNeaZB4
</code></pre>
<p>Every time you run this command, Vault:</p>
<ol>
<li><p>Connects to PostgreSQL as the admin user</p>
</li>
<li><p>Runs the <code>creation_statements</code> SQL with a random name and password</p>
</li>
<li><p>Returns the credentials to the caller</p>
</li>
<li><p>Sets a timer — after 1 hour, Vault deletes the user from PostgreSQL automatically</p>
</li>
</ol>
<h3>Verify in PostgreSQL</h3>
<pre><code class="language-bash">docker exec -it postgres-dev psql -U root -d mydb -c "\du"
</code></pre>
<p>You will see the dynamically created user in the list.</p>
<h3>Revoke a Credential Immediately</h3>
<pre><code class="language-bash">vault lease revoke database/creds/my-role/&lt;lease_id&gt;
</code></pre>
<p>The user is deleted from PostgreSQL instantly.</p>
<h3>Multiple Roles for Multiple Teams</h3>
<pre><code class="language-bash"># Backend team — read only
vault write database/roles/backend-role \
  db_name=mypostgres \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
  default_ttl="1h" \
  max_ttl="24h"

# Data team — read and write
vault write database/roles/data-role \
  db_name=mypostgres \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
  default_ttl="4h" \
  max_ttl="48h"
</code></pre>
<hr />
<h2>5. Phase 4 — Kubernetes Authentication</h2>
<h3>The Secret Zero Problem</h3>
<p>To get secrets from Vault, an application needs a token. But where does that token come from? If it lives in a <code>.env</code> file or a ConfigMap, it is itself a secret — and we are back to square one.</p>
<p><strong>Kubernetes Auth Method solves this.</strong> Instead of a pre-shared token, the application proves its identity using its Kubernetes Service Account Token — a credential that Kubernetes itself manages and rotates automatically.</p>
<h3>Concepts</h3>
<h4>Service Account</h4>
<p>Every Pod in Kubernetes has an identity called a <strong>Service Account</strong>. When a Pod starts, Kubernetes automatically mounts a JWT token inside it at:</p>
<pre><code class="language-plaintext">/var/run/secrets/kubernetes.io/serviceaccount/token
</code></pre>
<p>This token contains information about who the Pod is:</p>
<pre><code class="language-json">{
  "namespace": "default",
  "pod": "myapp-test",
  "serviceaccount": "myapp-sa"
}
</code></pre>
<h4>Policy</h4>
<p>A Policy in Vault defines what paths a token is allowed to access and what operations are permitted:</p>
<pre><code class="language-hcl">path "secret/data/myapp" {
  capabilities = ["read"]   # can only read, not write or delete
}
</code></pre>
<p>Available capabilities: <code>create</code>, <code>read</code>, <code>update</code>, <code>delete</code>, <code>list</code>, <code>deny</code></p>
<h4>Kubernetes Role (in Vault)</h4>
<p>A Vault Kubernetes Role links a Kubernetes Service Account to a Vault Policy:</p>
<pre><code class="language-plaintext">myapp-sa (in namespace default) → myapp-policy → can read secret/data/myapp
</code></pre>
<h4>ClusterRoleBinding</h4>
<p>When a Pod presents its Service Account token to Vault, Vault needs to verify it with the Kubernetes API Server. By default, Kubernetes does not allow just anyone to query its API. We use a <code>ClusterRoleBinding</code> to grant Vault's Service Account the <code>system:auth-delegator</code> role — which allows it to call the <strong>TokenReview API</strong> to verify tokens.</p>
<h3>Full Setup</h3>
<h4>Step 1 — Deploy Vault in Kubernetes</h4>
<pre><code class="language-bash">kubectl create namespace vault

kubectl apply -f - &lt;&lt;EOF
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vault
  namespace: vault
spec:
  replicas: 1
  selector:
    matchLabels:
      app: vault
  template:
    metadata:
      labels:
        app: vault
    spec:
      containers:
      - name: vault
        image: hashicorp/vault:latest
        args: ["server", "-dev"]
        env:
        - name: VAULT_DEV_ROOT_TOKEN_ID
          value: "myroot"
        - name: VAULT_DEV_LISTEN_ADDRESS
          value: "0.0.0.0:8200"
        ports:
        - containerPort: 8200
---
apiVersion: v1
kind: Service
metadata:
  name: vault
  namespace: vault
spec:
  selector:
    app: vault
  ports:
  - port: 8200
    targetPort: 8200
EOF
</code></pre>
<p>Vault is now reachable from any Pod inside the cluster at <code>vault.vault.svc:8200</code>.</p>
<h4>Step 2 — Create a Service Account for the App</h4>
<pre><code class="language-bash">kubectl create serviceaccount myapp-sa -n default
</code></pre>
<p>This Service Account will be the identity of our application Pod. It must exist before the Pod is created.</p>
<h4>Step 3 — Configure Vault</h4>
<p>Enter the Vault Pod:</p>
<pre><code class="language-bash">kubectl exec -it -n vault \
  $(kubectl get pod -n vault -l app=vault -o jsonpath='{.items[0].metadata.name}') -- sh

export VAULT_ADDR='http://127.0.0.1:8200'
export VAULT_TOKEN='myroot'
</code></pre>
<p>Put a secret:</p>
<pre><code class="language-bash">vault kv put secret/myapp \
  db_password="super_secret_123" \
  api_key="abc-xyz-789"
</code></pre>
<p>Enable Kubernetes auth:</p>
<pre><code class="language-bash">vault auth enable kubernetes
</code></pre>
<p>Tell Vault where the Kubernetes API Server is:</p>
<pre><code class="language-bash">vault write auth/kubernetes/config \
  kubernetes_host="https://kubernetes.default.svc:443"
</code></pre>
<p><code>kubernetes.default.svc</code> is the internal DNS name for the Kubernetes API Server — it is the same in every cluster.</p>
<p>Create a Policy:</p>
<pre><code class="language-bash">vault policy write myapp-policy - &lt;&lt;EOF
path "secret/data/myapp" {
  capabilities = ["read"]
}
EOF
</code></pre>
<p>Create a Kubernetes Role in Vault:</p>
<pre><code class="language-bash">vault write auth/kubernetes/role/myapp-role \
  bound_service_account_names=myapp-sa \
  bound_service_account_namespaces=default \
  policies=myapp-policy \
  ttl=1h
</code></pre>
<p>This says: <strong>"Any Pod running as</strong> <code>myapp-sa</code> <strong>in the</strong> <code>default</code> <strong>namespace gets a Vault token with</strong> <code>myapp-policy</code> <strong>for 1 hour."</strong></p>
<h4>Step 4 — Grant Vault Permission to Verify Tokens</h4>
<pre><code class="language-bash">kubectl apply -f - &lt;&lt;EOF
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: vault-tokenreview
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:auth-delegator
subjects:
- kind: ServiceAccount
  name: default
  namespace: vault
EOF
</code></pre>
<p>This grants the Vault Pod's Service Account the ability to call the Kubernetes TokenReview API — so Vault can verify whether the token a Pod presents is legitimate.</p>
<h4>Step 5 — Deploy the Application Pod</h4>
<pre><code class="language-bash">kubectl apply -f - &lt;&lt;EOF
apiVersion: v1
kind: Pod
metadata:
  name: myapp-test
  namespace: default
spec:
  serviceAccountName: myapp-sa
  containers:
  - name: myapp
    image: curlimages/curl:latest
    command: ["sleep", "3600"]
EOF
</code></pre>
<h4>Step 6 — Test the Full Flow</h4>
<p>Enter the Pod:</p>
<pre><code class="language-bash">kubectl exec -it myapp-test -- sh
</code></pre>
<p>Read the Service Account token:</p>
<pre><code class="language-bash">JWT=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
</code></pre>
<p>Authenticate with Vault using the Service Account token:</p>
<pre><code class="language-bash">curl -s \
  --request POST \
  --data "{\"jwt\": \"$JWT\", \"role\": \"myapp-role\"}" \
  http://vault.vault.svc:8200/v1/auth/kubernetes/login
</code></pre>
<p>Vault returns a token:</p>
<pre><code class="language-json">{
  "auth": {
    "client_token": "hvs.CAESI...",
    "policies": ["default", "myapp-policy"],
    "lease_duration": 3600
  }
}
</code></pre>
<p>Use the Vault token to read the secret:</p>
<pre><code class="language-bash">VAULT_TOKEN="hvs.CAESI..."

curl -s \
  --header "X-Vault-Token: $VAULT_TOKEN" \
  http://vault.vault.svc:8200/v1/secret/data/myapp
</code></pre>
<p>Output:</p>
<pre><code class="language-json">{
  "data": {
    "data": {
      "api_key": "abc-xyz-789",
      "db_password": "super_secret_123"
    }
  }
}
</code></pre>
<h3>The Complete Flow</h3>
<pre><code class="language-plaintext">Pod starts
  ↓
Kubernetes gives Pod a Service Account token (JWT)
  ↓
Pod sends JWT to Vault: "this is who I am"
  ↓
Vault calls Kubernetes TokenReview API: "is this token valid?"
  ↓  (ClusterRoleBinding makes this possible)
Kubernetes confirms: "yes, this is myapp-sa in namespace default"
  ↓
Vault checks: "is myapp-sa in myapp-role?" → yes
  ↓
Vault issues a token with myapp-policy (read access to secret/myapp)
  ↓
Pod uses Vault token to read secret
  ↓
Pod gets db_password and api_key ✅
</code></pre>
<p><strong>The key insight:</strong> The Pod never had a pre-shared password or token. It authenticated using its Kubernetes identity alone — solving the Secret Zero Problem.</p>
<hr />
<h2>Summary</h2>
<table>
<thead>
<tr>
<th>Phase</th>
<th>What We Learned</th>
</tr>
</thead>
<tbody><tr>
<td>Phase 1</td>
<td>Running Vault in Docker, Seal/Unseal concept</td>
</tr>
<tr>
<td>Phase 2</td>
<td>KV secrets engine, versioning, soft/hard delete</td>
</tr>
<tr>
<td>Phase 3</td>
<td>Dynamic database credentials with TTL and automatic cleanup</td>
</tr>
<tr>
<td>Phase 4</td>
<td>Kubernetes authentication, policies, and the Secret Zero solution</td>
</tr>
</tbody></table>
]]></content:encoded></item><item><title><![CDATA[Installing Kafka Clusters with Helm Charts: A Step-by-Step Guide]]></title><description><![CDATA[Apache Kafka is the backbone of modern data streaming, and deploying it on Kubernetes ensures scalability and resilience. In this tutorial, we will set up a Kafka cluster in KRaft mode (without Zookeeper) using a custom Helm Chart.
By the end of this...]]></description><link>https://amirkolahi.ir/installing-kafka-clusters-with-helm-charts-a-step-by-step-guide</link><guid isPermaLink="true">https://amirkolahi.ir/installing-kafka-clusters-with-helm-charts-a-step-by-step-guide</guid><category><![CDATA[kafka]]></category><category><![CDATA[Helm]]></category><category><![CDATA[k8s]]></category><category><![CDATA[cluster]]></category><dc:creator><![CDATA[Amir Kolahi]]></dc:creator><pubDate>Thu, 18 Dec 2025 06:48:34 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1766040432594/00b60c0a-caed-4dd2-8d93-a04e8f4753b4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Apache Kafka is the backbone of modern data streaming, and deploying it on Kubernetes ensures scalability and resilience. In this tutorial, we will set up a Kafka cluster in KRaft mode (without Zookeeper) using a custom Helm Chart.</p>
<p>By the end of this guide, you will have a running Kafka cluster defined as code, ready to handle your streaming data.</p>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>Before we dive in, make sure you have the following tools installed and configured:</p>
<ol>
<li><p>Kubernetes Cluster: A running cluster (Minikube, Kind, or a cloud provider like GKE/EKS).</p>
</li>
<li><p>kubectl: The Kubernetes command-line tool.</p>
</li>
<li><p>Helm: The package manager for Kubernetes.</p>
</li>
</ol>
<h3 id="heading-step-1-installing-helm">Step 1: Installing Helm</h3>
<p>If you haven't installed Helm yet, here is how you can do it on Linux/macOS.</p>
<p>For macOS (using Homebrew):</p>
<pre><code class="lang-bash">brew install helm
</code></pre>
<p>For Linux (using Script):</p>
<pre><code class="lang-bash">curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
</code></pre>
<p>Verify the installation:</p>
<pre><code class="lang-bash">helm version
</code></pre>
<h3 id="heading-step-2-initialize-the-helm-chart">Step 2: Initialize the Helm Chart</h3>
<p>Let's create the directory structure for our chart. Run the following command to generate a boilerplate chart:</p>
<pre><code class="lang-bash">helm create kafka-chart
</code></pre>
<p>This creates a folder named <code>kafka-chart</code>. Since we want to build our own logic, clean up the default templates:</p>
<pre><code class="lang-bash">rm -rf kafka-chart/templates/*
rm kafka-chart/values.yaml
</code></pre>
<p>Now we have a clean slate to add our configuration files.</p>
<h3 id="heading-step-3-configuration-files">Step 3: Configuration Files</h3>
<p>We need to define our Chart metadata and default values.</p>
<ol>
<li>Chart Definition (<code>Chart.yaml</code>)</li>
</ol>
<p>Open <code>kafka-chart/Chart.yaml</code> and replace its content with the following to define our application info:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">apiVersion:</span> <span class="hljs-string">v2</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">kafka-chart</span>
<span class="hljs-attr">description:</span> <span class="hljs-string">A</span> <span class="hljs-string">Helm</span> <span class="hljs-string">chart</span> <span class="hljs-string">for</span> <span class="hljs-string">deploying</span> <span class="hljs-string">Kafka</span> <span class="hljs-string">with</span> <span class="hljs-string">KRaft</span> <span class="hljs-string">mode</span>
<span class="hljs-attr">type:</span> <span class="hljs-string">application</span>
<span class="hljs-attr">version:</span> <span class="hljs-number">0.1</span><span class="hljs-number">.0</span>
<span class="hljs-attr">appVersion:</span> <span class="hljs-string">"1.0"</span>
</code></pre>
<ol start="2">
<li>Default Values (<code>values.yaml</code>)</li>
</ol>
<p>Create a new <code>kafka-chart/values.yaml</code>. This file serves as the single source of truth for our configuration (replicas, image, storage, etc.).</p>
<pre><code class="lang-yaml"><span class="hljs-attr">replicaCount:</span> <span class="hljs-number">3</span>

<span class="hljs-attr">service:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">kafka-svc</span>
  <span class="hljs-attr">port:</span> <span class="hljs-number">9092</span>

<span class="hljs-attr">image:</span>
  <span class="hljs-attr">repository:</span> <span class="hljs-string">doughgle/kafka-kraft</span>
  <span class="hljs-attr">tag:</span> <span class="hljs-string">latest</span>
  <span class="hljs-attr">pullPolicy:</span> <span class="hljs-string">IfNotPresent</span>

<span class="hljs-attr">pdb:</span>
  <span class="hljs-attr">minAvailable:</span> <span class="hljs-number">2</span>

<span class="hljs-attr">storage:</span>
  <span class="hljs-attr">size:</span> <span class="hljs-string">1Gi</span>

<span class="hljs-attr">kafka:</span>
  <span class="hljs-attr">clusterId:</span> <span class="hljs-string">"oh-sxaDRTcyAr6pFRbXyzA"</span>
  <span class="hljs-attr">replicationFactor:</span> <span class="hljs-number">3</span>
  <span class="hljs-attr">minInSyncReplicas:</span> <span class="hljs-number">2</span>
  <span class="hljs-attr">shareDir:</span> <span class="hljs-string">/mnt/kafka</span>

<span class="hljs-attr">namespace:</span> <span class="hljs-string">default</span>
</code></pre>
<h3 id="heading-step-4-creating-kubernetes-templates">Step 4: Creating Kubernetes Templates</h3>
<p>Now, let's create the actual Kubernetes resources inside the <code>kafka-chart/templates/ directory</code>.</p>
<ol>
<li>Headless Service (<code>templates/services.yaml</code>)</li>
</ol>
<p>We use a Headless Service (<code>clusterIP: None</code>) because Kafka brokers need stable network identities.</p>
<pre><code class="lang-yaml"><span class="hljs-attr">apiVersion:</span> <span class="hljs-string">v1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">Service</span>
<span class="hljs-attr">metadata:</span>
  <span class="hljs-attr">name:</span> {{ <span class="hljs-string">.Values.service.name</span> }}
  <span class="hljs-attr">labels:</span>
    <span class="hljs-attr">app:</span> <span class="hljs-string">kafka-app</span>
<span class="hljs-attr">spec:</span>
  <span class="hljs-attr">clusterIP:</span> <span class="hljs-string">None</span>
  <span class="hljs-attr">ports:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">'9092'</span>
      <span class="hljs-attr">port:</span> {{ <span class="hljs-string">.Values.service.port</span> }}
      <span class="hljs-attr">protocol:</span> <span class="hljs-string">TCP</span>
      <span class="hljs-attr">targetPort:</span> {{ <span class="hljs-string">.Values.service.port</span> }}
  <span class="hljs-attr">selector:</span>
    <span class="hljs-attr">app:</span> <span class="hljs-string">kafka-app</span>
</code></pre>
<ol start="2">
<li>Pod Disruption Budget (<code>templates/pdb.yaml</code>)</li>
</ol>
<p>To ensure high availability during voluntary disruptions (like node upgrades), we define a PDB.</p>
<pre><code class="lang-yaml"><span class="hljs-attr">apiVersion:</span> <span class="hljs-string">policy/v1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">PodDisruptionBudget</span>
<span class="hljs-attr">metadata:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">kafka-pdb</span>
<span class="hljs-attr">spec:</span>
  <span class="hljs-attr">minAvailable:</span> {{ <span class="hljs-string">.Values.pdb.minAvailable</span> }}
  <span class="hljs-attr">selector:</span>
    <span class="hljs-attr">matchLabels:</span>
      <span class="hljs-attr">app:</span> <span class="hljs-string">kafka-app</span>
</code></pre>
<ol start="3">
<li>StatefulSet (<code>templates/statefulset.yaml</code>)</li>
</ol>
<p>The StatefulSet manages the deployment and scaling of the Kafka pods. It handles the storage volume claims and passes necessary environment variables for the KRaft mode.</p>
<pre><code class="lang-yaml"><span class="hljs-attr">apiVersion:</span> <span class="hljs-string">apps/v1</span>
<span class="hljs-attr">kind:</span> <span class="hljs-string">StatefulSet</span>
<span class="hljs-attr">metadata:</span>
  <span class="hljs-attr">name:</span> <span class="hljs-string">kafka</span>
  <span class="hljs-attr">labels:</span>
    <span class="hljs-attr">app:</span> <span class="hljs-string">kafka-app</span>
<span class="hljs-attr">spec:</span>
  <span class="hljs-attr">serviceName:</span> {{ <span class="hljs-string">.Values.service.name</span> }}
  <span class="hljs-attr">replicas:</span> {{ <span class="hljs-string">.Values.replicaCount</span> }}
  <span class="hljs-attr">selector:</span>
    <span class="hljs-attr">matchLabels:</span>
      <span class="hljs-attr">app:</span> <span class="hljs-string">kafka-app</span>
  <span class="hljs-attr">template:</span>
    <span class="hljs-attr">metadata:</span>
      <span class="hljs-attr">labels:</span>
        <span class="hljs-attr">app:</span> <span class="hljs-string">kafka-app</span>
    <span class="hljs-attr">spec:</span>
      <span class="hljs-attr">containers:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">kafka-container</span>
          <span class="hljs-attr">image:</span> <span class="hljs-string">"<span class="hljs-template-variable">{{ .Values.image.repository }}</span>:<span class="hljs-template-variable">{{ .Values.image.tag }}</span>"</span>
          <span class="hljs-attr">imagePullPolicy:</span> {{ <span class="hljs-string">.Values.image.pullPolicy</span> }}
          <span class="hljs-attr">ports:</span>
            <span class="hljs-bullet">-</span> <span class="hljs-attr">containerPort:</span> <span class="hljs-number">9092</span>
            <span class="hljs-bullet">-</span> <span class="hljs-attr">containerPort:</span> <span class="hljs-number">9093</span>
          <span class="hljs-attr">env:</span>
            <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">REPLICAS</span>
              <span class="hljs-attr">value:</span> <span class="hljs-string">"<span class="hljs-template-variable">{{ .Values.replicaCount }}</span>"</span>
            <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">SERVICE</span>
              <span class="hljs-attr">value:</span> <span class="hljs-string">"<span class="hljs-template-variable">{{ .Values.service.name }}</span>"</span>
            <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">NAMESPACE</span>
              <span class="hljs-attr">value:</span> <span class="hljs-string">"<span class="hljs-template-variable">{{ .Values.namespace }}</span>"</span>
            <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">SHARE_DIR</span>
              <span class="hljs-attr">value:</span> <span class="hljs-string">"<span class="hljs-template-variable">{{ .Values.kafka.shareDir }}</span>"</span>
            <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">CLUSTER_ID</span>
              <span class="hljs-attr">value:</span> <span class="hljs-string">"<span class="hljs-template-variable">{{ .Values.kafka.clusterId }}</span>"</span>
            <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">DEFAULT_REPLICATION_FACTOR</span>
              <span class="hljs-attr">value:</span> <span class="hljs-string">"<span class="hljs-template-variable">{{ .Values.kafka.replicationFactor }}</span>"</span>
            <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">DEFAULT_MIN_INSYNC_REPLICAS</span>
              <span class="hljs-attr">value:</span> <span class="hljs-string">"<span class="hljs-template-variable">{{ .Values.kafka.minInSyncReplicas }}</span>"</span>
          <span class="hljs-attr">volumeMounts:</span>
            <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">data</span>
              <span class="hljs-attr">mountPath:</span> {{ <span class="hljs-string">.Values.kafka.shareDir</span> }}
  <span class="hljs-attr">volumeClaimTemplates:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">metadata:</span>
        <span class="hljs-attr">name:</span> <span class="hljs-string">data</span>
      <span class="hljs-attr">spec:</span>
        <span class="hljs-attr">accessModes:</span> [<span class="hljs-string">"ReadWriteOnce"</span>]
        <span class="hljs-attr">resources:</span>
          <span class="hljs-attr">requests:</span>
            <span class="hljs-attr">storage:</span> {{ <span class="hljs-string">.Values.storage.size</span> }}
</code></pre>
<h3 id="heading-step-5-deploying-the-chart">Step 5: Deploying the Chart</h3>
<p>With all files in place, we can now install our Kafka cluster.</p>
<ol>
<li><p>Dry Run (Optional):</p>
<p> It's good practice to verify what will be generated before applying it.</p>
<pre><code class="lang-bash"> helm install kafka-release ./kafka-chart --dry-run --debug
</code></pre>
</li>
<li><p>Install the Chart:</p>
<p> Run the following command to deploy:</p>
<pre><code class="lang-bash"> helm install kafka-release ./kafka-chart
</code></pre>
</li>
</ol>
<h3 id="heading-step-6-verification">Step 6: Verification</h3>
<p>Once installed, check the status of your pods:</p>
<pre><code class="lang-bash">kubectl get pods -w
</code></pre>
<p>You should see 3 pods (<code>kafka-0, kafka-1, kafka-2</code>) transitioning to the Running state.</p>
<p>To verify the service:</p>
<pre><code class="lang-bash">kubectl get svc
</code></pre>
<p>You have now successfully deployed a Kafka cluster using Helm! This setup uses the KRaft mode, removing the dependency on Zookeeper and simplifying the architecture.</p>
<p>Happy Coding! 🚀</p>
]]></content:encoded></item><item><title><![CDATA[Guide to Installing and Setting Up Krew and Starship Prompt]]></title><description><![CDATA[This playbook establishes a modern Kubernetes CLI workflow. You'll install Krew and the ctx/ns plugins for quick, hassle-free context and namespace switching, and configure the Starship prompt to display ⎈ <context> <namespace> directly in Zsh. An op...]]></description><link>https://amirkolahi.ir/guide-to-installing-and-setting-up-krew-and-starship-prompt</link><guid isPermaLink="true">https://amirkolahi.ir/guide-to-installing-and-setting-up-krew-and-starship-prompt</guid><category><![CDATA[Kubernetes]]></category><category><![CDATA[Prompt]]></category><dc:creator><![CDATA[Amir Kolahi]]></dc:creator><pubDate>Sat, 18 Oct 2025 08:47:58 GMT</pubDate><content:encoded><![CDATA[<h3 id="heading-this-playbook-establishes-a-modern-kubernetes-cli-workflow-youll-install-krew-and-the-ctxns-plugins-for-quick-hassle-free-context-and-namespace-switching-and-configure-the-starship-prompt-to-display-directly-in-zsh-an-optional-fzf-step-adds-fuzzy-selection-for-interactive-pickers-all-steps-are-aware-of-the-operating-system-and-architecture-linuxmacos-intelarm-and-are-safe-to-run-multiple-times">This playbook establishes a modern Kubernetes CLI workflow. You'll install Krew and the <code>ctx/ns</code> plugins for quick, hassle-free context and namespace switching, and configure the Starship prompt to display <code>⎈ &lt;context&gt; &lt;namespace&gt;</code> directly in Zsh. An optional fzf step adds fuzzy selection for interactive pickers. All steps are aware of the operating system and architecture (Linux/macOS, Intel/ARM) and are safe to run multiple times.</h3>
<h3 id="heading-0prerequisites"><strong>0.Prerequisites</strong></h3>
<p>Ensure <code>kubectl</code> is installed and in your <code>PATH</code>.</p>
<p>Install <code>ohmyzsh</code> in your terminal</p>
<h3 id="heading-1-install-krew-and-the-ctxns-plugins"><strong>1. Install Krew and the ctx/ns plugins:</strong></h3>
<pre><code class="lang-bash">( <span class="hljs-built_in">set</span> -euxo pipefail
  <span class="hljs-built_in">cd</span> <span class="hljs-string">"<span class="hljs-subst">$(mktemp -d)</span>"</span>
  OS=<span class="hljs-string">"<span class="hljs-subst">$(uname | tr '[:upper:]' '[:lower:]')</span>"</span>
  ARCH=<span class="hljs-string">"<span class="hljs-subst">$(uname -m | sed -e 's/x86_64/amd64/' -e 's/armv[0-9]*/arm/' -e 's/aarch64$/arm64/')</span>"</span>
  KREW=<span class="hljs-string">"krew-<span class="hljs-variable">${OS}</span>_<span class="hljs-variable">${ARCH}</span>"</span>
  curl -fsSLO <span class="hljs-string">"https://github.com/kubernetes-sigs/krew/releases/latest/download/<span class="hljs-variable">${KREW}</span>.tar.gz"</span>
  tar zxvf <span class="hljs-string">"<span class="hljs-variable">${KREW}</span>.tar.gz"</span>
  ./<span class="hljs-string">"<span class="hljs-variable">${KREW}</span>"</span> install krew
)
</code></pre>
<p>Add Krew to <code>PATH</code> (Zsh):</p>
<pre><code class="lang-bash">grep -q <span class="hljs-string">'\.krew.*/bin'</span> ~/.zshrc || <span class="hljs-built_in">echo</span> <span class="hljs-string">'export PATH="${KREW_ROOT:-$HOME/.krew}/bin:$PATH"'</span> &gt;&gt; ~/.zshrc
<span class="hljs-built_in">source</span> ~/.zshrc
</code></pre>
<p>Install plugins:</p>
<pre><code class="lang-bash">kubectl krew install ctx ns
</code></pre>
<p>Then install fzf:</p>
<pre><code class="lang-bash">sudo apt update &amp;&amp; sudo apt install -y fzf
</code></pre>
<p>Usege:</p>
<pre><code class="lang-bash">kubectl ctx                 <span class="hljs-comment"># list &amp; interactively switch contexts</span>
kubectl ctx &lt;context&gt;       <span class="hljs-comment"># switch directly</span>
kubectl ctx -               <span class="hljs-comment"># toggle to previous context</span>
kubectl ns                  <span class="hljs-comment"># list &amp; switch namespaces</span>
kubectl ns &lt;namespace&gt;      <span class="hljs-comment"># switch directly</span>
</code></pre>
<p>(Optional short aliases in ~/.zshrc: <code>alias kctx='kubectl ctx</code> and <code>alias kns='kubectl ns</code>)</p>
<h3 id="heading-2install-and-enable-starship">2.Install and enable Starship</h3>
<p>Install Starship:</p>
<pre><code class="lang-bash">curl -sS https://starship.rs/install.sh | sh
</code></pre>
<p>Enable Starship for Zsh (must be the last line in <code>~/.zshrc</code>):</p>
<pre><code class="lang-bash">grep -q <span class="hljs-string">'starship init zsh'</span> ~/.zshrc || <span class="hljs-built_in">echo</span> <span class="hljs-string">'eval "$(starship init zsh)"'</span> &gt;&gt; ~/.zshrc
<span class="hljs-built_in">source</span> ~/.zshrc
</code></pre>
<h3 id="heading-3configure-starship-to-show-kubernetes-contextnamespace">3.Configure Starship to show Kubernetes context/namespace</h3>
<p>Create config file ~/.config/starship.toml:</p>
<pre><code class="lang-bash">mkdir -p ~/.config
cat &gt; ~/.config/starship.toml &lt;&lt;<span class="hljs-string">'TOML'</span>
format = <span class="hljs-string">"<span class="hljs-variable">$kubernetes</span><span class="hljs-variable">$directory</span><span class="hljs-variable">$git_branch</span><span class="hljs-variable">$git_status</span><span class="hljs-variable">$python</span><span class="hljs-variable">$character</span>"</span>

[kubernetes]
disabled = <span class="hljs-literal">false</span>
symbol = <span class="hljs-string">"⎈ "</span>
style = <span class="hljs-string">"bold blue"</span>
format = <span class="hljs-string">'[$symbol$context( \($namespace\))]($style) '</span>

detect_files = []
detect_extensions = []
detect_folders = []

contexts = [
  { context_pattern = <span class="hljs-string">"kubernetes-super-admin@cluster.local"</span>, context_alias = <span class="hljs-string">"DemoCluster"</span> },
  { context_pattern = <span class="hljs-string">"kind-kind"</span>, context_alias = <span class="hljs-string">"kind"</span> }
]

[directory]
truncation_length = 3

[git_branch]
format = <span class="hljs-string">" on [<span class="hljs-variable">$symbol</span><span class="hljs-variable">$branch</span>](<span class="hljs-variable">$style</span>) "</span>

[git_status]
format = <span class="hljs-string">"([<span class="hljs-variable">$all_status</span>](<span class="hljs-variable">$style</span>)) "</span>

[python]
disabled = <span class="hljs-literal">true</span>

[character]
success_symbol = <span class="hljs-string">" ➜ "</span>     
error_symbol   = <span class="hljs-string">" ✗ "</span>         
vimcmd_symbol  = <span class="hljs-string">" ❮ "</span>
</code></pre>
<p>You should see something like:</p>
<pre><code class="lang-bash">⎈ DemoCluster (default) ~  ➜
</code></pre>
]]></content:encoded></item></channel></rss>