Nội dung bài viết
Hi các bạn 👋. Mình là Hùng Anh.
Mình bắt đầu một series mới ở đây, tên là Kubernetes Fundamentals. Thay vì nhảy thẳng vào kubectl apply và một đống YAML, mình muốn đi theo đúng trình tự mà công nghệ này đã thực sự phát triển: bắt đầu từ 1 máy vật lý duy nhất, gặp giới hạn, giải quyết bằng máy ảo, lại gặp giới hạn mới, giải quyết bằng container, và cuối cùng gặp đúng bài toán mà Kubernetes sinh ra để giải quyết. Mỗi bài viết chỉ giả định bạn biết đúng những gì đã xuất hiện tính đến thời điểm đó trong câu chuyện — không "spoil" trước khái niệm của các bài sau.
Và câu chuyện phải bắt đầu từ trước cả "máy vật lý". Nó bắt đầu từ thứ mà mọi giải pháp phía sau đều được xây trên nền của nó: Linux kernel. Nếu bạn chưa từng thực sự ngồi nghĩ kỹ kernel làm gì, driver là gì, hay người ta nói một tiến trình bị "sandbox" nghĩa là sao, bài này dành cho bạn.
Bắt đầu thôi.
1. Linux thực chất là gì?
Khi mọi người nói "Linux", họ thường muốn nói đến 1 trong 2 thứ: cả một bản phân phối hệ điều hành (distro) như Ubuntu, Debian, Fedora..., hoặc chỉ riêng phần kernel. Trong bài này, mình dùng theo nghĩa thứ hai.
Kernel là 1 lớp phần mềm nằm giữa phần cứng (CPU, RAM, ổ đĩa, card mạng) và mọi ứng dụng bạn chạy.
Đây là phần dễ gây bối rối nhất khi mới nghe lần đầu: ứng dụng không bao giờ được phép chạm thẳng vào phần cứng. Trình duyệt của bạn không được phép động vào bộ điều khiển ổ đĩa, và kubectl cũng không được phép ghi thẳng vào thanh ghi của card mạng. Thay vào đó, mọi thao tác như đọc file, cấp phát bộ nhớ, mở socket, tạo tiến trình mới... đều phải đi qua system call (syscall).
Syscall đơn giản là 1 "cửa chính" được kernel định nghĩa sẵn cho ứng dụng gọi vào. open(), read(), write(), fork(), socket() — tất cả đều là syscall. Khi ứng dụng của bạn gọi 1 trong số này:
- Luồng thực thi chuyển từ user mode (nơi ứng dụng của bạn chạy, quyền hạn bị giới hạn) sang kernel mode (nơi kernel chạy, có toàn quyền truy cập phần cứng).
- Kernel kiểm tra xem tiến trình gọi có được phép làm việc này không (quyền, quota, hay bất kỳ policy nào áp dụng).
- Nếu được phép, chính kernel là bên thực sự "nói chuyện" với CPU scheduler, bộ quản lý bộ nhớ, hay ổ đĩa — không phải ứng dụng của bạn.
- Luồng thực thi quay lại user mode kèm theo kết quả.
Bạn có thể quan sát điều này trực tiếp. Trên bất kỳ máy Linux nào, chạy thử:
strace -c ls > /dev/null
strace chặn lại từng syscall mà 1 chương trình thực hiện và in ra bảng tổng hợp. Ngay cả 1 lệnh đơn giản như ls cũng phải gọi hàng chục syscall (openat, read, close, mmap...) trước khi in ra được 1 cái tên file. Không có chuyện ls tự "chọc" vào filesystem — mỗi lời gọi đó đều là ls đang nhờ kernel làm hộ.
Quy tắc duy nhất này — ứng dụng yêu cầu, kernel quyết định và thực thi — chính là nền tảng cho mọi thứ còn lại trong bài viết này (và cả series).
2. Kernel vs. Driver
Bản thân kernel không tự biết cách "nói chuyện" với con chip WiFi cụ thể, con GPU cụ thể, hay ổ NVMe cụ thể mà bạn đang dùng. Có hàng nghìn hãng phần cứng với hàng chục nghìn model khác nhau. Vì vậy kernel không cố hardcode sẵn hỗ trợ cho từng con chip — thay vào đó, mỗi loại phần cứng có 1 module nhỏ riêng gọi là driver, với nhiệm vụ duy nhất là dịch các lệnh chung của kernel thành đúng chỉ thị mà con chip cụ thể đó hiểu được.
Không có driver cho 1 thiết bị, kernel đơn giản là không biết cách nói chuyện với nó — thiết bị đó coi như không tồn tại.
Đây cũng chính là nguồn gốc của sự khác biệt nổi tiếng giữa "cài Windows" và "cài Linux":
- Kernel của Windows là closed-source. Các hãng phần cứng phải tự đóng gói driver riêng và phát hành tách biệt, qua Windows Update hoặc trình cài đặt riêng của hãng. Đó là lý do khi cài máy Windows mới, bạn thường phải tải thêm bộ cài driver.
- Linux kernel là open-source, theo giấy phép GPL. Đại đa số hãng phần cứng đóng góp driver thẳng vào mã nguồn chính (mainline) của kernel. Đó là lý do khi cài 1 distro như Ubuntu lên 1 chiếc laptop bất kỳ, WiFi và touchpad thường "chạy luôn" ngay không cần làm gì thêm — bản kernel bạn tải về đã sẵn mang theo hàng nghìn driver.
Có 1 ngoại lệ đáng chú ý: những driver mà hãng muốn giữ closed-source — nổi tiếng nhất là driver GPU độc quyền của NVIDIA, cùng vài driver chip WiFi/Bluetooth — không thể được merge vào mainline kernel, vì GPL yêu cầu mã nguồn phải mở. NVIDIA những năm gần đây đã dần mở mã nguồn 1 phần module kernel của mình, nhưng khá nhiều phần khác trong stack (driver userspace cho OpenGL/Vulkan/CUDA) vẫn còn closed-source — đó chính là lý do bạn nhiều khi vẫn phải tự chạy apt install nvidia-driver-xxx thay vì nó có sẵn từ đầu.
3. Sandbox: cơ chế cô lập do kernel cung cấp
Có 1 câu hỏi trở nên rất quan trọng ngay khi nhiều tiến trình cùng chạy trên 1 máy: làm sao kernel ngăn được tiến trình này đọc bộ nhớ của tiến trình khác, thấy được file của tiến trình khác, hay chiếm hết CPU khiến những tiến trình còn lại bị đói tài nguyên?
Câu trả lời chung là: sandbox. Sandbox là 1 môi trường thực thi bị cô lập — code chạy bên trong nó bị giới hạn, không thể đọc, ghi hay can thiệp vào hệ thống host thật (hay các sandbox khác) xung quanh nó.
"Sandbox" không phải là 1 công nghệ cụ thể — nó là 1 khái niệm, và Linux cung cấp sẵn các viên gạch cấp thấp để bạn tự dựng nên 1 sandbox. Hai viên gạch quan trọng nhất, sẽ còn xuất hiện xuyên suốt series này, là:
- Namespace — giới hạn những gì 1 tiến trình có thể thấy. 1 tiến trình trong PID namespace chỉ thấy các tiến trình khác nằm trong cùng namespace đó; 1 tiến trình trong network namespace chỉ thấy network interface, route, firewall rule của riêng nó.
- cgroups (control groups) — giới hạn những gì 1 tiến trình được phép dùng. Bao nhiêu CPU, bao nhiêu RAM, bao nhiêu disk I/O mà 1 nhóm tiến trình được phép tiêu tốn.
Hãy ghi nhớ khái niệm này, vì 2 bài tiếp theo trong series thực chất đều chỉ là các "mức độ sandbox" khác nhau: máy ảo (VM) sandbox nguyên cả 1 hệ điều hành, còn container sandbox ở mức 1 tiến trình đơn lẻ, dùng đúng 2 cơ chế ở trên. Nhưng chưa vội — phần còn lại của bài này, hãy tự tay dùng thử namespace và cgroups, không cần gì khác.
4. Thực hành: tự tay dựng 1 sandbox
Mọi thứ bên dưới chạy được trên bất kỳ máy Linux đời mới nào (1 VM trên cloud, WSL2, hoặc 1 VM dùng-rồi-xoá nếu bạn không muốn thử nghiệm trên máy chính). Bạn sẽ cần quyền sudo.
4.1. Namespace: thay đổi những gì 1 tiến trình có thể thấy
Mọi tiến trình đang chạy đều gắn với 1 tập namespace. Bạn có thể xem ngay bây giờ:
ls -la /proc/self/ns/
lrwxrwxrwx 1 root root 0 Aug 16 10:00 mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 root root 0 Aug 16 10:00 net -> 'net:[4026531840]'
lrwxrwxrwx 1 root root 0 Aug 16 10:00 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Aug 16 10:00 uts -> 'uts:[4026531838]'
...
Mỗi dòng là 1 symlink trỏ tới 1 mã inode xác định instance namespace mà shell hiện tại của bạn đang thuộc về. Hai tiến trình cùng 1 namespace sẽ có cùng mã inode; hai tiến trình ở 2 namespace khác nhau sẽ có mã inode khác nhau. Mã inode đó chính là sổ sách nội bộ của kernel để trả lời câu "ai được thấy cái gì".
Giờ hãy tạo 1 tiến trình với PID, mount, và UTS (hostname) namespace của riêng nó, dùng lệnh unshare:
sudo unshare --pid --mount --uts --fork --mount-proc bash
Bên trong shell mới này, chạy:
echo $$
ps aux
Để ý $$ (PID của shell bạn) là 1 số rất nhỏ — thường là 1. Đây không phải trùng hợp: bên trong 1 PID namespace mới tinh, tiến trình đầu tiên chạy sẽ trở thành PID 1 của namespace đó, và ps aux chỉ hiển thị đúng các tiến trình nằm trong namespace này — mọi tiến trình khác trên host thật đều vô hình từ đây, dù chúng vẫn đang chạy ngay bên cạnh.
Thử đổi cả hostname xem sao:
hostname sandbox-demo
hostname
Mở 1 terminal thứ hai trên cùng máy (bên ngoài shell unshare) và chạy hostname ở đó — không hề bị ảnh hưởng. Bạn vừa đổi hostname của 1 UTS namespace bị cô lập, chứ không phải của máy thật.
Gõ exit để thoát khỏi namespace và quay lại góc nhìn của host thật.
Đây chính là bản chất của namespace: cùng 1 kernel, cùng 1 máy, nhưng 1 góc nhìn bị giới hạn có chủ đích.
4.2. cgroups: giới hạn những gì 1 tiến trình được dùng
Namespace kiểm soát tầm nhìn; cgroups kiểm soát mức tiêu thụ tài nguyên. Trên các distro đời mới, cgroup v2 được mount tại /sys/fs/cgroup:
mount | grep cgroup2
Hãy tạo 1 cgroup và giới hạn bộ nhớ của nó:
sudo mkdir /sys/fs/cgroup/demo
echo "50M" | sudo tee /sys/fs/cgroup/demo/memory.max
Giờ đưa shell hiện tại của bạn vào cgroup đó:
echo $$ | sudo tee /sys/fs/cgroup/demo/cgroup.procs
Từ thời điểm này, shell của bạn — và mọi tiến trình con của nó — bị giới hạn tối đa 50MB bộ nhớ. Hãy thử cố tình phá vỡ giới hạn đó:
python3 -c "x = bytearray(200 * 1024 * 1024)"
Killed
Ngay khi tiến trình cố vượt qua ngưỡng 50MB, cơ chế out-of-memory killer của kernel can thiệp và giết tiến trình đó — trước khi nó kịp chạm vào bộ nhớ thuộc về ai khác trên máy. Bạn có thể kiểm chứng đúng điều này đã xảy ra:
cat /sys/fs/cgroup/demo/memory.events
low 0
high 0
max 3
oom 1
oom_kill 1
oom_kill 1 chính là bộ đếm của cgroup xác nhận nó đã giết 1 tiến trình cố vượt giới hạn. Dọn dẹp lại khi xong:
sudo rmdir /sys/fs/cgroup/demo
Chỉ với unshare và vài dòng ghi vào /sys/fs/cgroup, bạn vừa tự tay dựng lên đúng 2 cơ chế nền tảng mà sau này các container runtime sẽ tự động hoá giúp bạn: namespace quyết định 1 tiến trình được thấy gì, cgroups quyết định nó được dùng gì. Từ Docker, containerd, cho đến chính Kubernetes, phía sau rất nhiều lớp tooling, vẫn chỉ đang gọi đúng những cơ chế kernel này.
5. Bài tiếp theo sẽ đi đâu
Giờ chúng ta đã có 3 mảnh ghép: kernel nói chuyện với phần cứng qua driver, ứng dụng nói chuyện với kernel qua syscall, và kernel có thể sandbox tiến trình này khỏi tiến trình khác bằng namespace và cgroups.
Tất cả những điều trên chưa cần đến hơn 1 máy. Nhưng 1 máy vật lý duy nhất — 1 kernel, gắn thẳng vào 1 bộ phần cứng — có những giới hạn rất thật: bạn không thể dễ dàng chạy 2 hệ điều hành khác nhau trên đó, tài nguyên của nó bị "đóng cứng" theo đúng những gì đã cài dù đang rảnh, và việc scale nghĩa là phải mua, lắp, đấu dây thêm 1 máy vật lý khác.
Đó chính là bức tường mà bài tiếp theo trong series sẽ giải quyết: ảo hoá giải quyết nó như thế nào bằng cách dùng 1 hypervisor để chạy nhiều hệ điều hành hoàn toàn độc lập trên cùng 1 phần cứng vật lý đó — và bức tường mới mà chính điều này lại tạo ra, thứ cuối cùng dẫn tới container.
Hẹn gặp lại các bạn ở bài viết tiếp theo.
Happy reading! 🍻
Tài liệu tham khảo
- namespaces(7) - Linux manual page
- unshare(1) - Linux manual page
- Control Groups v2 - The Linux Kernel documentation
- What even is a container: namespaces and cgroups - Julia Evans
- Complete Beginner's Guide to Linux Namespaces - Linux Handbook
Bài viết liên quan
CronJob & Cron Expressions
CronJob là một công cụ quan trọng giúp lập trình viên tự động hóa các tác vụ theo lịch trình định kỳ. Tuy nhiên, để thiết lập CronJob một cách chính xác, bạn cần nắm vững Cron Expression – biểu thức giúp xác định lịch trình cho các công việc tự động. Bài viết này sẽ giới thiệu về CronJob, cách xây dựng Cron Expression, và các công cụ hữu ích để tạo biểu thức cron một cách dễ dàng.
Top 10 Câu Hỏi Phỏng Vấn Về Java Core
Kiến thức nền tảng là thứ mà nhà tuyển dụng luôn kiểm tra, dù bạn là fresher hay senior. Bài viết tổng hợp 10 câu hỏi phỏng vấn Java Core thường gặp nhất, kèm gợi ý trả lời và phần giải thích chi tiết để bạn ôn tập trước buổi phỏng vấn.