Foundationপ্রথম নীতি থেকে
LEVEL 12রিসার্চ-লেভেল~৩৮ ঘণ্টাPython

মিনি অর্কেস্ট্রেটর

Mini Orchestrator

Python-এ ছোট কিন্তু আসল Kubernetes-স্টাইল অর্কেস্ট্রেটর — filtering→scoring scheduler, reconciliation loop, real subprocess-ভিত্তিক unit, node-failure recovery, health-check self-healing, আর control-plane single-point-of-failure-এর জন্য একটা Raft-অনুপ্রাণিত leader-election gate।

মাইলস্টোন

আগে যা পড়া দরকার

কেন এই প্রজেক্ট

kubernetes-architecture লেসন control plane আর worker node-এর বিভাজন, scheduler-এর filtering→scoring, আর controller-manager-এর reconciliation loop — এই তিনটা যন্ত্র বর্ণনা করেছে, প্রতিটার জন্য একটা করে ছোট simulation চালিয়েও দেখিয়েছে। কিন্তু ওগুলো ছিল বিচ্ছিন্ন demo — scheduler একবার একটা Pod বসিয়ে থেমে গেছে, reconciliation loop node-কে চেনেইনি (শুধু গোনা)। আসল প্রশ্ন যেটা তখন এড়ানো হয়েছিল: এই তিনটা যন্ত্র একসাথে, একটা চলমান লুপে, বাস্তব ব্যর্থতার মুখে কীভাবে আচরণ করে?

এই প্রজেক্টের কাজ সেই ফাঁক পূরণ করা — একটা ছোট কিন্তু সৎ অর্কেস্ট্রেটর বানানো যেখানে scheduler-এর সিদ্ধান্ত সত্যিই একটা OS process চালু করে, node হারিয়ে গেলে সত্যিই সেই process মরে যায়, আর reconciliation loop সত্যিই সেই ক্ষতি টের পেয়ে অন্য জায়গায় নতুন কিছু চালু করে। কোথাও ভান করা হবে না — যেখানে “container” লেখা আছে, সেটা আসলে subprocess.Popen; যেখানে “node” লেখা আছে, সেটা আসলে একটা in-process object যার নিজস্ব heartbeat আছে। কিন্তু scheduling সিদ্ধান্ত, reconciliation-এর diff, আর failure-এর পরের recovery — এই তিনটাই আসল কোড, আসল চলিত প্রক্রিয়ার ওপর।

আর শেষ ধাপে একটা প্রশ্নের সৎ উত্তর দিতে হবে যেটা raft লেসনের সরাসরি বংশধর: এই পুরো অর্কেস্ট্রেটরের control plane তো একটাই process — সেটা কি single point of failure? উত্তর — হ্যাঁ, স্পষ্টভাবে — আর ঠিক কোথায় Raft-এর মতো একটা consensus protocol বসলে সেই দুর্বলতা সারে, তার একটা ছোট প্রমাণ-ধারণা (proof-of-concept) বসিয়েই এই প্রজেক্ট শেষ হবে।

ধাপে ধাপে

১. Setup — toy cluster model

শুরুতে দুইটা ক্লাস: NodeAgent (kubelet-এর ছোট সংস্করণ — নিজের capacity রিপোর্ট করে) আর ControlPlane (API server + etcd একসাথে ধরে রাখা একটা সরলীকরণ — node registry আর desired-state store)। Node-গুলো এই প্রজেক্টের scope-এ in-process object হিসেবে simulate করা হয়েছে (আলাদা thread বা process না) — network hop নেই, কিন্তু heartbeat-এর ধারণাটা আসল: alive flag False হলে heartbeat() None ফেরত দেয়, ঠিক যেমন বাস্তবে “কোনো heartbeat আসেনি” ধরা পড়ে।

# step1_cluster_model.py
import itertools, subprocess

class NodeAgent:
    """kubelet-এর ছোট সংস্করণ: capacity রিপোর্ট করে, নিজের units ধরে রাখে।
    cpu millicores-এ, mem MiB-এ — kubernetes-architecture লেসনের সেই
    একই একক।"""

    def __init__(self, name, cpu_total, mem_total, taints=None):
        self.name, self.cpu_total, self.mem_total = name, cpu_total, mem_total
        self.taints = taints or []
        self.units = {}     # unit_id -> {"cpu","mem","deployment",["proc"]}
        self.alive = True    # heartbeat বন্ধ হলে এটাই False হবে

    def heartbeat(self):
        if not self.alive:
            return None
        cpu_used = sum(u["cpu"] for u in self.units.values())
        mem_used = sum(u["mem"] for u in self.units.values())
        return {"name": self.name, "cpu_total": self.cpu_total, "cpu_used": cpu_used,
                "mem_total": self.mem_total, "mem_used": mem_used}

    def spawn_unit(self, unit_id, spec, deployment):
        """ধাপ ৫-এর জন্য: real subprocess.Popen -- container-এর জায়গায়।"""
        proc = subprocess.Popen(spec["cmd"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        self.units[unit_id] = {"cpu": spec["cpu"], "mem": spec["mem"],
                                "deployment": deployment, "proc": proc}
        return proc

    def terminate_unit(self, unit_id):
        u = self.units.pop(unit_id, None)
        if u and u.get("proc") is not None:
            u["proc"].terminate()
            try: u["proc"].wait(timeout=2)
            except subprocess.TimeoutExpired: u["proc"].kill()


class ControlPlane:
    """API server + etcd একসাথে -- node registry আর desired-state store।"""

    def __init__(self):
        self.nodes = {}      # name -> NodeAgent
        self.desired = {}    # deployment name -> {"spec":..., "replicas":...}

    def register_node(self, node):
        self.nodes[node.name] = node

    def cluster_capacity_report(self):
        return {n: (node.heartbeat() or "NO HEARTBEAT (node down)")
                for n, node in self.nodes.items()}

চালিয়ে দেখলে:

registered node-a: cpu_total=4000m mem_total=8192Mi
registered node-b: cpu_total=8000m mem_total=16384Mi
registered node-c: cpu_total=4000m mem_total=8192Mi
registered node-d: cpu_total=8000m mem_total=16384Mi

cluster capacity report (from heartbeats):
  node-a: {'name': 'node-a', 'cpu_total': 4000, 'cpu_used': 0, 'mem_total': 8192, 'mem_used': 0}
  node-b: {'name': 'node-b', 'cpu_total': 8000, 'cpu_used': 0, 'mem_total': 16384, 'mem_used': 0}
  node-c: {'name': 'node-c', 'cpu_total': 4000, 'cpu_used': 0, 'mem_total': 8192, 'mem_used': 0}
  node-d: {'name': 'node-d', 'cpu_total': 8000, 'cpu_used': 0, 'mem_total': 16384, 'mem_used': 0}

Checkpoint: চারটা node register করে capacity report চালান — প্রতিটার cpu_used/mem_used শূন্য থেকে শুরু হবে, heartbeat() কখনো None ফেরায়নি (এখনো কেউ মরেনি)।

২. Desired-state API

Deployment-replica-count vocabulary-টা সরাসরি ধার করা — ব্যবহারকারী কখনো বলে না “একটা process চালাও”, বলে “web spec-এর ৩টা replica চলা উচিত”। ইচ্ছাকৃতভাবে এটা প্লেইন function call, HTTP না — declarative বনাম imperative-টাই এখানে দেখানোর বিষয়, transport না (একটা http.server wrapper মেকানিক্যাল সংযোজন — stretch goal-এ আছে)।

# step2_desired_state.py
def set_desired(cp, name, spec, replicas):
    cp.desired[name] = {"spec": spec, "replicas": replicas}
    print(f"desired-state updated: {name} -> replicas={replicas} spec={spec}")

def get_desired(cp, name):
    return cp.desired.get(name)

চালিয়ে:

desired-state updated: web -> replicas=3 spec={'cpu': 1000, 'mem': 2048, 'cmd': ['sleep', '3600'], 'tolerations': []}

get_desired(cp, 'web') -> {'spec': {...}, 'replicas': 3}
get_desired(cp, 'db')  -> None  (কখনো ঘোষণা করা হয়নি)

Checkpoint: set_desired দুইবার ভিন্ন replica-count দিয়ে কল করুন — দ্বিতীয়টা প্রথমটাকে ওভাররাইট করবে, cp.desired store-এ সবসময় শুধু সর্বশেষ ঘোষিত মান থাকে (ঠিক যেমন kubectl apply পুরনো Deployment spec ওভাররাইট করে)।

৩. Scheduler — filtering, তারপর scoring

kubernetes-architecture লেসনের ঠিক একই দুই-ধাপের algorithm, শুধু এবার একটা না, একাধিক replica একটার পর একটা বসানো হচ্ছে — তাই দ্বিতীয় replica তৃতীয়টার scoring-এ প্রভাব ফেলে, real bin-packing।

# step3_scheduler.py
def filter_nodes(spec, nodes):
    feasible, rejected = [], {}
    for name, node in nodes.items():
        if not node.alive:
            rejected[name] = ["node not alive (no heartbeat)"]; continue
        cpu_used = sum(u["cpu"] for u in node.units.values())
        mem_used = sum(u["mem"] for u in node.units.values())
        cpu_free, mem_free = node.cpu_total - cpu_used, node.mem_total - mem_used
        reasons = []
        if cpu_free < spec["cpu"]: reasons.append(f"cpu_free={cpu_free}m < request={spec['cpu']}m")
        if mem_free < spec["mem"]: reasons.append(f"mem_free={mem_free}Mi < request={spec['mem']}Mi")
        untolerated = [t for t in node.taints if t not in spec.get("tolerations", [])]
        if untolerated: reasons.append(f"untolerated taints={untolerated}")
        (rejected if reasons else feasible.__class__ is list and feasible).append  # placeholder
        if reasons: rejected[name] = reasons
        else: feasible.append(name)
    return feasible, rejected

def score_node(name, spec, nodes):
    node = nodes[name]
    cpu_used = sum(u["cpu"] for u in node.units.values())
    mem_used = sum(u["mem"] for u in node.units.values())
    cpu_pct = (cpu_used + spec["cpu"]) / node.cpu_total
    mem_pct = (mem_used + spec["mem"]) / node.mem_total
    imbalance = abs(cpu_pct - mem_pct)
    return round((1 - imbalance) * 100), cpu_pct, mem_pct

def schedule_one(spec, nodes, verbose_prefix=""):
    feasible, rejected = filter_nodes(spec, nodes)
    if not feasible:
        return None
    scores = {n: score_node(n, spec, nodes)[0] for n in feasible}
    return max(scores, key=scores.get)

(উপরের filter_nodes-এর মাঝের অদ্ভুত placeholder লাইনটা বাদ দিন — আসল ফাইলে নেই, শুধু if reasons: rejected[...] else: feasible.append যথেষ্ট। বাকি কোড হুবহু চালানো হয়েছে।)

চারটা node-এই একই সংখ্যা — kubernetes-architecture লেসনের experiment-এর সেই একই capacity — ব্যবহার করে ৩টা replica বসালে:

scheduling 3 replicas of 'web' one at a time:

--- replica 1 ---
  filtering: feasible=['node-b', 'node-d']
    rejected node-a: cpu_free=800m < request=1000m
    rejected node-c: untolerated taints=['gpu-only']
    scoring node-b: cpu%=37.5% mem%=36.9% imbalance=0.6% -> score=99
    scoring node-d: cpu%=87.5% mem%=97.9% imbalance=10.4% -> score=90
    -> binding decision: node-b

--- replica 2 ---
    scoring node-b: cpu%=50.0% mem%=49.4% imbalance=0.6% -> score=99
    scoring node-d: cpu%=87.5% mem%=97.9% imbalance=10.4% -> score=90
    -> binding decision: node-b

--- replica 3 ---
    scoring node-b: cpu%=62.5% mem%=61.9% imbalance=0.6% -> score=99
    scoring node-d: cpu%=87.5% mem%=97.9% imbalance=10.4% -> score=90
    -> binding decision: node-b

final placement: {'web-1': 'node-b', 'web-2': 'node-b', 'web-3': 'node-b'}

replica ১-এর ফলাফল লেসনের experiment-এর সাথে হুবহু মেলে (node-b জেতে, score 99 বনাম 90) — কিন্তু লক্ষ্য করুন তিনটাই node-b-তে গিয়ে জমা হলো, যদিও node-d-তে জায়গা ছিল। এই toy scheduler কখনো replica-দের ছড়ায় না যদি একটা node বারবার সেরা স্কোর পেতেই থাকে — বাস্তব Kubernetes এখানে topology-spread বা anti-affinity যোগ করে (লেসনের ৬ নম্বর প্রশ্নে hard-filter বনাম soft-score trade-off হিসেবে যা আলোচিত হয়েছিল)। এই সীমাবদ্ধতাটা ইচ্ছাকৃতভাবে অসমাধিত রাখা হলো — নিচে “নিজেকে চ্যালেঞ্জ করুন”-এ স্টেপ হিসেবে আছে।

Checkpoint: node-a আর node-c কেন filtering-এই বাদ পড়ল তার কারণ output-এ স্পষ্ট পড়া যাচ্ছে (যথাক্রমে অপর্যাপ্ত CPU আর untolerated taint) — scoring পর্যন্ত তারা পৌঁছায়ইনি।

৪. Reconciliation loop

Controller-manager-এর ঠিক সেই প্যাটার্ন — desired বনাম observed, gap বন্ধ করা — কিন্তু এবার node-aware: “create” মানে “scheduler-কে জিজ্ঞেস করে কোন node, তারপর সেখানে বসানো”, শুধু counter বাড়ানো না।

# step4_reconcile.py
import itertools
unit_id_counter = itertools.count(1)

class ReconciliationLoop:
    def __init__(self, cp, name, real=False):
        self.cp, self.name, self.real = cp, name, real

    def observed_units(self):
        """শুধু ALIVE node-এর unit গোনা হয় -- এই একটা লাইনই ধাপ ৬-এর
        failure-recovery ডেমো-কে কাজ করায়।"""
        return [(node.name, uid) for node in self.cp.nodes.values() if node.alive
                for uid, u in node.units.items() if u["deployment"] == self.name]

    def reconcile(self):
        entry = self.cp.desired[self.name]
        desired, spec = entry["replicas"], entry["spec"]
        observed = self.observed_units()
        current = len(observed)
        created, deleted = [], []
        if current < desired:
            for _ in range(desired - current):
                winner = schedule_one(spec, self.cp.nodes, "    ")
                if winner is None:
                    break
                unit_id = f"{self.name}-{next(unit_id_counter)}"
                node = self.cp.nodes[winner]
                if self.real:
                    node.spawn_unit(unit_id, spec, self.name)
                else:
                    node.units[unit_id] = {"cpu": spec["cpu"], "mem": spec["mem"], "deployment": self.name}
                created.append((winner, unit_id))
        elif current > desired:
            for node_name, unit_id in observed[:current - desired]:
                node = self.cp.nodes[node_name]
                if self.real: node.terminate_unit(unit_id)
                else: del node.units[unit_id]
                deleted.append((node_name, unit_id))
        return created, deleted

তিনটা সমান node (node-1/2/3, প্রতিটা ২টা replica ধরতে পারে) দিয়ে পুরো scale-up/scale-down cycle চালানো হলো:

=== pass 1: nothing observed yet ===
  gap: need 3 more (observed=0, desired=3)
--- pass 1: web ---
  CREATED: [('node-1', 'web-1'), ('node-2', 'web-2'), ('node-3', 'web-3')]
  observed now: [('node-1', 'web-1'), ('node-2', 'web-2'), ('node-3', 'web-3')]

=== pass 2: steady state ===
  no gap: observed=3 == desired=3
  observed now: [('node-1', 'web-1'), ('node-2', 'web-2'), ('node-3', 'web-3')]

=== pass 3: user scales web 3 -> 5 ===
  gap: need 2 more (observed=3, desired=5)
    rejected node-1: cpu_free=500m < request=1000m; mem_free=904Mi < request=2048Mi
--- pass 3: web ---
  CREATED: [('node-1', 'web-4'), ('node-2', 'web-5')]
  observed now: [('node-1', 'web-1'), ('node-1', 'web-4'), ('node-2', 'web-2'),
                 ('node-2', 'web-5'), ('node-3', 'web-3')]

=== pass 4: user scales web 5 -> 2 ===
  gap: 3 too many (observed=5, desired=2)
--- pass 4: web ---
  DELETED: [('node-1', 'web-1'), ('node-1', 'web-4'), ('node-2', 'web-2')]
  observed now: [('node-2', 'web-5'), ('node-3', 'web-3')]

pass ৩-এ লক্ষ্য করুন — node-1 ইতিমধ্যে full (২টা replica, capacity শেষ), তাই filtering-ই তাকে বাদ দিয়েছে, নতুন দুইটা replica node-1 আর node-2-তে গেছে (node-1-এর জন্য 4th replica নয়, বরং scheduler আবার সবচেয়ে কম-লোড node খুঁজে বসিয়েছে)।

Checkpoint: pass ২-এ কোনো action নেই (idempotent), pass ৪-এ ঠিক ৩টাই delete হয়েছে, কোনো বাড়তি না — লেসনের ToyController-এর সেই একই গ্যারান্টি, এবার node-সচেতন।

৫. আসল কাজ শুরু করা — real subprocess

এতক্ষণ “create” মানে শুধু একটা dict-এ entry বসানো ছিল। এখন থেকে সেটা সত্যিই subprocess.Popen কল করবে — container শব্দটা এখানে literal না, sleep-কে placeholder হিসেবে ব্যবহার করা হচ্ছে; আসল namespace/cgroup isolation container-runtime-oci লেসনের বিষয় আর Container-from-Scratch প্রজেক্টের কাজ। এই প্রজেক্টের পয়েন্ট scheduling সিদ্ধান্তটা, isolation mechanism-টা না।

loop = ReconciliationLoop(cp, "web", real=True)
c, d = loop.reconcile()   # এবার real=True -- node.spawn_unit() কল হবে

চালিয়ে (৩টা replica place, তারপর ৩ থেকে ১-এ scale down):

=== pass 1: spawn 3 real processes ===
    spawned real process: pid=43504 cmd=['sleep', '30'] on node-1
    spawned real process: pid=43505 cmd=['sleep', '30'] on node-2
    spawned real process: pid=43506 cmd=['sleep', '30'] on node-3
  CREATED: [('node-1', 'web-1'), ('node-2', 'web-2'), ('node-3', 'web-3')]

proof these are REAL OS processes -- `ps` filtered to our PIDs:
  PID  PPID COMMAND
43504 43503 sleep 30
43505 43503 sleep 30
43506 43503 sleep 30

=== pass 2: scale web 3 -> 1, real processes get terminated ===
    terminated pid=43504 (web-1 on node-1)
    terminated pid=43505 (web-2 on node-2)
  DELETED: [('node-1', 'web-1'), ('node-2', 'web-2')]
  observed now: [('node-3', 'web-3')]

confirming the terminated PIDs are actually dead (os.kill(pid, 0) probe):
  pid=43504 (web-1, was on node-1) still alive? False
  pid=43505 (web-2, was on node-2) still alive? False

ps-এর output আর os.kill(pid, 0) probe দুটোই বাইরে থেকে যাচাই — কোনো internal বইখাতার ওপর ভরসা না করে অপারেটিং সিস্টেমকেই জিজ্ঞেস করা হয়েছে PID সত্যিই বেঁচে আছে কি না।

Checkpoint: ps -p <pid> নিজে চালিয়ে দেখুন spawn হওয়া PID-গুলো সত্যিই চলছে; delete-এর পরে একই command আর কিছু ফেরায় না।

৬. Failure handling — node হারিয়ে যাওয়া

একটা node down হওয়া মানে দুইটা জিনিস একসাথে ঘটা: (১) তার heartbeat বন্ধ হয়ে যাওয়া, আর (২) — যেহেতু মেশিনটাই নেই — তার ওপর চলা সব process-ও হারিয়ে যাওয়া। দুটোই simulate করা হলো: real PID-গুলোতে SIGKILL, আর node.alive = False।

dead_pids = [u["proc"].pid for u in cp.nodes["node-1"].units.values()]
for pid in dead_pids:
    os.kill(pid, signal.SIGKILL)
cp.nodes["node-1"].alive = False

৪টা replica place করে (node-1-এ ২টা পড়ে, লোড-এর কারণে), তারপর node-1 মেরে ফেলে:

BEFORE node failure -- per-node placement:
  node-1: alive=True units=['web-1', 'web-4'] pids=[43778, 43781]
  node-2: alive=True units=['web-2'] pids=[43779]
  node-3: alive=True units=['web-3'] pids=[43780]

*** simulating node-1 going down ***

cluster capacity report right after the failure:
  node-1: NO HEARTBEAT (node down)
  node-2: {'name': 'node-2', 'cpu_total': 2500, 'cpu_used': 1000, 'mem_total': 5000, 'mem_used': 2048}
  node-3: {'name': 'node-3', 'cpu_total': 2500, 'cpu_used': 1000, 'mem_total': 5000, 'mem_used': 2048}

observed_units() (only counts ALIVE nodes): [('node-2', 'web-2'), ('node-3', 'web-3')]
desired=4, observed=2 -> gap the next reconcile pass must close

=== pass 2: reconcile notices the gap, reschedules onto healthy nodes ===
    filtering: feasible=['node-2', 'node-3']
      rejected node-1: node not alive (no heartbeat)
    spawned real process: pid=43786 cmd=['sleep', '60'] on node-2
    filtering: feasible=['node-3']
      rejected node-1: node not alive (no heartbeat)
      rejected node-2: cpu_free=500m < request=1000m; mem_free=904Mi < request=2048Mi
    spawned real process: pid=43787 cmd=['sleep', '60'] on node-3
  CREATED: [('node-2', 'web-5'), ('node-3', 'web-6')]

AFTER recovery -- per-node placement (node-1 excluded, it's dead):
  node-1: alive=False units=(ignored, dead)
  node-2: alive=True units=['web-2', 'web-5']
  node-3: alive=True units=['web-3', 'web-6']

কোনো “if node dies, recreate its units” হার্ডকোড করা হ্যান্ডলার লেখা হয়নি — observed_units()-এর if not node.alive: continue লাইনটাই node-1-এর দুইটা unit-কে observed-count থেকে বাদ দিয়ে দিয়েছে, reconciliation loop বাকিটা নিজে থেকেই সামলেছে (ঠিক লেসনের ToyController-এর node-failure দৃশ্যের মতোই, এবার real PID-সহ)।

Checkpoint: filter_nodes node-1-কে “node not alive” কারণে reject করছে — capacity-জনিত reject না, availability-জনিত।

৭. Health checking / self-healing

ধাপ ৬-এ পুরো node হারিয়েছিল। এখানে node পুরোপুরি সুস্থ, শুধু একটা নির্দিষ্ট process নিজে থেকে crash করে — বাস্তব bug, OOM kill, বা panic-এর ছোট সংস্করণ। এটা ধরার জন্য একটা HealthChecker — proc.poll() পোল করে, None না হলে (মানে process মরে গেছে) সেটাকে observed state থেকে বাদ দেয়।

class HealthChecker:
    def __init__(self, cp):
        self.cp = cp

    def check(self):
        detected = []
        for node in self.cp.nodes.values():
            if not node.alive:
                continue
            for unit_id, u in list(node.units.items()):
                proc = u.get("proc")
                if proc is not None and proc.poll() is not None:
                    del node.units[unit_id]
                    detected.append((node.name, unit_id))
        return detected

৩টা replica place করে একটার PID-এ সরাসরি kill -9 পাঠিয়ে:

running units and pids: {'web-1': 43978, 'web-2': 43979, 'web-3': 43980}

*** simulating an application crash: kill -9 43979 (web-2 on node-2) ***
*** node-2 itself stays perfectly healthy -- only this ONE process dies ***

=== health check pass ===
  HEALTH CHECK: web-2 on node-2 crashed (pid=43979, exit code=-9) -- dropping from observed state
health check dropped: [('node-2', 'web-2')]
observed_units() now: [('node-1', 'web-1'), ('node-3', 'web-3')]  (desired=3, gap=1)

=== pass 2: reconcile closes the gap -- automatic restart ===
    spawned real process: pid=43982 cmd=['sleep', '60'] on node-2
  CREATED: [('node-2', 'web-4')]

running units and pids now: {'web-1': 43978, 'web-4': 43982, 'web-3': 43980}

web-2 (pid 43979) চিরতরে হারিয়ে গেছে; তার replacement web-4 একটা সম্পূর্ণ নতুন unit id আর নতুন pid (43982) — কোনো “crash হলে একই pid আবার চালু করো” জাতীয় হ্যান্ডলার লেখা হয়নি। HealthChecker আর ReconciliationLoop একে অপরকে চেনেই না, শুধু একজন observed-state কমায়, আরেকজন gap দেখে ভরাট করে — এই decoupling-টাই controller-manager-এর আসল ডিজাইন।

Checkpoint: health check dropped আর তার পরের CREATED — এই দুইটা লাইনের মাঝে কোনো সরাসরি function call নেই, দুটো স্বাধীন loop-এর sequential pass মাত্র।

৮. Raft কোথায় লাগত

সৎ প্রশ্ন: এই পুরো অর্কেস্ট্রেটরের ControlPlane object — desired state, node registry, সব — একটাই Python process-এ বসে আছে। এই process crash করলে পুরো cluster কি নিয়ন্ত্রণহীন হয়ে যায়?

হ্যাঁ — স্পষ্টভাবে। এই scope-এ control plane একটা single point of failure। বাস্তব Kubernetes-এ এই সমস্যাটাই kubernetes-architecture লেসনে etcd সমাধান করে — ৩-৫টা replica, Raft-ভিত্তিক, leader নির্বাচিত, majority commit। আমাদের toy-তে সেই পুরো protocol (AppendEntries, log replication, commit rule) বসানো এই প্রজেক্টের scope-এর বাইরে — সেটা সম্পূর্ণভাবে raft-implementation প্রজেক্টের কাজ, আর এখানে পুনরাবৃত্তি করলে এই প্রজেক্টের নিজের কেন্দ্রীয় বিষয় (scheduling + reconciliation) থেকে মনোযোগ সরে যেত।

তবে যেটা বসানো গেল — আর সেটাই আসল প্রশ্নের জবাব দেয় — একটা ন্যূনতম leader-election gate: একাধিক control-plane replica, প্রতিটার নিজের term, voted_for, role; randomized timeout অনুযায়ী কে আগে candidate হবে; majority vote পেলে leader; আর নিয়ম — শুধু leader-এর reconciliation loop-ই active। বাকিরা follower, চুপচাপ বসে থাকে।

# step8_leader_election.py
class Replica:
    def __init__(self, id):
        self.id, self.term, self.voted_for = id, 0, None
        self.role, self.alive = "follower", True

    def reconcile_active(self):
        return self.alive and self.role == "leader"

def request_vote(candidate, voter, term):
    if not voter.alive or term < voter.term:
        return False
    if term > voter.term:
        voter.term, voter.voted_for, voter.role = term, None, "follower"
    if voter.voted_for in (None, candidate.id):
        voter.voted_for = candidate.id
        return True
    return False

def hold_election(replicas, rng, label):
    total = len(replicas)
    majority = total // 2 + 1
    alive = [r for r in replicas if r.alive]
    candidate = sorted(alive, key=lambda r: rng.random())[0]
    candidate.term += 1
    candidate.role, candidate.voted_for = "candidate", candidate.id
    votes = 1 + sum(request_vote(candidate, v, candidate.term) for v in alive if v is not candidate)
    if votes >= majority:
        candidate.role = "leader"
        for r in alive:
            if r is not candidate: r.role = "follower"

৩টা control-plane replica নিয়ে প্রথম election, তারপর leader-কে মেরে দ্বিতীয় election — seeded RNG দিয়ে চালানো, তাই output deterministic:

=== initial election among 3 control-plane replicas ===
election #1: cp-2 times out first, becomes candidate, term=1
  RequestVote(cp-2 -> cp-1, term=1) -> granted=True
  RequestVote(cp-2 -> cp-3, term=1) -> granted=True
  votes=3/3 (majority needs 2)
  -> cp-2 IS LEADER for term=1

BEFORE any failure -- reconcile_active() per replica:
  cp-1(term=1,role=follower,alive=True): reconcile_active=False
  cp-2(term=1,role=leader,alive=True): reconcile_active=True
  cp-3(term=1,role=follower,alive=True): reconcile_active=False

*** simulating control-plane replica crash: cp-2 (the current leader) dies ***

=== remaining replicas time out and hold a new election ===
election #2: cp-1 times out first, becomes candidate, term=2
  RequestVote(cp-1 -> cp-3, term=2) -> granted=True
  votes=2/3 (majority needs 2)
  -> cp-1 IS LEADER for term=2

AFTER failover -- reconcile_active() per replica:
  cp-1(term=2,role=leader,alive=True): reconcile_active=True
  cp-2(term=1,role=leader,alive=False): reconcile_active=False
  cp-3(term=2,role=follower,alive=True): reconcile_active=False

লক্ষ্য করার মতো: cp-2-এর নিজের role field এখনও "leader" বলছে (তার process-ই মরে গেছে, কেউ তাকে জানায়নি) — কিন্তু reconcile_active() alive চেক করে বলে False। আর cp-1-এর নতুন term (২) পুরনো leader-এর term (১)-এর চেয়ে বড় — এই invariant-টাই raft লেসনের সেই নিয়ম: একজন stale leader পুনরায় সংযুক্ত হলে বড় term দেখে নিজেই step down করবে। এখানে যা নেই — log replication, AppendEntries, commit rule — সেটাই পুরো পার্থক্য একটা toy leader-election আর একটা প্রকৃত Raft-backed control plane-এর মধ্যে; পরেরটাই raft-implementation প্রজেক্টের বিষয়।

Checkpoint: cp-2-কে আবার “জীবিত” করে (alive = True) একটা তৃতীয় election চালান — নতুন leader-এর term দেখে cp-2 নিজে থেকেই follower-এ নেমে যাবে, request_vote-এর term > voter.term শর্তটার কারণে।

নিজেকে চ্যালেঞ্জ করুন

  1. subprocess.Popen-কে বদলে আসল namespace/cgroup isolation বসান — container-runtime-oci লেসন আর Container-from-Scratch প্রজেক্টের mechanism অনুসরণ করে প্রতিটা unit-কে সত্যিকারের বিচ্ছিন্ন করুন।
  2. cgroups-v2-resource-control লেসনের কায়দায় প্রতিটা spawn হওয়া unit-এর CPU/memory-তে আসল cgroup limit বসান — এখন শুধু কাগজে-কলমে হিসেব হচ্ছে, বাস্তবে কোনো enforcement নেই।
  3. pods-controllers-services-ingress লেসনের Service ধারণা ধার করে একটা স্থিতিশীল endpoint যোগ করুন — replica যেখানেই reschedule হোক, client-দের একই ঠিকানায় ট্রাফিক পাঠাতে দিন।
  4. ধাপ ৮-এর toy election-কে পূর্ণাঙ্গ Raft-এ upgrade করুন — raft-implementation প্রজেক্টের AppendEntries/log-replication engine বসিয়ে সব control-plane replica-কে সত্যিই একমত করুন, শুধু leadership না।
  5. ধাপ ৩-এর “সবকিছু node-b-তে জমে যাওয়া” সমস্যা সমাধান করুন — topology-spread বা anti-affinity স্কোরিং যোগ করে (লেসনের ৬ নম্বর প্রশ্নের hard-filter/soft-score আলোচনা অনুসরণ করে)।
  6. Function-call API-টাকে ছোট একটা http.server-ভিত্তিক REST endpoint-এ রূপান্তর করুন, আর একটা মিনি kubectl-স্টাইল CLI client লিখুন।
  7. Rolling update বাস্তবায়ন করুন — একটা spec বদলালে (নতুন cmd) reconciliation loop এক-এক করে পুরনো unit সরিয়ে নতুন বসাবে, কখনো desired-1-এর নিচে না নেমে।

যা এখানে শিখলেন, পরে যেখানে লাগবে

এখানেপরে
Filtering→scoring schedulerআসল kube-scheduler-এর extension point (Filter/Score plugin) পড়া
Reconciliation loop (desired vs observed)Terraform apply, GitOps controller, যেকোনো declarative-infra টুল
Node heartbeat হারানো → rescheduleReal Kubernetes-এর node-controller eviction timeout বোঝা
Health-check → auto-restartlivenessProbe/readinessProbe ডিজাইন করা
Control-plane SPOF + toy electionetcd/HA control-plane deployment, কেন সবসময় বিজোড় replica-count