Container from Scratch
Container from Scratch
clone(), pivot_root() আর cgroup v2 দিয়ে প্রায় ৩০০ লাইনের একটা সত্যিকার মিনি container runtime C-তে লেখা — namespace isolation, root filesystem swap, resource limit থেকে শুরু করে একটা কাজ-করা `./minidocker run` কমান্ড আর একটা memory OOM-kill demonstration পর্যন্ত।
কেন এই প্রজেক্ট
এই module-এর তিনটা আগের লেসন — namespace, cgroup, আর OCI/runc — প্রতিটাই একটা নির্দিষ্ট প্রশ্নের গভীরে গিয়েছে, আর শেষেরটা সবগুলোকে একটা LayerTrace-এ জোড়া দিয়ে দেখিয়েছে docker run লিখলে ঠিক কোন syscall-এর ক্রম ঘটে। কিন্তু prose পড়ে trace বোঝা আর নিজের হাতে ঠিক সেই syscall-গুলো, ঠিক সেই ক্রমে, নিজের C কোডে লেখা — এই দুই অবস্থার মাঝখানে একটা বিশাল দূরত্ব থেকে যায়। clone()-এর flag ভুল bitwise-OR করলে namespace আধা তৈরি হয়; uid_map লেখার আগে setgroups: deny ভুলে গেলে write() নীরবে EPERM দেয় না, বরং বিভ্রান্তিকরভাবে ব্যর্থ হয়; cgroup-এ child PID যোগ করার আগেই execve() করে ফেললে entrypoint-এর প্রথম কয়েক মিলিসেকেন্ড সম্পূর্ণ unrestricted memory/CPU-তে চলে যায় — কোনো error বার্তা ছাড়াই একটা নিরাপত্তা গ্যারান্টি ফাঁকা হয়ে যায়।
এই প্রজেক্টের লক্ষ্য runc-এর মূল কাজটাই — একটা config.json-এর বদলে হার্ডকোড করা CLI flag থেকে — প্রায় ৩০০ লাইনের একটা single-file C প্রোগ্রামে বাস্তবায়ন করা: ./minidocker run --mem=100m --cpu=0.5 ./rootfs /bin/sh লিখলে সত্যিই একটা namespace-isolated, cgroup-limited shell পাওয়া যাবে। এর পরে docker/containerd/runc-এর সোর্স কোড আর একটা জাদুবাক্স থাকবে না — নিজের লেখা প্রতিটা লাইনের সাথে তাদের প্রতিটা লাইন মিলিয়ে পড়া যাবে।
একটা জরুরি honesty note শুরুতেই: এই লেসনগুলোর মতোই, এই প্রজেক্ট-লেখাও একটা macOS sandbox-এ হচ্ছে, যেখানে clone()-এর namespace flag, pivot_root(), বা /sys/fs/cgroup — কোনোটাই বিদ্যমান না (linux-namespaces-deep-dive আর cgroups-v2-resource-control লেসনের experiment অংশ ঠিক এই একই সীমাবদ্ধতা verifiably দেখিয়েছে)। তাই নিচের প্রতিটা কোড ব্লক এই sandbox-এ কম্পাইল করা যায়নি, চালানো যায়নি — এগুলো লেখা হয়েছে সরাসরি man page (clone(2), pivot_root(2), namespaces(7), cgroups(7)) আর আগের তিনটা লেসনের ইতিমধ্যে-verify-করা mechanics অনুসরণ করে। এই প্রজেক্টের আসল টার্গেট একটা প্রকৃত Linux মেশিন — একটা VM (UTM, Multipass, বা যেকোনো cloud provider-এর একটা ছোট instance), root বা sudo access সহ, kernel ৫.x বা নতুন, cgroup v2 unified hierarchy mounted। ধাপে ধাপে যেখানেই “প্রত্যাশিত output” দেখানো হয়েছে, সেটা স্পষ্টভাবে লেবেল করা থাকবে যে সেটা এই sandbox-এ যাচাই করা হয়নি, বরং man page ও kernel documentation থেকে যুক্তিসঙ্গতভাবে derive করা।
ধাপে ধাপে
১. Setup — target environment ও project skeleton
প্রথমেই একটা প্রকৃত Linux পরিবেশ দরকার। কয়েকটা সহজ বিকল্প:
- Cloud VM — যেকোনো provider-এর একটা ছোট Ubuntu/Debian instance,
sshদিয়ে root/sudo access - স্থানীয় VM — macOS-এ UTM/Parallels/VirtualBox দিয়ে একটা Ubuntu Server VM, বা Linux হোস্টে সরাসরি
- WSL2 (Windows হলে) — এটাও একটা প্রকৃত Linux kernel, namespace/cgroup syscall সম্পূর্ণ কাজ করে
Compiler ও headers যাচাই:
gcc --version
uname -r # 5.x বা নতুন হওয়া উচিত
cat /sys/fs/cgroup/cgroup.controllers # cgroup v2 unified — খালি না হওয়া উচিত
প্রয়োজনীয় header — সবগুলোই glibc-এর অংশ, বাড়তি লাইব্রেরি লাগে না:
#define _GNU_SOURCE /* clone(), MS_* flag, pivot_root syscall number-এর জন্য */
#include <sched.h> /* clone(), CLONE_NEW* */
#include <sys/mount.h> /* mount(), umount2(), MS_* */
#include <sys/syscall.h> /* syscall(), SYS_pivot_root */
#include <sys/wait.h> /* waitpid() */
#include <sys/stat.h> /* mkdir(), mknod() */
#include <sys/sysmacros.h> /* makedev() */
#include <unistd.h> /* fork, execvp, chdir, getuid/getgid */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <errno.h>
Project skeleton:
minidocker/
├── minidocker.c # পুরো runtime — একটাই ফাইল
├── Makefile
└── rootfs/ # নিচে ডাউনলোড করা মিনিমাল root filesystem
rootfs/ পাওয়ার সবচেয়ে সহজ উপায় — Alpine-এর official minimal rootfs tarball, কোনো root privilege ছাড়াই ডাউনলোড ও extract করা যায়:
mkdir -p minidocker/rootfs && cd minidocker
curl -LO https://dl-cdn.alpinelinux.org/alpine/v3.19/releases/x86_64/alpine-minirootfs-3.19.1-x86_64.tar.gz
tar -xzf alpine-minirootfs-3.19.1-x86_64.tar.gz -C rootfs
Debian/Ubuntu-শৈলীর rootfs চাইলে debootstrap বিকল্প (root লাগে, ধীর কিন্তু বেশি familiar):
sudo debootstrap --variant=minbase stable rootfs http://deb.debian.org/debian
Makefile খুবই ছোট — বাড়তি flag তেমন লাগে না:
minidocker: minidocker.c
gcc -Wall -Wextra -o minidocker minidocker.c
Checkpoint: make সফল হবে (এখনো একটা খালি main() দিয়ে), আর ls rootfs/bin/sh ফাইলটা দেখাবে — এই দুইটাই প্রমাণ যে compiler টুলচেইন আর rootfs দুটোই প্রস্তুত।
২. clone() — একটাই syscall-এ পাঁচটা namespace
linux-namespaces-deep-dive লেসনের exact hex flag টেবিলটাই এখানে সরাসরি কোডে যাচ্ছে। network namespace (CLONE_NEWNET) ইচ্ছাকৃতভাবে বাদ — সেটা “নিজেকে চ্যালেঞ্জ করুন” সেকশনের প্রথম আইটেম, কারণ veth pair/bridge সেটআপ ছাড়া একটা isolated network namespace থেকে কোনো বাইরের সংযোগ থাকে না (network namespace-এর নিষ্ক্রিয় lo-র কথা মনে করুন)।
#define STACK_SIZE (1024 * 1024)
#define CONTAINER_FLAGS ( CLONE_NEWPID /* 0x20000000 -- নিজস্ব PID সংখ্যায়ন */ \
| CLONE_NEWNS /* 0x00020000 -- নিজস্ব mount tree */ \
| CLONE_NEWUTS /* 0x04000000 -- নিজস্ব hostname */ \
| CLONE_NEWIPC /* 0x08000000 -- নিজস্ব SysV IPC id space */ \
| CLONE_NEWUSER /* 0x10000000 -- নিজস্ব UID/GID mapping */ )
/* combined = 0x3C020000 -- CLONE_NEWNET (0x40000000) যোগ করলে
ঠিক namespaces-deep-dive লেসনের সেই বিখ্যাত 0x7C020000 পাওয়া যায় */
struct child_args {
char **argv;
char *rootfs;
char *hostname;
int sync_pipe[2]; /* parent uid_map/cgroup শেষ না করা পর্যন্ত child আটকে থাকে */
};
static int child_func(void *arg); /* সংজ্ঞা ধাপ ৭-এ সম্পূর্ণ হবে */
static char child_stack[STACK_SIZE];
/* main()-এর ভেতরের প্রাথমিক clone() কল -- সম্পূর্ণ CLI-সহ ধাপ ৮-এ */
pid_t spawn(struct child_args *args) {
pid_t pid = clone(child_func, child_stack + STACK_SIZE,
CONTAINER_FLAGS | SIGCHLD, args);
if (pid == -1) {
perror("clone");
exit(1);
}
return pid;
}
লক্ষ করুন child_stack + STACK_SIZE — x86-64/ARM64-এ stack নিচের দিকে বাড়ে, তাই clone()-কে stack-এর উপরের ঠিকানা দিতে হয়, শুরুর ঠিকানা না। আর SIGCHLD flag-এ বিট-OR করা বাধ্যতামূলক — এটা না থাকলে child exit করলে waitpid() কখনো সিগন্যাল পাবে না।
Checkpoint (প্রকৃত Linux-এ প্রত্যাশিত, এই sandbox-এ যাচাই করা হয়নি): child_func-এর একটা স্টাব বসিয়ে শুধু printf("child pid = %d\n", getpid()) চালালে ভেতর থেকে 1 প্রিন্ট হবে — PID namespace-এর প্রথম process বলেই — অথচ parent-এর spawn()-এর রিটার্ন value (host-এর দৃষ্টিকোণ থেকে real PID) সেই 1-এর চেয়ে অনেক বড় একটা সংখ্যা। একই process, দুইটা namespace, দুইটা ভিন্ন PID — এটাই namespace-এর সবচেয়ে সরাসরি প্রমাণ।
৩. UID/GID mapping — child নিজেকে root হিসেবে দেখে
CLONE_NEWUSER দিয়ে namespace তৈরি হওয়ার সাথে সাথে child-এর ভেতরের UID/GID এখনো unmapped — mapping না লেখা পর্যন্ত namespace-এর ভেতরের সবকিছু overflow UID (65534, nobody)-এ দেখা যায়। এই mapping অবশ্যই parent থেকে লিখতে হয় (user_namespaces(7)-এর নির্ভুল প্রয়োজনীয়তা — namespaces-deep-dive লেসনের ব্যাখ্যা অনুযায়ী, child নিজে নিজের mapping লিখলে সে নিজের privilege নিজেই বাড়িয়ে নিতে পারত)।
static void write_file(const char *path, const char *content) {
int fd = open(path, O_WRONLY);
if (fd < 0) { fprintf(stderr, "open %s: %s\n", path, strerror(errno)); exit(1); }
if (write(fd, content, strlen(content)) < 0) {
fprintf(stderr, "write %s: %s\n", path, strerror(errno)); exit(1);
}
close(fd);
}
static void setup_id_map(pid_t pid) {
char path[64], line[64];
uid_t uid = getuid();
gid_t gid = getgid();
snprintf(path, sizeof(path), "/proc/%d/uid_map", pid);
snprintf(line, sizeof(line), "0 %d 1\n", uid);
write_file(path, line);
/* gid_map লেখার আগে setgroups deny বাধ্যতামূলক -- নাহলে সেই write()
নিজেই EPERM দিয়ে ব্যর্থ হয়। কারণ: supplementary group-এর মাধ্যমে
একটা unprivileged process নিজেকে host-এর কোনো privileged group-এর
(যেমন docker গোষ্ঠীর GID) সদস্য হিসেবে map করে ফেলতে পারত --
namespaces-deep-dive লেসনের এই একই callout, এখানে কোডে। */
snprintf(path, sizeof(path), "/proc/%d/setgroups", pid);
write_file(path, "deny");
snprintf(path, sizeof(path), "/proc/%d/gid_map", pid);
snprintf(line, sizeof(line), "0 %d 1\n", gid);
write_file(path, line);
}
"0 %d 1" মানে — namespace UID 0 (root) ↔ host UID getuid(), দৈর্ঘ্য 1। sudo ./minidocker দিয়ে চালালে host UID 0, তাই mapping সোজা 0 0 1; কিন্তু কোনো সাধারণ ব্যবহারকারীর নিজের UID (ধরুন 1000)-কেও একইভাবে namespace-এর ভেতরের root-এ map করা যায় — এটাই rootless container-এর ভিত্তি, “নিজেকে চ্যালেঞ্জ করুন” সেকশনে বহু-রেঞ্জ subuid mapping দিয়ে সম্প্রসারিত হবে।
এই প্রজেক্টের বাকি ধাপ (বিশেষত cgroup ফাইলে লেখা) সরলতার জন্য sudo ./minidocker ... ধরে নিচ্ছে — root cgroup delegation ছাড়া unprivileged cgroup write সাধারণত সম্ভব না।
Checkpoint (প্রকৃত Linux-এ প্রত্যাশিত): mapping লেখার পরে child-এর ভেতর getuid()/geteuid() প্রিন্ট করলে 0 দেখাবে, এমনকি sudo ছাড়া সাধারণ ব্যবহারকারী চালালেও — namespace-এর ভেতরে root, host-এ অপরিবর্তিত unprivileged user।
৪. pivot_root() — rootfs swap, chroot() না কেন
rootfs হিসেবে ধাপ ১-এ ডাউনলোড করা rootfs/ ব্যবহার হবে। pivot_root()-এর একটা শর্ত — নতুন root নিজেই একটা mount point হতে হবে, তাই একটা bind-mount দিয়ে নিজের ওপর নিজেকে mount করে সেই শর্ত পূরণ করা হয়:
static void do_pivot_root(const char *rootfs) {
/* mount propagation বিচ্ছিন্ন -- namespaces-deep-dive লেসনের ঠিক এই ধাপ।
না করলে পরের umount2() হোস্টের mount tree-তেও leak করতে পারে। */
if (mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL) != 0) {
perror("mount MS_PRIVATE"); exit(1);
}
if (mount(rootfs, rootfs, NULL, MS_BIND | MS_REC, NULL) != 0) {
perror("bind mount rootfs"); exit(1);
}
if (chdir(rootfs) != 0) { perror("chdir rootfs"); exit(1); }
if (mkdir(".old_root", 0700) != 0 && errno != EEXIST) {
perror("mkdir .old_root"); exit(1);
}
if (syscall(SYS_pivot_root, ".", ".old_root") != 0) {
perror("pivot_root"); exit(1);
}
if (chdir("/") != 0) { perror("chdir /"); exit(1); }
if (umount2("/.old_root", MNT_DETACH) != 0) {
perror("umount2 .old_root"); exit(1);
}
rmdir("/.old_root");
}
glibc-এ pivot_root()-এর কোনো সরাসরি wrapper নেই, তাই syscall(SYS_pivot_root, ...) দিয়ে raw syscall কল করতে হয়।
কেন chroot() না — namespaces-deep-dive লেসনের নির্ভুল পার্থক্যটাই এখানে সিদ্ধান্তের ভিত্তি। chroot() শুধু path resolution-এর apparent root বদলায়; পুরনো root filesystem তখনও mounted থেকে যায়, আর container-এর ভেতরের process যদি আগে থেকে খোলা কোনো file descriptor ধরে রাখে (একটা directory fd), সেই fd দিয়ে fchdir() করে পুরনো root-এ ফিরে যাওয়া সম্ভব — ক্লাসিক chroot escape। pivot_root() + umount2(MNT_DETACH) এর পরে পুরনো root filesystem-এর সাথে mount-tree-এর কোনো সংযোগই থাকে না — কোনো fd দিয়েই আর ফেরা যায় না, কারণ filesystem-ই আর mount tree-তে নেই। একটা container-এর ভেতরের process root privilege নিয়েই চলতে পারে (namespace-এর ভেতরে হলেও), তাই শুধু chroot() একটা নিরাপত্তা ফাঁক রেখে দেয় — এই কারণেই প্রায় সব real-world container runtime (runc-সহ) pivot_root() ব্যবহার করে।
Checkpoint (প্রকৃত Linux-এ প্রত্যাশিত): do_pivot_root()-এর পরে child-এর ভেতর ls / চালালে Alpine rootfs-এর কাঠামো (bin, etc, lib, usr, …) দেখাবে, host-এর root না; আর ls /.old_root No such file or directory দেবে — পুরনো root সত্যিই সম্পূর্ণ বিচ্ছিন্ন।
৫. /proc, /sys, /dev — একটা কাজ-করা environment বানানো
শুধু rootfs swap করলেই একটা ব্যবহারযোগ্য পরিবেশ হয় না — খালি rootfs-এ /proc না থাকলে ps কাজ করবে না (PID namespace থাকলেও তার তথ্য পড়ার একমাত্র রাস্তা /proc), /dev না থাকলে /dev/null-এ redirect করা কোনো কমান্ডও ব্যর্থ হবে।
static void setup_mounts(void) {
/* /proc -- এই namespace-এর নিজস্ব PID তালিকা দেখানোর একমাত্র রাস্তা;
PID namespace শুধু নম্বর আলাদা করে, /proc mount না করলে ps/top-এর
মতো টুল কোনো তথ্যই পড়তে পারবে না */
if (mount("proc", "/proc", "proc", 0, NULL) != 0) { perror("mount /proc"); exit(1); }
/* /sys -- device ও kernel-tunable তথ্যের জন্য, অনেক টুল এটা প্রত্যাশা করে */
if (mount("sysfs", "/sys", "sysfs", 0, NULL) != 0) { perror("mount /sys"); exit(1); }
/* /dev -- খালি tmpfs, তারপর জরুরি device node হাতে বানাতে হয়,
কারণ pivot_root-এর পরে rootfs-এর নিজস্ব /dev-এ কিছুই ছিল না */
if (mount("tmpfs", "/dev", "tmpfs", MS_NOSUID | MS_STRICTATIME, "mode=755") != 0) {
perror("mount /dev"); exit(1);
}
mkdir("/dev/pts", 0755);
mount("devpts", "/dev/pts", "devpts", 0, NULL);
mknod("/dev/null", S_IFCHR | 0666, makedev(1, 3));
mknod("/dev/zero", S_IFCHR | 0666, makedev(1, 5));
mknod("/dev/random", S_IFCHR | 0666, makedev(1, 8));
mknod("/dev/urandom", S_IFCHR | 0666, makedev(1, 9));
}
Checkpoint (প্রকৃত Linux-এ প্রত্যাশিত): container-এর ভেতর থেকে cat /proc/self/status চালালে Pid: লাইনে একটা ছোট সংখ্যা (1 বা 2) দেখাবে, host-এর real PID না; আর echo hi > /dev/null কোনো error ছাড়া নীরবে সফল হবে।
৬. cgroup v2 — limit লেখা, তারপর child-কে যোগ করা
cgroups-v2-resource-control লেসনের ঠিক এই তিনটা ফাইল আর ঠিক এই ক্রম। গুরুত্বপূর্ণ ডিজাইন সিদ্ধান্ত: child-এর PID কে cgroup.procs-এ লেখা হয় parent থেকে, execve()-এর আগেই (child তখনও sync pipe-এ আটকে আছে) — নাহলে entrypoint প্রথম কয়েক মুহূর্ত সম্পূর্ণ unrestricted অবস্থায় চলে যেত।
static void setup_cgroup(pid_t pid, const char *mem_limit,
const char *cpu_limit, const char *pids_limit) {
char dir[128], path[160], pidstr[32];
/* top-down constraint: parent-এ enable না থাকলে child cgroup
cpu.max/memory.max ফাইলই পাবে না */
write_file("/sys/fs/cgroup/cgroup.subtree_control", "+cpu +memory +pids");
snprintf(dir, sizeof(dir), "/sys/fs/cgroup/minidocker.%d", pid);
if (mkdir(dir, 0755) != 0 && errno != EEXIST) { perror("mkdir cgroup"); exit(1); }
if (mem_limit && mem_limit[0]) {
snprintf(path, sizeof(path), "%s/memory.max", dir);
write_file(path, mem_limit);
}
if (cpu_limit && cpu_limit[0]) {
snprintf(path, sizeof(path), "%s/cpu.max", dir);
write_file(path, cpu_limit);
}
if (pids_limit && pids_limit[0]) {
snprintf(path, sizeof(path), "%s/pids.max", dir);
write_file(path, pids_limit);
}
/* child PID-কে cgroup-এ যোগ করা -- এই মুহূর্ত থেকেই limit কার্যকর,
আর এই লাইনটা এখনও execve()-এর আগে ঘটছে */
snprintf(path, sizeof(path), "%s/cgroup.procs", dir);
snprintf(pidstr, sizeof(pidstr), "%d", pid);
write_file(path, pidstr);
}
--mem=100m → memory.max ফাইলে বাইট সংখ্যা (104857600); --cpu=0.5 → cpu.max ফাইলে "50000 100000" (quota/period, দুইটাই microsecond) — এই রূপান্তর ধাপ ৮-এর CLI parsing-এ। pids.max ডিফল্ট "64" রাখা হচ্ছে — একটা সাধারণ safety net, fork bomb থেকে বাঁচার জন্য।
Checkpoint (প্রকৃত Linux-এ প্রত্যাশিত): entrypoint চালু হওয়ার আগেই, host-এর আরেকটা terminal থেকে cat /sys/fs/cgroup/minidocker.PID/memory.max (যেখানে PID spawn()-এর রিটার্ন value) ঠিক লেখা byte সংখ্যাটা দেখাবে; cat .../cgroup.procs container-এর real host PID দেখাবে।
৭. UTS hostname সেট করে execve() — সব টুকরো এক জায়গায়
child_func এখন সম্পূর্ণ — sync pipe-এ অপেক্ষা, তারপর ধাপ ৪-৬-এর কাজ, তারপর hostname, তারপর execve():
static int child_func(void *arg) {
struct child_args *a = (struct child_args *)arg;
char buf;
close(a->sync_pipe[1]);
/* parent uid_map/gid_map/cgroup setup শেষ না করা পর্যন্ত আটকে থাকা --
নাহলে child pivot_root/mount চালানোর চেষ্টা করবে এমন এক user
namespace-এ যার mapping তখনো লেখা হয়নি (write() EPERM দেবে) */
read(a->sync_pipe[0], &buf, 1);
close(a->sync_pipe[0]);
do_pivot_root(a->rootfs);
setup_mounts();
/* UTS namespace ইতিমধ্যে clone() flag-এ সক্রিয় -- এই কল শুধু এই
namespace-এর hostname বদলায়, parent namespace অপ্রভাবিত */
if (sethostname(a->hostname, strlen(a->hostname)) != 0) {
perror("sethostname"); exit(1);
}
execvp(a->argv[0], a->argv);
perror("execvp"); /* এখানে পৌঁছালে মানে execve() ব্যর্থ হয়েছে */
exit(1);
}
execvp() সফল হলে এই function-এর বাকি কোনো লাইনই আর চলবে না — container-runtime-oci লেসনের সেই একই সত্য এখানেও: minidocker-এর child প্রসেস-image পুরোপুরি container-এর entrypoint দিয়ে প্রতিস্থাপিত হয়ে যায়, execvp()-এর পরের perror/exit লাইন কেবল ব্যর্থতার path।
Checkpoint (প্রকৃত Linux-এ প্রত্যাশিত): container-এর ভেতর hostname কমান্ড দিলে কাস্টম নামটা দেখাবে; একই সময়ে host-এর আরেকটা terminal-এ hostname চালালে সম্পূর্ণ অপ্রভাবিত, আগের মতোই।
৮. CLI wrapper — ./minidocker run --mem=100m --cpu=0.5 <rootfs> <cmd>
এখন সব ধাপ main()-এ জোড়া লাগানো — argument parsing, clone(), তারপর parent-এর কাজ (id map, cgroup), তারপর child-কে ছেড়ে দেওয়া, তারপর waitpid():
static unsigned long long parse_mem(const char *s) {
char unit = s[strlen(s) - 1];
unsigned long long n = strtoull(s, NULL, 10);
if (unit == 'm' || unit == 'M') return n * 1024ULL * 1024ULL;
if (unit == 'g' || unit == 'G') return n * 1024ULL * 1024ULL * 1024ULL;
return n; /* সংখ্যাই বাইট ধরে নেওয়া */
}
int main(int argc, char *argv[]) {
if (argc < 4 || strcmp(argv[1], "run") != 0) {
fprintf(stderr, "usage: %s run [--mem=100m] [--cpu=0.5] rootfs cmd [args...]\n", argv[0]);
return 1;
}
char *mem_str = NULL, *cpu_frac = NULL;
int i = 2;
for (; i < argc && argv[i][0] == '-'; i++) {
if (strncmp(argv[i], "--mem=", 6) == 0) mem_str = argv[i] + 6;
else if (strncmp(argv[i], "--cpu=", 6) == 0) cpu_frac = argv[i] + 6;
}
if (i + 1 >= argc) { fprintf(stderr, "rootfs আর cmd দুটোই দরকার\n"); return 1; }
char *rootfs = argv[i++];
char **cmd_argv = &argv[i];
char mem_limit[32] = {0}, cpu_limit[32] = {0};
if (mem_str) snprintf(mem_limit, sizeof(mem_limit), "%llu", parse_mem(mem_str));
if (cpu_frac) {
double frac = atof(cpu_frac);
snprintf(cpu_limit, sizeof(cpu_limit), "%d 100000", (int)(frac * 100000));
}
struct child_args args = {
.argv = cmd_argv,
.rootfs = rootfs,
.hostname = "minidocker",
};
if (pipe(args.sync_pipe) != 0) { perror("pipe"); return 1; }
pid_t pid = clone(child_func, child_stack + STACK_SIZE,
CONTAINER_FLAGS | SIGCHLD, &args);
if (pid == -1) { perror("clone"); return 1; }
close(args.sync_pipe[0]);
setup_id_map(pid);
setup_cgroup(pid, mem_str ? mem_limit : NULL,
cpu_frac ? cpu_limit : NULL, "64");
write(args.sync_pipe[1], "x", 1); /* child-কে ছেড়ে দেওয়া */
close(args.sync_pipe[1]);
int status;
waitpid(pid, &status, 0);
return WIFEXITED(status) ? WEXITSTATUS(status) : 128 + WTERMSIG(status);
}
exit code গণনায় 128 + সিগনাল-নম্বর কনভেনশনটা bash-এর নিজস্ব — একটা SIGKILL (৯) দিয়ে মারা process-এর exit code তাই 137, যা ধাপ ৯-এর OOM-kill demonstration-এ কাজে লাগবে।
Checkpoint: gcc -o minidocker minidocker.c কোনো warning ছাড়া কম্পাইল হবে; sudo ./minidocker run --mem=100m --cpu=0.5 ./rootfs /bin/sh চালালে একটা নতুন shell prompt পাওয়া যাবে — container-এর ভেতরে।
৯. Testing ও verification
তিনটা পৃথক পরীক্ষা, প্রতিটাই এই প্রজেক্টের কোনো-না-কোনো namespace/cgroup দাবির সরাসরি প্রমাণ। নিচের সবকিছুই প্রকৃত Linux-এ প্রত্যাশিত আচরণ — এই macOS sandbox-এ কিছুই চালানো বা যাচাই করা হয়নি।
১. PID isolation — ভেতর বনাম বাইরের ps:
# container-এর ভেতরে (Alpine rootfs-এ busybox ps থাকে)
sudo ./minidocker run ./rootfs /bin/sh
/ # ps aux
PID USER TIME COMMAND
1 root 0:00 /bin/sh
2 root 0:00 ps aux
একই মুহূর্তে হোস্টের আরেকটা terminal থেকে:
ps aux | grep minidocker
root 48213 0.0 0.0 ... ./minidocker run ./rootfs /bin/sh
root 48214 0.0 0.0 ... /bin/sh
container-এর ভেতরে 1/2, host-এ 48213/48214 — একই দুইটা process, দুইটা সম্পূর্ণ ভিন্ন PID namespace-এর দৃষ্টিভঙ্গি।
২. Hostname isolation:
/ # hostname
minidocker
Host-এর terminal-এ hostname সেই মুহূর্তেও নিজের আগের নাম দেখাবে, সম্পূর্ণ অপ্রভাবিত।
৩. Memory-limit OOM-kill demonstration — memory.max সত্যিই প্রয়োগ হয় তার সবচেয়ে নাটকীয় প্রমাণ। rootfs-এর ভেতরে (বা host-এ static কম্পাইল করে rootfs-এ কপি করে) একটা মেমরি-খেকো প্রোগ্রাম:
/* oom.c -- rootfs/oom নামে static কম্পাইল করে রাখুন:
gcc -static -O0 -o rootfs/oom oom.c */
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
int main(void) {
size_t chunk = 10 * 1024 * 1024; /* ১০ MB প্রতি ধাপ */
size_t total = 0;
while (1) {
char *p = malloc(chunk);
if (!p) { printf("malloc failed at %zu MB\n", total / 1024 / 1024); break; }
memset(p, 1, chunk); /* শুধু malloc যথেষ্ট না -- memset পেজ actually commit করায় */
total += chunk;
printf("allocated %zu MB\n", total / 1024 / 1024);
}
return 0;
}
sudo ./minidocker run --mem=100m --cpu=0.5 ./rootfs /oom
প্রত্যাশিত output (memory.max=100M ধরে):
allocated 10 MB
allocated 20 MB
allocated 30 MB
allocated 40 MB
allocated 50 MB
allocated 60 MB
allocated 70 MB
allocated 80 MB
allocated 90 MB
Killed
শেষ লাইন Killed — shell-এর নিজস্ব বার্তা যখন তার child SIGKILL পেয়ে মারা যায়। echo $? চালালে 137 (128 + 9) দেখাবে। আর একই মুহূর্তে host থেকে:
cat /sys/fs/cgroup/minidocker.PID/memory.events
low 0
high 12
max 1
oom 1
oom_kill 1
oom_kill 1 — cgroups-v2-resource-control লেসনের ঠিক এই একই ফাইল, এখন নিজের হাতে বানানো runtime দিয়ে ট্রিগার করা।
নিজেকে চ্যালেঞ্জ করুন
- Network namespace + veth pair —
CLONE_NEWNETযোগ করে (তখন combined flag ঠিক0x7C020000), host-এ একটা veth pair বানিয়ে এক প্রান্ত container-এর namespace-এ, আরেক প্রান্ত একটা bridge-এ যুক্ত করুন — module-এর পরেরcontainer-networkingলেসনের mechanics অনুসরণ করে, যাতে container-এর ভেতর থেকে সত্যিই ইন্টারনেটে পৌঁছানো যায়। - OCI-spec config.json reader — হার্ডকোড করা
--mem/--cpuCLI flag-এর বদলে একটা real OCI Runtime Spec-শৈলীরconfig.jsonপার্স করুন (namespaces list, uidMappings, resources, process.args) —container-runtime-ociলেসনেরMiniRuntimesimulation-এর যুক্তিটাই এখন সত্যিকার syscall চালিয়ে বাস্তবায়ন করুন। - overlayfs rootfs — একটা প্লেইন directory-র বদলে একাধিক read-only layer directory আর একটা writable upper layer
OverlayFSদিয়ে merge করে rootfs বানান,overlay-filesystems-image-layersলেসনের layer model অনুসরণ করে — এখন দুইটা container একই read-only layer share করতে পারবে, কেউ কারো লেখা প্রভাবিত না করে। minidocker exec— একটা চলমান container-এর namespace-এ নতুন একটা process ঢোকানোর কমান্ড লিখুন:/proc/PID/ns/pid,/proc/PID/ns/mnt, ইত্যাদির fd খুলেsetns()কল করে, তারপর নতুন process spawn করুন — এটাইdocker exec-এর মূল mechanics।- Rootless, বহু-রেঞ্জ UID mapping — এখনকার এক-লাইনের
"0 UID 1"mapping-এর বদলেlinux-namespaces-deep-diveলেসনের দুই-রেঞ্জ উদাহরণ (0 UID 1+1 100000 65536,/etc/subuid-ভিত্তিক) বাস্তবায়ন করে সম্পূর্ণsudo-বিহীন একটা rootless mode বানান। - Nested/delegated cgroup — container-এর ভেতর থেকেই নিজের sub-cgroup তৈরি করার ক্ষমতা দিন (
cgroup.subtree_controldelegation), যাতে container-এর ভেতরে চলা একটা প্রসেস-ম্যানেজার নিজের child process-দের আরও সূক্ষ্মভাবে ভাগ করতে পারে, parent-এর সীমা কখনো ছাড়িয়ে না গিয়ে।
যা এখানে শিখলেন, পরে যেখানে লাগবে
| এখানে | পরে |
|---|---|
clone()-এর exact namespace flag হাতে-কলমে | runc/containerd-এর সোর্স কোড (Go-তে হলেও) সরাসরি পড়া ও বোঝা |
uid_map/setgroups deny mechanics | rootless Podman বা Kubernetes-এর user-namespace-related সমস্যা debug করা |
pivot_root বনাম chroot-এর নিরাপত্তা যুক্তি | container escape CVE বিশ্লেষণ, gVisor/Kata-জাতীয় sandboxing runtime বোঝা |
| cgroup v2 limit হাতে লেখা ও verify করা | Kubernetes-এর resource requests/limits, QoS class, আর OOMKilled পড-এর মূল কারণ ধরা |
| নিজের হাতে ঘটানো OOM-kill | Production-এ একটা container exit code 137 দেখলে সঙ্গে সঙ্গে বুঝে ফেলা কী ঘটেছে, কেন |