Foundationপ্রথম নীতি থেকে
LEVEL 12লেসন ৯/২২কঠিন৫৫ মিনিট

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 দিয়ে ডিস্ক আর ব্যান্ডউইথ দুটোই বাঁচায় — এই তিনটাকে একটা একক, ধারাবাহিক ব্যাখ্যায় জোড়া দেয়।

এই লেসন শেষে আপনি পারবেন

  • OverlayFS-এর চারটা মৌলিক উপাদান — `lowerdir`, `upperdir`, `merged`, `workdir` — প্রতিটার নির্দিষ্ট কাজ আর union mount resolution algorithm নির্ভুলভাবে ব্যাখ্যা করতে পারবেন
  • copy-on-write সেমান্টিক্স — read সবসময় সরাসরি lower layer থেকে হয়, প্রথম write পুরো ফাইল upperdir-এ copy-up করে তারপর লেখে — হাতে ট্রেস করে এবং কোড দিয়ে যাচাই করে দেখাতে পারবেন
  • একটা বড় ফাইলে ছোট্ট একটা write কেন surprisingly ব্যয়বহুল হতে পারে তা copy-up-এর মেকানিক্স দিয়ে concrete সংখ্যাসহ ব্যাখ্যা করতে পারবেন
  • Docker image layer কীভাবে ঠিক OverlayFS-এর lowerdir/upperdir-এ ম্যাপ হয়, আর content-addressed hashing কীভাবে ভিন্ন ভিন্ন image-এর মধ্যে layer আক্ষরিক অর্থে শেয়ার করে দেয় তা ব্যাখ্যা করতে পারবেন
  • Dockerfile-এ 'ঘন ঘন বদলানো instruction পরে রাখুন' এই প্রচলিত পরামর্শটা layer-hash-cascade মেকানিজমের সাথে সরাসরি, concretely যুক্ত করে যুক্তি দিতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

ধরুন একটা মেশিনে ১০০টা 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-এর নিজস্ব পরিবর্তন
mergedcontainer/process যা আসলে দেখে — সবগুলো layer-এর মিলিত viewcontainer-এর rootfs, /
workdirOverlayFS-এর নিজস্ব অভ্যন্তরীণ 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-গুলো অগ্রাধিকার-ক্রমে) স্ক্যান করে:

  1. সবচেয়ে উঁচু স্তর যেখানে নামটা পাওয়া যায়, সেটাই জেতে। upperdir-এ etc/passwd থাকলে সেটাই দেখানো হয়, lowerdir-এ থাকা পুরনো সংস্করণ আড়ালে চলে যায় — মুছে যায় না, শুধু shadow হয়ে যায়।
  2. directory-র ক্ষেত্রে merge হয় recursively। একটা directory যদি একাধিক স্তরে থাকে, প্রতিটা স্তরের ভেতরের entry-গুলো একসাথে মিলিয়ে দেখানো হয় (ফাইল-level shadowing একই নিয়মে প্রযোজ্য) — পুরো directory replace হয় না, শুধু conflicting নামগুলো।
  3. 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 থেকে, শুধু এখানেই ছিল │
              └────────────────────────────────────┘
union resolutionএকটা ৩-স্তরের overlay -- দুটো lowerdir, একটা upperdir -- merged view কীভাবে বের হয়। বাম থেকে ডানে অগ্রাধিকার বাড়ছে; সবচেয়ে ডানের স্তর জেতে।

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 নিচের ধাপগুলো নেয়:

  1. চেক করে ফাইলটা ইতিমধ্যেই upperdir-এ আছে কি না।
  2. না থাকলে: সবচেয়ে উঁচু lower layer যেখানে ফাইলটা আছে, সেখান থেকে পুরো ফাইলের সম্পূর্ণ কনটেন্ট (metadata-সহ) upperdir-এ কপি করা হয় — এই ধাপটাই “copy-up”।
  3. তারপরই আসল 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-এর তুলনায় কী কী ফাইল যোগ/বদল/মুছে গেছে” তার একটা রেকর্ড।

একটা Dockerfile build — প্রতিটা filesystem-বদলানো instruction একটা নতুন layer
  1. FROM ubuntu:22.04base image-এর সব layer টেনে আনে — এগুলোই এখন সবচেয়ে নিচের lowerdir স্তর
  2. RUN apt-get install -y curlনতুন layer: base-এর diff হিসেবে শুধু curl-সংক্রান্ত নতুন/বদলানো ফাইল ধরে রাখে
  3. RUN pip install -r requirements.txtআরেকটা নতুন layer, আগের সব layer-এর ওপর
  4. COPY . /appশেষ layer — অ্যাপ্লিকেশন সোর্স কোড, সাধারণত সবচেয়ে ঘন ঘন বদলায়
docker run — image layer stack + একটা নতুন, খালি writable layer
  1. image-এর সবগুলো layer → lowerdirউপরের ৪টা layer-ই এখন colon-separated lowerdir তালিকা, build-ক্রমের বিপরীতে (শেষেরটা সবচেয়ে উঁচু অগ্রাধিকার)
  2. নতুন, সম্পূর্ণ খালি upperdir বরাদ্দপ্রতিটা নতুন container-এর নিজস্ব, খালি writable layer — image-এর কোনো layer এখনও স্পর্শ হয়নি
  3. workdir বরাদ্দ (অভ্যন্তরীণ)copy-up/rename-এর জন্য kernel-এর নিজস্ব scratch space
  4. 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-গুলো অক্ষত থাকে)
build বনাম runবাম দিকে: image build হওয়ার সময় প্রতিটা instruction একটা স্থায়ী layer তৈরি করে, ডিস্কে/registry-তে content-addressed হয়ে থাকে। ডান দিকে: docker run সেই একই layer-গুলোকে lowerdir বানিয়ে একটা fresh, খালি upperdir-এর সাথে overlay mount করে।

নিজে চালিয়ে দেখুন

EXPERIMENT

OverlayFS resolution ও copy-on-write সিমুলেশন — সত্যিকারভাবে চালানো

Python 3 (যেকোনো OS) — kernel overlayfs mount সরাসরি macOS-এ সম্ভব না· ১৫ মিনিট
# 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 থেকে আড়াল করে।

নিজে বানান

BUILD IT

LayerCacheCalculator — content-addressed sharing আর Dockerfile ordering-এর প্রভাব সংখ্যায়

Python · ●●●○○
  1. দুইটা ভিন্ন image একই base/apt-install layer content শেয়ার করলে real SHA-256 হ্যাশ দিয়ে দেখান তাদের diff_id অভিন্ন হয়ে যায়
  2. একটা Dockerfile-এর layer-চেইন cache key মডেল করুন -- প্রতিটা layer-এর key তার নিজের content আর আগের layer-এর key-এর ওপর নির্ভরশীল
  3. "COPY . /app" আগে বনাম পরে রেখে একই এক-লাইন কোড-বদলে কতগুলো layer rebuild করতে হয় তার তুলনা করুন
  4. ফলাফল থেকে 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-র পেছনে যুক্তিটা কী?যুক্তি
Read শুধু ডেটা দেখতে চায়, বদলাতে চায় না — lower layer read-only হলেও তা পড়তে কোনো বাধা নেই, তাই সরাসরি সেখান থেকে পড়া নিরাপদ ও দ্রুত। কিন্তু write ডেটা বদলাতে চায়, আর lowerdir বদলানো অসম্ভব (এটা read-only, প্রায়ই অন্য image/container-এর সাথে শেয়ার্ড)। তাই lower layer অক্ষত রেখে বদলটা কোথাও প্রয়োগ করতে হলে প্রথমে একটা writable কপি (upperdir-এ) দরকার — copy-up ঠিক এই বাধ্যবাধকতা থেকেই আসে।
3একটা container-এ একটা ৫০০MB SQLite ডেটাবেস ফাইল আছে, যা image build-এর সময়ই (একটা COPY instruction দিয়ে) বেক করা হয়েছিল — অর্থাৎ এখন এটা একটা lower layer-এ বসে আছে। container চালু হওয়ার পর প্রতি সেকেন্ডে ছোট ছোট write হচ্ছে এই ফাইলে। প্রথম write-এ কী ঘটবে, আর পরবর্তী write-গুলোতে কী পার্থক্য হবে?প্রয়োগ
প্রথম write copy-up ট্রিগার করবে — পুরো ৫০০MB ফাইলটা lower layer থেকে upperdir-এ কপি হবে, যা একটা লক্ষণীয় latency spike তৈরি করবে (ডিস্ক I/O-এর সমানুপাতিক)। এই copy-up একবারই হয় — এরপর ফাইলটা upperdir-এ থাকে, তাই পরবর্তী প্রতিটা write সরাসরি সেই upper কপির ওপর হয়, কোনো অতিরিক্ত copy-up ছাড়াই, স্বাভাবিক গতিতে। এই কারণেই প্র্যাকটিসে স্টেটফুল ডেটা image layer-এ বেক না করে volume/bind mount-এ রাখার পরামর্শ দেওয়া হয়।
4দুইটা Dockerfile একই RUN apt-get update && apt-get install -y build-essential লাইন দিয়ে শুরু, কিন্তু একটাতে ENV BUILD_DATE=2026-01-01 লাইনটা তার আগে, আরেকটাতে তার পরে আছে। এই দুই image-এর apt-install layer-এর diff_id কি একই হবে?প্রয়োগ
নির্ভর করে layer-এর content কীসের ওপর হ্যাশ হচ্ছে তার ওপর — যদি 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 ফাইল হিসেবে না কেন?যুক্তি
একটা সাধারণ ফাঁকা ফাইল দিয়ে “মোছা হয়েছে” বোঝানো যেত, কিন্তু সেটা একটা বৈধ, খালি regular ফাইলের সাথে গুলিয়ে যেত (একটা সত্যিকারের খালি ফাইলও তো merged view-তে দেখানো উচিত)। Character device 0/0 একটা এমন signature যা বাস্তবে কোনো normal ফাইল কখনো হতে পারে না (0/0 কোনো বৈধ device না) — তাই এটা unambiguous ভাবে “whiteout marker” হিসেবে চেনা যায়, কোনো সত্যিকারের ফাইলের সাথে কনফ্লিক্ট না করেই।
6আপনার প্রতিষ্ঠানের ১০টা মাইক্রোসার্ভিস আছে, প্রতিটার নিজস্ব Dockerfile, কিন্তু সবগুলো একই ভারী dependency-সেট ইনস্টল করে (একই base image, একই apt/pip প্যাকেজ তালিকা)। প্রতিটা সার্ভিস আলাদাভাবে তার নিজস্ব Dockerfile-এ RUN apt-get install ... আর RUN pip install ... লিখে রেখেছে। ডিস্ক ব্যবহার আর rebuild সময় — দুইটাই কমাতে আপনি কীভাবে এই সেটআপ পুনর্গঠন করবেন, আর প্রতিটা সিদ্ধান্ত কোন মেকানিজমের ওপর নির্ভর করছে?ডিজাইন
সমস্যাটা এই যে ১০টা Dockerfile আলাদাভাবে “একই” dependency-install ধাপ লিখলেও, তাদের বিল্ড-টাইম নির্ধারিততা (package-manager-এর টাইমস্ট্যাম্প, ক্যাশ অবস্থা ইত্যাদি) ভিন্ন হতে পারে বলে content বাইট-বাইট নাও মিলতে পারে, ফলে diff_id ভিন্ন হয়ে যেতে পারে এবং content-addressing-এর শেয়ারিং সুবিধা হারিয়ে যায়। সমাধান: একটা shared base image আলাদাভাবে একবার build করুন (base + apt-install + pip-install পর্যন্ত), registry-তে একটা নির্দিষ্ট ট্যাগে push করুন, আর ১০টা সার্ভিসের Dockerfile-কে 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 চালায় তার ব্যবহারিক, প্রথম-পক্ষ ডকুমেন্টেশন