Foundationপ্রথম নীতি থেকে
LEVEL 12কঠিন~১২ ঘণ্টাBashPython

Overlay FS Lab

Overlay FS Lab

নিজের হাতে real `mount -t overlay` চালিয়ে lowerdir/upperdir/merged/workdir-এর priority resolution, copy-up, আর whiteout দেখা, তারপর সেই একই algorithm Python-এ from scratch লিখে যাচাই করা — শেষে একটা mini Dockerfile-স্টাইল image builder দিয়ে সরাসরি Docker layer-এর সাথে যোগসূত্র বসানো।

মাইলস্টোন

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

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

virtualization-cloud/overlay-filesystems-image-layers লেসনে দেখেছেন merged view কীভাবে গণনা হয়, read কখন copy করে না, আর প্রথম write কেন পুরো ফাইল copy-up করে — কিন্তু এসব পড়ে “মেনে নেওয়া” আর নিজের হাতে ls upperdir/ চালিয়ে সেই কপি হওয়া ফাইলটা চোখে দেখা সম্পূর্ণ ভিন্ন জিনিস। এই প্রজেক্টের প্রথম অংশ আপনাকে একটা real Linux মেশিনে (বা VM-এ) mount -t overlay চালাতে বাধ্য করবে, যাতে whiteout-এর character-device marker-টা কোনো abstract বাক্য না থেকে আপনার টার্মিনালে ls -la-র আউটপুটে সরাসরি দেখা একটা জিনিস হয়ে যায়।

কিন্তু এই সাইটের sandbox পরিবেশ (Darwin/macOS) real overlay mount করতে পারে না — Linux kernel module এখানে নেই। তাই দ্বিতীয় অংশে ঠিক একই priority-resolution আর whiteout-hiding নিয়ম Python-এ from scratch লিখে, এই sandbox-এই চালিয়ে, বাস্তবিক আউটপুট দিয়ে যাচাই করা হবে। শেষে একটা ছোট “image builder” বানিয়ে সরাসরি দেখানো হবে — Docker যখন একটা Dockerfile থেকে layer stack বানায়, সেটা আক্ষরিক অর্থে এই একই resolution algorithm-এর ওপর দাঁড়িয়ে।

দুটো ট্র্যাক আলাদা রাখা ইচ্ছাকৃত: ট্র্যাক A (real mount কমান্ড) শুধু আপনার নিজের Linux পরিবেশে চালানো যাবে, এখানে শুধু প্রত্যাশিত আউটপুট দেখানো হচ্ছে। ট্র্যাক B (Python simulation) এই sandbox-এই সত্যিই চালানো হয়েছে, নিচের প্রতিটা আউটপুট copy করা প্রকৃত রান থেকে। কোনটা কোন ট্র্যাকের, সেটা প্রতি ধাপে স্পষ্ট লেখা থাকবে — বাস্তব সিস্টেম আর সিমুলেশন গুলিয়ে ফেলা এই বিষয়ে সবচেয়ে সাধারণ ভুল।

ধাপে ধাপে

১. Setup — overlapping lowerdir তৈরি [ট্র্যাক A, real Linux/VM]

তিনটা lowerdir বানান যাদের কিছু filename ইচ্ছাকৃতভাবে overlap করে, যাতে priority resolution পরীক্ষা করা যায়:

mkdir -p lab/{lower0-base,lower1-mid,lower2-top,upperdir,workdir,merged}
cd lab

echo "base motd"        > lower0-base/etc-motd.txt
echo "base config"      > lower0-base/app-config.txt
echo "base readme"      > lower0-base/app-readme.txt

echo "middle config"    > lower1-mid/app-config.txt   # base-কে override করবে
echo "middle extra"     > lower1-mid/app-extra.txt

echo "top config"       > lower2-top/app-config.txt   # সবচেয়ে বেশি অগ্রাধিকার
echo "top new"          > lower2-top/app-new.txt

লক্ষ করুন app-config.txt তিনটা layer-েই আছে, ভিন্ন content নিয়ে — resolution algorithm ঠিকভাবে কাজ করলে merged view-তে শুধু সবচেয়ে বেশি-অগ্রাধিকারের ভার্সনটাই (top) দেখা যাবে।

Checkpoint: lab/ ডিরেক্টরিতে তিনটা lowerdir, একটা খালি upperdir, একটা খালি workdir — সব প্রস্তুত, merged/ এখনো mount হয়নি।

২. Real overlay mount — priority resolution হাতে দেখা [ট্র্যাক A, real Linux/VM]

আপনার নিজের Linux মেশিনে (বা multipass/VirtualBox/Docker-in-Docker দিয়ে বানানো একটা Linux VM-এ, root privilege লাগবে):

sudo mount -t overlay overlay \
  -o lowerdir=lab/lower2-top:lab/lower1-mid:lab/lower0-base,\
upperdir=lab/upperdir,workdir=lab/workdir \
  lab/merged

lowerdir= তালিকায় বাম দিকেরটাই সর্বোচ্চ অগ্রাধিকার পায় — তাই lower2-top সবার আগে লেখা। এখন:

ls lab/merged/
cat lab/merged/app-config.txt

আপনার real Linux পরিবেশে প্রত্যাশিত আউটপুট:

$ ls lab/merged/
app-config.txt  app-extra.txt  app-new.txt  app-readme.txt  etc-motd.txt

$ cat lab/merged/app-config.txt
top config

পাঁচটা ফাইলই merged view-তে একসাথে দেখা যাচ্ছে (directory merge recursively হয়), কিন্তু app-config.txt-এর content শুধু lower2-top-এর — যদিও নামটা তিনটা layer-েই ছিল। এটাই লেসনের প্রথম নিয়ম: সবচেয়ে উঁচু স্তর যেখানে নামটা পাওয়া যায়, সেটাই জেতে, বাকিগুলো shadow হয়ে যায়, মোছে না।

Checkpoint (ট্র্যাক A): নিজের Linux পরিবেশে ঠিক এই কমান্ড চালিয়ে app-config.txt-এর content top config দেখা — তারপর lower2-top/ থেকে ফাইলটা সরিয়ে (mv) আবার cat করে দেখুন resolution কীভাবে পরের-অগ্রাধিকার layer-এ পড়ে যায়।

৩. Write ট্রিগার করে copy-up — টুকরো বদলে পুরো ফাইল কপি [ট্র্যাক A, real Linux/VM]

app-readme.txt শুধু lower0-base-এ আছে, upperdir-এ এখনো নেই। এখন copy-up-এর আগে-পরে upperdir পরীক্ষা করুন:

echo "--- copy-up-এর আগে ---"
ls lab/upperdir/                       # খালি বা app-config/extra/new নেই

echo "readme-তে এক লাইন যোগ করছি"
echo "extra line" >> lab/merged/app-readme.txt

echo "--- copy-up-এর পরে ---"
ls lab/upperdir/
diff lab/lower0-base/app-readme.txt lab/upperdir/app-readme.txt

প্রত্যাশিত আউটপুট:

--- copy-up-এর আগে ---
(কিছুই নেই, app-readme.txt upperdir-এ নেই)

--- copy-up-এর পরে ---
app-readme.txt

$ diff lab/lower0-base/app-readme.txt lab/upperdir/app-readme.txt
1a2
> extra line

গুরুত্বপূর্ণ অংশটা: শুধু এক লাইন যোগ করা হয়েছিল, কিন্তু app-readme.txt-এর সম্পূর্ণ মূল content আগে lower0-base থেকে upperdir-এ কপি হয়েছে (diff-এর 1a2 লাইনটাই প্রমাণ — পুরনো লাইন অক্ষত আছে, শুধু নতুনটা যোগ হয়েছে), তারপর নতুন লাইনটা লেখা হয়েছে। এই ফাইলটা যদি ৫০০MB হতো, একই এক-লাইন বদলে পুরো ৫০০MB copy-up হতো — লেসনের copy-on-write খরচের দাবিটা এখানে হাতে-কলমে verify হলো।

Checkpoint (ট্র্যাক A): ls -la lab/lower0-base/app-readme.txt lab/upperdir/app-readme.txt দিয়ে দুটো ফাইলের size তুলনা করুন — দুটোই প্রায় সমান (upperdir-েরটা শুধু কয়েক byte বড়, নতুন লাইনের জন্য), pure diff না।

৪. Delete → whiteout — character device marker খুঁজে বের করা [ট্র্যাক A, real Linux/VM]

app-extra.txt শুধু lower1-mid-এ আছে। এখন merged view থেকে মুছুন:

rm lab/merged/app-extra.txt
ls -la lab/upperdir/

প্রত্যাশিত আউটপুট (মনোযোগ দিন file-type কলামে):

$ ls -la lab/upperdir/
total 8
drwxr-xr-x 2 user user   80 Sep  9 10:00 .
drwxr-xr-x 6 user user  160 Sep  9 09:58 ..
-rw-r--r-- 1 user user   23 Sep  9 09:59 app-readme.txt
c--------- 2 root root 0, 0 Sep  9 10:00 app-extra.txt

app-extra.txt-এর লাইনটা -rw-r--r---এর বদলে c--------- দিয়ে শুরু — এই c মানেই character device, আর permission সব dash, আর owner root:root, আর size-এর জায়গায় major/minor device number 0,0 — এটাই লেসনে বর্ণিত whiteout marker, আক্ষরিক অর্থে। কোনো xattr না, কোনো বিশেষ ফাঁকা ফাইল না — একটা real device-node inode, যা কোনো স্বাভাবিক ফাইল কখনো হতে পারে না বলেই unambiguous।

মূল ফাইলটা lower layer-এ অক্ষত আছে তা যাচাই করুন:

cat lab/lower1-mid/app-extra.txt      # এখনো "middle extra" দেখাবে
ls lab/merged/                        # app-extra.txt আর নেই এখানে

Checkpoint (ট্র্যাক A): stat lab/upperdir/app-extra.txt চালিয়ে File type: character special file আর Device type: 0,0 — দুটো লাইন নিজের চোখে দেখা।

৫. resolve_merged_view() — from scratch লিখে চালানো [ট্র্যাক B, এই sandbox-এ সত্যিই চালানো]

উপরের চারটা ধাপের algorithm এবার Python-এ, কোনো real mount ছাড়া:

# resolve_sim.py
WHITEOUT = object()


def resolve_merged_view(lowerdirs, upperdir):
    """lowerdirs: dict-এর list, index 0 = সবচেয়ে কম অগ্রাধিকার (base),
    শেষেরটা = lowerdir-গুলোর মধ্যে সবচেয়ে বেশি অগ্রাধিকার। upperdir
    সবসময় সবার ওপরে। এটাই ধাপ ২-৪-এর ঠিক একই resolution + whiteout-hiding
    নিয়ম, শুধু kernel না, প্লেইন Python dict দিয়ে।"""
    layers = list(lowerdirs) + [upperdir]
    result = {}
    for layer in layers:
        for name, content in layer.items():
            if content is WHITEOUT:
                result.pop(name, None)
            else:
                result[name] = content
    return result


if __name__ == "__main__":
    lower0_base = {
        "etc/motd": "base motd",
        "app/config.txt": "base config",
        "app/readme.txt": "base readme",
    }
    lower1_middle = {
        "app/config.txt": "middle config",
        "app/extra.txt": "middle extra",
    }
    lower2_top = {
        "app/config.txt": "top config",
        "app/new.txt": "top new",
    }
    upperdir = {}

    view = resolve_merged_view([lower0_base, lower1_middle, lower2_top], upperdir)
    for name in sorted(view):
        print(f"{name:20s} -> {view[name]!r}")

    print()
    print("--- upperdir-এ একটা whiteout বসিয়ে app/extra.txt মুছে ফেলার পর ---")
    upperdir["app/extra.txt"] = WHITEOUT
    view2 = resolve_merged_view([lower0_base, lower1_middle, lower2_top], upperdir)
    for name in sorted(view2):
        print(f"{name:20s} -> {view2[name]!r}")
    print(f"'app/extra.txt' merged-এ আছে? {'app/extra.txt' in view2}")
    print(f"lower1_middle-এ এখনো আছে? {'app/extra.txt' in lower1_middle}")

এই মেশিনে সত্যিই চালিয়ে পাওয়া output (deterministic, dict insertion order-নির্ভর না — sorted() দিয়ে print করা):

app/config.txt       -> 'top config'
app/extra.txt        -> 'middle extra'
app/new.txt          -> 'top new'
app/readme.txt       -> 'base readme'
etc/motd             -> 'base motd'

--- upperdir-এ একটা whiteout বসিয়ে app/extra.txt মুছে ফেলার পর ---
app/config.txt       -> 'top config'
app/new.txt          -> 'top new'
app/readme.txt       -> 'base readme'
etc/motd             -> 'base motd'
'app/extra.txt' merged-এ আছে? False
lower1_middle-এ এখনো আছে? True

প্রথম block-টা ঠিক ধাপ ২-এর real ls merged/ আউটপুটের সাথে মিলে যায় — app/config.txt-এর জন্য top config জিতেছে, বাকি তিনটা ফাইল যার যার স্তর থেকে দেখা যাচ্ছে। দ্বিতীয় block ধাপ ৪-এর whiteout প্রমাণ করে: app/extra.txt merged view থেকে অদৃশ্য, কিন্তু lower1_middle dict-এ (আসল lower layer) অক্ষত রয়ে গেছে — ঠিক real kernel-এর whiteout সেমান্টিক্স।

Checkpoint: নিজের মেশিনে python3 resolve_sim.py চালিয়ে হুবহু এই আউটপুট পাওয়া, তারপর কোড বদলে একটা চতুর্থ lowerdir যোগ করে দেখান resolution ঠিক থাকে — একটা নতুন সবচেয়ে-বেশি-অগ্রাধিকার layer যোগ করলে তার filename-গুলোই সবার ওপরে জেতে।

৬. Mini image builder — Dockerfile instruction থেকে layer stack [ট্র্যাক B, এই sandbox-এ সত্যিই চালানো]

এবার ধাপ ৫-এর resolution ব্যবহার করে একটা ছোট টুল বানান, যা ADD/RUN-modify/RUN-delete টাইপের instruction-এর একটা sequence থেকে layer stack বানায় — ঠিক Docker যেভাবে প্রতিটা filesystem-বদলানো Dockerfile লাইনকে একটা নতুন layer বানায়:

# image_builder.py
WHITEOUT = object()


def resolve_merged_view(layers):
    result = {}
    for layer in layers:
        for name, content in layer.items():
            if content is WHITEOUT:
                result.pop(name, None)
            else:
                result[name] = content
    return result


def build_image(instructions):
    """instructions: [(op, name, value), ...] --
    ('ADD', filename, content) | ('RUN-modify', filename, new_content) |
    ('RUN-delete', filename, None). প্রতিটা instruction ঠিক একটা নতুন
    layer বানায়, আগেরগুলোর ওপর স্ট্যাক হয় -- ঠিক Dockerfile-এর
    RUN/COPY/ADD প্রতিটা এক নতুন layer বানানোর মতো।"""
    layers = []
    for op, name, value in instructions:
        layer = {}
        if op == "ADD":
            layer[name] = value
        elif op == "RUN-modify":
            layer[name] = value
        elif op == "RUN-delete":
            layer[name] = WHITEOUT
        else:
            raise ValueError(f"unknown op {op}")
        layers.append((f"{op} {name}", layer))
    return layers


if __name__ == "__main__":
    instructions = [
        ("ADD", "app/main.py", "print('v1')"),
        ("ADD", "app/config.json", '{"debug": false}'),
        ("RUN-modify", "app/config.json", '{"debug": true}'),
        ("ADD", "app/data/seed.csv", "id,name\n1,alice\n"),
        ("RUN-delete", "app/data/seed.csv", None),
        ("RUN-modify", "app/main.py", "print('v2')"),
    ]

    built = build_image(instructions)

    print("--- তৈরি হওয়া layer তালিকা (build-ক্রমে, নিচ থেকে ওপরে) ---")
    for i, (label, layer) in enumerate(built):
        content_desc = {
            k: ("<WHITEOUT>" if v is WHITEOUT else v) for k, v in layer.items()
        }
        print(f"layer{i}: {label:30s} -> {content_desc}")

    layers_only = [layer for _, layer in built]
    final_view = resolve_merged_view(layers_only)

    print()
    print("--- চূড়ান্ত merged view (সব লেয়ার স্ট্যাক করে resolve করার পর) ---")
    for name in sorted(final_view):
        print(f"{name:20s} -> {final_view[name]!r}")
    print(f"'app/data/seed.csv' merged-এ আছে? {'app/data/seed.csv' in final_view}")

এই মেশিনে সত্যিই চালিয়ে পাওয়া output:

--- তৈরি হওয়া layer তালিকা (build-ক্রমে, নিচ থেকে ওপরে) ---
layer0: ADD app/main.py                -> {'app/main.py': "print('v1')"}
layer1: ADD app/config.json            -> {'app/config.json': '{"debug": false}'}
layer2: RUN-modify app/config.json     -> {'app/config.json': '{"debug": true}'}
layer3: ADD app/data/seed.csv          -> {'app/data/seed.csv': 'id,name\n1,alice\n'}
layer4: RUN-delete app/data/seed.csv   -> {'app/data/seed.csv': '<WHITEOUT>'}
layer5: RUN-modify app/main.py         -> {'app/main.py': "print('v2')"}

--- চূড়ান্ত merged view (সব লেয়ার স্ট্যাক করে resolve করার পর) ---
app/config.json      -> '{"debug": true}'
app/main.py          -> "print('v2')"
'app/data/seed.csv' merged-এ আছে? False

ছয়টা layer তৈরি হয়েছে, প্রতিটা instruction থেকে ঠিক একটা — কিন্তু চূড়ান্ত merged view-তে মাত্র দুটো ফাইল বেঁচে আছে। app/config.json-এর জন্য layer2-এর RUN-modify layer1-এর মূল ভার্সন shadow করেছে (ধাপ ২-এর একই নিয়ম)। app/data/seed.csv তৈরি হয়েছিল layer3-এ, কিন্তু layer4-এর whiteout সেটাকে merged view থেকে মুছে দিয়েছে — যদিও ফাইলটা এখনো layer3-এর dict-এ অক্ষত আছে, ঠিক যেমন একটা real Docker image-এ পুরনো layer-গুলো ডিস্কে অক্ষত থেকে যায়, শুধু পরের layer-এর কাছে shadow/whiteout হয়ে যায়। এটাই সরাসরি সেই যন্ত্র, যার ওপর docker build-এর প্রতিটা layer দাঁড়িয়ে।

Checkpoint: image_builder.py চালিয়ে হুবহু এই আউটপুট পাওয়া, তারপর একটা সপ্তম instruction (("RUN-delete", "app/main.py", None)) যোগ করে দেখান final merged view-তে app/main.py-ও অদৃশ্য হয়ে যায় — একটা image-এর প্রতিটা ফাইল যেকোনো পরের layer দিয়ে override বা delete হতে পারে, এমনকি সেই ফাইল যদি সবচেয়ে প্রথম layer-েই আসে।

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

  1. আপনার নিজের Linux VM-এ একটা বড় (১GB+) ফাইল lowerdir-এ রেখে, তাতে এক byte বদলে copy-up-এর real latency মাপুন (time কমান্ড দিয়ে) — ফাইলের আকার দ্বিগুণ করলে latency-ও মোটামুটি দ্বিগুণ হয় কি না যাচাই করুন
  2. resolve_merged_view()-এ opaque directory সাপোর্ট যোগ করুন — লেসনের trusted.overlay.opaque=y মেকানিজম, যেখানে একটা গোটা directory-র lower content একটা মাত্র flag দিয়ে আড়াল হয়ে যায়, প্রতিটা child আলাদা whiteout না করেই
  3. image_builder.py-তে content-hash ভিত্তিক layer deduplication যোগ করুন — দুটো layer-এর content বাইট-বাইট এক হলে তাদের একই physical layer হিসেবে চিহ্নিত করুন (SHA-256, ঠিক OCI diffID-এর মতো), আর দেখান কতগুলো layer-reference আসলে ডিস্কে unique
  4. একটা docker save-স্টাইল ছোট tarball exporter বানান — আপনার simulated layer stack-কে একটা আসল .tar ফাইলে serialize করুন, প্রতিটা layer একটা আলাদা এন্ট্রি, সাথে একটা manifest JSON যা layer-ক্রম বর্ণনা করে
  5. write()-এর জন্য partial copy-up অপ্টিমাইজেশন গবেষণা করুন — আধুনিক kernel-এ কিছু ফাইল সিস্টেম (যেমন dm-thin বা reflink-সমর্থিত btrfs/XFS-এ overlay) পুরো ফাইল copy না করে reflink/COW extent শেয়ার করতে পারে কি না, আর OverlayFS ডিফল্টে কেন সেটা করে না তা লিখুন
  6. real mount -t overlay-এ একই filename দুইটা ভিন্ন type-এর হলে কী হয় (একটা lowerdir-এ regular file, ওপরের lowerdir-এ একই নামে directory) — হাতে পরীক্ষা করে resolution rule-টা বের করুন, তারপর resolve_merged_view()-এ সেই rule যোগ করুন

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

এখানেপরে
lowerdir/upperdir priority resolutionvirtualization-cloud/container-runtime-oci-এ runc কীভাবে rootfs mount করে বোঝা
copy-up-এর পুরো-ফাইল খরচProduction container-এ stateful ডেটা কেন volume/bind mount-এ রাখা হয়, তার ইঞ্জিনিয়ারিং কারণ
character-device whiteout marker/var/lib/docker/overlay2/ ডিরেক্টরি নিজে খুঁজে debug করার সময় “এটা কী ফাইল” প্রশ্নের উত্তর
layer stack থেকে merged view resolveDocker/OCI image layer caching, docker history, আর multi-stage build অপ্টিমাইজেশন পড়া