Foundationপ্রথম নীতি থেকে
LEVEL 4লেসন ২৮/২৯অ্যাডভান্সড১ ঘণ্টা ২৫ মিনিট

Namespace ও Cgroups — একটা 'container' আসলে কী

Namespaces and Cgroups

গত লেসনের শেষে একটা প্রতিশ্রুতি দেওয়া হয়েছিল — একটা process-কে সম্পূর্ণ বিভ্রম দেওয়া যায় যে সে একাই মেশিনে আছে, যদিও শত শত অন্য process একই kernel শেয়ার করছে। আজ সেই বিভ্রম কীভাবে বানানো হয় তা দেখব — namespace (কী দেখা যায়) আর cgroups (কতটুকু ব্যবহার করা যায়), দুইটা সম্পূর্ণ ভিন্ন, orthogonal kernel mechanism। শেষে raw clone()/unshare() আর /sys/fs/cgroup দিয়ে Docker ছাড়াই একটা বাস্তব, ন্যূনতম container বানাব — আর প্রমাণ করব কেন kernel-এ আসলে 'container' নামে কিছুই নেই।

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

  • clone()/unshare() দিয়ে ছয় ধরনের namespace (PID, mount, network, UTS, IPC, user) তৈরি করতে এবং প্রতিটা ঠিক কোন global resource virtualize করে তা ব্যাখ্যা করতে পারবেন
  • user namespace-এ UID/GID mapping কীভাবে কাজ করে তা ব্যাখ্যা করতে পারবেন, আর কেন এটাই rootless container সম্ভব করার একমাত্র কারণ তা যুক্তি দিতে পারবেন
  • cgroup v2-এর unified hierarchy ব্যবহার করে CPU, memory, ও process-count সীমা বেঁধে দিতে পারবেন, আর memory.max ভেঙে বাস্তবে OOM kill ঘটাতে পারবেন
  • namespace ও cgroup কেন সম্পূর্ণ orthogonal দুইটা mechanism তা কংক্রিট উদাহরণ দিয়ে (namespace ছাড়া cgroup, cgroup ছাড়া namespace) প্রমাণ করতে পারবেন
  • raw clone()/unshare() আর cgroup filesystem দিয়ে Docker ছাড়াই একটা ন্যূনতম, বাস্তব container হাতে বানাতে পারবেন
  • 'container কোনো kernel object নয়' — এই দাবিটা kernel-এর নিজস্ব data structure (nsproxy, cred, css_set) দেখিয়ে প্রমাণ করতে পারবেন, আর এই থেকে container escape কেন মূলত একটা kernel-privilege সমস্যা তা যুক্তি দিতে পারবেন

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

আগে এটা বুঝি

গত লেসনের শেষে একটা প্রতিশ্রুতি দেওয়া হয়েছিল। এখন সেটা রাখার পালা।

প্রশ্নটা ছিল: একটা process-কে কীভাবে সম্পূর্ণভাবে বিভ্রম দেওয়া যায় যে সে একাই একটা মেশিনে আছে — যখন আসলে সে শত শত অন্য process-এর সাথে একই kernel, একই hardware শেয়ার করছে?

docker run alpine sh লিখুন। ভেতরে ps aux চালান — একটা মাত্র process দেখাবে, নিজেকে। hostname চালান — একটা আলাদা নাম। netstat চালান — নিজস্ব network interface। মনে হয় যেন এই process-টা একটা সম্পূর্ণ, খালি, নতুন মেশিনে বসে আছে।

কিন্তু host-এর একটা terminal খুলে ps aux চালালে সেই একই process-টা সেখানেও দেখা যাবে — একটা সাধারণ entry, বাকি সব process-এর পাশে, একই ps কমান্ডে।

তাহলে প্রশ্ন: kernel-এর ভেতরে কি একটা “container” নামের বিশেষ object আছে, যা এই বিভ্রম তৈরি করে?

উত্তর, যা এই পুরো লেসনের কেন্দ্রে থাকবে:

না। kernel-এ struct container বলে কিছু নেই। একটা “container” শুধু একটা সাধারণ process — কিছু অস্বাভাবিক namespace আর cgroup সেটিং সহ।

এই লেসনে আমরা ঠিক দেখব সেই “অস্বাভাবিক সেটিং” গুলো কী, কীভাবে বসানো হয়, আর কেন এই সরল সত্যটা — container কোনো নতুন kernel object নয় — সরাসরি ব্যাখ্যা করে কেন container escape মূলত একটা kernel-privilege সমস্যা, আলাদা কোনো “container security” সমস্যা নয়। শেষে আমরা নিজেরাই, raw syscall দিয়ে, Docker ছাড়াই একটা কাজ-করা container বানাব — আর সেটাই এই দাবির চূড়ান্ত প্রমাণ হবে।

মূল ধারণা

ইতিহাস — এগুলো “container”-এর জন্য বানানো হয়নি

একটা গুরুত্বপূর্ণ, প্রায়ই ভুল বোঝা তথ্য দিয়ে শুরু করা যাক: namespace আর cgroups কোনোটাই মূলত “container” বানানোর জন্য ডিজাইন করা হয়নি। দুটোই আলাদা আলাদা কারণে, আলাদা আলাদা সময়ে, Linux kernel-এ যোগ হয়েছিল — Docker আসার বহু আগে।

বছরNamespace typeকেন যোগ হলো
২০০২mount (CLONE_NEWNS)প্রথম namespace — chroot-এর চেয়ে ভালো, প্রক্রিয়াভেদে ভিন্ন mount tree দেখানোর জন্য
২০০৬UTS, IPChostname ও SysV IPC আলাদা করার সহজ, ছোট সংযোজন
২০০৮PIDমূলত checkpoint/restore আর OpenVZ-ধাঁচের migration-এর জন্য — একটা process group-কে অন্য মেশিনে “একই PID” নিয়ে restore করা যায় যদি সে তার নিজস্ব PID namespace-এ থাকে
২০০৯–২০১২Networkধীরে ধীরে সম্পূর্ণ হয় — routing table, socket, iptables সব namespace-aware করতে সময় লেগেছিল
২০১৩User (সম্পূর্ণ, Linux 3.8)unprivileged process নিজে namespace বানাতে পারার জন্য — সবচেয়ে দেরিতে আসা, সবচেয়ে জটিল

আর cgroups-এর গল্প আরও স্পষ্ট। ২০০৬ সালে Google-এর দুই ইঞ্জিনিয়ার, Paul Menage আর Rohit Seth, একটা mechanism বানান নাম “process containers” — datacenter-এ হাজার হাজার process-এর মধ্যে CPU/memory ভাগ করে দেওয়ার জন্য। ২০০৭ সালে Linux kernel-এ merge করার সময় নাম পাল্টে হয় cgroups (control groups) — কারণ “container” শব্দটা kernel-এর container_of() macro-র সাথে বিভ্রান্তি তৈরি করছিল। (ইতিহাসের একটা কৌতুক — “container” শব্দটা এড়ানোর জন্য নাম পাল্টানো হয়েছিল, অথচ আজ পুরো শিল্প এই mechanism-টাকেই “container” বলে ডাকে।)

Docker রিলিজ হয় ২০১৩ সালে — অর্থাৎ mount namespace-এর ১১ বছর পর, cgroups-এর ৬ বছর পর। Docker এই primitive-গুলো আবিষ্কার করেনি — এটা এগুলোর উপর একটা সুবিধাজনক packaging (image, registry, Dockerfile) তৈরি করেছে। kernel-এর দৃষ্টিতে, Docker যা করে তা LXC (২০০৮, Docker-এর precursor) যা করত তার থেকে খুব বেশি আলাদা না — একই clone(), একই cgroup ফাইল।

Namespace কী — একটা global resource-কে “wrap” করা

Kernel-এর কিছু resource স্বভাবতই global — পুরো সিস্টেমে একটাই কপি থাকে। PID সংখ্যা, mount tree, hostname, network interface list — এগুলো ঐতিহাসিকভাবে সব process একসাথে শেয়ার করত, একটাই কপি।

Namespace এই global resource-টাকে wrap করে দেয় — একটা process যে namespace-এ আছে, সে শুধু সেই namespace-এর কপি দেখে, বাকি সব namespace-এর কপি তার কাছে অদৃশ্য। প্রতিটা namespace type একটা নির্দিষ্ট global resource-কে টার্গেট করে।

ছয় ধরনের namespace — এক নজরে

Namespaceclone() flagকী virtualize করে
PIDCLONE_NEWPIDProcess ID সংখ্যা — namespace-এর ভেতরে নিজস্ব PID 1 থেকে গোনা
MountCLONE_NEWNSFilesystem mount tree — কোন device কোথায় mount করা, নিজস্ব root
NetworkCLONE_NEWNETNetwork interface, routing table, port space, iptables rule
UTSCLONE_NEWUTSHostname ও NIS domain name
IPCCLONE_NEWIPCSystem V IPC id space (semaphore, shared memory segment, message queue) ও POSIX message queue
UserCLONE_NEWUSERUID/GID সংখ্যা, আর কোন UID-কে কোন capability দেওয়া হচ্ছে

(ঐতিহাসিক টীকা: এই ছয়টাই সবচেয়ে গুরুত্বপূর্ণ ও সবচেয়ে বেশি ব্যবহৃত। বাস্তবে আরও দুইটা আছে — cgroup namespace (CLONE_NEWCGROUP, ২০১৬) যা cgroup path নিজেই virtualize করে, আর time namespace (CLONE_NEWTIME, ২০২০) যা CLOCK_MONOTONIC/CLOCK_BOOTTIME virtualize করে — মূলত container checkpoint/restore-এর সময় ঘড়ি “থামিয়ে রাখার” জন্য। এই লেসন মূল ছয়টার উপর ফোকাস করবে।)

Syscall interface — clone(), unshare(), setns()

তিনটা syscall দিয়ে namespace নিয়ন্ত্রণ করা যায়:

clone(fn, stack, flags, arg) — গত লেসনের fork()-এর বেশি সাধারণ ভাই (fork-exec-wait লেসনে এই কথাটা আগেই বলা হয়েছিল)। flags-এ CLONE_NEW* bit যোগ করলে নতুন child-টা সরাসরি নতুন namespace-এ জন্ম নেয়। একটা clone() কলেই একসাথে ৬টা namespace flag দেওয়া যায় — একটা মাত্র syscall-এ সম্পূর্ণ বিচ্ছিন্ন একটা “container” প্রক্রিয়া তৈরি।

unshare(flags)ইতিমধ্যে চলমান একটা process নিজের namespace থেকে “বিচ্ছিন্ন” হতে চাইলে এটা ব্যবহার করে। কোনো নতুন process তৈরি হয় না — একই process, কিন্তু এখন থেকে নতুন namespace নিয়ে চলবে।

setns(fd, nstype) — একটা ইতিমধ্যে বিদ্যমান namespace-এ যোগ দেওয়ার জন্য। fd আসে /proc/[pid]/ns/ থেকে একটা ফাইল খুলে:

int fd = open("/proc/1234/ns/net", O_RDONLY);
setns(fd, CLONE_NEWNET);   /* এখন এই process PID 1234-এর net namespace-এ যোগ দিল */
close(fd);

এটাই docker exec বা kubectl exec-এর ভিত্তি — একটা নতুন process তৈরি করে সেটাকে একটা চলমান container-এর namespace-এ ঢুকিয়ে দেওয়া, নতুন namespace তৈরি না করেই।

/proc/[pid]/ns/ — প্রতিটা process-এর জন্য একটা directory, তার প্রতিটা namespace-এর একটা symlink:

ls -la /proc/self/ns/
lrwxrwxrwx 1 root root 0 Aug 20 10:00 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 Aug 20 10:00 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 Aug 20 10:00 mnt -> 'mnt:[4026531840]'
lrwxrwxrwx 1 root root 0 Aug 20 10:00 net -> 'net:[4026531992]'
lrwxrwxrwx 1 root root 0 Aug 20 10:00 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Aug 20 10:00 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 Aug 20 10:00 uts -> 'uts:[4026531838]'

(সংখ্যাগুলো namespace-এর inode number — সিস্টেমভেদে ভিন্ন হবে, কিন্তু নীতিটা সার্বজনীন: দুইটা process একই namespace-এ থাকলে তাদের এই সংখ্যা মিলবে, ভিন্ন namespace-এ থাকলে মিলবে না। এটাই পরের experiment-এর মাপকাঠি।)

Cgroups — সম্পূর্ণ ভিন্ন, orthogonal mechanism

এখানে একটা কথা খুব স্পষ্টভাবে বলে রাখা দরকার, কারণ প্রায়ই namespace আর cgroups গুলিয়ে ফেলা হয়:

Namespace নিয়ন্ত্রণ করে একটা process কী দেখতে পায় (visibility)। Cgroup নিয়ন্ত্রণ করে একটা process কতটুকু ব্যবহার করতে পারে (resource consumption)। এই দুইটা সম্পূর্ণ orthogonal — একটা ছাড়া আরেকটা পুরোপুরি চলে।

একটা process namespace দিয়ে সম্পূর্ণ বিচ্ছিন্ন হতে পারে অথচ কোনো resource সীমা না থেকেও (host-এর পুরো CPU/memory খেয়ে ফেলতে পারে)। আবার একটা process cgroup দিয়ে কড়াভাবে সীমাবদ্ধ হতে পারে অথচ কোনো namespace isolation ছাড়াই (host-এর পুরো filesystem, network, PID list স্বাভাবিকভাবে দেখতে পারে — শুধু তার CPU/memory ব্যবহার সীমিত)। এই লেসনের মিসকনসেপশন অংশে দুইটার concrete উদাহরণ দেখব।

Cgroups-এর interface namespace-এর মতো syscall-ভিত্তিক না — এটা একটা filesystem: /sys/fs/cgroup। Directory বানানো মানে একটা group বানানো; সেই directory-র ফাইলে সংখ্যা লেখা মানে সীমা বসানো। কোনো নতুন syscall শেখার দরকার নেই — শুধু mkdir আর write()

Controllerমূল ফাইলকী সীমিত করে
cpucpu.max, cpu.weightCPU সময় — hard quota বা relative share
memorymemory.max, memory.highMemory ব্যবহার — hard limit (OOM kill) বা soft (throttle)
ioio.maxBlock device bandwidth/IOPS
pidspids.maxসেই group-এ সর্বোচ্চ কতগুলো process/thread — fork bomb ঠেকাতে

cgroup v1 বনাম v2 — সংক্ষেপে

cgroup v1 (২০০৮): প্রতিটা controller-এর জন্য আলাদা hierarchy — একটা process একই সাথে cpu hierarchy-তে এক group-এ, memory hierarchy-তে সম্পূর্ণ ভিন্ন group-এ থাকতে পারত। নমনীয়, কিন্তু বিভ্রান্তিকর — একই process-এর জন্য একাধিক, অসামঞ্জস্যপূর্ণ group tree মেলানো কঠিন।

cgroup v2 (২০১৬, kernel 4.5+, এখন default — systemd, Kubernetes 1.25+, বেশিরভাগ আধুনিক distro): একটা একক, unified hierarchy। একটা process ঠিক একটাই cgroup-এ থাকে, আর সেই group-এর জন্য যেসব controller enable করা আছে সেগুলো সবাই একই সাথে প্রযোজ্য হয়। Controller enable করতে হয় উপর থেকে নিচে, parent-এর cgroup.subtree_control ফাইলে লিখে:

echo "+memory +pids +cpu" | sudo tee /sys/fs/cgroup/cgroup.subtree_control

এই লেসনের experiment ও build অংশ cgroup v2 ব্যবহার করবে — আজকের বাস্তব standard, আর অনেক বেশি সরল ও predictable।

ভেতরে কী ঘটছে

PID namespace — ভেতরে কী আছে

process-anatomy লেসনে দেখা হয়েছিল প্রতিটা process-এর একটা task_struct-এ pid_t pid ফিল্ড থাকে। কিন্তু PID namespace-এর পর সেই ছবিটা একটু বেশি সূক্ষ্ম — একটা process-এর একটাই PID থাকে না, বরং একাধিক, একটা করে প্রতিটা namespace-এ যেখানে সে দৃশ্যমান।

Namespace-গুলো hierarchical — একটা child namespace তার parent-এর “ভেতরে” নেস্ট করা থাকে। একটা process যদি একটা নেস্টেড child PID namespace-এ জন্মায়, সে দৃশ্যমান থাকে সেই namespace-এ এবং তার সব ancestor namespace-এ (parent, grandparent… root পর্যন্ত), কিন্তু কখনো sibling বা deeper namespace-এ না। প্রতিটা দৃশ্যমান স্তরে তার একটা ভিন্ন সংখ্যা থাকে।

Host (root PID namespace)         → এই process-এর PID = 48213
  └── Container A (নতুন PID ns)   → একই process-এর PID = 1

Container-এর ভেতরের init/entrypoint process নিজেকে PID 1 হিসেবে দেখে (echo $$1), অথচ host থেকে ps aux চালালে সেই একই process একটা সাধারণ, বড় PID নিয়ে (যেমন 48213) তালিকায় থাকে। দুইটাই সত্যি, দুইটাই একই process — শুধু দুইটা ভিন্ন namespace থেকে দেখা।

Mount namespace — private filesystem tree

vfs-layer লেসনে mount tree-র কাঠামো দেখা হয়েছিল — কীভাবে আলাদা আলাদা filesystem একটা একক গাছে জোড়া লাগে। Mount namespace এই পুরো গাছের একটা ব্যক্তিগত কপি দেয় একটা process-কে।

নতুন mount namespace তৈরি হলে সেটা parent-এর মুহূর্তের mount table-এর একটা কপি নিয়ে শুরু করে, কিন্তু এরপর থেকে সম্পূর্ণ স্বাধীন — নতুন namespace-এ কিছু mount/unmount করলে সেটা (ঠিকভাবে সেট করা থাকলে) parent-এর namespace-কে প্রভাবিত করে না, আর উল্টোটাও।

vfs-layer লেসনের bind mount ধারণাটা এখানে চূড়ান্তভাবে কাজে লাগে — একটা container-এর rootfs আসলে হোস্টের ফাইলসিস্টেমের একটা অংশ, pivot_root()/chroot() দিয়ে সেটাকেই নতুন mount namespace-এর / বানিয়ে দেওয়া হয়। Container যা /etc/passwd বলে ভাবছে, সেটা আসলে host-এর ডিস্কে একটা সম্পূর্ণ ভিন্ন path-এ বসে থাকা একটা ফাইল — mount namespace আর bind mount-এর সমন্বয়েই এই বিভ্রম তৈরি হয়।

Network namespace — private network stack

নতুন network namespace মানে নতুন network interface list (শুরুতে শুধু একটা বিচ্ছিন্ন lo, loopback), নতুন routing table, নতুন iptables rule set, নতুন port space। এই শেষ পয়েন্টটাই সবচেয়ে ব্যবহারিক: একটা process নিজের net namespace-এ port 80-এ bind করতে পারে, host-এর port 80 ইতিমধ্যে অন্য কিছু ব্যবহার করলেও — দুইটা সম্পূর্ণ ভিন্ন port space, কোনো conflict নেই।

নিজে থেকে একটা net namespace বাইরের জগতের সাথে সংযুক্ত না — সাধারণত একটা veth pair (virtual ethernet, একটা cable-এর দুই প্রান্তের মতো) দিয়ে জোড়া হয়: এক প্রান্ত container-এর net namespace-এ, আরেক প্রান্ত host-এর namespace-এ একটা bridge-এ, তারপর NAT বা routing দিয়ে বাইরের নেটওয়ার্কে।

UTS ও IPC namespace — ছোট কিন্তু বাস্তব

UTS namespace সবচেয়ে সহজ — শুধু hostname আর NIS domain name। sethostname() কল করলে শুধু বর্তমান namespace-এর hostname বদলায়, host-এর না। এই সরলতার কারণেই এটা প্রায়ই সবচেয়ে ভালো প্রথম উদাহরণ — কোনো জটিল filesystem বা network setup ছাড়াই namespace isolation চোখে দেখা যায়।

IPC namespace System V IPC-র (semaphore, shared memory segment, message queue) id space আলাদা করে, আর POSIX message queue-ও। এই মডিউলের IPC-সংক্রান্ত লেসনে (shared memory, message queue) যেই shmget()/msgget() id-গুলো দেখা হয়েছে বা হবে, সেগুলো আসলে namespace-স্কোপড — দুইটা container একই সংখ্যার IPC id ব্যবহার করলেও তারা সম্পূর্ণ ভিন্ন, বিচ্ছিন্ন object পাবে, একে অপরের data দেখতে বা touch করতে পারবে না।

User namespace — সবচেয়ে গুরুত্বপূর্ণ, গভীরে যাওয়া দরকার

বাকি পাঁচটা namespace “কী দেখা যায়” নিয়ন্ত্রণ করে। User namespace একটু আলাদা — এটা নিয়ন্ত্রণ করে কে কতটা ক্ষমতাবান, আর এটাই সবচেয়ে দেরিতে (২০১৩) সম্পূর্ণ হওয়া, সবচেয়ে জটিল, আর নিরাপত্তার দিক থেকে সবচেয়ে তাৎপর্যপূর্ণ namespace।

কেন্দ্রীয় ধারণা: একটা process user namespace-এর ভেতরে নিজেকে UID 0 (root) হিসেবে দেখতে পারে, অথচ host থেকে দেখলে সেই একই process একটা সাধারণ, unprivileged UID (যেমন 1000)।

এটা সম্ভব হয় UID/GID mapping দিয়ে — /proc/[pid]/uid_map আর /proc/[pid]/gid_map ফাইলে লেখা হয় “namespace-এর ভেতরের ID, বাইরের প্রকৃত ID, দৈর্ঘ্য”:

echo "0 1000 1" > /proc/48213/uid_map
# অর্থ: namespace-এর ভেতরে UID 0 ↔ host-এ প্রকৃত UID 1000, ১টা ID

কেন এটা rootless container সম্ভব করে: kernel-এর capability check (ns_capable()) সবসময় একটা নির্দিষ্ট user namespace-এর সাপেক্ষে হয়, global ভাবে না। একটা process যদি তার নিজের user namespace-এ UID 0-এ mapped থাকে, সে সেই namespace-এর নিজস্ব resource-এর (নিজের তৈরি mount, নিজের net namespace-এর ভেতরের device) উপর root-এর ক্ষমতা পায় — কিন্তু host-এর প্রকৃত ফাইল, প্রকৃত process, প্রকৃত network-এর উপর কোনো বাড়তি ক্ষমতা পায় না। “namespace-এর ভেতরে root” আর “মেশিনের root” — সম্পূর্ণ আলাদা দুইটা জিনিস।

এই একটা ধারণাই Podman, rootless Docker, আর বেশিরভাগ আধুনিক container runtime-এর ভিত্তি — কোনো root daemon ছাড়াই একটা সাধারণ ব্যবহারকারী নিজের container “root” হিসেবে চালাতে পারে, অথচ host-এর নিরাপত্তা সীমানা অক্ষত থাকে।

Cgroups-এর ভেতর — enforcement বাস্তব, advisory না

/sys/fs/cgroup-এ একটা directory বানানো মানে একটা নতুন group। একটা process-কে সেই group-এ ঢোকাতে তার PID cgroup.procs ফাইলে লিখতে হয়:

mkdir /sys/fs/cgroup/my-group
echo $$ > /sys/fs/cgroup/my-group/cgroup.procs   # বর্তমান shell-কে যোগ করুন

Child process স্বয়ংক্রিয়ভাবে parent-এর cgroup উত্তরাধিকার সূত্রে পায় — এই শেলের ভেতর থেকে যা কিছু চালানো হবে, সবই my-group-এর সদস্য হয়ে যাবে, আলাদা করে কিছু করতে হবে না।

Enforcement কোথায় ঘটে — kernel-এর ভেতরের হুক পয়েন্ট:

  • CPU: scheduler নিজেই (linux-schedulers লেসনের CFS/EEVDF) প্রতিটা cgroup-কে একটা নেস্টেড scheduling entity হিসেবে ট্রিট করে — cpu.max (hard quota, একটা period-এ নির্দিষ্ট সময়ের বেশি চলতেই দেয় না) বা cpu.weight (relative share, ঠিক nice value-র মতো, কিন্তু group-স্তরে)।
  • Memory: প্রতিটা page allocation-এ (page-faults-and-demand-paging লেসনের page fault handler) kernel চেক করে current process-এর cgroup-এর usage memory.max ছাড়িয়ে যাচ্ছে কি না। ছাড়ালে প্রথমে reclaim চেষ্টা করে (সেই লেসনের active/inactive list, swap), তাতেও জায়গা না হলে সেই cgroup-এর ভেতর থেকেই একটা process বেছে SIGKILL পাঠায় — সিস্টেম-ব্যাপী OOM killer না, একটা group-স্কোপড সংস্করণ।
  • IO: block layer-এ প্রতিটা request submit-এর সময় io.max চেক হয়, প্রয়োজনে throttle করা হয়।
  • PIDs: fork()/clone()-এর সময় (fork-exec-wait লেসন) kernel চেক করে group-এর pids.max ছাড়িয়ে যাচ্ছে কি না — ছাড়ালে fork() নিজেই EAGAIN দিয়ে ব্যর্থ হয়, fork bomb-কে উৎসেই থামিয়ে দেয়।

এই প্রতিটা hook বিদ্যমান, স্বাধীন kernel subsystem-এর ভেতরেই বসানো — cgroups কোনো নতুন, সমান্তরাল enforcement engine না, এটা বিদ্যমান scheduler, page-fault handler, block layer, আর fork path-এর ভেতরে একটা অতিরিক্ত চেক।

কেন্দ্রীয় প্রমাণ — kernel-এ “container” নামে কিছুই নেই

এখন সরাসরি kernel-এর data structure দেখা যাক, কারণ কথায় না, কোডে প্রমাণ করাই এই লেসনের লক্ষ্য।

প্রতিটা task_struct-এ (মনে করুন process-anatomy লেসনের কঙ্কাল সংস্করণ) তিনটা pointer আছে যেগুলো একসাথে namespace আর cgroup membership নির্ধারণ করে:

/* সরলীকৃত, ধারণাগতভাবে সঠিক — kernel/nsproxy.h */
struct nsproxy {
    atomic_t count;
    struct uts_namespace     *uts_ns;
    struct ipc_namespace     *ipc_ns;
    struct mnt_namespace     *mnt_ns;
    struct pid_namespace     *pid_ns_for_children;
    struct net               *net_ns;
    struct cgroup_namespace  *cgroup_ns;
};

struct task_struct {
    /* ... হাজারো অন্য ফিল্ড ... */
    struct nsproxy *nsproxy;   /* কোন ৬টা namespace-এ আছে */
    struct cred    *cred;      /* cred->user_ns — কোন user namespace */
    struct css_set *cgroups;   /* কোন cgroup-এ আছে */
};

লক্ষ করুন — user namespace থাকে nsproxy-তে না, cred-এ। এটা ইচ্ছাকৃত ও অর্থবহ: user namespace সরাসরি credential/permission-এর অংশ, তাই সেটা সেখানেই থাকা যুক্তিসঙ্গত। কিন্তু যেখানেই থাকুক — এটাও শেষ পর্যন্ত একটা সাধারণ pointer, যা একটা প্রক্রিয়ার task_struct-এই আগে থেকে বিদ্যমান ছিল।

task_struct   (এইটাই — শুধু এইটাই — একটা "container" প্রসেস)

├── nsproxy ─────────┬── uts_namespace      (hostname)
│                     ├── ipc_namespace      (SysV IPC id space)
│                     ├── mnt_namespace      (mount tree)
│                     ├── pid_namespace_for_children
│                     ├── net               (net_namespace)
│                     └── cgroup_namespace

├── cred ────────────────── user_namespace   (UID/GID mapping)

└── cgroups (css_set) ───── cgroup path      (memory.max, cpu.max...)
প্রমাণএকটা 'container' process-এর সম্পূর্ণ kernel-representation — কোথাও struct container নেই

এই একটা diagram-ই এই লেসনের punchline-এর সম্পূর্ণ প্রমাণ। docker run চালালে kernel-এ কোনো নতুন struct type allocate হয় না। একটা সাধারণ task_struct তৈরি হয় (ঠিক যেভাবে প্রতিটা fork()/clone()-এ হয়), আর তার nsproxy pointer কিছু নতুন namespace struct-এর দিকে নির্দেশ করে, cred->user_ns একটা নতুন user namespace-এর দিকে, আর cgroups একটা নির্দিষ্ট cgroup path-এর দিকে। container = process + কিছু pointer, ব্যস।

উদাহরণ

docker run alpine sh — উপর থেকে নিচে, সিসকল পর্যন্ত

docker run alpine sh — প্রতিটা স্তর, সবচেয়ে নিচে raw syscall
  1. docker run alpine shCLI — dockerd-কে একটা HTTP API কল পাঠায়
  2. dockerd → containerd → containerd-shimইমেজ pull/unpack করে rootfs বানায়, একটা OCI bundle (rootfs + config.json) তৈরি করে
  3. runc create/startOCI runtime — এখান থেকে আসল kernel-স্তরের কাজ শুরু
  4. clone(CLONE_NEWPID|NEWNS|NEWNET|NEWUTS|NEWIPC|NEWUSER|SIGCHLD)একটাই syscall — নতুন process, একসাথে ৬টা নতুন namespace-এ
  5. echo <child_pid> > cgroup.procs; write memory.max, cpu.maxনতুন process-কে cgroup-এ যোগ করা, resource সীমা বসানো
  6. mount(NULL,"/",...,MS_PRIVATE); pivot_root(rootfs)mount propagation বিচ্ছিন্ন করে rootfs-কে নতুন root বানানো
  7. execve("/bin/sh")container-এর entrypoint চালু — এখন থেকে এটা শুধুই একটা সাধারণ process, host-এর ps-এও দেখা যাবে

লক্ষ করুন — মাঝখানের চারটা ধাপ (clone, cgroup.procs/memory.max, mount/pivot_root, execve) ঠিক এই লেসনের concepthood অংশে আলোচিত syscall-গুলোই, একই ক্রমে। runc (আর তার আগে libcontainer, তার আগে LXC) মূলত এই ধাপগুলোকেই ভালোভাবে, নিরাপদে, error-handling সহ সাজানো একটা প্রোগ্রাম — কোনো জাদুকরী নতুন kernel feature ব্যবহার করে না।

হাতেকলমে — unshare কমান্ড দিয়ে এক লাইনে

Docker ছাড়াই, শুধু একটা কমান্ড দিয়ে namespace isolation দেখা যায়:

sudo unshare --pid --fork --mount-proc --uts bash

এখন এই নতুন shell-এর ভেতরে:

hostname mini-box
echo $$
1

$$ (নিজের PID) দেখাচ্ছে 1 — অথচ এই একই bash process host-এর ps aux-এ একটা সম্পূর্ণ ভিন্ন, বড় PID নিয়ে দেখা যাবে। পরের experiment-এ এটাই আরও কঠোরভাবে যাচাই করা হবে — শুধু echo $$-এর কথায় বিশ্বাস না করে, kernel-এর namespace inode number সরাসরি তুলনা করে।

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

EXPERIMENT

Namespace inode number দিয়ে isolation প্রমাণ করুন — কথায় না, kernel-এর নিজের হিসাবে

Linux· ১০ মিনিট

দুইটা টার্মিনাল খুলুন। প্রথমটায়:

sudo unshare --pid --fork --mount-proc bash
readlink /proc/self/ns/pid
pid:[4026532825]

এই shell-এর PID নোট করুন — অন্য টার্মিনাল থেকে দরকার হবে:

echo $$
1

দ্বিতীয় টার্মিনালে (host-এর নিজস্ব namespace-এ):

# host থেকে unshare-করা bash-টা খুঁজে বের করুন
ps aux | grep "unshare"
root     48213  0.0  0.0  ...  unshare --pid --fork --mount-proc bash
readlink /proc/48213/ns/pid    # সেই child bash-এর namespace
readlink /proc/self/ns/pid     # host shell-এর নিজের namespace
pid:[4026532825]
pid:[4026531836]

দুইটা সম্পূর্ণ ভিন্ন সংখ্যা — সংখ্যায় প্রমাণিত, অনুমানে না। একই process (host-এর দৃষ্টিতে PID 48213, নিজের দৃষ্টিতে PID 1) কে দুইটা ভিন্ন জায়গা থেকে দেখা হচ্ছে, আর namespace inode-ই সেই পার্থক্যের প্রকৃত, kernel-স্তরের প্রমাণ। যদি echo $$ শুধু একটা প্রদর্শনী হতো (আসল isolation ছাড়া), এই দুইটা readlink একই সংখ্যা দেখাত।

Rootless সংস্করণ (sudo ছাড়া, যদি unprivileged user namespace চালু থাকে):

unshare --user --map-root-user --pid --fork --mount-proc bash

এটা কাজ করলে সরাসরি প্রমাণ হয় user namespace কীভাবে বাকি namespace type-গুলোকে unprivileged access দেয় — এই লেসনের user namespace আলোচনার একটা তাৎক্ষণিক, হাতে-কলমে যাচাই।

এটা কী প্রমাণ করে

PID namespace সত্যিই আলাদা, বৈধ namespace object — শুধু 'মনে হওয়া' না। দুইটা ভিন্ন namespace-এর inode number কখনো মিলবে না, একই namespace-এর দুইটা process-এর সবসময় মিলবে।

EXPERIMENT

cgroup memory.max — advisory hint না, kernel-enforced hard limit

Linux (cgroup v2)· ১০ মিনিট
# একবার — root হিসেবে, memory controller enable করুন
echo "+memory" | sudo tee /sys/fs/cgroup/cgroup.subtree_control

# নিজের group বানান
sudo mkdir /sys/fs/cgroup/test-limit
echo $$ | sudo tee /sys/fs/cgroup/test-limit/cgroup.procs   # বর্তমান shell-কে যোগ করুন
echo 20M | sudo tee /sys/fs/cgroup/test-limit/memory.max    # 20MB hard limit

এখন এই একই shell-এর ভেতর থেকে ১০০ MB allocate করার চেষ্টা:

python3 -c "
data = bytearray(100 * 1024 * 1024)
for i in range(0, len(data), 4096):
    data[i] = 1              # প্রতিটা page সত্যিই touch করা — শুধু allocate যথেষ্ট না
print('এখানে পৌঁছালাম?!')
"
Killed

python3 প্রক্রিয়াটা print লাইনে পৌঁছানোর অনেক আগেই Killed বার্তা দিয়ে থেমে যায় — কারণ এটা test-limit cgroup-এর সদস্য (shell থেকে উত্তরাধিকার সূত্রে), আর সেই group-এর 20MB সীমা ছাড়িয়ে গেছে।

cat /sys/fs/cgroup/test-limit/memory.events
low 0
high 0
max 0
oom 1
oom_kill 1
dmesg | tail -3
[12345.678] memory: usage 20480kB, limit 20480kB, failcnt 0
[12345.678] Memory cgroup out of memory: Killed process 51204 (python3) total-vm:...kB, anon-rss:19940kB, ...

oom_kill 1 আর dmesg-এর log entry — দুইটাই স্বাধীনভাবে নিশ্চিত করছে কী ঘটেছে। প্রোগ্রামের কোড এই সীমা সম্পর্কে কিছুই জানত না — এটা কোনো try/except MemoryError না, kernel সরাসরি SIGKILL পাঠিয়েছে। এটাই hard enforcement-এর সংজ্ঞা।

# পরিষ্কার করুন
sudo rmdir /sys/fs/cgroup/test-limit
এটা কী প্রমাণ করে

একটা cgroup-এর memory.max ছাড়িয়ে গেলে kernel নিজে থেকে, প্রোগ্রামের সম্মতি ছাড়াই, একটা process মেরে ফেলে — এটা একটা soft suggestion না, একটা hard, বাধ্যতামূলক সীমা।

আরও পড়ুন

  • namespaces(7) — overview of Linux namespaces · প্রতিটা namespace type-এর প্রামাণ্য তালিকা, flag, ও ইতিহাস
  • user_namespaces(7) · UID/GID mapping, setgroups, আর capability-namespace সম্পর্কের নির্ভুল semantics
  • cgroups(7) · cgroup v1/v2-এর পার্থক্য ও controller তালিকার প্রামাণ্য উৎস
  • Control Group v2 — kernel admin guide · unified hierarchy, subtree_control, memory.max/memory.events-এর নির্ভুল আচরণ
  • clone(2) · CLONE_NEW* flag-গুলোর সম্পূর্ণ তালিকা ও semantics
  • Rice, Liz — Container Security — Liz Rice · O'Reilly, 2020 — কেন container isolation সম্পূর্ণভাবে kernel-privilege boundary-র উপর নির্ভরশীল, তার প্রামাণ্য আলোচনা