0

[Hành trình tự học DevOps] Bài 1. Docker thực sự làm gì?

Bài viết được biên soạn dựa trên nội dung video Docker Under the Hood (Finally Explained) của PawelCodeStuff, với mục đích tổng hợp và trình bày lại các khái niệm một cách dễ tiếp cận hơn.

🐳 Docker hoạt động bên dưới như thế nào?

Có lẽ bạn đã từng sử dụng Docker. Bạn viết Dockerfile, build image rồi chạy container. Sau đó, code của bạn dường như được tách hoàn toàn khỏi phần còn lại của máy tính: nó có filesystem riêng, network interface riêng và process tree riêng. Code có thể chạy gần như giống nhau trên laptop và production server.

Thông thường, chỉ cần container chạy được là chúng ta không quan tâm Docker làm gì bên dưới. Nhưng nếu muốn hiểu bản chất Docker, chúng ta cần xem chính xác điều gì xảy ra phía sau câu lệnh:

docker run

Phần 1. Docker Container và Virtual Machine

Chúng ta thường so sánh container với Virtual Machine (VM), nhưng cách hoạt động của chúng rất khác nhau.

Một VM sử dụng hypervisor để tạo ra một máy tính được mô phỏng hoàn chỉnh. Nó phải boot một kernel, load các driver phần cứng và chạy một hệ điều hành đầy đủ.

Nếu một server vật lý chạy 3 VM:

Server vật lý
 |
 +-- VM 1 -> Kernel 1
 |
 +-- VM 2 -> Kernel 2
 |
 +-- VM 3 -> Kernel 3

Bạn thực tế có 3 kernel khác nhau được load vào memory. Điều này mang lại isolation rất mạnh nhưng tốn nhiều RAM và thời gian khởi động lâu.

Container thì hoàn toàn khác.

Container không boot một hệ điều hành riêng và cũng không có kernel riêng. Nó thực chất chỉ là một Linux process bình thường, chạy trực tiếp trên host và sử dụng chung Linux kernel với các process/container khác.

Vì không phải boot cả hệ điều hành nên container có thể khởi động gần như ngay lập tức và chỉ sử dụng lượng memory mà ứng dụng thực sự cần.


Phần 2. Namespace — Container nhìn thấy gì?

Nếu container chỉ là một process bình thường thì sẽ xuất hiện một vấn đề.

Một Linux process bình thường có thể nhìn thấy các process khác trên máy. Chúng có thể cùng nhìn thấy filesystem, network interface và hostname của host.

Nếu container chỉ đơn giản là một process thì nó vẫn có thể nhìn thấy toàn bộ hệ thống.

Linux giải quyết vấn đề này bằng một tính năng gọi là Namespaces.

Có thể hiểu rất đơn giản:

Namespace quyết định process được phép nhìn thấy cái gì.

Docker kết hợp nhiều loại namespace để tạo ra cảm giác rằng process đang chạy trong một máy tính riêng.

2.1. PID Namespace

Một namespace quan trọng là PID namespace, dùng để cô lập Process ID.

Bình thường Linux có một danh sách process toàn hệ thống.

Ví dụ web server của bạn có:

PID 4500

Vì process này nằm trong danh sách toàn hệ thống nên nó có thể nhìn thấy những process khác.

Khi process được đưa vào một PID namespace, kernel tạo ra một "bức tường" xung quanh nó.

Bên trong container, process có thể nhìn thấy:

PID 1

Nó tưởng rằng mình là process đầu tiên của hệ thống.

Nhưng nếu bạn mở terminal trên host:

ps aux

bạn vẫn có thể nhìn thấy process đó với:

PID 4500

Đây vẫn là cùng một process, chỉ khác nhau về góc nhìn.

Host nhìn thấy PID 4500, còn container nhìn thấy chính process đó là PID 1.

PID namespace chỉ là một phần. Docker kết hợp nhiều namespace khác.

2.2. Mount Namespace

Cho process một góc nhìn filesystem riêng.

Container có thể thấy filesystem của nó mà không thấy toàn bộ filesystem của host.

2.3. Network Namespace

Tạo một network stack riêng cho container.

Container có network interface, IP, routing table... riêng.

2.4. UTS Namespace

Cho container có hostname riêng.

Ví dụ host có:

server-production

nhưng container có thể thấy:

my-container

Ngoài ra còn có namespace cho:

  • IPC — Interprocess Communication
  • User ID

Đặc biệt, User Namespace cho phép ánh xạ:

root trong container
        |
        v
user không có quyền
root trên host

Nhờ kết hợp các namespace này, process trong container gần như có cảm giác:

"Tôi đang sở hữu cả một máy tính riêng."


Phần 3. cgroups — Container được sử dụng bao nhiêu tài nguyên?

Namespace chỉ kiểm soát process được nhìn thấy gì.

Nó không kiểm soát process được phép sử dụng bao nhiêu CPU hoặc RAM.

Ví dụ một container bị lỗi và sử dụng toàn bộ RAM:

Container
 |
 +-- CPU 100%
 +-- RAM 100%
 |
 +-- Server crash

Linux giải quyết vấn đề này bằng Control Groups, thường gọi là cgroups.

Có thể nhớ:

Namespaces → giới hạn những gì process nhìn thấy.

cgroups → giới hạn những gì process được sử dụng.

3.1. Giới hạn CPU và RAM

Khi bạn chạy Docker với giới hạn CPU hoặc memory, Docker thực chất đang cấu hình cgroups.

Ví dụ:

docker run --memory=512m ...

Docker yêu cầu Linux kernel giới hạn container ở mức memory nhất định.

Bên dưới, kernel quản lý cgroups thông qua một filesystem ảo và một cấu trúc thư mục đặc biệt.

Có thể hình dung:

cgroup
 |
 +-- memory limit
 +-- cpu limit
 +-- process list

Kernel liên tục theo dõi container đang sử dụng bao nhiêu RAM và CPU.

Nếu container muốn sử dụng nhiều memory hơn giới hạn, kernel trước tiên cố gắng thu hồi memory. Nếu vẫn không đủ, process có thể bị kill.

Nhờ vậy, một container bị lỗi không dễ dàng "ăn hết" tài nguyên của toàn bộ server.


Phần 4. Filesystem — Container có filesystem riêng như thế nào?

Namespace và cgroups giải quyết việc process nhìn thấy gìđược dùng bao nhiêu tài nguyên.

Nhưng container vẫn cần một môi trường filesystem riêng để ứng dụng có thể chạy.

4.1. Root filesystem

Mount Namespace cho container một góc nhìn filesystem riêng.

Nhưng process vẫn cần một root directory để hoạt động.

Không thể để container sử dụng trực tiếp root filesystem của host. Vì vậy phải cung cấp cho nó một filesystem riêng.

Từ rất lâu trước Docker, Unix/Linux đã có cơ chế gọi là:

chroot

Bạn chỉ định một thư mục và kernel coi thư mục đó là / của process.

Ví dụ:

Host
 |
 +-- /home
 +-- /etc
 +-- /var
 |
 +-- /container-root
      |
      +-- /bin
      +-- /etc
      +-- /app

Process bên trong container sẽ coi /container-root chính là /.

Tuy nhiên, chroot không được thiết kế như một security boundary mạnh. Một process chạy với quyền root có thể tìm cách thoát khỏi môi trường chroot.

Nói đơn giản:

chroot giống như nói với process "hãy coi thư mục này là nhà của bạn", nhưng bản thân nó không phải một cơ chế sandbox hoàn chỉnh.

Do đó container runtime hiện đại cần một cơ chế mạnh hơn.

4.2. pivot_root

Linux cung cấp system call gọi là pivot_root.

Thay vì chỉ hạn chế process nhìn thấy filesystem như chroot, pivot_root thay đổi root filesystem của process sang một filesystem mới.

Kết quả là container có filesystem riêng và filesystem ban đầu của host được tách khỏi container.

Tuy nhiên, pivot_root chỉ giải quyết việc thay đổi root.

Filesystem mới không thể là thư mục rỗng. Nó phải chứa:

  • System libraries
  • Binaries
  • Configuration
  • Dependencies
  • Application code

Và đây chính là nhiệm vụ của Docker image.


4.3. Docker image — OverlayFS ghép các layer

Một Docker image không phải là một file lớn duy nhất.

Nó được tạo thành từ nhiều layer độc lập, sau đó được kết hợp bằng Union File System, phổ biến nhất là OverlayFS.

Ví dụ:

FROM ubuntu
RUN apt install nginx
COPY app /app

Có thể hình dung:

Layer 3: /app
Layer 2: nginx
Layer 1: ubuntu

Base OS là một layer.

Cài dependency tạo thêm layer.

Copy source code tạo thêm layer.

Một số instruction chỉ thiết lập metadata/configuration thì chủ yếu gắn các setting vào image.

OverlayFS lấy tất cả những layer riêng biệt và ghép chúng thành một filesystem thống nhất mà container nhìn thấy.

Ví dụ container thấy:

/
+-- bin
+-- etc
+-- usr
+-- app

nhưng thực tế các file này có thể nằm ở nhiều layer khác nhau.

Điểm quan trọng là các image layer ban đầu đều read-only.

Khi container được chạy, Docker thêm một writable layer ở trên cùng:

Writable layer
--------------
Image layer 3
Image layer 2
Image layer 1

4.4. Copy-on-Write

Giả sử image có:

/app/config.txt

Container sửa file này.

Docker không sửa trực tiếp file trong image layer.

OverlayFS sẽ copy file lên writable layer, sau đó sửa bản copy.

Writable layer
 |
 +-- config.txt  <- bản mới

Image layer
 |
 +-- config.txt  <- bản cũ

Container nghĩ rằng nó đã sửa file gốc.

Nhưng thực tế, nó chỉ tạo ra một phiên bản mới nằm trên writable layer và che phiên bản cũ bên dưới.

Đây chính là cơ chế Copy-on-Write.


4.5. Vì sao Docker tiết kiệm disk?

Nhờ layer, nhiều container có thể dùng chung cùng một image.

Ví dụ bạn chạy 10 container từ cùng một image:

             +-- Container 1
             |
Image layers +-- Container 2
             |
             +-- Container 3
             |
             +-- ...
             |
             +-- Container 10

10 container không cần 10 bản copy hoàn chỉnh của image.

Các read-only layers được chia sẻ, còn mỗi container chỉ cần writable layer riêng.

Đây là một trong những lý do Docker tiết kiệm đáng kể dung lượng disk.


4.6. Vì sao thứ tự Dockerfile lại quan trọng?

Cơ chế layer cũng giải thích tại sao thứ tự instruction trong Dockerfile rất quan trọng.

Nếu bạn thay đổi một layer ở gần đầu Dockerfile, Docker phải rebuild layer đó và những layer phía sau.

Ví dụ:

FROM ubuntu
RUN apt install ...
COPY source /app
RUN build

Nếu thay đổi phần cài dependency ở phía trên, nhiều layer phía sau có thể phải build lại.

Nhưng nếu chỉ thay đổi source code ở layer cuối, Docker có thể tận dụng các layer trước đó từ cache.

Vì vậy, sắp xếp Dockerfile hợp lý giúp tận dụng Docker build cache.


Phần 5. Network — Container kết nối như thế nào?

Network Namespace khiến container có network stack riêng và ban đầu bị tách khỏi network của host.

Nhưng container vẫn cần giao tiếp với bên ngoài.

Docker giải quyết bằng cách tạo một virtual Ethernet pair, có thể hình dung như một sợi dây mạng ảo.

Container
    |
 veth pair
    |
 docker0 <- bridge 
    |
 Host
    |
 Physical NIC
    |
 Internet

Một đầu của virtual cable nằm trong network namespace của container.

Đầu còn lại kết nối với một virtual switch trên host gọi là bridge network.

Bridge mặc định thường có tên:

docker0

5.1. Request từ container đi ra Internet

Khi container gửi request:

Container
    |
    v
veth pair
    |
    v
docker0 <- bridge
    |
    v
Host routing
    |
    v
Physical NIC
    |
    v
Internet

Traffic đi từ container qua virtual Ethernet pair đến bridge.

Host sau đó route traffic ra network adapter vật lý.

Nếu hai container cùng kết nối vào một bridge, chúng có thể giao tiếp trực tiếp với nhau thông qua IP nội bộ.


5.2. Port Mapping và NAT

Muốn đưa service trong container ra bên ngoài, chúng ta cần map port.

Ví dụ:

docker run -p 8080:80 nginx

Có nghĩa:

Container:80
    |
Host:8080

Khi request đến port 8080 trên host, Docker cấu hình network stack để bắt traffic đó.

Host sử dụng NAT — Network Address Translation để chuyển packet qua bridge và virtual Ethernet pair vào container.

Luồng đơn giản:

Container:80
    |
 veth pair
    |
 docker0 <- bridge
    |
   nat
    |
Host:8080

Phần 6. Docker Runtime — Ai thực sự tạo Container?

Sau khi hiểu Namespace, cgroups, filesystem và network, chúng ta có thể xem toàn bộ kiến trúc phía sau Docker.

Khi bạn chạy:

docker run nginx

Docker CLI không trực tiếp tạo container process.

Docker CLI gửi một API request đến background service gọi là Docker daemon.

6.1. dockerd — Quản lý và điều phối hệ sinh thái Docker

Docker daemon, hay còn gọi là dockerd, chịu trách nhiệm cung cấp và quản lý các chức năng của hệ sinh thái Docker, chẳng hạn như:

  • Images
  • Networks
  • Storage volumes
  • Containers

Khi cần chạy một container, Docker daemon yêu cầu containerd quản lý việc tạo và chạy container.


6.2. containerd — Quản lý vòng đời container

containerd chịu trách nhiệm quản lý vòng đời container.

Nó xử lý những việc như:

  • Download image
  • Unpack image layers
  • Quản lý lifecycle container
  • Chuẩn bị container configuration

Nhưng containerd cũng không trực tiếp tạo process cuối cùng.

Nó giao cấu hình cho một công cụ cấp thấp hơn:

runc.


6.3. runc — Nói chuyện với Linux Kernel

runc là container runtime cấp thấp.

Nhiệm vụ chính của nó là tương tác trực tiếp với Linux kernel để tạo container.

Nó thiết lập:

  • Namespaces
  • cgroups
  • Root filesystem
  • Container process

Có thể hình dung toàn bộ luồng:

docker CLI
    |
    v
Docker daemon
    |
    v
containerd
    |
    +-- "Tôi cần tạo một container nginx"
    |
    v
   runc
    |
    +-- "Tôi sẽ nói chuyện với Linux Kernel"
    +-- tạo namespace
    +-- tạo cgroup
    +-- chuẩn bị filesystem
    +-- start nginx process
    |
    v
Linux kernel

Một điểm rất thú vị là runc không cần chạy suốt vòng đời container.

Nó tạo container, thiết lập môi trường và start process.

Ngay khi process đã chạy, runc có thể kết thúc.

Có thể tưởng tượng runc giống như một người đến:

"Tôi dựng container, khởi động process rồi rời đi."

Nhưng Linux có một yêu cầu: mọi process đều cần một parent process.

Vậy khi runc biến mất, ai quản lý container process?


6.4. Container Shim — Tiếp quản container process

Đây là lúc shim xuất hiện.

Shim là một process nhỏ tiếp quản container process sau khi runc kết thúc.

Nó tồn tại trong suốt vòng đời của container và có nhiệm vụ:

  • Giữ container process hoạt động.
  • Cho phép container tiếp tục chạy ngay cả khi containerd restart.
  • Thu thập exit code khi container kết thúc.

Vì vậy, containerd không cần phải trực tiếp giữ process container chạy liên tục.


Phần 7. Tổng kết

Cuối cùng, có thể cô đọng toàn bộ cơ chế Docker thành một câu:

Container thực chất chỉ là một Linux process bình thường, được kernel cô lập và giới hạn tài nguyên bằng nhiều cơ chế khác nhau.

Các thành phần chính:

Thành phần Vai trò
Namespaces Process nhìn thấy gì
cgroups Process được dùng bao nhiêu CPU/RAM
OverlayFS Ghép các image layer
Writable layer Lưu thay đổi của container
veth pair Dây mạng ảo
docker0 / bridge Kết nối container với host
NAT Forward network/port
dockerd Điều phối Docker
containerd Quản lý container
runc Tạo và start container
shim Tiếp quản container process

Vì vậy, khi chạy:

docker run ...

không có một "máy Linux mini" nào được boot lên.

Docker chỉ đang kết hợp các tính năng của Linux:

Docker
  |
  +-- Namespace -> cô lập
  |
  +-- cgroups -> giới hạn tài nguyên
  |
  +-- OverlayFS -> filesystem
  |
  +-- veth + bridge -> network
  |
  +-- runc -> tạo process

Đây chính là bản chất của Docker:

Một Linux process + isolation + resource limits + filesystem riêng + network riêng → tạo thành container.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí