সরাসরি পর্বে যান
পর্ব ৫১ · ৬৪-এর মধ্যে · মডিউল ৮

Container-এর ভেতর — namespace, cgroup আর Docker

Inside Containers — Linux Namespaces, cgroups, Overlay Images, the OCI Runtime Stack and Rootless Podman

Taught in Bangla, this lesson shows what a container really is: an ordinary process with its own view of the kernel. It covers the history from chroot to Kubernetes, the eight Linux namespaces, PID namespaces and PID 1, cgroups for metering and limits, image layers with overlayfs and copy-up, user namespaces and rootless Podman, the OCI runtime chain behind docker run, container networking and container security.

এই পর্বে যা শিখবেন

  • Container আর VM-এর মূল পার্থক্য, আর chroot থেকে Kubernetes পর্যন্ত ইতিহাস
  • আটটি namespace কী আলাদা করে, clone/unshare/setns — আর এই পাতার রানারে নিজে দেখা
  • PID namespace-এ PID 1-এর দায়িত্ব, user namespace-এর UID-মানচিত্র আর rootless container
  • cgroup v1 বনাম v2, controller, সীমা আর হিসাব; OCI image, overlayfs আর layer cache
  • docker run থেকে runc পর্যন্ত পুরো পথ, Podman, নেটওয়ার্কিং, নিরাপত্তা-স্তর আর Kubernetes-এর সংযোগ

১ · Container আসলে কী — আলাদা চশমা পরা একটি সাধারণ প্রসেস

এই কোর্সের অনেক পর্বে আপনি এই পাতার অনলাইন রানারে কোড চালিয়েছেন, আর বারবার কিছু অদ্ভুত জিনিস চোখে পড়েছে। পর্ব ৭-এ দেখেছেন PID 1 হলো podman-init, systemd নয়। পর্ব ৪২-এ দেখেছেন root file system একটি overlay. পর্ব ২১ আর ৩৮-এ /sys/fs/cgroup/cpu.max আর memory.max পড়েছেন, আর পর্ব ৪৯-এ দেখেছেন প্রতিটি প্রসেসে আগে থেকেই একটি seccomp filter বসানো। সবগুলো সূত্র একই দিকে ইঙ্গিত করে: রানারটি একটি container. এই পর্বে সেই ধাঁধাটি পুরো খুলব।

প্রথমেই একটি ভুল ধারণা দূর করা যাক। অনেকে container-কে “ছোট virtual machine” ভাবেন। তা নয়। পর্ব ৫০-এর virtual machine-এ একটি পুরো আলাদা OS চলে, নিজের kernel সহ, আর hypervisor তাকে নকল হার্ডওয়্যার দেয়। Container-এ আলাদা kernel নেই। Container হলো host-এর kernel-এ চলা একটি (বা কয়েকটি) সাধারণ প্রসেস, যার ওপর kernel তিনটি জিনিস চাপিয়ে দিয়েছে:

  • আলাদা দৃশ্য (namespace): প্রসেসটি কোন ফাইল, কোন প্রসেস, কোন নেটওয়ার্ক, কোন hostname আর কোন user দেখবে — তার নিজস্ব কপি।
  • সীমিত রিসোর্স (cgroup): কতটা CPU, মেমরি, কতগুলো প্রসেস, কতটা ডিস্ক-I/O ব্যবহার করতে পারবে — সীমা আর হিসাব।
  • নিজের file system (image + overlay) আর সুরক্ষা-স্তর: একটি তৈরি করা ফোল্ডার-গাছ, যা root হিসেবে দেখানো হয়; তার ওপর পর্ব ৪৯-এর seccomp, capability আর AppArmor/SELinux.

একটি বড় আবাসিক ভবনের কথা ভাবুন। প্রতিটি ফ্ল্যাটের নিজের দরজা, নিজের নম্বর, নিজের বিদ্যুতের মিটার; ভেতরে বসে মনে হয় যেন আলাদা বাড়ি। কিন্তু সবার ভিত একটি, পানির মূল লাইন একটি, লিফট একটি, দারোয়ানও একজন। ফ্ল্যাট = container: আলাদা দৃশ্য (দরজা, নম্বর) আর মিটার-মাপা রিসোর্স (cgroup), কিন্তু ভিত — kernel — সবার এক। আলাদা আলাদা প্লটে বানানো বাড়ি হলো VM: নিজের ভিত, নিজের পানির পাম্প; খরচ বেশি, কিন্তু একটি বাড়ির ভিতের ফাটল পাশের বাড়িতে যায় না।

একটি container-এর চার উপাদান

সবই kernel-এর পুরোনো, আলাদা আলাদা বৈশিষ্ট্য — container runtime শুধু এগুলো একসাথে জোড়ে

  1. সাধারণ প্রসেসfork + execvehost kernel-এ
  2. আলাদা দৃশ্যnamespacesকী দেখা যায়
  3. সীমা ও হিসাবcgroupsকতটা পাওয়া যায়
  4. নিজের rootimage + overlayকোন ফাইল
  5. সুরক্ষাseccomp, caps, LSMকী করা যায়
ছবি ৫১.১ — Linux-এ “container” নামে kernel-এর কোনো একক বস্তু নেই; এটি কয়েকটি স্বাধীন যন্ত্রের সমষ্টি।
বিষয়Virtual machineContainer
Kernelপ্রতিটি VM-এর নিজেরhost-এর kernel সবার এক
চালু হতে সময়সাধারণত কয়েক সেকেন্ড বা বেশি (পুরো OS বুট)সাধারণত এক সেকেন্ডের কম (একটি প্রসেস চালু)
বাড়তি মেমরিপুরো guest OS-এরপ্রায় শুধু প্রোগ্রামটির
অন্য OS চালানোহ্যাঁ — Linux host-এ Windowsনা — Linux container-এর জন্য Linux kernel লাগে
বিচ্ছিন্নতার সীমানাহার্ডওয়্যার-ভিত্তিক, শক্তkernel-এর নিয়ম; kernel-এর বাগ সবার ঝুঁকি

দুটির মিশ্র রূপও আছে (microVM, gVisor), আর কখন কোনটি বেছে নেবেন — সেই আলোচনা পর্ব ৫২-এ। এখানে মনে রাখার কথা একটাই: container-এর সব জাদু kernel-এর ভেতরে, আর সেগুলো আমরা এই পর্বে একে একে খুলে দেখব।

Windows আর macOS-এ? উপরের সব কথা Linux-এর। Windows Server 2016 থেকে Windows-এর নিজস্ব container আছে, দুই রকম: process isolation (Windows kernel ভাগ করে, Windows-এর নিজস্ব আলাদা করার যন্ত্রে) আর Hyper-V isolation (প্রতিটি container একটি ছোট VM-এ)। macOS-এর kernel-এ Linux-এর মতো namespace বা cgroup নেই। তাই Mac বা Windows-এ Docker Desktop আসলে পেছনে একটি ছোট Linux VM চালায় (Windows-এ সাধারণত WSL 2-এর ভেতরে), আর Linux container চলে সেই VM-এর kernel-এ।

২ · ইতিহাস — chroot থেকে Kubernetes

Container নতুন আবিষ্কার নয়; প্রায় চার দশক ধরে ধাপে ধাপে জমা হওয়া ধারণার সমষ্টি। নিচের তারিখগুলো সাধারণভাবে উদ্ধৃত হয়, তবে কোনো বৈশিষ্ট্য কোন মাসে বা কোন সংস্করণে “সম্পূর্ণ” হলো, তা নিয়ে সূত্রভেদে সামান্য পার্থক্য দেখবেন।

১৯৭৯–১৯৮২ · chroot: Version 7 Unix-এ (১৯৭৯) প্রসেসের root ফোল্ডার বদলানোর এই syscall দেখা যায়; ১৯৮২ সালে Bill Joy একে BSD-তে আনেন বলে জানা যায়। পর্ব ৪৯-এ দেখেছেন, এটি শুধু path-এর শুরু বদলায় — নিরাপত্তার সীমানা নয়।

২০০০ · FreeBSD jail: Poul-Henning Kamp-এর কাজ, FreeBSD 4.0-এ। একটি “jail”-এর নিজের file system অংশ, hostname, IP ঠিকানা আর আলাদা root — এক hosting কোম্পানির প্রয়োজনে তৈরি। এটিই প্রথম পরিচিত পূর্ণাঙ্গ OS-স্তরের virtualization-গুলোর একটি।

২০০৪–২০০৫ · Solaris Zones: Sun Microsystems ২০০৪ সালে ঘোষণা করে, Solaris 10-এ (২০০৫) আসে — রিসোর্স-সীমাসহ আলাদা “zone”. এর মাঝে Linux-এর জন্য kernel patch আকারে Linux-VServer আর OpenVZ-ও আসে।

২০০২–২০১৩ · Linux namespace: mount namespace আসে Linux 2.4.19-এ (২০০২), Plan 9-এর “প্রতি প্রসেসের নিজস্ব namespace” ধারণায় অনুপ্রাণিত হয়ে। তারপর ধাপে ধাপে UTS, IPC, PID, network; আর ২০১৩ সালে Linux 3.8-এ user namespace সম্পূর্ণ হওয়ার পর সাধারণ ব্যবহারকারীও container বানাতে পারেন।

২০০৬–২০০৮ · cgroup: Google-এর প্রকৌশলী Paul Menage আর Rohit Seth “process containers” নামে কাজ শুরু করেন; নাম বদলে control groups হয়ে Linux 2.6.24-এ (জানুয়ারি ২০০৮) মূল kernel-এ যুক্ত হয়।

২০০৮ · LXC: namespace আর cgroup জুড়ে প্রথম জনপ্রিয় user-space টুল — “Linux Containers”.

২০১৩ · Docker: dotCloud কোম্পানির Solomon Hykes মার্চ ২০১৩-এ PyCon-এ দেখান। প্রথমে LXC-র ওপর চলত, ২০১৪ সালে নিজের libcontainer-এ যায়। নতুন kernel-যন্ত্র নয়, Docker-এর অবদান ছিল image-এর ফরম্যাট, layer আর registry — একটি কমান্ডে অ্যাপ বানানো, পাঠানো আর চালানো।

২০১৪ · Kubernetes: Google জুন ২০১৪-এ ঘোষণা করে, ভেতরের Borg ব্যবস্থার অভিজ্ঞতা থেকে; ২০১৫ সালে ১.০, আর পরিচালনার দায়িত্ব যায় নতুন CNCF-এর হাতে।

২০১৫ · OCI: Docker, CoreOS, Google-সহ শিল্পের বড় অংশ মিলে Open Container Initiative গঠন করে; Docker তার runtime দান করে, যার নাম হয় runc. Runtime আর image-এর মানদণ্ডের ১.০ সংস্করণ আসে ২০১৭ সালে।

এই ইতিহাসের শিক্ষা হলো: kernel-এর যন্ত্রগুলো Docker-এর অনেক আগেই ছিল, কিন্তু ব্যবহার করা কঠিন ছিল। যেমন বাংলাদেশে মোবাইল ব্যাংকিংয়ের প্রযুক্তি আগে থেকেই ছিল, কিন্তু সহজ অ্যাপ আর এজেন্ট-ব্যবস্থা আসার পরই তা সবার কাছে পৌঁছায় — সুবিধাজনক মোড়ক প্রযুক্তিকে গণমানুষের করে।

৩ · আটটি namespace — kernel-এর বৈশ্বিক জিনিসের নিজস্ব কপি

সাধারণ Linux-এ অনেক কিছু “বৈশ্বিক”: একটিই প্রসেস-তালিকা, একটিই mount table, একটিই hostname, একটিই নেটওয়ার্ক-স্ট্যাক। Namespace এর একটি জিনিসকে মোড়কে ঢাকে, যাতে একদল প্রসেস নিজস্ব কপি দেখে আর ভাবে সেটিই পুরো সিস্টেম। এখন Linux-এ আট রকম namespace আছে:

Namespaceclone flagকী আলাদা করেকবে (মোটামুটি)
mntCLONE_NEWNSmount table — কোন file system কোথায় জোড়া2.4.19 (২০০২)
utsCLONE_NEWUTShostname আর NIS domain name2.6.19 (২০০৬)
ipcCLONE_NEWIPCSystem V IPC আর POSIX message queue (পর্ব ১২)2.6.19 (২০০৬)
pidCLONE_NEWPIDPID নম্বরের জগৎ — ভেতরে নিজের PID 12.6.24 (২০০৮)
netCLONE_NEWNETনেটওয়ার্ক ডিভাইস, IP, routing table, firewall, port2.6.24–2.6.29 (২০০৮–০৯)
userCLONE_NEWUSERUID/GID-এর মানচিত্র আর capability-র এখতিয়ার3.8 (২০১৩)
cgroupCLONE_NEWCGROUPcgroup গাছের কোন অংশকে “মূল” দেখাবে4.6 (২০১৬)
timeCLONE_NEWTIMECLOCK_MONOTONIC আর CLOCK_BOOTTIME-এর offset (দেয়াল-ঘড়ি নয়)5.6 (২০২০)

খেয়াল করুন, mount namespace-এর flag-এর নাম শুধু CLONE_NEWNS — “new namespace”. ২০০২ সালে এটিই একমাত্র namespace ছিল, তাই কেউ ভাবেননি যে পরে আরও আসবে। আর time namespace শুধু “বুটের পর কত সময় গেল” ধরনের ঘড়ি বদলায় (যেমন একটি container এক যন্ত্র থেকে আরেক যন্ত্রে সরালে ঘড়ি যেন পিছিয়ে না যায়); আসল তারিখ-সময় (CLOCK_REALTIME) সবার এক।

তিনটি syscall

  • clone() (পর্ব ১৪-এ থ্রেড বানাতে দেখেছেন): flag-এ CLONE_NEW* দিলে নতুন প্রসেসটি জন্ম থেকেই নতুন namespace-এ। container runtime প্রধানত এটিই ব্যবহার করে।
  • unshare(): চলমান প্রসেস নিজেকে নতুন namespace-এ সরায়। ব্যতিক্রম PID আর time: এ দুটিতে প্রসেস নিজে সরে না, শুধু পরের সন্তানেরা নতুনটিতে জন্মায় — তাই /proc/self/ns-এ pid_for_children আর time_for_children নামের আলাদা লিংক থাকে। একই নামের একটি কমান্ডও আছে, util-linux-এর unshare.
  • setns(): একটি বিদ্যমান namespace-এর file descriptor (যেমন /proc/<pid>/ns/net খুলে) দিয়ে সেটিতে যোগ দেওয়া। docker exec আর nsenter এভাবেই চলমান container-এর “ভেতরে” নতুন প্রসেস ঢোকায়।

প্রতিটি namespace kernel-এর একটি বস্তু, যার একটি পরিচয়-নম্বর (inode) আছে। দুটি প্রসেসের নম্বর এক হলে তারা একই namespace-এ। এবার এই পাতার রানারকে নিজের পরিচয় জিজ্ঞেস করি — সবই শুধু-পড়ার কমান্ড:

echo "== my namespaces: type -> type:[inode] =="
ls -l /proc/self/ns | awk 'NR > 1 {print $9, $10, $11}'
echo "== uid_map (inside-start  outside-start  count) =="
cat /proc/self/uid_map
echo "== gid_map =="
cat /proc/self/gid_map
echo "== who am I here? =="
id
echo "== who is PID 1 in this PID namespace? =="
cat /proc/1/cmdline | tr '\0' ' '; echo
echo "== NSpid of this shell (one number per PID namespace level) =="
grep NSpid /proc/$$/status
echo "== network interfaces in my network namespace =="
for i in /sys/class/net/*; do echo "${i##*/} mtu=$(cat "$i/mtu")"; done

একটি আসল রানের আউটপুট (inode নম্বরগুলো রানভেদে বদলায়):

== my namespaces: type -> type:[inode] ==
cgroup -> cgroup:[4026532822]
ipc -> ipc:[4026532820]
mnt -> mnt:[4026532817]
net -> net:[4026532747]
pid -> pid:[4026532821]
pid_for_children -> pid:[4026532821]
time -> time:[4026531834]
time_for_children -> time:[4026531834]
user -> user:[4026532740]
uts -> uts:[4026532819]
== uid_map (inside-start  outside-start  count) ==
         0       1000          1
         1     100000      65536
== gid_map ==
         0       1000          1
         1     100000      65536
== who am I here? ==
uid=999(wandbox) gid=999(wandbox) groups=999(wandbox)
== who is PID 1 in this PID namespace? ==
/run/podman-init -- /bin/bash prog.sh
== NSpid of this shell (one number per PID namespace level) ==
NSpid:	2
== network interfaces in my network namespace ==
lo mtu=65536
tap0 mtu=65520

লাইন ধরে পড়ুন। আটটি ধরনই আছে (সাথে দুটি “for_children” লিংক)। Kernel শুরুতে যে প্রাথমিক namespace-গুলো বানায়, তাদের কয়েকটির নম্বর স্থির: যেমন প্রাথমিক time namespace সবসময় 4026531834. আমাদের time ঠিক এই নম্বর — অর্থাৎ runtime নতুন time namespace বানায়নি, ঘড়ি host-এর সাথে ভাগ। বাকিগুলো নতুন। বারবার চালালে user-এর নম্বর একই থাকে, অন্যগুলো বদলায় — সম্ভবত rootless Podman একজন ব্যবহারকারীর সব container-এর জন্য একটি user namespace রেখে দেয়। uid_map বলছে আমরা একটি user namespace-এর ভেতরে (হিসাব অংশ ৭-এ)। PID 1 হলো podman-init, আমাদের shell PID 2; NSpid-এ একটিই সংখ্যা, কারণ এই /proc শুধু container-এর স্তর দেখায় — host-এর চোখে একই shell-এর নম্বর অন্য। নেটওয়ার্কে শুধু lo আর tap0; host-এর আসল কার্ড অদৃশ্য, আর MTU ৬৫৫২০-এর tap0 সাধারণত rootless নেটওয়ার্কিংয়ের চিহ্ন (অংশ ৮)।

এবার namespace নিজে বানিয়ে দেখি — C-তে। নিচের প্রোগ্রামে সন্তান-প্রসেস unshare(CLONE_NEWUSER | CLONE_NEWUTS) দিয়ে নতুন user আর UTS namespace-এ যায়, তারপর নিজের hostname বদলাতে চেষ্টা করে। বাবা পরে দেখে তার নিজের hostname বদলেছে কি না। সাধারণ ব্যবহারকারী নতুন UTS namespace বানাতে পারেন না, কিন্তু একই কলে নতুন user namespace বানালে সেই নতুন জগতে তিনি সব capability পান — তাই দুটি flag একসাথে।

#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <sched.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
    char before[64] = "", after[64] = "", host[65], now[65];
    gethostname(host, sizeof host);
    fflush(stdout);                              /* empty the buffer before fork */
    if (fork() == 0) {
        readlink("/proc/self/ns/uts", before, sizeof before - 1);
        if (unshare(CLONE_NEWUSER | CLONE_NEWUTS) != 0) {
            printf("child: unshare failed: errno=%d (%s)\n", errno, strerror(errno));
            fflush(stdout); _exit(1);
        }
        readlink("/proc/self/ns/uts", after, sizeof after - 1);   /* "uts:[...]" */
        printf("child: unshare(NEWUSER|NEWUTS) = 0, new UTS namespace: %s\n",
               strcmp(before, after) != 0 ? "yes" : "no");
        if (sethostname("lab-box", 7) != 0)
            printf("child: sethostname failed: errno=%d (%s)\n", errno, strerror(errno));
        else if (gethostname(now, sizeof now) == 0)
            printf("child: my hostname is now %s\n", now);
        fflush(stdout); _exit(0);
    }
    wait(NULL);  gethostname(now, sizeof now);
    printf("parent: my hostname unchanged: %s\n", strcmp(now, host) == 0 ? "yes" : "no");
    return 0;
}

এই পাতার রানারে একটি আসল রানের আউটপুট:

child: unshare(NEWUSER|NEWUTS) = 0, new UTS namespace: yes
child: sethostname failed: errno=1 (Operation not permitted)
parent: my hostname unchanged: yes

প্রথম লাইন: unshare সফল, আর /proc/self/ns/uts-এর নম্বর বদলে গেছে — সত্যিই একটি নতুন UTS namespace তৈরি হয়েছে, container-এর ভেতরেই (namespace-এর ভেতরে namespace). দ্বিতীয় লাইনটি সৎভাবে ব্যাখ্যা করা দরকার। কোনো seccomp filter ছাড়া একটি সাধারণ Linux কম্পিউটারে এখানে সাধারণত ছাপা হয় child: my hostname is now lab-box; এই রানারে এসেছে EPERM. সবচেয়ে সম্ভাব্য কারণ runtime-এর seccomp profile: Docker-এর ডিফল্ট profile sethostname-কে শুধু CAP_SYS_ADMIN নিয়ে চালু container-এ খোলে, আর Podman-এরটিও একই ধরনের বলে জানা যায়। Kernel-এর capability-নিয়ম যে কারণ নয়, তার পরোক্ষ প্রমাণ অংশ ৭-এ: একই রকম নতুন user namespace-এ mount সফল হয়। তবে ভেতর থেকে filter পড়া যায় না, তাই এটি শতভাগ প্রমাণিত নয়; আর সাম্প্রতিক Ubuntu-র মতো কিছু distro-তে AppArmor-এর সীমাবদ্ধতায় সাধারণ ডেস্কটপেও একই EPERM আসতে পারে। তৃতীয় লাইন আসল শিক্ষা: সন্তান যা-ই করুক, বাবার hostname অপরিবর্তিত — namespace-এর বদল বাইরে যায় না। printf-এর পর _exit-এর আগে fflush(stdout) রাখা হয়েছে: pipe-এ stdout পুরো-বাফার্ড, আর _exit বাফার খালি করে না, তাই না দিলে সন্তানের লাইনগুলো হারিয়ে যেত (পর্ব ১০)।

৪ · PID namespace আর PID 1 — একই প্রসেস, দুই নম্বর

PID namespace-এর জগৎগুলো একটি গাছের মতো সাজানো। নতুন namespace-এর প্রথম প্রসেস ভেতরে PID 1, কিন্তু বাবার namespace থেকে সে একটি সাধারণ বড় নম্বর। একটি প্রসেসের প্রতিটি পূর্বপুরুষ-স্তরে আলাদা নম্বর থাকে; /proc/<pid>/status-এর NSpid লাইনে বাইরের স্তর থেকে ভেতরের দিকে সেগুলো লেখা থাকে। নিয়ম একমুখী: বাইরে থেকে ভেতরের সবাইকে দেখা যায়, ভেতর থেকে বাইরের কাউকে নয় — বাড়ির দারোয়ান সব ফ্ল্যাটের লোক চেনেন, কিন্তু ফ্ল্যাটের ভেতর থেকে অন্য ফ্ল্যাটে কে আছে দেখা যায় না।

PID 1 হওয়া শুধু একটি নম্বর নয়, তিনটি দায়িত্বও। পর্ব ৭ আর ১৩-এ host-এর init-এর ক্ষেত্রে এগুলো দেখেছেন; container-এর ভেতরে এগুলো আপনার অ্যাপের ঘাড়ে পড়ে:

  1. অনাথদের অভিভাবক: namespace-এর ভেতরে কোনো প্রসেসের বাবা মারা গেলে সেই orphan host-এর systemd-এর কাছে নয়, namespace-এর PID 1-এর কাছে যায় (যদি না মাঝে কোনো subreaper থাকে)।
  2. Reaping: সেই সন্তান শেষ হলে PID 1-কে wait() ডাকতে হয়, নইলে সে zombie হয়ে PID দখল করে থাকে। দীর্ঘদিন চলা container-এ zombie জমে pids.max (অংশ ৫) পর্যন্ত পৌঁছালে নতুন প্রসেসই আর তৈরি হয় না।
  3. Signal-এর বিশেষ নিয়ম: পর্ব ১৩-এ দেখেছেন, PID 1 যে signal-এর handler বসায়নি, ভেতর থেকে আসা সেই signal kernel ফেলে দেয় — ডিফল্ট “মরে যাও” কাজ করে না। তাই docker stop-এর SIGTERM অনেক অ্যাপ উপেক্ষা করে, আর ১০ সেকেন্ড পর SIGKILL আসে (বাবার namespace থেকে আসা SIGKILL আর SIGSTOP ঠেকানো যায় না)। আর PID 1 মারা গেলে kernel namespace-এর বাকি সব প্রসেসকে SIGKILL দেয় — পুরো container শেষ।

সমাধান হলো PID 1-এ একটি ছোট, নির্ভরযোগ্য init রাখা, যে signal অ্যাপের কাছে পৌঁছে দেয় আর orphan-দের reap করে: tini (Docker-এর docker run --init এটিই বসায়), dumb-init, আর Podman-এর podman-init — যা এই রানারেই দেখলেন (/run/podman-init -- /bin/bash prog.sh: init আগে, ---এর পরে আসল প্রোগ্রাম)। নিচে একটি container-এর জীবন ভেতর আর বাইরে — দুই চোখে দেখুন (host-এর PID নম্বরগুলো উদাহরণ):

দুই দিক থেকে ps
ভেতর থেকে (container)
বাইরে থেকে (host)
NSpid (বাইরে → ভেতরে)
ঘটনা
  1. শুরুর আগে। Host-এ PID 1 systemd, আর container চালানোর জন্য containerd-shim (PID ৪১০০) অপেক্ষা করছে। নতুন PID namespace এখনো তৈরি হয়নি, তাই “ভেতর” বলে কিছু নেই।
  2. নতুন জগতের প্রথম প্রসেস। runc clone()-এ CLONE_NEWPID দিল। নতুন প্রসেসটি host-এর চোখে ৪১২৩, কিন্তু নিজের namespace-এ PID 1 — এখানে সে tini চালাচ্ছে। তার NSpid লাইন: 4123 1.
  3. অ্যাপ চালু। tini fork করে node server.js চালাল। ভেতরে সে PID 2, বাইরে ৪১২৪. Node-এর getpid() ফেরত দেয় ২ — প্রসেস নিজের namespace-এর নম্বরই জানে।
  4. একটি অনাথ। Node একটি helper (ভেতরে PID 3) চালাল, helper ছবি-রূপান্তরের convert (PID 4) চালিয়ে নিজে শেষ হয়ে গেল। convert-এর বাবা নেই, তাই kernel তাকে এই namespace-এর PID 1-এর সন্তান বানাল: ভেতরে PPID ১, বাইরে PPID ৪১২৩ — host-এর systemd নয়।
  5. Reaping. convert কাজ শেষে বেরোল। তার বাবা এখন tini, আর tini লুপে wait() ডাকে — zombie এক মুহূর্তও টেকে না, PID ৪ মুক্ত। PID 1-এ যদি সাধারণ node থাকত, যে কখনো অপরিচিত সন্তানের জন্য wait করে না, তাহলে এখানে একটি Z অবস্থার zombie জমে থাকত।
  6. বন্ধ করা। docker stop PID 1-কে SIGTERM দিল; tini সেটি node-কে পৌঁছে দিল, node গুছিয়ে exit 0 করল, tini-ও একই code নিয়ে বেরোল। PID 1 মারা যাওয়ায় kernel বাকি সবাইকে SIGKILL দিত (এখানে কেউ ছিল না), namespace মুছে গেল, আর shim exit code ০ উপরে জানাল।
মূল ধারণা

Container-এর প্রসেস host-এর চোখে আর দশটি প্রসেসের মতোই — ps-এ দেখা যায়, scheduler-এর একই red-black tree-তে (পর্ব ২১) বসে, একই page table-এর নিয়মে চলে। Namespace শুধু নামকরণ বদলায়: “PID 2” আর “PID 4124” একই task_struct-এর দুই নাম।

৫ · cgroup — মিটার আর সীমা

Namespace ঠিক করে প্রসেস কী দেখবে; সে কতটা খাবে, তা ঠিক করে cgroup. Kernel প্রসেসগুলোকে একটি গাছে দল বাঁধে — প্রতিটি দল /sys/fs/cgroup-এর নিচে একটি ফোল্ডার — আর প্রতিটি দলের জন্য দুই কাজ করে: হিসাব (দলটি কতটা CPU-সময়, মেমরি, I/O খরচ করল) আর সীমা (এর বেশি নয়)। যে অংশ একটি নির্দিষ্ট রিসোর্স সামলায়, তাকে বলে controller:

Controllerমূল ফাইল (v2)সীমা ছাড়ালে কী হয়বিস্তারিত
cpucpu.max = “quota period”, cpu.weightquota ফুরোলে দলটি period-এর বাকি সময় থামিয়ে রাখা হয় (throttling)পর্ব ২১
memorymemory.max, memory.high, memory.currentআগে reclaim; না পারলে দলের ভেতরে OOM killer — exit code 137পর্ব ৩৮
pidspids.max, pids.currentনতুন fork/clone ব্যর্থ, EAGAINfork bomb-এর প্রতিরোধ
ioio.max, io.weightডিস্ক-ভিত্তিক bytes/s বা IOPS সীমা, অতিরিক্ত অনুরোধ অপেক্ষায়পর্ব ৪০
cpusetcpuset.cpus, cpuset.memsদলটি শুধু নির্দিষ্ট CPU আর NUMA node-এ চলেপর্ব ২০ (affinity)

v1 বনাম v2 — অনেক গাছ থেকে একটি গাছ

প্রথম সংস্করণে (v1, ২০০৮) প্রতিটি controller-এর নিজস্ব আলাদা গাছ থাকতে পারত: /sys/fs/cgroup/memory/…, /sys/fs/cgroup/cpu/…, /sys/fs/cgroup/blkio/…, আর একটি প্রসেস প্রতিটি গাছে ভিন্ন দলে থাকতে পারত। নমনীয় শোনায়, কিন্তু বাস্তবে বিশৃঙ্খলা — যেমন memory আর blkio আলাদা গাছে থাকায় “এই দলের নোংরা page লেখার I/O কার খাতায় যাবে” ঠিকমতো হিসাব করা যেত না, আর প্রতিটি controller-এর নিয়মও আলাদা ছিল। cgroup v2 (Linux 4.5, ২০১৬-তে স্থিতিশীল ঘোষিত) আনে একটিমাত্র গাছ (unified hierarchy): প্রতিটি প্রসেস ঠিক একটি দলে, আর সব controller সেই একই গাছে। নিয়ম পরিষ্কার: কোন controller একটি দলের সন্তানদের জন্য চালু, তা লেখা থাকে cgroup.subtree_control-এ; আর প্রসেস রাখা যায় শুধু পাতা-দলে (“no internal processes” নিয়ম)। আজকের বেশিরভাগ নতুন distro ডিফল্টে v2 ব্যবহার করে; পুরোনো সিস্টেমে v1 বা দুটির মিশ্রণ এখনো দেখবেন।

এই রানারের container নিজের cgroup সম্পর্কে কী জানে, দেখি। ফাইল না থাকলে স্ক্রিপ্ট থামবে না, শুধু জানাবে:

echo "== which cgroup am I in? (v2 line looks like 0::/path) =="
cat /proc/self/cgroup
cd /sys/fs/cgroup 2>/dev/null || { echo "no /sys/fs/cgroup here"; exit 0; }
echo "cgroup file system type: $(stat -f -c %T .)"
echo "== controllers enabled for this group =="
cat cgroup.controllers
show() {
    if [ -r "$1" ]; then printf '%-15s %s\n' "$1" "$(tr '\n' ' ' < "$1")"
    else printf '%-15s %s\n' "$1" "(no such file here)"; fi
}
echo "== limits and current usage =="
for f in memory.max memory.current memory.peak pids.max pids.current cpu.max cpu.weight io.max; do
    show "$f"
done
echo "== accounting counters =="
grep -E '^(usage_usec|nr_periods|nr_throttled)' cpu.stat
grep -E '^(max|oom_kill) ' memory.events
echo "processes in this cgroup: $(wc -l < cgroup.procs)"
mkdir /sys/fs/cgroup/mygroup 2>&1 || true

একটি আসল রানের আউটপুট (মেমরি আর সময়ের সংখ্যা রানভেদে বদলায়):

== which cgroup am I in? (v2 line looks like 0::/path) ==
0::/
cgroup file system type: cgroup2fs
== controllers enabled for this group ==
cpu memory pids
== limits and current usage ==
memory.max      max
memory.current  1232896
memory.peak     1400832
pids.max        2048
pids.current    4
cpu.max         max 100000
cpu.weight      100
io.max          (no such file here)
== accounting counters ==
usage_usec 32479
nr_periods 0
nr_throttled 0
max 0
oom_kill 0
processes in this cgroup: 4
mkdir: cannot create directory '/sys/fs/cgroup/mygroup': Read-only file system
  • 0::/ — cgroup v2-এর একটিমাত্র লাইন, আর path মাত্র /! আমরা গাছের মূলে নেই; cgroup namespace আমাদের নিজের দলটিকে “মূল” দেখাচ্ছে, যাতে host-এর গাছের নাম (যাতে প্রায়ই container ID থাকে) ভেতরে ফাঁস না হয়। ফাইল সিস্টেমের ধরন cgroup2fs — পুরোপুরি v2.
  • Controller শুধু তিনটি: cpu, memory, pids. io এই দলে দেওয়া হয়নি, তাই io.max নেই। Rootless container সাধারণত কেবল সেই controller-গুলো পায়, যেগুলো systemd সাধারণ ব্যবহারকারীকে হস্তান্তর (delegate) করেছে।
  • সীমা: মেমরি আর CPU-তে কোনো সীমা নেই (max; max 100000 মানে প্রতি ১০০ ms period-এ quota অসীম), কিন্তু pids.max = ২০৪৮ — একটি fork bomb-ও এর বেশি প্রসেস বানাতে পারবে না। (ওপরের, host-এর দলে সীমা থাকতে পারে — ভেতর থেকে দেখা যায় না।)
  • হিসাব: পুরো container এ পর্যন্ত ~৩২ ms CPU খেয়েছে, এখন ~১.২ MiB মেমরিতে, সর্বোচ্চ ~১.৩ MiB. সীমা নেই বলে nr_periods ০; OOM ঘটেনি (oom_kill 0). Kubernetes-এর মেমরি সীমা ছাড়ালে ঠিক এই oom_kill বাড়ে আর প্রসেস 137 নিয়ে মরে (পর্ব ৩৮, ১৩)।
  • শেষ লাইন — নিরাপত্তা: container-এর ভেতরে cgroup ফাইল সিস্টেম শুধু-পড়ার mount, তাই প্রসেস নিজের জন্য নতুন দল বানিয়ে সীমা এড়াতে বা বদলাতে পারে না।

সীমা বসানো হয় স্রেফ ফাইলে লিখে। একটি সাধারণ Linux-এ root হিসেবে হাতে-কলমে করলে (উদাহরণ, এই রানারে চলবে না):

# mkdir /sys/fs/cgroup/demo
# echo "+memory +pids +cpu" > /sys/fs/cgroup/cgroup.subtree_control
# echo 268435456 > /sys/fs/cgroup/demo/memory.max     # 256 MiB
# echo 64        > /sys/fs/cgroup/demo/pids.max
# echo "50000 100000" > /sys/fs/cgroup/demo/cpu.max  # 0.5 CPU
# echo $$ > /sys/fs/cgroup/demo/cgroup.procs          # move this shell in
# docker run --memory 256m --pids-limit 64 --cpus 0.5 nginx   # the same, via Docker

৬ · Image আর layer — overlayfs আর copy-up

Container-এর root file system আসে একটি image থেকে। OCI image মানদণ্ডে image তিন ধরনের জিনিস: একটি manifest (কোন কোন layer আর config, তাদের তালিকা), একটি config (কোন কমান্ড চালাবে, কোন environment variable, কোন user) আর কয়েকটি layer — প্রতিটি একটি সংকুচিত tar ফাইল, যাতে আগের layer-এর তুলনায় যোগ হওয়া, বদলানো বা মোছা ফাইল থাকে। প্রতিটি জিনিসের নাম তার নিজের SHA-256 hash (digest, যেমন sha256:3f1a…): বিষয়বস্তু এক বাইট বদলালেই নাম বদলায়। ফলে একই layer দুটি image-এ থাকলে ডিস্কে আর নেটওয়ার্কে একবারই লাগে, আর registry (Docker Hub, GitHub Container Registry, কোম্পানির নিজের registry) থেকে নামানোর সময় শুধু অনুপস্থিত layer আসে।

চালানোর সময় এই layer-গুলো একটির ওপর আরেকটি সাজিয়ে একটি গাছ দেখাতে হয়। কাজটি করে overlayfs (Linux 3.18, ২০১৪-তে মূল kernel-এ; তার আগে Docker AUFS ব্যবহার করত)। পর্ব ৪২-এ এর নাম দেখেছেন; এবার ভেতরটা:

  • lowerdir — এক বা একাধিক শুধু-পড়ার ফোল্ডার (image-এর layer), কোলনে আলাদা, বাম দিকেরটি সবচেয়ে ওপরে।
  • upperdir — এই container-এর নিজস্ব লেখার স্তর, শুরুতে খালি।
  • workdir — upperdir-এর একই ফাইল সিস্টেমে একটি খালি ফোল্ডার, যেখানে overlayfs ফাইল আগে প্রস্তুত করে তারপর এক ধাক্কায় (atomic rename) upperdir-এ বসায়, যাতে মাঝপথে বিদ্যুৎ গেলেও আধা-কপি করা ফাইল না দেখা যায়।
  • merged — যা container দেখে: একই নামের ফাইল ওপরের স্তরে থাকলে সেটি, নইলে নিচেরটি।

নিচের স্তর কখনো বদলায় না, তাই একই image থেকে একশোটি container চালালেও layer ডিস্কে একবারই থাকে, আর page cache-এও (পর্ব ৩৮) একবার। লেখার সময় কী ঘটে, ধাপে ধাপে দেখুন:

তিনটি স্তর আর container-এর দৃশ্য
lowerdir (image, শুধু-পড়া)
upperdir (container A)
merged — A যা দেখে
container B (একই image)
ঘটনা
  1. শুরু। Image-এর layer-এ /app/config.txt (port=80) আর একটি ২ MB-র old.log. Container A-এর upperdir খালি, তাই merged দৃশ্য হুবহু image.
  2. পড়া — কোনো কপি নয়। A cat /app/config.txt চালাল। overlayfs ওপরের স্তরে খুঁজে পেল না, নিচের স্তরের ফাইলটিই সরাসরি খুলে দিল। পড়ার জন্য কোনো খরচ নেই — যত container, সবাই একই ফাইল আর একই page cache থেকে পড়ে।
  3. Copy-up. A ফাইলটি লেখার জন্য খুলল। নিচের স্তর বদলানো নিষেধ, তাই overlayfs আগে পুরো ফাইলটি (মালিক, অনুমতি, extended attribute সহ) workdir-এ কপি করে, তারপর upperdir-এ বসায়। পর্ব ৩৫-এর copy-on-write-এর মতোই, তবে page-এর বদলে পুরো ফাইল ধরে — ১ GB-র ফাইলে এক বাইট বদলালেও প্রথমবার ১ GB কপি হয়।
  4. লেখা ওপরে। port=8080 লেখা হলো upperdir-এর কপিতে। এখন merged দৃশ্যে ওপরেরটি নিচেরটিকে ঢেকে দেয়। একই image থেকে চালু container B এখনো port=80 দেখে — নিচের স্তর অক্ষত, আর B-এর নিজের upperdir খালি।
  5. মোছা = whiteout. A rm /app/old.log দিল। নিচের স্তর থেকে তো মোছা যায় না! তাই overlayfs upperdir-এ একই নামে একটি বিশেষ চিহ্ন বসায় — device নম্বর ০/০-এর একটি character device, যাকে বলে whiteout. merged দৃশ্যে ফাইলটি আর নেই, কিন্তু image-এর ২ MB ডিস্কে আগের মতোই আছে। এজন্যই Dockerfile-এর পরের ধাপে বড় ফাইল মুছলে image ছোট হয় না।
  6. Container মুছলে। docker rm A শুধু A-এর upperdir আর workdir মুছে দেয় — A-এর সব পরিবর্তন শেষ, image অপরিবর্তিত, B চলছেই। স্থায়ী ডেটা তাই container-এর ভেতরে নয়, volume বা bind mount-এ রাখতে হয়। (docker commit দিলে upperdir-টিই একটি নতুন layer হয়ে যেত।)

Dockerfile আর layer cache — সংক্ষেপে

Dockerfile-এর প্রায় প্রতিটি ফাইল-বদলানো নির্দেশ (RUN, COPY, ADD) একটি নতুন layer বানায়, আর build টুল প্রতিটি ধাপের ফল ক্যাশ করে: নির্দেশ আর তার ইনপুট (কপি করা ফাইলের hash) না বদলালে আগের layer আবার ব্যবহার হয়। কোনো ধাপ বদলালে সেই ধাপ আর তার পরের সব ধাপ নতুন করে চলে। তাই কম-বদলানো জিনিস আগে রাখুন:

# (a Dockerfile comment must start its own line; "#" after an instruction is not a comment)
# base layers, shared by many images
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
# cached until the lock file changes
RUN npm ci --omit=dev
# source changes only rebuild from here
COPY . .
# do not run as root inside
USER node
# exec form: node gets signals directly
CMD ["node", "server.js"]

উল্টো ক্রমে (COPY . . আগে) লিখলে কোডের একটি লাইন বদলালেও প্রতিবার সব dependency নতুন করে নামবে। শেষ লাইনের “exec form” (["node", …]) খেয়াল করুন: “shell form” (CMD node server.js) লিখলে PID 1 হয় /bin/sh, আর পর্ব ১৩-এর সেই SIGTERM-হারানো সমস্যা ফিরে আসে।

৭ · User namespace আর rootless container — ভেতরের root, বাইরের সাধারণ মানুষ

পুরোনো Docker-এ container-এর ভেতরের root আর host-এর root একই UID ০ — শুধু namespace, capability আর seccomp দিয়ে বাঁধা। কোনোভাবে বাঁধন ছিঁড়ে বেরোতে পারলে আক্রমণকারী host-এর আসল root. User namespace এই সমীকরণ বদলায়: প্রতিটি namespace-এর নিজস্ব UID-জগৎ, আর একটি মানচিত্র বলে দেয় ভেতরের কোন UID বাইরের কোন UID. /proc/<pid>/uid_map-এর প্রতিটি লাইন তিন সংখ্যার: ভেতরের শুরু, বাইরের শুরু, কয়টি. অংশ ৩-এর রানে আমাদের মানচিত্র ছিল:

         0       1000          1     # inside 0      -> outside 1000
         1     100000      65536     # inside 1-65536 -> outside 100000-165535

অর্থাৎ container-এর root (০) আসলে host-এর একজন সাধারণ ব্যবহারকারী, UID ১০০০ — যিনি Podman চালিয়েছেন। বাকি ৬৫৫৩৬টি UID host-এর ১০০০০০ থেকে ১৬৫৫৩৫-এ বসানো; এই পরিসর প্রশাসক আগেই /etc/subuid আর /etc/subgid-এ ওই ব্যবহারকারীর নামে বরাদ্দ রেখেছেন, আর ছোট setuid সহায়ক newuidmap/newgidmap মানচিত্রটি লেখে। আমাদের wandbox (ভেতরে UID ৯৯৯) তাই host-এ ১০০০০০ + (৯৯৯ − ১) = ১০০৯৯৮. মানচিত্রের বাইরের কোনো UID-এর মালিকানার ফাইল ভেতর থেকে দেখায় ৬৫৫৩৪ (“nobody”).

User namespace-এর মালিককে সেই namespace-এর ভেতরে পূর্ণ capability দেওয়া হয় — কিন্তু শুধু সেই namespace-এর মালিকানাধীন জিনিসের ওপর: নিজের mount namespace-এ mount, নিজের network namespace-এ interface বানানো। Host-এর ফাইল, host-এর ঘড়ি বা kernel module-এর ওপর এই ক্ষমতা অচল। একেই বলে rootless container: container runtime থেকে শুরু করে সবকিছু একজন সাধারণ ব্যবহারকারী হিসেবে চলে, কোনো root daemon নেই। এই রানার নিজেই তার প্রমাণ:

echo "== the root file system =="
grep ' / ' /proc/mounts | cut -d' ' -f1-3
opts=$(awk '$2 == "/" {print $4}' /proc/mounts)
echo "$opts" | tr ',' '\n' | grep -E '^(lowerdir|upperdir|workdir|userxattr)' | cut -c1-72
echo "lower (read-only) layers: $(echo "$opts" | tr ',' '\n' | grep '^lowerdir=' | tr ':' '\n' | wc -l)"
echo
echo "== a new user namespace: am I root now? =="
unshare --user --map-root-user id || echo "unshare: not permitted here"
unshare -r sh -c 'cat /proc/self/uid_map
  touch /etc/x 2>/dev/null && echo "touch /etc/x: allowed" || echo "touch /etc/x: still denied"' \
  || echo "unshare: not permitted here"
unshare -r --mount sh -c 'mount -t tmpfs none /mnt && grep " /mnt " /proc/self/mounts' \
  || echo "mount in new namespace: not permitted"
echo
echo "== a new PID namespace =="
unshare -r --pid --fork sh -c 'echo "inside: my shell has pid $$"; grep NSpid /proc/self/status' \
  || echo "unshare --pid: not permitted here"

একটি আসল রানের আউটপুট (ফোল্ডারের ID আর বাইরের PID রানভেদে বদলায়):

== the root file system ==
overlay / overlay
lowerdir=/home/ubuntu/.local/share/containers/storage/overlay/l/YMR6VSHI
upperdir=/home/ubuntu/.local/share/containers/storage/overlay/31deda0135
workdir=/home/ubuntu/.local/share/containers/storage/overlay/31deda0135a
userxattr
lower (read-only) layers: 3

== a new user namespace: am I root now? ==
uid=0(root) gid=0(root) groups=0(root)
         0        999          1
touch /etc/x: still denied
none /mnt tmpfs rw,relatime,uid=100998,gid=100998,inode64 0 0

== a new PID namespace ==
inside: my shell has pid 1
NSpid:	25	2
  • Overlay-এর ঠিকানাই rootless-এর সাক্ষী: layer-গুলো আছে /home/ubuntu/.local/share/containers/storage-এ — একজন সাধারণ ব্যবহারকারীর home ফোল্ডারে, যেখানে rootless Podman image রাখে (root হিসেবে চালালে থাকত /var/lib/containers-এ)। image-এ ৩টি layer, তার ওপর এই রানের নিজের upperdir আর workdir. userxattr বিকল্পটি বলে overlayfs নিজের হিসাবের চিহ্ন রাখছে সাধারণ ব্যবহারকারীর user.* extended attribute-এ — user namespace-এর ভেতরে overlay mount করার পরিচিত চিহ্ন।
  • “root” হলাম, কিন্তু কিসের root? unshare --user --map-root-user একটি নতুন (নেস্টেড) user namespace বানিয়ে আমাদের UID ৯৯৯-কে ভেতরে ০ বানাল; id বলছে uid=0(root). মানচিত্র 0 999 1. তবু /etc-এ ফাইল বানানো গেল না — /etc বাইরের জগতের root-এর, আর নতুন namespace-এর capability সেখানে খাটে না।
  • যা নিজের, সেখানে ক্ষমতা আছে: নতুন mount namespace-এ tmpfs mount সফল — kernel-এর চোখে এটি আমাদের নিজের জগৎ। আর mount-এর বিকল্পে uid=100998: kernel ফাইল-মালিককে প্রাথমিক (host) namespace-এর সংখ্যায় দেখাচ্ছে — উপরের হিসাবের ১০০৯৯৮ হুবহু মিলে গেল! (অংশ ৩-এর C প্রোগ্রামে sethostname আটকেছিল, অথচ এখানে mount চলল — এজন্যই সেখানে কারণ হিসেবে kernel-এর নিয়ম নয়, seccomp-কে সম্ভাব্য বলা হয়েছিল।)
  • নতুন PID namespace: --pid --fork-এ shell হলো PID 1. কিন্তু NSpid: 25 2 লাইনটি দেখুন: grep প্রসেসটি ভেতরে PID 2, আর container-এর স্তরে ২৫ — অংশ ৪-এর stepper-এর মতোই, এক প্রসেসের দুই নাম। ভেতরে নতুন /proc mount করা হয়নি বলে /proc/self এখনো বাইরের স্তর থেকে গুনছে; আসল container-এ runtime তাই নতুন namespace-এর জন্য নতুন /proc mount করে।

User namespace-এর দাম: সাধারণ ব্যবহারকারী namespace-এর ভেতরে “root-এর মতো” capability পেলে kernel-এর এমন অনেক কোড-পথ তাঁর নাগালে আসে, যা আগে শুধু আসল root ছুঁতে পারত (যেমন নেটওয়ার্ক-filter নিয়ম বানানো)। গত দশকে kernel-এর বেশ কিছু নিরাপত্তা-ত্রুটি এই পথেই কাজে লাগানো গেছে। তাই কিছু distro অধিকারবিহীন user namespace সীমিত রাখার ব্যবস্থা দেয় (Debian-এর একটি sysctl, সাম্প্রতিক Ubuntu-তে AppArmor-ভিত্তিক সীমাবদ্ধতা)। ভারসাম্যটি হলো: rootless-এ container ভাঙলেও ক্ষতি সাধারণ ব্যবহারকারীর গণ্ডিতে আটকে থাকে, কিন্তু সবাইকে user namespace দিলে kernel-এর আক্রমণ-পৃষ্ঠ (attack surface) বাড়ে।

৮ · docker run থেকে আপনার প্রসেস — runtime-এর শিকল আর নেটওয়ার্ক

“Docker” আসলে একটি প্রোগ্রাম নয়, কয়েক স্তরের প্রোগ্রাম, আর প্রতিটি স্তর আলাদাভাবে বদলানো যায়:

  • docker CLI — শুধু একটি client; আপনার কমান্ডকে REST অনুরোধ বানিয়ে /var/run/docker.sock নামের Unix socket-এ পাঠায়।
  • dockerd — root হিসেবে চলা daemon: image, নেটওয়ার্ক, volume সামলায়।
  • containerd — container-এর জীবনচক্র (image নামানো, snapshot বানানো, চালানো, থামানো)। ২০১৬-তে Docker থেকে আলাদা হয়, এখন Kubernetes-ও সরাসরি এটি ব্যবহার করে।
  • shim (containerd-shim-runc-v2) — প্রতিটি container-এর জন্য একটি ছোট প্রসেস, যে container-এর বাবা হয়ে থাকে, stdio ধরে রাখে আর exit code জমায়; ফলে dockerd বা containerd আবার চালু হলেও container চলতে থাকে।
  • runc — OCI runtime: একটি “bundle” (একটি rootfs ফোল্ডার + config.json) নিয়ে namespace, cgroup, mount, capability, seccomp বসিয়ে আসল প্রোগ্রাম execve করে, তারপর নিজে বেরিয়ে যায়।
  1. docker CLIclientREST অনুরোধ
  2. dockerddaemonimage, নেটওয়ার্ক
  3. containerd+ shimজীবনচক্র
  4. runcOCI runtimekernel-এর কাছে
  5. nginxPID 1namespaced প্রসেস
এই ধাপে
কে কাজ করছে
কীভাবে
যা তৈরি হলো
  1. CLI শুধু বার্তাবাহক। আপনি docker run -p 8080:80 --memory 256m nginx লিখলেন। CLI নিজে কোনো container বানায় না; একটি JSON অনুরোধ /var/run/docker.sock-এ dockerd-কে পাঠায়। এজন্যই এই socket-এ লেখার অধিকার মানে কার্যত host-এর root-এর অধিকার (অংশ ৯)।
  2. dockerd প্রস্তুতি নেয়। nginx image না থাকলে registry থেকে manifest এনে অনুপস্থিত layer-গুলো digest মিলিয়ে নামায়; overlay-এর জন্য নতুন upperdir বানায়; নেটওয়ার্কের জন্য একটি veth জোড়া বানিয়ে এক মাথা docker0 bridge-এ জোড়ে; তারপর সব মিলিয়ে একটি OCI bundle: rootfs আর config.json.
  3. containerd আর shim. dockerd gRPC-তে containerd-কে বলে “এই bundle চালাও”। containerd একটি নতুন containerd-shim-runc-v2 প্রসেস চালায়; container-এর আজীবন বাবা হবে এই shim.
  4. runc kernel-এর সাথে কথা বলে। runc config.json পড়ে clone()-এ ছয়টি CLONE_NEW* flag দেয় (এখানে user বা time নেই — Docker-এর ডিফল্ট)। তারপর cgroup বানিয়ে memory.max-এ ২৫৬ × ১০৪৮৫৭৬ = ২৬৮৪৩৫৪৫৬ লেখে, rootfs-এ pivot_root করে, নতুন /proc mount করে, capability ছাঁটে, AppArmor/SELinux label আর seccomp filter বসায় — এই ক্রমেই, কারণ seccomp বসানোর পর অনেক syscall আর চলবে না।
  5. আপনার প্রসেস। সব শেষে runc execve দিয়ে image-এর config-এ লেখা কমান্ড (nginx) চালায়; ভেতরে সে PID 1. runc-এর কাজ শেষ, সে বেরিয়ে যায়; বাবা থাকে shim. Host-এর ps-এ nginx একটি সাধারণ প্রসেস, shim-এর সন্তান।
  6. প্যাকেট পৌঁছায়। কেউ host-এর ৮০৮০ port-এ এল। -p 8080:80-এর জন্য dockerd আগেই netfilter-এ একটি DNAT নিয়ম বসিয়েছিল: গন্তব্য বদলে container-এর IP (যেমন 172.17.0.2) আর port ৮০. প্যাকেট bridge আর veth হয়ে container-এর eth0-তে পৌঁছায়। Container বাইরে কিছু পাঠালে উল্টো দিকে MASQUERADE (NAT) নিয়ম উৎস-ঠিকানা host-এর ঠিকানায় বদলে দেয়।

Podman আর crun — daemon ছাড়া

Podman (Red Hat-এর প্রকল্প, ২০১৮–২০১৯ থেকে জনপ্রিয়) একই OCI image আর প্রায় একই কমান্ড (podman run …) ব্যবহার করে, কিন্তু কোনো স্থায়ী root daemon নেই: podman নিজেই fork-exec করে runtime চালায়, আর container-এর বাবা হয় conmon নামের একটি ছোট পর্যবেক্ষক — shim-এর মতোই। শুরু থেকেই rootless-কে প্রধান ব্যবহার ধরে বানানো। এর ডিফল্ট OCI runtime প্রায়ই crun — C-তে লেখা, runc-এর (Go) চেয়ে ছোট আর দ্রুত চালু হয়। পর্ব ৪৯-এর রানে AppArmor label ছিল crun (unconfined), আর এখানে PID 1 podman-init — অর্থাৎ এই রানার rootless Podman + crun দিয়ে চলে বলে সব চিহ্ন মেলে।

Container-এর নেটওয়ার্ক — সংক্ষেপে

প্রতিটি নতুন network namespace শুরুতে শুধু একটি বন্ধ lo নিয়ে জন্মায়। বাইরের সাথে জুড়তে Docker-এর ডিফল্ট “bridge” নেটওয়ার্ক চারটি যন্ত্র ব্যবহার করে:

  • veth জোড়া: একটি ভার্চুয়াল তারের দুই মাথা — এক মাথায় যা ঢোকে, অন্য মাথায় বেরোয়। এক মাথা container-এর namespace-এ eth0 নামে, অন্য মাথা host-এ vethXXXX নামে।
  • bridge: host-এর docker0, একটি সফটওয়্যার switch; সব container-এর veth-মাথা এতে জোড়া, তাই তারা একে অপরের সাথে কথা বলতে পারে (সাধারণত 172.17.0.0/16).
  • NAT: বাইরে যাওয়া প্যাকেটে উৎস-ঠিকানা host-এর ঠিকানায় বদল (MASQUERADE) — বাসার Wi-Fi router যেমন সব ফোনকে একটি ঠিকানার পেছনে রাখে।
  • Port mapping: -p 8080:80 = host-এর ৮০৮০-এ আসা প্যাকেটের গন্তব্য বদলে container-এর ৮০ (DNAT).

Rootless container veth বানিয়ে host-এর bridge-এ জুড়তে পারে না — তাতে host-এর নেটওয়ার্ক বদলানোর ক্ষমতা লাগে। তাই rootless Podman user-mode networking ব্যবহার করে (slirp4netns বা নতুন সংস্করণে pasta): container-এর ভেতরে একটি tap ডিভাইস, আর host-এ একটি সাধারণ প্রসেস প্যাকেটগুলো পড়ে নিজের সাধারণ socket দিয়ে বাইরে পাঠায়। অংশ ৩-এর রানে tap0 mtu=65520 ঠিক এই ব্যবস্থার চিহ্ন। দাম: একটু বেশি CPU আর কিছু সীমাবদ্ধতা; লাভ: কোনো root লাগে না।

৯ · নিরাপত্তা আর Kubernetes — এক kernel, কয়েক স্তরের পাহারা

Container-এর সবচেয়ে বড় সত্য: kernel সবার এক. একটি container-এর প্রসেস host-এর একই kernel-এ syscall করে। তাই kernel-এর একটি গুরুতর বাগ, বা runtime-এর ভুল কনফিগারেশন, একটি container থেকে host-এ বা পাশের container-এ পৌঁছানোর পথ খুলে দিতে পারে — একে বলে container escape. বাস্তব ঘটনাগুলো সাধারণত তিন শ্রেণির, আর প্রতিটির প্রতিরক্ষা জানা:

ঝুঁকির উৎসউদাহরণপ্রতিরক্ষা
ভুল কনফিগারেশন (সবচেয়ে সাধারণ)--privileged; /var/run/docker.sock container-এ mount; -v /:/host; বাড়তি CAP_SYS_ADMINএগুলো না দেওয়া; দরকার হলে শুধু নির্দিষ্ট --cap-add বা ডিভাইস
Kernel-এর বাগkernel-এর কোনো syscall-পথে memory-ত্রুটিkernel হালনাগাদ; seccomp-এ কম syscall; rootless; প্রয়োজনে gVisor বা microVM (পর্ব ৫২)
Runtime-এর বাগ২০১৯ সালে runc-এর একটি ত্রুটি (CVE-2019-5736), যাতে ক্ষতিকর container host-এর runc বদলে দিতে পারতruntime নিয়মিত হালনাগাদ; SELinux/AppArmor আর rootless-এ ক্ষতি সীমিত

Docker আর Podman ডিফল্টেই কয়েকটি স্তর বসায়, যার প্রায় সবগুলো এই রানারে আপনি নিজেই দেখেছেন:

  • seccomp profile: কয়েক ডজন ঝুঁকিপূর্ণ syscall বন্ধ (পর্ব ৪৯-এ Seccomp: 2, আর এই পর্বের sethostname-এর EPERM সম্ভবত এরই ফল)।
  • Capability ছাঁটা: root-এর প্রায় ৪০টি ক্ষমতার মধ্যে ডিফল্টে মোটামুটি এক ডজন রাখা হয়; পর্ব ৪৯-এর রানে bounding set-এ ১১টি ছিল, cap_sys_admin নেই।
  • MAC label: Ubuntu-তে AppArmor-এর docker-default profile; Fedora/RHEL-এ SELinux-এর container_t type, আর প্রতিটি container-কে আলাদা MCS category (যেমন s0:c123,c456), যাতে একটি container আরেকটির ফাইল ছুঁতে না পারে।
  • শুধু-পড়ার mount: /sys/fs/cgroup শুধু-পড়ার (অংশ ৫-এর mkdir ব্যর্থ), /proc-এর স্পর্শকাতর অংশ ঢাকা।

আপনি এর ওপর আরও কয়েকটি তালা যোগ করতে পারেন — একটি শক্ত-করা docker run:

docker run -d --name web \
  --user 1000:1000 \
  --read-only --tmpfs /tmp \
  --cap-drop ALL --cap-add NET_BIND_SERVICE \
  --security-opt no-new-privileges \
  --memory 256m --pids-limit 100 --cpus 0.5 \
  --init \
  -p 8080:80 myapp:1.4.2
# check what you got:
docker inspect web --format '{{.HostConfig.CapDrop}} {{.HostConfig.ReadonlyRootfs}}'

লাইন ধরে: ভেতরেও root নয় (--user); root file system শুধু-পড়ার, শুধু /tmp লেখা যায়; সব capability বাদ, শুধু নিচু port-এ বসার ক্ষমতা ফেরত; setuid প্রোগ্রাম চালিয়েও নতুন ক্ষমতা নয় (পর্ব ৪৯-এর no_new_privs); মেমরি, প্রসেস-সংখ্যা আর CPU-র cgroup সীমা; আর PID 1-এ tini. সাথে image-এর দিকেও নজর: ছোট base image (কম প্যাকেজ = কম দুর্বলতা), নির্দিষ্ট সংস্করণ বা digest দিয়ে image টানা (latest নয়), আর image স্ক্যান করা।

Kubernetes — এক অনুচ্ছেদে

Kubernetes অনেক যন্ত্রে হাজারো container চালানোর ব্যবস্থাপক, কিন্তু প্রতিটি যন্ত্রে নিচের কাজ এই পর্বের যন্ত্রগুলোই করে। Kubernetes-এর একক pod: এক বা একাধিক container, যারা একটি network namespace (একই IP, একই localhost) আর IPC namespace ভাগ করে; সেই namespace-গুলো ধরে রাখে একটি ক্ষুদ্র “pause” container, আর চাইলে PID namespace-ও ভাগ করা যায়। প্রতিটি node-এর kubelet CRI নামের ইন্টারফেসে containerd বা CRI-O-কে বলে, তারা runc/crun দিয়ে চালায় (Docker-এর মাঝের স্তর বা dockershim Kubernetes 1.24-এ, ২০২২ সালে, সরানো হয়)। Pod-এর resources সরাসরি cgroup-এ নামে: requests.cpu scheduler-কে বলে কোন node-এ জায়গা আছে আর cgroup-এ cpu.weight হয়; limits.cpu: 500m হয় cpu.max = "50000 100000" (পর্ব ২১-এর throttling); limits.memory: 512Mi হয় memory.max = 536870912, আর তা ছাড়ালে container “OOMKilled”, exit code 137 (পর্ব ৩৮)।

আরও গভীরে — kernel-এর ভেতরে container কোথায়? Linux kernel-এ প্রতিটি task-এর task_struct-এ একটি nsproxy pointer থাকে, যা mnt, uts, ipc, pid_for_children, net, cgroup আর time namespace-এর বস্তুগুলো দেখায়; user namespace থাকে credential (cred)-এর ভেতরে, আর cgroup-এর সদস্যপদ css_set-এ। Namespace-গুলো reference-count করা বস্তু: শেষ প্রসেস বেরিয়ে গেলে মুছে যায়, যদি না কেউ /proc/<pid>/ns/* ফাইল খুলে রাখে বা bind mount করে রাখে — ip netns এই কৌশলেই প্রসেস ছাড়া network namespace বাঁচিয়ে রাখে।

পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:

  • Container আর VM-এর মূল পার্থক্য? — VM-এ নিজের kernel আর নকল হার্ডওয়্যার; container একই host kernel-এ চলা প্রসেস, namespace দিয়ে আলাদা আর cgroup দিয়ে সীমিত।
  • Namespace আর cgroup-এর পার্থক্য? — namespace ঠিক করে কী দেখা যাবে (PID, mount, net…); cgroup ঠিক করে কতটা ব্যবহার করা যাবে (CPU, মেমরি, pids, I/O) আর হিসাব রাখে।
  • Container-এ PID 1 কেন বিশেষ? — orphan-দের reap করতে হয়, handler ছাড়া signal উপেক্ষিত হয়, আর PID 1 মরলে সবাই SIGKILL পায়; তাই tini বা --init.
  • Image layer আর copy-up কী? — শুধু-পড়ার layer-এর ওপর overlayfs-এর লেখার স্তর; নিচের ফাইল বদলালে পুরো ফাইল ওপরে কপি হয়, মুছলে whiteout বসে।
  • Rootless container কীভাবে কাজ করে? — user namespace-এ ভেতরের UID 0 বাইরের একজন সাধারণ ব্যবহারকারীতে map হয় (/etc/subuid, newuidmap), তাই container ভাঙলেও আক্রমণকারী host-এর root নয়।

১০ · AI-এর যুগে container — দ্রুত উত্তর, সাবধানে যাচাই

Dockerfile, docker-compose.yml আর Kubernetes YAML লেখায় AI সত্যিই সময় বাঁচায়। কিন্তু এই জগতে AI-এর ভুলগুলো চেনা দরকার, কারণ “চলছে” মানেই “ঠিক আছে” নয় — একটি container ঝুঁকিপূর্ণ সেটিং নিয়েও দিব্যি চলে, আর মেমরি-সীমার ভুল ধরা পড়ে শুধু আসল লোডে।

AI-এর সাধারণ পরামর্শসমস্যাভালো পথ
“Permission denied? --privileged দিন” বা docker.sock mountপ্রায় সব সুরক্ষা উঠে যায়; socket মানে host-এর rootআসল কারণ (label, UID, capability) খুঁজে শুধু সেটুকু দিন
Dockerfile-এ USER নেই, chmod -R 777 /appcontainer-এ root, আর সবার জন্য লেখার অনুমতিUSER দিয়ে সাধারণ ব্যবহারকারী, সঠিক মালিকানা
CMD npm start (shell form), --init নেইSIGTERM হারায়, zombie জমে, ১০ সেকেন্ড পর SIGKILLexec form, অ্যাপে SIGTERM handler, বা tini
cgroup v1-এর path (/sys/fs/cgroup/memory/memory.limit_in_bytes)নতুন distro-তে v2, ফাইলই নেইstat -f -c %T /sys/fs/cgroup দিয়ে আগে সংস্করণ দেখুন
JVM/Node-এর heap-সীমা ছাড়া মেমরি limit, বা ভুল একক (512M বনাম 512Mi)OOMKilled (137); M = ১০৬, Mi = ২২০সীমা হিসাব করে মেলান, লোড-পরীক্ষায় memory.events দেখুন
FROM ubuntu:latest, পুরোনো Docker-এর কমান্ডপ্রতিবার ভিন্ন image; পুরোনো বা অন্য টুলের নির্দেশনির্দিষ্ট tag বা digest; নিজের Docker/Podman সংস্করণের ডকুমেন্টেশন

AI-কে container-এর সমস্যা জিজ্ঞেস করার আগে নিজের পরিবেশের তথ্য জোগাড় করুন, তারপর এই কাঠামোয় প্রশ্ন করুন। শেষ দুটি শর্ত খেয়াল করুন — ঝুঁকিপূর্ণ “সমাধান” আগেই বাতিল করে রাখা হয়েছে।

আমার Ubuntu 24.04 সার্ভারে Docker 27 (cgroup v2) চলে। একটি Node.js container মাঝে মাঝে exit code 137 দিয়ে বন্ধ হয়, আর docker stop দিলে ১০ সেকেন্ড আটকে থাকে। - docker inspect থেকে: Memory=536870912, Init=false, User="" - Dockerfile-এর শেষ লাইন: CMD npm start - cat /sys/fs/cgroup/memory.events (container-এর ভেতরে): (এখানে আউটপুট দিলাম) ১. 137 আর ১০ সেকেন্ডের দেরি — দুটির সম্ভাব্য কারণ আলাদা করে বলো, আর আগে শুধু-পড়ার যাচাইয়ের কমান্ড দাও। ২. Dockerfile আর docker run-এর সংশোধিত রূপ দাও, প্রতিটি বদলের কারণসহ। ৩. --privileged, docker.sock mount বা root হিসেবে চালানো সমাধান হিসেবে চলবে না। ৪. তোমার প্রতিটি কমান্ড cgroup v2 আর আমার Docker সংস্করণে চলে কি না নিশ্চিত করো; অনিশ্চিত হলে তা বলো।

AI-এর container-পরামর্শ প্রয়োগের আগে — চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১FreeBSD jail ২০০০ সালে, LXC ২০০৮ সালে ছিল — তবু container-এর বিস্ফোরণ কেন ২০১৩ সালের Docker-এর পরে হলো?

২০০০-এর দশকে OS-স্তরের virtualization ছিল মূলত hosting কোম্পানি আর বড় প্রতিষ্ঠানের প্রশাসকদের হাতিয়ার। FreeBSD jail, Solaris Zones, OpenVZ — সবাই একটি প্রশ্নের উত্তর দিত: “একটি সার্ভারকে কয়েকজন গ্রাহকের মধ্যে কীভাবে ভাগ করব?” LXC Linux-এ সেই ক্ষমতা আনল, কিন্তু ব্যবহারের ধরন ছিল একটি হালকা VM-এর মতো: একটি পুরো distro-র file system বানানো, ভেতরে ঢুকে প্যাকেজ বসানো, হাতে দেখাশোনা করা।

Docker একটি ভিন্ন প্রশ্নের উত্তর দিল, আর সেটি ছিল ডেভেলপারের প্রশ্ন: “আমার ল্যাপটপে চলা অ্যাপটি সার্ভারে হুবহু কীভাবে চালাব?” এর তিনটি উপাদান নতুন ছিল। এক, image: একটি Dockerfile থেকে পুনরুৎপাদনযোগ্য build, যা অ্যাপ আর তার সব library একসাথে বাঁধে। দুই, layer আর content-addressing: একই base layer শত image-এ ভাগ হয়, তাই image পাঠানো সস্তা। তিন, registry: docker push আর docker pull — git-এর মতো সহজে image আদান-প্রদান। এর সাথে ছিল সময়টা: ২০১০-এর দশকের শুরুতে ক্লাউড, microservice আর continuous deployment-এর চাহিদা বাড়ছিল, আর “আমার মেশিনে তো চলে” সমস্যা সবার পরিচিত।

লক্ষ করার মতো ব্যাপার, Docker kernel-এ প্রায় কিছুই যোগ করেনি — namespace আর cgroup আগেই ছিল। পরে শিল্প যখন Docker-এর ওপর নির্ভর করতে শুরু করল, তখন একটি কোম্পানির হাতে মানদণ্ড থাকা নিয়ে উদ্বেগ থেকে OCI (২০১৫) তৈরি হয়, আর runtime ও image ফরম্যাট সবার সম্পদ হয়ে যায়। এজন্যই আজ Podman, containerd, CRI-O — সবাই একই image চালাতে পারে।

মূল উপলব্ধি: প্রযুক্তি থাকা আর প্রযুক্তি ব্যবহারযোগ্য হওয়া আলাদা জিনিস। Kernel-এর যন্ত্রের সাথে সঠিক বিমূর্ত ধারণা (image), বিতরণের ব্যবস্থা (registry) আর খোলা মানদণ্ড (OCI) যোগ হওয়ার পরই container সবার হলো।

প্র ০২ক্লাউড কোম্পানিগুলো অচেনা গ্রাহকের কোড চালাতে শুধু container-এর ওপর ভরসা না করে microVM বা gVisor-এর মতো বাড়তি স্তর কেন বানাল?

একটি কোম্পানির নিজের দলের লেখা কয়েকটি সার্ভিস একই সার্ভারে container-এ চালানো এক জিনিস; হাজার অচেনা গ্রাহকের কোড — যাদের কেউ কেউ ইচ্ছা করেই ক্ষতি করতে চান — একই যন্ত্রে চালানো সম্পূর্ণ আরেক জিনিস। দ্বিতীয় ক্ষেত্রে প্রশ্ন হলো: সীমানাটি কতটা শক্ত? Container-এর সীমানা kernel-এর নিয়ম। Linux kernel কয়েক কোটি লাইনের কোড, আর কয়েকশো syscall-এর প্রতিটি একটি দরজা। ২০১৬ সালের “Dirty COW”-এর মতো kernel-ত্রুটিগুলো দেখিয়েছে যে একটি সাধারণ প্রসেস kernel-এর বাগ কাজে লাগিয়ে বিশেষ ক্ষমতা পেতে পারে — আর container-এর প্রসেসও একটি সাধারণ প্রসেস।

তাই বড় ক্লাউড প্ল্যাটফর্মগুলো দুই পথে গেছে। Google ২০১৮ সালে gVisor প্রকাশ করে: Go-তে লেখা একটি user-space “kernel”, যা container-এর syscall ধরে নিজে উত্তর দেয় আর host kernel-এর কাছে খুব অল্প, বাছাই করা syscall পাঠায় — আক্রমণ-পৃষ্ঠ ছোট হয়, দাম কিছু কর্মক্ষমতা। অন্য পথ হলো প্রতিটি container বা function-কে একটি অতি-হালকা VM-এ রাখা: AWS ২০১৮ সালে Firecracker প্রকাশ করে, যা Lambda-র মতো সেবায় ব্যবহৃত হয় বলে জানা যায়, আর Kata Containers (২০১৭) একই চিন্তায় container-এর ইন্টারফেস রেখে প্রতিটি pod-কে VM-এ চালায়। এখানে সীমানা আবার হার্ডওয়্যার-ভিত্তিক virtualization (পর্ব ৫০), কিন্তু শুরু হতে সময় লাগে মিলিসেকেন্ডের হিসাবে, পুরো VM-এর মতো নয়।

অর্থাৎ শিল্প container-এর ব্যবহারের সুবিধা (image, OCI, Kubernetes) রেখে সীমানাটি বদলেছে। কখন কোনটি বেছে নেবেন, পর্ব ৫২-এর বিষয়।

মূল উপলব্ধি: নিরাপত্তার সীমানা বাছাই নির্ভর করে কাকে বিশ্বাস করা হয় তার ওপর। নিজের কোডের জন্য container যথেষ্ট হতে পারে; অবিশ্বস্ত, বহু-গ্রাহকের কোডের জন্য shared kernel-এর ঝুঁকি কমাতে বাড়তি স্তর লাগে।

প্র ০৩১৯৭০-এর দশকের Unix-এর একটি নকশা — “init অনাথদের দেখবে” — কীভাবে ২০১০-এর দশকের container-এ বাগ হয়ে ফিরে এল?

Unix-এর শুরু থেকে নিয়ম: প্রতিটি প্রসেসের একটি বাবা আছে, আর বাবা wait() না করা পর্যন্ত মৃত সন্তানের exit status kernel ধরে রাখে (zombie). বাবা আগে মারা গেলে কে wait করবে? উত্তর: init. এজন্য init-কে চিরকাল লুপে wait ডাকতে লেখা হয়েছে, আর kernel init-কে signal-এর ব্যাপারে বিশেষ সুরক্ষা দেয় — ভুল করে kill -TERM 1 দিলে যেন পুরো সিস্টেম না পড়ে। এই নকশা চার দশক নির্বিঘ্নে চলেছে, কারণ init সবসময় এই কাজের জন্যই লেখা একটি বিশেষ প্রোগ্রাম ছিল।

PID namespace (২০০৮) এই নিয়ম হুবহু নকল করল: প্রতিটি namespace-এর নিজের PID 1, নিজের অনাথ-অভিভাবক, নিজের signal-সুরক্ষা। Docker যখন জনপ্রিয় হলো, মানুষ স্বাভাবিকভাবেই লিখলেন CMD python app.py — ফলে PID 1 হয়ে গেল একটি সাধারণ অ্যাপ, যে জানেই না যে তার একটি পুরোনো দায়িত্ব আছে। ফল দুটি পরিচিত উপসর্গ: docker stop সবসময় ১০ সেকেন্ড আটকে থেকে SIGKILL (অ্যাপের SIGTERM handler নেই, আর PID 1-এর ডিফল্ট কাজ নেই), আর দীর্ঘদিন চলা container-এ zombie জমা (যেমন একটি shell script বা headless ব্রাউজার যে সন্তান-প্রসেস ছড়ায়)।

সমাধান এল ছোট init-এর আকারে: tini, Yelp-এর dumb-init, আর Docker 1.13 (২০১৭)-এ --init flag, যা tini বসায়। Kubernetes-এ pod-এর “pause” container namespace-গুলো ধরে রাখে, আর shareProcessNamespace চালু করলে pause-ই PID 1 হয়ে reaping করে। Podman ডিফল্টে না হলেও --init দিলে নিজের catatonit বা এই রানারের মতো podman-init বসায়।

মূল উপলব্ধি: পুরোনো বিমূর্ত ধারণা নকল করলে তার অলিখিত শর্তগুলোও নকল হয়। Namespace kernel-এর আচরণ ঠিকঠাক নকল করেছিল; ভুলটি ছিল এই ধরে নেওয়ায় যে যেকোনো প্রোগ্রাম init-এর ভূমিকা পালন করতে পারে।

প্র ০৪Rootless container নিরাপত্তা বাড়ায়, অথচ কিছু distro অধিকারবিহীন user namespace সীমিত করে — এই দুই সিদ্ধান্ত কি পরস্পরবিরোধী?

User namespace-এর কাজ Eric Biederman-এর নেতৃত্বে বহু বছর চলে, আর Linux 3.8 (২০১৩)-এ এটি এমন অবস্থায় পৌঁছায় যে সাধারণ ব্যবহারকারী নিজেই একটি user namespace বানাতে পারেন। এর প্রতিশ্রুতি বড়: container চালাতে আর root daemon লাগবে না; container ভাঙলেও আক্রমণকারী host-এ একজন সাধারণ ব্যবহারকারী, যেমন এই পাতার রানারে ভেতরের wandbox বাইরে UID ১০০৯৯৮ মাত্র। Podman, Docker-এর rootless মোড, ব্রাউজারের sandbox, Flatpak — সবাই এর ওপর দাঁড়িয়ে।

কিন্তু উল্টো দিক আছে। User namespace-এর মালিক তার ভেতরে CAP_NET_ADMIN, CAP_SYS_ADMIN-এর মতো ক্ষমতা পান — নিজের namespace-এর সীমায়। সমস্যা হলো, ওই ক্ষমতা দিয়ে kernel-এর যে কোড চালানো যায় (নেটওয়ার্ক-filter, নানা file system-এর mount-কোড), তা বহু বছর ধরে লেখা হয়েছিল এই ধারণায় যে এখানে শুধু বিশ্বস্ত root আসবে। ফলে সেই কোডের বাগ হঠাৎ যেকোনো সাধারণ ব্যবহারকারীর নাগালে চলে এল। গত দশকে kernel-এর বেশ কিছু privilege-escalation ত্রুটি কাজে লাগাতে প্রথম ধাপই ছিল একটি user namespace বানানো।

এজন্য Debian বহু বছর একটি নিজস্ব sysctl (kernel.unprivileged_userns_clone) রেখেছিল, আর Ubuntu 23.10/24.04 থেকে AppArmor দিয়ে অধিকারবিহীন user namespace সীমিত করে: শুধু যে প্রোগ্রামের profile-এ অনুমতি আছে, সে-ই পূর্ণ ক্ষমতা পায়। আমাদের রানারের একটি পরীক্ষামূলক রানে /proc/sys/kernel/apparmor_restrict_unprivileged_userns-এর মান 1 দেখা গেছে, অথচ unshare -r চলেছে — সম্ভবত runtime-এর crun profile-এ এই অনুমতি দেওয়া আছে।

তাই দুটি সিদ্ধান্ত পরস্পরবিরোধী নয়; দুটিই কাকে ক্ষমতা দেওয়া হবে, সেই প্রশ্নের সূক্ষ্ম উত্তর: container runtime-এর মতো বিশ্বস্ত প্রোগ্রামকে user namespace দেওয়া, আর যেকোনো প্রোগ্রামকে স্বয়ংক্রিয়ভাবে নয়।

মূল উপলব্ধি: একটি নিরাপত্তা-যন্ত্র একদিকে ক্ষতির সীমা ছোট করে, অন্যদিকে নতুন দরজা খোলে। ভালো নীতি দুই দিকই মাপে।

প্র ০৫cgroup v1 থেকে v2-এ যেতে প্রায় এক দশক লাগল কেন, আর এই রূপান্তর থেকে বড় সফটওয়্যার-পরিবর্তন সম্পর্কে কী শেখা যায়?

cgroup v1 (২০০৮) ছিল একটি পরীক্ষামূলক মনোভাবের ফসল: প্রতিটি controller আলাদা দল আলাদা গাছে সাজাতে পারত, আর প্রতিটি controller-এর লেখকেরা নিজেদের নিয়ম বানাতেন। কয়েক বছরের মধ্যে সমস্যা স্পষ্ট হলো। একটি প্রসেস memory-গাছে এক দলে আর blkio-গাছে অন্য দলে থাকলে, তার নোংরা page লেখার I/O কার খাতায় যাবে? কিছু controller দল-ভিত্তিক নিয়ম মানত, কিছু থ্রেড-ভিত্তিক। Kernel ডেভেলপার Tejun Heo-র নেতৃত্বে নতুন নকশা আসে: একটি গাছ, প্রসেস শুধু পাতা-দলে, controller চালুর স্পষ্ট নিয়ম, আর অধিকারবিহীন ব্যবহারকারীকে গাছের একটি শাখা নিরাপদে হস্তান্তরের (delegation) ব্যবস্থা — যা ছাড়া rootless container-এ cgroup দেওয়াই কঠিন। v2 Linux 4.5 (২০১৬)-এ স্থিতিশীল ঘোষিত হয়।

কিন্তু kernel-এ নতুন ফাইল আসাই যথেষ্ট নয়। প্রতিটি controller-কে v2-এ আনতে হয়েছে (cpu controller নিয়ে বিতর্কে বেশ সময় লাগে), systemd-কে সমর্থন দিতে হয়েছে, তারপর runc, Docker, Kubernetes, Java-র মতো runtime — যারা নিজের মেমরি-সীমা বুঝতে /sys/fs/cgroup পড়ে — সবাইকে নতুন path শিখতে হয়েছে। Fedora 31 (২০১৯) প্রথম বড় distro হিসেবে ডিফল্টে v2 চালু করে, আর তখন Docker সেখানে কিছুদিন সরাসরি চলত না; Docker 20.10 (২০২০) সমর্থন আনে, আর Kubernetes-এ cgroup v2 সমর্থন ২০২২ সালে (১.২৫) সাধারণভাবে স্থিতিশীল হয়। এই পুরো সময় distro-গুলো “hybrid” অবস্থায় দুটিকেই একসাথে চালিয়েছে।

আজ এই রানারের মতো সিস্টেমে cgroup2fs, memory.peak, cpu.pressure — নতুন সুবিধাগুলো স্বাভাবিক। কিন্তু AI বা পুরোনো ব্লগ এখনো প্রায়ই v1-এর memory.limit_in_bytes path দেয়।

মূল উপলব্ধি: kernel-এর একটি ইন্টারফেস একবার প্রকাশিত হলে তা পুরো ইকোসিস্টেমের চুক্তি হয়ে যায়। নতুন নকশা যত ভালোই হোক, রূপান্তরের আসল কাজ হলো সবাইকে একসাথে সরানো — আর সেজন্য দীর্ঘ সময় দুই সংস্করণ পাশাপাশি চালাতে হয়।

অনুশীলন

খাতায় হিসাব করুন, তারপর উত্তর মেলান।

  1. হিসাব — cgroup-এর CPU আর মেমরি সীমা: একটি ৮-core সার্ভারে একটি container-এর cpu.max = 150000 100000. Container-এর ভেতরে ৪টি থ্রেড সারাক্ষণ CPU-তে ব্যস্ত থাকতে চায়। (ক) Container কত CPU-র সমান পায়? (খ) প্রতিটি ১০০ ms period-এর শুরু থেকে কত ms পরে quota ফুরোবে, আর তারপর কতক্ষণ দলটি থেমে থাকবে? (গ) এক সেকেন্ডে মোট কত CPU-সময় পাবে? (ঘ) Kubernetes-এ limits.memory: 512Mi আর limits.cpu: 250m হলে memory.max আর cpu.max-এ কী লেখা হবে?

    (ক) quota ÷ period = ১৫০০০০ ÷ ১০০০০০ = ১.৫ CPU. (খ) ৪টি থ্রেড একসাথে চললে প্রতি ১ ms দেয়াল-সময়ে ৪ ms CPU-সময় খরচ হয়। ১৫০ ms quota ফুরোয় ১৫০ ÷ ৪ = ৩৭.৫ ms-এ; তারপর period-এর বাকি ১০০ − ৩৭.৫ = ৬২.৫ ms চারটি থ্রেডই throttled, আর প্রতি period-এ nr_throttled ১ করে বাড়ে। (গ) এক সেকেন্ডে ১০টি period × ১৫০ ms = ১৫০০ ms = ১.৫ CPU-সেকেন্ড — ৮টি core খালি থাকলেও এর বেশি নয়। একটি অনুরোধ যদি ৪০ ms একটানা CPU চায় আর period-এর শেষ দিকে আসে, তাকে পরের period পর্যন্ত অপেক্ষা করতে হতে পারে — এটিই latency-র লেজ বাড়ার কারণ (পর্ব ২১)। (ঘ) ৫১২ × ১০২৪ × ১০২৪ = memory.max = 536870912; ২৫০ millicore = ০.২৫ CPU = প্রতি ১০০০০০ µs-এ ২৫০০০ µs ⇒ cpu.max = 25000 100000.

  2. হাতে trace — UID-এর মানচিত্র: একটি rootless container-এর uid_map: 0 1000 1 আর 1 100000 65536. (ক) ভেতরের UID ০, ৯৯৯ আর ১০০০ — host-এ কোন UID? (খ) Host-এর UID ১০০৫০০-এর মালিকানার ফাইল ভেতর থেকে কোন UID-এর দেখাবে? (গ) Host-এর root (UID ০)-এর ফাইল ভেতর থেকে কেমন দেখাবে? (ঘ) ভেতরে কেউ UID ৭০০০০ ব্যবহার করতে চাইলে কী হবে?

    নিয়ম: একটি লাইন “ভেতরের শুরু a, বাইরের শুরু b, সংখ্যা n” মানে ভেতরের a থেকে a+n−১ যায় বাইরের b থেকে b+n−১-এ, অর্থাৎ বাইরে = b + (ভেতরে − a).

    (ক) ০ → প্রথম লাইন ⇒ ১০০০ (Podman চালানো ব্যবহারকারী)। ৯৯৯ → দ্বিতীয় লাইন ⇒ ১০০০০০ + (৯৯৯ − ১) = ১০০৯৯৮ — এই পাতার রানারের uid=100998-এর সাথে মেলে। ১০০০ ⇒ ১০০০০০ + ৯৯৯ = ১০০৯৯৯. (খ) উল্টো হিসাব: ১০০৫০০ দ্বিতীয় লাইনের বাইরের পরিসরে (১০০০০০–১৬৫৫৩৫) ⇒ ভেতরে = ১ + (১০০৫০০ − ১০০০০০) = ৫০১. (গ) Host-এর ০ কোনো লাইনের বাইরের পরিসরে নেই ⇒ map হয় না ⇒ ভেতরে দেখায় ৬৫৫৩৪ (nobody), আর ভেতরের “root”-ও সেই ফাইল বদলাতে পারে না। (ঘ) ৭০০০০ কোনো লাইনের ভেতরের পরিসরে (০, ১–৬৫৫৩৬) নেই ⇒ সেই UID-এ যাওয়া (setuid(70000)) বা ফাইলের মালিক বানানো ব্যর্থ হয় (সাধারণত EINVAL) — image-এ খুব বড় UID-এর ফাইল থাকলে rootless-এ নামাতে সমস্যা হয় এজন্যই।

  3. আউটপুট অনুমান করুন — overlay: একটি image-এ /app/config.txt (বিষয়বস্তু port=80) আর /app/old.log আছে। একই image থেকে container A আর B চালু। A-তে চালানো হলো: echo port=8080 > /app/config.txt; rm /app/old.log; cat /app/config.txt; ls /app. তারপর B-তে cat /app/config.txt; ls /app. দুটি container কী ছাপবে? A-এর upperdir-এ কী কী থাকবে? A মুছে নতুন container C চালু করলে C কী দেখবে?

    A ছাপবে: port=8080, তারপর config.txt (শুধু একটি ফাইল; old.log নেই)। B ছাপবে: port=80, তারপর config.txt old.log — B-এর নিজের upperdir খালি, আর নিচের layer কখনো বদলায় না।

    A-এর upperdir: app/ ফোল্ডার, তাতে copy-up হওয়া পুরো config.txt (নতুন বিষয়বস্তু port=8080), আর old.log নামে একটি whiteout — ls -l-এ c--------- ধরনের character device, device নম্বর 0, 0. Image-এর আসল old.log-এর জায়গা ডিস্কে আগের মতোই আছে। C দেখবে: একদম মূল image — port=80 আর দুটি ফাইলই; A-এর upperdir মুছে যাওয়ায় তার সব পরিবর্তন হারিয়েছে। স্থায়ী রাখতে চাইলে volume বা bind mount লাগত।

  4. কমান্ড লিখুন — শক্ত-করা container: myapi:2.1.0 image-এর একটি ওয়েব API container-এ ৮০ নম্বর port-এ শোনে। এমন একটি docker run লিখুন যাতে: host-এর ৮০৮০ → container-এর ৮০; ভেতরে UID ১০০০; root file system শুধু-পড়ার কিন্তু /tmp লেখা যায়; সব capability বাদ, শুধু ১০২৪-এর নিচের port-এ বসার ক্ষমতা; setuid দিয়ে নতুন ক্ষমতা নয়; ৩০০ MiB মেমরি, ১ CPU, ২০০ প্রসেস; PID 1-এ ছোট init. তারপর লিখুন কীভাবে যাচাই করবেন।
    docker run -d --name api -p 8080:80 --user 1000:1000 \
      --read-only --tmpfs /tmp \
      --cap-drop ALL --cap-add NET_BIND_SERVICE \
      --security-opt no-new-privileges \
      --memory 300m --cpus 1 --pids-limit 200 \
      --init myapi:2.1.0

    যাচাই: docker exec api id (uid=1000); docker exec api grep -E 'CapBnd|CapEff|NoNewPrivs|Seccomp' /proc/1/status — NoNewPrivs: 1, Seccomp: 2, CapBnd-এ শুধু bit ১০ (0000000000000400); প্রসেস root না হওয়ায় CapEff সাধারণত শূন্য দেখাবে। docker exec api cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max /sys/fs/cgroup/pids.max — 314572800 (৩০০ × ১০৪৮৫৭৬), 100000 100000, 200; docker exec api touch /x ব্যর্থ (Read-only file system) কিন্তু touch /tmp/x সফল; আর docker exec api cat /proc/1/cmdline-এ PID 1 হিসেবে Docker-এর init (docker-init, আসলে tini). সূক্ষ্ম বিষয়: সাধারণ ব্যবহারকারী ভেতরে ৮০-তে বসতে পারবে কি না, runtime-ভেদে আলাদা (no_new_privs থাকায় setcap-ও কাজ করবে না); বহনযোগ্য পথ হলো অ্যাপকে ৮০৮০-এ শোনানো আর -p 8080:8080.

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমdocker run nginx দেওয়ার পর যা যা ঘটে — সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. ⌨️ docker CLI একটি REST অনুরোধ /var/run/docker.sock-এ dockerd-কে পাঠায়
  2. 🏗️ dockerd image-এর layer নামিয়ে overlay rootfs আর config.json (OCI bundle) বানায়
  3. 📦 containerd একটি shim প্রসেস চালায়, যে container-এর বাবা থাকবে
  4. 🔧 runc clone(CLONE_NEW*) করে, cgroup সীমা লেখে, capability ছাঁটে আর seccomp বসায়
  5. 🚀 runc execve দিয়ে nginx চালায় — ভেতরে PID 1 — আর নিজে বেরিয়ে যায়

Linux container সম্পর্কে কোন বাক্যটি সঠিক?

  • প্রতিটি container নিজের ছোট Linux kernel চালায়
  • Container হলো host-এর kernel-এ চলা প্রসেস, যাকে namespace আলাদা দৃশ্য আর cgroup সীমিত রিসোর্স দেয়
  • Container চালাতে সবসময় একটি hypervisor লাগে
  • Linux container macOS-এর kernel-এ সরাসরি চলতে পারে

কোন namespace একটি container-কে নিজের hostname দেয়?

  • mnt (CLONE_NEWNS)
  • ipc (CLONE_NEWIPC)
  • uts (CLONE_NEWUTS)
  • time (CLONE_NEWTIME)

একটি container-এর PID 1 একটি সাধারণ অ্যাপ, যার কোনো SIGTERM handler নেই। docker stop দিলে সাধারণত কী ঘটে?

  • SIGTERM উপেক্ষিত হয়; প্রায় ১০ সেকেন্ড পর SIGKILL দিয়ে বন্ধ হয়
  • SIGTERM পেয়ে সাথে সাথে বন্ধ হয়
  • Container চিরকাল চলতে থাকে, কারণ PID 1 কোনো signal পায় না
  • host-এর systemd-ও SIGTERM পেয়ে বন্ধ হয়ে যায়

একটি container image-এর নিচের layer-এ থাকা একটি ফাইল container-এর ভেতরে মুছে ফেলা হলো। overlayfs কী করে?

  • নিচের layer থেকে ফাইলটি স্থায়ীভাবে মুছে দেয়
  • ফাইলটি upperdir-এ কপি করে তারপর মোছে
  • EROFS ত্রুটি দেয়, কারণ নিচের layer শুধু-পড়ার
  • upperdir-এ একই নামে একটি whiteout বসায়, ফলে merged দৃশ্যে ফাইলটি আর দেখা যায় না

একটি rootless container-এর uid_map হলো 0 1000 1 আর 1 100000 65536. ভেতরের UID ৫০০ host-এ কোন UID?

  • ৫০০
  • ১০০৪৯৯
  • ১০০৫০০
  • ১৫০০

Kubernetes-এ একটি container-এর limits.memory: 512Mi. cgroup v2-এ কোন মান লেখা হয়, আর সীমা ছাড়ালে কী হয়?

  • memory.max = 512000000; container ধীর হয়ে যায় কিন্তু বেঁচে থাকে
  • cpu.max = 512; container throttled হয়
  • memory.max = 536870912; সীমা ছাড়ালে OOM killer মারে, exit code 137
  • pids.max = 512; নতুন fork ব্যর্থ হয়

কোন কাজটি একটি container-কে host-এর ওপর কার্যত root-এর সমান ক্ষমতা দেওয়ার মতো ঝুঁকিপূর্ণ?

  • container-এর ভেতরে /var/run/docker.sock mount করা
  • --read-only দিয়ে চালানো
  • --cap-drop ALL দেওয়া
  • --pids-limit 100 দেওয়া

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Container = সাধারণ প্রসেস + namespace (দৃশ্য) + cgroup (সীমা) + image/overlay (নিজের root) + seccomp/caps/LSM (সুরক্ষা); kernel সবার এক — VM নয়।
  2. আটটি namespace: mnt, uts, ipc, pid, net, user, cgroup, time; clone/unshare দিয়ে তৈরি, setns দিয়ে প্রবেশ; /proc/<pid>/ns-এর inode এক মানে একই namespace.
  3. PID 1 orphan reap করে, handler-বিহীন signal পায় না, মরলে সবাই SIGKILL — তাই tini/--init/podman-init আর exec-form CMD.
  4. cgroup v2 = এক গাছ; cpu.max, memory.max, pids.max, io.max; Kubernetes-এর limit সরাসরি এখানে নামে (512Mi = 536870912, ছাড়ালে 137).
  5. Image = digest-চিহ্নিত layer; overlayfs: lowerdir + upperdir + workdir, বদলালে copy-up, মুছলে whiteout. Rootless: uid_map-এ ভেতরের root বাইরের সাধারণ ব্যবহারকারী। শিকল: CLI → dockerd → containerd → shim → runc/crun.

পরের পর্বে: VM দেখলেন, container-এর ভেতরও দেখলেন। কিন্তু Firecracker-এর microVM, gVisor আর serverless function কোথায় বসে? কোন কাজে কোনটি — খরচ, গতি আর নিরাপত্তার হিসাবে — পর্ব ৫২-এ।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান