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

Linux Namespaces গভীরে — সাতটি isolation primitive-এর ভেতরের যন্ত্র

Linux Namespaces Deep Dive: The Seven Isolation Primitives

operating-systems/namespaces-and-cgroups লেসন namespace-এর একটা survey-level পরিচয় দিয়েছিল — এই লেসন সেই ভূমি থেকে অনেক গভীরে যায়: প্রতিটা CLONE_NEW* flag-এর exact hex constant, PID-1-এর সিগন্যাল-হ্যান্ডলিং quirk, chroot বনাম pivot_root-এর নির্ভুল পার্থক্য, আর user namespace-এর UID/GID mapping-এর গাণিতিক যন্ত্র, একটা সত্যিকার চলমান Python simulation দিয়ে।

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

  • সাতটি Linux namespace type-এর প্রতিটির exact clone()/unshare() flag constant এবং hex value বলতে পারবেন, আর প্রতিটা ঠিক কোন kernel resource isolate করে তা ব্যাখ্যা করতে পারবেন।
  • PID namespace-এর ভেতরের PID-1 quirk — কেন namespace-এর প্রথম process default signal disposition পায় না, আর কেন তাকে নিজে zombie child reap করতে হয় — বিস্তারিতভাবে ব্যাখ্যা করতে পারবেন, আর এই কারণেই কেন container-এ tini/dumb-init দরকার হয় তা যুক্তি দিতে পারবেন।
  • chroot() আর pivot_root()-এর মধ্যে নির্ভুল পার্থক্য বলতে পারবেন, আর কেন container runtime-গুলো নিরাপদ root-swap-এর জন্য pivot_root ব্যবহার করে, chroot না, তা প্রমাণ করতে পারবেন।
  • user namespace-এর UID/GID mapping mechanic — /proc/[pid]/uid_map-এর নির্দিষ্ট ফরম্যাট, আর একটা namespace-ভেতরের UID কীভাবে host UID-এ translate হয় — হাতে-কলমে হিসাব করে এবং কোড চালিয়ে দেখাতে পারবেন।
  • network, mount, IPC, UTS, ও cgroup namespace প্রতিটার concrete isolation আচরণ (কী দেখা যায়, কী দেখা যায় না) বাস্তব উদাহরণ দিয়ে ব্যাখ্যা করতে পারবেন, আর কেন namespace শুধু 'visibility' নিয়ন্ত্রণ করে, resource consumption না, তা পরবর্তী cgroups লেসনের ভূমিকা হিসেবে বলতে পারবেন।

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

আগে এটা বুঝি

operating-systems/namespaces-and-cgroups লেসনের কেন্দ্রীয় দাবিটা মনে করুন: kernel-এ struct container বলে কিছু নেই, একটা “container” আসলে শুধু একটা সাধারণ process — কিছু অস্বাভাবিক nsproxy pointer আর একটা cgroup path সহ। সেই লেসন ছয় (প্রকৃতপক্ষে সাত) ধরনের namespace-এর একটা survey দিয়েছিল — প্রতিটার clone() flag নাম, প্রতিটা মোটামুটি কী isolate করে, আর unshare/setns//proc/[pid]/ns/-এর মৌলিক ব্যবহার।

এই লেসন সেই ভূমি থেকে শুরু করে, কিন্তু থামে না। তিনটা প্রশ্ন সেই লেসন ইচ্ছাকৃতভাবে খোলা রেখেছিল, যেগুলো এখানে বন্ধ করব:

১. একটা PID namespace-এর ভেতরের PID 1 মারা গেলে kernel পুরো namespace-এ SIGKILL পাঠায় — এই তথ্যটা সেই লেসনে ছিল। কিন্তু কেন PID 1-কে এত বিশেষ ট্রিট করা হয়? PID 1 নিজে ঠিক কোন অস্বাভাবিক আচরণ পায়, আর সেটা একটা সাধারণ Docker container-এর ভেতরে বাস্তবে কী সমস্যা তৈরি করে?

২. mount namespace আলোচনায় pivot_root()/chroot() একসাথে, প্রায় বিনিময়যোগ্য শব্দ হিসেবে উল্লেখ হয়েছিল। কিন্তু এই দুইটা syscall আসলে সম্পূর্ণ ভিন্ন নিরাপত্তা গ্যারান্টি দেয় — একটা escape করা যায়, আরেকটা যায় না। পার্থক্যটা ঠিক কোথায়?

৩. UID mapping-এর একটা একক-লাইনের উদাহরণ (0 1000 1) দেখানো হয়েছিল। কিন্তু বাস্তব container runtime-এ mapping প্রায়ই একাধিক লাইনের, ৬৫৫৩৬-দৈর্ঘ্যের রেঞ্জ নিয়ে কাজ করে (/etc/subuid-এর মতো)। একটা namespace-ভেতরের arbitrary UID-কে host UID-এ ঠিক কীভাবে গণনা করে বের করা হয়, যখন mapping-এ একাধিক রেঞ্জ থাকে?

এই তিনটা প্রশ্নের উত্তরই এই লেসনের কেন্দ্রে থাকবে, সাতটা namespace type-এর প্রতিটার নির্ভুল mechanics-এর সাথে। শেষে একটা honest experiment থাকবে — এই লেসন যে sandbox-এ লেখা হচ্ছে সেটা আসলে macOS, Linux না, তাই যেখানে সত্যিকার namespace syscall চালানো সম্ভব না, সেটা স্পষ্টভাবে স্বীকার করে বরং যা সত্যিই চালানো ও যাচাই করা সম্ভব — UID mapping-এর গাণিতিক যন্ত্র — সেটা একটা সত্যিকার, verified Python প্রোগ্রাম দিয়ে দেখানো হবে।

মূল ধারণা

সাতটা namespace, সাতটা exact flag — এক জায়গায়

namespaces-and-cgroups লেসন ছয়টা namespace type প্রধান হিসেবে দেখিয়েছিল, আর cgroup namespace-কে একটা টীকায় উল্লেখ করেছিল। এই লেসন সবগুলোকে সমান গুরুত্ব দিয়ে, তাদের exact hexadecimal constant সহ একসাথে রাখছে — কারণ বাস্তব C কোডে (runc, libcontainer, বা নিজের লেখা কোনো clone() কল-এ) আপনি এই hex সংখ্যাগুলোই ব্যবহার করবেন, শব্দগত নাম না।

NamespaceFlag (macro, linux/sched.h)Hex valueপ্রথম সম্পূর্ণ হয়কী isolate করে
MountCLONE_NEWNS0x00020000Linux 2.4.19 (2002)mount point tree — কোন filesystem কোথায় mount করা
CgroupCLONE_NEWCGROUP0x02000000Linux 4.6 (2016)cgroup hierarchy-র দৃশ্যমান “root”
UTSCLONE_NEWUTS0x04000000Linux 2.6.19 (2006)hostname, NIS domainname
IPCCLONE_NEWIPC0x08000000Linux 2.6.19 (2006)SysV IPC id space, POSIX message queue
UserCLONE_NEWUSER0x10000000Linux 3.8 (2013)UID/GID সংখ্যা ও capability
PIDCLONE_NEWPID0x20000000Linux 2.6.24 (2008)process ID সংখ্যা
NetworkCLONE_NEWNET0x40000000Linux ~3.0 (2011, ধাপে ধাপে)network interface, routing, port space

তিনটা syscall, সংক্ষেপে পুনরাবৃত্তি — কারণ এই লেসনের ভিত্তি

আগের লেসনের clone()/unshare()/setns() ব্যাখ্যা এই লেসনেও প্রযোজ্য, তাই এক লাইনে মনে করিয়ে দেওয়া যথেষ্ট: clone() — নতুন process, নতুন namespace, একসাথে। unshare() — চলমান process নিজের namespace থেকে বিচ্ছিন্ন হয় (PID namespace-এ একটা ব্যতিক্রম আছে, যা আগের লেসনে দেখানো হয়েছিল — কলিং process না, শুধু তার পরবর্তী children নতুন namespace পায়)। setns() — একটা বিদ্যমান namespace-এ যোগ দেওয়া, /proc/[pid]/ns/*-এর একটা fd দিয়ে। এই লেসনের বাকি অংশ ধরে নেবে এই ভিত্তি আপনার কাছে পরিষ্কার, আর সরাসরি প্রতিটা namespace type-এর ভেতরের মেকানিক্স-এ যাবে।

ভেতরে কী ঘটছে

PID namespace — শুধু নম্বরিং না, একটা গভীর সিগন্যাল-হ্যান্ডলিং quirk

namespaces-and-cgroups লেসন দেখিয়েছিল একটা process একাধিক namespace-এ একাধিক PID নিয়ে দৃশ্যমান হতে পারে, আর namespace-এর PID 1 মারা গেলে kernel পুরো namespace-এ SIGKILL পাঠায়। এখন কেন এই আচরণ, তার আসল উত্তর।

PID 1-এর জন্য সিগন্যালের নিয়ম আলাদা

সাধারণ কোনো process যদি একটা সিগন্যালের (যেমন SIGTERM) জন্য নিজের sigaction() handler ইনস্টল না করে, kernel তার default disposition প্রয়োগ করে — SIGTERM-এর ডিফল্ট হলো process টার্মিনেট করা। কিন্তু PID 1 (host-এর systemd/init হোক বা একটা container namespace-এর ভেতরের PID 1 হোক) — কোনো namespace-এর তোয়াক্কা না করেই — এই ডিফল্ট আচরণ থেকে exempt। যদি PID 1 নিজে কোনো signal-এর জন্য handler ইনস্টল না করে, kernel সেই সিগন্যাল চুপচাপ উপেক্ষা করে — এমনকি SIGTERM, SIGINT-এর মতো সাধারণত-প্রাণঘাতী সিগন্যালও।

সাধারণ process, handler নেই, SIGTERM পেল  → kernel default disposition চালায় → process টার্মিনেট
PID 1 (namespace-এর ভেতরে হোক বা host-এ), handler নেই, SIGTERM পেল → kernel সিগন্যাল ফেলে দেয় → কিছুই হয় না

এটা ইচ্ছাকৃত kernel ডিজাইন — একটা দুর্ঘটনাক্রমে পাঠানো সিগন্যাল যেন পুরো সিস্টেমের (বা namespace-এর) init process-টাকেই মেরে না ফেলে, যেটা একটা বিপর্যয়কর ব্যর্থতা হতো। কিন্তু এই একই সুরক্ষা যখন একটা অসতর্ক container image-এর entrypoint-এ প্রযোজ্য হয়, তখন এটা একটা বাস্তব বাগে পরিণত হয়, যা এখনই দেখব।

দ্বিতীয় দায়িত্ব — orphan reaping, বাধ্যতামূলক

PID 1-এর (host-এ হোক বা namespace-এ) আরেকটা কাজ যা কোনো ঐচ্ছিক সুবিধা না, বরং কার্যত বাধ্যতামূলক — যখন কোনো process-এর parent মারা যায় (process-anatomy/fork-exec-wait-জাতীয় লেসনের orphan/zombie আলোচনার সরাসরি প্রয়োগ), সেই orphan child-কে PID 1-এর কাছে reparent করা হয়। PID 1-এর দায়িত্ব সেই child শেষ পর্যন্ত exit করলে তার উপর wait()/waitpid() কল করে zombie entry-টা reap করা — যদি না করে, zombie process table-এ চিরকাল আটকে থাকে।

host-এ systemd এই কাজ যত্ন সহকারে করে। কিন্তু একটা সাধারণ Docker image-এর entrypoint — ধরুন একটা raw node server.js বা python app.py — এমনভাবে লেখা হয়নি যে সে জানে সে PID 1 হয়ে যাবে। এটা কখনো SIGCHLD handle করে না, কখনো waitpid() কল করে না, কারণ প্রোগ্রামটা এভাবে ডিজাইনই করা হয়নি (একটা normal process হিসেবে, যেখানে তার parent shell orphan-দের সামলাবে ধরে নিয়ে)।

সমাধান — একটা real init process

এই কারণেই container ecosystem-এ tini আর dumb-init-এর মতো ছোট টুল জন্ম নিয়েছে। এদের কাজ একটাই — PID 1 হিসেবে চলা, container-এর আসল entrypoint-কে child হিসেবে spawn করা, আর তারপর PID 1-এর দুইটা দায়িত্বই সঠিকভাবে পালন করা:

১. সিগন্যাল (SIGTERM, SIGINT) সঠিকভাবে forward করা মূল প্রোগ্রামের দিকে (যেহেতু PID 1 নিজে সেগুলো উপেক্ষা করত যদি handler না থাকত) ২. SIGCHLD handle করে যেকোনো orphaned zombie নিয়মিত waitpid() দিয়ে reap করা

PID 1 = tini
  └── PID 2 = app.py (আসল entrypoint, tini-এর child হিসেবে চলছে)
        └── PID 3 = subprocess (app.py-এর child)

এই ব্যবস্থায় app.py-কে আর কখনো PID 1 হতে হয় না — সে একটা সাধারণ, স্বাভাবিক PID 2 process, যার নিজস্ব parent (tini) আছে যে তার orphan-দের সামলাবে। Docker-এর নিজস্ব --init ফ্ল্যাগ (docker run --init ...) ঠিক tini-কেই ইনজেক্ট করে দেয় PID 1 হিসেবে, ব্যবহারকারীর image পরিবর্তন না করেই।

Network namespace — একটা সম্পূর্ণ, খালি স্ট্যাক থেকে শুরু

নতুন network namespace (CLONE_NEWNET) মানে সম্পূর্ণ আলাদা network stack — নিজস্ব interface তালিকা, নিজস্ব routing table, নিজস্ব iptables/nftables rule set, নিজস্ব port space। কিন্তু একটা সূক্ষ্ম, প্রায়ই ভুল বোঝা ডিটেইল আছে: এই নতুন namespace সম্পূর্ণ খালি না।

Loopback আছে, কিন্তু DOWN

প্রতিটা নতুন network namespace একটা lo (loopback) interface নিয়ে শুরু হয় — কিন্তু ডিফল্টভাবে সেটা প্রশাসনিকভাবে DOWN অবস্থায় থাকে। যদি namespace-এর ভেতরের কোনো প্রোগ্রাম 127.0.0.1-এ connect করার চেষ্টা করে, এই ধাপটা এড়িয়ে গেলে ব্যর্থ হবে — Connection refused বা Network is unreachable।

ip netns add mybox
ip netns exec mybox ip addr show lo
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

লক্ষ করুন state DOWN — device বিদ্যমান, কিন্তু নিষ্ক্রিয়। এটাকে সক্রিয় করতে হয় explicit-ভাবে:

ip netns exec mybox ip link set lo up
ip netns exec mybox ip addr show lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever

এখন state UP, আর 127.0.0.1 কাজ করবে। container runtime-গুলো (runc-সহ) এই ধাপটা স্বয়ংক্রিয়ভাবে করে namespace তৈরির অংশ হিসেবে — কিন্তু হাতে raw unshare --net চালালে এই ধাপ মনে করে করতে হয়, নাহলে localhost-নির্ভর যেকোনো কোড (অনেক dev server, database client) ব্যর্থ হবে।

দুই container, একই port, কোনো সংঘর্ষ নেই

Network namespace-এর সবচেয়ে ব্যবহারিক ফল — port space namespace-স্কোপড। দুইটা container একই সাথে port 80-এ bind করতে পারে, কারণ প্রতিটার নিজস্ব, সম্পূর্ণ আলাদা port space:

Container A (net ns X): bind(0.0.0.0:80)  → সফল, X namespace-এর port 80 দখল
Container B (net ns Y): bind(0.0.0.0:80)  → সফল, Y namespace-এর port 80 দখল — কোনো conflict নেই
Host (root net ns):     bind(0.0.0.0:80)  → এটাও সফল হতে পারে — সম্পূর্ণ তৃতীয় একটা namespace

এটাই docker run -p 8080:80 আর docker run -p 8081:80-এর মতো কমান্ড একই সাথে চালানো সম্ভব করে — প্রতিটা container ভেতরে ভাবছে সে একাই port 80 ব্যবহার করছে, host-এর তরফ থেকে ভিন্ন external port-এ map হয়ে দৃশ্যমান হচ্ছে।

Mount namespace — নাম বিভ্রান্তিকর, mechanics গুরুত্বপূর্ণ

প্রথমে একটা ছোট কিন্তু বিরক্তিকর ঐতিহাসিক ব্যাপার: mount namespace-এর flag-এর নাম CLONE_NEWNS — কোনো “MOUNT” শব্দ নেই flag-এর নামে। এটা ঐতিহাসিক কারণে — mount namespace ছিল প্রথম namespace type (২০০২), তখন ধরে নেওয়া হয়েছিল এটাই একমাত্র namespace ধরনের হবে, তাই generic নাম NEWNS (“new namespace”) ব্যবহার করা হয়েছিল। পরে আরও ছয়টা namespace type যোগ হলে সবাই যথাযথ নির্দিষ্ট নাম পায় (NEWPID, NEWNET, ইত্যাদি), কিন্তু প্রথমটার নাম আর বদলানো যায়নি — backward compatibility ভাঙত। ফলাফল: আজও CLONE_NEWNS মানেই “mount namespace”, যদিও নামে সেটা স্পষ্ট না।

chroot() বনাম pivot_root() — নির্ভুল পার্থক্য

এটাই এই সেকশনের কেন্দ্রীয় প্রশ্ন। দুইটাই “root পরিবর্তন করে” মনে হয়, কিন্তু গ্যারান্টি সম্পূর্ণ ভিন্ন।

chroot(path) শুধু বদলায় একটা process-এর জন্য path resolution-এর apparent root। /-দিয়ে শুরু হওয়া যেকোনো path এখন path-এর সাপেক্ষে resolve হয়। কিন্তু:

  • পুরনো root filesystem এখনও mounted আছে — শুধু নতুন root-এর ভেতর থেকে সরাসরি path দিয়ে পৌঁছানো যায় না।
  • যদি process-এর কাছে পুরনো root-এর দিকে একটা খোলা file descriptor (একটা directory-র fd) আগে থেকেই থাকে, সে সেই fd দিয়ে fchdir() করে পুরনো root-এ ফিরে যেতে পারে — এটাই ক্লাসিক “chroot escape” কৌশল, কয়েক দশক ধরে পরিচিত।
  • chroot()-এর জন্য CAP_SYS_CHROOT capability লাগে, কিন্তু এটা কখনোই একটা namespace mechanism ছিল না — এটা mount namespace আসারও অনেক আগে থেকে বিদ্যমান একটা পুরনো, সাধারণ syscall (BSD থেকে উত্তরাধিকারসূত্রে)।
/* ক্লাসিক chroot escape — সরলীকৃত ধারণা */
int old_root_fd = open("/", O_RDONLY);  /* escape-এর আগেই খোলা */
chroot("/new/restricted/root");
/* ... */
fchdir(old_root_fd);   /* পুরনো root-এ ফিরে যাওয়া, ফাইল descriptor দিয়ে */
chroot(".");            /* এখন apparent root-ও পুরনো root-এ ফিরিয়ে আনা */
/* এখন সম্পূর্ণ পুরনো filesystem আবার দৃশ্যমান — chroot ভাঙা হয়েছে */

pivot_root(new_root, put_old) সম্পূর্ণ ভিন্ন গ্যারান্টি দেয় — এটা আসলে বদলে দেয় কোন mounted filesystem “root” হিসেবে গণ্য হবে। বর্তমান root mount সরিয়ে put_old directory-তে বসানো হয় (এখনও mounted, কিন্তু এখন root-এর একটা sub-mount হিসেবে), আর new_root নতুন root হয়ে যায়। এরপর, গুরুত্বপূর্ণভাবে, container tooling সাধারণত পরবর্তী ধাপে সেই পুরনো root unmount করে দেয়:

/* container tooling-এর সাধারণ pattern, সরলীকৃত */
mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL);  /* propagation বিচ্ছিন্ন করা — namespaces-and-cgroups লেসনের callout */
pivot_root(new_root, old_root_path);  /* new_root -> নতুন "/", পুরনো "/" -> old_root_path-এ */
chdir("/");
umount2(old_root_path, MNT_DETACH);   /* পুরনো root সম্পূর্ণ unmount — এখন আর সেখানে কিছুই নেই */
rmdir(old_root_path);

এই শেষ umount2() ধাপের পর, পুরনো root filesystem-এর সাথে কোনো mount-tree সংযোগই আর নেই — কোনো খোলা fd দিয়ে fchdir() করলেও আর পুরনো ফাইলগুলো ফিরে পাওয়া যায় না, কারণ সেই filesystem আর mounted-ই না। এটাই মূল পার্থক্য: chroot শুধু দেখার কোণ বদলায় (path resolution), pivot_root প্রকৃতপক্ষে সরিয়ে দেয় কোন filesystem root — আর তারপর পুরনোটাকে সম্পূর্ণ বিচ্ছিন্ন করে দেওয়া যায়।

chroot()pivot_root()
কী বদলায়শুধু path resolution-এর apparent rootকোন mounted filesystem root, বাস্তবিকভাবে
পুরনো root filesystemmounted-ই থেকে যায়unmount করে দেওয়া যায়
খোলা fd দিয়ে escape সম্ভব?হ্যাঁ (ঐতিহাসিকভাবে পরিচিত কৌশল)না — filesystem-ই আর mount-tree-তে নেই
namespace-নির্ভর?না, mount namespace আসার আগে থেকেই ছিলকার্যত সবসময় mount namespace-এর সাথে ব্যবহৃত হয়
container runtime ব্যবহার করে?না (নিরাপত্তার জন্য যথেষ্ট না)হ্যাঁ — runc, containerd-সহ প্রায় সব আধুনিক runtime

UTS namespace — সবচেয়ে সহজ, কিন্তু সেটাই তার শিক্ষামূলক মূল্য

CLONE_NEWUTS isolate করে শুধু দুইটা জিনিস — hostname আর NIS domainname। কোনো filesystem, কোনো network setup, কোনো UID mapping জড়িত না। sethostname("mybox") কল করলে শুধু বর্তমান namespace-এর hostname বদলায়:

unshare(CLONE_NEWUTS);
sethostname("mybox", 6);
/* এই process আর তার children এখন hostname "mybox" দেখবে
   parent namespace-এর hostname একদম অপরিবর্তিত থাকবে */

এই সরলতার কারণেই UTS namespace প্রায়ই “namespace isolation” ধারণার সবচেয়ে ভালো প্রথম demonstration — কোনো জটিল setup ছাড়াই একটা visible, তাৎক্ষণিক প্রমাণ পাওয়া যায় যে namespace সত্যিই কাজ করছে।

IPC namespace — id space, বাস্তব বিচ্ছিন্নতা সহ

CLONE_NEWIPC System V IPC-র id space আলাদা করে — semaphore, shared memory segment, message queue-র জন্য shmget()/semget()/msgget() যে integer id ফেরত দেয়, সেগুলো এখন namespace-স্কোপড। সাথে POSIX message queue-ও (/dev/mqueue-এর ভেতরের entry)।

গুরুত্বপূর্ণ ব্যবহারিক প্রভাব: দুইটা container একই সংখ্যার IPC id (যেমন উভয়েই shmget() থেকে id 5 পেল) ব্যবহার করতে পারে, অথচ তারা সম্পূর্ণ ভিন্ন, বিচ্ছিন্ন kernel object পাবে — একে অপরের shared memory content দেখতে বা touch করতে পারবে না। এটা মূলত একটা নিরাপত্তা বৈশিষ্ট্য, কিন্তু এর একটা সূক্ষ্ম ফলও আছে — legacy সফটওয়্যার যা একটা নির্দিষ্ট, hardcoded IPC key প্রত্যাশা করে (পুরনো database সার্ভার, কিছু scientific computing কোড), সেগুলো namespace-এর ভেতরে চালানো নিরাপদ, কারণ একই key ব্যবহার করা একাধিক instance একে অপরের data-তে দুর্ঘটনাক্রমে ধাক্কা খাবে না।

User namespace — সবচেয়ে জটিল, তাই এখানে সবচেয়ে বেশি সময়

namespaces-and-cgroups লেসন একটা একক-UID mapping দেখিয়েছিল (0 1000 1)। বাস্তব container runtime-এ mapping প্রায়ই একাধিক রেঞ্জ নিয়ে গঠিত — এখানে সম্পূর্ণ mechanics, একাধিক রেঞ্জ সহ।

uid_map ফরম্যাট — নির্ভুলভাবে

/proc/[pid]/uid_map-এ প্রতিটা লাইনের ফরম্যাট তিনটা সংখ্যা, space দিয়ে আলাদা করা:

<namespace-এর শুরু ID>  <host-এর প্রকৃত শুরু ID>  <রেঞ্জের দৈর্ঘ্য>

একটা বাস্তবসম্মত, দুই-লাইনের mapping যা rootless container tooling (Podman, rootless Docker) সাধারণত newuidmap-এর মাধ্যমে সেট করে:

0       1000        1
1       100000      65536

এর মানে:

  • namespace UID 0 (root) ↔ host UID 1000 (এই ব্যবহারকারীর নিজের, স্বাভাবিক UID) — একটা একক ID, দৈর্ঘ্য ১
  • namespace UID 1 থেকে 65536 (৬৫৫৩৬টা ধারাবাহিক ID) ↔ host UID 100000 থেকে 165535 — /etc/subuid-এ বরাদ্দকৃত একটা “subordinate UID” রেঞ্জ, যা root-privilege ছাড়াই এই ব্যবহারকারীকে দেওয়া হয়েছে ঠিক এই কাজের জন্য

Translation-এর গণিত — একটা offset যোগ করা, ব্যস

namespace UID থেকে host UID বের করার নিয়ম প্রতিটা রেঞ্জের জন্য একই: namespace UID যদি একটা রেঞ্জের ভেতরে পড়ে, তার offset (রেঞ্জের শুরু থেকে দূরত্ব) বের করে সেই একই offset host-রেঞ্জের শুরুতে যোগ করা।

host_uid=host_start+(ns_uid−ns_start)যদি ns_start≤ns_uid<ns_start+length\text{host\_uid} = \text{host\_start} + (\text{ns\_uid} - \text{ns\_start}) \quad \text{যদি } \text{ns\_start} \le \text{ns\_uid} < \text{ns\_start} + \text{length}

উদাহরণ — namespace UID 500, দ্বিতীয় রেঞ্জে পড়ে (1 থেকে 65536):

host_uid=100000+(500−1)=100499\text{host\_uid} = 100000 + (500 - 1) = 100499

আর যদি namespace UID কোনো রেঞ্জেই না পড়ে (mapping-এর বাইরে), kernel সেটাকে map করে একটা বিশেষ overflow UID-এ, ডিফল্টভাবে 65534 (/proc/sys/kernel/overflowuid-এ কনফিগারযোগ্য) — যেটা সাধারণত nobody user-এর UID, ইচ্ছাকৃতভাবে একটা প্রায়-privilege-বিহীন পরিচয়।

এই ঠিক এই গণিতটাই — একাধিক রেঞ্জ, offset যোগ, overflow fallback — পরের experiment/build অংশে একটা সত্যিকার, চালানো Python প্রোগ্রামে বাস্তবায়িত ও verify করা হবে।

কেন এটাই rootless container-এর ভিত্তি

kernel-এর প্রতিটা capability check (ns_capable() ফাংশন, যেটা CAP_SYS_ADMIN-এর মতো capability যাচাই করে) একটা নির্দিষ্ট user namespace-এর সাপেক্ষে কাজ করে, global ভাবে না। একটা process যদি তার নিজের user namespace-এ UID 0-এ mapped থাকে:

  • সেই namespace-এর নিজস্ব resource-এর উপর (তার নিজের mount namespace-এ mount করা কিছু, তার নিজের net namespace-এর ভেতরের virtual device) সে সত্যিকারের root-এর ক্ষমতা পায়।
  • কিন্তু host-এর প্রকৃত ফাইল, প্রকৃত process, প্রকৃত network-এর উপর — kernel যাচাই করে দেখে এই UID প্রকৃতপক্ষে (mapping অনুযায়ী) host-এ কে, আর সেই হিসেবে তার কোনো বাড়তি ক্ষমতা নেই। উপরের উদাহরণে namespace-এর “root” আসলে host UID 1000 — একজন সাধারণ, unprivileged ব্যবহারকারী।

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

Cgroup namespace — সবচেয়ে নতুন, সবচেয়ে সরল উদ্দেশ্য

CLONE_NEWCGROUP (২০১৬, সাতটার মধ্যে সবচেয়ে নতুন) একটু ভিন্ন ধরনের isolation দেয় — এটা resource limit করে না (সেটা cgroup নিজেই করে, namespace না), বরং isolate করে cgroup hierarchy-র দৃশ্যমান অংশ। এই namespace ছাড়া, একটা container-এর ভেতর থেকে /proc/self/cgroup পড়লে host-এর সম্পূর্ণ cgroup path দেখা যেত (যেমন /docker/a1b2c3.../), যা container-এর ভেতরের সফটওয়্যারের কাছে বিভ্রান্তিকর ও তথ্য-ফাঁসকারী (host-এর directory structure সম্পর্কে ইঙ্গিত দেয়)। cgroup namespace-এর সাথে, container-এর ভেতর থেকে সেই path দেখায় নিজের cgroup-কেই root (/) হিসেবে — ঠিক যেমন mount namespace filesystem root-কে re-anchor করে, cgroup namespace cgroup hierarchy-র root-কে re-anchor করে।

উদাহরণ

একটা container spawn-এর সম্পূর্ণ syscall trace, exact hex flags সহ

raw clone() কল থেকে PID 1-এর প্রথম মুহূর্ত পর্যন্ত
  1. clone(flags=0x7C020000, ...)CLONE_NEWPID|NEWNS|NEWNET|NEWUTS|NEWIPC|NEWUSER — একসাথে ছয়টা namespace, একটাই syscall
  2. নতুন child-এ: uid_map/gid_map লেখাparent process থেকে /proc/[child_pid]/uid_map-এ লেখা হয় — child নিজে লিখতে পারে না নিজের mapping
  3. ip link set lo upnetwork namespace-এর নিষ্ক্রিয় loopback সক্রিয় করা, নাহলে localhost অকেজো
  4. mount(NULL, "/", MS_REC|MS_PRIVATE, NULL)mount propagation বিচ্ছিন্ন করা, root mount-কে private করা
  5. pivot_root(new_root, put_old); umount2(put_old, MNT_DETACH)root filesystem বাস্তবিকভাবে swap, পুরনোটা সম্পূর্ণ unmount
  6. sethostname("container-id", ...)UTS namespace-এ নতুন hostname বসানো
  7. echo <pid> > cgroup.procs; write cpu.max, memory.maxcgroup সদস্যতা ও resource সীমা — namespace থেকে সম্পূর্ণ orthogonal ধাপ
  8. execve(entrypoint)namespace-এর প্রথম process চালু — এখন থেকেই এই process PID 1-এর বিশেষ সিগন্যাল-আচরণে চলে যাচ্ছে

লক্ষ করুন uid_map লেখাটা child তৈরি হওয়ার পরে, কিন্তু execve()-এর আগে ঘটে — আর এটা parent process থেকে লেখা হয়, child নিজে থেকে না (user_namespaces(7)-এর নির্ভুল প্রয়োজনীয়তা — একটা process নিজের uid_map নিজে লিখতে পারে না, কারণ তাহলে সে নিজের privilege নিজেই বাড়িয়ে নিতে পারত)।

দুই container, একই hostname pattern, ভিন্ন namespace — একসাথে দেখা

একটা concrete, চালানো-সম্ভব demonstration যা UTS আর network namespace-এর independence একসাথে দেখায় (বাস্তব Linux-এ):

sudo unshare --uts --net --fork bash -c 'hostname box-a; hostname; ip link show lo'
box-a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

আরেকটা টার্মিনালে, সম্পূর্ণ স্বাধীনভাবে:

sudo unshare --uts --net --fork bash -c 'hostname box-b; hostname; ip link show lo'
box-b
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

দুইটা namespace, দুইটা সম্পূর্ণ ভিন্ন hostname, প্রতিটার নিজস্ব (এখনও নিষ্ক্রিয়) loopback — আর host-এর hostname কমান্ড এই কোনোটাতেই প্রভাবিত হয় না।

ছবিতে

uid_map:
  ns_start  host_start  length
  0         1000        1        ← namespace root (UID 0) সরাসরি এই ব্যবহারকারীর নিজের UID-তে
  1         100000      65536    ← subordinate রেঞ্জ, /etc/subuid থেকে বরাদ্দকৃত

namespace-এর ভেতরের দৃশ্য:        host-এর দৃশ্য:
┌───────────────┐                ┌──────────────────────┐
│ UID 0 (root)  │ ─────────────► │ UID 1000 (স্বাভাবিক user) │
│ UID 1         │ ─────────────► │ UID 100000            │
│ UID 500       │ ─────────────► │ UID 100499             │
│ UID 65536     │ ─────────────► │ UID 165535             │
│ UID 70000     │ ─────────────► │ UID 65534 (overflow — mapping-এর বাইরে) │
└───────────────┘                └──────────────────────┘
চিত্র ১UID/GID mapping-এর দুই-রেঞ্জ উদাহরণ — namespace UID থেকে host UID-এ translation
Root PID namespace (host)
  PID 1 = systemd
  PID 4821 = দৃশ্যমান, এই একই process নিচের namespace-এও আছে
    │
    └── Container PID namespace (child)
          PID 1 = tini (host-এ PID 4821, container-এ PID 1)
            │
            └── PID 2 = app.py (host-এ PID 4830, container-এ PID 2)
                  │
                  └── PID 3 = subprocess (host-এ PID 4835, container-এ PID 3)

যদি tini (namespace-এর PID 1) মারা যায়
  → kernel সাথে সাথে SIGKILL পাঠায় PID 2 আর PID 3-কে (namespace-এর বাকি সব প্রসেস)
  → পুরো container namespace মুহূর্তেই খালি হয়ে যায়
চিত্র ২PID namespace-এর nesting আর PID 1-এর propagating SIGKILL — একটা তিন-স্তরের উদাহরণ

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

EXPERIMENT

একটা honest পরীক্ষা — এই লেসন যেই সিস্টেমে লেখা হয়েছে, সেখানে namespace syscall সত্যিই কাজ করে কি?

এই sandbox: macOS (Darwin) — Linux namespace syscall macOS-এ থাকেই না· ৫ মিনিট

সততার সাথে বলা দরকার: এই লেসনটা যেই পরিবেশে লেখা ও পরীক্ষা করা হচ্ছে সেটা macOS, Linux না। তাই unshare(2)/clone(2)-এর মতো raw Linux namespace syscall এখানে চালানো সম্ভবই না — এগুলো Linux kernel-এর নিজস্ব ABI-র অংশ, POSIX-এর অংশ না, আর macOS-এর XNU kernel-এ এদের কোনো সমতুল্য নেই। নিচের কোডটা সেই সীমাবদ্ধতা সরাসরি, verifiably দেখায়:

import ctypes, ctypes.util, os, sys

libc_path = ctypes.util.find_library("c")
print("platform:", sys.platform)
print("libc found at:", libc_path)

libc = ctypes.CDLL(libc_path, use_errno=True)

CLONE_NEWUTS = 0x04000000  # Linux-এর সংখ্যা — macOS-এ অর্থহীন, শুধু demonstration

has_unshare = hasattr(libc, "unshare")
print("libc has 'unshare' symbol:", has_unshare)

প্রকৃত, এই sandbox-এ চালিয়ে পাওয়া output:

platform: darwin
libc found at: /usr/lib/libc.dylib
libc has 'unshare' symbol: False

libc.dylib-এ unshare নামের কোনো symbol-ই নেই — এটা কোনো permission error বা runtime failure না, বরং প্রমাণ যে ফাংশনটা এই বাইনারিতে কখনোই কম্পাইল হয়নি। এটাই নিশ্চিত করে namespace একটা Linux-নির্দিষ্ট kernel feature, cross-platform libc API না।

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

Linux namespace সম্পূর্ণভাবে Linux kernel-নির্দিষ্ট একটা feature — এটা libc-এর কোনো portable API না, বরং সরাসরি Linux syscall table-এর অংশ। অন্য কোনো OS-এ এই নাম-এর ফাংশনই বিদ্যমান না।

নিজে বানান

BUILD IT

UID/GID Mapping Translator — namespace UID ↔ host UID, একাধিক রেঞ্জ সহ

Python · ●●●○○
  1. একটা uid_map ডেটা-স্ট্রাকচার বানান — (ns_start, host_start, length) tuple-এর একটা list, ঠিক /proc/[pid]/uid_map ফাইলের ফরম্যাট অনুসরণ করে
  2. ns_to_host(ns_uid) ফাংশন লিখুন — প্রতিটা রেঞ্জ পরীক্ষা করে দেখুন ns_uid সেই রেঞ্জে পড়ে কি না, পড়লে offset যোগ করে host UID ফেরত দিন
  3. host_to_ns(host_uid) ফাংশন লিখুন — বিপরীত দিকের একই লজিক, host রেঞ্জের ভেতরে খুঁজে ns UID বের করা
  4. কোনো রেঞ্জে না পড়লে overflow UID (৬৫৫৩৪) ফেরত দিন — kernel-এর প্রকৃত fallback আচরণ অনুকরণ করে
  5. একাধিক test case দিয়ে যাচাই করুন — রেঞ্জের শুরু, মাঝখান, শেষ, আর রেঞ্জের ঠিক বাইরের একটা মান
  6. নিজের mapping দিয়ে চালিয়ে দেখুন — /etc/subuid-এর একটা বাস্তব রেঞ্জ (যেমন 100000 65536) দিয়ে

এই প্রোগ্রামটা user_namespaces(7)-এ বর্ণিত kernel-এর map_id_range_down()/map_id_range_up() লজিকের একটা সরল, ধারণাগতভাবে সঠিক পুনর্নির্মাণ — বাস্তব kernel কোড C-তে লেখা আর অনেক বেশি edge-case handle করে (একাধিক সদৃশ রেঞ্জ প্রতিরোধ, overflow checking), কিন্তু মূল গাণিতিক ধারণা ঠিক এইটাই।

"""
uid_map ট্রান্সলেশন সিমুলেশন — /proc/[pid]/uid_map ফাইলে যা লেখা হয়
তার ঠিক সেই যুক্তি।

প্রতিটা লাইন: (namespace-এর ভেতরের শুরু ID, host-এ প্রকৃত শুরু ID, দৈর্ঘ্য)
"""

UID_MAP = [
    (0, 1000, 1),          # namespace UID 0 (root) -> host UID 1000 (এই ব্যবহারকারীর নিজের UID)
    (1, 100000, 65536),    # namespace UID 1..65536 -> host UID 100000..165535 (/etc/subuid রেঞ্জ)
]

OVERFLOW_UID = 65534  # kernel fallback, /proc/sys/kernel/overflowuid


def ns_to_host(ns_uid, uid_map=UID_MAP):
    """namespace-এর ভেতরের UID -> host-এর প্রকৃত UID"""
    for ns_start, host_start, length in uid_map:
        if ns_start <= ns_uid < ns_start + length:
            offset = ns_uid - ns_start
            return host_start + offset
    return OVERFLOW_UID  # mapping-এ নেই -> kernel-এর overflow UID


def host_to_ns(host_uid, uid_map=UID_MAP):
    """host-এর প্রকৃত UID -> namespace-এর ভেতরের UID"""
    for ns_start, host_start, length in uid_map:
        if host_start <= host_uid < host_start + length:
            offset = host_uid - host_start
            return ns_start + offset
    return None  # এই host UID এই namespace থেকে দৃশ্যমানই না


if __name__ == "__main__":
    print("uid_map:")
    for ns_start, host_start, length in UID_MAP:
        print(f"  {ns_start:>6} {host_start:>8} {length:>8}")
    print()

    print("namespace UID -> host UID:")
    for ns_uid in [0, 1, 500, 65536, 65537, 70000]:
        print(f"  ns_uid={ns_uid:>6}  ->  host_uid={ns_to_host(ns_uid)}")
    print()

    print("host UID -> namespace UID:")
    for host_uid in [1000, 100000, 100500, 165535, 165536, 999999]:
        print(f"  host_uid={host_uid:>7}  ->  ns_uid={host_to_ns(host_uid)}")

এই sandbox-এ সত্যিই চালিয়ে পাওয়া output (macOS-এ Python 3.9.6, সম্পূর্ণ portable কোড — কোনো Linux syscall লাগেনি):

uid_map:
       0     1000        1
       1   100000    65536

namespace UID -> host UID:
  ns_uid=     0  ->  host_uid=1000
  ns_uid=     1  ->  host_uid=100000
  ns_uid=   500  ->  host_uid=100499
  ns_uid= 65536  ->  host_uid=165535
  ns_uid= 65537  ->  host_uid=65534
  ns_uid= 70000  ->  host_uid=65534

host UID -> namespace UID:
  host_uid=   1000  ->  ns_uid=0
  host_uid= 100000  ->  ns_uid=1
  host_uid= 100500  ->  ns_uid=501
  host_uid= 165535  ->  ns_uid=65536
  host_uid= 165536  ->  ns_uid=None
  host_uid= 999999  ->  ns_uid=None

কয়েকটা ফলাফল হাতে যাচাই করা যাক। ns_uid=500 দ্বিতীয় রেঞ্জে পড়ে (ns_start=1, host_start=100000): offset = 500 - 1 = 499, তাই host_uid = 100000 + 499 = 100499 — প্রোগ্রামের output-এর সাথে হুবহু মিলছে। ns_uid=65537 কোনো রেঞ্জেই পড়ে না (দ্বিতীয় রেঞ্জ শেষ হয় 65536-এ, কারণ 1 + 65536 = 65537 exclusive upper bound) — তাই সঠিকভাবে overflow_uid=65534 ফিরে এসেছে। আর host_uid=165536 কোনো রেঞ্জেই পড়ে না (দ্বিতীয় রেঞ্জ শেষ হয় 100000+65536=165536, exclusive) — সঠিকভাবে None ফিরে এসেছে, মানে এই host UID এই namespace থেকে অদৃশ্য।

বাস্তব সিস্টেমে

বাস্তব সিস্টেমে — কোথায় এই সাত namespace কাজ করছে

  • tini (Docker --init) ও dumb-init — এই লেসনের PID-1 quirk-এর সরাসরি, প্রতিদিনকার সমাধান। Kubernetes-এও একটা সমতুল্য ধারণা আছে — pod-এর “pause container” pod-এর shared network/IPC namespace-এর জন্য একটা ন্যূনতম anchor process হিসেবে কাজ করে, যদিও প্রতিটা application container নিজস্ব PID namespace-এ থাকে (ডিফল্টে)।

  • Podman (rootless mode) — user namespace-এর UID mapping-এর সবচেয়ে পরিণত বাস্তব প্রয়োগ। কোনো root daemon ছাড়াই, newuidmap/newgidmap (setuid হেল্পার, /etc/subuid//etc/subgid পড়ে) ব্যবহার করে একজন সাধারণ ব্যবহারকারী container-কে “root” হিসেবে চালাতে পারে, host-এ কখনো root privilege না পেয়েই।

  • runc/containerd — এই লেসনের LayerTrace-এ দেখানো ঠিক এই ধাপগুলো (clone সব flag একসাথে, pivot_root, cgroup সেটআপ) বাস্তবায়ন করে, প্রায় প্রতিটা modern container runtime-এর নিচে (Docker, Kubernetes-এর CRI, সবাই শেষ পর্যন্ত runc-জাতীয় একটা OCI runtime-এ নেমে আসে)।

  • systemd-nspawn — namespace-ভিত্তিক container-এর আরেকটা বাস্তবায়ন, Docker-এর চেয়ে অনেক আগের, systemd-এর নিজস্ব টুলিং — দেখায় namespace primitive-গুলো কোনো একক vendor-এর সম্পত্তি না।

  • gVisor, Kata Containers — namespace-ভিত্তিক isolation-এর একটা বিকল্প দিক দেখায়: gVisor একটা userspace kernel দিয়ে syscall-গুলো intercept করে (namespace-এর বদলে/সাথে অতিরিক্ত একটা স্তর), আর Kata Containers প্রতিটা container-কে একটা lightweight VM-এ চালায় — namespace isolation যথেষ্ট শক্তিশালী মনে না হলে (বিশেষত multi-tenant, untrusted কোড চালানোর পরিবেশে) এই বিকল্পগুলো ব্যবহৃত হয়, যা এই module-এর containers-vs-vms লেসনের আলোচনার সরাসরি সম্প্রসারণ।

  • Kubernetes NetworkPolicy ও CNI প্লাগইন — network namespace-কে ভিত্তি ধরে তার উপর veth pair, bridge, ও routing rule বসিয়ে পুরো cluster-ব্যাপী networking মডেল তৈরি করে — এই module-এর পরের একটা container-networking লেসনের বিষয়, যার ভিত্তি এই লেসনের network namespace আলোচনা।

যে ভুলগুলো সবাই করে

“PID 1 (host-এ বা namespace-এ) একটা সাধারণ process-এর মতোই সিগন্যাল হ্যান্ডেল করে, শুধু সংখ্যাটা ১।”

সম্পূর্ণ ভুল, আর এই লেসনের সবচেয়ে ব্যবহারিক ভুল-ধারণা। PID 1 kernel-এর কাছে বিশেষভাবে ট্রিট হয় — যদি সে নিজে কোনো signal-এর জন্য handler ইনস্টল না করে, kernel সেই signal-এর ডিফল্ট disposition (যেমন SIGTERM-এর ডিফল্ট “terminate”) প্রয়োগ করে না, বরং সিগন্যাল উপেক্ষা করে। একটা raw python app.py-কে সরাসরি container-এর entrypoint (তাই PID 1) হিসেবে চালালে, docker stop (যা SIGTERM পাঠায়) কার্যত কিছুই করবে না যদি app.py নিজে explicit signal handler না রাখে — Docker তখন গ্রেসফুল টাইমআউটের পর জোরপূর্বক SIGKILL পাঠাতে বাধ্য হয়, যা কোনো cleanup সুযোগ দেয় না। এটাই tini/--init-এর প্রকৃত, ব্যবহারিক প্রয়োজনীয়তা।

“chroot() আর pivot_root() কার্যত একই জিনিস — pivot_root শুধু একটা নতুন, আধুনিক নাম।”

দুইটা সম্পূর্ণ ভিন্ন গ্যারান্টি দেয়। chroot() শুধু path resolution-এর apparent root বদলায় — পুরনো root filesystem mounted থেকে যায়, আর যদি process-এর কাছে পুরনো root-এর দিকে একটা খোলা file descriptor থাকে, fchdir() দিয়ে সেই পুরনো filesystem-এ ফিরে যাওয়া সম্ভব (ক্লাসিক chroot escape)। pivot_root() প্রকৃতপক্ষে কোন mounted filesystem “root” তা বদলে দেয়, আর সাধারণত এর পরপরই পুরনো root সম্পূর্ণ unmount করে দেওয়া হয় — তখন আর কোনো fd দিয়েই সেই filesystem-এ ফেরা সম্ভব না, কারণ সেটা আর mount-tree-তেই নেই। ঠিক এই পার্থক্যের কারণে runc-সহ সব আধুনিক container runtime pivot_root ব্যবহার করে, chroot না — নিরাপত্তা গ্যারান্টির স্তর ভিন্ন।

“user namespace-এ UID 0 হিসেবে দেখানো একটা process আসলে host-এও root-এর মতো ক্ষমতাবান, শুধু আলাদা একটা নম্বর দিয়ে ছদ্মবেশে আছে।”

উল্টো — এটাই user namespace-এর পুরো নিরাপত্তা-মূল্য। kernel-এর প্রতিটা capability check একটা নির্দিষ্ট user namespace-এর সাপেক্ষে করা হয়, global UID-এর সাপেক্ষে না। namespace-এর ভেতরে UID 0 হওয়া মানে সেই namespace-এর নিজস্ব resource-এর উপর root ক্ষমতা — host-এর প্রকৃত ফাইল, প্রকৃত process টার্গেট করার ক্ষমতা না। uid_map-এ namespace UID 0 কে host UID 1000-এ map করা থাকলে, kernel সব সময় জানে এই process প্রকৃতপক্ষে host UID 1000, একজন সাধারণ ব্যবহারকারী — namespace-এর ভেতরের “root” শুধু namespace-scoped resource-এর জন্য অর্থবহ, host-ব্যাপী কোনো privilege escalation না। এই ভুল-ধারণার বিপরীতটাই ঠিক — user namespace root privilege সীমাবদ্ধ করার একটা mechanism, বাড়ানোর না।

“namespace দিয়ে একটা process সম্পূর্ণভাবে isolate করা মানে সেটা host-এর resource-ও কম ব্যবহার করবে — isolation আর resource-নিয়ন্ত্রণ একই জিনিসের দুই নাম।”

সম্পূর্ণ ভুল, আর এটাই এই module-এর পরের লেসনের সূচনা-বিন্দু। সাতটা namespace-এর একটাও CPU সময়, memory পরিমাণ, disk bandwidth, বা process সংখ্যা সীমাবদ্ধ করে না — namespace শুধু নিয়ন্ত্রণ করে একটা process কী দেখতে পায় (তার PID তালিকা, তার mount tree, তার network interface)। একটা process সাতটা namespace দিয়েই সম্পূর্ণ বিচ্ছিন্ন হতে পারে, অথচ কোনো cgroup limit ছাড়া host-এর পুরো CPU আর memory একাই খেয়ে ফেলতে পারে — অন্য namespace-এ থাকা প্রতিবেশী process-গুলোকে না জানিয়েই। “visibility” আর “consumption” — সম্পূর্ণ ভিন্ন, orthogonal প্রশ্ন, যা পরের লেসনের কেন্দ্রীয় বিষয়।

বুঝেছেন কি না দেখুন

1

সাতটা namespace type আর তাদের exact CLONE_NEW* flag-এর নাম বলুন। কোনটা সবচেয়ে পুরনো, কোনটা সবচেয়ে নতুন?

স্মরণ

Mount (CLONE_NEWNS, 2002 — সবচেয়ে পুরনো), UTS (CLONE_NEWUTS, 2006), IPC (CLONE_NEWIPC, 2006), PID (CLONE_NEWPID, 2008), Network (CLONE_NEWNET, ~2011), User (CLONE_NEWUSER, 2013), Cgroup (CLONE_NEWCGROUP, 2016 — সবচেয়ে নতুন)। লক্ষণীয়: mount namespace-এর flag নামে “mount” শব্দটাই নেই (NEWNS), কারণ এটা তৈরির সময় ধরে নেওয়া হয়েছিল এটাই একমাত্র namespace type হবে।

2

একজন সহকর্মী একটা raw Python script (worker.py) সরাসরি Dockerfile-এর CMD হিসেবে চালাচ্ছেন, কোনো --init ফ্ল্যাগ বা tini ছাড়া। কয়েক সপ্তাহ পর তারা লক্ষ করেন ps aux container-এর ভেতরে অনেকগুলো <defunct> (zombie) entry দেখাচ্ছে, যদিও worker.py নিজে ক্র্যাশ করেনি। কী ঘটছে, আর কেন?

যুক্তি

worker.py-ই এই PID namespace-এর PID 1, কারণ এটাই container-এর entrypoint। worker.py সম্ভবত মাঝেমধ্যে subprocess spawn করছে (helper script, periodic task), যেগুলো শেষ হওয়ার পর zombie হয়ে যাচ্ছে যতক্ষণ না কেউ তাদের উপর wait()/waitpid() কল করে reap করে। যেহেতু worker.py কখনো নিজে PID 1 হবে ভেবে লেখা হয়নি, এটা কখনো SIGCHLD handle করে না বা explicit waitpid() কল করে না — সেই দায়িত্ব সাধারণত shell বা init process পালন করত, কিন্তু এখানে worker.py-ই সরাসরি PID 1, তাই সেই দায়িত্ব কেউ পালন করছে না। সমাধান: docker run --init ব্যবহার করা (যা tini-কে PID 1 হিসেবে ইনজেক্ট করে, worker.py-কে PID 2-এ নামিয়ে আনে), অথবা worker.py-তে explicit signal/reap লজিক যোগ করা।

3

একটা uid_map-এ দুইটা লাইন আছে: 0 5000 1 আর 1 200000 30000। namespace UID 29999-এর host UID কত? আর namespace UID 30001-এর কী হবে?

প্রয়োগ

ns_uid=29999 দ্বিতীয় রেঞ্জে পড়ে (ns_start=1, length=30000, তাই বৈধ রেঞ্জ 1 থেকে 30000 পর্যন্ত, exclusive upper bound 30001)। offset = 29999 - 1 = 29998, তাই host_uid = 200000 + 29998 = 229998।

ns_uid=30001 কোনো রেঞ্জেই পড়ে না — প্রথম রেঞ্জ শুধু 0 কভার করে (length ১), দ্বিতীয় রেঞ্জ 1 থেকে 30000 পর্যন্ত কভার করে ( 1 + 30000 = 30001 exclusive upper bound, তাই 30000 পর্যন্তই বৈধ, 30001 না)। ফলাফল: kernel-এর overflow UID, 65534 — এই namespace-এর ভেতর থেকে এই id-টা কার্যত “nobody” হিসেবে দেখা যাবে।

4

কেন একটা container runtime chroot() একা ব্যবহার করে না, বরং pivot_root() আর একটা explicit umount2() কল একসাথে ব্যবহার করে? শুধু “নিরাপদ” বলাটা যথেষ্ট উত্তর না — নির্দিষ্ট আক্রমণ-দৃশ্যকল্পটা বর্ণনা করুন যা pivot_root+umount2 প্রতিরোধ করে কিন্তু chroot করে না।

যুক্তি

chroot()-এর সাথে সমস্যা: পুরনো root filesystem chroot-এর পরও mounted থেকে যায়। যদি container-এর ভেতরের কোনো (malicious বা compromised) process-এর কাছে chroot করার আগে থেকেই খোলা একটা file descriptor থাকে পুরনো root-এর কোনো directory-র দিকে (open("/", O_RDONLY)), সে chroot-এর পরও সেই fd ধরে রাখতে পারে। তারপর fchdir(fd) কল করে সেই fd-র directory-তে current working directory বদলে ফেলতে পারে — এটা chroot-এর apparent-root বাধ্যতা ভাঙে না সরাসরি, কিন্তু তারপর chroot(".") কল করে (নিজের নতুন cwd-কে নতুন apparent root বানিয়ে) সে কার্যকরভাবে পুরনো, সম্পূর্ণ filesystem-এ প্রবেশাধিকার ফিরে পায়। pivot_root() + umount2(MNT_DETACH)-এর পর পুরনো root filesystem আর mount-tree-তেই নেই — কোনো fd, কোনো fchdir, কোনো কৌশলই সেই ভাঙা mount সংযোগ ফিরিয়ে আনতে পারে না, কারণ filesystem-টাই আর accessible না কোনো path দিয়ে।

5

একটা network namespace সদ্য তৈরি হয়েছে (unshare --net)। এর ভেতর থেকে একটা প্রোগ্রাম curl http://127.0.0.1:8080 চালালে সাথে সাথে ব্যর্থ হচ্ছে, connection refused/unreachable। সবচেয়ে সম্ভাব্য কারণ কী, আর এক-লাইনের সমাধান কী?

প্রয়োগ

সম্ভাব্য কারণ: নতুন network namespace-এর lo (loopback) interface ডিফল্টভাবে প্রশাসনিকভাবে DOWN অবস্থায় থাকে — device বিদ্যমান, কিন্তু নিষ্ক্রিয়। 127.0.0.1-এ যেকোনো কানেকশন তাই ব্যর্থ হবে, এমনকি সেই পোর্টে কিছু listen করলেও। সমাধান: ip link set lo up চালিয়ে loopback সক্রিয় করা। container runtime-গুলো (runc-সহ) এই ধাপ স্বয়ংক্রিয়ভাবে করে, কিন্তু raw unshare --net ব্যবহারে এটা মনে করে করতে হয়।

6

আপনাকে একটা multi-tenant CI/CD সিস্টেম ডিজাইন করতে বলা হয়েছে যেখানে অবিশ্বস্ত (untrusted), ব্যবহারকারী-জমা দেওয়া কোড চালাতে হবে। শুধু namespace ব্যবহার করবেন, নাকি gVisor বা Kata Containers-এর মতো একটা ভারী isolation layer যোগ করবেন? এই সিদ্ধান্তে user namespace-এর সীমাবদ্ধতা কীভাবে প্রভাব ফেলে?

ডিজাইন

শুধু namespace যথেষ্ট না এই পরিস্থিতিতে, কারণ namespace isolation শেষ পর্যন্ত একই kernel-এর উপর দাঁড়িয়ে — namespace enforcement-এ যেকোনো bug (একটা ভুল capability check, একটা race condition mount/pivot_root sequence-এ) সরাসরি container escape-এ পরিণত হয়, আর history-তে এই ধরনের বাগ বহুবার পাওয়া গেছে (namespaces-and-cgroups লেসনের “container escape মানে একটা kernel-privilege bug” আলোচনার সরাসরি প্রয়োগ)। বিশেষভাবে user namespace, যদিও নিরাপত্তার জন্য দরকারি (rootless চালানোর জন্য), নিজেই একটা বর্ধিত attack surface তৈরি করে — unprivileged process-কে এমন কিছু kernel code path (নতুন mount type parse করা, নতুন network configuration) খুলে দেয় যা আগে শুধু প্রকৃত root চালাতে পারত, আর সেই path-গুলোতেই ঐতিহাসিকভাবে বেশ কিছু privilege-escalation বাগ পাওয়া গেছে। একটা untrusted, multi-tenant পরিস্থিতিতে সঠিক ডিজাইন সিদ্ধান্ত: namespace + cgroup ব্যবহার করা তো বটেই (এদের বাদ দেওয়ার কারণ নেই), কিন্তু তার উপর একটা অতিরিক্ত স্তর যোগ করা — হয় gVisor-এর মতো একটা userspace kernel দিয়ে syscall আলাদাভাবে intercept ও validate করা, অথবা Kata Containers-এর মতো প্রতিটা tenant-এর workload একটা সম্পূর্ণ আলাদা VM-এ (আলাদা kernel-সহ) চালানো — যাতে একটা namespace-enforcement bug একাই পুরো host আপস না করতে পারে।

এরপর কী

আমরা এখন সাতটা namespace-এর প্রতিটার নির্ভুল mechanics জানি — কী দেখা যায় (PID তালিকা, mount tree, network stack, hostname, IPC id space, UID/GID, cgroup path), আর ঠিক কীভাবে সেই দৃশ্যমানতা তৈরি ও সীমাবদ্ধ করা হয়। কিন্তু পুরো এই লেসন জুড়ে একটা প্রশ্ন ইচ্ছাকৃতভাবে অনুত্তরিত রাখা হয়েছে — শেষ misconception-এ যা স্পষ্ট করা হয়েছিল তারই সম্প্রসারণ।

Namespace নিয়ন্ত্রণ করে একটা process কী দেখতে পায়। এটা কিছুই বলে না সে কতটুকু ব্যবহার করতে পারে। নিজস্ব PID namespace, নিজস্ব network namespace, নিজস্ব mount namespace-এ সম্পূর্ণ বিচ্ছিন্ন একটা container তবুও পুরো host-এর CPU খেয়ে ফেলতে পারে, পুরো memory দখল করে অন্য সব প্রতিবেশী container-কে OOM-এ ঠেলে দিতে পারে — কারণ কোনো namespace-ই resource consumption সীমাবদ্ধ করে না।

পরের লেসনে (virtualization-cloud/cgroups-v2-resource-control) আমরা ঠিক সেই বাকি অর্ধেক দেখব — cgroups v2, unified hierarchy, cpu.max/memory.max/pids.max, আর কীভাবে kernel-এর scheduler, page-fault handler, আর fork path-এর ভেতরেই এই সীমাগুলো বাস্তবে enforce হয়। Isolation-এর গল্প তখনই সম্পূর্ণ হবে — namespace দেখার সীমানা আঁকে, cgroups ব্যবহারের সীমানা আঁকে, আর একটা “container” আসলে এই দুইটার একসাথে প্রয়োগ, আর কিছুই না।

আরও পড়ুন

  • namespaces(7) — overview of Linux namespaces · প্রতিটা namespace type-এর প্রামাণ্য তালিকা, flag constant, ও kernel সংস্করণ ইতিহাস
  • clone(2) · CLONE_NEW* flag-গুলোর exact hex constant ও semantics-এর প্রামাণ্য উৎস
  • user_namespaces(7) · uid_map/gid_map ফরম্যাট, setgroups, আর capability-namespace সম্পর্কের নির্ভুল semantics
  • Namespaces in operation (LWN.net সিরিজ) — Michael Kerrisk · ২০১৩ — namespace-গুলোর প্রথম দিককার, সবচেয়ে বিস্তারিত বাস্তবায়ন-স্তরের প্রামাণ্য আলোচনা, যা পরবর্তীতে container tooling-এর ভিত্তি হয়ে ওঠে