Chương 14 — Ảo hóa & Container
Tổng quan
Một máy chủ vật lý chạy cùng lúc nhiều thứ không liên quan gì tới nhau là chuyện bình thường — và toàn bộ an toàn của cách sắp xếp đó nằm ở một câu hỏi: cái ranh giới ngăn chúng với nhau được dựng bằng gì, và nó vỡ ở đâu. Vì khi kẻ tấn công chiếm được một workload rồi escape ra ngoài, cả host lẫn mọi workload còn lại đều mất theo. Chương này đi qua hai mô hình cô lập — ảo hóa và container — dưới đúng góc nhìn đó.
Ảo hóa dựng ranh giới dày: mỗi VM có kernel riêng, và hypervisor đứng giữa phân chia phần cứng thật rồi cưỡng chế cô lập. Phần này đi qua Proxmox VE (mã nguồn mở, nền KVM) và VMware ESXi/vSphere, cùng những thứ làm nên giá trị vận hành của chúng: snapshot để rollback, backup, và live migration chuyển VM đang chạy sang host khác.
Container thì ngược lại — ranh giới mỏng hơn hẳn, vì mọi container dùng chung kernel của host. Đổi lại nó khởi động trong mili giây và gần như không tốn tài nguyên thừa. Cái gọi là "cô lập" ở đây thực chất là hai cơ chế kernel ghép lại: namespaces giới hạn container thấy được gì (PID, mạng, mount, hostname), còn cgroups giới hạn nó dùng được bao nhiêu (RAM, CPU, số PID) để một container không vét cạn cả máy. Kèm theo là mô hình image/layer/registry và Dockerfile, rồi Trivy để quét image tìm CVE, misconfig và secret hardcode ngay trong pipeline.
Từ chỗ "ranh giới mỏng" đó, phần sau đi thẳng vào container escape: --privileged, mount nhầm docker.sock, cấp dư capability — biết đường thoát thì mới bịt được. Quy mô lớn hơn là Kubernetes với bốn điểm bảo mật trọng yếu: RBAC, NetworkPolicy (firewall L3/L4 giữa các Pod), Secret (mặc định chỉ base64 chứ chưa phải mã hóa — phải bật encryption-at-rest), và Pod Security Standards. Cuối cùng là Falco — lớp phát hiện lúc đang chạy, giám sát syscall và kêu lên khi có hành vi lạ như mở shell trong container hay đọc /etc/shadow, thường đẩy cảnh báo về SIEM.
> Mỗi mục đi theo trình tự: là gì → cơ chế bên trong (tới mức tham số/bước cụ thể) → ví dụ thực tế → lưu ý bảo mật.
14.1. Nền tảng ảo hóa: vì sao và cô lập ở đâu
14.1.1. Bài toán gốc — đặc quyền CPU (protection rings)
CPU x86/x86-64 cung cấp bốn mức đặc quyền (privilege rings), được mã hóa trong 2 bit CPL (Current Privilege Level) nằm ở 2 bit thấp của thanh ghi đoạn CS:
| Ring | CPL (2 bit) | Vai trò truyền thống |
|---|---|---|
| 0 | 00 |
Kernel — toàn quyền lệnh đặc quyền (HLT, LGDT, MOV CR3, IN/OUT...) |
| 1 | 01 |
Hầu như không dùng (một số driver cũ) |
| 2 | 10 |
Hầu như không dùng |
| 3 | 11 |
User space — không được chạy lệnh đặc quyền |
Vấn đề ảo hóa cổ điển: một guest OS được viết để chạy ở Ring 0 (nó nghĩ nó sở hữu phần cứng). Nếu để guest chạy thật ở Ring 0 thì nó chiếm máy. Nếu hạ guest xuống Ring 1/3 thì các lệnh đặc quyền của nó phải bị "bẫy" (trap) để hypervisor xử lý thay. x86 nguyên thủy KHÔNG đáp ứng tiêu chí Popek–Goldberg vì có một nhóm lệnh "nhạy cảm nhưng không trap" (sensitive but unprivileged) — ví dụ POPF khi ở Ring 3 sẽ âm thầm bỏ qua việc ghi cờ IF thay vì gây fault. Đây là lý do ra đời ba kỹ thuật: binary translation, paravirtualization, và hardware-assisted (VT-x/AMD-V).
14.1.2. Hypervisor Type 1 vs Type 2
| Tiêu chí | Type 1 (bare-metal) | Type 2 (hosted) |
|---|---|---|
| Vị trí | Chạy trực tiếp trên phần cứng, là kernel | Chạy như tiến trình trên OS host |
| Ví dụ | VMware ESXi, Proxmox VE (KVM), Microsoft Hyper-V, Xen | VMware Workstation/Player, VirtualBox, QEMU thuần |
| Lập lịch CPU | Hypervisor tự lập lịch vCPU lên pCPU | Dựa vào scheduler của host OS |
| Overhead | Thấp (đường truy cập phần cứng ngắn) | Cao hơn (qua một lớp OS) |
| Bề mặt tấn công | Hypervisor mỏng (vài MB tới vài chục MB) | Cả host OS đầy đủ → bề mặt lớn |
| Dùng cho | Datacenter, production | Lab, dev trên laptop |
Lưu ý KVM là trường hợp lai: KVM là một module kernel Linux (kvm.ko + kvm-intel.ko/kvm-amd.ko) biến chính Linux thành hypervisor Type 1. Proxmox VE đóng gói Linux + KVM + QEMU + LXC. Vì vậy "Proxmox = Type 1" theo nghĩa kernel host trở thành hypervisor, dù bản thân nó là một bản Debian đầy đủ.
14.1.3. Hardware-assisted virtualization: VT-x / AMD-V và VMCS
Lưu ý: phần này đào sâu xuống mức phần cứng. Nếu bạn chỉ cần bức tranh lớn, đọc lướt và chuyển sang 14.2.
Intel VT-x thêm hai chế độ vận hành mới (không phải ring mới):
- VMX root mode: nơi hypervisor (VMM) chạy. Có thêm các lệnh
VMXON,VMLAUNCH,VMRESUME,VMEXIT,VMREAD,VMWRITE,VMPTRLD. - VMX non-root mode: nơi guest chạy. Guest vẫn thấy đủ Ring 0–3 của riêng nó, nhưng mọi sự kiện "nhạy cảm" gây VM-exit chuyển điều khiển về root mode.
Trạng thái chuyển đổi được lưu trong cấu trúc VMCS (Virtual Machine Control Structure) — một vùng 4KB căn theo trang, gồm 6 vùng logic:
| Vùng VMCS | Nội dung chính |
|---|---|
| Guest-state area | Trạng thái guest được lưu khi VM-exit, nạp lại khi VM-entry (RIP, RSP, CR0/CR3/CR4, segment, RFLAGS...) |
| Host-state area | Trạng thái host nạp lại khi VM-exit |
| VM-execution control | Bitmap quy định lệnh/sự kiện nào gây exit (ví dụ bit "HLT exiting", "use I/O bitmaps", "enable EPT") |
| VM-exit control | Hành vi khi exit (lưu/khôi phục MSR...) |
| VM-entry control | Hành vi khi entry (tiêm sự kiện/interrupt vào guest) |
| VM-exit information | Lý do exit (exit reason, exit qualification) — hypervisor đọc để biết tại sao |
Khi VM-exit xảy ra, CPU ghi mã lý do (Basic Exit Reason — 16 bit) vào VMCS. Ví dụ exit reason 0 = NMI, 1 = external interrupt, 12 = HLT, 30 = I/O instruction, 48 = EPT violation. Hypervisor đọc bằng VMREAD để dispatch xử lý.
14.1.4. Ảo hóa bộ nhớ: shadow page tables vs EPT/NPT
Lưu ý: phần này tiếp tục đào sâu xuống cơ chế MMU. Nếu chỉ cần bức tranh lớn, đọc lướt và chuyển sang 14.2.
Guest có bảng phân trang riêng dịch GVA → GPA (Guest Virtual → Guest Physical). Nhưng GPA không phải địa chỉ RAM thật (HPA — Host Physical Address). Cần thêm một lớp dịch GPA → HPA.
Cách cũ (shadow page tables): hypervisor duy trì một bảng "bóng" dịch thẳng GVA → HPA, đồng bộ mỗi khi guest sửa bảng trang của nó (bắt write-fault vào page table) → rất tốn kém.
Cách mới — EPT (Intel Extended Page Tables) / NPT/RVI (AMD Nested Page Tables): phần cứng làm dịch hai cấp. MMU đi qua bảng trang guest (GVA→GPA) rồi qua bảng EPT (GPA→HPA) hoàn toàn bằng phần cứng. Bảng EPT là cấu trúc 4 cấp (PML4 → PDPT → PD → PT), mỗi mục 64 bit.
Cấu trúc một EPT entry (PTE cấp lá) 64 bit (mô tả các bit quan trọng):
| Bit | Kích thước | Ý nghĩa | Ví dụ |
|---|---|---|---|
| 0 | 1 bit | Read access cho phép | 1 = guest đọc được GPA này |
| 1 | 1 bit | Write access | 1 = guest ghi được |
| 2 | 1 bit | Execute access | 0 = không thực thi (chống chạy code) |
| 3–5 | 3 bit | EPT memory type (giống PAT: WB=6, UC=0) | 6 = write-back |
| 6 | 1 bit | Ignore PAT | 0 |
| 7 | 1 bit | (cấp PD/PDPT) large page | 1 = trang 2MB/1GB |
| 12–51 | 40 bit | Physical frame number (HPA >> 12) | địa chỉ trang host |
EPT violation (exit reason 48) xảy ra khi guest truy cập vi phạm quyền R/W/X trong EPT — đây là cơ chế nền cho cô lập bộ nhớ giữa các VM: VM A không có bất kỳ EPT entry nào trỏ vào RAM của VM B.
Lưu ý bảo mật: chính lớp dịch hai cấp này là biên giới cô lập phần cứng mạnh nhất giữa các VM. Các lỗ hổng nghiêm trọng (ví dụ họ tấn công kiểu Foreshadow/L1TF, hay lỗi VENOM CVE-2015-3456 ở floppy controller QEMU) đều nhắm vào việc phá vỡ ranh giới này. Vá microcode + vá hypervisor là bắt buộc.
14.1.5. Paravirtualization (PV) và virtio
Thay vì giả lập phần cứng đầy đủ (tốn VM-exit), PV sửa guest để nó "biết mình đang ảo hóa" và gọi thẳng hypervisor qua hypercall. Xen là đại diện kinh điển. Trong KVM, mô hình thực tế là virtio: guest dùng driver virtio-net, virtio-blk, virtio-scsi nói chuyện với host qua virtqueue — vùng nhớ chia sẻ dạng ring buffer gồm 3 phần:
Descriptor Table -> mảng phần tử {addr(64bit), len(32bit), flags(16bit), next(16bit)}
Available Ring -> guest đẩy index descriptor "đã sẵn sàng cho host"
Used Ring -> host đẩy index descriptor "đã xử lý xong"
Cơ chế này giảm số VM-exit (gom nhiều I/O, "notify" theo lô), nên throughput gần native. Đây là lý do máy ảo Linux trên KVM/Proxmox nên dùng disk bus virtio-scsi và NIC model virtio thay vì giả lập e1000/IDE.
Tới đây ta đã thấy ảo hóa dựng biên giới cô lập bằng phần cứng (VT-x/EPT) và một kernel riêng cho mỗi VM. Container chọn cách khác: chia sẻ kernel host. Phần 14.4 sẽ đặt hai mô hình cạnh nhau để thấy rõ đánh đổi cô lập; trước đó, 14.2–14.3 đi qua hai hypervisor thực tế là Proxmox và VMware.
14.2. Proxmox VE
14.2.1. Kiến trúc
Proxmox VE = Debian + kernel Ubuntu + KVM/QEMU (cho VM) + LXC (cho container hệ điều hành). Các thành phần cốt lõi:
- pve-cluster (
pmxcfs): một filesystem phân tán gắn ở/etc/pve, đồng bộ qua Corosync. Mọi file cấu hình (VM, ACL, storage) nằm ở đây và tự nhân bản giữa các node. - Corosync: lớp truyền thông cluster, dùng giao thức Totem (token ring ảo) qua UDP/multicast hoặc unicast. Quyết định quorum.
- pvedaemon / pveproxy: API REST (cổng 8006/TCP, HTTPS) và web UI.
- qemu-server, pve-container: quản lý VM và LXC.
14.2.2. File cấu hình VM thực tế
Mỗi VM có file /etc/pve/qemu-server/<vmid>.conf. Ví dụ thật:
# /etc/pve/qemu-server/100.conf
boot: order=scsi0;net0
cores: 4
cpu: host
memory: 8192
name: web-prod-01
net0: virtio=DE:AD:BE:EF:00:01,bridge=vmbr0,firewall=1
scsi0: local-lvm:vm-100-disk-0,size=40G,iothread=1
scsihw: virtio-scsi-single
machine: q35
bios: ovmf # UEFI thay vì SeaBIOS
efidisk0: local-lvm:vm-100-disk-1,size=4M
agent: 1 # bật QEMU guest agent
smbios1: uuid=...
Giải thích tham số quan trọng:
- cpu: host — phơi toàn bộ cờ CPU vật lý cho guest (nhanh nhất, nhưng cản trở live-migrate giữa CPU khác đời). Dùng cpu: x86-64-v2-AES nếu cần tương thích migrate.
- net0: ...,firewall=1 — bật firewall Proxmox cho NIC này (xem 14.2.4).
- iothread=1 — tách luồng I/O riêng cho disk, giảm chèn ép vCPU.
- machine: q35 — chipset PCIe hiện đại (cần cho passthrough).
14.2.3. Snapshot & backup
Proxmox snapshot phụ thuộc storage backend: - LVM-thin / ZFS / qcow2: hỗ trợ snapshot (copy-on-write). ZFS snapshot ở mức block, gần như tức thời. - LVM dày / raw trên iSCSI: KHÔNG snapshot được ở mức storage.
Lệnh thật:
# Tạo snapshot có lưu RAM (vmstate) để rollback về trạng thái đang chạy
qm snapshot 100 before-upgrade --vmstate 1 --description "Trước khi vá kernel"
# Liệt kê
qm listsnapshot 100
# Rollback
qm rollback 100 before-upgrade
# Backup nén zstd ra Proxmox Backup Server hoặc NFS
vzdump 100 --storage pbs-main --mode snapshot --compress zstd
--mode snapshot dùng cơ chế dirty bitmap của QEMU: chỉ các block thay đổi kể từ backup trước được gửi (incremental), giúp backup nhanh và nhỏ.
14.2.4. Proxmox Firewall — cơ chế
Firewall Proxmox là một lớp sinh ra rule iptables/nftables từ file cấu hình ở ba mức: datacenter (/etc/pve/firewall/cluster.fw), node (/etc/pve/nodes/<node>/host.fw), và VM (/etc/pve/firewall/<vmid>.fw). Ví dụ:
# /etc/pve/firewall/100.fw
[OPTIONS]
enable: 1
policy_in: DROP
[RULES]
IN SSH(ACCEPT) -source 10.0.0.0/24 -log nolog
IN ACCEPT -p tcp -dport 443 -source +web_clients
Lưu ý bảo mật: LXC container của Proxmox dùng chung kernel với host (không phải VM thật). Một LXC chạy privileged (
unprivileged: 0) có user root ánh xạ thẳng UID 0 trên host → escape rất nguy hiểm. Luôn ưu tiênunprivileged: 1(user namespace remap, root trong container = UID cao như 100000 trên host). Với workload không tin cậy, dùng VM (KVM) thay vì LXC.
14.3. VMware vSphere/ESXi
14.3.1. Kiến trúc ESXi
ESXi là hypervisor Type 1 với kernel độc quyền VMkernel (POSIX-like nhưng không phải Linux). Tiến trình quan trọng:
- VMM: một instance trên mỗi VM, dùng VT-x/EPT.
- VMX (vmx process): xử lý giả lập thiết bị, I/O không hiệu năng cao, kết nối console.
- hostd: agent quản lý local (API cho vSphere Client trực tiếp).
- vpxa: agent kết nối tới vCenter Server.
File định nghĩa VM là <vm>.vmx (text), disk là <vm>.vmdk (descriptor) + -flat.vmdk (dữ liệu) hoặc -sparse.
14.3.2. VMFS, snapshot delta disk
VMFS là filesystem cụm có khóa phân tán (SCSI reservations / ATS - Atomic Test and Set). Khi tạo snapshot, ESXi đóng băng disk gốc (read-only) và tạo delta disk (-000001.vmdk) ghi mọi thay đổi mới theo cơ chế redo-log. Chuỗi snapshot là một cây; mỗi delta trỏ về parent. Xóa snapshot = consolidate (merge delta vào base).
Lưu ý bảo mật / vận hành: delta disk phình to vô hạn nếu để snapshot lâu ngày → đầy datastore → toàn bộ VM trên datastore treo. Snapshot KHÔNG phải backup. Về bảo mật, các lỗ hổng nghiêm trọng của ESXi thường ở dịch vụ SLP (CVE-2021-21974 — ransomware ESXiArgs) và OpenSLP cổng 427; vô hiệu hóa SLP và đóng cổng quản lý là biện pháp cứng hóa cơ bản.
14.3.3. Cluster: HA, DRS, vMotion
- vMotion: di chuyển VM đang chạy giữa host. Cơ chế: copy trước toàn bộ RAM qua mạng, rồi lặp copy các trang "dirty" (pre-copy iterative), cuối cùng đóng băng vài chục ms để copy phần dirty còn lại và chuyển quyền. Cần mạng vMotion riêng (khuyến nghị mã hóa — Encrypted vMotion).
- HA (High Availability): nếu host chết, VM được khởi động lại trên host khác (không phải zero-downtime).
- DRS: cân bằng tải tự động bằng vMotion.
14.4. VM vs Container — so sánh tới gốc
Sơ đồ stack hai mô hình cho thấy khác biệt cốt lõi: VM nhân bản cả kernel + OS cho mỗi workload (cô lập bằng hypervisor/phần cứng), còn container chia sẻ một kernel host (cô lập bằng namespaces/cgroups của chính kernel đó).
VIRTUAL MACHINES CONTAINERS
+------+ +------+ +------+ +------+ +------+ +------+
| App | | App | | App | | App | | App | | App |
| Libs | | Libs | | Libs | | Libs | | Libs | | Libs |
+------+ +------+ +------+ +------+ +------+ +------+
|Guest | |Guest | |Guest | <- kernel | Container Runtime |
| OS | | OS | | OS | riêng | (containerd/CRI-O) |
+------+-+------+-+------+ mỗi VM +----------------------+
| Hypervisor | | Kernel host (CHUNG) | <- 1 kernel
+-----------------------+ +----------------------+ cho tất cả
| Host OS (Type 2) / | | Host OS |
| none (Type 1) | +----------------------+
+-----------------------+ | Phần cứng vật lý |
| Phần cứng vật lý | +----------------------+
+-----------------------+
Biên giới cô lập của VM nằm ở hypervisor + VT-x/EPT (mỗi VM một kernel độc lập); biên giới của container là namespaces + cgroups + capabilities trên một kernel dùng chung — mỏng hơn, bề mặt escape là toàn bộ syscall của kernel host.
| Tiêu chí | Virtual Machine | Container |
|---|---|---|
| Cô lập bằng | VT-x/EPT (phần cứng), kernel riêng mỗi VM | namespaces + cgroups + capabilities (kernel host CHUNG) |
| Kernel | Mỗi VM 1 kernel độc lập | Tất cả container dùng chung 1 kernel host |
| Khởi động | Giây → phút (boot OS) | Mili giây (chỉ là tiến trình + namespace) |
| Overhead RAM | Cao (full OS mỗi VM) | Rất thấp (chỉ binary + lib) |
| Bề mặt tấn công escape | Hypervisor (mỏng) | Toàn bộ kernel syscall → bề mặt lớn hơn |
| Ranh giới bảo mật | Mạnh (mặc định an toàn cho multi-tenant) | Yếu hơn — KHÔNG nên coi là biên giới bảo mật cho tenant không tin cậy nếu không có thêm gVisor/Kata |
Điểm mấu chốt bảo mật: container chia sẻ kernel. Một lỗ hổng kernel (ví dụ Dirty COW CVE-2016-5195, Dirty Pipe CVE-2022-0847) bị khai thác trong container có thể leo thẳng lên host. Đây là lý do "container không phải VM về mặt cô lập". Với workload không tin cậy, dùng gVisor (kernel user-space chặn syscall) hoặc Kata Containers (mỗi container một microVM KVM).
14.5. Docker internals
14.5.1. Namespaces — cô lập gì
Namespace là cơ chế kernel Linux cô lập một tài nguyên toàn cục sao cho tiến trình bên trong chỉ thấy view riêng. Mỗi namespace có một file ở /proc/<pid>/ns/. Có 8 loại (kernel hiện đại):
| Namespace | Cô lập | Container thấy gì khác host |
|---|---|---|
| PID | Cây tiến trình | Tiến trình đầu tiên trong container là PID 1; không thấy PID host |
| NET | Stack mạng | Interface, bảng định tuyến, iptables, cổng, /proc/net riêng |
| MNT | Mount point | Cây thư mục riêng (rootfs của image), không thấy mount host |
| UTS | hostname & domainname | hostname riêng (UTS = Unix Time-sharing System) |
| IPC | System V IPC, POSIX message queue | Shared memory / semaphore riêng |
| USER | UID/GID mapping | root (UID 0) trong container có thể map sang UID không-đặc-quyền trên host |
| CGROUP | View của cgroup hierarchy | Ẩn đường dẫn cgroup thật của host |
| TIME | CLOCK_MONOTONIC/BOOTTIME offset | (ít dùng) lệch đồng hồ boot |
Quan sát thực tế:
# Chạy container, xem các namespace của nó
docker run -d --name demo alpine sleep 3600
PID=$(docker inspect -f '{{.State.Pid}}' demo)
ls -l /proc/$PID/ns/
# lrwxrwxrwx ... net -> 'net:[4026532567]' <- inode namespace
# lrwxrwxrwx ... pid -> 'pid:[4026532569]'
# So sánh với host: số inode khác nhau => khác namespace
ls -l /proc/1/ns/net
# net -> 'net:[4026531840]' <- inode khác hẳn
# Tự tạo namespace bằng unshare (không cần Docker)
sudo unshare --pid --fork --mount-proc bash
# Trong shell mới: ps aux chỉ thấy vài tiến trình, bash là PID 1
Syscall nền: clone() với cờ CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWUSER | CLONE_NEWCGROUP; unshare() tách namespace cho tiến trình hiện tại; setns() gia nhập namespace có sẵn (đây chính là cái docker exec và nsenter dùng).
14.5.2. cgroups v1 vs v2 — giới hạn tài nguyên
cgroups (control groups) giới hạn và hạch toán tài nguyên (CPU, RAM, I/O, PID count). Namespace cô lập view, cgroups giới hạn lượng.
cgroups v1: mỗi controller (cpu, memory, blkio, pids...) là một cây phân cấp RIÊNG, gắn ở /sys/fs/cgroup/<controller>/. Phức tạp vì một tiến trình ở nhiều cây.
cgroups v2: một cây thống nhất (unified hierarchy) ở /sys/fs/cgroup/, mỗi cgroup bật controller qua file cgroup.subtree_control. Đây là mặc định trên distro hiện đại (systemd).
Ví dụ giới hạn thật với cgroup v2:
# Docker tạo cgroup ở /sys/fs/cgroup/system.slice/docker-<id>.scope/
docker run -d --name limited --memory=256m --cpus=1.5 nginx
ID=$(docker inspect -f '{{.Id}}' limited)
CG=/sys/fs/cgroup/system.slice/docker-$ID.scope
cat $CG/memory.max # 268435456 (256*1024*1024 byte)
cat $CG/cpu.max # 150000 100000 -> (quota 150000us / period 100000us) = 1.5 CPU
cat $CG/pids.max # giới hạn số PID (chống fork bomb)
Giải thích cpu.max: định dạng <quota> <period>, đơn vị microgiây. 150000 100000 nghĩa là mỗi chu kỳ 100ms, tiến trình được dùng tối đa 150ms CPU-time → tương đương 1.5 lõi. Khi vượt, scheduler throttle (chặn) tiến trình tới chu kỳ sau.
memory.max là hard limit. Vượt qua → kernel OOM killer giết tiến trình trong cgroup (xem dmesg, exit code 137 = 128+SIGKILL(9)).
Lưu ý bảo mật: KHÔNG đặt giới hạn → một container có thể nuốt hết RAM/CPU host (DoS nội bộ). Đặt
--memory,--cpus,--pids-limitcho mọi workload.--pids-limitchặn fork bomb.
14.5.3. OverlayFS — lower / upper / merged
Docker image gồm nhiều layer chỉ-đọc. Khi chạy container, Docker xếp chồng chúng bằng overlay2 storage driver dựa trên OverlayFS của kernel:
merged/ (cái container nhìn thấy = union)
^
+--------+--------+
| |
upperdir lowerdir(s)
(read-write, (read-only, các image layer
thay đổi của xếp chồng, lower nhất ở cuối)
container)
|
workdir (kernel dùng để thực hiện copy-up nguyên tử)
Cơ chế copy-up: khi container ghi vào một file đang nằm ở lowerdir (read-only), kernel sao chép file đó lên upperdir rồi mới ghi. Lowerdir không bao giờ thay đổi → image bất biến, nhiều container chia sẻ chung layer (tiết kiệm đĩa). Xóa file ở lower → tạo một whiteout (file thiết bị ký tự đặc biệt c 0 0) ở upper để "che" file đó trong merged.
docker inspect -f '{{json .GraphDriver.Data}}' limited | python3 -m json.tool
# {
# "LowerDir": "/var/lib/docker/overlay2/<l1>/diff:/var/lib/docker/overlay2/<l2>/diff",
# "UpperDir": "/var/lib/docker/overlay2/<id>/diff",
# "MergedDir": "/var/lib/docker/overlay2/<id>/merged",
# "WorkDir": "/var/lib/docker/overlay2/<id>/work"
# }
mount | grep overlay # thấy filesystem type 'overlay'
Lưu ý bảo mật: dữ liệu "đã xóa" ở layer dưới VẪN nằm trong image (chỉ bị whiteout che).
docker historyvà việc giải nén từng layer (tar) có thể phục hồi secret bịRUN rmở instruction sau. Đây là lỗi rò rỉ secret kinh điển — phải dùng multi-stage build hoặc BuildKit secrets, không bao giờ COPY secret rồi xóa.Lưu ý vận hành (kinh nghiệm thực tế): image và build cache tích luỹ ở
/var/lib/docker/overlay2/là thủ phạm đầy đĩa số một trên máy chạy Docker lâu ngày. Trên máy build/deploy liên tục, root FS có thể đầy chỉ sau vài ngày vì mỗi lần CI build lại đẻ ra image mới mà image cũ không ai dọn. Dùngdocker system dfđể xem dung lượng tách theo Images / Containers / Local Volumes / Build Cache (thêm-vđể liệt kê chi tiết từng image) trước khi quyết định xoá gì. Cảnh báo quan trọng:docker system prunemặc định không đụng volume, nhưngdocker system prune --volumes(hoặcdocker volume prune) sẽ xoá volume không được container nào tham chiếu — trên máy có volume chứa data (DB, upload) mà container tạm thời dừng, đây là đường xoá nhầm dữ liệu. An toàn hơn: chỉ dọn image không dùng bằngdocker image prune -a --filter "until=72h"chạy định kỳ (cron 3h sáng), và tránh--volumestrừ khi đã chắc chắn không còn data cần giữ.
14.5.4. Image, layer, digest — định dạng
Một Docker/OCI image gồm: manifest (JSON), config (JSON), và các layer blob (tar.gz). Mỗi đối tượng được định danh bằng digest = sha256: + hash SHA-256 của nội dung (content-addressable). Vì địa chỉ = hash, nội dung bất biến: đổi 1 byte → đổi digest → là object khác.
OCI Image Manifest (ví dụ rút gọn):
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"size": 7023,
"digest": "sha256:b5b2b2c5...e3a"
},
"layers": [
{ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"size": 32654, "digest": "sha256:9f0c...4d" },
{ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"size": 16724, "digest": "sha256:3c3a...91" }
]
}
| Trường | Ý nghĩa |
|---|---|
config.digest |
Trỏ tới image config JSON (chứa ENV, CMD, history, rootfs.diff_ids) |
layers[].digest |
Digest của layer blob (đã nén) — dùng để pull/dedup |
rootfs.diff_ids (trong config) |
Digest của layer chưa nén — dùng để khớp layer trên đĩa |
Khác biệt image tag vs digest: nginx:1.25 là tag có thể thay đổi (publisher push lại). nginx@sha256:abc... là digest bất biến. Để bảo mật supply chain, pin theo digest trong production.
docker pull nginx@sha256:0d17b565c37bcbd895e9d92315a05c1c3c9a29f762b011a10c54a66cd53c9b31
docker inspect --format '{{index .RepoDigests 0}}' nginx
14.5.5. Dockerfile — từng instruction & caching
Mỗi instruction tạo (hoặc không) một layer. BuildKit cache theo nội dung instruction + ngữ cảnh.
| Instruction | Vai trò | Tạo layer? | Ghi chú caching/bảo mật |
|---|---|---|---|
FROM |
Base image | (kế thừa) | Pin digest. Dùng image tối thiểu (alpine/distroless) |
RUN |
Chạy lệnh build | Có | Gộp apt-get update && install && rm -rf /var/lib/apt/lists/* trong 1 RUN |
COPY |
Sao chép từ context | Có | Cache invalidate nếu file nguồn đổi (theo checksum) |
ADD |
Như COPY + giải nén tar + URL | Có | Tránh — COPY rõ ràng hơn, ADD URL gây bất ngờ |
ENV |
Biến môi trường | Metadata | KHÔNG đặt secret ở ENV (nằm trong image config, ai cũng đọc) |
ARG |
Biến build-time | Metadata | Cũng lưu trong history nếu dùng sai; không cho secret |
USER |
Đổi user chạy | Metadata | Đặt non-root TRƯỚC ENTRYPOINT |
WORKDIR |
Thư mục làm việc | Metadata | |
EXPOSE |
Tài liệu hóa cổng | Metadata | Chỉ ghi chú, không mở cổng thật |
ENTRYPOINT |
Lệnh cố định khi chạy | Metadata | Dạng exec ["nginx","-g","daemon off;"] để nhận tín hiệu đúng (PID 1) |
CMD |
Tham số mặc định | Metadata | Bị override bởi docker run ... <cmd> |
HEALTHCHECK |
Kiểm tra sống | Metadata |
Caching: build đi tuần tự; nếu một instruction cache-hit, các instruction sau vẫn dùng cache cho tới khi gặp thay đổi. Vì vậy đặt thứ ít đổi (cài dependency) TRƯỚC, thứ hay đổi (copy source code) SAU:
# Sai thứ tự: mỗi lần đổi code phải cài lại deps
COPY . .
RUN npm ci
# Đúng: tách package.json -> cache npm ci được giữ khi chỉ đổi src
COPY package*.json ./
RUN npm ci
COPY . .
Multi-stage build thật (Go) — chỉ giữ binary, bỏ toolchain:
# ---- Stage build ----
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app ./cmd/server
# ---- Stage runtime: distroless, non-root ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app /app
USER 65532:65532 # nonroot uid của distroless
ENTRYPOINT ["/app"]
Vì sao distroless: không có shell, không có package manager, không có coreutils → kẻ tấn công vào container không có sh, curl, cat để xoay sở; bề mặt tấn công nhỏ; ít CVE từ thư viện thừa.
BuildKit secret (không để lại dấu vết trong layer):
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmtoken \
NPM_TOKEN=$(cat /run/secrets/npmtoken) npm ci
DOCKER_BUILDKIT=1 docker build --secret id=npmtoken,src=./.npmtoken -t app .
Secret được mount tạm vào RUN, không ghi vào layer.
.dockerignore — chống nuốt nhầm secret/file thừa vào context:
.git
node_modules
*.pem
*.key
.env
Dockerfile
14.5.6. Registry & content trust
- Docker Registry / OCI Distribution: API HTTP (
/v2/). Pull = GET manifest theo tag/digest, rồi GET từng layer blob theo digest. - Cosign / Notary (DCT): ký image.
cosign signtạo chữ ký lưu cạnh image;cosign verifykiểm tra trước khi deploy.
cosign generate-key-pair
cosign sign --key cosign.key registry.example.com/app@sha256:...
cosign verify --key cosign.pub registry.example.com/app@sha256:...
Lưu ý bảo mật supply chain: bật admission policy chỉ cho phép image đã ký (cosign + Kyverno/Gatekeeper), pin digest, quét trước khi push.
14.6. Quét image với Trivy
Trivy quét: lỗ hổng OS package + lib ứng dụng (đọc lockfile), misconfig (Dockerfile/K8s/Terraform), secret hardcode, license.
# Quét image, chỉ báo HIGH/CRITICAL, fail CI nếu có CRITICAL
trivy image --severity HIGH,CRITICAL --exit-code 1 \
--ignore-unfixed nginx:1.25
# Quét filesystem / Dockerfile misconfig
trivy fs --scanners vuln,secret,misconfig .
# Xuất SARIF cho GitLab/GitHub code scanning
trivy image --format sarif -o trivy.sarif app:latest
Giải thích tham số:
- --ignore-unfixed: bỏ qua CVE chưa có bản vá (giảm nhiễu, tập trung cái fix được).
- --exit-code 1: trả mã lỗi để pipeline CI fail → gate bảo mật.
- --scanners vuln,secret,misconfig: bật đồng thời nhiều bộ quét.
Output mẫu (rút gọn):
nginx:1.25 (debian 12.4)
Total: 2 (HIGH: 1, CRITICAL: 1)
┌────────────┬────────────────┬──────────┬───────────────┬───────────────┐
│ Library │ Vulnerability │ Severity │ Installed Ver │ Fixed Version │
├────────────┼────────────────┼──────────┼───────────────┼───────────────┤
│ libssl3 │ CVE-2024-XXXX │ CRITICAL │ 3.0.11-1 │ 3.0.13-1 │
│ zlib1g │ CVE-2023-YYYY │ HIGH │ 1:1.2.13 │ │ (unfixed)
└────────────┴────────────────┴──────────┴───────────────┴───────────────┘
Tích hợp GitLab CI (đúng môi trường DevSecOps):
trivy_scan:
stage: security
image: aquasec/trivy:latest
script:
- trivy image --exit-code 0 --format sarif -o gl-trivy.sarif "$IMAGE"
- trivy image --exit-code 1 --severity CRITICAL "$IMAGE"
artifacts:
reports: { container_scanning: gl-trivy.sarif }
14.7. Container escape — cơ chế cụ thể
Container escape = vượt khỏi cô lập namespace/cgroup để truy cập host. Ba lớp phổ biến:
14.7.1. --privileged
docker run --privileged tắt gần như mọi cô lập: cấp tất cả capabilities, vô hiệu seccomp/AppArmor mặc định, cho truy cập mọi device ở /dev. Hệ quả: container thấy đĩa host (/dev/sda) → mount → đọc/ghi rootfs host.
# Bên trong container privileged
fdisk -l # thấy /dev/sda của host
mkdir /hostroot
mount /dev/sda1 /hostroot # mount rootfs host
echo 'attacker ALL=(ALL) NOPASSWD:ALL' >> /hostroot/etc/sudoers
# hoặc ghi /hostroot/root/.ssh/authorized_keys -> escape hoàn toàn
Một biến thể kinh điển dùng release_agent của cgroup v1 (khi privileged + cgroup v1): ghi vào file release_agent một script chạy với quyền host khi cgroup rỗng → thực thi lệnh trên host.
14.7.2. Mount docker.sock
/var/run/docker.sock là Unix socket nói chuyện với Docker daemon (chạy quyền root). Ai truy cập được socket = điều khiển daemon = tạo container privileged mới mount / của host → root host.
# Nếu container bị mount nhầm -v /var/run/docker.sock:/var/run/docker.sock
docker -H unix:///var/run/docker.sock run -v /:/host -it alpine chroot /host sh
# giờ là root trên host
Đây là lý do KHÔNG bao giờ mount docker.sock vào container untrusted (CI runner, app). Tương đương trao root host.
14.7.3. CAP_SYS_ADMIN và capability thừa
Linux chia quyền root thành nhiều capabilities (bitmask trong cred của tiến trình). Docker mặc định giữ một tập nhỏ và bỏ phần nguy hiểm. CAP_SYS_ADMIN là "root mới" — cho phép mount, pivot_root, thao tác namespace... → con đường escape phổ biến.
# Xem capabilities hiện có trong container
grep Cap /proc/1/status
# CapEff: 00000000a80425fb <- bitmask, giải mã:
capsh --decode=00000000a80425fb
# cap_chown,cap_dac_override,...,cap_net_raw,cap_setuid,...
# Nguyên tắc: drop tất cả, thêm lại cái cần
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx
Phòng thủ tổng hợp cho container:
| Biện pháp | Lệnh / hiệu quả |
|---|---|
| Non-root | USER 65532 trong Dockerfile + --user 65532 |
| Read-only rootfs | --read-only (chỉ ghi vào volume/tmpfs khai báo) |
| Drop caps | --cap-drop=ALL rồi add tối thiểu |
| No new privileges | --security-opt=no-new-privileges (chặn setuid leo quyền) |
| Seccomp | Mặc định Docker chặn một loạt syscall nguy hiểm; viết profile custom |
| AppArmor/SELinux | MAC giới hạn truy cập file/cap |
| User namespace | --userns-remap → root container = UID cao trên host |
| Không privileged | KHÔNG dùng --privileged; không mount docker.sock |
Ví dụ seccomp profile JSON (allowlist tối thiểu):
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{ "names": ["read","write","openat","close","fstat","mmap","execve",
"brk","arch_prctl","exit_group"],
"action": "SCMP_ACT_ALLOW" }
]
}
docker run --security-opt seccomp=./profile.json myapp
SCMP_ACT_ERRNO = syscall không có trong allowlist sẽ trả EPERM thay vì thực thi.
14.8. Kubernetes
14.8.1. Kiến trúc: control plane & node
CONTROL PLANE (master)
+-----------------------------------------------+
| kube-apiserver <---> etcd (key-value store) |
| ^ ^ |
| | +-- kube-scheduler |
| +------ kube-controller-manager |
+-----------------------------------------------+
| (API: HTTPS 6443)
+--------+----------------+
| |
NODE 1 NODE 2
kubelet kubelet
kube-proxy kube-proxy
container runtime (CRI) container runtime
(containerd/CRI-O) (containerd/CRI-O)
|
Pods (1..n container/pod)
| Thành phần | Vai trò | Chi tiết bảo mật |
|---|---|---|
| kube-apiserver | Cổng duy nhất vào cluster; mọi thao tác qua REST API; xác thực + authz + admission | Cổng 6443/TCP TLS. Là mục tiêu số 1 |
| etcd | Lưu TOÀN BỘ state cluster (kể cả Secret) dạng key-value | Phải mã hóa at-rest + mTLS + chỉ apiserver truy cập |
| kube-scheduler | Quyết định Pod chạy node nào (theo resource, taint/toleration, affinity) | |
| kube-controller-manager | Vòng lặp điều hòa (reconcile) đưa trạng thái thực về trạng thái mong muốn | |
| kubelet | Agent trên mỗi node; nói với CRI để chạy Pod; báo cáo trạng thái | API kubelet (10250) phải xác thực — nếu mở anonymous = RCE node |
| kube-proxy | Cài rule iptables/IPVS để hiện thực Service (cân bằng tải tới Pod) | |
| CRI runtime | containerd / CRI-O thực sự chạy container qua runc |
Luồng một request (ví dụ kubectl apply -f deploy.yaml):
- kubectl → HTTPS POST tới apiserver (cổng 6443), kèm client cert/token.
- Authentication: apiserver xác định "ai" (cert CN, ServiceAccount JWT, OIDC).
- Authorization (RBAC): kiểm "user này được làm verb
createtrên resourcedeploymentstrong namespace này không?". - Admission controllers: mutating (sửa object, vd tiêm sidecar, set default) rồi validating (chấp nhận/từ chối, vd OPA/Gatekeeper, Pod Security).
- apiserver ghi object vào etcd.
- controller-manager thấy Deployment mới → tạo ReplicaSet → tạo Pod object.
- scheduler gán Pod vào node (ghi
nodeName). - kubelet node đó watch thấy Pod của mình → gọi CRI → containerd → runc tạo container.
14.8.2. Đối tượng cốt lõi (manifest thật)
Pod — đơn vị nhỏ nhất, một hoặc nhiều container chia sẻ NET namespace (cùng IP, cùng localhost) và volume:
apiVersion: v1
kind: Pod
metadata:
name: web
namespace: app
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
seccompProfile: { type: RuntimeDefault }
containers:
- name: nginx
image: nginx@sha256:0d17b565... # pin digest
ports: [{ containerPort: 8080 }]
resources:
requests: { cpu: "100m", memory: "128Mi" }
limits: { cpu: "500m", memory: "256Mi" }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
100m = 100 millicores = 0.1 CPU. 128Mi = 128 mebibyte = 128*2^20 byte. requests dùng để scheduler đặt chỗ; limits là cgroup hard cap.
Deployment (quản lý ReplicaSet, rolling update):
apiVersion: apps/v1
kind: Deployment
metadata: { name: web, namespace: app }
spec:
replicas: 3
selector: { matchLabels: { app: web } }
strategy:
type: RollingUpdate
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
template:
metadata: { labels: { app: web } }
spec:
containers:
- name: web
image: registry.example.com/web@sha256:...
Quan hệ: Deployment → tạo/quản lý ReplicaSet → giữ đúng số Pod. Rolling update: tạo ReplicaSet mới, tăng dần Pod mới, giảm dần Pod cũ theo maxSurge/maxUnavailable.
Service — IP/DNS ổn định cho tập Pod (Pod IP thay đổi liên tục):
| Loại | Cơ chế | Dùng khi |
|---|---|---|
| ClusterIP | IP ảo nội cụm, kube-proxy DNAT tới Pod | Service nội bộ (mặc định) |
| NodePort | Mở cùng một cổng (30000–32767) trên MỌI node | Truy cập từ ngoài đơn giản |
| LoadBalancer | Gọi cloud provider tạo LB ngoài trỏ vào NodePort | Cloud production |
| ExternalName | Trả CNAME DNS | Trỏ tới dịch vụ ngoài |
apiVersion: v1
kind: Service
metadata: { name: web, namespace: app }
spec:
type: ClusterIP
selector: { app: web } # chọn Pod theo label
ports:
- port: 80 # cổng của Service (ClusterIP:80)
targetPort: 8080 # cổng container đích
Cơ chế kube-proxy (chế độ iptables): cho mỗi Service tạo chuỗi KUBE-SERVICES → KUBE-SVC-xxx → DNAT ngẫu nhiên (xác suất đều) tới một trong các KUBE-SEP-xxx (mỗi SEP = một Pod endpoint). Đây là cân bằng tải L4.
Ingress — định tuyến HTTP(S) L7 (host/path) vào các Service, do Ingress Controller (nginx, traefik) hiện thực:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
annotations: { nginx.ingress.kubernetes.io/ssl-redirect: "true" }
spec:
tls: [{ hosts: ["app.example.com"], secretName: web-tls }]
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend: { service: { name: web, port: { number: 80 } } }
ConfigMap & Secret:
apiVersion: v1
kind: ConfigMap
metadata: { name: app-cfg, namespace: app }
data:
LOG_LEVEL: "info"
config.yaml: |
server: { port: 8080 }
---
apiVersion: v1
kind: Secret
metadata: { name: db-cred, namespace: app }
type: Opaque
data:
password: c3VwZXJzZWNyZXQ= # base64("supersecret") — KHÔNG mã hóa
14.8.3. RBAC — Role / RoleBinding
RBAC trả lời: "subject (user/group/ServiceAccount) được làm verb gì trên resource nào, ở đâu". Role/RoleBinding = phạm vi 1 namespace; ClusterRole/ClusterRoleBinding = toàn cụm.
# Role: chỉ đọc pod & log trong namespace app
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: pod-reader, namespace: app }
rules:
- apiGroups: [""] # "" = core group
resources: ["pods", "pods/log"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: bind-pod-reader, namespace: app }
subjects:
- kind: ServiceAccount
name: viewer
namespace: app
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io
Kiểm tra quyền thực tế:
kubectl auth can-i create deployments --namespace app --as system:serviceaccount:app:viewer
# no
kubectl auth can-i get pods --namespace app --as system:serviceaccount:app:viewer
# yes
Lưu ý bảo mật: tránh
verbs: ["*"],resources: ["*"]. Đặc biệt nguy hiểm: quyềncreatetrênpods(có thể tạo Pod mount host hoặc privileged để escape), quyềnget/createtrênsecrets, quyềnescalate/bindtrên RBAC, quyềncreatetrênpods/exec. Cấp theo least-privilege.
14.8.4. NetworkPolicy
Mặc định trong K8s mọi Pod nói chuyện được với mọi Pod (flat network). NetworkPolicy (cần CNI hỗ trợ: Calico, Cilium...) áp firewall L3/L4 theo label. Quy tắc: một khi có policy chọn một Pod, Pod đó chuyển sang default-deny cho chiều được khai báo.
# Default deny mọi ingress trong namespace app
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny-ingress, namespace: app }
spec:
podSelector: {} # áp cho MỌI pod
policyTypes: ["Ingress"]
---
# Chỉ cho frontend gọi backend:8080
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-frontend-to-backend, namespace: app }
spec:
podSelector: { matchLabels: { app: backend } }
policyTypes: ["Ingress"]
ingress:
- from:
- podSelector: { matchLabels: { app: frontend } }
ports:
- { protocol: TCP, port: 8080 }
Lưu ý: NetworkPolicy chỉ có hiệu lực nếu CNI cài đặt thực thi nó. Trên cluster dùng CNI không hỗ trợ (vd flannel thuần), manifest được chấp nhận nhưng KHÔNG chặn gì → ảo giác an toàn. Kiểm chứng bằng test thực tế (
kubectl execcurl giữa các pod).
14.8.5. Secret — base64 ≠ mã hóa & mã hóa etcd
Trường data của Secret chỉ là base64 encode (để đựng nhị phân trong YAML), KHÔNG phải mã hóa:
echo 'c3VwZXJzZWNyZXQ=' | base64 -d # supersecret <- ai cũng giải được
Theo mặc định, Secret nằm trong etcd dưới dạng plaintext (chỉ base64). Ai đọc được etcd (backup etcd, node control plane) = đọc mọi secret. Bật encryption at rest bằng EncryptionConfiguration cho apiserver:
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources: ["secrets"]
providers:
- aescbc: # hoặc aesgcm; tốt nhất là kms: (KMS ngoài)
keys:
- name: key1
secret: <32-byte base64 key>
- identity: {} # fallback đọc dữ liệu cũ chưa mã hóa
Cờ apiserver: --encryption-provider-config=/etc/kubernetes/enc.yaml. Khuyến nghị provider kms: (envelope encryption với HSM/cloud KMS) thay vì để khóa trong file.
Lưu ý bảo mật: ngoài mã hóa etcd, hạn chế ai
get secretsqua RBAC; tránh để Secret thành ENV (lộ qua/proc/<pid>/environ, crash dump, logs) — ưu tiên mount dạng file (tmpfs). Cân nhắc external secret (Vault, External Secrets Operator).
14.8.6. Pod Security Standards (PSS) & Admission
PSS thay thế PodSecurityPolicy (đã bỏ ở v1.25). Ba mức:
| Mức | Cho phép |
|---|---|
| privileged | Không giới hạn (chỉ cho hệ thống) |
| baseline | Chặn các cấu hình nguy hiểm rõ ràng (privileged, hostNetwork, hostPID, capabilities thêm nguy hiểm) |
| restricted | Cứng nhất: bắt buộc runAsNonRoot, drop ALL caps, seccomp RuntimeDefault, không privilege escalation |
Bật bằng nhãn namespace (Pod Security Admission — built-in):
kubectl label namespace app \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/warn=restricted
Pod không đạt restricted sẽ bị apiserver TỪ CHỐI tạo.
Admission controller tùy biến — OPA/Gatekeeper (chính sách phức tạp hơn PSS):
# ConstraintTemplate: định nghĩa policy bằng Rego
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata: { name: k8sallowedrepos }
spec:
crd:
spec:
names: { kind: K8sAllowedRepos }
validation:
openAPIV3Schema:
type: object
properties: { repos: { type: array, items: { type: string } } }
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8sallowedrepos
violation[{"msg": msg}] {
c := input.review.object.spec.containers[_]
not startswith(c.image, input.parameters.repos[_])
msg := sprintf("image %v không từ registry tin cậy", [c.image])
}
---
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos
metadata: { name: only-trusted-registry }
spec:
match: { kinds: [{ apiGroups: [""], kinds: ["Pod"] }] }
parameters: { repos: ["registry.example.com/"] }
Gatekeeper là một validating admission webhook: apiserver gọi nó (HTTPS) trước khi lưu object; trả về allow/deny + lý do. Kyverno là lựa chọn tương đương dùng YAML thay Rego.
14.8.7. ServiceAccount token & API kubelet — bề mặt tấn công
Mỗi Pod (mặc định) được gắn token ServiceAccount tại /var/run/secrets/kubernetes.io/serviceaccount/token (JWT). Nếu attacker chiếm Pod, token này dùng để gọi apiserver với quyền của SA đó.
Cấu trúc JWT (3 phần Base64URL ngăn bởi .): header.payload.signature.
| Phần | Nội dung |
|---|---|
| header | {"alg":"RS256","kid":"..."} — thuật toán ký + key id |
| payload (claims) | iss, sub (vd system:serviceaccount:app:viewer), aud, exp, kubernetes.io (namespace, pod, sa) |
| signature | RS256 ký bởi khóa riêng của apiserver — chống giả mạo |
Token hiện đại là bound token (projected): có exp ngắn, gắn với vòng đời Pod và audience cụ thể.
Cứng hóa: đặt
automountServiceAccountToken: falsecho Pod không cần gọi API; khóa API kubelet (--anonymous-auth=false,--authorization-mode=Webhook); chặn metadata cloud (169.254.169.254) bằng NetworkPolicy để tránh đánh cắp credential IAM của node.
14.9. Falco — runtime threat detection
Falco là engine phát hiện hành vi bất thường theo thời gian thực bằng cách đọc syscall của kernel (qua eBPF probe hoặc kernel module) và đối chiếu với luật. Nó thấy: tiến trình mới, mở file, kết nối mạng, exec vào container...
Kiến trúc: kernel → (eBPF/modern_ebpf probe) → vùng ring buffer → libsinsp parse thành "event có ngữ cảnh container" → rule engine → output (stderr/JSON/gRPC/Falcosidekick → Slack, SIEM).
Một rule Falco thật:
- rule: Shell vào container (đáng ngờ)
desc: Phát hiện bash/sh được spawn bên trong container
condition: >
spawned_process and container
and proc.name in (bash, sh, zsh)
and not container.image.repository in (allowed_debug_images)
output: >
Shell mở trong container (user=%user.name container=%container.name
image=%container.image.repository proc=%proc.cmdline)
priority: WARNING
tags: [container, shell, mitre_execution]
Giải thích trường:
- condition: biểu thức trên các field sysdig (proc.name, container.id, fd.name, evt.type...). spawned_process là macro = evt.type=execve and evt.dir=<.
- output: template log, %proc.cmdline v.v. nội suy từ event.
- priority: mức nghiêm trọng.
Các rule mặc định hữu ích: "Write below /etc", "Read sensitive file (/etc/shadow)", "Container drift (binary mới không có trong image)", "Outbound connection to C2", "Launch privileged container".
# Chạy thử trong container, theo dõi sự kiện
docker exec -it web sh # -> Falco bắn alert "Shell vào container"
cat /etc/shadow # -> "Read sensitive file"
Lưu ý bảo mật: Falco là detective control (phát hiện) bổ sung cho preventive control (PSS, seccomp, NetworkPolicy). eBPF probe cần kernel hỗ trợ (BTF). Gửi output sang SIEM và đặt alert; rule cần tinh chỉnh để giảm false positive (whitelist image debug hợp lệ).
14.10. Checklist cứng hóa tổng hợp
| Lớp | Kiểm soát chính |
|---|---|
| Hypervisor (ESXi/Proxmox) | Vá hypervisor + microcode (L1TF/MDS); tách mạng quản lý; tắt SLP; mã hóa vMotion; LXC unprivileged |
| Image | Distroless/minimal, multi-stage, non-root USER, pin digest, ký cosign, quét Trivy gate CI, .dockerignore, BuildKit secret |
| Container runtime | --cap-drop=ALL, --read-only, no-new-privileges, seccomp/AppArmor, không --privileged, không mount docker.sock, --pids-limit, userns-remap |
| K8s control plane | etcd mTLS + encryption at rest (KMS), apiserver authz=RBAC, audit log bật, kubelet auth |
| K8s workload | securityContext restricted, PSS=restricted, resource limits, NetworkPolicy default-deny, RBAC least-privilege, automountServiceAccountToken=false |
| Admission | Pod Security + Gatekeeper/Kyverno (chỉ image đã ký từ registry tin cậy) |
| Runtime detect | Falco + Falcosidekick → SIEM; audit log apiserver → SIEM |
| Supply chain | SBOM (syft), ký + verify (cosign), pin digest, quét định kỳ lại image đang chạy |
Ghi chú của mình
Khu vực ghi chú cá nhân: những điểm từng hiểu sai, phần còn đang tìm hiểu, hoặc kinh nghiệm rút ra khi thực hành — cập nhật dần.