Overlay Filesystem ও Image Layer — একই বেস, হাজার container, একটাই কপি
Overlay Filesystems and Image Layers
একই মেশিনে একশোটা container, প্রতিটাই একটা Ubuntu base image থেকে বানানো — যদি প্রতিটার filesystem-এর নিজস্ব, সম্পূর্ণ কপি লাগত, ডিস্ক আর pull-time দুটোই দ্রুত অসহনীয় হয়ে উঠত। এই লেসন সেই সমস্যার সমাধান — OverlayFS-এর lowerdir/upperdir/merged/workdir মেকানিজম, copy-on-write-এর নির্ভুল সেমান্টিক্স, আর কীভাবে Docker image layer এই একই যন্ত্রের ওপর দাঁড়িয়ে content-addressing দিয়ে ডিস্ক আর ব্যান্ডউইথ দুটোই বাঁচায় — এই তিনটাকে একটা একক, ধারাবাহিক ব্যাখ্যায় জোড়া দেয়।
আগে এটা বুঝি
ধরুন একটা মেশিনে ১০০টা container চালাতে হবে, প্রতিটাই FROM ubuntu:22.04 দিয়ে বানানো একটা image থেকে। operating-systems/filesystem-inodes লেসনে দেখা হয়েছিল একটা alpine/ubuntu image আসলে কোনো চলমান OS না — শুধু একগুচ্ছ userspace ফাইল (libc, coreutils, shell), যা container-এর mount namespace-এ বসানো হয়। প্রশ্নটা এবার আরো নির্দিষ্ট করে তোলা যাক: সেই ১০০টা container-এর প্রতিটার কি Ubuntu-র সম্পূর্ণ ফাইল-সেটের নিজস্ব একটা কপি লাগবে?
উত্তরটা যদি “হ্যাঁ” হতো — প্রতিটা container নিজের জন্য পুরো ~৭০-১০০MB Ubuntu rootfs copy করত — তাহলে দুটো জিনিস অসহনীয় হয়ে উঠত। প্রথমত, ডিস্ক: ১০০টা container মানে ১০০ × ~৮০MB ≈ ৮GB শুধু একই, অভিন্ন base ফাইলের পুনরাবৃত্তির জন্য, যেখানে প্রতিটা container হয়তো নিজে থেকে বদলাচ্ছে মাত্র কয়েক কিলোবাইট (একটা config ফাইল, একটা লগ)। দ্বিতীয়ত, pull-time: প্রতিবার একটা নতুন image pull করলে যদি প্রতিটা layer সম্পূর্ণ নতুন করে ডাউনলোড হতো — এমনকি একই ubuntu:22.04 base যা আপনার মেশিনে ইতিমধ্যেই দশবার আছে — তাহলে CI pipeline-এর প্রতিটা build মিনিটের পর মিনিট শুধু নেটওয়ার্কে অপচয় হতো।
বাস্তবে কোনোটাই ঘটে না। docker pull ubuntu:22.04 করে তারপর দুইটা ভিন্ন image FROM ubuntu:22.04 দিয়ে build করলে, দ্বিতীয় build-এ Ubuntu layer আবার ডাউনলোড হয় না — Docker সরাসরি বলে দেয় Using cached layer বা layer ID মিলে গেলে re-pull স্কিপ করে। আর রানটাইমে, ১০০টা container-এর প্রতিটাই তার filesystem-এর জন্য কার্যত শূন্যের কাছাকাছি অতিরিক্ত ডিস্ক ব্যবহার করে — যতক্ষণ না সে নিজে থেকে কিছু লেখে।
এই দুটো ঘটনার (build-time layer sharing, run-time near-zero-copy filesystem) পেছনে একটাই kernel মেকানিজম — union mount, নির্দিষ্টভাবে Linux-এ যার বাস্তবায়নের নাম OverlayFS। এই লেসনের কাজ: OverlayFS-এর নির্ভুল মেকানিক্স বোঝা, তার copy-on-write সেমান্টিক্স হাতে-কলমে ট্রেস করা, আর তারপর দেখানো Docker-এর “image layer” ধারণাটা এই একই যন্ত্রের ওপর কীভাবে সরাসরি বসানো — যাতে “layer caching” আর “Dockerfile-এ instruction order matters” মতো পরামর্শগুলো আর জাদু না থেকে, প্রকৌশলগত কারণ হয়ে দাঁড়ায়।
মূল ধারণা
Union mount — একাধিক directory-কে একটা একক view-তে সেলাই করা
একটা union mount একাধিক, ভিন্ন ভিন্ন directory-কে একটা একক, মিলিত (“merged”) view-তে দেখায় — প্রতিটা আলাদা directory তার নিজের জায়গায় ডিস্কে অক্ষত থেকে যায়, কোনো ডেটা সত্যিকারভাবে একজায়গায় জড়ো হয় না। Linux kernel-এ এই ধারণার আধুনিক, mainline বাস্তবায়নের নাম OverlayFS (২০১৪ সাল থেকে kernel 3.18-এ mainline, তার আগে কয়েক বছর আউট-অফ-ট্রি প্যাচ হিসেবে ছিল)।
OverlayFS চারটা directory নিয়ে কাজ করে, প্রতিটার একদম নির্দিষ্ট, পরস্পর-অবিনিময়যোগ্য ভূমিকা:
| উপাদান | ভূমিকা | container-এর প্রেক্ষাপটে |
|---|---|---|
| lowerdir | এক বা একাধিক read-only layer, অগ্রাধিকার-ক্রমে স্ট্যাক করা | image-এর প্রতিটা layer — কখনো লেখা হয় না |
| upperdir | ঠিক একটা, writable layer — এই mount-এ যা কিছু নতুন লেখা হয় সব এখানে যায় | এই একটা container-এর নিজস্ব পরিবর্তন |
| merged | container/process যা আসলে দেখে — সবগুলো layer-এর মিলিত view | container-এর rootfs, / |
| workdir | OverlayFS-এর নিজস্ব অভ্যন্তরীণ scratch space — কখনো সরাসরি পড়া/লেখা হয় না | কেউ সরাসরি ছোঁয় না, এমনকি container-ও না |
বাস্তব ব্যবহার একটা mount কমান্ডেই দেখা যায়:
mount -t overlay overlay \
-o lowerdir=/img/layer2:/img/layer1:/img/layer0,upperdir=/container/upper,workdir=/container/work \
/container/mergedলক্ষ করার মতো একটা খুঁটিনাটি — lowerdir কোলন (:) দিয়ে আলাদা করা তালিকায়, বাম দিকেরটাই সবচেয়ে বেশি অগ্রাধিকার পায় (সবচেয়ে “উপরের” lower layer)। উপরের উদাহরণে layer2 সবচেয়ে নতুন, layer0 সবচেয়ে পুরনো/base — ঠিক যেমন একটা Dockerfile-এ পরের RUN/COPY instruction আগেরটার ওপর বসে।
merged কীভাবে গণনা হয় — resolution algorithm
merged-এ কোনো ফাইল দেখতে গেলে kernel একটা নির্দিষ্ট নিয়মে স্তরগুলো (upperdir সবার ওপরে, তারপর lowerdir-গুলো অগ্রাধিকার-ক্রমে) স্ক্যান করে:
- সবচেয়ে উঁচু স্তর যেখানে নামটা পাওয়া যায়, সেটাই জেতে। upperdir-এ
etc/passwdথাকলে সেটাই দেখানো হয়, lowerdir-এ থাকা পুরনো সংস্করণ আড়ালে চলে যায় — মুছে যায় না, শুধু shadow হয়ে যায়। - directory-র ক্ষেত্রে merge হয় recursively। একটা directory যদি একাধিক স্তরে থাকে, প্রতিটা স্তরের ভেতরের entry-গুলো একসাথে মিলিয়ে দেখানো হয় (ফাইল-level shadowing একই নিয়মে প্রযোজ্য) — পুরো directory replace হয় না, শুধু conflicting নামগুলো।
- whiteout marker upperdir-এ থাকলে সেই নাম merged-এ সম্পূর্ণ অদৃশ্য, লোয়ার স্তরে থাকা সত্ত্বেও (পরের সেকশনে বিস্তারিত)।
lowerdir (base, সবচেয়ে কম lowerdir (মাঝের, upperdir (এই
অগ্রাধিকার) উচ্চতর অগ্রাধিকার) container-এর, সর্বোচ্চ)
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ bin/bash │ │ usr/bin/curl │ │ etc/passwd (নতুন│
│ etc/passwd (মূল) │ │ │ │ ইউজার যোগ হয়েছে)│
│ etc/os-release │ │ │ │ app/secret.txt │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└──────────────────┬───────────────┴────────────────────────┘
▼
merged (container দেখে এইটা)
┌────────────────────────────────────┐
│ bin/bash ← lower(base) থেকে │
│ etc/passwd ← upper থেকে (shadow করেছে lower-কে) │
│ etc/os-release ← lower(base) থেকে │
│ usr/bin/curl ← lower(মাঝের) থেকে │
│ app/secret.txt ← upper থেকে, শুধু এখানেই ছিল │
└────────────────────────────────────┘workdir — কেন এটা লাগে, container যাকে কখনো ছোঁয় না
workdir-এর ভূমিকা প্রায়ই ভুল বোঝা হয় কারণ merged view-তে এর কোনো অস্তিত্বই দেখা যায় না — সেটাই ঠিক ইচ্ছাকৃত। OverlayFS-এর কিছু অপারেশন — বিশেষত copy-up (পরের সেকশনের বিষয়) আর rename — atomic হতে হবে: হয় পুরোপুরি সম্পন্ন হবে, নয়তো একদমই হবে না, মাঝপথে কোনো অসম্পূর্ণ অবস্থা রাখা যাবে না (একটা power-loss বা crash-এর মাঝপথে ধরা পড়লেও)। এই atomicity বাস্তবায়নের জন্য kernel-এর একটা অস্থায়ী, লুকানো জায়গা দরকার যেখানে সে আধা-সম্পন্ন অপারেশন প্রস্তুত করতে পারে, তারপর একটা একক atomic rename দিয়ে তা upperdir-এ “প্রকাশ” করতে পারে।
workdir অবশ্যই upperdir-এর মতো একই filesystem-এ হতে হবে (rename একই filesystem-এর মধ্যে atomic, ভিন্ন filesystem-এর মধ্যে না) আর mount করার সময় খালি থাকতে হবে — kernel নিজেই এর ভেতরের কাঠামো পরিচালনা করে, কোনো userspace process (container-এর ভেতরের বা বাইরের) এর সাথে সরাসরি interact করার কথা না।
ভেতরে কী ঘটছে
Copy-on-write — নির্ভুল সেমান্টিক্স
operating-systems/filesystem-inodes লেসনে দেখা হয়েছিল inode-এ ফাইলের নাম থাকে না, ঠিক নামের মতোই এখানেও একটা সূক্ষ্ম-কিন্তু-সিদ্ধান্তকারী নিয়ম আছে যা OverlayFS-এর প্রায় সবকিছু ব্যাখ্যা করে দেয়:
Read সরাসরি সবচেয়ে উঁচু স্তর থেকে হয়, যেখানেই ফাইলটা থাকুক — কোনো copy ছাড়াই। Write-এর প্রথমবার, যদি ফাইলটা এখনো upperdir-এ না থাকে, পুরো ফাইলটা আগে upperdir-এ copy হয় (“copy-up”), তারপরই write সেই কপির ওপর প্রয়োগ হয়।
Read — সবসময় zero-copy passthrough
bin/bash যদি শুধু lowerdir-এ থাকে (base image layer), তাহলে container-এর ভেতর থেকে bin/bash পড়লে kernel সরাসরি সেই lower layer-এর data block থেকে পড়ে — কোনো copy হয় না, upperdir-এ কিছু লেখা হয় না, একটাও extra byte ডিস্কে বরাদ্দ হয় না। শত শত container একই base image থেকে একই bin/bash পড়লেও তারা আক্ষরিক অর্থে একই ডিস্ক ব্লক পড়ছে — page cache-এও এটা একবারই থাকে, প্রতিটা container-এর জন্য আলাদা কপি না।
Write — প্রথমবার সবসময় পুরো ফাইল কপি হয়
কোনো container যদি একটা lower-only ফাইলে লিখতে চায় (এমনকি শুধু একটা byte বদলাতে, বা শুধু metadata — permission, timestamp — বদলাতে চায়), OverlayFS নিচের ধাপগুলো নেয়:
- চেক করে ফাইলটা ইতিমধ্যেই
upperdir-এ আছে কি না। - না থাকলে: সবচেয়ে উঁচু lower layer যেখানে ফাইলটা আছে, সেখান থেকে পুরো ফাইলের সম্পূর্ণ কনটেন্ট (metadata-সহ)
upperdir-এ কপি করা হয় — এই ধাপটাই “copy-up”। - তারপরই আসল write অপারেশনটা প্রয়োগ হয়, কিন্তু upperdir-এর সেই নতুন কপির ওপর — lower layer-এর মূল কপি কখনো স্পর্শ পর্যন্ত হয় না, সবসময় read-only থেকে যায়।
এর ফলে একটা গুরুত্বপূর্ণ, প্রায়ই surprising পারফরম্যান্স ধরন তৈরি হয়: copy-up-এর খরচ পুরো ফাইলের আকারের সমানুপাতিক, আপনি কতটুকু বদলাচ্ছেন তার সাথে সম্পর্কহীন। একটা ২GB ডেটাবেস ফাইলে একটা মাত্র byte বদলালে, যদি সেই ফাইলটা এখনো শুধু lowerdir-এ থাকে, পুরো ২GB প্রথমে copy-up হবে — তারপর সেই ১ byte লেখা হবে upperdir-এর কপিতে।
Delete — whiteout, মোছা না
একটা lower-only ফাইল “মুছে ফেলা” আসলে মোছা না — এটা সম্ভবই না, কারণ lowerdir চিরকাল read-only। container-এর ভেতর থেকে rm চালালে kernel upperdir-এ একটা বিশেষ marker বসায়, যাকে বলে whiteout।
উদাহরণ
Docker image layer — ঠিক এই যন্ত্রের ওপর বসানো
একটা Dockerfile-এর প্রতিটা instruction যা filesystem বদলায় (RUN, COPY, ADD — কিন্তু ENV/CMD/LABEL না, এগুলো শুধু metadata, filesystem না) একটা নতুন layer তৈরি করে — কার্যত একটা tarball diff, “আগের layer-এর তুলনায় কী কী ফাইল যোগ/বদল/মুছে গেছে” তার একটা রেকর্ড।
- FROM ubuntu:22.04base image-এর সব layer টেনে আনে — এগুলোই এখন সবচেয়ে নিচের lowerdir স্তর
- RUN apt-get install -y curlনতুন layer: base-এর diff হিসেবে শুধু curl-সংক্রান্ত নতুন/বদলানো ফাইল ধরে রাখে
- RUN pip install -r requirements.txtআরেকটা নতুন layer, আগের সব layer-এর ওপর
- COPY . /appশেষ layer — অ্যাপ্লিকেশন সোর্স কোড, সাধারণত সবচেয়ে ঘন ঘন বদলায়
- image-এর সবগুলো layer → lowerdirউপরের ৪টা layer-ই এখন colon-separated lowerdir তালিকা, build-ক্রমের বিপরীতে (শেষেরটা সবচেয়ে উঁচু অগ্রাধিকার)
- নতুন, সম্পূর্ণ খালি upperdir বরাদ্দপ্রতিটা নতুন container-এর নিজস্ব, খালি writable layer — image-এর কোনো layer এখনও স্পর্শ হয়নি
- workdir বরাদ্দ (অভ্যন্তরীণ)copy-up/rename-এর জন্য kernel-এর নিজস্ব scratch space
- mount(overlay, lowerdir=..., upperdir=..., workdir=...) → mergedএই merged view-ই container-এর rootfs (root path) হিসেবে pivot_root হয়
এই দুটো trace পাশাপাশি রাখলে পুরো ছবিটা এক জায়গায় ধরা পড়ে: build-time-এ layer তৈরি হয় (প্রতিটা instruction = এক layer, স্থায়ী, image-এর অংশ হিসেবে ডিস্কে/registry-তে সংরক্ষিত), আর run-time-এ সেই একই layer-গুলো শুধু পড়া হয়, lowerdir হিসেবে, একটা fresh upperdir দিয়ে। container বন্ধ হয়ে গেলে (docker rm) সেই upperdir-টাই মুছে যায় — image-এর কোনো layer অপরিবর্তিত থেকে যায়, নিখুঁতভাবে পরের container-এর জন্যও পুনর্ব্যবহারযোগ্য।
Content-addressing — কেন layer শেয়ার হয়, শুধু cache হয় না
OCI Image Specification অনুযায়ী প্রতিটা layer-এর দুটো ভিন্ন hash আছে: digest (compressed tarball blob-এর hash, নেটওয়ার্কে ট্রান্সফারের জন্য) আর diffID (uncompressed layer content-এর hash, layer-এর প্রকৃত পরিচয় — “চেইন ID” গণনার ভিত্তি)। গুরুত্বপূর্ণ অংশটা: এই hash content থেকে বের হয়, কোনো নাম বা তারিখ থেকে না। যদি দুইটা সম্পূর্ণ ভিন্ন image-এর RUN apt-get install -y curl layer বাইট-বাইট অভিন্ন হয় (একই base, একই instruction, একই সময়ে বানানো), তাদের diffID একই — আর Docker/OCI-compliant registry-র storage layer সেটাকে একটাই ফিজিক্যাল কপি হিসেবে রাখে।
এটাই সেই যান্ত্রিকতা যা lesson-এর শুরুর দাবিটাকে সত্যি করে — ১০০টা container একটা ubuntu:22.04 layer-এর একটাই কপি ডিস্কে শেয়ার করে, কারণ তাদের সবার সংশ্লিষ্ট layer-এর diffID অভিন্ন, “cache” নামের কোনো heuristic-এর জন্য না, বরং content identity-র সরাসরি ফলাফল হিসেবে।
ছবিতে
একটা container-এর পুরো ছবি — build থেকে run পর্যন্ত
IMAGE (স্থায়ী, ডিস্কে/registry-তে) CONTAINER (ক্ষণস্থায়ী, রানটাইমে)
────────────────────────────── ──────────────────────────────
layer3: COPY . /app ─┐
layer2: RUN pip install ... │ diffID দিয়ে lowerdir=layer3:layer2:layer1:layer0
layer1: RUN apt-get install..│ content-addressed ┌─────────────────────────────┐
layer0: FROM ubuntu:22.04 ─┘ (read-only) │ ★ upperdir (এই container-এর│
│ নিজস্ব, খালি শুরু হওয়া, │
┌──────────────────────┐ │ writable layer) │
│ অন্য যেকোনো image │ └─────────────────────────────┘
│ যদি একই layer0/1 হ্যাশ │──── শেয়ার করে, আবার │
│ ব্যবহার করে │ ডাউনলোড/স্টোর হয় না ▼
└──────────────────────┘ merged = container-এর rootfs
(docker rm হলে শুধু upperdir মুছে যায়,
image-এর layer-গুলো অক্ষত থাকে)নিজে চালিয়ে দেখুন
OverlayFS resolution ও copy-on-write সিমুলেশন — সত্যিকারভাবে চালানো
# overlay_sim.py -- a faithful simulation of the OverlayFS union-mount
# resolution algorithm and copy-on-write semantics described in the kernel's
# Documentation/filesystems/overlayfs.rst.
WHITEOUT = object() # sentinel: "this file is deleted at this layer"
class BinaryFile:
"""Stand-in for a real binary file's bytes -- we only care about its
SIZE for the copy-up cost demonstration, not its actual content."""
__slots__ = ("size", "desc")
def __init__(self, size, desc):
self.size = size
self.desc = desc
def __len__(self):
return self.size
def __repr__(self):
return f"<{self.desc}, {self.size:,} bytes>"
class OverlayFS:
def __init__(self, lowerdirs, upperdir=None):
self.lowerdirs = lowerdirs # bottom(0) -> top(highest priority lower)
self.upperdir = upperdir if upperdir is not None else {}
self.copy_up_log = [] # (filename, source_layer_index, bytes_copied)
def _layers_bottom_to_top(self):
return list(enumerate(self.lowerdirs)) + [("upper", self.upperdir)]
def merged_view(self):
result = {}
for _, layer in self._layers_bottom_to_top():
for name, content in layer.items():
if content is WHITEOUT:
result.pop(name, None)
else:
result[name] = content
return result
def read(self, filename):
# top-down: first layer that has the name wins -- no copy, ever.
for _, layer in reversed(self._layers_bottom_to_top()):
if filename in layer:
content = layer[filename]
return None if content is WHITEOUT else content
raise FileNotFoundError(filename)
def write(self, filename, new_content):
# copy-up: if not already in upperdir, copy the WHOLE file up first.
if filename not in self.upperdir:
for idx, layer in reversed(list(enumerate(self.lowerdirs))):
if filename in layer and layer[filename] is not WHITEOUT:
size = len(layer[filename])
self.copy_up_log.append((filename, idx, size))
self.upperdir[filename] = layer[filename]
break
self.upperdir[filename] = new_content
def delete(self, filename):
# can never touch a lower layer -- write a whiteout marker instead.
self.upperdir[filename] = WHITEOUT
if __name__ == "__main__":
layer0_base = {
"etc/os-release": "Ubuntu 22.04",
"bin/bash": BinaryFile(1_234_567, "bash binary"),
"usr/lib/libc.so.6": BinaryFile(2_000_000, "libc shared library"),
"etc/passwd": "root:x:0:0:root:/root:/bin/bash\n",
}
layer1_curl = {"usr/bin/curl": BinaryFile(200_000, "curl binary")}
layer2_app = {"app/main.py": "print('hello')", "app/config.json": '{"debug": true}'}
fs = OverlayFS(lowerdirs=[layer0_base, layer1_curl, layer2_app])
# ২: lower-only ফাইল পড়া -- কোনো copy হয় না
content = fs.read("bin/bash")
print(f"read('bin/bash') -> {content!r}")
print(f"copy_up_log এখনো: {fs.copy_up_log}")
# ৩: lower-only ফাইলে প্রথম write -- copy-up ট্রিগার
old = fs.read("etc/passwd")
fs.write("etc/passwd", old + "alice:x:1000:1000:Alice:/home/alice:/bin/bash\n")
print(f"copy_up_log: {fs.copy_up_log}")
# ৪: বড় ফাইলে "১ বাইট" বদল -- copy-up পুরো ফাইলের আকারে
fs.write("usr/lib/libc.so.6", BinaryFile(2_000_000, "libc shared library (patched)"))
fname, src_idx, nbytes = fs.copy_up_log[-1]
print(f"copy-up: filename={fname!r}, source_layer_index={src_idx}, bytes_copied={nbytes:,}")
# ৫: lower-only ফাইল delete -- whiteout
fs.delete("app/config.json")
print(f"'app/config.json' merged_view-এ আছে? {'app/config.json' in fs.merged_view()}")
print(f"layer2_app-এ এখনো আছে? {'app/config.json' in layer2_app}")প্রকৃত চালিয়ে পাওয়া output (এই মেশিনে; deterministic, প্রতিবার হুবহু একই):
read('bin/bash') -> <bash binary, 1,234,567 bytes>
copy_up_log এখনো: []
copy_up_log: [('etc/passwd', 0, 32)]
copy-up: filename='usr/lib/libc.so.6', source_layer_index=0, bytes_copied=2,000,000
'app/config.json' merged_view-এ আছে? False
layer2_app-এ এখনো আছে? Trueতিনটা লাইন এই লেসনের কেন্দ্রীয় দাবিগুলো সংখ্যায় প্রমাণ করে। প্রথম লাইন: read copy_up_log খালি রাখে — কোনো copy হয়নি। দ্বিতীয়/তৃতীয় লাইন: etc/passwd-এ মাত্র একটা নতুন লাইন যোগ করতে ৩২ বাইট copy-up হয়েছে (পুরনো ফাইলের আকার) — আর libc-তে conceptually “১ বাইট” বদলাতে পুরো ২০,০০,০০০ বাইট copy-up হয়েছে, bytes_copied ফিল্ডটাই সরাসরি দেখাচ্ছে খরচ লেখার আকারের না, ফাইলের পুরো আকারের সমানুপাতিক। শেষ দুই লাইন: app/config.json merged view থেকে অদৃশ্য হয়ে গেছে, অথচ মূল layer2_app dict-এ (lower layer) অক্ষত রয়ে গেছে — ঠিক whiteout-এর সংজ্ঞা অনুযায়ী।
merged view কীভাবে গণনা হয় (উপরের স্তর শাডো করে নিচেরটাকে), read কখনো copy করে না, প্রথম write পুরো ফাইল copy-up করে (আকার-নির্বিশেষে বদলের পরিমাণ), আর whiteout lower layer-এর আসল ডেটা অক্ষত রেখে শুধু merged view থেকে আড়াল করে।
নিজে বানান
LayerCacheCalculator — content-addressed sharing আর Dockerfile ordering-এর প্রভাব সংখ্যায়
- দুইটা ভিন্ন image একই base/apt-install layer content শেয়ার করলে real SHA-256 হ্যাশ দিয়ে দেখান তাদের diff_id অভিন্ন হয়ে যায়
- একটা Dockerfile-এর layer-চেইন cache key মডেল করুন -- প্রতিটা layer-এর key তার নিজের content আর আগের layer-এর key-এর ওপর নির্ভরশীল
- "COPY . /app" আগে বনাম পরে রেখে একই এক-লাইন কোড-বদলে কতগুলো layer rebuild করতে হয় তার তুলনা করুন
- ফলাফল থেকে Dockerfile-লেখার পরামর্শটা (ঘন ঘন বদলানো instruction পরে রাখুন) সরাসরি বের করে দেখান
লক্ষ্য: “layer sharing ডিস্ক বাঁচায়” আর “layer ordering rebuild সময় বাঁচায়” — এই দুইটা প্রচলিত দাবিকে বাক্যে না রেখে, real hashing আর real cache-invalidation লজিক দিয়ে সংখ্যায় নামানো।
# layer_cache_sim.py -- LayerCacheCalculator
import hashlib
def layer_hash(content: str) -> str:
"""content-addressed layer id -- exactly the mechanism Docker/OCI use:
sha256 of the layer's content."""
return hashlib.sha256(content.encode()).hexdigest()[:12]
def build_image(name, instructions):
"""instructions: [(dockerfile_line, layer_content), ...] in Dockerfile
order. Each step's BUILD-CACHE key chains off the previous step's key
-- this is what makes an early change cascade forward."""
layers = []
parent_key = "scratch"
for line, content in instructions:
diff_id = layer_hash(content)
cache_key = layer_hash(parent_key + "|" + line + "|" + content)
layers.append({"line": line, "diff_id": diff_id, "cache_key": cache_key})
parent_key = cache_key
return {"name": name, "layers": layers}
base_ubuntu = "UBUNTU_22.04_ROOTFS_TARBALL_BYTES"
apt_curl = "APT_INSTALL_CURL_DIFF_BYTES"
# ---- (1) cross-image sharing ----
image_a = build_image("service-a:v1", [
("FROM ubuntu:22.04", base_ubuntu),
("RUN apt-get install -y curl", apt_curl),
("COPY . /app", "COPY_APP_A_SOURCE_BYTES"),
])
image_b = build_image("service-b:v1", [
("FROM ubuntu:22.04", base_ubuntu),
("RUN apt-get install -y curl", apt_curl),
("COPY . /app", "COPY_APP_B_SOURCE_BYTES"),
])
shared = set(l["diff_id"] for l in image_a["layers"]) & set(l["diff_id"] for l in image_b["layers"])
total_naive = len(image_a["layers"]) + len(image_b["layers"])
total_unique = len(set(l["diff_id"] for l in image_a["layers"] + image_b["layers"]))
print(f"শেয়ার্ড layer সংখ্যা: {len(shared)}")
print(f"মোট layer reference (আলাদা গুনলে): {total_naive}, ডিস্কে unique: {total_unique}")
# ---- (2) instruction-order cache cascade ----
def rebuilt_count(instructions, changed_line):
img = build_image("x", instructions)
idx = next(i for i, l in enumerate(img["layers"]) if l["line"] == changed_line)
return len(img["layers"]) - idx
bad_order = [
("FROM ubuntu:22.04", base_ubuntu),
("COPY . /app", "APP_SOURCE_V2"),
("RUN apt-get install -y curl", apt_curl),
("RUN pip install -r requirements.txt", "PIP_DEPS_V1"),
]
good_order = [
("FROM ubuntu:22.04", base_ubuntu),
("RUN apt-get install -y curl", apt_curl),
("RUN pip install -r requirements.txt", "PIP_DEPS_V1"),
("COPY . /app", "APP_SOURCE_V2"),
]
n_bad = rebuilt_count(bad_order, "COPY . /app")
n_good = rebuilt_count(good_order, "COPY . /app")
print(f"খারাপ ক্রম (COPY আগে): {n_bad}/4 layer rebuild")
print(f"ভালো ক্রম (COPY শেষে): {n_good}/4 layer rebuild")প্রকৃত চালিয়ে পাওয়া output (deterministic — SHA-256 নির্ধারক, হুবহু এটাই আসবে):
শেয়ার্ড layer সংখ্যা: 2
মোট layer reference (আলাদা গুনলে): 6, ডিস্কে unique: 4
খারাপ ক্রম (COPY আগে): 3/4 layer rebuild
ভালো ক্রম (COPY শেষে): 1/4 layer rebuildদুইটা সংখ্যাই এই লেসনের হাতে-কলমে প্রমাণ। প্রথমটা: service-a আর service-b — সম্পূর্ণ ভিন্ন দুটো image, ভিন্ন নাম, ভিন্ন app কোড — তবু তাদের প্রথম দুইটা layer-এর diff_id হুবহু মিলে যায় (একই content-এর SHA-256 সবসময় একই), তাই ৬টা layer-reference-এর জায়গায় ডিস্কে আসলে ৪টা unique layer লাগে। দ্বিতীয়টা: ঠিক একই এক-লাইন কোড বদল, শুধু Dockerfile-এ COPY . /app-এর অবস্থান বদলে — rebuild ৩টা layer থেকে নেমে ১টাতে চলে আসে, কারণ cache key-চেইনে COPY . /app-এর পরে আর কিছু নেই বলে rebuild করার আর কিছু বাকি থাকে না। “ঘন ঘন বদলানো instruction পরে রাখুন” — এই পরামর্শটা এই একটা চেইন-ইনভ্যালিডেশন মেকানিজম থেকেই সরাসরি বেরিয়ে আসছে, কোনো arbitrary নিয়ম না।
বাস্তব সিস্টেমে
বাস্তব সিস্টেমে — overlay2, layer ordering, আর একটা practical সীমা
Docker-এর ডিফল্ট storage driver-এর নাম আক্ষরিক অর্থেই overlay2 — এই লেসনের পুরো আলোচনা কোনো hypothetical মডেল না, এটাই আজকের প্রায় প্রতিটা docker run-এর পেছনের প্রকৃত প্রক্রিয়া (Docker Engine documentation-এর overlay2 driver পৃষ্ঠায় নির্ভুলভাবে বর্ণিত)। /var/lib/docker/overlay2/ ডিরেক্টরিতে গিয়ে দেখলে প্রতিটা layer আর প্রতিটা container-এর upperdir/workdir আক্ষরিকভাবেই আলাদা সাব-ডিরেক্টরি হিসেবে বসে থাকতে দেখা যায়।
Dockerfile-লেখার পরামর্শ, এবার একটা যান্ত্রিক কারণসহ। “ঘন ঘন বদলানো instruction (COPY . ., ADD) সবসময় শেষে রাখুন, rarely-বদলানো instruction (RUN apt-get install) আগে রাখুন” — এই পরামর্শ প্রায় প্রতিটা Docker গাইডে থাকে, কিন্তু কারণটা প্রায়ই ব্যাখ্যা ছাড়াই দেওয়া হয়। এই লেসনের build অংশ ঠিক দেখিয়েছে কেন: build cache key-চেইন প্রতিটা instruction-এ তার আগের সব instruction-এর ওপর নির্ভর করে গণনা হয় (parent_key-র মাধ্যমে)। কোনো একটা instruction-এর content বদলালে, তার পরের সবগুলো layer-ও নতুন করে rebuild হতে বাধ্য — cache আর মেলে না। তাই সবচেয়ে ঘন ঘন বদলানো ধাপ (application কোড) সবচেয়ে শেষে রাখলে, বদল হলে শুধু সেই একটা layer-ই rebuild হয়; ভারী, ধীর, rarely-বদলানো ধাপ (apt-get install, pip install) আগে রাখলে সেগুলো প্রায় সবসময় cache-hit পায়।
একটা practical সীমা — মাউন্ট অপশনের দৈর্ঘ্য। একটা image-এ layer সংখ্যা যত বাড়ে, lowerdir= অপশনের কোলন-সেপারেটেড path তালিকাও তত লম্বা হয় — আর mount() syscall-এ পাঠানো ডেটার একটা kernel-imposed আকার-সীমা আছে (ঐতিহাসিকভাবে এক page, সাধারণত ৪KB)। এই সীমা এড়াতে Docker-এর overlay2 driver প্রতিটা layer-এর জন্য /var/lib/docker/overlay2/l/ -এর নিচে একটা সংক্ষিপ্ত symlink রাখে (যেমন l/A1B2C3), আর lowerdir= অপশনে পুরো path-এর বদলে সেই সংক্ষিপ্ত symlink ব্যবহার করে — একটা ছোট, বাস্তবিক ইঞ্জিনিয়ারিং trick যা দেখায় “কতগুলো layer একটা image-এ থাকা উচিত” প্রশ্নটাও সম্পূর্ণ তাত্ত্বিক না।
যে ভুলগুলো সবাই করে
“OverlayFS merged view বানানোর সময় সব layer-এর ফাইল একসাথে কপি করে একটা নতুন directory বানায়”
ভুল — merged view সম্পূর্ণ ভার্চুয়াল, lazy। কোনো ফাইল কোথাও copy হয় না merge-এর সময়; kernel শুধু প্রতিটা lookup-এ স্তরগুলো (উপর থেকে নিচে) স্ক্যান করে প্রথম match ফেরত দেয়। ডিস্কে সত্যিকারের একটা “merged” directory কোথাও তৈরি হয় না — এটা একটা রানটাইম resolution, প্রতিটা open()/lookup()-এ নতুন করে গণনা হয়, কোনো স্ন্যাপশট আগে থেকে বানানো নেই।
“একটা বড় ফাইলে সামান্য একটা বদল মানেই সামান্য একটা disk write”
সাধারণ filesystem-এ এটা সত্যি হতে পারে, কিন্তু একটা overlay-mounted container filesystem-এ না — যদি ফাইলটা এখনও শুধু lowerdir-এ থাকে, প্রথম write পুরো ফাইলটাই upperdir-এ copy-up করে, তারপর সেই বদলটা প্রয়োগ হয়। এই লেসনের experiment-এ একটা ২০,০০,০০০ বাইটের ফাইলে “১ বাইট” বদল করতে পুরো ২০,০০,০০০ বাইটই copy-up হতে দেখা গেছে — খরচ বদলের আকারের না, ফাইলের পুরো আকারের সমানুপাতিক।
“একটা container-এর ভেতর থেকে কোনো ফাইল মুছলে (rm) সেই ফাইল lower/image layer থেকেও মুছে যায়”
lowerdir চিরকাল read-only — container-এর কোনো অপারেশনই কখনো তাতে লিখতে পারে না। rm আসলে upperdir-এ একটা whiteout marker (kernel-level বাস্তবায়নে একটা বিশেষ character-device node, 0/0) বসায়, যা merged view-তে সেই নামকে আড়াল করে দেয়। lower layer-এর আসল ফাইল, image-এর ডিস্কে-থাকা কপি, অক্ষত থেকেই যায় — এই কারণেই একই image থেকে বানানো একটা নতুন, ভিন্ন container-এ সেই ফাইলটা আবার স্বাভাবিকভাবেই থাকে।
“একটা running container-এর upperdir-এ করা পরিবর্তন স্বয়ংক্রিয়ভাবে image-এর একটা নতুন layer হয়ে যায়”
upperdir সম্পূর্ণ ক্ষণস্থায়ী — container বন্ধ/মুছে ফেললে (docker rm) সেই layer পুরোপুরি হারিয়ে যায়, image-এ যোগ হয় না। image-এর layer শুধু build-time-এ তৈরি হয় (Dockerfile instruction থেকে) অথবা explicit docker commit-এর মতো কমান্ড দিয়ে (যা upperdir-এর বর্তমান অবস্থাকে একটা নতুন, স্থায়ী layer হিসেবে snapshot করে) — কোনো স্বয়ংক্রিয় পথ নেই যেখানে running container-এর writable layer নিজে থেকেই image-এ মিশে যায়।
বুঝেছেন কি না দেখুন
1OverlayFS-এর চারটা directory-র নাম বলুন, আর প্রতিটার এক-লাইন কাজ কী?স্মরণ
lowerdir — এক বা একাধিক read-only layer, অগ্রাধিকার-ক্রমে স্ট্যাক করা। upperdir — একমাত্র writable layer, সব নতুন লেখা এখানে যায়। merged — container যা দেখে, সবগুলো layer-এর মিলিত view। workdir — kernel-এর অভ্যন্তরীণ scratch space, copy-up/rename-এর atomicity-র জন্য দরকার, container সরাসরি কখনো ছোঁয় না।2কেন read কখনো copy-up ট্রিগার করে না, কিন্তু write সবসময় করে (যদি ফাইলটা এখনো upperdir-এ না থাকে)? এই asymmetry-র পেছনে যুক্তিটা কী?যুক্তি
3একটা container-এ একটা ৫০০MB SQLite ডেটাবেস ফাইল আছে, যা image build-এর সময়ই (একটা COPY instruction দিয়ে) বেক করা হয়েছিল — অর্থাৎ এখন এটা একটা lower layer-এ বসে আছে। container চালু হওয়ার পর প্রতি সেকেন্ডে ছোট ছোট write হচ্ছে এই ফাইলে। প্রথম write-এ কী ঘটবে, আর পরবর্তী write-গুলোতে কী পার্থক্য হবে?প্রয়োগ
4দুইটা Dockerfile একই RUN apt-get update && apt-get install -y build-essential লাইন দিয়ে শুরু, কিন্তু একটাতে ENV BUILD_DATE=2026-01-01 লাইনটা তার আগে, আরেকটাতে তার পরে আছে। এই দুই image-এর apt-install layer-এর diff_id কি একই হবে?প্রয়োগ
ENV (pure metadata, filesystem না) কোনো নতুন filesystem layer তৈরি না করে (বাস্তবে করে না), তাহলে apt-install RUN-এর filesystem diff content অভিন্ন থাকবে, diff_id-ও অভিন্ন হবে। কিন্তু build cache key-চেইন (যা প্রতিটা instruction, filesystem-affecting হোক বা না হোক, ক্রমানুসারে জমা করে) ভিন্ন হতে পারে যদি ENV-এর অবস্থান cache-key গণনায় ধরা হয় — এই লেসনের layer_cache_sim.py-র মতো, chain-key নির্ভর করে ঠিক কোন instruction-গুলো তার আগে এসেছে তার ওপর, শুধু filesystem-diff-এর ওপর না।5Whiteout একটা বিশেষ character-device node (0/0) হিসেবে বাস্তবায়িত হয়, শুধু “নাম নেই” এমন একটা খালি marker ফাইল হিসেবে না কেন?যুক্তি
6আপনার প্রতিষ্ঠানের ১০টা মাইক্রোসার্ভিস আছে, প্রতিটার নিজস্ব Dockerfile, কিন্তু সবগুলো একই ভারী dependency-সেট ইনস্টল করে (একই base image, একই apt/pip প্যাকেজ তালিকা)। প্রতিটা সার্ভিস আলাদাভাবে তার নিজস্ব Dockerfile-এ RUN apt-get install ... আর RUN pip install ... লিখে রেখেছে। ডিস্ক ব্যবহার আর rebuild সময় — দুইটাই কমাতে আপনি কীভাবে এই সেটআপ পুনর্গঠন করবেন, আর প্রতিটা সিদ্ধান্ত কোন মেকানিজমের ওপর নির্ভর করছে?ডিজাইন
FROM your-registry/shared-base:v1 দিয়ে শুরু করান, তারপর শুধু নিজস্ব COPY . /app যোগ করান শেষে। এতে (ক) content-addressing নিশ্চিতভাবে কাজ করে কারণ সব সার্ভিস আক্ষরিক অর্থেই একই, pre-built layer digest রেফার করছে — ডিস্কে/registry-তে একটাই কপি; আর (খ) layer ordering-এর সুবিধা (COPY শেষে) প্রতিটা সার্ভিসে আলাদাভাবে প্রযোজ্য হয়, তাই কোনো একটা সার্ভিসের কোড বদলালে বাকি ৯টার build cache একদম অক্ষত থাকে।এরপর কী
আজকের লেসন একটা container-এর filesystem অর্ধেক সম্পূর্ণ করল — একটা image-এর layer কীভাবে ডিস্কে বসে, কীভাবে শেয়ার হয়, আর কীভাবে OverlayFS-এর lowerdir/upperdir/merged/workdir মেকানিজম দিয়ে একটা running container-এর rootfs-এ পরিণত হয়। কিন্তু একটা বড় প্রশ্ন এখনো অনুত্তরিত: docker run লেখার পর ঠিক কোন syscall-গুলো ঘটে, কে সেগুলো চালায়, আর কীভাবে namespace/cgroup সেটআপের সাথে এই filesystem mount-টা মিলে গিয়ে একটা সম্পূর্ণ, চলমান container তৈরি হয়?
পরের লেসন, virtualization-cloud/container-runtime-oci, ঠিক এই বাকি অংশটাই খুলে দেখাবে — runc, containerd, আর OCI runtime specification, যা একটা image (এখন যা এই লেসনের পর সম্পূর্ণ পরিচিত) আর একগুচ্ছ namespace/cgroup নির্দেশনা নিয়ে সেগুলোকে একটা প্রকৃত, চলমান process-এ রূপান্তর করে। এই module-এর শুরু থেকে যে প্রশ্নটা তাড়া করছে — “docker run লিখলে ঠিক কোন syscall হয়?” — সেটার একটা সম্পূর্ণ, ট্রেস-করা উত্তর অবশেষে সেখানেই পাওয়া যাবে।
আরও পড়ুন
- Overlay Filesystem — Linux kernel documentation (Documentation/filesystems/overlayfs.rst) · lowerdir/upperdir/workdir/merged-এর প্রামাণ্য kernel documentation — whiteout-এর প্রকৃত বাস্তবায়ন (character device, 0/0), opaque directory, আর copy-up-এর নির্ভুল সেমান্টিক্সের প্রাথমিক উৎস, এই লেসনের প্রতিটা মেকানিক্স দাবির ভিত্তি
- OCI Image Format Specification — Open Container Initiative · image layer কী, diffID (uncompressed layer content-hash) বনাম digest (compressed blob hash) কীভাবে আলাদা, আর layer কীভাবে content-addressed — এই লেসনের 'Docker image layer' অংশের প্রামাণ্য সংজ্ঞা-উৎস
- Use the OverlayFS storage driver — Docker, Inc. — Docker Engine documentation · docker-এর overlay2 storage driver ঠিক কীভাবে kernel OverlayFS-এর lowerdir/upperdir ব্যবহার করে প্রতিটা container চালায় তার ব্যবহারিক, প্রথম-পক্ষ ডকুমেন্টেশন