Chaos Lab
Chaos Lab
FaultBus injector দিয়ে distributed system-এ ইচ্ছে করে বিপদ ডাকা — delay/jitter/drop/partition/crash scenario DSL, fault-matrix sweep, percentile metrics, ASCII timeline আর findings report template।
কেন এই প্রজেক্ট
Distributed system ভাঙে সবসময় সবচেয়ে খারাপ মুহূর্তে, আর ভাঙার ভঙ্গি production-এর বাইরে rehearse করা যায় না বলেই মানুষ প্রথমবার দেখে। Chaos engineering-এর মূল চিন্তা উল্টো: ভাঙুন আগে, নিজের শর্তে, দিনের বেলায় — তাহলে রাত ৩টার incident দিনের ৩টার experiment হয়ে যায়।
কিন্তু যত্নহীন ভাঙা শুধুই noise। শৃঙ্খলা তিনটা জায়গায়: fault ঢোকানো একটা নিয়ন্ত্রিত জায়গা দিয়ে (FaultBus), ফল মাপা এক জায়গায় (metrics), আর সিদ্ধান্ত লেখা একটা ছকে (findings report)। Hypothesis ছাড়া ঢোকানো fault আর slot machine-এ টাকা ঢালার পার্থক্য কিছু নেই — এই lab-এ তাই প্রতিটা run-এর আগে লিখতেই হবে “কী ঘটবে ভাবছি, আর ভুললে বুঝব কী”।
ধাপে ধাপে
১. FaultBus — central injector আর scenario DSL
System-এর সব traffic একটা ফানেলের ভেতর দিয়ে যাবে — ওই ফানেলই FaultBus. Fault-গুলো declarative, কোডে নয় DSL-এ:
name: failover-under-jitter
steps:
- at: 1.0 inject: jitter targets: [n2, n3] base_ms: 40 spread_ms: 60
- at: 3.0 inject: partition groups: [[n1, n2], [n3]]
- at: 5.0 inject: crash targets: [n1]
- at: 7.0 clear: all
duration_s: 10
@dataclass
class Fault:
kind: str # delay | jitter | drop | partition | crash
targets: frozenset[str]
prob: float = 0.0
base_ms: int = 0
spread_ms: int = 0
class FaultBus:
def on_message(self, src, dst, send_fn):
if not self._allowed(src, dst):
self.record("dropped-by-partition", src, dst); return
if f := self._matching_fault(dst, "drop"):
if self.rng.random() < f.prob:
self.record("dropped", src, dst); return
if f := self._matching_fault(dst, ("delay", "jitter")):
d = (f.base_ms + self.rng.uniform(0, f.spread_ms)) / 1000
time.sleep(d)
send_fn()
সুবিধা দুটো: scenario file version-control-এ থাকে, আর bus-এর record log-ই timeline-এর কাঁচামাল। Crash fault বাস্তবে thread kill — in-process victim-এ সেটাই সবচেয়ে সৎ।
Checkpoint: একটা scenario চালিয়ে bus log-এ প্রতিটা step-এর timestamp আর action মিলছে — timeline-এর ভিত্তি এখানেই।
২. Toy replicated system — victim workload
Victim সচেতনে অসম্পূর্ণ রাখুন: primary-backup replication, heartbeat failover, কিন্তু lease বা fencing নেই। কেন? কারণ তখনই partition-এ split-brain-এর দরজা খোলা থাকে — আর আপনার experiment সেটা ধরতে পারে কি না, সেটাই প্রশ্ন।
class ToyNode:
def __init__(self, name, bus):
self.bus = bus # সব send/receive bus-এর হাত ধরে
self.kv = {}
self.role = "follower"
def replicate(self, entry):
self.bus.send(self.primary, "append", entry) # fault এখানেই বসতে পারে
Workload driver-ও instrumented — প্রতিটা client op-এ invoke আর complete timestamp, ফল result। Divergence check-এর জন্য শেষে সব node-এর kv snapshot নিয়ে diff করুন।
Checkpoint: কোনো fault ছাড়া baseline run — p99 latency, error rate 0, divergence 0। Baseline ছাড়া fault-run-এর সংখ্যার কোনো মানে নেই।
৩. Experiment runner — fault matrix sweep
একটা run দর্শন, একটা sweep বিজ্ঞান:
MATRIX = [
{"kind": "drop", "prob": p} for p in (0.01, 0.05, 0.10, 0.25)
] + [
{"kind": "partition", "groups": g} for g in ([["n1"], ["n2", "n3"]],
[["n1", "n2"], ["n3"]])
]
for i, spec in enumerate(MATRIX):
for seed in (11, 23, 47): # প্রতি cell-এ ৩ repetition
res = runner.run(scenario_for(spec), seed=seed)
save(f"results/{spec['kind']}-{i}/seed-{seed}.json", res)
Runner প্রতি run-এ আলাদা directory বানায়: scenario.yaml, metrics.json, timeline.txt, manifest.json। Directory structure-টাই আপনার lab notebook — এক মাস পরেও কোন finding-এর পেছনের data খুঁজে পাওয়ার একমাত্র রাস্তা।
Checkpoint: sweep শেষে একটা summary.csv — প্রতি cell-এ median p95 আর error rate; চোখে পড়বে drop_prob 0.05→0.10-এ কোন metric লাফায়।
৪. Metrics collector
def percentiles(sorted_samples, qs=(50, 95, 99)):
out = {}
for q in qs:
idx = min(len(sorted_samples) - 1, round(q / 100 * len(sorted_samples)))
out[f"p{q}"] = sorted_samples[idx]
return out
def divergence(nodes) -> int:
snaps = [n.snapshot_kv() for n in nodes]
return max(sum(a != b for a, b in zip(s, snaps[0])) for s in snaps)
তিনটা metric-ই দরকার, একটাও কম নয়: latency percentiles বলে user কী অনুভব করছে, error rate বলে কতটা অনুরোধ ফেরত খেয়েছে, divergence বলে ভেতরের state কতটা ছেড়াছাড়ি। Mean latency-তে দেখার নাম মাপা নয় — fault-এর ক্ষতি tail-এ লুকিয়ে থাকে, তাই p95/p99-ই আসল চরিত্র।
Checkpoint: jitter scenario-তে mean প্রায় অপরিবর্তিত থেকে p99 দুই গুণ — এই একটা ফলই percentile কেন দেখতে হয়, তার প্রমাণ।
৫. ASCII timeline renderer
সংখ্যার টেবিলে fault আর ফলের সম্পর্ক চোখে পড়ে না; এক সেকেন্ড-বাকেট timeline-এ পড়ে:
scenario: partition-n1 | seed 11
t(s) 0 2 4 6 8 10
fault ....████████████....│....
errors ....................██....
p95(ms) 12 15 210 480 520 18
diverg. 0 0 1 1 2 2
BUCKET = {"none": ".", "low": "+", "high": "#"}
def render_timeline(records, bucket_s=0.5):
for b_start in range(0, records.duration_s, bucket_s):
faults = records.fault_active(b_start, bucket_s)
errors = records.error_rate(b_start, bucket_s)
print(BUCKET["high" if errors > 0.2 else "low" if errors else "none"],
end="")
Renderer-টাই hypothesis test-এর চোখ — fault window-এর ঠিক পরে error burst এলে causality সন্দেহের জায়গা হয়, fault-এর ভেতরেই এলে resilience-এর প্রমাণ।
Checkpoint: partition heal-এর পরে error rate কত দ্রুত baseline- এ ফেরে — recovery time-টা timeline থেকেই মেপে নোটে লিখুন।
৬. Findings report template
প্রতিটা meaningful run-এর পরে একটা file, এই ছকে:
## Finding: split-brain under n1-partition
- Hypothesis: single-node partition-এ backup promote হবে, heal-এ
পুরনো primary-এর লেখা হারাবে
- Injection: at 3s partition [[n1],[n2,n3]], workload 20 writes/s
- Observation: heal-এর পরে n1-এ 7টা write, বাকিদের সেখানে 12টা;
divergence 5 entries, কোনো alert নেই
- Root cause: promote-এ fencing নেই — দুই primary সমান্তরালে লিখেছে
- Fix + re-run: fencing token যোগ, একই scenario — divergence 0
- Production echo: কোন symptom-এ আজ এই bug সন্দেহ করব
শেষ লাইনটাই সবচেয়ে দামি — lab-এর ফল production intuition-এ বদলানোর সেতু। Template খালি জায়গা সহ repo-তে থাকবে, লেখা ঐচ্ছিক নয়।
Checkpoint: অন্তত তিনটা finding file, প্রতিটাতে fix+re-run অংশ ভরা — re-run ছাড়া finding report অর্ধেক।
৭. Reproducibility — seeded runs
master = random.SeedSequence(seed)
children = master.spawn(3) # bus, workload, scheduler আলাদা stream
bus.rng = random.Random(children[0])
নিয়ম: প্রতিটা component-এর নিজস্ব RNG stream (একটা shared rng হলে component যোগ-বিয়োগ করলেই সব interleaving বদলে যায়), manifest.json-এ seed, scenario hash, code version। Assertion একটাই: একই seed-এ দুই run — timeline byte-identical।
$ python lab.py --scenario s.yaml --seed 11 --verify
run#1 sha256=9f2c... run#2 sha256=9f2c... IDENTICAL ✓
Non-determinism যেখানে ঢুকতে চায় (wall-clock sleep, thread scheduling) — সেখানে virtual clock বা deterministic scheduler নিয়ে আসতে হবে। ওই কষ্টটাই deterministic simulation testing-এর প্রবেশদ্বার।
Checkpoint: CI-তে --verify flag — কেউ nondeterministic কোড
ঢুকিয়ে দিলে build লাল হবে।
নিজেকে চ্যালেঞ্জ করুন
- Clock-skew fault — এক node-এর ঘড়ি ২ সেকেন্ড এগিয়ে দিন, LWW-ধাঁচা logic কোথায় ভাঙে দেখান
- Message reorder fault — FIFO ভেঙে causal delivery gate-এর মতো hold-back লাগে কি না পরীক্ষা করুন
- Slow-disk injection — fsync-এ artificial delay, commit latency-র উপরে ফল মাপুন
- Statistical rigor — প্রতি cell-এ n=30 run, confidence interval-সহ comparison
- Raft-কে victim বানান — নিজের raft implementation project-এর cluster FaultBus-এর পেছনে বসিয়ে election storm মাপুন
- SLO burn-rate — metrics থেকে error budget burn হিসাব করে alert rule-এর ন্যায্যতা প্রমাণ করুন
যা এখানে শিখলেন, পরে যেখানে লাগবে
| এখানে | পরে |
|---|---|
| Scenario DSL + FaultBus | Production chaos platform (Litmus, FIS) পড়া সহজ |
| Percentile-first mindset | SLO/error-budget culture-র প্রথম ভাষা |
| Findings report ছক | Postmortem লেখার অভ্যাস — hypothesis/evidence/fix |
| Seeded determinism | Deterministic simulation testing — FoundationDB-র পথ |