Namespace ও Cgroups — একটা 'container' আসলে কী
Namespaces and Cgroups
গত লেসনের শেষে একটা প্রতিশ্রুতি দেওয়া হয়েছিল — একটা process-কে সম্পূর্ণ বিভ্রম দেওয়া যায় যে সে একাই মেশিনে আছে, যদিও শত শত অন্য process একই kernel শেয়ার করছে। আজ সেই বিভ্রম কীভাবে বানানো হয় তা দেখব — namespace (কী দেখা যায়) আর cgroups (কতটুকু ব্যবহার করা যায়), দুইটা সম্পূর্ণ ভিন্ন, orthogonal kernel mechanism। শেষে raw clone()/unshare() আর /sys/fs/cgroup দিয়ে Docker ছাড়াই একটা বাস্তব, ন্যূনতম container বানাব — আর প্রমাণ করব কেন kernel-এ আসলে 'container' নামে কিছুই নেই।
আগে এটা বুঝি
গত লেসনের শেষে একটা প্রতিশ্রুতি দেওয়া হয়েছিল। এখন সেটা রাখার পালা।
প্রশ্নটা ছিল: একটা 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, IPC | hostname ও 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 — এক নজরে
| Namespace | clone() flag | কী virtualize করে |
|---|---|---|
| PID | CLONE_NEWPID | Process ID সংখ্যা — namespace-এর ভেতরে নিজস্ব PID 1 থেকে গোনা |
| Mount | CLONE_NEWNS | Filesystem mount tree — কোন device কোথায় mount করা, নিজস্ব root |
| Network | CLONE_NEWNET | Network interface, routing table, port space, iptables rule |
| UTS | CLONE_NEWUTS | Hostname ও NIS domain name |
| IPC | CLONE_NEWIPC | System V IPC id space (semaphore, shared memory segment, message queue) ও POSIX message queue |
| User | CLONE_NEWUSER | UID/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 | মূল ফাইল | কী সীমিত করে |
|---|---|---|
cpu | cpu.max, cpu.weight | CPU সময় — hard quota বা relative share |
memory | memory.max, memory.high | Memory ব্যবহার — hard limit (OOM kill) বা soft (throttle) |
io | io.max | Block device bandwidth/IOPS |
pids | pids.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 = 1Container-এর ভেতরের 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-এর usagememory.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...)এই একটা 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 shCLI — dockerd-কে একটা HTTP API কল পাঠায়
- dockerd → containerd → containerd-shimইমেজ pull/unpack করে rootfs বানায়, একটা OCI bundle (rootfs + config.json) তৈরি করে
- runc create/startOCI runtime — এখান থেকে আসল kernel-স্তরের কাজ শুরু
- clone(CLONE_NEWPID|NEWNS|NEWNET|NEWUTS|NEWIPC|NEWUSER|SIGCHLD)একটাই syscall — নতুন process, একসাথে ৬টা নতুন namespace-এ
- echo <child_pid> > cgroup.procs; write memory.max, cpu.maxনতুন process-কে cgroup-এ যোগ করা, resource সীমা বসানো
- mount(NULL,"/",...,MS_PRIVATE); pivot_root(rootfs)mount propagation বিচ্ছিন্ন করে rootfs-কে নতুন root বানানো
- execve("/bin/sh")container-এর entrypoint চালু — এখন থেকে এটা শুধুই একটা সাধারণ process, host-এর ps-এও দেখা যাবে
লক্ষ করুন — মাঝখানের চারটা ধাপ (clone, cgroup.procs/memory.max,
mount/pivot_root, execve) ঠিক এই লেসনের concept ও hood
অংশে আলোচিত 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 সরাসরি
তুলনা করে।
নিজে চালিয়ে দেখুন
Namespace inode number দিয়ে isolation প্রমাণ করুন — কথায় না, kernel-এর নিজের হিসাবে
দুইটা টার্মিনাল খুলুন। প্রথমটায়:
sudo unshare --pid --fork --mount-proc bash
readlink /proc/self/ns/pidpid:[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 bashreadlink /proc/48213/ns/pid # সেই child bash-এর namespace
readlink /proc/self/ns/pid # host shell-এর নিজের namespacepid:[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-এর সবসময় মিলবে।
cgroup memory.max — advisory hint না, kernel-enforced hard limit
# একবার — 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('এখানে পৌঁছালাম?!')
"Killedpython3 প্রক্রিয়াটা print লাইনে পৌঁছানোর অনেক আগেই Killed
বার্তা দিয়ে থেমে যায় — কারণ এটা test-limit cgroup-এর সদস্য
(shell থেকে উত্তরাধিকার সূত্রে), আর সেই group-এর 20MB সীমা
ছাড়িয়ে গেছে।
cat /sys/fs/cgroup/test-limit/memory.eventslow 0
high 0
max 0
oom 1
oom_kill 1dmesg | 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-র উপর নির্ভরশীল, তার প্রামাণ্য আলোচনা